Files
yunzerwebsiteallinone/go/controllers/platform_wechat_mp.go
T
2026-09-30 00:57:36 +08:00

729 lines
21 KiB
Go
Raw 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 controllers
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/wechatmp"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// 平台端「微信公众号(服务号)」:
// 配置(AppID/Secret/Token/AESKey)、绑定(扫码关注取验证码)、粉丝管理、消息推送
// 未认证阶段可完整跑通「扫码关注 → 公众号回复验证码 → 平台核销绑定」;
// 认证后开启模板消息推送(提醒/公告)。
// =============================================================
// PlatformWechatMpController 平台端微信公众号管理
type PlatformWechatMpController struct {
beego.Controller
}
func (c *PlatformWechatMpController) claims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "platform" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *PlatformWechatMpController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformWechatMpController) ok(data interface{}, msg string) {
if msg == "" {
msg = "success"
}
resp := map[string]interface{}{"code": 200, "msg": msg}
if data != nil {
resp["data"] = data
}
c.Data["json"] = resp
_ = c.ServeJSON()
}
func (c *PlatformWechatMpController) readBody(out interface{}) bool {
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return false
}
if err := json.Unmarshal(raw, out); err != nil {
c.jsonErr(400, 400, "参数错误")
return false
}
return true
}
// ============================ 配置 ============================
// GetConfig GET /platform/wechatMp/config
func (c *PlatformWechatMpController) GetConfig() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
data := map[string]interface{}{
"app_id": "",
"app_secret": "",
"token": "",
"aes_key": "",
"encrypt_mode": wechatmp.EncryptModePlain,
"template_id": "",
"verified": 0,
"enabled": 0,
"remark": "",
"configured": false,
"callback_url": wechatmp.CallbackURL(),
}
if cfg, err := wechatmp.LoadConfig(); err == nil {
data["app_id"] = cfg.AppID
data["app_secret"] = wechatmp.MaskSecret(cfg.AppSecret)
data["token"] = cfg.Token
data["aes_key"] = wechatmp.MaskSecret(cfg.AESKey)
data["encrypt_mode"] = cfg.EncryptMode
data["template_id"] = cfg.TemplateID
data["remark"] = cfg.Remark
data["follow_qrcode"] = cfg.FollowQrcode
data["configured"] = cfg.AppID != ""
if cfg.Verified {
data["verified"] = 1
}
if cfg.Enabled {
data["enabled"] = 1
}
}
c.ok(data, "获取成功")
}
// SaveConfig POST /platform/wechatMp/config
func (c *PlatformWechatMpController) SaveConfig() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var p struct {
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
Token string `json:"token"`
AESKey string `json:"aes_key"`
EncryptMode string `json:"encrypt_mode"`
TemplateID string `json:"template_id"`
Verified bool `json:"verified"`
Enabled bool `json:"enabled"`
Remark string `json:"remark"`
}
if !c.readBody(&p) {
return
}
if strings.TrimSpace(p.AppID) == "" {
c.jsonErr(400, 400, "AppID 不能为空")
return
}
err := wechatmp.SaveConfig(wechatmp.SaveInput{
AppID: p.AppID,
AppSecret: p.AppSecret,
Token: p.Token,
AESKey: p.AESKey,
EncryptMode: p.EncryptMode,
TemplateID: p.TemplateID,
Verified: p.Verified,
Enabled: p.Enabled,
Remark: p.Remark,
})
if err != nil {
c.jsonErr(400, 400, "保存失败:"+err.Error())
return
}
c.ok(nil, "保存成功")
}
// TestConnection POST /platform/wechatMp/test
// 使用当前配置获取 access_token,验证 AppID/AppSecret 是否正确
func (c *PlatformWechatMpController) TestConnection() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
cfg, err := wechatmp.LoadConfig()
if err != nil || cfg.AppID == "" || cfg.AppSecret == "" {
c.jsonErr(400, 400, "请先保存 AppID 与 AppSecret")
return
}
token, err := wechatmp.GetAccessToken(cfg)
if err != nil {
msg := "连接失败:" + err.Error()
if hint, ok := wechatmp.ExplainError(err); ok {
msg += ";" + hint
}
c.jsonErr(400, 400, msg)
return
}
c.ok(map[string]interface{}{
"access_token_suffix": wechatmp.MaskSecret(token),
"callback_url": wechatmp.CallbackURL(),
"verified": boolToInt8(cfg.Verified),
}, "连接成功:AppID 与 AppSecret 校验通过")
}
// ============================ 绑定(当前平台用户) ============================
// BindStart POST /platform/wechatMp/bind/start
// 未认证公众号无法生成带参二维码(48001):不再下发二维码,
// 返回绑定指引(公众号内发送「验证码」取码 → 页面填码 → bind/confirm 核销)。
func (c *PlatformWechatMpController) BindStart() {
claims, err := c.claims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
uid := uint64(claims.UserID)
if uid == 0 {
c.jsonErr(401, 401, "未获取到用户信息")
return
}
if _, err := wechatmp.LoadEnabledConfig(); err != nil {
c.jsonErr(400, 400, "公众号未启用:"+err.Error())
return
}
c.ok(map[string]interface{}{
"mode": "mp_message",
"guides": wechatmp.BindGuides(),
"follow_qrcode": wechatmp.FollowQrcodeURL(),
"expires_in": int(wechatmp.DefaultVerifyTTL.Seconds()),
"callback_url": wechatmp.CallbackURL(),
}, "请按指引完成绑定")
}
// ============================ 自定义菜单 ============================
// PublishMenu POST /platform/wechatMp/menu/publish
// 通过接口在公众号底部发布「扫码登录」菜单(点击 → 自动回复确认登录链接)。
// 需认证公众号;未认证(48001)时提示改用「公众号内发送登录」路径。
func (c *PlatformWechatMpController) PublishMenu() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
cfg, err := wechatmp.LoadEnabledConfig()
if err != nil {
c.jsonErr(400, 400, "公众号未启用:"+err.Error())
return
}
res, merr := wechatmp.PublishLoginMenu(cfg)
if merr != nil {
msg := "发布菜单失败:" + merr.Error()
if hint, ok := wechatmp.ExplainError(merr); ok {
msg += ";" + hint
}
c.jsonErr(400, 400, msg)
return
}
// 微信客户端有菜单缓存:发布后需重新关注或等待(最长 24 小时)才可见
msg := "菜单已发布:微信客户端有缓存,请重新关注公众号或等待刷新(最长 24 小时)后可见「扫码登录」"
if res.AlreadyExists {
msg = "菜单中已存在「扫码登录」入口,未重复发布;若底部未显示,请等待客户端刷新或重新关注"
}
if res.ConditionalMenuCount > 0 {
msg += fmt.Sprintf(";检测到个性化菜单 %d 条,个性化菜单会覆盖默认菜单,请到公众号后台确认",
res.ConditionalMenuCount)
}
c.ok(map[string]interface{}{
"menu": res.Buttons,
"already_exists": res.AlreadyExists,
"conditional_menu_count": res.ConditionalMenuCount,
}, msg)
}
// ============================ 关注二维码 ============================
// 二维码图片限制:2MB,常见图片格式
const followQrcodeMaxBytes = 2 * 1024 * 1024
var followQrcodeAllowedExts = map[string]bool{
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true,
}
// saveFollowQrcode 校验图片并写入配置表(data URL 存储),返回 data URL
func (c *PlatformWechatMpController) saveFollowQrcode() (string, error) {
if err := c.Ctx.Request.ParseMultipartForm(followQrcodeMaxBytes); err != nil {
return "", fmt.Errorf("解析上传失败: %w", err)
}
fh, header, err := c.GetFile("file")
if err != nil || fh == nil {
return "", fmt.Errorf("请选择要上传的二维码图片")
}
defer fh.Close()
if header != nil && header.Size > followQrcodeMaxBytes {
return "", fmt.Errorf("图片大小不能超过 2MB")
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(header.Filename), "."))
if !followQrcodeAllowedExts[ext] {
return "", fmt.Errorf("仅支持 jpg / png / gif / webp / bmp 格式")
}
raw, err := io.ReadAll(fh)
if err != nil {
return "", fmt.Errorf("读取图片失败: %w", err)
}
if len(raw) == 0 {
return "", fmt.Errorf("图片内容为空")
}
mime := "image/png"
switch ext {
case "jpg", "jpeg":
mime = "image/jpeg"
case "gif":
mime = "image/gif"
case "webp":
mime = "image/webp"
case "bmp":
mime = "image/bmp"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
}
// UploadFollowQrcode POST /platform/wechatMp/followQrcode
// 上传公众号「关注二维码」图片(普通二维码,用户扫码进入公众号会话)
func (c *PlatformWechatMpController) UploadFollowQrcode() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
dataURL, err := c.saveFollowQrcode()
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
if err := wechatmp.SaveFollowQrcode(dataURL); err != nil {
c.jsonErr(500, 500, "保存失败:"+err.Error())
return
}
c.ok(map[string]interface{}{"follow_qrcode": dataURL}, "上传成功")
}
// DeleteFollowQrcode POST /platform/wechatMp/followQrcode/delete
func (c *PlatformWechatMpController) DeleteFollowQrcode() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
if err := wechatmp.SaveFollowQrcode(""); err != nil {
c.jsonErr(500, 500, "删除失败:"+err.Error())
return
}
c.ok(nil, "已删除")
}
// BindStatus GET /platform/wechatMp/bind/status?scene=xxx
func (c *PlatformWechatMpController) BindStatus() {
claims, err := c.claims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
scene := strings.TrimSpace(c.GetString("scene"))
row, err := wechatmp.GetVerifyCode(scene)
if err != nil {
c.jsonErr(404, 404, err.Error())
return
}
if row.BindType != models.WechatBindTypePlatformUser || row.BindID != uint64(claims.UserID) {
c.jsonErr(403, 403, "该二维码不是当前账号发起,请重新获取")
return
}
c.ok(map[string]interface{}{
"status": row.Status,
"expire_at": row.ExpireAt,
}, "获取成功")
}
// BindConfirm POST /platform/wechatMp/bind/confirm {scene, code}
func (c *PlatformWechatMpController) BindConfirm() {
claims, err := c.claims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var p struct {
Scene string `json:"scene"`
Code string `json:"code"`
}
if !c.readBody(&p) {
return
}
if strings.TrimSpace(p.Code) == "" {
c.jsonErr(400, 400, "请输入公众号返回的验证码")
return
}
var follower *models.WechatMpFollower
if strings.TrimSpace(p.Scene) == "" {
// 未展示二维码(在公众号内发送「验证码」获取):按验证码认领待认领会话
follower, err = wechatmp.ConfirmVerifyCodeByCode(models.WechatBindTypePlatformUser, uint64(claims.UserID), 0, p.Code)
} else {
follower, err = wechatmp.ConfirmVerifyCode(models.WechatBindTypePlatformUser, uint64(claims.UserID), 0, p.Scene, p.Code)
}
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{
"openid": follower.OpenID,
"nickname": follower.Nickname,
"bind_time": follower.BindTime,
}, "绑定成功")
}
// MyBinding GET /platform/wechatMp/my
func (c *PlatformWechatMpController) MyBinding() {
claims, err := c.claims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
follower, err := wechatmp.GetFollowerByUser(models.WechatBindTypePlatformUser, uint64(claims.UserID))
if err != nil {
c.jsonErr(500, 500, "查询失败:"+err.Error())
return
}
if follower == nil {
c.ok(map[string]interface{}{"bound": false}, "获取成功")
return
}
c.ok(map[string]interface{}{
"bound": true,
"openid": follower.OpenID,
"nickname": follower.Nickname,
"avatar": follower.Avatar,
"subscribe": follower.Subscribe,
"bind_time": follower.BindTime,
"subscribe_time": follower.SubscribeTime,
}, "获取成功")
}
// Unbind POST /platform/wechatMp/unbind
func (c *PlatformWechatMpController) Unbind() {
claims, err := c.claims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
if err := wechatmp.UnbindByUser(models.WechatBindTypePlatformUser, uint64(claims.UserID)); err != nil {
c.jsonErr(500, 500, "解绑失败:"+err.Error())
return
}
c.ok(nil, "已解绑")
}
// ============================ 粉丝管理 ============================
// ListFollowers GET /platform/wechatMp/followers?page=&pageSize=&keyword=&bind_type=&subscribe=
func (c *PlatformWechatMpController) ListFollowers() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 20
}
qs := models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("delete_time__isnull", true)
if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" {
cond := orm.NewCondition().
Or("nickname__icontains", kw).
Or("openid__icontains", kw).
Or("remark__icontains", kw)
qs = qs.SetCond(cond)
}
if bindType := strings.TrimSpace(c.GetString("bind_type")); bindType != "" {
qs = qs.Filter("bind_type", bindType)
}
if sub := strings.TrimSpace(c.GetString("subscribe")); sub == "0" || sub == "1" {
qs = qs.Filter("subscribe", sub)
}
total, err := qs.Count()
if err != nil {
c.jsonErr(500, 500, "获取失败:"+err.Error())
return
}
var rows []models.WechatMpFollower
if _, err := qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows); err != nil {
c.jsonErr(500, 500, "获取失败:"+err.Error())
return
}
// 绑定对象名称(平台用户 / 租户用户)
nameMap := loadWechatBindNames(rows)
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
r := rows[i]
nickname := r.Nickname
if strings.TrimSpace(nickname) == "" {
nickname = maskOpenID(r.OpenID)
}
list = append(list, map[string]interface{}{
"id": r.ID,
"openid": r.OpenID,
"nickname": nickname,
"avatar": r.Avatar,
"sex": r.Sex,
"city": r.City,
"subscribe": r.Subscribe,
"subscribe_time": r.SubscribeTime,
"unsubscribe_time": r.UnsubscribeTime,
"bind_type": r.BindType,
"bind_id": r.BindID,
"bind_tid": r.BindTid,
"bind_name": nameMap[r.BindType+"#"+fmt.Sprintf("%d", r.BindID)+"#"+fmt.Sprintf("%d", r.BindTid)],
"bind_time": r.BindTime,
"remark": r.Remark,
"create_time": r.CreateTime,
})
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize}, "获取成功")
}
// SetFollowerRemark POST /platform/wechatMp/follower/remark {id, remark}
func (c *PlatformWechatMpController) SetFollowerRemark() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var p struct {
ID uint64 `json:"id"`
Remark string `json:"remark"`
}
if !c.readBody(&p) {
return
}
if p.ID == 0 {
c.jsonErr(400, 400, "参数错误")
return
}
now := time.Now()
if _, err := models.Orm.QueryTable(new(models.WechatMpFollower)).
Filter("id", p.ID).
Update(map[string]interface{}{"remark": strings.TrimSpace(p.Remark), "update_time": now}); err != nil {
c.jsonErr(500, 500, "保存失败:"+err.Error())
return
}
c.ok(nil, "保存成功")
}
// UnbindFollower POST /platform/wechatMp/follower/unbind {id}
// 仅清除绑定关系(保留粉丝记录)
func (c *PlatformWechatMpController) UnbindFollower() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var p struct {
ID uint64 `json:"id"`
}
if !c.readBody(&p) {
return
}
if p.ID == 0 {
c.jsonErr(400, 400, "参数错误")
return
}
now := time.Now()
if _, err := models.Orm.QueryTable(new(models.WechatMpFollower)).
Filter("id", p.ID).
Update(map[string]interface{}{
"bind_type": "", "bind_id": 0, "bind_tid": 0, "update_time": now,
}); err != nil {
c.jsonErr(500, 500, "解绑失败:"+err.Error())
return
}
c.ok(nil, "已解绑")
}
// ============================ 推送 ============================
// Push POST /platform/wechatMp/push
// {title, content, remark, url, bind_type, bind_id, tid}
func (c *PlatformWechatMpController) Push() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var p struct {
Title string `json:"title"`
Content string `json:"content"`
Remark string `json:"remark"`
URL string `json:"url"`
BindType string `json:"bind_type"`
BindID uint64 `json:"bind_id"`
Tid uint64 `json:"tid"`
}
if !c.readBody(&p) {
return
}
if strings.TrimSpace(p.Title) == "" && strings.TrimSpace(p.Content) == "" {
c.jsonErr(400, 400, "请填写推送内容")
return
}
cfg, err := wechatmp.LoadEnabledConfig()
if err != nil {
c.jsonErr(400, 400, "请先启用并配置微信公众号:"+err.Error())
return
}
sent, failed, perr := wechatmp.PushToFollowers(cfg, wechatmp.PushScope{
BindType: strings.TrimSpace(p.BindType),
BindID: p.BindID,
Tid: p.Tid,
}, wechatmp.PushMessage{
Title: strings.TrimSpace(p.Title),
Content: strings.TrimSpace(p.Content),
Remark: strings.TrimSpace(p.Remark),
URL: strings.TrimSpace(p.URL),
})
if perr != nil {
c.jsonErr(400, 400, perr.Error())
return
}
c.ok(map[string]interface{}{"sent": sent, "failed": failed},
fmt.Sprintf("推送完成:成功 %d 条,失败 %d 条", sent, failed))
}
// PushTest POST /platform/wechatMp/push/test {title, content, remark}
// 推送给当前登录平台用户绑定的微信
func (c *PlatformWechatMpController) PushTest() {
claims, err := c.claims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var p struct {
Title string `json:"title"`
Content string `json:"content"`
Remark string `json:"remark"`
}
if !c.readBody(&p) {
return
}
cfg, err := wechatmp.LoadEnabledConfig()
if err != nil {
c.jsonErr(400, 400, "请先启用并配置微信公众号:"+err.Error())
return
}
follower, err := wechatmp.GetFollowerByUser(models.WechatBindTypePlatformUser, uint64(claims.UserID))
if err != nil || follower == nil {
c.jsonErr(400, 400, "当前账号尚未绑定微信,请先扫码绑定")
return
}
if follower.Subscribe != 1 {
c.jsonErr(400, 400, "该微信已取消关注,请重新关注后再试")
return
}
if p.Title == "" {
p.Title = "平台消息测试"
}
if p.Content == "" {
p.Content = "这是一条来自平台的测试消息,收到说明推送链路正常。"
}
if err := wechatmp.PushToOpenID(cfg, follower.OpenID, wechatmp.PushMessage{
Title: strings.TrimSpace(p.Title),
Content: strings.TrimSpace(p.Content),
Remark: strings.TrimSpace(p.Remark),
}); err != nil {
c.jsonErr(400, 400, "推送失败:"+err.Error())
return
}
c.ok(nil, "测试消息已发送")
}
// ============================ 辅助 ============================
func boolToInt8(b bool) int8 {
if b {
return 1
}
return 0
}
func maskOpenID(openid string) string {
if len(openid) <= 8 {
return openid
}
return openid[:4] + "****" + openid[len(openid)-4:]
}
// loadWechatBindNames 批量加载粉丝绑定对象名称:key = bindType#bindID#bindTid
func loadWechatBindNames(rows []models.WechatMpFollower) map[string]string {
names := map[string]string{}
if len(rows) == 0 {
return names
}
adminIDs := make([]uint64, 0)
tenantIDs := make([]uint64, 0)
for _, r := range rows {
switch r.BindType {
case models.WechatBindTypePlatformUser:
adminIDs = append(adminIDs, r.BindID)
case models.WechatBindTypeTenantUser:
tenantIDs = append(tenantIDs, r.BindID)
}
}
if len(adminIDs) > 0 {
var admins []models.AdminUser
if _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id__in", adminIDs).All(&admins, "ID", "Account", "Name"); err == nil {
for _, a := range admins {
name := a.Account
if a.Name != nil && strings.TrimSpace(*a.Name) != "" {
name = *a.Name
}
names[models.WechatBindTypePlatformUser+"#"+fmt.Sprintf("%d", a.ID)+"#0"] = name
}
}
}
if len(tenantIDs) > 0 {
var users []models.SystemTenantUser
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("uid__in", tenantIDs).All(&users, "UID", "Tid", "Account", "Name"); err == nil {
for _, u := range users {
name := ""
if u.Account != nil {
name = *u.Account
}
if u.Name != nil && strings.TrimSpace(*u.Name) != "" {
name = *u.Name
}
names[models.WechatBindTypeTenantUser+"#"+fmt.Sprintf("%d", u.Uid)+"#"+fmt.Sprintf("%d", u.Tid)] = name
}
}
}
return names
}