feat(chat): 添加聊天功能相关API接口、本地缓存和数据仓库
- 添加DemoApi接口定义用户认证和资料管理API - 实现LocalImCache用于本地存储IM对话和消息历史 - 添加MessageContent模型处理多媒体消息内容 - 创建AttachmentRepository处理图片视频音频文件发送 - 实现AuthRepository管理用户登录注册和会话 - 添加VoiceRecorder支持语音录制功能 - 创建AppDependencies依赖注入容器 - 添加ChatScreen界面组件实现聊天UI逻辑
这个提交包含在:
父节点
bc329ec566
当前提交
763c097289
15
README.md
15
README.md
@ -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:}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户