feat: 补充后端API对齐腾讯IM

新增API:
- GET /api/im/admin/users/state - 查询用户在线状态
- POST /api/im/admin/users/kick - 强制用户下线
- POST /api/im/admin/messages/batch-send - 批量发消息
- POST /api/im/admin/messages/read - 管理员设置消息已读
- POST /api/im/admin/messages/import - 导入历史消息
- POST /api/im/friends/check - 校验好友关系
- PUT /api/im/groups/{groupId}/members/{userId}/info - 修改群成员资料

新增字段:
- ImGroupEntity.memberInfo - 群成员资料(JSON)

修复编译import错误
这个提交包含在:
XuqmGroup 2026-05-01 23:13:09 +08:00
父节点 b25b4746e9
当前提交 3f4d78f175
共有 8 个文件被更改,包括 251 次插入1 次删除

查看文件

@ -5,6 +5,7 @@ import com.xuqm.common.security.JwtUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
@ -21,6 +22,7 @@ import java.util.List;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtUtil jwtUtil;

查看文件

@ -113,5 +113,21 @@ public class FriendController {
return friendIds == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(friendIds));
}
public record FriendBatchRequest(List<String> friendIds) {}
@PostMapping("/check")
public ResponseEntity<ApiResponse<List<FriendCheckResult>>> checkFriends(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestBody FriendCheckRequest req) {
List<FriendCheckResult> results = new ArrayList<>();
for (String friendId : req.friendIds() == null ? List.<String>of() : req.friendIds()) {
boolean isFriend = friendRepository.existsByAppIdAndUserIdAndFriendId(appId, userId, friendId)
|| friendRepository.existsByAppIdAndUserIdAndFriendId(appId, friendId, userId);
results.add(new FriendCheckResult(friendId, isFriend));
}
return ResponseEntity.ok(ApiResponse.success(results));
}
public record FriendBatchRequest(List<String> friendIds) {}
public record FriendCheckRequest(List<String> friendIds) {}
public record FriendCheckResult(String userId, boolean isFriend) {}
}

查看文件

@ -199,6 +199,16 @@ public class GroupController {
groupService.rejectJoinRequests(appId, groupId, req.requestIds(), userId)));
}
@PutMapping("/{groupId}/members/{userId}/info")
public ResponseEntity<ApiResponse<ImGroupEntity>> modifyMemberInfo(
@PathVariable String groupId,
@PathVariable String userId,
@RequestBody ModifyMemberInfoRequest req,
@AuthenticationPrincipal String operatorId) {
return ResponseEntity.ok(ApiResponse.success(
groupService.modifyMemberInfo(groupId, userId, req.nickName())));
}
public record CreateGroupRequest(String name, List<String> memberIds, String groupType) {}
public record UpdateGroupRequest(String name, String groupType, String announcement) {}
public record MemberRequest(String userId) {}
@ -206,4 +216,5 @@ public class GroupController {
public record SetRoleRequest(String userId, String role) {}
public record MuteMemberRequest(String userId, long minutes) {}
public record RequestBatch(List<String> requestIds) {}
public record ModifyMemberInfoRequest(String nickName) {}
}

查看文件

