From bc329ec5668cc88dfe3c0f9368a4d637d94b97b1 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Apr 2026 23:41:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=95=8C=E9=9D=A2=E5=92=8C=E4=BC=9A=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了本地IM缓存功能,支持会话、消息历史和草稿的存储 - 开发了聊天界面UI组件,包含消息列表、输入框和搜索功能 - 创建了聊天相关的ViewModel,处理消息收发和状态管理 - 构建了会话列表界面,支持置顶、免打扰和删除操作 - 集成了群组功能,实现群聊管理和群设置界面 - 添加了实时消息推送和会话状态同步机制 --- README.md | 4 + .../security/AppRequestSignatureUtil.java | 52 ++++++ .../demo/service/DemoAppSecretClient.java | 63 ++++++++ .../xuqm/demo/service/DemoAuthService.java | 39 ++++- .../src/main/resources/application.yml | 2 + docs/API_ACCESS.md | 16 +- .../xuqm/im/controller/AuthController.java | 11 +- .../im/controller/BlacklistController.java | 50 ++++++ .../im/controller/ConversationController.java | 68 +++++++- .../controller/FriendRequestController.java | 62 ++++++++ .../im/controller/GlobalExceptionHandler.java | 45 ++++++ .../xuqm/im/controller/GroupController.java | 47 +++++- .../xuqm/im/controller/MessageController.java | 2 +- .../java/com/xuqm/im/entity/BaseIdEntity.java | 19 +++ .../com/xuqm/im/entity/ImAccountEntity.java | 4 + .../com/xuqm/im/entity/ImBlacklistEntity.java | 42 +++++ .../im/entity/ImConversationStateEntity.java | 85 ++++++++++ .../xuqm/im/entity/ImFriendRequestEntity.java | 61 +++++++ .../com/xuqm/im/entity/ImGroupEntity.java | 10 ++ .../com/xuqm/im/entity/ImGroupMuteEntity.java | 47 ++++++ .../com/xuqm/im/entity/ImMessageEntity.java | 4 + .../EpochMillisLocalDateTimeSerializer.java | 21 +++ .../com/xuqm/im/model/ConversationView.java | 12 ++ .../im/repository/ImBlacklistRepository.java | 18 +++ .../ImConversationStateRepository.java | 19 +++ .../repository/ImFriendRequestRepository.java | 15 ++ .../im/repository/ImGroupMuteRepository.java | 14 ++ .../im/repository/ImMessageRepository.java | 29 ++++ .../com/xuqm/im/service/BlacklistService.java | 51 ++++++ .../im/service/ConversationStateService.java | 115 ++++++++++++++ .../xuqm/im/service/FriendRequestService.java | 89 +++++++++++ .../com/xuqm/im/service/ImAccountService.java | 29 +++- .../xuqm/im/service/ImAppSecretClient.java | 59 +++++++ .../com/xuqm/im/service/ImGroupService.java | 113 ++++++++++++- .../com/xuqm/im/service/MessageService.java | 78 ++++++++- im-service/src/main/resources/application.yml | 2 + .../xuqm/tenant/config/SdkAppInitializer.java | 21 +++ .../xuqm/tenant/config/SecurityConfig.java | 1 + .../controller/InternalSdkController.java | 43 +++++ .../controller/SdkConfigController.java | 23 ++- .../tenant/repository/TenantRepository.java | 1 + .../service/SdkAppProvisioningService.java | 149 ++++++++++++++++++ .../src/main/resources/application.yml | 5 + 43 files changed, 1605 insertions(+), 35 deletions(-) create mode 100644 common/src/main/java/com/xuqm/common/security/AppRequestSignatureUtil.java create mode 100644 demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java create mode 100644 im-service/src/main/java/com/xuqm/im/controller/BlacklistController.java create mode 100644 im-service/src/main/java/com/xuqm/im/controller/FriendRequestController.java create mode 100644 im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/BaseIdEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImBlacklistEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImConversationStateEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImFriendRequestEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImGroupMuteEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/json/EpochMillisLocalDateTimeSerializer.java create mode 100644 im-service/src/main/java/com/xuqm/im/model/ConversationView.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImBlacklistRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImConversationStateRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImFriendRequestRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImGroupMuteRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/BlacklistService.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/ConversationStateService.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/ImAppSecretClient.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/config/SdkAppInitializer.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java diff --git a/README.md b/README.md index 9815d16..7b7c715 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ cd update-service && mvn spring-boot:run & } ``` +> 说明:SDK 和 demo 侧传入的 `appId` 实际按 `appKey` 解析。当前默认值是 `ak_demo_chat`,如果数据库里没有这条记录,tenant-service 会在启动时自动创建。 + #### 功能服务(需 Token) | 方法 | 路径 | 说明 | @@ -220,6 +222,8 @@ POST /api/im/auth/login &avatar=https://... (可选) ``` +该接口需要由 demo-service 带上 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature` 头完成 AppSecret 验签。 + 响应:`{ "data": { "token": "eyJ..." } }` ### HTTP 消息接口 diff --git a/common/src/main/java/com/xuqm/common/security/AppRequestSignatureUtil.java b/common/src/main/java/com/xuqm/common/security/AppRequestSignatureUtil.java new file mode 100644 index 0000000..bd7f2cd --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/AppRequestSignatureUtil.java @@ -0,0 +1,52 @@ +package com.xuqm.common.security; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +public final class AppRequestSignatureUtil { + + private static final String HMAC_ALG = "HmacSHA256"; + + private AppRequestSignatureUtil() { + } + + public static String payload(String appId, + String userId, + String nickname, + String avatar, + long timestamp, + String nonce) { + return normalize(appId) + '\n' + + normalize(userId) + '\n' + + normalize(nickname) + '\n' + + normalize(avatar) + '\n' + + timestamp + '\n' + + normalize(nonce); + } + + public static String sign(String secret, String payload) { + try { + Mac mac = Mac.getInstance(HMAC_ALG); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALG)); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (Exception e) { + throw new IllegalStateException("Failed to sign app request", e); + } + } + + public static boolean matches(String secret, String payload, String expectedSignature) { + String actual = sign(secret, payload); + return MessageDigest.isEqual( + actual.getBytes(StandardCharsets.UTF_8), + normalize(expectedSignature).getBytes(StandardCharsets.UTF_8) + ); + } + + private static String normalize(String value) { + return value == null ? "" : value; + } +} diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java new file mode 100644 index 0000000..3b71f58 --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java @@ -0,0 +1,63 @@ +package com.xuqm.demo.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.xuqm.common.exception.BusinessException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class DemoAppSecretClient { + + private final RestTemplate restTemplate; + private final Map cache = new ConcurrentHashMap<>(); + + @Value("${demo.tenant-service-url:http://xuqm-tenant-service:8081}") + private String tenantServiceUrl; + + @Value("${demo.internal-token:xuqm-internal-token}") + private String internalToken; + + public DemoAppSecretClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + public String getAppSecret(String appId) { + return cache.computeIfAbsent(appId, this::fetchAppSecret); + } + + private String fetchAppSecret(String appId) { + String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl) + .path("/api/internal/sdk/apps/{appId}/secret") + .buildAndExpand(appId) + .toUriString(); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + try { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + JsonNode.class + ); + JsonNode body = response.getBody(); + if (response.getStatusCode().is2xxSuccessful() + && body != null + && body.path("code").asInt() == 200) { + return body.path("data").path("appSecret").asText(null); + } + } catch (RestClientException e) { + throw new BusinessException(502, "Failed to resolve app secret: " + e.getMessage()); + } + throw new BusinessException(502, "Failed to resolve app secret for appId: " + appId); + } +} diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java index 50091e1..33675b2 100644 --- a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java @@ -2,12 +2,17 @@ package com.xuqm.demo.service; import com.fasterxml.jackson.databind.JsonNode; import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.security.AppRequestSignatureUtil; import com.xuqm.common.security.JwtUtil; import com.xuqm.demo.entity.DemoUserEntity; import com.xuqm.demo.repository.DemoUserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +33,7 @@ public class DemoAuthService { private final JwtUtil jwtUtil; private final PasswordEncoder passwordEncoder; private final RestTemplate restTemplate; + private final DemoAppSecretClient appSecretClient; @Value("${demo.im-service-url:http://xuqm-im-service:8082}") private String imServiceUrl; @@ -35,11 +41,13 @@ public class DemoAuthService { public DemoAuthService(DemoUserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, - RestTemplate restTemplate) { + RestTemplate restTemplate, + DemoAppSecretClient appSecretClient) { this.userRepository = userRepository; this.jwtUtil = jwtUtil; this.passwordEncoder = passwordEncoder; this.restTemplate = restTemplate; + this.appSecretClient = appSecretClient; } public record AuthResult(String demoToken, String imToken, UserProfile profile) {} @@ -97,23 +105,40 @@ public class DemoAuthService { /** * Calls im-service to ensure the IM account exists and obtain an IM token. - * GET {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}&nickname={nickname} + * POST {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}&nickname={nickname} * Response: {"code":200,"data":{"token":"..."}} */ private String callImServiceLogin(String appId, String userId, String nickname) { + long timestamp = System.currentTimeMillis(); + String nonce = UUID.randomUUID().toString(); + String effectiveNickname = nickname != null ? nickname : userId; + String appSecret = appSecretClient.getAppSecret(appId); + String payload = AppRequestSignatureUtil.payload(appId, userId, effectiveNickname, null, timestamp, nonce); + String signature = AppRequestSignatureUtil.sign(appSecret, payload); + String url = UriComponentsBuilder.fromHttpUrl(imServiceUrl) .path("/api/im/auth/login") .queryParam("appId", appId) .queryParam("userId", userId) - .queryParam("nickname", nickname != null ? nickname : userId) + .queryParam("nickname", effectiveNickname) .toUriString(); try { - JsonNode response = restTemplate.getForObject(url, JsonNode.class); - if (response != null && response.path("code").asInt() == 200) { - return response.path("data").path("token").asText(); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-App-Timestamp", String.valueOf(timestamp)); + headers.set("X-App-Nonce", nonce); + headers.set("X-App-Signature", signature); + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(headers), + JsonNode.class + ); + JsonNode body = response.getBody(); + if (body != null && body.path("code").asInt() == 200) { + return body.path("data").path("token").asText(); } - log.warn("im-service login returned unexpected response for appId={} userId={}: {}", appId, userId, response); + log.warn("im-service login returned unexpected response for appId={} userId={}: {}", appId, userId, body); return null; } catch (RestClientException e) { log.error("Failed to call im-service login for appId={} userId={}: {}", appId, userId, e.getMessage()); diff --git a/demo-service/src/main/resources/application.yml b/demo-service/src/main/resources/application.yml index 4bda86a..1fa3a3f 100644 --- a/demo-service/src/main/resources/application.yml +++ b/demo-service/src/main/resources/application.yml @@ -35,6 +35,8 @@ jwt: expiration: 86400000 demo: + tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081} + internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} im-service-url: ${IM_SERVICE_URL:http://xuqm-im-service:8082} logging: diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index 2e6f826..90a117e 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -87,7 +87,7 @@ | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| -| POST | `/api/im/auth/login` | 否 | 获取 IM Token | +| POST | `/api/im/auth/login` | 否 | 获取 IM Token;需要 `X-App-Timestamp` / `X-App-Nonce` / `X-App-Signature` | | POST | `/api/im/messages/send` | 是 | 发送消息 | | POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 | | GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 | @@ -114,6 +114,8 @@ | POST | `/api/v1/rn/{id}/publish` | 是 | 发布 Bundle | | GET | `/api/v1/rn/files/{appId}/{platform}/{moduleId}` | 否 | 下载 Bundle | +说明:这里的 `appId` 按 `appKey` 解析。当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用;如果没有,会自动补一条默认 demo 应用和基础服务配置。`POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。 + ## curl 示例 ### 运营平台登录 @@ -141,3 +143,15 @@ curl 'https://dev.xuqinmin.com/api/v1/rn/update/check?appId=ak_demo_chat&platfor ```bash curl -X POST 'https://dev.xuqinmin.com/api/im/auth/login?appId=ak_demo_chat&userId=demo_alice' ``` + +### IM 会话与关系链 + +```bash +curl 'https://dev.xuqinmin.com/api/im/conversations?appId=ak_demo_chat' +curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/pinned?appId=ak_demo_chat&chatType=SINGLE&pinned=true' +curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/draft?appId=ak_demo_chat&chatType=SINGLE&draft=hello' +curl -X DELETE 'https://dev.xuqinmin.com/api/im/conversations/user_002?appId=ak_demo_chat&chatType=SINGLE' +curl 'https://dev.xuqinmin.com/api/im/groups?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&direction=incoming' +``` diff --git a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java index 8a9af97..6f28116 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java @@ -1,9 +1,11 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; +import com.xuqm.common.security.AppRequestSignatureUtil; import com.xuqm.im.service.ImAccountService; import jakarta.validation.constraints.NotBlank; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -26,7 +28,14 @@ public class AuthController { @RequestParam @NotBlank String appId, @RequestParam @NotBlank String userId, @RequestParam(required = false) String nickname, - @RequestParam(required = false) String avatar) { + @RequestParam(required = false) String avatar, + @RequestHeader(value = "X-App-Timestamp", required = false) String timestamp, + @RequestHeader(value = "X-App-Nonce", required = false) String nonce, + @RequestHeader(value = "X-App-Signature", required = false) String signature) { + if (timestamp == null || nonce == null || signature == null) { + return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature")); + } + accountService.validateSignature(appId, userId, nickname, avatar, timestamp, nonce, signature); String token = accountService.loginOrRegister(appId, userId, nickname, avatar); return ResponseEntity.ok(ApiResponse.success(Map.of("token", token))); } diff --git a/im-service/src/main/java/com/xuqm/im/controller/BlacklistController.java b/im-service/src/main/java/com/xuqm/im/controller/BlacklistController.java new file mode 100644 index 0000000..8655831 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/controller/BlacklistController.java @@ -0,0 +1,50 @@ +package com.xuqm.im.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.im.entity.ImBlacklistEntity; +import com.xuqm.im.service.BlacklistService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/im/blacklist") +public class BlacklistController { + + private final BlacklistService blacklistService; + + public BlacklistController(BlacklistService blacklistService) { + this.blacklistService = blacklistService; + } + + @GetMapping + public ResponseEntity>> list( + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(blacklistService.list(appId, userId))); + } + + @PostMapping + public ResponseEntity> add( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestParam String blockedUserId) { + return ResponseEntity.ok(ApiResponse.success(blacklistService.add(appId, userId, blockedUserId))); + } + + @DeleteMapping + public ResponseEntity> remove( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestParam String blockedUserId) { + blacklistService.remove(appId, userId, blockedUserId); + return ResponseEntity.ok(ApiResponse.ok()); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/controller/ConversationController.java b/im-service/src/main/java/com/xuqm/im/controller/ConversationController.java index 4efba54..2a11af7 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/ConversationController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/ConversationController.java @@ -1,11 +1,15 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; -import com.xuqm.im.repository.ImMessageRepository; +import com.xuqm.im.model.ConversationView; +import com.xuqm.im.service.ConversationStateService; import com.xuqm.im.service.MessageService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -17,17 +21,73 @@ import java.util.List; public class ConversationController { private final MessageService messageService; + private final ConversationStateService conversationStateService; - public ConversationController(MessageService messageService) { + public ConversationController(MessageService messageService, + ConversationStateService conversationStateService) { this.messageService = messageService; + this.conversationStateService = conversationStateService; } @GetMapping("/conversations") - public ResponseEntity>> conversations( + public ResponseEntity>> conversations( @AuthenticationPrincipal String userId, @RequestParam String appId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - return ResponseEntity.ok(ApiResponse.success(messageService.conversations(appId, userId, size))); + return ResponseEntity.ok(ApiResponse.success(messageService.conversationViews(appId, userId, size))); + } + + @PutMapping("/conversations/{targetId}/pinned") + public ResponseEntity> setPinned( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String targetId, + @RequestParam String chatType, + @RequestParam boolean pinned) { + conversationStateService.setPinned(appId, userId, targetId, chatType, pinned); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PutMapping("/conversations/{targetId}/muted") + public ResponseEntity> setMuted( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String targetId, + @RequestParam String chatType, + @RequestParam boolean muted) { + conversationStateService.setMuted(appId, userId, targetId, chatType, muted); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PutMapping("/conversations/{targetId}/read") + public ResponseEntity> markRead( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String targetId, + @RequestParam String chatType) { + conversationStateService.markRead(appId, userId, targetId, chatType); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PutMapping("/conversations/{targetId}/draft") + public ResponseEntity> setDraft( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String targetId, + @RequestParam String chatType, + @RequestParam(required = false) String draft) { + conversationStateService.setDraft(appId, userId, targetId, chatType, draft); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @DeleteMapping("/conversations/{targetId}") + public ResponseEntity> deleteConversation( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String targetId, + @RequestParam String chatType) { + conversationStateService.hideConversation(appId, userId, targetId, chatType); + return ResponseEntity.ok(ApiResponse.ok()); } } diff --git a/im-service/src/main/java/com/xuqm/im/controller/FriendRequestController.java b/im-service/src/main/java/com/xuqm/im/controller/FriendRequestController.java new file mode 100644 index 0000000..5c2c117 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/controller/FriendRequestController.java @@ -0,0 +1,62 @@ +package com.xuqm.im.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.im.entity.ImFriendRequestEntity; +import com.xuqm.im.service.FriendRequestService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.List; + +@RestController +@RequestMapping("/api/im/friend-requests") +public class FriendRequestController { + + private final FriendRequestService friendRequestService; + + public FriendRequestController(FriendRequestService friendRequestService) { + this.friendRequestService = friendRequestService; + } + + @GetMapping + public ResponseEntity>> list( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestParam(defaultValue = "incoming") String direction) { + List list = "outgoing".equalsIgnoreCase(direction) + ? friendRequestService.outgoing(appId, userId) + : friendRequestService.incoming(appId, userId); + return ResponseEntity.ok(ApiResponse.success(list)); + } + + @PostMapping + public ResponseEntity> send( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestParam String toUserId, + @RequestParam(required = false) String remark) { + return ResponseEntity.ok(ApiResponse.success(friendRequestService.send(appId, userId, toUserId, remark))); + } + + @PostMapping("/{requestId}/accept") + public ResponseEntity> accept( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String requestId) { + return ResponseEntity.ok(ApiResponse.success(friendRequestService.accept(appId, requestId, userId))); + } + + @PostMapping("/{requestId}/reject") + public ResponseEntity> reject( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String requestId) { + return ResponseEntity.ok(ApiResponse.success(friendRequestService.reject(appId, requestId, userId))); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java b/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..4a4ced8 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +package com.xuqm.im.controller; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.model.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusiness(BusinessException e) { + return ResponseEntity.status(resolveStatus(e.getCode())) + .body(ApiResponse.error(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + return ResponseEntity.badRequest().body(ApiResponse.badRequest(e.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("参数错误"))); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(500, e.getMessage() == null ? "服务异常" : e.getMessage())); + } + + private HttpStatus resolveStatus(int code) { + return switch (code) { + case 400 -> HttpStatus.BAD_REQUEST; + case 401 -> HttpStatus.UNAUTHORIZED; + case 403 -> HttpStatus.FORBIDDEN; + case 404 -> HttpStatus.NOT_FOUND; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/im-service/src/main/java/com/xuqm/im/controller/GroupController.java b/im-service/src/main/java/com/xuqm/im/controller/GroupController.java index 6b417fc..ae9bd7a 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/GroupController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/GroupController.java @@ -19,6 +19,13 @@ public class GroupController { this.groupService = groupService; } + @GetMapping("/{groupId}") + public ResponseEntity> get( + @PathVariable String groupId, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success(groupService.get(groupId, userId))); + } + @PostMapping public ResponseEntity> create( @RequestBody CreateGroupRequest req, @@ -35,12 +42,21 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.success(groupService.listUserGroups(appId, userId))); } + @PutMapping("/{groupId}") + public ResponseEntity> update( + @PathVariable String groupId, + @RequestBody UpdateGroupRequest req, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success( + groupService.update(groupId, userId, req.name(), req.announcement()))); + } + @PostMapping("/{groupId}/members") public ResponseEntity> addMember( @PathVariable String groupId, @RequestBody MemberRequest req, @AuthenticationPrincipal String userId) { - return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId()))); + return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId(), userId))); } @DeleteMapping("/{groupId}/members/{targetUserId}") @@ -51,6 +67,35 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId))); } + @PostMapping("/{groupId}/roles") + public ResponseEntity> setRole( + @PathVariable String groupId, + @RequestBody SetRoleRequest req, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success( + groupService.setRole(groupId, userId, req.userId(), req.role()))); + } + + @PostMapping("/{groupId}/mute") + public ResponseEntity> muteMember( + @PathVariable String groupId, + @RequestBody MuteMemberRequest req, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success( + groupService.muteMember(groupId, userId, req.userId(), req.minutes()))); + } + + @DeleteMapping("/{groupId}") + public ResponseEntity> dismiss( + @PathVariable String groupId, + @AuthenticationPrincipal String userId) { + groupService.dismiss(groupId, userId); + return ResponseEntity.ok(ApiResponse.ok()); + } + public record CreateGroupRequest(String name, List memberIds) {} + public record UpdateGroupRequest(String name, String announcement) {} public record MemberRequest(String userId) {} + public record SetRoleRequest(String userId, String role) {} + public record MuteMemberRequest(String userId, long minutes) {} } diff --git a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java index f9ec6ea..4579254 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java @@ -59,6 +59,6 @@ public class MessageController { @RequestParam String appId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - return ResponseEntity.ok(ApiResponse.success(messageService.groupHistory(appId, groupId, page, size))); + return ResponseEntity.ok(ApiResponse.success(messageService.groupHistory(appId, groupId, userId, page, size))); } } diff --git a/im-service/src/main/java/com/xuqm/im/entity/BaseIdEntity.java b/im-service/src/main/java/com/xuqm/im/entity/BaseIdEntity.java new file mode 100644 index 0000000..5eeddb7 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/BaseIdEntity.java @@ -0,0 +1,19 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; + +@MappedSuperclass +public abstract class BaseIdEntity { + + @Id + private String id; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java index 971ecae..2bab2d8 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java @@ -1,5 +1,7 @@ package com.xuqm.im.entity; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -41,6 +43,7 @@ public class ImAccountEntity { private Status status; @Column(nullable = false) + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) private LocalDateTime createdAt; public String getId() { return id; } @@ -64,6 +67,7 @@ public class ImAccountEntity { public Status getStatus() { return status; } public void setStatus(Status status) { this.status = status; } + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } } diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImBlacklistEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImBlacklistEntity.java new file mode 100644 index 0000000..928ffc8 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImBlacklistEntity.java @@ -0,0 +1,42 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "im_blacklist", + uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "blockedUserId"}), + indexes = @Index(name = "idx_blacklist_app_user", columnList = "appId,userId") +) +public class ImBlacklistEntity extends BaseIdEntity { + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String userId; + + @Column(nullable = false, length = 128) + private String blockedUserId; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getBlockedUserId() { return blockedUserId; } + public void setBlockedUserId(String blockedUserId) { this.blockedUserId = blockedUserId; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImConversationStateEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImConversationStateEntity.java new file mode 100644 index 0000000..d81a075 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImConversationStateEntity.java @@ -0,0 +1,85 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "im_conversation_state", + uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "targetId", "chatType"}), + indexes = { + @Index(name = "idx_conv_state_app_user", columnList = "appId,userId"), + @Index(name = "idx_conv_state_app_target", columnList = "appId,targetId") + } +) +public class ImConversationStateEntity extends BaseIdEntity { + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String userId; + + @Column(nullable = false, length = 128) + private String targetId; + + @Column(nullable = false, length = 16) + private String chatType; + + @Column(nullable = false) + private boolean pinned; + + @Column(nullable = false) + private boolean muted; + + @Column(nullable = false) + private boolean hidden; + + @Column(columnDefinition = "TEXT") + private String draft; + + private LocalDateTime lastReadAt; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getTargetId() { return targetId; } + public void setTargetId(String targetId) { this.targetId = targetId; } + + public String getChatType() { return chatType; } + public void setChatType(String chatType) { this.chatType = chatType; } + + public boolean isPinned() { return pinned; } + public void setPinned(boolean pinned) { this.pinned = pinned; } + + public boolean isMuted() { return muted; } + public void setMuted(boolean muted) { this.muted = muted; } + + public boolean isHidden() { return hidden; } + public void setHidden(boolean hidden) { this.hidden = hidden; } + + public String getDraft() { return draft; } + public void setDraft(String draft) { this.draft = draft; } + + public LocalDateTime getLastReadAt() { return lastReadAt; } + public void setLastReadAt(LocalDateTime lastReadAt) { this.lastReadAt = lastReadAt; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImFriendRequestEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImFriendRequestEntity.java new file mode 100644 index 0000000..1b32192 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImFriendRequestEntity.java @@ -0,0 +1,61 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "im_friend_request", + uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "fromUserId", "toUserId"}), + indexes = @Index(name = "idx_friend_request_app_to", columnList = "appId,toUserId") +) +public class ImFriendRequestEntity extends BaseIdEntity { + + public enum Status { PENDING, ACCEPTED, REJECTED } + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String fromUserId; + + @Column(nullable = false, length = 128) + private String toUserId; + + @Column(length = 256) + private String remark; + + @Column(nullable = false, length = 16) + private String status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + private LocalDateTime reviewedAt; + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getFromUserId() { return fromUserId; } + public void setFromUserId(String fromUserId) { this.fromUserId = fromUserId; } + + public String getToUserId() { return toUserId; } + public void setToUserId(String toUserId) { this.toUserId = toUserId; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getReviewedAt() { return reviewedAt; } + public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java index d824a7f..bf699f6 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java @@ -1,5 +1,7 @@ package com.xuqm.im.entity; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -28,7 +30,11 @@ public class ImGroupEntity { @Column(nullable = false, columnDefinition = "TEXT") private String adminIds; + @Column(columnDefinition = "TEXT") + private String announcement; + @Column(nullable = false) + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) private LocalDateTime createdAt; public String getId() { return id; } @@ -49,6 +55,10 @@ public class ImGroupEntity { public String getAdminIds() { return adminIds; } public void setAdminIds(String adminIds) { this.adminIds = adminIds; } + public String getAnnouncement() { return announcement; } + public void setAnnouncement(String announcement) { this.announcement = announcement; } + + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } } diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImGroupMuteEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImGroupMuteEntity.java new file mode 100644 index 0000000..155a124 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImGroupMuteEntity.java @@ -0,0 +1,47 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "im_group_mute", + uniqueConstraints = @UniqueConstraint(columnNames = {"groupId", "userId"}), + indexes = @Index(name = "idx_group_mute_group_user", columnList = "groupId,userId") +) +public class ImGroupMuteEntity extends BaseIdEntity { + + @Column(nullable = false, length = 64) + private String groupId; + + @Column(nullable = false, length = 128) + private String userId; + + private LocalDateTime mutedUntil; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public String getGroupId() { return groupId; } + public void setGroupId(String groupId) { this.groupId = groupId; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public LocalDateTime getMutedUntil() { return mutedUntil; } + public void setMutedUntil(LocalDateTime mutedUntil) { this.mutedUntil = mutedUntil; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java index 78116e9..4daf776 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java @@ -1,5 +1,7 @@ package com.xuqm.im.entity; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -54,6 +56,7 @@ public class ImMessageEntity { private String mentionedUserIds; @Column(nullable = false) + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) private LocalDateTime createdAt; public String getId() { return id; } @@ -83,6 +86,7 @@ public class ImMessageEntity { public String getMentionedUserIds() { return mentionedUserIds; } public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = mentionedUserIds; } + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } } diff --git a/im-service/src/main/java/com/xuqm/im/json/EpochMillisLocalDateTimeSerializer.java b/im-service/src/main/java/com/xuqm/im/json/EpochMillisLocalDateTimeSerializer.java new file mode 100644 index 0000000..b92520b --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/json/EpochMillisLocalDateTimeSerializer.java @@ -0,0 +1,21 @@ +package com.xuqm.im.json; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +public class EpochMillisLocalDateTimeSerializer extends JsonSerializer { + + @Override + public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (value == null) { + gen.writeNull(); + return; + } + gen.writeNumber(value.toInstant(ZoneOffset.UTC).toEpochMilli()); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/model/ConversationView.java b/im-service/src/main/java/com/xuqm/im/model/ConversationView.java new file mode 100644 index 0000000..023dc5e --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/model/ConversationView.java @@ -0,0 +1,12 @@ +package com.xuqm.im.model; + +public record ConversationView( + String targetId, + String chatType, + String lastMsgContent, + String lastMsgType, + Long lastMsgTime, + int unreadCount, + boolean isMuted, + boolean isPinned +) {} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImBlacklistRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImBlacklistRepository.java new file mode 100644 index 0000000..5ea6949 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImBlacklistRepository.java @@ -0,0 +1,18 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImBlacklistEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ImBlacklistRepository extends JpaRepository { + List findByAppIdAndUserId(String appId, String userId); + + Optional findByAppIdAndUserIdAndBlockedUserId( + String appId, String userId, String blockedUserId); + + boolean existsByAppIdAndUserIdAndBlockedUserId(String appId, String userId, String blockedUserId); + + void deleteByAppIdAndUserIdAndBlockedUserId(String appId, String userId, String blockedUserId); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImConversationStateRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImConversationStateRepository.java new file mode 100644 index 0000000..c324ac0 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImConversationStateRepository.java @@ -0,0 +1,19 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImConversationStateEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ImConversationStateRepository extends JpaRepository { + Optional findByAppIdAndUserIdAndTargetIdAndChatType( + String appId, String userId, String targetId, String chatType); + + List findByAppIdAndUserId(String appId, String userId); + + List findByAppIdAndUserIdAndHiddenFalse(String appId, String userId); + + void deleteByAppIdAndUserIdAndTargetIdAndChatType( + String appId, String userId, String targetId, String chatType); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImFriendRequestRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImFriendRequestRepository.java new file mode 100644 index 0000000..757185f --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImFriendRequestRepository.java @@ -0,0 +1,15 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImFriendRequestEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ImFriendRequestRepository extends JpaRepository { + Optional findByAppIdAndFromUserIdAndToUserId( + String appId, String fromUserId, String toUserId); + + List findByAppIdAndToUserId(String appId, String toUserId); + List findByAppIdAndFromUserId(String appId, String fromUserId); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImGroupMuteRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImGroupMuteRepository.java new file mode 100644 index 0000000..1704453 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImGroupMuteRepository.java @@ -0,0 +1,14 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImGroupMuteEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface ImGroupMuteRepository extends JpaRepository { + Optional findByGroupIdAndUserIdAndMutedUntilAfter( + String groupId, String userId, LocalDateTime time); + + void deleteByGroupId(String groupId); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java index e5a5b74..b5fe1f4 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java @@ -76,6 +76,35 @@ public interface ImMessageRepository extends JpaRepository :userId + and (:since is null or m.createdAt > :since) + """) + long countUnreadSingleConversation( + @Param("appId") String appId, + @Param("userId") String userId, + @Param("peerId") String peerId, + @Param("since") LocalDateTime since); + + @Query(""" + select count(m) from ImMessageEntity m + where m.appId = :appId + and m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.GROUP + and m.toId = :groupId + and m.fromUserId <> :userId + and (:since is null or m.createdAt > :since) + """) + long countUnreadGroupConversation( + @Param("appId") String appId, + @Param("userId") String userId, + @Param("groupId") String groupId, + @Param("since") LocalDateTime since); + long countByAppId(String appId); @Query("select count(m) from ImMessageEntity m where m.appId = :appId and m.createdAt >= :since") diff --git a/im-service/src/main/java/com/xuqm/im/service/BlacklistService.java b/im-service/src/main/java/com/xuqm/im/service/BlacklistService.java new file mode 100644 index 0000000..80c24b9 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/BlacklistService.java @@ -0,0 +1,51 @@ +package com.xuqm.im.service; + +import com.xuqm.im.entity.ImBlacklistEntity; +import com.xuqm.im.repository.ImBlacklistRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class BlacklistService { + + private final ImBlacklistRepository repository; + + public BlacklistService(ImBlacklistRepository repository) { + this.repository = repository; + } + + @Transactional + public ImBlacklistEntity add(String appId, String userId, String blockedUserId) { + return repository.findByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId) + .orElseGet(() -> { + ImBlacklistEntity entity = new ImBlacklistEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setUserId(userId); + entity.setBlockedUserId(blockedUserId); + entity.setCreatedAt(LocalDateTime.now()); + return repository.save(entity); + }); + } + + @Transactional + public void remove(String appId, String userId, String blockedUserId) { + repository.deleteByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId); + } + + public List list(String appId, String userId) { + return repository.findByAppIdAndUserId(appId, userId); + } + + public boolean isBlocked(String appId, String userId, String blockedUserId) { + return repository.existsByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId); + } + + public boolean isEitherBlocked(String appId, String userId, String targetUserId) { + return isBlocked(appId, userId, targetUserId) || isBlocked(appId, targetUserId, userId); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ConversationStateService.java b/im-service/src/main/java/com/xuqm/im/service/ConversationStateService.java new file mode 100644 index 0000000..9e3959f --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/ConversationStateService.java @@ -0,0 +1,115 @@ +package com.xuqm.im.service; + +import com.xuqm.im.entity.ImConversationStateEntity; +import com.xuqm.im.repository.ImConversationStateRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Service +public class ConversationStateService { + + private final ImConversationStateRepository repository; + + public ConversationStateService(ImConversationStateRepository repository) { + this.repository = repository; + } + + @Transactional + public ImConversationStateEntity setPinned(String appId, String userId, String targetId, String chatType, boolean pinned) { + ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType); + state.setPinned(pinned); + touch(state); + return repository.save(state); + } + + @Transactional + public ImConversationStateEntity setMuted(String appId, String userId, String targetId, String chatType, boolean muted) { + ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType); + state.setMuted(muted); + touch(state); + return repository.save(state); + } + + @Transactional + public ImConversationStateEntity markRead(String appId, String userId, String targetId, String chatType) { + ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType); + state.setLastReadAt(LocalDateTime.now()); + state.setHidden(false); + touch(state); + return repository.save(state); + } + + @Transactional + public ImConversationStateEntity setDraft(String appId, String userId, String targetId, String chatType, String draft) { + ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType); + state.setDraft(draft); + touch(state); + return repository.save(state); + } + + @Transactional + public ImConversationStateEntity hideConversation(String appId, String userId, String targetId, String chatType) { + ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType); + state.setHidden(true); + state.setDraft(null); + touch(state); + return repository.save(state); + } + + @Transactional + public void deleteConversationState(String appId, String userId, String targetId, String chatType) { + repository.deleteByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType); + } + + @Transactional + public void clearHiddenForUsers(String appId, String targetId, String chatType, Collection userIds) { + for (String userId : userIds) { + ImConversationStateEntity state = repository + .findByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType) + .orElse(null); + if (state != null && state.isHidden()) { + state.setHidden(false); + touch(state); + repository.save(state); + } + } + } + + public ImConversationStateEntity getOrCreate(String appId, String userId, String targetId, String chatType) { + return repository.findByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType) + .orElseGet(() -> { + ImConversationStateEntity entity = new ImConversationStateEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setUserId(userId); + entity.setTargetId(targetId); + entity.setChatType(chatType); + entity.setPinned(false); + entity.setMuted(false); + entity.setHidden(false); + entity.setDraft(null); + entity.setLastReadAt(null); + entity.setCreatedAt(LocalDateTime.now()); + entity.setUpdatedAt(LocalDateTime.now()); + return entity; + }); + } + + public ImConversationStateEntity find(String appId, String userId, String targetId, String chatType) { + return repository.findByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType) + .orElse(null); + } + + public List listVisible(String appId, String userId) { + return repository.findByAppIdAndUserIdAndHiddenFalse(appId, userId); + } + + private void touch(ImConversationStateEntity entity) { + entity.setUpdatedAt(LocalDateTime.now()); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java b/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java new file mode 100644 index 0000000..6a7e46d --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java @@ -0,0 +1,89 @@ +package com.xuqm.im.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.im.entity.ImFriendRequestEntity; +import com.xuqm.im.repository.ImFriendRequestRepository; +import com.xuqm.im.repository.ImFriendRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class FriendRequestService { + + private final ImFriendRequestRepository requestRepository; + private final ImFriendRepository friendRepository; + + public FriendRequestService(ImFriendRequestRepository requestRepository, + ImFriendRepository friendRepository) { + this.requestRepository = requestRepository; + this.friendRepository = friendRepository; + } + + @Transactional + public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) { + return requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId) + .orElseGet(() -> { + ImFriendRequestEntity entity = new ImFriendRequestEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setFromUserId(fromUserId); + entity.setToUserId(toUserId); + entity.setRemark(remark); + entity.setStatus(ImFriendRequestEntity.Status.PENDING.name()); + entity.setCreatedAt(LocalDateTime.now()); + return requestRepository.save(entity); + }); + } + + @Transactional + public ImFriendRequestEntity accept(String appId, String requestId, String operatorId) { + ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); + request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name()); + request.setReviewedAt(LocalDateTime.now()); + requestRepository.save(request); + friendRepository + .findByAppIdAndUserIdAndFriendId(appId, request.getFromUserId(), request.getToUserId()) + .orElseGet(() -> friendEntity(appId, request.getFromUserId(), request.getToUserId())); + friendRepository + .findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId()) + .orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId())); + return request; + } + + @Transactional + public ImFriendRequestEntity reject(String appId, String requestId, String operatorId) { + ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); + request.setStatus(ImFriendRequestEntity.Status.REJECTED.name()); + request.setReviewedAt(LocalDateTime.now()); + return requestRepository.save(request); + } + + public List incoming(String appId, String userId) { + return requestRepository.findByAppIdAndToUserId(appId, userId); + } + + public List outgoing(String appId, String userId) { + return requestRepository.findByAppIdAndFromUserId(appId, userId); + } + + private ImFriendRequestEntity getRequest(String appId, String requestId, String operatorId) { + ImFriendRequestEntity request = requestRepository.findById(requestId) + .orElseThrow(() -> new BusinessException(404, "好友申请不存在")); + if (!request.getAppId().equals(appId) || !request.getToUserId().equals(operatorId)) { + throw new BusinessException(403, "无权操作"); + } + return request; + } + + private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) { + com.xuqm.im.entity.ImFriendEntity entity = new com.xuqm.im.entity.ImFriendEntity(); + entity.setAppId(appId); + entity.setUserId(userId); + entity.setFriendId(friendId); + return friendRepository.save(entity); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java index a54df2e..af88a49 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java @@ -1,6 +1,7 @@ package com.xuqm.im.service; import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.security.AppRequestSignatureUtil; import com.xuqm.common.security.JwtUtil; import com.xuqm.im.entity.ImAccountEntity; import com.xuqm.im.repository.ImAccountRepository; @@ -15,10 +16,36 @@ public class ImAccountService { private final ImAccountRepository accountRepository; private final JwtUtil jwtUtil; + private final ImAppSecretClient appSecretClient; - public ImAccountService(ImAccountRepository accountRepository, JwtUtil jwtUtil) { + public ImAccountService(ImAccountRepository accountRepository, JwtUtil jwtUtil, ImAppSecretClient appSecretClient) { this.accountRepository = accountRepository; this.jwtUtil = jwtUtil; + this.appSecretClient = appSecretClient; + } + + public void validateSignature(String appId, + String userId, + String nickname, + String avatar, + String timestamp, + String nonce, + String signature) { + long ts; + try { + ts = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + throw new BusinessException(401, "Invalid app signature"); + } + long now = System.currentTimeMillis(); + if (Math.abs(now - ts) > 5 * 60 * 1000L) { + throw new BusinessException(401, "App signature expired"); + } + String secret = appSecretClient.getAppSecret(appId); + String payload = AppRequestSignatureUtil.payload(appId, userId, nickname, avatar, ts, nonce); + if (!AppRequestSignatureUtil.matches(secret, payload, signature)) { + throw new BusinessException(401, "Invalid app signature"); + } } public String loginOrRegister(String appId, String userId, String nickname, String avatar) { diff --git a/im-service/src/main/java/com/xuqm/im/service/ImAppSecretClient.java b/im-service/src/main/java/com/xuqm/im/service/ImAppSecretClient.java new file mode 100644 index 0000000..edf0399 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/ImAppSecretClient.java @@ -0,0 +1,59 @@ +package com.xuqm.im.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.xuqm.common.exception.BusinessException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class ImAppSecretClient { + + private final RestTemplate restTemplate = new RestTemplate(); + private final Map cache = new ConcurrentHashMap<>(); + + @Value("${im.tenant-service-url:http://xuqm-tenant-service:8081}") + private String tenantServiceUrl; + + @Value("${im.internal-token:xuqm-internal-token}") + private String internalToken; + + public String getAppSecret(String appId) { + return cache.computeIfAbsent(appId, this::fetchAppSecret); + } + + private String fetchAppSecret(String appId) { + String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl) + .path("/api/internal/sdk/apps/{appId}/secret") + .buildAndExpand(appId) + .toUriString(); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + try { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + JsonNode.class + ); + JsonNode body = response.getBody(); + if (response.getStatusCode().is2xxSuccessful() + && body != null + && body.path("code").asInt() == 200) { + return body.path("data").path("appSecret").asText(null); + } + } catch (RestClientException e) { + throw new BusinessException(502, "Failed to resolve app secret: " + e.getMessage()); + } + throw new BusinessException(502, "Failed to resolve app secret for appId: " + appId); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java b/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java index 4bb97e5..f9efabd 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java @@ -4,25 +4,34 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.common.exception.BusinessException; import com.xuqm.im.entity.ImGroupEntity; +import com.xuqm.im.entity.ImGroupMuteEntity; import com.xuqm.im.repository.ImGroupRepository; +import com.xuqm.im.repository.ImGroupMuteRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; @Service public class ImGroupService { private final ImGroupRepository groupRepository; + private final ImGroupMuteRepository muteRepository; private final ObjectMapper objectMapper; - public ImGroupService(ImGroupRepository groupRepository, ObjectMapper objectMapper) { + public ImGroupService(ImGroupRepository groupRepository, + ImGroupMuteRepository muteRepository, + ObjectMapper objectMapper) { this.groupRepository = groupRepository; + this.muteRepository = muteRepository; this.objectMapper = objectMapper; } + @Transactional public ImGroupEntity create(String appId, String name, String creatorId, List memberIds) { List members = new ArrayList<>(memberIds); if (!members.contains(creatorId)) members.add(creatorId); @@ -34,13 +43,28 @@ public class ImGroupService { group.setCreatorId(creatorId); group.setMemberIds(toJson(members)); group.setAdminIds(toJson(List.of(creatorId))); + group.setAnnouncement(null); group.setCreatedAt(LocalDateTime.now()); return groupRepository.save(group); } - public ImGroupEntity addMember(String groupId, String userId) { - ImGroupEntity group = groupRepository.findById(groupId) + public ImGroupEntity get(String groupId) { + return groupRepository.findById(groupId) .orElseThrow(() -> new BusinessException(404, "群组不存在")); + } + + public ImGroupEntity get(String groupId, String requesterId) { + ImGroupEntity group = get(groupId); + if (!memberIds(group).contains(requesterId) && !group.getCreatorId().equals(requesterId)) { + throw new BusinessException(403, "不在群内"); + } + return group; + } + + @Transactional + public ImGroupEntity addMember(String groupId, String userId, String operatorId) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); List members = fromJson(group.getMemberIds()); if (!members.contains(userId)) { members.add(userId); @@ -50,9 +74,9 @@ public class ImGroupService { return group; } + @Transactional public ImGroupEntity removeMember(String groupId, String userId, String operatorId) { - ImGroupEntity group = groupRepository.findById(groupId) - .orElseThrow(() -> new BusinessException(404, "群组不存在")); + ImGroupEntity group = get(groupId); List admins = fromJson(group.getAdminIds()); if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) { throw new BusinessException(403, "无权操作"); @@ -63,6 +87,78 @@ public class ImGroupService { return groupRepository.save(group); } + @Transactional + public ImGroupEntity update(String groupId, String operatorId, String name, String announcement) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + if (name != null && !name.isBlank()) { + group.setName(name); + } + if (announcement != null) { + group.setAnnouncement(announcement); + } + return groupRepository.save(group); + } + + @Transactional + public ImGroupEntity setRole(String groupId, String operatorId, String userId, String role) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + List admins = new ArrayList<>(fromJson(group.getAdminIds())); + if ("ADMIN".equalsIgnoreCase(role)) { + if (!admins.contains(userId)) admins.add(userId); + } else { + admins.remove(userId); + if (userId.equals(group.getCreatorId())) { + throw new BusinessException(403, "群主不能降级"); + } + } + group.setAdminIds(toJson(admins)); + return groupRepository.save(group); + } + + @Transactional + public ImGroupEntity muteMember(String groupId, String operatorId, String userId, long minutes) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + ImGroupMuteEntity mute = muteRepository + .findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now()) + .orElseGet(() -> { + ImGroupMuteEntity entity = new ImGroupMuteEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setGroupId(groupId); + entity.setUserId(userId); + entity.setCreatedAt(LocalDateTime.now()); + return entity; + }); + mute.setMutedUntil(LocalDateTime.now().plusMinutes(Math.max(minutes, 0))); + mute.setUpdatedAt(LocalDateTime.now()); + muteRepository.save(mute); + return group; + } + + @Transactional + public void dismiss(String groupId, String operatorId) { + ImGroupEntity group = get(groupId); + if (!group.getCreatorId().equals(operatorId)) { + throw new BusinessException(403, "只有群主可以解散群"); + } + muteRepository.deleteByGroupId(groupId); + groupRepository.delete(group); + } + + public boolean isMemberMuted(String groupId, String userId) { + return muteRepository.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now()).isPresent(); + } + + public List memberIds(ImGroupEntity group) { + return fromJson(group.getMemberIds()); + } + + public List adminIds(ImGroupEntity group) { + return fromJson(group.getAdminIds()); + } + public List listByApp(String appId) { return groupRepository.findByAppId(appId); } @@ -78,4 +174,11 @@ public class ImGroupService { private List fromJson(String json) { try { return objectMapper.readValue(json, new TypeReference<>() {}); } catch (Exception e) { return new ArrayList<>(); } } + + private void ensureCanManage(ImGroupEntity group, String operatorId) { + List admins = fromJson(group.getAdminIds()); + if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) { + throw new BusinessException(403, "无权操作"); + } + } } diff --git a/im-service/src/main/java/com/xuqm/im/service/MessageService.java b/im-service/src/main/java/com/xuqm/im/service/MessageService.java index a38fab0..afaea86 100644 --- a/im-service/src/main/java/com/xuqm/im/service/MessageService.java +++ b/im-service/src/main/java/com/xuqm/im/service/MessageService.java @@ -3,8 +3,10 @@ package com.xuqm.im.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.common.exception.BusinessException; import com.xuqm.im.cluster.ImClusterPublisher; +import com.xuqm.im.entity.ImGroupEntity; import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.WebhookConfigEntity; +import com.xuqm.im.model.ConversationView; import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.repository.WebhookConfigRepository; import org.springframework.beans.factory.annotation.Value; @@ -18,8 +20,10 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.LocalDateTime; +import java.time.ZoneOffset; import com.xuqm.im.repository.ImMessageRepository; import java.util.List; +import java.util.Objects; import java.util.UUID; @Service @@ -29,6 +33,9 @@ public class MessageService { private final WebhookConfigRepository webhookRepository; private final KeywordFilterService keywordFilterService; private final ImClusterPublisher clusterPublisher; + private final ImGroupService groupService; + private final BlacklistService blacklistService; + private final ConversationStateService conversationStateService; private final ObjectMapper objectMapper; @Value("${im.webhook-timeout-ms:3000}") @@ -38,11 +45,17 @@ public class MessageService { WebhookConfigRepository webhookRepository, KeywordFilterService keywordFilterService, ImClusterPublisher clusterPublisher, + ImGroupService groupService, + BlacklistService blacklistService, + ConversationStateService conversationStateService, ObjectMapper objectMapper) { this.messageRepository = messageRepository; this.webhookRepository = webhookRepository; this.keywordFilterService = keywordFilterService; this.clusterPublisher = clusterPublisher; + this.groupService = groupService; + this.blacklistService = blacklistService; + this.conversationStateService = conversationStateService; this.objectMapper = objectMapper; } @@ -54,6 +67,18 @@ public class MessageService { throw new BusinessException("消息包含违禁内容"); } } + ImGroupEntity group = null; + if (req.chatType() == ImMessageEntity.ChatType.GROUP) { + group = groupService.get(req.toId()); + if (!groupService.memberIds(group).contains(fromUserId)) { + throw new BusinessException(403, "不在群内"); + } + if (groupService.isMemberMuted(req.toId(), fromUserId)) { + throw new BusinessException(403, "当前用户已被禁言"); + } + } else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) { + throw new BusinessException(403, "已被拉黑,无法发送消息"); + } ImMessageEntity message = new ImMessageEntity(); message.setId(UUID.randomUUID().toString()); @@ -74,6 +99,9 @@ public class MessageService { clusterPublisher.publish(destination, message); if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) { clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", message); + conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId())); + } else if (req.chatType() == ImMessageEntity.ChatType.GROUP) { + conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), groupService.memberIds(group)); } dispatchWebhooks(appId, message); @@ -109,7 +137,11 @@ public class MessageService { appId, userId, toId, PageRequest.of(page, size)); } - public Page groupHistory(String appId, String groupId, int page, int size) { + public Page groupHistory(String appId, String groupId, String userId, int page, int size) { + ImGroupEntity group = groupService.get(groupId); + if (!groupService.memberIds(group).contains(userId)) { + throw new BusinessException(403, "不在群内"); + } return messageRepository.findGroupHistory(appId, groupId, PageRequest.of(page, size)); } @@ -117,6 +149,50 @@ public class MessageService { return messageRepository.findConversations(appId, userId, size); } + public List conversationViews(String appId, String userId, int size) { + int fetchSize = Math.max(size * 3, size); + return messageRepository.findConversations(appId, userId, fetchSize).stream() + .map(summary -> toConversationView(appId, userId, summary)) + .filter(Objects::nonNull) + .limit(size) + .toList(); + } + + private ConversationView toConversationView( + String appId, + String userId, + ImMessageRepository.ConversationSummary summary + ) { + String targetId = summary.getTargetId(); + String chatType = summary.getChatType(); + var state = conversationStateService.find(appId, userId, targetId, chatType); + if (state != null && state.isHidden()) { + return null; + } + Page page = chatType.equals("GROUP") + ? messageRepository.findGroupHistory(appId, targetId, PageRequest.of(0, 1)) + : messageRepository.findSingleConversation(appId, userId, targetId, PageRequest.of(0, 1)); + ImMessageEntity lastMessage = page.getContent().stream().findFirst().orElse(null); + LocalDateTime lastReadAt = state == null ? null : state.getLastReadAt(); + long unreadCount = chatType.equals("GROUP") + ? messageRepository.countUnreadGroupConversation(appId, userId, targetId, lastReadAt) + : messageRepository.countUnreadSingleConversation(appId, userId, targetId, lastReadAt); + return new ConversationView( + targetId, + chatType, + lastMessage != null ? lastMessage.getContent() : null, + lastMessage != null ? lastMessage.getMsgType().name() : null, + toEpochMillis(lastMessage != null ? lastMessage.getCreatedAt() : summary.getLastTime()), + (int) unreadCount, + state != null && state.isMuted(), + state != null && state.isPinned() + ); + } + + private long toEpochMillis(LocalDateTime time) { + return time == null ? 0L : time.toInstant(ZoneOffset.UTC).toEpochMilli(); + } + @Async protected void dispatchWebhooks(String appId, ImMessageEntity message) { List webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); diff --git a/im-service/src/main/resources/application.yml b/im-service/src/main/resources/application.yml index 4d8a1ea..7f234cb 100644 --- a/im-service/src/main/resources/application.yml +++ b/im-service/src/main/resources/application.yml @@ -44,6 +44,8 @@ jwt: expiration: 86400000 im: + tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081} + internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} multi-login: true message-history-days: 30 webhook-timeout-ms: 3000 diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/SdkAppInitializer.java b/tenant-service/src/main/java/com/xuqm/tenant/config/SdkAppInitializer.java new file mode 100644 index 0000000..bbd5030 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/SdkAppInitializer.java @@ -0,0 +1,21 @@ +package com.xuqm.tenant.config; + +import com.xuqm.tenant.service.SdkAppProvisioningService; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class SdkAppInitializer implements ApplicationRunner { + + private final SdkAppProvisioningService provisioningService; + + public SdkAppInitializer(SdkAppProvisioningService provisioningService) { + this.provisioningService = provisioningService; + } + + @Override + public void run(ApplicationArguments args) { + provisioningService.ensureBootstrapApp(); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java index 86814cb..466d1a9 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java @@ -34,6 +34,7 @@ public class SecurityConfig { .requestMatchers( "/api/auth/**", "/api/sdk/**", + "/api/internal/sdk/**", "/actuator/health", "/actuator/info" ).permitAll() diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java new file mode 100644 index 0000000..2dce4ea --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java @@ -0,0 +1,43 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.service.SdkAppProvisioningService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/internal/sdk") +public class InternalSdkController { + + private final SdkAppProvisioningService provisioningService; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + public InternalSdkController(SdkAppProvisioningService provisioningService) { + this.provisioningService = provisioningService; + } + + @GetMapping("/apps/{appId}/secret") + public ResponseEntity>> getAppSecret( + @PathVariable String appId, + @RequestHeader(value = "X-Internal-Token", required = false) String token) { + if (token == null || !internalToken.equals(token)) { + return ResponseEntity.status(403) + .body(ApiResponse.error(403, "Forbidden")); + } + AppEntity app = provisioningService.resolveApp(appId); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "appId", app.getAppKey(), + "appSecret", app.getAppSecret() + ))); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java index 9143fd6..5585bb1 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java @@ -1,9 +1,10 @@ package com.xuqm.tenant.controller; import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; -import com.xuqm.tenant.repository.AppRepository; import com.xuqm.tenant.repository.FeatureServiceRepository; +import com.xuqm.tenant.service.SdkAppProvisioningService; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -18,8 +19,8 @@ import java.util.Map; @RequestMapping("/api/sdk") public class SdkConfigController { - private final AppRepository appRepository; private final FeatureServiceRepository featureServiceRepository; + private final SdkAppProvisioningService sdkAppProvisioningService; @Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}") private String imWsUrl; @@ -30,28 +31,24 @@ public class SdkConfigController { @Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}") private String imApiUrl; - public SdkConfigController(AppRepository appRepository, - FeatureServiceRepository featureServiceRepository) { - this.appRepository = appRepository; + public SdkConfigController(FeatureServiceRepository featureServiceRepository, + SdkAppProvisioningService sdkAppProvisioningService) { this.featureServiceRepository = featureServiceRepository; + this.sdkAppProvisioningService = sdkAppProvisioningService; } /** * GET /api/sdk/config?appId=XXX — public, no auth required. * - * Returns SDK configuration URLs and enabled feature flags for the given appId. - * Returns 404 if the appId does not exist in the system. + * Returns SDK configuration URLs and enabled feature flags for the given appId/appKey. + * The demo app (`ak_demo_chat`) is auto-provisioned if it does not exist. */ @GetMapping("/config") public ResponseEntity> getConfig( @RequestParam String appId) { - if (!appRepository.existsById(appId)) { - return ResponseEntity.status(404) - .body(ApiResponse.error(404, "App not found: " + appId)); - } - - List features = featureServiceRepository.findByAppId(appId); + AppEntity app = sdkAppProvisioningService.resolveApp(appId); + List features = featureServiceRepository.findByAppId(app.getAppKey()); boolean imEnabled = features.stream() .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.IM && f.isEnabled()); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/TenantRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/TenantRepository.java index 8a7c8d6..f552df4 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/repository/TenantRepository.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/TenantRepository.java @@ -15,6 +15,7 @@ import java.util.Optional; public interface TenantRepository extends JpaRepository { Optional findByUsername(String username); Optional findByEmail(String email); + Optional findFirstByOrderByCreatedAtAsc(); boolean existsByUsername(String username); boolean existsByEmail(String email); List findByParentId(String parentId); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java new file mode 100644 index 0000000..8c1ed41 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java @@ -0,0 +1,149 @@ +package com.xuqm.tenant.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.entity.FeatureServiceEntity; +import com.xuqm.tenant.entity.TenantEntity; +import com.xuqm.tenant.repository.AppRepository; +import com.xuqm.tenant.repository.FeatureServiceRepository; +import com.xuqm.tenant.repository.TenantRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +@Service +public class SdkAppProvisioningService { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private final AppRepository appRepository; + private final TenantRepository tenantRepository; + private final FeatureServiceRepository featureServiceRepository; + private final PasswordEncoder passwordEncoder; + + @Value("${sdk.bootstrap-app-key:ak_demo_chat}") + private String bootstrapAppKey; + + @Value("${sdk.bootstrap-app-name:Demo Chat}") + private String bootstrapAppName; + + @Value("${sdk.bootstrap-app-package:com.xuqm.demo}") + private String bootstrapAppPackage; + + @Value("${sdk.bootstrap-app-description:XuqmGroup demo app}") + private String bootstrapAppDescription; + + public SdkAppProvisioningService(AppRepository appRepository, + TenantRepository tenantRepository, + FeatureServiceRepository featureServiceRepository, + PasswordEncoder passwordEncoder) { + this.appRepository = appRepository; + this.tenantRepository = tenantRepository; + this.featureServiceRepository = featureServiceRepository; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public AppEntity ensureBootstrapApp() { + return ensureApp(bootstrapAppKey, true); + } + + @Transactional + public AppEntity resolveApp(String appId) { + return appRepository.findByAppKey(appId) + .or(() -> appRepository.findById(appId)) + .orElseGet(() -> { + if (!bootstrapAppKey.equals(appId)) { + throw new BusinessException(404, "App not found: " + appId); + } + return ensureApp(appId, true); + }); + } + + @Transactional + public AppEntity ensureApp(String appKey, boolean bootstrapDefaults) { + AppEntity existing = appRepository.findByAppKey(appKey).orElse(null); + if (existing != null) { + if (bootstrapDefaults) { + ensureFeatureDefaults(existing); + } + return existing; + } + + TenantEntity owner = resolveOwnerTenant(); + AppEntity app = new AppEntity(); + app.setId(UUID.randomUUID().toString()); + app.setTenantId(owner.getId()); + app.setPackageName(bootstrapAppPackage); + app.setName(bootstrapAppName); + app.setDescription(bootstrapAppDescription); + app.setIconUrl(null); + app.setAppKey(appKey); + app.setAppSecret(generateSecret()); + app.setCreatedAt(LocalDateTime.now()); + app = appRepository.save(app); + + if (bootstrapDefaults) { + ensureFeatureDefaults(app); + } + return app; + } + + private TenantEntity resolveOwnerTenant() { + return tenantRepository.findFirstByOrderByCreatedAtAsc() + .orElseGet(this::createBootstrapTenant); + } + + private TenantEntity createBootstrapTenant() { + TenantEntity tenant = new TenantEntity(); + tenant.setId(UUID.randomUUID().toString()); + tenant.setUsername("system"); + tenant.setPassword(passwordEncoder.encode(generateSecret())); + tenant.setEmail("system@xuqinmin.com"); + tenant.setNickname("System"); + tenant.setPhone(null); + tenant.setType(TenantEntity.Type.MAIN); + tenant.setStatus(TenantEntity.Status.ACTIVE); + tenant.setParentId(null); + tenant.setCreatedAt(LocalDateTime.now()); + return tenantRepository.save(tenant); + } + + private void ensureFeatureDefaults(AppEntity app) { + for (FeatureServiceEntity.Platform platform : List.of( + FeatureServiceEntity.Platform.ANDROID, + FeatureServiceEntity.Platform.IOS, + FeatureServiceEntity.Platform.HARMONY)) { + for (FeatureServiceEntity.ServiceType serviceType : List.of( + FeatureServiceEntity.ServiceType.IM, + FeatureServiceEntity.ServiceType.PUSH, + FeatureServiceEntity.ServiceType.UPDATE)) { + featureServiceRepository.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, serviceType) + .orElseGet(() -> { + FeatureServiceEntity feature = new FeatureServiceEntity(); + feature.setId(UUID.randomUUID().toString()); + feature.setAppId(app.getAppKey()); + feature.setPlatform(platform); + feature.setServiceType(serviceType); + feature.setEnabled(true); + feature.setConfig(null); + feature.setCreatedAt(LocalDateTime.now()); + return featureServiceRepository.save(feature); + }); + } + } + } + + private String generateSecret() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index e151679..e56a2c1 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -80,6 +80,11 @@ management: include: health,info sdk: + internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} + bootstrap-app-key: ${SDK_BOOTSTRAP_APP_KEY:ak_demo_chat} + bootstrap-app-name: ${SDK_BOOTSTRAP_APP_NAME:Demo Chat} + bootstrap-app-package: ${SDK_BOOTSTRAP_APP_PACKAGE:com.xuqm.demo} + bootstrap-app-description: ${SDK_BOOTSTRAP_APP_DESCRIPTION:XuqmGroup demo app} im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im} file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com} im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com}