diff --git a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java index dbf50c7..a0a8e00 100644 --- a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java +++ b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java @@ -3,6 +3,7 @@ package com.xuqm.im.sdk; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.xuqm.common.model.ApiResponse; @@ -18,16 +19,20 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.function.Supplier; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; public final class XuqmImServerSdk { @@ -103,6 +108,17 @@ public final class XuqmImServerSdk { return response.data(); } + public ImMessage editMessage(String messageId, String content) { + ApiResponse response = request( + "PUT", + buildUri("/api/im/messages/" + encode(messageId), Map.of("appId", appId)), + Map.of("content", content), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public List listConversations(int size) { ApiResponse> response = request( "GET", @@ -268,6 +284,42 @@ public final class XuqmImServerSdk { return response.data(); } + public boolean verifyCallbackSignature(String timestamp, String nonce, String body, String signature) { + long ts; + try { + ts = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + return false; + } + long now = System.currentTimeMillis(); + if (Math.abs(now - ts) > 5 * 60 * 1000L) { + return false; + } + String payload = appId + "\n" + timestamp + "\n" + normalize(nonce) + "\n" + sha256Hex(body); + String expected = hmacSha256Hex(appSecret, payload); + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + normalize(signature).getBytes(StandardCharsets.UTF_8) + ); + } + + public WebhookCallbackEnvelope parseCallbackEnvelope(String body) { + try { + JsonNode root = objectMapper.readTree(body); + return new WebhookCallbackEnvelope( + text(root, "callbackId"), + text(root, "callbackType"), + text(root, "callbackEvent"), + longValue(root, "requestTime"), + root.get("payload"), + text(root, "signature"), + text(root, "appId") + ); + } catch (JsonProcessingException e) { + throw new ImSdkException("Invalid callback body", e); + } + } + public PageResult searchMessages( String keyword, String chatType, @@ -1030,6 +1082,44 @@ public final class XuqmImServerSdk { ); } + private static String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(normalize(value).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception e) { + throw new ImSdkException("Failed to hash callback body", e); + } + } + + private static String hmacSha256Hex(String secret, String payload) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception e) { + throw new ImSdkException("Failed to verify callback signature", e); + } + } + + private static String normalize(String value) { + return value == null ? "" : value; + } + + private static String text(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + private static long longValue(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + if (value == null || value.isNull()) { + return 0L; + } + return value.asLong(); + } + private Map authorizedHeaders() { String token = bearerTokenSupplier == null ? null : bearerTokenSupplier.get(); if (token == null || token.isBlank()) { @@ -1411,6 +1501,16 @@ public final class XuqmImServerSdk { Long updatedAt ) {} + public record WebhookCallbackEnvelope( + String callbackId, + String callbackType, + String callbackEvent, + long requestTime, + JsonNode payload, + String signature, + String appId + ) {} + public record AppVersionView( String id, String appId, @@ -1480,7 +1580,8 @@ public final class XuqmImServerSdk { String status, String mentionedUserIds, Integer groupReadCount, - Long createdAt + Long createdAt, + Long editedAt ) {} public record SendMessageRequest( diff --git a/im-service/src/main/java/com/xuqm/im/controller/AccountController.java b/im-service/src/main/java/com/xuqm/im/controller/AccountController.java index e05b5ed..adc7867 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/AccountController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/AccountController.java @@ -13,6 +13,7 @@ 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; import java.util.Objects; @RestController @@ -47,4 +48,12 @@ public class AccountController { return ResponseEntity.ok(ApiResponse.success( accountService.updateAccount(appId, userId, nickname, avatar, gender))); } + + @GetMapping("/search") + public ResponseEntity>> search( + @RequestParam String appId, + @RequestParam String keyword, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success(accountService.searchAccounts(appId, keyword, size))); + } } 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 c8d7c8b..6bbe213 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 @@ -50,6 +50,14 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.success(groupService.listPublicGroups(appId, keyword))); } + @GetMapping("/search") + public ResponseEntity>> search( + @RequestParam String appId, + @RequestParam String keyword, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success(groupService.searchGroups(appId, keyword, size))); + } + @PutMapping("/{groupId}") public ResponseEntity> update( @PathVariable String groupId, 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 dfeac30..26913ec 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 @@ -2,6 +2,7 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.im.entity.ImMessageEntity; +import com.xuqm.im.model.EditMessageRequest; import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.service.MessageService; import jakarta.validation.Valid; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RestController; import java.time.LocalDateTime; @@ -44,6 +46,15 @@ public class MessageController { return ResponseEntity.ok(ApiResponse.success(messageService.revoke(appId, id, userId))); } + @PutMapping("/{id}") + public ResponseEntity> edit( + @PathVariable String id, + @Valid @RequestBody EditMessageRequest req, + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(messageService.edit(appId, id, userId, req))); + } + @GetMapping("/history/{toId}") public ResponseEntity> history( @PathVariable String toId, 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 3439ccc..cfc739c 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 @@ -61,6 +61,11 @@ public class ImMessageEntity { @Transient private Integer groupReadCount; + @Column + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + @JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class) + private LocalDateTime editedAt; + @Column(nullable = false) @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) @JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class) @@ -96,6 +101,11 @@ public class ImMessageEntity { public Integer getGroupReadCount() { return groupReadCount; } public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = groupReadCount; } + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + @JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class) + public LocalDateTime getEditedAt() { return editedAt; } + public void setEditedAt(LocalDateTime editedAt) { this.editedAt = editedAt; } + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) @JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class) public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/im-service/src/main/java/com/xuqm/im/model/EditMessageRequest.java b/im-service/src/main/java/com/xuqm/im/model/EditMessageRequest.java new file mode 100644 index 0000000..75f044c --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/model/EditMessageRequest.java @@ -0,0 +1,7 @@ +package com.xuqm.im.model; + +import jakarta.validation.constraints.NotBlank; + +public record EditMessageRequest( + @NotBlank String content +) {} 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 1a14b78..2aaa6c2 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 @@ -5,11 +5,13 @@ import com.xuqm.common.security.AppRequestSignatureUtil; import com.xuqm.common.security.JwtUtil; import com.xuqm.im.entity.ImAccountEntity; import com.xuqm.im.repository.ImAccountRepository; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.time.Instant; import java.util.Map; +import java.util.List; import java.util.UUID; @Service @@ -87,4 +89,8 @@ public class ImAccountService { if (gender != null) account.setGender(gender); return accountRepository.save(account); } + + public List searchAccounts(String appId, String keyword, int size) { + return accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1))); + } } 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 8839e9b..04647a1 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 @@ -2,18 +2,24 @@ package com.xuqm.im.service; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.xuqm.common.exception.BusinessException; +import com.xuqm.im.cluster.ImClusterPublisher; import com.xuqm.im.entity.ImGroupEntity; import com.xuqm.im.entity.ImGroupJoinRequestEntity; +import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImGroupMuteEntity; import com.xuqm.im.repository.ImGroupJoinRequestRepository; import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImGroupMuteRepository; +import com.xuqm.im.repository.ImMessageRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.data.domain.PageRequest; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -24,15 +30,21 @@ public class ImGroupService { private final ImGroupRepository groupRepository; private final ImGroupMuteRepository muteRepository; private final ImGroupJoinRequestRepository joinRequestRepository; + private final ImMessageRepository messageRepository; + private final ImClusterPublisher clusterPublisher; private final ObjectMapper objectMapper; public ImGroupService(ImGroupRepository groupRepository, ImGroupMuteRepository muteRepository, ImGroupJoinRequestRepository joinRequestRepository, + ImMessageRepository messageRepository, + ImClusterPublisher clusterPublisher, ObjectMapper objectMapper) { this.groupRepository = groupRepository; this.muteRepository = muteRepository; this.joinRequestRepository = joinRequestRepository; + this.messageRepository = messageRepository; + this.clusterPublisher = clusterPublisher; this.objectMapper = objectMapper; } @@ -189,6 +201,10 @@ public class ImGroupService { .toList(); } + public List searchGroups(String appId, String keyword, int size) { + return groupRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1))); + } + @Transactional public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) { ImGroupEntity group = get(groupId); @@ -211,7 +227,17 @@ public class ImGroupService { entity.setRemark(remark); entity.setStatus(ImGroupJoinRequestEntity.Status.PENDING.name()); entity.setCreatedAt(LocalDateTime.now()); - return joinRequestRepository.save(entity); + ImGroupJoinRequestEntity saved = joinRequestRepository.save(entity); + publishJoinRequestNotification( + group, + requesterId, + uniqueRecipients(group), + "GROUP_JOIN_REQUEST", + "入群申请", + buildDescription("入群申请", remark), + saved + ); + return saved; }); } @@ -228,9 +254,18 @@ public class ImGroupService { ensureCanManage(group, operatorId); request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name()); request.setReviewedAt(LocalDateTime.now()); - joinRequestRepository.save(request); + ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); addMemberInternal(group, request.getRequesterId()); - return request; + publishJoinRequestNotification( + group, + operatorId, + List.of(request.getRequesterId()), + "GROUP_JOIN_REQUEST_STATUS", + "入群申请已通过", + buildDescription("入群申请已通过", null), + saved + ); + return saved; } @Transactional @@ -240,7 +275,17 @@ public class ImGroupService { ensureCanManage(group, operatorId); request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); request.setReviewedAt(LocalDateTime.now()); - return joinRequestRepository.save(request); + ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); + publishJoinRequestNotification( + group, + operatorId, + List.of(request.getRequesterId()), + "GROUP_JOIN_REQUEST_STATUS", + "入群申请已拒绝", + buildDescription("入群申请已拒绝", null), + saved + ); + return saved; } private String toJson(List list) { @@ -258,6 +303,67 @@ public class ImGroupService { } } + private List uniqueRecipients(ImGroupEntity group) { + LinkedHashSet recipients = new LinkedHashSet<>(fromJson(group.getAdminIds())); + recipients.add(group.getCreatorId()); + return new ArrayList<>(recipients); + } + + private void publishJoinRequestNotification( + ImGroupEntity group, + String fromUserId, + List recipients, + String type, + String title, + String content, + ImGroupJoinRequestEntity request + ) { + for (String recipient : recipients) { + if (recipient == null || recipient.isBlank() || recipient.equals(fromUserId)) continue; + ImMessageEntity message = new ImMessageEntity(); + message.setId(UUID.randomUUID().toString()); + message.setAppId(group.getAppId()); + message.setFromUserId(fromUserId); + message.setToId(recipient); + message.setChatType(ImMessageEntity.ChatType.SINGLE); + message.setMsgType(ImMessageEntity.MsgType.NOTIFY); + message.setContent(buildNotificationContent(type, title, content, request, group)); + message.setStatus(ImMessageEntity.MsgStatus.SENT); + message.setCreatedAt(LocalDateTime.now()); + ImMessageEntity saved = messageRepository.save(message); + clusterPublisher.publish("/user/" + recipient + "/queue/messages", saved); + } + } + + private String buildNotificationContent( + String type, + String title, + String content, + ImGroupJoinRequestEntity request, + ImGroupEntity group + ) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("type", type); + node.put("title", title); + node.put("content", content); + node.put("requestId", request.getId()); + node.put("groupId", request.getGroupId()); + node.put("groupName", group.getName()); + node.put("requesterId", request.getRequesterId()); + node.put("status", request.getStatus()); + if (request.getRemark() != null) { + node.put("remark", request.getRemark()); + } + return node.toString(); + } + + private String buildDescription(String prefix, String remark) { + if (remark == null || remark.isBlank()) { + return prefix; + } + return prefix + ":" + remark; + } + private void addMemberInternal(ImGroupEntity group, String userId) { List members = fromJson(group.getMemberIds()); if (!members.contains(userId)) { 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 fe024c6..0a09704 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 @@ -7,6 +7,7 @@ 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.EditMessageRequest; import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.repository.ImFriendRepository; import com.xuqm.im.repository.WebhookConfigRepository; @@ -190,6 +191,61 @@ public class MessageService { return saved; } + public ImMessageEntity edit(String appId, String messageId, String requestUserId, EditMessageRequest req) { + ImMessageEntity message = messageRepository.findById(messageId) + .orElseThrow(() -> new BusinessException(404, "消息不存在")); + if (!message.getAppId().equals(appId)) { + throw new BusinessException(403, "无权操作"); + } + if (!message.getFromUserId().equals(requestUserId)) { + throw new BusinessException(403, "只能编辑自己发送的消息"); + } + if (message.getStatus() == ImMessageEntity.MsgStatus.REVOKED || message.getMsgType() == ImMessageEntity.MsgType.REVOKED) { + throw new BusinessException(400, "已撤回消息不能编辑"); + } + if (message.getMsgType() != ImMessageEntity.MsgType.TEXT) { + throw new BusinessException(400, "仅支持编辑文本消息"); + } + + String content = keywordFilterService.filter(appId, req.content()); + if (content == null) { + throw new BusinessException("消息包含违禁内容"); + } + + message.setContent(content); + message.setEditedAt(LocalDateTime.now()); + ImMessageEntity saved = messageRepository.save(message); + + if (saved.getChatType() == ImMessageEntity.ChatType.SINGLE) { + clusterPublisher.publish("/user/" + saved.getToId() + "/queue/messages", saved); + if (!saved.getFromUserId().equals(saved.getToId())) { + clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved); + } + pushBridgeClient.notifyUsers( + appId, + List.of(saved.getToId()), + "消息已编辑", + saved.getContent(), + buildPushPayload(saved) + ); + } else { + clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); + List memberIds = groupService.memberIds(groupService.get(saved.getToId())); + pushBridgeClient.notifyUsers( + appId, + memberIds.stream() + .filter(memberId -> !memberId.equals(saved.getFromUserId())) + .toList(), + "群消息已编辑", + saved.getContent(), + buildPushPayload(saved) + ); + } + + dispatchWebhooks(appId, saved); + return saved; + } + public ImMessageEntity adminRevoke(String appId, String messageId) { ImMessageEntity message = messageRepository.findById(messageId) .orElseThrow(() -> new BusinessException(404, "消息不存在")); @@ -371,7 +427,8 @@ public class MessageService { "fromUserId", message.getFromUserId(), "toId", message.getToId(), "chatType", message.getChatType().name(), - "msgType", message.getMsgType().name() + "msgType", message.getMsgType().name(), + "editedAt", message.getEditedAt() == null ? null : message.getEditedAt().toInstant(ZoneOffset.UTC).toEpochMilli() )); } catch (Exception e) { return "{}";