package controllers import ( "encoding/json" "fmt" "io" "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["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 // 生成带参二维码;用户扫码关注后公众号将被动回复验证码 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 } scene, _, qrURL, expireAt, err := wechatmp.CreateVerifyCode(models.WechatBindTypePlatformUser, uid, 0, 0) if err != nil { c.jsonErr(400, 400, "生成二维码失败:"+err.Error()) return } c.ok(map[string]interface{}{ "scene": scene, "qrcode_url": qrURL, "expire_at": expireAt, "expires_in": int(time.Until(expireAt).Seconds()), "callback_url": wechatmp.CallbackURL(), }, "生成成功") } // 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 }