208 lines
5.0 KiB
Go
208 lines
5.0 KiB
Go
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{}
|
||
}
|