489 lines
14 KiB
Go
489 lines
14 KiB
Go
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
|
||
}
|