docs(server): 添加服务器信息记录和联调接口文档

- 创建信息记录文档,包含项目管理要求、产物范围、Git仓库、制品仓库信息
- 添加服务器部署信息,包括应用服务器、MySQL/Redis服务器、Jenkins服务配置
- 记录邮件服务、DNS/HTTPS证书配置及安全备注
- 创建API联调文档,包含线上入口、ID约定、初始化管理员账号信息
- 添加统一响应格式、常见错误码、鉴权规则说明
- 提供核心接口清单,涵盖tenant-service、im-service、push-service等服务
- 补充curl示例,包含运营平台登录、IM登录、会话管理等操作示例
- 实现会话控制器,支持置顶、免打扰、标记已读、草稿等功能
- 添加全局异常处理器,统一处理业务异常和参数校验错误
- 创建IM管理控制器,提供用户管理、好友请求、黑名单等管理功能
这个提交包含在:
XuqmGroup 2026-04-29 12:33:25 +08:00
父节点 b89cd35b15
当前提交 d7f5fd02c2
共有 34 个文件被更改,包括 1614 次插入195 次删除

查看文件

@ -356,11 +356,12 @@ Frame 格式:
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| POST | `/api/push/register` | 注册设备 token | | POST | `/api/push/register` | 注册设备 token |
| POST | `/api/push/receive-push` | 开启或关闭接收推送 |
| DELETE | `/api/push/device/unregister` | 解绑设备 token | | DELETE | `/api/push/device/unregister` | 解绑设备 token |
| POST | `/api/push/send` | 向指定用户推送通知 | | POST | `/api/push/send` | 向指定用户推送通知 |
| POST | `/api/push/internal/notify` | IM 服务内部调用,批量触发离线推送 | | POST | `/api/push/internal/notify` | IM 服务内部调用,批量触发离线推送 |
其中 `/api/push/register`、`/api/push/device/unregister` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。 其中 `/api/push/register`、`/api/push/device/unregister`、`/api/push/receive-push` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。
**注册 token** **注册 token**
``` ```
@ -381,6 +382,14 @@ POST /api/push/send
&payload={"type":"IM","msgId":"uuid"} &payload={"type":"IM","msgId":"uuid"}
``` ```
**开关接收推送**
```
POST /api/push/receive-push
?appId=ak_xxx
&userId=user_001
&enabled=false
```
**内部通知** **内部通知**
```json ```json
{ {

查看文件

@ -13,6 +13,21 @@
| App 更新 | `https://dev.xuqinmin.com/api/v1/updates/` | 原生版本管理 | | App 更新 | `https://dev.xuqinmin.com/api/v1/updates/` | 原生版本管理 |
| RN 热更新 | `https://dev.xuqinmin.com/api/v1/rn/` | Bundle 热更新 | | RN 热更新 | `https://dev.xuqinmin.com/api/v1/rn/` | Bundle 热更新 |
## ID 约定
这套工程里同时存在两种“应用标识”,不要混用:
| 名称 | 含义 | 常见位置 |
|------|------|----------|
| `tenant appId` | 租户平台应用主键,`tenant-service` 的 `/api/apps/{id}`、`/api/apps/{appId}/services` 使用它 | 租户平台、服务配置页 |
| `IM appKey` | IM 业务域作用域标识,`im-service` 的管理接口和消息接口虽然参数名仍叫 `appId`,但实际传的是这个值 | IM 管理页、IM HTTP 接口 |
结论:
- `tenant-platform` 的“服务配置”只认租户 `app.id`
- `tenant-platform` 的“IM 管理”必须带 `appKey`
- `im-service` 代码里沿用旧参数名 `appId`,但这是历史命名,调用方传的是 `appKey`
## 初始化管理员账号 ## 初始化管理员账号
| 字段 | 值 | | 字段 | 值 |
@ -110,6 +125,7 @@
| 方法 | 路径 | 鉴权 | 说明 | | 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------| |------|------|------|------|
| POST | `/api/push/register` | 是 | 注册设备 token | | POST | `/api/push/register` | 是 | 注册设备 token |
| POST | `/api/push/receive-push` | 是 | 开启或关闭接收推送 |
| POST | `/api/push/send` | 是 | 发送推送通知 | | POST | `/api/push/send` | 是 | 发送推送通知 |
### file-service ### file-service
@ -215,6 +231,170 @@ curl 'https://dev.xuqinmin.com/api/im/admin/messages?appId=ak_demo_chat&userA=us
curl 'https://dev.xuqinmin.com/api/im/admin/operation-logs?appId=ak_demo_chat&page=0&size=20' curl 'https://dev.xuqinmin.com/api/im/admin/operation-logs?appId=ak_demo_chat&page=0&size=20'
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/messages/msg_001/revoke?appId=ak_demo_chat' curl -X POST 'https://dev.xuqinmin.com/api/im/admin/messages/msg_001/revoke?appId=ak_demo_chat'
curl -X DELETE 'https://dev.xuqinmin.com/api/im/admin/groups/group_001' curl -X DELETE 'https://dev.xuqinmin.com/api/im/admin/groups/group_001'
curl 'https://dev.xuqinmin.com/api/im/admin/friend-requests?appId=ak_demo_chat'
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/friend-requests/req_001/accept?appId=ak_demo_chat'
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/blacklist?appId=ak_demo_chat' \
-H 'Content-Type: application/json' \
-d '{"userId":"user_001","blockedUserId":"user_002"}'
curl 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/members?appId=ak_demo_chat'
curl 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/join-requests?appId=ak_demo_chat'
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat'
curl 'https://dev.xuqinmin.com/api/im/admin/keyword-filters?appId=ak_demo_chat'
curl 'https://dev.xuqinmin.com/api/im/admin/global-mute?appId=ak_demo_chat'
```
### IM Webhook 回调
IM 服务会在消息、好友和已读等事件发生后,以 `POST` 方式向你配置的回调地址推送事件。
请求头:
- `Content-Type: application/json`
- `X-App-Id`: 应用 `appId`
- `X-App-Timestamp`: 请求时间戳
- `X-App-Nonce`: 随机串
- `X-App-Signature`: 签名结果
签名规则:
```text
HMAC-SHA256(appSecret, appId + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body))
```
统一请求体结构:
```json
{
"callbackId": "uuid",
"callbackType": "message",
"callbackEvent": "message.sent",
"requestTime": 1714360000000,
"payload": {},
"signature": null,
"appId": "ak_demo_chat"
}
```
字段说明:
| 字段 | 说明 |
|------|------|
| `callbackId` | 回调唯一 ID,用于去重和排障 |
| `callbackType` | 回调大类,例如 `message`、`friend`、`group`、`blacklist` |
| `callbackEvent` | 具体事件,例如 `message.sent`、`friend.request.sent` |
| `requestTime` | 毫秒时间戳 |
| `payload` | 事件数据,结构随事件变化 |
| `signature` | 预留字段,当前由请求头承载 |
| `appId` | 回调所属应用 ID |
`payload` 会根据事件类型变化:
| 事件 | payload 类型 | 说明 |
|------|-------------|------|
| `message.sent` | `ImMessageEntity` | 发送消息成功后触发 |
| `message.revoked` | `ImMessageEntity` | 撤回消息后触发 |
| `message.edited` | `ImMessageEntity` | 编辑文本消息后触发 |
| `message.read` | `MessageReadCallbackPayload` | 已读回执同步后触发 |
| `friend.request.sent` | `FriendRequestCallbackPayload` | 好友申请创建后触发 |
| `friend.request.accepted` | `FriendRequestCallbackPayload` | 好友申请通过后触发 |
| `friend.request.rejected` | `FriendRequestCallbackPayload` | 好友申请拒绝后触发 |
| `group.join.request.sent` | `GroupJoinRequestCallbackPayload` | 入群申请创建后触发 |
| `group.join.request.accepted` | `GroupJoinRequestCallbackPayload` | 入群申请通过后触发 |
| `group.join.request.rejected` | `GroupJoinRequestCallbackPayload` | 入群申请拒绝后触发 |
| `blacklist.added` | `BlacklistCallbackPayload` | 黑名单记录新增后触发 |
| `blacklist.removed` | `BlacklistCallbackPayload` | 黑名单记录移除后触发 |
各 payload 字段如下:
#### `MessageReadCallbackPayload`
```json
{
"appId": "ak_demo_chat",
"readerId": "user_001",
"peerId": "user_002",
"groupId": null,
"chatType": "SINGLE",
"readAt": 1714360000000,
"messageIds": ["msg_001", "msg_002"]
}
```
#### `FriendRequestCallbackPayload`
```json
{
"appId": "ak_demo_chat",
"requestId": "req_001",
"fromUserId": "user_001",
"toUserId": "user_002",
"remark": "hi",
"status": "PENDING",
"reviewedAt": null
}
```
#### `GroupJoinRequestCallbackPayload`
```json
{
"appId": "ak_demo_chat",
"requestId": "req_001",
"groupId": "group_001",
"groupName": "技术群",
"requesterId": "user_003",
"remark": "申请加入",
"status": "PENDING",
"reviewedAt": null
}
```
#### `BlacklistCallbackPayload`
```json
{
"appId": "ak_demo_chat",
"id": "blk_001",
"userId": "user_001",
"blockedUserId": "user_002",
"action": "ADD",
"createdAt": 1714360000000
}
```
说明:
- 回调发送失败不会影响主流程,只记录日志。
- 当前版本的回调配置是按应用维度管理的,多个地址会同时接收同一事件。
- 新增的好友、入群、黑名单回调都复用同一 envelope 和签名规则,payload 字段按上表中的类型序列化。
- 接收方建议用 `callbackId` 做幂等去重,避免重复投递造成重复处理。
- 服务端当前不做重试队列,失败只会在调用端记录日志。
示例验签:
```ts
import crypto from 'crypto'
export function verifyWebhook({
appId,
timestamp,
nonce,
body,
signature,
appSecret,
}: {
appId: string
timestamp: string
nonce: string
body: string
signature: string
appSecret: string
}) {
const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex')
const payload = `${appId}\n${timestamp}\n${nonce}\n${bodyHash}`
const expected = crypto.createHmac('sha256', appSecret).update(payload, 'utf8').digest('hex')
return expected === signature
}
``` ```
### 好友申请 / 黑名单 ### 好友申请 / 黑名单

查看文件

@ -2,6 +2,7 @@ package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.model.ConversationView; import com.xuqm.im.model.ConversationView;
import com.xuqm.im.service.ImFeatureConfigClient;
import com.xuqm.im.service.ConversationStateService; import com.xuqm.im.service.ConversationStateService;
import com.xuqm.im.service.MessageService; import com.xuqm.im.service.MessageService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -22,11 +23,14 @@ public class ConversationController {
private final MessageService messageService; private final MessageService messageService;
private final ConversationStateService conversationStateService; private final ConversationStateService conversationStateService;
private final ImFeatureConfigClient featureConfigClient;
public ConversationController(MessageService messageService, public ConversationController(MessageService messageService,
ConversationStateService conversationStateService) { ConversationStateService conversationStateService,
ImFeatureConfigClient featureConfigClient) {
this.messageService = messageService; this.messageService = messageService;
this.conversationStateService = conversationStateService; this.conversationStateService = conversationStateService;
this.featureConfigClient = featureConfigClient;
} }
@GetMapping("/conversations") @GetMapping("/conversations")
@ -92,7 +96,8 @@ public class ConversationController {
@RequestParam String appId, @RequestParam String appId,
@PathVariable String targetId, @PathVariable String targetId,
@RequestParam String chatType) { @RequestParam String chatType) {
conversationStateService.hideConversation(appId, userId, targetId, chatType); boolean syncAcrossClients = featureConfigClient.multiClientConversationDeleteSync(appId);
conversationStateService.deleteConversation(appId, userId, targetId, chatType, syncAcrossClients);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
} }

查看文件

@ -4,6 +4,7 @@ import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
@ -27,6 +28,16 @@ public class GlobalExceptionHandler {
.orElse("参数错误"))); .orElse("参数错误")));
} }
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.badRequest(e.getMessage() == null ? "参数错误" : e.getMessage()));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleUnreadable(HttpMessageNotReadableException e) {
return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误"));
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) { public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)

查看文件

