feat(chat): 添加聊天界面和文件更新SDK功能

- 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型
- 集成IM消息收发功能,实现消息气泡显示和用户头像占位符
- 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像
- 实现语音录制和播放功能,包含按住说话交互和权限处理
- 添加群组提及功能,支持@用户和提及候选列表显示
- 实现消息回复和引用功能,支持消息长按回复操作
- 添加本地消息搜索功能,支持搜索当前会话的历史消息
- 实现文件上传下载功能,集成FileSDK进行文件传输管理
- 添加应用更新检查功能,集成UpdateSDK支持版本更新
- 实现消息状态显示,包括发送、送达、已读等状态标识
- 添加群组已读人数统计,显示消息在群聊中的阅读情况
- 实现草稿保存和恢复功能,支持断点续聊体验
- 添加连接状态横幅,实时显示IM服务连接状态
- 实现滚动加载更多历史消息,优化大量消息的性能表现
- 添加多媒体文件下载保存功能,支持保存到应用专属目录
这个提交包含在:
XuqmGroup 2026-04-28 20:11:38 +08:00
父节点 c217e96482
当前提交 1e395171a3
共有 9 个文件被更改,包括 321 次插入6 次删除

查看文件

@ -3,6 +3,7 @@ package com.xuqm.im.sdk;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
@ -18,16 +19,20 @@ import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.function.Supplier; import java.util.function.Supplier;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class XuqmImServerSdk { public final class XuqmImServerSdk {
@ -103,6 +108,17 @@ public final class XuqmImServerSdk {
return response.data(); return response.data();
} }
public ImMessage editMessage(String messageId, String content) {
ApiResponse<ImMessage> 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<ConversationView> listConversations(int size) { public List<ConversationView> listConversations(int size) {
ApiResponse<List<ConversationView>> response = request( ApiResponse<List<ConversationView>> response = request(
"GET", "GET",
@ -268,6 +284,42 @@ public final class XuqmImServerSdk {
return response.data(); 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<ImMessage> searchMessages( public PageResult<ImMessage> searchMessages(
String keyword, String keyword,
String chatType, 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<String, String> authorizedHeaders() { private Map<String, String> authorizedHeaders() {
String token = bearerTokenSupplier == null ? null : bearerTokenSupplier.get(); String token = bearerTokenSupplier == null ? null : bearerTokenSupplier.get();
if (token == null || token.isBlank()) { if (token == null || token.isBlank()) {
@ -1411,6 +1501,16 @@ public final class XuqmImServerSdk {
Long updatedAt Long updatedAt
) {} ) {}
public record WebhookCallbackEnvelope(
String callbackId,
String callbackType,
String callbackEvent,
long requestTime,
JsonNode payload,
String signature,
String appId
) {}
public record AppVersionView( public record AppVersionView(
String id, String id,
String appId, String appId,
@ -1480,7 +1580,8 @@ public final class XuqmImServerSdk {
String status, String status,
String mentionedUserIds, String mentionedUserIds,
Integer groupReadCount, Integer groupReadCount,
Long createdAt Long createdAt,
Long editedAt
) {} ) {}
public record SendMessageRequest( public record SendMessageRequest(

查看文件

@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Objects; import java.util.Objects;
@RestController @RestController
@ -47,4 +48,12 @@ public class AccountController {
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
accountService.updateAccount(appId, userId, nickname, avatar, gender))); accountService.updateAccount(appId, userId, nickname, avatar, gender)));
} }
@GetMapping("/search")
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> search(
@RequestParam String appId,
@RequestParam String keyword,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(accountService.searchAccounts(appId, keyword, size)));
}
} }

查看文件

@ -50,6 +50,14 @@ public class GroupController {
return ResponseEntity.ok(ApiResponse.success(groupService.listPublicGroups(appId, keyword))); return ResponseEntity.ok(ApiResponse.success(groupService.listPublicGroups(appId, keyword)));
} }
@GetMapping("/search")
public ResponseEntity<ApiResponse<List<ImGroupEntity>>> search(
@RequestParam String appId,
@RequestParam String keyword,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(groupService.searchGroups(appId, keyword, size)));
}
@PutMapping("/{groupId}") @PutMapping("/{groupId}")
public ResponseEntity<ApiResponse<ImGroupEntity>> update( public ResponseEntity<ApiResponse<ImGroupEntity>> update(
@PathVariable String groupId, @PathVariable String groupId,

查看文件

@ -2,6 +2,7 @@ package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.EditMessageRequest;
import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService; import com.xuqm.im.service.MessageService;
import jakarta.validation.Valid; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -44,6 +46,15 @@ public class MessageController {
return ResponseEntity.ok(ApiResponse.success(messageService.revoke(appId, id, userId))); return ResponseEntity.ok(ApiResponse.success(messageService.revoke(appId, id, userId)));
} }
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<ImMessageEntity>> 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}") @GetMapping("/history/{toId}")
public ResponseEntity<ApiResponse<?>> history( public ResponseEntity<ApiResponse<?>> history(
@PathVariable String toId, @PathVariable String toId,

查看文件

@ -61,6 +61,11 @@ public class ImMessageEntity {
@Transient @Transient
private Integer groupReadCount; private Integer groupReadCount;
@Column
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
private LocalDateTime editedAt;
@Column(nullable = false) @Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class) @JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
@ -96,6 +101,11 @@ public class ImMessageEntity {
public Integer getGroupReadCount() { return groupReadCount; } public Integer getGroupReadCount() { return groupReadCount; }
public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = 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) @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class) @JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }

查看文件

@ -0,0 +1,7 @@
package com.xuqm.im.model;
import jakarta.validation.constraints.NotBlank;
public record EditMessageRequest(
@NotBlank String content
) {}

查看文件

@ -5,11 +5,13 @@ import com.xuqm.common.security.AppRequestSignatureUtil;
import com.xuqm.common.security.JwtUtil; import com.xuqm.common.security.JwtUtil;
import com.xuqm.im.entity.ImAccountEntity; import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.repository.ImAccountRepository; import com.xuqm.im.repository.ImAccountRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.List;
import java.util.UUID; import java.util.UUID;
@Service @Service
@ -87,4 +89,8 @@ public class ImAccountService {
if (gender != null) account.setGender(gender); if (gender != null) account.setGender(gender);
return accountRepository.save(account); return accountRepository.save(account);
} }
public List<ImAccountEntity> searchAccounts(String appId, String keyword, int size) {
return accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1)));
}
} }

