比较提交

..

没有共同的提交。c11e8f6d710dc5cc059e01adf4a610170e214269 和 55826db8c4e2fabe92adf679489d7e56a66e50cf 的历史完全不同。

共有 19 个文件被更改,包括 695 次插入1237 次删除

查看文件

@ -1,10 +0,0 @@
.git
.idea
node_modules
**/node_modules
dist
**/dist
docs-site/docs/.vitepress/dist
**/.DS_Store
**/*.log
*.iml

1
.npmrc
查看文件

@ -1 +0,0 @@
@xuqm:registry=https://nexus.xuqinmin.com/repository/npm-hosted/

2
.nvmrc
查看文件

@ -1 +1 @@
22.22.2 22

查看文件

@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /workspace WORKDIR /workspace
@ -8,10 +7,7 @@ COPY tenant-platform ./tenant-platform
COPY ops-platform ./ops-platform COPY ops-platform ./ops-platform
COPY docs-site ./docs-site COPY docs-site ./docs-site
ENV YARN_CACHE_FOLDER=/var/cache/yarn RUN yarn install --frozen-lockfile
RUN --mount=type=cache,target=/var/cache/yarn,sharing=locked \
yarn install --frozen-lockfile
ARG TENANT_APP_BASE=/ ARG TENANT_APP_BASE=/
ARG OPS_APP_BASE=/ops/ ARG OPS_APP_BASE=/ops/

4
Jenkinsfile vendored
查看文件

@ -15,7 +15,6 @@ pipeline {
PROD_USER = 'ubuntu' PROD_USER = 'ubuntu'
COMPOSE_FILE = '/opt/xuqm/deploy/compose.production.yaml' COMPOSE_FILE = '/opt/xuqm/deploy/compose.production.yaml'
IMAGE_NAME = 'web' IMAGE_NAME = 'web'
DOCKER_BUILDKIT = '1'
} }
stages { stages {
@ -30,8 +29,7 @@ pipeline {
def fullImage = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${IMAGE_NAME}:${params.IMAGE_TAG}" def fullImage = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${IMAGE_NAME}:${params.IMAGE_TAG}"
bat """ bat """
docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p %ACR_PASS% docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p %ACR_PASS%
docker pull ${fullImage} || exit 0 docker build -t ${fullImage} .
docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from ${fullImage} -t ${fullImage} .
docker push ${fullImage} docker push ${fullImage}
docker rmi ${fullImage} docker rmi ${fullImage}
""" """

查看文件

@ -1,280 +1,249 @@
# Server API 参考 # Server API 参考
XuqmGroup 的服务端能力分成三类:
- `IM 服务`:账号、消息、群组、好友、会话、黑名单、管理端、回调
- `Push 服务`:设备注册、离线推送、诊断、管理端
- `Update 服务`应用版本、RN Bundle、应用商店提审、发布配置、操作日志
**Base URL**`https://dev.xuqinmin.com` **Base URL**`https://dev.xuqinmin.com`
所有接口都通过 Nginx 反代,无需关心内部端口。 所有 API 通过 Nginx 反代,无需区分内部端口。
---
## 认证 ## 认证
### IM 服务 `/api/im/auth/login` 外,所有 IM 接口需在请求头携带 JWT Token
- `POST /api/im/auth/login` 之前的所有请求,客户端和服务端均需携带 `Authorization: Bearer <im_jwt>` ```
- `im_jwt` 通过 `POST /api/im/auth/login` 颁发 Authorization: Bearer <token>
- 登录请求需要 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature` ```
### Push / Update / Tenant 相关服务 Token 由 `/api/im/auth/login` 接口签发。当前 IM 登录不做过期功能,只校验 `userId + UserSig` 是否匹配。
- 业务侧调用通常使用 `appKey`
- 管理端接口需要租户或运营平台的 JWT
- 内部接口统一使用 `X-Internal-Token`
--- ---
## IM 服务 ## IM 服务(/api/im/
### 账号与登录 ### 登录 · 获取 Token
| 方法 | 路径 | 说明 | ```
POST /api/im/auth/login
```
**Query 参数**
| 参数 | 必填 | 说明 |
|------|------|------| |------|------|------|
| `POST` | `/api/im/auth/login` | IM 登录,返回 JWT Token | | `appId` | 是 | 应用 ID |
| `GET` | `/api/im/accounts/{userId}` | 获取账号资料 | | `userId` | 是 | 用户 ID |
| `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` | 检查账号是否存在 |
### 消息 **请求头**
| 方法 | 路径 | 说明 | | 头 | 必填 | 说明 |
|------|------|------| |----|------|------|
| `POST` | `/api/im/messages/send` | 发送消息 | | `X-App-Timestamp` | 是 | 当前时间戳(毫秒) |
| `POST` | `/api/im/messages/{id}/revoke` | 撤回消息 | | `X-App-Nonce` | 是 | 随机字符串 |
| `PUT` | `/api/im/messages/{id}` | 编辑消息 | | `X-App-Signature` | 是 | HmacSHA256 签名 |
| `GET` | `/api/im/messages/history/{toId}` | 单聊历史 |
| `GET` | `/api/im/messages/group-history/{groupId}` | 群聊历史 |
| `GET` | `/api/im/messages/search` | 按关键字搜索消息 |
| `GET` | `/api/im/messages/offline/count` | 离线消息数 |
| `POST` | `/api/im/messages/offline` | 同步离线消息 |
### 会话 **签名 Payload**(已简化,不再包含 nickname/avatar
```
{appId}\n{userId}\n{timestamp}\n{nonce}
```
| 方法 | 路径 | 说明 | **响应**
|------|------|------|
| `GET` | `/api/im/conversations` | 会话列表 |
| `PUT` | `/api/im/conversations/{targetId}/pinned` | 置顶会话 |
| `PUT` | `/api/im/conversations/{targetId}/muted` | 免打扰 |
| `PUT` | `/api/im/conversations/{targetId}/read` | 标记已读 |
| `PUT` | `/api/im/conversations/{targetId}/draft` | 设置草稿 |
| `PUT` | `/api/im/conversations/{targetId}/hidden` | 隐藏会话 |
| `PUT` | `/api/im/conversations/{targetId}/group` | 设置会话分组 |
| `GET` | `/api/im/conversation-groups` | 会话分组列表 |
| `GET` | `/api/im/conversation-groups/{groupName}` | 指定分组内容 |
| `DELETE` | `/api/im/conversations/{targetId}` | 删除会话 |
### 好友与黑名单 ```json
{ "token": "eyJ..." }
```
| 方法 | 路径 | 说明 | > 重复登录会覆盖当前会话;SDK 侧不做生命周期检测或旧登录兼容。
|------|------|------|
| `GET` | `/api/im/friends` | 好友列表 |
| `POST` | `/api/im/friends` | 添加好友 |
| `POST` | `/api/im/friends/batch` | 批量添加好友 |
| `DELETE` | `/api/im/friends/{friendId}` | 删除好友 |
| `DELETE` | `/api/im/friends` | 删除全部好友 |
| `POST` | `/api/im/friends/batch/remove` | 批量删除好友 |
| `PUT` | `/api/im/friends/{friendId}/group` | 设置好友分组 |
| `GET` | `/api/im/friends/groups` | 好友分组列表 |
| `GET` | `/api/im/friends/groups/{groupName}` | 指定分组好友 |
| `POST` | `/api/im/friends/check` | 校验好友关系 |
| `GET` | `/api/im/blacklist` | 黑名单列表 |
| `POST` | `/api/im/blacklist` | 添加黑名单 |
| `DELETE` | `/api/im/blacklist` | 删除黑名单 |
| `GET` | `/api/im/blacklist/check` | 校验黑名单关系 |
### 群组
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/im/groups` | 群列表 |
| `POST` | `/api/im/groups` | 创建群组 |
| `GET` | `/api/im/groups/public` | 公开群列表 |
| `GET` | `/api/im/groups/search` | 搜索群 |
| `GET` | `/api/im/groups/{groupId}` | 群详情 |
| `GET` | `/api/im/groups/{groupId}/members` | 群成员 |
| `GET` | `/api/im/groups/{groupId}/members/search` | 搜索群成员 |
| `PUT` | `/api/im/groups/{groupId}` | 更新群信息 |
| `POST` | `/api/im/groups/{groupId}/members` | 添加群成员 |
| `POST` | `/api/im/groups/{groupId}/members/batch` | 批量添加群成员 |
| `DELETE` | `/api/im/groups/{groupId}/members/{targetUserId}` | 删除群成员 |
| `POST` | `/api/im/groups/{groupId}/members/batch/remove` | 批量删除群成员 |
| `POST` | `/api/im/groups/{groupId}/roles` | 设置群角色 |
| `POST` | `/api/im/groups/{groupId}/owner` | 转让群主 |
| `PUT` | `/api/im/groups/{groupId}/attributes` | 更新群属性 |
| `POST` | `/api/im/groups/{groupId}/attributes/delete` | 删除群属性 |
| `POST` | `/api/im/groups/{groupId}/mute` | 群成员禁言 |
| `DELETE` | `/api/im/groups/{groupId}` | 解散群 |
| `POST` | `/api/im/groups/{groupId}/join-requests` | 发送加群申请 |
| `GET` | `/api/im/groups/{groupId}/join-requests` | 查询加群申请 |
| `POST` | `/api/im/groups/{groupId}/join-requests/{requestId}/accept` | 接受加群申请 |
| `POST` | `/api/im/groups/{groupId}/join-requests/{requestId}/reject` | 拒绝加群申请 |
| `POST` | `/api/im/groups/{groupId}/join-requests/batch/accept` | 批量接受加群申请 |
| `POST` | `/api/im/groups/{groupId}/join-requests/batch/reject` | 批量拒绝加群申请 |
### 管理端
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/im/admin/users/state` | 查询用户在线状态 |
| `POST` | `/api/im/admin/users/kick` | 踢下线用户 |
| `POST` | `/api/im/admin/messages/batch-send` | 批量发消息 |
| `POST` | `/api/im/admin/messages/read` | 管理端标记已读 |
| `POST` | `/api/im/admin/messages/import` | 导入消息 |
| `GET` | `/api/im/admin/groups/{groupId}/owner` | 查询群主 |
| `PUT` | `/api/im/admin/groups/{groupId}/owner` | 管理端转让群主 |
| `PUT` | `/api/im/admin/groups/{groupId}/attributes` | 管理端更新群属性 |
| `POST` | `/api/im/admin/groups/{groupId}/attributes/delete` | 管理端删除群属性 |
| `GET` | `/api/im/admin/groups/{groupId}/read-receipts` | 群已读统计 |
| `GET` | `/api/im/admin/webhooks` | Webhook 列表 |
| `POST` | `/api/im/admin/webhooks` | 创建 Webhook |
| `PUT` | `/api/im/admin/webhooks/{id}` | 更新 Webhook |
| `DELETE` | `/api/im/admin/webhooks/{id}` | 删除 Webhook |
| `GET` | `/api/im/admin/webhook-deliveries` | Webhook 投递记录 |
| `GET` | `/api/im/admin/webhook-alerts` | Webhook 告警 |
| `POST` | `/api/im/admin/webhook-alerts/{id}/acknowledge` | 告警确认 |
| `GET` | `/api/im/admin/webhooks/{id}/health` | Webhook 健康状态 |
| `GET` | `/api/im/admin/keyword-filters` | 敏感词列表 |
| `POST` | `/api/im/admin/keyword-filters` | 新增敏感词 |
| `PUT` | `/api/im/admin/keyword-filters/{id}` | 更新敏感词 |
| `DELETE` | `/api/im/admin/keyword-filters/{id}` | 删除敏感词 |
| `GET` | `/api/im/admin/global-mute` | 全局禁言状态 |
| `PUT` | `/api/im/admin/global-mute` | 更新全局禁言 |
| `GET` | `/api/im/admin/operation-logs` | IM 操作日志 |
### 内部接口
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/im/internal/presence/resolve-token` | 解析 IM Token |
| `GET` | `/api/im/internal/presence/users/{userId}` | 查询用户在线状态 |
--- ---
## Push 服务 ### 发送消息
### 公开接口 ```
POST /api/im/messages/send
Authorization: Bearer <token>
Content-Type: application/json
```
| 方法 | 路径 | 说明 | **请求体**
|------|------|------|
| `POST` | `/api/push/register` | 设备注册推送 token |
| `DELETE` | `/api/push/unregister` | 设备取消注册 |
| `POST` | `/api/push/send` | 发送推送 |
| `POST` | `/api/push/receive-push` | 推送回执/接收 |
### 管理端
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/push/admin/user-status` | 查询用户推送状态 |
| `GET` | `/api/push/admin/device-logs` | 设备日志 |
| `POST` | `/api/push/admin/test-offline` | 离线推送测试 |
### 内部接口
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/push/internal/notify` | 内部通知转发 |
| `GET` | `/api/push/internal/diagnostics/search` | 按 token 查询诊断 |
| `GET` | `/api/push/internal/device-logs` | 内部设备日志 |
| `POST` | `/api/push/internal/test-offline` | 内部离线测试 |
---
## Update 服务
### 应用版本
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/v1/updates/app/check` | 检查应用更新 |
| `POST` | `/api/v1/updates/app/upload` | 上传应用版本 |
| `GET|POST` | `/api/v1/updates/app/inspect` | 检查上传内容 |
| `POST` | `/api/v1/updates/app/{id}/publish` | 发布应用版本 |
| `POST` | `/api/v1/updates/app/{id}/unpublish` | 取消发布 |
| `POST` | `/api/v1/updates/app/{id}/gray` | 灰度发布 |
| `GET` | `/api/v1/updates/app/list` | 版本列表 |
### RN Bundle
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/v1/rn/update/check` | 检查 RN 更新 |
| `POST` | `/api/v1/rn/upload` | 上传 RN Bundle |
| `GET|POST` | `/api/v1/rn/inspect` | 检查 RN 上传内容 |
| `GET` | `/api/v1/rn/list` | RN 列表 |
| `POST` | `/api/v1/rn/{id}/publish` | 发布 RN Bundle |
| `POST` | `/api/v1/rn/{id}/unpublish` | 取消发布 RN Bundle |
| `POST` | `/api/v1/rn/{id}/gray` | RN 灰度 |
### 应用商店提审
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/v1/updates/publish/config` | 获取发布配置 |
| `PUT` | `/api/v1/updates/publish/config` | 更新发布配置 |
| `GET` | `/api/v1/updates/gray/members` | 灰度成员列表 |
| `POST` | `/api/v1/updates/gray/members/sync` | 同步灰度成员 |
| `POST` | `/api/v1/updates/gray/members/import` | 导入灰度成员 |
| `GET` | `/api/v1/updates/store/configs` | 获取应用市场配置 |
| `PUT` | `/api/v1/updates/store/configs/{storeType}` | 更新应用市场配置 |
| `DELETE` | `/api/v1/updates/store/configs/{storeType}` | 删除应用市场配置 |
| `GET` | `/api/v1/updates/store/credentials` | 获取市场凭据摘要 |
| `POST` | `/api/v1/updates/store/app/{versionId}/submit` | 预提交应用市场 |
| `POST` | `/api/v1/updates/store/app/{versionId}/execute-submit` | 执行提审 |
| `POST` | `/api/v1/updates/store/app/{versionId}/review` | 回写审核结果 |
### 运营日志与文件
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/v1/updates/ops/logs` | 更新操作日志 |
| `GET` | `/api/v1/updates/files/apk/{filename}` | APK 文件访问 |
| `GET` | `/api/v1/rn/files/{appKey}/{platform}/{moduleId}` | RN Bundle 文件访问 |
| `POST` | `/api/v1/updates/unified/upload` | 统一发布包上传 |
---
## 数据模型约定
### 应用标识
- 所有业务侧判断应用身份使用 `appKey`
- `appId` 仅保留在第三方厂商账号字段里,不作为应用身份
### 常见响应
```json ```json
{ {
"code": 200, "toId": "user_002",
"status": "0", "chatType": "SINGLE",
"data": { }, "msgType": "TEXT",
"message": "success" "content": "Hello!"
} }
``` ```
### 错误码 `chatType``SINGLE` | `GROUP`
| HTTP 状态 | code | 含义 | `msgType``TEXT` | `IMAGE` | `VIDEO` | `AUDIO` | `FILE` | `CUSTOM` | `LOCATION` | `NOTIFY` | `RICH_TEXT` | `CALL_AUDIO` | `CALL_VIDEO` | `FORWARD`
|-----------|------|------|
| `400` | `400` | 参数错误 | **响应**`ImMessage` 对象
| `401` | `401` | 鉴权失败 |
| `403` | `403` | 无权限 |
| `404` | `404` | 资源不存在 |
| `500` | `500` | 服务端内部错误 |
--- ---
## 集成建议 ### 撤回消息
- 服务端业务代码统一使用 Java / Go / Python Server SDK 调用上述 REST 接口 ```
- 客户端统一使用 `appKey` 作为应用上下文 PUT /api/im/messages/{messageId}/revoke
- 实时刷新场景建议使用 IM SDK 订阅服务端事件 Authorization: Bearer <token>
- 更新、Push、IM 的后台能力可以独立接入,也可以组合使用 ```
**响应**:更新后的 `ImMessage``status: "REVOKED"`,`msgType: "REVOKED"`
---
### 消息历史(单聊)
```
GET /api/im/messages/history?appId=&userId=&toId=&page=0&size=50
Authorization: Bearer <token>
```
**响应**:分页 `ImMessage` 列表
---
### 创建群组
```
POST /api/im/groups
Authorization: Bearer <token>
Content-Type: application/json
```
```json
{
"appId": "ak_demo_chat",
"name": "开发群",
"memberIds": ["user_001", "user_002"]
}
```
**响应**`ImGroup` 对象
---
### 获取群列表
```
GET /api/im/groups?appId=ak_demo_chat
Authorization: Bearer <token>
```
---
### 添加群成员
```
POST /api/im/groups/{groupId}/members
Authorization: Bearer <token>
Content-Type: application/json
{ "userId": "user_003" }
```
---
### 移除群成员
```
DELETE /api/im/groups/{groupId}/members/{targetUserId}
Authorization: Bearer <token>
```
---
### 会话扩展
```
PUT /api/im/conversations/{targetId}/hidden?appId=&chatType=SINGLE&hidden=true
PUT /api/im/conversations/{targetId}/group?appId=&chatType=SINGLE&groupName=重要客户
GET /api/im/conversation-groups?appId=
GET /api/im/conversation-groups/{groupName}?appId=
```
### 好友扩展
```
DELETE /api/im/friends?appId=
PUT /api/im/friends/{friendId}/group?appId=&groupName=同事
GET /api/im/friends/groups?appId=
GET /api/im/friends/groups/{groupName}?appId=
```
### 黑名单校验
```
GET /api/im/blacklist/check?appId=&targetUserId=user_002
```
### 群扩展
```
POST /api/im/groups/{groupId}/owner
PUT /api/im/groups/{groupId}/attributes
POST /api/im/groups/{groupId}/attributes/delete
```
管理端补充:
```
POST /api/im/admin/groups/{groupId}/owner?appId=
PUT /api/im/admin/groups/{groupId}/attributes?appId=
POST /api/im/admin/groups/{groupId}/attributes/delete?appId=
POST /api/im/admin/groups/{groupId}/read-receipts?appId=
```
---
## 数据模型
### ImMessage
```json
{
"id": "uuid",
"appId": "ak_demo_chat",
"fromUserId": "user_001",
"toId": "user_002",
"chatType": "SINGLE",
"msgType": "TEXT",
"content": "Hello!",
"status": "SENT",
"mentionedUserIds": [],
"createdAt": "2026-04-24T10:00:00"
}
```
### ImGroup
```json
{
"id": "group_uuid",
"appId": "ak_demo_chat",
"name": "开发群",
"creatorId": "user_001",
"memberIds": ["user_001", "user_002"],
"adminIds": ["user_001"],
"extAttributes": "{\"department\":\"sales\"}",
"createdAt": "2026-04-24T10:00:00"
}
```
---
## 错误码
| HTTP 状态 | code | 说明 |
|-----------|------|------|
| 400 | 400 | 请求参数错误 |
| 401 | 401 | Token 无效或签名验证失败 |
| 403 | 403 | 无权限操作(如撤回他人消息)|
| 404 | 404 | 资源不存在 |
| 500 | 500 | 服务器内部错误 |
错误响应格式:
```json
{ "code": 403, "message": "只能撤回自己发送的消息" }
```

查看文件

@ -1,18 +1,13 @@
# Go Server SDK # Go Server SDK
XuqmGroup 服务端 Go SDK,按照腾讯云服务端 API 的分类方式封装了: XuqmGroup 服务端 Go SDK,提供 IM 消息发送、群管理、Push 推送等能力。
- IM 账号、消息、群组、好友、会话、黑名单
- 管理端 Webhook、统计、操作日志
- Push 注册与推送
- Update 版本管理、RN Bundle、应用商店提审
--- ---
## 安装 ## 安装
```bash ```bash
go get github.com/xuqmgroup/xuqmgroup-server-sdk-go go get xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go
``` ```
--- ---
@ -20,40 +15,19 @@ go get github.com/xuqmgroup/xuqmgroup-server-sdk-go
## 初始化 ## 初始化
```go ```go
import xuqmsdk "github.com/xuqmgroup/xuqmgroup-server-sdk-go" import xuqmsdk "xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go"
client, err := xuqmsdk.NewClient(xuqmsdk.ClientConfig{ client := xuqmsdk.NewClient(xuqmsdk.Config{
BaseURL: "https://dev.xuqinmin.com", BaseURL: "https://dev.xuqinmin.com",
AppKey: "your_app_key", AppID: "your_app_id",
AppSecret: "your_app_secret", AppSecret: "your_app_secret",
}) })
if err != nil {
log.Fatal(err)
}
``` ```
> 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。 > 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。
--- ---
## 能力分类
| 分类 | 主要方法 |
|------|----------|
| 登录 | `Login` |
| 账号 | `ImportAccount`, `ImportAccounts`, `DeleteAccount`, `GetProfile`, `UpdateProfile`, `SearchAccounts`, `CheckAccount` |
| 消息 | `SendMessage`, `RevokeMessage`, `EditMessage`, `FetchHistory`, `FetchGroupHistory`, `SearchMessages` |
| 会话 | `ListConversations`, `SetConversationPinned`, `SetConversationMuted`, `MarkRead`, `SetDraft`, `SetConversationHidden`, `SetConversationGroup`, `DeleteConversation` |
| 好友 | `ListFriends`, `AddFriend`, `AddFriends`, `RemoveFriend`, `RemoveFriends`, `RemoveAllFriends`, `SetFriendGroup`, `ListFriendGroups`, `ListFriendsByGroup`, `CheckFriends` |
| 黑名单 | `ListBlacklist`, `AddBlacklist`, `RemoveBlacklist`, `CheckBlacklist` |
| 群组 | `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` |
| Webhook | `ListWebhooks`, `CreateWebhook`, `UpdateWebhook`, `DeleteWebhook`, `VerifyCallbackSignature`, `ParseCallbackEnvelope` |
| Push | `RegisterPushToken`, `SendPush` |
| Update | `CheckAppUpdate`, `UploadAppVersion`, `PublishAppVersion`, `UnpublishAppVersion`, `GrayAppVersion`, `ListAppVersions`, `CheckRnUpdate`, `UploadRnBundle`, `PublishRnBundle`, `UnpublishRnBundle`, `ListRnBundles` |
---
## 发送消息 ## 发送消息
```go ```go
@ -91,15 +65,22 @@ err := client.RevokeMessage("message_id")
## 群管理 ## 群管理
```go ```go
group, err := client.CreateGroup("项目讨论", []string{"user_001", "user_002"}, "NORMAL") // 创建群组
if err != nil { group, err := client.CreateGroup(xuqmsdk.CreateGroupRequest{
log.Fatal(err) Name: "项目讨论",
} MemberIDs: []string{"user_001", "user_002"},
})
// 添加群成员
err = client.AddGroupMember("group_xxx", "user_003") err = client.AddGroupMember("group_xxx", "user_003")
// 移除群成员
err = client.RemoveGroupMember("group_xxx", "user_003") err = client.RemoveGroupMember("group_xxx", "user_003")
// 获取群列表
groups, err := client.ListGroups() groups, err := client.ListGroups()
// 获取群成员
members, err := client.ListGroupMembers("group_xxx") members, err := client.ListGroupMembers("group_xxx")
``` ```
@ -108,7 +89,12 @@ members, err := client.ListGroupMembers("group_xxx")
## Push 推送 ## Push 推送
```go ```go
err := client.SendPush("user_001", "新消息", "你有一条未读消息", `{"chatId":"user_002"}`) err := client.SendPush(xuqmsdk.SendPushRequest{
UserID: "user_001",
Title: "新消息",
Body: "你有一条未读消息",
Payload: map[string]string{"chatId": "user_002"},
})
``` ```
--- ---
@ -120,10 +106,11 @@ SDK 返回的错误实现了标准 `error` 接口。业务方可根据错误类
```go ```go
msg, err := client.SendMessage(req) msg, err := client.SendMessage(req)
if err != nil { if err != nil {
// 可断言为 *xuqmsdk.APIError 获取 HTTP 状态码和详细错误信息
if apiErr, ok := err.(*xuqmsdk.APIError); ok { if apiErr, ok := err.(*xuqmsdk.APIError); ok {
log.Printf("API 错误: status=%d, code=%d, message=%s", apiErr.Status, apiErr.Code, apiErr.Message) log.Printf("API 错误: status=%d, code=%d, message=%s", apiErr.Status, apiErr.Code, apiErr.Message)
} }
} }
``` ```
[→ Server API 文档](/server/api) [→ Server API 文档](/server/api)

查看文件

@ -1,13 +1,6 @@
# Java Server SDK # Java Server SDK
XuqmGroup 服务端 Java SDK,按腾讯云服务端 API 的思路封装了以下能力: XuqmGroup 服务端 Java SDK,提供 IM 消息发送、群管理、Push 推送等能力。
- IM 账号与登录
- 消息发送、编辑、撤回、历史查询
- 好友、黑名单、会话、群组
- Webhook、管理端操作、统计
- Push 注册与发送
- Update 版本检查、上传、发布、灰度、应用商店提审
--- ---
@ -17,14 +10,14 @@ Maven 在 `pom.xml` 中添加:
```xml ```xml
<repository> <repository>
<id>xuqm-nexus</id> <id>nexus-xuqm</id>
<url>https://nexus.xuqinmin.com/repository/maven-public/</url> <url>https://nexus.xuqinmin.com/repository/android/</url>
</repository> </repository>
<dependency> <dependency>
<groupId>com.xuqm</groupId> <groupId>com.xuqm</groupId>
<artifactId>im-sdk</artifactId> <artifactId>xuqm-server-sdk</artifactId>
<version>0.1.0-SNAPSHOT</version> <version>0.1.0</version>
</dependency> </dependency>
``` ```
@ -32,11 +25,11 @@ Gradle
```kotlin ```kotlin
repositories { repositories {
maven { url = uri("https://nexus.xuqinmin.com/repository/maven-public/") } maven { url = uri("https://nexus.xuqinmin.com/repository/android/") }
} }
dependencies { dependencies {
implementation("com.xuqm:im-sdk:0.1.0-SNAPSHOT") implementation("com.xuqm:xuqm-server-sdk:0.1.0")
} }
``` ```
@ -45,13 +38,14 @@ dependencies {
## 初始化 ## 初始化
```java ```java
import com.xuqm.im.sdk.XuqmImServerSdk; import com.xuqm.sdk.server.XuqmServerClient;
import com.xuqm.sdk.server.XuqmServerConfig;
XuqmImServerSdk client = XuqmImServerSdk.builder() XuqmServerClient client = new XuqmServerClient(XuqmServerConfig.builder()
.baseUrl("https://dev.xuqinmin.com") .baseUrl("https://dev.xuqinmin.com")
.appKey("your_app_key") .appId("your_app_id")
.appSecret("your_app_secret") .appSecret("your_app_secret")
.build(); .build());
``` ```
> 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。 > 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。
@ -61,18 +55,15 @@ XuqmImServerSdk client = XuqmImServerSdk.builder()
## 发送消息 ## 发送消息
```java ```java
import com.xuqm.im.sdk.XuqmImServerSdk.ImMessage; import com.xuqm.sdk.server.model.SendMessageRequest;
import com.xuqm.im.sdk.XuqmImServerSdk.SendMessageRequest; import com.xuqm.sdk.server.model.ImMessage;
ImMessage msg = client.sendMessage(new SendMessageRequest( ImMessage msg = client.sendMessage(SendMessageRequest.builder()
"user_002", .toId("user_002")
"SINGLE", .chatType("SINGLE")
"TEXT", .msgType("TEXT")
"Hello from Java SDK!", .content("Hello from Java SDK!")
null, .build());
null,
null
));
System.out.println("消息已发送: " + msg.getId()); System.out.println("消息已发送: " + msg.getId());
``` ```
@ -96,32 +87,28 @@ client.revokeMessage("message_id");
--- ---
## 能力分类 ## 群管理
| 分类 | 主要方法 |
|------|----------|
| IM 账号 | `login`, `importAccount`, `importAccounts`, `deleteAccount`, `getProfile`, `updateProfile`, `searchAccounts` |
| 消息 | `sendMessage`, `revokeMessage`, `editMessage`, `fetchHistory`, `fetchGroupHistory`, `searchMessages` |
| 会话 | `listConversations`, `setConversationPinned`, `setConversationMuted`, `markRead`, `setDraft`, `setConversationHidden`, `setConversationGroup`, `deleteConversation` |
| 好友 | `listFriends`, `addFriend`, `addFriends`, `removeFriend`, `removeFriends`, `removeAllFriends`, `setFriendGroup`, `listFriendGroups`, `listFriendsByGroup`, `checkFriends` |
| 黑名单 | `listBlacklist`, `addBlacklist`, `removeBlacklist`, `checkBlacklist` |
| 群组 | `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` |
| Webhook | `listWebhooks`, `createWebhook`, `updateWebhook`, `deleteWebhook`, `verifyCallbackSignature`, `parseCallbackEnvelope`, 各类 `parse*CallbackPayload` |
| Push | `registerPushToken`, `sendPush` |
| Update | `checkAppUpdate`, `uploadAppVersion`, `publishAppVersion`, `unpublishAppVersion`, `grayAppVersion`, `listAppVersions`, `checkRnUpdate`, `uploadRnBundle`, `publishRnBundle`, `unpublishRnBundle`, `listRnBundles` |
## 群管理示例
```java ```java
import com.xuqm.im.sdk.XuqmImServerSdk.GroupView; import com.xuqm.sdk.server.model.*;
GroupView group = client.createGroup("项目讨论", List.of("user_001", "user_002"), "NORMAL"); // 创建群组
ImGroup group = client.createGroup(CreateGroupRequest.builder()
.name("项目讨论")
.memberIds(List.of("user_001", "user_002"))
.build());
// 添加群成员
client.addGroupMember("group_xxx", "user_003"); client.addGroupMember("group_xxx", "user_003");
// 移除群成员
client.removeGroupMember("group_xxx", "user_003"); client.removeGroupMember("group_xxx", "user_003");
List<GroupView> groups = client.listGroups(); // 获取群列表
List<ImGroup> groups = client.listGroups();
// 获取群成员
List<ImUser> members = client.listGroupMembers("group_xxx");
``` ```
--- ---
@ -144,11 +131,11 @@ client.sendPush(SendPushRequest.builder()
## 错误处理 ## 错误处理
```java ```java
import com.xuqm.im.sdk.XuqmImServerSdk.ImSdkException; import com.xuqm.sdk.server.exception.XuqmApiException;
try { try {
ImMessage msg = client.sendMessage(request); ImMessage msg = client.sendMessage(request);
} catch (ImSdkException e) { } catch (XuqmApiException e) {
System.err.printf("API 错误: status=%d, code=%d, message=%s%n", System.err.printf("API 错误: status=%d, code=%d, message=%s%n",
e.getHttpStatus(), e.getCode(), e.getMessage()); e.getHttpStatus(), e.getCode(), e.getMessage());
} }

查看文件

@ -1,18 +1,13 @@
# Python Server SDK # Python Server SDK
XuqmGroup 服务端 Python SDK,按照腾讯云服务端 API 的分类方式封装了: XuqmGroup 服务端 Python SDK,提供 IM 消息发送、群管理、Push 推送等能力。
- IM 账号、消息、群组、好友、会话、黑名单
- 管理端 Webhook、统计、操作日志
- Push 注册与推送
- Update 版本管理、RN Bundle、应用商店提审
--- ---
## 安装 ## 安装
```bash ```bash
pip install xuqmgroup-server-sdk-python pip install xuqm-im-server-sdk
``` ```
--- ---
@ -24,7 +19,7 @@ from xuqm_im_server_sdk import XuqmImServerSdk
sdk = XuqmImServerSdk( sdk = XuqmImServerSdk(
base_url="https://dev.xuqinmin.com", base_url="https://dev.xuqinmin.com",
app_key="your_app_key", app_id="your_app_id",
app_secret="your_app_secret", app_secret="your_app_secret",
) )
``` ```
@ -33,24 +28,6 @@ sdk = XuqmImServerSdk(
--- ---
## 能力分类
| 分类 | 主要方法 |
|------|----------|
| 登录 | `login` |
| 账号 | `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` |
| 会话 | `list_conversations`, `set_conversation_pinned`, `set_conversation_muted`, `mark_read`, `set_draft`, `set_conversation_hidden`, `set_conversation_group`, `delete_conversation` |
| 好友 | `list_friends`, `add_friend`, `add_friends`, `remove_friend`, `remove_friends`, `remove_all_friends`, `set_friend_group`, `list_friend_groups`, `list_friends_by_group`, `check_friends` |
| 黑名单 | `list_blacklist`, `add_blacklist`, `remove_blacklist`, `check_blacklist` |
| 群组 | `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` |
| Webhook | `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook`, `verify_callback_signature`, `parse_callback_envelope` |
| Push | `register_push_token`, `send_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` |
---
## 发送消息 ## 发送消息
```python ```python
@ -85,15 +62,22 @@ sdk.revoke_message("message_id")
## 群管理 ## 群管理
```python ```python
# 创建群组
group = sdk.create_group( group = sdk.create_group(
name="项目讨论", name="项目讨论",
member_ids=["user_001", "user_002"], member_ids=["user_001", "user_002"],
) )
# 添加群成员
sdk.add_group_member("group_xxx", "user_003") sdk.add_group_member("group_xxx", "user_003")
# 移除群成员
sdk.remove_group_member("group_xxx", "user_003") sdk.remove_group_member("group_xxx", "user_003")
# 获取群列表
groups = sdk.list_groups() groups = sdk.list_groups()
# 获取群成员
members = sdk.list_group_members("group_xxx") members = sdk.list_group_members("group_xxx")
``` ```
@ -125,4 +109,4 @@ except XuqmAPIError as e:
print(f"API 错误: status={e.status}, code={e.code}, message={e.message}") print(f"API 错误: status={e.status}, code={e.code}, message={e.message}")
``` ```
[→ Server API 文档](/server/api) [→ Server API 文档](/server/api)

查看文件

@ -93,7 +93,7 @@ SEND
destination:/app/chat.send destination:/app/chat.send
content-type:application/json content-type:application/json
{"appKey":"ak_demo_chat","messageId":"...","toId":"user_002","chatType":"SINGLE","msgType":"TEXT","content":"Hello"} {"appId":"ak_demo_chat","messageId":"...","toId":"user_002","chatType":"SINGLE","msgType":"TEXT","content":"Hello"}
\x00 \x00
``` ```
@ -104,7 +104,7 @@ SEND
destination:/app/chat.revoke destination:/app/chat.revoke
content-type:application/json content-type:application/json
{"appKey":"ak_demo_chat","messageId":"..."} {"appId":"ak_demo_chat","messageId":"..."}
\x00 \x00
``` ```
@ -115,7 +115,7 @@ SEND
destination:/app/chat.sync destination:/app/chat.sync
content-type:application/json content-type:application/json
{"appKey":"ak_demo_chat"} {"appId":"ak_demo_chat"}
\x00 \x00
``` ```

查看文件

@ -10,7 +10,6 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
"@xuqm/vue3-sdk": "0.2.0",
"element-plus": "^2.9.1", "element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"pinia": "^3.0.1", "pinia": "^3.0.1",

查看文件

@ -61,87 +61,53 @@ export interface UpdateServiceConfig {
defaultMarketUrl?: string defaultMarketUrl?: string
} }
export type PushVendorKey = 'huawei' | 'xiaomi' | 'oppo' | 'vivo' | 'honor' | 'harmony' | 'apns' export interface PushVendorConfig {
export type PushImportance = 'MIN' | 'LOW' | 'DEFAULT' | 'HIGH' | 'MAX'
export type PushPriority = 'LOW' | 'DEFAULT' | 'HIGH'
export type PushInterruptionLevel = '' | 'passive' | 'active' | 'time-sensitive' | 'critical'
export interface XiaomiPushVendorConfig {
appId?: string appId?: string
appKey?: string appKey?: string
appSecret?: string appSecret?: string
}
export interface HuaweiPushVendorConfig {
appId?: string
appSecret?: string
}
export interface OppoPushVendorConfig {
appId?: string
appKey?: string
masterSecret?: string masterSecret?: string
}
export interface VivoPushVendorConfig {
appId?: string
appKey?: string
appSecret?: string
}
export interface HonorPushVendorConfig {
appId?: string
clientId?: string clientId?: string
clientSecret?: string clientSecret?: string
}
export interface HarmonyPushVendorConfig {
appId?: string
appSecret?: string
}
export interface ApnsPushVendorConfig {
teamId?: string teamId?: string
keyId?: string keyId?: string
bundleId?: string bundleId?: string
privateKey?: string keyPath?: string
sandbox?: boolean sandbox?: boolean
} serviceAccountJson?: string
export interface PushVendorsConfig {
huawei?: HuaweiPushVendorConfig
xiaomi?: XiaomiPushVendorConfig
oppo?: OppoPushVendorConfig
vivo?: VivoPushVendorConfig
honor?: HonorPushVendorConfig
harmony?: HarmonyPushVendorConfig
apns?: ApnsPushVendorConfig
}
export interface PushProfileConfig {
profileKey: string
vendor: PushVendorKey
routeType: string
enabled: boolean
channelId?: string channelId?: string
category?: string category?: string
importance?: PushImportance receiptId?: string
priority?: PushPriority
threadIdentifier?: string
interruptionLevel?: PushInterruptionLevel
badge?: boolean
sound?: boolean
vibration?: boolean
notifyType?: number
version?: number
remark?: string
} }
export interface PushServiceConfig { export interface PushServiceConfig {
schemaVersion?: number huawei?: PushVendorConfig
updatedAt?: string xiaomi?: PushVendorConfig
vendors?: PushVendorsConfig oppo?: PushVendorConfig
profiles?: PushProfileConfig[] vivo?: PushVendorConfig
honor?: PushVendorConfig
harmony?: PushVendorConfig
apns?: PushVendorConfig
fcm?: PushVendorConfig
channels?: PushNotificationChannelConfig[]
routing?: Record<string, PushNotificationRouteConfig>
}
export interface PushNotificationChannelConfig {
key: string
channelId: string
version: number
name: string
description?: string
importance: 'MIN' | 'LOW' | 'DEFAULT' | 'HIGH' | 'MAX'
sound: boolean
vibration: boolean
badge: boolean
}
export interface PushNotificationRouteConfig {
channel: string
category: string
priority: 'LOW' | 'DEFAULT' | 'HIGH'
} }
export const appApi = { export const appApi = {

查看文件

@ -80,7 +80,7 @@ updateClient.interceptors.response.use(
) )
export type StoreType = 'HUAWEI' | 'MI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APP_STORE' | 'GOOGLE_PLAY' | 'HARMONY_APP' | 'REVIEW_WEBHOOK' export type StoreType = 'HUAWEI' | 'MI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APP_STORE' | 'GOOGLE_PLAY' | 'HARMONY_APP' | 'REVIEW_WEBHOOK'
export type StoreReviewState = 'PENDING' | 'SUBMITTING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED' export type StoreReviewState = 'PENDING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW' export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
export type GrayMode = 'PERCENT' | 'MEMBERS' export type GrayMode = 'PERCENT' | 'MEMBERS'
export type GraySelectionSource = 'LOCAL' | 'CALLBACK' export type GraySelectionSource = 'LOCAL' | 'CALLBACK'

查看文件

@ -1,91 +0,0 @@
import { ElMessage } from 'element-plus'
import { init, login, ImClient, type ImMessage } from '@xuqm/vue3-sdk'
import client from '@/api/client'
export interface StoreReviewRefreshEvent {
event: string
appKey: string
versionId?: string
storeType?: string
reviewState?: string
reviewReason?: string
stage?: string
batchId?: string
publishStatus?: string
source?: string
timestamp?: number
}
interface PlatformEventTokenResponse {
userId: string
token: string
}
let imClient: ImClient | null = null
let activeAppKey = ''
function sdkBaseUrl() {
return import.meta.env.VITE_IM_API_BASE_URL ?? ''
}
function sdkWsUrl() {
return import.meta.env.VITE_IM_WS_URL ?? ''
}
function parseEvent(message: ImMessage): StoreReviewRefreshEvent | null {
if (!['NOTIFY', 'CUSTOM'].includes(message.msgType)) return null
try {
const payload = JSON.parse(message.content) as Partial<StoreReviewRefreshEvent>
if (!payload || payload.event !== 'store_review_update') return null
if (!payload.appKey) return null
return payload as StoreReviewRefreshEvent
} catch {
return null
}
}
export async function connectStoreReviewRealtime(appKey: string, onEvent: (event: StoreReviewRefreshEvent) => void) {
if (!appKey) return
disconnectStoreReviewRealtime()
activeAppKey = appKey
const res = await client.get<{ data: PlatformEventTokenResponse }>('/im/platform-events/token', {
params: { appKey },
})
const token = res.data.data
init({
appKey,
baseUrl: sdkBaseUrl(),
wsUrl: sdkWsUrl(),
debug: import.meta.env.DEV,
})
login(token.userId, token.token)
const clientInstance = new ImClient()
clientInstance.on('message', (message) => {
const event = parseEvent(message)
if (!event || event.appKey !== activeAppKey) return
onEvent(event)
})
clientInstance.on('error', (error) => {
if (import.meta.env.DEV) {
console.warn('[tenant-platform][IM] store review realtime error', error)
}
})
clientInstance.connect()
imClient = clientInstance
}
export function disconnectStoreReviewRealtime() {
if (imClient) {
imClient.disconnect()
imClient = null
}
activeAppKey = ''
}
export function notifyStoreReviewRefresh(appKey: string) {
if (appKey && appKey === activeAppKey) {
ElMessage.info('检测到审核状态更新,正在刷新...')
}
}

查看文件

@ -698,7 +698,7 @@
/> />
<el-descriptions :column="1" border> <el-descriptions :column="1" border>
<el-descriptions-item label="调用方式">POST 到你配置的回调地址`Content-Type: application/json`</el-descriptions-item> <el-descriptions-item label="调用方式">POST 到你配置的回调地址`Content-Type: application/json`</el-descriptions-item>
<el-descriptions-item label="请求头">`X-App-Key``X-App-Timestamp``X-App-Nonce``X-App-Signature`</el-descriptions-item> <el-descriptions-item label="请求头">`X-App-Id``X-App-Timestamp``X-App-Nonce``X-App-Signature`</el-descriptions-item>
<el-descriptions-item label="请求体">统一 envelope`callbackId``callbackType``callbackEvent``requestTime``payload``appKey`</el-descriptions-item> <el-descriptions-item label="请求体">统一 envelope`callbackId``callbackType``callbackEvent``requestTime``payload``appKey`</el-descriptions-item>
<el-descriptions-item label="签名">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`</el-descriptions-item> <el-descriptions-item label="签名">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`</el-descriptions-item>
<el-descriptions-item label="失败处理">回调发送失败只记录日志不会中断消息发送撤回等主流程</el-descriptions-item> <el-descriptions-item label="失败处理">回调发送失败只记录日志不会中断消息发送撤回等主流程</el-descriptions-item>

查看文件

@ -29,7 +29,7 @@
/> />
<el-descriptions :column="1" border> <el-descriptions :column="1" border>
<el-descriptions-item label="调用方式">服务端以 `POST` 方式推送到你配置的回调地址</el-descriptions-item> <el-descriptions-item label="调用方式">服务端以 `POST` 方式推送到你配置的回调地址</el-descriptions-item>
<el-descriptions-item label="签名头">`X-App-Key``X-App-Timestamp``X-App-Nonce``X-App-Signature`</el-descriptions-item> <el-descriptions-item label="签名头">`X-App-Id``X-App-Timestamp``X-App-Nonce``X-App-Signature`</el-descriptions-item>
<el-descriptions-item label="验签公式">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`</el-descriptions-item> <el-descriptions-item label="验签公式">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`</el-descriptions-item>
<el-descriptions-item label="幂等建议">接收方建议按 `callbackId` 去重</el-descriptions-item> <el-descriptions-item label="幂等建议">接收方建议按 `callbackId` 去重</el-descriptions-item>
</el-descriptions> </el-descriptions>
@ -244,3 +244,4 @@ onMounted(async () => {
await Promise.all([loadApp(), loadWebhooks()]) await Promise.all([loadApp(), loadWebhooks()])
}) })
</script> </script>

查看文件

@ -24,171 +24,131 @@
</el-card> </el-card>
<el-alert <el-alert
title="新推送模型按 vendors + profiles 管理。厂商凭据只负责连厂商,profiles 负责按场景配置 Channel ID、Category、Importance、角标、声音、振动等。" title="这里维护各厂商推送凭据。推送服务开通后,离线推送才会按厂商路由发送。"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
style="margin-bottom:16px" style="margin-bottom:16px"
/> />
<el-card style="margin-bottom:16px">
<template #header>厂商凭据</template>
<div class="vendor-grid">
<el-card v-for="vendor in vendorDefs" :key="vendor.key" shadow="never" class="vendor-card">
<template #header>{{ vendor.label }}</template>
<div class="vendor-hint">{{ vendor.hint }}</div>
<el-form :label-position="isMobile ? 'top' : 'right'" label-width="110px">
<el-form-item v-for="field in vendor.fields" :key="field.key" :label="field.label">
<el-input
v-if="field.type === 'textarea'"
v-model="vendorModel(vendor.key)[field.key]"
type="textarea"
:rows="field.rows ?? 4"
:placeholder="field.placeholder"
/>
<el-switch
v-else-if="field.type === 'switch'"
v-model="vendorModel(vendor.key)[field.key]"
/>
<el-input
v-else
v-model="vendorModel(vendor.key)[field.key]"
:placeholder="field.placeholder"
/>
</el-form-item>
</el-form>
</el-card>
</div>
</el-card>
<el-card> <el-card>
<template #header> <template #header>厂商配置</template>
<div class="profiles-header"> <el-divider content-position="left">通知通道</el-divider>
<span>平台 Profiles</span>
<div class="profiles-actions">
<el-button size="small" @click="addProfile()">新增通用 profile</el-button>
<el-button size="small" @click="addProfile('xiaomi')">新增小米</el-button>
<el-button size="small" @click="addProfile('huawei')">新增华为</el-button>
<el-button size="small" @click="addProfile('honor')">新增荣耀</el-button>
<el-button size="small" @click="addProfile('oppo')">新增 OPPO</el-button>
<el-button size="small" @click="addProfile('vivo')">新增 vivo</el-button>
<el-button size="small" @click="addProfile('harmony')">新增鸿蒙</el-button>
<el-button size="small" @click="addProfile('apns')">新增 iOS</el-button>
</div>
</div>
</template>
<el-alert <el-alert
description="routeType 为空时表示默认兜底 profile。你可以先建 3 条,后续随时补充剩余的 2 条,或者删除不再使用的 profile。" description="通知通道定义 Android 通知渠道,以及 iOS/鸿蒙 对应的分类配置。业务键Key为业务层自定义的唯一标识,代码内部用于引用此通道,例如 im_message;Channel ID 需与 App manifest 中声明的 NotificationChannel ID 完全一致;版本号在渠道参数变更时自增,客户端将以新版本号重新注册渠道。"
type="info" type="info"
:closable="false" :closable="false"
:show-icon="false" :show-icon="false"
style="margin-bottom:12px" style="margin-bottom:12px"
/> />
<el-table :data="pushConfig.channels" border style="margin-bottom:16px">
<el-table :data="pushConfig.profiles" border style="margin-bottom:16px" class="profiles-table"> <el-table-column label="业务键" min-width="130">
<el-table-column label="启用" width="78" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-switch v-model="row.enabled" /> <el-input v-model="row.key" />
</template>
</el-table-column>
<el-table-column label="厂商" min-width="120">
<template #default="{ row }">
<el-select v-model="row.vendor" filterable>
<el-option v-for="vendor in vendorOptions" :key="vendor.value" :label="vendor.label" :value="vendor.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="场景 RouteType" min-width="180">
<template #default="{ row }">
<el-select
v-model="row.routeType"
filterable
allow-create
default-first-option
placeholder="例如 IM_MESSAGE / SYSTEM_NOTICE"
>
<el-option v-for="route in routeTypeOptions" :key="route" :label="route" :value="route" />
</el-select>
</template>
</el-table-column>
<el-table-column label="Profile Key" min-width="180">
<template #default="{ row }">
<el-input v-model="row.profileKey" placeholder="例如 xiaomi_im_message_v1" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Channel ID" min-width="180"> <el-table-column label="Channel ID" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-model="row.channelId" placeholder="厂商 Channel ID / 通知通道 ID" /> <el-input v-model="row.channelId" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Category" min-width="160"> <el-table-column label="版本" width="82">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-model="row.category" placeholder="MESSAGE / SYSTEM / ..." /> <el-input-number v-model="row.version" :min="1" controls-position="right" style="width:72px" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="重要性" width="120"> <el-table-column label="名称" min-width="130">
<template #default="{ row }">
<el-input v-model="row.name" />
</template>
</el-table-column>
<el-table-column label="重要性" width="130">
<template #default="{ row }"> <template #default="{ row }">
<el-select v-model="row.importance"> <el-select v-model="row.importance">
<el-option v-for="item in importanceOptions" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in importanceOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="优先级" width="120"> <el-table-column label="声音" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }"><el-switch v-model="row.sound" /></template>
<el-select v-model="row.priority">
<el-option v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column> </el-table-column>
<el-table-column label="角标" width="78" align="center"> <el-table-column label="振动" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }"><el-switch v-model="row.vibration" /></template>
<el-switch v-model="row.badge" />
</template>
</el-table-column> </el-table-column>
<el-table-column label="声音" width="78" align="center"> <el-table-column label="角标" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }"><el-switch v-model="row.badge" /></template>
<el-switch v-model="row.sound" />
</template>
</el-table-column> </el-table-column>
<el-table-column label="振动" width="78" align="center"> <el-table-column label="操作" width="80">
<template #default="{ row }">
<el-switch v-model="row.vibration" />
</template>
</el-table-column>
<el-table-column label="Thread ID" min-width="150">
<template #default="{ row }">
<el-input v-model="row.threadIdentifier" placeholder="iOS thread-id" />
</template>
</el-table-column>
<el-table-column label="中断级别" min-width="150">
<template #default="{ row }">
<el-select v-model="row.interruptionLevel" clearable placeholder="默认">
<el-option v-for="item in interruptionOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="NotifyType" width="120">
<template #default="{ row }">
<el-input-number v-model="row.notifyType" :min="0" :max="10" controls-position="right" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="版本" width="92">
<template #default="{ row }">
<el-input-number v-model="row.version" :min="1" controls-position="right" style="width:74px" />
</template>
</el-table-column>
<el-table-column label="备注" min-width="200">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="可选说明" />
</template>
</el-table-column>
<el-table-column label="操作" width="84" fixed="right">
<template #default="{ $index }"> <template #default="{ $index }">
<el-button link type="danger" @click="removeProfile($index)">删除</el-button> <el-button link type="danger" @click="removeChannel($index)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-button style="margin-bottom:20px" @click="addChannel">新增通道</el-button>
<el-divider content-position="left">业务分类路由</el-divider>
<el-alert
description="业务分类路由将系统内置的推送类型(如 IM 消息、好友请求、系统通知)映射到对应通道和优先级,决定推送到达时的展示行为。"
type="info"
:closable="false"
:show-icon="false"
style="margin-bottom:12px"
/>
<el-table :data="routeRows" border style="margin-bottom:20px">
<el-table-column label="业务分类" width="160">
<template #default="{ row }">{{ row.type }}</template>
</el-table-column>
<el-table-column label="通道" min-width="180">
<template #default="{ row }">
<el-select v-model="row.route.channel">
<el-option v-for="channel in pushConfig.channels" :key="channel.key" :label="channel.name || channel.key" :value="channel.key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="Category" min-width="160">
<template #default="{ row }">
<el-input v-model="row.route.category" />
</template>
</el-table-column>
<el-table-column label="优先级" width="130">
<template #default="{ row }">
<el-select v-model="row.route.priority">
<el-option label="低" value="LOW" />
<el-option label="默认" value="DEFAULT" />
<el-option label="高" value="HIGH" />
</el-select>
</template>
</el-table-column>
</el-table>
<el-divider content-position="left">厂商凭据</el-divider>
<div class="vendor-grid">
<el-card v-for="vendor in vendorDefs" :key="vendor.key" shadow="never" class="vendor-card">
<template #header>{{ vendor.label }}</template>
<div class="vendor-hint">{{ vendor.hint }}</div>
<el-form :label-position="isMobile ? 'top' : 'right'" label-width="120px">
<el-form-item v-for="field in vendor.fields" :key="field.key" :label="field.label">
<el-input
v-if="field.type === 'textarea'"
v-model="pushConfig[vendor.key][field.key]"
type="textarea"
:rows="field.rows ?? 4"
:placeholder="field.placeholder"
/>
<el-switch
v-else-if="field.type === 'switch'"
v-model="pushConfig[vendor.key][field.key]"
/>
<el-input
v-else
v-model="pushConfig[vendor.key][field.key]"
type="text"
:placeholder="field.placeholder"
/>
</el-form-item>
</el-form>
</el-card>
</div>
<div class="toolbar"> <div class="toolbar">
<el-button @click="reloadConfig" :loading="loading">刷新</el-button> <el-button @click="reloadConfig" :loading="loading">刷新</el-button>
@ -201,37 +161,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { CopyDocument } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
import { import {
appApi, appApi,
type App, type App,
type FeatureService, type FeatureService,
type ApnsPushVendorConfig, type PushNotificationChannelConfig,
type HarmonyPushVendorConfig, type PushNotificationRouteConfig,
type HonorPushVendorConfig,
type HuaweiPushVendorConfig,
type OppoPushVendorConfig,
type PushImportance,
type PushInterruptionLevel,
type PushPriority,
type PushProfileConfig,
type PushServiceConfig, type PushServiceConfig,
type PushVendorKey,
type XiaomiPushVendorConfig,
type VivoPushVendorConfig,
} from '@/api/app' } from '@/api/app'
type PushVendorsState = { type VendorKey = keyof PushServiceConfig
huawei: HuaweiPushVendorConfig
xiaomi: XiaomiPushVendorConfig
oppo: OppoPushVendorConfig
vivo: VivoPushVendorConfig
honor: HonorPushVendorConfig
harmony: HarmonyPushVendorConfig
apns: ApnsPushVendorConfig
}
type FieldDef = { type FieldDef = {
key: string key: string
label: string label: string
@ -239,21 +180,13 @@ type FieldDef = {
placeholder?: string placeholder?: string
rows?: number rows?: number
} }
type VendorDef = { type VendorDef = {
key: PushVendorKey key: VendorKey
label: string label: string
hint: string hint: string
fields: FieldDef[] fields: FieldDef[]
} }
type PushConfigState = {
schemaVersion: number
updatedAt: string
vendors: PushVendorsState
profiles: PushProfileConfig[]
}
const route = useRoute() const route = useRoute()
const app = ref<App | null>(null) const app = ref<App | null>(null)
const services = ref<FeatureService[]>([]) const services = ref<FeatureService[]>([])
@ -261,31 +194,29 @@ const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const isMobile = ref(window.innerWidth < 768) const isMobile = ref(window.innerWidth < 768)
const selectedPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID') const selectedPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
const platformOptions = [ const platformOptions = [
{ label: 'Android', value: 'ANDROID' }, { label: 'Android', value: 'ANDROID' },
{ label: 'iOS', value: 'IOS' }, { label: 'iOS', value: 'IOS' },
{ label: '鸿蒙', value: 'HARMONY' }, { label: '鸿蒙', value: 'HARMONY' },
] ]
const vendorOptions: Array<{ label: string; value: PushVendorKey }> = [ function updateViewport() {
{ label: '华为', value: 'huawei' }, isMobile.value = window.innerWidth < 768
{ label: '小米', value: 'xiaomi' }, }
{ label: 'OPPO', value: 'oppo' },
{ label: 'vivo', value: 'vivo' },
{ label: '荣耀', value: 'honor' },
{ label: '鸿蒙', value: 'harmony' },
{ label: 'iOS', value: 'apns' },
]
const routeTypeOptions = [ const pushConfig = reactive<Required<PushServiceConfig>>({
'IM_MESSAGE', huawei: { appId: '', appSecret: '', category: '' },
'FRIEND_REQUEST', xiaomi: { appId: '', appKey: '', appSecret: '', channelId: '' },
'SYSTEM_NOTICE', oppo: { appId: '', appKey: '', masterSecret: '', channelId: '' },
'SERVICE_NOTICE', vivo: { appId: '', appKey: '', appSecret: '', category: '', receiptId: '' },
'MARKETING', honor: { appId: '', clientId: '', clientSecret: '' },
'DEFAULT', harmony: { appId: '', appSecret: '' },
] apns: { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false },
fcm: { serviceAccountJson: '' },
channels: defaultChannels(),
routing: defaultRouting(),
} as Required<PushServiceConfig>)
const originalChannels = ref<PushNotificationChannelConfig[]>([])
const importanceOptions = [ const importanceOptions = [
{ label: '最小', value: 'MIN' }, { label: '最小', value: 'MIN' },
@ -293,83 +224,78 @@ const importanceOptions = [
{ label: '默认', value: 'DEFAULT' }, { label: '默认', value: 'DEFAULT' },
{ label: '高', value: 'HIGH' }, { label: '高', value: 'HIGH' },
{ label: '最高', value: 'MAX' }, { label: '最高', value: 'MAX' },
] as const satisfies ReadonlyArray<{ label: string; value: PushImportance }> ] as const
const priorityOptions = [ const routeTypes = [
{ label: '低', value: 'LOW' }, { type: 'IM_MESSAGE', channel: 'im_message', category: 'MESSAGE', priority: 'HIGH' },
{ label: '默认', value: 'DEFAULT' }, { type: 'FRIEND_REQUEST', channel: 'im_message', category: 'SOCIAL', priority: 'HIGH' },
{ label: '高', value: 'HIGH' }, { type: 'SYSTEM_NOTICE', channel: 'system_notice', category: 'SYSTEM', priority: 'DEFAULT' },
] as const satisfies ReadonlyArray<{ label: string; value: PushPriority }> ] as const
const interruptionOptions = [ const routeRows = computed(() => routeTypes.map(item => ({
{ label: 'Passive', value: 'passive' }, type: item.type,
{ label: 'Active', value: 'active' }, route: pushConfig.routing[item.type] ?? ensureRoute(item.type, item.channel, item.category, item.priority),
{ label: 'Time Sensitive', value: 'time-sensitive' }, })))
{ label: 'Critical', value: 'critical' },
] as const satisfies ReadonlyArray<{ label: string; value: PushInterruptionLevel }>
const vendors = reactive<PushVendorsState>(createEmptyVendors())
const pushConfig = reactive<PushConfigState>({
schemaVersion: 2,
updatedAt: '',
vendors,
profiles: [],
})
const vendorDefs: VendorDef[] = [ const vendorDefs: VendorDef[] = [
{
key: 'huawei',
label: '华为 HMS',
hint: '填写 AppId / AppSecret;Category 用于消息分类(如 IM。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appSecret', label: 'AppSecret' },
{ key: 'category', label: 'Category', placeholder: 'IM' },
],
},
{ {
key: 'xiaomi', key: 'xiaomi',
label: '小米 MiPush', label: '小米 MiPush',
hint: '这里仅填写厂商凭据。Channel ID 放在 profiles 中按场景单独配置,支持后续补充或删除。', hint: '填写 AppId / AppKey / AppSecret;Android 包名在应用设置中统一配置。Channel ID 为小米通知通道 ID如 118060。',
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' }, { key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret' }, { key: 'appSecret', label: 'AppSecret' },
{ key: 'channelId', label: 'Channel ID', placeholder: '118060' },
], ],
}, },
{ {
key: 'huawei', key: 'oppo',
label: '华为 HMS', label: 'OPPO 推送',
hint: '仅保留厂商账号信息。Category、Channel ID、重要性等由 profiles 负责。', hint: '填写 AppId / AppKey / MasterSecret;Channel ID 为 OPPO 推送通道 ID如 IM。',
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'masterSecret', label: 'MasterSecret' },
{ key: 'channelId', label: 'Channel ID', placeholder: 'IM' },
],
},
{
key: 'vivo',
label: 'vivo 推送',
hint: '填写 AppId / AppKey / AppSecret;Category 用于消息分类IM 代表 IM 消息);回执 ID 为 vivo 控制台预注册的消息回执标识。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret' }, { key: 'appSecret', label: 'AppSecret' },
{ key: 'category', label: 'Category', placeholder: 'IM' },
{ key: 'receiptId', label: '回执 ID', placeholder: '4470' },
], ],
}, },
{ {
key: 'honor', key: 'honor',
label: '荣耀 Push', label: '荣耀推送',
hint: '荣耀账号凭据。业务场景与展示策略在 profiles 中配置。', hint: '填写 AppId / ClientId / ClientSecret。',
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'clientId', label: 'ClientId' }, { key: 'clientId', label: 'ClientId' },
{ key: 'clientSecret', label: 'ClientSecret' }, { key: 'clientSecret', label: 'ClientSecret' },
], ],
}, },
{
key: 'oppo',
label: 'OPPO 推送',
hint: '仅保留 AppKey / MasterSecret。通道配置与优先级在 profiles 中维护。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'masterSecret', label: 'MasterSecret' },
],
},
{
key: 'vivo',
label: 'vivo 推送',
hint: '厂商凭据与业务分类分离。classification 由 profiles 的 Category 驱动。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret' },
],
},
{ {
key: 'harmony', key: 'harmony',
label: '鸿蒙 Push Kit', label: '鸿蒙 Push Kit',
hint: '鸿蒙厂商凭据。需要的 Category / Channel ID 放到 profiles 中。', hint: '填写 HarmonyOS Push Kit AppId / AppSecret。',
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'appSecret', label: 'AppSecret' }, { key: 'appSecret', label: 'AppSecret' },
@ -377,13 +303,13 @@ const vendorDefs: VendorDef[] = [
}, },
{ {
key: 'apns', key: 'apns',
label: 'iOS APNs', label: 'APNsiOS',
hint: 'Team ID / Key ID / Bundle ID / 私钥统一放这里。Badge、Thread ID、Interruption Level 放在 profiles 中。', hint: '填写 Team ID / Key ID / Bundle ID / p8 文件路径。',
fields: [ fields: [
{ key: 'teamId', label: 'Team ID' }, { key: 'teamId', label: 'Team ID' },
{ key: 'keyId', label: 'Key ID' }, { key: 'keyId', label: 'Key ID' },
{ key: 'bundleId', label: 'Bundle ID' }, { key: 'bundleId', label: 'Bundle ID' },
{ key: 'privateKey', label: 'Private Key', type: 'textarea', rows: 6, placeholder: '粘贴 .p8 内容' }, { key: 'keyPath', label: 'p8 文件路径' },
{ key: 'sandbox', label: 'Sandbox', type: 'switch' }, { key: 'sandbox', label: 'Sandbox', type: 'switch' },
], ],
}, },
@ -392,166 +318,73 @@ const vendorDefs: VendorDef[] = [
const pushEnabled = computed(() => services.value.some( const pushEnabled = computed(() => services.value.some(
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value && s.enabled, s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value && s.enabled,
)) ))
const servicePlatform = computed(() => selectedPlatform.value)
function updateViewport() {
isMobile.value = window.innerWidth < 768
}
function createEmptyVendors(): PushVendorsState {
return {
huawei: { appId: '', appSecret: '' },
xiaomi: { appId: '', appKey: '', appSecret: '' },
oppo: { appId: '', appKey: '', masterSecret: '' },
vivo: { appId: '', appKey: '', appSecret: '' },
honor: { appId: '', clientId: '', clientSecret: '' },
harmony: { appId: '', appSecret: '' },
apns: { teamId: '', keyId: '', bundleId: '', privateKey: '', sandbox: false },
}
}
function createEmptyState(): PushConfigState {
return {
schemaVersion: 2,
updatedAt: '',
vendors: createEmptyVendors(),
profiles: [],
}
}
function createProfile(vendor: PushVendorKey = 'xiaomi', routeType = ''): PushProfileConfig {
return {
profileKey: `${vendor}_${routeType || 'default'}_${Date.now()}`,
vendor,
routeType,
enabled: true,
channelId: '',
category: '',
importance: 'DEFAULT',
priority: 'DEFAULT',
threadIdentifier: '',
interruptionLevel: '',
badge: true,
sound: true,
vibration: true,
notifyType: 1,
version: 1,
remark: '',
}
}
function vendorModel(vendor: PushVendorKey): Record<string, any> {
return vendors[vendor] as Record<string, any>
}
function replaceState(next: PushConfigState) {
Object.assign(pushConfig.vendors.huawei, next.vendors.huawei)
Object.assign(pushConfig.vendors.xiaomi, next.vendors.xiaomi)
Object.assign(pushConfig.vendors.oppo, next.vendors.oppo)
Object.assign(pushConfig.vendors.vivo, next.vendors.vivo)
Object.assign(pushConfig.vendors.honor, next.vendors.honor)
Object.assign(pushConfig.vendors.harmony, next.vendors.harmony)
Object.assign(pushConfig.vendors.apns, next.vendors.apns)
pushConfig.schemaVersion = next.schemaVersion
pushConfig.updatedAt = next.updatedAt
pushConfig.profiles.splice(0, pushConfig.profiles.length, ...next.profiles.map(profile => normalizeProfile(profile)))
}
function normalizeProfile(profile: Partial<PushProfileConfig> & Pick<PushProfileConfig, 'vendor'>): PushProfileConfig {
return {
profileKey: profile.profileKey?.trim() || `${profile.vendor}_${profile.routeType || 'default'}_${Date.now()}`,
vendor: profile.vendor,
routeType: profile.routeType?.trim() || '',
enabled: profile.enabled ?? true,
channelId: profile.channelId?.trim() || '',
category: profile.category?.trim() || '',
importance: profile.importance || 'DEFAULT',
priority: profile.priority || 'DEFAULT',
threadIdentifier: profile.threadIdentifier?.trim() || '',
interruptionLevel: profile.interruptionLevel || '',
badge: profile.badge ?? true,
sound: profile.sound ?? true,
vibration: profile.vibration ?? true,
notifyType: Number.isFinite(profile.notifyType as number) ? Math.max(Number(profile.notifyType ?? 1), 0) : 1,
version: Math.max(Number(profile.version ?? 1), 1),
remark: profile.remark?.trim() || '',
}
}
function parseConfig(raw?: string | null): PushConfigState {
if (!raw) {
return createEmptyState()
}
try {
const parsed = JSON.parse(raw) as Partial<PushConfigState>
return {
schemaVersion: parsed.schemaVersion ?? 2,
updatedAt: parsed.updatedAt ?? '',
vendors: {
...createEmptyVendors(),
...parsed.vendors,
huawei: { ...createEmptyVendors().huawei, ...(parsed.vendors?.huawei ?? {}) },
xiaomi: { ...createEmptyVendors().xiaomi, ...(parsed.vendors?.xiaomi ?? {}) },
oppo: { ...createEmptyVendors().oppo, ...(parsed.vendors?.oppo ?? {}) },
vivo: { ...createEmptyVendors().vivo, ...(parsed.vendors?.vivo ?? {}) },
honor: { ...createEmptyVendors().honor, ...(parsed.vendors?.honor ?? {}) },
harmony: { ...createEmptyVendors().harmony, ...(parsed.vendors?.harmony ?? {}) },
apns: { ...createEmptyVendors().apns, ...(parsed.vendors?.apns ?? {}) },
},
profiles: Array.isArray(parsed.profiles)
? parsed.profiles.map(profile => normalizeProfile(profile as Partial<PushProfileConfig> & Pick<PushProfileConfig, 'vendor'>))
: [],
}
} catch {
return createEmptyState()
}
}
async function loadData() { async function loadData() {
loading.value = true const id = route.params.appKey as string
try { const [appRes, svcRes] = await Promise.all([
const id = route.params.appKey as string appApi.get(id),
const [appRes, svcRes] = await Promise.all([ appApi.getServices(id),
appApi.get(id), ])
appApi.getServices(id), app.value = appRes.data.data
]) services.value = svcRes.data.data
app.value = appRes.data.data const firstPushService = services.value.find(s => s.serviceType === 'PUSH')
services.value = svcRes.data.data if (firstPushService && !services.value.some(s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value)) {
const firstPushService = services.value.find(s => s.serviceType === 'PUSH') selectedPlatform.value = firstPushService.platform
if (firstPushService && !services.value.some(s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value)) {
selectedPlatform.value = firstPushService.platform
}
applySelectedPlatformConfig()
} finally {
loading.value = false
} }
applySelectedPlatformConfig()
} }
function applySelectedPlatformConfig() { function applySelectedPlatformConfig() {
const raw = services.value.find( applyConfig(services.value.find(
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value, s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value,
)?.config )?.config)
replaceState(parseConfig(raw)) }
function applyConfig(raw?: string | null) {
resetPushConfig()
const parsed = parseConfig(raw)
pushConfig.huawei = { ...pushConfig.huawei, ...parsed.huawei }
pushConfig.xiaomi = { ...pushConfig.xiaomi, ...parsed.xiaomi }
pushConfig.oppo = { ...pushConfig.oppo, ...parsed.oppo }
pushConfig.vivo = { ...pushConfig.vivo, ...parsed.vivo }
pushConfig.honor = { ...pushConfig.honor, ...parsed.honor }
pushConfig.harmony = { ...pushConfig.harmony, ...parsed.harmony }
pushConfig.apns = { ...pushConfig.apns, ...parsed.apns }
pushConfig.fcm = { ...pushConfig.fcm, ...parsed.fcm }
pushConfig.channels = normalizeChannels(parsed.channels)
pushConfig.routing = normalizeRouting(parsed.routing)
originalChannels.value = pushConfig.channels.map(channel => ({ ...channel }))
}
function resetPushConfig() {
pushConfig.huawei = { appId: '', appSecret: '', category: '' }
pushConfig.xiaomi = { appId: '', appKey: '', appSecret: '', channelId: '' }
pushConfig.oppo = { appId: '', appKey: '', masterSecret: '', channelId: '' }
pushConfig.vivo = { appId: '', appKey: '', appSecret: '', category: '', receiptId: '' }
pushConfig.honor = { appId: '', clientId: '', clientSecret: '' }
pushConfig.harmony = { appId: '', appSecret: '' }
pushConfig.apns = { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false }
pushConfig.fcm = { serviceAccountJson: '' }
pushConfig.channels = defaultChannels()
pushConfig.routing = defaultRouting()
}
function parseConfig(raw?: string | null): PushServiceConfig {
if (!raw) return {}
try {
return JSON.parse(raw) as PushServiceConfig
} catch {
return {}
}
} }
async function saveConfig() { async function saveConfig() {
if (!app.value) return if (!app.value) return
saving.value = true saving.value = true
try { try {
const payload: PushServiceConfig = { bumpChangedChannelVersions()
schemaVersion: 2, await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', toPushConfigRequest())
updatedAt: new Date().toISOString(),
vendors: {
huawei: { ...pushConfig.vendors.huawei },
xiaomi: { ...pushConfig.vendors.xiaomi },
oppo: { ...pushConfig.vendors.oppo },
vivo: { ...pushConfig.vendors.vivo },
honor: { ...pushConfig.vendors.honor },
harmony: { ...pushConfig.vendors.harmony },
apns: { ...pushConfig.vendors.apns },
},
profiles: pushConfig.profiles.map(profile => normalizeProfile(profile)),
}
await appApi.updateServiceConfig(app.value.id, selectedPlatform.value, 'PUSH', payload)
ElMessage.success('推送配置已保存') ElMessage.success('推送配置已保存')
await loadData() await loadData()
} catch { } catch {
@ -561,12 +394,143 @@ async function saveConfig() {
} }
} }
function addProfile(vendor: PushVendorKey = 'xiaomi') { function toPushConfigRequest(): Record<string, unknown> {
pushConfig.profiles.push(createProfile(vendor)) return {
huaweiAppId: pushConfig.huawei.appId ?? '',
huaweiAppSecret: pushConfig.huawei.appSecret ?? '',
huaweiCategory: pushConfig.huawei.category ?? '',
xiaomiAppId: pushConfig.xiaomi.appId ?? '',
xiaomiAppKey: pushConfig.xiaomi.appKey ?? '',
xiaomiAppSecret: pushConfig.xiaomi.appSecret ?? '',
xiaomiChannelId: pushConfig.xiaomi.channelId ?? '',
oppoAppId: pushConfig.oppo.appId ?? '',
oppoAppKey: pushConfig.oppo.appKey ?? '',
oppoMasterSecret: pushConfig.oppo.masterSecret ?? '',
oppoChannelId: pushConfig.oppo.channelId ?? '',
vivoAppId: pushConfig.vivo.appId ?? '',
vivoAppKey: pushConfig.vivo.appKey ?? '',
vivoAppSecret: pushConfig.vivo.appSecret ?? '',
vivoCategory: pushConfig.vivo.category ?? '',
vivoReceiptId: pushConfig.vivo.receiptId ?? '',
honorAppId: pushConfig.honor.appId ?? '',
honorClientId: pushConfig.honor.clientId ?? '',
honorClientSecret: pushConfig.honor.clientSecret ?? '',
harmonyAppId: pushConfig.harmony.appId ?? '',
harmonyAppSecret: pushConfig.harmony.appSecret ?? '',
apnsTeamId: pushConfig.apns.teamId ?? '',
apnsKeyId: pushConfig.apns.keyId ?? '',
apnsBundleId: pushConfig.apns.bundleId ?? '',
apnsKeyPath: pushConfig.apns.keyPath ?? '',
apnsSandbox: pushConfig.apns.sandbox ?? false,
fcmServiceAccountJson: pushConfig.fcm.serviceAccountJson ?? '',
channels: pushConfig.channels,
routing: pushConfig.routing,
}
} }
function removeProfile(index: number) { function defaultChannels(): PushNotificationChannelConfig[] {
pushConfig.profiles.splice(index, 1) return [
{
key: 'im_message',
channelId: 'xuqm_im_message',
version: 1,
name: '聊天消息',
description: '单聊、群聊和好友消息',
importance: 'HIGH',
sound: true,
vibration: true,
badge: true,
},
{
key: 'system_notice',
channelId: 'xuqm_system_notice',
version: 1,
name: '系统通知',
description: '系统通知和业务提醒',
importance: 'DEFAULT',
sound: true,
vibration: true,
badge: true,
},
]
}
function defaultRouting(): Record<string, PushNotificationRouteConfig> {
return {
IM_MESSAGE: { channel: 'im_message', category: 'MESSAGE', priority: 'HIGH' },
FRIEND_REQUEST: { channel: 'im_message', category: 'SOCIAL', priority: 'HIGH' },
SYSTEM_NOTICE: { channel: 'system_notice', category: 'SYSTEM', priority: 'DEFAULT' },
}
}
function normalizeChannels(channels?: PushNotificationChannelConfig[]): PushNotificationChannelConfig[] {
const source = Array.isArray(channels) && channels.length > 0 ? channels : defaultChannels()
return source.map((channel, index) => ({
key: channel.key || `channel_${index + 1}`,
channelId: channel.channelId || `xuqm_channel_${index + 1}`,
version: Math.max(Number(channel.version || 1), 1),
name: channel.name || channel.key || `通知通道 ${index + 1}`,
description: channel.description ?? '',
importance: channel.importance || 'DEFAULT',
sound: channel.sound ?? true,
vibration: channel.vibration ?? true,
badge: channel.badge ?? true,
}))
}
function normalizeRouting(routing?: Record<string, PushNotificationRouteConfig>): Record<string, PushNotificationRouteConfig> {
return { ...defaultRouting(), ...(routing ?? {}) }
}
function ensureRoute(
type: string,
channel: string,
category: string,
priority: PushNotificationRouteConfig['priority'],
): PushNotificationRouteConfig {
const route = { channel, category, priority }
pushConfig.routing[type] = route
return route
}
function addChannel() {
const next = pushConfig.channels.length + 1
pushConfig.channels.push({
key: `custom_${next}`,
channelId: `xuqm_custom_${next}`,
version: 1,
name: `自定义通道 ${next}`,
description: '',
importance: 'DEFAULT',
sound: true,
vibration: true,
badge: true,
})
}
function removeChannel(index: number) {
const [removed] = pushConfig.channels.splice(index, 1)
if (!removed) return
Object.values(pushConfig.routing).forEach(route => {
if (route.channel === removed.key) {
route.channel = pushConfig.channels[0]?.key ?? ''
}
})
}
function bumpChangedChannelVersions() {
const originalByKey = new Map(originalChannels.value.map(channel => [channel.key, channel]))
pushConfig.channels.forEach(channel => {
const original = originalByKey.get(channel.key)
if (!original) return
const immutableChanged = original.importance !== channel.importance
|| original.sound !== channel.sound
|| original.vibration !== channel.vibration
|| original.badge !== channel.badge
if (immutableChanged && channel.version <= original.version) {
channel.version = original.version + 1
}
})
} }
async function onTogglePushService(enable: boolean) { async function onTogglePushService(enable: boolean) {
@ -580,7 +544,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.id, servicePlatform.value, 'PUSH', false)
ElMessage.success('已关闭') ElMessage.success('已关闭')
await loadData() await loadData()
} }
@ -600,20 +564,11 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
</script> </script>
<style scoped> <style scoped>
.mono { .mono { font-family: monospace; font-size: 12px; }
font-family: monospace; .hint { font-size: 12px; color: #909399; }
font-size: 12px;
}
.hint {
font-size: 12px;
color: #909399;
}
.info-card :deep(.el-descriptions__body) { .info-card :deep(.el-descriptions__body) {
overflow-x: auto; overflow-x: auto;
} }
.push-switch-row { .push-switch-row {
margin-top: 12px; margin-top: 12px;
display: flex; display: flex;
@ -621,38 +576,20 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
} }
.vendor-grid { .vendor-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px; gap: 16px;
} }
.vendor-card { .vendor-card {
min-height: 100%; min-height: 100%;
} }
.vendor-hint { .vendor-hint {
margin-bottom: 12px; margin-bottom: 12px;
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
line-height: 1.5; line-height: 1.5;
} }
.profiles-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.profiles-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.toolbar { .toolbar {
margin-top: 16px; margin-top: 16px;
display: flex; display: flex;
@ -660,12 +597,6 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
flex-wrap: wrap; flex-wrap: wrap;
} }
.profiles-table :deep(.el-select),
.profiles-table :deep(.el-input),
.profiles-table :deep(.el-input-number) {
width: 100%;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.vendor-grid { .vendor-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -676,8 +607,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
align-items: flex-start; align-items: flex-start;
} }
.toolbar :deep(.el-button), .toolbar :deep(.el-button) {
.profiles-actions :deep(.el-button) {
width: 100%; width: 100%;
} }

查看文件

@ -1,10 +1,5 @@
<template> <template>
<div <div>
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="handleDrop"
>
<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">
@ -46,36 +41,27 @@
</el-table-column> </el-table-column>
<el-table-column label="应用商店" width="220" show-overflow-tooltip> <el-table-column label="应用商店" width="220" show-overflow-tooltip>
<template #default="{row}"> <template #default="{row}">
<div v-if="parseStoreReview(row.storeReviewStatus).length" class="store-review-cell"> <template v-if="parseStoreReview(row.storeReviewStatus).length">
<div class="store-review-tags"> <template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store"> <el-tooltip
<el-tooltip v-if="item.state === 'REJECTED' && item.reason"
v-if="item.state === 'REJECTED' && item.reason" :content="item.reason"
:content="item.reason" placement="top"
placement="top" >
>
<el-tag
:type="reviewTagType(item.state)"
size="small"
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
</el-tooltip>
<el-tag <el-tag
v-else
:type="reviewTagType(item.state)" :type="reviewTagType(item.state)"
size="small" size="small"
style="margin:2px" style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag> >{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
</template> </el-tooltip>
</div> <el-tag
<el-button v-else
link :type="reviewTagType(item.state)"
type="primary" size="small"
size="small" style="margin:2px"
class="store-review-detail-btn" >{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
@click="openStoreReviewDetail(row)" </template>
>查看详情</el-button> </template>
</div>
<span v-else class="text-muted"></span> <span v-else class="text-muted"></span>
</template> </template>
</el-table-column> </el-table-column>
@ -494,81 +480,6 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- Store Review Detail Dialog -->
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="dialogWidth">
<div v-if="storeReviewDetailVersion">
<el-descriptions :column="1" border style="margin-bottom:16px">
<el-descriptions-item label="版本">
{{ storeReviewDetailVersion.versionName }} · {{ storeReviewDetailVersion.versionCode }}
</el-descriptions-item>
<el-descriptions-item label="发布状态">
<el-tag :type="statusTagType(storeReviewDetailVersion)" size="small">
{{ statusLabel(storeReviewDetailVersion) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="市场提交目标">
<span v-if="parseStoreTargets(storeReviewDetailVersion.storeSubmitTargets).length">
{{ parseStoreTargets(storeReviewDetailVersion.storeSubmitTargets).map(storeLabel).join('、') }}
</span>
<span v-else class="text-muted">未配置</span>
</el-descriptions-item>
<el-descriptions-item label="上传时间">
{{ formatTime(storeReviewDetailVersion.createdAt) }}
</el-descriptions-item>
</el-descriptions>
<el-table :data="storeReviewDetailItems" border stripe>
<el-table-column prop="store" label="市场" width="150">
<template #default="{row}">{{ storeLabel(row.store) }}</template>
</el-table-column>
<el-table-column prop="state" label="状态" width="120">
<template #default="{row}">
<el-tag :type="reviewTagType(row.state)" size="small">
{{ reviewLabel(row.state) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="stage" label="阶段" width="120">
<template #default="{row}">
<el-tag v-if="row.stage" type="info" size="small">{{ row.stage }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="submittedAt" label="提交时间" width="180">
<template #default="{row}">
<span>{{ row.submittedAt ? formatTime(row.submittedAt) : '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="batchId" label="批次 ID" width="220" show-overflow-tooltip>
<template #default="{row}">
<span>{{ row.batchId || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="失败原因 / 说明" min-width="260" show-overflow-tooltip>
<template #default="{row}">
<el-text v-if="row.reason" :type="row.state === 'REJECTED' ? 'danger' : 'default'">
{{ row.reason }}
</el-text>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
</el-table>
<el-alert
v-if="storeReviewDetailItems.some(item => item.state === 'REJECTED')"
type="error"
show-icon
:closable="false"
style="margin-top:16px"
title="存在审核失败的市场,请查看上方失败原因。"
/>
</div>
<el-empty v-else description="暂无商店状态数据" />
<template #footer>
<el-button type="primary" @click="showStoreReviewDetail = false">关闭</el-button>
</template>
</el-dialog>
<!-- Store Credential Config Dialog --> <!-- Store Credential Config Dialog -->
<el-dialog <el-dialog
v-model="showStoreConfig" v-model="showStoreConfig"
@ -746,20 +657,12 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- Global Drag Drop Overlay -->
<div v-if="isDraggingOver" class="drag-overlay">
<div class="drag-overlay-content">
<el-icon size="64"><UploadFilled /></el-icon>
<p class="drag-overlay-title">释放文件以上传</p>
<p class="drag-overlay-hint">支持 .apk.bundle.js</p>
</div>
</div>
</template> </template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue' import { UploadFilled } from '@element-plus/icons-vue'
@ -778,7 +681,6 @@ import {
type StoreConfig, type StoreConfig,
type StoreType, type StoreType,
} from '@/api/update' } from '@/api/update'
import { connectStoreReviewRealtime, disconnectStoreReviewRealtime } 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'
@ -795,54 +697,6 @@ const pageTitle = computed(() => app.value?.name ?? appKey)
const isMobile = ref(false) const isMobile = ref(false)
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '920px')) const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '920px'))
const isDraggingOver = ref(false)
let dragCounter = 0
function handleDragEnter(e: DragEvent) {
e.preventDefault()
dragCounter++
isDraggingOver.value = true
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy'
}
}
function handleDragLeave(e: DragEvent) {
dragCounter--
if (dragCounter <= 0) {
isDraggingOver.value = false
dragCounter = 0
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault()
isDraggingOver.value = false
dragCounter = 0
const files = Array.from(e.dataTransfer?.files || [])
if (!files.length) return
const file = files[0]
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
if (ext === '.apk') {
openUploadAppDialog()
await nextTick()
await onAppPackageChange({ raw: file })
} else if (ext === '.bundle' || ext === '.js') {
showUploadRn.value = true
await nextTick()
await onRnBundleChange({ raw: file })
} else {
ElMessage.warning(`不支持的文件类型:${file.name},请拖入 .apk、.bundle 或 .js 文件`)
}
}
const activeTab = ref('app') const activeTab = ref('app')
const storeTab = ref<'configs' | 'guide'>('configs') const storeTab = ref<'configs' | 'guide'>('configs')
const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID') const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
@ -888,7 +742,6 @@ const operationLogs = ref<{
createdAt: string createdAt: string
}[]>([]) }[]>([])
const loadingOperationLogs = ref(false) const loadingOperationLogs = ref(false)
let storeReviewReloadTimer: ReturnType<typeof setTimeout> | null = null
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim())) const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim())) const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
@ -1282,10 +1135,6 @@ const selectedStores = ref<StoreType[]>([])
const submitStoreMode = ref<PublishMode>('MANUAL') const submitStoreMode = ref<PublishMode>('MANUAL')
const submitStoreScheduledAt = ref('') const submitStoreScheduledAt = ref('')
const showStoreReviewDetail = ref(false)
const storeReviewDetailVersion = ref<AppVersion | null>(null)
const storeReviewDetailItems = ref<{ store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[]>([])
function openSubmitStoreDialog(row: AppVersion) { function openSubmitStoreDialog(row: AppVersion) {
submitStoreVersion.value = row submitStoreVersion.value = row
selectedStores.value = enabledStores.value.map(s => s.type) selectedStores.value = enabledStores.value.map(s => s.type)
@ -1294,22 +1143,6 @@ function openSubmitStoreDialog(row: AppVersion) {
showSubmitStore.value = true showSubmitStore.value = true
} }
function parseStoreTargets(json?: string) {
if (!json) return []
try {
const value = JSON.parse(json)
return Array.isArray(value) ? value.map(item => String(item)) : []
} catch {
return []
}
}
function openStoreReviewDetail(row: AppVersion) {
storeReviewDetailVersion.value = row
storeReviewDetailItems.value = parseStoreReview(row.storeReviewStatus)
showStoreReviewDetail.value = true
}
async function confirmSubmitToStores() { async function confirmSubmitToStores() {
if (!submitStoreVersion.value || !selectedStores.value.length) { if (!submitStoreVersion.value || !selectedStores.value.length) {
ElMessage.warning('请选择至少一个可用市场') ElMessage.warning('请选择至少一个可用市场')
@ -1828,11 +1661,11 @@ function storeLabel(type: string) {
} }
function reviewLabel(state: string): string { function reviewLabel(state: string): string {
return { PENDING: '待提交', SUBMITTING: '提交中', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state return { PENDING: '待提交', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
} }
function reviewTagType(state: string): string { function reviewTagType(state: string): string {
return { PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? '' return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
} }
function operationResourceLabel(resourceType: string) { function operationResourceLabel(resourceType: string) {
@ -1852,15 +1685,6 @@ function operationActionLabel(action: string) {
SAVE_DRAFT: '保存草稿', SAVE_DRAFT: '保存草稿',
UNPUBLISH: '下架', UNPUBLISH: '下架',
STORE_SUBMIT: '提交市场', STORE_SUBMIT: '提交市场',
STORE_SUBMIT_REQUEST: '提交市场请求',
STORE_SUBMIT_BATCH_START: '市场提交开始',
STORE_SUBMIT_BATCH_END: '市场提交结束',
STORE_SUBMIT_BATCH_FAILED: '市场提交失败',
STORE_SUBMIT_BATCH_SKIPPED: '市场提交跳过',
STORE_SUBMIT_STORE_START: '市场提交开始',
STORE_SUBMIT_STORE_STAGE: '市场提交阶段',
STORE_SUBMIT_STORE_SUCCESS: '市场提交成功',
STORE_SUBMIT_STORE_FAILED: '市场提交失败',
STORE_REVIEW: '审核回写', STORE_REVIEW: '审核回写',
GRAY_UPDATE: '灰度配置', GRAY_UPDATE: '灰度配置',
AUTO_PUBLISH: '自动发布', AUTO_PUBLISH: '自动发布',
@ -1898,17 +1722,7 @@ async function loadOperationLogs() {
} }
} }
function scheduleStoreReviewReload() { function parseStoreReview(json?: string): { store: string; state: string; reason?: string }[] {
if (storeReviewReloadTimer) {
clearTimeout(storeReviewReloadTimer)
}
storeReviewReloadTimer = setTimeout(() => {
void loadAppVersions()
void loadOperationLogs()
}, 200)
}
function parseStoreReview(json?: string): { store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[] {
if (!json) return [] if (!json) return []
try { try {
const m = JSON.parse(json) as Record<string, unknown> const m = JSON.parse(json) as Record<string, unknown>
@ -1922,10 +1736,6 @@ function parseStoreReview(json?: string): { store: string; state: string; reason
store, store,
state: String(item.state ?? ''), state: String(item.state ?? ''),
reason: String(item.reason ?? ''), reason: String(item.reason ?? ''),
stage: String(item.stage ?? ''),
submittedAt: String(item.submittedAt ?? ''),
updatedAt: String(item.updatedAt ?? ''),
batchId: String(item.batchId ?? ''),
} }
} }
return { store, state: String(value ?? ''), reason: '' } return { store, state: String(value ?? ''), reason: '' }
@ -1975,22 +1785,10 @@ onMounted(() => {
loadStoreConfigs() loadStoreConfigs()
loadPublishConfig() loadPublishConfig()
loadOperationLogs() loadOperationLogs()
void connectStoreReviewRealtime(appKey, () => {
scheduleStoreReviewReload()
}).catch((error) => {
if (import.meta.env.DEV) {
console.warn('[tenant-platform] store review realtime unavailable', error)
}
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport) window.removeEventListener('resize', updateViewport)
disconnectStoreReviewRealtime()
if (storeReviewReloadTimer) {
clearTimeout(storeReviewReloadTimer)
storeReviewReloadTimer = null
}
}) })
</script> </script>
@ -2215,24 +2013,6 @@ onBeforeUnmount(() => {
min-width: 980px; min-width: 980px;
} }
.store-review-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.store-review-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.store-review-detail-btn {
align-self: flex-start;
padding: 0;
min-height: 20px;
}
.store-grid, .store-grid,
.guide-grid { .guide-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -2266,36 +2046,4 @@ onBeforeUnmount(() => {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
} }
.drag-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.drag-overlay-content {
background: var(--el-bg-color);
border-radius: 16px;
padding: 48px 64px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
.drag-overlay-title {
margin-top: 16px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.drag-overlay-hint {
margin-top: 8px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style> </style>

查看文件

@ -1172,11 +1172,6 @@
dependencies: dependencies:
vue "^3.5.13" vue "^3.5.13"
"@xuqm/vue3-sdk@0.2.0":
version "0.2.0"
resolved "https://nexus.xuqinmin.com/repository/npm-hosted/@xuqm/vue3-sdk/-/vue3-sdk-0.2.0.tgz#f527250cb1b3b944920133d694866432709559bc"
integrity sha512-JlvaAVkxXpgawVCR1fGDVaeDJeOsk3XKoKLOFpPw3zAokkWL3cvGhXt2RWHiZ89Fwj5+VRFzSeJNx3DesmPDmw==
abbrev@^2.0.0: abbrev@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz" resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz"