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 |
|
| POST | `/api/push/register` | 注册设备 token |
|
||||||
|
| DELETE | `/api/push/device/unregister` | 解绑设备 token |
|
||||||
| POST | `/api/push/send` | 向指定用户推送通知 |
|
| POST | `/api/push/send` | 向指定用户推送通知 |
|
||||||
|
| POST | `/api/push/internal/notify` | IM 服务内部调用,批量触发离线推送 |
|
||||||
|
|
||||||
|
其中 `/api/push/register`、`/api/push/device/unregister` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。
|
||||||
|
|
||||||
**注册 token**
|
**注册 token**
|
||||||
```
|
```
|
||||||
@ -377,6 +381,17 @@ POST /api/push/send
|
|||||||
&payload={"type":"IM","msgId":"uuid"}
|
&payload={"type":"IM","msgId":"uuid"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**内部通知**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appId": "ak_xxx",
|
||||||
|
"userIds": ["user_001", "user_002"],
|
||||||
|
"title": "群聊消息",
|
||||||
|
"body": "张三: Hello!",
|
||||||
|
"payload": "{\"type\":\"IM\",\"msgId\":\"uuid\"}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 环境变量配置
|
### 环境变量配置
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@ -20,6 +20,10 @@ public class JwtUtil {
|
|||||||
@Value("${jwt.expiration:86400000}")
|
@Value("${jwt.expiration:86400000}")
|
||||||
private long expiration;
|
private long expiration;
|
||||||
|
|
||||||
|
public long getExpirationMillis() {
|
||||||
|
return expiration;
|
||||||
|
}
|
||||||
|
|
||||||
private SecretKey getSigningKey() {
|
private SecretKey getSigningKey() {
|
||||||
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
|
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||||
return Keys.hmacShaKeyFor(keyBytes);
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.xuqm.demo.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.demo.service.DemoAuthService;
|
import com.xuqm.demo.service.DemoAuthService;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -55,11 +56,31 @@ public class DemoAuthController {
|
|||||||
private Map<String, Object> buildResponse(DemoAuthService.AuthResult result) {
|
private Map<String, Object> buildResponse(DemoAuthService.AuthResult result) {
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"demoToken", result.demoToken() != null ? result.demoToken() : "",
|
"demoToken", result.demoToken() != null ? result.demoToken() : "",
|
||||||
|
"demoTokenExpiresAt", result.demoTokenExpiresAt(),
|
||||||
"imToken", result.imToken() != null ? result.imToken() : "",
|
"imToken", result.imToken() != null ? result.imToken() : "",
|
||||||
|
"imTokenExpiresAt", result.imTokenExpiresAt(),
|
||||||
"profile", result.profile()
|
"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")
|
@PostMapping("/reset-password")
|
||||||
public ApiResponse<Void> resetPassword(@RequestBody ResetPasswordRequest body) {
|
public ApiResponse<Void> resetPassword(@RequestBody ResetPasswordRequest body) {
|
||||||
if (body.appId() == null || body.appId().isBlank()) {
|
if (body.appId() == null || body.appId().isBlank()) {
|
||||||
|
|||||||
@ -50,7 +50,8 @@ public class DemoAuthService {
|
|||||||
this.appSecretClient = appSecretClient;
|
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) {}
|
public record UserProfile(String appId, String userId, String nickname, String avatar, String gender) {}
|
||||||
|
|
||||||
@ -71,9 +72,15 @@ public class DemoAuthService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
String demoToken = generateDemoToken(appId, userId);
|
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)
|
@Transactional(readOnly = true)
|
||||||
@ -86,9 +93,15 @@ public class DemoAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String demoToken = generateDemoToken(appId, userId);
|
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
|
@Transactional
|
||||||
@ -103,12 +116,22 @@ public class DemoAuthService {
|
|||||||
return jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER"));
|
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.
|
* 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}
|
* POST {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}&nickname={nickname}
|
||||||
* Response: {"code":200,"data":{"token":"..."}}
|
* 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();
|
long timestamp = System.currentTimeMillis();
|
||||||
String nonce = UUID.randomUUID().toString();
|
String nonce = UUID.randomUUID().toString();
|
||||||
String effectiveNickname = nickname != null ? nickname : userId;
|
String effectiveNickname = nickname != null ? nickname : userId;
|
||||||
@ -136,13 +159,21 @@ public class DemoAuthService {
|
|||||||
);
|
);
|
||||||
JsonNode body = response.getBody();
|
JsonNode body = response.getBody();
|
||||||
if (body != null && body.path("code").asInt() == 200) {
|
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);
|
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) {
|
} catch (RestClientException e) {
|
||||||
log.error("Failed to call im-service login for appId={} userId={}: {}", appId, userId, e.getMessage());
|
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 联调接口文档
|
# XuqmGroup Server 联调接口文档
|
||||||
|
|
||||||
> 最后更新:2026-04-24
|
> 最后更新:2026-04-28
|
||||||
|
|
||||||
## 线上入口
|
## 线上入口
|
||||||
|
|
||||||
@ -9,6 +9,7 @@
|
|||||||
| 租户服务 | `https://dev.xuqinmin.com/api/` | 认证、应用、子账号、运营平台 |
|
| 租户服务 | `https://dev.xuqinmin.com/api/` | 认证、应用、子账号、运营平台 |
|
||||||
| IM HTTP | `https://dev.xuqinmin.com/api/im/` | IM 登录、消息发送、撤回、历史消息 |
|
| IM HTTP | `https://dev.xuqinmin.com/api/im/` | IM 登录、消息发送、撤回、历史消息 |
|
||||||
| IM WebSocket | `wss://dev.xuqinmin.com/ws/im` | 实时消息 |
|
| IM WebSocket | `wss://dev.xuqinmin.com/ws/im` | 实时消息 |
|
||||||
|
| 文件服务 | `https://file.dev.xuqinmin.com/api/file/` | 文件上传、下载、缩略图 |
|
||||||
| App 更新 | `https://dev.xuqinmin.com/api/v1/updates/` | 原生版本管理 |
|
| App 更新 | `https://dev.xuqinmin.com/api/v1/updates/` | 原生版本管理 |
|
||||||
| RN 热更新 | `https://dev.xuqinmin.com/api/v1/rn/` | Bundle 热更新 |
|
| 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/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` | 是 | 撤回消息 |
|
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
|
||||||
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
|
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
|
||||||
| WS | `/ws/im` | IM Token | 建立实时连接 |
|
| WS | `/ws/im` | IM Token | 建立实时连接 |
|
||||||
@ -100,6 +101,14 @@
|
|||||||
| POST | `/api/push/register` | 是 | 注册设备 token |
|
| POST | `/api/push/register` | 是 | 注册设备 token |
|
||||||
| POST | `/api/push/send` | 是 | 发送推送通知 |
|
| 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
|
### 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'
|
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 会话与关系链
|
### IM 会话与关系链
|
||||||
|
|
||||||
```bash
|
```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 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 -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?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/blacklist?appId=ak_demo_chat'
|
||||||
curl 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&direction=incoming'
|
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;
|
package com.xuqm.im.controller;
|
||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.common.security.AppRequestSignatureUtil;
|
|
||||||
import com.xuqm.im.service.ImAccountService;
|
import com.xuqm.im.service.ImAccountService;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@ -24,7 +23,7 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<ApiResponse<Map<String, String>>> login(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
||||||
@RequestParam @NotBlank String appId,
|
@RequestParam @NotBlank String appId,
|
||||||
@RequestParam @NotBlank String userId,
|
@RequestParam @NotBlank String userId,
|
||||||
@RequestParam(required = false) String nickname,
|
@RequestParam(required = false) String nickname,
|
||||||
@ -36,7 +35,10 @@ public class AuthController {
|
|||||||
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature"));
|
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature"));
|
||||||
}
|
}
|
||||||
accountService.validateSignature(appId, userId, nickname, avatar, timestamp, nonce, signature);
|
accountService.validateSignature(appId, userId, nickname, avatar, timestamp, nonce, signature);
|
||||||
String token = accountService.loginOrRegister(appId, userId, nickname, avatar);
|
ImAccountService.LoginResult result = accountService.loginOrRegister(appId, userId, nickname, avatar);
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("token", token)));
|
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||||
|
"token", result.token(),
|
||||||
|
"expiresAt", result.expiresAt()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,7 +66,12 @@ public class ConversationController {
|
|||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@PathVariable String targetId,
|
@PathVariable String targetId,
|
||||||
@RequestParam String chatType) {
|
@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());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.xuqm.im.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.im.entity.ImGroupEntity;
|
import com.xuqm.im.entity.ImGroupEntity;
|
||||||
|
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
||||||
import com.xuqm.im.service.ImGroupService;
|
import com.xuqm.im.service.ImGroupService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
@ -32,7 +33,7 @@ public class GroupController {
|
|||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
@RequestParam String appId) {
|
@RequestParam String appId) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
groupService.create(appId, req.name(), userId, req.memberIds())));
|
groupService.create(appId, req.name(), userId, req.memberIds(), req.groupType())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -42,6 +43,13 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.listUserGroups(appId, userId)));
|
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}")
|
@PutMapping("/{groupId}")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> update(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> update(
|
||||||
@PathVariable String groupId,
|
@PathVariable String groupId,
|
||||||
@ -93,7 +101,42 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok());
|
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 UpdateGroupRequest(String name, String announcement) {}
|
||||||
public record MemberRequest(String userId) {}
|
public record MemberRequest(String userId) {}
|
||||||
public record SetRoleRequest(String userId, String role) {}
|
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.repository.ImMessageRepository;
|
||||||
import com.xuqm.im.service.ImAccountService;
|
import com.xuqm.im.service.ImAccountService;
|
||||||
import com.xuqm.im.service.ImGroupService;
|
import com.xuqm.im.service.ImGroupService;
|
||||||
|
import com.xuqm.im.service.MessageService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -25,17 +27,20 @@ public class ImAdminController {
|
|||||||
private final ImMessageRepository messageRepository;
|
private final ImMessageRepository messageRepository;
|
||||||
private final ImAccountService accountService;
|
private final ImAccountService accountService;
|
||||||
private final ImGroupService groupService;
|
private final ImGroupService groupService;
|
||||||
|
private final MessageService messageService;
|
||||||
|
|
||||||
public ImAdminController(ImAccountRepository accountRepository,
|
public ImAdminController(ImAccountRepository accountRepository,
|
||||||
ImGroupRepository groupRepository,
|
ImGroupRepository groupRepository,
|
||||||
ImMessageRepository messageRepository,
|
ImMessageRepository messageRepository,
|
||||||
ImAccountService accountService,
|
ImAccountService accountService,
|
||||||
ImGroupService groupService) {
|
ImGroupService groupService,
|
||||||
|
MessageService messageService) {
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.accountService = accountService;
|
this.accountService = accountService;
|
||||||
this.groupService = groupService;
|
this.groupService = groupService;
|
||||||
|
this.messageService = messageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List all registered IM users for the given appId. */
|
/** List all registered IM users for the given appId. */
|
||||||
@ -83,7 +88,7 @@ public class ImAdminController {
|
|||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@RequestBody CreateGroupRequest req) {
|
@RequestBody CreateGroupRequest req) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
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. */
|
/** 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 RegisterUserRequest(String userId, String nickname, String avatar) {}
|
||||||
public record CreateGroupRequest(String name, String creatorId, List<String> memberIds) {}
|
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.entity.ImMessageEntity;
|
||||||
import com.xuqm.im.model.SendMessageRequest;
|
import com.xuqm.im.model.SendMessageRequest;
|
||||||
import com.xuqm.im.service.MessageService;
|
import com.xuqm.im.service.MessageService;
|
||||||
import io.jsonwebtoken.Claims;
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/im/messages")
|
@RequestMapping("/api/im/messages")
|
||||||
public class MessageController {
|
public class MessageController {
|
||||||
@ -47,9 +49,14 @@ public class MessageController {
|
|||||||
@PathVariable String toId,
|
@PathVariable String toId,
|
||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
@RequestParam String appId,
|
@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 = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@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}")
|
@GetMapping("/group-history/{groupId}")
|
||||||
@ -57,8 +64,13 @@ public class MessageController {
|
|||||||
@PathVariable String groupId,
|
@PathVariable String groupId,
|
||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
@RequestParam String appId,
|
@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 = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@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)
|
@Column(nullable = false, length = 128)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Column(length = 16)
|
||||||
|
private String groupType;
|
||||||
|
|
||||||
@Column(nullable = false, length = 128)
|
@Column(nullable = false, length = 128)
|
||||||
private String creatorId;
|
private String creatorId;
|
||||||
|
|
||||||
@ -46,6 +49,9 @@ public class ImGroupEntity {
|
|||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public void setName(String name) { this.name = 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 String getCreatorId() { return creatorId; }
|
||||||
public void setCreatorId(String creatorId) { this.creatorId = 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.Id;
|
||||||
import jakarta.persistence.Index;
|
import jakarta.persistence.Index;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Transient;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@ -21,7 +22,7 @@ public class ImMessageEntity {
|
|||||||
public enum ChatType { SINGLE, GROUP }
|
public enum ChatType { SINGLE, GROUP }
|
||||||
public enum MsgType {
|
public enum MsgType {
|
||||||
TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY,
|
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 }
|
public enum MsgStatus { SENT, DELIVERED, READ, REVOKED }
|
||||||
|
|
||||||
@ -55,6 +56,9 @@ public class ImMessageEntity {
|
|||||||
@Column(length = 128)
|
@Column(length = 128)
|
||||||
private String mentionedUserIds;
|
private String mentionedUserIds;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private Integer groupReadCount;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
@ -86,6 +90,9 @@ public class ImMessageEntity {
|
|||||||
public String getMentionedUserIds() { return mentionedUserIds; }
|
public String getMentionedUserIds() { return mentionedUserIds; }
|
||||||
public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = 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)
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = 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,
|
@Param("peerId") String peerId,
|
||||||
Pageable pageable);
|
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("""
|
@Query("""
|
||||||
select m from ImMessageEntity m
|
select m from ImMessageEntity m
|
||||||
where m.appId = :appId
|
where m.appId = :appId
|
||||||
@ -76,6 +114,29 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
|
|||||||
@Param("groupId") String groupId,
|
@Param("groupId") String groupId,
|
||||||
Pageable pageable);
|
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("""
|
@Query("""
|
||||||
select count(m) from ImMessageEntity m
|
select count(m) from ImMessageEntity m
|
||||||
where m.appId = :appId
|
where m.appId = :appId
|
||||||
|
|||||||
@ -8,12 +8,15 @@ import com.xuqm.im.repository.ImAccountRepository;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ImAccountService {
|
public class ImAccountService {
|
||||||
|
|
||||||
|
public record LoginResult(String token, long expiresAt) {}
|
||||||
|
|
||||||
private final ImAccountRepository accountRepository;
|
private final ImAccountRepository accountRepository;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final ImAppSecretClient appSecretClient;
|
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)
|
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
ImAccountEntity e = new ImAccountEntity();
|
ImAccountEntity e = new ImAccountEntity();
|
||||||
@ -67,7 +70,8 @@ public class ImAccountService {
|
|||||||
throw new BusinessException(403, "账号已被封禁");
|
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) {
|
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.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.im.entity.ImGroupEntity;
|
import com.xuqm.im.entity.ImGroupEntity;
|
||||||
|
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
||||||
import com.xuqm.im.entity.ImGroupMuteEntity;
|
import com.xuqm.im.entity.ImGroupMuteEntity;
|
||||||
|
import com.xuqm.im.repository.ImGroupJoinRequestRepository;
|
||||||
import com.xuqm.im.repository.ImGroupRepository;
|
import com.xuqm.im.repository.ImGroupRepository;
|
||||||
import com.xuqm.im.repository.ImGroupMuteRepository;
|
import com.xuqm.im.repository.ImGroupMuteRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -21,18 +23,21 @@ public class ImGroupService {
|
|||||||
|
|
||||||
private final ImGroupRepository groupRepository;
|
private final ImGroupRepository groupRepository;
|
||||||
private final ImGroupMuteRepository muteRepository;
|
private final ImGroupMuteRepository muteRepository;
|
||||||
|
private final ImGroupJoinRequestRepository joinRequestRepository;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public ImGroupService(ImGroupRepository groupRepository,
|
public ImGroupService(ImGroupRepository groupRepository,
|
||||||
ImGroupMuteRepository muteRepository,
|
ImGroupMuteRepository muteRepository,
|
||||||
|
ImGroupJoinRequestRepository joinRequestRepository,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.muteRepository = muteRepository;
|
this.muteRepository = muteRepository;
|
||||||
|
this.joinRequestRepository = joinRequestRepository;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@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);
|
List<String> members = new ArrayList<>(memberIds);
|
||||||
if (!members.contains(creatorId)) members.add(creatorId);
|
if (!members.contains(creatorId)) members.add(creatorId);
|
||||||
|
|
||||||
@ -40,6 +45,7 @@ public class ImGroupService {
|
|||||||
group.setId(UUID.randomUUID().toString());
|
group.setId(UUID.randomUUID().toString());
|
||||||
group.setAppId(appId);
|
group.setAppId(appId);
|
||||||
group.setName(name);
|
group.setName(name);
|
||||||
|
group.setGroupType(normalizeGroupType(groupType));
|
||||||
group.setCreatorId(creatorId);
|
group.setCreatorId(creatorId);
|
||||||
group.setMemberIds(toJson(members));
|
group.setMemberIds(toJson(members));
|
||||||
group.setAdminIds(toJson(List.of(creatorId)));
|
group.setAdminIds(toJson(List.of(creatorId)));
|
||||||
@ -147,6 +153,12 @@ public class ImGroupService {
|
|||||||
groupRepository.delete(group);
|
groupRepository.delete(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void adminDismiss(String groupId) {
|
||||||
|
muteRepository.deleteByGroupId(groupId);
|
||||||
|
groupRepository.deleteById(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isMemberMuted(String groupId, String userId) {
|
public boolean isMemberMuted(String groupId, String userId) {
|
||||||
return muteRepository.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now()).isPresent();
|
return muteRepository.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now()).isPresent();
|
||||||
}
|
}
|
||||||
@ -167,6 +179,70 @@ public class ImGroupService {
|
|||||||
return groupRepository.findUserGroups(appId, userId);
|
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) {
|
private String toJson(List<String> list) {
|
||||||
try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; }
|
try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; }
|
||||||
}
|
}
|
||||||
@ -181,4 +257,26 @@ public class ImGroupService {
|
|||||||
throw new BusinessException(403, "无权操作");
|
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 java.time.ZoneOffset;
|
||||||
import com.xuqm.im.repository.ImMessageRepository;
|
import com.xuqm.im.repository.ImMessageRepository;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ public class MessageService {
|
|||||||
private final ImGroupService groupService;
|
private final ImGroupService groupService;
|
||||||
private final BlacklistService blacklistService;
|
private final BlacklistService blacklistService;
|
||||||
private final ConversationStateService conversationStateService;
|
private final ConversationStateService conversationStateService;
|
||||||
|
private final ImPushBridgeClient pushBridgeClient;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${im.webhook-timeout-ms:3000}")
|
@Value("${im.webhook-timeout-ms:3000}")
|
||||||
@ -48,6 +50,7 @@ public class MessageService {
|
|||||||
ImGroupService groupService,
|
ImGroupService groupService,
|
||||||
BlacklistService blacklistService,
|
BlacklistService blacklistService,
|
||||||
ConversationStateService conversationStateService,
|
ConversationStateService conversationStateService,
|
||||||
|
ImPushBridgeClient pushBridgeClient,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.webhookRepository = webhookRepository;
|
this.webhookRepository = webhookRepository;
|
||||||
@ -56,6 +59,7 @@ public class MessageService {
|
|||||||
this.groupService = groupService;
|
this.groupService = groupService;
|
||||||
this.blacklistService = blacklistService;
|
this.blacklistService = blacklistService;
|
||||||
this.conversationStateService = conversationStateService;
|
this.conversationStateService = conversationStateService;
|
||||||
|
this.pushBridgeClient = pushBridgeClient;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,21 +95,41 @@ public class MessageService {
|
|||||||
message.setStatus(ImMessageEntity.MsgStatus.SENT);
|
message.setStatus(ImMessageEntity.MsgStatus.SENT);
|
||||||
message.setMentionedUserIds(req.mentionedUserIds());
|
message.setMentionedUserIds(req.mentionedUserIds());
|
||||||
message.setCreatedAt(LocalDateTime.now());
|
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
|
String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE
|
||||||
? "/user/" + req.toId() + "/queue/messages"
|
? "/user/" + req.toId() + "/queue/messages"
|
||||||
: "/topic/group/" + req.toId();
|
: "/topic/group/" + req.toId();
|
||||||
clusterPublisher.publish(destination, message);
|
clusterPublisher.publish(destination, saved);
|
||||||
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) {
|
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()));
|
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) {
|
} 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);
|
dispatchWebhooks(appId, saved);
|
||||||
return message;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ImMessageEntity revoke(String appId, String messageId, String requestUserId) {
|
public ImMessageEntity revoke(String appId, String messageId, String requestUserId) {
|
||||||
@ -132,17 +156,126 @@ public class MessageService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<ImMessageEntity> history(String appId, String userId, String toId, int page, int size) {
|
public ImMessageEntity adminRevoke(String appId, String messageId) {
|
||||||
return messageRepository.findSingleConversation(
|
ImMessageEntity message = messageRepository.findById(messageId)
|
||||||
appId, userId, toId, PageRequest.of(page, size));
|
.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);
|
ImGroupEntity group = groupService.get(groupId);
|
||||||
if (!groupService.memberIds(group).contains(userId)) {
|
if (!groupService.memberIds(group).contains(userId)) {
|
||||||
throw new BusinessException(403, "不在群内");
|
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) {
|
public List<ImMessageRepository.ConversationSummary> conversations(String appId, String userId, int size) {
|
||||||
@ -180,7 +313,7 @@ public class MessageService {
|
|||||||
return new ConversationView(
|
return new ConversationView(
|
||||||
targetId,
|
targetId,
|
||||||
chatType,
|
chatType,
|
||||||
lastMessage != null ? lastMessage.getContent() : null,
|
lastMessage != null ? conversationPreview(lastMessage) : null,
|
||||||
lastMessage != null ? lastMessage.getMsgType().name() : null,
|
lastMessage != null ? lastMessage.getMsgType().name() : null,
|
||||||
toEpochMillis(lastMessage != null ? lastMessage.getCreatedAt() : summary.getLastTime()),
|
toEpochMillis(lastMessage != null ? lastMessage.getCreatedAt() : summary.getLastTime()),
|
||||||
(int) unreadCount,
|
(int) unreadCount,
|
||||||
@ -193,6 +326,73 @@ public class MessageService {
|
|||||||
return time == null ? 0L : time.toInstant(ZoneOffset.UTC).toEpochMilli();
|
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
|
@Async
|
||||||
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
|
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
|
||||||
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
||||||
|
|||||||
@ -46,6 +46,7 @@ jwt:
|
|||||||
im:
|
im:
|
||||||
tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081}
|
tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081}
|
||||||
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
|
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
|
||||||
|
push-service-url: ${PUSH_SERVICE_URL:http://xuqm-push-service:8083}
|
||||||
multi-login: true
|
multi-login: true
|
||||||
message-history-days: 30
|
message-history-days: 30
|
||||||
webhook-timeout-ms: 3000
|
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) {
|
public void registerToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String token) {
|
||||||
Optional<DeviceTokenEntity> existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor);
|
Optional<DeviceTokenEntity> existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor);
|
||||||
DeviceTokenEntity entity = existing.orElseGet(() -> {
|
DeviceTokenEntity entity = existing.orElseGet(() -> {
|
||||||
|
|||||||
@ -21,10 +21,11 @@ spring:
|
|||||||
show-sql: false
|
show-sql: false
|
||||||
|
|
||||||
jwt:
|
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
|
expiration: 86400000
|
||||||
|
|
||||||
push:
|
push:
|
||||||
|
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
|
||||||
huawei:
|
huawei:
|
||||||
app-id: ${HUAWEI_APP_ID:}
|
app-id: ${HUAWEI_APP_ID:}
|
||||||
app-secret: ${HUAWEI_APP_SECRET:}
|
app-secret: ${HUAWEI_APP_SECRET:}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户