feat(update): add app store distribution — store config, server-side submission, scheduled publish, webhook

feat(im): expand IM API with friends, groups, admin ops, operation logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-04-29 00:34:17 +08:00
父节点 30a9f71eac
当前提交 eae18723a5
共有 22 个文件被更改,包括 1409 次插入327 次删除

查看文件

@ -89,7 +89,18 @@
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| 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 |
| GET | `/api/im/messages/search` | 是 | 云端消息搜索 |
| PUT | `/api/im/messages/{id}` | 是 | 编辑自己发送的文本消息 |
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
| 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 会话与关系链
```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 -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 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/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 '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'
@ -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/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/operation-logs?appId=ak_demo_chat&page=0&size=20'
curl -X POST 'https://dev.xuqinmin.com/api/im/admin/messages/msg_001/revoke?appId=ak_demo_chat'
curl -X DELETE 'https://dev.xuqinmin.com/api/im/admin/groups/group_001'
```
@ -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/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 '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 DELETE 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002'
```

查看文件

@ -119,6 +119,50 @@ public final class XuqmImServerSdk {
return response.data();
}
public AccountView importAccount(String userId, String nickname, String avatar, String gender, String status) {
ApiResponse<AccountView> response = request(
"POST",
buildUri("/api/im/accounts/import", appQuery()),
new ImportAccountRequest(userId, nickname, avatar, gender, status),
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<AccountView> importAccounts(List<ImportAccountRequest> accounts) {
ApiResponse<List<AccountView>> response = request(
"POST",
buildUri("/api/im/accounts/import/batch", appQuery()),
accounts,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public void deleteAccount(String userId) {
request(
"DELETE",
buildUri("/api/im/accounts/" + encode(userId), appQuery()),
null,
authorizedHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
public boolean checkAccount(String userId) {
ApiResponse<Map<String, Object>> response = request(
"GET",
buildUri("/api/im/accounts/" + encode(userId) + "/exists", appQuery()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
Object exists = response.data().get("exists");
return exists instanceof Boolean b && b;
}
public List<ConversationView> listConversations(int size) {
ApiResponse<List<ConversationView>> response = request(
"GET",
@ -174,6 +218,72 @@ public final class XuqmImServerSdk {
return response.data();
}
public PageResult<ImMessage> searchMessages(MessageSearchQuery query) {
MessageSearchQuery effective = query == null ? new MessageSearchQuery() : query;
ApiResponse<PageResult<ImMessage>> response = request(
"GET",
buildUri(
"/api/im/messages/search",
messageSearchQuery(
effective.chatType(),
effective.msgType(),
effective.keyword(),
effective.startTime(),
effective.endTime(),
effective.page() == null ? 0 : effective.page(),
effective.size() == null ? 20 : effective.size()
)
),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<WebhookConfigView> listWebhooks() {
ApiResponse<List<WebhookConfigView>> response = request(
"GET",
buildUri("/api/im/admin/webhooks", appQuery()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public WebhookConfigView createWebhook(WebhookConfigRequest request) {
ApiResponse<WebhookConfigView> response = request(
"POST",
buildUri("/api/im/admin/webhooks", appQuery()),
request,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public WebhookConfigView updateWebhook(String id, WebhookConfigRequest request) {
ApiResponse<WebhookConfigView> response = request(
"PUT",
buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()),
request,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public void deleteWebhook(String id) {
request(
"DELETE",
buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()),
null,
authorizedHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
public List<ConversationView> listConversations() {
return listConversations(20);
}
@ -210,73 +320,10 @@ public final class XuqmImServerSdk {
return response.data();
}
public PageResult<AccountView> listUsers(int page, int size) {
ApiResponse<PageResult<AccountView>> response = request(
"GET",
buildUri("/api/im/admin/users", queryWithPage(page, size)),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public AccountView registerUser(String userId, String nickname, String avatar) {
Map<String, String> body = new LinkedHashMap<>();
body.put("userId", userId);
if (nickname != null) {
body.put("nickname", nickname);
}
if (avatar != null) {
body.put("avatar", avatar);
}
ApiResponse<AccountView> response = request(
"POST",
buildUri("/api/im/admin/users", appQuery()),
body,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public AccountView updateUserStatus(String userId, String status) {
ApiResponse<AccountView> response = request(
"PUT",
buildUri("/api/im/admin/users/" + encode(userId) + "/status", appQuery()),
Map.of("status", status),
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<AccountView> searchUsers(String keyword, int size) {
public List<AccountView> searchAccounts(String keyword, int size) {
ApiResponse<List<AccountView>> response = request(
"GET",
buildUri("/api/im/admin/users/search", queryWithSize("keyword", keyword, size)),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public StatsView stats() {
ApiResponse<StatsView> response = request(
"GET",
buildUri("/api/im/admin/stats", appQuery()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<GroupView> searchGroups(String keyword, int size) {
ApiResponse<List<GroupView>> response = request(
"GET",
buildUri("/api/im/admin/groups/search", queryWithSize("keyword", keyword, size)),
buildUri("/api/im/accounts/search", queryWithSize("keyword", keyword, size)),
null,
authorizedHeaders(),
new TypeReference<>() {}
@ -372,150 +419,6 @@ public final class XuqmImServerSdk {
return readPayload(envelope, MessageReadCallbackPayload.class);
}
public PageResult<ImMessage> searchMessages(
String keyword,
String chatType,
String msgType,
LocalDateTime startTime,
LocalDateTime endTime,
int page,
int size) {
Map<String, String> query = appQuery();
if (keyword != null) {
query.put("keyword", keyword);
}
if (chatType != null) {
query.put("chatType", chatType);
}
if (msgType != null) {
query.put("msgType", msgType);
}
if (startTime != null) {
query.put("startTime", startTime.toInstant(ZoneOffset.UTC).toString());
}
if (endTime != null) {
query.put("endTime", endTime.toInstant(ZoneOffset.UTC).toString());
}
query.put("page", String.valueOf(page));
query.put("size", String.valueOf(size));
ApiResponse<PageResult<ImMessage>> response = request(
"GET",
buildUri("/api/im/admin/messages/search", query),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<WebhookConfigView> listWebhookConfigs() {
ApiResponse<List<WebhookConfigView>> response = request(
"GET",
buildUri("/api/im/admin/webhooks", appQuery()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public WebhookConfigView createWebhookConfig(String url, String secret, Boolean enabled) {
ApiResponse<WebhookConfigView> response = request(
"POST",
buildUri("/api/im/admin/webhooks", appQuery()),
new WebhookConfigRequest(url, secret, enabled),
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public WebhookConfigView updateWebhookConfig(String id, String url, String secret, Boolean enabled) {
ApiResponse<WebhookConfigView> response = request(
"PUT",
buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()),
new WebhookConfigRequest(url, secret, enabled),
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public void deleteWebhookConfig(String id) {
request(
"DELETE",
buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()),
null,
authorizedHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
public List<KeywordFilterView> listKeywordFilters() {
ApiResponse<List<KeywordFilterView>> response = request(
"GET",
buildUri("/api/im/admin/keyword-filters", appQuery()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public KeywordFilterView createKeywordFilter(String pattern, String replacement, String action, Boolean enabled) {
ApiResponse<KeywordFilterView> response = request(
"POST",
buildUri("/api/im/admin/keyword-filters", appQuery()),
new KeywordFilterRequest(pattern, replacement, action, enabled),
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public KeywordFilterView updateKeywordFilter(String id, String pattern, String replacement, String action, Boolean enabled) {
ApiResponse<KeywordFilterView> response = request(
"PUT",
buildUri("/api/im/admin/keyword-filters/" + encode(id), appQuery()),
new KeywordFilterRequest(pattern, replacement, action, enabled),
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public void deleteKeywordFilter(String id) {
request(
"DELETE",
buildUri("/api/im/admin/keyword-filters/" + encode(id), appQuery()),
null,
authorizedHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
public GlobalMuteView getGlobalMute() {
ApiResponse<GlobalMuteView> response = request(
"GET",
buildUri("/api/im/admin/global-mute", appQuery()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public GlobalMuteView setGlobalMute(boolean enabled) {
ApiResponse<GlobalMuteView> response = request(
"PUT",
buildUri("/api/im/admin/global-mute", Map.of("appId", appId, "enabled", String.valueOf(enabled))),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public void registerPushToken(String userId, String vendor, String token) {
request(
"POST",
@ -939,6 +842,17 @@ public final class XuqmImServerSdk {
return response.data();
}
public List<GroupView> searchGroups(String keyword, int size) {
ApiResponse<List<GroupView>> response = request(
"GET",
buildUri("/api/im/groups/search", queryWithSize("keyword", keyword, size)),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public GroupView getGroup(String groupId) {
ApiResponse<GroupView> response = request(
"GET",
@ -950,6 +864,28 @@ public final class XuqmImServerSdk {
return response.data();
}
public List<AccountView> listGroupMembers(String groupId) {
ApiResponse<List<AccountView>> response = request(
"GET",
buildUri("/api/im/groups/" + encode(groupId) + "/members", appQuery()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<AccountView> searchGroupMembers(String groupId, String keyword, int size) {
ApiResponse<List<AccountView>> response = request(
"GET",
buildUri("/api/im/groups/" + encode(groupId) + "/members/search", queryWithSize("keyword", keyword, size)),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public GroupView createGroup(String name, List<String> memberIds, String groupType) {
ApiResponse<GroupView> response = request(
"POST",
@ -1183,63 +1119,6 @@ public final class XuqmImServerSdk {
);
}
public PageResult<ImMessage> queryAdminMessages(
String userA,
String userB,
String msgType,
String keyword,
LocalDateTime startTime,
LocalDateTime endTime,
int page,
int size) {
Map<String, String> query = appQuery();
query.put("userA", userA);
query.put("userB", userB);
if (msgType != null) {
query.put("msgType", msgType);
}
if (keyword != null) {
query.put("keyword", keyword);
}
if (startTime != null) {
query.put("startTime", startTime.toInstant(ZoneOffset.UTC).toString());
}
if (endTime != null) {
query.put("endTime", endTime.toInstant(ZoneOffset.UTC).toString());
}
query.put("page", String.valueOf(page));
query.put("size", String.valueOf(size));
ApiResponse<PageResult<ImMessage>> response = request(
"GET",
buildUri("/api/im/admin/messages", query),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public ImMessage revokeAdminMessage(String messageId) {
ApiResponse<ImMessage> response = request(
"POST",
buildUri("/api/im/admin/messages/" + encode(messageId) + "/revoke", appQuery()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public void dismissAdminGroup(String groupId) {
request(
"DELETE",
buildUri("/api/im/admin/groups/" + encode(groupId), appQuery()),
null,
authorizedHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
private static String sha256Hex(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
@ -1555,6 +1434,37 @@ public final class XuqmImServerSdk {
return query;
}
private Map<String, String> messageSearchQuery(
String chatType,
String msgType,
String keyword,
LocalDateTime startTime,
LocalDateTime endTime,
int page,
int size
) {
Map<String, String> query = new LinkedHashMap<>();
query.put("appId", appId);
if (chatType != null && !chatType.isBlank()) {
query.put("chatType", chatType);
}
if (msgType != null && !msgType.isBlank()) {
query.put("msgType", msgType);
}
if (keyword != null && !keyword.isBlank()) {
query.put("keyword", keyword);
}
if (startTime != null) {
query.put("startTime", startTime.toInstant(ZoneOffset.UTC).toString());
}
if (endTime != null) {
query.put("endTime", endTime.toInstant(ZoneOffset.UTC).toString());
}
query.put("page", String.valueOf(page));
query.put("size", String.valueOf(size));
return query;
}
private String[] flatten(Map<String, String> headers) {
String[] pairs = new String[headers.size() * 2];
int index = 0;
@ -1642,6 +1552,14 @@ public final class XuqmImServerSdk {
LocalDateTime createdAt
) {}
public record ImportAccountRequest(
String userId,
String nickname,
String avatar,
String gender,
String status
) {}
public record FriendLinkView(
Long id,
String appId,
@ -1702,40 +1620,6 @@ public final class XuqmImServerSdk {
LocalDateTime reviewedAt
) {}
public record StatsView(
long totalMessages,
long totalUsers,
long totalGroups,
long todayMessages
) {}
public record WebhookConfigView(
String id,
String appId,
String url,
String secret,
boolean enabled,
Long createdAt
) {}
public record KeywordFilterView(
String id,
String appId,
String pattern,
String replacement,
String action,
boolean enabled,
Long createdAt
) {}
public record GlobalMuteView(
String id,
String appId,
boolean enabled,
Long createdAt,
Long updatedAt
) {}
public record WebhookCallbackEnvelope(
String callbackId,
String callbackType,
@ -1820,6 +1704,20 @@ public final class XuqmImServerSdk {
}
}
public record MessageSearchQuery(
String chatType,
String msgType,
String keyword,
LocalDateTime startTime,
LocalDateTime endTime,
Integer page,
Integer size
) {
public MessageSearchQuery() {
this(null, null, null, null, null, null, null);
}
}
public record PageResult<T>(
List<T> content,
long totalElements,
@ -1862,6 +1760,15 @@ public final class XuqmImServerSdk {
Boolean enabled
) {}
public record WebhookConfigView(
String id,
String appId,
String url,
String secret,
boolean enabled,
LocalDateTime createdAt
) {}
public record KeywordFilterRequest(
String pattern,
String replacement,

查看文件

@ -12,8 +12,12 @@ import org.springframework.web.bind.annotation.PutMapping;
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.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;
@RestController
@ -56,4 +60,47 @@ public class AccountController {
@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
) {}
}

查看文件

@ -8,6 +8,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
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.RestController;
import org.springframework.web.bind.annotation.PathVariable;

查看文件

@ -1,6 +1,7 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import com.xuqm.im.service.ImGroupService;
@ -58,6 +59,24 @@ public class GroupController {
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}")
public ResponseEntity<ApiResponse<ImGroupEntity>> update(
@PathVariable String groupId,

查看文件

@ -15,10 +15,12 @@ import com.xuqm.im.service.ImGroupService;
import com.xuqm.im.service.GlobalMuteService;
import com.xuqm.im.service.KeywordFilterService;
import com.xuqm.im.service.MessageService;
import com.xuqm.im.service.OperationLogService;
import com.xuqm.im.service.WebhookConfigService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
@ -38,6 +40,7 @@ public class ImAdminController {
private final WebhookConfigService webhookConfigService;
private final KeywordFilterService keywordFilterService;
private final GlobalMuteService globalMuteService;
private final OperationLogService operationLogService;
public ImAdminController(ImAccountRepository accountRepository,
ImGroupRepository groupRepository,
@ -47,7 +50,8 @@ public class ImAdminController {
MessageService messageService,
WebhookConfigService webhookConfigService,
KeywordFilterService keywordFilterService,
GlobalMuteService globalMuteService) {
GlobalMuteService globalMuteService,
OperationLogService operationLogService) {
this.accountRepository = accountRepository;
this.groupRepository = groupRepository;
this.messageRepository = messageRepository;
@ -57,6 +61,7 @@ public class ImAdminController {
this.webhookConfigService = webhookConfigService;
this.keywordFilterService = keywordFilterService;
this.globalMuteService = globalMuteService;
this.operationLogService = operationLogService;
}
/** List all registered IM users for the given appId. */
@ -74,11 +79,14 @@ public class ImAdminController {
public ResponseEntity<ApiResponse<ImAccountEntity>> updateUserStatus(
@RequestParam String appId,
@PathVariable String userId,
@AuthenticationPrincipal String operatorId,
@RequestBody Map<String, String> body) {
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
.orElseThrow(() -> new RuntimeException("User not found"));
account.setStatus(ImAccountEntity.Status.valueOf(body.get("status").toUpperCase()));
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. */
@ -91,10 +99,12 @@ public class ImAdminController {
@PostMapping("/users")
public ResponseEntity<ApiResponse<ImAccountEntity>> registerUser(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody RegisterUserRequest req) {
accountService.loginOrRegister(appId, req.userId(), req.nickname(), req.avatar());
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, req.userId())
.orElseThrow();
operationLogService.record(appId, operatorId, "REGISTER_USER", "ACCOUNT", req.userId(), req.nickname());
return ResponseEntity.ok(ApiResponse.success(account));
}
@ -102,18 +112,22 @@ public class ImAdminController {
@PostMapping("/groups")
public ResponseEntity<ApiResponse<ImGroupEntity>> createGroup(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody CreateGroupRequest req) {
return ResponseEntity.ok(ApiResponse.success(
groupService.create(appId, req.name(), req.creatorId(), req.memberIds(), "WORK")));
ImGroupEntity group = 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. */
@GetMapping("/users/search")
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> searchUsers(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestParam String keyword,
@RequestParam(defaultValue = "20") int 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));
}
@ -121,9 +135,11 @@ public class ImAdminController {
@GetMapping("/groups/search")
public ResponseEntity<ApiResponse<List<ImGroupEntity>>> searchGroups(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestParam String keyword,
@RequestParam(defaultValue = "20") int 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));
}
@ -131,6 +147,7 @@ public class ImAdminController {
@GetMapping("/messages/search")
public ResponseEntity<ApiResponse<Page<ImMessageEntity>>> searchMessages(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestParam(required = false) ImMessageEntity.ChatType chatType,
@RequestParam(required = false) ImMessageEntity.MsgType msgType,
@RequestParam(required = false) String keyword,
@ -138,6 +155,7 @@ public class ImAdminController {
@RequestParam(required = false) LocalDateTime endTime,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
operationLogService.record(appId, operatorId, "SEARCH_MESSAGES", "MESSAGE", null, keyword);
return ResponseEntity.ok(ApiResponse.success(
messageRepository.searchByKeyword(
appId, chatType, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
@ -145,11 +163,14 @@ public class ImAdminController {
/** Message statistics for the given appId. */
@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 totalUsers = accountRepository.countByAppId(appId);
long totalGroups = groupRepository.countByAppId(appId);
long todayMessages = messageRepository.countTodayByAppId(appId);
operationLogService.record(appId, operatorId, "VIEW_STATS", "STATS", null, "summary");
return ResponseEntity.ok(ApiResponse.success(Map.of(
"totalMessages", totalMessages,
@ -163,6 +184,7 @@ public class ImAdminController {
@GetMapping("/messages")
public ResponseEntity<ApiResponse<Page<com.xuqm.im.entity.ImMessageEntity>>> history(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestParam String userA,
@RequestParam String userB,
@RequestParam(required = false) com.xuqm.im.entity.ImMessageEntity.MsgType msgType,
@ -171,6 +193,7 @@ public class ImAdminController {
@RequestParam(required = false) LocalDateTime endTime,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
operationLogService.record(appId, operatorId, "VIEW_HISTORY", "MESSAGE", userA + "," + userB, keyword);
return ResponseEntity.ok(ApiResponse.success(
messageRepository.findSingleConversationFiltered(
appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
@ -180,14 +203,21 @@ public class ImAdminController {
@PostMapping("/messages/{messageId}/revoke")
public ResponseEntity<ApiResponse<com.xuqm.im.entity.ImMessageEntity>> adminRevoke(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@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. */
@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);
operationLogService.record(appId, operatorId, "ADMIN_DISMISS_GROUP", "GROUP", groupId, null);
return ResponseEntity.ok(ApiResponse.ok());
}
@ -199,25 +229,31 @@ public class ImAdminController {
@PostMapping("/webhooks")
public ResponseEntity<ApiResponse<WebhookConfigEntity>> createWebhook(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody WebhookConfigRequest req) {
return ResponseEntity.ok(ApiResponse.success(
webhookConfigService.create(appId, req.url(), req.secret(), req.enabled())));
WebhookConfigEntity saved = 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}")
public ResponseEntity<ApiResponse<WebhookConfigEntity>> updateWebhook(
@RequestParam String appId,
@PathVariable String id,
@AuthenticationPrincipal String operatorId,
@RequestBody WebhookConfigRequest req) {
return ResponseEntity.ok(ApiResponse.success(
webhookConfigService.update(appId, id, req.url(), req.secret(), req.enabled())));
WebhookConfigEntity saved = 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}")
public ResponseEntity<ApiResponse<Void>> deleteWebhook(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@PathVariable String id) {
webhookConfigService.delete(appId, id);
operationLogService.record(appId, operatorId, "DELETE_WEBHOOK", "WEBHOOK", id, null);
return ResponseEntity.ok(ApiResponse.ok());
}
@ -229,25 +265,31 @@ public class ImAdminController {
@PostMapping("/keyword-filters")
public ResponseEntity<ApiResponse<KeywordFilterEntity>> createKeywordFilter(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@RequestBody KeywordFilterRequest req) {
return ResponseEntity.ok(ApiResponse.success(
keywordFilterService.add(appId, req.pattern(), req.replacement(), req.action())));
KeywordFilterEntity saved = 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}")
public ResponseEntity<ApiResponse<KeywordFilterEntity>> updateKeywordFilter(
@RequestParam String appId,
@PathVariable String id,
@AuthenticationPrincipal String operatorId,
@RequestBody KeywordFilterRequest req) {
return ResponseEntity.ok(ApiResponse.success(
keywordFilterService.update(appId, id, req.pattern(), req.replacement(), req.action(), req.enabled())));
KeywordFilterEntity saved = 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}")
public ResponseEntity<ApiResponse<Void>> deleteKeywordFilter(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@PathVariable String id) {
keywordFilterService.delete(appId, id);
operationLogService.record(appId, operatorId, "DELETE_KEYWORD_FILTER", "KEYWORD_FILTER", id, null);
return ResponseEntity.ok(ApiResponse.ok());
}
@ -259,8 +301,20 @@ public class ImAdminController {
@PutMapping("/global-mute")
public ResponseEntity<ApiResponse<ImGlobalMuteEntity>> setGlobalMute(
@RequestParam String appId,
@AuthenticationPrincipal String operatorId,
@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) {}

查看文件

@ -15,8 +15,8 @@ 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.RequestParam;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
import java.time.LocalDateTime;

查看文件

@ -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,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);
}

查看文件

@ -90,7 +90,50 @@ public class ImAccountService {
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
) {}
}

查看文件

@ -9,7 +9,9 @@ import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.ImGroupMuteEntity;
import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.repository.ImGroupJoinRequestRepository;
import com.xuqm.im.repository.ImAccountRepository;
import com.xuqm.im.repository.ImGroupRepository;
import com.xuqm.im.repository.ImGroupMuteRepository;
import com.xuqm.im.repository.ImMessageRepository;
@ -31,6 +33,7 @@ public class ImGroupService {
private final ImGroupMuteRepository muteRepository;
private final ImGroupJoinRequestRepository joinRequestRepository;
private final ImMessageRepository messageRepository;
private final ImAccountRepository accountRepository;
private final ImClusterPublisher clusterPublisher;
private final ObjectMapper objectMapper;
@ -38,12 +41,14 @@ public class ImGroupService {
ImGroupMuteRepository muteRepository,
ImGroupJoinRequestRepository joinRequestRepository,
ImMessageRepository messageRepository,
ImAccountRepository accountRepository,
ImClusterPublisher clusterPublisher,
ObjectMapper objectMapper) {
this.groupRepository = groupRepository;
this.muteRepository = muteRepository;
this.joinRequestRepository = joinRequestRepository;
this.messageRepository = messageRepository;
this.accountRepository = accountRepository;
this.clusterPublisher = clusterPublisher;
this.objectMapper = objectMapper;
}
@ -249,6 +254,24 @@ public class ImGroupService {
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
public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) {
ImGroupEntity group = get(groupId);
@ -498,4 +521,12 @@ public class ImGroupService {
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;
}
}

查看文件

@ -456,15 +456,17 @@ public class MessageService {
private String buildPushPayload(ImMessageEntity message) {
try {
return objectMapper.writeValueAsString(Map.of(
"messageId", message.getId(),
"appId", message.getAppId(),
"fromUserId", message.getFromUserId(),
"toId", message.getToId(),
"chatType", message.getChatType().name(),
"msgType", message.getMsgType().name(),
"editedAt", message.getEditedAt() == null ? null : message.getEditedAt().toInstant(ZoneOffset.UTC).toEpochMilli()
));
Map<String, Object> payload = new java.util.LinkedHashMap<>();
payload.put("messageId", message.getId());
payload.put("appId", message.getAppId());
payload.put("fromUserId", message.getFromUserId());
payload.put("toId", message.getToId());
payload.put("chatType", message.getChatType().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) {
return "{}";
}

查看文件

@ -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.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
@ComponentScan(basePackages = {"com.xuqm.update", "com.xuqm.common"})
public class UpdateServiceApplication {
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)));
}
}

查看文件

@ -61,7 +61,12 @@ public class AppVersionController {
@RequestParam int versionCode,
@RequestParam(required = false) String changeLog,
@RequestParam(defaultValue = "false") boolean forceUpdate,
@RequestParam(required = false) MultipartFile apkFile) throws Exception {
@RequestParam(required = false) MultipartFile apkFile,
@RequestParam(required = false) String scheduledPublishAt,
@RequestParam(required = false) String webhookUrl,
@RequestParam(required = false) String storeSubmitTargets,
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
@RequestParam(required = false) String packageName) throws Exception {
AppVersionEntity entity = new AppVersionEntity();
entity.setId(UUID.randomUUID().toString());
@ -74,6 +79,13 @@ public class AppVersionController {
entity.setForceUpdate(forceUpdate);
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
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)));
}

查看文件

@ -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 PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
/** Per-store review state used in storeReviewStatus JSON values. */
public enum StoreReviewState { PENDING, UNDER_REVIEW, APPROVED, REJECTED }
@Id
private String id;
@ -56,6 +58,35 @@ public class AppVersionEntity {
@Column(nullable = false)
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)
private LocalDateTime createdAt;
@ -98,6 +129,24 @@ public class AppVersionEntity {
public int getGrayPercent() { return 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 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,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 org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@ -11,4 +12,6 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
String appId, AppVersionEntity.Platform platform);
Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc(
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;
}
}