docs(deploy): 添加完整的部署文档和配置示例

- 新增 compose.production.yaml 和 compose.production.server.yaml 部署配置
- 添加 nginx.dev.xuqinmin.com.conf 和 nginx.sentry.xuqinmin.com.conf 反向代理配置
- 创建详细的部署指南文档 deploy/README.md,涵盖架构设计和部署步骤
- 添加前端访问文档 web/README.md,包含线上地址和接口说明
- 补充平台文档总览 README.md,整合各模块文档入口
- 配置多服务容器化部署,包括 tenant-service、im-service、push-service 等
- 设置外部数据库和 Redis 连接配置,确保服务间正确通信
- 配置 WebSocket 和 API 路由转发规则,支持实时通信和版本更新服务
这个提交包含在:
XuqmGroup 2026-05-09 14:53:43 +08:00
父节点 3151df4054
当前提交 09891bf46e
共有 19 个文件被更改,包括 305 次插入58 次删除

查看文件

@ -109,6 +109,8 @@ JWT Payload 由 `atob(token.split('.')[1])` 解析,无需额外请求。
## 运营管理平台ops-platform`:5174` ## 运营管理平台ops-platform`:5174`
> 内部使用,管理所有租户、查看统计数据。独立账号体系。 > 内部使用,管理所有租户、查看统计数据。独立账号体系。
>
> 生产环境独立域名:`https://ops.xuqinmin.com/`
### 路由结构 ### 路由结构
@ -153,10 +155,10 @@ API 请求通过 `src/api/client.ts` 中的 axios 实例统一附加,401 跳
```env ```env
# tenant-platform # tenant-platform
VITE_API_BASE_URL=https://api.xuqm.com/api VITE_API_BASE_URL=https://dev.xuqinmin.com/api
# ops-platform # ops-platform
VITE_API_BASE_URL=https://api.xuqm.com/api VITE_API_BASE_URL=https://ops.xuqinmin.com/api
``` ```
未设置时默认代理到 `http://localhost:8081`Vite dev server proxy 未设置时默认代理到 `http://localhost:8081`Vite dev server proxy

查看文件

@ -21,9 +21,12 @@ UserSig 是 XuqmGroup 的登录鉴权凭证,由业务服务端用 `appSecret`
### 特点 ### 特点
- 当前版本不过期,只校验 `userId + UserSig` 是否匹配 - 客户端 IM 登录使用 `userId + UserSig`
- 服务端 SDK 可本地生成和校验 UserSig
- 当前版本支持过期时间,签发时可按业务控制有效期
- `appSecret` **绝不下发到客户端** - `appSecret` **绝不下发到客户端**
- 若需撤销权限,可在租户平台重置 `appSecret` 或拉黑账号 - 若需撤销权限,可在租户平台重置 `appSecret` 或拉黑账号
- 注册用户可标记为管理员;管理员账号可用于服务端 SDK / 管理端 REST API
### 签发方式(示例) ### 签发方式(示例)
@ -32,9 +35,9 @@ UserSig 是 XuqmGroup 的登录鉴权凭证,由业务服务端用 `appSecret`
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
const userSig = jwt.sign( const userSig = jwt.sign(
{ userId: 'user_001', appKey: 'your_app_key' }, { userId: 'user_001', appKey: 'your_app_key', iat: Math.floor(Date.now() / 1000) },
'your_app_secret', 'your_app_secret',
{ algorithm: 'HS256' } { algorithm: 'HS256', expiresIn: '180d' }
) )
``` ```

查看文件

@ -43,6 +43,8 @@ XuqmSDK.shared.initialize(config: config)
## 4. 服务端签发 UserSig ## 4. 服务端签发 UserSig
服务端可以通过 SDK 本地生成 `UserSig`,也可以通过 IM 管理页生成并校验。
如果账号需要用于服务端 SDK / 管理端 REST API,请把该注册用户标记为管理员。
### 签发逻辑(示例) ### 签发逻辑(示例)
@ -51,8 +53,9 @@ XuqmSDK.shared.initialize(config: config)
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
return jwt.sign( return jwt.sign(
{ userId, appKey }, { userId, appKey, iat: Math.floor(Date.now() / 1000) },
{ algorithm: 'HS256' } appSecret,
{ algorithm: 'HS256', expiresIn: '180d' }
) )
} }
``` ```
@ -60,10 +63,11 @@ import jwt from 'jsonwebtoken'
```python ```python
# Python # Python
import jwt import jwt
import time
def generate_user_sig(user_id: str, app_key: str, app_secret: str) -> str: def generate_user_sig(user_id: str, app_key: str, app_secret: str) -> str:
return jwt.encode( return jwt.encode(
{"userId": user_id, "appKey": app_key}, {"userId": user_id, "appKey": app_key, "iat": int(time.time())},
app_secret, app_secret,
algorithm="HS256" algorithm="HS256"
) )
@ -72,10 +76,12 @@ def generate_user_sig(user_id: str, app_key: str, app_secret: str) -> str:
```go ```go
// Go // Go
import "github.com/golang-jwt/jwt/v5" import "github.com/golang-jwt/jwt/v5"
import "time"
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"userId": userID, "userId": userID,
"appKey": appKey, "appKey": appKey,
"iat": time.Now().Unix(),
}) })
} }
``` ```

查看文件

