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` 指定;默认 `/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 }