优化小程序部分
This commit is contained in:
1 parent
727319a6eb
commit
d4e89788c5
46 files changed
+4906
-63
No files matched your search
@@ -20,8 +20,10 @@ import (
|
||||
|
||||
"server/models"
|
||||
"server/services/erp"
|
||||
"server/services/wechatmini"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
// Approver 展开后的单个审批人
|
||||
@@ -946,7 +948,26 @@ func createTasks(instID uint64, in SubmitInput, node Node, approvers []Approver)
|
||||
})
|
||||
}
|
||||
_, err := models.Orm.InsertMulti(len(tasks), tasks)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 租户侧审批人同时推小程序订阅消息(授权过才有一次发送机会,失败不影响流程)
|
||||
if in.Scope == models.ApprovalScopeTenant {
|
||||
for _, a := range approvers {
|
||||
approverID, nodeName := a.ID, node.NodeName
|
||||
go func(uid uint64, name string) {
|
||||
res := wechatmini.PushTenantNotice(uid, "您有新的审批待办", "审批事项:"+name+",请及时处理。")
|
||||
if res.Sent > 0 {
|
||||
logs.Info("[wechat-mini] 审批待办已推送订阅消息: user=%d sent=%d", uid, res.Sent)
|
||||
}
|
||||
for _, e := range res.Errors {
|
||||
logs.Info("[wechat-mini] 订阅消息推送失败: user=%d err=%v", uid, e)
|
||||
}
|
||||
}(approverID, nodeName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fireNotifyTasks 为通知节点创建已读通知任务(不阻塞流程)
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/services/wechatmini"
|
||||
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
type ReminderData struct {
|
||||
@@ -294,6 +297,18 @@ func (s *SiteMsgSender) Send(ctx context.Context, reminder ReminderData, title,
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 同时推送到小程序订阅消息(用户授权过才有一次发送机会,失败不影响站内信)
|
||||
receiverID := reminder.ReceiverUserID
|
||||
go func(uid uint64, t, c string) {
|
||||
res := wechatmini.PushTenantNotice(uid, t, c)
|
||||
if res.Sent > 0 {
|
||||
logs.Info("[wechat-mini] 日程提醒已推送订阅消息: user=%d sent=%d", uid, res.Sent)
|
||||
}
|
||||
for _, e := range res.Errors {
|
||||
logs.Info("[wechat-mini] 订阅消息推送失败: user=%d err=%v", uid, e)
|
||||
}
|
||||
}(receiverID, title, content)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"server/models"
|
||||
"server/services/wechatmini"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
// GetSiteReminderConfig 获取站内信配置(从 yz_platform_normal_setting 读取)
|
||||
@@ -296,8 +299,29 @@ func SendSiteReminder(title, content string, senderID uint64, senderType string,
|
||||
}
|
||||
|
||||
// 批量插入
|
||||
_, err = models.Orm.InsertMulti(100, reminders)
|
||||
return err
|
||||
if _, err = models.Orm.InsertMulti(100, reminders); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同步推送到小程序订阅消息(异步执行,不拖慢站内信本身)
|
||||
// 只有用户在小程序端授权过订阅、且平台配了对应场景模板时才会真正下发。
|
||||
for _, r := range receivers {
|
||||
if r.Type != "tenant" {
|
||||
continue // 平台端用户没有小程序 openid
|
||||
}
|
||||
tenantUserID := r.ID
|
||||
titleCopy, contentCopy := title, content
|
||||
go func(uid uint64, t, c string) {
|
||||
res := wechatmini.PushTenantNotice(uid, t, c)
|
||||
if res.Sent > 0 {
|
||||
logs.Info("[wechat-mini] 站内信已推送订阅消息: user=%d sent=%d", uid, res.Sent)
|
||||
}
|
||||
for _, e := range res.Errors {
|
||||
logs.Info("[wechat-mini] 订阅消息推送失败: user=%d err=%v", uid, e)
|
||||
}
|
||||
}(tenantUserID, titleCopy, contentCopy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListReminders 列表查询
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// Package wechatmini 微信小程序「消息推送」服务端能力。
|
||||
//
|
||||
// 对应小程序后台:开发管理 → 开发设置 → 消息推送。
|
||||
// 后台需填写 URL / Token / EncodingAESKey / 加解密方式 / 数据格式,
|
||||
// 微信会在保存时向 URL 发一次 GET 校验(signature + echostr),
|
||||
// 保存成功后,用户向小程序客服发消息等事件会以 POST 推送到该 URL。
|
||||
//
|
||||
// 协议与公众号「服务器配置」一致(signature = sha1(sort(token,timestamp,nonce))),
|
||||
// 复用 services/wechatmp 的签名与 AES 加解密原语,本文件补齐小程序侧
|
||||
// 的多应用路由、JSON 数据格式与被动回复构造。
|
||||
package wechatmini
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/services/wechatmp"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// 消息数据格式(小程序后台「数据格式」)
|
||||
const (
|
||||
DataFormatXML = "xml"
|
||||
DataFormatJSON = "json"
|
||||
)
|
||||
|
||||
// PushConfig 小程序消息推送运行配置(敏感字段已解密)
|
||||
type PushConfig struct {
|
||||
AppID string
|
||||
Token string
|
||||
AESKey string // 明文(43 位 EncodingAESKey)
|
||||
EncryptMode string // plain / compatible / safe
|
||||
DataFormat string // xml / json
|
||||
ReplyText string // 客服消息自动回复文案(空 = 不自动回复)
|
||||
}
|
||||
|
||||
// ErrPushNotConfigured 未配置消息推送
|
||||
var ErrPushNotConfigured = errors.New("小程序消息推送未配置")
|
||||
|
||||
// pushConfigFromRow 读取行数据并解密 AESKey
|
||||
func pushConfigFromRow(row models.WechatMiniApp) (*PushConfig, error) {
|
||||
cfg := &PushConfig{
|
||||
AppID: strings.TrimSpace(row.AppID),
|
||||
Token: strings.TrimSpace(row.Token),
|
||||
EncryptMode: strings.TrimSpace(row.EncryptMode),
|
||||
DataFormat: strings.ToLower(strings.TrimSpace(row.DataFormat)),
|
||||
ReplyText: strings.TrimSpace(row.PushReply),
|
||||
}
|
||||
if cfg.EncryptMode == "" {
|
||||
cfg.EncryptMode = wechatmp.EncryptModePlain
|
||||
}
|
||||
if cfg.DataFormat == "" {
|
||||
cfg.DataFormat = DataFormatXML
|
||||
}
|
||||
if row.AESKey != "" {
|
||||
plain, err := wechatmp.DecryptSecret(row.AESKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.AESKey = plain
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadPushConfigs 读取全部已配置 Token 的小程序推送配置(未删除的记录)。
|
||||
// 微信回调不携带 appid(明文模式下 ToUserName 即 appid),故按应用逐个匹配签名。
|
||||
func LoadPushConfigs() ([]PushConfig, error) {
|
||||
var rows []models.WechatMiniApp
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id").
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]PushConfig, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if strings.TrimSpace(r.Token) == "" {
|
||||
continue // 未配置 Token 的应用不参与签名匹配
|
||||
}
|
||||
cfg, cerr := pushConfigFromRow(r)
|
||||
if cerr != nil {
|
||||
continue // 解密失败的应用跳过,避免阻塞其它小程序
|
||||
}
|
||||
list = append(list, *cfg)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// LoadPushConfigByAppID 按 appid 读取单个小程序的推送配置
|
||||
func LoadPushConfigByAppID(appID string) (*PushConfig, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, ErrPushNotConfigured
|
||||
}
|
||||
var row models.WechatMiniApp
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("app_id", appID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrPushNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return pushConfigFromRow(row)
|
||||
}
|
||||
|
||||
// SavePushConfigInput 保存推送配置入参(敏感字段支持掩码表示不修改)
|
||||
type SavePushConfigInput struct {
|
||||
AppID string
|
||||
Token string
|
||||
AESKey string // 掩码 / 空串 = 保持原值
|
||||
EncryptMode string
|
||||
DataFormat string
|
||||
ReplyText string
|
||||
}
|
||||
|
||||
// SavePushConfig 保存小程序消息推送配置
|
||||
func SavePushConfig(in SavePushConfigInput) error {
|
||||
appID := strings.TrimSpace(in.AppID)
|
||||
if appID == "" {
|
||||
return errors.New("AppID 不能为空")
|
||||
}
|
||||
mode := strings.TrimSpace(in.EncryptMode)
|
||||
switch mode {
|
||||
case wechatmp.EncryptModePlain, wechatmp.EncryptModeCompatible, wechatmp.EncryptModeSafe:
|
||||
case "":
|
||||
mode = wechatmp.EncryptModePlain
|
||||
default:
|
||||
return errors.New("加解密方式无效(plain/compatible/safe)")
|
||||
}
|
||||
format := strings.ToLower(strings.TrimSpace(in.DataFormat))
|
||||
switch format {
|
||||
case DataFormatXML, DataFormatJSON:
|
||||
case "":
|
||||
format = DataFormatXML
|
||||
default:
|
||||
return errors.New("数据格式无效(xml/json)")
|
||||
}
|
||||
token := strings.TrimSpace(in.Token)
|
||||
if token != "" && (len(token) < 3 || len(token) > 32) {
|
||||
return errors.New("Token 长度需为 3-32 个字符")
|
||||
}
|
||||
|
||||
var row models.WechatMiniApp
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("app_id", appID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return errors.New("小程序不存在或已删除")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
aesKey := strings.TrimSpace(in.AESKey)
|
||||
if aesKey != "" && !wechatmp.IsMasked(aesKey) {
|
||||
if len(aesKey) != 43 {
|
||||
return errors.New("EncodingAESKey 长度必须为 43 位")
|
||||
}
|
||||
enc, eerr := wechatmp.EncryptSecret(aesKey)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
row.AESKey = enc
|
||||
}
|
||||
if token != "" {
|
||||
row.Token = token
|
||||
}
|
||||
row.EncryptMode = mode
|
||||
row.DataFormat = format
|
||||
row.PushReply = strings.TrimSpace(in.ReplyText)
|
||||
now := time.Now()
|
||||
row.UpdateTime = &now
|
||||
_, err = models.Orm.Update(&row)
|
||||
return err
|
||||
}
|
||||
|
||||
// PushCallbackURL 小程序消息推送回调地址(填到小程序后台「消息推送 - URL」)。
|
||||
// 优先读取 app.conf 的 wechat_mini_callback_base,其次回落 wechat_mp_callback_base
|
||||
// / payment_callback_base(均为对外 API 域名)。
|
||||
func PushCallbackURL() string {
|
||||
base, _ := beego.AppConfig.String("wechat_mini_callback_base")
|
||||
if strings.TrimSpace(base) == "" {
|
||||
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/mini/callback"
|
||||
}
|
||||
return base + "/api/wechat/mini/callback"
|
||||
}
|
||||
|
||||
// ============================ 随机 Token / EncodingAESKey ============================
|
||||
|
||||
const aesKeyAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// randomString 从指定字符集随机取 n 个字符(crypto/rand 源)
|
||||
func randomString(alphabet string, n int) string {
|
||||
b := []byte(alphabet)
|
||||
out := make([]byte, n)
|
||||
max := big.NewInt(int64(len(b)))
|
||||
for i := range out {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
// 随机源异常时退化为时间派生,保证不返回空串
|
||||
out[i] = b[(time.Now().UnixNano()+int64(i))%int64(len(b))]
|
||||
continue
|
||||
}
|
||||
out[i] = b[idx.Int64()]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// GeneratePushToken 生成消息推送 Token(3-32 位字母数字)
|
||||
func GeneratePushToken() string {
|
||||
return randomString(aesKeyAlphabet, 24)
|
||||
}
|
||||
|
||||
// GeneratePushAESKey 生成消息推送 EncodingAESKey(43 位字母数字)
|
||||
// 43 位 base64 字符解码后为 32 字节,符合微信 AES-256-CBC 的密钥长度要求。
|
||||
func GeneratePushAESKey() string {
|
||||
return randomString(aesKeyAlphabet, 43)
|
||||
}
|
||||
|
||||
// ============================ 消息解析 ============================
|
||||
|
||||
// PushMessage 小程序推送的消息 / 事件(已解密)
|
||||
type PushMessage struct {
|
||||
ToUserName string
|
||||
FromUserName string
|
||||
CreateTime int64
|
||||
MsgType string // text / image / event ...
|
||||
Content string
|
||||
Event string // subscribe / unsubscribe / SCAN / CLICK ...
|
||||
EventKey string
|
||||
MsgID int64
|
||||
Encrypt string // 兼容/安全模式外层携带的密文
|
||||
MsgSignature string
|
||||
TimeStamp string
|
||||
Nonce string
|
||||
}
|
||||
|
||||
// IsEvent 是否为事件推送
|
||||
func (m *PushMessage) IsEvent() bool { return strings.EqualFold(m.MsgType, "event") }
|
||||
|
||||
// pushXML 微信 XML 推送体
|
||||
type pushXML struct {
|
||||
XMLName struct{} `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Content string `xml:"Content"`
|
||||
Event string `xml:"Event"`
|
||||
EventKey string `xml:"EventKey"`
|
||||
MsgID int64 `xml:"MsgId"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
MsgSignature string `xml:"MsgSignature"`
|
||||
TimeStamp string `xml:"TimeStamp"`
|
||||
Nonce string `xml:"Nonce"`
|
||||
}
|
||||
|
||||
// pushJSON 微信 JSON 推送体
|
||||
type pushJSON struct {
|
||||
ToUserName string `json:"ToUserName"`
|
||||
FromUserName string `json:"FromUserName"`
|
||||
CreateTime int64 `json:"CreateTime"`
|
||||
MsgType string `json:"MsgType"`
|
||||
Content string `json:"Content"`
|
||||
Event string `json:"Event"`
|
||||
EventKey string `json:"EventKey"`
|
||||
MsgID int64 `json:"MsgId"`
|
||||
Encrypt string `json:"Encrypt"`
|
||||
MsgSignature string `json:"MsgSignature"`
|
||||
TimeStamp string `json:"TimeStamp"`
|
||||
Nonce string `json:"Nonce"`
|
||||
}
|
||||
|
||||
// ParsePushXML 解析 XML 推送体
|
||||
func ParsePushXML(raw string) (*PushMessage, error) {
|
||||
var p pushXML
|
||||
if err := xml.Unmarshal([]byte(raw), &p); err != nil {
|
||||
return nil, fmt.Errorf("解析微信推送消息失败: %w", err)
|
||||
}
|
||||
return &PushMessage{
|
||||
ToUserName: p.ToUserName,
|
||||
FromUserName: p.FromUserName,
|
||||
CreateTime: p.CreateTime,
|
||||
MsgType: p.MsgType,
|
||||
Content: p.Content,
|
||||
Event: p.Event,
|
||||
EventKey: p.EventKey,
|
||||
MsgID: p.MsgID,
|
||||
Encrypt: p.Encrypt,
|
||||
MsgSignature: p.MsgSignature,
|
||||
TimeStamp: p.TimeStamp,
|
||||
Nonce: p.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParsePushJSON 解析 JSON 推送体
|
||||
func ParsePushJSON(raw string) (*PushMessage, error) {
|
||||
var p pushJSON
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
return nil, fmt.Errorf("解析微信推送消息失败: %w", err)
|
||||
}
|
||||
return &PushMessage{
|
||||
ToUserName: p.ToUserName,
|
||||
FromUserName: p.FromUserName,
|
||||
CreateTime: p.CreateTime,
|
||||
MsgType: p.MsgType,
|
||||
Content: p.Content,
|
||||
Event: p.Event,
|
||||
EventKey: p.EventKey,
|
||||
MsgID: p.MsgID,
|
||||
Encrypt: p.Encrypt,
|
||||
MsgSignature: p.MsgSignature,
|
||||
TimeStamp: p.TimeStamp,
|
||||
Nonce: p.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DetectPushFormat 根据请求体判断数据格式(JSON 以 { 开头)
|
||||
func DetectPushFormat(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
return DataFormatJSON
|
||||
}
|
||||
return DataFormatXML
|
||||
}
|
||||
|
||||
// ============================ 被动回复构造 ============================
|
||||
|
||||
// BuildTextReply 构造文本被动回复(按数据格式输出 XML / JSON)
|
||||
func BuildTextReply(format, toUser, fromUser, content string, timestamp int64) (string, error) {
|
||||
if format == DataFormatJSON {
|
||||
payload := map[string]interface{}{
|
||||
"ToUserName": toUser,
|
||||
"FromUserName": fromUser,
|
||||
"CreateTime": timestamp,
|
||||
"MsgType": "text",
|
||||
"Content": content,
|
||||
}
|
||||
bs, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
esc := strings.NewReplacer("]]>", "]] >")
|
||||
return fmt.Sprintf(
|
||||
`<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName><CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>`,
|
||||
esc.Replace(toUser), esc.Replace(fromUser), timestamp, esc.Replace(content)), nil
|
||||
}
|
||||
|
||||
// BuildEncryptedReply 构造加密模式的被动回复(安全模式 / 兼容模式共用)
|
||||
func BuildEncryptedReply(cfg *PushConfig, plainReply, timestamp, nonce, format string) (string, error) {
|
||||
encrypt, err := wechatmp.EncryptMessage(cfg.AESKey, cfg.AppID, plainReply)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sig := wechatmp.MsgSignature(cfg.Token, timestamp, nonce, encrypt)
|
||||
if format == DataFormatJSON {
|
||||
payload := map[string]interface{}{
|
||||
"Encrypt": encrypt,
|
||||
"MsgSignature": sig,
|
||||
"TimeStamp": timestamp,
|
||||
"Nonce": nonce,
|
||||
}
|
||||
bs, merr := json.Marshal(payload)
|
||||
if merr != nil {
|
||||
return "", merr
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp>%s</TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`,
|
||||
encrypt, sig, timestamp, nonce), nil
|
||||
}
|
||||
|
||||
// ============================ 多应用匹配 ============================
|
||||
|
||||
// MatchPushConfigBySignature 按明文签名匹配小程序(sha1(sort(token,timestamp,nonce)))
|
||||
func MatchPushConfigBySignature(cfgs []PushConfig, timestamp, nonce, signature string) (*PushConfig, bool) {
|
||||
for i := range cfgs {
|
||||
if wechatmp.CheckSignature(cfgs[i].Token, timestamp, nonce, signature) {
|
||||
return &cfgs[i], true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// MatchPushConfigByMsgSignature 按加密消息签名匹配小程序
|
||||
// (sha1(sort(token,timestamp,nonce,encrypt)))
|
||||
func MatchPushConfigByMsgSignature(cfgs []PushConfig, timestamp, nonce, encrypt, msgSignature string) (*PushConfig, bool) {
|
||||
for i := range cfgs {
|
||||
if wechatmp.CheckMsgSignature(cfgs[i].Token, timestamp, nonce, encrypt, msgSignature) {
|
||||
return &cfgs[i], true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// DecryptPushMessage 用匹配的 AESKey 解密消息体(安全 / 兼容模式)
|
||||
func DecryptPushMessage(cfg *PushConfig, encrypt string) (string, error) {
|
||||
return wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, encrypt)
|
||||
}
|
||||
|
||||
// PushHandler 消息处理钩子:返回给用户的被动回复文案,空串表示不回复。
|
||||
// 默认按小程序配置的「客服自动回复文案」回复,可在业务层覆盖(如按关键字回复)。
|
||||
var PushHandler func(cfg *PushConfig, msg *PushMessage) string
|
||||
|
||||
// HandlePushMessage 处理推送消息并返回被动回复文案
|
||||
func HandlePushMessage(cfg *PushConfig, msg *PushMessage) string {
|
||||
if PushHandler != nil {
|
||||
return strings.TrimSpace(PushHandler(cfg, msg))
|
||||
}
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.ReplyText)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package wechatmini
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"server/services/wechatmp"
|
||||
)
|
||||
|
||||
// sign 复刻微信签名算法:sha1(sort(token, timestamp, nonce))
|
||||
func sign(token, timestamp, nonce string) string {
|
||||
arr := []string{token, timestamp, nonce}
|
||||
sort.Strings(arr)
|
||||
sum := sha1.Sum([]byte(strings.Join(arr, "")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestGeneratePushSecrets(t *testing.T) {
|
||||
token := GeneratePushToken()
|
||||
if len(token) < 3 || len(token) > 32 {
|
||||
t.Fatalf("Token 长度非法: %d", len(token))
|
||||
}
|
||||
for _, c := range token {
|
||||
if !strings.ContainsRune(aesKeyAlphabet, c) {
|
||||
t.Fatalf("Token 含非法字符: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
key := GeneratePushAESKey()
|
||||
if len(key) != 43 {
|
||||
t.Fatalf("EncodingAESKey 应为 43 位,实际 %d", len(key))
|
||||
}
|
||||
for _, c := range key {
|
||||
if !strings.ContainsRune(aesKeyAlphabet, c) {
|
||||
t.Fatalf("EncodingAESKey 含非法字符: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成的 EncodingAESKey 必须能被微信 AES-256-CBC 直接使用
|
||||
func TestGeneratedAESKeyUsable(t *testing.T) {
|
||||
key := GeneratePushAESKey()
|
||||
plain := `<xml><ToUserName>wx123</ToUserName></xml>`
|
||||
cipherText, err := wechatmp.EncryptMessage(key, "wx123", plain)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
got, err := wechatmp.DecryptMessage(key, "wx123", cipherText)
|
||||
if err != nil {
|
||||
t.Fatalf("解密失败: %v", err)
|
||||
}
|
||||
if got != plain {
|
||||
t.Fatalf("解密内容不一致: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPushConfigBySignature(t *testing.T) {
|
||||
cfgs := []PushConfig{
|
||||
{AppID: "wxA", Token: "tokenA", EncryptMode: wechatmp.EncryptModePlain, DataFormat: DataFormatXML},
|
||||
{AppID: "wxB", Token: "tokenB", EncryptMode: wechatmp.EncryptModeSafe, DataFormat: DataFormatJSON},
|
||||
}
|
||||
ts, nonce := "1700000000", "abc123"
|
||||
|
||||
cfg, ok := MatchPushConfigBySignature(cfgs, ts, nonce, sign("tokenB", ts, nonce))
|
||||
if !ok || cfg.AppID != "wxB" {
|
||||
t.Fatalf("应匹配到 wxB,实际 ok=%v cfg=%v", ok, cfg)
|
||||
}
|
||||
if _, ok := MatchPushConfigBySignature(cfgs, ts, nonce, "bad-signature"); ok {
|
||||
t.Fatal("错误签名不应匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePushXML(t *testing.T) {
|
||||
raw := `<xml><ToUserName>wxApp</ToUserName><FromUserName>oOpenId</FromUserName><CreateTime>1700000000</CreateTime><MsgType>text</MsgType><Content>你好</Content><MsgId>123456</MsgId></xml>`
|
||||
msg, err := ParsePushXML(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if msg.ToUserName != "wxApp" || msg.FromUserName != "oOpenId" || msg.Content != "你好" || msg.MsgID != 123456 {
|
||||
t.Fatalf("解析结果不符: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePushJSON(t *testing.T) {
|
||||
raw := `{"ToUserName":"wxApp","FromUserName":"oOpenId","CreateTime":1700000000,"MsgType":"text","Content":"hello","MsgId":987654}`
|
||||
msg, err := ParsePushJSON(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if msg.ToUserName != "wxApp" || msg.Content != "hello" || msg.MsgID != 987654 {
|
||||
t.Fatalf("解析结果不符: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPushFormat(t *testing.T) {
|
||||
if got := DetectPushFormat(`{"a":1}`); got != DataFormatJSON {
|
||||
t.Fatalf("应为 JSON,实际 %s", got)
|
||||
}
|
||||
if got := DetectPushFormat("<xml></xml>"); got != DataFormatXML {
|
||||
t.Fatalf("应为 XML,实际 %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTextReply(t *testing.T) {
|
||||
xmlReply, err := BuildTextReply(DataFormatXML, "oOpenId", "wxApp", "收到", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 XML 回复失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(xmlReply, "<Content><![CDATA[收到]]></Content>") {
|
||||
t.Fatalf("XML 回复内容不符: %s", xmlReply)
|
||||
}
|
||||
|
||||
jsonReply, err := BuildTextReply(DataFormatJSON, "oOpenId", "wxApp", "收到", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 JSON 回复失败: %v", err)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonReply), &payload); err != nil {
|
||||
t.Fatalf("JSON 回复不是合法 JSON: %v", err)
|
||||
}
|
||||
if payload["Content"] != "收到" || payload["MsgType"] != "text" {
|
||||
t.Fatalf("JSON 回复内容不符: %s", jsonReply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEncryptedReply(t *testing.T) {
|
||||
cfg := &PushConfig{AppID: "wxApp", Token: "token", AESKey: GeneratePushAESKey(), EncryptMode: wechatmp.EncryptModeSafe, DataFormat: DataFormatJSON}
|
||||
plain, err := BuildTextReply(DataFormatJSON, "oOpenId", "wxApp", "收到", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("构造明文回复失败: %v", err)
|
||||
}
|
||||
reply, err := BuildEncryptedReply(cfg, plain, "1700000000", "nonce1", DataFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("构造加密回复失败: %v", err)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(reply), &payload); err != nil {
|
||||
t.Fatalf("加密回复不是合法 JSON: %v", err)
|
||||
}
|
||||
enc, _ := payload["Encrypt"].(string)
|
||||
if enc == "" {
|
||||
t.Fatal("加密回复缺少 Encrypt")
|
||||
}
|
||||
if payload["MsgSignature"] != wechatmp.MsgSignature(cfg.Token, "1700000000", "nonce1", enc) {
|
||||
t.Fatal("加密回复签名不正确")
|
||||
}
|
||||
decrypted, err := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, enc)
|
||||
if err != nil {
|
||||
t.Fatalf("解密回复失败: %v", err)
|
||||
}
|
||||
if decrypted != plain {
|
||||
t.Fatalf("解密回复内容不符: %s", decrypted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package wechatmini
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 小程序订阅消息 / 客服消息发送
|
||||
//
|
||||
// 订阅消息(用户主动授权后才能收到):
|
||||
// POST /wxa/subscribe-message/send?access_token=xxx
|
||||
// {"touser":"openid","template_id":"xxx","page":"pages/index/index",
|
||||
// "data":{"thing1":{"value":"标题"},"time2":{"value":"2026-01-01 08:00"}}}
|
||||
//
|
||||
// 客服消息(用户在 48 小时内发过客服消息才能收到):
|
||||
// POST /cgi-bin/message/custom/send?access_token=xxx
|
||||
// {"touser":"openid","msgtype":"text","text":{"content":"..."}}
|
||||
//
|
||||
// 与「消息推送」(receive 方向)是两条不同的链路:
|
||||
// 消息推送 = 用户发给你(服务器收)
|
||||
// 订阅消息 = 你发给用户(用户收),需要用户在客户端授权
|
||||
// =============================================================
|
||||
|
||||
// 订阅消息常见错误码
|
||||
const (
|
||||
ErrCodeUserRefuse = 43101 // 用户未订阅 / 拒绝订阅(一次性订阅用尽)
|
||||
ErrCodeOpenIDInvalid = 40003 // openid 无效
|
||||
ErrCodeTplParamError = 47003 // 模板参数不匹配(字段键或类型不对)
|
||||
ErrCodeTokenInvalid = 40001 // access_token 无效或过期
|
||||
)
|
||||
|
||||
// ErrNoSubscribe 未找到可用的订阅授权
|
||||
var ErrNoSubscribe = errors.New("用户未授权订阅消息")
|
||||
|
||||
// SendSubscribeMessage 发送订阅消息。
|
||||
// data 为「微信字段键 → 文本值」,例如 {"thing1":"标题","time2":"2026-10-31 18:00"}
|
||||
func SendSubscribeMessage(cfg *Config, openID, templateID, page string, data map[string]string) error {
|
||||
if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
openID = strings.TrimSpace(openID)
|
||||
templateID = strings.TrimSpace(templateID)
|
||||
if openID == "" || templateID == "" {
|
||||
return errors.New("缺少 openid 或 template_id")
|
||||
}
|
||||
token, err := getAccessToken(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields := make(map[string]map[string]string, len(data))
|
||||
for k, v := range data {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
continue
|
||||
}
|
||||
fields[k] = map[string]string{"value": v}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"touser": openID,
|
||||
"template_id": templateID,
|
||||
"data": fields,
|
||||
"miniprogram_state": "formal",
|
||||
"lang": "zh_CN",
|
||||
}
|
||||
if strings.TrimSpace(page) != "" {
|
||||
payload["page"] = page
|
||||
}
|
||||
|
||||
api := fmt.Sprintf("%s/wxa/subscribe-message/send?access_token=%s", apiBase, url.QueryEscape(token))
|
||||
var resp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := httpPostJSON(api, payload, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return &APIError{API: "发送订阅消息", Code: resp.ErrCode, Msg: resp.ErrMsg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCustomerServiceText 发送客服消息(文本)。
|
||||
// 前提:用户 48 小时内向小程序客服发过消息,否则微信返回 43101 / 45009。
|
||||
func SendCustomerServiceText(cfg *Config, openID, content string) error {
|
||||
if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
openID = strings.TrimSpace(openID)
|
||||
content = strings.TrimSpace(content)
|
||||
if openID == "" || content == "" {
|
||||
return errors.New("缺少 openid 或消息内容")
|
||||
}
|
||||
token, err := getAccessToken(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"touser": openID,
|
||||
"msgtype": "text",
|
||||
"text": map[string]string{"content": content},
|
||||
}
|
||||
api := fmt.Sprintf("%s/cgi-bin/message/custom/send?access_token=%s", apiBase, url.QueryEscape(token))
|
||||
var resp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := httpPostJSON(api, payload, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return &APIError{API: "发送客服消息", Code: resp.ErrCode, Msg: resp.ErrMsg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExplainSubscribeError 订阅消息错误码的中文排查建议
|
||||
func ExplainSubscribeError(err error) string {
|
||||
var ae *APIError
|
||||
if !errors.As(err, &ae) {
|
||||
return ""
|
||||
}
|
||||
switch ae.Code {
|
||||
case ErrCodeUserRefuse:
|
||||
return "用户没有授权订阅(一次性订阅已用尽或被拒绝):请在小程序端引导用户重新点击订阅授权"
|
||||
case ErrCodeOpenIDInvalid:
|
||||
return "openid 无效:确认该用户是用这个小程序登录的,且 openid 属于同一个 AppID"
|
||||
case ErrCodeTplParamError:
|
||||
return "模板字段不匹配:检查模板配置的字段映射(微信字段键 → 变量名)是否与微信后台选的模板一致"
|
||||
case 40066:
|
||||
return "模板 ID 无效:请确认填的是小程序后台「功能 → 订阅消息」里选中的那个模板 ID"
|
||||
case ErrCodeTokenInvalid:
|
||||
return "access_token 失效:稍后重试,工具会自动刷新"
|
||||
case 41001:
|
||||
return "access_token 无效:请重新获取"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 模板配置(平台端维护)
|
||||
// =============================================================
|
||||
|
||||
// TemplateInput 保存模板入参
|
||||
type TemplateInput struct {
|
||||
ID uint64
|
||||
AppID string
|
||||
Code string
|
||||
TemplateID string
|
||||
Title string
|
||||
Page string
|
||||
Fields string // JSON 字符串
|
||||
Enabled bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
// ListTemplates 查询某小程序的模板列表
|
||||
func ListTemplates(appID string) ([]models.WechatMiniTemplate, error) {
|
||||
var rows []models.WechatMiniTemplate
|
||||
q := models.Orm.QueryTable(new(models.WechatMiniTemplate)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id")
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID != "" {
|
||||
q = q.Filter("app_id", appID)
|
||||
}
|
||||
_, err := q.All(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetTemplateByCode 按场景码取模板
|
||||
func GetTemplateByCode(appID, code string) (*models.WechatMiniTemplate, error) {
|
||||
var row models.WechatMiniTemplate
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniTemplate)).
|
||||
Filter("app_id", strings.TrimSpace(appID)).
|
||||
Filter("code", strings.TrimSpace(code)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// SaveTemplate 保存模板
|
||||
func SaveTemplate(in TemplateInput) error {
|
||||
appID := strings.TrimSpace(in.AppID)
|
||||
code := strings.TrimSpace(in.Code)
|
||||
if appID == "" {
|
||||
return errors.New("AppID 不能为空")
|
||||
}
|
||||
if code == "" {
|
||||
return errors.New("场景码不能为空")
|
||||
}
|
||||
tplID := strings.TrimSpace(in.TemplateID)
|
||||
if tplID == "" {
|
||||
return errors.New("模板 ID 不能为空")
|
||||
}
|
||||
fields := strings.TrimSpace(in.Fields)
|
||||
if fields != "" {
|
||||
var probe map[string]string
|
||||
if err := json.Unmarshal([]byte(fields), &probe); err != nil {
|
||||
return errors.New("字段映射必须是合法 JSON,例如 {\"thing1\":\"title\"}")
|
||||
}
|
||||
} else {
|
||||
fields = ""
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var row models.WechatMiniTemplate
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniTemplate)).
|
||||
Filter("app_id", appID).
|
||||
Filter("code", code).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
isNew := err == orm.ErrNoRows
|
||||
if isNew {
|
||||
row = models.WechatMiniTemplate{AppID: appID, Code: code, CreateTime: now}
|
||||
}
|
||||
row.TemplateID = tplID
|
||||
row.Title = strings.TrimSpace(in.Title)
|
||||
row.Page = strings.TrimSpace(in.Page)
|
||||
row.Fields = fields
|
||||
row.Remark = strings.TrimSpace(in.Remark)
|
||||
row.Enabled = 0
|
||||
if in.Enabled {
|
||||
row.Enabled = 1
|
||||
}
|
||||
row.UpdateTime = &now
|
||||
if isNew {
|
||||
_, err = models.Orm.Insert(&row)
|
||||
return err
|
||||
}
|
||||
_, err = models.Orm.Update(&row)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteTemplate 软删模板
|
||||
func DeleteTemplate(id uint64) error {
|
||||
if id == 0 {
|
||||
return errors.New("无效ID")
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniTemplate)).
|
||||
Filter("id", id).
|
||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
return err
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 订阅授权记录
|
||||
// =============================================================
|
||||
|
||||
// GrantSubscribe 记录用户授权(一次性订阅:每次授权 +1 次发送机会)
|
||||
func GrantSubscribe(identityID uint64, appID string, templateIDs []string) error {
|
||||
if identityID == 0 {
|
||||
return errors.New("未登录")
|
||||
}
|
||||
now := time.Now()
|
||||
for _, tid := range templateIDs {
|
||||
tid = strings.TrimSpace(tid)
|
||||
if tid == "" {
|
||||
continue
|
||||
}
|
||||
var row models.WechatMiniSubscribe
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniSubscribe)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("app_id", appID).
|
||||
Filter("template_id", tid).
|
||||
One(&row)
|
||||
if err == nil {
|
||||
row.Remain++
|
||||
row.UpdateTime = &now
|
||||
if _, uerr := models.Orm.Update(&row); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != orm.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
row = models.WechatMiniSubscribe{
|
||||
IdentityID: identityID,
|
||||
AppID: appID,
|
||||
TemplateID: tid,
|
||||
Remain: 1,
|
||||
CreateTime: now,
|
||||
}
|
||||
if _, ierr := models.Orm.Insert(&row); ierr != nil {
|
||||
return ierr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubscribeRemain 查询剩余可发送次数
|
||||
func SubscribeRemain(identityID uint64, appID, templateID string) int {
|
||||
var row models.WechatMiniSubscribe
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniSubscribe)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("app_id", appID).
|
||||
Filter("template_id", templateID).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return row.Remain
|
||||
}
|
||||
|
||||
// consumeSubscribe 消耗一次发送机会(成功发送后调用)
|
||||
func consumeSubscribe(identityID uint64, appID, templateID string) {
|
||||
now := time.Now()
|
||||
models.Orm.QueryTable(new(models.WechatMiniSubscribe)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("app_id", appID).
|
||||
Filter("template_id", templateID).
|
||||
Filter("remain__gt", 0).
|
||||
Update(map[string]interface{}{"remain": orm.ColValue(orm.ColMinus, 1), "update_time": now})
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 推送编排
|
||||
// =============================================================
|
||||
|
||||
// MiniOpenIDByIdentity 取身份绑定的小程序 openid(未绑定返回空串)
|
||||
func MiniOpenIDByIdentity(identityID uint64) string {
|
||||
if identityID == 0 {
|
||||
return ""
|
||||
}
|
||||
var row models.AuthIdentityThird
|
||||
err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("provider", "wechat_mini").
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row.OpenID)
|
||||
}
|
||||
|
||||
// PushResult 一次推送的结果汇总
|
||||
type PushResult struct {
|
||||
Sent int // 成功条数
|
||||
Skipped int // 跳过(未授权 / 未绑定 / 无模板)
|
||||
Errors []error // 失败原因
|
||||
}
|
||||
|
||||
// PushSubscribe 按场景向某个身份推送订阅消息。
|
||||
// vars 为语义变量(如 title / content / time),按模板配置的字段映射填入微信字段。
|
||||
func PushSubscribe(scene string, identityID uint64, vars map[string]string) PushResult {
|
||||
return pushSubscribe(scene, "", identityID, vars)
|
||||
}
|
||||
|
||||
// PushSubscribeToTenantUser 按场景向租户用户推送(appID 为空表示所有启用的小程序)
|
||||
func PushSubscribeToTenantUser(tenantUserID uint64, scene, appID string, vars map[string]string) PushResult {
|
||||
identityID, err := tenantUserIdentityID(tenantUserID)
|
||||
if err != nil || identityID == 0 {
|
||||
return PushResult{Skipped: 1}
|
||||
}
|
||||
return pushSubscribe(scene, appID, identityID, vars)
|
||||
}
|
||||
|
||||
func pushSubscribe(scene, appIDFilter string, identityID uint64, vars map[string]string) PushResult {
|
||||
res := PushResult{}
|
||||
if identityID == 0 {
|
||||
res.Skipped++
|
||||
return res
|
||||
}
|
||||
openID := MiniOpenIDByIdentity(identityID)
|
||||
if openID == "" {
|
||||
res.Skipped++
|
||||
return res
|
||||
}
|
||||
|
||||
apps, err := ListApps()
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Errorf("读取小程序列表失败: %w", err))
|
||||
return res
|
||||
}
|
||||
for _, app := range apps {
|
||||
if app.Enabled != 1 {
|
||||
continue
|
||||
}
|
||||
if appIDFilter != "" && app.AppID != appIDFilter {
|
||||
continue
|
||||
}
|
||||
tpl, terr := GetTemplateByCode(app.AppID, scene)
|
||||
if terr != nil || tpl.Enabled != 1 || tpl.TemplateID == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
if SubscribeRemain(identityID, app.AppID, tpl.TemplateID) <= 0 {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
cfg, cerr := LoadEnabledAppByAppID(app.AppID)
|
||||
if cerr != nil {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
data := buildTemplateData(tpl.Fields, vars)
|
||||
serr := SendSubscribeMessage(cfg, openID, tpl.TemplateID, tpl.Page, data)
|
||||
if serr != nil {
|
||||
// 用户拒绝 / 授权用尽:清零机会,避免反复调接口
|
||||
if ae, ok := serr.(*APIError); ok && ae.Code == ErrCodeUserRefuse {
|
||||
models.Orm.QueryTable(new(models.WechatMiniSubscribe)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("app_id", app.AppID).
|
||||
Filter("template_id", tpl.TemplateID).
|
||||
Update(map[string]interface{}{"remain": 0, "update_time": time.Now()})
|
||||
}
|
||||
res.Errors = append(res.Errors, fmt.Errorf("%s: %w", tpl.Title, serr))
|
||||
continue
|
||||
}
|
||||
consumeSubscribe(identityID, app.AppID, tpl.TemplateID)
|
||||
res.Sent++
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// buildTemplateData 按字段映射组装微信模板数据
|
||||
// mapping 形如 {"thing1":"title","phrase2":"content"}:微信字段键 → 语义变量名
|
||||
func buildTemplateData(mapping string, vars map[string]string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
m := map[string]string{}
|
||||
if strings.TrimSpace(mapping) != "" {
|
||||
_ = json.Unmarshal([]byte(mapping), &m)
|
||||
}
|
||||
for field, semantic := range m {
|
||||
val, ok := vars[semantic]
|
||||
if !ok {
|
||||
// 映射里的变量没有值时给个占位,避免微信报字段缺失
|
||||
val = "-"
|
||||
}
|
||||
out[field] = val
|
||||
}
|
||||
// 变量表里多出来的键也直接按微信字段名填(允许不配映射直接用 thing1 这种键)
|
||||
for k, v := range vars {
|
||||
if _, exists := out[k]; !exists {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PushTenantNotice 推送站内通知到小程序(按租户用户)
|
||||
func PushTenantNotice(tenantUserID uint64, title, content string) PushResult {
|
||||
vars := map[string]string{
|
||||
"title": title,
|
||||
"content": content,
|
||||
"time": time.Now().Format("2006-01-02 15:04"),
|
||||
}
|
||||
return PushSubscribeToTenantUser(tenantUserID, SceneNotice, "", vars)
|
||||
}
|
||||
|
||||
// 场景码
|
||||
const (
|
||||
SceneNotice = "notice" // 站内通知 / 审批待办等通用提醒
|
||||
)
|
||||
|
||||
// tenantUserIdentityID 租户用户 ID → 身份 ID(yz_system_tenant_user.uid)
|
||||
func tenantUserIdentityID(tenantUserID uint64) (uint64, error) {
|
||||
if tenantUserID == 0 {
|
||||
return 0, errors.New("无效用户")
|
||||
}
|
||||
var row models.SystemTenantUser
|
||||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("id", tenantUserID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.Uid, nil
|
||||
}
|
||||
Reference in new issue
Block a user