@ -1,9 +1,13 @@
package com.xuqm.im.controller; package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.entity.ImBlacklistEntity;
import com.xuqm.im.entity.ImAccountEntity; import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.entity.ImGlobalMuteEntity; import com.xuqm.im.entity.ImGlobalMuteEntity;
import com.xuqm.im.entity.ImGroupEntity; import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImFriendRequestEntity;
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.KeywordFilterEntity; import com.xuqm.im.entity.KeywordFilterEntity;
import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.entity.WebhookConfigEntity;
@ -11,6 +15,8 @@ import com.xuqm.im.repository.ImAccountRepository;
import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImGroupRepository;
import com.xuqm.im.repository.ImMessageRepository; import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.service.ImAccountService; import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.BlacklistService;
import com.xuqm.im.service.FriendRequestService;
import com.xuqm.im.service.ImGroupService; import com.xuqm.im.service.ImGroupService;
import com.xuqm.im.service.GlobalMuteService; import com.xuqm.im.service.GlobalMuteService;
import com.xuqm.im.service.KeywordFilterService; import com.xuqm.im.service.KeywordFilterService;
@ -35,6 +41,8 @@ public class ImAdminController {
private final ImGroupRepository groupRepository; private final ImGroupRepository groupRepository;
private final ImMessageRepository messageRepository; private final ImMessageRepository messageRepository;
private final ImAccountService accountService; private final ImAccountService accountService;
private final FriendRequestService friendRequestService;
private final BlacklistService blacklistService;
private final ImGroupService groupService; private final ImGroupService groupService;
private final MessageService messageService; private final MessageService messageService;
private final WebhookConfigService webhookConfigService; private final WebhookConfigService webhookConfigService;
@ -46,6 +54,8 @@ public class ImAdminController {
ImGroupRepository groupRepository, ImGroupRepository groupRepository,
ImMessageRepository messageRepository, ImMessageRepository messageRepository,
ImAccountService accountService, ImAccountService accountService,
FriendRequestService friendRequestService,
BlacklistService blacklistService,
ImGroupService groupService, ImGroupService groupService,
MessageService messageService, MessageService messageService,
WebhookConfigService webhookConfigService, WebhookConfigService webhookConfigService,
@ -56,6 +66,8 @@ public class ImAdminController {
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.accountService = accountService; this.accountService = accountService;
this.friendRequestService = friendRequestService;
this.blacklistService = blacklistService;
this.groupService = groupService; this.groupService = groupService;
this.messageService = messageService; this.messageService = messageService;
this.webhookConfigService = webhookConfigService; this.webhookConfigService = webhookConfigService;
@ -82,10 +94,14 @@ public class ImAdminController {
@AuthenticationPrincipal String operatorId, @AuthenticationPrincipal String operatorId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, String> body) {
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId) ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new BusinessException(404, "账号不存在"));
account.setStatus(ImAccountEntity.Status.valueOf(body.get("status").toUpperCase())); String status = body.get("status");
if (status == null || status.isBlank()) {
throw new BusinessException(400, "状态不能为空");
}
account.setStatus(ImAccountEntity.Status.valueOf(status.toUpperCase()));
ImAccountEntity saved = accountRepository.save(account); ImAccountEntity saved = accountRepository.save(account);
operationLogService.record(appId, operatorId, "UPDATE_USER_STATUS", "ACCOUNT", userId, body.get("status")); operationLogService.record(appId, operatorId, "UPDATE_USER_STATUS", "ACCOUNT", userId, status);
return ResponseEntity.ok(ApiResponse.success(saved)); return ResponseEntity.ok(ApiResponse.success(saved));
} }
@ -266,6 +282,156 @@ public class ImAdminController {
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@GetMapping("/friend-requests")
public ResponseEntity<ApiResponse<List<ImFriendRequestEntity>>> listFriendRequests(@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(friendRequestService.listByApp(appId)));
}
@PostMapping("/friend-requests")
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> createFriendRequest(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody FriendRequestCreateRequest req) {
ImFriendRequestEntity saved = friendRequestService.send(appId, req.fromUserId(), req.toUserId(), req.remark());
operationLogService.record(appId, operatorId, "CREATE_FRIEND_REQUEST", "FRIEND_REQUEST", saved.getId(), req.toUserId());
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/friend-requests/{requestId}/accept")
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> acceptFriendRequest(
@RequestParam String appId,
@PathVariable String requestId,
@AuthenticationPrincipal String operatorId) {
ImFriendRequestEntity saved = friendRequestService.adminAccept(appId, requestId);
operationLogService.record(appId, operatorId, "ACCEPT_FRIEND_REQUEST", "FRIEND_REQUEST", requestId, null);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/friend-requests/{requestId}/reject")
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> rejectFriendRequest(
@RequestParam String appId,
@PathVariable String requestId,
@AuthenticationPrincipal String operatorId) {
ImFriendRequestEntity saved = friendRequestService.adminReject(appId, requestId);
operationLogService.record(appId, operatorId, "REJECT_FRIEND_REQUEST", "FRIEND_REQUEST", requestId, null);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@GetMapping("/blacklist")
public ResponseEntity<ApiResponse<List<ImBlacklistEntity>>> listBlacklist(@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(blacklistService.listByApp(appId)));
}
@PostMapping("/blacklist")
public ResponseEntity<ApiResponse<ImBlacklistEntity>> addBlacklist(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody BlacklistRequest req) {
ImBlacklistEntity saved = blacklistService.add(appId, req.userId(), req.blockedUserId());
operationLogService.record(appId, operatorId, "ADD_BLACKLIST", "BLACKLIST", saved.getId(), req.blockedUserId());
return ResponseEntity.ok(ApiResponse.success(saved));
}
@DeleteMapping("/blacklist")
public ResponseEntity<ApiResponse<Void>> removeBlacklist(
@RequestParam String appId,
@RequestParam String userId,
@RequestParam String blockedUserId,
@AuthenticationPrincipal String operatorId) {
blacklistService.remove(appId, userId, blockedUserId);
operationLogService.record(appId, operatorId, "REMOVE_BLACKLIST", "BLACKLIST", userId, blockedUserId);
return ResponseEntity.ok(ApiResponse.ok());
}
@GetMapping("/groups/{groupId}/members")
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> listGroupMembers(
@RequestParam String appId,
@PathVariable String groupId) {
return ResponseEntity.ok(ApiResponse.success(groupService.adminListMembers(appId, groupId)));
}
@GetMapping("/groups/{groupId}/members/search")
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> searchGroupMembers(
@RequestParam String appId,
@PathVariable String groupId,
@RequestParam String keyword,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(groupService.adminSearchMembers(appId, groupId, keyword, size)));
}
@PostMapping("/groups/{groupId}/members")
public ResponseEntity<ApiResponse<ImGroupEntity>> addGroupMember(
@RequestParam String appId,
@PathVariable String groupId,
@AuthenticationPrincipal String operatorId,
@RequestBody MemberRequest req) {
ImGroupEntity saved = groupService.adminAddMember(appId, groupId, req.userId());
operationLogService.record(appId, operatorId, "ADMIN_ADD_GROUP_MEMBER", "GROUP", groupId, req.userId());
return ResponseEntity.ok(ApiResponse.success(saved));
}
@DeleteMapping("/groups/{groupId}/members/{userId}")
public ResponseEntity<ApiResponse<ImGroupEntity>> removeGroupMember(
@RequestParam String appId,
@PathVariable String groupId,
@PathVariable String userId,
@AuthenticationPrincipal String operatorId) {
ImGroupEntity saved = groupService.adminRemoveMember(appId, groupId, userId);
operationLogService.record(appId, operatorId, "ADMIN_REMOVE_GROUP_MEMBER", "GROUP", groupId, userId);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/groups/{groupId}/roles")
public ResponseEntity<ApiResponse<ImGroupEntity>> setGroupRole(
@RequestParam String appId,
@PathVariable String groupId,
@AuthenticationPrincipal String operatorId,
@RequestBody SetRoleRequest req) {
ImGroupEntity saved = groupService.adminSetRole(appId, groupId, req.userId(), req.role());
operationLogService.record(appId, operatorId, "ADMIN_SET_GROUP_ROLE", "GROUP", groupId, req.userId() + ":" + req.role());
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/groups/{groupId}/mute")
public ResponseEntity<ApiResponse<ImGroupEntity>> muteGroupMember(
@RequestParam String appId,
@PathVariable String groupId,
@AuthenticationPrincipal String operatorId,
@RequestBody MuteMemberRequest req) {
ImGroupEntity saved = groupService.adminMuteMember(appId, groupId, req.userId(), req.minutes());
operationLogService.record(appId, operatorId, "ADMIN_MUTE_GROUP_MEMBER", "GROUP", groupId, req.userId() + ":" + req.minutes());
return ResponseEntity.ok(ApiResponse.success(saved));
}
@GetMapping("/groups/{groupId}/join-requests")
public ResponseEntity<ApiResponse<List<ImGroupJoinRequestEntity>>> listGroupJoinRequests(
@RequestParam String appId,
@PathVariable String groupId) {
return ResponseEntity.ok(ApiResponse.success(groupService.adminListJoinRequests(appId, groupId)));
}
@PostMapping("/groups/{groupId}/join-requests/{requestId}/accept")
public ResponseEntity<ApiResponse<ImGroupJoinRequestEntity>> acceptGroupJoinRequest(
@RequestParam String appId,
@PathVariable String groupId,
@PathVariable String requestId,
@AuthenticationPrincipal String operatorId) {
ImGroupJoinRequestEntity saved = groupService.adminAcceptJoinRequest(appId, groupId, requestId);
operationLogService.record(appId, operatorId, "ADMIN_ACCEPT_GROUP_JOIN_REQUEST", "GROUP_JOIN_REQUEST", requestId, null);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/groups/{groupId}/join-requests/{requestId}/reject")
public ResponseEntity<ApiResponse<ImGroupJoinRequestEntity>> rejectGroupJoinRequest(
@RequestParam String appId,
@PathVariable String groupId,
@PathVariable String requestId,
@AuthenticationPrincipal String operatorId) {
ImGroupJoinRequestEntity saved = groupService.adminRejectJoinRequest(appId, groupId, requestId);
operationLogService.record(appId, operatorId, "ADMIN_REJECT_GROUP_JOIN_REQUEST", "GROUP_JOIN_REQUEST", requestId, null);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@GetMapping("/webhooks") @GetMapping("/webhooks")
public ResponseEntity<ApiResponse<List<WebhookConfigEntity>>> listWebhooks(@RequestParam String appId) { public ResponseEntity<ApiResponse<List<WebhookConfigEntity>>> listWebhooks(@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(webhookConfigService.list(appId))); return ResponseEntity.ok(ApiResponse.success(webhookConfigService.list(appId)));
@ -377,4 +543,9 @@ public class ImAdminController {
public record UpdateGroupRequest(String name, String groupType, String announcement) {} public record UpdateGroupRequest(String name, String groupType, String announcement) {}
public record WebhookConfigRequest(String url, String secret, Boolean enabled) {} public record WebhookConfigRequest(String url, String secret, Boolean enabled) {}
public record KeywordFilterRequest(String pattern, String replacement, String action, Boolean enabled) {} public record KeywordFilterRequest(String pattern, String replacement, String action, Boolean enabled) {}
public record FriendRequestCreateRequest(String fromUserId, String toUserId, String remark) {}
public record BlacklistRequest(String userId, String blockedUserId) {}
public record MemberRequest(String userId) {}
public record SetRoleRequest(String userId, String role) {}
public record MuteMemberRequest(String userId, long minutes) {}
} }

查看文件

@ -0,0 +1,10 @@
package com.xuqm.im.model;
public record BlacklistCallbackPayload(
String appId,
String id,
String userId,
String blockedUserId,
String action,
Long createdAt
) {}

查看文件

@ -0,0 +1,11 @@
package com.xuqm.im.model;
public record FriendRequestCallbackPayload(
String appId,
String requestId,
String fromUserId,
String toUserId,
String remark,
String status,
Long reviewedAt
) {}

查看文件

@ -0,0 +1,12 @@
package com.xuqm.im.model;
public record GroupJoinRequestCallbackPayload(
String appId,
String requestId,
String groupId,
String groupName,
String requesterId,
String remark,
String status,
Long reviewedAt
) {}

查看文件

@ -7,6 +7,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface ImBlacklistRepository extends JpaRepository<ImBlacklistEntity, String> { public interface ImBlacklistRepository extends JpaRepository<ImBlacklistEntity, String> {
List<ImBlacklistEntity> findByAppId(String appId);
List<ImBlacklistEntity> findByAppIdAndUserId(String appId, String userId); List<ImBlacklistEntity> findByAppIdAndUserId(String appId, String userId);
Optional<ImBlacklistEntity> findByAppIdAndUserIdAndBlockedUserId( Optional<ImBlacklistEntity> findByAppIdAndUserIdAndBlockedUserId(

查看文件

@ -7,6 +7,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface ImFriendRequestRepository extends JpaRepository<ImFriendRequestEntity, String> { public interface ImFriendRequestRepository extends JpaRepository<ImFriendRequestEntity, String> {
List<ImFriendRequestEntity> findByAppId(String appId);
Optional<ImFriendRequestEntity> findByAppIdAndFromUserIdAndToUserId( Optional<ImFriendRequestEntity> findByAppIdAndFromUserIdAndToUserId(
String appId, String fromUserId, String toUserId); String appId, String fromUserId, String toUserId);

查看文件

@ -7,6 +7,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface ImGroupJoinRequestRepository extends JpaRepository<ImGroupJoinRequestEntity, String> { public interface ImGroupJoinRequestRepository extends JpaRepository<ImGroupJoinRequestEntity, String> {
List<ImGroupJoinRequestEntity> findByAppId(String appId);
Optional<ImGroupJoinRequestEntity> findByAppIdAndGroupIdAndRequesterId( Optional<ImGroupJoinRequestEntity> findByAppIdAndGroupIdAndRequesterId(
String appId, String groupId, String requesterId); String appId, String groupId, String requesterId);

查看文件

@ -1,6 +1,7 @@
package com.xuqm.im.service; package com.xuqm.im.service;
import com.xuqm.im.entity.ImBlacklistEntity; import com.xuqm.im.entity.ImBlacklistEntity;
import com.xuqm.im.model.BlacklistCallbackPayload;
import com.xuqm.im.repository.ImBlacklistRepository; import com.xuqm.im.repository.ImBlacklistRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -13,9 +14,11 @@ import java.util.UUID;
public class BlacklistService { public class BlacklistService {
private final ImBlacklistRepository repository; private final ImBlacklistRepository repository;
private final WebhookDispatchService webhookDispatchService;
public BlacklistService(ImBlacklistRepository repository) { public BlacklistService(ImBlacklistRepository repository, WebhookDispatchService webhookDispatchService) {
this.repository = repository; this.repository = repository;
this.webhookDispatchService = webhookDispatchService;
} }
@Transactional @Transactional
@ -28,19 +31,30 @@ public class BlacklistService {
entity.setUserId(userId); entity.setUserId(userId);
entity.setBlockedUserId(blockedUserId); entity.setBlockedUserId(blockedUserId);
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
return repository.save(entity); ImBlacklistEntity saved = repository.save(entity);
dispatchWebhook(saved, "blacklist.added");
return saved;
}); });
} }
@Transactional @Transactional
public void remove(String appId, String userId, String blockedUserId) { public void remove(String appId, String userId, String blockedUserId) {
ImBlacklistEntity entity = repository.findByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId)
.orElse(null);
repository.deleteByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId); repository.deleteByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
if (entity != null) {
dispatchWebhook(entity, "blacklist.removed");
}
} }
public List<ImBlacklistEntity> list(String appId, String userId) { public List<ImBlacklistEntity> list(String appId, String userId) {
return repository.findByAppIdAndUserId(appId, userId); return repository.findByAppIdAndUserId(appId, userId);
} }
public List<ImBlacklistEntity> listByApp(String appId) {
return repository.findByAppId(appId);
}
public boolean isBlocked(String appId, String userId, String blockedUserId) { public boolean isBlocked(String appId, String userId, String blockedUserId) {
return repository.existsByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId); return repository.existsByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
} }
@ -48,4 +62,20 @@ public class BlacklistService {
public boolean isEitherBlocked(String appId, String userId, String targetUserId) { public boolean isEitherBlocked(String appId, String userId, String targetUserId) {
return isBlocked(appId, userId, targetUserId) || isBlocked(appId, targetUserId, userId); return isBlocked(appId, userId, targetUserId) || isBlocked(appId, targetUserId, userId);
} }
private void dispatchWebhook(ImBlacklistEntity entity, String callbackEvent) {
webhookDispatchService.dispatch(
entity.getAppId(),
"blacklist",
callbackEvent,
new BlacklistCallbackPayload(
entity.getAppId(),
entity.getId(),
entity.getUserId(),
entity.getBlockedUserId(),
callbackEvent,
entity.getCreatedAt() == null ? null : entity.getCreatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
)
);
}
} }

查看文件

@ -66,6 +66,19 @@ public class ConversationStateService {
repository.deleteByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType); repository.deleteByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType);
} }
@Transactional
public void deleteConversation(String appId,
String userId,
String targetId,
String chatType,
boolean syncAcrossClients) {
if (syncAcrossClients) {
deleteConversationState(appId, userId, targetId, chatType);
return;
}
hideConversation(appId, userId, targetId, chatType);
}
@Transactional @Transactional
public void clearHiddenForUsers(String appId, String targetId, String chatType, Collection<String> userIds) { public void clearHiddenForUsers(String appId, String targetId, String chatType, Collection<String> userIds) {
for (String userId : userIds) { for (String userId : userIds) {

查看文件

@ -6,6 +6,7 @@ import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.cluster.ImClusterPublisher; import com.xuqm.im.cluster.ImClusterPublisher;
import com.xuqm.im.entity.ImFriendRequestEntity; import com.xuqm.im.entity.ImFriendRequestEntity;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.FriendRequestCallbackPayload;
import com.xuqm.im.repository.ImFriendRequestRepository; import com.xuqm.im.repository.ImFriendRequestRepository;
import com.xuqm.im.repository.ImFriendRepository; import com.xuqm.im.repository.ImFriendRepository;
import com.xuqm.im.repository.ImMessageRepository; import com.xuqm.im.repository.ImMessageRepository;
@ -23,24 +24,36 @@ public class FriendRequestService {
private final ImFriendRepository friendRepository; private final ImFriendRepository friendRepository;
private final ImMessageRepository messageRepository; private final ImMessageRepository messageRepository;
private final ImClusterPublisher clusterPublisher; private final ImClusterPublisher clusterPublisher;
private final ImFeatureConfigClient featureConfigClient;
private final WebhookDispatchService webhookDispatchService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public FriendRequestService(ImFriendRequestRepository requestRepository, public FriendRequestService(ImFriendRequestRepository requestRepository,
ImFriendRepository friendRepository, ImFriendRepository friendRepository,
ImMessageRepository messageRepository, ImMessageRepository messageRepository,
ImClusterPublisher clusterPublisher, ImClusterPublisher clusterPublisher,
ImFeatureConfigClient featureConfigClient,
WebhookDispatchService webhookDispatchService,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.requestRepository = requestRepository; this.requestRepository = requestRepository;
this.friendRepository = friendRepository; this.friendRepository = friendRepository;
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.clusterPublisher = clusterPublisher; this.clusterPublisher = clusterPublisher;
this.featureConfigClient = featureConfigClient;
this.webhookDispatchService = webhookDispatchService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@Transactional @Transactional
public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) { public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) {
String mode = featureConfigClient.friendRequestMode(appId);
if ("DISALLOW".equals(mode)) {
throw new BusinessException(403, "当前应用未开放好友申请");
}
final boolean[] created = {false};
ImFriendRequestEntity saved = requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId) ImFriendRequestEntity saved = requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId)
.orElseGet(() -> { .orElseGet(() -> {
created[0] = true;
ImFriendRequestEntity entity = new ImFriendRequestEntity(); ImFriendRequestEntity entity = new ImFriendRequestEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId); entity.setAppId(appId);
@ -51,9 +64,18 @@ public class FriendRequestService {
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
return requestRepository.save(entity); return requestRepository.save(entity);
}); });
if ("DIRECT_ACCEPT".equals(mode)) {
if (ImFriendRequestEntity.Status.ACCEPTED.name().equals(saved.getStatus())) {
return saved;
}
return acceptRequest(saved);
}
if (!ImFriendRequestEntity.Status.PENDING.name().equals(saved.getStatus())) { if (!ImFriendRequestEntity.Status.PENDING.name().equals(saved.getStatus())) {
return saved; return saved;
} }
if (created[0]) {
dispatchWebhook(saved, "friend.request.sent");
}
publishNotification( publishNotification(
saved, saved,
saved.getFromUserId(), saved.getFromUserId(),
@ -77,6 +99,7 @@ public class FriendRequestService {
friendRepository friendRepository
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId()) .findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId())); .orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId()));
dispatchWebhook(request, "friend.request.accepted");
publishNotification( publishNotification(
request, request,
request.getToUserId(), request.getToUserId(),
@ -94,6 +117,7 @@ public class FriendRequestService {
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name()); request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now()); request.setReviewedAt(LocalDateTime.now());
ImFriendRequestEntity saved = requestRepository.save(request); ImFriendRequestEntity saved = requestRepository.save(request);
dispatchWebhook(saved, "friend.request.rejected");
publishNotification( publishNotification(
saved, saved,
saved.getToUserId(), saved.getToUserId(),
@ -133,6 +157,22 @@ public class FriendRequestService {
return requestRepository.findByAppIdAndFromUserId(appId, userId); return requestRepository.findByAppIdAndFromUserId(appId, userId);
} }
public List<ImFriendRequestEntity> listByApp(String appId) {
return requestRepository.findByAppId(appId);
}
@Transactional
public ImFriendRequestEntity adminAccept(String appId, String requestId) {
ImFriendRequestEntity request = getRequest(appId, requestId);
return acceptRequest(request);
}
@Transactional
public ImFriendRequestEntity adminReject(String appId, String requestId) {
ImFriendRequestEntity request = getRequest(appId, requestId);
return rejectRequest(request);
}
private ImFriendRequestEntity getRequest(String appId, String requestId, String operatorId) { private ImFriendRequestEntity getRequest(String appId, String requestId, String operatorId) {
ImFriendRequestEntity request = requestRepository.findById(requestId) ImFriendRequestEntity request = requestRepository.findById(requestId)
.orElseThrow(() -> new BusinessException(404, "好友申请不存在")); .orElseThrow(() -> new BusinessException(404, "好友申请不存在"));
@ -142,17 +182,31 @@ public class FriendRequestService {
return request; return request;
} }
private ImFriendRequestEntity getRequest(String appId, String requestId) {
ImFriendRequestEntity request = requestRepository.findById(requestId)
.orElseThrow(() -> new BusinessException(404, "好友申请不存在"));
if (!request.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作");
}
return request;
}
private ImFriendRequestEntity acceptInternal(String appId, String requestId, String operatorId) { private ImFriendRequestEntity acceptInternal(String appId, String requestId, String operatorId) {
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
return acceptRequest(request);
}
private ImFriendRequestEntity acceptRequest(ImFriendRequestEntity request) {
request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name()); request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name());
request.setReviewedAt(LocalDateTime.now()); request.setReviewedAt(LocalDateTime.now());
ImFriendRequestEntity saved = requestRepository.save(request); ImFriendRequestEntity saved = requestRepository.save(request);
friendRepository friendRepository
.findByAppIdAndUserIdAndFriendId(appId, request.getFromUserId(), request.getToUserId()) .findByAppIdAndUserIdAndFriendId(request.getAppId(), request.getFromUserId(), request.getToUserId())
.orElseGet(() -> friendEntity(appId, request.getFromUserId(), request.getToUserId())); .orElseGet(() -> friendEntity(request.getAppId(), request.getFromUserId(), request.getToUserId()));
friendRepository friendRepository
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId()) .findByAppIdAndUserIdAndFriendId(request.getAppId(), request.getToUserId(), request.getFromUserId())
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId())); .orElseGet(() -> friendEntity(request.getAppId(), request.getToUserId(), request.getFromUserId()));
dispatchWebhook(saved, "friend.request.accepted");
publishNotification( publishNotification(
request, request,
request.getToUserId(), request.getToUserId(),
@ -166,18 +220,7 @@ public class FriendRequestService {
private ImFriendRequestEntity rejectInternal(String appId, String requestId, String operatorId) { private ImFriendRequestEntity rejectInternal(String appId, String requestId, String operatorId) {
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name()); return rejectRequest(request);
request.setReviewedAt(LocalDateTime.now());
ImFriendRequestEntity saved = requestRepository.save(request);
publishNotification(
saved,
saved.getToUserId(),
saved.getFromUserId(),
"FRIEND_REQUEST_STATUS",
"好友申请已拒绝",
buildDescription("好友申请已拒绝", saved.getRemark())
);
return saved;
} }
private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) { private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) {
@ -240,4 +283,37 @@ public class FriendRequestService {
} }
return prefix + "" + remark; return prefix + "" + remark;
} }
private ImFriendRequestEntity rejectRequest(ImFriendRequestEntity request) {
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now());
ImFriendRequestEntity saved = requestRepository.save(request);
dispatchWebhook(saved, "friend.request.rejected");
publishNotification(
saved,
saved.getToUserId(),
saved.getFromUserId(),
"FRIEND_REQUEST_STATUS",
"好友申请已拒绝",
buildDescription("好友申请已拒绝", saved.getRemark())
);
return saved;
}
private void dispatchWebhook(ImFriendRequestEntity request, String callbackEvent) {
webhookDispatchService.dispatch(
request.getAppId(),
"friend_request",
callbackEvent,
new FriendRequestCallbackPayload(
request.getAppId(),
request.getId(),
request.getFromUserId(),
request.getToUserId(),
request.getRemark(),
request.getStatus(),
request.getReviewedAt() == null ? null : request.getReviewedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
)
);
}
} }

查看文件

@ -29,6 +29,48 @@ public class ImFeatureConfigClient {
} }
public boolean allowStrangerMessage(String appId) { public boolean allowStrangerMessage(String appId) {
return readConfig(appId).path("allowStrangerMessage").asBoolean(false);
}
public boolean allowFriendRequest(String appId) {
return readConfig(appId).path("allowFriendRequest").asBoolean(true);
}
public String friendRequestMode(String appId) {
JsonNode node = readConfig(appId);
String mode = node.path("friendRequestMode").asText("");
String normalized = mode == null ? "" : mode.trim().toUpperCase();
return switch (normalized) {
case "DIRECT_ACCEPT", "DISALLOW", "REQUIRE_CONFIRM" -> normalized;
default -> node.path("allowFriendRequest").asBoolean(true) ? "REQUIRE_CONFIRM" : "DISALLOW";
};
}
public boolean allowGroupJoinRequest(String appId) {
return readConfig(appId).path("allowGroupJoinRequest").asBoolean(true);
}
public boolean blacklistSendSuccess(String appId) {
return readConfig(appId).path("blacklistSendSuccess").asBoolean(true);
}
public int messageRecallMinutes(String appId) {
return Math.max(readConfig(appId).path("messageRecallMinutes").asInt(2), 0);
}
public int historyRetentionDays(String appId) {
return Math.max(readConfig(appId).path("historyRetentionDays").asInt(7), 1);
}
public int conversationPullLimit(String appId) {
return Math.min(Math.max(readConfig(appId).path("conversationPullLimit").asInt(100), 1), 500);
}
public boolean multiClientConversationDeleteSync(String appId) {
return readConfig(appId).path("multiClientConversationDeleteSync").asBoolean(false);
}
private JsonNode readConfig(String appId) {
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl) String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
.path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}") .path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}")
.buildAndExpand(appId, "ANDROID", "IM") .buildAndExpand(appId, "ANDROID", "IM")
@ -45,14 +87,14 @@ public class ImFeatureConfigClient {
JsonNode body = response.getBody(); JsonNode body = response.getBody();
if (response.getStatusCode().is2xxSuccessful() && body != null && body.path("code").asInt() == 200) { if (response.getStatusCode().is2xxSuccessful() && body != null && body.path("code").asInt() == 200) {
String config = body.path("data").path("config").asText(""); String config = body.path("data").path("config").asText("");
if (config.isBlank()) { if (!config.isBlank()) {
return false; JsonNode node = OBJECT_MAPPER.readTree(config);
return node == null ? OBJECT_MAPPER.createObjectNode() : node;
} }
return OBJECT_MAPPER.readTree(config).path("allowStrangerMessage").asBoolean(false);
} }
} catch (Exception e) { } catch (Exception e) {
return false; // Fail closed: if config cannot be read, keep the feature disabled.
} }
return false; return OBJECT_MAPPER.createObjectNode();
} }
} }

