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

View File

@ -0,0 +1,304 @@
package push
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
/*
推送通道的**配置式**接入(2026-09-15 用户的第二条要求)。
原话:「我们要支持多厂商配置式接入统一推送服务,推送密钥(文件)应当是配置项,
用户可为不同的推送服务配置对应的凭证,因为我们的设计中是每个用户各自部署服务器」。
所以契约是这样定的:
- **多厂商**:配置里是一张表,每一项是一个厂商(`type`)。一个实例可以同时接
HMS 和小米/Web Push/任何东西 —— 只要那个厂商在 factories 里注册过实现。
- **配置式**:加厂商不改代码路径,只加一个 `Factory` 实现 + 一行 `RegisterType`;
用户侧只改配置文件,不重编译。
- **凭证/密钥文件是配置项**:`app_secret_file` 指向密钥文件(推荐),也接受内联
`app_secret`(图省事/做实验);`client_config_file` 指向厂商给的客户端配置
(如华为的 agconnect-services.json)—— 这类文件属于**部署物料**,由部署者提供。
- **谁部署谁配**:每个用户自己部署服务端、自己选厂商、自己填凭证。没配 = 推送
不可用,但服务端一切照常(这就是「可选」的落地)。
配置文件位置:`PUSH_CONFIG` 指定;默认 `<AGENTMAIL_DATA_DIR>/push.json`。
文件不存在**不是错误**(自部署实例默认就没配推送)。
# 单项配错不能拖垮整个服务
某一条配置写坏(类型未知、密钥文件读不到、JSON 写错)时:**只跳过那一条**并打印
一条明确的日志,其余条目照常启用,网关照常启动。理由很直接:推送是可选旁路,
它不该有能力让整个邮件服务起不来 —— 那是把"锦上添花"变成了"单点故障"。
*/
// ProviderConfig 是配置里的一项:一个推送厂商 + 它自己的凭证。
type ProviderConfig struct {
// Type 是厂商实现名("hms"、"webpush"…),必须已 RegisterType。
Type string `json:"type"`
// Name 覆盖推送给客户端看的 provider 名(默认 = Type)。
// 用途:同一个实例接两套同厂商凭证(例如两个应用)时区分开来,
// 客户端登记 token 时用的就是这个值。
Name string `json:"name"`
// Enabled 缺省视为 true;显式 false = 留配置但不启用。
Enabled *bool `json:"enabled"`
// AppID / AppSecret 是厂商的凭证。AppSecret 建议走 AppSecretFile。
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
// AppSecretFile 指向**存放密钥的文件**(配置项,不是硬编码)。
AppSecretFile string `json:"app_secret_file"`
// ClientConfigFile 指向厂商给的客户端配置文件(如 agconnect-services.json)。
// 服务端用它核对 app_id/package_name 是否与客户端一致——不一致的推送永远送不到,
// 而症状会表现为"推送静默失效",所以这里宁可启动时就说清楚。
ClientConfigFile string `json:"client_config_file"`
// Files 是**厂商自定义的文件类配置**(键名由厂商实现定义)。
//
// 为什么要有它:不同厂商的密钥形状本就不同(华为是 app_secret,
// Web Push 是 VAPID 密钥对,有的用服务账号 JSON…)。给每个厂商加一个专用字段
// 会让配置层随厂商数量膨胀;一张「名字 → 文件路径」的表则不用改配置层就能接新厂商。
//
// 例:{"app_secret": "/etc/agentmail/hms.secret", "vapid_private_key": "/etc/agentmail/vapid.pem"}
Files map[string]string `json:"files"`
// TestMessage 见 hms.go:未上架应用必须为 true。缺省 true。
TestMessage *bool `json:"test_message"`
// DailyLimit 每日发送上限(条),0 = 用实现的默认值。
DailyLimit int `json:"daily_limit"`
}
// Factory 按配置造一个通道。凭证已在这之前解析好(见 resolveSecret)。
type Factory func(cfg ProviderConfig) (Notifier, error)
var factories = map[string]Factory{}
// RegisterType 注册一个厂商实现。加厂商 = 加一个实现 + 一行这个调用。
func RegisterType(name string, f Factory) {
factories[name] = f
}
func init() {
RegisterType("hms", newHMSFromConfig)
}
// KnownTypes 列出已注册的厂商类型(日志与文档用)。
func KnownTypes() []string {
out := make([]string, 0, len(factories))
for k := range factories {
out = append(out, k)
}
return out
}
type configFile struct {
Providers []ProviderConfig `json:"providers"`
}
// configPath 返回配置文件路径。
func configPath() string {
if p := strings.TrimSpace(os.Getenv("PUSH_CONFIG")); p != "" {
return p
}
dir := strings.TrimSpace(os.Getenv("AGENTMAIL_DATA_DIR"))
if dir == "" {
dir = "data"
}
return filepath.Join(dir, "push.json")
}
// LoadProviders 读配置并造出所有启用的通道。
//
// 单项失败只跳过该项(见包注释):返回值可能少于配置里的条目数。
func LoadProviders() []Notifier {
path := configPath()
entries, err := readConfigEntries(path)
if err != nil {
log.Printf("[push] 配置文件 %s 读取失败,推送不可用(不影响邮件服务): %v", path, err)
return nil
}
// 环境变量是**可选覆盖**:只有在配置里没有同类型的条目时才补一条。
// 保留它是因为临时验证(以及没有配置文件的小部署)很常用;
// 但它不是主路径 —— 主路径是配置文件(用户要求「密钥应当是配置项」)。
if env, ok := hmsConfigFromEnv(); ok && !hasType(entries, env.Type) {
entries = append(entries, env)
}
var out []Notifier
for i, cfg := range entries {
cfg.Type = strings.TrimSpace(cfg.Type)
if cfg.Type == "" {
log.Printf("[push] 第 %d 项缺 type 字段,已跳过", i+1)
continue
}
if cfg.Enabled != nil && !*cfg.Enabled {
log.Printf("[push] %s(%s)在配置里是 disabled,已跳过", nameOf(cfg), cfg.Type)
continue
}
f, ok := factories[cfg.Type]
if !ok {
log.Printf("[push] 不支持的类型 %q(已注册:%s),已跳过该项", cfg.Type, strings.Join(KnownTypes(), ", "))
continue
}
if err := resolveSecret(&cfg); err != nil {
log.Printf("[push] %s(%s)凭证不可用,已跳过: %v", nameOf(cfg), cfg.Type, err)
continue
}
checkClientConfig(&cfg)
n, err := f(cfg)
if err != nil {
log.Printf("[push] %s(%s)初始化失败,已跳过: %v", nameOf(cfg), cfg.Type, err)
continue
}
out = append(out, n)
}
return out
}
// Setup 读配置、注册通道,并把结果打成一行日志(main 调用它)。
//
// 返回已启用的通道名:空表示"这台实例没配推送"—— 那是正常状态,不是错误,
// 所以这里用普通日志而不是告警。
func Setup() []string {
providers := LoadProviders()
for _, p := range providers {
Register(p)
}
if len(providers) == 0 {
log.Printf("[push] 未配置推送通道(%s 不存在或为空)—— 正常状态,SSE 仍是收信主通道", configPath())
return nil
}
return Names()
}
func readConfigEntries(path string) ([]ProviderConfig, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // 没配就是没配
}
return nil, err
}
if strings.TrimSpace(string(b)) == "" {
return nil, nil
}
var f configFile
if err := json.Unmarshal(b, &f); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %w", err)
}
return f.Providers, nil
}
func hasType(entries []ProviderConfig, t string) bool {
for _, e := range entries {
if e.Type == t {
return true
}
}
return false
}
// resolveSecret 把密钥文件的内容解析到 cfg.AppSecret(文件优先于内联)。
//
// **不在这里要求密钥必须存在**:不是每个厂商都用 app_secret(Web Push 用 VAPID
// 密钥对),"这个字段是必需的"是各厂商自己的事,由它的 Factory 判定。
// 这条是判据抓出来的:第一版把"必须有密钥"写在通用层,于是无密钥的厂商条目
// 被默默跳过(测试里 test-echo 就没建起来)。
//
// 但**指明了文件却读不到**是真错误(配置里写了却用不了),所以它照旧让该项被判失败。
func resolveSecret(cfg *ProviderConfig) error {
file := strings.TrimSpace(cfg.AppSecretFile)
if file == "" {
file = strings.TrimSpace(cfg.Files["app_secret"])
}
if file == "" {
return nil
}
fi, err := os.Stat(file)
if err != nil {
return fmt.Errorf("密钥文件不可读 %s: %w", file, err)
}
if fi.Mode().Perm()&0o044 != 0 {
log.Printf("[push] 提醒:密钥文件 %s 权限 %o 对同组/其他人可读,建议 chmod 600", file, fi.Mode().Perm())
}
b, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("读密钥文件 %s 失败: %w", file, err)
}
secret := strings.TrimSpace(string(b))
if secret == "" {
return fmt.Errorf("密钥文件 %s 是空的", file)
}
cfg.AppSecret = secret
return nil
}
// checkClientConfig 核对客户端配置文件里的 app_id / package_name 与配置是否一致。
//
// 为什么值得做:推送送不到设备的最隐蔽原因是**服务端应用与设备上装的包不是同一个**
// (包名/App ID 对不上),而症状只是"怎么都不来通知"。这里在启动时说清楚,
// 比事后拿着一堆 80300007 猜要便宜得多。只比对能对上的字段,格式不认识就跳过。
func checkClientConfig(cfg *ProviderConfig) {
path := strings.TrimSpace(cfg.ClientConfigFile)
if path == "" {
return
}
b, err := os.ReadFile(path)
if err != nil {
log.Printf("[push] 提醒:客户端配置文件 %s 读不到: %v", path, err)
return
}
var doc struct {
Client struct {
AppID string `json:"app_id"`
PackageName string `json:"package_name"`
} `json:"client"`
}
if err := json.Unmarshal(b, &doc); err != nil {
log.Printf("[push] 提醒:客户端配置文件 %s 不是可识别的 JSON(已跳过核对)", path)
return
}
if doc.Client.AppID != "" && cfg.AppID != "" && doc.Client.AppID != cfg.AppID {
log.Printf("[push] 不一致:客户端配置的 app_id=%s 与服务端配置的 app_id=%s 不是同一个应用 —— 推送送不到设备",
doc.Client.AppID, cfg.AppID)
}
if doc.Client.PackageName != "" {
log.Printf("[push] 客户端包名:%s(设备的包名必须与它一致,且签名指纹要在厂商后台登记过)", doc.Client.PackageName)
}
}
func nameOf(cfg ProviderConfig) string {
if n := strings.TrimSpace(cfg.Name); n != "" {
return n
}
return cfg.Type
}
// hmsConfigFromEnv 把 HMS_* 环境变量转成一条配置(可选覆盖,见 LoadProviders)。
func hmsConfigFromEnv() (ProviderConfig, bool) {
appID := strings.TrimSpace(os.Getenv("HMS_APP_ID"))
secret := strings.TrimSpace(os.Getenv("HMS_APP_SECRET"))
if appID == "" || secret == "" {
return ProviderConfig{}, false
}
cfg := ProviderConfig{Type: "hms", AppID: appID, AppSecret: secret}
if v := strings.TrimSpace(os.Getenv("HMS_TEST_MESSAGE")); v != "" {
b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
cfg.TestMessage = &b
}
if v := strings.TrimSpace(os.Getenv("HMS_DAILY_LIMIT")); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n >= 0 {
cfg.DailyLimit = n
}
}
if v := strings.TrimSpace(os.Getenv("HMS_CLIENT_CONFIG_FILE")); v != "" {
cfg.ClientConfigFile = v
}
return cfg, true
}

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]) + "…"
}

