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 }