查看文件

@ -10,6 +10,7 @@ import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.ImGroupMuteEntity; import com.xuqm.im.entity.ImGroupMuteEntity;
import com.xuqm.im.entity.ImAccountEntity; import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.model.GroupJoinRequestCallbackPayload;
import com.xuqm.im.repository.ImGroupJoinRequestRepository; import com.xuqm.im.repository.ImGroupJoinRequestRepository;
import com.xuqm.im.repository.ImAccountRepository; import com.xuqm.im.repository.ImAccountRepository;
import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImGroupRepository;
@ -35,6 +36,8 @@ public class ImGroupService {
private final ImMessageRepository messageRepository; private final ImMessageRepository messageRepository;
private final ImAccountRepository accountRepository; private final ImAccountRepository accountRepository;
private final ImClusterPublisher clusterPublisher; private final ImClusterPublisher clusterPublisher;
private final ImFeatureConfigClient featureConfigClient;
private final WebhookDispatchService webhookDispatchService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ImGroupService(ImGroupRepository groupRepository, public ImGroupService(ImGroupRepository groupRepository,
@ -43,6 +46,8 @@ public class ImGroupService {
ImMessageRepository messageRepository, ImMessageRepository messageRepository,
ImAccountRepository accountRepository, ImAccountRepository accountRepository,
ImClusterPublisher clusterPublisher, ImClusterPublisher clusterPublisher,
ImFeatureConfigClient featureConfigClient,
WebhookDispatchService webhookDispatchService,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.muteRepository = muteRepository; this.muteRepository = muteRepository;
@ -50,6 +55,8 @@ public class ImGroupService {
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.clusterPublisher = clusterPublisher; this.clusterPublisher = clusterPublisher;
this.featureConfigClient = featureConfigClient;
this.webhookDispatchService = webhookDispatchService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@ -283,6 +290,9 @@ public class ImGroupService {
@Transactional @Transactional
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) { public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
if (!featureConfigClient.allowGroupJoinRequest(appId)) {
throw new BusinessException(403, "当前应用未开放群加入申请");
}
ImGroupEntity group = get(groupId); ImGroupEntity group = get(groupId);
if (!group.getAppId().equals(appId)) { if (!group.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作"); throw new BusinessException(403, "无权操作");
@ -313,6 +323,7 @@ public class ImGroupService {
buildDescription("入群申请", remark), buildDescription("入群申请", remark),
saved saved
); );
dispatchJoinRequestWebhook(group, saved, "group.join.request.sent");
return saved; return saved;
}); });
} }
@ -332,6 +343,7 @@ public class ImGroupService {
request.setReviewedAt(LocalDateTime.now()); request.setReviewedAt(LocalDateTime.now());
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
addMemberInternal(group, request.getRequesterId()); addMemberInternal(group, request.getRequesterId());
dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted");
publishJoinRequestNotification( publishJoinRequestNotification(
group, group,
operatorId, operatorId,
@ -352,6 +364,7 @@ public class ImGroupService {
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now()); request.setReviewedAt(LocalDateTime.now());
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected");
publishJoinRequestNotification( publishJoinRequestNotification(
group, group,
operatorId, operatorId,
@ -401,6 +414,169 @@ public class ImGroupService {
} }
} }
private void ensureAppMatches(ImGroupEntity group, String appId) {
if (!group.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作");
}
}
public List<ImAccountEntity> adminListMembers(String appId, String groupId) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
return resolveMembers(appId, memberIds(group));
}
public List<ImAccountEntity> adminSearchMembers(String appId, String groupId, String keyword, int size) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
List<String> ids = memberIds(group);
if (keyword == null || keyword.isBlank()) {
return resolveMembers(appId, ids).stream().limit(Math.max(size, 1)).toList();
}
LinkedHashSet<String> memberIdSet = new LinkedHashSet<>(ids);
return accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1)))
.stream()
.filter(account -> memberIdSet.contains(account.getUserId()))
.toList();
}
@Transactional
public ImGroupEntity adminAddMember(String appId, String groupId, String userId) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
addMemberInternal(group, userId);
return group;
}
@Transactional
public ImGroupEntity adminAddMembers(String appId, String groupId, List<String> userIds) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
boolean changed = false;
for (String userId : userIds == null ? List.<String>of() : userIds) {
if (userId == null || userId.isBlank()) {
continue;
}
if (!members.contains(userId)) {
members.add(userId);
changed = true;
}
}
if (changed) {
group.setMemberIds(toJson(members));
return groupRepository.save(group);
}
return group;
}
@Transactional
public ImGroupEntity adminRemoveMember(String appId, String groupId, String userId) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
if (members.remove(userId)) {
group.setMemberIds(toJson(members));
return groupRepository.save(group);
}
return group;
}
@Transactional
public ImGroupEntity adminRemoveMembers(String appId, String groupId, List<String> userIds) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
boolean changed = false;
for (String userId : userIds == null ? List.<String>of() : userIds) {
if (userId == null || userId.isBlank()) {
continue;
}
changed |= members.remove(userId);
}
if (changed) {
group.setMemberIds(toJson(members));
return groupRepository.save(group);
}
return group;
}
@Transactional
public ImGroupEntity adminSetRole(String appId, String groupId, String userId, String role) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
List<String> admins = new ArrayList<>(fromJson(group.getAdminIds()));
if ("ADMIN".equalsIgnoreCase(role)) {
if (!admins.contains(userId)) {
admins.add(userId);
}
} else {
admins.remove(userId);
if (userId.equals(group.getCreatorId())) {
throw new BusinessException(403, "群主不能降级");
}
}
group.setAdminIds(toJson(admins));
return groupRepository.save(group);
}
@Transactional
public ImGroupEntity adminMuteMember(String appId, String groupId, String userId, long minutes) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
ImGroupMuteEntity mute = muteRepository
.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now())
.orElseGet(() -> {
ImGroupMuteEntity entity = new ImGroupMuteEntity();
entity.setId(UUID.randomUUID().toString());
entity.setGroupId(groupId);
entity.setUserId(userId);
entity.setCreatedAt(LocalDateTime.now());
return entity;
});
mute.setMutedUntil(LocalDateTime.now().plusMinutes(Math.max(minutes, 0)));
mute.setUpdatedAt(LocalDateTime.now());
muteRepository.save(mute);
return group;
}
public List<ImGroupJoinRequestEntity> adminListJoinRequests(String appId, String groupId) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
return joinRequestRepository.findByAppIdAndGroupId(appId, groupId);
}
@Transactional
public ImGroupJoinRequestEntity adminAcceptJoinRequest(String appId, String groupId, String requestId) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
if (!group.getId().equals(request.getGroupId())) {
throw new BusinessException(400, "加群申请不属于当前群");
}
request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name());
request.setReviewedAt(LocalDateTime.now());
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
addMemberInternal(group, request.getRequesterId());
dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted");
return saved;
}
@Transactional
public ImGroupJoinRequestEntity adminRejectJoinRequest(String appId, String groupId, String requestId) {
ImGroupEntity group = get(groupId);
ensureAppMatches(group, appId);
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
if (!group.getId().equals(request.getGroupId())) {
throw new BusinessException(400, "加群申请不属于当前群");
}
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now());
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected");
return saved;
}
private List<String> uniqueRecipients(ImGroupEntity group) { private List<String> uniqueRecipients(ImGroupEntity group) {
LinkedHashSet<String> recipients = new LinkedHashSet<>(fromJson(group.getAdminIds())); LinkedHashSet<String> recipients = new LinkedHashSet<>(fromJson(group.getAdminIds()));
recipients.add(group.getCreatorId()); recipients.add(group.getCreatorId());
@ -433,6 +609,24 @@ public class ImGroupService {
} }
} }
private void dispatchJoinRequestWebhook(ImGroupEntity group, ImGroupJoinRequestEntity request, String callbackEvent) {
webhookDispatchService.dispatch(
group.getAppId(),
"group_join_request",
callbackEvent,
new GroupJoinRequestCallbackPayload(
group.getAppId(),
request.getId(),
request.getGroupId(),
group.getName(),
request.getRequesterId(),
request.getRemark(),
request.getStatus(),
request.getReviewedAt() == null ? null : request.getReviewedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
)
);
}
private String buildNotificationContent( private String buildNotificationContent(
String type, String type,
String title, String title,
@ -490,6 +684,7 @@ public class ImGroupService {
request.setReviewedAt(LocalDateTime.now()); request.setReviewedAt(LocalDateTime.now());
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
addMemberInternal(group, request.getRequesterId()); addMemberInternal(group, request.getRequesterId());
dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted");
publishJoinRequestNotification( publishJoinRequestNotification(
group, group,
operatorId, operatorId,
@ -511,6 +706,7 @@ public class ImGroupService {
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now()); request.setReviewedAt(LocalDateTime.now());
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected");
publishJoinRequestNotification( publishJoinRequestNotification(
group, group,
operatorId, operatorId,

查看文件

@ -5,38 +5,24 @@ import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.cluster.ImClusterPublisher; import com.xuqm.im.cluster.ImClusterPublisher;
import com.xuqm.im.entity.ImGroupEntity; import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.model.ConversationView; import com.xuqm.im.model.ConversationView;
import com.xuqm.im.model.EditMessageRequest; import com.xuqm.im.model.EditMessageRequest;
import com.xuqm.im.model.MessageReadCallbackPayload; import com.xuqm.im.model.MessageReadCallbackPayload;
import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.model.WebhookCallbackEnvelope;
import com.xuqm.im.repository.ImFriendRepository; import com.xuqm.im.repository.ImFriendRepository;
import com.xuqm.im.repository.WebhookConfigRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import com.xuqm.im.repository.ImMessageRepository; import com.xuqm.im.repository.ImMessageRepository;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
@Service @Service
public class MessageService { public class MessageService {
@ -44,7 +30,6 @@ public class MessageService {
private static final Logger log = LoggerFactory.getLogger(MessageService.class); private static final Logger log = LoggerFactory.getLogger(MessageService.class);
private final ImMessageRepository messageRepository; private final ImMessageRepository messageRepository;
private final WebhookConfigRepository webhookRepository;
private final KeywordFilterService keywordFilterService; private final KeywordFilterService keywordFilterService;
private final GlobalMuteService globalMuteService; private final GlobalMuteService globalMuteService;
private final ImClusterPublisher clusterPublisher; private final ImClusterPublisher clusterPublisher;
@ -54,14 +39,10 @@ public class MessageService {
private final ImPushBridgeClient pushBridgeClient; private final ImPushBridgeClient pushBridgeClient;
private final ImFeatureConfigClient featureConfigClient; private final ImFeatureConfigClient featureConfigClient;
private final ImFriendRepository friendRepository; private final ImFriendRepository friendRepository;
private final ImAppSecretClient appSecretClient; private final WebhookDispatchService webhookDispatchService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@Value("${im.webhook-timeout-ms:3000}")
private int webhookTimeoutMs;
public MessageService(ImMessageRepository messageRepository, public MessageService(ImMessageRepository messageRepository,
WebhookConfigRepository webhookRepository,
KeywordFilterService keywordFilterService, KeywordFilterService keywordFilterService,
GlobalMuteService globalMuteService, GlobalMuteService globalMuteService,
ImClusterPublisher clusterPublisher, ImClusterPublisher clusterPublisher,
@ -71,10 +52,9 @@ public class MessageService {
ImPushBridgeClient pushBridgeClient, ImPushBridgeClient pushBridgeClient,
ImFeatureConfigClient featureConfigClient, ImFeatureConfigClient featureConfigClient,
ImFriendRepository friendRepository, ImFriendRepository friendRepository,
ImAppSecretClient appSecretClient, WebhookDispatchService webhookDispatchService,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.webhookRepository = webhookRepository;
this.keywordFilterService = keywordFilterService; this.keywordFilterService = keywordFilterService;
this.globalMuteService = globalMuteService; this.globalMuteService = globalMuteService;
this.clusterPublisher = clusterPublisher; this.clusterPublisher = clusterPublisher;
@ -84,7 +64,7 @@ public class MessageService {
this.pushBridgeClient = pushBridgeClient; this.pushBridgeClient = pushBridgeClient;
this.featureConfigClient = featureConfigClient; this.featureConfigClient = featureConfigClient;
this.friendRepository = friendRepository; this.friendRepository = friendRepository;
this.appSecretClient = appSecretClient; this.webhookDispatchService = webhookDispatchService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@ -100,6 +80,7 @@ public class MessageService {
} }
} }
ImGroupEntity group = null; ImGroupEntity group = null;
boolean receiverBlocksSender = false;
if (req.chatType() == ImMessageEntity.ChatType.GROUP) { if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
group = groupService.get(req.toId()); group = groupService.get(req.toId());
if (!groupService.memberIds(group).contains(fromUserId)) { if (!groupService.memberIds(group).contains(fromUserId)) {
@ -108,11 +89,19 @@ public class MessageService {
if (groupService.isMemberMuted(req.toId(), fromUserId)) { if (groupService.isMemberMuted(req.toId(), fromUserId)) {
throw new BusinessException(403, "当前用户已被禁言"); throw new BusinessException(403, "当前用户已被禁言");
} }
} else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) {
throw new BusinessException(403, "已被拉黑,无法发送消息");
} else if (!isFriend(appId, fromUserId, req.toId()) } else if (!isFriend(appId, fromUserId, req.toId())
&& !featureConfigClient.allowStrangerMessage(appId)) { && !featureConfigClient.allowStrangerMessage(appId)) {
throw new BusinessException(403, "仅允许好友之间发送消息"); throw new BusinessException(403, "仅允许好友之间发送消息");
} else {
boolean senderBlocksReceiver = blacklistService.isBlocked(appId, fromUserId, req.toId());
receiverBlocksSender = blacklistService.isBlocked(appId, req.toId(), fromUserId);
boolean blacklistSendSuccess = featureConfigClient.blacklistSendSuccess(appId);
if (senderBlocksReceiver && receiverBlocksSender) {
throw new BusinessException(403, "已被拉黑,无法发送消息");
}
if (receiverBlocksSender && !blacklistSendSuccess) {
throw new BusinessException(403, "已被拉黑,无法发送消息");
}
} }
ImMessageEntity message = new ImMessageEntity(); ImMessageEntity message = new ImMessageEntity();
@ -133,25 +122,30 @@ public class MessageService {
saved.setGroupReadCount(groupReadCount(appId, req.toId(), saved.getCreatedAt(), saved.getFromUserId())); saved.setGroupReadCount(groupReadCount(appId, req.toId(), saved.getCreatedAt(), saved.getFromUserId()));
} }
String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE
? "/user/" + req.toId() + "/queue/messages"
: "/topic/group/" + req.toId();
log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}",
appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination);
clusterPublisher.publish(destination, saved);
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) { if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) {
log.debug("echo message back to sender appId={} from={} to={}", log.debug("echo message back to sender appId={} from={} to={}",
appId, fromUserId, req.toId()); appId, fromUserId, req.toId());
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved); clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved);
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId())); if (!receiverBlocksSender) {
pushBridgeClient.notifyUsers( log.debug("deliver message to receiver appId={} from={} to={}",
appId, appId, fromUserId, req.toId());
List.of(req.toId()), clusterPublisher.publish("/user/" + req.toId() + "/queue/messages", saved);
"新消息", conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId()));
saved.getContent(), pushBridgeClient.notifyUsers(
buildPushPayload(saved) appId,
); List.of(req.toId()),
"新消息",
saved.getContent(),
buildPushPayload(saved)
);
} else {
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId));
}
} else if (req.chatType() == ImMessageEntity.ChatType.GROUP) { } else if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
String destination = "/topic/group/" + req.toId();
log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}",
appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination);
clusterPublisher.publish(destination, saved);
List<String> memberIds = groupService.memberIds(group); List<String> memberIds = groupService.memberIds(group);
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds);
pushBridgeClient.notifyUsers( pushBridgeClient.notifyUsers(
@ -163,6 +157,13 @@ public class MessageService {
saved.getContent(), saved.getContent(),
buildPushPayload(saved) buildPushPayload(saved)
); );
} else {
String destination = "/user/" + req.toId() + "/queue/messages";
log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}",
appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination);
if (!receiverBlocksSender) {
clusterPublisher.publish(destination, saved);
}
} }
dispatchWebhooks(appId, "message.sent", saved); dispatchWebhooks(appId, "message.sent", saved);
@ -183,6 +184,10 @@ public class MessageService {
if (!message.getFromUserId().equals(requestUserId)) { if (!message.getFromUserId().equals(requestUserId)) {
throw new BusinessException(403, "只能撤回自己发送的消息"); throw new BusinessException(403, "只能撤回自己发送的消息");
} }
int recallMinutes = featureConfigClient.messageRecallMinutes(appId);
if (recallMinutes > 0 && message.getCreatedAt().plusMinutes(recallMinutes).isBefore(LocalDateTime.now())) {
throw new BusinessException(403, "已超过可撤回时长");
}
message.setStatus(ImMessageEntity.MsgStatus.REVOKED); message.setStatus(ImMessageEntity.MsgStatus.REVOKED);
message.setMsgType(ImMessageEntity.MsgType.REVOKED); message.setMsgType(ImMessageEntity.MsgType.REVOKED);
ImMessageEntity saved = messageRepository.save(message); ImMessageEntity saved = messageRepository.save(message);
@ -286,13 +291,14 @@ public class MessageService {
String userId, String userId,
String toId, String toId,
ImMessageEntity.MsgType msgType, ImMessageEntity.MsgType msgType,
String keyword, String keyword,
LocalDateTime startTime, LocalDateTime startTime,
LocalDateTime endTime, LocalDateTime endTime,
int page, int page,
int size) { int size) {
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
return messageRepository.findSingleConversationFiltered( return messageRepository.findSingleConversationFiltered(
appId, userId, toId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); appId, userId, toId, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size));
} }
public Page<ImMessageEntity> groupHistory( public Page<ImMessageEntity> groupHistory(
@ -302,15 +308,16 @@ public class MessageService {
ImMessageEntity.MsgType msgType, ImMessageEntity.MsgType msgType,
String keyword, String keyword,
LocalDateTime startTime, LocalDateTime startTime,
LocalDateTime endTime, LocalDateTime endTime,
int page, int page,
int size) { int size) {
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
ImGroupEntity group = groupService.get(groupId); ImGroupEntity group = groupService.get(groupId);
if (!groupService.memberIds(group).contains(userId)) { if (!groupService.memberIds(group).contains(userId)) {
throw new BusinessException(403, "不在群内"); throw new BusinessException(403, "不在群内");
} }
Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered( Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered(
appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); appId, groupId, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size));
pageResult.forEach(message -> message.setGroupReadCount( pageResult.forEach(message -> message.setGroupReadCount(
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()))); groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
return pageResult; return pageResult;
@ -383,11 +390,12 @@ public class MessageService {
ImMessageEntity.MsgType msgType, ImMessageEntity.MsgType msgType,
String keyword, String keyword,
LocalDateTime startTime, LocalDateTime startTime,
LocalDateTime endTime, LocalDateTime endTime,
int page, int page,
int size) { int size) {
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
return messageRepository.findSingleConversationFiltered( return messageRepository.findSingleConversationFiltered(
appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); appId, userA, userB, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size));
} }
public Page<ImMessageEntity> adminGroupHistory( public Page<ImMessageEntity> adminGroupHistory(
@ -396,29 +404,46 @@ public class MessageService {
ImMessageEntity.MsgType msgType, ImMessageEntity.MsgType msgType,
String keyword, String keyword,
LocalDateTime startTime, LocalDateTime startTime,
LocalDateTime endTime, LocalDateTime endTime,
int page, int page,
int size) { int size) {
LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime);
Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered( Page<ImMessageEntity> pageResult = messageRepository.findGroupHistoryFiltered(
appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); appId, groupId, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size));
pageResult.forEach(message -> message.setGroupReadCount( pageResult.forEach(message -> message.setGroupReadCount(
groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()))); groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())));
return pageResult; return pageResult;
} }
public List<ImMessageRepository.ConversationSummary> conversations(String appId, String userId, int size) { public List<ImMessageRepository.ConversationSummary> conversations(String appId, String userId, int size) {
return messageRepository.findConversations(appId, userId, size); return messageRepository.findConversations(appId, userId, normalizeConversationSize(appId, size));
} }
public List<ConversationView> conversationViews(String appId, String userId, int size) { public List<ConversationView> conversationViews(String appId, String userId, int size) {
int fetchSize = Math.max(size * 3, size); int cappedSize = normalizeConversationSize(appId, size);
int fetchSize = Math.max(cappedSize * 3, cappedSize);
return messageRepository.findConversations(appId, userId, fetchSize).stream() return messageRepository.findConversations(appId, userId, fetchSize).stream()
.map(summary -> toConversationView(appId, userId, summary)) .map(summary -> toConversationView(appId, userId, summary))
.filter(Objects::nonNull) .filter(Objects::nonNull)
.limit(size) .limit(cappedSize)
.toList(); .toList();
} }
private LocalDateTime applyHistoryRetention(String appId, LocalDateTime requestedStart) {
int retentionDays = featureConfigClient.historyRetentionDays(appId);
LocalDateTime retentionStart = LocalDateTime.now().minusDays(retentionDays);
if (requestedStart == null || requestedStart.isBefore(retentionStart)) {
return retentionStart;
}
return requestedStart;
}
private int normalizeConversationSize(String appId, int requestedSize) {
int limit = featureConfigClient.conversationPullLimit(appId);
int safeRequested = Math.max(requestedSize, 1);
return Math.min(safeRequested, limit);
}
private ConversationView toConversationView( private ConversationView toConversationView(
String appId, String appId,
String userId, String userId,
@ -525,73 +550,10 @@ public class MessageService {
} }
protected void dispatchWebhooks(String appId, String callbackEvent, ImMessageEntity message) { protected void dispatchWebhooks(String appId, String callbackEvent, ImMessageEntity message) {
dispatchWebhooks(appId, callbackEvent, (Object) message); webhookDispatchService.dispatch(appId, "message", callbackEvent, message);
} }
protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) { protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) {
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); webhookDispatchService.dispatch(appId, "message", callbackEvent, payload);
if (webhooks.isEmpty()) return;
try {
String appSecret = appSecretClient.getAppSecret(appId);
long requestTime = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replace("-", "");
String callbackId = UUID.randomUUID().toString();
WebhookCallbackEnvelope envelope = new WebhookCallbackEnvelope(
callbackId,
"message",
callbackEvent,
requestTime,
objectMapper.valueToTree(payload),
null,
appId
);
String body = objectMapper.writeValueAsString(envelope);
String signature = signWebhook(appId, appSecret, requestTime, nonce, body);
HttpClient client = HttpClient.newHttpClient();
for (WebhookConfigEntity webhook : webhooks) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webhook.getUrl()))
.header("Content-Type", "application/json")
.header("X-App-Id", appId)
.header("X-App-Timestamp", String.valueOf(requestTime))
.header("X-App-Nonce", nonce)
.header("X-App-Signature", signature)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
log.warn("dispatch webhook failed appId={} url={} event={} reason={}",
appId, webhook.getUrl(), callbackEvent, e.getMessage());
}
}
} catch (Exception e) {
log.warn("prepare webhook failed appId={} event={} reason={}", appId, callbackEvent, e.getMessage());
}
}
private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) {
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
return hmacSha256Hex(appSecret, payload);
}
private String sha256Hex(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Failed to hash webhook body", e);
}
}
private String hmacSha256Hex(String secret, String payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("Failed to sign webhook body", e);
}
} }
} }

