增加微信通知功能

This commit is contained in:
2026-09-16 18:06:18 +08:00
parent b48688cbab
commit f386899625
23 changed files with 3539 additions and 18 deletions
+207
View File
@@ -0,0 +1,207 @@
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{}
}