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 }