Files
2026-10-31 19:17:37 +08:00

489 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}