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