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