查看文件

@ -28,7 +28,7 @@ public class OperationLogService {
ImOperationLogEntity entity = new ImOperationLogEntity(); ImOperationLogEntity entity = new ImOperationLogEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId); entity.setAppId(appId);
entity.setOperatorId(operatorId); entity.setOperatorId(operatorId == null || operatorId.isBlank() ? "system" : operatorId);
entity.setAction(action); entity.setAction(action);
entity.setResourceType(resourceType); entity.setResourceType(resourceType);
entity.setResourceId(resourceId); entity.setResourceId(resourceId);

查看文件

@ -0,0 +1,112 @@
package com.xuqm.im.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.model.WebhookCallbackEnvelope;
import com.xuqm.im.repository.WebhookConfigRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpClient;
import java.time.Duration;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
@Component
public class WebhookDispatchService {
private final WebhookConfigRepository webhookRepository;
private final ImAppSecretClient appSecretClient;
private final ObjectMapper objectMapper;
@Value("${im.webhook-timeout-ms:3000}")
private int webhookTimeoutMs;
public WebhookDispatchService(WebhookConfigRepository webhookRepository,
ImAppSecretClient appSecretClient,
ObjectMapper objectMapper) {
this.webhookRepository = webhookRepository;
this.appSecretClient = appSecretClient;
this.objectMapper = objectMapper;
}
public void dispatch(String appId, String callbackType, String callbackEvent, Object payload) {
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
if (webhooks.isEmpty()) {
return;
}
try {
String appSecret = appSecretClient.getAppSecret(appId);
long requestTime = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replace("-", "");
String callbackId = UUID.randomUUID().toString();
WebhookCallbackEnvelope envelope = new WebhookCallbackEnvelope(
callbackId,
callbackType,
callbackEvent,
requestTime,
objectMapper.valueToTree(payload),
null,
appId
);
String body = objectMapper.writeValueAsString(envelope);
String signature = signWebhook(appId, appSecret, requestTime, nonce, body);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(webhookTimeoutMs))
.build();
for (WebhookConfigEntity webhook : webhooks) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webhook.getUrl()))
.timeout(Duration.ofMillis(webhookTimeoutMs))
.header("Content-Type", "application/json")
.header("X-App-Id", appId)
.header("X-App-Timestamp", String.valueOf(requestTime))
.header("X-App-Nonce", nonce)
.header("X-App-Signature", signature)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
// 回调失败不影响主流程
}
}
} catch (Exception e) {
// 准备失败不影响主流程
}
}
private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) {
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
return hmacSha256Hex(appSecret, payload);
}
private String sha256Hex(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Failed to hash webhook body", e);
}
}
private String hmacSha256Hex(String secret, String payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("Failed to sign webhook body", e);
}
}
}

