Files
MailUI4Agents/docs/HMS-PUSH-PLAN.md
JianFeeeee 46fa7fa729 feat(push): 可选、配置式、多厂商的推送通道(HMS 为首个实现)
用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持
多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。
所以落地成:

· internal/push:通道抽象 + 工厂表(RegisterType),加厂商不改配置层与端点形状;
  HMS 只是第一个实现(internal/push/hms.go)
· 配置在 PUSH_CONFIG(默认 <AGENTMAIL_DATA_DIR>/push.json),一项一个厂商,
  凭证走文件(app_secret_file / files.*,建议 600);环境变量只是可选覆盖
· 没配 = 整条推送路径连一次查库都不发生(shouldDispatch 早退);
  单项配错(未知类型/密钥读不到/enabled:false)只跳过那一条,不影响启动
· push_tokens 表带 provider 维度 + 三个 /me/devices/push-token 端点;
  没配推送时端点照存并回 enabled:false(登记成功 != 服务端开了推送)
· notify.Recipients 末尾异步挂钩:收件人名单直接用 SSE 那份 seen(两条通道
  共用同一份"谁该收到"的判据);失败只记日志,绝不拖住收信

HMS 的形状是拿真凭证打线上接口问出来的(v1 + message.token[] + testMessage;
payload/target 形状 v1 不认、v2 要服务账号 JWT)。未上架应用必须 test_message=true,
单批 ≤10 token(MaxTokensPerRequest 声明)、每日 1000 条兜底(项目级额度)。
实测:App ID + App Secret 能换到 access_token(3600s);形状被线上服务接受。

判据:repo 6 条 + push 12 条 + handler 3 组,全部做过**变异验证** ——
过程中抓出两条假判据(异步分发与 t.Cleanup 赛跑而假绿;密钥文件优先级没被覆盖)
并补掉。Go 全量测试与 go vet 干净。

★ 未验:端到端真机送达(需要真机 token + 客户端按 com.jianf.agentmail 重编并签名,
签名指纹还要在 AGC 登记)—— 从未真正发出过一条能到达设备的推送。
详见 docs/HMS-PUSH-PLAN.md 的「实现状态」一节。
2026-09-15 11:21:00 +08:00

330 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 华为统一推送(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`(默认 `<AGENTMAIL_DATA_DIR>/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' '<App Secret>' > /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 仍是收信主通道`;配置有错时逐条说明跳过了哪一项、为什么。