docs(server): 添加服务器信息记录和联调接口文档
- 创建信息记录文档,包含项目管理要求、产物范围、Git仓库、制品仓库信息 - 添加服务器部署信息,包括应用服务器、MySQL/Redis服务器、Jenkins服务配置 - 记录邮件服务、DNS/HTTPS证书配置及安全备注 - 创建API联调文档,包含线上入口、ID约定、初始化管理员账号信息 - 添加统一响应格式、常见错误码、鉴权规则说明 - 提供核心接口清单,涵盖tenant-service、im-service、push-service等服务 - 补充curl示例,包含运营平台登录、IM登录、会话管理等操作示例 - 实现会话控制器,支持置顶、免打扰、标记已读、草稿等功能 - 添加全局异常处理器,统一处理业务异常和参数校验错误 - 创建IM管理控制器,提供用户管理、好友请求、黑名单等管理功能
这个提交包含在:
父节点
b89cd35b15
当前提交
d7f5fd02c2
11
README.md
11
README.md
@ -356,11 +356,12 @@ Frame 格式:
|
|||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| POST | `/api/push/register` | 注册设备 token |
|
| POST | `/api/push/register` | 注册设备 token |
|
||||||
|
| POST | `/api/push/receive-push` | 开启或关闭接收推送 |
|
||||||
| DELETE | `/api/push/device/unregister` | 解绑设备 token |
|
| DELETE | `/api/push/device/unregister` | 解绑设备 token |
|
||||||
| POST | `/api/push/send` | 向指定用户推送通知 |
|
| POST | `/api/push/send` | 向指定用户推送通知 |
|
||||||
| POST | `/api/push/internal/notify` | IM 服务内部调用,批量触发离线推送 |
|
| POST | `/api/push/internal/notify` | IM 服务内部调用,批量触发离线推送 |
|
||||||
|
|
||||||
其中 `/api/push/register`、`/api/push/device/unregister` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。
|
其中 `/api/push/register`、`/api/push/device/unregister`、`/api/push/receive-push` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。
|
||||||
|
|
||||||
**注册 token**
|
**注册 token**
|
||||||
```
|
```
|
||||||
@ -381,6 +382,14 @@ POST /api/push/send
|
|||||||
&payload={"type":"IM","msgId":"uuid"}
|
&payload={"type":"IM","msgId":"uuid"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**开关接收推送**
|
||||||
|
```
|
||||||
|
POST /api/push/receive-push
|
||||||
|
?appId=ak_xxx
|
||||||
|
&userId=user_001
|
||||||
|
&enabled=false
|
||||||
|
```
|
||||||
|
|
||||||
**内部通知**
|
**内部通知**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@ -13,6 +13,21 @@
|
|||||||
| 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 热更新 |
|
||||||
|
|
||||||
|
## ID 约定
|
||||||
|
|
||||||
|
这套工程里同时存在两种“应用标识”,不要混用:
|
||||||
|
|
||||||
|
| 名称 | 含义 | 常见位置 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `tenant appId` | 租户平台应用主键,`tenant-service` 的 `/api/apps/{id}`、`/api/apps/{appId}/services` 使用它 | 租户平台、服务配置页 |
|
||||||
|
| `IM appKey` | IM 业务域作用域标识,`im-service` 的管理接口和消息接口虽然参数名仍叫 `appId`,但实际传的是这个值 | IM 管理页、IM HTTP 接口 |
|
||||||
|
|
||||||
|
结论:
|
||||||
|
|
||||||
|
- `tenant-platform` 的“服务配置”只认租户 `app.id`
|
||||||
|
- `tenant-platform` 的“IM 管理”必须带 `appKey`
|
||||||
|
- `im-service` 代码里沿用旧参数名 `appId`,但这是历史命名,调用方传的是 `appKey`
|
||||||
|
|
||||||
## 初始化管理员账号
|
## 初始化管理员账号
|
||||||
|
|
||||||
| 字段 | 值 |
|
| 字段 | 值 |
|
||||||
@ -110,6 +125,7 @@
|
|||||||
| 方法 | 路径 | 鉴权 | 说明 |
|
| 方法 | 路径 | 鉴权 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| POST | `/api/push/register` | 是 | 注册设备 token |
|
| POST | `/api/push/register` | 是 | 注册设备 token |
|
||||||
|
| POST | `/api/push/receive-push` | 是 | 开启或关闭接收推送 |
|
||||||
| POST | `/api/push/send` | 是 | 发送推送通知 |
|
| POST | `/api/push/send` | 是 | 发送推送通知 |
|
||||||
|
|
||||||
### file-service
|
### file-service
|
||||||
@ -215,6 +231,170 @@ curl 'https://dev.xuqinmin.com/api/im/admin/messages?appId=ak_demo_chat&userA=us
|
|||||||
curl 'https://dev.xuqinmin.com/api/im/admin/operation-logs?appId=ak_demo_chat&page=0&size=20'
|
curl 'https://dev.xuqinmin.com/api/im/admin/operation-logs?appId=ak_demo_chat&page=0&size=20'
|
||||||
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/messages/msg_001/revoke?appId=ak_demo_chat'
|
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'
|
curl -X DELETE 'https://dev.xuqinmin.com/api/im/admin/groups/group_001'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/admin/friend-requests?appId=ak_demo_chat'
|
||||||
|
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/friend-requests/req_001/accept?appId=ak_demo_chat'
|
||||||
|
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/blacklist?appId=ak_demo_chat' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"userId":"user_001","blockedUserId":"user_002"}'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/members?appId=ak_demo_chat'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/join-requests?appId=ak_demo_chat'
|
||||||
|
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/admin/keyword-filters?appId=ak_demo_chat'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/admin/global-mute?appId=ak_demo_chat'
|
||||||
|
```
|
||||||
|
|
||||||
|
### IM Webhook 回调
|
||||||
|
|
||||||
|
IM 服务会在消息、好友和已读等事件发生后,以 `POST` 方式向你配置的回调地址推送事件。
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `X-App-Id`: 应用 `appId`
|
||||||
|
- `X-App-Timestamp`: 请求时间戳
|
||||||
|
- `X-App-Nonce`: 随机串
|
||||||
|
- `X-App-Signature`: 签名结果
|
||||||
|
|
||||||
|
签名规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HMAC-SHA256(appSecret, appId + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body))
|
||||||
|
```
|
||||||
|
|
||||||
|
统一请求体结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"callbackId": "uuid",
|
||||||
|
"callbackType": "message",
|
||||||
|
"callbackEvent": "message.sent",
|
||||||
|
"requestTime": 1714360000000,
|
||||||
|
"payload": {},
|
||||||
|
"signature": null,
|
||||||
|
"appId": "ak_demo_chat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `callbackId` | 回调唯一 ID,用于去重和排障 |
|
||||||
|
| `callbackType` | 回调大类,例如 `message`、`friend`、`group`、`blacklist` |
|
||||||
|
| `callbackEvent` | 具体事件,例如 `message.sent`、`friend.request.sent` |
|
||||||
|
| `requestTime` | 毫秒时间戳 |
|
||||||
|
| `payload` | 事件数据,结构随事件变化 |
|
||||||
|
| `signature` | 预留字段,当前由请求头承载 |
|
||||||
|
| `appId` | 回调所属应用 ID |
|
||||||
|
|
||||||
|
`payload` 会根据事件类型变化:
|
||||||
|
|
||||||
|
| 事件 | payload 类型 | 说明 |
|
||||||
|
|------|-------------|------|
|
||||||
|
| `message.sent` | `ImMessageEntity` | 发送消息成功后触发 |
|
||||||
|
| `message.revoked` | `ImMessageEntity` | 撤回消息后触发 |
|
||||||
|
| `message.edited` | `ImMessageEntity` | 编辑文本消息后触发 |
|
||||||
|
| `message.read` | `MessageReadCallbackPayload` | 已读回执同步后触发 |
|
||||||
|
| `friend.request.sent` | `FriendRequestCallbackPayload` | 好友申请创建后触发 |
|
||||||
|
| `friend.request.accepted` | `FriendRequestCallbackPayload` | 好友申请通过后触发 |
|
||||||
|
| `friend.request.rejected` | `FriendRequestCallbackPayload` | 好友申请拒绝后触发 |
|
||||||
|
| `group.join.request.sent` | `GroupJoinRequestCallbackPayload` | 入群申请创建后触发 |
|
||||||
|
| `group.join.request.accepted` | `GroupJoinRequestCallbackPayload` | 入群申请通过后触发 |
|
||||||
|
| `group.join.request.rejected` | `GroupJoinRequestCallbackPayload` | 入群申请拒绝后触发 |
|
||||||
|
| `blacklist.added` | `BlacklistCallbackPayload` | 黑名单记录新增后触发 |
|
||||||
|
| `blacklist.removed` | `BlacklistCallbackPayload` | 黑名单记录移除后触发 |
|
||||||
|
|
||||||
|
各 payload 字段如下:
|
||||||
|
|
||||||
|
#### `MessageReadCallbackPayload`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appId": "ak_demo_chat",
|
||||||
|
"readerId": "user_001",
|
||||||
|
"peerId": "user_002",
|
||||||
|
"groupId": null,
|
||||||
|
"chatType": "SINGLE",
|
||||||
|
"readAt": 1714360000000,
|
||||||
|
"messageIds": ["msg_001", "msg_002"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `FriendRequestCallbackPayload`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appId": "ak_demo_chat",
|
||||||
|
"requestId": "req_001",
|
||||||
|
"fromUserId": "user_001",
|
||||||
|
"toUserId": "user_002",
|
||||||
|
"remark": "hi",
|
||||||
|
"status": "PENDING",
|
||||||
|
"reviewedAt": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GroupJoinRequestCallbackPayload`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appId": "ak_demo_chat",
|
||||||
|
"requestId": "req_001",
|
||||||
|
"groupId": "group_001",
|
||||||
|
"groupName": "技术群",
|
||||||
|
"requesterId": "user_003",
|
||||||
|
"remark": "申请加入",
|
||||||
|
"status": "PENDING",
|
||||||
|
"reviewedAt": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `BlacklistCallbackPayload`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appId": "ak_demo_chat",
|
||||||
|
"id": "blk_001",
|
||||||
|
"userId": "user_001",
|
||||||
|
"blockedUserId": "user_002",
|
||||||
|
"action": "ADD",
|
||||||
|
"createdAt": 1714360000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 回调发送失败不会影响主流程,只记录日志。
|
||||||
|
- 当前版本的回调配置是按应用维度管理的,多个地址会同时接收同一事件。
|
||||||
|
- 新增的好友、入群、黑名单回调都复用同一 envelope 和签名规则,payload 字段按上表中的类型序列化。
|
||||||
|
- 接收方建议用 `callbackId` 做幂等去重,避免重复投递造成重复处理。
|
||||||
|
- 服务端当前不做重试队列,失败只会在调用端记录日志。
|
||||||
|
|
||||||
|
示例验签:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
export function verifyWebhook({
|
||||||
|
appId,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
appSecret,
|
||||||
|
}: {
|
||||||
|
appId: string
|
||||||
|
timestamp: string
|
||||||
|
nonce: string
|
||||||
|
body: string
|
||||||
|
signature: string
|
||||||
|
appSecret: string
|
||||||
|
}) {
|
||||||
|
const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex')
|
||||||
|
const payload = `${appId}\n${timestamp}\n${nonce}\n${bodyHash}`
|
||||||
|
const expected = crypto.createHmac('sha256', appSecret).update(payload, 'utf8').digest('hex')
|
||||||
|
return expected === signature
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 好友申请 / 黑名单
|
### 好友申请 / 黑名单
|
||||||
|
|||||||
@ -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.model.ConversationView;
|
import com.xuqm.im.model.ConversationView;
|
||||||
|
import com.xuqm.im.service.ImFeatureConfigClient;
|
||||||
import com.xuqm.im.service.ConversationStateService;
|
import com.xuqm.im.service.ConversationStateService;
|
||||||
import com.xuqm.im.service.MessageService;
|
import com.xuqm.im.service.MessageService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@ -22,11 +23,14 @@ public class ConversationController {
|
|||||||
|
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
private final ConversationStateService conversationStateService;
|
private final ConversationStateService conversationStateService;
|
||||||
|
private final ImFeatureConfigClient featureConfigClient;
|
||||||
|
|
||||||
public ConversationController(MessageService messageService,
|
public ConversationController(MessageService messageService,
|
||||||
ConversationStateService conversationStateService) {
|
ConversationStateService conversationStateService,
|
||||||
|
ImFeatureConfigClient featureConfigClient) {
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.conversationStateService = conversationStateService;
|
this.conversationStateService = conversationStateService;
|
||||||
|
this.featureConfigClient = featureConfigClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/conversations")
|
@GetMapping("/conversations")
|
||||||
@ -92,7 +96,8 @@ public class ConversationController {
|
|||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@PathVariable String targetId,
|
@PathVariable String targetId,
|
||||||
@RequestParam String chatType) {
|
@RequestParam String chatType) {
|
||||||
conversationStateService.hideConversation(appId, userId, targetId, chatType);
|
boolean syncAcrossClients = featureConfigClient.multiClientConversationDeleteSync(appId);
|
||||||
|
conversationStateService.deleteConversation(appId, userId, targetId, chatType, syncAcrossClients);
|
||||||
return ResponseEntity.ok(ApiResponse.ok());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import com.xuqm.common.exception.BusinessException;
|
|||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@ -27,6 +28,16 @@ public class GlobalExceptionHandler {
|
|||||||
.orElse("参数错误")));
|
.orElse("参数错误")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.badRequest(e.getMessage() == null ? "参数错误" : e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleUnreadable(HttpMessageNotReadableException e) {
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误"));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
|
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
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.exception.BusinessException;
|
||||||
|
import com.xuqm.im.entity.ImBlacklistEntity;
|
||||||
import com.xuqm.im.entity.ImAccountEntity;
|
import com.xuqm.im.entity.ImAccountEntity;
|
||||||
import com.xuqm.im.entity.ImGlobalMuteEntity;
|
import com.xuqm.im.entity.ImGlobalMuteEntity;
|
||||||
import com.xuqm.im.entity.ImGroupEntity;
|
import com.xuqm.im.entity.ImGroupEntity;
|
||||||
|
import com.xuqm.im.entity.ImFriendRequestEntity;
|
||||||
|
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
||||||
import com.xuqm.im.entity.ImMessageEntity;
|
import com.xuqm.im.entity.ImMessageEntity;
|
||||||
import com.xuqm.im.entity.KeywordFilterEntity;
|
import com.xuqm.im.entity.KeywordFilterEntity;
|
||||||
import com.xuqm.im.entity.WebhookConfigEntity;
|
import com.xuqm.im.entity.WebhookConfigEntity;
|
||||||
@ -11,6 +15,8 @@ import com.xuqm.im.repository.ImAccountRepository;
|
|||||||
import com.xuqm.im.repository.ImGroupRepository;
|
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.BlacklistService;
|
||||||
|
import com.xuqm.im.service.FriendRequestService;
|
||||||
import com.xuqm.im.service.ImGroupService;
|
import com.xuqm.im.service.ImGroupService;
|
||||||
import com.xuqm.im.service.GlobalMuteService;
|
import com.xuqm.im.service.GlobalMuteService;
|
||||||
import com.xuqm.im.service.KeywordFilterService;
|
import com.xuqm.im.service.KeywordFilterService;
|
||||||
@ -35,6 +41,8 @@ public class ImAdminController {
|
|||||||
private final ImGroupRepository groupRepository;
|
private final ImGroupRepository groupRepository;
|
||||||
private final ImMessageRepository messageRepository;
|
private final ImMessageRepository messageRepository;
|
||||||
private final ImAccountService accountService;
|
private final ImAccountService accountService;
|
||||||
|
private final FriendRequestService friendRequestService;
|
||||||
|
private final BlacklistService blacklistService;
|
||||||
private final ImGroupService groupService;
|
private final ImGroupService groupService;
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
private final WebhookConfigService webhookConfigService;
|
private final WebhookConfigService webhookConfigService;
|
||||||
@ -46,6 +54,8 @@ public class ImAdminController {
|
|||||||
ImGroupRepository groupRepository,
|
ImGroupRepository groupRepository,
|
||||||
ImMessageRepository messageRepository,
|
ImMessageRepository messageRepository,
|
||||||
ImAccountService accountService,
|
ImAccountService accountService,
|
||||||
|
FriendRequestService friendRequestService,
|
||||||
|
BlacklistService blacklistService,
|
||||||
ImGroupService groupService,
|
ImGroupService groupService,
|
||||||
MessageService messageService,
|
MessageService messageService,
|
||||||
WebhookConfigService webhookConfigService,
|
WebhookConfigService webhookConfigService,
|
||||||
@ -56,6 +66,8 @@ public class ImAdminController {
|
|||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.accountService = accountService;
|
this.accountService = accountService;
|
||||||
|
this.friendRequestService = friendRequestService;
|
||||||
|
this.blacklistService = blacklistService;
|
||||||
this.groupService = groupService;
|
this.groupService = groupService;
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.webhookConfigService = webhookConfigService;
|
this.webhookConfigService = webhookConfigService;
|
||||||
@ -82,10 +94,14 @@ public class ImAdminController {
|
|||||||
@AuthenticationPrincipal String operatorId,
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
|
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
|
||||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
.orElseThrow(() -> new BusinessException(404, "账号不存在"));
|
||||||
account.setStatus(ImAccountEntity.Status.valueOf(body.get("status").toUpperCase()));
|
String status = body.get("status");
|
||||||
|
if (status == null || status.isBlank()) {
|
||||||
|
throw new BusinessException(400, "状态不能为空");
|
||||||
|
}
|
||||||
|
account.setStatus(ImAccountEntity.Status.valueOf(status.toUpperCase()));
|
||||||
ImAccountEntity saved = accountRepository.save(account);
|
ImAccountEntity saved = accountRepository.save(account);
|
||||||
operationLogService.record(appId, operatorId, "UPDATE_USER_STATUS", "ACCOUNT", userId, body.get("status"));
|
operationLogService.record(appId, operatorId, "UPDATE_USER_STATUS", "ACCOUNT", userId, status);
|
||||||
return ResponseEntity.ok(ApiResponse.success(saved));
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,6 +282,156 @@ public class ImAdminController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/friend-requests")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImFriendRequestEntity>>> listFriendRequests(@RequestParam String appId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(friendRequestService.listByApp(appId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/friend-requests")
|
||||||
|
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> createFriendRequest(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
|
@RequestBody FriendRequestCreateRequest req) {
|
||||||
|
ImFriendRequestEntity saved = friendRequestService.send(appId, req.fromUserId(), req.toUserId(), req.remark());
|
||||||
|
operationLogService.record(appId, operatorId, "CREATE_FRIEND_REQUEST", "FRIEND_REQUEST", saved.getId(), req.toUserId());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/friend-requests/{requestId}/accept")
|
||||||
|
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> acceptFriendRequest(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String requestId,
|
||||||
|
@AuthenticationPrincipal String operatorId) {
|
||||||
|
ImFriendRequestEntity saved = friendRequestService.adminAccept(appId, requestId);
|
||||||
|
operationLogService.record(appId, operatorId, "ACCEPT_FRIEND_REQUEST", "FRIEND_REQUEST", requestId, null);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/friend-requests/{requestId}/reject")
|
||||||
|
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> rejectFriendRequest(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String requestId,
|
||||||
|
@AuthenticationPrincipal String operatorId) {
|
||||||
|
ImFriendRequestEntity saved = friendRequestService.adminReject(appId, requestId);
|
||||||
|
operationLogService.record(appId, operatorId, "REJECT_FRIEND_REQUEST", "FRIEND_REQUEST", requestId, null);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/blacklist")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImBlacklistEntity>>> listBlacklist(@RequestParam String appId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(blacklistService.listByApp(appId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/blacklist")
|
||||||
|
public ResponseEntity<ApiResponse<ImBlacklistEntity>> addBlacklist(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
|
@RequestBody BlacklistRequest req) {
|
||||||
|
ImBlacklistEntity saved = blacklistService.add(appId, req.userId(), req.blockedUserId());
|
||||||
|
operationLogService.record(appId, operatorId, "ADD_BLACKLIST", "BLACKLIST", saved.getId(), req.blockedUserId());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/blacklist")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> removeBlacklist(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam String userId,
|
||||||
|
@RequestParam String blockedUserId,
|
||||||
|
@AuthenticationPrincipal String operatorId) {
|
||||||
|
blacklistService.remove(appId, userId, blockedUserId);
|
||||||
|
operationLogService.record(appId, operatorId, "REMOVE_BLACKLIST", "BLACKLIST", userId, blockedUserId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/groups/{groupId}/members")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> listGroupMembers(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.adminListMembers(appId, groupId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/groups/{groupId}/members/search")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> searchGroupMembers(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@RequestParam String keyword,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.adminSearchMembers(appId, groupId, keyword, size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/members")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupEntity>> addGroupMember(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
|
@RequestBody MemberRequest req) {
|
||||||
|
ImGroupEntity saved = groupService.adminAddMember(appId, groupId, req.userId());
|
||||||
|
operationLogService.record(appId, operatorId, "ADMIN_ADD_GROUP_MEMBER", "GROUP", groupId, req.userId());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/groups/{groupId}/members/{userId}")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupEntity>> removeGroupMember(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@PathVariable String userId,
|
||||||
|
@AuthenticationPrincipal String operatorId) {
|
||||||
|
ImGroupEntity saved = groupService.adminRemoveMember(appId, groupId, userId);
|
||||||
|
operationLogService.record(appId, operatorId, "ADMIN_REMOVE_GROUP_MEMBER", "GROUP", groupId, userId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/roles")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupEntity>> setGroupRole(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
|
@RequestBody SetRoleRequest req) {
|
||||||
|
ImGroupEntity saved = groupService.adminSetRole(appId, groupId, req.userId(), req.role());
|
||||||
|
operationLogService.record(appId, operatorId, "ADMIN_SET_GROUP_ROLE", "GROUP", groupId, req.userId() + ":" + req.role());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/mute")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupEntity>> muteGroupMember(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
|
@RequestBody MuteMemberRequest req) {
|
||||||
|
ImGroupEntity saved = groupService.adminMuteMember(appId, groupId, req.userId(), req.minutes());
|
||||||
|
operationLogService.record(appId, operatorId, "ADMIN_MUTE_GROUP_MEMBER", "GROUP", groupId, req.userId() + ":" + req.minutes());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/groups/{groupId}/join-requests")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImGroupJoinRequestEntity>>> listGroupJoinRequests(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.adminListJoinRequests(appId, groupId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/join-requests/{requestId}/accept")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupJoinRequestEntity>> acceptGroupJoinRequest(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@PathVariable String requestId,
|
||||||
|
@AuthenticationPrincipal String operatorId) {
|
||||||
|
ImGroupJoinRequestEntity saved = groupService.adminAcceptJoinRequest(appId, groupId, requestId);
|
||||||
|
operationLogService.record(appId, operatorId, "ADMIN_ACCEPT_GROUP_JOIN_REQUEST", "GROUP_JOIN_REQUEST", requestId, null);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/join-requests/{requestId}/reject")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupJoinRequestEntity>> rejectGroupJoinRequest(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@PathVariable String requestId,
|
||||||
|
@AuthenticationPrincipal String operatorId) {
|
||||||
|
ImGroupJoinRequestEntity saved = groupService.adminRejectJoinRequest(appId, groupId, requestId);
|
||||||
|
operationLogService.record(appId, operatorId, "ADMIN_REJECT_GROUP_JOIN_REQUEST", "GROUP_JOIN_REQUEST", requestId, null);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/webhooks")
|
@GetMapping("/webhooks")
|
||||||
public ResponseEntity<ApiResponse<List<WebhookConfigEntity>>> listWebhooks(@RequestParam String appId) {
|
public ResponseEntity<ApiResponse<List<WebhookConfigEntity>>> listWebhooks(@RequestParam String appId) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(webhookConfigService.list(appId)));
|
return ResponseEntity.ok(ApiResponse.success(webhookConfigService.list(appId)));
|
||||||
@ -377,4 +543,9 @@ public class ImAdminController {
|
|||||||
public record UpdateGroupRequest(String name, String groupType, String announcement) {}
|
public record UpdateGroupRequest(String name, String groupType, String announcement) {}
|
||||||
public record WebhookConfigRequest(String url, String secret, Boolean enabled) {}
|
public record WebhookConfigRequest(String url, String secret, Boolean enabled) {}
|
||||||
public record KeywordFilterRequest(String pattern, String replacement, String action, Boolean enabled) {}
|
public record KeywordFilterRequest(String pattern, String replacement, String action, Boolean enabled) {}
|
||||||
|
public record FriendRequestCreateRequest(String fromUserId, String toUserId, String remark) {}
|
||||||
|
public record BlacklistRequest(String userId, String blockedUserId) {}
|
||||||
|
public record MemberRequest(String userId) {}
|
||||||
|
public record SetRoleRequest(String userId, String role) {}
|
||||||
|
public record MuteMemberRequest(String userId, long minutes) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.xuqm.im.model;
|
||||||
|
|
||||||
|
public record BlacklistCallbackPayload(
|
||||||
|
String appId,
|
||||||
|
String id,
|
||||||
|
String userId,
|
||||||
|
String blockedUserId,
|
||||||
|
String action,
|
||||||
|
Long createdAt
|
||||||
|
) {}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.xuqm.im.model;
|
||||||
|
|
||||||
|
public record FriendRequestCallbackPayload(
|
||||||
|
String appId,
|
||||||
|
String requestId,
|
||||||
|
String fromUserId,
|
||||||
|
String toUserId,
|
||||||
|
String remark,
|
||||||
|
String status,
|
||||||
|
Long reviewedAt
|
||||||
|
) {}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.xuqm.im.model;
|
||||||
|
|
||||||
|
public record GroupJoinRequestCallbackPayload(
|
||||||
|
String appId,
|
||||||
|
String requestId,
|
||||||
|
String groupId,
|
||||||
|
String groupName,
|
||||||
|
String requesterId,
|
||||||
|
String remark,
|
||||||
|
String status,
|
||||||
|
Long reviewedAt
|
||||||
|
) {}
|
||||||
@ -7,6 +7,7 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ImBlacklistRepository extends JpaRepository<ImBlacklistEntity, String> {
|
public interface ImBlacklistRepository extends JpaRepository<ImBlacklistEntity, String> {
|
||||||
|
List<ImBlacklistEntity> findByAppId(String appId);
|
||||||
List<ImBlacklistEntity> findByAppIdAndUserId(String appId, String userId);
|
List<ImBlacklistEntity> findByAppIdAndUserId(String appId, String userId);
|
||||||
|
|
||||||
Optional<ImBlacklistEntity> findByAppIdAndUserIdAndBlockedUserId(
|
Optional<ImBlacklistEntity> findByAppIdAndUserIdAndBlockedUserId(
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ImFriendRequestRepository extends JpaRepository<ImFriendRequestEntity, String> {
|
public interface ImFriendRequestRepository extends JpaRepository<ImFriendRequestEntity, String> {
|
||||||
|
List<ImFriendRequestEntity> findByAppId(String appId);
|
||||||
Optional<ImFriendRequestEntity> findByAppIdAndFromUserIdAndToUserId(
|
Optional<ImFriendRequestEntity> findByAppIdAndFromUserIdAndToUserId(
|
||||||
String appId, String fromUserId, String toUserId);
|
String appId, String fromUserId, String toUserId);
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ImGroupJoinRequestRepository extends JpaRepository<ImGroupJoinRequestEntity, String> {
|
public interface ImGroupJoinRequestRepository extends JpaRepository<ImGroupJoinRequestEntity, String> {
|
||||||
|
List<ImGroupJoinRequestEntity> findByAppId(String appId);
|
||||||
Optional<ImGroupJoinRequestEntity> findByAppIdAndGroupIdAndRequesterId(
|
Optional<ImGroupJoinRequestEntity> findByAppIdAndGroupIdAndRequesterId(
|
||||||
String appId, String groupId, String requesterId);
|
String appId, String groupId, String requesterId);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.xuqm.im.service;
|
package com.xuqm.im.service;
|
||||||
|
|
||||||
import com.xuqm.im.entity.ImBlacklistEntity;
|
import com.xuqm.im.entity.ImBlacklistEntity;
|
||||||
|
import com.xuqm.im.model.BlacklistCallbackPayload;
|
||||||
import com.xuqm.im.repository.ImBlacklistRepository;
|
import com.xuqm.im.repository.ImBlacklistRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@ -13,9 +14,11 @@ import java.util.UUID;
|
|||||||
public class BlacklistService {
|
public class BlacklistService {
|
||||||
|
|
||||||
private final ImBlacklistRepository repository;
|
private final ImBlacklistRepository repository;
|
||||||
|
private final WebhookDispatchService webhookDispatchService;
|
||||||
|
|
||||||
public BlacklistService(ImBlacklistRepository repository) {
|
public BlacklistService(ImBlacklistRepository repository, WebhookDispatchService webhookDispatchService) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
|
this.webhookDispatchService = webhookDispatchService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@ -28,19 +31,30 @@ public class BlacklistService {
|
|||||||
entity.setUserId(userId);
|
entity.setUserId(userId);
|
||||||
entity.setBlockedUserId(blockedUserId);
|
entity.setBlockedUserId(blockedUserId);
|
||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
return repository.save(entity);
|
ImBlacklistEntity saved = repository.save(entity);
|
||||||
|
dispatchWebhook(saved, "blacklist.added");
|
||||||
|
return saved;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void remove(String appId, String userId, String blockedUserId) {
|
public void remove(String appId, String userId, String blockedUserId) {
|
||||||
|
ImBlacklistEntity entity = repository.findByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId)
|
||||||
|
.orElse(null);
|
||||||
repository.deleteByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
|
repository.deleteByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
|
||||||
|
if (entity != null) {
|
||||||
|
dispatchWebhook(entity, "blacklist.removed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ImBlacklistEntity> list(String appId, String userId) {
|
public List<ImBlacklistEntity> list(String appId, String userId) {
|
||||||
return repository.findByAppIdAndUserId(appId, userId);
|
return repository.findByAppIdAndUserId(appId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<ImBlacklistEntity> listByApp(String appId) {
|
||||||
|
return repository.findByAppId(appId);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isBlocked(String appId, String userId, String blockedUserId) {
|
public boolean isBlocked(String appId, String userId, String blockedUserId) {
|
||||||
return repository.existsByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
|
return repository.existsByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
|
||||||
}
|
}
|
||||||
@ -48,4 +62,20 @@ public class BlacklistService {
|
|||||||
public boolean isEitherBlocked(String appId, String userId, String targetUserId) {
|
public boolean isEitherBlocked(String appId, String userId, String targetUserId) {
|
||||||
return isBlocked(appId, userId, targetUserId) || isBlocked(appId, targetUserId, userId);
|
return isBlocked(appId, userId, targetUserId) || isBlocked(appId, targetUserId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void dispatchWebhook(ImBlacklistEntity entity, String callbackEvent) {
|
||||||
|
webhookDispatchService.dispatch(
|
||||||
|
entity.getAppId(),
|
||||||
|
"blacklist",
|
||||||
|
callbackEvent,
|
||||||
|
new BlacklistCallbackPayload(
|
||||||
|
entity.getAppId(),
|
||||||
|
entity.getId(),
|
||||||
|
entity.getUserId(),
|
||||||
|
entity.getBlockedUserId(),
|
||||||
|
callbackEvent,
|
||||||
|
entity.getCreatedAt() == null ? null : entity.getCreatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,6 +66,19 @@ public class ConversationStateService {
|
|||||||
repository.deleteByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType);
|
repository.deleteByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteConversation(String appId,
|
||||||
|
String userId,
|
||||||
|
String targetId,
|
||||||
|
String chatType,
|
||||||
|
boolean syncAcrossClients) {
|
||||||
|
if (syncAcrossClients) {
|
||||||
|
deleteConversationState(appId, userId, targetId, chatType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideConversation(appId, userId, targetId, chatType);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void clearHiddenForUsers(String appId, String targetId, String chatType, Collection<String> userIds) {
|
public void clearHiddenForUsers(String appId, String targetId, String chatType, Collection<String> userIds) {
|
||||||
for (String userId : userIds) {
|
for (String userId : userIds) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import com.xuqm.common.exception.BusinessException;
|
|||||||
import com.xuqm.im.cluster.ImClusterPublisher;
|
import com.xuqm.im.cluster.ImClusterPublisher;
|
||||||
import com.xuqm.im.entity.ImFriendRequestEntity;
|
import com.xuqm.im.entity.ImFriendRequestEntity;
|
||||||
import com.xuqm.im.entity.ImMessageEntity;
|
import com.xuqm.im.entity.ImMessageEntity;
|
||||||
|
import com.xuqm.im.model.FriendRequestCallbackPayload;
|
||||||
import com.xuqm.im.repository.ImFriendRequestRepository;
|
import com.xuqm.im.repository.ImFriendRequestRepository;
|
||||||
import com.xuqm.im.repository.ImFriendRepository;
|
import com.xuqm.im.repository.ImFriendRepository;
|
||||||
import com.xuqm.im.repository.ImMessageRepository;
|
import com.xuqm.im.repository.ImMessageRepository;
|
||||||
@ -23,24 +24,36 @@ public class FriendRequestService {
|
|||||||
private final ImFriendRepository friendRepository;
|
private final ImFriendRepository friendRepository;
|
||||||
private final ImMessageRepository messageRepository;
|
private final ImMessageRepository messageRepository;
|
||||||
private final ImClusterPublisher clusterPublisher;
|
private final ImClusterPublisher clusterPublisher;
|
||||||
|
private final ImFeatureConfigClient featureConfigClient;
|
||||||
|
private final WebhookDispatchService webhookDispatchService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public FriendRequestService(ImFriendRequestRepository requestRepository,
|
public FriendRequestService(ImFriendRequestRepository requestRepository,
|
||||||
ImFriendRepository friendRepository,
|
ImFriendRepository friendRepository,
|
||||||
ImMessageRepository messageRepository,
|
ImMessageRepository messageRepository,
|
||||||
ImClusterPublisher clusterPublisher,
|
ImClusterPublisher clusterPublisher,
|
||||||
|
ImFeatureConfigClient featureConfigClient,
|
||||||
|
WebhookDispatchService webhookDispatchService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.requestRepository = requestRepository;
|
this.requestRepository = requestRepository;
|
||||||
this.friendRepository = friendRepository;
|
this.friendRepository = friendRepository;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.clusterPublisher = clusterPublisher;
|
this.clusterPublisher = clusterPublisher;
|
||||||
|
this.featureConfigClient = featureConfigClient;
|
||||||
|
this.webhookDispatchService = webhookDispatchService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) {
|
public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) {
|
||||||
|
String mode = featureConfigClient.friendRequestMode(appId);
|
||||||
|
if ("DISALLOW".equals(mode)) {
|
||||||
|
throw new BusinessException(403, "当前应用未开放好友申请");
|
||||||
|
}
|
||||||
|
final boolean[] created = {false};
|
||||||
ImFriendRequestEntity saved = requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId)
|
ImFriendRequestEntity saved = requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
|
created[0] = true;
|
||||||
ImFriendRequestEntity entity = new ImFriendRequestEntity();
|
ImFriendRequestEntity entity = new ImFriendRequestEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
entity.setId(UUID.randomUUID().toString());
|
||||||
entity.setAppId(appId);
|
entity.setAppId(appId);
|
||||||
@ -51,9 +64,18 @@ public class FriendRequestService {
|
|||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
return requestRepository.save(entity);
|
return requestRepository.save(entity);
|
||||||
});
|
});
|
||||||
|
if ("DIRECT_ACCEPT".equals(mode)) {
|
||||||
|
if (ImFriendRequestEntity.Status.ACCEPTED.name().equals(saved.getStatus())) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
return acceptRequest(saved);
|
||||||
|
}
|
||||||
if (!ImFriendRequestEntity.Status.PENDING.name().equals(saved.getStatus())) {
|
if (!ImFriendRequestEntity.Status.PENDING.name().equals(saved.getStatus())) {
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
if (created[0]) {
|
||||||
|
dispatchWebhook(saved, "friend.request.sent");
|
||||||
|
}
|
||||||
publishNotification(
|
publishNotification(
|
||||||
saved,
|
saved,
|
||||||
saved.getFromUserId(),
|
saved.getFromUserId(),
|
||||||
@ -77,6 +99,7 @@ public class FriendRequestService {
|
|||||||
friendRepository
|
friendRepository
|
||||||
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
|
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
|
||||||
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId()));
|
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId()));
|
||||||
|
dispatchWebhook(request, "friend.request.accepted");
|
||||||
publishNotification(
|
publishNotification(
|
||||||
request,
|
request,
|
||||||
request.getToUserId(),
|
request.getToUserId(),
|
||||||
@ -94,6 +117,7 @@ public class FriendRequestService {
|
|||||||
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
|
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
ImFriendRequestEntity saved = requestRepository.save(request);
|
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||||
|
dispatchWebhook(saved, "friend.request.rejected");
|
||||||
publishNotification(
|
publishNotification(
|
||||||
saved,
|
saved,
|
||||||
saved.getToUserId(),
|
saved.getToUserId(),
|
||||||
@ -133,6 +157,22 @@ public class FriendRequestService {
|
|||||||
return requestRepository.findByAppIdAndFromUserId(appId, userId);
|
return requestRepository.findByAppIdAndFromUserId(appId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<ImFriendRequestEntity> listByApp(String appId) {
|
||||||
|
return requestRepository.findByAppId(appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImFriendRequestEntity adminAccept(String appId, String requestId) {
|
||||||
|
ImFriendRequestEntity request = getRequest(appId, requestId);
|
||||||
|
return acceptRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImFriendRequestEntity adminReject(String appId, String requestId) {
|
||||||
|
ImFriendRequestEntity request = getRequest(appId, requestId);
|
||||||
|
return rejectRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
private ImFriendRequestEntity getRequest(String appId, String requestId, String operatorId) {
|
private ImFriendRequestEntity getRequest(String appId, String requestId, String operatorId) {
|
||||||
ImFriendRequestEntity request = requestRepository.findById(requestId)
|
ImFriendRequestEntity request = requestRepository.findById(requestId)
|
||||||
.orElseThrow(() -> new BusinessException(404, "好友申请不存在"));
|
.orElseThrow(() -> new BusinessException(404, "好友申请不存在"));
|
||||||
@ -142,17 +182,31 @@ public class FriendRequestService {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ImFriendRequestEntity getRequest(String appId, String requestId) {
|
||||||
|
ImFriendRequestEntity request = requestRepository.findById(requestId)
|
||||||
|
.orElseThrow(() -> new BusinessException(404, "好友申请不存在"));
|
||||||
|
if (!request.getAppId().equals(appId)) {
|
||||||
|
throw new BusinessException(403, "无权操作");
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
private ImFriendRequestEntity acceptInternal(String appId, String requestId, String operatorId) {
|
private ImFriendRequestEntity acceptInternal(String appId, String requestId, String operatorId) {
|
||||||
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
||||||
|
return acceptRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImFriendRequestEntity acceptRequest(ImFriendRequestEntity request) {
|
||||||
request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name());
|
request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
ImFriendRequestEntity saved = requestRepository.save(request);
|
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||||
friendRepository
|
friendRepository
|
||||||
.findByAppIdAndUserIdAndFriendId(appId, request.getFromUserId(), request.getToUserId())
|
.findByAppIdAndUserIdAndFriendId(request.getAppId(), request.getFromUserId(), request.getToUserId())
|
||||||
.orElseGet(() -> friendEntity(appId, request.getFromUserId(), request.getToUserId()));
|
.orElseGet(() -> friendEntity(request.getAppId(), request.getFromUserId(), request.getToUserId()));
|
||||||
friendRepository
|
friendRepository
|
||||||
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
|
.findByAppIdAndUserIdAndFriendId(request.getAppId(), request.getToUserId(), request.getFromUserId())
|
||||||
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId()));
|
.orElseGet(() -> friendEntity(request.getAppId(), request.getToUserId(), request.getFromUserId()));
|
||||||
|
dispatchWebhook(saved, "friend.request.accepted");
|
||||||
publishNotification(
|
publishNotification(
|
||||||
request,
|
request,
|
||||||
request.getToUserId(),
|
request.getToUserId(),
|
||||||
@ -166,18 +220,7 @@ public class FriendRequestService {
|
|||||||
|
|
||||||
private ImFriendRequestEntity rejectInternal(String appId, String requestId, String operatorId) {
|
private ImFriendRequestEntity rejectInternal(String appId, String requestId, String operatorId) {
|
||||||
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
||||||
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
|
return rejectRequest(request);
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
|
||||||
ImFriendRequestEntity saved = requestRepository.save(request);
|
|
||||||
publishNotification(
|
|
||||||
saved,
|
|
||||||
saved.getToUserId(),
|
|
||||||
saved.getFromUserId(),
|
|
||||||
"FRIEND_REQUEST_STATUS",
|
|
||||||
"好友申请已拒绝",
|
|
||||||
buildDescription("好友申请已拒绝", saved.getRemark())
|
|
||||||
);
|
|
||||||
return saved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) {
|
private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) {
|
||||||
@ -240,4 +283,37 @@ public class FriendRequestService {
|
|||||||
}
|
}
|
||||||
return prefix + ":" + remark;
|
return prefix + ":" + remark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ImFriendRequestEntity rejectRequest(ImFriendRequestEntity request) {
|
||||||
|
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||||
|
dispatchWebhook(saved, "friend.request.rejected");
|
||||||
|
publishNotification(
|
||||||
|
saved,
|
||||||
|
saved.getToUserId(),
|
||||||
|
saved.getFromUserId(),
|
||||||
|
"FRIEND_REQUEST_STATUS",
|
||||||
|
"好友申请已拒绝",
|
||||||
|
buildDescription("好友申请已拒绝", saved.getRemark())
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatchWebhook(ImFriendRequestEntity request, String callbackEvent) {
|
||||||
|
webhookDispatchService.dispatch(
|
||||||
|
request.getAppId(),
|
||||||
|
"friend_request",
|
||||||
|
callbackEvent,
|
||||||
|
new FriendRequestCallbackPayload(
|
||||||
|
request.getAppId(),
|
||||||
|
request.getId(),
|
||||||
|
request.getFromUserId(),
|
||||||
|
request.getToUserId(),
|
||||||
|
request.getRemark(),
|
||||||
|
request.getStatus(),
|
||||||
|
request.getReviewedAt() == null ? null : request.getReviewedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,48 @@ public class ImFeatureConfigClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean allowStrangerMessage(String appId) {
|
public boolean allowStrangerMessage(String appId) {
|
||||||
|
return readConfig(appId).path("allowStrangerMessage").asBoolean(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean allowFriendRequest(String appId) {
|
||||||
|
return readConfig(appId).path("allowFriendRequest").asBoolean(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String friendRequestMode(String appId) {
|
||||||
|
JsonNode node = readConfig(appId);
|
||||||
|
String mode = node.path("friendRequestMode").asText("");
|
||||||
|
String normalized = mode == null ? "" : mode.trim().toUpperCase();
|
||||||
|
return switch (normalized) {
|
||||||
|
case "DIRECT_ACCEPT", "DISALLOW", "REQUIRE_CONFIRM" -> normalized;
|
||||||
|
default -> node.path("allowFriendRequest").asBoolean(true) ? "REQUIRE_CONFIRM" : "DISALLOW";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean allowGroupJoinRequest(String appId) {
|
||||||
|
return readConfig(appId).path("allowGroupJoinRequest").asBoolean(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean blacklistSendSuccess(String appId) {
|
||||||
|
return readConfig(appId).path("blacklistSendSuccess").asBoolean(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int messageRecallMinutes(String appId) {
|
||||||
|
return Math.max(readConfig(appId).path("messageRecallMinutes").asInt(2), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int historyRetentionDays(String appId) {
|
||||||
|
return Math.max(readConfig(appId).path("historyRetentionDays").asInt(7), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int conversationPullLimit(String appId) {
|
||||||
|
return Math.min(Math.max(readConfig(appId).path("conversationPullLimit").asInt(100), 1), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean multiClientConversationDeleteSync(String appId) {
|
||||||
|
return readConfig(appId).path("multiClientConversationDeleteSync").asBoolean(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readConfig(String appId) {
|
||||||
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
|
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
|
||||||
.path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}")
|
.path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}")
|
||||||
.buildAndExpand(appId, "ANDROID", "IM")
|
.buildAndExpand(appId, "ANDROID", "IM")
|
||||||
@ -45,14 +87,14 @@ public class ImFeatureConfigClient {
|
|||||||
JsonNode body = response.getBody();
|
JsonNode body = response.getBody();
|
||||||
if (response.getStatusCode().is2xxSuccessful() && body != null && body.path("code").asInt() == 200) {
|
if (response.getStatusCode().is2xxSuccessful() && body != null && body.path("code").asInt() == 200) {
|
||||||
String config = body.path("data").path("config").asText("");
|
String config = body.path("data").path("config").asText("");
|
||||||
if (config.isBlank()) {
|
if (!config.isBlank()) {
|
||||||
return false;
|
JsonNode node = OBJECT_MAPPER.readTree(config);
|
||||||
|
return node == null ? OBJECT_MAPPER.createObjectNode() : node;
|
||||||
}
|
}
|
||||||
return OBJECT_MAPPER.readTree(config).path("allowStrangerMessage").asBoolean(false);
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
// Fail closed: if config cannot be read, keep the feature disabled.
|
||||||
}
|
}
|
||||||
return false;
|
return OBJECT_MAPPER.createObjectNode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
|||||||
import com.xuqm.im.entity.ImMessageEntity;
|
import com.xuqm.im.entity.ImMessageEntity;
|
||||||
import com.xuqm.im.entity.ImGroupMuteEntity;
|
import com.xuqm.im.entity.ImGroupMuteEntity;
|
||||||
import com.xuqm.im.entity.ImAccountEntity;
|
import com.xuqm.im.entity.ImAccountEntity;
|
||||||
|
import com.xuqm.im.model.GroupJoinRequestCallbackPayload;
|
||||||
import com.xuqm.im.repository.ImGroupJoinRequestRepository;
|
import com.xuqm.im.repository.ImGroupJoinRequestRepository;
|
||||||
import com.xuqm.im.repository.ImAccountRepository;
|
import com.xuqm.im.repository.ImAccountRepository;
|
||||||
import com.xuqm.im.repository.ImGroupRepository;
|
import com.xuqm.im.repository.ImGroupRepository;
|
||||||
@ -35,6 +36,8 @@ public class ImGroupService {
|
|||||||
private final ImMessageRepository messageRepository;
|
private final ImMessageRepository messageRepository;
|
||||||
private final ImAccountRepository accountRepository;
|
private final ImAccountRepository accountRepository;
|
||||||
private final ImClusterPublisher clusterPublisher;
|
private final ImClusterPublisher clusterPublisher;
|
||||||
|
private final ImFeatureConfigClient featureConfigClient;
|
||||||
|
private final WebhookDispatchService webhookDispatchService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public ImGroupService(ImGroupRepository groupRepository,
|
public ImGroupService(ImGroupRepository groupRepository,
|
||||||
@ -43,6 +46,8 @@ public class ImGroupService {
|
|||||||
ImMessageRepository messageRepository,
|
ImMessageRepository messageRepository,
|
||||||
ImAccountRepository accountRepository,
|
ImAccountRepository accountRepository,
|
||||||
ImClusterPublisher clusterPublisher,
|
ImClusterPublisher clusterPublisher,
|
||||||
|
ImFeatureConfigClient featureConfigClient,
|
||||||
|
WebhookDispatchService webhookDispatchService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.muteRepository = muteRepository;
|
this.muteRepository = muteRepository;
|
||||||
@ -50,6 +55,8 @@ public class ImGroupService {
|
|||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.clusterPublisher = clusterPublisher;
|
this.clusterPublisher = clusterPublisher;
|
||||||
|
this.featureConfigClient = featureConfigClient;
|
||||||
|
this.webhookDispatchService = webhookDispatchService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,6 +290,9 @@ public class ImGroupService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
|
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
|
||||||
|
if (!featureConfigClient.allowGroupJoinRequest(appId)) {
|
||||||
|
throw new BusinessException(403, "当前应用未开放群加入申请");
|
||||||
|
}
|
||||||
ImGroupEntity group = get(groupId);
|
ImGroupEntity group = get(groupId);
|
||||||
if (!group.getAppId().equals(appId)) {
|
if (!group.getAppId().equals(appId)) {
|
||||||
throw new BusinessException(403, "无权操作");
|
throw new BusinessException(403, "无权操作");
|
||||||
@ -313,6 +323,7 @@ public class ImGroupService {
|
|||||||
buildDescription("入群申请", remark),
|
buildDescription("入群申请", remark),
|
||||||
saved
|
saved
|
||||||
);
|
);
|
||||||
|
dispatchJoinRequestWebhook(group, saved, "group.join.request.sent");
|
||||||
return saved;
|
return saved;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -332,6 +343,7 @@ public class ImGroupService {
|
|||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
addMemberInternal(group, request.getRequesterId());
|
addMemberInternal(group, request.getRequesterId());
|
||||||
|
dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted");
|
||||||
publishJoinRequestNotification(
|
publishJoinRequestNotification(
|
||||||
group,
|
group,
|
||||||
operatorId,
|
operatorId,
|
||||||
@ -352,6 +364,7 @@ public class ImGroupService {
|
|||||||
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected");
|
||||||
publishJoinRequestNotification(
|
publishJoinRequestNotification(
|
||||||
group,
|
group,
|
||||||
operatorId,
|
operatorId,
|
||||||
@ -401,6 +414,169 @@ public class ImGroupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureAppMatches(ImGroupEntity group, String appId) {
|
||||||
|
if (!group.getAppId().equals(appId)) {
|
||||||
|
throw new BusinessException(403, "无权操作");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImAccountEntity> adminListMembers(String appId, String groupId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
return resolveMembers(appId, memberIds(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImAccountEntity> adminSearchMembers(String appId, String groupId, String keyword, int size) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
List<String> ids = memberIds(group);
|
||||||
|
if (keyword == null || keyword.isBlank()) {
|
||||||
|
return resolveMembers(appId, ids).stream().limit(Math.max(size, 1)).toList();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> memberIdSet = new LinkedHashSet<>(ids);
|
||||||
|
return accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1)))
|
||||||
|
.stream()
|
||||||
|
.filter(account -> memberIdSet.contains(account.getUserId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity adminAddMember(String appId, String groupId, String userId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
addMemberInternal(group, userId);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity adminAddMembers(String appId, String groupId, List<String> userIds) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
|
||||||
|
boolean changed = false;
|
||||||
|
for (String userId : userIds == null ? List.<String>of() : userIds) {
|
||||||
|
if (userId == null || userId.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!members.contains(userId)) {
|
||||||
|
members.add(userId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
group.setMemberIds(toJson(members));
|
||||||
|
return groupRepository.save(group);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity adminRemoveMember(String appId, String groupId, String userId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
|
||||||
|
if (members.remove(userId)) {
|
||||||
|
group.setMemberIds(toJson(members));
|
||||||
|
return groupRepository.save(group);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity adminRemoveMembers(String appId, String groupId, List<String> userIds) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
|
||||||
|
boolean changed = false;
|
||||||
|
for (String userId : userIds == null ? List.<String>of() : userIds) {
|
||||||
|
if (userId == null || userId.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed |= members.remove(userId);
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
group.setMemberIds(toJson(members));
|
||||||
|
return groupRepository.save(group);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity adminSetRole(String appId, String groupId, String userId, String role) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
List<String> admins = new ArrayList<>(fromJson(group.getAdminIds()));
|
||||||
|
if ("ADMIN".equalsIgnoreCase(role)) {
|
||||||
|
if (!admins.contains(userId)) {
|
||||||
|
admins.add(userId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
admins.remove(userId);
|
||||||
|
if (userId.equals(group.getCreatorId())) {
|
||||||
|
throw new BusinessException(403, "群主不能降级");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.setAdminIds(toJson(admins));
|
||||||
|
return groupRepository.save(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity adminMuteMember(String appId, String groupId, String userId, long minutes) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
ImGroupMuteEntity mute = muteRepository
|
||||||
|
.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
ImGroupMuteEntity entity = new ImGroupMuteEntity();
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
entity.setGroupId(groupId);
|
||||||
|
entity.setUserId(userId);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
mute.setMutedUntil(LocalDateTime.now().plusMinutes(Math.max(minutes, 0)));
|
||||||
|
mute.setUpdatedAt(LocalDateTime.now());
|
||||||
|
muteRepository.save(mute);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImGroupJoinRequestEntity> adminListJoinRequests(String appId, String groupId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
return joinRequestRepository.findByAppIdAndGroupId(appId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupJoinRequestEntity adminAcceptJoinRequest(String appId, String groupId, String requestId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
|
||||||
|
if (!group.getId().equals(request.getGroupId())) {
|
||||||
|
throw new BusinessException(400, "加群申请不属于当前群");
|
||||||
|
}
|
||||||
|
request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
addMemberInternal(group, request.getRequesterId());
|
||||||
|
dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted");
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupJoinRequestEntity adminRejectJoinRequest(String appId, String groupId, String requestId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureAppMatches(group, appId);
|
||||||
|
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
|
||||||
|
if (!group.getId().equals(request.getGroupId())) {
|
||||||
|
throw new BusinessException(400, "加群申请不属于当前群");
|
||||||
|
}
|
||||||
|
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected");
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> uniqueRecipients(ImGroupEntity group) {
|
private List<String> uniqueRecipients(ImGroupEntity group) {
|
||||||
LinkedHashSet<String> recipients = new LinkedHashSet<>(fromJson(group.getAdminIds()));
|
LinkedHashSet<String> recipients = new LinkedHashSet<>(fromJson(group.getAdminIds()));
|
||||||
recipients.add(group.getCreatorId());
|
recipients.add(group.getCreatorId());
|
||||||
@ -433,6 +609,24 @@ public class ImGroupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void dispatchJoinRequestWebhook(ImGroupEntity group, ImGroupJoinRequestEntity request, String callbackEvent) {
|
||||||
|
webhookDispatchService.dispatch(
|
||||||
|
group.getAppId(),
|
||||||
|
"group_join_request",
|
||||||
|
callbackEvent,
|
||||||
|
new GroupJoinRequestCallbackPayload(
|
||||||
|
group.getAppId(),
|
||||||
|
request.getId(),
|
||||||
|
request.getGroupId(),
|
||||||
|
group.getName(),
|
||||||
|
request.getRequesterId(),
|
||||||
|
request.getRemark(),
|
||||||
|
request.getStatus(),
|
||||||
|
request.getReviewedAt() == null ? null : request.getReviewedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private String buildNotificationContent(
|
private String buildNotificationContent(
|
||||||
String type,
|
String type,
|
||||||
String title,
|
String title,
|
||||||
@ -490,6 +684,7 @@ public class ImGroupService {
|
|||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
addMemberInternal(group, request.getRequesterId());
|
addMemberInternal(group, request.getRequesterId());
|
||||||
|
dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted");
|
||||||
publishJoinRequestNotification(
|
publishJoinRequestNotification(
|
||||||
group,
|
group,
|
||||||
operatorId,
|
operatorId,
|
||||||
@ -511,6 +706,7 @@ public class ImGroupService {
|
|||||||
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected");
|
||||||
publishJoinRequestNotification(
|
publishJoinRequestNotification(
|
||||||
group,
|
group,
|
||||||
operatorId,
|
operatorId,
|
||||||
|
|||||||
@ -5,38 +5,24 @@ import com.xuqm.common.exception.BusinessException;
|
|||||||
import com.xuqm.im.cluster.ImClusterPublisher;
|
import com.xuqm.im.cluster.ImClusterPublisher;
|
||||||
import com.xuqm.im.entity.ImGroupEntity;
|
import com.xuqm.im.entity.ImGroupEntity;
|
||||||
import com.xuqm.im.entity.ImMessageEntity;
|
import com.xuqm.im.entity.ImMessageEntity;
|
||||||
import com.xuqm.im.entity.WebhookConfigEntity;
|
|
||||||
import com.xuqm.im.model.ConversationView;
|
import com.xuqm.im.model.ConversationView;
|
||||||
import com.xuqm.im.model.EditMessageRequest;
|
import com.xuqm.im.model.EditMessageRequest;
|
||||||
import com.xuqm.im.model.MessageReadCallbackPayload;
|
import com.xuqm.im.model.MessageReadCallbackPayload;
|
||||||
import com.xuqm.im.model.SendMessageRequest;
|
import com.xuqm.im.model.SendMessageRequest;
|
||||||
import com.xuqm.im.model.WebhookCallbackEnvelope;
|
|
||||||
import com.xuqm.im.repository.ImFriendRepository;
|
import com.xuqm.im.repository.ImFriendRepository;
|
||||||
import com.xuqm.im.repository.WebhookConfigRepository;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
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.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.LocalDateTime;
|
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.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import javax.crypto.Mac;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MessageService {
|
public class MessageService {
|
||||||
@ -44,7 +30,6 @@ public class MessageService {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(MessageService.class);
|
private static final Logger log = LoggerFactory.getLogger(MessageService.class);
|
||||||
|
|
||||||
private final ImMessageRepository messageRepository;
|
private final ImMessageRepository messageRepository;
|
||||||
private final WebhookConfigRepository webhookRepository;
|
|
||||||
private final KeywordFilterService keywordFilterService;
|
private final KeywordFilterService keywordFilterService;
|
||||||
private final GlobalMuteService globalMuteService;
|
private final GlobalMuteService globalMuteService;
|
||||||
private final ImClusterPublisher clusterPublisher;
|
private final ImClusterPublisher clusterPublisher;
|
||||||
@ -54,14 +39,10 @@ public class MessageService {
|
|||||||
private final ImPushBridgeClient pushBridgeClient;
|
private final ImPushBridgeClient pushBridgeClient;
|
||||||
private final ImFeatureConfigClient featureConfigClient;
|
private final ImFeatureConfigClient featureConfigClient;
|
||||||
private final ImFriendRepository friendRepository;
|
private final ImFriendRepository friendRepository;
|
||||||
private final ImAppSecretClient appSecretClient;
|
private final WebhookDispatchService webhookDispatchService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${im.webhook-timeout-ms:3000}")
|
|
||||||
private int webhookTimeoutMs;
|
|
||||||
|
|
||||||
public MessageService(ImMessageRepository messageRepository,
|
public MessageService(ImMessageRepository messageRepository,
|
||||||
WebhookConfigRepository webhookRepository,
|
|
||||||
KeywordFilterService keywordFilterService,
|
KeywordFilterService keywordFilterService,
|
||||||
GlobalMuteService globalMuteService,
|
GlobalMuteService globalMuteService,
|
||||||
ImClusterPublisher clusterPublisher,
|
ImClusterPublisher clusterPublisher,
|
||||||
@ -71,10 +52,9 @@ public class MessageService {
|
|||||||
ImPushBridgeClient pushBridgeClient,
|
ImPushBridgeClient pushBridgeClient,
|
||||||
ImFeatureConfigClient featureConfigClient,
|
ImFeatureConfigClient featureConfigClient,
|
||||||
ImFriendRepository friendRepository,
|
ImFriendRepository friendRepository,
|
||||||
ImAppSecretClient appSecretClient,
|
WebhookDispatchService webhookDispatchService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.webhookRepository = webhookRepository;
|
|
||||||
this.keywordFilterService = keywordFilterService;
|
this.keywordFilterService = keywordFilterService;
|
||||||
this.globalMuteService = globalMuteService;
|
this.globalMuteService = globalMuteService;
|
||||||
this.clusterPublisher = clusterPublisher;
|
this.clusterPublisher = clusterPublisher;
|
||||||
@ -84,7 +64,7 @@ public class MessageService {
|
|||||||
this.pushBridgeClient = pushBridgeClient;
|
this.pushBridgeClient = pushBridgeClient;
|
||||||
this.featureConfigClient = featureConfigClient;
|
this.featureConfigClient = featureConfigClient;
|
||||||
this.friendRepository = friendRepository;
|
this.friendRepository = friendRepository;
|
||||||
this.appSecretClient = appSecretClient;
|
this.webhookDispatchService = webhookDispatchService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +80,7 @@ public class MessageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGroupEntity group = null;
|
ImGroupEntity group = null;
|
||||||
|
boolean receiverBlocksSender = false;
|
||||||
if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
|
if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
|
||||||
group = groupService.get(req.toId());
|
group = groupService.get(req.toId());
|
||||||
if (!groupService.memberIds(group).contains(fromUserId)) {
|
if (!groupService.memberIds(group).contains(fromUserId)) {
|
||||||
@ -108,11 +89,19 @@ public class MessageService {
|
|||||||
if (groupService.isMemberMuted(req.toId(), fromUserId)) {
|
if (groupService.isMemberMuted(req.toId(), fromUserId)) {
|
||||||
throw new BusinessException(403, "当前用户已被禁言");
|
throw new BusinessException(403, "当前用户已被禁言");
|
||||||
}
|
}
|
||||||
} else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) {
|
|
||||||
throw new BusinessException(403, "已被拉黑,无法发送消息");
|
|
||||||
} else if (!isFriend(appId, fromUserId, req.toId())
|
} else if (!isFriend(appId, fromUserId, req.toId())
|
||||||
&& !featureConfigClient.allowStrangerMessage(appId)) {
|
&& !featureConfigClient.allowStrangerMessage(appId)) {
|
||||||
throw new BusinessException(403, "仅允许好友之间发送消息");
|
throw new BusinessException(403, "仅允许好友之间发送消息");
|
||||||
|
} else {
|
||||||
|
boolean senderBlocksReceiver = blacklistService.isBlocked(appId, fromUserId, req.toId());
|
||||||
|
receiverBlocksSender = blacklistService.isBlocked(appId, req.toId(), fromUserId);
|
||||||
|
boolean blacklistSendSuccess = featureConfigClient.blacklistSendSuccess(appId);
|
||||||
|
if (senderBlocksReceiver && receiverBlocksSender) {
|
||||||
|
throw new BusinessException(403, "已被拉黑,无法发送消息");
|
||||||
|
}
|
||||||
|
if (receiverBlocksSender && !blacklistSendSuccess) {
|
||||||
|
throw new BusinessException(403, "已被拉黑,无法发送消息");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImMessageEntity message = new ImMessageEntity();
|
ImMessageEntity message = new ImMessageEntity();
|
||||||
@ -133,16 +122,14 @@ public class MessageService {
|
|||||||
saved.setGroupReadCount(groupReadCount(appId, req.toId(), saved.getCreatedAt(), saved.getFromUserId()));
|
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();
|
|
||||||
log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}",
|
|
||||||
appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination);
|
|
||||||
clusterPublisher.publish(destination, saved);
|
|
||||||
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) {
|
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) {
|
||||||
log.debug("echo message back to sender appId={} from={} to={}",
|
log.debug("echo message back to sender appId={} from={} to={}",
|
||||||
appId, fromUserId, req.toId());
|
appId, fromUserId, req.toId());
|
||||||
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved);
|
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved);
|
||||||
|
if (!receiverBlocksSender) {
|
||||||
|
log.debug("deliver message to receiver appId={} from={} to={}",
|
||||||
|
appId, fromUserId, req.toId());
|
||||||
|
clusterPublisher.publish("/user/" + req.toId() + "/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(
|
pushBridgeClient.notifyUsers(
|
||||||
appId,
|
appId,
|
||||||
@ -151,7 +138,14 @@ public class MessageService {
|
|||||||
saved.getContent(),
|
saved.getContent(),
|
||||||
buildPushPayload(saved)
|
buildPushPayload(saved)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId));
|
||||||
|
}
|
||||||
} else if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
|
} else if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
|
||||||
|
String destination = "/topic/group/" + req.toId();
|
||||||
|
log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}",
|
||||||
|
appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination);
|
||||||
|
clusterPublisher.publish(destination, saved);
|
||||||
List<String> memberIds = groupService.memberIds(group);
|
List<String> memberIds = groupService.memberIds(group);
|
||||||
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds);
|
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds);
|
||||||
pushBridgeClient.notifyUsers(
|
pushBridgeClient.notifyUsers(
|
||||||
@ -163,6 +157,13 @@ public class MessageService {
|
|||||||
saved.getContent(),
|
saved.getContent(),
|
||||||
buildPushPayload(saved)
|
buildPushPayload(saved)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
String destination = "/user/" + req.toId() + "/queue/messages";
|
||||||
|
log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}",
|
||||||
|
appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination);
|
||||||
|
if (!receiverBlocksSender) {
|
||||||
|
clusterPublisher.publish(destination, saved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchWebhooks(appId, "message.sent", saved);
|
dispatchWebhooks(appId, "message.sent", saved);
|
||||||
@ -183,6 +184,10 @@ public class MessageService {
|
|||||||
if (!message.getFromUserId().equals(requestUserId)) {
|
if (!message.getFromUserId().equals(requestUserId)) {
|
||||||
throw new BusinessException(403, "只能撤回自己发送的消息");
|
throw new BusinessException(403, "只能撤回自己发送的消息");
|
||||||
}
|
}
|
||||||
|
int recallMinutes = featureConfigClient.messageRecallMinutes(appId);
|
||||||
|
if (recallMinutes > 0 && message.getCreatedAt().plusMinutes(recallMinutes).isBefore(LocalDateTime.now())) {
|
||||||
|
throw new BusinessException(403, "已超过可撤回时长");
|
||||||
|
}
|
||||||
message.setStatus(ImMessageEntity.MsgStatus.REVOKED);
|
message.setStatus(ImMessageEntity.MsgStatus.REVOKED);
|
||||||
message.setMsgType(ImMessageEntity.MsgType.REVOKED);
|
message.setMsgType(ImMessageEntity.MsgType.REVOKED);
|
||||||
ImMessageEntity saved = messageRepository.save(message);
|
ImMessageEntity saved = messageRepository.save(message);
|
||||||
@ -291,8 +296,9 @@ public class MessageService {
|
|||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
int page,
|
int page,
|
||||||
int size) {
|
int size) {
|
||||||
|
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
|
||||||
return messageRepository.findSingleConversationFiltered(
|
return messageRepository.findSingleConversationFiltered(
|
||||||
appId, userId, toId, msgType, keyword, startTime, endTime, PageRequest.of(page, size));
|
appId, userId, toId, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<ImMessageEntity> groupHistory(
|
public Page<ImMessageEntity> groupHistory(
|
||||||
@ -305,12 +311,13 @@ public class MessageService {
|
|||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
int page,
|
int page,
|
||||||
int size) {
|
int size) {
|
||||||
|
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
|
||||||
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, "不在群内");
|
||||||
}
|
}
|
||||||
Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered(
|
Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered(
|
||||||
appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size));
|
appId, groupId, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size));
|
||||||
pageResult.forEach(message -> message.setGroupReadCount(
|
pageResult.forEach(message -> message.setGroupReadCount(
|
||||||
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
|
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
|
||||||
return pageResult;
|
return pageResult;
|
||||||
@ -386,8 +393,9 @@ public class MessageService {
|
|||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
int page,
|
int page,
|
||||||
int size) {
|
int size) {
|
||||||
|
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
|
||||||
return messageRepository.findSingleConversationFiltered(
|
return messageRepository.findSingleConversationFiltered(
|
||||||
appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size));
|
appId, userA, userB, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<ImMessageEntity> adminGroupHistory(
|
public Page<ImMessageEntity> adminGroupHistory(
|
||||||
@ -399,26 +407,43 @@ public class MessageService {
|
|||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
int page,
|
int page,
|
||||||
int size) {
|
int size) {
|
||||||
|
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
|
||||||
Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered(
|
Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered(
|
||||||
appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size));
|
appId, groupId, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size));
|
||||||
pageResult.forEach(message -> message.setGroupReadCount(
|
pageResult.forEach(message -> message.setGroupReadCount(
|
||||||
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
|
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
|
||||||
return pageResult;
|
return pageResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ImMessageRepository.ConversationSummary> conversations(String appId, String userId, int size) {
|
public List<ImMessageRepository.ConversationSummary> conversations(String appId, String userId, int size) {
|
||||||
return messageRepository.findConversations(appId, userId, size);
|
return messageRepository.findConversations(appId, userId, normalizeConversationSize(appId, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ConversationView> conversationViews(String appId, String userId, int size) {
|
public List<ConversationView> conversationViews(String appId, String userId, int size) {
|
||||||
int fetchSize = Math.max(size * 3, size);
|
int cappedSize = normalizeConversationSize(appId, size);
|
||||||
|
int fetchSize = Math.max(cappedSize * 3, cappedSize);
|
||||||
return messageRepository.findConversations(appId, userId, fetchSize).stream()
|
return messageRepository.findConversations(appId, userId, fetchSize).stream()
|
||||||
.map(summary -> toConversationView(appId, userId, summary))
|
.map(summary -> toConversationView(appId, userId, summary))
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.limit(size)
|
.limit(cappedSize)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private LocalDateTime applyHistoryRetention(String appId, LocalDateTime requestedStart) {
|
||||||
|
int retentionDays = featureConfigClient.historyRetentionDays(appId);
|
||||||
|
LocalDateTime retentionStart = LocalDateTime.now().minusDays(retentionDays);
|
||||||
|
if (requestedStart == null || requestedStart.isBefore(retentionStart)) {
|
||||||
|
return retentionStart;
|
||||||
|
}
|
||||||
|
return requestedStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int normalizeConversationSize(String appId, int requestedSize) {
|
||||||
|
int limit = featureConfigClient.conversationPullLimit(appId);
|
||||||
|
int safeRequested = Math.max(requestedSize, 1);
|
||||||
|
return Math.min(safeRequested, limit);
|
||||||
|
}
|
||||||
|
|
||||||
private ConversationView toConversationView(
|
private ConversationView toConversationView(
|
||||||
String appId,
|
String appId,
|
||||||
String userId,
|
String userId,
|
||||||
@ -525,73 +550,10 @@ public class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected void dispatchWebhooks(String appId, String callbackEvent, ImMessageEntity message) {
|
protected void dispatchWebhooks(String appId, String callbackEvent, ImMessageEntity message) {
|
||||||
dispatchWebhooks(appId, callbackEvent, (Object) message);
|
webhookDispatchService.dispatch(appId, "message", callbackEvent, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) {
|
protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) {
|
||||||
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
webhookDispatchService.dispatch(appId, "message", callbackEvent, payload);
|
||||||
if (webhooks.isEmpty()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
String appSecret = appSecretClient.getAppSecret(appId);
|
|
||||||
long requestTime = System.currentTimeMillis();
|
|
||||||
String nonce = UUID.randomUUID().toString().replace("-", "");
|
|
||||||
String callbackId = UUID.randomUUID().toString();
|
|
||||||
WebhookCallbackEnvelope envelope = new WebhookCallbackEnvelope(
|
|
||||||
callbackId,
|
|
||||||
"message",
|
|
||||||
callbackEvent,
|
|
||||||
requestTime,
|
|
||||||
objectMapper.valueToTree(payload),
|
|
||||||
null,
|
|
||||||
appId
|
|
||||||
);
|
|
||||||
String body = objectMapper.writeValueAsString(envelope);
|
|
||||||
String signature = signWebhook(appId, appSecret, requestTime, nonce, body);
|
|
||||||
HttpClient client = HttpClient.newHttpClient();
|
|
||||||
for (WebhookConfigEntity webhook : webhooks) {
|
|
||||||
try {
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(webhook.getUrl()))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("X-App-Id", appId)
|
|
||||||
.header("X-App-Timestamp", String.valueOf(requestTime))
|
|
||||||
.header("X-App-Nonce", nonce)
|
|
||||||
.header("X-App-Signature", signature)
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
|
||||||
.build();
|
|
||||||
client.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("dispatch webhook failed appId={} url={} event={} reason={}",
|
|
||||||
appId, webhook.getUrl(), callbackEvent, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("prepare webhook failed appId={} event={} reason={}", appId, callbackEvent, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) {
|
|
||||||
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
|
|
||||||
return hmacSha256Hex(appSecret, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String sha256Hex(String value) {
|
|
||||||
try {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new IllegalStateException("Failed to hash webhook body", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String hmacSha256Hex(String secret, String payload) {
|
|
||||||
try {
|
|
||||||
Mac mac = Mac.getInstance("HmacSHA256");
|
|
||||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
||||||
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("Failed to sign webhook body", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ public class OperationLogService {
|
|||||||
ImOperationLogEntity entity = new ImOperationLogEntity();
|
ImOperationLogEntity entity = new ImOperationLogEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
entity.setId(UUID.randomUUID().toString());
|
||||||
entity.setAppId(appId);
|
entity.setAppId(appId);
|
||||||
entity.setOperatorId(operatorId);
|
entity.setOperatorId(operatorId == null || operatorId.isBlank() ? "system" : operatorId);
|
||||||
entity.setAction(action);
|
entity.setAction(action);
|
||||||
entity.setResourceType(resourceType);
|
entity.setResourceType(resourceType);
|
||||||
entity.setResourceId(resourceId);
|
entity.setResourceId(resourceId);
|
||||||
|
|||||||
@ -0,0 +1,112 @@
|
|||||||
|
package com.xuqm.im.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.xuqm.im.entity.WebhookConfigEntity;
|
||||||
|
import com.xuqm.im.model.WebhookCallbackEnvelope;
|
||||||
|
import com.xuqm.im.repository.WebhookConfigRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class WebhookDispatchService {
|
||||||
|
|
||||||
|
private final WebhookConfigRepository webhookRepository;
|
||||||
|
private final ImAppSecretClient appSecretClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${im.webhook-timeout-ms:3000}")
|
||||||
|
private int webhookTimeoutMs;
|
||||||
|
|
||||||
|
public WebhookDispatchService(WebhookConfigRepository webhookRepository,
|
||||||
|
ImAppSecretClient appSecretClient,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.webhookRepository = webhookRepository;
|
||||||
|
this.appSecretClient = appSecretClient;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void dispatch(String appId, String callbackType, String callbackEvent, Object payload) {
|
||||||
|
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
||||||
|
if (webhooks.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String appSecret = appSecretClient.getAppSecret(appId);
|
||||||
|
long requestTime = System.currentTimeMillis();
|
||||||
|
String nonce = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
String callbackId = UUID.randomUUID().toString();
|
||||||
|
WebhookCallbackEnvelope envelope = new WebhookCallbackEnvelope(
|
||||||
|
callbackId,
|
||||||
|
callbackType,
|
||||||
|
callbackEvent,
|
||||||
|
requestTime,
|
||||||
|
objectMapper.valueToTree(payload),
|
||||||
|
null,
|
||||||
|
appId
|
||||||
|
);
|
||||||
|
String body = objectMapper.writeValueAsString(envelope);
|
||||||
|
String signature = signWebhook(appId, appSecret, requestTime, nonce, body);
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofMillis(webhookTimeoutMs))
|
||||||
|
.build();
|
||||||
|
for (WebhookConfigEntity webhook : webhooks) {
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(webhook.getUrl()))
|
||||||
|
.timeout(Duration.ofMillis(webhookTimeoutMs))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-App-Id", appId)
|
||||||
|
.header("X-App-Timestamp", String.valueOf(requestTime))
|
||||||
|
.header("X-App-Nonce", nonce)
|
||||||
|
.header("X-App-Signature", signature)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 回调失败不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 准备失败不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) {
|
||||||
|
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
|
||||||
|
return hmacSha256Hex(appSecret, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256Hex(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("Failed to hash webhook body", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hmacSha256Hex(String secret, String payload) {
|
||||||
|
try {
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
|
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||||
|
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to sign webhook body", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,15 @@ public class PushController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/receive-push")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> receivePush(
|
||||||
|
@RequestParam @NotBlank String appId,
|
||||||
|
@RequestParam @NotBlank String userId,
|
||||||
|
@RequestParam boolean enabled) {
|
||||||
|
pushDispatcher.setReceivePush(appId, userId, enabled);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/send")
|
@PostMapping("/send")
|
||||||
public ResponseEntity<ApiResponse<Void>> send(
|
public ResponseEntity<ApiResponse<Void>> send(
|
||||||
@RequestParam @NotBlank String appId,
|
@RequestParam @NotBlank String appId,
|
||||||
|
|||||||
@ -32,6 +32,9 @@ public class DeviceTokenEntity {
|
|||||||
@Column(nullable = false, length = 512)
|
@Column(nullable = false, length = 512)
|
||||||
private String token;
|
private String token;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean receivePush = true;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@ -53,6 +56,9 @@ public class DeviceTokenEntity {
|
|||||||
public String getToken() { return token; }
|
public String getToken() { return token; }
|
||||||
public void setToken(String token) { this.token = token; }
|
public void setToken(String token) { this.token = token; }
|
||||||
|
|
||||||
|
public boolean isReceivePush() { return receivePush; }
|
||||||
|
public void setReceivePush(boolean receivePush) { this.receivePush = receivePush; }
|
||||||
|
|
||||||
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; }
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity, String> {
|
public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity, String> {
|
||||||
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
|
List<DeviceTokenEntity> findByAppIdAndUserIdAndReceivePushTrue(String appId, String userId);
|
||||||
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
|
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
|
||||||
String appId, String userId, DeviceTokenEntity.Vendor vendor);
|
String appId, String userId, DeviceTokenEntity.Vendor vendor);
|
||||||
|
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,7 @@ public class PushDispatcher {
|
|||||||
|
|
||||||
@Async
|
@Async
|
||||||
public void pushToUser(String appId, String userId, String title, String body, String payload) {
|
public void pushToUser(String appId, String userId, String title, String body, String payload) {
|
||||||
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserId(appId, userId);
|
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId);
|
||||||
for (DeviceTokenEntity t : tokens) {
|
for (DeviceTokenEntity t : tokens) {
|
||||||
PushProvider provider = providers.get(t.getVendor().name());
|
PushProvider provider = providers.get(t.getVendor().name());
|
||||||
if (provider != null) {
|
if (provider != null) {
|
||||||
@ -63,7 +63,17 @@ public class PushDispatcher {
|
|||||||
return e;
|
return e;
|
||||||
});
|
});
|
||||||
entity.setToken(token);
|
entity.setToken(token);
|
||||||
|
entity.setReceivePush(true);
|
||||||
entity.setUpdatedAt(LocalDateTime.now());
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
tokenRepository.save(entity);
|
tokenRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setReceivePush(String appId, String userId, boolean enabled) {
|
||||||
|
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserId(appId, userId);
|
||||||
|
for (DeviceTokenEntity token : tokens) {
|
||||||
|
token.setReceivePush(enabled);
|
||||||
|
token.setUpdatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
tokenRepository.saveAll(tokens);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -58,14 +59,28 @@ public class FeatureServiceController {
|
|||||||
@PathVariable String appId,
|
@PathVariable String appId,
|
||||||
@RequestParam FeatureServiceEntity.Platform platform,
|
@RequestParam FeatureServiceEntity.Platform platform,
|
||||||
@RequestParam FeatureServiceEntity.ServiceType serviceType,
|
@RequestParam FeatureServiceEntity.ServiceType serviceType,
|
||||||
@RequestParam boolean allowStrangerMessage,
|
@RequestBody FeatureServiceConfigRequest req,
|
||||||
@AuthenticationPrincipal String tenantId) {
|
@AuthenticationPrincipal String tenantId) {
|
||||||
appService.getById(appId, tenantId);
|
appService.getById(appId, tenantId);
|
||||||
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig(
|
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig(
|
||||||
appId,
|
appId,
|
||||||
platform,
|
platform,
|
||||||
serviceType,
|
serviceType,
|
||||||
featureServiceManager.buildAllowStrangerConfig(allowStrangerMessage)
|
serviceType == FeatureServiceEntity.ServiceType.IM
|
||||||
|
? featureServiceManager.buildImConfig(
|
||||||
|
appId,
|
||||||
|
platform,
|
||||||
|
req == null ? null : req.allowStrangerMessage(),
|
||||||
|
req == null ? null : req.allowFriendRequest(),
|
||||||
|
req == null ? null : req.friendRequestMode(),
|
||||||
|
req == null ? null : req.allowGroupJoinRequest(),
|
||||||
|
req == null ? null : req.blacklistSendSuccess(),
|
||||||
|
req == null ? null : req.messageRecallMinutes(),
|
||||||
|
req == null ? null : req.historyRetentionDays(),
|
||||||
|
req == null ? null : req.conversationPullLimit(),
|
||||||
|
req == null ? null : req.multiClientConversationDeleteSync())
|
||||||
|
: featureServiceManager.buildAllowStrangerConfig(
|
||||||
|
req != null && Boolean.TRUE.equals(req.allowStrangerMessage()))
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,4 +103,16 @@ public class FeatureServiceController {
|
|||||||
appService.getById(appId, tenantId);
|
appService.getById(appId, tenantId);
|
||||||
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId)));
|
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record FeatureServiceConfigRequest(
|
||||||
|
Boolean allowStrangerMessage,
|
||||||
|
Boolean allowFriendRequest,
|
||||||
|
String friendRequestMode,
|
||||||
|
Boolean allowGroupJoinRequest,
|
||||||
|
Boolean blacklistSendSuccess,
|
||||||
|
Integer messageRecallMinutes,
|
||||||
|
Integer historyRetentionDays,
|
||||||
|
Integer conversationPullLimit,
|
||||||
|
Boolean multiClientConversationDeleteSync
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
public interface FeatureServiceRepository extends JpaRepository<FeatureServiceEntity, String> {
|
public interface FeatureServiceRepository extends JpaRepository<FeatureServiceEntity, String> {
|
||||||
List<FeatureServiceEntity> findByAppId(String appId);
|
List<FeatureServiceEntity> findByAppId(String appId);
|
||||||
|
List<FeatureServiceEntity> findByAppIdAndServiceType(String appId, FeatureServiceEntity.ServiceType serviceType);
|
||||||
Optional<FeatureServiceEntity> findByAppIdAndPlatformAndServiceType(
|
Optional<FeatureServiceEntity> findByAppIdAndPlatformAndServiceType(
|
||||||
String appId,
|
String appId,
|
||||||
FeatureServiceEntity.Platform platform,
|
FeatureServiceEntity.Platform platform,
|
||||||
|
|||||||
@ -15,6 +15,9 @@ public interface ServiceActivationRequestRepository extends JpaRepository<Servic
|
|||||||
Optional<ServiceActivationRequestEntity> findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(
|
Optional<ServiceActivationRequestEntity> findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(
|
||||||
String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType);
|
String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType);
|
||||||
|
|
||||||
|
Optional<ServiceActivationRequestEntity> findFirstByAppIdAndServiceTypeOrderByCreatedAtDesc(
|
||||||
|
String appId, FeatureServiceEntity.ServiceType serviceType);
|
||||||
|
|
||||||
List<ServiceActivationRequestEntity> findByAppIdOrderByCreatedAtDesc(String appId);
|
List<ServiceActivationRequestEntity> findByAppIdOrderByCreatedAtDesc(String appId);
|
||||||
|
|
||||||
Page<ServiceActivationRequestEntity> findByStatusOrderByCreatedAtDesc(Status status, Pageable pageable);
|
Page<ServiceActivationRequestEntity> findByStatusOrderByCreatedAtDesc(Status status, Pageable pageable);
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -32,11 +33,25 @@ public class FeatureServiceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<FeatureServiceEntity> listByApp(String appId) {
|
public List<FeatureServiceEntity> listByApp(String appId) {
|
||||||
return repository.findByAppId(appId);
|
List<FeatureServiceEntity> services = repository.findByAppId(appId);
|
||||||
|
if (services.isEmpty()) {
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FeatureServiceEntity> normalized = new ArrayList<>();
|
||||||
|
services.stream()
|
||||||
|
.filter(service -> service.getServiceType() == FeatureServiceEntity.ServiceType.IM)
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(normalized::add);
|
||||||
|
services.stream()
|
||||||
|
.filter(service -> service.getServiceType() != FeatureServiceEntity.ServiceType.IM)
|
||||||
|
.forEach(normalized::add);
|
||||||
|
return normalized.isEmpty() ? services : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit an activation request. Disabling is immediate; enabling requires ops approval.
|
* Submit an activation request. Disabling is immediate; enabling requires ops approval.
|
||||||
|
* IM is app-wide, so duplicate checks ignore platform.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public ServiceActivationRequestEntity submitActivationRequest(
|
public ServiceActivationRequestEntity submitActivationRequest(
|
||||||
@ -45,13 +60,21 @@ public class FeatureServiceManager {
|
|||||||
FeatureServiceEntity.ServiceType serviceType,
|
FeatureServiceEntity.ServiceType serviceType,
|
||||||
String applyReason) {
|
String applyReason) {
|
||||||
|
|
||||||
// Check if there's already a pending request
|
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
|
||||||
|
requestRepository.findFirstByAppIdAndServiceTypeOrderByCreatedAtDesc(appId, serviceType)
|
||||||
|
.ifPresent(req -> {
|
||||||
|
if (req.getStatus() == Status.PENDING) {
|
||||||
|
throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
requestRepository.findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(appId, platform, serviceType)
|
requestRepository.findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(appId, platform, serviceType)
|
||||||
.ifPresent(req -> {
|
.ifPresent(req -> {
|
||||||
if (req.getStatus() == Status.PENDING) {
|
if (req.getStatus() == Status.PENDING) {
|
||||||
throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理");
|
throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ServiceActivationRequestEntity req = new ServiceActivationRequestEntity();
|
ServiceActivationRequestEntity req = new ServiceActivationRequestEntity();
|
||||||
req.setId(UUID.randomUUID().toString());
|
req.setId(UUID.randomUUID().toString());
|
||||||
@ -70,6 +93,16 @@ public class FeatureServiceManager {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public FeatureServiceEntity disable(String appId, FeatureServiceEntity.Platform platform,
|
public FeatureServiceEntity disable(String appId, FeatureServiceEntity.Platform platform,
|
||||||
FeatureServiceEntity.ServiceType serviceType) {
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
|
||||||
|
List<FeatureServiceEntity> services = repository.findByAppIdAndServiceType(appId, serviceType);
|
||||||
|
if (services.isEmpty()) {
|
||||||
|
throw new BusinessException(404, "服务未开通");
|
||||||
|
}
|
||||||
|
services.forEach(service -> service.setEnabled(false));
|
||||||
|
repository.saveAll(services);
|
||||||
|
return services.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
FeatureServiceEntity entity = repository
|
FeatureServiceEntity entity = repository
|
||||||
.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
|
.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
|
||||||
.orElseThrow(() -> new BusinessException(404, "服务未开通"));
|
.orElseThrow(() -> new BusinessException(404, "服务未开通"));
|
||||||
@ -92,7 +125,24 @@ public class FeatureServiceManager {
|
|||||||
req.setReviewedAt(LocalDateTime.now());
|
req.setReviewedAt(LocalDateTime.now());
|
||||||
requestRepository.save(req);
|
requestRepository.save(req);
|
||||||
|
|
||||||
// Activate the service
|
if (req.getServiceType() == FeatureServiceEntity.ServiceType.IM) {
|
||||||
|
List<FeatureServiceEntity> services = repository.findByAppIdAndServiceType(req.getAppId(), req.getServiceType());
|
||||||
|
if (services.isEmpty()) {
|
||||||
|
FeatureServiceEntity created = new FeatureServiceEntity();
|
||||||
|
created.setId(UUID.randomUUID().toString());
|
||||||
|
created.setAppId(req.getAppId());
|
||||||
|
created.setPlatform(req.getPlatform());
|
||||||
|
created.setServiceType(req.getServiceType());
|
||||||
|
created.setEnabled(true);
|
||||||
|
created.setCreatedAt(LocalDateTime.now());
|
||||||
|
repository.save(created);
|
||||||
|
} else {
|
||||||
|
services.forEach(service -> service.setEnabled(true));
|
||||||
|
repository.saveAll(services);
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
FeatureServiceEntity entity = repository
|
FeatureServiceEntity entity = repository
|
||||||
.findByAppIdAndPlatformAndServiceType(req.getAppId(), req.getPlatform(), req.getServiceType())
|
.findByAppIdAndPlatformAndServiceType(req.getAppId(), req.getPlatform(), req.getServiceType())
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
@ -131,6 +181,12 @@ public class FeatureServiceManager {
|
|||||||
|
|
||||||
public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform,
|
public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform,
|
||||||
FeatureServiceEntity.ServiceType serviceType) {
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
|
||||||
|
return repository.findByAppIdAndServiceType(appId, serviceType)
|
||||||
|
.stream()
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new BusinessException(404, "服务未配置"));
|
||||||
|
}
|
||||||
return repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
|
return repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
|
||||||
.orElseThrow(() -> new BusinessException(404, "服务未配置"));
|
.orElseThrow(() -> new BusinessException(404, "服务未配置"));
|
||||||
}
|
}
|
||||||
@ -140,6 +196,16 @@ public class FeatureServiceManager {
|
|||||||
FeatureServiceEntity.Platform platform,
|
FeatureServiceEntity.Platform platform,
|
||||||
FeatureServiceEntity.ServiceType serviceType,
|
FeatureServiceEntity.ServiceType serviceType,
|
||||||
String config) {
|
String config) {
|
||||||
|
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
|
||||||
|
List<FeatureServiceEntity> services = repository.findByAppIdAndServiceType(appId, serviceType);
|
||||||
|
if (services.isEmpty()) {
|
||||||
|
throw new BusinessException(404, "服务未配置");
|
||||||
|
}
|
||||||
|
services.forEach(service -> service.setConfig(config));
|
||||||
|
repository.saveAll(services);
|
||||||
|
return services.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
FeatureServiceEntity entity = getOrFail(appId, platform, serviceType);
|
FeatureServiceEntity entity = getOrFail(appId, platform, serviceType);
|
||||||
entity.setConfig(config);
|
entity.setConfig(config);
|
||||||
return repository.save(entity);
|
return repository.save(entity);
|
||||||
@ -148,23 +214,179 @@ public class FeatureServiceManager {
|
|||||||
public boolean allowStrangerMessage(String appId,
|
public boolean allowStrangerMessage(String appId,
|
||||||
FeatureServiceEntity.Platform platform,
|
FeatureServiceEntity.Platform platform,
|
||||||
FeatureServiceEntity.ServiceType serviceType) {
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
FeatureServiceEntity entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
|
return readConfigNode(appId, platform, serviceType).path("allowStrangerMessage").asBoolean(false);
|
||||||
.orElse(null);
|
|
||||||
if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
JsonNode node = objectMapper.readTree(entity.getConfig());
|
public boolean allowFriendRequest(String appId,
|
||||||
return node.path("allowStrangerMessage").asBoolean(false);
|
FeatureServiceEntity.Platform platform,
|
||||||
} catch (Exception e) {
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
return false;
|
return readConfigNode(appId, platform, serviceType).path("allowFriendRequest").asBoolean(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean allowGroupJoinRequest(String appId,
|
||||||
|
FeatureServiceEntity.Platform platform,
|
||||||
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
return readConfigNode(appId, platform, serviceType).path("allowGroupJoinRequest").asBoolean(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String friendRequestMode(String appId,
|
||||||
|
FeatureServiceEntity.Platform platform,
|
||||||
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
String mode = readConfigNode(appId, platform, serviceType).path("friendRequestMode").asText("");
|
||||||
|
return switch (mode == null ? "" : mode.trim().toUpperCase()) {
|
||||||
|
case "DIRECT_ACCEPT", "DISALLOW" -> mode.trim().toUpperCase();
|
||||||
|
default -> "REQUIRE_CONFIRM";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean blacklistSendSuccess(String appId,
|
||||||
|
FeatureServiceEntity.Platform platform,
|
||||||
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
return readConfigNode(appId, platform, serviceType).path("blacklistSendSuccess").asBoolean(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int messageRecallMinutes(String appId,
|
||||||
|
FeatureServiceEntity.Platform platform,
|
||||||
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
int minutes = readConfigNode(appId, platform, serviceType).path("messageRecallMinutes").asInt(2);
|
||||||
|
return Math.max(minutes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int conversationPullLimit(String appId,
|
||||||
|
FeatureServiceEntity.Platform platform,
|
||||||
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
int limit = readConfigNode(appId, platform, serviceType).path("conversationPullLimit").asInt(100);
|
||||||
|
return Math.min(Math.max(limit, 1), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int historyRetentionDays(String appId,
|
||||||
|
FeatureServiceEntity.Platform platform,
|
||||||
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
int days = readConfigNode(appId, platform, serviceType).path("historyRetentionDays").asInt(7);
|
||||||
|
return Math.max(days, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildImConfig(String appId,
|
||||||
|
FeatureServiceEntity.Platform platform,
|
||||||
|
Boolean allowStrangerMessage,
|
||||||
|
Boolean allowFriendRequest,
|
||||||
|
String friendRequestMode,
|
||||||
|
Boolean allowGroupJoinRequest,
|
||||||
|
Boolean blacklistSendSuccess,
|
||||||
|
Integer messageRecallMinutes,
|
||||||
|
Integer historyRetentionDays,
|
||||||
|
Integer conversationPullLimit,
|
||||||
|
Boolean multiClientConversationDeleteSync) {
|
||||||
|
ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.IM).deepCopy();
|
||||||
|
if (!node.has("allowStrangerMessage")) {
|
||||||
|
node.put("allowStrangerMessage", false);
|
||||||
|
}
|
||||||
|
if (!node.has("allowFriendRequest")) {
|
||||||
|
node.put("allowFriendRequest", true);
|
||||||
|
}
|
||||||
|
if (!node.has("friendRequestMode")) {
|
||||||
|
node.put("friendRequestMode", "REQUIRE_CONFIRM");
|
||||||
|
}
|
||||||
|
if (!node.has("allowGroupJoinRequest")) {
|
||||||
|
node.put("allowGroupJoinRequest", true);
|
||||||
|
}
|
||||||
|
if (!node.has("blacklistSendSuccess")) {
|
||||||
|
node.put("blacklistSendSuccess", true);
|
||||||
|
}
|
||||||
|
if (!node.has("messageRecallMinutes")) {
|
||||||
|
node.put("messageRecallMinutes", 2);
|
||||||
|
}
|
||||||
|
if (!node.has("historyRetentionDays")) {
|
||||||
|
node.put("historyRetentionDays", 7);
|
||||||
|
}
|
||||||
|
if (!node.has("conversationPullLimit")) {
|
||||||
|
node.put("conversationPullLimit", 100);
|
||||||
|
}
|
||||||
|
if (!node.has("multiClientConversationDeleteSync")) {
|
||||||
|
node.put("multiClientConversationDeleteSync", false);
|
||||||
|
}
|
||||||
|
if (allowStrangerMessage != null) {
|
||||||
|
node.put("allowStrangerMessage", allowStrangerMessage);
|
||||||
|
}
|
||||||
|
if (allowFriendRequest != null) {
|
||||||
|
node.put("allowFriendRequest", allowFriendRequest);
|
||||||
|
}
|
||||||
|
String effectiveFriendRequestMode = null;
|
||||||
|
if (friendRequestMode != null && !friendRequestMode.isBlank()) {
|
||||||
|
effectiveFriendRequestMode = normalizeFriendRequestMode(friendRequestMode);
|
||||||
|
} else if (allowFriendRequest != null && !allowFriendRequest) {
|
||||||
|
effectiveFriendRequestMode = "DISALLOW";
|
||||||
|
}
|
||||||
|
if (effectiveFriendRequestMode != null) {
|
||||||
|
node.put("friendRequestMode", effectiveFriendRequestMode);
|
||||||
|
if ("DISALLOW".equals(effectiveFriendRequestMode)) {
|
||||||
|
node.put("allowFriendRequest", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allowGroupJoinRequest != null) {
|
||||||
|
node.put("allowGroupJoinRequest", allowGroupJoinRequest);
|
||||||
|
}
|
||||||
|
if (blacklistSendSuccess != null) {
|
||||||
|
node.put("blacklistSendSuccess", blacklistSendSuccess);
|
||||||
|
}
|
||||||
|
if (messageRecallMinutes != null) {
|
||||||
|
node.put("messageRecallMinutes", Math.max(messageRecallMinutes, 0));
|
||||||
|
}
|
||||||
|
if (historyRetentionDays != null) {
|
||||||
|
node.put("historyRetentionDays", Math.max(historyRetentionDays, 1));
|
||||||
|
}
|
||||||
|
if (conversationPullLimit != null) {
|
||||||
|
node.put("conversationPullLimit", Math.min(Math.max(conversationPullLimit, 1), 500));
|
||||||
|
}
|
||||||
|
if (multiClientConversationDeleteSync != null) {
|
||||||
|
node.put("multiClientConversationDeleteSync", multiClientConversationDeleteSync);
|
||||||
|
}
|
||||||
|
return node.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String buildAllowStrangerConfig(boolean allowStrangerMessage) {
|
public String buildAllowStrangerConfig(boolean allowStrangerMessage) {
|
||||||
ObjectNode node = objectMapper.createObjectNode();
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
node.put("allowStrangerMessage", allowStrangerMessage);
|
node.put("allowStrangerMessage", allowStrangerMessage);
|
||||||
|
node.put("allowFriendRequest", true);
|
||||||
|
node.put("friendRequestMode", "REQUIRE_CONFIRM");
|
||||||
|
node.put("allowGroupJoinRequest", true);
|
||||||
|
node.put("blacklistSendSuccess", true);
|
||||||
|
node.put("messageRecallMinutes", 2);
|
||||||
|
node.put("historyRetentionDays", 7);
|
||||||
|
node.put("conversationPullLimit", 100);
|
||||||
|
node.put("multiClientConversationDeleteSync", false);
|
||||||
return node.toString();
|
return node.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeFriendRequestMode(String mode) {
|
||||||
|
String normalized = mode == null ? "" : mode.trim().toUpperCase();
|
||||||
|
return switch (normalized) {
|
||||||
|
case "DIRECT_ACCEPT", "DISALLOW" -> normalized;
|
||||||
|
default -> "REQUIRE_CONFIRM";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readConfigNode(String appId,
|
||||||
|
FeatureServiceEntity.Platform platform,
|
||||||
|
FeatureServiceEntity.ServiceType serviceType) {
|
||||||
|
FeatureServiceEntity entity;
|
||||||
|
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
|
||||||
|
entity = repository.findByAppIdAndServiceType(appId, serviceType)
|
||||||
|
.stream()
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
} else {
|
||||||
|
entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) {
|
||||||
|
return objectMapper.createObjectNode();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(entity.getConfig());
|
||||||
|
return node == null ? objectMapper.createObjectNode() : node;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return objectMapper.createObjectNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,12 +117,28 @@ public class SdkAppProvisioningService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void ensureFeatureDefaults(AppEntity app) {
|
private void ensureFeatureDefaults(AppEntity app) {
|
||||||
|
featureServiceRepository.findByAppIdAndServiceType(app.getAppKey(), FeatureServiceEntity.ServiceType.IM)
|
||||||
|
.stream()
|
||||||
|
.findFirst()
|
||||||
|
.orElseGet(() -> {
|
||||||
|
FeatureServiceEntity feature = new FeatureServiceEntity();
|
||||||
|
feature.setId(UUID.randomUUID().toString());
|
||||||
|
feature.setAppId(app.getAppKey());
|
||||||
|
feature.setPlatform(FeatureServiceEntity.Platform.ANDROID);
|
||||||
|
feature.setServiceType(FeatureServiceEntity.ServiceType.IM);
|
||||||
|
feature.setEnabled(true);
|
||||||
|
feature.setConfig("""
|
||||||
|
{"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"multiClientConversationDeleteSync":false}
|
||||||
|
""".trim());
|
||||||
|
feature.setCreatedAt(LocalDateTime.now());
|
||||||
|
return featureServiceRepository.save(feature);
|
||||||
|
});
|
||||||
|
|
||||||
for (FeatureServiceEntity.Platform platform : List.of(
|
for (FeatureServiceEntity.Platform platform : List.of(
|
||||||
FeatureServiceEntity.Platform.ANDROID,
|
FeatureServiceEntity.Platform.ANDROID,
|
||||||
FeatureServiceEntity.Platform.IOS,
|
FeatureServiceEntity.Platform.IOS,
|
||||||
FeatureServiceEntity.Platform.HARMONY)) {
|
FeatureServiceEntity.Platform.HARMONY)) {
|
||||||
for (FeatureServiceEntity.ServiceType serviceType : List.of(
|
for (FeatureServiceEntity.ServiceType serviceType : List.of(
|
||||||
FeatureServiceEntity.ServiceType.IM,
|
|
||||||
FeatureServiceEntity.ServiceType.PUSH,
|
FeatureServiceEntity.ServiceType.PUSH,
|
||||||
FeatureServiceEntity.ServiceType.UPDATE)) {
|
FeatureServiceEntity.ServiceType.UPDATE)) {
|
||||||
featureServiceRepository.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, serviceType)
|
featureServiceRepository.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, serviceType)
|
||||||
|
|||||||
@ -2,11 +2,17 @@ package com.xuqm.update.config;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
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.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@ -16,8 +22,10 @@ public class SecurityConfig {
|
|||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.cors(cors -> {})
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/actuator/**",
|
"/actuator/**",
|
||||||
"/api/v1/updates/app/check",
|
"/api/v1/updates/app/check",
|
||||||
@ -31,4 +39,25 @@ public class SecurityConfig {
|
|||||||
.formLogin(AbstractHttpConfigurer::disable);
|
.formLogin(AbstractHttpConfigurer::disable);
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
config.setAllowedOriginPatterns(List.of(
|
||||||
|
"http://localhost:*",
|
||||||
|
"http://127.0.0.1:*",
|
||||||
|
"http://192.168.116.9:*",
|
||||||
|
"http://*.xuqinmin.com",
|
||||||
|
"https://*.xuqinmin.com"
|
||||||
|
));
|
||||||
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||||
|
config.setAllowedHeaders(List.of("*"));
|
||||||
|
config.setExposedHeaders(List.of("Location"));
|
||||||
|
config.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", config);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.xuqm.update.controller;
|
|||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.update.entity.AppVersionEntity;
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
import com.xuqm.update.repository.AppVersionRepository;
|
import com.xuqm.update.repository.AppVersionRepository;
|
||||||
|
import com.xuqm.update.model.AppPackageInspectResult;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@ -57,8 +58,8 @@ public class AppVersionController {
|
|||||||
public ResponseEntity<ApiResponse<AppVersionEntity>> upload(
|
public ResponseEntity<ApiResponse<AppVersionEntity>> upload(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@RequestParam AppVersionEntity.Platform platform,
|
@RequestParam AppVersionEntity.Platform platform,
|
||||||
@RequestParam String versionName,
|
@RequestParam(required = false) String versionName,
|
||||||
@RequestParam int versionCode,
|
@RequestParam(required = false) Integer versionCode,
|
||||||
@RequestParam(required = false) String changeLog,
|
@RequestParam(required = false) String changeLog,
|
||||||
@RequestParam(defaultValue = "false") boolean forceUpdate,
|
@RequestParam(defaultValue = "false") boolean forceUpdate,
|
||||||
@RequestParam(required = false) MultipartFile apkFile,
|
@RequestParam(required = false) MultipartFile apkFile,
|
||||||
@ -68,12 +69,20 @@ public class AppVersionController {
|
|||||||
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
|
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
|
||||||
@RequestParam(required = false) String packageName) throws Exception {
|
@RequestParam(required = false) String packageName) throws Exception {
|
||||||
|
|
||||||
|
AppPackageInspectResult inspected = updateAssetService.inspectAppPackage(apkFile);
|
||||||
|
String resolvedVersionName = hasText(versionName) ? versionName : inspected.versionName();
|
||||||
|
Integer resolvedVersionCode = versionCode != null ? versionCode : inspected.versionCode();
|
||||||
|
String resolvedPackageName = hasText(packageName) ? packageName : inspected.packageName();
|
||||||
|
if (!hasText(resolvedVersionName) || resolvedVersionCode == null) {
|
||||||
|
throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package");
|
||||||
|
}
|
||||||
|
|
||||||
AppVersionEntity entity = new AppVersionEntity();
|
AppVersionEntity entity = new AppVersionEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
entity.setId(UUID.randomUUID().toString());
|
||||||
entity.setAppId(appId);
|
entity.setAppId(appId);
|
||||||
entity.setPlatform(platform);
|
entity.setPlatform(platform);
|
||||||
entity.setVersionName(versionName);
|
entity.setVersionName(resolvedVersionName);
|
||||||
entity.setVersionCode(versionCode);
|
entity.setVersionCode(resolvedVersionCode);
|
||||||
entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile));
|
entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile));
|
||||||
entity.setChangeLog(changeLog);
|
entity.setChangeLog(changeLog);
|
||||||
entity.setForceUpdate(forceUpdate);
|
entity.setForceUpdate(forceUpdate);
|
||||||
@ -85,10 +94,15 @@ public class AppVersionController {
|
|||||||
entity.setWebhookUrl(webhookUrl);
|
entity.setWebhookUrl(webhookUrl);
|
||||||
entity.setStoreSubmitTargets(storeSubmitTargets);
|
entity.setStoreSubmitTargets(storeSubmitTargets);
|
||||||
entity.setAutoPublishAfterReview(autoPublishAfterReview);
|
entity.setAutoPublishAfterReview(autoPublishAfterReview);
|
||||||
entity.setPackageName(packageName);
|
entity.setPackageName(resolvedPackageName);
|
||||||
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
|
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/app/inspect")
|
||||||
|
public ResponseEntity<ApiResponse<AppPackageInspectResult>> inspect(@RequestParam(required = false) MultipartFile apkFile) throws Exception {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkFile)));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/app/{id}/publish")
|
@PostMapping("/app/{id}/publish")
|
||||||
public ResponseEntity<ApiResponse<AppVersionEntity>> publish(@PathVariable String id) {
|
public ResponseEntity<ApiResponse<AppVersionEntity>> publish(@PathVariable String id) {
|
||||||
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
|
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
|
||||||
@ -122,4 +136,8 @@ public class AppVersionController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform)));
|
versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasText(String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.xuqm.update.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.update.entity.RnBundleEntity;
|
import com.xuqm.update.entity.RnBundleEntity;
|
||||||
|
import com.xuqm.update.model.RnBundleInspectResult;
|
||||||
import com.xuqm.update.repository.RnBundleRepository;
|
import com.xuqm.update.repository.RnBundleRepository;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@ -61,30 +62,43 @@ public class RnBundleController {
|
|||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
public ResponseEntity<ApiResponse<RnBundleEntity>> upload(
|
public ResponseEntity<ApiResponse<RnBundleEntity>> upload(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@RequestParam String moduleId,
|
@RequestParam(required = false) String moduleId,
|
||||||
@RequestParam RnBundleEntity.Platform platform,
|
@RequestParam(required = false) RnBundleEntity.Platform platform,
|
||||||
@RequestParam String version,
|
@RequestParam(required = false) String version,
|
||||||
@RequestParam(required = false) String minCommonVersion,
|
@RequestParam(required = false) String minCommonVersion,
|
||||||
@RequestParam(required = false) String note,
|
@RequestParam(required = false) String note,
|
||||||
@RequestParam MultipartFile bundle) throws Exception {
|
@RequestParam MultipartFile bundle) throws Exception {
|
||||||
|
RnBundleInspectResult inspected = updateAssetService.inspectRnBundle(bundle);
|
||||||
|
String resolvedModuleId = hasText(moduleId) ? moduleId : inspected.moduleId();
|
||||||
|
String resolvedVersion = hasText(version) ? version : inspected.version();
|
||||||
|
String resolvedMinCommonVersion = hasText(minCommonVersion) ? minCommonVersion : inspected.minCommonVersion();
|
||||||
|
RnBundleEntity.Platform resolvedPlatform = platform != null ? platform : parsePlatform(inspected.platform());
|
||||||
|
if (!hasText(resolvedModuleId) || !hasText(resolvedVersion) || resolvedPlatform == null) {
|
||||||
|
throw new IllegalArgumentException("moduleId, version and platform are required or must be readable from the bundle name");
|
||||||
|
}
|
||||||
UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle(
|
UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle(
|
||||||
appId, platform.name(), moduleId, bundle);
|
appId, resolvedPlatform.name(), resolvedModuleId, bundle);
|
||||||
|
|
||||||
RnBundleEntity entity = new RnBundleEntity();
|
RnBundleEntity entity = new RnBundleEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
entity.setId(UUID.randomUUID().toString());
|
||||||
entity.setAppId(appId);
|
entity.setAppId(appId);
|
||||||
entity.setModuleId(moduleId);
|
entity.setModuleId(resolvedModuleId);
|
||||||
entity.setPlatform(platform);
|
entity.setPlatform(resolvedPlatform);
|
||||||
entity.setVersion(version);
|
entity.setVersion(resolvedVersion);
|
||||||
entity.setBundleUrl(stored.bundlePath());
|
entity.setBundleUrl(stored.bundlePath());
|
||||||
entity.setMd5(stored.md5());
|
entity.setMd5(stored.md5());
|
||||||
entity.setMinCommonVersion(minCommonVersion);
|
entity.setMinCommonVersion(resolvedMinCommonVersion);
|
||||||
entity.setNote(note);
|
entity.setNote(note);
|
||||||
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/inspect")
|
||||||
|
public ResponseEntity<ApiResponse<RnBundleInspectResult>> inspect(@RequestParam(required = false) MultipartFile bundle) throws Exception {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectRnBundle(bundle)));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public ResponseEntity<ApiResponse<List<RnBundleEntity>>> list(
|
public ResponseEntity<ApiResponse<List<RnBundleEntity>>> list(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@ -137,4 +151,19 @@ public class RnBundleController {
|
|||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasText(String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private RnBundleEntity.Platform parsePlatform(String platform) {
|
||||||
|
if (!hasText(platform)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return RnBundleEntity.Platform.valueOf(platform.toUpperCase());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.xuqm.update.model;
|
||||||
|
|
||||||
|
public record AppPackageInspectResult(
|
||||||
|
String platform,
|
||||||
|
String packageName,
|
||||||
|
String versionName,
|
||||||
|
Integer versionCode,
|
||||||
|
String fileName,
|
||||||
|
boolean detected) {
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.xuqm.update.model;
|
||||||
|
|
||||||
|
public record RnBundleInspectResult(
|
||||||
|
String moduleId,
|
||||||
|
String platform,
|
||||||
|
String version,
|
||||||
|
String minCommonVersion,
|
||||||
|
String fileName,
|
||||||
|
boolean detected) {
|
||||||
|
}
|
||||||
@ -1,17 +1,34 @@
|
|||||||
package com.xuqm.update.service;
|
package com.xuqm.update.service;
|
||||||
|
|
||||||
|
import com.xuqm.update.model.AppPackageInspectResult;
|
||||||
|
import com.xuqm.update.model.RnBundleInspectResult;
|
||||||
|
import net.dongliu.apk.parser.ApkFile;
|
||||||
|
import net.dongliu.apk.parser.bean.ApkMeta;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.security.DigestInputStream;
|
import java.security.DigestInputStream;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipFile;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
|
import org.w3c.dom.Node;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UpdateAssetService {
|
public class UpdateAssetService {
|
||||||
@ -34,6 +51,41 @@ public class UpdateAssetService {
|
|||||||
return baseUrl + "/files/apk/" + filename;
|
return baseUrl + "/files/apk/" + filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AppPackageInspectResult inspectAppPackage(MultipartFile packageFile) throws Exception {
|
||||||
|
String fileName = Optional.ofNullable(packageFile != null ? packageFile.getOriginalFilename() : null)
|
||||||
|
.orElse("");
|
||||||
|
String normalized = fileName.toLowerCase(Locale.ROOT);
|
||||||
|
if (packageFile == null || packageFile.isEmpty()) {
|
||||||
|
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path temp = Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName));
|
||||||
|
try {
|
||||||
|
try (InputStream in = packageFile.getInputStream()) {
|
||||||
|
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.endsWith(".apk")) {
|
||||||
|
return inspectApk(temp, fileName);
|
||||||
|
}
|
||||||
|
if (normalized.endsWith(".ipa")) {
|
||||||
|
return inspectIpa(temp, fileName);
|
||||||
|
}
|
||||||
|
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception {
|
||||||
|
String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null)
|
||||||
|
.orElse("");
|
||||||
|
if (bundle == null || bundle.isEmpty()) {
|
||||||
|
return new RnBundleInspectResult(null, null, null, null, fileName, false);
|
||||||
|
}
|
||||||
|
return inspectRnBundleName(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception {
|
public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception {
|
||||||
if (bundle == null || bundle.isEmpty()) {
|
if (bundle == null || bundle.isEmpty()) {
|
||||||
throw new IllegalArgumentException("bundle file is required");
|
throw new IllegalArgumentException("bundle file is required");
|
||||||
@ -59,5 +111,138 @@ public class UpdateAssetService {
|
|||||||
return HexFormat.of().formatHex(digest.digest());
|
return HexFormat.of().formatHex(digest.digest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AppPackageInspectResult inspectApk(Path file, String fileName) throws Exception {
|
||||||
|
try (ApkFile apk = new ApkFile(file.toFile())) {
|
||||||
|
ApkMeta meta = apk.getApkMeta();
|
||||||
|
Integer versionCode = meta.getVersionCode() == null ? null : meta.getVersionCode().intValue();
|
||||||
|
return new AppPackageInspectResult(
|
||||||
|
"ANDROID",
|
||||||
|
blankToNull(meta.getPackageName()),
|
||||||
|
blankToNull(meta.getVersionName()),
|
||||||
|
versionCode,
|
||||||
|
fileName,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppPackageInspectResult inspectIpa(Path file, String fileName) throws Exception {
|
||||||
|
try (ZipFile zipFile = new ZipFile(file.toFile())) {
|
||||||
|
ZipEntry entry = zipFile.stream()
|
||||||
|
.filter(e -> e.getName().endsWith("Info.plist"))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (entry != null) {
|
||||||
|
byte[] data = zipFile.getInputStream(entry).readAllBytes();
|
||||||
|
String text = new String(data, StandardCharsets.UTF_8);
|
||||||
|
if (text.contains("<plist")) {
|
||||||
|
var plist = parsePlistXml(text);
|
||||||
|
return new AppPackageInspectResult(
|
||||||
|
"IOS",
|
||||||
|
blankToNull(plist.get("CFBundleIdentifier")),
|
||||||
|
blankToNull(plist.get("CFBundleShortVersionString")),
|
||||||
|
parseInteger(plist.get("CFBundleVersion")),
|
||||||
|
fileName,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AppPackageInspectResult("IOS", null, null, null, fileName, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RnBundleInspectResult inspectRnBundleName(String fileName) {
|
||||||
|
String baseName = stripExtension(fileName);
|
||||||
|
String[] parts = baseName.split("__");
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
return new RnBundleInspectResult(
|
||||||
|
blankToNull(parts[0]),
|
||||||
|
platformFromToken(parts[1]),
|
||||||
|
blankToNull(parts[2]),
|
||||||
|
blankToNull(parts[3]),
|
||||||
|
fileName,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
return new RnBundleInspectResult(
|
||||||
|
null,
|
||||||
|
platformFromFileName(fileName),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
fileName,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> parsePlistXml(String xml) throws Exception {
|
||||||
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
|
factory.setNamespaceAware(false);
|
||||||
|
factory.setExpandEntityReferences(false);
|
||||||
|
Document document = factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
NodeList dictNodes = document.getElementsByTagName("dict");
|
||||||
|
if (dictNodes.getLength() == 0) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Element dict = (Element) dictNodes.item(0);
|
||||||
|
Map<String, String> values = new java.util.LinkedHashMap<>();
|
||||||
|
Node child = dict.getFirstChild();
|
||||||
|
String currentKey = null;
|
||||||
|
while (child != null) {
|
||||||
|
if (child.getNodeType() == Node.ELEMENT_NODE) {
|
||||||
|
String nodeName = child.getNodeName();
|
||||||
|
if ("key".equals(nodeName)) {
|
||||||
|
currentKey = child.getTextContent();
|
||||||
|
} else if (currentKey != null && ("string".equals(nodeName) || "integer".equals(nodeName))) {
|
||||||
|
values.put(currentKey, child.getTextContent());
|
||||||
|
currentKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child = child.getNextSibling();
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String platformFromFileName(String fileName) {
|
||||||
|
String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
|
||||||
|
if (lower.contains("ios") || lower.endsWith(".ipa")) {
|
||||||
|
return "IOS";
|
||||||
|
}
|
||||||
|
return "ANDROID";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String platformFromToken(String token) {
|
||||||
|
if (token == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = token.trim().toUpperCase(Locale.ROOT);
|
||||||
|
return switch (normalized) {
|
||||||
|
case "ANDROID", "IOS" -> normalized;
|
||||||
|
case "A", "ANDROIDSDK" -> "ANDROID";
|
||||||
|
case "I", "IOSSDK" -> "IOS";
|
||||||
|
default -> normalized.isBlank() ? null : normalized;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stripExtension(String fileName) {
|
||||||
|
if (fileName == null) return "";
|
||||||
|
int idx = fileName.lastIndexOf('.');
|
||||||
|
return idx > 0 ? fileName.substring(0, idx) : fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String suffixFor(String fileName) {
|
||||||
|
if (fileName == null) return ".tmp";
|
||||||
|
int idx = fileName.lastIndexOf('.');
|
||||||
|
return idx > 0 ? fileName.substring(idx) : ".tmp";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer parseInteger(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(value.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
public record StoredRnBundle(String bundlePath, String md5) {}
|
public record StoredRnBundle(String bundlePath, String md5) {}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户