查看文件

@ -31,6 +31,15 @@ public class PushController {
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@PostMapping("/receive-push")
public ResponseEntity<ApiResponse<Void>> receivePush(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam boolean enabled) {
pushDispatcher.setReceivePush(appId, userId, enabled);
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping("/send") @PostMapping("/send")
public ResponseEntity<ApiResponse<Void>> send( public ResponseEntity<ApiResponse<Void>> send(
@RequestParam @NotBlank String appId, @RequestParam @NotBlank String appId,

查看文件

@ -32,6 +32,9 @@ public class DeviceTokenEntity {
@Column(nullable = false, length = 512) @Column(nullable = false, length = 512)
private String token; private String token;
@Column(nullable = false)
private boolean receivePush = true;
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -53,6 +56,9 @@ public class DeviceTokenEntity {
public String getToken() { return token; } public String getToken() { return token; }
public void setToken(String token) { this.token = token; } public void setToken(String token) { this.token = token; }
public boolean isReceivePush() { return receivePush; }
public void setReceivePush(boolean receivePush) { this.receivePush = receivePush; }
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

查看文件

@ -7,7 +7,8 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity, String> { public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity, String> {
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId); List<DeviceTokenEntity> findByAppIdAndUserIdAndReceivePushTrue(String appId, String userId);
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor( Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
String appId, String userId, DeviceTokenEntity.Vendor vendor); String appId, String userId, DeviceTokenEntity.Vendor vendor);
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
} }

查看文件

@ -31,7 +31,7 @@ public class PushDispatcher {
@Async @Async
public void pushToUser(String appId, String userId, String title, String body, String payload) { public void pushToUser(String appId, String userId, String title, String body, String payload) {
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserId(appId, userId); List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId);
for (DeviceTokenEntity t : tokens) { for (DeviceTokenEntity t : tokens) {
PushProvider provider = providers.get(t.getVendor().name()); PushProvider provider = providers.get(t.getVendor().name());
if (provider != null) { if (provider != null) {
@ -63,7 +63,17 @@ public class PushDispatcher {
return e; return e;
}); });
entity.setToken(token); entity.setToken(token);
entity.setReceivePush(true);
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
tokenRepository.save(entity); tokenRepository.save(entity);
} }
public void setReceivePush(String appId, String userId, boolean enabled) {
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserId(appId, userId);
for (DeviceTokenEntity token : tokens) {
token.setReceivePush(enabled);
token.setUpdatedAt(LocalDateTime.now());
}
tokenRepository.saveAll(tokens);
}
} }

查看文件

@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@ -55,17 +56,31 @@ public class FeatureServiceController {
@PutMapping("/config") @PutMapping("/config")
public ResponseEntity<ApiResponse<FeatureServiceEntity>> updateConfig( public ResponseEntity<ApiResponse<FeatureServiceEntity>> updateConfig(
@PathVariable String appId, @PathVariable String appId,
@RequestParam FeatureServiceEntity.Platform platform, @RequestParam FeatureServiceEntity.Platform platform,
@RequestParam FeatureServiceEntity.ServiceType serviceType, @RequestParam FeatureServiceEntity.ServiceType serviceType,
@RequestParam boolean allowStrangerMessage, @RequestBody FeatureServiceConfigRequest req,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId); appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig( return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig(
appId, appId,
platform, platform,
serviceType, serviceType,
featureServiceManager.buildAllowStrangerConfig(allowStrangerMessage) serviceType == FeatureServiceEntity.ServiceType.IM
? featureServiceManager.buildImConfig(
appId,
platform,
req == null ? null : req.allowStrangerMessage(),
req == null ? null : req.allowFriendRequest(),
req == null ? null : req.friendRequestMode(),
req == null ? null : req.allowGroupJoinRequest(),
req == null ? null : req.blacklistSendSuccess(),
req == null ? null : req.messageRecallMinutes(),
req == null ? null : req.historyRetentionDays(),
req == null ? null : req.conversationPullLimit(),
req == null ? null : req.multiClientConversationDeleteSync())
: featureServiceManager.buildAllowStrangerConfig(
req != null && Boolean.TRUE.equals(req.allowStrangerMessage()))
))); )));
} }
@ -88,4 +103,16 @@ public class FeatureServiceController {
appService.getById(appId, tenantId); appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId))); return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId)));
} }
public record FeatureServiceConfigRequest(
Boolean allowStrangerMessage,
Boolean allowFriendRequest,
String friendRequestMode,
Boolean allowGroupJoinRequest,
Boolean blacklistSendSuccess,
Integer messageRecallMinutes,
Integer historyRetentionDays,
Integer conversationPullLimit,
Boolean multiClientConversationDeleteSync
) {}
} }