@ -25,11 +25,18 @@ import com.xuqm.im.service.OperationLogService;
import com.xuqm.im.service.WebhookConfigService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -49,6 +56,9 @@ public class ImAdminController {
private final KeywordFilterService keywordFilterService;
private final GlobalMuteService globalMuteService;
private final OperationLogService operationLogService;
private final SimpUserRegistry simpUserRegistry;
private final SimpMessagingTemplate messagingTemplate;
private final StringRedisTemplate redisTemplate;
public ImAdminController(ImAccountRepository accountRepository,
ImGroupRepository groupRepository,
@ -61,7 +71,10 @@ public class ImAdminController {
WebhookConfigService webhookConfigService,
KeywordFilterService keywordFilterService,
GlobalMuteService globalMuteService,
OperationLogService operationLogService) {
OperationLogService operationLogService,
SimpUserRegistry simpUserRegistry,
SimpMessagingTemplate messagingTemplate,
StringRedisTemplate redisTemplate) {
this.accountRepository = accountRepository;
this.groupRepository = groupRepository;
this.messageRepository = messageRepository;
@ -74,6 +87,9 @@ public class ImAdminController {
this.keywordFilterService = keywordFilterService;
this.globalMuteService = globalMuteService;
this.operationLogService = operationLogService;
this.simpUserRegistry = simpUserRegistry;
this.messagingTemplate = messagingTemplate;
this.redisTemplate = redisTemplate;
}
/** List all registered IM users for the given appId. */
@ -528,6 +544,75 @@ public class ImAdminController {
operationLogService.list(appId, PageRequest.of(page, size))));
}
@GetMapping("/users/state")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Boolean>>> queryUserState(
@RequestParam String userIds) {
Map<String, Boolean> result = new LinkedHashMap<>();
for (String userId : userIds.split(",")) {
String trimmed = userId.trim();
if (!trimmed.isBlank()) {
result.put(trimmed, simpUserRegistry.getUser(trimmed) != null);
}
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/users/kick")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Void>> kickUsers(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody KickRequest req) {
for (String userId : req.userIds()) {
messagingTemplate.convertAndSendToUser(userId, "/queue/kick",
Map.of("type", "KICK", "reason", "管理员强制下线"));
redisTemplate.opsForValue().set("im:kick:" + appId + ":" + userId, "1", Duration.ofMinutes(5));
}
operationLogService.record(appId, operatorId, "KICK_USERS", "ACCOUNT", null,
String.join(",", req.userIds()));
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping("/messages/batch-send")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> batchSendMsg(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody BatchSendRequest req) {
List<ImMessageEntity> result = new ArrayList<>();
for (String toId : req.toIds()) {
ImMessageEntity sent = messageService.adminSend(appId, operatorId, toId, req.msgType(), req.content());
result.add(sent);
}
operationLogService.record(appId, operatorId, "BATCH_SEND_MESSAGE", "MESSAGE", null,
"count=" + result.size());
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/messages/read")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Void>> adminSetMsgRead(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody SetMsgReadRequest req) {
messageService.adminSetMsgRead(appId, req.userId());
operationLogService.record(appId, operatorId, "ADMIN_SET_MSG_READ", "MESSAGE", req.userId(), null);
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping("/messages/import")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> importMessages(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody List<MessageService.ImportMessageRequest> req) {
List<ImMessageEntity> result = messageService.importMessages(appId, req);
operationLogService.record(appId, operatorId, "IMPORT_MESSAGES", "MESSAGE", null,
"count=" + result.size());
return ResponseEntity.ok(ApiResponse.success(result));
}
public record RegisterUserRequest(
String userId,
String nickname,
@ -548,4 +633,7 @@ public class ImAdminController {
public record MemberRequest(String userId) {}
public record SetRoleRequest(String userId, String role) {}
public record MuteMemberRequest(String userId, long minutes) {}
public record KickRequest(List<String> userIds) {}
public record BatchSendRequest(List<String> toIds, ImMessageEntity.MsgType msgType, String content) {}
public record SetMsgReadRequest(String userId) {}
}

查看文件

@ -36,6 +36,9 @@ public class ImGroupEntity {
@Column(columnDefinition = "TEXT")
private String announcement;
@Column(columnDefinition = "TEXT")
private String memberInfo;
@Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
@ -64,6 +67,9 @@ public class ImGroupEntity {
public String getAnnouncement() { return announcement; }
public void setAnnouncement(String announcement) { this.announcement = announcement; }
public String getMemberInfo() { return memberInfo; }
public void setMemberInfo(String memberInfo) { this.memberInfo = memberInfo; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

查看文件

@ -203,4 +203,15 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
default long countTodayByAppId(String appId) {
return countByAppIdAndCreatedAtAfter(appId, LocalDateTime.now().toLocalDate().atStartOfDay());
}
@Query("""
select m from ImMessageEntity m
where m.appId = :appId
and m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.SINGLE
and m.toId = :userId
and m.status <> com.xuqm.im.entity.ImMessageEntity$MsgStatus.READ
""")
List<ImMessageEntity> findUnreadByAppIdAndToId(
@Param("appId") String appId,
@Param("userId") String userId);
}

查看文件

@ -22,8 +22,10 @@ import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@ -419,6 +421,14 @@ public class ImGroupService {
try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; }
}
private String toJson(Map<?, ?> map) {
try { return objectMapper.writeValueAsString(map); } catch (Exception e) { return "{}"; }
}
private String toJson(Object obj) {
try { return objectMapper.writeValueAsString(obj); } catch (Exception e) { return "{}"; }
}
private List<String> fromJson(String json) {
try { return objectMapper.readValue(json, new TypeReference<>() {}); } catch (Exception e) { return new ArrayList<>(); }
}
@ -562,6 +572,40 @@ public class ImGroupService {
return joinRequestRepository.findByAppIdAndGroupId(appId, groupId);
}
@Transactional
public ImGroupEntity modifyMemberInfo(String groupId, String userId, String nickName) {
ImGroupEntity group = get(groupId);
List<String> members = memberIds(group);
if (!members.contains(userId)) {
throw new BusinessException(404, "群成员不存在");
}
Map<String, Map<String, String>> memberInfoMap = parseMemberInfo(group.getMemberInfo());
Map<String, String> info = memberInfoMap.computeIfAbsent(userId, k -> new java.util.HashMap<>());
if (nickName != null) {
info.put("nickName", nickName);
}
group.setMemberInfo(toJson(memberInfoMap));
return groupRepository.save(group);
}
@Transactional
public ImGroupEntity adminModifyMemberInfo(String appId, String groupId, String userId, String nickName) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
return modifyMemberInfo(groupId, userId, nickName);
}
private Map<String, Map<String, String>> parseMemberInfo(String memberInfoJson) {
try {
if (memberInfoJson == null || memberInfoJson.isBlank()) {
return new java.util.HashMap<>();
}
return objectMapper.readValue(memberInfoJson, new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception e) {
return new java.util.HashMap<>();
}
}
@Transactional
public ImGroupJoinRequestEntity adminAcceptJoinRequest(String appId, String groupId, String requestId) {
ImGroupEntity group = get(groupId);

查看文件

@ -19,6 +19,8 @@ import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import com.xuqm.im.repository.ImMessageRepository;
import java.util.ArrayList;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -556,4 +558,74 @@ public class MessageService {
protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) {
webhookDispatchService.dispatch(appId, "message", callbackEvent, payload);
}
public ImMessageEntity adminSend(String appId, String fromUserId, String toId, ImMessageEntity.MsgType msgType, String content) {
ImMessageEntity message = new ImMessageEntity();
message.setId(UUID.randomUUID().toString());
message.setAppId(appId);
message.setFromUserId(fromUserId);
message.setToId(toId);
message.setChatType(ImMessageEntity.ChatType.SINGLE);
message.setMsgType(msgType);
message.setContent(content);
message.setStatus(ImMessageEntity.MsgStatus.SENT);
message.setCreatedAt(LocalDateTime.now());
ImMessageEntity saved = messageRepository.save(message);
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved);
clusterPublisher.publish("/user/" + toId + "/queue/messages", saved);
conversationStateService.clearHiddenForUsers(appId, toId, ImMessageEntity.ChatType.SINGLE.name(), List.of(fromUserId, toId));
imPushBridge.sendOfflinePushToUsers(
appId,
List.of(toId),
"新消息",
saved.getContent(),
buildPushPayload(saved)
);
dispatchWebhooks(appId, "message.sent", saved);
return saved;
}
public void adminSetMsgRead(String appId, String userId) {
List<ImMessageEntity> messages = messageRepository.findUnreadByAppIdAndToId(appId, userId);
for (ImMessageEntity message : messages) {
message.setStatus(ImMessageEntity.MsgStatus.READ);
messageRepository.save(message);
}
}
public List<ImMessageEntity> importMessages(String appId, List<ImportMessageRequest> requests) {
List<ImMessageEntity> result = new ArrayList<>();
for (ImportMessageRequest req : requests == null ? List.<ImportMessageRequest>of() : requests) {
if (req == null || req.fromUserId() == null || req.fromUserId().isBlank()
|| req.toId() == null || req.toId().isBlank()) {
continue;
}
ImMessageEntity message = new ImMessageEntity();
message.setId(req.messageId() != null && !req.messageId().isBlank()
? req.messageId()
: UUID.randomUUID().toString());
message.setAppId(appId);
message.setFromUserId(req.fromUserId());
message.setToId(req.toId());
message.setChatType(req.chatType() != null ? req.chatType() : ImMessageEntity.ChatType.SINGLE);
message.setMsgType(req.msgType() != null ? req.msgType() : ImMessageEntity.MsgType.TEXT);
message.setContent(req.content() != null ? req.content() : "");
message.setStatus(req.status() != null ? req.status() : ImMessageEntity.MsgStatus.READ);
message.setCreatedAt(req.createdAt() != null ? req.createdAt() : LocalDateTime.now());
result.add(messageRepository.save(message));
}
return result;
}
public record ImportMessageRequest(
String messageId,
String fromUserId,
String toId,
ImMessageEntity.ChatType chatType,
ImMessageEntity.MsgType msgType,
String content,
ImMessageEntity.MsgStatus status,
LocalDateTime createdAt) {}
}