@ -16,9 +16,14 @@ XuqmGroup 的服务端能力分成三类:
### IM 服务 ### IM 服务
- `POST /api/im/auth/login` 之前的所有请求,客户端和服务端均需携带 `Authorization: Bearer <im_jwt>` - `POST /api/im/auth/login` 返回 `im_jwt`
- `im_jwt` 通过 `POST /api/im/auth/login` 颁发 - 客户端登录使用 `userId + userSig`
- 登录请求需要 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature` - `userSig` 由服务端或 IM 管理页生成,服务端 SDK 可本地生成和校验
- `GET /api/im/platform-events/token` 由 IM 服务返回平台事件账号 `platform` 的登录 token,用于租户平台实时刷新
- 服务端管理类调用使用具备管理员权限的 IM 账号登录后获得的 `im_jwt`
- 服务端如果不想先走 SDK,也可以直接调用 `/api/im/admin/users/{userId}/usersig` 生成 `userSig`,再用 `/api/im/auth/login?userSig=...` 换取 `im_jwt`
- 只有被标记为管理员的 IM 注册账号,才允许用于服务端 SDK / 管理端 REST API
- IM 管理页里的 `UserSig` 生成 / 校验工具也只对管理员账号开放
### Push / Update / Tenant 相关服务 ### Push / Update / Tenant 相关服务
@ -34,7 +39,9 @@ XuqmGroup 的服务端能力分成三类:
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| `POST` | `/api/im/auth/login` | IM 登录,返回 JWT Token | | `POST` | `/api/im/auth/login` | IM 登录,返回 JWT Token;支持 `userSig` |
| `POST` | `/api/im/admin/users/{userId}/usersig` | 生成 UserSig |
| `POST` | `/api/im/admin/users/{userId}/usersig/verify` | 校验 UserSig |
| `GET` | `/api/im/accounts/{userId}` | 获取账号资料 | | `GET` | `/api/im/accounts/{userId}` | 获取账号资料 |
| `PUT` | `/api/im/accounts/{userId}` | 更新账号资料 | | `PUT` | `/api/im/accounts/{userId}` | 更新账号资料 |
| `GET` | `/api/im/accounts/search` | 搜索账号 | | `GET` | `/api/im/accounts/search` | 搜索账号 |
@ -177,6 +184,9 @@ XuqmGroup 的服务端能力分成三类:
| `GET` | `/api/push/admin/device-logs` | 设备日志 | | `GET` | `/api/push/admin/device-logs` | 设备日志 |
| `POST` | `/api/push/admin/test-offline` | 离线推送测试 | | `POST` | `/api/push/admin/test-offline` | 离线推送测试 |
> Push 服务不使用 UserSig;公开接口面向业务服务,管理端接口继续使用平台登录态或服务端管理态。
> Push SDK 与 REST API 的公开调用面向业务系统,管理员诊断能力通过平台 JWT 或服务端管理态访问。
### 内部接口 ### 内部接口
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |

查看文件

@ -2,7 +2,7 @@
XuqmGroup 服务端 Go SDK,按照腾讯云服务端 API 的分类方式封装了: XuqmGroup 服务端 Go SDK,按照腾讯云服务端 API 的分类方式封装了:
- IM 账号、消息、群组、好友、会话、黑名单 - IM 账号、UserSig 生成与登录、消息、群组、好友、会话、黑名单
- 管理端 Webhook、统计、操作日志 - 管理端 Webhook、统计、操作日志
- Push 注册与推送 - Push 注册与推送
- Update 版本管理、RN Bundle、应用商店提审 - Update 版本管理、RN Bundle、应用商店提审
@ -36,11 +36,23 @@ if err != nil {
--- ---
## UserSig
```go
userSig := client.GenerateUserSig("user_001")
valid := client.VerifyUserSig("user_001", userSig)
login, err := client.LoginWithUserSig("user_001", userSig)
```
管理员账号可用于服务端 SDK / 管理端 REST API。
---
## 能力分类 ## 能力分类
| 分类 | 主要方法 | | 分类 | 主要方法 |
|------|----------| |------|----------|
| 登录 | `Login` | | 登录 | `GenerateUserSig`, `VerifyUserSig`, `Login`, `LoginWithUserSig` |
| 账号 | `ImportAccount`, `ImportAccounts`, `DeleteAccount`, `GetProfile`, `UpdateProfile`, `SearchAccounts`, `CheckAccount` | | 账号 | `ImportAccount`, `ImportAccounts`, `DeleteAccount`, `GetProfile`, `UpdateProfile`, `SearchAccounts`, `CheckAccount` |
| 消息 | `SendMessage`, `RevokeMessage`, `EditMessage`, `FetchHistory`, `FetchGroupHistory`, `SearchMessages` | | 消息 | `SendMessage`, `RevokeMessage`, `EditMessage`, `FetchHistory`, `FetchGroupHistory`, `SearchMessages` |
| 会话 | `ListConversations`, `SetConversationPinned`, `SetConversationMuted`, `MarkRead`, `SetDraft`, `SetConversationHidden`, `SetConversationGroup`, `DeleteConversation` | | 会话 | `ListConversations`, `SetConversationPinned`, `SetConversationMuted`, `MarkRead`, `SetDraft`, `SetConversationHidden`, `SetConversationGroup`, `DeleteConversation` |
@ -49,7 +61,7 @@ if err != nil {
| 群组 | `ListGroups`, `ListPublicGroups`, `SearchGroups`, `GetGroup`, `ListGroupMembers`, `SearchGroupMembers`, `CreateGroup`, `UpdateGroup`, `AddGroupMember`, `AddGroupMembers`, `RemoveGroupMember`, `RemoveGroupMembers`, `SetGroupRole`, `TransferGroupOwner`, `LeaveGroup`, `UpdateGroupAttributes`, `RemoveGroupAttributes`, `MuteGroupMember`, `DismissGroup`, `SendGroupJoinRequest`, `ListGroupJoinRequests`, `AcceptGroupJoinRequest`, `RejectGroupJoinRequest`, `AcceptGroupJoinRequests`, `RejectGroupJoinRequests` | | 群组 | `ListGroups`, `ListPublicGroups`, `SearchGroups`, `GetGroup`, `ListGroupMembers`, `SearchGroupMembers`, `CreateGroup`, `UpdateGroup`, `AddGroupMember`, `AddGroupMembers`, `RemoveGroupMember`, `RemoveGroupMembers`, `SetGroupRole`, `TransferGroupOwner`, `LeaveGroup`, `UpdateGroupAttributes`, `RemoveGroupAttributes`, `MuteGroupMember`, `DismissGroup`, `SendGroupJoinRequest`, `ListGroupJoinRequests`, `AcceptGroupJoinRequest`, `RejectGroupJoinRequest`, `AcceptGroupJoinRequests`, `RejectGroupJoinRequests` |
| 管理端 | `QueryUserState`, `KickUsers`, `BatchSendMessage`, `AdminSetMsgRead`, `ImportMessages`, `AdminTransferGroupOwner`, `AdminUpdateGroupAttributes`, `AdminRemoveGroupAttributes`, `AdminGroupReadReceipts` | | 管理端 | `QueryUserState`, `KickUsers`, `BatchSendMessage`, `AdminSetMsgRead`, `ImportMessages`, `AdminTransferGroupOwner`, `AdminUpdateGroupAttributes`, `AdminRemoveGroupAttributes`, `AdminGroupReadReceipts` |
| Webhook | `ListWebhooks`, `CreateWebhook`, `UpdateWebhook`, `DeleteWebhook`, `VerifyCallbackSignature`, `ParseCallbackEnvelope` | | Webhook | `ListWebhooks`, `CreateWebhook`, `UpdateWebhook`, `DeleteWebhook`, `VerifyCallbackSignature`, `ParseCallbackEnvelope` |
| Push | `RegisterPushToken`, `SendPush` | | Push | `RegisterPushToken`, `SendPush`, `PushUserStatus`, `PushDeviceLogs`, `TestOfflinePush` |
| Update | `CheckAppUpdate`, `UploadAppVersion`, `PublishAppVersion`, `UnpublishAppVersion`, `GrayAppVersion`, `ListAppVersions`, `CheckRnUpdate`, `UploadRnBundle`, `PublishRnBundle`, `UnpublishRnBundle`, `ListRnBundles` | | Update | `CheckAppUpdate`, `UploadAppVersion`, `PublishAppVersion`, `UnpublishAppVersion`, `GrayAppVersion`, `ListAppVersions`, `CheckRnUpdate`, `UploadRnBundle`, `PublishRnBundle`, `UnpublishRnBundle`, `ListRnBundles` |
--- ---

查看文件

@ -2,7 +2,7 @@
XuqmGroup 服务端 Java SDK,按腾讯云服务端 API 的思路封装了以下能力: XuqmGroup 服务端 Java SDK,按腾讯云服务端 API 的思路封装了以下能力:
- IM 账号与登录 - IM 账号、UserSig 生成与登录
- 消息发送、编辑、撤回、历史查询 - 消息发送、编辑、撤回、历史查询
- 好友、黑名单、会话、群组 - 好友、黑名单、会话、群组
- Webhook、管理端操作、统计 - Webhook、管理端操作、统计
@ -58,6 +58,18 @@ XuqmImServerSdk client = XuqmImServerSdk.builder()
--- ---
## UserSig
```java
String userSig = client.generateUserSig("user_001");
boolean valid = client.verifyUserSig("user_001", userSig);
var login = client.loginWithUserSig("user_001", userSig);
```
管理员账号可用于服务端 SDK / 管理端 REST API;建议在 IM 管理页先给对应注册用户打上管理员标记。
---
## 发送消息 ## 发送消息
```java ```java
@ -100,7 +112,7 @@ client.revokeMessage("message_id");
| 分类 | 主要方法 | | 分类 | 主要方法 |
|------|----------| |------|----------|
| IM 账号 | `login`, `importAccount`, `importAccounts`, `deleteAccount`, `getProfile`, `updateProfile`, `searchAccounts` | | IM 账号 | `generateUserSig`, `verifyUserSig`, `login`, `loginWithUserSig`, `importAccount`, `importAccounts`, `deleteAccount`, `getProfile`, `updateProfile`, `searchAccounts` |
| 消息 | `sendMessage`, `revokeMessage`, `editMessage`, `fetchHistory`, `fetchGroupHistory`, `searchMessages` | | 消息 | `sendMessage`, `revokeMessage`, `editMessage`, `fetchHistory`, `fetchGroupHistory`, `searchMessages` |
| 会话 | `listConversations`, `setConversationPinned`, `setConversationMuted`, `markRead`, `setDraft`, `setConversationHidden`, `setConversationGroup`, `deleteConversation` | | 会话 | `listConversations`, `setConversationPinned`, `setConversationMuted`, `markRead`, `setDraft`, `setConversationHidden`, `setConversationGroup`, `deleteConversation` |
| 好友 | `listFriends`, `addFriend`, `addFriends`, `removeFriend`, `removeFriends`, `removeAllFriends`, `setFriendGroup`, `listFriendGroups`, `listFriendsByGroup`, `checkFriends` | | 好友 | `listFriends`, `addFriend`, `addFriends`, `removeFriend`, `removeFriends`, `removeAllFriends`, `setFriendGroup`, `listFriendGroups`, `listFriendsByGroup`, `checkFriends` |
@ -108,7 +120,7 @@ client.revokeMessage("message_id");
| 群组 | `listGroups`, `createGroup`, `updateGroup`, `addGroupMember`, `addGroupMembers`, `removeGroupMember`, `removeGroupMembers`, `setGroupRole`, `transferGroupOwner`, `leaveGroup`, `updateGroupAttributes`, `removeGroupAttributes`, `muteGroupMember`, `dismissGroup`, `sendGroupJoinRequest`, `listGroupJoinRequests`, `acceptGroupJoinRequest`, `rejectGroupJoinRequest`, `acceptGroupJoinRequests`, `rejectGroupJoinRequests` | | 群组 | `listGroups`, `createGroup`, `updateGroup`, `addGroupMember`, `addGroupMembers`, `removeGroupMember`, `removeGroupMembers`, `setGroupRole`, `transferGroupOwner`, `leaveGroup`, `updateGroupAttributes`, `removeGroupAttributes`, `muteGroupMember`, `dismissGroup`, `sendGroupJoinRequest`, `listGroupJoinRequests`, `acceptGroupJoinRequest`, `rejectGroupJoinRequest`, `acceptGroupJoinRequests`, `rejectGroupJoinRequests` |
| 管理端 | `queryUserState`, `kickUsers`, `batchSendMessage`, `adminSetMsgRead`, `importMessages`, `adminTransferGroupOwner`, `adminUpdateGroupAttributes`, `adminRemoveGroupAttributes`, `adminGroupReadReceipts` | | 管理端 | `queryUserState`, `kickUsers`, `batchSendMessage`, `adminSetMsgRead`, `importMessages`, `adminTransferGroupOwner`, `adminUpdateGroupAttributes`, `adminRemoveGroupAttributes`, `adminGroupReadReceipts` |
| Webhook | `listWebhooks`, `createWebhook`, `updateWebhook`, `deleteWebhook`, `verifyCallbackSignature`, `parseCallbackEnvelope`, 各类 `parse*CallbackPayload` | | Webhook | `listWebhooks`, `createWebhook`, `updateWebhook`, `deleteWebhook`, `verifyCallbackSignature`, `parseCallbackEnvelope`, 各类 `parse*CallbackPayload` |
| Push | `registerPushToken`, `sendPush` | | Push | `registerPushToken`, `sendPush`, `pushUserStatus`, `pushDeviceLogs`, `testOfflinePush` |
| Update | `checkAppUpdate`, `uploadAppVersion`, `publishAppVersion`, `unpublishAppVersion`, `grayAppVersion`, `listAppVersions`, `checkRnUpdate`, `uploadRnBundle`, `publishRnBundle`, `unpublishRnBundle`, `listRnBundles` | | Update | `checkAppUpdate`, `uploadAppVersion`, `publishAppVersion`, `unpublishAppVersion`, `grayAppVersion`, `listAppVersions`, `checkRnUpdate`, `uploadRnBundle`, `publishRnBundle`, `unpublishRnBundle`, `listRnBundles` |
## 群管理示例 ## 群管理示例

查看文件

@ -2,7 +2,7 @@
XuqmGroup 服务端 Python SDK,按照腾讯云服务端 API 的分类方式封装了: XuqmGroup 服务端 Python SDK,按照腾讯云服务端 API 的分类方式封装了:
- IM 账号、消息、群组、好友、会话、黑名单 - IM 账号、UserSig 生成与登录、消息、群组、好友、会话、黑名单
- 管理端 Webhook、统计、操作日志 - 管理端 Webhook、统计、操作日志
- Push 注册与推送 - Push 注册与推送
- Update 版本管理、RN Bundle、应用商店提审 - Update 版本管理、RN Bundle、应用商店提审
@ -33,11 +33,23 @@ sdk = XuqmImServerSdk(
--- ---
## UserSig
```python
user_sig = sdk.generate_user_sig("user_001")
valid = sdk.verify_user_sig("user_001", user_sig)
login = sdk.login_with_user_sig("user_001", user_sig)
```
管理员账号可用于服务端 SDK / 管理端 REST API。
---
## 能力分类 ## 能力分类
| 分类 | 主要方法 | | 分类 | 主要方法 |
|------|----------| |------|----------|
| 登录 | `login` | | 登录 | `generate_user_sig`, `verify_user_sig`, `login`, `login_with_user_sig` |
| 账号 | `import_account`, `import_accounts`, `delete_account`, `get_profile`, `update_profile`, `search_accounts`, `check_account` | | 账号 | `import_account`, `import_accounts`, `delete_account`, `get_profile`, `update_profile`, `search_accounts`, `check_account` |
| 消息 | `send_message`, `revoke_message`, `edit_message`, `fetch_history`, `fetch_group_history`, `search_messages` | | 消息 | `send_message`, `revoke_message`, `edit_message`, `fetch_history`, `fetch_group_history`, `search_messages` |
| 会话 | `list_conversations`, `set_conversation_pinned`, `set_conversation_muted`, `mark_read`, `set_draft`, `set_conversation_hidden`, `set_conversation_group`, `delete_conversation` | | 会话 | `list_conversations`, `set_conversation_pinned`, `set_conversation_muted`, `mark_read`, `set_draft`, `set_conversation_hidden`, `set_conversation_group`, `delete_conversation` |
@ -46,7 +58,7 @@ sdk = XuqmImServerSdk(
| 群组 | `list_groups`, `list_public_groups`, `search_groups`, `get_group`, `list_group_members`, `search_group_members`, `create_group`, `update_group`, `add_group_member`, `add_group_members`, `remove_group_member`, `remove_group_members`, `set_group_role`, `transfer_group_owner`, `leave_group`, `update_group_attributes`, `remove_group_attributes`, `mute_group_member`, `dismiss_group`, `send_group_join_request`, `list_group_join_requests`, `accept_group_join_request`, `reject_group_join_request`, `accept_group_join_requests`, `reject_group_join_requests` | | 群组 | `list_groups`, `list_public_groups`, `search_groups`, `get_group`, `list_group_members`, `search_group_members`, `create_group`, `update_group`, `add_group_member`, `add_group_members`, `remove_group_member`, `remove_group_members`, `set_group_role`, `transfer_group_owner`, `leave_group`, `update_group_attributes`, `remove_group_attributes`, `mute_group_member`, `dismiss_group`, `send_group_join_request`, `list_group_join_requests`, `accept_group_join_request`, `reject_group_join_request`, `accept_group_join_requests`, `reject_group_join_requests` |
| 管理端 | `query_user_state`, `kick_users`, `batch_send_message`, `admin_set_msg_read`, `import_messages`, `admin_transfer_group_owner`, `admin_update_group_attributes`, `admin_remove_group_attributes`, `admin_group_read_receipts` | | 管理端 | `query_user_state`, `kick_users`, `batch_send_message`, `admin_set_msg_read`, `import_messages`, `admin_transfer_group_owner`, `admin_update_group_attributes`, `admin_remove_group_attributes`, `admin_group_read_receipts` |
| Webhook | `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook`, `verify_callback_signature`, `parse_callback_envelope` | | Webhook | `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook`, `verify_callback_signature`, `parse_callback_envelope` |
| Push | `register_push_token`, `send_push` | | Push | `register_push_token`, `send_push`, `push_user_status`, `push_device_logs`, `test_offline_push` |
| Update | `check_app_update`, `upload_app_version`, `publish_app_version`, `unpublish_app_version`, `gray_app_version`, `list_app_versions`, `check_rn_update`, `upload_rn_bundle`, `publish_rn_bundle`, `unpublish_rn_bundle`, `list_rn_bundles` | | Update | `check_app_update`, `upload_app_version`, `publish_app_version`, `unpublish_app_version`, `gray_app_version`, `list_app_versions`, `check_rn_update`, `upload_rn_bundle`, `publish_rn_bundle`, `unpublish_rn_bundle`, `list_rn_bundles` |
--- ---

查看文件

@ -7,7 +7,7 @@
| 系统 | 地址 | 说明 | | 系统 | 地址 | 说明 |
|------|------|------| |------|------|------|
| 租户开放平台 | `https://dev.xuqinmin.com/` | 主账号注册、登录、应用管理 | | 租户开放平台 | `https://dev.xuqinmin.com/` | 主账号注册、登录、应用管理 |
| 运营管理平台 | `https://dev.xuqinmin.com/ops/` | 内部运营管理入口 | | 运营管理平台 | `https://ops.xuqinmin.com/` | 内部运营管理入口 |
## 初始化管理员账号 ## 初始化管理员账号
@ -41,9 +41,9 @@
| 路径 | 说明 | | 路径 | 说明 |
|------|------| |------|------|
| `/ops/login` | 运营管理员登录 | | `/login` | 运营管理员登录 |
| `/ops/tenants` | 租户列表 | | `/tenants` | 租户列表 |
| `/ops/statistics` | 统计面板 | | `/statistics` | 统计面板 |
## 主要联调接口 ## 主要联调接口
@ -75,4 +75,7 @@
```env ```env
VITE_API_BASE_URL=https://dev.xuqinmin.com/api VITE_API_BASE_URL=https://dev.xuqinmin.com/api
# ops-platform
VITE_API_BASE_URL=https://ops.xuqinmin.com/api
``` ```