查看文件

@ -8,6 +8,7 @@ import java.util.Optional;
public interface FeatureServiceRepository extends JpaRepository<FeatureServiceEntity, String> { public interface FeatureServiceRepository extends JpaRepository<FeatureServiceEntity, String> {
List<FeatureServiceEntity> findByAppId(String appId); List<FeatureServiceEntity> findByAppId(String appId);
List<FeatureServiceEntity> findByAppIdAndServiceType(String appId, FeatureServiceEntity.ServiceType serviceType);
Optional<FeatureServiceEntity> findByAppIdAndPlatformAndServiceType( Optional<FeatureServiceEntity> findByAppIdAndPlatformAndServiceType(
String appId, String appId,
FeatureServiceEntity.Platform platform, FeatureServiceEntity.Platform platform,

查看文件

@ -15,6 +15,9 @@ public interface ServiceActivationRequestRepository extends JpaRepository<Servic
Optional<ServiceActivationRequestEntity> findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc( Optional<ServiceActivationRequestEntity> findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(
String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType); String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType);
Optional<ServiceActivationRequestEntity> findFirstByAppIdAndServiceTypeOrderByCreatedAtDesc(
String appId, FeatureServiceEntity.ServiceType serviceType);
List<ServiceActivationRequestEntity> findByAppIdOrderByCreatedAtDesc(String appId); List<ServiceActivationRequestEntity> findByAppIdOrderByCreatedAtDesc(String appId);
Page<ServiceActivationRequestEntity> findByStatusOrderByCreatedAtDesc(Status status, Pageable pageable); Page<ServiceActivationRequestEntity> findByStatusOrderByCreatedAtDesc(Status status, Pageable pageable);

查看文件

@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -32,11 +33,25 @@ public class FeatureServiceManager {
} }
public List<FeatureServiceEntity> listByApp(String appId) { public List<FeatureServiceEntity> listByApp(String appId) {
return repository.findByAppId(appId); List<FeatureServiceEntity> services = repository.findByAppId(appId);
if (services.isEmpty()) {
return services;
}
List<FeatureServiceEntity> normalized = new ArrayList<>();
services.stream()
.filter(service -> service.getServiceType() == FeatureServiceEntity.ServiceType.IM)
.findFirst()
.ifPresent(normalized::add);
services.stream()
.filter(service -> service.getServiceType() != FeatureServiceEntity.ServiceType.IM)
.forEach(normalized::add);
return normalized.isEmpty() ? services : normalized;
} }
/** /**
* Submit an activation request. Disabling is immediate; enabling requires ops approval. * Submit an activation request. Disabling is immediate; enabling requires ops approval.
* IM is app-wide, so duplicate checks ignore platform.
*/ */
@Transactional @Transactional
public ServiceActivationRequestEntity submitActivationRequest( public ServiceActivationRequestEntity submitActivationRequest(
@ -45,13 +60,21 @@ public class FeatureServiceManager {
FeatureServiceEntity.ServiceType serviceType, FeatureServiceEntity.ServiceType serviceType,
String applyReason) { String applyReason) {
// Check if there's already a pending request if (serviceType == FeatureServiceEntity.ServiceType.IM) {
requestRepository.findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(appId, platform, serviceType) requestRepository.findFirstByAppIdAndServiceTypeOrderByCreatedAtDesc(appId, serviceType)
.ifPresent(req -> { .ifPresent(req -> {
if (req.getStatus() == Status.PENDING) { if (req.getStatus() == Status.PENDING) {
throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理"); 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(); ServiceActivationRequestEntity req = new ServiceActivationRequestEntity();
req.setId(UUID.randomUUID().toString()); req.setId(UUID.randomUUID().toString());
@ -70,6 +93,16 @@ public class FeatureServiceManager {
@Transactional @Transactional
public FeatureServiceEntity disable(String appId, FeatureServiceEntity.Platform platform, public FeatureServiceEntity disable(String appId, FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType) { FeatureServiceEntity.ServiceType serviceType) {
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
List<FeatureServiceEntity> services = repository.findByAppIdAndServiceType(appId, serviceType);
if (services.isEmpty()) {
throw new BusinessException(404, "服务未开通");
}
services.forEach(service -> service.setEnabled(false));
repository.saveAll(services);
return services.get(0);
}
FeatureServiceEntity entity = repository FeatureServiceEntity entity = repository
.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) .findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
.orElseThrow(() -> new BusinessException(404, "服务未开通")); .orElseThrow(() -> new BusinessException(404, "服务未开通"));
@ -92,7 +125,24 @@ public class FeatureServiceManager {
req.setReviewedAt(LocalDateTime.now()); req.setReviewedAt(LocalDateTime.now());
requestRepository.save(req); requestRepository.save(req);
// Activate the service if (req.getServiceType() == FeatureServiceEntity.ServiceType.IM) {
List<FeatureServiceEntity> services = repository.findByAppIdAndServiceType(req.getAppId(), req.getServiceType());
if (services.isEmpty()) {
FeatureServiceEntity created = new FeatureServiceEntity();
created.setId(UUID.randomUUID().toString());
created.setAppId(req.getAppId());
created.setPlatform(req.getPlatform());
created.setServiceType(req.getServiceType());
created.setEnabled(true);
created.setCreatedAt(LocalDateTime.now());
repository.save(created);
} else {
services.forEach(service -> service.setEnabled(true));
repository.saveAll(services);
}
return req;
}
FeatureServiceEntity entity = repository FeatureServiceEntity entity = repository
.findByAppIdAndPlatformAndServiceType(req.getAppId(), req.getPlatform(), req.getServiceType()) .findByAppIdAndPlatformAndServiceType(req.getAppId(), req.getPlatform(), req.getServiceType())
.orElseGet(() -> { .orElseGet(() -> {
@ -131,6 +181,12 @@ public class FeatureServiceManager {
public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform, public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType) { FeatureServiceEntity.ServiceType serviceType) {
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
return repository.findByAppIdAndServiceType(appId, serviceType)
.stream()
.findFirst()
.orElseThrow(() -> new BusinessException(404, "服务未配置"));
}
return repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) return repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
.orElseThrow(() -> new BusinessException(404, "服务未配置")); .orElseThrow(() -> new BusinessException(404, "服务未配置"));
} }
@ -140,6 +196,16 @@ public class FeatureServiceManager {
FeatureServiceEntity.Platform platform, FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType, FeatureServiceEntity.ServiceType serviceType,
String config) { String config) {
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
List<FeatureServiceEntity> services = repository.findByAppIdAndServiceType(appId, serviceType);
if (services.isEmpty()) {
throw new BusinessException(404, "服务未配置");
}
services.forEach(service -> service.setConfig(config));
repository.saveAll(services);
return services.get(0);
}
FeatureServiceEntity entity = getOrFail(appId, platform, serviceType); FeatureServiceEntity entity = getOrFail(appId, platform, serviceType);
entity.setConfig(config); entity.setConfig(config);
return repository.save(entity); return repository.save(entity);
@ -148,23 +214,179 @@ public class FeatureServiceManager {
public boolean allowStrangerMessage(String appId, public boolean allowStrangerMessage(String appId,
FeatureServiceEntity.Platform platform, FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType) { FeatureServiceEntity.ServiceType serviceType) {
FeatureServiceEntity entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) return readConfigNode(appId, platform, serviceType).path("allowStrangerMessage").asBoolean(false);
.orElse(null); }
if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) {
return false; 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);
} }
try { if (!node.has("allowFriendRequest")) {
JsonNode node = objectMapper.readTree(entity.getConfig()); node.put("allowFriendRequest", true);
return node.path("allowStrangerMessage").asBoolean(false);
} catch (Exception e) {
return false;
} }
if (!node.has("friendRequestMode")) {
node.put("friendRequestMode", "REQUIRE_CONFIRM");
}
if (!node.has("allowGroupJoinRequest")) {
node.put("allowGroupJoinRequest", true);
}
if (!node.has("blacklistSendSuccess")) {
node.put("blacklistSendSuccess", true);
}
if (!node.has("messageRecallMinutes")) {
node.put("messageRecallMinutes", 2);
}
if (!node.has("historyRetentionDays")) {
node.put("historyRetentionDays", 7);
}
if (!node.has("conversationPullLimit")) {
node.put("conversationPullLimit", 100);
}
if (!node.has("multiClientConversationDeleteSync")) {
node.put("multiClientConversationDeleteSync", false);
}
if (allowStrangerMessage != null) {
node.put("allowStrangerMessage", allowStrangerMessage);
}
if (allowFriendRequest != null) {
node.put("allowFriendRequest", allowFriendRequest);
}
String effectiveFriendRequestMode = null;
if (friendRequestMode != null && !friendRequestMode.isBlank()) {
effectiveFriendRequestMode = normalizeFriendRequestMode(friendRequestMode);
} else if (allowFriendRequest != null && !allowFriendRequest) {
effectiveFriendRequestMode = "DISALLOW";
}
if (effectiveFriendRequestMode != null) {
node.put("friendRequestMode", effectiveFriendRequestMode);
if ("DISALLOW".equals(effectiveFriendRequestMode)) {
node.put("allowFriendRequest", false);
}
}
if (allowGroupJoinRequest != null) {
node.put("allowGroupJoinRequest", allowGroupJoinRequest);
}
if (blacklistSendSuccess != null) {
node.put("blacklistSendSuccess", blacklistSendSuccess);
}
if (messageRecallMinutes != null) {
node.put("messageRecallMinutes", Math.max(messageRecallMinutes, 0));
}
if (historyRetentionDays != null) {
node.put("historyRetentionDays", Math.max(historyRetentionDays, 1));
}
if (conversationPullLimit != null) {
node.put("conversationPullLimit", Math.min(Math.max(conversationPullLimit, 1), 500));
}
if (multiClientConversationDeleteSync != null) {
node.put("multiClientConversationDeleteSync", multiClientConversationDeleteSync);
}
return node.toString();
} }
public String buildAllowStrangerConfig(boolean allowStrangerMessage) { public String buildAllowStrangerConfig(boolean allowStrangerMessage) {
ObjectNode node = objectMapper.createObjectNode(); ObjectNode node = objectMapper.createObjectNode();
node.put("allowStrangerMessage", allowStrangerMessage); node.put("allowStrangerMessage", allowStrangerMessage);
node.put("allowFriendRequest", true);
node.put("friendRequestMode", "REQUIRE_CONFIRM");
node.put("allowGroupJoinRequest", true);
node.put("blacklistSendSuccess", true);
node.put("messageRecallMinutes", 2);
node.put("historyRetentionDays", 7);
node.put("conversationPullLimit", 100);
node.put("multiClientConversationDeleteSync", false);
return node.toString(); return node.toString();
} }
private String normalizeFriendRequestMode(String mode) {
String normalized = mode == null ? "" : mode.trim().toUpperCase();
return switch (normalized) {
case "DIRECT_ACCEPT", "DISALLOW" -> normalized;
default -> "REQUIRE_CONFIRM";
};
}
private JsonNode readConfigNode(String appId,
FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType) {
FeatureServiceEntity entity;
if (serviceType == FeatureServiceEntity.ServiceType.IM) {
entity = repository.findByAppIdAndServiceType(appId, serviceType)
.stream()
.findFirst()
.orElse(null);
} else {
entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
.orElse(null);
}
if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) {
return objectMapper.createObjectNode();
}
try {
JsonNode node = objectMapper.readTree(entity.getConfig());
return node == null ? objectMapper.createObjectNode() : node;
} catch (Exception e) {
return objectMapper.createObjectNode();
}
}
} }

查看文件

@ -117,12 +117,28 @@ public class SdkAppProvisioningService {
} }
private void ensureFeatureDefaults(AppEntity app) { private void ensureFeatureDefaults(AppEntity app) {
featureServiceRepository.findByAppIdAndServiceType(app.getAppKey(), FeatureServiceEntity.ServiceType.IM)
.stream()
.findFirst()
.orElseGet(() -> {
FeatureServiceEntity feature = new FeatureServiceEntity();
feature.setId(UUID.randomUUID().toString());
feature.setAppId(app.getAppKey());
feature.setPlatform(FeatureServiceEntity.Platform.ANDROID);
feature.setServiceType(FeatureServiceEntity.ServiceType.IM);
feature.setEnabled(true);
feature.setConfig("""
{"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"multiClientConversationDeleteSync":false}
""".trim());
feature.setCreatedAt(LocalDateTime.now());
return featureServiceRepository.save(feature);
});
for (FeatureServiceEntity.Platform platform : List.of( for (FeatureServiceEntity.Platform platform : List.of(
FeatureServiceEntity.Platform.ANDROID, FeatureServiceEntity.Platform.ANDROID,
FeatureServiceEntity.Platform.IOS, FeatureServiceEntity.Platform.IOS,
FeatureServiceEntity.Platform.HARMONY)) { FeatureServiceEntity.Platform.HARMONY)) {
for (FeatureServiceEntity.ServiceType serviceType : List.of( for (FeatureServiceEntity.ServiceType serviceType : List.of(
FeatureServiceEntity.ServiceType.IM,
FeatureServiceEntity.ServiceType.PUSH, FeatureServiceEntity.ServiceType.PUSH,
FeatureServiceEntity.ServiceType.UPDATE)) { FeatureServiceEntity.ServiceType.UPDATE)) {
featureServiceRepository.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, serviceType) featureServiceRepository.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, serviceType)

查看文件

@ -2,11 +2,17 @@ package com.xuqm.update.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -16,8 +22,10 @@ public class SecurityConfig {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers( .requestMatchers(
"/actuator/**", "/actuator/**",
"/api/v1/updates/app/check", "/api/v1/updates/app/check",
@ -31,4 +39,25 @@ public class SecurityConfig {
.formLogin(AbstractHttpConfigurer::disable); .formLogin(AbstractHttpConfigurer::disable);
return http.build(); return http.build();
} }
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOriginPatterns(List.of(
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.116.9:*",
"http://*.xuqinmin.com",
"https://*.xuqinmin.com"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Location"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
} }

查看文件

@ -3,6 +3,7 @@ package com.xuqm.update.controller;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.model.AppPackageInspectResult;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -57,8 +58,8 @@ public class AppVersionController {
public ResponseEntity<ApiResponse<AppVersionEntity>> upload( public ResponseEntity<ApiResponse<AppVersionEntity>> upload(
@RequestParam String appId, @RequestParam String appId,
@RequestParam AppVersionEntity.Platform platform, @RequestParam AppVersionEntity.Platform platform,
@RequestParam String versionName, @RequestParam(required = false) String versionName,
@RequestParam int versionCode, @RequestParam(required = false) Integer versionCode,
@RequestParam(required = false) String changeLog, @RequestParam(required = false) String changeLog,
@RequestParam(defaultValue = "false") boolean forceUpdate, @RequestParam(defaultValue = "false") boolean forceUpdate,
@RequestParam(required = false) MultipartFile apkFile, @RequestParam(required = false) MultipartFile apkFile,
@ -68,12 +69,20 @@ public class AppVersionController {
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview, @RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
@RequestParam(required = false) String packageName) throws Exception { @RequestParam(required = false) String packageName) throws Exception {
AppPackageInspectResult inspected = updateAssetService.inspectAppPackage(apkFile);
String resolvedVersionName = hasText(versionName) ? versionName : inspected.versionName();
Integer resolvedVersionCode = versionCode != null ? versionCode : inspected.versionCode();
String resolvedPackageName = hasText(packageName) ? packageName : inspected.packageName();
if (!hasText(resolvedVersionName) || resolvedVersionCode == null) {
throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package");
}
AppVersionEntity entity = new AppVersionEntity(); AppVersionEntity entity = new AppVersionEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId); entity.setAppId(appId);
entity.setPlatform(platform); entity.setPlatform(platform);
entity.setVersionName(versionName); entity.setVersionName(resolvedVersionName);
entity.setVersionCode(versionCode); entity.setVersionCode(resolvedVersionCode);
entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile)); entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile));
entity.setChangeLog(changeLog); entity.setChangeLog(changeLog);
entity.setForceUpdate(forceUpdate); entity.setForceUpdate(forceUpdate);
@ -85,10 +94,15 @@ public class AppVersionController {
entity.setWebhookUrl(webhookUrl); entity.setWebhookUrl(webhookUrl);
entity.setStoreSubmitTargets(storeSubmitTargets); entity.setStoreSubmitTargets(storeSubmitTargets);
entity.setAutoPublishAfterReview(autoPublishAfterReview); entity.setAutoPublishAfterReview(autoPublishAfterReview);
entity.setPackageName(packageName); entity.setPackageName(resolvedPackageName);
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
} }
@PostMapping("/app/inspect")
public ResponseEntity<ApiResponse<AppPackageInspectResult>> inspect(@RequestParam(required = false) MultipartFile apkFile) throws Exception {
return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkFile)));
}
@PostMapping("/app/{id}/publish") @PostMapping("/app/{id}/publish")
public ResponseEntity<ApiResponse<AppVersionEntity>> publish(@PathVariable String id) { public ResponseEntity<ApiResponse<AppVersionEntity>> publish(@PathVariable String id) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
@ -122,4 +136,8 @@ public class AppVersionController {
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform))); versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform)));
} }
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
} }

