比较提交

..

2 次代码提交

作者 SHA1 备注 提交日期
XuqmGroup
c11e8f6d71 feat(im): 添加平台事件通知功能支持应用审核状态实时更新
- 新增 ImPlatformEventController 提供令牌获取接口
- 新增 InternalImPlatformEventController 处理内部通知请求
- 实现 ImPlatformEventService 核心服务逻辑包括令牌签发和消息推送
- 添加 StoreReviewImNotifier 在更新服务中触发审核状态变更通知
- 在前端平台中集成实时审核状态更新功能
- 配置各项目环境版本管理文件 (.java-version, .nvmrc)
- 更新 Docker 忽略文件和 Maven 配置以优化构建流程
2026-05-08 18:32:46 +08:00
XuqmGroup
168bf4662c docs(deploy): 添加部署文档和安全设计规范
- 新增 XuqmGroup 部署文档,包含部署方案、架构建议和部署步骤
- 添加安全设计规范,涵盖密码安全、AppSecret验证和服务端API认证
- 补充平台REST API规范,定义Server-to-Server调用接口和错误码
- 创建Java IM服务端SDK计划文档,规划Maven包发布和接口实现
2026-05-08 18:32:00 +08:00
共有 19 个文件被更改,包括 1246 次插入704 次删除

10
.dockerignore 普通文件
查看文件

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

1
.npmrc 普通文件
查看文件

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

2
.nvmrc
查看文件

@ -1 +1 @@
22
22.22.2

查看文件

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

4
Jenkinsfile vendored
查看文件

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

查看文件

