package wechatmp import ( "errors" "strings" "sync" "time" "server/models" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // ============================================================= // 微信公众号(服务号)配置与 access_token // ============================================================= // 消息加解密方式 const ( EncryptModePlain = "plain" // 明文模式 EncryptModeCompatible = "compatible" // 兼容模式 EncryptModeSafe = "safe" // 安全模式 ) // Config 服务号运行配置(敏感字段已解密) type Config struct { ID uint64 AppID string AppSecret string // 明文 Token string AESKey string // 明文(43 位 EncodingAESKey) EncryptMode string TemplateID string Verified bool Enabled bool Remark string } // ErrNotConfigured 未配置或未启用 var ErrNotConfigured = errors.New("微信公众号未配置或未启用") // LoadConfig 读取服务号配置(单行,敏感字段解密);未配置返回 ErrNotConfigured func LoadConfig() (*Config, error) { var row models.WechatMpConfig err := models.Orm.QueryTable(new(models.WechatMpConfig)).OrderBy("id").One(&row) if err != nil { if err == orm.ErrNoRows { return nil, ErrNotConfigured } return nil, err } cfg := &Config{ ID: row.ID, AppID: strings.TrimSpace(row.AppID), Token: strings.TrimSpace(row.Token), EncryptMode: strings.TrimSpace(row.EncryptMode), TemplateID: strings.TrimSpace(row.TemplateID), Verified: row.Verified == 1, Enabled: row.Enabled == 1, Remark: row.Remark, } if cfg.EncryptMode == "" { cfg.EncryptMode = EncryptModePlain } if row.AppSecret != "" { if plain, derr := DecryptSecret(row.AppSecret); derr == nil { cfg.AppSecret = plain } else { return nil, derr } } if row.AESKey != "" { if plain, derr := DecryptSecret(row.AESKey); derr == nil { cfg.AESKey = plain } else { return nil, derr } } return cfg, nil } // LoadEnabledConfig 读取并校验启用状态 func LoadEnabledConfig() (*Config, error) { cfg, err := LoadConfig() if err != nil { return nil, err } if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { return nil, ErrNotConfigured } return cfg, nil } // SaveInput 保存配置入参(敏感字段支持掩码表示不改) type SaveInput struct { AppID string AppSecret string // 掩码 / 空串 = 保持不变 Token string AESKey string // 掩码 / 空串 = 保持不变 EncryptMode string TemplateID string Verified bool Enabled bool Remark string } // SaveConfig 保存服务号配置(不存在则创建) func SaveConfig(in SaveInput) error { if strings.TrimSpace(in.AppID) == "" { return errors.New("AppID 不能为空") } mode := strings.TrimSpace(in.EncryptMode) if mode == "" { mode = EncryptModePlain } var row models.WechatMpConfig err := models.Orm.QueryTable(new(models.WechatMpConfig)).OrderBy("id").One(&row) now := time.Now() isNew := false if err != nil { if err != orm.ErrNoRows { return err } isNew = true row = models.WechatMpConfig{} } // 敏感字段:掩码/空串表示保持原值 secret := strings.TrimSpace(in.AppSecret) if secret != "" && !IsMasked(secret) { enc, eerr := EncryptSecret(secret) if eerr != nil { return eerr } row.AppSecret = enc } aesKey := strings.TrimSpace(in.AESKey) if aesKey != "" && !IsMasked(aesKey) { enc, eerr := EncryptSecret(aesKey) if eerr != nil { return eerr } row.AESKey = enc } row.AppID = strings.TrimSpace(in.AppID) row.Token = strings.TrimSpace(in.Token) row.EncryptMode = mode row.TemplateID = strings.TrimSpace(in.TemplateID) row.Remark = strings.TrimSpace(in.Remark) if in.Verified { row.Verified = 1 } else { row.Verified = 0 } if in.Enabled { row.Enabled = 1 } else { row.Enabled = 0 } row.UpdateTime = &now if isNew { row.CreateTime = now _, err = models.Orm.Insert(&row) return err } _, err = models.Orm.Update(&row) return err } // CallbackURL 微信服务器回调地址(供公众号后台「服务器配置」填写)。 // 优先读取 app.conf 的 wechat_mp_callback_base,其次回落 payment_callback_base(同为对外 API 域名)。 func CallbackURL() string { base, _ := beego.AppConfig.String("wechat_mp_callback_base") if strings.TrimSpace(base) == "" { base, _ = beego.AppConfig.String("payment_callback_base") } base = strings.TrimRight(strings.TrimSpace(base), "/") if base == "" { return "/api/wechat/mp/callback" } return base + "/api/wechat/mp/callback" } // ============================ access_token 缓存 ============================ type tokenEntry struct { appID string token string expiresAt time.Time } var ( tokenMu sync.Mutex tokenCache tokenEntry tokenMinTTL = 5 * time.Minute ) // resetTokenCacheForTest 仅供测试清理缓存 func resetTokenCacheForTest() { tokenMu.Lock() defer tokenMu.Unlock() tokenCache = tokenEntry{} }