View File

@ -0,0 +1,179 @@
/*
Package push 是「新邮件」的第二条送达通道(第一条是 SSE)。
# 它是可选的,且默认关闭
自部署实例通常**一个推送渠道都没配** —— 那是正常状态,不是配置错误:客户端
在线时 SSE 已经够用,推送只解决「App 不在前台 / 被系统杀掉」这一种情形。
因此本包所有入口在没注册任何 provider 时都是**立即返回**:不查库、不建连接、
不刷日志(用户 2026-09-15 的明确要求:「不能写死推送方式,因为我们是自部署后端」
「即推送密钥应当是可选项」)。
# 为什么不写死华为
表与端点都带 `provider` 维度,Notifier 是接口:加一个通道(web push、别的厂商)
只加一个实现 + 一行注册,不动 schema、不动端点形状、不动调用方。
华为 HMS 只是第一个实现(internal/push/hms.go)。
# 为什么发送是异步且会丢
推送发生在**收信路径**上(notify.Recipients),而它已经在库事务之外的下发阶段:
一个慢的推送 HTTP 请求不能拖住邮件送达 —— 收信是主功能,推送是锦上添花。
因此:有界并发 + 超时 + 失败只记日志。**宁可丢一条通知,不可慢一封邮件**。
*/
package push
import (
"context"
"log"
"sync"
"time"
"github.com/agentmail/gateway/internal/repo"
)
// NewMail 是要推送的一封新邮件。
//
// 字段刻意少:推送只负责「通知栏那一行 + 点进去看哪封信」。正文不进通知,
// 否则锁屏上就会露出邮件内容(SSE 是给已解锁的在线客户端用的,两者隐私模型不同)。
type NewMail struct {
MailID string
SessionID string
// From 是发件方名字,用于通知标题。
From string
// Subject 是邮件主题。
Subject string
// Recipients 是**该收到这封信的人名**(= SSE 的收件判据:主收件人 + 抄送方)。
// 两条通道共用同一份名单,不各算一套。
Recipients []string
}
// Notifier 是一个推送通道。
type Notifier interface {
// Name 是 provider 标识,与 push_tokens.provider 的值一致(如 "hms")。
Name() string
// MaxTokensPerRequest 是单次请求能带的最大 token 数(厂商限额,如华为测试消息 ≤10)。
// 由通道自己声明,而不是调用方写死一个「10」—— 限额是通道的属性。
MaxTokensPerRequest() int
// Send 向一批 token 投递同一条通知。返回错误只用于**记日志**。
Send(ctx context.Context, tokens []string, n NewMail) error
}
const (
// maxInFlight 是在途推送任务上限。超了就丢掉这一轮(记日志),不排队:
// 排队的后果是通知在几十秒后集中弹出来,那比丢掉更糟。
maxInFlight = 4
// sendTimeout 是单个 provider 单批的超时。
sendTimeout = 10 * time.Second
)
var (
mu sync.RWMutex
providers []Notifier
slots = make(chan struct{}, maxInFlight)
)
// Register 注册一个推送通道。由 main 按配置调用 —— 没配就不注册。
func Register(n Notifier) {
if n == nil {
return
}
mu.Lock()
defer mu.Unlock()
providers = append(providers, n)
log.Printf("[push] 通道已启用: %s(单批最多 %d 个 token)", n.Name(), n.MaxTokensPerRequest())
}
// Enabled 报告是否配了任何推送通道。
//
// 端点据此回 `enabled`,客户端据此知道自己「登记了也可能收不到」——
// 而不是以为登记失败。
func Enabled() bool {
mu.RLock()
defer mu.RUnlock()
return len(providers) > 0
}
// Names 返回已启用的通道名(端点回给客户端看)。
func Names() []string {
mu.RLock()
defer mu.RUnlock()
out := make([]string, 0, len(providers))
for _, p := range providers {
out = append(out, p.Name())
}
return out
}
// shouldDispatch 报告这封邮件值不值得进推送管线:没配通道、或没有收件人 → 不值。
//
// 为什么单独成一个**纯函数**(不碰库、不起 goroutine):因为“没配通道时零开销”
// 这条判据必须能**同步**验证。2026-09-15 变异验证实测过:把判据写成“调 NotifyNewMail
// 后用 nil 库不 panic”,去掉本函数里的 Enabled() 后测试**仍然绿** —— 分发在
// goroutine 里跑,而 t.Cleanup 已经把真库装回去了,于是判据在错误的理由上通过。
// 纯函数没有这个<E8BF99>赛跑面。
func shouldDispatch(n NewMail) bool {
return Enabled() && len(n.Recipients) > 0
}
// NotifyNewMail 异步把一封新邮件推给收件方登记的设备。
//
// 调用方(notify.Recipients)**永远不因此拿到错误**:推送失败不该影响收信,
// 也不该让调用方写一半成功一半失败的处理逻辑。
func NotifyNewMail(ctx context.Context, n NewMail) {
if !shouldDispatch(n) {
return
}
select {
case slots <- struct{}{}:
default:
log.Printf("[push] 在途任务已达上限 %d,跳过本轮推送(可选通道,丢一条通知不影响收信)", maxInFlight)
return
}
go func() {
defer func() { <-slots }()
// 用 Background 而不是请求的 ctx:发信请求一旦返回,ctx 就被取消,
// 挂在它上面的推送会被立刻掐断(而收信方恰恰是那个已经离线的人)。
sendCtx, cancel := context.WithTimeout(context.Background(), sendTimeout)
defer cancel()
dispatch(sendCtx, n)
}()
}
// dispatch 按 provider 分组投递,每批不超过该通道声明的上限。
func dispatch(ctx context.Context, n NewMail) {
tokens, err := repo.ListPushTokensOfOwners(ctx, n.Recipients)
if err != nil {
log.Printf("[push] 读推送登记失败(不影响收信): %v", err)
return
}
if len(tokens) == 0 {
return
}
grouped := map[string][]string{}
for _, t := range tokens {
grouped[t.Provider] = append(grouped[t.Provider], t.Token)
}
mu.RLock()
list := append([]Notifier(nil), providers...)
mu.RUnlock()
for _, p := range list {
ts := grouped[p.Name()]
if len(ts) == 0 {
continue
}
batch := p.MaxTokensPerRequest()
if batch <= 0 {
batch = 1
}
for i := 0; i < len(ts); i += batch {
end := i + batch
if end > len(ts) {
end = len(ts)
}
if err := p.Send(ctx, ts[i:end], n); err != nil {
log.Printf("[push] %s 投递失败(不影响收信): %v", p.Name(), err)
}
}
}
}

