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

13 KiB
Raw Blame History

华为统一推送(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 通用):

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   查询已注册的设备列表

请求体(注册):

{
  "push_token": "CAHPxxx...",
  "device_id": "device-uuid-xxx",
  "platform": "harmonyos"
}

3.3 推送触发点

在 notify/mail.go 的 Recipients() 函数末尾增加推送调用:

// 现有 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):

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" }

客户端收到通知后,打开应用并路由到对应邮件详情页。


四、配置项

# 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 参考)

// 鸿蒙端伪代码
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)。 这一条我这边无法代做(涉及你的签名材料)。

五、一次完整接入的步骤(自部署用户视角)

# 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 仍是收信主通道;配置有错时逐条说明跳过了哪一项、为什么。