@ -1,249 +1,280 @@
# Server API 参考
XuqmGroup 的服务端能力分成三类:
- `IM 服务`:账号、消息、群组、好友、会话、黑名单、管理端、回调
- `Push 服务`:设备注册、离线推送、诊断、管理端
- `Update 服务`应用版本、RN Bundle、应用商店提审、发布配置、操作日志
**Base URL**`https://dev.xuqinmin.com`
所有 API 通过 Nginx 反代,无需区分内部端口。
所有接口都通过 Nginx 反代,无需关心内部端口。
---
## 认证
`/api/im/auth/login` 外,所有 IM 接口需在请求头携带 JWT Token
### IM 服务
```
Authorization: Bearer <token>
```
- `POST /api/im/auth/login` 之前的所有请求,客户端和服务端均需携带 `Authorization: Bearer <im_jwt>`
- `im_jwt` 通过 `POST /api/im/auth/login` 颁发
- 登录请求需要 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature`
Token 由 `/api/im/auth/login` 接口签发。当前 IM 登录不做过期功能,只校验 `userId + UserSig` 是否匹配。
### Push / Update / Tenant 相关服务
- 业务侧调用通常使用 `appKey`
- 管理端接口需要租户或运营平台的 JWT
- 内部接口统一使用 `X-Internal-Token`
---
## IM 服务(/api/im/
## IM 服务
### 登录 · 获取 Token
### 账号与登录
```
POST /api/im/auth/login
```
**Query 参数**
| 参数 | 必填 | 说明 |
| 方法 | 路径 | 说明 |
|------|------|------|
| `appId` | 是 | 应用 ID |
| `userId` | 是 | 用户 ID |
| `POST` | `/api/im/auth/login` | IM 登录,返回 JWT Token |
| `GET` | `/api/im/accounts/{userId}` | 获取账号资料 |
| `PUT` | `/api/im/accounts/{userId}` | 更新账号资料 |
| `GET` | `/api/im/accounts/search` | 搜索账号 |
| `POST` | `/api/im/accounts/import` | 导入单个账号 |
| `POST` | `/api/im/accounts/import/batch` | 批量导入账号 |
| `DELETE` | `/api/im/accounts/{userId}` | 删除账号 |
| `GET` | `/api/im/accounts/{userId}/exists` | 检查账号是否存在 |
**请求头**
### 消息
| 头 | 必填 | 说明 |
|----|------|------|
| `X-App-Timestamp` | 是 | 当前时间戳(毫秒) |
| `X-App-Nonce` | 是 | 随机字符串 |
| `X-App-Signature` | 是 | HmacSHA256 签名 |
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/im/messages/send` | 发送消息 |
| `POST` | `/api/im/messages/{id}/revoke` | 撤回消息 |
| `PUT` | `/api/im/messages/{id}` | 编辑消息 |
| `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
{
"toId": "user_002",
"chatType": "SINGLE",
"msgType": "TEXT",
"content": "Hello!"
"code": 200,
"status": "0",
"data": { },
"message": "success"
}
```
`chatType``SINGLE` | `GROUP`
### 错误码
`msgType``TEXT` | `IMAGE` | `VIDEO` | `AUDIO` | `FILE` | `CUSTOM` | `LOCATION` | `NOTIFY` | `RICH_TEXT` | `CALL_AUDIO` | `CALL_VIDEO` | `FORWARD`
**响应**`ImMessage` 对象
---
### 撤回消息
```
PUT /api/im/messages/{messageId}/revoke
Authorization: Bearer <token>
```
**响应**:更新后的 `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 | 说明 |
| HTTP 状态 | code | 含义 |
|-----------|------|------|
| 400 | 400 | 请求参数错误 |
| 401 | 401 | Token 无效或签名验证失败 |
| 403 | 403 | 无权限操作(如撤回他人消息)|
| 404 | 404 | 资源不存在 |
| 500 | 500 | 服务器内部错误 |
| `400` | `400` | 参数错误 |
| `401` | `401` | 鉴权失败 |
| `403` | `403` | 无权限 |
| `404` | `404` | 资源不存在 |
| `500` | `500` | 服务端内部错误 |
错误响应格式:
---
```json
{ "code": 403, "message": "只能撤回自己发送的消息" }
```
## 集成建议
- 服务端业务代码统一使用 Java / Go / Python Server SDK 调用上述 REST 接口
- 客户端统一使用 `appKey` 作为应用上下文
- 实时刷新场景建议使用 IM SDK 订阅服务端事件
- 更新、Push、IM 的后台能力可以独立接入,也可以组合使用

查看文件

@ -1,13 +1,18 @@
# Go Server SDK
XuqmGroup 服务端 Go SDK,提供 IM 消息发送、群管理、Push 推送等能力。
XuqmGroup 服务端 Go SDK,按照腾讯云服务端 API 的分类方式封装了:
- IM 账号、消息、群组、好友、会话、黑名单
- 管理端 Webhook、统计、操作日志
- Push 注册与推送
- Update 版本管理、RN Bundle、应用商店提审
---
## 安装
```bash
go get xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go
go get github.com/xuqmgroup/xuqmgroup-server-sdk-go
```
---
@ -15,19 +20,40 @@ go get xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go
## 初始化
```go
import xuqmsdk "xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go"
import xuqmsdk "github.com/xuqmgroup/xuqmgroup-server-sdk-go"
client := xuqmsdk.NewClient(xuqmsdk.Config{
client, err := xuqmsdk.NewClient(xuqmsdk.ClientConfig{
BaseURL: "https://dev.xuqinmin.com",
AppID: "your_app_id",
AppKey: "your_app_key",
AppSecret: "your_app_secret",
})
if err != nil {
log.Fatal(err)
}
```
> 服务端 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
@ -65,22 +91,15 @@ err := client.RevokeMessage("message_id")
## 群管理
```go
// 创建群组
group, err := client.CreateGroup(xuqmsdk.CreateGroupRequest{
Name: "项目讨论",
MemberIDs: []string{"user_001", "user_002"},
})
group, err := client.CreateGroup("项目讨论", []string{"user_001", "user_002"}, "NORMAL")
if err != nil {
log.Fatal(err)
}
// 添加群成员
err = client.AddGroupMember("group_xxx", "user_003")
// 移除群成员
err = client.RemoveGroupMember("group_xxx", "user_003")
// 获取群列表
groups, err := client.ListGroups()
// 获取群成员
members, err := client.ListGroupMembers("group_xxx")
```
@ -89,12 +108,7 @@ members, err := client.ListGroupMembers("group_xxx")
## Push 推送
```go
err := client.SendPush(xuqmsdk.SendPushRequest{
UserID: "user_001",
Title: "新消息",
Body: "你有一条未读消息",
Payload: map[string]string{"chatId": "user_002"},
})
err := client.SendPush("user_001", "新消息", "你有一条未读消息", `{"chatId":"user_002"}`)
```
---
@ -106,11 +120,10 @@ SDK 返回的错误实现了标准 `error` 接口。业务方可根据错误类
```go
msg, err := client.SendMessage(req)
if err != nil {
// 可断言为 *xuqmsdk.APIError 获取 HTTP 状态码和详细错误信息
if apiErr, ok := err.(*xuqmsdk.APIError); ok {
log.Printf("API 错误: status=%d, code=%d, message=%s", apiErr.Status, apiErr.Code, apiErr.Message)
}
}
```
[→ Server API 文档](/server/api)
[→ Server API 文档](/server/api)

查看文件

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

查看文件

@ -1,13 +1,18 @@
# Python Server SDK
XuqmGroup 服务端 Python SDK,提供 IM 消息发送、群管理、Push 推送等能力。
XuqmGroup 服务端 Python SDK,按照腾讯云服务端 API 的分类方式封装了:
- IM 账号、消息、群组、好友、会话、黑名单
- 管理端 Webhook、统计、操作日志
- Push 注册与推送
- Update 版本管理、RN Bundle、应用商店提审
---
## 安装
```bash
pip install xuqm-im-server-sdk
pip install xuqmgroup-server-sdk-python
```
---
@ -19,7 +24,7 @@ from xuqm_im_server_sdk import XuqmImServerSdk
sdk = XuqmImServerSdk(
base_url="https://dev.xuqinmin.com",
app_id="your_app_id",
app_key="your_app_key",
app_secret="your_app_secret",
)
```
@ -28,6 +33,24 @@ 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
@ -62,22 +85,15 @@ sdk.revoke_message("message_id")
## 群管理
```python
# 创建群组
group = sdk.create_group(
name="项目讨论",
member_ids=["user_001", "user_002"],
)
# 添加群成员
sdk.add_group_member("group_xxx", "user_003")
# 移除群成员
sdk.remove_group_member("group_xxx", "user_003")
# 获取群列表
groups = sdk.list_groups()
# 获取群成员
members = sdk.list_group_members("group_xxx")
```
@ -109,4 +125,4 @@ except XuqmAPIError as e:
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
content-type:application/json
{"appId":"ak_demo_chat","messageId":"...","toId":"user_002","chatType":"SINGLE","msgType":"TEXT","content":"Hello"}
{"appKey":"ak_demo_chat","messageId":"...","toId":"user_002","chatType":"SINGLE","msgType":"TEXT","content":"Hello"}
\x00
```
@ -104,7 +104,7 @@ SEND
destination:/app/chat.revoke
content-type:application/json
{"appId":"ak_demo_chat","messageId":"..."}
{"appKey":"ak_demo_chat","messageId":"..."}
\x00
```
@ -115,7 +115,7 @@ SEND
destination:/app/chat.sync
content-type:application/json
{"appId":"ak_demo_chat"}
{"appKey":"ak_demo_chat"}
\x00
```

查看文件

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

查看文件

@ -61,53 +61,87 @@ export interface UpdateServiceConfig {
defaultMarketUrl?: string
}
export interface PushVendorConfig {
export type PushVendorKey = 'huawei' | 'xiaomi' | 'oppo' | 'vivo' | 'honor' | 'harmony' | 'apns'
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
appKey?: string
appSecret?: string
}
export interface HuaweiPushVendorConfig {
appId?: string
appSecret?: string
}
export interface OppoPushVendorConfig {
appId?: string
appKey?: string
masterSecret?: string
}
export interface VivoPushVendorConfig {
appId?: string
appKey?: string
appSecret?: string
}
export interface HonorPushVendorConfig {
appId?: string
clientId?: string
clientSecret?: string
}
export interface HarmonyPushVendorConfig {
appId?: string
appSecret?: string
}
export interface ApnsPushVendorConfig {
teamId?: string
keyId?: string
bundleId?: string
keyPath?: string
privateKey?: string
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
category?: string
receiptId?: string
importance?: PushImportance
priority?: PushPriority
threadIdentifier?: string
interruptionLevel?: PushInterruptionLevel
badge?: boolean
sound?: boolean
vibration?: boolean
notifyType?: number
version?: number
remark?: string
}
export interface PushServiceConfig {
huawei?: PushVendorConfig
xiaomi?: PushVendorConfig
oppo?: PushVendorConfig
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'
schemaVersion?: number
updatedAt?: string
vendors?: PushVendorsConfig
profiles?: PushProfileConfig[]
}
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 StoreReviewState = 'PENDING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
export type StoreReviewState = 'PENDING' | 'SUBMITTING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
export type GrayMode = 'PERCENT' | 'MEMBERS'
export type GraySelectionSource = 'LOCAL' | 'CALLBACK'

查看文件

@ -0,0 +1,91 @@
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-item label="调用方式">POST 到你配置的回调地址`Content-Type: application/json`</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="请求头">`X-App-Key``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="签名">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`</el-descriptions-item>
<el-descriptions-item label="失败处理">回调发送失败只记录日志不会中断消息发送撤回等主流程</el-descriptions-item>

查看文件

@ -29,7 +29,7 @@
/>
<el-descriptions :column="1" border>
<el-descriptions-item label="调用方式">服务端以 `POST` 方式推送到你配置的回调地址</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="签名头">`X-App-Key``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="幂等建议">接收方建议按 `callbackId` 去重</el-descriptions-item>
</el-descriptions>
@ -244,4 +244,3 @@ onMounted(async () => {
await Promise.all([loadApp(), loadWebhooks()])
})
</script>

查看文件

@ -24,131 +24,171 @@
</el-card>
<el-alert
title="这里维护各厂商推送凭据。推送服务开通后,离线推送才会按厂商路由发送。"
title="新推送模型按 vendors + profiles 管理。厂商凭据只负责连厂商,profiles 负责按场景配置 Channel ID、Category、Importance、角标、声音、振动等。"
type="info"
:closable="false"
show-icon
style="margin-bottom:16px"
/>
<el-card>
<template #header>厂商配置</template>
<el-divider content-position="left">通知通道</el-divider>
<el-alert
description="通知通道定义 Android 通知渠道,以及 iOS/鸿蒙 对应的分类配置。业务键Key为业务层自定义的唯一标识,代码内部用于引用此通道,例如 im_message;Channel ID 需与 App manifest 中声明的 NotificationChannel ID 完全一致;版本号在渠道参数变更时自增,客户端将以新版本号重新注册渠道。"
type="info"
:closable="false"
:show-icon="false"
style="margin-bottom:12px"
/>
<el-table :data="pushConfig.channels" border style="margin-bottom:16px">
<el-table-column label="业务键" min-width="130">
<template #default="{ row }">
<el-input v-model="row.key" />
</template>
</el-table-column>
<el-table-column label="Channel ID" min-width="180">
<template #default="{ row }">
<el-input v-model="row.channelId" />
</template>
</el-table-column>
<el-table-column label="版本" width="82">
<template #default="{ row }">
<el-input-number v-model="row.version" :min="1" controls-position="right" style="width:72px" />
</template>
</el-table-column>
<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 }">
<el-select v-model="row.importance">
<el-option v-for="item in importanceOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="声音" width="80" align="center">
<template #default="{ row }"><el-switch v-model="row.sound" /></template>
</el-table-column>
<el-table-column label="振动" width="80" align="center">
<template #default="{ row }"><el-switch v-model="row.vibration" /></template>
</el-table-column>
<el-table-column label="角标" width="80" align="center">
<template #default="{ row }"><el-switch v-model="row.badge" /></template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button link type="danger" @click="removeChannel($index)">删除</el-button>
</template>
</el-table-column>
</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>
<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="120px">
<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="pushConfig[vendor.key][field.key]"
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="pushConfig[vendor.key][field.key]"
v-model="vendorModel(vendor.key)[field.key]"
/>
<el-input
v-else
v-model="pushConfig[vendor.key][field.key]"
type="text"
v-model="vendorModel(vendor.key)[field.key]"
:placeholder="field.placeholder"
/>
</el-form-item>
</el-form>
</el-card>
</div>
</el-card>
<el-card>
<template #header>
<div class="profiles-header">
<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
description="routeType 为空时表示默认兜底 profile。你可以先建 3 条,后续随时补充剩余的 2 条,或者删除不再使用的 profile。"
type="info"
:closable="false"
:show-icon="false"
style="margin-bottom:12px"
/>
<el-table :data="pushConfig.profiles" border style="margin-bottom:16px" class="profiles-table">
<el-table-column label="启用" width="78" align="center">
<template #default="{ row }">
<el-switch v-model="row.enabled" />
</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>
</el-table-column>
<el-table-column label="Channel ID" min-width="180">
<template #default="{ row }">
<el-input v-model="row.channelId" placeholder="厂商 Channel ID / 通知通道 ID" />
</template>
</el-table-column>
<el-table-column label="Category" min-width="160">
<template #default="{ row }">
<el-input v-model="row.category" placeholder="MESSAGE / SYSTEM / ..." />
</template>
</el-table-column>
<el-table-column label="重要性" width="120">
<template #default="{ row }">
<el-select v-model="row.importance">
<el-option v-for="item in importanceOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="优先级" width="120">
<template #default="{ row }">
<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 label="角标" width="78" align="center">
<template #default="{ row }">
<el-switch v-model="row.badge" />
</template>
</el-table-column>
<el-table-column label="声音" width="78" align="center">
<template #default="{ row }">
<el-switch v-model="row.sound" />
</template>
</el-table-column>
<el-table-column label="振动" width="78" align="center">
<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 }">
<el-button link type="danger" @click="removeProfile($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="toolbar">
<el-button @click="reloadConfig" :loading="loading">刷新</el-button>
@ -161,18 +201,37 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
appApi,
type App,
type FeatureService,
type PushNotificationChannelConfig,
type PushNotificationRouteConfig,
type ApnsPushVendorConfig,
type HarmonyPushVendorConfig,
type HonorPushVendorConfig,
type HuaweiPushVendorConfig,
type OppoPushVendorConfig,
type PushImportance,
type PushInterruptionLevel,
type PushPriority,
type PushProfileConfig,
type PushServiceConfig,
type PushVendorKey,
type XiaomiPushVendorConfig,
type VivoPushVendorConfig,
} from '@/api/app'
type VendorKey = keyof PushServiceConfig
type PushVendorsState = {
huawei: HuaweiPushVendorConfig
xiaomi: XiaomiPushVendorConfig
oppo: OppoPushVendorConfig
vivo: VivoPushVendorConfig
honor: HonorPushVendorConfig
harmony: HarmonyPushVendorConfig
apns: ApnsPushVendorConfig
}
type FieldDef = {
key: string
label: string
@ -180,13 +239,21 @@ type FieldDef = {
placeholder?: string
rows?: number
}
type VendorDef = {
key: VendorKey
key: PushVendorKey
label: string
hint: string
fields: FieldDef[]
}
type PushConfigState = {
schemaVersion: number
updatedAt: string
vendors: PushVendorsState
profiles: PushProfileConfig[]
}
const route = useRoute()
const app = ref<App | null>(null)
const services = ref<FeatureService[]>([])
@ -194,29 +261,31 @@ const loading = ref(false)
const saving = ref(false)
const isMobile = ref(window.innerWidth < 768)
const selectedPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
const platformOptions = [
{ label: 'Android', value: 'ANDROID' },
{ label: 'iOS', value: 'IOS' },
{ label: '鸿蒙', value: 'HARMONY' },
]
function updateViewport() {
isMobile.value = window.innerWidth < 768
}
const vendorOptions: Array<{ label: string; value: PushVendorKey }> = [
{ label: '华为', value: 'huawei' },
{ label: '小米', value: 'xiaomi' },
{ label: 'OPPO', value: 'oppo' },
{ label: 'vivo', value: 'vivo' },
{ label: '荣耀', value: 'honor' },
{ label: '鸿蒙', value: 'harmony' },
{ label: 'iOS', value: 'apns' },
]
const pushConfig = reactive<Required<PushServiceConfig>>({
huawei: { appId: '', appSecret: '', category: '' },
xiaomi: { appId: '', appKey: '', appSecret: '', channelId: '' },
oppo: { appId: '', appKey: '', masterSecret: '', channelId: '' },
vivo: { appId: '', appKey: '', appSecret: '', category: '', receiptId: '' },
honor: { appId: '', clientId: '', clientSecret: '' },
harmony: { appId: '', appSecret: '' },
apns: { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false },
fcm: { serviceAccountJson: '' },
channels: defaultChannels(),
routing: defaultRouting(),
} as Required<PushServiceConfig>)
const originalChannels = ref<PushNotificationChannelConfig[]>([])
const routeTypeOptions = [
'IM_MESSAGE',
'FRIEND_REQUEST',
'SYSTEM_NOTICE',
'SERVICE_NOTICE',
'MARKETING',
'DEFAULT',
]
const importanceOptions = [
{ label: '最小', value: 'MIN' },
@ -224,78 +293,83 @@ const importanceOptions = [
{ label: '默认', value: 'DEFAULT' },
{ label: '高', value: 'HIGH' },
{ label: '最高', value: 'MAX' },
] as const
] as const satisfies ReadonlyArray<{ label: string; value: PushImportance }>
const routeTypes = [
{ type: 'IM_MESSAGE', channel: 'im_message', category: 'MESSAGE', priority: 'HIGH' },
{ type: 'FRIEND_REQUEST', channel: 'im_message', category: 'SOCIAL', priority: 'HIGH' },
{ type: 'SYSTEM_NOTICE', channel: 'system_notice', category: 'SYSTEM', priority: 'DEFAULT' },
] as const
const priorityOptions = [
{ label: '低', value: 'LOW' },
{ label: '默认', value: 'DEFAULT' },
{ label: '高', value: 'HIGH' },
] as const satisfies ReadonlyArray<{ label: string; value: PushPriority }>
const routeRows = computed(() => routeTypes.map(item => ({
type: item.type,
route: pushConfig.routing[item.type] ?? ensureRoute(item.type, item.channel, item.category, item.priority),
})))
const interruptionOptions = [
{ label: 'Passive', value: 'passive' },
{ label: 'Active', value: 'active' },
{ 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[] = [
{
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',
label: '小米 MiPush',
hint: '填写 AppId / AppKey / AppSecret;Android 包名在应用设置中统一配置。Channel ID 为小米通知通道 ID如 118060。',
hint: '这里仅填写厂商凭据。Channel ID 放在 profiles 中按场景单独配置,支持后续补充或删除。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret' },
{ key: 'channelId', label: 'Channel ID', placeholder: '118060' },
],
},
{
key: 'oppo',
label: 'OPPO 推送',
hint: '填写 AppId / AppKey / MasterSecret;Channel ID 为 OPPO 推送通道 ID如 IM。',
key: 'huawei',
label: '华为 HMS',
hint: '仅保留厂商账号信息。Category、Channel ID、重要性等由 profiles 负责。',
fields: [
{ 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: 'category', label: 'Category', placeholder: 'IM' },
{ key: 'receiptId', label: '回执 ID', placeholder: '4470' },
],
},
{
key: 'honor',
label: '荣耀推送',
hint: '填写 AppId / ClientId / ClientSecret。',
label: '荣耀 Push',
hint: '荣耀账号凭据。业务场景与展示策略在 profiles 中配置。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'clientId', label: 'ClientId' },
{ 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',
label: '鸿蒙 Push Kit',
hint: '填写 HarmonyOS Push Kit AppId / AppSecret。',
hint: '鸿蒙厂商凭据。需要的 Category / Channel ID 放到 profiles 中。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appSecret', label: 'AppSecret' },
@ -303,13 +377,13 @@ const vendorDefs: VendorDef[] = [
},
{
key: 'apns',
label: 'APNsiOS',
hint: '填写 Team ID / Key ID / Bundle ID / p8 文件路径。',
label: 'iOS APNs',
hint: 'Team ID / Key ID / Bundle ID / 私钥统一放这里。Badge、Thread ID、Interruption Level 放在 profiles 中。',
fields: [
{ key: 'teamId', label: 'Team ID' },
{ key: 'keyId', label: 'Key ID' },
{ key: 'bundleId', label: 'Bundle ID' },
{ key: 'keyPath', label: 'p8 文件路径' },
{ key: 'privateKey', label: 'Private Key', type: 'textarea', rows: 6, placeholder: '粘贴 .p8 内容' },
{ key: 'sandbox', label: 'Sandbox', type: 'switch' },
],
},
@ -318,9 +392,123 @@ const vendorDefs: VendorDef[] = [
const pushEnabled = computed(() => services.value.some(
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() {
loading.value = true
try {
const id = route.params.appKey as string
const [appRes, svcRes] = await Promise.all([
appApi.get(id),
@ -333,58 +521,37 @@ async function loadData() {
selectedPlatform.value = firstPushService.platform
}
applySelectedPlatformConfig()
} finally {
loading.value = false
}
}
function applySelectedPlatformConfig() {
applyConfig(services.value.find(
const raw = services.value.find(
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value,
)?.config)
}
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 {}
}
)?.config
replaceState(parseConfig(raw))
}
async function saveConfig() {
if (!app.value) return
saving.value = true
try {
bumpChangedChannelVersions()
await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', toPushConfigRequest())
const payload: PushServiceConfig = {
schemaVersion: 2,
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('推送配置已保存')
await loadData()
} catch {
@ -394,143 +561,12 @@ async function saveConfig() {
}
}
function toPushConfigRequest(): Record<string, unknown> {
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 addProfile(vendor: PushVendorKey = 'xiaomi') {
pushConfig.profiles.push(createProfile(vendor))
}
function defaultChannels(): PushNotificationChannelConfig[] {
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
}
})
function removeProfile(index: number) {
pushConfig.profiles.splice(index, 1)
}
async function onTogglePushService(enable: boolean) {
@ -544,7 +580,7 @@ async function onTogglePushService(enable: boolean) {
cancelButtonText: '取消',
})
if (!app.value) return
await appApi.toggleService(app.value.id, servicePlatform.value, 'PUSH', false)
await appApi.toggleService(app.value.id, selectedPlatform.value, 'PUSH', false)
ElMessage.success('已关闭')
await loadData()
}
@ -564,11 +600,20 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
</script>
<style scoped>
.mono { font-family: monospace; font-size: 12px; }
.hint { font-size: 12px; color: #909399; }
.mono {
font-family: monospace;
font-size: 12px;
}
.hint {
font-size: 12px;
color: #909399;
}
.info-card :deep(.el-descriptions__body) {
overflow-x: auto;
}
.push-switch-row {
margin-top: 12px;
display: flex;
@ -576,20 +621,38 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
flex-wrap: wrap;
align-items: center;
}
.vendor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.vendor-card {
min-height: 100%;
}
.vendor-hint {
margin-bottom: 12px;
font-size: 12px;
color: #909399;
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 {
margin-top: 16px;
display: flex;
@ -597,6 +660,12 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
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) {
.vendor-grid {
grid-template-columns: 1fr;
@ -607,7 +676,8 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
align-items: flex-start;
}
.toolbar :deep(.el-button) {
.toolbar :deep(.el-button),
.profiles-actions :deep(.el-button) {
width: 100%;
}

查看文件

@ -1,5 +1,10 @@
<template>
<div>
<div
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="handleDrop"
>
<div v-if="isServicesPortal" class="portal-bar">
<span class="portal-bar-title">版本管理</span>
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
@ -41,7 +46,8 @@
</el-table-column>
<el-table-column label="应用商店" width="220" show-overflow-tooltip>
<template #default="{row}">
<template v-if="parseStoreReview(row.storeReviewStatus).length">
<div v-if="parseStoreReview(row.storeReviewStatus).length" class="store-review-cell">
<div class="store-review-tags">
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
<el-tooltip
v-if="item.state === 'REJECTED' && item.reason"
@ -61,7 +67,15 @@
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
</template>
</template>
</div>
<el-button
link
type="primary"
size="small"
class="store-review-detail-btn"
@click="openStoreReviewDetail(row)"
>查看详情</el-button>
</div>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
@ -480,6 +494,81 @@
</template>
</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 -->
<el-dialog
v-model="showStoreConfig"
@ -657,12 +746,20 @@
</template>
</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>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
@ -681,6 +778,7 @@ import {
type StoreConfig,
type StoreType,
} from '@/api/update'
import { connectStoreReviewRealtime, disconnectStoreReviewRealtime } from '@/services/storeReviewRealtime'
import huaweiGuideImage from '@/assets/update-store/huawei/01.png'
import miGuideImage from '@/assets/update-store/mi/01.png'
import oppoGuideImage from '@/assets/update-store/oppo/01.png'
@ -697,6 +795,54 @@ const pageTitle = computed(() => app.value?.name ?? appKey)
const isMobile = ref(false)
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 storeTab = ref<'configs' | 'guide'>('configs')
const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
@ -742,6 +888,7 @@ const operationLogs = ref<{
createdAt: string
}[]>([])
const loadingOperationLogs = ref(false)
let storeReviewReloadTimer: ReturnType<typeof setTimeout> | null = null
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
@ -1135,6 +1282,10 @@ const selectedStores = ref<StoreType[]>([])
const submitStoreMode = ref<PublishMode>('MANUAL')
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) {
submitStoreVersion.value = row
selectedStores.value = enabledStores.value.map(s => s.type)
@ -1143,6 +1294,22 @@ function openSubmitStoreDialog(row: AppVersion) {
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() {
if (!submitStoreVersion.value || !selectedStores.value.length) {
ElMessage.warning('请选择至少一个可用市场')
@ -1661,11 +1828,11 @@ function storeLabel(type: string) {
}
function reviewLabel(state: string): string {
return { PENDING: '待提交', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
return { PENDING: '待提交', SUBMITTING: '提交中', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
}
function reviewTagType(state: string): string {
return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
return { PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
}
function operationResourceLabel(resourceType: string) {
@ -1685,6 +1852,15 @@ function operationActionLabel(action: string) {
SAVE_DRAFT: '保存草稿',
UNPUBLISH: '下架',
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: '审核回写',
GRAY_UPDATE: '灰度配置',
AUTO_PUBLISH: '自动发布',
@ -1722,7 +1898,17 @@ async function loadOperationLogs() {
}
}
function parseStoreReview(json?: string): { store: string; state: string; reason?: string }[] {
function scheduleStoreReviewReload() {
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 []
try {
const m = JSON.parse(json) as Record<string, unknown>
@ -1736,6 +1922,10 @@ function parseStoreReview(json?: string): { store: string; state: string; reason
store,
state: String(item.state ?? ''),
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: '' }
@ -1785,10 +1975,22 @@ onMounted(() => {
loadStoreConfigs()
loadPublishConfig()
loadOperationLogs()
void connectStoreReviewRealtime(appKey, () => {
scheduleStoreReviewReload()
}).catch((error) => {
if (import.meta.env.DEV) {
console.warn('[tenant-platform] store review realtime unavailable', error)
}
})
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
disconnectStoreReviewRealtime()
if (storeReviewReloadTimer) {
clearTimeout(storeReviewReloadTimer)
storeReviewReloadTimer = null
}
})
</script>
@ -2013,6 +2215,24 @@ onBeforeUnmount(() => {
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,
.guide-grid {
grid-template-columns: 1fr;
@ -2046,4 +2266,36 @@ onBeforeUnmount(() => {
font-size: 16px;
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>

查看文件

@ -1172,6 +1172,11 @@
dependencies:
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:
version "2.0.0"
resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz"