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/receive-push` | 开启或关闭接收推送 |
|
||||
| DELETE | `/api/push/device/unregister` | 解绑设备 token |
|
||||
| POST | `/api/push/send` | 向指定用户推送通知 |
|
||||
| POST | `/api/push/internal/notify` | IM 服务内部调用,批量触发离线推送 |
|
||||
|
||||
其中 `/api/push/register`、`/api/push/device/unregister` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。
|
||||
其中 `/api/push/register`、`/api/push/device/unregister`、`/api/push/receive-push` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。
|
||||
|
||||
**注册 token**
|
||||
```
|
||||
@ -381,6 +382,14 @@ POST /api/push/send
|
||||
&payload={"type":"IM","msgId":"uuid"}
|
||||
```
|
||||
|
||||
**开关接收推送**
|
||||
```
|
||||
POST /api/push/receive-push
|
||||
?appId=ak_xxx
|
||||
&userId=user_001
|
||||
&enabled=false
|
||||
```
|
||||
|
||||
**内部通知**
|
||||
```json
|
||||
{
|
||||
|
||||
@ -13,6 +13,21 @@
|
||||
| App 更新 | `https://dev.xuqinmin.com/api/v1/updates/` | 原生版本管理 |
|
||||
| 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/receive-push` | 是 | 开启或关闭接收推送 |
|
||||
| POST | `/api/push/send` | 是 | 发送推送通知 |
|
||||
|
||||
### 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 -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 '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.im.model.ConversationView;
|
||||
import com.xuqm.im.service.ImFeatureConfigClient;
|
||||
import com.xuqm.im.service.ConversationStateService;
|
||||
import com.xuqm.im.service.MessageService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@ -22,11 +23,14 @@ public class ConversationController {
|
||||
|
||||
private final MessageService messageService;
|
||||
private final ConversationStateService conversationStateService;
|
||||
private final ImFeatureConfigClient featureConfigClient;
|
||||
|
||||
public ConversationController(MessageService messageService,
|
||||
ConversationStateService conversationStateService) {
|
||||
ConversationStateService conversationStateService,
|
||||
ImFeatureConfigClient featureConfigClient) {
|
||||
this.messageService = messageService;
|
||||
this.conversationStateService = conversationStateService;
|
||||
this.featureConfigClient = featureConfigClient;
|
||||
}
|
||||
|
||||
@GetMapping("/conversations")
|
||||
@ -92,7 +96,8 @@ public class ConversationController {
|
||||
@RequestParam String appId,
|
||||
@PathVariable String targetId,
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import com.xuqm.common.exception.BusinessException;
|
||||
import com.xuqm.common.model.ApiResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@ -27,6 +28,16 @@ public class GlobalExceptionHandler {
|
||||
.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)
|
||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
package com.xuqm.im.controller;
|
||||
|
||||
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.ImGlobalMuteEntity;
|
||||
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.KeywordFilterEntity;
|
||||
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.ImMessageRepository;
|
||||
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.GlobalMuteService;
|
||||
import com.xuqm.im.service.KeywordFilterService;
|
||||
@ -35,6 +41,8 @@ public class ImAdminController {
|
||||
private final ImGroupRepository groupRepository;
|
||||
private final ImMessageRepository messageRepository;
|
||||
private final ImAccountService accountService;
|
||||
private final FriendRequestService friendRequestService;
|
||||
private final BlacklistService blacklistService;
|
||||
private final ImGroupService groupService;
|
||||
private final MessageService messageService;
|
||||
private final WebhookConfigService webhookConfigService;
|
||||
@ -46,6 +54,8 @@ public class ImAdminController {
|
||||
ImGroupRepository groupRepository,
|
||||
ImMessageRepository messageRepository,
|
||||
ImAccountService accountService,
|
||||
FriendRequestService friendRequestService,
|
||||
BlacklistService blacklistService,
|
||||
ImGroupService groupService,
|
||||
MessageService messageService,
|
||||
WebhookConfigService webhookConfigService,
|
||||
@ -56,6 +66,8 @@ public class ImAdminController {
|
||||
this.groupRepository = groupRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
this.accountService = accountService;
|
||||
this.friendRequestService = friendRequestService;
|
||||
this.blacklistService = blacklistService;
|
||||
this.groupService = groupService;
|
||||
this.messageService = messageService;
|
||||
this.webhookConfigService = webhookConfigService;
|
||||
@ -82,10 +94,14 @@ public class ImAdminController {
|
||||
@AuthenticationPrincipal String operatorId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
account.setStatus(ImAccountEntity.Status.valueOf(body.get("status").toUpperCase()));
|
||||
.orElseThrow(() -> new BusinessException(404, "账号不存在"));
|
||||
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);
|
||||
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));
|
||||
}
|
||||
|
||||
@ -266,6 +282,156 @@ public class ImAdminController {
|
||||
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")
|
||||
public ResponseEntity<ApiResponse<List<WebhookConfigEntity>>> listWebhooks(@RequestParam String 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 WebhookConfigRequest(String url, String secret, 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;
|
||||
|
||||
public interface ImBlacklistRepository extends JpaRepository<ImBlacklistEntity, String> {
|
||||
List<ImBlacklistEntity> findByAppId(String appId);
|
||||
List<ImBlacklistEntity> findByAppIdAndUserId(String appId, String userId);
|
||||
|
||||
Optional<ImBlacklistEntity> findByAppIdAndUserIdAndBlockedUserId(
|
||||
|
||||
@ -7,6 +7,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ImFriendRequestRepository extends JpaRepository<ImFriendRequestEntity, String> {
|
||||
List<ImFriendRequestEntity> findByAppId(String appId);
|
||||
Optional<ImFriendRequestEntity> findByAppIdAndFromUserIdAndToUserId(
|
||||
String appId, String fromUserId, String toUserId);
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ImGroupJoinRequestRepository extends JpaRepository<ImGroupJoinRequestEntity, String> {
|
||||
List<ImGroupJoinRequestEntity> findByAppId(String appId);
|
||||
Optional<ImGroupJoinRequestEntity> findByAppIdAndGroupIdAndRequesterId(
|
||||
String appId, String groupId, String requesterId);
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.xuqm.im.service;
|
||||
|
||||
import com.xuqm.im.entity.ImBlacklistEntity;
|
||||
import com.xuqm.im.model.BlacklistCallbackPayload;
|
||||
import com.xuqm.im.repository.ImBlacklistRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@ -13,9 +14,11 @@ import java.util.UUID;
|
||||
public class BlacklistService {
|
||||
|
||||
private final ImBlacklistRepository repository;
|
||||
private final WebhookDispatchService webhookDispatchService;
|
||||
|
||||
public BlacklistService(ImBlacklistRepository repository) {
|
||||
public BlacklistService(ImBlacklistRepository repository, WebhookDispatchService webhookDispatchService) {
|
||||
this.repository = repository;
|
||||
this.webhookDispatchService = webhookDispatchService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@ -28,19 +31,30 @@ public class BlacklistService {
|
||||
entity.setUserId(userId);
|
||||
entity.setBlockedUserId(blockedUserId);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
return repository.save(entity);
|
||||
ImBlacklistEntity saved = repository.save(entity);
|
||||
dispatchWebhook(saved, "blacklist.added");
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void remove(String appId, String userId, String blockedUserId) {
|
||||
ImBlacklistEntity entity = repository.findByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId)
|
||||
.orElse(null);
|
||||
repository.deleteByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
|
||||
if (entity != null) {
|
||||
dispatchWebhook(entity, "blacklist.removed");
|
||||
}
|
||||
}
|
||||
|
||||
public List<ImBlacklistEntity> list(String appId, String 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) {
|
||||
return repository.existsByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
|
||||
}
|
||||
@ -48,4 +62,20 @@ public class BlacklistService {
|
||||
public boolean isEitherBlocked(String appId, String userId, String targetUserId) {
|
||||
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);
|
||||
}
|
||||
|
||||
@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
|
||||
public void clearHiddenForUsers(String appId, String targetId, String chatType, Collection<String> 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.entity.ImFriendRequestEntity;
|
||||
import com.xuqm.im.entity.ImMessageEntity;
|
||||
import com.xuqm.im.model.FriendRequestCallbackPayload;
|
||||
import com.xuqm.im.repository.ImFriendRequestRepository;
|
||||
import com.xuqm.im.repository.ImFriendRepository;
|
||||
import com.xuqm.im.repository.ImMessageRepository;
|
||||
@ -23,24 +24,36 @@ public class FriendRequestService {
|
||||
private final ImFriendRepository friendRepository;
|
||||
private final ImMessageRepository messageRepository;
|
||||
private final ImClusterPublisher clusterPublisher;
|
||||
private final ImFeatureConfigClient featureConfigClient;
|
||||
private final WebhookDispatchService webhookDispatchService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public FriendRequestService(ImFriendRequestRepository requestRepository,
|
||||
ImFriendRepository friendRepository,
|
||||
ImMessageRepository messageRepository,
|
||||
ImClusterPublisher clusterPublisher,
|
||||
ImFeatureConfigClient featureConfigClient,
|
||||
WebhookDispatchService webhookDispatchService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.requestRepository = requestRepository;
|
||||
this.friendRepository = friendRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
this.clusterPublisher = clusterPublisher;
|
||||
this.featureConfigClient = featureConfigClient;
|
||||
this.webhookDispatchService = webhookDispatchService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
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)
|
||||
.orElseGet(() -> {
|
||||
created[0] = true;
|
||||
ImFriendRequestEntity entity = new ImFriendRequestEntity();
|
||||
entity.setId(UUID.randomUUID().toString());
|
||||
entity.setAppId(appId);
|
||||
@ -51,9 +64,18 @@ public class FriendRequestService {
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
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())) {
|
||||
return saved;
|
||||
}
|
||||
if (created[0]) {
|
||||
dispatchWebhook(saved, "friend.request.sent");
|
||||
}
|
||||
publishNotification(
|
||||
saved,
|
||||
saved.getFromUserId(),
|
||||
@ -77,6 +99,7 @@ public class FriendRequestService {
|
||||
friendRepository
|
||||
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
|
||||
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId()));
|
||||
dispatchWebhook(request, "friend.request.accepted");
|
||||
publishNotification(
|
||||
request,
|
||||
request.getToUserId(),
|
||||
@ -94,6 +117,7 @@ public class FriendRequestService {
|
||||
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||
dispatchWebhook(saved, "friend.request.rejected");
|
||||
publishNotification(
|
||||
saved,
|
||||
saved.getToUserId(),
|
||||
@ -133,6 +157,22 @@ public class FriendRequestService {
|
||||
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) {
|
||||
ImFriendRequestEntity request = requestRepository.findById(requestId)
|
||||
.orElseThrow(() -> new BusinessException(404, "好友申请不存在"));
|
||||
@ -142,17 +182,31 @@ public class FriendRequestService {
|
||||
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) {
|
||||
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
||||
return acceptRequest(request);
|
||||
}
|
||||
|
||||
private ImFriendRequestEntity acceptRequest(ImFriendRequestEntity request) {
|
||||
request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||
friendRepository
|
||||
.findByAppIdAndUserIdAndFriendId(appId, request.getFromUserId(), request.getToUserId())
|
||||
.orElseGet(() -> friendEntity(appId, request.getFromUserId(), request.getToUserId()));
|
||||
.findByAppIdAndUserIdAndFriendId(request.getAppId(), request.getFromUserId(), request.getToUserId())
|
||||
.orElseGet(() -> friendEntity(request.getAppId(), request.getFromUserId(), request.getToUserId()));
|
||||
friendRepository
|
||||
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
|
||||
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId()));
|
||||
.findByAppIdAndUserIdAndFriendId(request.getAppId(), request.getToUserId(), request.getFromUserId())
|
||||
.orElseGet(() -> friendEntity(request.getAppId(), request.getToUserId(), request.getFromUserId()));
|
||||
dispatchWebhook(saved, "friend.request.accepted");
|
||||
publishNotification(
|
||||
request,
|
||||
request.getToUserId(),
|
||||
@ -166,18 +220,7 @@ public class FriendRequestService {
|
||||
|
||||
private ImFriendRequestEntity rejectInternal(String appId, String requestId, String operatorId) {
|
||||
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
||||
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||
publishNotification(
|
||||
saved,
|
||||
saved.getToUserId(),
|
||||
saved.getFromUserId(),
|
||||
"FRIEND_REQUEST_STATUS",
|
||||
"好友申请已拒绝",
|
||||
buildDescription("好友申请已拒绝", saved.getRemark())
|
||||
);
|
||||
return saved;
|
||||
return rejectRequest(request);
|
||||
}
|
||||
|
||||
private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) {
|
||||
@ -240,4 +283,37 @@ public class FriendRequestService {
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
.path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}")
|
||||
.buildAndExpand(appId, "ANDROID", "IM")
|
||||
@ -45,14 +87,14 @@ public class ImFeatureConfigClient {
|
||||
JsonNode body = response.getBody();
|
||||
if (response.getStatusCode().is2xxSuccessful() && body != null && body.path("code").asInt() == 200) {
|
||||
String config = body.path("data").path("config").asText("");
|
||||
if (config.isBlank()) {
|
||||
return false;
|
||||
if (!config.isBlank()) {
|
||||
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) {
|
||||
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.ImGroupMuteEntity;
|
||||
import com.xuqm.im.entity.ImAccountEntity;
|
||||
import com.xuqm.im.model.GroupJoinRequestCallbackPayload;
|
||||
import com.xuqm.im.repository.ImGroupJoinRequestRepository;
|
||||
import com.xuqm.im.repository.ImAccountRepository;
|
||||
import com.xuqm.im.repository.ImGroupRepository;
|
||||
@ -35,6 +36,8 @@ public class ImGroupService {
|
||||
private final ImMessageRepository messageRepository;
|
||||
private final ImAccountRepository accountRepository;
|
||||
private final ImClusterPublisher clusterPublisher;
|
||||
private final ImFeatureConfigClient featureConfigClient;
|
||||
private final WebhookDispatchService webhookDispatchService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ImGroupService(ImGroupRepository groupRepository,
|
||||
@ -43,6 +46,8 @@ public class ImGroupService {
|
||||
ImMessageRepository messageRepository,
|
||||
ImAccountRepository accountRepository,
|
||||
ImClusterPublisher clusterPublisher,
|
||||
ImFeatureConfigClient featureConfigClient,
|
||||
WebhookDispatchService webhookDispatchService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.groupRepository = groupRepository;
|
||||
this.muteRepository = muteRepository;
|
||||
@ -50,6 +55,8 @@ public class ImGroupService {
|
||||
this.messageRepository = messageRepository;
|
||||
this.accountRepository = accountRepository;
|
||||
this.clusterPublisher = clusterPublisher;
|
||||
this.featureConfigClient = featureConfigClient;
|
||||
this.webhookDispatchService = webhookDispatchService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@ -283,6 +290,9 @@ public class ImGroupService {
|
||||
|
||||
@Transactional
|
||||
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
|
||||
if (!featureConfigClient.allowGroupJoinRequest(appId)) {
|
||||
throw new BusinessException(403, "当前应用未开放群加入申请");
|
||||
}
|
||||
ImGroupEntity group = get(groupId);
|
||||
if (!group.getAppId().equals(appId)) {
|
||||
throw new BusinessException(403, "无权操作");
|
||||
@ -313,6 +323,7 @@ public class ImGroupService {
|
||||
buildDescription("入群申请", remark),
|
||||
saved
|
||||
);
|
||||
dispatchJoinRequestWebhook(group, saved, "group.join.request.sent");
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
@ -332,6 +343,7 @@ public class ImGroupService {
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||
addMemberInternal(group, request.getRequesterId());
|
||||
dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted");
|
||||
publishJoinRequestNotification(
|
||||
group,
|
||||
operatorId,
|
||||
@ -352,6 +364,7 @@ public class ImGroupService {
|
||||
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||
dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected");
|
||||
publishJoinRequestNotification(
|
||||
group,
|
||||
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) {
|
||||
LinkedHashSet<String> recipients = new LinkedHashSet<>(fromJson(group.getAdminIds()));
|
||||
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(
|
||||
String type,
|
||||
String title,
|
||||
@ -490,6 +684,7 @@ public class ImGroupService {
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||
addMemberInternal(group, request.getRequesterId());
|
||||
dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted");
|
||||
publishJoinRequestNotification(
|
||||
group,
|
||||
operatorId,
|
||||
@ -511,6 +706,7 @@ public class ImGroupService {
|
||||
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||
dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected");
|
||||
publishJoinRequestNotification(
|
||||
group,
|
||||
operatorId,
|
||||
|
||||
@ -5,38 +5,24 @@ import com.xuqm.common.exception.BusinessException;
|
||||
import com.xuqm.im.cluster.ImClusterPublisher;
|
||||
import com.xuqm.im.entity.ImGroupEntity;
|
||||
import com.xuqm.im.entity.ImMessageEntity;
|
||||
import com.xuqm.im.entity.WebhookConfigEntity;
|
||||
import com.xuqm.im.model.ConversationView;
|
||||
import com.xuqm.im.model.EditMessageRequest;
|
||||
import com.xuqm.im.model.MessageReadCallbackPayload;
|
||||
import com.xuqm.im.model.SendMessageRequest;
|
||||
import com.xuqm.im.model.WebhookCallbackEnvelope;
|
||||
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.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.slf4j.Logger;
|
||||
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.ZoneOffset;
|
||||
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.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
@Service
|
||||
public class MessageService {
|
||||
@ -44,7 +30,6 @@ public class MessageService {
|
||||
private static final Logger log = LoggerFactory.getLogger(MessageService.class);
|
||||
|
||||
private final ImMessageRepository messageRepository;
|
||||
private final WebhookConfigRepository webhookRepository;
|
||||
private final KeywordFilterService keywordFilterService;
|
||||
private final GlobalMuteService globalMuteService;
|
||||
private final ImClusterPublisher clusterPublisher;
|
||||
@ -54,14 +39,10 @@ public class MessageService {
|
||||
private final ImPushBridgeClient pushBridgeClient;
|
||||
private final ImFeatureConfigClient featureConfigClient;
|
||||
private final ImFriendRepository friendRepository;
|
||||
private final ImAppSecretClient appSecretClient;
|
||||
private final WebhookDispatchService webhookDispatchService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${im.webhook-timeout-ms:3000}")
|
||||
private int webhookTimeoutMs;
|
||||
|
||||
public MessageService(ImMessageRepository messageRepository,
|
||||
WebhookConfigRepository webhookRepository,
|
||||
KeywordFilterService keywordFilterService,
|
||||
GlobalMuteService globalMuteService,
|
||||
ImClusterPublisher clusterPublisher,
|
||||
@ -71,10 +52,9 @@ public class MessageService {
|
||||
ImPushBridgeClient pushBridgeClient,
|
||||
ImFeatureConfigClient featureConfigClient,
|
||||
ImFriendRepository friendRepository,
|
||||
ImAppSecretClient appSecretClient,
|
||||
WebhookDispatchService webhookDispatchService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.messageRepository = messageRepository;
|
||||
this.webhookRepository = webhookRepository;
|
||||
this.keywordFilterService = keywordFilterService;
|
||||
this.globalMuteService = globalMuteService;
|
||||
this.clusterPublisher = clusterPublisher;
|
||||
@ -84,7 +64,7 @@ public class MessageService {
|
||||
this.pushBridgeClient = pushBridgeClient;
|
||||
this.featureConfigClient = featureConfigClient;
|
||||
this.friendRepository = friendRepository;
|
||||
this.appSecretClient = appSecretClient;
|
||||
this.webhookDispatchService = webhookDispatchService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@ -100,6 +80,7 @@ public class MessageService {
|
||||
}
|
||||
}
|
||||
ImGroupEntity group = null;
|
||||
boolean receiverBlocksSender = false;
|
||||
if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
|
||||
group = groupService.get(req.toId());
|
||||
if (!groupService.memberIds(group).contains(fromUserId)) {
|
||||
@ -108,11 +89,19 @@ public class MessageService {
|
||||
if (groupService.isMemberMuted(req.toId(), fromUserId)) {
|
||||
throw new BusinessException(403, "当前用户已被禁言");
|
||||
}
|
||||
} else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) {
|
||||
throw new BusinessException(403, "已被拉黑,无法发送消息");
|
||||
} else if (!isFriend(appId, fromUserId, req.toId())
|
||||
&& !featureConfigClient.allowStrangerMessage(appId)) {
|
||||
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();
|
||||
@ -133,16 +122,14 @@ public class MessageService {
|
||||
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())) {
|
||||
log.debug("echo message back to sender appId={} from={} to={}",
|
||||
appId, fromUserId, req.toId());
|
||||
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()));
|
||||
pushBridgeClient.notifyUsers(
|
||||
appId,
|
||||
@ -151,7 +138,14 @@ public class MessageService {
|
||||
saved.getContent(),
|
||||
buildPushPayload(saved)
|
||||
);
|
||||
} else {
|
||||
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId));
|
||||
}
|
||||
} 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);
|
||||
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds);
|
||||
pushBridgeClient.notifyUsers(
|
||||
@ -163,6 +157,13 @@ public class MessageService {
|
||||
saved.getContent(),
|
||||
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);
|
||||
@ -183,6 +184,10 @@ public class MessageService {
|
||||
if (!message.getFromUserId().equals(requestUserId)) {
|
||||
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.setMsgType(ImMessageEntity.MsgType.REVOKED);
|
||||
ImMessageEntity saved = messageRepository.save(message);
|
||||
@ -291,8 +296,9 @@ public class MessageService {
|
||||
LocalDateTime endTime,
|
||||
int page,
|
||||
int size) {
|
||||
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
|
||||
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(
|
||||
@ -305,12 +311,13 @@ public class MessageService {
|
||||
LocalDateTime endTime,
|
||||
int page,
|
||||
int size) {
|
||||
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
|
||||
ImGroupEntity group = groupService.get(groupId);
|
||||
if (!groupService.memberIds(group).contains(userId)) {
|
||||
throw new BusinessException(403, "不在群内");
|
||||
}
|
||||
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(
|
||||
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
|
||||
return pageResult;
|
||||
@ -386,8 +393,9 @@ public class MessageService {
|
||||
LocalDateTime endTime,
|
||||
int page,
|
||||
int size) {
|
||||
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
|
||||
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(
|
||||
@ -399,26 +407,43 @@ public class MessageService {
|
||||
LocalDateTime endTime,
|
||||
int page,
|
||||
int size) {
|
||||
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
|
||||
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(
|
||||
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
|
||||
return pageResult;
|
||||
}
|
||||
|
||||
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) {
|
||||
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()
|
||||
.map(summary -> toConversationView(appId, userId, summary))
|
||||
.filter(Objects::nonNull)
|
||||
.limit(size)
|
||||
.limit(cappedSize)
|
||||
.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(
|
||||
String appId,
|
||||
String userId,
|
||||
@ -525,73 +550,10 @@ public class MessageService {
|
||||
}
|
||||
|
||||
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) {
|
||||
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,
|
||||
"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);
|
||||
}
|
||||
webhookDispatchService.dispatch(appId, "message", callbackEvent, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ public class OperationLogService {
|
||||
ImOperationLogEntity entity = new ImOperationLogEntity();
|
||||
entity.setId(UUID.randomUUID().toString());
|
||||
entity.setAppId(appId);
|
||||
entity.setOperatorId(operatorId);
|
||||
entity.setOperatorId(operatorId == null || operatorId.isBlank() ? "system" : operatorId);
|
||||
entity.setAction(action);
|
||||
entity.setResourceType(resourceType);
|
||||
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());
|
||||
}
|
||||
|
||||
@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")
|
||||
public ResponseEntity<ApiResponse<Void>> send(
|
||||
@RequestParam @NotBlank String appId,
|
||||
|
||||
@ -32,6 +32,9 @@ public class DeviceTokenEntity {
|
||||
@Column(nullable = false, length = 512)
|
||||
private String token;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean receivePush = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ -53,6 +56,9 @@ public class DeviceTokenEntity {
|
||||
public String getToken() { return 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 void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
|
||||
@ -7,7 +7,8 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity, String> {
|
||||
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
|
||||
List<DeviceTokenEntity> findByAppIdAndUserIdAndReceivePushTrue(String appId, String userId);
|
||||
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
|
||||
String appId, String userId, DeviceTokenEntity.Vendor vendor);
|
||||
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ public class PushDispatcher {
|
||||
|
||||
@Async
|
||||
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) {
|
||||
PushProvider provider = providers.get(t.getVendor().name());
|
||||
if (provider != null) {
|
||||
@ -63,7 +63,17 @@ public class PushDispatcher {
|
||||
return e;
|
||||
});
|
||||
entity.setToken(token);
|
||||
entity.setReceivePush(true);
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
@ -58,14 +59,28 @@ public class FeatureServiceController {
|
||||
@PathVariable String appId,
|
||||
@RequestParam FeatureServiceEntity.Platform platform,
|
||||
@RequestParam FeatureServiceEntity.ServiceType serviceType,
|
||||
@RequestParam boolean allowStrangerMessage,
|
||||
@RequestBody FeatureServiceConfigRequest req,
|
||||
@AuthenticationPrincipal String tenantId) {
|
||||
appService.getById(appId, tenantId);
|
||||
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig(
|
||||
appId,
|
||||
platform,
|
||||
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);
|
||||
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> {
|
||||
List<FeatureServiceEntity> findByAppId(String appId);
|
||||
List<FeatureServiceEntity> findByAppIdAndServiceType(String appId, FeatureServiceEntity.ServiceType serviceType);
|
||||
Optional<FeatureServiceEntity> findByAppIdAndPlatformAndServiceType(
|
||||
String appId,
|
||||
FeatureServiceEntity.Platform platform,
|
||||
|
||||
@ -15,6 +15,9 @@ public interface ServiceActivationRequestRepository extends JpaRepository<Servic
|
||||
Optional<ServiceActivationRequestEntity> findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(
|
||||
String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType);
|
||||
|
||||
Optional<ServiceActivationRequestEntity> findFirstByAppIdAndServiceTypeOrderByCreatedAtDesc(
|
||||
String appId, FeatureServiceEntity.ServiceType serviceType);
|
||||
|
||||
List<ServiceActivationRequestEntity> findByAppIdOrderByCreatedAtDesc(String appId);
|
||||
|
||||
Page<ServiceActivationRequestEntity> findByStatusOrderByCreatedAtDesc(Status status, Pageable pageable);
|
||||
|
||||
@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@ -32,11 +33,25 @@ public class FeatureServiceManager {
|
||||
}
|
||||
|
||||
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.
|
||||
* IM is app-wide, so duplicate checks ignore platform.
|
||||
*/
|
||||
@Transactional
|
||||
public ServiceActivationRequestEntity submitActivationRequest(
|
||||
@ -45,13 +60,21 @@ public class FeatureServiceManager {
|
||||
FeatureServiceEntity.ServiceType serviceType,
|
||||
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)
|
||||
.ifPresent(req -> {
|
||||
if (req.getStatus() == Status.PENDING) {
|
||||
throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ServiceActivationRequestEntity req = new ServiceActivationRequestEntity();
|
||||
req.setId(UUID.randomUUID().toString());
|
||||
@ -70,6 +93,16 @@ public class FeatureServiceManager {
|
||||
@Transactional
|
||||
public FeatureServiceEntity disable(String appId, FeatureServiceEntity.Platform platform,
|
||||
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
|
||||
.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
|
||||
.orElseThrow(() -> new BusinessException(404, "服务未开通"));
|
||||
@ -92,7 +125,24 @@ public class FeatureServiceManager {
|
||||
req.setReviewedAt(LocalDateTime.now());
|
||||
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
|
||||
.findByAppIdAndPlatformAndServiceType(req.getAppId(), req.getPlatform(), req.getServiceType())
|
||||
.orElseGet(() -> {
|
||||
@ -131,6 +181,12 @@ public class FeatureServiceManager {
|
||||
|
||||
public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform,
|
||||
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)
|
||||
.orElseThrow(() -> new BusinessException(404, "服务未配置"));
|
||||
}
|
||||
@ -140,6 +196,16 @@ public class FeatureServiceManager {
|
||||
FeatureServiceEntity.Platform platform,
|
||||
FeatureServiceEntity.ServiceType serviceType,
|
||||
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);
|
||||
entity.setConfig(config);
|
||||
return repository.save(entity);
|
||||
@ -148,23 +214,179 @@ public class FeatureServiceManager {
|
||||
public boolean allowStrangerMessage(String appId,
|
||||
FeatureServiceEntity.Platform platform,
|
||||
FeatureServiceEntity.ServiceType serviceType) {
|
||||
FeatureServiceEntity entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
|
||||
.orElse(null);
|
||||
if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) {
|
||||
return false;
|
||||
return readConfigNode(appId, platform, serviceType).path("allowStrangerMessage").asBoolean(false);
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(entity.getConfig());
|
||||
return node.path("allowStrangerMessage").asBoolean(false);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
|
||||
public boolean allowFriendRequest(String appId,
|
||||
FeatureServiceEntity.Platform platform,
|
||||
FeatureServiceEntity.ServiceType serviceType) {
|
||||
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) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
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(
|
||||
FeatureServiceEntity.Platform.ANDROID,
|
||||
FeatureServiceEntity.Platform.IOS,
|
||||
FeatureServiceEntity.Platform.HARMONY)) {
|
||||
for (FeatureServiceEntity.ServiceType serviceType : List.of(
|
||||
FeatureServiceEntity.ServiceType.IM,
|
||||
FeatureServiceEntity.ServiceType.PUSH,
|
||||
FeatureServiceEntity.ServiceType.UPDATE)) {
|
||||
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.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@ -16,8 +22,10 @@ public class SecurityConfig {
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> {})
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers(
|
||||
"/actuator/**",
|
||||
"/api/v1/updates/app/check",
|
||||
@ -31,4 +39,25 @@ public class SecurityConfig {
|
||||
.formLogin(AbstractHttpConfigurer::disable);
|
||||
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.update.entity.AppVersionEntity;
|
||||
import com.xuqm.update.repository.AppVersionRepository;
|
||||
import com.xuqm.update.model.AppPackageInspectResult;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@ -57,8 +58,8 @@ public class AppVersionController {
|
||||
public ResponseEntity<ApiResponse<AppVersionEntity>> upload(
|
||||
@RequestParam String appId,
|
||||
@RequestParam AppVersionEntity.Platform platform,
|
||||
@RequestParam String versionName,
|
||||
@RequestParam int versionCode,
|
||||
@RequestParam(required = false) String versionName,
|
||||
@RequestParam(required = false) Integer versionCode,
|
||||
@RequestParam(required = false) String changeLog,
|
||||
@RequestParam(defaultValue = "false") boolean forceUpdate,
|
||||
@RequestParam(required = false) MultipartFile apkFile,
|
||||
@ -68,12 +69,20 @@ public class AppVersionController {
|
||||
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
|
||||
@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();
|
||||
entity.setId(UUID.randomUUID().toString());
|
||||
entity.setAppId(appId);
|
||||
entity.setPlatform(platform);
|
||||
entity.setVersionName(versionName);
|
||||
entity.setVersionCode(versionCode);
|
||||
entity.setVersionName(resolvedVersionName);
|
||||
entity.setVersionCode(resolvedVersionCode);
|
||||
entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile));
|
||||
entity.setChangeLog(changeLog);
|
||||
entity.setForceUpdate(forceUpdate);
|
||||
@ -85,10 +94,15 @@ public class AppVersionController {
|
||||
entity.setWebhookUrl(webhookUrl);
|
||||
entity.setStoreSubmitTargets(storeSubmitTargets);
|
||||
entity.setAutoPublishAfterReview(autoPublishAfterReview);
|
||||
entity.setPackageName(packageName);
|
||||
entity.setPackageName(resolvedPackageName);
|
||||
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")
|
||||
public ResponseEntity<ApiResponse<AppVersionEntity>> publish(@PathVariable String id) {
|
||||
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
|
||||
@ -122,4 +136,8 @@ public class AppVersionController {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
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.update.entity.RnBundleEntity;
|
||||
import com.xuqm.update.model.RnBundleInspectResult;
|
||||
import com.xuqm.update.repository.RnBundleRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@ -61,30 +62,43 @@ public class RnBundleController {
|
||||
@PostMapping("/upload")
|
||||
public ResponseEntity<ApiResponse<RnBundleEntity>> upload(
|
||||
@RequestParam String appId,
|
||||
@RequestParam String moduleId,
|
||||
@RequestParam RnBundleEntity.Platform platform,
|
||||
@RequestParam String version,
|
||||
@RequestParam(required = false) String moduleId,
|
||||
@RequestParam(required = false) RnBundleEntity.Platform platform,
|
||||
@RequestParam(required = false) String version,
|
||||
@RequestParam(required = false) String minCommonVersion,
|
||||
@RequestParam(required = false) String note,
|
||||
@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(
|
||||
appId, platform.name(), moduleId, bundle);
|
||||
appId, resolvedPlatform.name(), resolvedModuleId, bundle);
|
||||
|
||||
RnBundleEntity entity = new RnBundleEntity();
|
||||
entity.setId(UUID.randomUUID().toString());
|
||||
entity.setAppId(appId);
|
||||
entity.setModuleId(moduleId);
|
||||
entity.setPlatform(platform);
|
||||
entity.setVersion(version);
|
||||
entity.setModuleId(resolvedModuleId);
|
||||
entity.setPlatform(resolvedPlatform);
|
||||
entity.setVersion(resolvedVersion);
|
||||
entity.setBundleUrl(stored.bundlePath());
|
||||
entity.setMd5(stored.md5());
|
||||
entity.setMinCommonVersion(minCommonVersion);
|
||||
entity.setMinCommonVersion(resolvedMinCommonVersion);
|
||||
entity.setNote(note);
|
||||
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
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")
|
||||
public ResponseEntity<ApiResponse<List<RnBundleEntity>>> list(
|
||||
@RequestParam String appId,
|
||||
@ -137,4 +151,19 @@ public class RnBundleController {
|
||||
}
|
||||
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;
|
||||
|
||||
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.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
import java.util.Locale;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
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
|
||||
public class UpdateAssetService {
|
||||
@ -34,6 +51,41 @@ public class UpdateAssetService {
|
||||
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 {
|
||||
if (bundle == null || bundle.isEmpty()) {
|
||||
throw new IllegalArgumentException("bundle file is required");
|
||||
@ -59,5 +111,138 @@ public class UpdateAssetService {
|
||||
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) {}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户