feat(chat): 添加聊天功能相关API接口、本地缓存和数据仓库

- 添加DemoApi接口定义用户认证和资料管理API
- 实现LocalImCache用于本地存储IM对话和消息历史
- 添加MessageContent模型处理多媒体消息内容
- 创建AttachmentRepository处理图片视频音频文件发送
- 实现AuthRepository管理用户登录注册和会话
- 添加VoiceRecorder支持语音录制功能
- 创建AppDependencies依赖注入容器
- 添加ChatScreen界面组件实现聊天UI逻辑
这个提交包含在:
XuqmGroup 2026-04-28 09:45:20 +08:00
父节点 bc329ec566
当前提交 763c097289
共有 24 个文件被更改,包括 884 次插入40 次删除

查看文件

@ -356,7 +356,11 @@ Frame 格式:
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/push/register` | 注册设备 token |
| DELETE | `/api/push/device/unregister` | 解绑设备 token |
| POST | `/api/push/send` | 向指定用户推送通知 |
| POST | `/api/push/internal/notify` | IM 服务内部调用,批量触发离线推送 |
其中 `/api/push/register`、`/api/push/device/unregister` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。
**注册 token**
```
@ -377,6 +381,17 @@ POST /api/push/send
&payload={"type":"IM","msgId":"uuid"}
```
**内部通知**
```json
{
"appId": "ak_xxx",
"userIds": ["user_001", "user_002"],
"title": "群聊消息",
"body": "张三: Hello!",
"payload": "{\"type\":\"IM\",\"msgId\":\"uuid\"}"
}
```
### 环境变量配置
```yaml

查看文件

@ -20,6 +20,10 @@ public class JwtUtil {
@Value("${jwt.expiration:86400000}")
private long expiration;
public long getExpirationMillis() {
return expiration;
}
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);

查看文件