查看文件

@ -2,18 +2,24 @@ package com.xuqm.im.service;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.cluster.ImClusterPublisher;
import com.xuqm.im.entity.ImGroupEntity; import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImGroupJoinRequestEntity; import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.ImGroupMuteEntity; import com.xuqm.im.entity.ImGroupMuteEntity;
import com.xuqm.im.repository.ImGroupJoinRequestRepository; import com.xuqm.im.repository.ImGroupJoinRequestRepository;
import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImGroupRepository;
import com.xuqm.im.repository.ImGroupMuteRepository; import com.xuqm.im.repository.ImGroupMuteRepository;
import com.xuqm.im.repository.ImMessageRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@ -24,15 +30,21 @@ public class ImGroupService {
private final ImGroupRepository groupRepository; private final ImGroupRepository groupRepository;
private final ImGroupMuteRepository muteRepository; private final ImGroupMuteRepository muteRepository;
private final ImGroupJoinRequestRepository joinRequestRepository; private final ImGroupJoinRequestRepository joinRequestRepository;
private final ImMessageRepository messageRepository;
private final ImClusterPublisher clusterPublisher;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ImGroupService(ImGroupRepository groupRepository, public ImGroupService(ImGroupRepository groupRepository,
ImGroupMuteRepository muteRepository, ImGroupMuteRepository muteRepository,
ImGroupJoinRequestRepository joinRequestRepository, ImGroupJoinRequestRepository joinRequestRepository,
ImMessageRepository messageRepository,
ImClusterPublisher clusterPublisher,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.muteRepository = muteRepository; this.muteRepository = muteRepository;
this.joinRequestRepository = joinRequestRepository; this.joinRequestRepository = joinRequestRepository;
this.messageRepository = messageRepository;
this.clusterPublisher = clusterPublisher;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@ -189,6 +201,10 @@ public class ImGroupService {
.toList(); .toList();
} }
public List<ImGroupEntity> searchGroups(String appId, String keyword, int size) {
return groupRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1)));
}
@Transactional @Transactional
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) { public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
ImGroupEntity group = get(groupId); ImGroupEntity group = get(groupId);
@ -211,7 +227,17 @@ public class ImGroupService {
entity.setRemark(remark); entity.setRemark(remark);
entity.setStatus(ImGroupJoinRequestEntity.Status.PENDING.name()); entity.setStatus(ImGroupJoinRequestEntity.Status.PENDING.name());
entity.setCreatedAt(LocalDateTime.now()); 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); ensureCanManage(group, operatorId);
request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name()); request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name());
request.setReviewedAt(LocalDateTime.now()); request.setReviewedAt(LocalDateTime.now());
joinRequestRepository.save(request); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
addMemberInternal(group, request.getRequesterId()); addMemberInternal(group, request.getRequesterId());
return request; publishJoinRequestNotification(
group,
operatorId,
List.of(request.getRequesterId()),
"GROUP_JOIN_REQUEST_STATUS",
"入群申请已通过",
buildDescription("入群申请已通过", null),
saved
);
return saved;
} }
@Transactional @Transactional
@ -240,7 +275,17 @@ public class ImGroupService {
ensureCanManage(group, operatorId); ensureCanManage(group, operatorId);
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now()); 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<String> list) { private String toJson(List<String> list) {
@ -258,6 +303,67 @@ public class ImGroupService {
} }
} }
private List<String> uniqueRecipients(ImGroupEntity group) {
LinkedHashSet<String> recipients = new LinkedHashSet<>(fromJson(group.getAdminIds()));
recipients.add(group.getCreatorId());
return new ArrayList<>(recipients);
}
private void publishJoinRequestNotification(
ImGroupEntity group,
String fromUserId,
List<String> 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) { private void addMemberInternal(ImGroupEntity group, String userId) {
List<String> members = fromJson(group.getMemberIds()); List<String> members = fromJson(group.getMemberIds());
if (!members.contains(userId)) { if (!members.contains(userId)) {

查看文件

@ -7,6 +7,7 @@ import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.model.ConversationView; import com.xuqm.im.model.ConversationView;
import com.xuqm.im.model.EditMessageRequest;
import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.repository.ImFriendRepository; import com.xuqm.im.repository.ImFriendRepository;
import com.xuqm.im.repository.WebhookConfigRepository; import com.xuqm.im.repository.WebhookConfigRepository;
@ -190,6 +191,61 @@ public class MessageService {
return saved; 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<String> 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) { public ImMessageEntity adminRevoke(String appId, String messageId) {
ImMessageEntity message = messageRepository.findById(messageId) ImMessageEntity message = messageRepository.findById(messageId)
.orElseThrow(() -> new BusinessException(404, "消息不存在")); .orElseThrow(() -> new BusinessException(404, "消息不存在"));
@ -371,7 +427,8 @@ public class MessageService {
"fromUserId", message.getFromUserId(), "fromUserId", message.getFromUserId(),
"toId", message.getToId(), "toId", message.getToId(),
"chatType", message.getChatType().name(), "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) { } catch (Exception e) {
return "{}"; return "{}";