查看文件

@ -2,6 +2,7 @@ package com.xuqm.update.controller;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.RnBundleEntity; import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.model.RnBundleInspectResult;
import com.xuqm.update.repository.RnBundleRepository; import com.xuqm.update.repository.RnBundleRepository;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -61,30 +62,43 @@ public class RnBundleController {
@PostMapping("/upload") @PostMapping("/upload")
public ResponseEntity<ApiResponse<RnBundleEntity>> upload( public ResponseEntity<ApiResponse<RnBundleEntity>> upload(
@RequestParam String appId, @RequestParam String appId,
@RequestParam String moduleId, @RequestParam(required = false) String moduleId,
@RequestParam RnBundleEntity.Platform platform, @RequestParam(required = false) RnBundleEntity.Platform platform,
@RequestParam String version, @RequestParam(required = false) String version,
@RequestParam(required = false) String minCommonVersion, @RequestParam(required = false) String minCommonVersion,
@RequestParam(required = false) String note, @RequestParam(required = false) String note,
@RequestParam MultipartFile bundle) throws Exception { @RequestParam MultipartFile bundle) throws Exception {
RnBundleInspectResult inspected = updateAssetService.inspectRnBundle(bundle);
String resolvedModuleId = hasText(moduleId) ? moduleId : inspected.moduleId();
String resolvedVersion = hasText(version) ? version : inspected.version();
String resolvedMinCommonVersion = hasText(minCommonVersion) ? minCommonVersion : inspected.minCommonVersion();
RnBundleEntity.Platform resolvedPlatform = platform != null ? platform : parsePlatform(inspected.platform());
if (!hasText(resolvedModuleId) || !hasText(resolvedVersion) || resolvedPlatform == null) {
throw new IllegalArgumentException("moduleId, version and platform are required or must be readable from the bundle name");
}
UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle( UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle(
appId, platform.name(), moduleId, bundle); appId, resolvedPlatform.name(), resolvedModuleId, bundle);
RnBundleEntity entity = new RnBundleEntity(); RnBundleEntity entity = new RnBundleEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId); entity.setAppId(appId);
entity.setModuleId(moduleId); entity.setModuleId(resolvedModuleId);
entity.setPlatform(platform); entity.setPlatform(resolvedPlatform);
entity.setVersion(version); entity.setVersion(resolvedVersion);
entity.setBundleUrl(stored.bundlePath()); entity.setBundleUrl(stored.bundlePath());
entity.setMd5(stored.md5()); entity.setMd5(stored.md5());
entity.setMinCommonVersion(minCommonVersion); entity.setMinCommonVersion(resolvedMinCommonVersion);
entity.setNote(note); entity.setNote(note);
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
} }
@PostMapping("/inspect")
public ResponseEntity<ApiResponse<RnBundleInspectResult>> inspect(@RequestParam(required = false) MultipartFile bundle) throws Exception {
return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectRnBundle(bundle)));
}
@GetMapping("/list") @GetMapping("/list")
public ResponseEntity<ApiResponse<List<RnBundleEntity>>> list( public ResponseEntity<ApiResponse<List<RnBundleEntity>>> list(
@RequestParam String appId, @RequestParam String appId,
@ -137,4 +151,19 @@ public class RnBundleController {
} }
return normalized; return normalized;
} }
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private RnBundleEntity.Platform parsePlatform(String platform) {
if (!hasText(platform)) {
return null;
}
try {
return RnBundleEntity.Platform.valueOf(platform.toUpperCase());
} catch (Exception e) {
return null;
}
}
} }