@ -2,6 +2,7 @@ package com.xuqm.demo.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.demo.service.DemoAuthService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@ -55,11 +56,31 @@ public class DemoAuthController {
private Map<String, Object> buildResponse(DemoAuthService.AuthResult result) {
return Map.of(
"demoToken", result.demoToken() != null ? result.demoToken() : "",
"demoTokenExpiresAt", result.demoTokenExpiresAt(),
"imToken", result.imToken() != null ? result.imToken() : "",
"imTokenExpiresAt", result.imTokenExpiresAt(),
"profile", result.profile()
);
}
@PostMapping("/refresh-im")
public ApiResponse<Map<String, Object>> refreshIm(
@RequestParam(required = false) String appId,
Authentication auth) {
if (auth == null || auth.getPrincipal() == null) {
return ApiResponse.unauthorized("Unauthorized");
}
if (appId == null || appId.isBlank()) {
return ApiResponse.badRequest("appId is required");
}
DemoAuthService.ImCredential result = authService.refreshImToken(appId, auth.getPrincipal().toString());
return ApiResponse.success(Map.of(
"imToken", result.token(),
"imTokenExpiresAt", result.expiresAt()
));
}
@PostMapping("/reset-password")
public ApiResponse<Void> resetPassword(@RequestBody ResetPasswordRequest body) {
if (body.appId() == null || body.appId().isBlank()) {

查看文件

@ -50,7 +50,8 @@ public class DemoAuthService {
this.appSecretClient = appSecretClient;
}
public record AuthResult(String demoToken, String imToken, UserProfile profile) {}
public record AuthResult(String demoToken, long demoTokenExpiresAt, String imToken, long imTokenExpiresAt, UserProfile profile) {}
public record ImCredential(String token, long expiresAt) {}
public record UserProfile(String appId, String userId, String nickname, String avatar, String gender) {}
@ -71,9 +72,15 @@ public class DemoAuthService {
userRepository.save(user);
String demoToken = generateDemoToken(appId, userId);
String imToken = callImServiceLogin(appId, userId, user.getNickname());
ImCredential imCredential = callImServiceLogin(appId, userId, user.getNickname());
return new AuthResult(demoToken, imToken, toProfile(user));
return new AuthResult(
demoToken,
tokenExpiresAt(),
imCredential.token(),
imCredential.expiresAt(),
toProfile(user)
);
}
@Transactional(readOnly = true)
@ -86,9 +93,15 @@ public class DemoAuthService {
}
String demoToken = generateDemoToken(appId, userId);
String imToken = callImServiceLogin(appId, userId, user.getNickname());
ImCredential imCredential = callImServiceLogin(appId, userId, user.getNickname());
return new AuthResult(demoToken, imToken, toProfile(user));
return new AuthResult(
demoToken,
tokenExpiresAt(),
imCredential.token(),
imCredential.expiresAt(),
toProfile(user)
);
}
@Transactional
@ -103,12 +116,22 @@ public class DemoAuthService {
return jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER"));
}
private long tokenExpiresAt() {
return Instant.now().toEpochMilli() + jwtUtil.getExpirationMillis();
}
public ImCredential refreshImToken(String appId, String userId) {
DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId)
.orElseThrow(() -> new BusinessException(404, "User not found: " + userId));
return callImServiceLogin(appId, userId, user.getNickname());
}
/**
* Calls im-service to ensure the IM account exists and obtain an IM token.
* 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) {
private ImCredential callImServiceLogin(String appId, String userId, String nickname) {
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString();
String effectiveNickname = nickname != null ? nickname : userId;
@ -136,13 +159,21 @@ public class DemoAuthService {
);
JsonNode body = response.getBody();
if (body != null && body.path("code").asInt() == 200) {
return body.path("data").path("token").asText();
JsonNode data = body.path("data");
String token = data.path("token").asText(null);
if (token == null || token.isBlank()) {
throw new BusinessException(502, "Failed to refresh IM token");
}
return new ImCredential(
token,
data.path("expiresAt").asLong(tokenExpiresAt())
);
}
log.warn("im-service login returned unexpected response for appId={} userId={}: {}", appId, userId, body);
return null;
throw new BusinessException(502, "Failed to refresh IM token");
} catch (RestClientException e) {
log.error("Failed to call im-service login for appId={} userId={}: {}", appId, userId, e.getMessage());
return null;
throw new BusinessException(502, "Failed to refresh IM token");
}
}

查看文件

@ -1,6 +1,6 @@
# XuqmGroup Server 联调接口文档
> 最后更新2026-04-24
> 最后更新2026-04-28
## 线上入口
@ -9,6 +9,7 @@
| 租户服务 | `https://dev.xuqinmin.com/api/` | 认证、应用、子账号、运营平台 |
| IM HTTP | `https://dev.xuqinmin.com/api/im/` | IM 登录、消息发送、撤回、历史消息 |
| IM WebSocket | `wss://dev.xuqinmin.com/ws/im` | 实时消息 |
| 文件服务 | `https://file.dev.xuqinmin.com/api/file/` | 文件上传、下载、缩略图 |
| App 更新 | `https://dev.xuqinmin.com/api/v1/updates/` | 原生版本管理 |
| RN 热更新 | `https://dev.xuqinmin.com/api/v1/rn/` | Bundle 热更新 |
@ -88,7 +89,7 @@
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| 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/send` | 是 | 发送消息TEXT / IMAGE / AUDIO / VIDEO / FILE / LOCATION / CUSTOM / NOTIFY / RICH_TEXT / CALL_AUDIO / CALL_VIDEO / FORWARD / QUOTE / MERGE |
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
| WS | `/ws/im` | IM Token | 建立实时连接 |
@ -100,6 +101,14 @@
| POST | `/api/push/register` | 是 | 注册设备 token |
| POST | `/api/push/send` | 是 | 发送推送通知 |
### file-service
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| POST | `/api/file/upload` | 是 | 文件上传,按 SHA-256 去重 |
| GET | `/api/file/{hash}` | 否 | 按 hash 获取文件 |
| GET | `/api/file/{hash}/thumbnail` | 否 | 按 hash 获取缩略图 |
### update-service
| 方法 | 路径 | 鉴权 | 说明 |
@ -144,6 +153,17 @@ curl 'https://dev.xuqinmin.com/api/v1/rn/update/check?appId=ak_demo_chat&platfor
curl -X POST 'https://dev.xuqinmin.com/api/im/auth/login?appId=ak_demo_chat&userId=demo_alice'
```
返回示例中的 `data` 会同时包含 `token``expiresAt`,用于客户端提前做静默续签。
### Demo IM 刷新
```bash
curl -X POST 'https://dev.xuqinmin.com/api/demo/auth/refresh-im?appId=ak_demo_chat' \
-H 'Authorization: Bearer <demo_jwt>'
```
该接口会基于 demo 登录态重新签发 IM token,并返回新的 `expiresAt`
### IM 会话与关系链
```bash
@ -152,6 +172,57 @@ curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/pinned?appId
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/groups/public?appId=ak_demo_chat&keyword=demo'
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat&remark=申请加入'
curl 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat'
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat'
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/reject?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'
```
### IM 管理后台接口
```bash
curl 'https://dev.xuqinmin.com/api/im/admin/users?appId=ak_demo_chat&page=0&size=20'
curl 'https://dev.xuqinmin.com/api/im/admin/groups?appId=ak_demo_chat'
curl 'https://dev.xuqinmin.com/api/im/admin/messages?appId=ak_demo_chat&userA=user_001&userB=user_002&page=0&size=20'
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/messages/msg_001/revoke?appId=ak_demo_chat'
curl -X DELETE 'https://dev.xuqinmin.com/api/im/admin/groups/group_001'
```
### 好友申请 / 黑名单
```bash
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&toUserId=user_002&remark=hi'
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/accept?appId=ak_demo_chat'
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/reject?appId=ak_demo_chat'
curl -X POST 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002'
curl -X DELETE 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002'
```
### IM 历史过滤查询
```bash
curl 'https://dev.xuqinmin.com/api/im/messages/history/user_002?appId=ak_demo_chat&page=0&size=20&keyword=hello&msgType=TEXT'
curl 'https://dev.xuqinmin.com/api/im/messages/group-history/group_001?appId=ak_demo_chat&page=0&size=20&keyword=user_002&startTime=2026-04-27T00:00:00&endTime=2026-04-27T23:59:59'
```
支持的筛选参数:`keyword`、`msgType`、`startTime`、`endTime`。
### IM 媒体消息
```bash
curl -X POST 'https://file.dev.xuqinmin.com/api/file/upload' \
-H 'Authorization: Bearer <im_jwt>' \
-F 'file=@image.jpg'
curl -X POST 'https://im.dev.xuqinmin.com/api/im/messages/send?appId=ak_demo_chat' \
-H 'Authorization: Bearer <im_jwt>' \
-H 'Content-Type: application/json' \
-d '{"toId":"user_002","chatType":"SINGLE","msgType":"IMAGE","content":"{\"url\":\"https://file.dev.xuqinmin.com/api/file/abc\"}"}'
```
当前 `im-service` 的消息发送接口已经支持 `AUDIO`、`LOCATION`、`CUSTOM`、`RICH_TEXT`、`FORWARD`、`QUOTE`、`MERGE`、`CALL_AUDIO`、`CALL_VIDEO` 这些通用类型,客户端只需按协议传入对应 JSON 内容即可。
群聊消息在历史查询和实时推送里会携带 `groupReadCount`,用于展示 `N 人已读`

查看文件

@ -1,7 +1,6 @@
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;
@ -24,7 +23,7 @@ public class AuthController {
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, String>>> login(
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam(required = false) String nickname,
@ -36,7 +35,10 @@ public class AuthController {
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)));
ImAccountService.LoginResult result = accountService.loginOrRegister(appId, userId, nickname, avatar);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"token", result.token(),
"expiresAt", result.expiresAt()
)));
}
}

查看文件

@ -66,7 +66,12 @@ public class ConversationController {
@RequestParam String appId,
@PathVariable String targetId,
@RequestParam String chatType) {
conversationStateService.markRead(appId, userId, targetId, chatType);
var state = conversationStateService.markRead(appId, userId, targetId, chatType);
if ("GROUP".equalsIgnoreCase(chatType)) {
messageService.syncGroupReadReceipt(appId, userId, targetId, state.getLastReadAt());
} else {
messageService.syncReadReceipt(appId, userId, targetId, chatType, state.getLastReadAt());
}
return ResponseEntity.ok(ApiResponse.ok());
}

查看文件

@ -2,6 +2,7 @@ package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import com.xuqm.im.service.ImGroupService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@ -32,7 +33,7 @@ public class GroupController {
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(
groupService.create(appId, req.name(), userId, req.memberIds())));
groupService.create(appId, req.name(), userId, req.memberIds(), req.groupType())));
}
@GetMapping
@ -42,6 +43,13 @@ public class GroupController {
return ResponseEntity.ok(ApiResponse.success(groupService.listUserGroups(appId, userId)));
}
@GetMapping("/public")
public ResponseEntity<ApiResponse<List<ImGroupEntity>>> listPublic(
@RequestParam String appId,
@RequestParam(required = false) String keyword) {
return ResponseEntity.ok(ApiResponse.success(groupService.listPublicGroups(appId, keyword)));
}
@PutMapping("/{groupId}")
public ResponseEntity<ApiResponse<ImGroupEntity>> update(
@PathVariable String groupId,
@ -93,7 +101,42 @@ public class GroupController {
return ResponseEntity.ok(ApiResponse.ok());
}
public record CreateGroupRequest(String name, List<String> memberIds) {}
@PostMapping("/{groupId}/join-requests")
public ResponseEntity<ApiResponse<ImGroupJoinRequestEntity>> sendJoinRequest(
@PathVariable String groupId,
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam(required = false) String remark) {
return ResponseEntity.ok(ApiResponse.success(groupService.sendJoinRequest(appId, groupId, userId, remark)));
}
@GetMapping("/{groupId}/join-requests")
public ResponseEntity<ApiResponse<List<ImGroupJoinRequestEntity>>> listJoinRequests(
@PathVariable String groupId,
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(groupService.listJoinRequests(appId, groupId, userId)));
}
@PostMapping("/{groupId}/join-requests/{requestId}/accept")
public ResponseEntity<ApiResponse<ImGroupJoinRequestEntity>> acceptJoinRequest(
@PathVariable String groupId,
@PathVariable String requestId,
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(groupService.acceptJoinRequest(appId, requestId, userId)));
}
@PostMapping("/{groupId}/join-requests/{requestId}/reject")
public ResponseEntity<ApiResponse<ImGroupJoinRequestEntity>> rejectJoinRequest(
@PathVariable String groupId,
@PathVariable String requestId,
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(groupService.rejectJoinRequest(appId, requestId, userId)));
}
public record CreateGroupRequest(String name, List<String> memberIds, String groupType) {}
public record UpdateGroupRequest(String name, String announcement) {}
public record MemberRequest(String userId) {}
public record SetRoleRequest(String userId, String role) {}

查看文件

@ -8,11 +8,13 @@ import com.xuqm.im.repository.ImGroupRepository;
import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.ImGroupService;
import com.xuqm.im.service.MessageService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@ -25,17 +27,20 @@ public class ImAdminController {
private final ImMessageRepository messageRepository;
private final ImAccountService accountService;
private final ImGroupService groupService;
private final MessageService messageService;
public ImAdminController(ImAccountRepository accountRepository,
ImGroupRepository groupRepository,
ImMessageRepository messageRepository,
ImAccountService accountService,
ImGroupService groupService) {
ImGroupService groupService,
MessageService messageService) {
this.accountRepository = accountRepository;
this.groupRepository = groupRepository;
this.messageRepository = messageRepository;
this.accountService = accountService;
this.groupService = groupService;
this.messageService = messageService;
}
/** List all registered IM users for the given appId. */
@ -83,7 +88,7 @@ public class ImAdminController {
@RequestParam String appId,
@RequestBody CreateGroupRequest req) {
return ResponseEntity.ok(ApiResponse.success(
groupService.create(appId, req.name(), req.creatorId(), req.memberIds())));
groupService.create(appId, req.name(), req.creatorId(), req.memberIds(), "WORK")));
}
/** Fuzzy search users by userId or nickname. */
@ -112,6 +117,38 @@ public class ImAdminController {
)));
}
/** Admin queries conversation history between any two users. */
@GetMapping("/messages")
public ResponseEntity<ApiResponse<Page<com.xuqm.im.entity.ImMessageEntity>>> history(
@RequestParam String appId,
@RequestParam String userA,
@RequestParam String userB,
@RequestParam(required = false) com.xuqm.im.entity.ImMessageEntity.MsgType msgType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) LocalDateTime startTime,
@RequestParam(required = false) LocalDateTime endTime,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(
messageRepository.findSingleConversationFiltered(
appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
}
/** Admin revokes an arbitrary message. */
@PostMapping("/messages/{messageId}/revoke")
public ResponseEntity<ApiResponse<com.xuqm.im.entity.ImMessageEntity>> adminRevoke(
@RequestParam String appId,
@PathVariable String messageId) {
return ResponseEntity.ok(ApiResponse.success(messageService.adminRevoke(appId, messageId)));
}
/** Admin force dismisses a group. */
@DeleteMapping("/groups/{groupId}")
public ResponseEntity<ApiResponse<Void>> adminDismissGroup(@PathVariable String groupId) {
groupService.adminDismiss(groupId);
return ResponseEntity.ok(ApiResponse.ok());
}
public record RegisterUserRequest(String userId, String nickname, String avatar) {}
public record CreateGroupRequest(String name, String creatorId, List<String> memberIds) {}
}