查看文件

@ -8,7 +8,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, new URL('.', import.meta.url).pathname, '') const env = loadEnv(mode, new URL('.', import.meta.url).pathname, '')
return { return {
base: env.VITE_APP_BASE || '/ops/', base: env.VITE_APP_BASE || '/',
plugins: [ plugins: [
vue(), vue(),
AutoImport({ resolvers: [ElementPlusResolver()] }), AutoImport({ resolvers: [ElementPlusResolver()] }),

查看文件

@ -11,6 +11,8 @@
"dev:tenant": "yarn workspace tenant-platform dev", "dev:tenant": "yarn workspace tenant-platform dev",
"dev:ops": "yarn workspace ops-platform dev", "dev:ops": "yarn workspace ops-platform dev",
"dev:docs": "yarn workspace docs-site dev", "dev:docs": "yarn workspace docs-site dev",
"build:tenant": "yarn workspace tenant-platform build",
"build:ops": "yarn workspace ops-platform build",
"build": "yarn workspaces run build" "build": "yarn workspaces run build"
}, },
"devDependencies": { "devDependencies": {

查看文件

@ -147,13 +147,13 @@ export interface PushServiceConfig {
export const appApi = { export const appApi = {
list: () => client.get<{ data: App[] }>('/apps'), list: () => client.get<{ data: App[] }>('/apps'),
get: (id: string) => client.get<{ data: App }>(`/apps/${id}`), get: (appKey: string) => client.get<{ data: App }>(`/apps/${appKey}`),
create: (data: CreateAppRequest) => client.post<{ data: App }>('/apps', data), create: (data: CreateAppRequest) => client.post<{ data: App }>('/apps', data),
update: (id: string, data: CreateAppRequest) => client.put<{ data: App }>(`/apps/${id}`, data), update: (appKey: string, data: CreateAppRequest) => client.put<{ data: App }>(`/apps/${appKey}`, data),
delete: (id: string) => client.delete(`/apps/${id}`), delete: (appKey: string) => client.delete(`/apps/${appKey}`),
getServices: (appKey: string) => getServices: (appKey: string) =>
client.get<{ data: FeatureService[] }>(`/apps/${appKey}/services`), client.get<{ data: FeatureService[] }>(`/apps/${appKey}/services`),

查看文件

@ -72,6 +72,7 @@ export interface ImUser {
userId: string userId: string
nickname: string nickname: string
avatar?: string avatar?: string
admin?: boolean
status: 'ACTIVE' | 'BANNED' status: 'ACTIVE' | 'BANNED'
gender: 'UNKNOWN' | 'MALE' | 'FEMALE' gender: 'UNKNOWN' | 'MALE' | 'FEMALE'
createdAt: number createdAt: number
@ -252,7 +253,7 @@ export const imAdminApi = {
updateUser( updateUser(
appKey: string, appKey: string,
userId: string, userId: string,
form: { nickname?: string; avatar?: string; gender?: string; status?: string }, form: { nickname?: string; avatar?: string; gender?: string; status?: string; admin?: boolean },
) { ) {
return imClient.put<{ data: ImUser }>( return imClient.put<{ data: ImUser }>(
`/api/im/admin/users/${encodeURIComponent(userId)}`, `/api/im/admin/users/${encodeURIComponent(userId)}`,
@ -261,6 +262,30 @@ export const imAdminApi = {
) )
}, },
generateUserSig(
appKey: string,
userId: string,
form?: { expireSeconds?: number; userBuf?: string },
) {
return imClient.post<{ data: { appKey: string; userId: string; userSig: string; issuedAt: number; expiresAt: number; userBuf?: string } }>(
`/api/im/admin/users/${encodeURIComponent(userId)}/usersig`,
form ?? {},
{ params: { appKey } },
)
},
verifyUserSig(
appKey: string,
userId: string,
userSig: string,
) {
return imClient.post<{ data: { valid: boolean; appKey: string; userId: string; issuedAt: number; expiresAt: number; userBuf?: string } }>(
`/api/im/admin/users/${encodeURIComponent(userId)}/usersig/verify`,
{ userSig },
{ params: { appKey } },
)
},
updateWebhook(appKey: string, webhookId: string, form: WebhookConfigForm) { updateWebhook(appKey: string, webhookId: string, form: WebhookConfigForm) {
return imClient.put<{ data: WebhookConfig }>(`/api/im/admin/webhooks/${encodeURIComponent(webhookId)}`, form, { return imClient.put<{ data: WebhookConfig }>(`/api/im/admin/webhooks/${encodeURIComponent(webhookId)}`, form, {
params: { appKey }, params: { appKey },
@ -444,6 +469,7 @@ export const imAdminApi = {
avatar?: string, avatar?: string,
gender?: string, gender?: string,
status?: string, status?: string,
admin?: boolean,
) { ) {
return imClient.post<{ data: ImUser }>( return imClient.post<{ data: ImUser }>(
'/api/im/admin/users', '/api/im/admin/users',
@ -453,6 +479,7 @@ export const imAdminApi = {
avatar, avatar,
...(gender ? { gender } : {}), ...(gender ? { gender } : {}),
...(status ? { status } : {}), ...(status ? { status } : {}),
...(admin !== undefined ? { admin } : {}),
}, },
{ params: { appKey } }, { params: { appKey } },
) )

查看文件

@ -33,12 +33,25 @@ function sdkWsUrl() {
} }
function parseEvent(message: ImMessage): StoreReviewRefreshEvent | null { function parseEvent(message: ImMessage): StoreReviewRefreshEvent | null {
if (!['NOTIFY', 'CUSTOM'].includes(message.msgType)) return null
try { try {
const payload = JSON.parse(message.content) as Partial<StoreReviewRefreshEvent> const raw = JSON.parse(message.content) as Record<string, unknown>
if (!payload || payload.event !== 'store_review_update') return null const payload = (raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw) as Record<string, unknown>
if (!payload.appKey) return null const event = String(payload.event ?? raw?.event ?? '')
return payload as StoreReviewRefreshEvent const appKey = String(payload.appKey ?? raw?.appKey ?? '')
if (event !== 'store_review_update' || !appKey) return null
return {
event,
appKey,
versionId: String(payload.versionId ?? raw?.versionId ?? ''),
storeType: String(payload.storeType ?? raw?.storeType ?? ''),
reviewState: String(payload.reviewState ?? raw?.reviewState ?? ''),
reviewReason: String(payload.reviewReason ?? raw?.reviewReason ?? ''),
stage: String(payload.stage ?? raw?.stage ?? ''),
batchId: String(payload.batchId ?? raw?.batchId ?? ''),
publishStatus: String(payload.publishStatus ?? raw?.publishStatus ?? ''),
source: String(payload.source ?? raw?.source ?? ''),
timestamp: Number(payload.timestamp ?? raw?.timestamp ?? Date.now()),
}
} catch { } catch {
return null return null
} }
@ -52,7 +65,7 @@ export async function connectStoreReviewRealtime(appKey: string, onEvent: (event
const res = await client.get<{ data: PlatformEventTokenResponse }>('/im/platform-events/token', { const res = await client.get<{ data: PlatformEventTokenResponse }>('/im/platform-events/token', {
params: { appKey }, params: { appKey },
}) })
const token = res.data.data const token = res.data.data ?? (res.data as unknown as PlatformEventTokenResponse)
init({ init({
appKey, appKey,
baseUrl: sdkBaseUrl(), baseUrl: sdkBaseUrl(),
@ -60,6 +73,12 @@ export async function connectStoreReviewRealtime(appKey: string, onEvent: (event
debug: import.meta.env.DEV, debug: import.meta.env.DEV,
}) })
login(token.userId, token.token) login(token.userId, token.token)
if (import.meta.env.DEV) {
console.debug('[tenant-platform][IM] store review realtime connected token acquired', {
appKey,
userId: token.userId,
})
}
const clientInstance = new ImClient() const clientInstance = new ImClient()
clientInstance.on('message', (message) => { clientInstance.on('message', (message) => {

查看文件

@ -32,8 +32,9 @@ PushSDK.initialize(userId)</pre>
<el-col :xs="24" :md="8"> <el-col :xs="24" :md="8">
<el-card shadow="hover" class="doc-card"> <el-card shadow="hover" class="doc-card">
<template #header>服务端</template> <template #header>服务端</template>
<pre class="code-block">POST /api/auth/login <pre class="code-block">POST /api/im/admin/users/{userId}/usersig
POST /api/im/auth/login POST /api/im/admin/users/{userId}/usersig/verify
POST /api/im/auth/login?userSig=...
POST /api/ops/service-requests/{id}/approve</pre> POST /api/ops/service-requests/{id}/approve</pre>
</el-card> </el-card>
</el-col> </el-col>
@ -46,6 +47,13 @@ POST /api/ops/service-requests/{id}/approve</pre>
<el-step title="开启服务" description="在应用详情开启 IM / Push / Update" /> <el-step title="开启服务" description="在应用详情开启 IM / Push / Update" />
<el-step title="接入 SDK" description="按平台文档完成登录和消息能力接入" /> <el-step title="接入 SDK" description="按平台文档完成登录和消息能力接入" />
</el-steps> </el-steps>
<el-alert
title="IM 登录使用 userSig;平台事件账号会使用预先注册的 admin / platform 用户,服务端 SDK / REST API 仅面向管理员账号。"
type="info"
:closable="false"
show-icon
style="margin-top:16px"
/>
</el-card> </el-card>
</div> </div>
</template> </template>

查看文件

@ -52,6 +52,12 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="权限" width="100">
<template #default="{ row }">
<el-tag v-if="row.admin" type="warning" size="small">管理员</el-tag>
<el-tag v-else type="info" size="small">普通</el-tag>
</template>
</el-table-column>
<el-table-column label="在线" width="120"> <el-table-column label="在线" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="userOnlineState[row.userId]?.online" type="success" size="small">在线</el-tag> <el-tag v-if="userOnlineState[row.userId]?.online" type="success" size="small">在线</el-tag>
@ -71,6 +77,9 @@
<el-button link type="primary" size="small" @click="openEditUserDialog(row)"> <el-button link type="primary" size="small" @click="openEditUserDialog(row)">
编辑 编辑
</el-button> </el-button>
<el-button link type="primary" size="small" @click="openUserSigDialog(row)">
UserSig
</el-button>
<el-button <el-button
link link
:type="row.status === 'ACTIVE' ? 'danger' : 'success'" :type="row.status === 'ACTIVE' ? 'danger' : 'success'"
@ -374,6 +383,9 @@
<el-option label="封禁" value="BANNED" /> <el-option label="封禁" value="BANNED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="管理员">
<el-switch v-model="registerForm.admin" inline-prompt active-text="" inactive-text="" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="showRegisterUser = false">取消</el-button> <el-button @click="showRegisterUser = false">取消</el-button>
@ -716,6 +728,37 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="showUserSigDialog" title="UserSig 生成与校验" width="760px" @closed="resetUserSigDialog">
<el-descriptions v-if="selectedUserForSig" :column="2" border style="margin-bottom: 16px">
<el-descriptions-item label="用户ID">{{ selectedUserForSig.userId }}</el-descriptions-item>
<el-descriptions-item label="权限">{{ selectedUserForSig.admin ? '管理员' : '普通用户' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ selectedUserForSig.status === 'ACTIVE' ? '正常' : '封禁' }}</el-descriptions-item>
<el-descriptions-item label="应用">{{ appKey }}</el-descriptions-item>
</el-descriptions>
<el-form label-width="100px">
<el-form-item label="过期秒数">
<el-input-number v-model="userSigExpireSeconds" :min="60" :max="315360000" style="width: 240px" />
</el-form-item>
<el-form-item label="UserBuf">
<el-input v-model="userSigUserBuf" placeholder="可选,用于附加业务信息" />
</el-form-item>
<el-form-item label="UserSig">
<el-input
v-model="userSigValue"
type="textarea"
:rows="6"
placeholder="点击生成后这里会显示 UserSig"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUserSigDialog = false">关闭</el-button>
<el-button :disabled="!selectedUserForSig" @click="copyUserSig">复制</el-button>
<el-button :loading="generatingUserSig" type="primary" @click="generateUserSig">生成</el-button>
<el-button :loading="verifyingUserSig" @click="verifyUserSig">校验</el-button>
</template>
</el-dialog>
<el-dialog <el-dialog
v-model="showKeywordFilterDialog" v-model="showKeywordFilterDialog"
:title="editingKeywordFilterId ? '编辑过滤规则' : '新增过滤规则'" :title="editingKeywordFilterId ? '编辑过滤规则' : '新增过滤规则'"
@ -886,8 +929,17 @@ const registerForm = ref({
avatar: '', avatar: '',
gender: 'UNKNOWN' as 'UNKNOWN' | 'MALE' | 'FEMALE', gender: 'UNKNOWN' as 'UNKNOWN' | 'MALE' | 'FEMALE',
status: 'ACTIVE' as 'ACTIVE' | 'BANNED', status: 'ACTIVE' as 'ACTIVE' | 'BANNED',
admin: false,
}) })
const showUserSigDialog = ref(false)
const selectedUserForSig = ref<ImUser | null>(null)
const userSigExpireSeconds = ref(180 * 24 * 60 * 60)
const userSigUserBuf = ref('')
const userSigValue = ref('')
const generatingUserSig = ref(false)
const verifyingUserSig = ref(false)
const showCreateGroup = ref(false) const showCreateGroup = ref(false)
const submittingCreateGroup = ref(false) const submittingCreateGroup = ref(false)
const createGroupForm = ref({ const createGroupForm = ref({
@ -1033,6 +1085,7 @@ function openCreateUserDialog() {
avatar: '', avatar: '',
gender: 'UNKNOWN', gender: 'UNKNOWN',
status: 'ACTIVE', status: 'ACTIVE',
admin: false,
} }
showRegisterUser.value = true showRegisterUser.value = true
} }
@ -1045,6 +1098,7 @@ function openEditUserDialog(user: ImUser) {
avatar: user.avatar ?? '', avatar: user.avatar ?? '',
gender: user.gender ?? 'UNKNOWN', gender: user.gender ?? 'UNKNOWN',
status: user.status ?? 'ACTIVE', status: user.status ?? 'ACTIVE',
admin: Boolean(user.admin),
} }
showRegisterUser.value = true showRegisterUser.value = true
} }
@ -1057,9 +1111,71 @@ function resetUserForm() {
avatar: '', avatar: '',
gender: 'UNKNOWN', gender: 'UNKNOWN',
status: 'ACTIVE', status: 'ACTIVE',
admin: false,
} }
} }
function openUserSigDialog(user: ImUser) {
selectedUserForSig.value = user
userSigExpireSeconds.value = 180 * 24 * 60 * 60
userSigUserBuf.value = ''
userSigValue.value = ''
showUserSigDialog.value = true
}
function resetUserSigDialog() {
selectedUserForSig.value = null
userSigExpireSeconds.value = 180 * 24 * 60 * 60
userSigUserBuf.value = ''
userSigValue.value = ''
}
async function generateUserSig() {
if (!selectedUserForSig.value) return
generatingUserSig.value = true
try {
const res = await imAdminApi.generateUserSig(appKey.value, selectedUserForSig.value.userId, {
expireSeconds: userSigExpireSeconds.value,
userBuf: userSigUserBuf.value.trim() || undefined,
})
userSigValue.value = res.data.data.userSig
ElMessage.success('UserSig 已生成')
} catch {
ElMessage.error('UserSig 生成失败')
} finally {
generatingUserSig.value = false
}
}
async function verifyUserSig() {
if (!selectedUserForSig.value || !userSigValue.value.trim()) {
ElMessage.warning('请先生成或粘贴 UserSig')
return
}
verifyingUserSig.value = true
try {
const res = await imAdminApi.verifyUserSig(appKey.value, selectedUserForSig.value.userId, userSigValue.value.trim())
if (res.data.data.valid) {
ElMessage.success('UserSig 校验通过')
} else {
ElMessage.warning('UserSig 校验未通过')
}
} catch {
ElMessage.error('UserSig 校验失败')
} finally {
verifyingUserSig.value = false
}
}
async function copyUserSig() {
if (!userSigValue.value.trim()) {
ElMessage.warning('没有可复制的 UserSig')
return
}
await navigator.clipboard.writeText(userSigValue.value.trim())
ElMessage.success('已复制 UserSig')
}
function openCreateGroupDialog() { function openCreateGroupDialog() {
resetCreateGroupForm() resetCreateGroupForm()
showCreateGroup.value = true showCreateGroup.value = true
@ -1690,6 +1806,7 @@ async function submitRegisterUser() {
avatar: registerForm.value.avatar.trim() || undefined, avatar: registerForm.value.avatar.trim() || undefined,
gender: registerForm.value.gender, gender: registerForm.value.gender,
status: registerForm.value.status, status: registerForm.value.status,
admin: registerForm.value.admin,
} }
if (editingUserId.value) { if (editingUserId.value) {
await imAdminApi.updateUser(appKey.value, editingUserId.value, payload) await imAdminApi.updateUser(appKey.value, editingUserId.value, payload)
@ -1702,6 +1819,7 @@ async function submitRegisterUser() {
payload.avatar, payload.avatar,
payload.gender, payload.gender,
payload.status, payload.status,
payload.admin,
) )
ElMessage.success('用户注册成功') ElMessage.success('用户注册成功')
} }

查看文件

@ -551,7 +551,7 @@ async function saveConfig() {
}, },
profiles: pushConfig.profiles.map(profile => normalizeProfile(profile)), profiles: pushConfig.profiles.map(profile => normalizeProfile(profile)),
} }
await appApi.updateServiceConfig(app.value.id, selectedPlatform.value, 'PUSH', payload) await appApi.updateServiceConfig(app.value.appKey, selectedPlatform.value, 'PUSH', payload)
ElMessage.success('推送配置已保存') ElMessage.success('推送配置已保存')
await loadData() await loadData()
} catch { } catch {
@ -580,7 +580,7 @@ async function onTogglePushService(enable: boolean) {
cancelButtonText: '取消', cancelButtonText: '取消',
}) })
if (!app.value) return if (!app.value) return
await appApi.toggleService(app.value.id, selectedPlatform.value, 'PUSH', false) await appApi.toggleService(app.value.appKey, selectedPlatform.value, 'PUSH', false)
ElMessage.success('已关闭') ElMessage.success('已关闭')
await loadData() await loadData()
} }

查看文件

@ -142,7 +142,7 @@ async function sendVerifyCode() {
if (!selectedApp.value) return if (!selectedApp.value) return
sendingCode.value = true sendingCode.value = true
try { try {
await appApi.requestSecretVerify(selectedApp.value.id, dialogMode.value) await appApi.requestSecretVerify(selectedApp.value.appKey, dialogMode.value)
codeSent.value = true codeSent.value = true
ElMessage.success('验证码已发送到邮箱') ElMessage.success('验证码已发送到邮箱')
} finally { } finally {
@ -159,10 +159,10 @@ async function submitVerify() {
submitting.value = true submitting.value = true
try { try {
if (dialogMode.value === 'REVEAL_SECRET') { if (dialogMode.value === 'REVEAL_SECRET') {
const res = await appApi.revealSecret(selectedApp.value.id, verifyCode.value.trim()) const res = await appApi.revealSecret(selectedApp.value.appKey, verifyCode.value.trim())
secretResult.value = res.data.data.appSecret secretResult.value = res.data.data.appSecret
} else { } else {
const res = await appApi.resetSecret(selectedApp.value.id, verifyCode.value.trim()) const res = await appApi.resetSecret(selectedApp.value.appKey, verifyCode.value.trim())
secretResult.value = res.data.data.appSecret secretResult.value = res.data.data.appSecret
} }
showDialog.value = false showDialog.value = false

查看文件

@ -110,10 +110,10 @@ const STORE_GUIDES: StoreGuide[] = [
urlLabel: '查看小米官方文档', urlLabel: '查看小米官方文档',
steps: [ steps: [
{ title: '进入应用管理', description: '定位到目标应用。' }, { title: '进入应用管理', description: '定位到目标应用。' },
{ title: '准备自动发布接口密钥', description: '记录用户名和 RSA 私钥。' }, { title: '准备自动发布接口密钥', description: '记录用户名、公钥证书和 RSA 私钥。' },
{ title: '保存到租户平台', description: '完成后可提交审核。' }, { title: '保存到租户平台', description: '完成后可提交审核。' },
], ],
hint: '字段按 username / privateKey 保存。', hint: '字段按 username / publicKey / privateKey 保存。',
image: miGuideImage, image: miGuideImage,
enabled: true, enabled: true,
}, },

查看文件

@ -8,7 +8,7 @@
<div v-if="isServicesPortal" class="portal-bar"> <div v-if="isServicesPortal" class="portal-bar">
<span class="portal-bar-title">版本管理</span> <span class="portal-bar-title">版本管理</span>
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp"> <el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
<el-option v-for="a in portalApps" :key="a.id" :label="a.name" :value="a.id" /> <el-option v-for="a in portalApps" :key="a.appKey" :label="a.name" :value="a.appKey" />
</el-select> </el-select>
</div> </div>
<el-page-header v-else @back="$router.back()" :content="`版本管理 — ${pageTitle}`" style="margin-bottom:20px" /> <el-page-header v-else @back="$router.back()" :content="`版本管理 — ${pageTitle}`" style="margin-bottom:20px" />
@ -778,7 +778,12 @@ import {
type StoreConfig, type StoreConfig,
type StoreType, type StoreType,
} from '@/api/update' } from '@/api/update'
import { connectStoreReviewRealtime, disconnectStoreReviewRealtime } from '@/services/storeReviewRealtime' import {
connectStoreReviewRealtime,
disconnectStoreReviewRealtime,
notifyStoreReviewRefresh,
type StoreReviewRefreshEvent,
} from '@/services/storeReviewRealtime'
import huaweiGuideImage from '@/assets/update-store/huawei/01.png' import huaweiGuideImage from '@/assets/update-store/huawei/01.png'
import miGuideImage from '@/assets/update-store/mi/01.png' import miGuideImage from '@/assets/update-store/mi/01.png'
import oppoGuideImage from '@/assets/update-store/oppo/01.png' import oppoGuideImage from '@/assets/update-store/oppo/01.png'
@ -1975,7 +1980,15 @@ onMounted(() => {
loadStoreConfigs() loadStoreConfigs()
loadPublishConfig() loadPublishConfig()
loadOperationLogs() loadOperationLogs()
void connectStoreReviewRealtime(appKey, () => { void connectStoreReviewRealtime(appKey, (event: StoreReviewRefreshEvent) => {
notifyStoreReviewRefresh(event.appKey)
const storeName = storeLabel(event.storeType || '') || event.storeType || '应用市场'
const stateName = reviewLabel((event.reviewState || '').toUpperCase()) || event.reviewState || '状态变更'
ElMessage.success(
event.reviewReason
? `${storeName} 审核状态更新为 ${stateName}${event.reviewReason}`
: `${storeName} 审核状态更新为 ${stateName}`,
)
scheduleStoreReviewReload() scheduleStoreReviewReload()
}).catch((error) => { }).catch((error) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {