用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持 多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。 所以落地成: · 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 的「实现状态」一节。
281 lines
9.2 KiB
Go
281 lines
9.2 KiB
Go
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]) + "…"
|
||
}
|