查看文件

@ -4,8 +4,8 @@ import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService;
import io.jsonwebtoken.Claims;
import jakarta.validation.Valid;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
@ -16,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/api/im/messages")
public class MessageController {
@ -47,9 +49,14 @@ public class MessageController {
@PathVariable String toId,
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam(required = false) ImMessageEntity.MsgType msgType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(messageService.history(appId, userId, toId, page, size)));
return ResponseEntity.ok(ApiResponse.success(
messageService.history(appId, userId, toId, msgType, keyword, startTime, endTime, page, size)));
}
@GetMapping("/group-history/{groupId}")
@ -57,8 +64,13 @@ public class MessageController {
@PathVariable String groupId,
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam(required = false) ImMessageEntity.MsgType msgType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(messageService.groupHistory(appId, groupId, userId, page, size)));
return ResponseEntity.ok(ApiResponse.success(
messageService.groupHistory(appId, groupId, userId, msgType, keyword, startTime, endTime, page, size)));
}
}

查看文件

@ -21,6 +21,9 @@ public class ImGroupEntity {
@Column(nullable = false, length = 128)
private String name;
@Column(length = 16)
private String groupType;
@Column(nullable = false, length = 128)
private String creatorId;
@ -46,6 +49,9 @@ public class ImGroupEntity {
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getGroupType() { return groupType; }
public void setGroupType(String groupType) { this.groupType = groupType; }
public String getCreatorId() { return creatorId; }
public void setCreatorId(String creatorId) { this.creatorId = creatorId; }

查看文件

@ -0,0 +1,63 @@
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.Index;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(
name = "im_group_join_request",
indexes = @Index(name = "idx_group_join_request_app_group", columnList = "appId,groupId")
)
public class ImGroupJoinRequestEntity extends BaseIdEntity {
public enum Status { PENDING, ACCEPTED, REJECTED }
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 64)
private String groupId;
@Column(nullable = false, length = 128)
private String requesterId;
@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 getGroupId() { return groupId; }
public void setGroupId(String groupId) { this.groupId = groupId; }
public String getRequesterId() { return requesterId; }
public void setRequesterId(String requesterId) { this.requesterId = requesterId; }
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; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getReviewedAt() { return reviewedAt; }
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
}

查看文件

@ -9,6 +9,7 @@ import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import java.time.LocalDateTime;
@Entity
@ -21,7 +22,7 @@ public class ImMessageEntity {
public enum ChatType { SINGLE, GROUP }
public enum MsgType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY,
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, FORWARD, QUOTE, MERGE, REVOKED
}
public enum MsgStatus { SENT, DELIVERED, READ, REVOKED }
@ -55,6 +56,9 @@ public class ImMessageEntity {
@Column(length = 128)
private String mentionedUserIds;
@Transient
private Integer groupReadCount;
@Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
@ -86,6 +90,9 @@ public class ImMessageEntity {
public String getMentionedUserIds() { return mentionedUserIds; }
public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = mentionedUserIds; }
public Integer getGroupReadCount() { return groupReadCount; }
public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = groupReadCount; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

查看文件

@ -0,0 +1,15 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ImGroupJoinRequestRepository extends JpaRepository<ImGroupJoinRequestEntity, String> {
Optional<ImGroupJoinRequestEntity> findByAppIdAndGroupIdAndRequesterId(
String appId, String groupId, String requesterId);
List<ImGroupJoinRequestEntity> findByAppIdAndGroupId(String appId, String groupId);
List<ImGroupJoinRequestEntity> findByAppIdAndRequesterId(String appId, String requesterId);
}

查看文件

@ -64,6 +64,44 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
@Param("peerId") String peerId,
Pageable pageable);
@Query("""
select m from ImMessageEntity m
where m.appId = :appId
and m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.SINGLE
and ((m.fromUserId = :userId and m.toId = :peerId)
or (m.fromUserId = :peerId and m.toId = :userId))
and (:msgType is null or m.msgType = :msgType)
and (:keyword is null or :keyword = '' or
lower(m.content) like lower(concat('%', :keyword, '%')) or
lower(coalesce(m.mentionedUserIds, '')) like lower(concat('%', :keyword, '%')) or
lower(m.fromUserId) like lower(concat('%', :keyword, '%')) or
lower(m.toId) like lower(concat('%', :keyword, '%')))
and (:startTime is null or m.createdAt >= :startTime)
and (:endTime is null or m.createdAt <= :endTime)
order by m.createdAt desc
""")
Page<ImMessageEntity> findSingleConversationFiltered(
@Param("appId") String appId,
@Param("userId") String userId,
@Param("peerId") String peerId,
@Param("msgType") ImMessageEntity.MsgType msgType,
@Param("keyword") String keyword,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
Pageable pageable);
List<ImMessageEntity> findByAppIdAndFromUserIdAndToIdAndCreatedAtLessThanEqualOrderByCreatedAtAsc(
String appId,
String fromUserId,
String toId,
LocalDateTime createdAt);
List<ImMessageEntity> findByAppIdAndToIdAndChatTypeAndCreatedAtLessThanEqualOrderByCreatedAtAsc(
String appId,
String toId,
ImMessageEntity.ChatType chatType,
LocalDateTime createdAt);
@Query("""
select m from ImMessageEntity m
where m.appId = :appId
@ -76,6 +114,29 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
@Param("groupId") String groupId,
Pageable pageable);
@Query("""
select m from ImMessageEntity m
where m.appId = :appId
and m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.GROUP
and m.toId = :groupId
and (:msgType is null or m.msgType = :msgType)
and (:keyword is null or :keyword = '' or
lower(m.content) like lower(concat('%', :keyword, '%')) or
lower(coalesce(m.mentionedUserIds, '')) like lower(concat('%', :keyword, '%')) or
lower(m.fromUserId) like lower(concat('%', :keyword, '%')))
and (:startTime is null or m.createdAt >= :startTime)
and (:endTime is null or m.createdAt <= :endTime)
order by m.createdAt desc
""")
Page<ImMessageEntity> findGroupHistoryFiltered(
@Param("appId") String appId,
@Param("groupId") String groupId,
@Param("msgType") ImMessageEntity.MsgType msgType,
@Param("keyword") String keyword,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
Pageable pageable);
@Query("""
select count(m) from ImMessageEntity m
where m.appId = :appId

查看文件

@ -8,12 +8,15 @@ import com.xuqm.im.repository.ImAccountRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@Service
public class ImAccountService {
public record LoginResult(String token, long expiresAt) {}
private final ImAccountRepository accountRepository;
private final JwtUtil jwtUtil;
private final ImAppSecretClient appSecretClient;
@ -48,7 +51,7 @@ public class ImAccountService {
}
}
public String loginOrRegister(String appId, String userId, String nickname, String avatar) {
public LoginResult loginOrRegister(String appId, String userId, String nickname, String avatar) {
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
.orElseGet(() -> {
ImAccountEntity e = new ImAccountEntity();
@ -67,7 +70,8 @@ public class ImAccountService {
throw new BusinessException(403, "账号已被封禁");
}
return jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER"));
long expiresAt = Instant.now().toEpochMilli() + jwtUtil.getExpirationMillis();
return new LoginResult(jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER")), expiresAt);
}
public ImAccountEntity getAccount(String appId, String userId) {

查看文件

@ -4,7 +4,9 @@ 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.ImGroupJoinRequestEntity;
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 org.springframework.stereotype.Service;
@ -21,18 +23,21 @@ public class ImGroupService {
private final ImGroupRepository groupRepository;
private final ImGroupMuteRepository muteRepository;
private final ImGroupJoinRequestRepository joinRequestRepository;
private final ObjectMapper objectMapper;
public ImGroupService(ImGroupRepository groupRepository,
ImGroupMuteRepository muteRepository,
ImGroupJoinRequestRepository joinRequestRepository,
ObjectMapper objectMapper) {
this.groupRepository = groupRepository;
this.muteRepository = muteRepository;
this.joinRequestRepository = joinRequestRepository;
this.objectMapper = objectMapper;
}
@Transactional
public ImGroupEntity create(String appId, String name, String creatorId, List<String> memberIds) {
public ImGroupEntity create(String appId, String name, String creatorId, List<String> memberIds, String groupType) {
List<String> members = new ArrayList<>(memberIds);
if (!members.contains(creatorId)) members.add(creatorId);
@ -40,6 +45,7 @@ public class ImGroupService {
group.setId(UUID.randomUUID().toString());
group.setAppId(appId);
group.setName(name);
group.setGroupType(normalizeGroupType(groupType));
group.setCreatorId(creatorId);
group.setMemberIds(toJson(members));
group.setAdminIds(toJson(List.of(creatorId)));
@ -147,6 +153,12 @@ public class ImGroupService {
groupRepository.delete(group);
}
@Transactional
public void adminDismiss(String groupId) {
muteRepository.deleteByGroupId(groupId);
groupRepository.deleteById(groupId);
}
public boolean isMemberMuted(String groupId, String userId) {
return muteRepository.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now()).isPresent();
}
@ -167,6 +179,70 @@ public class ImGroupService {
return groupRepository.findUserGroups(appId, userId);
}
public List<ImGroupEntity> listPublicGroups(String appId, String keyword) {
String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase();
return groupRepository.findByAppId(appId).stream()
.filter(group -> "PUBLIC".equalsIgnoreCase(normalizeGroupType(group.getGroupType())))
.filter(group -> normalizedKeyword.isBlank()
|| group.getName().toLowerCase().contains(normalizedKeyword)
|| group.getId().toLowerCase().contains(normalizedKeyword))
.toList();
}
@Transactional
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
ImGroupEntity group = get(groupId);
if (!group.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作");
}
if (!"PUBLIC".equalsIgnoreCase(normalizeGroupType(group.getGroupType()))) {
throw new BusinessException(400, "该群不支持申请加入");
}
if (memberIds(group).contains(requesterId)) {
throw new BusinessException(400, "已经在群内");
}
return joinRequestRepository.findByAppIdAndGroupIdAndRequesterId(appId, groupId, requesterId)
.orElseGet(() -> {
ImGroupJoinRequestEntity entity = new ImGroupJoinRequestEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setGroupId(groupId);
entity.setRequesterId(requesterId);
entity.setRemark(remark);
entity.setStatus(ImGroupJoinRequestEntity.Status.PENDING.name());
entity.setCreatedAt(LocalDateTime.now());
return joinRequestRepository.save(entity);
});
}
public List<ImGroupJoinRequestEntity> listJoinRequests(String appId, String groupId, String operatorId) {
ImGroupEntity group = get(groupId);
ensureCanManage(group, operatorId);
return joinRequestRepository.findByAppIdAndGroupId(appId, groupId);
}
@Transactional
public ImGroupJoinRequestEntity acceptJoinRequest(String appId, String requestId, String operatorId) {
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
ImGroupEntity group = get(request.getGroupId());
ensureCanManage(group, operatorId);
request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name());
request.setReviewedAt(LocalDateTime.now());
joinRequestRepository.save(request);
addMemberInternal(group, request.getRequesterId());
return request;
}
@Transactional
public ImGroupJoinRequestEntity rejectJoinRequest(String appId, String requestId, String operatorId) {
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
ImGroupEntity group = get(request.getGroupId());
ensureCanManage(group, operatorId);
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now());
return joinRequestRepository.save(request);
}
private String toJson(List<String> list) {
try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; }
}
@ -181,4 +257,26 @@ public class ImGroupService {
throw new BusinessException(403, "无权操作");
}
}
private void addMemberInternal(ImGroupEntity group, String userId) {
List<String> members = fromJson(group.getMemberIds());
if (!members.contains(userId)) {
members.add(userId);
group.setMemberIds(toJson(members));
groupRepository.save(group);
}
}
private ImGroupJoinRequestEntity getJoinRequest(String appId, String requestId) {
ImGroupJoinRequestEntity request = joinRequestRepository.findById(requestId)
.orElseThrow(() -> new BusinessException(404, "加群申请不存在"));
if (!request.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作");
}
return request;
}
private String normalizeGroupType(String groupType) {
return (groupType == null || groupType.isBlank()) ? "WORK" : groupType.trim().toUpperCase();
}
}

查看文件

@ -0,0 +1,54 @@
package com.xuqm.im.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
public class ImPushBridgeClient {
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper;
@Value("${im.push-service-url:http://xuqm-push-service:8083}")
private String pushServiceUrl;
@Value("${im.internal-token:xuqm-internal-token}")
private String internalToken;
public ImPushBridgeClient(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public void notifyUsers(String appId, List<String> userIds, String title, String body, String payload) {
if (userIds == null || userIds.isEmpty()) {
return;
}
try {
Map<String, Object> bodyMap = new LinkedHashMap<>();
bodyMap.put("appId", appId);
bodyMap.put("userIds", userIds);
bodyMap.put("title", title);
bodyMap.put("body", body);
bodyMap.put("payload", payload);
String json = objectMapper.writeValueAsString(bodyMap);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(pushServiceUrl + "/api/push/internal/notify"))
.header("Content-Type", "application/json")
.header("X-Internal-Token", internalToken)
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception ignored) {
}
}
}

查看文件

@ -23,6 +23,7 @@ import java.time.LocalDateTime;
import java.time.ZoneOffset;
import com.xuqm.im.repository.ImMessageRepository;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@ -36,6 +37,7 @@ public class MessageService {
private final ImGroupService groupService;
private final BlacklistService blacklistService;
private final ConversationStateService conversationStateService;
private final ImPushBridgeClient pushBridgeClient;
private final ObjectMapper objectMapper;
@Value("${im.webhook-timeout-ms:3000}")
@ -48,6 +50,7 @@ public class MessageService {
ImGroupService groupService,
BlacklistService blacklistService,
ConversationStateService conversationStateService,
ImPushBridgeClient pushBridgeClient,
ObjectMapper objectMapper) {
this.messageRepository = messageRepository;
this.webhookRepository = webhookRepository;
@ -56,6 +59,7 @@ public class MessageService {
this.groupService = groupService;
this.blacklistService = blacklistService;
this.conversationStateService = conversationStateService;
this.pushBridgeClient = pushBridgeClient;
this.objectMapper = objectMapper;
}
@ -91,21 +95,41 @@ public class MessageService {
message.setStatus(ImMessageEntity.MsgStatus.SENT);
message.setMentionedUserIds(req.mentionedUserIds());
message.setCreatedAt(LocalDateTime.now());
messageRepository.save(message);
ImMessageEntity saved = messageRepository.save(message);
if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
saved.setGroupReadCount(groupReadCount(appId, req.toId(), saved.getCreatedAt(), saved.getFromUserId()));
}
String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE
? "/user/" + req.toId() + "/queue/messages"
: "/topic/group/" + req.toId();
clusterPublisher.publish(destination, message);
clusterPublisher.publish(destination, saved);
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) {
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", message);
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved);
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId()));
pushBridgeClient.notifyUsers(
appId,
List.of(req.toId()),
"新消息",
saved.getContent(),
buildPushPayload(saved)
);
} else if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), groupService.memberIds(group));
List<String> memberIds = groupService.memberIds(group);
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds);
pushBridgeClient.notifyUsers(
appId,
memberIds.stream()
.filter(memberId -> !memberId.equals(fromUserId))
.toList(),
"群聊消息",
saved.getContent(),
buildPushPayload(saved)
);
}
dispatchWebhooks(appId, message);
return message;
dispatchWebhooks(appId, saved);
return saved;
}
public ImMessageEntity revoke(String appId, String messageId, String requestUserId) {
@ -132,17 +156,126 @@ public class MessageService {
return saved;
}
public Page<ImMessageEntity> history(String appId, String userId, String toId, int page, int size) {
return messageRepository.findSingleConversation(
appId, userId, toId, PageRequest.of(page, size));
public ImMessageEntity adminRevoke(String appId, String messageId) {
ImMessageEntity message = messageRepository.findById(messageId)
.orElseThrow(() -> new BusinessException(404, "消息不存在"));
if (!message.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作");
}
message.setStatus(ImMessageEntity.MsgStatus.REVOKED);
message.setMsgType(ImMessageEntity.MsgType.REVOKED);
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);
}
} else {
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
}
return saved;
}
public Page<ImMessageEntity> groupHistory(String appId, String groupId, String userId, int page, int size) {
public Page<ImMessageEntity> history(
String appId,
String userId,
String toId,
ImMessageEntity.MsgType msgType,
String keyword,
LocalDateTime startTime,
LocalDateTime endTime,
int page,
int size) {
return messageRepository.findSingleConversationFiltered(
appId, userId, toId, msgType, keyword, startTime, endTime, PageRequest.of(page, size));
}
public Page<ImMessageEntity> groupHistory(
String appId,
String groupId,
String userId,
ImMessageEntity.MsgType msgType,
String keyword,
LocalDateTime startTime,
LocalDateTime endTime,
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));
Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered(
appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size));
pageResult.forEach(message -> message.setGroupReadCount(
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
return pageResult;
}
public void syncReadReceipt(String appId, String readerId, String peerId, String chatType, LocalDateTime readAt) {
if (!ImMessageEntity.ChatType.SINGLE.name().equals(chatType) || readerId.equals(peerId)) {
return;
}
List<ImMessageEntity> messages = messageRepository
.findByAppIdAndFromUserIdAndToIdAndCreatedAtLessThanEqualOrderByCreatedAtAsc(
appId, peerId, readerId, readAt);
if (messages.isEmpty()) {
return;
}
for (ImMessageEntity message : messages) {
if (message.getStatus() == ImMessageEntity.MsgStatus.READ) {
continue;
}
message.setStatus(ImMessageEntity.MsgStatus.READ);
ImMessageEntity saved = messageRepository.save(message);
clusterPublisher.publish("/user/" + peerId + "/queue/messages", saved);
}
}
public void syncGroupReadReceipt(String appId, String readerId, String groupId, LocalDateTime readAt) {
ImGroupEntity group = groupService.get(groupId);
if (!groupService.memberIds(group).contains(readerId)) {
return;
}
List<ImMessageEntity> messages = messageRepository
.findByAppIdAndToIdAndChatTypeAndCreatedAtLessThanEqualOrderByCreatedAtAsc(
appId, groupId, ImMessageEntity.ChatType.GROUP, readAt);
if (messages.isEmpty()) {
return;
}
for (ImMessageEntity message : messages) {
message.setGroupReadCount(groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()));
clusterPublisher.publish("/topic/group/" + groupId, message);
}
}
public Page<ImMessageEntity> adminHistory(
String appId,
String userA,
String userB,
ImMessageEntity.MsgType msgType,
String keyword,
LocalDateTime startTime,
LocalDateTime endTime,
int page,
int size) {
return messageRepository.findSingleConversationFiltered(
appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size));
}
public Page<ImMessageEntity> adminGroupHistory(
String appId,
String groupId,
ImMessageEntity.MsgType msgType,
String keyword,
LocalDateTime startTime,
LocalDateTime endTime,
int page,
int size) {
Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered(
appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size));
pageResult.forEach(message -> message.setGroupReadCount(
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
return pageResult;
}
public List<ImMessageRepository.ConversationSummary> conversations(String appId, String userId, int size) {
@ -180,7 +313,7 @@ public class MessageService {
return new ConversationView(
targetId,
chatType,
lastMessage != null ? lastMessage.getContent() : null,
lastMessage != null ? conversationPreview(lastMessage) : null,
lastMessage != null ? lastMessage.getMsgType().name() : null,
toEpochMillis(lastMessage != null ? lastMessage.getCreatedAt() : summary.getLastTime()),
(int) unreadCount,
@ -193,6 +326,73 @@ public class MessageService {
return time == null ? 0L : time.toInstant(ZoneOffset.UTC).toEpochMilli();
}
private String buildPushPayload(ImMessageEntity message) {
try {
return objectMapper.writeValueAsString(Map.of(
"messageId", message.getId(),
"appId", message.getAppId(),
"fromUserId", message.getFromUserId(),
"toId", message.getToId(),
"chatType", message.getChatType().name(),
"msgType", message.getMsgType().name()
));
} catch (Exception e) {
return "{}";
}
}
private String conversationPreview(ImMessageEntity message) {
String content = message.getContent();
return switch (message.getMsgType()) {
case TEXT -> extractJsonField(content, "text").orElse(content);
case IMAGE -> "[图片]";
case AUDIO -> "[语音]";
case VIDEO -> "[视频]";
case FILE -> "[文件]" + extractJsonField(content, "name").map(name -> " " + name).orElse("");
case LOCATION -> "[位置]";
case CUSTOM -> "[自定义]";
case RICH_TEXT -> "[富文本]";
case CALL_AUDIO -> "[语音通话]";
case CALL_VIDEO -> "[视频通话]";
case FORWARD -> "[转发]";
case QUOTE -> extractJsonField(content, "text").orElse("[引用]");
case MERGE -> extractJsonField(content, "title").orElse("[合并转发]");
case REVOKED -> "[消息已撤回]";
case NOTIFY -> extractJsonField(content, "content").orElse("[通知]");
default -> content;
};
}
private int groupReadCount(String appId, String groupId, LocalDateTime createdAt, String senderId) {
ImGroupEntity group = groupService.get(groupId);
int count = 0;
for (String memberId : groupService.memberIds(group)) {
if (memberId.equals(senderId)) {
count += 1;
continue;
}
var state = conversationStateService.find(appId, memberId, groupId, ImMessageEntity.ChatType.GROUP.name());
if (state != null && state.getLastReadAt() != null && !state.getLastReadAt().isBefore(createdAt)) {
count += 1;
}
}
return Math.max(count, 1);
}
private java.util.Optional<String> extractJsonField(String content, String field) {
try {
var node = objectMapper.readTree(content);
if (node.hasNonNull(field)) {
String value = node.get(field).asText();
if (!value.isBlank()) {
return java.util.Optional.of(value);
}
}
} catch (Exception ignored) {
}
return java.util.Optional.empty();
}
@Async
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);

查看文件

@ -46,6 +46,7 @@ jwt:
im:
tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081}
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
push-service-url: ${PUSH_SERVICE_URL:http://xuqm-push-service:8083}
multi-login: true
message-history-days: 30
webhook-timeout-ms: 3000

查看文件

@ -0,0 +1,36 @@
package com.xuqm.push.config;
import com.xuqm.common.security.JwtAuthFilter;
import com.xuqm.common.security.JwtUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtUtil jwtUtil;
public SecurityConfig(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/push/internal/**", "/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

查看文件

@ -0,0 +1,47 @@
package com.xuqm.push.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.push.service.PushDispatcher;
import jakarta.validation.constraints.NotBlank;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/push/internal")
public class InternalPushController {
private final PushDispatcher pushDispatcher;
@Value("${push.internal-token:xuqm-internal-token}")
private String internalToken;
public InternalPushController(PushDispatcher pushDispatcher) {
this.pushDispatcher = pushDispatcher;
}
@PostMapping("/notify")
public ResponseEntity<ApiResponse<Void>> notify(
@RequestHeader(value = "X-Internal-Token", required = false) String token,
@RequestBody NotifyRequest request) {
if (token == null || !internalToken.equals(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
}
pushDispatcher.pushToUsers(request.appId(), request.userIds(), request.title(), request.body(), request.payload());
return ResponseEntity.ok(ApiResponse.ok());
}
public record NotifyRequest(
@NotBlank String appId,
List<@NotBlank String> userIds,
@NotBlank String title,
@NotBlank String body,
String payload
) {}
}

查看文件

@ -41,6 +41,16 @@ public class PushDispatcher {
}
}
@Async
public void pushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
if (userIds == null || userIds.isEmpty()) {
return;
}
for (String userId : userIds) {
pushToUser(appId, userId, title, body, payload);
}
}
public void registerToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String token) {
Optional<DeviceTokenEntity> existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor);
DeviceTokenEntity entity = existing.orElseGet(() -> {

查看文件

@ -21,10 +21,11 @@ spring:
show-sql: false
jwt:
secret: xuqm-push-service-secret-key-must-be-at-least-256-bits-long-for-hmac-sha
secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac}
expiration: 86400000
push:
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
huawei:
app-id: ${HUAWEI_APP_ID:}
app-secret: ${HUAWEI_APP_SECRET:}