diff --git a/backend/src/api/wechatMp.js b/backend/src/api/wechatMp.js new file mode 100644 index 0000000..32e91b6 --- /dev/null +++ b/backend/src/api/wechatMp.js @@ -0,0 +1,48 @@ +import request from "@/utils/request"; + +/************************************************* + * 微信公众号绑定(租户端) + * 流程:生成二维码 → 微信扫码关注 → 公众号回复验证码 → 输入验证码完成绑定 + *************************************************/ + +/** 发起绑定:生成带参数二维码 */ +export function startWechatMpBind() { + return request({ + url: "/backend/wechatMp/bind/start", + method: "post", + }); +} + +/** 查询绑定会话状态:0待扫码 1已扫码待核销 2已核销 3已过期 */ +export function getWechatMpBindStatus(scene) { + return request({ + url: "/backend/wechatMp/bind/status", + method: "get", + params: { scene }, + }); +} + +/** 核销验证码完成绑定 */ +export function confirmWechatMpBind(data) { + return request({ + url: "/backend/wechatMp/bind/confirm", + method: "post", + data, + }); +} + +/** 当前租户用户的绑定信息 */ +export function getMyWechatMpBinding() { + return request({ + url: "/backend/wechatMp/my", + method: "get", + }); +} + +/** 解除当前用户的微信绑定 */ +export function unbindWechatMp() { + return request({ + url: "/backend/wechatMp/unbind", + method: "post", + }); +} diff --git a/backend/src/views/user/components/WechatBindCard.vue b/backend/src/views/user/components/WechatBindCard.vue new file mode 100644 index 0000000..c5cc983 --- /dev/null +++ b/backend/src/views/user/components/WechatBindCard.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/backend/src/views/user/userProfile.vue b/backend/src/views/user/userProfile.vue index b49550d..1075bc4 100644 --- a/backend/src/views/user/userProfile.vue +++ b/backend/src/views/user/userProfile.vue @@ -24,6 +24,9 @@ @bind-phone="bindPhoneDialogVisible = true" @bind-email="bindEmailDialogVisible = true" /> + + + @@ -71,6 +74,7 @@ import EditProfileDialog from "./components/EditProfileDialog.vue"; import ChangePasswordDialog from "./components/ChangePasswordDialog.vue"; import BindPhoneDialog from "./components/BindPhoneDialog.vue"; import BindEmailDialog from "./components/BindEmailDialog.vue"; +import WechatBindCard from "./components/WechatBindCard.vue"; const router = useRouter(); const authStore = useAuthStore(); diff --git a/go/conf/app.conf b/go/conf/app.conf index f7e2e98..017fa30 100644 --- a/go/conf/app.conf +++ b/go/conf/app.conf @@ -53,6 +53,13 @@ acme_directory = # Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。 ssl_cert_dir = /www/wwwroot/ssl-certs +# ==================== 微信公众号(服务号) ==================== +# 服务号 AppSecret / EncodingAESKey 入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。 +# 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。 +wechat_mp_secret_key = 8f3c2a1d94b74e28a6c5f0187d3e9b4c72a5d0e6f13b8c47d92a6e5f0b7c3148 +# 微信服务器回调地址基址(默认回落 payment_callback_base,即 https://api.yunzer.cn): +# wechat_mp_callback_base = https://api.yunzer.cn + # ==================== 支付模块 ==================== # 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。 # 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。 diff --git a/go/controllers/api_wechat_mp.go b/go/controllers/api_wechat_mp.go new file mode 100644 index 0000000..30d3925 --- /dev/null +++ b/go/controllers/api_wechat_mp.go @@ -0,0 +1,124 @@ +package controllers + +import ( + "io" + "strconv" + "strings" + "time" + + "server/services/wechatmp" + + beego "github.com/beego/beego/v2/server/web" +) + +// WechatMpCallbackController 微信公众号服务器回调(微信服务器调用,无需平台鉴权) +// +// 公众号后台「设置与开发 → 服务器配置」填写: +// URL(服务器地址):https://<你的域名>/api/wechat/mp/callback +// Token:与平台「通知设置 → 微信配置」中的 Token 保持一致 +// 消息加解密方式:明文 / 兼容 / 安全(三种均支持) +type WechatMpCallbackController struct { + beego.Controller +} + +// Callback 服务器地址验证(GET,回显 echostr)与消息/事件推送(POST,被动回复) +func (c *WechatMpCallbackController) Callback() { + cfg, err := wechatmp.LoadEnabledConfig() + if err != nil { + c.Ctx.Output.SetStatus(403) + c.Ctx.Output.Body([]byte("wechat mp not configured")) + return + } + + q := c.Ctx.Request.URL.Query() + timestamp := q.Get("timestamp") + nonce := q.Get("nonce") + + // ---------------- GET:服务器地址校验 ---------------- + if strings.EqualFold(c.Ctx.Input.Method(), "GET") { + echostr := q.Get("echostr") + if cfg.EncryptMode == wechatmp.EncryptModeSafe { + if !wechatmp.CheckMsgSignature(cfg.Token, timestamp, nonce, echostr, q.Get("msg_signature")) { + c.Ctx.Output.SetStatus(403) + c.Ctx.Output.Body([]byte("invalid signature")) + return + } + plain, derr := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, echostr) + if derr != nil { + c.Ctx.Output.SetStatus(403) + c.Ctx.Output.Body([]byte("decrypt failed")) + return + } + c.Ctx.Output.Body([]byte(plain)) + return + } + if !wechatmp.CheckSignature(cfg.Token, timestamp, nonce, q.Get("signature")) { + c.Ctx.Output.SetStatus(403) + c.Ctx.Output.Body([]byte("invalid signature")) + return + } + c.Ctx.Output.Body([]byte(echostr)) + return + } + + // ---------------- POST:消息 / 事件 ---------------- + body, _ := io.ReadAll(c.Ctx.Request.Body) + rawBody := string(body) + if strings.TrimSpace(rawBody) == "" { + c.Ctx.Output.Body([]byte("")) + return + } + + needEncryptReply := false + if strings.Contains(rawBody, "") { + // 安全/兼容模式:外层为加密 XML,先验签再解密 + outer, perr := wechatmp.ParseInboundXML(rawBody) + if perr != nil { + c.Ctx.Output.Body([]byte("")) + return + } + if !wechatmp.CheckMsgSignature(cfg.Token, timestamp, nonce, outer.Encrypt, q.Get("msg_signature")) { + c.Ctx.Output.Body([]byte("")) + return + } + plain, derr := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, outer.Encrypt) + if derr != nil { + c.Ctx.Output.Body([]byte("")) + return + } + rawBody = plain + needEncryptReply = true + } else if !wechatmp.CheckSignature(cfg.Token, timestamp, nonce, q.Get("signature")) { + // 明文模式:签名校验不通过直接拒绝(微信会重试) + c.Ctx.Output.Body([]byte("")) + return + } + + msg, merr := wechatmp.ParseInboundXML(rawBody) + if merr != nil { + c.Ctx.Output.Body([]byte("")) + return + } + + reply := wechatmp.HandleInbound(cfg, msg) + if strings.TrimSpace(reply) == "" { + // 无需回复:返回空串,微信视为处理成功 + c.Ctx.Output.Body([]byte("")) + return + } + + now := time.Now().Unix() + plainReply := wechatmp.BuildTextReply(msg.FromUserName, msg.ToUserName, reply, now) + c.Ctx.Output.Header("Content-Type", "application/xml; charset=utf-8") + if needEncryptReply { + ts := strconv.FormatInt(now, 10) + enc, eerr := wechatmp.BuildEncryptedReply(cfg, plainReply, ts, nonce) + if eerr != nil { + c.Ctx.Output.Body([]byte("")) + return + } + c.Ctx.Output.Body([]byte(enc)) + return + } + c.Ctx.Output.Body([]byte(plainReply)) +} diff --git a/go/controllers/backend_wechat_mp.go b/go/controllers/backend_wechat_mp.go new file mode 100644 index 0000000..502d612 --- /dev/null +++ b/go/controllers/backend_wechat_mp.go @@ -0,0 +1,202 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services/wechatmp" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 租户端「微信公众号绑定」: +// 扫码关注公众号 → 公众号被动回复验证码 → 本端输入验证码完成绑定 +// 绑定后(公众号认证后)可接收平台/租户侧的提醒、公告推送 +// ============================================================= + +// BackendWechatMpController 租户端微信公众号绑定 +type BackendWechatMpController struct { + beego.Controller +} + +func (c *BackendWechatMpController) 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 != "backend" && claims.UserType != "app" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendWechatMpController) 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 *BackendWechatMpController) 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 *BackendWechatMpController) 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 +} + +// BindStart POST /backend/wechatMp/bind/start +func (c *BackendWechatMpController) BindStart() { + claims, err := c.claims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + uid := uint64(claims.UserID) + tid := uint64(claims.TenantId) + if uid == 0 || tid == 0 { + c.jsonErr(401, 401, "未获取到用户信息") + return + } + scene, _, qrURL, expireAt, err := wechatmp.CreateVerifyCode(models.WechatBindTypeTenantUser, uid, tid, 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()), + }, "生成成功") +} + +// BindStatus GET /backend/wechatMp/bind/status?scene=xxx +func (c *BackendWechatMpController) 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.WechatBindTypeTenantUser || row.BindID != uint64(claims.UserID) { + c.jsonErr(403, 403, "该二维码不是当前账号发起,请重新获取") + return + } + c.ok(map[string]interface{}{ + "status": row.Status, + "expire_at": row.ExpireAt, + }, "获取成功") +} + +// BindConfirm POST /backend/wechatMp/bind/confirm {scene, code} +func (c *BackendWechatMpController) 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 + } + follower, err := wechatmp.ConfirmVerifyCode( + models.WechatBindTypeTenantUser, + uint64(claims.UserID), + uint64(claims.TenantId), + 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 /backend/wechatMp/my +func (c *BackendWechatMpController) MyBinding() { + claims, err := c.claims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + follower, err := wechatmp.GetFollowerByUser(models.WechatBindTypeTenantUser, uint64(claims.UserID)) + if err != nil { + c.jsonErr(500, 500, "查询失败:"+err.Error()) + return + } + if follower == nil || follower.BindTid != uint64(claims.TenantId) { + 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 /backend/wechatMp/unbind +func (c *BackendWechatMpController) Unbind() { + claims, err := c.claims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + if err := wechatmp.UnbindByUser(models.WechatBindTypeTenantUser, uint64(claims.UserID)); err != nil { + c.jsonErr(500, 500, "解绑失败:"+err.Error()) + return + } + c.ok(nil, "已解绑") +} diff --git a/go/controllers/platform_wechat_mp.go b/go/controllers/platform_wechat_mp.go new file mode 100644 index 0000000..bfc068c --- /dev/null +++ b/go/controllers/platform_wechat_mp.go @@ -0,0 +1,599 @@ +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 + } + 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 +} diff --git a/go/docs/sql/create_platform_wechat_mp.sql b/go/docs/sql/create_platform_wechat_mp.sql new file mode 100644 index 0000000..0ea3f0c --- /dev/null +++ b/go/docs/sql/create_platform_wechat_mp.sql @@ -0,0 +1,101 @@ +-- ============================================================= +-- 微信公众号(服务号)绑定与推送 +-- +-- 业务说明: +-- 1. 服务号配置(yz_platform_wechat_mp_config):AppID / AppSecret / Token / EncodingAESKey, +-- 其中 AppSecret 与 EncodingAESKey 以 AES-256-GCM 加密后存储 +-- (密钥取 app.conf 的 wechat_mp_secret_key,代码内部 SHA-256 派生)。 +-- 2. 关注用户(yz_platform_wechat_mp_follower):用户关注/取关事件沉淀,openid 维度唯一; +-- 通过「扫码关注 → 公众号回复验证码 → 平台输入验证码」流程绑定到 +-- 平台用户(platform_user)或租户用户(tenant_user),认证后可定向推送。 +-- 3. 验证码(yz_platform_wechat_mp_verify_code):一次绑定会话一条,scene 绑定二维码场景值, +-- 默认 5 分钟有效;状态:0待扫码 1已扫码待核销 2已核销 3已过期。 +-- +-- 未认证服务号限制:仅支持服务器配置、关注/扫码事件、被动回复、带参数二维码; +-- 模板消息/订阅通知/客服消息需认证后用。 +-- +-- 本脚本可重复执行:建表用 IF NOT EXISTS,旧表名(yz_wechat_*)做存在性改名兼容。 +-- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_platform_wechat_mp.sql +-- ============================================================= + +-- 0) 兼容早期表名(yz_wechat_mp_config / yz_wechat_follower / yz_wechat_verify_code): +-- 旧表存在且新表不存在时直接改名,保留已配置数据。 +SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_mp_config'); +SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_config'); +SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_mp_config` TO `yz_platform_wechat_mp_config`', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_follower'); +SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_follower'); +SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_follower` TO `yz_platform_wechat_mp_follower`', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_verify_code'); +SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_verify_code'); +SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_verify_code` TO `yz_platform_wechat_mp_verify_code`', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 1) 服务号配置(单行) +CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_config` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `app_id` varchar(64) NOT NULL DEFAULT '' COMMENT '公众号 AppID', + `app_secret` varchar(512) NOT NULL DEFAULT '' COMMENT 'AppSecret(AES 加密存储)', + `token` varchar(128) NOT NULL DEFAULT '' COMMENT '服务器配置 Token(自定义,用于签名校验)', + `aes_key` varchar(512) NOT NULL DEFAULT '' COMMENT 'EncodingAESKey(43位,AES 加密存储)', + `encrypt_mode` varchar(16) NOT NULL DEFAULT 'plain' COMMENT '消息加解密方式:plain明文 compatible兼容 safe安全', + `template_id` varchar(128) NOT NULL DEFAULT '' COMMENT '模板消息ID(认证后在公众号后台申请后填入)', + `verified` tinyint(4) NOT NULL DEFAULT 0 COMMENT '公众号是否已认证:1是 0否(认证后才可主动推送)', + `enabled` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否启用:1启用 0停用', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号(服务号)配置表'; + +-- 2) 关注用户(粉丝) +CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_follower` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `openid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信 openid(公众号维度用户标识)', + `unionid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信 unionid(开放平台绑定后才有)', + `nickname` varchar(128) NOT NULL DEFAULT '' COMMENT '昵称(未认证公众号可能拉取不到,留空展示 openid 缩写)', + `avatar` varchar(512) NOT NULL DEFAULT '' COMMENT '头像地址', + `sex` tinyint(4) NOT NULL DEFAULT 0 COMMENT '性别:0未知 1男 2女', + `city` varchar(64) NOT NULL DEFAULT '' COMMENT '城市', + `province` varchar(64) NOT NULL DEFAULT '' COMMENT '省份', + `country` varchar(64) NOT NULL DEFAULT '' COMMENT '国家', + `subscribe` tinyint(4) NOT NULL DEFAULT 1 COMMENT '关注状态:1已关注 0已取关', + `subscribe_time` datetime DEFAULT NULL COMMENT '关注时间', + `unsubscribe_time` datetime DEFAULT NULL COMMENT '取关时间', + `bind_type` varchar(32) NOT NULL DEFAULT '' COMMENT '绑定对象:platform_user平台用户 tenant_user租户用户 空=未绑定', + `bind_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '绑定对象ID(平台用户ID / 租户用户ID)', + `bind_tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '绑定租户ID(租户用户时记录)', + `bind_time` datetime DEFAULT NULL COMMENT '绑定时间', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注(平台可编辑)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_openid` (`openid`), + KEY `idx_bind` (`bind_type`,`bind_id`), + KEY `idx_subscribe` (`subscribe`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号关注用户(粉丝)表'; + +-- 3) 关注验证码(绑定会话) +CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_verify_code` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `scene` varchar(64) NOT NULL DEFAULT '' COMMENT '二维码场景值(随机串,唯一)', + `code` varchar(8) NOT NULL DEFAULT '' COMMENT '验证码(6位数字,公众号被动回复给用户)', + `openid` varchar(64) NOT NULL DEFAULT '' COMMENT '扫码用户 openid(扫码后写入)', + `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态:0待扫码 1已扫码待核销 2已核销 3已过期', + `bind_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发起绑定方:platform_user / tenant_user', + `bind_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '发起绑定方ID', + `bind_tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '发起绑定租户ID', + `expire_at` datetime DEFAULT NULL COMMENT '有效期截止(默认5分钟)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_scene` (`scene`), + KEY `idx_openid` (`openid`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号关注取码/绑定验证码表'; diff --git a/go/models/init.go b/go/models/init.go index 0aed1f6..6dc29c8 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -159,6 +159,11 @@ func Init(_ string) { // 平台端推广佣金(docs/sql/create_platform_commission.sql) new(PlatformCommissionRule), new(PlatformCommissionOrder), + + // 微信公众号(服务号)绑定与推送(docs/sql/create_platform_wechat_mp.sql) + new(WechatMpConfig), + new(WechatMpFollower), + new(WechatMpVerifyCode), ) // 创建全局 Ormer diff --git a/go/models/wechat_mp.go b/go/models/wechat_mp.go new file mode 100644 index 0000000..43ef12e --- /dev/null +++ b/go/models/wechat_mp.go @@ -0,0 +1,85 @@ +package models + +import "time" + +// ============================================================= +// 微信公众号(服务号)绑定与推送(表结构见 docs/sql/create_platform_wechat_mp.sql) +// 1. 配置:AppID / AppSecret / Token / EncodingAESKey(敏感字段加密存储) +// 2. 粉丝:关注/取关事件沉淀,可绑定平台用户或租户用户,认证后定向推送 +// 3. 验证码:扫码关注 → 公众号被动回复验证码 → 平台核销完成绑定 +// ============================================================= + +// 绑定对象类型 +const ( + WechatBindTypePlatformUser = "platform_user" // 平台用户(yz_system_admin_user) + WechatBindTypeTenantUser = "tenant_user" // 租户用户(yz_system_tenant_user) +) + +// 验证码状态 +const ( + WechatVerifyStatusWaiting = 0 // 待扫码 + WechatVerifyStatusScanned = 1 // 已扫码待核销 + WechatVerifyStatusUsed = 2 // 已核销 + WechatVerifyStatusExpired = 3 // 已过期 +) + +// WechatMpConfig 微信公众号(服务号)配置 yz_platform_wechat_mp_config(单行) +type WechatMpConfig struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + AppID string `orm:"column(app_id);size(64)" json:"app_id"` + AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显 + Token string `orm:"column(token);size(128)" json:"token"` + AESKey string `orm:"column(aes_key);size(512)" json:"-"` // 加密存储,不回显 + EncryptMode string `orm:"column(encrypt_mode);size(16)" json:"encrypt_mode"` // plain / compatible / safe + TemplateID string `orm:"column(template_id);size(128)" json:"template_id"` + Verified int8 `orm:"column(verified);default(0)" json:"verified"` + Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *WechatMpConfig) TableName() string { return "yz_platform_wechat_mp_config" } + +// WechatMpFollower 微信公众号关注用户(粉丝) yz_platform_wechat_mp_follower +type WechatMpFollower struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + OpenID string `orm:"column(openid);size(64)" json:"openid"` + UnionID string `orm:"column(unionid);size(64)" json:"unionid"` + Nickname string `orm:"column(nickname);size(128)" json:"nickname"` + Avatar string `orm:"column(avatar);size(512)" json:"avatar"` + Sex int8 `orm:"column(sex);default(0)" json:"sex"` + City string `orm:"column(city);size(64)" json:"city"` + Province string `orm:"column(province);size(64)" json:"province"` + Country string `orm:"column(country);size(64)" json:"country"` + Subscribe int8 `orm:"column(subscribe);default(1)" json:"subscribe"` + SubscribeTime *time.Time `orm:"column(subscribe_time);type(datetime);null" json:"subscribe_time"` + UnsubscribeTime *time.Time `orm:"column(unsubscribe_time);type(datetime);null" json:"unsubscribe_time"` + BindType string `orm:"column(bind_type);size(32)" json:"bind_type"` + BindID uint64 `orm:"column(bind_id)" json:"bind_id"` + BindTid uint64 `orm:"column(bind_tid)" json:"bind_tid"` + BindTime *time.Time `orm:"column(bind_time);type(datetime);null" json:"bind_time"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *WechatMpFollower) TableName() string { return "yz_platform_wechat_mp_follower" } + +// WechatMpVerifyCode 公众号关注取码/绑定验证码 yz_platform_wechat_mp_verify_code +type WechatMpVerifyCode struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Scene string `orm:"column(scene);size(64)" json:"scene"` + Code string `orm:"column(code);size(8)" json:"code"` + OpenID string `orm:"column(openid);size(64)" json:"openid"` + Status int8 `orm:"column(status);default(0)" json:"status"` + BindType string `orm:"column(bind_type);size(32)" json:"bind_type"` + BindID uint64 `orm:"column(bind_id)" json:"bind_id"` + BindTid uint64 `orm:"column(bind_tid)" json:"bind_tid"` + ExpireAt *time.Time `orm:"column(expire_at);type(datetime);null" json:"expire_at"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *WechatMpVerifyCode) TableName() string { return "yz_platform_wechat_mp_verify_code" } diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index db5c932..b1e329e 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -13,6 +13,7 @@ func Register() { RegisterRoleRoutes() RegisterPaymentRoutes() RegisterProductRoutes() + RegisterWechatMpRoutes() } // RegisterProductRoutes 注册租户端「产品购买」路由。 diff --git a/go/routers/backend/wechat_mp.go b/go/routers/backend/wechat_mp.go new file mode 100644 index 0000000..7eb6af5 --- /dev/null +++ b/go/routers/backend/wechat_mp.go @@ -0,0 +1,17 @@ +package backend + +import ( + "server/controllers" + + beego "github.com/beego/beego/v2/server/web" +) + +// RegisterWechatMpRoutes 注册租户端「微信公众号绑定」路由。 +// 扫码关注公众号 → 公众号被动回复验证码 → 本端核销绑定(接口见 controllers/backend_wechat_mp.go)。 +func RegisterWechatMpRoutes() { + beego.Router("/backend/wechatMp/bind/start", &controllers.BackendWechatMpController{}, "post:BindStart") + beego.Router("/backend/wechatMp/bind/status", &controllers.BackendWechatMpController{}, "get:BindStatus") + beego.Router("/backend/wechatMp/bind/confirm", &controllers.BackendWechatMpController{}, "post:BindConfirm") + beego.Router("/backend/wechatMp/my", &controllers.BackendWechatMpController{}, "get:MyBinding") + beego.Router("/backend/wechatMp/unbind", &controllers.BackendWechatMpController{}, "post:Unbind") +} diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 83e9474..2705120 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -11,6 +11,9 @@ func Register() { // 支付模块(渠道配置 / 支付订单 / 对账 / 佣金 / 渠道回调) RegisterPaymentRoutes() + // 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调 + RegisterWechatMpRoutes() + // 平台登录相关 beego.Router("/platform/login", &controllers.PlatformAuthController{}, "post:LoginPlatform") beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser") diff --git a/go/routers/platform/wechat_mp.go b/go/routers/platform/wechat_mp.go new file mode 100644 index 0000000..0f03271 --- /dev/null +++ b/go/routers/platform/wechat_mp.go @@ -0,0 +1,34 @@ +package platform + +import ( + "server/controllers" + + beego "github.com/beego/beego/v2/server/web" +) + +// RegisterWechatMpRoutes 注册微信公众号(服务号)相关路由(表结构见 docs/sql/create_platform_wechat_mp.sql)。 +// 注意:/api/wechat/mp/callback 供微信服务器回调,无平台鉴权(控制器内完成签名校验)。 +func RegisterWechatMpRoutes() { + // 微信服务器回调(URL 填入公众号后台「服务器配置」,Token 与平台配置一致) + beego.Router("/api/wechat/mp/callback", &controllers.WechatMpCallbackController{}, "get:Callback;post:Callback") + + // 服务号配置 + beego.Router("/platform/wechatMp/config", &controllers.PlatformWechatMpController{}, "get:GetConfig;post:SaveConfig") + beego.Router("/platform/wechatMp/test", &controllers.PlatformWechatMpController{}, "post:TestConnection") + + // 绑定(平台用户) + beego.Router("/platform/wechatMp/bind/start", &controllers.PlatformWechatMpController{}, "post:BindStart") + beego.Router("/platform/wechatMp/bind/status", &controllers.PlatformWechatMpController{}, "get:BindStatus") + beego.Router("/platform/wechatMp/bind/confirm", &controllers.PlatformWechatMpController{}, "post:BindConfirm") + beego.Router("/platform/wechatMp/my", &controllers.PlatformWechatMpController{}, "get:MyBinding") + beego.Router("/platform/wechatMp/unbind", &controllers.PlatformWechatMpController{}, "post:Unbind") + + // 粉丝管理 + beego.Router("/platform/wechatMp/followers", &controllers.PlatformWechatMpController{}, "get:ListFollowers") + beego.Router("/platform/wechatMp/follower/remark", &controllers.PlatformWechatMpController{}, "post:SetFollowerRemark") + beego.Router("/platform/wechatMp/follower/unbind", &controllers.PlatformWechatMpController{}, "post:UnbindFollower") + + // 消息推送(认证后可用:提醒 / 公告) + beego.Router("/platform/wechatMp/push", &controllers.PlatformWechatMpController{}, "post:Push") + beego.Router("/platform/wechatMp/push/test", &controllers.PlatformWechatMpController{}, "post:PushTest") +} diff --git a/go/services/wechatmp/api.go b/go/services/wechatmp/api.go new file mode 100644 index 0000000..c6f5e02 --- /dev/null +++ b/go/services/wechatmp/api.go @@ -0,0 +1,282 @@ +package wechatmp + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// ============================================================= +// 微信公众平台服务端 API 调用(access_token / 带参二维码 / 模板消息 / 订阅通知 / 用户信息) +// ============================================================= + +const apiBase = "https://api.weixin.qq.com" + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +// APIError 微信接口业务错误(HTTP 200 但 errcode 非 0)。 +// Error() 输出保留 errcode / errmsg 原始信息,便于日志检索与前端展示。 +type APIError struct { + API string // 调用的接口(如「获取 access_token」) + Code int + Msg string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("%s 失败:errcode=%d errmsg=%s", e.API, e.Code, e.Msg) +} + +// Explain 返回常见 errcode 的中文排查建议;未知错误码返回空串。 +func (e *APIError) Explain() string { + switch e.Code { + case 40013: + return "AppID 无效:请核对微信后台「设置与开发 → 基本配置」中的 AppID" + case 40125: + return "AppSecret 错误:请在微信后台重置后复制新的 AppSecret 填入本页" + case 40164, 61004: + return "服务器出口 IP 不在白名单:请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)" + case 45009: + return "接口调用频次超限:请稍后重试" + case 48001: + return "接口未获授权:需认证服务号,或该账号未开通此接口能力" + } + return "" +} + +// ExplainError 提取微信接口错误的中文排查建议;无可用建议时返回 ("", false)。 +func ExplainError(err error) (string, bool) { + var ae *APIError + if errors.As(err, &ae) { + if hint := ae.Explain(); hint != "" { + return hint, true + } + } + return "", false +} + +// GetAccessToken 获取 access_token(内存缓存,提前 5 分钟刷新) +func GetAccessToken(cfg *Config) (string, error) { + if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" { + return "", fmt.Errorf("AppID / AppSecret 未配置") + } + tokenMu.Lock() + if tokenCache.appID == cfg.AppID && tokenCache.token != "" && time.Now().Before(tokenCache.expiresAt) { + t := tokenCache.token + tokenMu.Unlock() + return t, nil + } + tokenMu.Unlock() + + api := fmt.Sprintf("%s/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", + apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret)) + var resp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := httpGetJSON(api, &resp); err != nil { + return "", err + } + if resp.AccessToken == "" { + return "", &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg} + } + ttl := time.Duration(resp.ExpiresIn) * time.Second + if ttl <= tokenMinTTL { + ttl = time.Hour + } + tokenMu.Lock() + tokenCache = tokenEntry{appID: cfg.AppID, token: resp.AccessToken, expiresAt: time.Now().Add(ttl - tokenMinTTL)} + tokenMu.Unlock() + return resp.AccessToken, nil +} + +// CreateQRCode 创建带参数临时二维码(字符串场景值),返回二维码图片地址。 +// scene 会通过「关注事件的 EventKey(qrscene_)/ 扫码事件的 EventKey()」回传。 +func CreateQRCode(cfg *Config, scene string, expireSeconds int) (string, error) { + token, err := GetAccessToken(cfg) + if err != nil { + return "", err + } + if expireSeconds <= 0 || expireSeconds > 2592000 { + expireSeconds = 300 + } + payload := map[string]interface{}{ + "expire_seconds": expireSeconds, + "action_name": "QR_STR_SCENE", + "action_info": map[string]interface{}{ + "scene": map[string]string{"scene_str": scene}, + }, + } + api := fmt.Sprintf("%s/cgi-bin/qrcode/create?access_token=%s", apiBase, url.QueryEscape(token)) + var resp struct { + Ticket string `json:"ticket"` + ExpireSeconds int `json:"expire_seconds"` + URL string `json:"url"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := httpPostJSON(api, payload, &resp); err != nil { + return "", err + } + if resp.Ticket == "" { + return "", &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg} + } + return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(resp.Ticket), nil +} + +// SendTemplateMessage 发送模板消息(需认证服务号 + 模板ID) +// data 键为模板字段名(first / keyword1 / remark ...),值为文本内容。 +func SendTemplateMessage(cfg *Config, openid, jumpURL string, data map[string]string) error { + if cfg.TemplateID == "" { + return fmt.Errorf("未配置模板消息 ID(认证后在公众号后台申请)") + } + token, err := GetAccessToken(cfg) + if err != nil { + return err + } + fields := make(map[string]map[string]string, len(data)) + for k, v := range data { + fields[k] = map[string]string{"value": v} + } + payload := map[string]interface{}{ + "touser": openid, + "template_id": cfg.TemplateID, + "data": fields, + } + if jumpURL != "" { + payload["url"] = jumpURL + } + api := fmt.Sprintf("%s/cgi-bin/message/template/send?access_token=%s", apiBase, url.QueryEscape(token)) + var resp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + MsgID int64 `json:"msgid"` + } + 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 +} + +// SendSubscribeMessage 发送订阅通知(一次性订阅,需用户先在页面完成订阅授权) +func SendSubscribeMessage(cfg *Config, openid, templateID, page string, data map[string]string) error { + if templateID == "" { + return fmt.Errorf("未配置订阅通知模板 ID") + } + token, err := GetAccessToken(cfg) + if err != nil { + return err + } + fields := make(map[string]map[string]string, len(data)) + for k, v := range data { + fields[k] = map[string]string{"value": v} + } + payload := map[string]interface{}{ + "touser": openid, + "template_id": templateID, + "data": fields, + } + if page != "" { + payload["page"] = page + } + api := fmt.Sprintf("%s/cgi-bin/message/subscribe/bizsend?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 +} + +// UserInfo 用户基本信息(昵称/头像等;未认证公众号可能接口受限,调用失败由上层忽略) +type UserInfo struct { + OpenID string `json:"openid"` + Nickname string `json:"nickname"` + Sex int `json:"sex"` + City string `json:"city"` + Province string `json:"province"` + Country string `json:"country"` + HeadImgURL string `json:"headimgurl"` + Subscribe int `json:"subscribe"` +} + +// FetchUserInfo 拉取用户基本信息(失败不算错,由调用方容错) +func FetchUserInfo(cfg *Config, openid string) (*UserInfo, error) { + token, err := GetAccessToken(cfg) + if err != nil { + return nil, err + } + api := fmt.Sprintf("%s/cgi-bin/user/info?access_token=%s&openid=%s&lang=zh_CN", + apiBase, url.QueryEscape(token), url.QueryEscape(openid)) + var resp struct { + UserInfo + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := httpGetJSON(api, &resp); err != nil { + return nil, err + } + if resp.ErrCode != 0 { + return nil, &APIError{API: "获取用户信息", Code: resp.ErrCode, Msg: resp.ErrMsg} + } + return &resp.UserInfo, nil +} + +// ============================ HTTP 辅助 ============================ + +func httpGetJSON(api string, out interface{}) error { + resp, err := httpClient.Get(api) + if err != nil { + return fmt.Errorf("请求微信接口失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取微信响应失败: %w", err) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200)) + } + return nil +} + +func httpPostJSON(api string, payload interface{}, out interface{}) error { + bs, err := json.Marshal(payload) + if err != nil { + return err + } + resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs)) + if err != nil { + return fmt.Errorf("请求微信接口失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取微信响应失败: %w", err) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200)) + } + return nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/go/services/wechatmp/bind.go b/go/services/wechatmp/bind.go new file mode 100644 index 0000000..ae3fd9a --- /dev/null +++ b/go/services/wechatmp/bind.go @@ -0,0 +1,351 @@ +package wechatmp + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "server/models" + + "github.com/beego/beego/v2/client/orm" +) + +// ============================================================= +// 关注绑定:扫码关注 → 公众号被动回复验证码 → 平台核销完成绑定 +// 一个绑定会话 = 一条 yz_platform_wechat_mp_verify_code(scene + 6位验证码 + 过期时间) +// ============================================================= + +// DefaultVerifyTTL 验证码默认有效期 +const DefaultVerifyTTL = 5 * time.Minute + +var ( + ErrSceneNotScanned = errors.New("尚未检测到扫码关注,请先使用微信扫码并关注公众号") + ErrCodeUsed = errors.New("该验证码已使用,请重新获取二维码") + ErrCodeExpired = errors.New("二维码已过期,请重新获取") + ErrCodeMismatch = errors.New("验证码不正确") + ErrSceneInvalid = errors.New("二维码无效或已失效") +) + +// CreateVerifyCode 创建绑定会话并生成带参二维码。 +// bindType 为 platform_user / tenant_user,bindID 为发起方用户 ID。 +func CreateVerifyCode(bindType string, bindID, bindTid uint64, ttl time.Duration) (scene, code, qrURL string, expireAt time.Time, err error) { + cfg, err := LoadEnabledConfig() + if err != nil { + return "", "", "", time.Time{}, err + } + if ttl <= 0 { + ttl = DefaultVerifyTTL + } + scene = randomScene() + code = randomCode6() + now := time.Now() + expireAt = now.Add(ttl) + + row := &models.WechatMpVerifyCode{ + Scene: scene, + Code: code, + Status: models.WechatVerifyStatusWaiting, + BindType: bindType, + BindID: bindID, + BindTid: bindTid, + ExpireAt: &expireAt, + CreateTime: now, + UpdateTime: &now, + } + if _, err = models.Orm.Insert(row); err != nil { + return "", "", "", time.Time{}, fmt.Errorf("创建绑定会话失败: %w", err) + } + + qrURL, err = CreateQRCode(cfg, scene, int(ttl.Seconds())+120) + if err != nil { + return "", "", "", time.Time{}, err + } + return scene, code, qrURL, expireAt, nil +} + +// GetVerifyCode 按 scene 查询绑定会话(过期时惰性标记) +func GetVerifyCode(scene string) (*models.WechatMpVerifyCode, error) { + scene = strings.TrimSpace(scene) + if scene == "" { + return nil, ErrSceneInvalid + } + var row models.WechatMpVerifyCode + if err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).Filter("scene", scene).One(&row); err != nil { + return nil, ErrSceneInvalid + } + if row.Status != models.WechatVerifyStatusUsed && row.ExpireAt != nil && row.ExpireAt.Before(time.Now()) { + row.Status = models.WechatVerifyStatusExpired + now := time.Now() + _, _ = models.Orm.QueryTable(new(models.WechatMpVerifyCode)). + Filter("id", row.ID). + Update(map[string]interface{}{"status": row.Status, "update_time": now}) + } + return &row, nil +} + +// ConfirmVerifyCode 核销验证码并完成绑定,返回粉丝记录 +func ConfirmVerifyCode(bindType string, bindID, bindTid uint64, scene, code string) (*models.WechatMpFollower, error) { + row, err := GetVerifyCode(scene) + if err != nil { + return nil, err + } + if row.BindType != bindType || row.BindID != bindID { + return nil, errors.New("该二维码不是当前账号发起,请重新获取") + } + switch row.Status { + case models.WechatVerifyStatusWaiting: + return nil, ErrSceneNotScanned + case models.WechatVerifyStatusUsed: + return nil, ErrCodeUsed + case models.WechatVerifyStatusExpired: + return nil, ErrCodeExpired + } + if strings.TrimSpace(code) != row.Code { + return nil, ErrCodeMismatch + } + now := time.Now() + if _, err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)). + Filter("id", row.ID). + Update(map[string]interface{}{"status": models.WechatVerifyStatusUsed, "update_time": now}); err != nil { + return nil, err + } + return BindFollower(row.OpenID, bindType, bindID, bindTid) +} + +// BindFollower 将 openid 绑定到指定账号。 +// 同一账号只保留一个微信:旧的 openid 绑定关系会被清除。 +func BindFollower(openid, bindType string, bindID, bindTid uint64) (*models.WechatMpFollower, error) { + openid = strings.TrimSpace(openid) + if openid == "" { + return nil, errors.New("openid 为空") + } + now := time.Now() + + _, _ = models.Orm.QueryTable(new(models.WechatMpFollower)). + Filter("bind_type", bindType). + Filter("bind_id", bindID). + Filter("openid__ne", openid). + Update(map[string]interface{}{ + "bind_type": "", "bind_id": 0, "bind_tid": 0, "update_time": now, + }) + + var row models.WechatMpFollower + err := models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("openid", openid).One(&row) + if err == orm.ErrNoRows { + row = models.WechatMpFollower{ + OpenID: openid, + Subscribe: 1, + SubscribeTime: &now, + CreateTime: now, + } + } + row.BindType = bindType + row.BindID = bindID + row.BindTid = bindTid + row.BindTime = &now + row.UpdateTime = &now + if row.ID == 0 { + id, ierr := models.Orm.Insert(&row) + if ierr != nil { + return nil, ierr + } + row.ID = uint64(id) + return &row, nil + } + if _, uerr := models.Orm.Update(&row, "BindType", "BindID", "BindTid", "BindTime", "UpdateTime"); uerr != nil { + return nil, uerr + } + return &row, nil +} + +// UnbindByUser 清除某账号的微信绑定(按绑定对象维度) +func UnbindByUser(bindType string, bindID uint64) error { + if bindID == 0 { + return nil + } + now := time.Now() + _, err := models.Orm.QueryTable(new(models.WechatMpFollower)). + Filter("bind_type", bindType). + Filter("bind_id", bindID). + Update(map[string]interface{}{ + "bind_type": "", "bind_id": 0, "bind_tid": 0, "update_time": now, + }) + return err +} + +// GetFollowerByUser 查询某账号已绑定的粉丝记录(未绑定返回 nil, nil) +func GetFollowerByUser(bindType string, bindID uint64) (*models.WechatMpFollower, error) { + if bindID == 0 { + return nil, nil + } + var row models.WechatMpFollower + err := models.Orm.QueryTable(new(models.WechatMpFollower)). + Filter("bind_type", bindType). + Filter("bind_id", bindID). + Filter("delete_time__isnull", true). + OrderBy("-id"). + One(&row) + if err == orm.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +// UpsertSubscribe 关注事件:创建/恢复粉丝记录 +func UpsertSubscribe(openid string, at time.Time) (*models.WechatMpFollower, error) { + openid = strings.TrimSpace(openid) + if openid == "" { + return nil, errors.New("openid 为空") + } + var row models.WechatMpFollower + err := models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("openid", openid).One(&row) + if err == orm.ErrNoRows { + row = models.WechatMpFollower{OpenID: openid, Subscribe: 1, SubscribeTime: &at, CreateTime: at, UpdateTime: &at} + id, ierr := models.Orm.Insert(&row) + if ierr != nil { + return nil, ierr + } + row.ID = uint64(id) + return &row, nil + } + if err != nil { + return nil, err + } + row.Subscribe = 1 + row.SubscribeTime = &at + row.UnsubscribeTime = nil + row.UpdateTime = &at + if _, err := models.Orm.Update(&row, "Subscribe", "SubscribeTime", "UnsubscribeTime", "UpdateTime"); err != nil { + return nil, err + } + return &row, nil +} + +// MarkUnsubscribe 取关事件 +func MarkUnsubscribe(openid string, at time.Time) error { + openid = strings.TrimSpace(openid) + if openid == "" { + return nil + } + _, err := models.Orm.QueryTable(new(models.WechatMpFollower)). + Filter("openid", openid). + Update(map[string]interface{}{ + "subscribe": 0, "unsubscribe_time": at, "update_time": at, + }) + return err +} + +// RefreshFollowerProfile 尝试拉取粉丝昵称/头像(未认证公众号可能受限,失败静默忽略) +func RefreshFollowerProfile(cfg *Config, openid string) { + if cfg == nil || openid == "" { + return + } + info, err := FetchUserInfo(cfg, openid) + if err != nil || info == nil { + return + } + now := time.Now() + up := map[string]interface{}{ + "sex": info.Sex, + "city": info.City, + "province": info.Province, + "country": info.Country, + "update_time": now, + } + if info.Nickname != "" { + up["nickname"] = info.Nickname + } + if info.HeadImgURL != "" { + up["avatar"] = info.HeadImgURL + } + _, _ = models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("openid", openid).Update(up) +} + +// HandleInbound 处理入站消息/事件,返回需要被动回复的文本(空字符串表示不回复) +func HandleInbound(cfg *Config, msg *InboundMessage) string { + if msg == nil { + return "" + } + openid := strings.TrimSpace(msg.FromUserName) + now := time.Now() + + switch strings.ToLower(strings.TrimSpace(msg.MsgType)) { + case "event": + switch strings.ToUpper(strings.TrimSpace(msg.Event)) { + case "subscribe": + _, _ = UpsertSubscribe(openid, now) + scene := strings.TrimPrefix(strings.TrimSpace(msg.EventKey), "qrscene_") + if scene != "" { + return handleScanScene(cfg, scene, openid) + } + return "欢迎关注云泽平台!\n如需接收系统提醒、公告推送,请回到系统页面点击「绑定微信」,使用微信扫码获取专属验证码。" + case "scan": + return handleScanScene(cfg, strings.TrimSpace(msg.EventKey), openid) + case "unsubscribe": + _ = MarkUnsubscribe(openid, now) + return "" + } + case "text": + return "如需绑定账号:请回到系统页面点击「绑定微信」,展示专属二维码后使用微信扫码,公众号会自动回复验证码。\n绑定成功后即可接收系统提醒、公告推送。" + } + return "" +} + +// handleScanScene 扫码(关注/已关注)后的统一处理:写入验证码会话 + 回复验证码 +func handleScanScene(cfg *Config, scene, openid string) string { + if strings.TrimSpace(scene) == "" { + return "未能识别二维码信息,请在系统页面重新获取二维码后再扫码。" + } + row, err := GetVerifyCode(scene) + if err != nil { + return "二维码无效或已失效,请在系统页面重新获取。" + } + switch row.Status { + case models.WechatVerifyStatusUsed: + return "该验证码已使用,请在系统页面重新获取二维码。" + case models.WechatVerifyStatusExpired: + return "二维码已过期,请在系统页面重新获取。" + } + + now := time.Now() + if _, uerr := models.Orm.QueryTable(new(models.WechatMpVerifyCode)). + Filter("id", row.ID). + Update(map[string]interface{}{ + "openid": openid, "status": models.WechatVerifyStatusScanned, "update_time": now, + }); uerr != nil { + return "系统繁忙,请稍后重新扫码。" + } + // 拉取昵称头像(失败不影响绑定流程) + RefreshFollowerProfile(cfg, openid) + + minutes := int(time.Until(*row.ExpireAt).Minutes()) + if minutes < 1 { + minutes = 1 + } + return fmt.Sprintf("您的验证码是:%s\n请回到系统页面输入完成绑定(%d 分钟内有效)。", row.Code, minutes) +} + +// ============================ 随机值 ============================ + +func randomScene() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("s%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +func randomCode6() string { + n, err := rand.Int(rand.Reader, big.NewInt(900000)) + if err != nil { + return "123456" + } + return fmt.Sprintf("%06d", n.Int64()+100000) +} diff --git a/go/services/wechatmp/config.go b/go/services/wechatmp/config.go new file mode 100644 index 0000000..d9ea2f1 --- /dev/null +++ b/go/services/wechatmp/config.go @@ -0,0 +1,207 @@ +package wechatmp + +import ( + "errors" + "strings" + "sync" + "time" + + "server/models" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 微信公众号(服务号)配置与 access_token +// ============================================================= + +// 消息加解密方式 +const ( + EncryptModePlain = "plain" // 明文模式 + EncryptModeCompatible = "compatible" // 兼容模式 + EncryptModeSafe = "safe" // 安全模式 +) + +// Config 服务号运行配置(敏感字段已解密) +type Config struct { + ID uint64 + AppID string + AppSecret string // 明文 + Token string + AESKey string // 明文(43 位 EncodingAESKey) + EncryptMode string + TemplateID string + Verified bool + Enabled bool + Remark string +} + +// ErrNotConfigured 未配置或未启用 +var ErrNotConfigured = errors.New("微信公众号未配置或未启用") + +// LoadConfig 读取服务号配置(单行,敏感字段解密);未配置返回 ErrNotConfigured +func LoadConfig() (*Config, error) { + var row models.WechatMpConfig + err := models.Orm.QueryTable(new(models.WechatMpConfig)).OrderBy("id").One(&row) + if err != nil { + if err == orm.ErrNoRows { + return nil, ErrNotConfigured + } + return nil, err + } + cfg := &Config{ + ID: row.ID, + AppID: strings.TrimSpace(row.AppID), + Token: strings.TrimSpace(row.Token), + EncryptMode: strings.TrimSpace(row.EncryptMode), + TemplateID: strings.TrimSpace(row.TemplateID), + Verified: row.Verified == 1, + Enabled: row.Enabled == 1, + Remark: row.Remark, + } + if cfg.EncryptMode == "" { + cfg.EncryptMode = EncryptModePlain + } + if row.AppSecret != "" { + if plain, derr := DecryptSecret(row.AppSecret); derr == nil { + cfg.AppSecret = plain + } else { + return nil, derr + } + } + if row.AESKey != "" { + if plain, derr := DecryptSecret(row.AESKey); derr == nil { + cfg.AESKey = plain + } else { + return nil, derr + } + } + return cfg, nil +} + +// LoadEnabledConfig 读取并校验启用状态 +func LoadEnabledConfig() (*Config, error) { + cfg, err := LoadConfig() + if err != nil { + return nil, err + } + if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { + return nil, ErrNotConfigured + } + return cfg, nil +} + +// SaveInput 保存配置入参(敏感字段支持掩码表示不改) +type SaveInput struct { + AppID string + AppSecret string // 掩码 / 空串 = 保持不变 + Token string + AESKey string // 掩码 / 空串 = 保持不变 + EncryptMode string + TemplateID string + Verified bool + Enabled bool + Remark string +} + +// SaveConfig 保存服务号配置(不存在则创建) +func SaveConfig(in SaveInput) error { + if strings.TrimSpace(in.AppID) == "" { + return errors.New("AppID 不能为空") + } + mode := strings.TrimSpace(in.EncryptMode) + if mode == "" { + mode = EncryptModePlain + } + + var row models.WechatMpConfig + err := models.Orm.QueryTable(new(models.WechatMpConfig)).OrderBy("id").One(&row) + now := time.Now() + isNew := false + if err != nil { + if err != orm.ErrNoRows { + return err + } + isNew = true + row = models.WechatMpConfig{} + } + + // 敏感字段:掩码/空串表示保持原值 + secret := strings.TrimSpace(in.AppSecret) + if secret != "" && !IsMasked(secret) { + enc, eerr := EncryptSecret(secret) + if eerr != nil { + return eerr + } + row.AppSecret = enc + } + aesKey := strings.TrimSpace(in.AESKey) + if aesKey != "" && !IsMasked(aesKey) { + enc, eerr := EncryptSecret(aesKey) + if eerr != nil { + return eerr + } + row.AESKey = enc + } + + row.AppID = strings.TrimSpace(in.AppID) + row.Token = strings.TrimSpace(in.Token) + row.EncryptMode = mode + row.TemplateID = strings.TrimSpace(in.TemplateID) + row.Remark = strings.TrimSpace(in.Remark) + if in.Verified { + row.Verified = 1 + } else { + row.Verified = 0 + } + if in.Enabled { + row.Enabled = 1 + } else { + row.Enabled = 0 + } + row.UpdateTime = &now + + if isNew { + row.CreateTime = now + _, err = models.Orm.Insert(&row) + return err + } + _, err = models.Orm.Update(&row) + return err +} + +// CallbackURL 微信服务器回调地址(供公众号后台「服务器配置」填写)。 +// 优先读取 app.conf 的 wechat_mp_callback_base,其次回落 payment_callback_base(同为对外 API 域名)。 +func CallbackURL() string { + base, _ := beego.AppConfig.String("wechat_mp_callback_base") + if strings.TrimSpace(base) == "" { + base, _ = beego.AppConfig.String("payment_callback_base") + } + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return "/api/wechat/mp/callback" + } + return base + "/api/wechat/mp/callback" +} + +// ============================ access_token 缓存 ============================ + +type tokenEntry struct { + appID string + token string + expiresAt time.Time +} + +var ( + tokenMu sync.Mutex + tokenCache tokenEntry + tokenMinTTL = 5 * time.Minute +) + +// resetTokenCacheForTest 仅供测试清理缓存 +func resetTokenCacheForTest() { + tokenMu.Lock() + defer tokenMu.Unlock() + tokenCache = tokenEntry{} +} diff --git a/go/services/wechatmp/crypto.go b/go/services/wechatmp/crypto.go new file mode 100644 index 0000000..52afa6b --- /dev/null +++ b/go/services/wechatmp/crypto.go @@ -0,0 +1,106 @@ +package wechatmp + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "strings" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 微信公众号敏感字段(AppSecret / EncodingAESKey)的对称加密与掩码 +// +// yz_platform_wechat_mp_config.app_secret / aes_key 以 AES-256-GCM 加密后存储, +// 格式:base64( "YZW1:" + nonce + ciphertext )。 +// 密钥来源:app.conf 的 wechat_mp_secret_key(任意长度字符串,内部做 SHA-256 派生); +// 未配置时退回内置兜底密钥(保证开箱可用),上线前建议在 app.conf 配置该值。 +// 注意:更换密钥后,历史密文将无法解密,需要重新保存一次配置。 +// ============================================================= + +const ( + cipherPrefix = "YZW1:" + fallbackSecretKey = "yunzer_wechat_mp_default_secret_key_v1" +) + +func configKey() []byte { + raw, _ := beego.AppConfig.String("wechat_mp_secret_key") + if strings.TrimSpace(raw) == "" { + raw = fallbackSecretKey + } + sum := sha256.Sum256([]byte(raw)) + return sum[:] +} + +// EncryptSecret 加密敏感字段;空串原样返回 +func EncryptSecret(plain string) (string, error) { + if plain == "" { + return "", nil + } + block, err := aes.NewCipher(configKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil) + buf := append([]byte(cipherPrefix), nonce...) + buf = append(buf, ciphertext...) + return base64.StdEncoding.EncodeToString(buf), nil +} + +// DecryptSecret 解密敏感字段;空串原样返回 +func DecryptSecret(encoded string) (string, error) { + if encoded == "" { + return "", nil + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", errors.New("密文不是有效的 base64") + } + if len(raw) < len(cipherPrefix) || string(raw[:len(cipherPrefix)]) != cipherPrefix { + return "", errors.New("密文格式不正确(缺少加密前缀)") + } + block, err := aes.NewCipher(configKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := raw[len(cipherPrefix) : len(cipherPrefix)+gcm.NonceSize()] + ciphertext := raw[len(cipherPrefix)+gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", errors.New("解密失败(wechat_mp_secret_key 是否被更换过?)") + } + return string(plain), nil +} + +// MaskSecret 生成掩码:保留末 4 位 +func MaskSecret(v string) string { + if v == "" { + return "" + } + if len(v) <= 4 { + return "******" + } + return "******" + v[len(v)-4:] +} + +// IsMasked 判断前端回传的值是否仍是掩码(表示「不修改原值」) +func IsMasked(v string) bool { + return strings.HasPrefix(v, "******") +} diff --git a/go/services/wechatmp/message.go b/go/services/wechatmp/message.go new file mode 100644 index 0000000..4c8c7b2 --- /dev/null +++ b/go/services/wechatmp/message.go @@ -0,0 +1,214 @@ +package wechatmp + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "io" + "sort" + "strings" +) + +// ============================================================= +// 微信被动消息:签名校验 / 安全模式 AES 加解密 / XML 解析与回复构造 +// 规范参考:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_encryption_and_decryption_instructions.html +// ============================================================= + +// pkcs7BlockSize 微信官方填充块大小(注意不是 16,是 32) +const pkcs7BlockSize = 32 + +// InboundMessage 接收到的消息/事件(已解密后的 XML 内容) +type InboundMessage struct { + XMLName xml.Name `xml:"xml"` + ToUserName string `xml:"ToUserName"` + FromUserName string `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType string `xml:"MsgType"` // text / image / event ... + Content string `xml:"Content"` + Event string `xml:"Event"` // subscribe / unsubscribe / SCAN / CLICK ... + EventKey string `xml:"EventKey"` // qrscene_ 或 + Ticket string `xml:"Ticket"` + MsgID int64 `xml:"MsgId"` + Encrypt string `xml:"Encrypt"` // 安全模式下外层 XML 携带的密文 +} + +// ParseInboundXML 解析(明文/已解密的)消息 XML +func ParseInboundXML(raw string) (*InboundMessage, error) { + var msg InboundMessage + if err := xml.Unmarshal([]byte(raw), &msg); err != nil { + return nil, fmt.Errorf("解析微信消息失败: %w", err) + } + return &msg, nil +} + +// CheckSignature 明文模式签名校验:sha1(sort(token, timestamp, nonce)) +func CheckSignature(token, timestamp, nonce, signature string) bool { + if token == "" || signature == "" { + return false + } + arr := []string{token, timestamp, nonce} + sort.Strings(arr) + sum := sha1.Sum([]byte(strings.Join(arr, ""))) + return hex.EncodeToString(sum[:]) == signature +} + +// CheckMsgSignature 安全/兼容模式签名校验:sha1(sort(token, timestamp, nonce, encrypt)) +func CheckMsgSignature(token, timestamp, nonce, encrypt, msgSignature string) bool { + if token == "" || msgSignature == "" { + return false + } + arr := []string{token, timestamp, nonce, encrypt} + sort.Strings(arr) + sum := sha1.Sum([]byte(strings.Join(arr, ""))) + return hex.EncodeToString(sum[:]) == msgSignature +} + +// MsgSignature 计算安全模式消息签名(回复加密时使用) +func MsgSignature(token, timestamp, nonce, encrypt string) string { + arr := []string{token, timestamp, nonce, encrypt} + sort.Strings(arr) + sum := sha1.Sum([]byte(strings.Join(arr, ""))) + return hex.EncodeToString(sum[:]) +} + +// ============================ AES 加解密 ============================ + +func decodeAESKey(encodingAESKey string) ([]byte, error) { + key := strings.TrimSpace(encodingAESKey) + if len(key) != 43 { + return nil, fmt.Errorf("EncodingAESKey 长度应为 43 位(当前 %d)", len(key)) + } + return base64.StdEncoding.DecodeString(key + "=") +} + +// DecryptMessage 安全模式消息解密,返回明文 XML;appID 非空时会校验一致性 +func DecryptMessage(aesKey, appID, encryptBase64 string) (string, error) { + key, err := decodeAESKey(aesKey) + if err != nil { + return "", err + } + raw, err := base64.StdEncoding.DecodeString(encryptBase64) + if err != nil { + return "", errors.New("密文不是有效的 base64") + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + if len(raw) < aes.BlockSize || len(raw)%aes.BlockSize != 0 { + return "", errors.New("密文长度不合法") + } + iv := key[:aes.BlockSize] + plain := make([]byte, len(raw)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, raw) + plain, err = pkcs7Unpad(plain) + if err != nil { + return "", err + } + if len(plain) < 20 { + return "", errors.New("解密内容长度不合法") + } + msgLen := int(binary.BigEndian.Uint32(plain[16:20])) + if msgLen < 0 || 20+msgLen > len(plain) { + return "", errors.New("解密内容长度不合法") + } + msg := string(plain[20 : 20+msgLen]) + gotAppID := strings.TrimSpace(string(plain[20+msgLen:])) + if appID != "" && gotAppID != appID { + return "", fmt.Errorf("AppID 校验失败(收到 %s)", gotAppID) + } + return msg, nil +} + +// EncryptMessage 安全模式消息加密,返回 base64 密文 +func EncryptMessage(aesKey, appID, msg string) (string, error) { + key, err := decodeAESKey(aesKey) + if err != nil { + return "", err + } + random := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, random); err != nil { + return "", err + } + msgBytes := []byte(msg) + lenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(lenBytes, uint32(len(msgBytes))) + + buf := make([]byte, 0, 16+4+len(msgBytes)+len(appID)) + buf = append(buf, random...) + buf = append(buf, lenBytes...) + buf = append(buf, msgBytes...) + buf = append(buf, []byte(appID)...) + buf = pkcs7Pad(buf) + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + iv := key[:aes.BlockSize] + ciphertext := make([]byte, len(buf)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, buf) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func pkcs7Pad(data []byte) []byte { + pad := pkcs7BlockSize - len(data)%pkcs7BlockSize + if pad <= 0 { + pad = pkcs7BlockSize + } + out := make([]byte, 0, len(data)+pad) + out = append(out, data...) + for i := 0; i < pad; i++ { + out = append(out, byte(pad)) + } + return out +} + +func pkcs7Unpad(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, errors.New("空数据") + } + pad := int(data[len(data)-1]) + if pad < 1 || pad > pkcs7BlockSize || pad > len(data) { + return nil, errors.New("PKCS7 填充不合法") + } + for i := len(data) - pad; i < len(data); i++ { + if int(data[i]) != pad { + return nil, errors.New("PKCS7 填充不合法") + } + } + return data[:len(data)-pad], nil +} + +// ============================ 被动回复构造 ============================ + +// BuildTextReply 构造文本消息回复 XML(明文模式直接返回) +func BuildTextReply(toUser, fromUser, content string, timestamp int64) string { + esc := func(s string) string { + return strings.NewReplacer( + "]]>", "]] >", + ).Replace(s) + } + return fmt.Sprintf( + `%d`, + esc(toUser), esc(fromUser), timestamp, esc(content)) +} + +// BuildEncryptedReply 安全模式回复:对明文回复加密并组装外层 XML +func BuildEncryptedReply(cfg *Config, plainReply, timestamp, nonce string) (string, error) { + encrypt, err := EncryptMessage(cfg.AESKey, cfg.AppID, plainReply) + if err != nil { + return "", err + } + sig := MsgSignature(cfg.Token, timestamp, nonce, encrypt) + return fmt.Sprintf( + `%s`, + encrypt, sig, timestamp, nonce), nil +} diff --git a/go/services/wechatmp/push.go b/go/services/wechatmp/push.go new file mode 100644 index 0000000..cc60c30 --- /dev/null +++ b/go/services/wechatmp/push.go @@ -0,0 +1,113 @@ +package wechatmp + +import ( + "errors" + "time" + + "server/models" +) + +// ============================================================= +// 消息推送(认证后可用): +// 未认证服务号微信不允许主动推送(模板消息/订阅通知/客服消息均受限), +// 本层只负责在认证后通过模板消息把提醒/公告发给已关注粉丝; +// 未认证时返回 ErrNotVerified,由上层给出明确提示。 +// ============================================================= + +// ErrNotVerified 公众号未认证,无法主动推送 +var ErrNotVerified = errors.New("公众号未认证:模板消息推送需认证后使用(当前仅支持关注与被动回复)") + +// PushMessage 推送内容(对应「标题/内容/备注」通用模板字段) +type PushMessage struct { + Title string // first:首行说明 + Content string // keyword1:主体内容 + Remark string // remark:备注 + URL string // 点击跳转地址(可选) +} + +// PushToOpenID 向指定粉丝推送一条模板消息 +func PushToOpenID(cfg *Config, openid string, msg PushMessage) error { + if cfg == nil { + return ErrNotConfigured + } + if !cfg.Verified { + return ErrNotVerified + } + if openid == "" { + return errors.New("openid 为空") + } + if msg.Title == "" { + msg.Title = "您有一条新的消息" + } + if len([]rune(msg.Title)) > 100 { + msg.Title = string([]rune(msg.Title)[:100]) + } + if len([]rune(msg.Content)) > 200 { + msg.Content = string([]rune(msg.Content)[:200]) + } + if len([]rune(msg.Remark)) > 100 { + msg.Remark = string([]rune(msg.Remark)[:100]) + } + return SendTemplateMessage(cfg, openid, msg.URL, map[string]string{ + "first": msg.Title, + "keyword1": msg.Content, + "remark": msg.Remark, + }) +} + +// PushScope 推送范围 +type PushScope struct { + // BindType 为空表示全部已关注粉丝;platform_user / tenant_user 只推绑定对应类型账号的粉丝 + BindType string + // BindID 非 0 时只推给该绑定账号(与 BindType 组合使用) + BindID uint64 + // Tid 非 0 时只推给绑定该租户的用户(tenant_user 场景) + Tid uint64 +} + +// MaxBatchPush 单次批量推送上限 +const MaxBatchPush = 500 + +// PushToFollowers 按范围批量推送,返回成功/失败数量。 +// 仅推送给「已关注(subscribe=1)且未删除」的粉丝。 +func PushToFollowers(cfg *Config, scope PushScope, msg PushMessage) (sent, failed int, err error) { + if cfg == nil { + return 0, 0, ErrNotConfigured + } + if !cfg.Verified { + return 0, 0, ErrNotVerified + } + qs := models.Orm.QueryTable(new(models.WechatMpFollower)). + Filter("subscribe", 1). + Filter("delete_time__isnull", true) + if scope.BindType != "" { + qs = qs.Filter("bind_type", scope.BindType) + } + if scope.BindID > 0 { + qs = qs.Filter("bind_id", scope.BindID) + } + if scope.Tid > 0 { + qs = qs.Filter("bind_tid", scope.Tid) + } + + var rows []models.WechatMpFollower + if _, qerr := qs.OrderBy("-id").Limit(MaxBatchPush).All(&rows); qerr != nil { + return 0, 0, qerr + } + if len(rows) == 0 { + return 0, 0, errors.New("没有匹配的已关注粉丝") + } + + for i := range rows { + if perr := PushToOpenID(cfg, rows[i].OpenID, msg); perr != nil { + failed++ + continue + } + sent++ + // 简单节流,避免触发微信频率限制 + if i%20 == 19 { + time.Sleep(200 * time.Millisecond) + } + } + return sent, failed, nil +} diff --git a/platform/components.d.ts b/platform/components.d.ts index c83c562..32ee612 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -61,6 +61,8 @@ declare module 'vue' { ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElStep: typeof import('element-plus/es')['ElStep'] + ElSteps: typeof import('element-plus/es')['ElSteps'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] diff --git a/platform/src/api/wechatMp.js b/platform/src/api/wechatMp.js new file mode 100644 index 0000000..c5efeda --- /dev/null +++ b/platform/src/api/wechatMp.js @@ -0,0 +1,117 @@ +import request from "@/utils/request"; + +/************************************************* + * 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 + *************************************************/ + +/** 获取服务号配置(敏感字段返回掩码) */ +export function getWechatMpConfig() { + return request({ + url: "/platform/wechatMp/config", + method: "get", + }); +} + +/** 保存服务号配置(掩码 / 空串表示不修改原值) */ +export function saveWechatMpConfig(data) { + return request({ + url: "/platform/wechatMp/config", + method: "post", + data, + }); +} + +/** 连接测试:校验 AppID / AppSecret 是否能换取 access_token */ +export function testWechatMpConnection() { + return request({ + url: "/platform/wechatMp/test", + method: "post", + }); +} + +/** 发起绑定:生成带参数二维码(扫码关注后公众号回复验证码) */ +export function startWechatMpBind() { + return request({ + url: "/platform/wechatMp/bind/start", + method: "post", + }); +} + +/** 查询绑定会话状态:0待扫码 1已扫码待核销 2已核销 3已过期 */ +export function getWechatMpBindStatus(scene) { + return request({ + url: "/platform/wechatMp/bind/status", + method: "get", + params: { scene }, + }); +} + +/** 核销验证码完成绑定 */ +export function confirmWechatMpBind(data) { + return request({ + url: "/platform/wechatMp/bind/confirm", + method: "post", + data, + }); +} + +/** 当前平台用户的绑定信息 */ +export function getMyWechatMpBinding() { + return request({ + url: "/platform/wechatMp/my", + method: "get", + }); +} + +/** 解除当前平台用户的微信绑定 */ +export function unbindWechatMp() { + return request({ + url: "/platform/wechatMp/unbind", + method: "post", + }); +} + +/** 粉丝列表 */ +export function getWechatMpFollowers(params) { + return request({ + url: "/platform/wechatMp/followers", + method: "get", + params, + }); +} + +/** 设置粉丝备注 */ +export function setWechatMpFollowerRemark(data) { + return request({ + url: "/platform/wechatMp/follower/remark", + method: "post", + data, + }); +} + +/** 解除粉丝绑定(保留粉丝记录) */ +export function unbindWechatMpFollower(data) { + return request({ + url: "/platform/wechatMp/follower/unbind", + method: "post", + data, + }); +} + +/** 批量推送(认证后可用) */ +export function pushWechatMpMessage(data) { + return request({ + url: "/platform/wechatMp/push", + method: "post", + data, + }); +} + +/** 测试推送:发送给当前账号绑定的微信 */ +export function pushWechatMpTest(data) { + return request({ + url: "/platform/wechatMp/push/test", + method: "post", + data, + }); +} diff --git a/platform/src/views/system/platformsettings/components/notificationSettings.vue b/platform/src/views/system/platformsettings/components/notificationSettings.vue index 5215f2f..ccc8b7c 100644 --- a/platform/src/views/system/platformsettings/components/notificationSettings.vue +++ b/platform/src/views/system/platformsettings/components/notificationSettings.vue @@ -317,24 +317,213 @@
+ - + - + - - + + - - + + - - + + + + + + + + + + + + + 明文模式 + 兼容模式 + 安全模式 + + + + + 认证后才可主动推送(模板消息/订阅通知) + + + + + + + + +
+ ① 微信后台(公众平台 / 微信开发者平台)「设置与开发 → 基本配置」→ 复制 AppID / AppSecret 填入上方,保存并启用;
+ ② 将本地址连同上方 Token / EncodingAESKey 填入微信后台「服务器配置」(加解密方式保持一致)——提交时微信会立即请求本地址校验,需本页已保存并启用;
+ ③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)。 +
+
+ + 保存配置 + 连接测试 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 单次最多 500 人 + + + + + + + + + + + + + + 发送推送 + @@ -386,8 +575,8 @@ @@ -846,4 +1388,88 @@ onMounted(() => { gap: 12px; padding: 10px 0; } + +/* ===== 微信公众号(服务号) ===== */ +.bind-card { + margin-top: 16px; +} + +.bind-actions { + margin-top: 12px; + display: flex; + gap: 12px; +} + +.bind-steps { + margin-bottom: 16px; +} + +.bind-area { + display: flex; + gap: 24px; + align-items: flex-start; + flex-wrap: wrap; +} + +.bind-qr { + width: 180px; + height: 180px; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed var(--el-border-color); + border-radius: 8px; + overflow: hidden; +} + +.qr-img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.bind-form { + flex: 1; + min-width: 240px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.bind-input { + width: 220px; +} + +.follower-tools { + display: flex; + gap: 8px; + align-items: center; + margin-left: auto; +} + +.follower-select { + width: 140px; +} + +.follower-pager { + margin-top: 12px; + justify-content: flex-end; +} + +.push-alert { + margin-bottom: 16px; +} + +.push-url { + margin-top: 8px; +} + +.ml8 { + margin-left: 8px; +} + +.muted { + color: var(--el-text-color-secondary); + font-size: 12px; +}