比较提交
4 次代码提交
c217e96482
...
eae18723a5
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
eae18723a5 | ||
|
|
30a9f71eac | ||
|
|
73060518f0 | ||
|
|
1e395171a3 |
@ -89,7 +89,18 @@
|
|||||||
| 方法 | 路径 | 鉴权 | 说明 |
|
| 方法 | 路径 | 鉴权 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| POST | `/api/im/auth/login` | 否 | 获取 IM Token;需要 `X-App-Timestamp` / `X-App-Nonce` / `X-App-Signature` |
|
| POST | `/api/im/auth/login` | 否 | 获取 IM Token;需要 `X-App-Timestamp` / `X-App-Nonce` / `X-App-Signature` |
|
||||||
|
| GET | `/api/im/accounts/{userId}` | 是 | 查询用户资料 |
|
||||||
|
| PUT | `/api/im/accounts/{userId}` | 是 | 更新自己的资料 |
|
||||||
|
| GET | `/api/im/accounts/search` | 否 | 搜索账号 |
|
||||||
|
| POST | `/api/im/accounts/import` | 是 | 导入单个账号 |
|
||||||
|
| POST | `/api/im/accounts/import/batch` | 是 | 批量导入账号 |
|
||||||
|
| DELETE | `/api/im/accounts/{userId}` | 是 | 删除账号 |
|
||||||
|
| GET | `/api/im/accounts/{userId}/exists` | 是 | 检查账号是否存在 |
|
||||||
|
| GET | `/api/im/groups/{groupId}/members` | 是 | 查询群成员列表 |
|
||||||
|
| GET | `/api/im/groups/{groupId}/members/search` | 是 | 搜索群成员 |
|
||||||
| POST | `/api/im/messages/send` | 是 | 发送消息(TEXT / IMAGE / AUDIO / VIDEO / FILE / LOCATION / CUSTOM / NOTIFY / RICH_TEXT / CALL_AUDIO / CALL_VIDEO / FORWARD / QUOTE / MERGE) |
|
| POST | `/api/im/messages/send` | 是 | 发送消息(TEXT / IMAGE / AUDIO / VIDEO / FILE / LOCATION / CUSTOM / NOTIFY / RICH_TEXT / CALL_AUDIO / CALL_VIDEO / FORWARD / QUOTE / MERGE) |
|
||||||
|
| GET | `/api/im/messages/search` | 是 | 云端消息搜索 |
|
||||||
|
| PUT | `/api/im/messages/{id}` | 是 | 编辑自己发送的文本消息 |
|
||||||
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
|
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
|
||||||
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
|
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
|
||||||
| WS | `/ws/im` | IM Token | 建立实时连接 |
|
| WS | `/ws/im` | IM Token | 建立实时连接 |
|
||||||
@ -167,12 +178,26 @@ curl -X POST 'https://dev.xuqinmin.com/api/demo/auth/refresh-im?appId=ak_demo_ch
|
|||||||
### IM 会话与关系链
|
### IM 会话与关系链
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/accounts/user_001?appId=ak_demo_chat'
|
||||||
|
curl -X POST 'https://dev.xuqinmin.com/api/im/accounts/import?appId=ak_demo_chat' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"userId":"user_001","nickname":"Alice"}'
|
||||||
|
curl -X POST 'https://dev.xuqinmin.com/api/im/accounts/import/batch?appId=ak_demo_chat' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '[{"userId":"user_001"},{"userId":"user_002"}]'
|
||||||
|
curl -X DELETE 'https://dev.xuqinmin.com/api/im/accounts/user_001?appId=ak_demo_chat'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/accounts/user_001/exists?appId=ak_demo_chat'
|
||||||
curl 'https://dev.xuqinmin.com/api/im/conversations?appId=ak_demo_chat'
|
curl 'https://dev.xuqinmin.com/api/im/conversations?appId=ak_demo_chat'
|
||||||
curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/pinned?appId=ak_demo_chat&chatType=SINGLE&pinned=true'
|
curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/pinned?appId=ak_demo_chat&chatType=SINGLE&pinned=true'
|
||||||
curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/draft?appId=ak_demo_chat&chatType=SINGLE&draft=hello'
|
curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/draft?appId=ak_demo_chat&chatType=SINGLE&draft=hello'
|
||||||
curl -X DELETE 'https://dev.xuqinmin.com/api/im/conversations/user_002?appId=ak_demo_chat&chatType=SINGLE'
|
curl -X DELETE 'https://dev.xuqinmin.com/api/im/conversations/user_002?appId=ak_demo_chat&chatType=SINGLE'
|
||||||
curl 'https://dev.xuqinmin.com/api/im/groups?appId=ak_demo_chat'
|
curl 'https://dev.xuqinmin.com/api/im/groups?appId=ak_demo_chat'
|
||||||
curl 'https://dev.xuqinmin.com/api/im/groups/public?appId=ak_demo_chat&keyword=demo'
|
curl 'https://dev.xuqinmin.com/api/im/groups/public?appId=ak_demo_chat&keyword=demo'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/groups/search?appId=ak_demo_chat&keyword=demo&size=20'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/groups/group_001/members?appId=ak_demo_chat'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/groups/group_001/members/search?appId=ak_demo_chat&keyword=demo&size=20'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/messages/search?appId=ak_demo_chat&keyword=hello&page=0&size=20'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/admin/webhooks?appId=ak_demo_chat'
|
||||||
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat&remark=申请加入'
|
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat&remark=申请加入'
|
||||||
curl 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat'
|
curl 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat'
|
||||||
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat'
|
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat'
|
||||||
@ -187,6 +212,7 @@ curl 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&directi
|
|||||||
curl 'https://dev.xuqinmin.com/api/im/admin/users?appId=ak_demo_chat&page=0&size=20'
|
curl 'https://dev.xuqinmin.com/api/im/admin/users?appId=ak_demo_chat&page=0&size=20'
|
||||||
curl 'https://dev.xuqinmin.com/api/im/admin/groups?appId=ak_demo_chat'
|
curl 'https://dev.xuqinmin.com/api/im/admin/groups?appId=ak_demo_chat'
|
||||||
curl 'https://dev.xuqinmin.com/api/im/admin/messages?appId=ak_demo_chat&userA=user_001&userB=user_002&page=0&size=20'
|
curl 'https://dev.xuqinmin.com/api/im/admin/messages?appId=ak_demo_chat&userA=user_001&userB=user_002&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'
|
||||||
```
|
```
|
||||||
@ -197,6 +223,10 @@ curl -X DELETE 'https://dev.xuqinmin.com/api/im/admin/groups/group_001'
|
|||||||
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&toUserId=user_002&remark=hi'
|
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&toUserId=user_002&remark=hi'
|
||||||
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/accept?appId=ak_demo_chat'
|
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/accept?appId=ak_demo_chat'
|
||||||
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/reject?appId=ak_demo_chat'
|
curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/reject?appId=ak_demo_chat'
|
||||||
|
curl 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat'
|
||||||
|
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat&remark=申请加入'
|
||||||
|
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat'
|
||||||
|
curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/reject?appId=ak_demo_chat'
|
||||||
curl -X POST 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002'
|
curl -X POST 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002'
|
||||||
curl -X DELETE 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002'
|
curl -X DELETE 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002'
|
||||||
```
|
```
|
||||||
|
|||||||
文件差异内容过多而无法显示
加载差异
@ -12,7 +12,12 @@ import org.springframework.web.bind.annotation.PutMapping;
|
|||||||
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.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -47,4 +52,55 @@ public class AccountController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
accountService.updateAccount(appId, userId, nickname, avatar, gender)));
|
accountService.updateAccount(appId, userId, nickname, avatar, gender)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> search(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam String keyword,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(accountService.searchAccounts(appId, keyword, size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/import")
|
||||||
|
public ResponseEntity<ApiResponse<ImAccountEntity>> importAccount(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody ImportAccountRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
accountService.importAccount(appId, req.userId(), req.nickname(), req.avatar(), req.gender(), req.status())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/import/batch")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> importAccounts(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody List<ImportAccountRequest> req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(accountService.importAccounts(
|
||||||
|
appId,
|
||||||
|
req == null ? List.of() : req.stream()
|
||||||
|
.map(item -> new ImAccountService.ImportAccountRequest(
|
||||||
|
item.userId(), item.nickname(), item.avatar(), item.gender(), item.status()))
|
||||||
|
.toList())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{userId}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String userId) {
|
||||||
|
accountService.deleteAccount(appId, userId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{userId}/exists")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Boolean>>> exists(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable String userId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(Map.of("exists", accountService.exists(appId, userId))));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ImportAccountRequest(
|
||||||
|
String userId,
|
||||||
|
String nickname,
|
||||||
|
String avatar,
|
||||||
|
ImAccountEntity.Gender gender,
|
||||||
|
ImAccountEntity.Status status
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,10 +9,13 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
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.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -41,7 +44,50 @@ public class FriendController {
|
|||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@RequestParam String friendId) {
|
@RequestParam String friendId) {
|
||||||
// Insert userId -> friendId if not already present
|
return ResponseEntity.ok(ApiResponse.success(addFriendLink(appId, userId, friendId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImFriendEntity>>> addFriends(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody FriendBatchRequest req) {
|
||||||
|
List<ImFriendEntity> links = new ArrayList<>();
|
||||||
|
for (String friendId : unique(req.friendIds())) {
|
||||||
|
if (friendId == null || friendId.isBlank() || userId.equals(friendId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
links.add(addFriendLink(appId, userId, friendId));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(links));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{friendId}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> removeFriend(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String friendId,
|
||||||
|
@RequestParam String appId) {
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId);
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch/remove")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> removeFriends(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody FriendBatchRequest req) {
|
||||||
|
for (String friendId : unique(req.friendIds())) {
|
||||||
|
if (friendId == null || friendId.isBlank() || userId.equals(friendId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId);
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImFriendEntity addFriendLink(String appId, String userId, String friendId) {
|
||||||
ImFriendEntity forward = friendRepository
|
ImFriendEntity forward = friendRepository
|
||||||
.findByAppIdAndUserIdAndFriendId(appId, userId, friendId)
|
.findByAppIdAndUserIdAndFriendId(appId, userId, friendId)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
@ -52,7 +98,6 @@ public class FriendController {
|
|||||||
return friendRepository.save(e);
|
return friendRepository.save(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert friendId -> userId bi-directionally if not already present
|
|
||||||
friendRepository.findByAppIdAndUserIdAndFriendId(appId, friendId, userId)
|
friendRepository.findByAppIdAndUserIdAndFriendId(appId, friendId, userId)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
ImFriendEntity e = new ImFriendEntity();
|
ImFriendEntity e = new ImFriendEntity();
|
||||||
@ -61,18 +106,12 @@ public class FriendController {
|
|||||||
e.setFriendId(userId);
|
e.setFriendId(userId);
|
||||||
return friendRepository.save(e);
|
return friendRepository.save(e);
|
||||||
});
|
});
|
||||||
|
return forward;
|
||||||
return ResponseEntity.ok(ApiResponse.success(forward));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{friendId}")
|
private List<String> unique(List<String> friendIds) {
|
||||||
public ResponseEntity<ApiResponse<Void>> removeFriend(
|
return friendIds == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(friendIds));
|
||||||
@AuthenticationPrincipal String userId,
|
|
||||||
@PathVariable String friendId,
|
|
||||||
@RequestParam String appId) {
|
|
||||||
// Remove both directions
|
|
||||||
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId);
|
|
||||||
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(null));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record FriendBatchRequest(List<String> friendIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@ -59,4 +60,22 @@ public class FriendRequestController {
|
|||||||
@PathVariable String requestId) {
|
@PathVariable String requestId) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(friendRequestService.reject(appId, requestId, userId)));
|
return ResponseEntity.ok(ApiResponse.success(friendRequestService.reject(appId, requestId, userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch/accept")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImFriendRequestEntity>>> acceptBatch(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody BatchRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(friendRequestService.acceptBatch(appId, req.requestIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch/reject")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImFriendRequestEntity>>> rejectBatch(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody BatchRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(friendRequestService.rejectBatch(appId, req.requestIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BatchRequest(List<String> requestIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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.im.entity.ImAccountEntity;
|
||||||
import com.xuqm.im.entity.ImGroupEntity;
|
import com.xuqm.im.entity.ImGroupEntity;
|
||||||
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
||||||
import com.xuqm.im.service.ImGroupService;
|
import com.xuqm.im.service.ImGroupService;
|
||||||
@ -50,6 +51,32 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.listPublicGroups(appId, keyword)));
|
return ResponseEntity.ok(ApiResponse.success(groupService.listPublicGroups(appId, keyword)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImGroupEntity>>> search(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam String keyword,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.searchGroups(appId, keyword, size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{groupId}/members")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> listMembers(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.listMembers(appId, groupId, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{groupId}/members/search")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> searchMembers(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam String keyword,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.searchMembers(appId, groupId, userId, keyword, size)));
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{groupId}")
|
@PutMapping("/{groupId}")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> update(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> update(
|
||||||
@PathVariable String groupId,
|
@PathVariable String groupId,
|
||||||
@ -67,6 +94,14 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId(), userId)));
|
return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId(), userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{groupId}/members/batch")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupEntity>> addMembers(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@RequestBody MemberBatchRequest req,
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.addMembers(groupId, req.userIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{groupId}/members/{targetUserId}")
|
@DeleteMapping("/{groupId}/members/{targetUserId}")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> removeMember(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> removeMember(
|
||||||
@PathVariable String groupId,
|
@PathVariable String groupId,
|
||||||
@ -75,6 +110,14 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId)));
|
return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{groupId}/members/batch/remove")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupEntity>> removeMembers(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@RequestBody MemberBatchRequest req,
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.removeMembers(groupId, req.userIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{groupId}/roles")
|
@PostMapping("/{groupId}/roles")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> setRole(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> setRole(
|
||||||
@PathVariable String groupId,
|
@PathVariable String groupId,
|
||||||
@ -136,9 +179,31 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.rejectJoinRequest(appId, requestId, userId)));
|
return ResponseEntity.ok(ApiResponse.success(groupService.rejectJoinRequest(appId, requestId, userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{groupId}/join-requests/batch/accept")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImGroupJoinRequestEntity>>> acceptJoinRequests(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody RequestBatch req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
groupService.acceptJoinRequests(appId, groupId, req.requestIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{groupId}/join-requests/batch/reject")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImGroupJoinRequestEntity>>> rejectJoinRequests(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody RequestBatch req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
groupService.rejectJoinRequests(appId, groupId, req.requestIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
public record CreateGroupRequest(String name, List<String> memberIds, String groupType) {}
|
public record CreateGroupRequest(String name, List<String> memberIds, String groupType) {}
|
||||||
public record UpdateGroupRequest(String name, String announcement) {}
|
public record UpdateGroupRequest(String name, String announcement) {}
|
||||||
public record MemberRequest(String userId) {}
|
public record MemberRequest(String userId) {}
|
||||||
|
public record MemberBatchRequest(List<String> userIds) {}
|
||||||
public record SetRoleRequest(String userId, String role) {}
|
public record SetRoleRequest(String userId, String role) {}
|
||||||
public record MuteMemberRequest(String userId, long minutes) {}
|
public record MuteMemberRequest(String userId, long minutes) {}
|
||||||
|
public record RequestBatch(List<String> requestIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,10 +15,12 @@ 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;
|
||||||
import com.xuqm.im.service.MessageService;
|
import com.xuqm.im.service.MessageService;
|
||||||
|
import com.xuqm.im.service.OperationLogService;
|
||||||
import com.xuqm.im.service.WebhookConfigService;
|
import com.xuqm.im.service.WebhookConfigService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@ -38,6 +40,7 @@ public class ImAdminController {
|
|||||||
private final WebhookConfigService webhookConfigService;
|
private final WebhookConfigService webhookConfigService;
|
||||||
private final KeywordFilterService keywordFilterService;
|
private final KeywordFilterService keywordFilterService;
|
||||||
private final GlobalMuteService globalMuteService;
|
private final GlobalMuteService globalMuteService;
|
||||||
|
private final OperationLogService operationLogService;
|
||||||
|
|
||||||
public ImAdminController(ImAccountRepository accountRepository,
|
public ImAdminController(ImAccountRepository accountRepository,
|
||||||
ImGroupRepository groupRepository,
|
ImGroupRepository groupRepository,
|
||||||
@ -47,7 +50,8 @@ public class ImAdminController {
|
|||||||
MessageService messageService,
|
MessageService messageService,
|
||||||
WebhookConfigService webhookConfigService,
|
WebhookConfigService webhookConfigService,
|
||||||
KeywordFilterService keywordFilterService,
|
KeywordFilterService keywordFilterService,
|
||||||
GlobalMuteService globalMuteService) {
|
GlobalMuteService globalMuteService,
|
||||||
|
OperationLogService operationLogService) {
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
@ -57,6 +61,7 @@ public class ImAdminController {
|
|||||||
this.webhookConfigService = webhookConfigService;
|
this.webhookConfigService = webhookConfigService;
|
||||||
this.keywordFilterService = keywordFilterService;
|
this.keywordFilterService = keywordFilterService;
|
||||||
this.globalMuteService = globalMuteService;
|
this.globalMuteService = globalMuteService;
|
||||||
|
this.operationLogService = operationLogService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List all registered IM users for the given appId. */
|
/** List all registered IM users for the given appId. */
|
||||||
@ -74,11 +79,14 @@ public class ImAdminController {
|
|||||||
public ResponseEntity<ApiResponse<ImAccountEntity>> updateUserStatus(
|
public ResponseEntity<ApiResponse<ImAccountEntity>> updateUserStatus(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@PathVariable String userId,
|
@PathVariable String userId,
|
||||||
|
@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 RuntimeException("User not found"));
|
||||||
account.setStatus(ImAccountEntity.Status.valueOf(body.get("status").toUpperCase()));
|
account.setStatus(ImAccountEntity.Status.valueOf(body.get("status").toUpperCase()));
|
||||||
return ResponseEntity.ok(ApiResponse.success(accountRepository.save(account)));
|
ImAccountEntity saved = accountRepository.save(account);
|
||||||
|
operationLogService.record(appId, operatorId, "UPDATE_USER_STATUS", "ACCOUNT", userId, body.get("status"));
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List all groups for the given appId. */
|
/** List all groups for the given appId. */
|
||||||
@ -91,10 +99,12 @@ public class ImAdminController {
|
|||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
public ResponseEntity<ApiResponse<ImAccountEntity>> registerUser(
|
public ResponseEntity<ApiResponse<ImAccountEntity>> registerUser(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody RegisterUserRequest req) {
|
@RequestBody RegisterUserRequest req) {
|
||||||
accountService.loginOrRegister(appId, req.userId(), req.nickname(), req.avatar());
|
accountService.loginOrRegister(appId, req.userId(), req.nickname(), req.avatar());
|
||||||
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, req.userId())
|
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, req.userId())
|
||||||
.orElseThrow();
|
.orElseThrow();
|
||||||
|
operationLogService.record(appId, operatorId, "REGISTER_USER", "ACCOUNT", req.userId(), req.nickname());
|
||||||
return ResponseEntity.ok(ApiResponse.success(account));
|
return ResponseEntity.ok(ApiResponse.success(account));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,18 +112,22 @@ public class ImAdminController {
|
|||||||
@PostMapping("/groups")
|
@PostMapping("/groups")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> createGroup(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> createGroup(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody CreateGroupRequest req) {
|
@RequestBody CreateGroupRequest req) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
ImGroupEntity group = groupService.create(appId, req.name(), req.creatorId(), req.memberIds(), "WORK");
|
||||||
groupService.create(appId, req.name(), req.creatorId(), req.memberIds(), "WORK")));
|
operationLogService.record(appId, operatorId, "CREATE_GROUP", "GROUP", group.getId(), group.getName());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(group));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fuzzy search users by userId or nickname. */
|
/** Fuzzy search users by userId or nickname. */
|
||||||
@GetMapping("/users/search")
|
@GetMapping("/users/search")
|
||||||
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> searchUsers(
|
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> searchUsers(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestParam String keyword,
|
@RequestParam String keyword,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
List<ImAccountEntity> results = accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, size));
|
List<ImAccountEntity> results = accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, size));
|
||||||
|
operationLogService.record(appId, operatorId, "SEARCH_USERS", "ACCOUNT", null, keyword);
|
||||||
return ResponseEntity.ok(ApiResponse.success(results));
|
return ResponseEntity.ok(ApiResponse.success(results));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,9 +135,11 @@ public class ImAdminController {
|
|||||||
@GetMapping("/groups/search")
|
@GetMapping("/groups/search")
|
||||||
public ResponseEntity<ApiResponse<List<ImGroupEntity>>> searchGroups(
|
public ResponseEntity<ApiResponse<List<ImGroupEntity>>> searchGroups(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestParam String keyword,
|
@RequestParam String keyword,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
List<ImGroupEntity> results = groupRepository.searchByKeyword(appId, keyword, PageRequest.of(0, size));
|
List<ImGroupEntity> results = groupRepository.searchByKeyword(appId, keyword, PageRequest.of(0, size));
|
||||||
|
operationLogService.record(appId, operatorId, "SEARCH_GROUPS", "GROUP", null, keyword);
|
||||||
return ResponseEntity.ok(ApiResponse.success(results));
|
return ResponseEntity.ok(ApiResponse.success(results));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,6 +147,7 @@ public class ImAdminController {
|
|||||||
@GetMapping("/messages/search")
|
@GetMapping("/messages/search")
|
||||||
public ResponseEntity<ApiResponse<Page<ImMessageEntity>>> searchMessages(
|
public ResponseEntity<ApiResponse<Page<ImMessageEntity>>> searchMessages(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestParam(required = false) ImMessageEntity.ChatType chatType,
|
@RequestParam(required = false) ImMessageEntity.ChatType chatType,
|
||||||
@RequestParam(required = false) ImMessageEntity.MsgType msgType,
|
@RequestParam(required = false) ImMessageEntity.MsgType msgType,
|
||||||
@RequestParam(required = false) String keyword,
|
@RequestParam(required = false) String keyword,
|
||||||
@ -138,6 +155,7 @@ public class ImAdminController {
|
|||||||
@RequestParam(required = false) LocalDateTime endTime,
|
@RequestParam(required = false) LocalDateTime endTime,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
operationLogService.record(appId, operatorId, "SEARCH_MESSAGES", "MESSAGE", null, keyword);
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
messageRepository.searchByKeyword(
|
messageRepository.searchByKeyword(
|
||||||
appId, chatType, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
|
appId, chatType, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
|
||||||
@ -145,11 +163,14 @@ public class ImAdminController {
|
|||||||
|
|
||||||
/** Message statistics for the given appId. */
|
/** Message statistics for the given appId. */
|
||||||
@GetMapping("/stats")
|
@GetMapping("/stats")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> stats(@RequestParam String appId) {
|
public ResponseEntity<ApiResponse<Map<String, Object>>> stats(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId) {
|
||||||
long totalMessages = messageRepository.countByAppId(appId);
|
long totalMessages = messageRepository.countByAppId(appId);
|
||||||
long totalUsers = accountRepository.countByAppId(appId);
|
long totalUsers = accountRepository.countByAppId(appId);
|
||||||
long totalGroups = groupRepository.countByAppId(appId);
|
long totalGroups = groupRepository.countByAppId(appId);
|
||||||
long todayMessages = messageRepository.countTodayByAppId(appId);
|
long todayMessages = messageRepository.countTodayByAppId(appId);
|
||||||
|
operationLogService.record(appId, operatorId, "VIEW_STATS", "STATS", null, "summary");
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||||
"totalMessages", totalMessages,
|
"totalMessages", totalMessages,
|
||||||
@ -163,6 +184,7 @@ public class ImAdminController {
|
|||||||
@GetMapping("/messages")
|
@GetMapping("/messages")
|
||||||
public ResponseEntity<ApiResponse<Page<com.xuqm.im.entity.ImMessageEntity>>> history(
|
public ResponseEntity<ApiResponse<Page<com.xuqm.im.entity.ImMessageEntity>>> history(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestParam String userA,
|
@RequestParam String userA,
|
||||||
@RequestParam String userB,
|
@RequestParam String userB,
|
||||||
@RequestParam(required = false) com.xuqm.im.entity.ImMessageEntity.MsgType msgType,
|
@RequestParam(required = false) com.xuqm.im.entity.ImMessageEntity.MsgType msgType,
|
||||||
@ -171,6 +193,7 @@ public class ImAdminController {
|
|||||||
@RequestParam(required = false) LocalDateTime endTime,
|
@RequestParam(required = false) LocalDateTime endTime,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
operationLogService.record(appId, operatorId, "VIEW_HISTORY", "MESSAGE", userA + "," + userB, keyword);
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
messageRepository.findSingleConversationFiltered(
|
messageRepository.findSingleConversationFiltered(
|
||||||
appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
|
appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
|
||||||
@ -180,14 +203,21 @@ public class ImAdminController {
|
|||||||
@PostMapping("/messages/{messageId}/revoke")
|
@PostMapping("/messages/{messageId}/revoke")
|
||||||
public ResponseEntity<ApiResponse<com.xuqm.im.entity.ImMessageEntity>> adminRevoke(
|
public ResponseEntity<ApiResponse<com.xuqm.im.entity.ImMessageEntity>> adminRevoke(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@PathVariable String messageId) {
|
@PathVariable String messageId) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(messageService.adminRevoke(appId, messageId)));
|
ImMessageEntity revoked = messageService.adminRevoke(appId, messageId);
|
||||||
|
operationLogService.record(appId, operatorId, "ADMIN_REVOKE_MESSAGE", "MESSAGE", messageId, null);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(revoked));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin force dismisses a group. */
|
/** Admin force dismisses a group. */
|
||||||
@DeleteMapping("/groups/{groupId}")
|
@DeleteMapping("/groups/{groupId}")
|
||||||
public ResponseEntity<ApiResponse<Void>> adminDismissGroup(@PathVariable String groupId) {
|
public ResponseEntity<ApiResponse<Void>> adminDismissGroup(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
|
@PathVariable String groupId) {
|
||||||
groupService.adminDismiss(groupId);
|
groupService.adminDismiss(groupId);
|
||||||
|
operationLogService.record(appId, operatorId, "ADMIN_DISMISS_GROUP", "GROUP", groupId, null);
|
||||||
return ResponseEntity.ok(ApiResponse.ok());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,25 +229,31 @@ public class ImAdminController {
|
|||||||
@PostMapping("/webhooks")
|
@PostMapping("/webhooks")
|
||||||
public ResponseEntity<ApiResponse<WebhookConfigEntity>> createWebhook(
|
public ResponseEntity<ApiResponse<WebhookConfigEntity>> createWebhook(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody WebhookConfigRequest req) {
|
@RequestBody WebhookConfigRequest req) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
WebhookConfigEntity saved = webhookConfigService.create(appId, req.url(), req.secret(), req.enabled());
|
||||||
webhookConfigService.create(appId, req.url(), req.secret(), req.enabled())));
|
operationLogService.record(appId, operatorId, "CREATE_WEBHOOK", "WEBHOOK", saved.getId(), req.url());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/webhooks/{id}")
|
@PutMapping("/webhooks/{id}")
|
||||||
public ResponseEntity<ApiResponse<WebhookConfigEntity>> updateWebhook(
|
public ResponseEntity<ApiResponse<WebhookConfigEntity>> updateWebhook(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody WebhookConfigRequest req) {
|
@RequestBody WebhookConfigRequest req) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
WebhookConfigEntity saved = webhookConfigService.update(appId, id, req.url(), req.secret(), req.enabled());
|
||||||
webhookConfigService.update(appId, id, req.url(), req.secret(), req.enabled())));
|
operationLogService.record(appId, operatorId, "UPDATE_WEBHOOK", "WEBHOOK", id, req.url());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/webhooks/{id}")
|
@DeleteMapping("/webhooks/{id}")
|
||||||
public ResponseEntity<ApiResponse<Void>> deleteWebhook(
|
public ResponseEntity<ApiResponse<Void>> deleteWebhook(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
webhookConfigService.delete(appId, id);
|
webhookConfigService.delete(appId, id);
|
||||||
|
operationLogService.record(appId, operatorId, "DELETE_WEBHOOK", "WEBHOOK", id, null);
|
||||||
return ResponseEntity.ok(ApiResponse.ok());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,25 +265,31 @@ public class ImAdminController {
|
|||||||
@PostMapping("/keyword-filters")
|
@PostMapping("/keyword-filters")
|
||||||
public ResponseEntity<ApiResponse<KeywordFilterEntity>> createKeywordFilter(
|
public ResponseEntity<ApiResponse<KeywordFilterEntity>> createKeywordFilter(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody KeywordFilterRequest req) {
|
@RequestBody KeywordFilterRequest req) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
KeywordFilterEntity saved = keywordFilterService.add(appId, req.pattern(), req.replacement(), req.action());
|
||||||
keywordFilterService.add(appId, req.pattern(), req.replacement(), req.action())));
|
operationLogService.record(appId, operatorId, "CREATE_KEYWORD_FILTER", "KEYWORD_FILTER", saved.getId(), req.pattern());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/keyword-filters/{id}")
|
@PutMapping("/keyword-filters/{id}")
|
||||||
public ResponseEntity<ApiResponse<KeywordFilterEntity>> updateKeywordFilter(
|
public ResponseEntity<ApiResponse<KeywordFilterEntity>> updateKeywordFilter(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody KeywordFilterRequest req) {
|
@RequestBody KeywordFilterRequest req) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
KeywordFilterEntity saved = keywordFilterService.update(appId, id, req.pattern(), req.replacement(), req.action(), req.enabled());
|
||||||
keywordFilterService.update(appId, id, req.pattern(), req.replacement(), req.action(), req.enabled())));
|
operationLogService.record(appId, operatorId, "UPDATE_KEYWORD_FILTER", "KEYWORD_FILTER", id, req.pattern());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/keyword-filters/{id}")
|
@DeleteMapping("/keyword-filters/{id}")
|
||||||
public ResponseEntity<ApiResponse<Void>> deleteKeywordFilter(
|
public ResponseEntity<ApiResponse<Void>> deleteKeywordFilter(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
keywordFilterService.delete(appId, id);
|
keywordFilterService.delete(appId, id);
|
||||||
|
operationLogService.record(appId, operatorId, "DELETE_KEYWORD_FILTER", "KEYWORD_FILTER", id, null);
|
||||||
return ResponseEntity.ok(ApiResponse.ok());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,8 +301,20 @@ public class ImAdminController {
|
|||||||
@PutMapping("/global-mute")
|
@PutMapping("/global-mute")
|
||||||
public ResponseEntity<ApiResponse<ImGlobalMuteEntity>> setGlobalMute(
|
public ResponseEntity<ApiResponse<ImGlobalMuteEntity>> setGlobalMute(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestParam boolean enabled) {
|
@RequestParam boolean enabled) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(globalMuteService.setEnabled(appId, enabled)));
|
ImGlobalMuteEntity saved = globalMuteService.setEnabled(appId, enabled);
|
||||||
|
operationLogService.record(appId, operatorId, "SET_GLOBAL_MUTE", "GLOBAL_MUTE", saved.getId(), String.valueOf(enabled));
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/operation-logs")
|
||||||
|
public ResponseEntity<ApiResponse<Page<com.xuqm.im.entity.ImOperationLogEntity>>> operationLogs(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
operationLogService.list(appId, PageRequest.of(page, size))));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RegisterUserRequest(String userId, String nickname, String avatar) {}
|
public record RegisterUserRequest(String userId, String nickname, String avatar) {}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.xuqm.im.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.im.entity.ImMessageEntity;
|
import com.xuqm.im.entity.ImMessageEntity;
|
||||||
|
import com.xuqm.im.model.EditMessageRequest;
|
||||||
import com.xuqm.im.model.SendMessageRequest;
|
import com.xuqm.im.model.SendMessageRequest;
|
||||||
import com.xuqm.im.service.MessageService;
|
import com.xuqm.im.service.MessageService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
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.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@ -44,6 +46,15 @@ public class MessageController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(messageService.revoke(appId, id, userId)));
|
return ResponseEntity.ok(ApiResponse.success(messageService.revoke(appId, id, userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ImMessageEntity>> edit(
|
||||||
|
@PathVariable String id,
|
||||||
|
@Valid @RequestBody EditMessageRequest req,
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(messageService.edit(appId, id, userId, req)));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/history/{toId}")
|
@GetMapping("/history/{toId}")
|
||||||
public ResponseEntity<ApiResponse<?>> history(
|
public ResponseEntity<ApiResponse<?>> history(
|
||||||
@PathVariable String toId,
|
@PathVariable String toId,
|
||||||
|
|||||||
@ -61,6 +61,11 @@ public class ImMessageEntity {
|
|||||||
@Transient
|
@Transient
|
||||||
private Integer groupReadCount;
|
private Integer groupReadCount;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
|
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
|
||||||
|
private LocalDateTime editedAt;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
|
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
|
||||||
@ -96,6 +101,11 @@ public class ImMessageEntity {
|
|||||||
public Integer getGroupReadCount() { return groupReadCount; }
|
public Integer getGroupReadCount() { return groupReadCount; }
|
||||||
public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = groupReadCount; }
|
public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = groupReadCount; }
|
||||||
|
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
|
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
|
||||||
|
public LocalDateTime getEditedAt() { return editedAt; }
|
||||||
|
public void setEditedAt(LocalDateTime editedAt) { this.editedAt = editedAt; }
|
||||||
|
|
||||||
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
|
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
|
||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
|||||||
@ -0,0 +1,62 @@
|
|||||||
|
package com.xuqm.im.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "im_operation_log", indexes = {
|
||||||
|
@Index(name = "idx_op_log_app_time", columnList = "appId,createdAt"),
|
||||||
|
@Index(name = "idx_op_log_app_operator", columnList = "appId,operatorId")
|
||||||
|
})
|
||||||
|
public class ImOperationLogEntity extends BaseIdEntity {
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 128)
|
||||||
|
private String operatorId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String resourceType;
|
||||||
|
|
||||||
|
@Column(length = 128)
|
||||||
|
private String resourceId;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String detail;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public String getAppId() { return appId; }
|
||||||
|
public void setAppId(String appId) { this.appId = appId; }
|
||||||
|
|
||||||
|
public String getOperatorId() { return operatorId; }
|
||||||
|
public void setOperatorId(String operatorId) { this.operatorId = operatorId; }
|
||||||
|
|
||||||
|
public String getAction() { return action; }
|
||||||
|
public void setAction(String action) { this.action = action; }
|
||||||
|
|
||||||
|
public String getResourceType() { return resourceType; }
|
||||||
|
public void setResourceType(String resourceType) { this.resourceType = resourceType; }
|
||||||
|
|
||||||
|
public String getResourceId() { return resourceId; }
|
||||||
|
public void setResourceId(String resourceId) { this.resourceId = resourceId; }
|
||||||
|
|
||||||
|
public String getDetail() { return detail; }
|
||||||
|
public void setDetail(String detail) { this.detail = detail; }
|
||||||
|
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.xuqm.im.model;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record EditMessageRequest(
|
||||||
|
@NotBlank String content
|
||||||
|
) {}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.xuqm.im.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record MessageReadCallbackPayload(
|
||||||
|
String appId,
|
||||||
|
String readerId,
|
||||||
|
String peerId,
|
||||||
|
String groupId,
|
||||||
|
String chatType,
|
||||||
|
long readAt,
|
||||||
|
List<String> messageIds
|
||||||
|
) {}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.xuqm.im.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
public record WebhookCallbackEnvelope(
|
||||||
|
String callbackId,
|
||||||
|
String callbackType,
|
||||||
|
String callbackEvent,
|
||||||
|
long requestTime,
|
||||||
|
JsonNode payload,
|
||||||
|
String signature,
|
||||||
|
String appId
|
||||||
|
) {}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.xuqm.im.repository;
|
||||||
|
|
||||||
|
import com.xuqm.im.entity.ImOperationLogEntity;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ImOperationLogRepository extends JpaRepository<ImOperationLogEntity, String> {
|
||||||
|
Page<ImOperationLogEntity> findByAppIdOrderByCreatedAtDesc(String appId, Pageable pageable);
|
||||||
|
}
|
||||||
@ -105,6 +105,24 @@ public class FriendRequestService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ImFriendRequestEntity> acceptBatch(String appId, List<String> requestIds, String operatorId) {
|
||||||
|
List<ImFriendRequestEntity> result = new java.util.ArrayList<>();
|
||||||
|
for (String requestId : unique(requestIds)) {
|
||||||
|
result.add(acceptInternal(appId, requestId, operatorId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ImFriendRequestEntity> rejectBatch(String appId, List<String> requestIds, String operatorId) {
|
||||||
|
List<ImFriendRequestEntity> result = new java.util.ArrayList<>();
|
||||||
|
for (String requestId : unique(requestIds)) {
|
||||||
|
result.add(rejectInternal(appId, requestId, operatorId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public List<ImFriendRequestEntity> incoming(String appId, String userId) {
|
public List<ImFriendRequestEntity> incoming(String appId, String userId) {
|
||||||
return requestRepository.findByAppIdAndToUserId(appId, userId).stream()
|
return requestRepository.findByAppIdAndToUserId(appId, userId).stream()
|
||||||
.filter(request -> ImFriendRequestEntity.Status.PENDING.name().equals(request.getStatus()))
|
.filter(request -> ImFriendRequestEntity.Status.PENDING.name().equals(request.getStatus()))
|
||||||
@ -124,6 +142,44 @@ public class FriendRequestService {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ImFriendRequestEntity acceptInternal(String appId, String requestId, String operatorId) {
|
||||||
|
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
||||||
|
request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||||
|
friendRepository
|
||||||
|
.findByAppIdAndUserIdAndFriendId(appId, request.getFromUserId(), request.getToUserId())
|
||||||
|
.orElseGet(() -> friendEntity(appId, request.getFromUserId(), request.getToUserId()));
|
||||||
|
friendRepository
|
||||||
|
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
|
||||||
|
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId()));
|
||||||
|
publishNotification(
|
||||||
|
request,
|
||||||
|
request.getToUserId(),
|
||||||
|
request.getFromUserId(),
|
||||||
|
"FRIEND_REQUEST_STATUS",
|
||||||
|
"好友申请已通过",
|
||||||
|
buildDescription("好友申请已通过", request.getRemark())
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImFriendRequestEntity rejectInternal(String appId, String requestId, String operatorId) {
|
||||||
|
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
||||||
|
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||||
|
publishNotification(
|
||||||
|
saved,
|
||||||
|
saved.getToUserId(),
|
||||||
|
saved.getFromUserId(),
|
||||||
|
"FRIEND_REQUEST_STATUS",
|
||||||
|
"好友申请已拒绝",
|
||||||
|
buildDescription("好友申请已拒绝", saved.getRemark())
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
com.xuqm.im.entity.ImFriendEntity entity = new com.xuqm.im.entity.ImFriendEntity();
|
com.xuqm.im.entity.ImFriendEntity entity = new com.xuqm.im.entity.ImFriendEntity();
|
||||||
entity.setAppId(appId);
|
entity.setAppId(appId);
|
||||||
@ -132,6 +188,10 @@ public class FriendRequestService {
|
|||||||
return friendRepository.save(entity);
|
return friendRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> unique(List<String> requestIds) {
|
||||||
|
return requestIds == null ? List.of() : new java.util.ArrayList<>(new java.util.LinkedHashSet<>(requestIds));
|
||||||
|
}
|
||||||
|
|
||||||
private void publishNotification(
|
private void publishNotification(
|
||||||
ImFriendRequestEntity request,
|
ImFriendRequestEntity request,
|
||||||
String fromUserId,
|
String fromUserId,
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import com.xuqm.common.security.AppRequestSignatureUtil;
|
|||||||
import com.xuqm.common.security.JwtUtil;
|
import com.xuqm.common.security.JwtUtil;
|
||||||
import com.xuqm.im.entity.ImAccountEntity;
|
import com.xuqm.im.entity.ImAccountEntity;
|
||||||
import com.xuqm.im.repository.ImAccountRepository;
|
import com.xuqm.im.repository.ImAccountRepository;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -87,4 +89,51 @@ public class ImAccountService {
|
|||||||
if (gender != null) account.setGender(gender);
|
if (gender != null) account.setGender(gender);
|
||||||
return accountRepository.save(account);
|
return accountRepository.save(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ImAccountEntity importAccount(String appId, String userId, String nickname,
|
||||||
|
String avatar, ImAccountEntity.Gender gender,
|
||||||
|
ImAccountEntity.Status status) {
|
||||||
|
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
ImAccountEntity entity = new ImAccountEntity();
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
entity.setAppId(appId);
|
||||||
|
entity.setUserId(userId);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
account.setNickname(nickname);
|
||||||
|
account.setAvatar(avatar);
|
||||||
|
account.setGender(gender == null ? ImAccountEntity.Gender.UNKNOWN : gender);
|
||||||
|
account.setStatus(status == null ? ImAccountEntity.Status.ACTIVE : status);
|
||||||
|
return accountRepository.save(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImAccountEntity> importAccounts(String appId, List<ImportAccountRequest> requests) {
|
||||||
|
return requests == null ? List.of() : requests.stream()
|
||||||
|
.filter(req -> req != null && req.userId() != null && !req.userId().isBlank())
|
||||||
|
.map(req -> importAccount(appId, req.userId(), req.nickname(), req.avatar(), req.gender(), req.status()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteAccount(String appId, String userId) {
|
||||||
|
accountRepository.findByAppIdAndUserId(appId, userId)
|
||||||
|
.ifPresent(accountRepository::delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean exists(String appId, String userId) {
|
||||||
|
return accountRepository.existsByAppIdAndUserId(appId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImAccountEntity> searchAccounts(String appId, String keyword, int size) {
|
||||||
|
return accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ImportAccountRequest(
|
||||||
|
String userId,
|
||||||
|
String nickname,
|
||||||
|
String avatar,
|
||||||
|
ImAccountEntity.Gender gender,
|
||||||
|
ImAccountEntity.Status status
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,18 +2,26 @@ package com.xuqm.im.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
|
import com.xuqm.im.cluster.ImClusterPublisher;
|
||||||
import com.xuqm.im.entity.ImGroupEntity;
|
import com.xuqm.im.entity.ImGroupEntity;
|
||||||
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
||||||
|
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.repository.ImGroupJoinRequestRepository;
|
import com.xuqm.im.repository.ImGroupJoinRequestRepository;
|
||||||
|
import com.xuqm.im.repository.ImAccountRepository;
|
||||||
import com.xuqm.im.repository.ImGroupRepository;
|
import com.xuqm.im.repository.ImGroupRepository;
|
||||||
import com.xuqm.im.repository.ImGroupMuteRepository;
|
import com.xuqm.im.repository.ImGroupMuteRepository;
|
||||||
|
import com.xuqm.im.repository.ImMessageRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@ -24,15 +32,24 @@ public class ImGroupService {
|
|||||||
private final ImGroupRepository groupRepository;
|
private final ImGroupRepository groupRepository;
|
||||||
private final ImGroupMuteRepository muteRepository;
|
private final ImGroupMuteRepository muteRepository;
|
||||||
private final ImGroupJoinRequestRepository joinRequestRepository;
|
private final ImGroupJoinRequestRepository joinRequestRepository;
|
||||||
|
private final ImMessageRepository messageRepository;
|
||||||
|
private final ImAccountRepository accountRepository;
|
||||||
|
private final ImClusterPublisher clusterPublisher;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public ImGroupService(ImGroupRepository groupRepository,
|
public ImGroupService(ImGroupRepository groupRepository,
|
||||||
ImGroupMuteRepository muteRepository,
|
ImGroupMuteRepository muteRepository,
|
||||||
ImGroupJoinRequestRepository joinRequestRepository,
|
ImGroupJoinRequestRepository joinRequestRepository,
|
||||||
|
ImMessageRepository messageRepository,
|
||||||
|
ImAccountRepository accountRepository,
|
||||||
|
ImClusterPublisher clusterPublisher,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.muteRepository = muteRepository;
|
this.muteRepository = muteRepository;
|
||||||
this.joinRequestRepository = joinRequestRepository;
|
this.joinRequestRepository = joinRequestRepository;
|
||||||
|
this.messageRepository = messageRepository;
|
||||||
|
this.accountRepository = accountRepository;
|
||||||
|
this.clusterPublisher = clusterPublisher;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +97,28 @@ public class ImGroupService {
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity addMembers(String groupId, List<String> userIds, String operatorId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
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
|
@Transactional
|
||||||
public ImGroupEntity removeMember(String groupId, String userId, String operatorId) {
|
public ImGroupEntity removeMember(String groupId, String userId, String operatorId) {
|
||||||
ImGroupEntity group = get(groupId);
|
ImGroupEntity group = get(groupId);
|
||||||
@ -93,6 +132,28 @@ public class ImGroupService {
|
|||||||
return groupRepository.save(group);
|
return groupRepository.save(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity removeMembers(String groupId, List<String> userIds, String operatorId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
List<String> admins = fromJson(group.getAdminIds());
|
||||||
|
if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) {
|
||||||
|
throw new BusinessException(403, "无权操作");
|
||||||
|
}
|
||||||
|
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
|
@Transactional
|
||||||
public ImGroupEntity update(String groupId, String operatorId, String name, String announcement) {
|
public ImGroupEntity update(String groupId, String operatorId, String name, String announcement) {
|
||||||
ImGroupEntity group = get(groupId);
|
ImGroupEntity group = get(groupId);
|
||||||
@ -189,6 +250,28 @@ public class ImGroupService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<ImGroupEntity> searchGroups(String appId, String keyword, int size) {
|
||||||
|
return groupRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImAccountEntity> listMembers(String appId, String groupId, String requesterId) {
|
||||||
|
ImGroupEntity group = get(groupId, requesterId);
|
||||||
|
return resolveMembers(appId, memberIds(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImAccountEntity> searchMembers(String appId, String groupId, String requesterId, String keyword, int size) {
|
||||||
|
ImGroupEntity group = get(groupId, requesterId);
|
||||||
|
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
|
@Transactional
|
||||||
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
|
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
|
||||||
ImGroupEntity group = get(groupId);
|
ImGroupEntity group = get(groupId);
|
||||||
@ -211,7 +294,17 @@ public class ImGroupService {
|
|||||||
entity.setRemark(remark);
|
entity.setRemark(remark);
|
||||||
entity.setStatus(ImGroupJoinRequestEntity.Status.PENDING.name());
|
entity.setStatus(ImGroupJoinRequestEntity.Status.PENDING.name());
|
||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
return joinRequestRepository.save(entity);
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(entity);
|
||||||
|
publishJoinRequestNotification(
|
||||||
|
group,
|
||||||
|
requesterId,
|
||||||
|
uniqueRecipients(group),
|
||||||
|
"GROUP_JOIN_REQUEST",
|
||||||
|
"入群申请",
|
||||||
|
buildDescription("入群申请", remark),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,9 +321,18 @@ public class ImGroupService {
|
|||||||
ensureCanManage(group, operatorId);
|
ensureCanManage(group, operatorId);
|
||||||
request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name());
|
request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
joinRequestRepository.save(request);
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
addMemberInternal(group, request.getRequesterId());
|
addMemberInternal(group, request.getRequesterId());
|
||||||
return request;
|
publishJoinRequestNotification(
|
||||||
|
group,
|
||||||
|
operatorId,
|
||||||
|
List.of(request.getRequesterId()),
|
||||||
|
"GROUP_JOIN_REQUEST_STATUS",
|
||||||
|
"入群申请已通过",
|
||||||
|
buildDescription("入群申请已通过", null),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@ -240,7 +342,39 @@ public class ImGroupService {
|
|||||||
ensureCanManage(group, operatorId);
|
ensureCanManage(group, operatorId);
|
||||||
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
return joinRequestRepository.save(request);
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
publishJoinRequestNotification(
|
||||||
|
group,
|
||||||
|
operatorId,
|
||||||
|
List.of(request.getRequesterId()),
|
||||||
|
"GROUP_JOIN_REQUEST_STATUS",
|
||||||
|
"入群申请已拒绝",
|
||||||
|
buildDescription("入群申请已拒绝", null),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ImGroupJoinRequestEntity> acceptJoinRequests(String appId, String groupId, List<String> requestIds, String operatorId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
List<ImGroupJoinRequestEntity> result = new ArrayList<>();
|
||||||
|
for (String requestId : unique(requestIds)) {
|
||||||
|
result.add(acceptJoinRequestInternal(appId, group, requestId, operatorId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ImGroupJoinRequestEntity> rejectJoinRequests(String appId, String groupId, List<String> requestIds, String operatorId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
List<ImGroupJoinRequestEntity> result = new ArrayList<>();
|
||||||
|
for (String requestId : unique(requestIds)) {
|
||||||
|
result.add(rejectJoinRequestInternal(appId, group, requestId, operatorId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String toJson(List<String> list) {
|
private String toJson(List<String> list) {
|
||||||
@ -258,6 +392,67 @@ public class ImGroupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> uniqueRecipients(ImGroupEntity group) {
|
||||||
|
LinkedHashSet<String> recipients = new LinkedHashSet<>(fromJson(group.getAdminIds()));
|
||||||
|
recipients.add(group.getCreatorId());
|
||||||
|
return new ArrayList<>(recipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishJoinRequestNotification(
|
||||||
|
ImGroupEntity group,
|
||||||
|
String fromUserId,
|
||||||
|
List<String> recipients,
|
||||||
|
String type,
|
||||||
|
String title,
|
||||||
|
String content,
|
||||||
|
ImGroupJoinRequestEntity request
|
||||||
|
) {
|
||||||
|
for (String recipient : recipients) {
|
||||||
|
if (recipient == null || recipient.isBlank() || recipient.equals(fromUserId)) continue;
|
||||||
|
ImMessageEntity message = new ImMessageEntity();
|
||||||
|
message.setId(UUID.randomUUID().toString());
|
||||||
|
message.setAppId(group.getAppId());
|
||||||
|
message.setFromUserId(fromUserId);
|
||||||
|
message.setToId(recipient);
|
||||||
|
message.setChatType(ImMessageEntity.ChatType.SINGLE);
|
||||||
|
message.setMsgType(ImMessageEntity.MsgType.NOTIFY);
|
||||||
|
message.setContent(buildNotificationContent(type, title, content, request, group));
|
||||||
|
message.setStatus(ImMessageEntity.MsgStatus.SENT);
|
||||||
|
message.setCreatedAt(LocalDateTime.now());
|
||||||
|
ImMessageEntity saved = messageRepository.save(message);
|
||||||
|
clusterPublisher.publish("/user/" + recipient + "/queue/messages", saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildNotificationContent(
|
||||||
|
String type,
|
||||||
|
String title,
|
||||||
|
String content,
|
||||||
|
ImGroupJoinRequestEntity request,
|
||||||
|
ImGroupEntity group
|
||||||
|
) {
|
||||||
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
|
node.put("type", type);
|
||||||
|
node.put("title", title);
|
||||||
|
node.put("content", content);
|
||||||
|
node.put("requestId", request.getId());
|
||||||
|
node.put("groupId", request.getGroupId());
|
||||||
|
node.put("groupName", group.getName());
|
||||||
|
node.put("requesterId", request.getRequesterId());
|
||||||
|
node.put("status", request.getStatus());
|
||||||
|
if (request.getRemark() != null) {
|
||||||
|
node.put("remark", request.getRemark());
|
||||||
|
}
|
||||||
|
return node.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDescription(String prefix, String remark) {
|
||||||
|
if (remark == null || remark.isBlank()) {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
return prefix + ":" + remark;
|
||||||
|
}
|
||||||
|
|
||||||
private void addMemberInternal(ImGroupEntity group, String userId) {
|
private void addMemberInternal(ImGroupEntity group, String userId) {
|
||||||
List<String> members = fromJson(group.getMemberIds());
|
List<String> members = fromJson(group.getMemberIds());
|
||||||
if (!members.contains(userId)) {
|
if (!members.contains(userId)) {
|
||||||
@ -276,7 +471,62 @@ public class ImGroupService {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ImGroupJoinRequestEntity acceptJoinRequestInternal(String appId, ImGroupEntity group, String requestId, String operatorId) {
|
||||||
|
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
|
||||||
|
if (!group.getId().equals(request.getGroupId())) {
|
||||||
|
throw new BusinessException(400, "加群申请不属于当前群");
|
||||||
|
}
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
addMemberInternal(group, request.getRequesterId());
|
||||||
|
publishJoinRequestNotification(
|
||||||
|
group,
|
||||||
|
operatorId,
|
||||||
|
List.of(request.getRequesterId()),
|
||||||
|
"GROUP_JOIN_REQUEST_STATUS",
|
||||||
|
"入群申请已通过",
|
||||||
|
buildDescription("入群申请已通过", null),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImGroupJoinRequestEntity rejectJoinRequestInternal(String appId, ImGroupEntity group, String requestId, String operatorId) {
|
||||||
|
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
|
||||||
|
if (!group.getId().equals(request.getGroupId())) {
|
||||||
|
throw new BusinessException(400, "加群申请不属于当前群");
|
||||||
|
}
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
publishJoinRequestNotification(
|
||||||
|
group,
|
||||||
|
operatorId,
|
||||||
|
List.of(request.getRequesterId()),
|
||||||
|
"GROUP_JOIN_REQUEST_STATUS",
|
||||||
|
"入群申请已拒绝",
|
||||||
|
buildDescription("入群申请已拒绝", null),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
private String normalizeGroupType(String groupType) {
|
private String normalizeGroupType(String groupType) {
|
||||||
return (groupType == null || groupType.isBlank()) ? "WORK" : groupType.trim().toUpperCase();
|
return (groupType == null || groupType.isBlank()) ? "WORK" : groupType.trim().toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> unique(List<String> values) {
|
||||||
|
return values == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ImAccountEntity> resolveMembers(String appId, List<String> ids) {
|
||||||
|
List<ImAccountEntity> members = new ArrayList<>();
|
||||||
|
for (String userId : ids == null ? List.<String>of() : ids) {
|
||||||
|
accountRepository.findByAppIdAndUserId(appId, userId).ifPresent(members::add);
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,13 +7,15 @@ 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.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.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 com.xuqm.im.repository.WebhookConfigRepository;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.scheduling.annotation.Async;
|
|
||||||
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;
|
||||||
@ -22,13 +24,19 @@ import java.net.URI;
|
|||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
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 {
|
||||||
@ -46,6 +54,7 @@ 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 ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${im.webhook-timeout-ms:3000}")
|
@Value("${im.webhook-timeout-ms:3000}")
|
||||||
@ -62,6 +71,7 @@ public class MessageService {
|
|||||||
ImPushBridgeClient pushBridgeClient,
|
ImPushBridgeClient pushBridgeClient,
|
||||||
ImFeatureConfigClient featureConfigClient,
|
ImFeatureConfigClient featureConfigClient,
|
||||||
ImFriendRepository friendRepository,
|
ImFriendRepository friendRepository,
|
||||||
|
ImAppSecretClient appSecretClient,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.webhookRepository = webhookRepository;
|
this.webhookRepository = webhookRepository;
|
||||||
@ -74,6 +84,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.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,7 +165,7 @@ public class MessageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchWebhooks(appId, saved);
|
dispatchWebhooks(appId, "message.sent", saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,6 +198,62 @@ public class MessageService {
|
|||||||
log.debug("revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
|
log.debug("revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
|
||||||
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
||||||
}
|
}
|
||||||
|
dispatchWebhooks(appId, "message.revoked", saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImMessageEntity edit(String appId, String messageId, String requestUserId, EditMessageRequest req) {
|
||||||
|
ImMessageEntity message = messageRepository.findById(messageId)
|
||||||
|
.orElseThrow(() -> new BusinessException(404, "消息不存在"));
|
||||||
|
if (!message.getAppId().equals(appId)) {
|
||||||
|
throw new BusinessException(403, "无权操作");
|
||||||
|
}
|
||||||
|
if (!message.getFromUserId().equals(requestUserId)) {
|
||||||
|
throw new BusinessException(403, "只能编辑自己发送的消息");
|
||||||
|
}
|
||||||
|
if (message.getStatus() == ImMessageEntity.MsgStatus.REVOKED || message.getMsgType() == ImMessageEntity.MsgType.REVOKED) {
|
||||||
|
throw new BusinessException(400, "已撤回消息不能编辑");
|
||||||
|
}
|
||||||
|
if (message.getMsgType() != ImMessageEntity.MsgType.TEXT) {
|
||||||
|
throw new BusinessException(400, "仅支持编辑文本消息");
|
||||||
|
}
|
||||||
|
|
||||||
|
String content = keywordFilterService.filter(appId, req.content());
|
||||||
|
if (content == null) {
|
||||||
|
throw new BusinessException("消息包含违禁内容");
|
||||||
|
}
|
||||||
|
|
||||||
|
message.setContent(content);
|
||||||
|
message.setEditedAt(LocalDateTime.now());
|
||||||
|
ImMessageEntity saved = messageRepository.save(message);
|
||||||
|
|
||||||
|
if (saved.getChatType() == ImMessageEntity.ChatType.SINGLE) {
|
||||||
|
clusterPublisher.publish("/user/" + saved.getToId() + "/queue/messages", saved);
|
||||||
|
if (!saved.getFromUserId().equals(saved.getToId())) {
|
||||||
|
clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved);
|
||||||
|
}
|
||||||
|
pushBridgeClient.notifyUsers(
|
||||||
|
appId,
|
||||||
|
List.of(saved.getToId()),
|
||||||
|
"消息已编辑",
|
||||||
|
saved.getContent(),
|
||||||
|
buildPushPayload(saved)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
||||||
|
List<String> memberIds = groupService.memberIds(groupService.get(saved.getToId()));
|
||||||
|
pushBridgeClient.notifyUsers(
|
||||||
|
appId,
|
||||||
|
memberIds.stream()
|
||||||
|
.filter(memberId -> !memberId.equals(saved.getFromUserId()))
|
||||||
|
.toList(),
|
||||||
|
"群消息已编辑",
|
||||||
|
saved.getContent(),
|
||||||
|
buildPushPayload(saved)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchWebhooks(appId, "message.edited", saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,6 +277,7 @@ public class MessageService {
|
|||||||
log.debug("admin revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
|
log.debug("admin revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
|
||||||
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
||||||
}
|
}
|
||||||
|
dispatchWebhooks(appId, "message.revoked", saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,14 +326,26 @@ public class MessageService {
|
|||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
List<String> messageIds = new java.util.ArrayList<>();
|
||||||
for (ImMessageEntity message : messages) {
|
for (ImMessageEntity message : messages) {
|
||||||
if (message.getStatus() == ImMessageEntity.MsgStatus.READ) {
|
if (message.getStatus() == ImMessageEntity.MsgStatus.READ) {
|
||||||
|
messageIds.add(message.getId());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
message.setStatus(ImMessageEntity.MsgStatus.READ);
|
message.setStatus(ImMessageEntity.MsgStatus.READ);
|
||||||
ImMessageEntity saved = messageRepository.save(message);
|
ImMessageEntity saved = messageRepository.save(message);
|
||||||
clusterPublisher.publish("/user/" + peerId + "/queue/messages", saved);
|
clusterPublisher.publish("/user/" + peerId + "/queue/messages", saved);
|
||||||
|
messageIds.add(saved.getId());
|
||||||
}
|
}
|
||||||
|
dispatchWebhooks(appId, "message.read", new MessageReadCallbackPayload(
|
||||||
|
appId,
|
||||||
|
readerId,
|
||||||
|
peerId,
|
||||||
|
null,
|
||||||
|
chatType,
|
||||||
|
toEpochMillis(readAt),
|
||||||
|
messageIds
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void syncGroupReadReceipt(String appId, String readerId, String groupId, LocalDateTime readAt) {
|
public void syncGroupReadReceipt(String appId, String readerId, String groupId, LocalDateTime readAt) {
|
||||||
@ -279,10 +359,21 @@ public class MessageService {
|
|||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
List<String> messageIds = new java.util.ArrayList<>();
|
||||||
for (ImMessageEntity message : messages) {
|
for (ImMessageEntity message : messages) {
|
||||||
message.setGroupReadCount(groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()));
|
message.setGroupReadCount(groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()));
|
||||||
clusterPublisher.publish("/topic/group/" + groupId, message);
|
clusterPublisher.publish("/topic/group/" + groupId, message);
|
||||||
|
messageIds.add(message.getId());
|
||||||
}
|
}
|
||||||
|
dispatchWebhooks(appId, "message.read", new MessageReadCallbackPayload(
|
||||||
|
appId,
|
||||||
|
readerId,
|
||||||
|
null,
|
||||||
|
groupId,
|
||||||
|
ImMessageEntity.ChatType.GROUP.name(),
|
||||||
|
toEpochMillis(readAt),
|
||||||
|
messageIds
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<ImMessageEntity> adminHistory(
|
public Page<ImMessageEntity> adminHistory(
|
||||||
@ -365,14 +456,17 @@ public class MessageService {
|
|||||||
|
|
||||||
private String buildPushPayload(ImMessageEntity message) {
|
private String buildPushPayload(ImMessageEntity message) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.writeValueAsString(Map.of(
|
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
||||||
"messageId", message.getId(),
|
payload.put("messageId", message.getId());
|
||||||
"appId", message.getAppId(),
|
payload.put("appId", message.getAppId());
|
||||||
"fromUserId", message.getFromUserId(),
|
payload.put("fromUserId", message.getFromUserId());
|
||||||
"toId", message.getToId(),
|
payload.put("toId", message.getToId());
|
||||||
"chatType", message.getChatType().name(),
|
payload.put("chatType", message.getChatType().name());
|
||||||
"msgType", message.getMsgType().name()
|
payload.put("msgType", message.getMsgType().name());
|
||||||
));
|
if (message.getEditedAt() != null) {
|
||||||
|
payload.put("editedAt", message.getEditedAt().toInstant(ZoneOffset.UTC).toEpochMilli());
|
||||||
|
}
|
||||||
|
return objectMapper.writeValueAsString(payload);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return "{}";
|
return "{}";
|
||||||
}
|
}
|
||||||
@ -430,26 +524,74 @@ public class MessageService {
|
|||||||
return java.util.Optional.empty();
|
return java.util.Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Async
|
protected void dispatchWebhooks(String appId, String callbackEvent, ImMessageEntity message) {
|
||||||
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
|
dispatchWebhooks(appId, callbackEvent, (Object) message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) {
|
||||||
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
||||||
if (webhooks.isEmpty()) return;
|
if (webhooks.isEmpty()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String body = objectMapper.writeValueAsString(message);
|
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();
|
HttpClient client = HttpClient.newHttpClient();
|
||||||
for (WebhookConfigEntity webhook : webhooks) {
|
for (WebhookConfigEntity webhook : webhooks) {
|
||||||
try {
|
try {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(webhook.getUrl()))
|
.uri(URI.create(webhook.getUrl()))
|
||||||
.header("Content-Type", "application/json")
|
.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))
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
.build();
|
.build();
|
||||||
client.send(request, HttpResponse.BodyHandlers.ofString());
|
client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
} catch (Exception ignored) {
|
} catch (Exception e) {
|
||||||
|
log.warn("dispatch webhook failed appId={} url={} event={} reason={}",
|
||||||
|
appId, webhook.getUrl(), callbackEvent, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.xuqm.im.service;
|
||||||
|
|
||||||
|
import com.xuqm.im.entity.ImOperationLogEntity;
|
||||||
|
import com.xuqm.im.repository.ImOperationLogRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OperationLogService {
|
||||||
|
|
||||||
|
private final ImOperationLogRepository repository;
|
||||||
|
|
||||||
|
public OperationLogService(ImOperationLogRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImOperationLogEntity record(
|
||||||
|
String appId,
|
||||||
|
String operatorId,
|
||||||
|
String action,
|
||||||
|
String resourceType,
|
||||||
|
String resourceId,
|
||||||
|
String detail) {
|
||||||
|
ImOperationLogEntity entity = new ImOperationLogEntity();
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
entity.setAppId(appId);
|
||||||
|
entity.setOperatorId(operatorId);
|
||||||
|
entity.setAction(action);
|
||||||
|
entity.setResourceType(resourceType);
|
||||||
|
entity.setResourceId(resourceId);
|
||||||
|
entity.setDetail(detail);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
return repository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<ImOperationLogEntity> list(String appId, Pageable pageable) {
|
||||||
|
return repository.findByAppIdOrderByCreatedAtDesc(appId, pageable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,8 +3,12 @@ package com.xuqm.update;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.context.annotation.ComponentScan;
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
|
@EnableAsync
|
||||||
@ComponentScan(basePackages = {"com.xuqm.update", "com.xuqm.common"})
|
@ComponentScan(basePackages = {"com.xuqm.update", "com.xuqm.common"})
|
||||||
public class UpdateServiceApplication {
|
public class UpdateServiceApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@ -0,0 +1,134 @@
|
|||||||
|
package com.xuqm.update.controller;
|
||||||
|
|
||||||
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.update.entity.AppStoreConfigEntity;
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.service.AppStoreService;
|
||||||
|
import com.xuqm.update.service.StoreSubmissionService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App store distribution channel management.
|
||||||
|
*
|
||||||
|
* Store configs (credentials) are managed here and fetched by the release script.
|
||||||
|
* Actual APK/IPA submission to stores is performed by the release script on the
|
||||||
|
* developer's machine; this controller records the outcome and drives notifications.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/updates/store")
|
||||||
|
public class AppStoreController {
|
||||||
|
|
||||||
|
private final AppStoreService storeService;
|
||||||
|
private final StoreSubmissionService submissionService;
|
||||||
|
|
||||||
|
public AppStoreController(AppStoreService storeService, StoreSubmissionService submissionService) {
|
||||||
|
this.storeService = storeService;
|
||||||
|
this.submissionService = submissionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store credential config ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/configs")
|
||||||
|
public ResponseEntity<ApiResponse<List<AppStoreConfigEntity>>> getConfigs(@RequestParam String appId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(storeService.getConfigs(appId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert credentials for one store channel.
|
||||||
|
*
|
||||||
|
* Body: { "configJson": "{\"clientId\":\"...\",\"clientSecret\":\"...\"}", "enabled": true }
|
||||||
|
*/
|
||||||
|
@PutMapping("/configs/{storeType}")
|
||||||
|
public ResponseEntity<ApiResponse<AppStoreConfigEntity>> saveConfig(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable AppStoreConfigEntity.StoreType storeType,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
|
||||||
|
String configJson = body.get("configJson") instanceof String s ? s : null;
|
||||||
|
boolean enabled = !Boolean.FALSE.equals(body.get("enabled"));
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
storeService.saveConfig(appId, storeType, configJson, enabled)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/configs/{storeType}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteConfig(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@PathVariable AppStoreConfigEntity.StoreType storeType) {
|
||||||
|
storeService.deleteConfig(appId, storeType);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns enabled store credentials for the given appId.
|
||||||
|
* Called by the release script so it can submit to stores without storing secrets locally.
|
||||||
|
*/
|
||||||
|
@GetMapping("/credentials")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getCredentials(@RequestParam String appId) throws Exception {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(storeService.getStoreCredentials(appId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Version store submission ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a version as submitted to specific stores and initialise review tracking.
|
||||||
|
*
|
||||||
|
* Body: { "storeTypes": ["HUAWEI", "MI", "OPPO"] }
|
||||||
|
*
|
||||||
|
* The release script calls this after successfully uploading to each store's API.
|
||||||
|
*/
|
||||||
|
@PostMapping("/app/{versionId}/submit")
|
||||||
|
public ResponseEntity<ApiResponse<AppVersionEntity>> markSubmitted(
|
||||||
|
@PathVariable String versionId,
|
||||||
|
@RequestBody Map<String, Object> body) throws Exception {
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> storeTypes = (List<String>) body.get("storeTypes");
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(storeService.markSubmitted(versionId, storeTypes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execute submission (server calls store APIs) ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger server-side submission to app stores.
|
||||||
|
* The server fetches the local APK, calls each store's API, and updates review status.
|
||||||
|
* Runs asynchronously — returns immediately.
|
||||||
|
*
|
||||||
|
* Body: { "storeTypes": ["HUAWEI", "MI"] } — optional, overrides version's saved targets.
|
||||||
|
*/
|
||||||
|
@PostMapping("/app/{versionId}/execute-submit")
|
||||||
|
public ResponseEntity<ApiResponse<AppVersionEntity>> executeSubmit(
|
||||||
|
@PathVariable String versionId,
|
||||||
|
@RequestBody(required = false) Map<String, Object> body) throws Exception {
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> storeTypes = body != null ? (List<String>) body.get("storeTypes") : null;
|
||||||
|
AppVersionEntity v = storeService.markSubmitted(versionId,
|
||||||
|
storeTypes != null ? storeTypes : List.of());
|
||||||
|
submissionService.executeSubmitAsync(versionId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review status update (from store webhook or manual) ──────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the review state for a single store channel.
|
||||||
|
* Stores may call this via webhook, or developers can call it manually.
|
||||||
|
*
|
||||||
|
* Body: { "storeType": "HUAWEI", "state": "APPROVED" }
|
||||||
|
*/
|
||||||
|
@PostMapping("/app/{versionId}/review")
|
||||||
|
public ResponseEntity<ApiResponse<AppVersionEntity>> updateReview(
|
||||||
|
@PathVariable String versionId,
|
||||||
|
@RequestBody Map<String, Object> body) throws Exception {
|
||||||
|
|
||||||
|
String storeType = (String) body.get("storeType");
|
||||||
|
AppVersionEntity.StoreReviewState state =
|
||||||
|
AppVersionEntity.StoreReviewState.valueOf((String) body.get("state"));
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
storeService.updateStoreReview(versionId, storeType, state)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,35 +3,27 @@ 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 org.springframework.beans.factory.annotation.Value;
|
|
||||||
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;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/updates")
|
@RequestMapping("/api/v1/updates")
|
||||||
public class AppVersionController {
|
public class AppVersionController {
|
||||||
|
|
||||||
private final AppVersionRepository versionRepository;
|
private final AppVersionRepository versionRepository;
|
||||||
|
private final UpdateAssetService updateAssetService;
|
||||||
|
|
||||||
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService) {
|
||||||
private String uploadDir;
|
|
||||||
|
|
||||||
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
|
||||||
private String baseUrl;
|
|
||||||
|
|
||||||
public AppVersionController(AppVersionRepository versionRepository) {
|
|
||||||
this.versionRepository = versionRepository;
|
this.versionRepository = versionRepository;
|
||||||
|
this.updateAssetService = updateAssetService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/app/check")
|
@GetMapping("/app/check")
|
||||||
@ -69,17 +61,12 @@ public class AppVersionController {
|
|||||||
@RequestParam int versionCode,
|
@RequestParam int 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) throws IOException {
|
@RequestParam(required = false) MultipartFile apkFile,
|
||||||
|
@RequestParam(required = false) String scheduledPublishAt,
|
||||||
String downloadUrl = null;
|
@RequestParam(required = false) String webhookUrl,
|
||||||
if (apkFile != null && !apkFile.isEmpty()) {
|
@RequestParam(required = false) String storeSubmitTargets,
|
||||||
String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename();
|
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
|
||||||
Path dir = Paths.get(uploadDir, "apk");
|
@RequestParam(required = false) String packageName) throws Exception {
|
||||||
Files.createDirectories(dir);
|
|
||||||
Path dest = dir.resolve(filename);
|
|
||||||
apkFile.transferTo(dest.toFile());
|
|
||||||
downloadUrl = baseUrl + "/files/apk/" + filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
AppVersionEntity entity = new AppVersionEntity();
|
AppVersionEntity entity = new AppVersionEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
entity.setId(UUID.randomUUID().toString());
|
||||||
@ -87,11 +74,18 @@ public class AppVersionController {
|
|||||||
entity.setPlatform(platform);
|
entity.setPlatform(platform);
|
||||||
entity.setVersionName(versionName);
|
entity.setVersionName(versionName);
|
||||||
entity.setVersionCode(versionCode);
|
entity.setVersionCode(versionCode);
|
||||||
entity.setDownloadUrl(downloadUrl);
|
entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile));
|
||||||
entity.setChangeLog(changeLog);
|
entity.setChangeLog(changeLog);
|
||||||
entity.setForceUpdate(forceUpdate);
|
entity.setForceUpdate(forceUpdate);
|
||||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
||||||
|
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
|
||||||
|
}
|
||||||
|
entity.setWebhookUrl(webhookUrl);
|
||||||
|
entity.setStoreSubmitTargets(storeSubmitTargets);
|
||||||
|
entity.setAutoPublishAfterReview(autoPublishAfterReview);
|
||||||
|
entity.setPackageName(packageName);
|
||||||
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
|
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,33 +8,26 @@ 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;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.security.DigestInputStream;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/rn")
|
@RequestMapping("/api/v1/rn")
|
||||||
public class RnBundleController {
|
public class RnBundleController {
|
||||||
|
|
||||||
private final RnBundleRepository bundleRepository;
|
private final RnBundleRepository bundleRepository;
|
||||||
|
private final UpdateAssetService updateAssetService;
|
||||||
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
|
||||||
private String uploadDir;
|
|
||||||
|
|
||||||
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
public RnBundleController(RnBundleRepository bundleRepository) {
|
public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService) {
|
||||||
this.bundleRepository = bundleRepository;
|
this.bundleRepository = bundleRepository;
|
||||||
|
this.updateAssetService = updateAssetService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/update/check")
|
@GetMapping("/update/check")
|
||||||
@ -74,14 +67,8 @@ public class RnBundleController {
|
|||||||
@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 {
|
||||||
|
UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle(
|
||||||
String filename = moduleId + "." + platform.name().toLowerCase() + ".bundle";
|
appId, platform.name(), moduleId, bundle);
|
||||||
Path dir = Paths.get(uploadDir, "rn", appId, platform.name().toLowerCase(), moduleId);
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
Path dest = dir.resolve(filename);
|
|
||||||
|
|
||||||
String md5 = computeMd5(bundle);
|
|
||||||
bundle.transferTo(dest.toFile());
|
|
||||||
|
|
||||||
RnBundleEntity entity = new RnBundleEntity();
|
RnBundleEntity entity = new RnBundleEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
entity.setId(UUID.randomUUID().toString());
|
||||||
@ -89,8 +76,8 @@ public class RnBundleController {
|
|||||||
entity.setModuleId(moduleId);
|
entity.setModuleId(moduleId);
|
||||||
entity.setPlatform(platform);
|
entity.setPlatform(platform);
|
||||||
entity.setVersion(version);
|
entity.setVersion(version);
|
||||||
entity.setBundleUrl(dest.toAbsolutePath().toString());
|
entity.setBundleUrl(stored.bundlePath());
|
||||||
entity.setMd5(md5);
|
entity.setMd5(stored.md5());
|
||||||
entity.setMinCommonVersion(minCommonVersion);
|
entity.setMinCommonVersion(minCommonVersion);
|
||||||
entity.setNote(note);
|
entity.setNote(note);
|
||||||
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||||
@ -142,15 +129,6 @@ public class RnBundleController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String computeMd5(MultipartFile file) throws Exception {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
|
||||||
try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) {
|
|
||||||
byte[] buf = new byte[8192];
|
|
||||||
while (dis.read(buf) != -1) {}
|
|
||||||
}
|
|
||||||
return HexFormat.of().formatHex(digest.digest());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolvePublicBaseUrl() {
|
private String resolvePublicBaseUrl() {
|
||||||
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||||
String suffix = "/api/v1/updates";
|
String suffix = "/api/v1/updates";
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
package com.xuqm.update.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.entity.RnBundleEntity;
|
||||||
|
import com.xuqm.update.model.UnifiedReleaseManifest;
|
||||||
|
import com.xuqm.update.model.UnifiedReleaseResult;
|
||||||
|
import com.xuqm.update.repository.AppVersionRepository;
|
||||||
|
import com.xuqm.update.repository.RnBundleRepository;
|
||||||
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/updates")
|
||||||
|
public class UnifiedReleaseController {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AppVersionRepository appVersionRepository;
|
||||||
|
private final RnBundleRepository rnBundleRepository;
|
||||||
|
private final UpdateAssetService updateAssetService;
|
||||||
|
|
||||||
|
public UnifiedReleaseController(
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
AppVersionRepository appVersionRepository,
|
||||||
|
RnBundleRepository rnBundleRepository,
|
||||||
|
UpdateAssetService updateAssetService) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.appVersionRepository = appVersionRepository;
|
||||||
|
this.rnBundleRepository = rnBundleRepository;
|
||||||
|
this.updateAssetService = updateAssetService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/unified/upload")
|
||||||
|
public ResponseEntity<ApiResponse<UnifiedReleaseResult>> upload(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam String manifest,
|
||||||
|
HttpServletRequest request) throws Exception {
|
||||||
|
|
||||||
|
if (!(request instanceof MultipartHttpServletRequest multipartRequest)) {
|
||||||
|
throw new IllegalArgumentException("multipart request required");
|
||||||
|
}
|
||||||
|
|
||||||
|
UnifiedReleaseManifest unifiedReleaseManifest =
|
||||||
|
objectMapper.readValue(manifest, UnifiedReleaseManifest.class);
|
||||||
|
|
||||||
|
List<AppVersionEntity> appVersions = new ArrayList<>();
|
||||||
|
for (UnifiedReleaseManifest.AppUploadItem item : safeList(unifiedReleaseManifest.appVersions())) {
|
||||||
|
MultipartFile file = multipartRequest.getFile(item.fileKey());
|
||||||
|
AppVersionEntity entity = new AppVersionEntity();
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
entity.setAppId(appId);
|
||||||
|
entity.setPlatform(item.platform());
|
||||||
|
entity.setVersionName(item.versionName());
|
||||||
|
entity.setVersionCode(item.versionCode());
|
||||||
|
entity.setChangeLog(item.changeLog());
|
||||||
|
entity.setForceUpdate(item.forceUpdate());
|
||||||
|
entity.setAppStoreUrl(item.appStoreUrl());
|
||||||
|
entity.setMarketUrl(item.marketUrl());
|
||||||
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
entity.setDownloadUrl(updateAssetService.storeAppPackage(file));
|
||||||
|
appVersions.add(appVersionRepository.save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RnBundleEntity> rnBundles = new ArrayList<>();
|
||||||
|
for (UnifiedReleaseManifest.RnBundleUploadItem item : safeList(unifiedReleaseManifest.rnBundles())) {
|
||||||
|
MultipartFile file = multipartRequest.getFile(item.fileKey());
|
||||||
|
UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle(
|
||||||
|
appId,
|
||||||
|
item.platform().name(),
|
||||||
|
item.moduleId(),
|
||||||
|
file);
|
||||||
|
|
||||||
|
RnBundleEntity entity = new RnBundleEntity();
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
entity.setAppId(appId);
|
||||||
|
entity.setModuleId(item.moduleId());
|
||||||
|
entity.setPlatform(item.platform());
|
||||||
|
entity.setVersion(item.version());
|
||||||
|
entity.setBundleUrl(stored.bundlePath());
|
||||||
|
entity.setMd5(stored.md5());
|
||||||
|
entity.setMinCommonVersion(item.minCommonVersion());
|
||||||
|
entity.setNote(item.note());
|
||||||
|
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
rnBundles.add(rnBundleRepository.save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new UnifiedReleaseResult(appVersions, rnBundles)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> List<T> safeList(List<T> input) {
|
||||||
|
return input == null ? List.of() : input;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package com.xuqm.update.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "update_store_config", uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = {"appId", "storeType"})
|
||||||
|
})
|
||||||
|
public class AppStoreConfigEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported distribution channels.
|
||||||
|
* Android: HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY
|
||||||
|
* iOS: APP_STORE
|
||||||
|
*/
|
||||||
|
public enum StoreType {
|
||||||
|
HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY, APP_STORE;
|
||||||
|
|
||||||
|
public boolean isAndroid() {
|
||||||
|
return this != APP_STORE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 16)
|
||||||
|
private StoreType storeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store-specific credentials stored as a flat JSON object.
|
||||||
|
*
|
||||||
|
* HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."}
|
||||||
|
* MI: {"username":"...","privateKey":"..."}
|
||||||
|
* OPPO: {"clientId":"...","clientSecret":"..."}
|
||||||
|
* VIVO: {"accessKey":"...","accessSecret":"..."}
|
||||||
|
* GOOGLE_PLAY: {"serviceAccountJson":"..."}
|
||||||
|
* APP_STORE: {"teamId":"...","keyId":"...","privateKey":"...","bundleId":"..."}
|
||||||
|
*/
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String configJson;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public void setId(String id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getAppId() { return appId; }
|
||||||
|
public void setAppId(String appId) { this.appId = appId; }
|
||||||
|
|
||||||
|
public StoreType getStoreType() { return storeType; }
|
||||||
|
public void setStoreType(StoreType storeType) { this.storeType = storeType; }
|
||||||
|
|
||||||
|
public String getConfigJson() { return configJson; }
|
||||||
|
public void setConfigJson(String configJson) { this.configJson = configJson; }
|
||||||
|
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@ public class AppVersionEntity {
|
|||||||
|
|
||||||
public enum Platform { ANDROID, IOS }
|
public enum Platform { ANDROID, IOS }
|
||||||
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
|
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
|
||||||
|
/** Per-store review state used in storeReviewStatus JSON values. */
|
||||||
|
public enum StoreReviewState { PENDING, UNDER_REVIEW, APPROVED, REJECTED }
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
private String id;
|
private String id;
|
||||||
@ -56,6 +58,35 @@ public class AppVersionEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private int grayPercent = 0;
|
private int grayPercent = 0;
|
||||||
|
|
||||||
|
/** Optional: publish automatically at this UTC time. Null means manual publish. */
|
||||||
|
private LocalDateTime scheduledPublishAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON array of StoreType names to submit to, e.g. ["HUAWEI","MI","OPPO"].
|
||||||
|
* Null or empty means no store submission.
|
||||||
|
*/
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String storeSubmitTargets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON map of StoreType -> StoreReviewState, e.g. {"HUAWEI":"UNDER_REVIEW","MI":"APPROVED"}.
|
||||||
|
* Updated by the store webhook endpoint.
|
||||||
|
*/
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String storeReviewStatus;
|
||||||
|
|
||||||
|
/** When true, publishStatus flips to PUBLISHED once every targeted store reaches APPROVED. */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean autoPublishAfterReview = false;
|
||||||
|
|
||||||
|
/** Webhook URL that receives review-status change notifications from this service. */
|
||||||
|
@Column(length = 512)
|
||||||
|
private String webhookUrl;
|
||||||
|
|
||||||
|
/** App package name / bundle identifier, e.g. com.example.myapp */
|
||||||
|
@Column(length = 256)
|
||||||
|
private String packageName;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@ -98,6 +129,24 @@ public class AppVersionEntity {
|
|||||||
public int getGrayPercent() { return grayPercent; }
|
public int getGrayPercent() { return grayPercent; }
|
||||||
public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; }
|
public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; }
|
||||||
|
|
||||||
|
public String getPackageName() { return packageName; }
|
||||||
|
public void setPackageName(String packageName) { this.packageName = packageName; }
|
||||||
|
|
||||||
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; }
|
||||||
|
|
||||||
|
public LocalDateTime getScheduledPublishAt() { return scheduledPublishAt; }
|
||||||
|
public void setScheduledPublishAt(LocalDateTime scheduledPublishAt) { this.scheduledPublishAt = scheduledPublishAt; }
|
||||||
|
|
||||||
|
public String getStoreSubmitTargets() { return storeSubmitTargets; }
|
||||||
|
public void setStoreSubmitTargets(String storeSubmitTargets) { this.storeSubmitTargets = storeSubmitTargets; }
|
||||||
|
|
||||||
|
public String getStoreReviewStatus() { return storeReviewStatus; }
|
||||||
|
public void setStoreReviewStatus(String storeReviewStatus) { this.storeReviewStatus = storeReviewStatus; }
|
||||||
|
|
||||||
|
public boolean isAutoPublishAfterReview() { return autoPublishAfterReview; }
|
||||||
|
public void setAutoPublishAfterReview(boolean autoPublishAfterReview) { this.autoPublishAfterReview = autoPublishAfterReview; }
|
||||||
|
|
||||||
|
public String getWebhookUrl() { return webhookUrl; }
|
||||||
|
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.xuqm.update.model;
|
||||||
|
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.entity.RnBundleEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record UnifiedReleaseManifest(
|
||||||
|
List<AppUploadItem> appVersions,
|
||||||
|
List<RnBundleUploadItem> rnBundles) {
|
||||||
|
|
||||||
|
public record AppUploadItem(
|
||||||
|
String fileKey,
|
||||||
|
AppVersionEntity.Platform platform,
|
||||||
|
String versionName,
|
||||||
|
int versionCode,
|
||||||
|
String changeLog,
|
||||||
|
boolean forceUpdate,
|
||||||
|
String appStoreUrl,
|
||||||
|
String marketUrl) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RnBundleUploadItem(
|
||||||
|
String fileKey,
|
||||||
|
String moduleId,
|
||||||
|
RnBundleEntity.Platform platform,
|
||||||
|
String version,
|
||||||
|
String minCommonVersion,
|
||||||
|
String note) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.xuqm.update.model;
|
||||||
|
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.entity.RnBundleEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record UnifiedReleaseResult(
|
||||||
|
List<AppVersionEntity> appVersions,
|
||||||
|
List<RnBundleEntity> rnBundles) {
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.xuqm.update.repository;
|
||||||
|
|
||||||
|
import com.xuqm.update.entity.AppStoreConfigEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface AppStoreConfigRepository extends JpaRepository<AppStoreConfigEntity, String> {
|
||||||
|
|
||||||
|
List<AppStoreConfigEntity> findByAppId(String appId);
|
||||||
|
|
||||||
|
List<AppStoreConfigEntity> findByAppIdAndEnabled(String appId, boolean enabled);
|
||||||
|
|
||||||
|
Optional<AppStoreConfigEntity> findByAppIdAndStoreType(String appId, AppStoreConfigEntity.StoreType storeType);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package com.xuqm.update.repository;
|
|||||||
import com.xuqm.update.entity.AppVersionEntity;
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@ -11,4 +12,6 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
|
|||||||
String appId, AppVersionEntity.Platform platform);
|
String appId, AppVersionEntity.Platform platform);
|
||||||
Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc(
|
Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc(
|
||||||
String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status);
|
String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status);
|
||||||
|
List<AppVersionEntity> findByPublishStatusAndScheduledPublishAtBefore(
|
||||||
|
AppVersionEntity.PublishStatus status, LocalDateTime before);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,182 @@
|
|||||||
|
package com.xuqm.update.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.xuqm.update.entity.AppStoreConfigEntity;
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.repository.AppStoreConfigRepository;
|
||||||
|
import com.xuqm.update.repository.AppVersionRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AppStoreService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AppStoreService.class);
|
||||||
|
private static final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
private final HttpClient http = HttpClient.newHttpClient();
|
||||||
|
|
||||||
|
private final AppStoreConfigRepository configRepo;
|
||||||
|
private final AppVersionRepository versionRepo;
|
||||||
|
|
||||||
|
public AppStoreService(AppStoreConfigRepository configRepo, AppVersionRepository versionRepo) {
|
||||||
|
this.configRepo = configRepo;
|
||||||
|
this.versionRepo = versionRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store config CRUD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public List<AppStoreConfigEntity> getConfigs(String appId) {
|
||||||
|
return configRepo.findByAppId(appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppStoreConfigEntity saveConfig(String appId,
|
||||||
|
AppStoreConfigEntity.StoreType storeType,
|
||||||
|
String configJson,
|
||||||
|
boolean enabled) {
|
||||||
|
AppStoreConfigEntity entity = configRepo
|
||||||
|
.findByAppIdAndStoreType(appId, storeType)
|
||||||
|
.orElseGet(AppStoreConfigEntity::new);
|
||||||
|
|
||||||
|
if (entity.getId() == null) {
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
entity.setAppId(appId);
|
||||||
|
entity.setStoreType(storeType);
|
||||||
|
}
|
||||||
|
entity.setConfigJson(configJson);
|
||||||
|
entity.setEnabled(enabled);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return configRepo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteConfig(String appId, AppStoreConfigEntity.StoreType storeType) {
|
||||||
|
configRepo.findByAppIdAndStoreType(appId, storeType).ifPresent(configRepo::delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store submission ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a version as submitted to the given stores and initialise review status to PENDING.
|
||||||
|
* Actual submission to store APIs must be performed by the release script on the developer
|
||||||
|
* machine (it has the APK/IPA file). This endpoint records the intent and provides the
|
||||||
|
* credentials the script needs.
|
||||||
|
*/
|
||||||
|
public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception {
|
||||||
|
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
||||||
|
|
||||||
|
Map<String, String> reviewMap = new LinkedHashMap<>();
|
||||||
|
for (String store : storeTypes) {
|
||||||
|
reviewMap.put(store, AppVersionEntity.StoreReviewState.PENDING.name());
|
||||||
|
}
|
||||||
|
v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes));
|
||||||
|
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
|
||||||
|
return versionRepo.save(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch enabled store credentials for use by the release script.
|
||||||
|
* Returns a map of storeType -> configJson (as parsed map, not raw string).
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getStoreCredentials(String appId) throws Exception {
|
||||||
|
List<AppStoreConfigEntity> configs = configRepo.findByAppIdAndEnabled(appId, true);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
for (AppStoreConfigEntity cfg : configs) {
|
||||||
|
Map<String, Object> parsed = cfg.getConfigJson() != null
|
||||||
|
? mapper.readValue(cfg.getConfigJson(), new TypeReference<>() {})
|
||||||
|
: Map.of();
|
||||||
|
result.put(cfg.getStoreType().name(), parsed);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review status webhook ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by store webhook or manually to update the review state for a single store.
|
||||||
|
* When autoPublishAfterReview=true and all targets are APPROVED, flips to PUBLISHED.
|
||||||
|
*/
|
||||||
|
public AppVersionEntity updateStoreReview(String versionId,
|
||||||
|
String storeType,
|
||||||
|
AppVersionEntity.StoreReviewState state) throws Exception {
|
||||||
|
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
||||||
|
|
||||||
|
Map<String, String> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
|
||||||
|
reviewMap.put(storeType, state.name());
|
||||||
|
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
|
||||||
|
|
||||||
|
if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) {
|
||||||
|
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
||||||
|
log.info("Auto-published version {} after all stores approved", versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppVersionEntity saved = versionRepo.save(v);
|
||||||
|
sendWebhook(saved, storeType, state);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scheduled publish ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 60_000)
|
||||||
|
public void processScheduledPublish() {
|
||||||
|
List<AppVersionEntity> due = versionRepo
|
||||||
|
.findByPublishStatusAndScheduledPublishAtBefore(
|
||||||
|
AppVersionEntity.PublishStatus.DRAFT, LocalDateTime.now());
|
||||||
|
for (AppVersionEntity v : due) {
|
||||||
|
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
||||||
|
versionRepo.save(v);
|
||||||
|
log.info("Scheduled publish executed for version {}", v.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Webhook delivery ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state) {
|
||||||
|
String url = v.getWebhookUrl();
|
||||||
|
if (url == null || url.isBlank()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
String body = mapper.writeValueAsString(Map.of(
|
||||||
|
"event", "store_review_update",
|
||||||
|
"versionId", v.getId(),
|
||||||
|
"appId", v.getAppId(),
|
||||||
|
"versionName", v.getVersionName(),
|
||||||
|
"storeType", storeType,
|
||||||
|
"reviewState", state.name(),
|
||||||
|
"publishStatus", v.getPublishStatus().name(),
|
||||||
|
"timestamp", System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
http.sendAsync(request, HttpResponse.BodyHandlers.discarding())
|
||||||
|
.exceptionally(e -> { log.warn("Webhook delivery failed: {}", e.getMessage()); return null; });
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to build webhook payload: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Map<String, String> parseReviewStatus(String json) throws Exception {
|
||||||
|
if (json == null || json.isBlank()) return new LinkedHashMap<>();
|
||||||
|
return mapper.readValue(json, new TypeReference<LinkedHashMap<String, String>>() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean allApproved(AppVersionEntity v, Map<String, String> reviewMap) throws Exception {
|
||||||
|
if (v.getStoreSubmitTargets() == null) return false;
|
||||||
|
List<String> targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {});
|
||||||
|
return targets.stream().allMatch(t ->
|
||||||
|
AppVersionEntity.StoreReviewState.APPROVED.name().equals(reviewMap.get(t)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,362 @@
|
|||||||
|
package com.xuqm.update.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.xuqm.update.entity.AppStoreConfigEntity;
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.repository.AppStoreConfigRepository;
|
||||||
|
import com.xuqm.update.repository.AppVersionRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles actual APK/IPA submission to vendor app stores on behalf of the tenant.
|
||||||
|
*
|
||||||
|
* Each store's implementation follows the same pattern:
|
||||||
|
* 1. Authenticate with the store API using stored credentials
|
||||||
|
* 2. Upload the package file
|
||||||
|
* 3. Submit for review
|
||||||
|
* 4. Return so {@link AppStoreService} can update review state and send webhook
|
||||||
|
*
|
||||||
|
* Reference: XiaoZhuan project channel implementations.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StoreSubmissionService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(StoreSubmissionService.class);
|
||||||
|
private static final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
private static final String HUAWEI_API = "https://connect-api.cloud.huawei.com";
|
||||||
|
|
||||||
|
private final RestTemplate rest = new RestTemplate();
|
||||||
|
private final AppVersionRepository versionRepo;
|
||||||
|
private final AppStoreConfigRepository configRepo;
|
||||||
|
private final AppStoreService storeService;
|
||||||
|
|
||||||
|
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
||||||
|
private String uploadDir;
|
||||||
|
|
||||||
|
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
public StoreSubmissionService(AppVersionRepository versionRepo,
|
||||||
|
AppStoreConfigRepository configRepo,
|
||||||
|
AppStoreService storeService) {
|
||||||
|
this.versionRepo = versionRepo;
|
||||||
|
this.configRepo = configRepo;
|
||||||
|
this.storeService = storeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute submission to all configured target stores for the given version.
|
||||||
|
* Runs asynchronously so the API endpoint returns immediately.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void executeSubmitAsync(String versionId) {
|
||||||
|
AppVersionEntity v = versionRepo.findById(versionId).orElse(null);
|
||||||
|
if (v == null) { log.error("Version not found: {}", versionId); return; }
|
||||||
|
|
||||||
|
List<String> targets = parseTargets(v.getStoreSubmitTargets());
|
||||||
|
if (targets.isEmpty()) { log.warn("No store targets for version {}", versionId); return; }
|
||||||
|
|
||||||
|
File apkFile = resolveLocalFile(v.getDownloadUrl());
|
||||||
|
|
||||||
|
for (String storeType : targets) {
|
||||||
|
try {
|
||||||
|
AppStoreConfigEntity cfg = configRepo
|
||||||
|
.findByAppIdAndStoreType(v.getAppId(), AppStoreConfigEntity.StoreType.valueOf(storeType))
|
||||||
|
.orElse(null);
|
||||||
|
if (cfg == null || !cfg.isEnabled()) {
|
||||||
|
log.warn("Store config not found or disabled for {}/{}", v.getAppId(), storeType);
|
||||||
|
storeService.updateStoreReview(versionId, storeType,
|
||||||
|
AppVersionEntity.StoreReviewState.REJECTED);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, String> creds = parseConfig(cfg.getConfigJson());
|
||||||
|
submitToStore(storeType, v, apkFile, creds);
|
||||||
|
storeService.updateStoreReview(versionId, storeType,
|
||||||
|
AppVersionEntity.StoreReviewState.UNDER_REVIEW);
|
||||||
|
log.info("Submitted version {} to {}", versionId, storeType);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Submission to {} failed for version {}: {}", storeType, versionId, e.getMessage(), e);
|
||||||
|
try {
|
||||||
|
storeService.updateStoreReview(versionId, storeType,
|
||||||
|
AppVersionEntity.StoreReviewState.REJECTED);
|
||||||
|
} catch (Exception ex) { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispatch ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void submitToStore(String storeType, AppVersionEntity v, File file,
|
||||||
|
Map<String, String> creds) throws Exception {
|
||||||
|
switch (storeType) {
|
||||||
|
case "HUAWEI" -> submitToHuawei(v, file, creds);
|
||||||
|
case "HONOR" -> submitToHonor(v, file, creds);
|
||||||
|
case "MI" -> submitToMi(v, file, creds);
|
||||||
|
case "OPPO" -> submitToOppo(v, file, creds);
|
||||||
|
case "VIVO" -> submitToVivo(v, file, creds);
|
||||||
|
case "APP_STORE" -> submitToAppStore(v, file, creds);
|
||||||
|
case "GOOGLE_PLAY" -> submitToGooglePlay(v, file, creds);
|
||||||
|
default -> throw new IllegalArgumentException("Unknown store: " + storeType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Huawei AppGallery Connect ─────────────────────────────────────────────
|
||||||
|
// API docs: https://developer.huawei.com/consumer/cn/doc/AppGallery-connect-Guides/agcapi-getstarted-0000001111845114
|
||||||
|
|
||||||
|
private void submitToHuawei(AppVersionEntity v, File file, Map<String, String> creds) throws Exception {
|
||||||
|
String clientId = require(creds, "clientId", "HUAWEI");
|
||||||
|
String clientSecret = require(creds, "clientSecret", "HUAWEI");
|
||||||
|
String packageName = v.getPackageName();
|
||||||
|
if (packageName == null || packageName.isBlank())
|
||||||
|
throw new IllegalStateException("packageName is required for Huawei submission");
|
||||||
|
|
||||||
|
// 1. OAuth token
|
||||||
|
String token = huaweiGetToken(clientId, clientSecret);
|
||||||
|
|
||||||
|
// 2. Resolve appId from package name
|
||||||
|
String hwAppId = huaweiGetAppId(clientId, token, packageName);
|
||||||
|
|
||||||
|
// 3. Request upload URL
|
||||||
|
Map<String, Object> uploadUrlResp = huaweiGetUploadUrl(clientId, token, hwAppId, file);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> urlInfo = (Map<String, Object>) ((List<?>) uploadUrlResp.get("urlList")).get(0);
|
||||||
|
String uploadUrl = (String) urlInfo.get("url");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, String> uploadHeaders = (Map<String, String>) urlInfo.get("headers");
|
||||||
|
String objectId = (String) urlInfo.get("objectId");
|
||||||
|
|
||||||
|
// 4. Upload file
|
||||||
|
huaweiUploadFile(uploadUrl, uploadHeaders, file);
|
||||||
|
|
||||||
|
// 5. Bind APK
|
||||||
|
String pkgId = huaweiBindApk(clientId, token, hwAppId, file.getName(), objectId);
|
||||||
|
|
||||||
|
// 6. Wait for compile (poll up to 3 minutes)
|
||||||
|
huaweiWaitCompile(clientId, token, hwAppId, pkgId);
|
||||||
|
|
||||||
|
// 7. Update version description
|
||||||
|
huaweiUpdateDesc(clientId, token, hwAppId, v.getChangeLog());
|
||||||
|
|
||||||
|
// 8. Submit for review
|
||||||
|
huaweiSubmit(clientId, token, hwAppId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String huaweiGetToken(String clientId, String clientSecret) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
Map<String, String> body = Map.of(
|
||||||
|
"client_id", clientId,
|
||||||
|
"client_secret", clientSecret,
|
||||||
|
"grant_type", "client_credentials");
|
||||||
|
ResponseEntity<Map> resp = rest.postForEntity(
|
||||||
|
HUAWEI_API + "/api/oauth2/v1/token", new HttpEntity<>(body, headers), Map.class);
|
||||||
|
return Objects.requireNonNull(resp.getBody()).get("access_token").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private String huaweiGetAppId(String clientId, String token, String packageName) {
|
||||||
|
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||||
|
ResponseEntity<Map> resp = rest.exchange(
|
||||||
|
HUAWEI_API + "/api/publish/v2/appid-list?packageName=" + packageName,
|
||||||
|
HttpMethod.GET, new HttpEntity<>(headers), Map.class);
|
||||||
|
Map<String, Object> body = resp.getBody();
|
||||||
|
List<Map<String, Object>> list = (List<Map<String, Object>>) body.get("appids");
|
||||||
|
if (list == null || list.isEmpty()) throw new RuntimeException("Huawei: app not found for " + packageName);
|
||||||
|
return list.get(0).get("id").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> huaweiGetUploadUrl(String clientId, String token, String hwAppId, File file) {
|
||||||
|
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||||
|
String url = HUAWEI_API + "/api/publish/v2/upload-url/for-obs?appId=" + hwAppId
|
||||||
|
+ "&fileName=" + file.getName() + "&contentLength=" + file.length();
|
||||||
|
ResponseEntity<Map> resp = rest.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), Map.class);
|
||||||
|
return resp.getBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void huaweiUploadFile(String uploadUrl, Map<String, String> extraHeaders, File file) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||||
|
if (extraHeaders != null) extraHeaders.forEach(headers::set);
|
||||||
|
FileSystemResource resource = new FileSystemResource(file);
|
||||||
|
rest.exchange(uploadUrl, HttpMethod.PUT, new HttpEntity<>(resource, headers), Void.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private String huaweiBindApk(String clientId, String token, String hwAppId, String fileName, String objectId) {
|
||||||
|
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
Map<String, Object> body = Map.of("files", List.of(Map.of("fileName", fileName, "fileDestUrl", objectId)));
|
||||||
|
ResponseEntity<Map> resp = rest.exchange(
|
||||||
|
HUAWEI_API + "/api/publish/v2/app-file-info?appId=" + hwAppId,
|
||||||
|
HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class);
|
||||||
|
List<Map<String, Object>> pkgList = (List<Map<String, Object>>) resp.getBody().get("pkgVersion");
|
||||||
|
return pkgList.get(0).get("id").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void huaweiWaitCompile(String clientId, String token, String hwAppId, String pkgId) throws InterruptedException {
|
||||||
|
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||||
|
long deadline = System.currentTimeMillis() + 3 * 60_000L;
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(10_000);
|
||||||
|
ResponseEntity<Map> resp = rest.exchange(
|
||||||
|
HUAWEI_API + "/api/publish/v2/package/compile/status?appId=" + hwAppId + "&pkgIds=" + pkgId,
|
||||||
|
HttpMethod.GET, new HttpEntity<>(headers), Map.class);
|
||||||
|
List<Map<String, Object>> states = (List<Map<String, Object>>) resp.getBody().get("pkgStateList");
|
||||||
|
if (states != null && !states.isEmpty()) {
|
||||||
|
Object compileStatus = states.get(0).get("compileStatus");
|
||||||
|
if ("2".equals(String.valueOf(compileStatus))) return; // 2 = success
|
||||||
|
if ("3".equals(String.valueOf(compileStatus)))
|
||||||
|
throw new RuntimeException("Huawei compile failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Huawei compile timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void huaweiUpdateDesc(String clientId, String token, String hwAppId, String changeLog) {
|
||||||
|
if (changeLog == null || changeLog.isBlank()) return;
|
||||||
|
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
Map<String, Object> body = Map.of("lang", "zh-CN", "newFeatures", changeLog);
|
||||||
|
rest.exchange(HUAWEI_API + "/api/publish/v2/app-language-info?appId=" + hwAppId,
|
||||||
|
HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void huaweiSubmit(String clientId, String token, String hwAppId) {
|
||||||
|
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||||
|
rest.postForEntity(HUAWEI_API + "/api/publish/v2/app-submit?appId=" + hwAppId,
|
||||||
|
new HttpEntity<>(headers), Map.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpHeaders huaweiHeaders(String clientId, String token) {
|
||||||
|
HttpHeaders h = new HttpHeaders();
|
||||||
|
h.set("client_id", clientId);
|
||||||
|
h.set("Authorization", "Bearer " + token);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Honor AppGallery (same API as Huawei) ─────────────────────────────────
|
||||||
|
|
||||||
|
private void submitToHonor(AppVersionEntity v, File file, Map<String, String> creds) throws Exception {
|
||||||
|
// Honor uses the same Connect API as Huawei — reuse implementation
|
||||||
|
submitToHuawei(v, file, creds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Xiaomi Market ─────────────────────────────────────────────────────────
|
||||||
|
// API: https://dev.mi.com/distribute/doc/details?pId=1134
|
||||||
|
|
||||||
|
private void submitToMi(AppVersionEntity v, File file, Map<String, String> creds) {
|
||||||
|
// TODO: Implement Xiaomi Market API submission
|
||||||
|
// Required creds: username, privateKey (RSA private key for request signing)
|
||||||
|
// Flow:
|
||||||
|
// 1. Sign request parameters with RSA private key (MiApiSigner in XiaoZhuan)
|
||||||
|
// 2. POST https://api.developer.xiaomi.com/devupload/dev/push with signed form + APK file
|
||||||
|
// 3. Check response for success
|
||||||
|
log.warn("MI store submission not yet implemented - mark as UNDER_REVIEW manually");
|
||||||
|
throw new UnsupportedOperationException("MI submission not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OPPO Software Store ───────────────────────────────────────────────────
|
||||||
|
// API: https://open.oppomobile.com/new/developmentDoc/info?id=11119
|
||||||
|
|
||||||
|
private void submitToOppo(AppVersionEntity v, File file, Map<String, String> creds) {
|
||||||
|
// TODO: Implement OPPO Market API submission
|
||||||
|
// Required creds: clientId, clientSecret
|
||||||
|
// Flow:
|
||||||
|
// 1. POST https://oop-openapi-cn.heytapmobi.com/developer/v1/token → access_token
|
||||||
|
// 2. POST upload URL to get upload address
|
||||||
|
// 3. PUT file to upload address
|
||||||
|
// 4. POST update app info + submit for review
|
||||||
|
log.warn("OPPO store submission not yet implemented");
|
||||||
|
throw new UnsupportedOperationException("OPPO submission not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── vivo App Store ────────────────────────────────────────────────────────
|
||||||
|
// API: https://dev.vivo.com.cn/documentCenter/doc/326
|
||||||
|
|
||||||
|
private void submitToVivo(AppVersionEntity v, File file, Map<String, String> creds) {
|
||||||
|
// TODO: Implement vivo Market API submission
|
||||||
|
// Required creds: accessKey, accessSecret
|
||||||
|
// Flow:
|
||||||
|
// 1. Build signed request (HMAC-SHA256 of accessKey + timestamp + nonce + accessSecret)
|
||||||
|
// 2. POST https://developer-api.vivo.com.cn/router/rest with signed params + APK file
|
||||||
|
log.warn("VIVO store submission not yet implemented");
|
||||||
|
throw new UnsupportedOperationException("VIVO submission not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apple App Store Connect ───────────────────────────────────────────────
|
||||||
|
// API: https://developer.apple.com/documentation/appstoreconnectapi
|
||||||
|
|
||||||
|
private void submitToAppStore(AppVersionEntity v, File file, Map<String, String> creds) {
|
||||||
|
// TODO: Implement App Store Connect API submission
|
||||||
|
// Required creds: teamId, keyId, privateKey (P8 content), bundleId
|
||||||
|
// Flow:
|
||||||
|
// 1. Generate JWT using ES256 with privateKey (keyId + teamId in header/payload)
|
||||||
|
// 2. POST /v1/apps/{appId}/appStoreVersions to create version
|
||||||
|
// 3. POST /v1/appStoreVersionSubmissions to submit
|
||||||
|
// Note: IPA submission still requires xcrun altool or Transporter CLI — not REST-only
|
||||||
|
log.warn("App Store submission not yet implemented - use Transporter or fastlane");
|
||||||
|
throw new UnsupportedOperationException("App Store submission not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Google Play ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void submitToGooglePlay(AppVersionEntity v, File file, Map<String, String> creds) {
|
||||||
|
// TODO: Implement Google Play Developer API submission
|
||||||
|
// Required creds: serviceAccountJson (Google service account JSON key)
|
||||||
|
// Flow (using google-api-client-java):
|
||||||
|
// 1. Authenticate with service account JSON
|
||||||
|
// 2. Create edit: POST https://www.googleapis.com/androidpublisher/v3/applications/{packageName}/edits
|
||||||
|
// 3. Upload APK to edit
|
||||||
|
// 4. Assign to track (production/beta)
|
||||||
|
// 5. Commit edit
|
||||||
|
log.warn("Google Play submission not yet implemented");
|
||||||
|
throw new UnsupportedOperationException("Google Play submission not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private File resolveLocalFile(String downloadUrl) {
|
||||||
|
if (downloadUrl == null) throw new IllegalStateException("downloadUrl is null");
|
||||||
|
String path = URI.create(downloadUrl).getPath();
|
||||||
|
// path like /files/apk/{filename} or /api/v1/updates/files/apk/{filename}
|
||||||
|
String filename = Paths.get(path).getFileName().toString();
|
||||||
|
File file = Paths.get(uploadDir, "apk", filename).toFile();
|
||||||
|
if (!file.exists()) throw new IllegalStateException("APK file not found locally: " + file);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> parseTargets(String json) {
|
||||||
|
if (json == null || json.isBlank()) return List.of();
|
||||||
|
try { return mapper.readValue(json, new TypeReference<List<String>>() {}); }
|
||||||
|
catch (Exception e) { return List.of(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> parseConfig(String json) throws Exception {
|
||||||
|
if (json == null || json.isBlank()) return Map.of();
|
||||||
|
return mapper.readValue(json, new TypeReference<Map<String, String>>() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String require(Map<String, String> creds, String key, String store) {
|
||||||
|
String v = creds.get(key);
|
||||||
|
if (v == null || v.isBlank())
|
||||||
|
throw new IllegalStateException(store + " credential missing: " + key);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.xuqm.update.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.security.DigestInputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UpdateAssetService {
|
||||||
|
|
||||||
|
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
||||||
|
private String uploadDir;
|
||||||
|
|
||||||
|
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
public String storeAppPackage(MultipartFile apkFile) throws IOException {
|
||||||
|
if (apkFile == null || apkFile.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename();
|
||||||
|
Path dir = Paths.get(uploadDir, "apk");
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
Path dest = dir.resolve(filename);
|
||||||
|
apkFile.transferTo(dest.toFile());
|
||||||
|
return baseUrl + "/files/apk/" + filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception {
|
||||||
|
if (bundle == null || bundle.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("bundle file is required");
|
||||||
|
}
|
||||||
|
String filename = moduleId + "." + platform.toLowerCase() + ".bundle";
|
||||||
|
Path dir = Paths.get(uploadDir, "rn", appId, platform.toLowerCase(), moduleId);
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
Path dest = dir.resolve(filename);
|
||||||
|
|
||||||
|
String md5 = computeMd5(bundle);
|
||||||
|
bundle.transferTo(dest.toFile());
|
||||||
|
return new StoredRnBundle(dest.toAbsolutePath().toString(), md5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeMd5(MultipartFile file) throws Exception {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||||
|
try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) {
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
while (dis.read(buf) != -1) {
|
||||||
|
// read fully
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HexFormat.of().formatHex(digest.digest());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StoredRnBundle(String bundlePath, String md5) {}
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户