View File

@ -0,0 +1,604 @@
package push
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/repo"
)
/*
推送通道的判据(2026-09-15)。
用户对这件事的要求是**可选**:「不能写死推送方式,因为我们是自部署后端」
「即推送密钥应当是可选项」。所以第一组判据钉的不是"推得出去",而是
**没配凭证时它必须彻底不存在**(不查库、不占 goroutine、不刷日志)。
第二组钉华为那条路的具体形状 —— 那些常量是拿真凭证打真接口问出来的
(见 hms.go 的注释),判据把形状钉住,避免以后"顺手改一下"就悄悄失效。
*/
// ─── 夹具 ────────────────────────────────────────────────────────────────
type fakeNotifier struct {
name string
max int
mu sync.Mutex
calls [][]string
last NewMail
}
func (f *fakeNotifier) Name() string { return f.name }
func (f *fakeNotifier) MaxTokensPerRequest() int { return f.max }
func (f *fakeNotifier) Send(_ context.Context, tokens []string, n NewMail) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, append([]string(nil), tokens...))
f.last = n
return nil
}
func (f *fakeNotifier) sizes() []int {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]int, 0, len(f.calls))
for _, c := range f.calls {
out = append(out, len(c))
}
return out
}
// withProviders 临时替换全局通道表(测试之间互不影响)。
func withProviders(t *testing.T, ps ...Notifier) {
t.Helper()
mu.Lock()
saved := providers
providers = nil
mu.Unlock()
for _, p := range ps {
Register(p)
}
t.Cleanup(func() {
mu.Lock()
providers = saved
mu.Unlock()
})
}
func setupPushDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), filepath.Join(dir, "t.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(db.Close)
}
// ─── 一、可选性:没配凭证 = 彻底不存在 ──────────────────────────────────
// 没注册任何通道时,推送必须彻底不存在:不分发、不查库、不起 goroutine。
//
// 判据分两侧(只验一侧的判据是假的):
// - 坏样本:没通道时 shouldDispatch 必须为 false;
// - 干净样本:注了通道且有收件人时必须为 true —— 否则“永远返回 false”也能骗过上面一条。
//
// 另外真的拿 nil 库调一次 NotifyNewMail 做冒烟(任何 repo 调用都会 panic)。
// 它**不是**主判据:分发在 goroutine 里,与 t.Cleanup 赛跑 —— 2026-09-15 变异验证
// 实测过:只写这一点时,去掉 push.go 的 Enabled() 早退仍然绿(判据在错误的理由上通过)。
func TestNoProvidersTouchesNothing(t *testing.T) {
withProviders(t) // 一个都不注册
saved := db.DB
db.DB = nil
t.Cleanup(func() { db.DB = saved })
if Enabled() {
t.Fatal("一个通道都没注册时 Enabled() 必须是 false")
}
if names := Names(); len(names) != 0 {
t.Fatalf("没注册通道时不该有名字,实际 %v", names)
}
if shouldDispatch(NewMail{Recipients: []string{"alice"}}) {
t.Fatal("没配任何推送通道时分发必须被跳过(零开销)")
}
if shouldDispatch(NewMail{}) {
t.Fatal("没有收件人时不该分发")
}
// 冒烟:走完 NotifyNewMail 不该碰库
NotifyNewMail(context.Background(), NewMail{
MailID: "m1", SessionID: "s1", From: "bob", Subject: "你好",
Recipients: []string{"alice"},
})
NotifyNewMail(context.Background(), NewMail{MailID: "m2"})
// 干净样本:注了通道 + 有收件人 → 必须分发
withProviders(t, &fakeNotifier{name: "fake", max: 10})
if !shouldDispatch(NewMail{Recipients: []string{"alice"}}) {
t.Fatal("配了通道且有收件人时必须分发(否则这条判据挡不住“永远不分发”的实现)")
}
if shouldDispatch(NewMail{}) {
t.Fatal("注了通道但没有收件人时仍不该分发")
}
}
/*
配置式多厂商接入的判据(2026-09-15 用户的第二条要求)。
原话:「我们要支持多厂商配置式接入统一推送服务,推送密钥(文件)应当是配置项,
用户可为不同的推送服务配置对应的凭证,因为我们的设计中是每个用户各自部署服务器」。
所以这里钉四件事:
1. 配置能真的造出多个通道(而不是只有一个 HMS 硬编码路径);
2. **密钥文件**能作为配置项用(app_secret_file),且权限过松会提醒;
3. **单项配错不能拖垮服务**:未知类型 / 密钥读不到 / 被 disabled → 只跳过那一条;
4. 没配文件 = 没推送,且不是错误(自部署实例的默认形态)。
*/
func writePushConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "push.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("PUSH_CONFIG", path)
return path
}
func writeSecret(t *testing.T, mode os.FileMode) string {
t.Helper()
p := filepath.Join(t.TempDir(), "hms.secret")
if err := os.WriteFile(p, []byte("sec-from-file\n"), mode); err != nil {
t.Fatal(err)
}
return p
}
// 未知的 type 只跳过自己;同实例可以同时接多个厂商。
func TestConfigFileDrivesMultipleProviders(t *testing.T) {
RegisterType("test-echo", func(cfg ProviderConfig) (Notifier, error) {
return &fakeNotifier{name: nameOf(cfg), max: 3}, nil
})
secret := writeSecret(t, 0o600)
writePushConfig(t, `{
"providers": [
{"type":"hms","app_id":"app-1","app_secret_file":`+jsonStr(secret)+`},
{"type":"test-echo","name":"echo-a"},
{"type":"vendor-not-implemented","app_id":"x"},
{"type":"hms","name":"hms-disabled","enabled":false,"app_id":"a","app_secret":"s"}
]
}`)
ps := LoadProviders()
names := map[string]Notifier{}
for _, p := range ps {
names[p.Name()] = p
}
if len(ps) != 2 {
t.Fatalf("应启用 2 个通道(hms + test-echo),实际 %d: %v", len(ps), names)
}
if _, ok := names["hms"]; !ok {
t.Fatalf("缺 hms 通道: %v", names)
}
if _, ok := names["echo-a"]; !ok {
t.Fatalf("Name 覆盖没生效(应叫 echo-a): %v", names)
}
// 密钥来自**文件**(配置项),而不是内联
h, _ := names["hms"].(*HMS)
if h == nil || h.AppSecret != "sec-from-file" {
t.Fatalf("app_secret_file 没被读进来: %+v", h)
}
// 缺省 = 测试消息开(未上架应用只有这个模式收得到)
if !h.TestMessage {
t.Fatal("test_message 缺省必须为 true(未上架应用)")
}
if h.DailyLimit != 1000 {
t.Fatalf("daily_limit 缺省应为 1000(华为测试消息的项目级限制),实际 %d", h.DailyLimit)
}
}
// 密钥文件权限过松要提醒(一个 0644 的密钥文件是真实的配置错误),但不拦启动。
func TestConfigWarnsOnLooseSecretFile(t *testing.T) {
secret := writeSecret(t, 0o644)
writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","app_secret_file":`+jsonStr(secret)+`}]}`)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
if ps := LoadProviders(); len(ps) != 1 {
t.Fatalf("权限过松只该提醒不该拒绝,实际 %d 个通道", len(ps))
}
log.SetOutput(os.Stderr)
if !strings.Contains(buf.String(), "权限") {
t.Fatalf("应提醒密钥文件权限过松,实际日志:%s", buf.String())
}
}
// 单项配错不能拖垮其他项:第一个密钥文件读不到,第二个仍必须启用。
func TestConfigBadEntryDoesNotKillOthers(t *testing.T) {
RegisterType("test-echo", func(cfg ProviderConfig) (Notifier, error) {
return &fakeNotifier{name: nameOf(cfg), max: 3}, nil
})
writePushConfig(t, `{
"providers": [
{"type":"hms","app_id":"a","app_secret_file":"/nonexistent/secret"},
{"type":"hms","name":"hms-ok","app_id":"b","app_secret":"inline"}
]
}`)
ps := LoadProviders()
if len(ps) != 1 || ps[0].Name() != "hms-ok" {
t.Fatalf("坏条目应只跳自己,实际 %v", ps)
}
}
// 没配配置文件 = 没推送,而且不是错误。
func TestNoConfigMeansNoProviders(t *testing.T) {
t.Setenv("PUSH_CONFIG", filepath.Join(t.TempDir(), "does-not-exist.json"))
if ps := LoadProviders(); len(ps) != 0 {
t.Fatalf("没配置文件时应 0 个通道,实际 %d", len(ps))
}
}
// 环境变量是**可选覆盖**:配置里没有同类型条目时补一条;已有则以配置为准。
func TestEnvIsOverrideNotTheMainPath(t *testing.T) {
t.Setenv("HMS_APP_ID", "env-app")
t.Setenv("HMS_APP_SECRET", "env-secret")
t.Setenv("HMS_TEST_MESSAGE", "false")
// 1) 配置文件里没有 hms 条目 → 用环境变量补一条
writePushConfig(t, `{"providers":[]}`)
ps := LoadProviders()
if len(ps) != 1 || ps[0].Name() != "hms" {
t.Fatalf("环境变量应能补出一条 hms 通道,实际 %v", ps)
}
if h := ps[0].(*HMS); h.TestMessage {
t.Fatal("HMS_TEST_MESSAGE=false 没生效")
}
// 2) 配置文件里已有 hms 条目 → 环境变量不得再补一条(避免两个同名通道)
writePushConfig(t, `{"providers":[{"type":"hms","name":"hms-from-file","app_id":"f","app_secret":"s"}]}`)
ps = LoadProviders()
if len(ps) != 1 || ps[0].Name() != "hms-from-file" {
t.Fatalf("配置文件优先,环境变量不该再加一条,实际 %v", ps)
}
}
// 密钥文件优先于内联密钥(用户明确要求「推送密钥(文件)应当是配置项」)。
//
// 为什么这条值得单独写:两者都给是**常见**情况(配置里留着旧的内联密钥做参考,
// 同时切到文件)。不明确优先关系的结果是「改了文件却没生效」这种最难查的静默失效。
// 第一版这条判据缺失,变异验证(把优先级反转)居然全绿 —— 因此补上。
func TestSecretFileBeatsInlineSecret(t *testing.T) {
secret := writeSecret(t, 0o600)
writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","app_secret":"inline-wrong","app_secret_file":`+jsonStr(secret)+`}]}`)
ps := LoadProviders()
if len(ps) != 1 {
t.Fatalf("应有 1 个通道,实际 %d", len(ps))
}
if h := ps[0].(*HMS); h.AppSecret != "sec-from-file" {
t.Fatalf("密钥文件应优先于内联密钥,实际用了 %q", h.AppSecret)
}
}
// 厂自定义文件表(files)也能当密钥配置项用:不是每个厂商都叫 app_secret。
func TestGenericFilesMapWorksAsSecretSource(t *testing.T) {
secret := writeSecret(t, 0o600)
writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","files":{"app_secret":`+jsonStr(secret)+`}}]}`)
ps := LoadProviders()
if len(ps) != 1 {
t.Fatalf("files.app_secret 应被当作密钥文件配置项,实际 %d 个通道", len(ps))
}
if h := ps[0].(*HMS); h.AppSecret != "sec-from-file" {
t.Fatalf("files 里的密钥文件没被读进来: %q", h.AppSecret)
}
}
// 缺密钥的厂商条目由**它自己的 Factory** 判失败(而不是通用配置层):
// 通用层要求密钥就会把 Web Push 这类不用 app_secret 的厂商误杀(判据抓出来过)。
func TestMissingSecretIsFactoryBusiness(t *testing.T) {
RegisterType("test-nosecret", func(cfg ProviderConfig) (Notifier, error) {
return &fakeNotifier{name: nameOf(cfg), max: 1}, nil
})
writePushConfig(t, `{"providers":[
{"type":"test-nosecret","name":"no-secret-needed"},
{"type":"hms","app_id":"a"}
]}`)
ps := LoadProviders()
if len(ps) != 1 || ps[0].Name() != "no-secret-needed" {
t.Fatalf("不需要密钥的厂商应能建起来;需要密钥而没给的 hms 应被自己的 Factory 判失败。实际 %v", ps)
}
}
func jsonStr(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// ─── 二、分发:按 provider 分组、按通道声明的上限分批 ────────────────────
func TestDispatchBatchesByProviderLimit(t *testing.T) {
setupPushDB(t)
ctx := context.Background()
// 23 个 token,通道声明的上限是 10 → 必须切成 10/10/3
for i := 0; i < 23; i++ {
if err := repo.UpsertPushToken(ctx, "fake", tokName(i), "alice", "", ""); err != nil {
t.Fatal(err)
}
}
f := &fakeNotifier{name: "fake", max: 10}
withProviders(t, f)
dispatch(ctx, NewMail{MailID: "m-1", SessionID: "s-1", From: "bob", Subject: "主题", Recipients: []string{"alice"}})
if got := f.sizes(); len(got) != 3 || got[0] != 10 || got[1] != 10 || got[2] != 3 {
t.Fatalf("分批不对:期望 [10 10 3],实际 %v(超限会被华为拒:token count should within 1 and 1,000;测试消息另限 10)", got)
}
if f.last.MailID != "m-1" || f.last.SessionID != "s-1" || f.last.From != "bob" {
t.Fatalf("推给通道的邮件内容不对: %+v", f.last)
}
}
func TestDispatchGroupsByProvider(t *testing.T) {
setupPushDB(t)
ctx := context.Background()
if err := repo.UpsertPushToken(ctx, "a", "t-a1", "alice", "", ""); err != nil {
t.Fatal(err)
}
if err := repo.UpsertPushToken(ctx, "b", "t-b1", "bob", "", ""); err != nil {
t.Fatal(err)
}
fa := &fakeNotifier{name: "a", max: 10}
fb := &fakeNotifier{name: "b", max: 10}
withProviders(t, fa, fb)
dispatch(ctx, NewMail{MailID: "m", Recipients: []string{"alice", "bob"}})
if len(fa.calls) != 1 || len(fa.calls[0]) != 1 || fa.calls[0][0] != "t-a1" {
t.Fatalf("通道 a 应只拿到自己的 token,实际 %v", fa.calls)
}
if len(fb.calls) != 1 || len(fb.calls[0]) != 1 || fb.calls[0][0] != "t-b1" {
t.Fatalf("通道 b 应只拿到自己的 token,实际 %v", fb.calls)
}
}
func TestDispatchSkipsRecipientsWithoutTokens(t *testing.T) {
setupPushDB(t)
f := &fakeNotifier{name: "fake", max: 10}
withProviders(t, f)
// 谁都没登记过:不该有任何发送,也不该报错
dispatch(context.Background(), NewMail{MailID: "m", Recipients: []string{"nobody"}})
if len(f.calls) != 0 {
t.Fatalf("没人登记过就不该发,实际 %v", f.calls)
}
}
func tokName(i int) string {
return "tok-" + strings.Repeat("0", 2-len(itoa(i))) + itoa(i)
}
func itoa(i int) string {
if i == 0 {
return "0"
}
var b []byte
for i > 0 {
b = append([]byte{byte('0' + i%10)}, b...)
i /= 10
}
return string(b)
}
// ─── 三、华为通道的具体形状 ─────────────────────────────────────────────
// hmsStub 起一个假的华为端点:/token 发令牌,其余路径收推送。
type hmsStub struct {
srv *httptest.Server
mu sync.Mutex
tokenReq int
pushReqs []map[string]any
authHdrs []string
code string
}
func newHMSStub(t *testing.T) *hmsStub {
t.Helper()
s := &hmsStub{code: "80000000"}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
s.mu.Lock()
s.tokenReq++
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok-abc","expires_in":3600}`)
return
}
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
s.mu.Lock()
s.pushReqs = append(s.pushReqs, m)
s.authHdrs = append(s.authHdrs, r.Header.Get("Authorization"))
code := s.code
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":"`+code+`","msg":"stub"}`)
}))
t.Cleanup(s.srv.Close)
return s
}
func (s *hmsStub) hms() *HMS {
return &HMS{
AppID: "app-1", AppSecret: "sec-1", TestMessage: true, DailyLimit: 1000,
Endpoint: s.srv.URL, TokenURL: s.srv.URL + "/token", Client: s.srv.Client(),
}
}
func (s *hmsStub) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.pushReqs)
}
// 推送请求的形状:v1 端点 + message.token + notification + data(字符串) + testMessage。
// 形状不对时华为回的是参数类错误(实测 80300010),而这条判据把它钉死在本地。
func TestHMSSendShape(t *testing.T) {
s := newHMSStub(t)
err := s.hms().Send(context.Background(), []string{"tok-1", "tok-2"}, NewMail{
MailID: "mail-1", SessionID: "sess-1", From: "bob", Subject: "标题",
})
if err != nil {
t.Fatalf("成功码应被当成功: %v", err)
}
if s.count() != 1 {
t.Fatalf("应发出 1 个请求,实际 %d", s.count())
}
req := s.pushReqs[0]
if req["testMessage"] != true {
t.Fatalf("未上架应用必须带 testMessage=true,实际 %v", req["testMessage"])
}
msg, ok := req["message"].(map[string]any)
if !ok {
t.Fatalf("缺 message 字段: %v", req)
}
tokens, _ := msg["token"].([]any)
if len(tokens) != 2 || tokens[0] != "tok-1" || tokens[1] != "tok-2" {
t.Fatalf("token 列表不对: %v", msg["token"])
}
notif, _ := msg["notification"].(map[string]any)
if notif == nil || !strings.Contains(str(notif["title"]), "标题") || notif["body"] != "bob" {
t.Fatalf("通知内容不对: %v", notif)
}
// data 必须是**字符串**(华为这套要求序列化后的 JSON 字符串)
dataStr, ok := msg["data"].(string)
if !ok {
t.Fatalf("data 必须是字符串,实际 %T", msg["data"])
}
var data map[string]string
if err := json.Unmarshal([]byte(dataStr), &data); err != nil {
t.Fatalf("data 不是合法 JSON 字符串: %v", err)
}
if data["type"] != "new_mail" || data["mail_id"] != "mail-1" || data["session_id"] != "sess-1" || data["action"] != "open_mail" {
t.Fatalf("data 的跳转契约不对(客户端按它跳转): %v", data)
}
if s.authHdrs[0] != "Bearer tok-abc" {
t.Fatalf("Authorization 头不对: %q", s.authHdrs[0])
}
}
// 访问令牌要缓存:两次发送只换一次令牌(华为给 3600 秒,我们的实现提前 5 分钟刷新)。
func TestHMSCachesAccessToken(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
for i := 0; i < 2; i++ {
if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m"}); err != nil {
t.Fatal(err)
}
}
s.mu.Lock()
defer s.mu.Unlock()
if s.tokenReq != 1 {
t.Fatalf("两次发送应只换一次令牌,实际换了 %d 次", s.tokenReq)
}
}
func TestHMSTestMessageCanBeDisabled(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
h.TestMessage = false // 上架之后
if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m"}); err != nil {
t.Fatal(err)
}
if _, present := s.pushReqs[0]["testMessage"]; present {
t.Fatal("HMS_TEST_MESSAGE=false 时不该带 testMessage")
}
}
// 华为回「这些 token 全无效」时必须把它们从库里清掉:
// 否则每次发信都向死 token 发(白吃项目级额度),而用户永远收不到。
func TestHMSSendPrunesAllInvalidTokens(t *testing.T) {
setupPushDB(t)
ctx := context.Background()
if err := repo.UpsertPushToken(ctx, "hms", "dead-1", "alice", "", ""); err != nil {
t.Fatal(err)
}
if err := repo.UpsertPushToken(ctx, "hms", "dead-2", "alice", "", ""); err != nil {
t.Fatal(err)
}
s := newHMSStub(t)
s.code = "80300007" // 实测码:All the tokens are invalid
h := s.hms()
err := h.Send(ctx, []string{"dead-1", "dead-2"}, NewMail{MailID: "m"})
if err == nil {
t.Fatal("无效 token 必须报错(不能静默当成功)")
}
if !strings.Contains(err.Error(), "80300007") {
t.Fatalf("错误里应带上华为的 code,便于排查: %v", err)
}
left, _ := repo.ListPushTokensOf(ctx, "alice")
if len(left) != 0 {
t.Fatalf("无效 token 没被清理: %+v", left)
}
}
// 认证/参数类错误不该清 token(那是我们自己的问题,不是设备的问题)。
func TestHMSSendKeepsTokensOnOtherErrors(t *testing.T) {
setupPushDB(t)
ctx := context.Background()
if err := repo.UpsertPushToken(ctx, "hms", "good-1", "alice", "", ""); err != nil {
t.Fatal(err)
}
s := newHMSStub(t)
s.code = "80200001" // Authentication Error
if err := s.hms().Send(ctx, []string{"good-1"}, NewMail{MailID: "m"}); err == nil {
t.Fatal("认证失败必须报错")
}
left, _ := repo.ListPushTokensOf(ctx, "alice")
if len(left) != 1 {
t.Fatal("认证类错误不该删 token(设备是好的,错在我们)")
}
}
// 每日上限只是**兜底**(华为对未上架应用的测试消息限 1000 条/天/项目)。
// 到线就停手,不把额度打光换来一串失败响应。
func TestHMSDailyLimitStopsSending(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
h.DailyLimit = 1
if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m1"}); err != nil {
t.Fatal(err)
}
err := h.Send(context.Background(), []string{"tok-2"}, NewMail{MailID: "m2"})
if err == nil {
t.Fatal("超过每日上限必须拒绝发送")
}
if !strings.Contains(err.Error(), "上限") {
t.Fatalf("错误信息应说明是上限问题: %v", err)
}
if s.count() != 1 {
t.Fatalf("到线后不该再打网络,实际打了 %d 次", s.count())
}
}
func str(v any) string {
s, _ := v.(string)
return s
}