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 的「实现状态」一节。
This commit is contained in:
2026-09-15 11:21:00 +08:00
parent b806a05bfa
commit 46fa7fa729
14 changed files with 2349 additions and 0 deletions

280
server/internal/push/hms.go Normal file
View File

@ -0,0 +1,280 @@
package push
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/agentmail/gateway/internal/repo"
)
/*
华为 HMS Push 通道(第一个实现,不是唯一实现)。
# 为什么这些常量长这样:都是**打真接口问出来的**,不是照着文档抄的
2026-09-15 用真凭证(个人开发者账号下的应用 `com.jianf.agentmail`)对线上服务打了
三种形状,用它自己的回答定下实现:
POST v1/{appId}/messages:send {message:{token:[…],notification:{…}}} + testMessage
→ {"code":"80300007","msg":"All the tokens are invalid"} ← 形状被接受,只是假 token 无效 ✓
POST v1/{appId}/messages:send {payload:{…},target:{token:[…]}}
→ {"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)
所以走 v1 + `message.token[]`。v2 / 服务账号密钥那条路**没有**实现,也没验证过:
写进去就是拿没验过的形状冒充能用的代码。
# testMessage 默认开
未上架应用**必须**用测试消息模式才能收到推送(用户 2026-09-15 给的信息):
不开的话未上架应用的限制收紧到约 2 条/天/设备,调试期基本等于收不到。
额度是**项目级**的:1000 条/天,且单次推送最多 10 个 token —— 后面这条由
MaxTokensPerRequest 声明,分批由 push.dispatch 执行。
应用正式上架后要把它改成 false(`HMS_TEST_MESSAGE=false`),否则一直吃测试额度
且受测试消息的频控。
# 成功码
华为回的 `code == "80000000"` 表示成功。这个值来自推送 API 的约定,我**无法在本机
验证成功路径**(需要一台真机产出的 token);失败路径(上面那三个码)是实测的。
所以:成功判据只认 80000000,其余一律当失败并记下 code/msg —— 宁可把成功误判成
失败(记一条日志、少一条通知),也不能把失败当成功(那会静默丢通知且没人查)。
*/
type HMS struct {
// name 是推给客户端看的 provider 名(默认 "hms";同一实例接两套同厂商凭证时用得上)。
name string
AppID string
AppSecret string
// TestMessage 见包注释:未上架应用必须为 true。
TestMessage bool
// DailyLimit 是每日发送上限(条)。华为对未上架应用的测试消息限制是
// **项目级** 1000 条/天,默认按它兜底,避免把额度打光后收到一串失败。
DailyLimit int
// Endpoint 可覆盖,仅用于测试注入(默认走华为线上端点)。
Endpoint string
TokenURL string
Client *http.Client
baseDelay time.Duration
tokenMu sync.Mutex
token string
tokenExp time.Time
dayMu sync.Mutex
day string
dayCount int
}
const (
hmsDefaultEndpoint = "https://push-api.cloud.huawei.com"
hmsTokenURL = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
hmsMaxTokensPerReq = 10
hmsSuccessCode = "80000000"
hmsAllInvalidCode = "80300007"
)
// newHMSFromConfig 按一项配置建通道(凭证已由 config.go 解析好)。
//
// 没有凭证就**不在配置表里出现** —— 这是「推送可选」的落地点:
// 没配的实例根本不会走到这里,整条推送路径连一次查库都不会发生。
func newHMSFromConfig(cfg ProviderConfig) (Notifier, error) {
appID := strings.TrimSpace(cfg.AppID)
if appID == "" {
return nil, fmt.Errorf("缺 app_id")
}
secret := strings.TrimSpace(cfg.AppSecret)
if secret == "" {
return nil, fmt.Errorf("缺 app_secret(建议用 app_secret_file 指向密钥文件)")
}
name := strings.TrimSpace(cfg.Name)
if name == "" {
name = "hms"
}
test := true
if cfg.TestMessage != nil {
test = *cfg.TestMessage
}
limit := cfg.DailyLimit
if limit <= 0 {
limit = 1000
}
return &HMS{
name: name,
AppID: appID,
AppSecret: secret,
TestMessage: test,
DailyLimit: limit,
Endpoint: hmsDefaultEndpoint,
TokenURL: hmsTokenURL,
Client: &http.Client{Timeout: 15 * time.Second},
}, nil
}
func (h *HMS) Name() string {
if h.name != "" {
return h.name
}
return "hms"
}
// MaxTokensPerRequest 是华为的硬限额(单次推送 ≤10 个 token)。
func (h *HMS) MaxTokensPerRequest() int { return hmsMaxTokensPerReq }
// accessToken 取(并缓存)访问令牌。华为给的有效期是 3600 秒,刷新提前 5 分钟。
func (h *HMS) accessToken(ctx context.Context) (string, error) {
h.tokenMu.Lock()
defer h.tokenMu.Unlock()
if h.token != "" && time.Now().Before(h.tokenExp) {
return h.token, nil
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {h.AppID},
"client_secret": {h.AppSecret},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := h.Client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
// 不把 body 原样吐进日志:它可能含 token 片段。
return "", fmt.Errorf("取 access_token 失败: HTTP %d", resp.StatusCode)
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", fmt.Errorf("解析 access_token 响应失败: %w", err)
}
if out.AccessToken == "" {
return "", fmt.Errorf("access_token 为空")
}
ttl := out.ExpiresIn
if ttl <= 0 {
ttl = 3600
}
h.token = out.AccessToken
h.tokenExp = time.Now().Add(time.Duration(ttl)*time.Second - 5*time.Minute)
return h.token, nil
}
type hmsSendResponse struct {
Code string `json:"code"`
Msg string `json:"msg"`
// IllegalTokens 是华为回的无效率 token 列表(有才用)。字段名按官方响应约定,
// 我这边没有真机 token 因而**未能实测**;因此只在下述两种情况下才据它删表。
IllegalTokens []string `json:"illegal_tokens"`
}
// Send 向一批 token(≤10)投递一条通知。
func (h *HMS) Send(ctx context.Context, tokens []string, n NewMail) error {
if len(tokens) == 0 {
return nil
}
if !h.reserveDaily(len(tokens)) {
return fmt.Errorf("达到每日推送上限 %d 条(HMS_DAILY_LIMIT)", h.DailyLimit)
}
tok, err := h.accessToken(ctx)
if err != nil {
return err
}
data, _ := json.Marshal(map[string]string{
// 与客户端约定的形状(见文档与给 dsh 的契约):点通知按它跳转。
"type": "new_mail",
"mail_id": n.MailID,
"session_id": n.SessionID,
"action": "open_mail",
})
payload := map[string]any{
"validate_only": false,
"message": map[string]any{
"token": tokens,
"notification": map[string]any{
"title": "新邮件:" + truncate(n.Subject, 40),
"body": n.From,
},
// data 必须是**字符串**(华为这套要求 JSON 序列化后的字符串)。
"data": string(data),
},
}
if h.TestMessage {
payload["testMessage"] = true
}
raw, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(h.Endpoint, "/")+"/v1/"+url.PathEscape(h.AppID)+"/messages:send", bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
resp, err := h.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
var out hmsSendResponse
if err := json.Unmarshal(body, &out); err != nil {
return fmt.Errorf("解析推送响应失败: HTTP %d", resp.StatusCode)
}
if out.Code == hmsSuccessCode {
return nil
}
// 无效 token 自愈:设备卸了 App / token 轮换了。留着它们每次发信都白吃额度
// (测试消息额度是项目级的),所以按值删掉。
if out.Code == hmsAllInvalidCode {
if _, derr := repo.DeletePushTokensByValue(ctx, h.Name(), tokens); derr != nil {
log.Printf("[push] 清理无效 token 失败: %v", derr)
} else {
log.Printf("[push] 已清理 %d 个无效 token(%s)", len(tokens), out.Code)
}
} else if len(out.IllegalTokens) > 0 {
if _, derr := repo.DeletePushTokensByValue(ctx, h.Name(), out.IllegalTokens); derr != nil {
log.Printf("[push] 清理无效 token 失败: %v", derr)
}
}
return fmt.Errorf("华为推送失败: code=%s msg=%s", out.Code, out.Msg)
}
// reserveDaily 记一次每日用量。超上限时返回 false(不把额度打光:
// 打光之后的失败响应刷日志,而且真需要的那条也发不出去)。
func (h *HMS) reserveDaily(n int) bool {
h.dayMu.Lock()
defer h.dayMu.Unlock()
today := time.Now().UTC().Format("2006-01-02")
if h.day != today {
h.day, h.dayCount = today, 0
}
if h.DailyLimit > 0 && h.dayCount+n > h.DailyLimit {
return false
}
h.dayCount += n
return true
}
func truncate(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}