查看文件

@ -0,0 +1,10 @@
package com.xuqm.update.model;
public record AppPackageInspectResult(
String platform,
String packageName,
String versionName,
Integer versionCode,
String fileName,
boolean detected) {
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.update.model;
public record RnBundleInspectResult(
String moduleId,
String platform,
String version,
String minCommonVersion,
String fileName,
boolean detected) {
}

查看文件

@ -1,17 +1,34 @@
package com.xuqm.update.service; package com.xuqm.update.service;
import com.xuqm.update.model.AppPackageInspectResult;
import com.xuqm.update.model.RnBundleInspectResult;
import net.dongliu.apk.parser.ApkFile;
import net.dongliu.apk.parser.bean.ApkMeta;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream; import java.security.DigestInputStream;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.Map;
import java.util.Locale;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
@Service @Service
public class UpdateAssetService { public class UpdateAssetService {
@ -34,6 +51,41 @@ public class UpdateAssetService {
return baseUrl + "/files/apk/" + filename; return baseUrl + "/files/apk/" + filename;
} }
public AppPackageInspectResult inspectAppPackage(MultipartFile packageFile) throws Exception {
String fileName = Optional.ofNullable(packageFile != null ? packageFile.getOriginalFilename() : null)
.orElse("");
String normalized = fileName.toLowerCase(Locale.ROOT);
if (packageFile == null || packageFile.isEmpty()) {
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
}
Path temp = Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName));
try {
try (InputStream in = packageFile.getInputStream()) {
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
}
if (normalized.endsWith(".apk")) {
return inspectApk(temp, fileName);
}
if (normalized.endsWith(".ipa")) {
return inspectIpa(temp, fileName);
}
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
} finally {
Files.deleteIfExists(temp);
}
}
public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception {
String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null)
.orElse("");
if (bundle == null || bundle.isEmpty()) {
return new RnBundleInspectResult(null, null, null, null, fileName, false);
}
return inspectRnBundleName(fileName);
}
public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception { public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception {
if (bundle == null || bundle.isEmpty()) { if (bundle == null || bundle.isEmpty()) {
throw new IllegalArgumentException("bundle file is required"); throw new IllegalArgumentException("bundle file is required");
@ -59,5 +111,138 @@ public class UpdateAssetService {
return HexFormat.of().formatHex(digest.digest()); return HexFormat.of().formatHex(digest.digest());
} }
private AppPackageInspectResult inspectApk(Path file, String fileName) throws Exception {
try (ApkFile apk = new ApkFile(file.toFile())) {
ApkMeta meta = apk.getApkMeta();
Integer versionCode = meta.getVersionCode() == null ? null : meta.getVersionCode().intValue();
return new AppPackageInspectResult(
"ANDROID",
blankToNull(meta.getPackageName()),
blankToNull(meta.getVersionName()),
versionCode,
fileName,
true);
}
}
private AppPackageInspectResult inspectIpa(Path file, String fileName) throws Exception {
try (ZipFile zipFile = new ZipFile(file.toFile())) {
ZipEntry entry = zipFile.stream()
.filter(e -> e.getName().endsWith("Info.plist"))
.findFirst()
.orElse(null);
if (entry != null) {
byte[] data = zipFile.getInputStream(entry).readAllBytes();
String text = new String(data, StandardCharsets.UTF_8);
if (text.contains("<plist")) {
var plist = parsePlistXml(text);
return new AppPackageInspectResult(
"IOS",
blankToNull(plist.get("CFBundleIdentifier")),
blankToNull(plist.get("CFBundleShortVersionString")),
parseInteger(plist.get("CFBundleVersion")),
fileName,
true);
}
}
}
return new AppPackageInspectResult("IOS", null, null, null, fileName, false);
}
private RnBundleInspectResult inspectRnBundleName(String fileName) {
String baseName = stripExtension(fileName);
String[] parts = baseName.split("__");
if (parts.length >= 4) {
return new RnBundleInspectResult(
blankToNull(parts[0]),
platformFromToken(parts[1]),
blankToNull(parts[2]),
blankToNull(parts[3]),
fileName,
true);
}
return new RnBundleInspectResult(
null,
platformFromFileName(fileName),
null,
null,
fileName,
false);
}
private Map<String, String> parsePlistXml(String xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
factory.setExpandEntityReferences(false);
Document document = factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
NodeList dictNodes = document.getElementsByTagName("dict");
if (dictNodes.getLength() == 0) {
return Map.of();
}
Element dict = (Element) dictNodes.item(0);
Map<String, String> values = new java.util.LinkedHashMap<>();
Node child = dict.getFirstChild();
String currentKey = null;
while (child != null) {
if (child.getNodeType() == Node.ELEMENT_NODE) {
String nodeName = child.getNodeName();
if ("key".equals(nodeName)) {
currentKey = child.getTextContent();
} else if (currentKey != null && ("string".equals(nodeName) || "integer".equals(nodeName))) {
values.put(currentKey, child.getTextContent());
currentKey = null;
}
}
child = child.getNextSibling();
}
return values;
}
private String platformFromFileName(String fileName) {
String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
if (lower.contains("ios") || lower.endsWith(".ipa")) {
return "IOS";
}
return "ANDROID";
}
private String platformFromToken(String token) {
if (token == null) {
return null;
}
String normalized = token.trim().toUpperCase(Locale.ROOT);
return switch (normalized) {
case "ANDROID", "IOS" -> normalized;
case "A", "ANDROIDSDK" -> "ANDROID";
case "I", "IOSSDK" -> "IOS";
default -> normalized.isBlank() ? null : normalized;
};
}
private String stripExtension(String fileName) {
if (fileName == null) return "";
int idx = fileName.lastIndexOf('.');
return idx > 0 ? fileName.substring(0, idx) : fileName;
}
private String suffixFor(String fileName) {
if (fileName == null) return ".tmp";
int idx = fileName.lastIndexOf('.');
return idx > 0 ? fileName.substring(idx) : ".tmp";
}
private Integer parseInteger(String value) {
if (value == null || value.isBlank()) return null;
try {
return Integer.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public record StoredRnBundle(String bundlePath, String md5) {} public record StoredRnBundle(String bundlePath, String md5) {}
} }