# 华为统一推送(HMS Push)服务端集成方案 > pi(API 维护方)编写 | 2026-09-07 --- ## 一、背景 当前 Gateway 通过 SSE(Server-Sent Events)向在线客户端实时推送新邮件。但当客户端在后台或被系统杀死时,SSE 断开,用户无法收到新邮件通知。华为鸿蒙统一推送(HMS Push)可在设备关闭应用时仍推送通知——点击通知打开应用并跳转对应邮件。 **目标**:Gateway 在 SSE 之外增加推送管道,当 `new_mail` 到达时,对所有已注册推送 token 的设备发送 HMS Push 通知。 --- ## 二、HMS Push API 工作方式 ``` 客户端 (鸿蒙) 服务端 (Gateway) 华为 Push API │ │ │ │── 注册 HMS Push,获取 token ──→ │ │ │ │ │ │── POST /me/devices/push-token ──→│ 存储 token │ │ │ │ │ ...应用关闭/进入后台... │ │ │ │ │ │ ← new_mail SSE 断开 ────────── │ │ │ │ │ │ │── 调用 HMS Push API ────────→│ │ │ (POST v1/messages:send) │ │ │ │ │ │ ← 推送到设备 ──│ │ ← 通知栏弹出 ────────────────── │ │ │── 用户点击通知 ──→ 打开应用 ──────│ │ │ ``` **HMS Push API 核心接口**: ``` POST https://push-api.cloud.huawei.com/v1/{app_id}/messages:send Authorization: Bearer {access_token} Body: { "validate_only": false, "message": { "token": ["push_token_1", ...], "notification": { "title": "新邮件:{subject}", "body": "发件人:{from_name}" }, "data": { "mail_id": "...", "session_id": "...", "action": "open_mail" } } } ``` **认证**:需要华为开发者账号的 App ID + App Secret → 获取 access_token(有效期 1 小时,需刷新)。 --- ## 三、服务端架构 ### 3.1 数据模型 新增 `push_tokens` 表(SQLite + PostgreSQL 通用): ```sql CREATE TABLE push_tokens ( user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, push_token TEXT NOT NULL, -- HMS Push token(设备唯一) platform TEXT NOT NULL DEFAULT 'harmonyos', -- 标识来源(未来可扩展) device_id TEXT NOT NULL DEFAULT '', -- 客户端生成的设备唯一 ID created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (user_id, push_token) ); ``` - 每个用户可注册多个设备(手机、平板) - 同一设备的 token 更新时 upsert(`device_id` 去重) - 用户删除时级联清理 ### 3.2 API 端点 ``` POST /me/devices/push-token 注册/更新推送 token DELETE /me/devices/push-token 注销推送 token GET /me/devices/push-tokens 查询已注册的设备列表 ``` 请求体(注册): ```json { "push_token": "CAHPxxx...", "device_id": "device-uuid-xxx", "platform": "harmonyos" } ``` ### 3.3 推送触发点 在 `notify/mail.go` 的 `Recipients()` 函数末尾增加推送调用: ```go // 现有 SSE 推送 sse.Default.SendToRecipient(m.To.Name, "new_mail", payload(...)) // 新增:HMS Push 推送(异步,不阻塞 SSE) go pushToHMS(m.To.Name, payload(...)) ``` `pushToHMS` 逻辑: 1. 查询该用户的所有 `push_tokens` 2. 组装 HMS Push 请求(title + body + data payload) 3. 调用 HMS Push API(带 access_token 刷新逻辑) 4. 失败静默(SSE 已保证在线设备收到,Push 是补充通道) ### 3.4 access_token 管理 HMS Push API 需要 OAuth 2.0 access_token(有效期 ~3600s): ```go type HMSTokenManager struct { appID string appSecret string token string expiresAt time.Time } func (m *HMSTokenManager) getToken() (string, error) { if time.Now().Before(m.expiresAt) { return m.token, nil } // POST https://oauth-login.cloud.huawei.com/oauth2/v3/token // grant_type=client_credentials&client_id=appID&client_secret=appSecret // ... return newToken, nil } ``` ### 3.5 推送内容模板 ``` 标题:新邮件 正文:{from_name}:{subject} 数据:{ mail_id, session_id, from_name, subject, action: "open_mail" } ``` 客户端收到通知后,打开应用并路由到对应邮件详情页。 --- ## 四、配置项 ```bash # HMS Push 配置(环境变量) HMS_APP_ID=10xxxxxx # 华为开发者 App ID HMS_APP_SECRET=xxxxxx # App Secret HMS_ENABLED=true # 开关(false 时只走 SSE 不推 HMS) ``` `HMS_ENABLED=false` 时 `pushToHMS` 直接 return,零开销。 --- ## 五、鸿蒙客户端侧需要做的事 1. **集成 HMS Push SDK**:`@hms.push` 包 2. **注册 token**:App 启动时调用 HMS Push 获取 token → `POST /me/devices/push-token` 3. **接收通知**:收到通知后解析 `data` 字段 → 路由到对应邮件 4. **点击通知**:`action: "open_mail"` → 跳转到 `mail_id` 对应的邮件详情 --- ## 六、时机与限制 | 场景 | 行为 | |---|---| | 客户端在线(SSE 连接) | SSE 已推送,**Push 不发**(避免重复通知) | | 客户端后台/SSE 断开 | Push 发送,唤醒设备 | | 用户已禁用通知 | 服务端不知情,Push 发送但被系统拦截(正常行为) | | 同一用户多设备 | 所有注册的 token 都推送 | | 推送频率限制 | HMS Push 免费版有 QPS 限制(按 App 而定),大量并发时需队列化 | **SSE 与 Push 的切换判断**:最简实现是**无条件推送**(SSE 在线时 Push 也会发,但客户端收到 SSE 后可以忽略 Push)。如果要精确判断,客户端在线时上报一个 `push_active` 标记,服务端据此跳过。但最简方案更可靠——依赖客户端状态判断有延迟风险。 --- ## 七、鸿蒙端集成要点(供 dsh 参考) ```typescript // 鸿蒙端伪代码 import push from '@hms.push'; // App 启动时 async function registerPushToken() { const token = await push.getToken({ scope: 'message' }); if (token) { await fetch(`${gateway}/me/devices/push-token`, { method: 'POST', headers: { Authorization: `Bearer ${userKey}` }, body: JSON.stringify({ push_token: token, device_id: deviceId }) }); } } // 收到推送 push.onMessage((data) => { if (data.action === 'open_mail') { navigateToMail(data.mail_id); } }); ``` --- ## 八、实施阶段 | 阶段 | 内容 | 估时 | |---|---|---| | **P1** | `push_tokens` 表 + API 端点 + HMS access_token 管理 | 1 天 | | **P2** | `notify/mail.go` 接入 + 推送触发逻辑 | 0.5 天 | | **P3** | 鸿蒙客户端集成(dsh 侧) | 由 dsh 实施 | | **P4** | 测试 + 边界(token 刷新失败、推送频率、多设备) | 0.5 天 | 总计 Gateway 侧 **1.5 天**,可与多账号方案并行推进。 --- # 实现状态(2026-09-15) 本节是**交付后的事实记录**:哪些验过、哪些没验过、还有哪些前提条件。 (本文件上半部分写于 09-07,是设计构想;实现按下面的口径做了修正。) ## 一、设计口径的变化:从「HMS 专用」改成「多厂商配置式」 用户 2026-09-15 的两条要求把设计定死了: 1. 「推送密钥应当是可选项」「不能写死推送方式,因为我们是自部署后端」 2. 「我们要支持多厂商配置式接入统一推送服务,推送密钥(文件)应当是配置项, 用户可为不同的推送服务配置对应的凭证,因为我们的设计中是每个用户各自部署服务器」 落地形状(`internal/push`): | 关注点 | 实现 | |---|---| | 可选 | 没配任何通道 → 整条推送路径连一次查库都不发生(`shouldDispatch` 早退) | | 多厂商 | `Notifier` 接口 + `RegisterType()` 工厂表;一个实例可同时接多个厂商 | | 配置式 | `PUSH_CONFIG`(默认 `/push.json`),providers 是一张表 | | 凭证是配置项 | `app_secret_file` / `files.*` 指向密钥文件;内联 `app_secret` 仅是便利 | | 单项配错不致命 | 未知类型 / 密钥读不到 / `enabled:false` → 只跳过那一条,网关照常启动 | | provider 维度 | 表 `push_tokens` 与三个端点都带 `provider`,客户端按名字登记 | 端点(都需要登录,与 `/me/*` 同一套鉴权): ``` POST /api/v1/me/devices/push-token {provider, token, device_name?, session_id?} DELETE /api/v1/me/devices/push-token {provider, token} GET /api/v1/me/devices/push-token → {enabled, providers[], tokens[{token_tail…}]} ``` 没配推送时 POST 仍返回 200 并**照存**,响应里 `enabled:false`: 客户端据此知道「登记成功了,但服务端现在没开推送」,而不是把收不到通知当成登记失败。 ## 二、HMS 的实际 API 形状(探针实测,不是照文档抄的) 拿真凭证对线上服务打了三种形状: | 试法 | 服务端回答 | 结论 | |---|---|---| | `POST /v1/{appId}/messages:send` + `message.token[]`/`notification` + `testMessage` | `{"code":"80300007","msg":"All the tokens are invalid"}` | ✅ 形状被接受(只是假 token 无效) | | 同端点 + `payload`/`target` | `{"code":"80300010","msg":"token count should within 1 and 1,000"}` | ❌ v1 不认这种形状 | | `POST /v2/{appId}/messages:send` + `payload`/`target` | `{"code":"80200001","msg":"Authentication Error"}` | ❌ v2 要服务账号 JWT(未实现) | 认证:`POST https://oauth-login.cloud.huawei.com/oauth2/v3/token`(client_credentials), 实测换到 access_token(有效期 3600s)✓。服务端缓存并提前 5 分钟刷新。 **未上架应用必须 `test_message: true`**(用户提供的信息):不开的话未上架应用限到约 2 条/天/设备;测试消息额度是**项目级 1000 条/天**,单次最多 10 个 token (10 由 `MaxTokensPerRequest()` 声明,分批由 `push.dispatch` 执行)。应用上架后改 `test_message: false`。 ## 三、验过的 / 没验过的(不夸大) **验过的:** - 凭证真的能认证:App ID + App Secret → access_token(线上,3600s)✓ - 推送请求的形状被线上服务接受(上表第一行)✓ - 「没配通道 = 零开销」:把 `db.DB` 置 nil(任何查库都会 panic)后调用 `NotifyNewMail` 仍不碰库 ✓,且判据做了**变异验证**(去掉早退立刻红) - 「单项配错不致命」「密钥文件优先」「enabled:false 生效」「环境变量只是覆盖」 等配置语义 ✓(同样做了变异验证 —— 过程中抓出两条**假判据**并补掉) - 「没配推送时端点照常可用」:handler 层判据 ✓ - Go 全量测试 ✓;`go vet` 干净 ✓ **没验过的(必须说清楚):** - ★ **端到端真机送达从未验证过**:需要 (a) 真机产出的 push token,(b) 客户端按新包名 重编**并签名**,(c) 该签名指纹在 AGC 登记过。本机一个都不具备 ⇒ 从未真正发出过 一条能到达设备的推送。上面的「形状被接受」不等于「设备收得到」。 - 成功码 `80000000` 来自 API 约定(失败码是实测的),成功路径无法在本机验证。 - 响应里的 `illegal_tokens` 字段(用于部分无效时清理)未实测,实现是防御式的。 - v2 端点 / 服务账号密钥鉴权**未实现**。 - 客户端半边(Push Kit 取 token、上报、点通知跳转)由 dsh 实施,尚未完成。 ## 四、两个前提条件(阻碍"真的能收到") 1. **包名必须改**:AGC 拒绝 `com.agentmail.harmony`(保留字 `harmony`,实测), 已按用户决定改为 **`com.jianf.agentmail`**,客户端 `AppScope/app.json5` 要同步改。 2. **签名与指纹**:设备的包名 + 签名必须与 AGC 登记的一致。AGC 应用页有 「SHA256证书/公钥指纹:添加公钥指纹」。之前交付的 HAP 是 **unsigned** 的 —— 推送要真的送达,客户端需要用 AGC 里登记的证书签名(或把实际使用的签名指纹登进 AGC)。 这一条我这边无法代做(涉及你的签名材料)。 ## 五、一次完整接入的步骤(自部署用户视角) ```bash # 1) 在厂商后台建应用,拿到 app_id / 密钥(华为:AGC → 项目设置 → 常规 → 应用) # 2) 把密钥写成文件并收紧权限 install -m 600 /dev/null /etc/agentmail/hms-app-secret printf '%s' '' > /etc/agentmail/hms-app-secret # 3) 配置通道(照 deploy/push.json.example 改) cp deploy/push.json.example /opt/agentmail/data/push.json # 或 PUSH_CONFIG 指向别处 # 4) 重启后看一行日志确认通道启用/未启用 systemctl restart agentmail-gateway && journalctl -u agentmail-gateway | grep '\[push\]' ``` 启动日志会明确说出结果:`通道已启用: hms(单批最多 10 个 token)` 或 `未配置推送通道 —— 正常状态,SSE 仍是收信主通道`;配置有错时逐条说明跳过了哪一项、为什么。