From 1224735aa8b530edb3af4047be25f5013a50d806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Wed, 16 Sep 2026 18:15:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=AC=E4=BC=97=E5=8F=B7?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/user/components/WechatBindCard.vue | 11 +- go/controllers/backend_mcp_server.go | 2 +- go/controllers/backend_wechat_mp.go | 25 ++- go/controllers/platform_wechat_mp.go | 8 +- go/services/wechatmp/bind.go | 170 +++++++++++++++++- .../components/notificationSettings.vue | 15 +- 6 files changed, 200 insertions(+), 31 deletions(-) diff --git a/backend/src/views/user/components/WechatBindCard.vue b/backend/src/views/user/components/WechatBindCard.vue index c5cc983..c4be9d4 100644 --- a/backend/src/views/user/components/WechatBindCard.vue +++ b/backend/src/views/user/components/WechatBindCard.vue @@ -29,8 +29,8 @@

绑定微信后,可通过公众号接收系统提醒与公告推送。

- - + +
@@ -45,7 +45,7 @@ 完成绑定 -

扫码关注后公众号会自动回复 6 位验证码({{ ttlText }})。

+

两种方式任选:① 扫描左侧二维码(未关注会自动关注);② 在公众号内发送「验证码」。都会回复 6 位验证码({{ ttlText }}),在上方输入即可绑定。

@@ -133,10 +133,7 @@ const handleStart = async () => { }; const handleConfirm = async () => { - if (!scene.value) { - ElMessage.warning("请先获取绑定二维码并扫码关注"); - return; - } + // scene 可为空:在公众号内发送「验证码」获取时无需二维码,后端按验证码认领会话 if (!code.value.trim()) { ElMessage.warning("请输入公众号回复的验证码"); return; diff --git a/go/controllers/backend_mcp_server.go b/go/controllers/backend_mcp_server.go index 8ee8929..ea47f26 100644 --- a/go/controllers/backend_mcp_server.go +++ b/go/controllers/backend_mcp_server.go @@ -83,7 +83,7 @@ func (p *mcpServerPayload) validate() error { return fmt.Errorf("请填写服务地址 URL") } default: - return fmt.Errorf("不支持的传输类型: %s(可选 stdio/http/sse)") + return fmt.Errorf("不支持的传输类型: %s(可选 stdio/http/sse)", p.Transport) } return nil } diff --git a/go/controllers/backend_wechat_mp.go b/go/controllers/backend_wechat_mp.go index 502d612..a9a8354 100644 --- a/go/controllers/backend_wechat_mp.go +++ b/go/controllers/backend_wechat_mp.go @@ -142,13 +142,24 @@ func (c *BackendWechatMpController) BindConfirm() { c.jsonErr(400, 400, "请输入公众号返回的验证码") return } - follower, err := wechatmp.ConfirmVerifyCode( - models.WechatBindTypeTenantUser, - uint64(claims.UserID), - uint64(claims.TenantId), - p.Scene, - p.Code, - ) + var follower *models.WechatMpFollower + if strings.TrimSpace(p.Scene) == "" { + // 未展示二维码(在公众号内发送「验证码」获取):按验证码认领待认领会话 + follower, err = wechatmp.ConfirmVerifyCodeByCode( + models.WechatBindTypeTenantUser, + uint64(claims.UserID), + uint64(claims.TenantId), + p.Code, + ) + } else { + 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 diff --git a/go/controllers/platform_wechat_mp.go b/go/controllers/platform_wechat_mp.go index bfc068c..86f18b0 100644 --- a/go/controllers/platform_wechat_mp.go +++ b/go/controllers/platform_wechat_mp.go @@ -257,7 +257,13 @@ func (c *PlatformWechatMpController) BindConfirm() { c.jsonErr(400, 400, "请输入公众号返回的验证码") return } - follower, err := wechatmp.ConfirmVerifyCode(models.WechatBindTypePlatformUser, uint64(claims.UserID), 0, p.Scene, p.Code) + 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 diff --git a/go/services/wechatmp/bind.go b/go/services/wechatmp/bind.go index ae3fd9a..11f7c9b 100644 --- a/go/services/wechatmp/bind.go +++ b/go/services/wechatmp/bind.go @@ -26,7 +26,7 @@ var ( ErrSceneNotScanned = errors.New("尚未检测到扫码关注,请先使用微信扫码并关注公众号") ErrCodeUsed = errors.New("该验证码已使用,请重新获取二维码") ErrCodeExpired = errors.New("二维码已过期,请重新获取") - ErrCodeMismatch = errors.New("验证码不正确") + ErrCodeNotPending = errors.New("验证码不正确或已过期:请在公众号内发送「验证码」重新获取") ErrSceneInvalid = errors.New("二维码无效或已失效") ) @@ -105,7 +105,9 @@ func ConfirmVerifyCode(bindType string, bindID, bindTid uint64, scene, code stri return nil, ErrCodeExpired } if strings.TrimSpace(code) != row.Code { - return nil, ErrCodeMismatch + // 验证码不属于该二维码会话(用户在公众号内单独发送「验证码」取得): + // 回退按验证码认领,保证「不扫码直接取码」的绑定路径可用 + return ConfirmVerifyCodeByCode(bindType, bindID, bindTid, code) } now := time.Now() if _, err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)). @@ -116,6 +118,78 @@ func ConfirmVerifyCode(bindType string, bindID, bindTid uint64, scene, code stri return BindFollower(row.OpenID, bindType, bindID, bindTid) } +// CreateOpenIDVerify 由公众号消息发起绑定会话:openid 已知(消息发送者), +// 绑定目标(bind_type/bind_id)为空,待系统页面登录用户输入验证码后认领。 +// 与扫码会话共用一张表,区别仅在发起方与归属信息。 +func CreateOpenIDVerify(openid string, ttl time.Duration) (*models.WechatMpVerifyCode, error) { + openid = strings.TrimSpace(openid) + if openid == "" { + return nil, errors.New("openid 为空") + } + if ttl <= 0 { + ttl = DefaultVerifyTTL + } + now := time.Now() + expireAt := now.Add(ttl) + row := &models.WechatMpVerifyCode{ + Scene: randomScene(), + Code: randomCode6(), + OpenID: openid, + Status: models.WechatVerifyStatusScanned, // 微信号已知,等同「已扫码待核销」 + ExpireAt: &expireAt, + CreateTime: now, + UpdateTime: &now, + } + if _, err := models.Orm.Insert(row); err != nil { + return nil, err + } + return row, nil +} + +// ConfirmVerifyCodeByCode 按验证码认领「公众号发起」的绑定会话(系统页面未展示二维码时使用): +// 匹配最近一条 bind_id=0、openid 非空、未核销、未过期的会话,用 CAS 更新占位后绑定到当前账号。 +// 只认领无归属的会话,不会影响他人扫码发起的会话,避免绑定串号。 +func ConfirmVerifyCodeByCode(bindType string, bindID, bindTid uint64, code string) (*models.WechatMpFollower, error) { + code = strings.TrimSpace(code) + if code == "" { + return nil, ErrCodeNotPending + } + var row models.WechatMpVerifyCode + err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)). + Filter("code", code). + Filter("bind_id", 0). + Filter("openid__ne", ""). + Filter("status", models.WechatVerifyStatusScanned). + Filter("expire_at__gt", time.Now()). + OrderBy("-id"). + One(&row) + if err == orm.ErrNoRows { + return nil, ErrCodeNotPending + } + if err != nil { + return nil, err + } + now := time.Now() + // CAS:仅当仍是「待核销」时更新,避免同一验证码被并发认领 + affected, uerr := models.Orm.QueryTable(new(models.WechatMpVerifyCode)). + Filter("id", row.ID). + Filter("status", models.WechatVerifyStatusScanned). + Update(map[string]interface{}{ + "bind_type": bindType, + "bind_id": bindID, + "bind_tid": bindTid, + "status": models.WechatVerifyStatusUsed, + "update_time": now, + }) + if uerr != nil { + return nil, uerr + } + if affected == 0 { + return nil, ErrCodeUsed + } + return BindFollower(row.OpenID, bindType, bindID, bindTid) +} + // BindFollower 将 openid 绑定到指定账号。 // 同一账号只保留一个微信:旧的 openid 绑定关系会被清除。 func BindFollower(openid, bindType string, bindID, bindTid uint64) (*models.WechatMpFollower, error) { @@ -198,6 +272,47 @@ func GetFollowerByUser(bindType string, bindID uint64) (*models.WechatMpFollower return &row, nil } +// GetFollowerByOpenID 按 openid 查询粉丝记录(不存在返回 nil, nil) +func GetFollowerByOpenID(openid string) (*models.WechatMpFollower, error) { + openid = strings.TrimSpace(openid) + if openid == "" { + return nil, nil + } + var row models.WechatMpFollower + err := models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("openid", openid).One(&row) + if err == orm.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +// LatestPendingVerifyByOpenID 查询该微信号最近一条「未核销且未过期」的绑定会话,用于重发验证码。 +// 命中范围:扫码会话(扫码时写入 openid)与公众号内发起的待认领会话(创建即带 openid)。 +// 他人发起的、尚未扫码的会话 openid 为空,不会被命中,避免绑定串号。 +func LatestPendingVerifyByOpenID(openid string) (*models.WechatMpVerifyCode, error) { + openid = strings.TrimSpace(openid) + if openid == "" { + return nil, nil + } + var row models.WechatMpVerifyCode + err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)). + Filter("openid", openid). + Filter("status__in", models.WechatVerifyStatusWaiting, models.WechatVerifyStatusScanned). + Filter("expire_at__gt", time.Now()). + 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) @@ -285,7 +400,7 @@ func HandleInbound(cfg *Config, msg *InboundMessage) string { if scene != "" { return handleScanScene(cfg, scene, openid) } - return "欢迎关注云泽平台!\n如需接收系统提醒、公告推送,请回到系统页面点击「绑定微信」,使用微信扫码获取专属验证码。" + return "欢迎关注云泽平台!\n如需接收系统提醒、公告推送,请回到系统页面点击「绑定微信」,使用微信扫码获取专属验证码;已扫码但未收到时可发送「验证码」重新获取。" case "scan": return handleScanScene(cfg, strings.TrimSpace(msg.EventKey), openid) case "unsubscribe": @@ -293,11 +408,58 @@ func HandleInbound(cfg *Config, msg *InboundMessage) string { return "" } case "text": - return "如需绑定账号:请回到系统页面点击「绑定微信」,展示专属二维码后使用微信扫码,公众号会自动回复验证码。\n绑定成功后即可接收系统提醒、公告推送。" + return handleTextIntent(openid, msg.Content) } return "" } +// handleTextIntent 处理公众号文本消息: +// - 发送「验证码」等关键词:返回该微信号的验证码(复用未核销会话;没有则在公众号侧 +// 新建「待认领」会话),用户回系统页面输入即完成绑定,无需先扫码; +// - 其余文本:给出绑定引导。 +func handleTextIntent(openid, content string) string { + if !isBindKeyword(strings.TrimSpace(content)) { + return textBindGuide() + } + // 1) 复用该微信号未核销、未过期的会话(扫码会话或此前公众号发起的会话) + row, err := LatestPendingVerifyByOpenID(openid) + if err != nil || row == nil { + // 2) 公众号侧直接发起:新建待认领会话,绑定目标由系统页面登录用户输入验证码后认领 + row, err = CreateOpenIDVerify(openid, 0) + if err != nil { + return "系统繁忙,请稍后重新发送。" + } + } + minutes := 1 + if row.ExpireAt != nil { + if m := int(time.Until(*row.ExpireAt).Minutes()); m > 1 { + minutes = m + } + } + tip := "" + if f, ferr := GetFollowerByOpenID(openid); ferr == nil && f != nil && strings.TrimSpace(f.BindType) != "" { + tip = "\n提示:当前微信已绑定账号,输入本验证码将更换绑定。" + } + return fmt.Sprintf("您的验证码是:%s\n请在系统页面「绑定微信」处输入完成绑定(%d 分钟内有效)。%s", row.Code, minutes, tip) +} + +// isBindKeyword 判断文本是否为「获取验证码 / 绑定」类意图 +func isBindKeyword(text string) bool { + if text == "" { + return false + } + switch text { + case "验证码", "获取验证码", "重新获取验证码", "绑定", "绑定微信", "码": + return true + } + return strings.Contains(text, "验证码") +} + +// textBindGuide 非关键词文本的绑定引导 +func textBindGuide() string { + return "如需绑定账号:请在系统页面点击「绑定微信」,然后任选一种方式——① 扫描页面上的二维码;② 直接在本公众号发送「验证码」。\n两种方式都会回复 6 位验证码,回到系统页面输入即可完成绑定。" +} + // handleScanScene 扫码(关注/已关注)后的统一处理:写入验证码会话 + 回复验证码 func handleScanScene(cfg *Config, scene, openid string) string { if strings.TrimSpace(scene) == "" { diff --git a/platform/src/views/system/platformsettings/components/notificationSettings.vue b/platform/src/views/system/platformsettings/components/notificationSettings.vue index ccc8b7c..44b4df4 100644 --- a/platform/src/views/system/platformsettings/components/notificationSettings.vue +++ b/platform/src/views/system/platformsettings/components/notificationSettings.vue @@ -410,8 +410,8 @@ @@ -1101,10 +1101,7 @@ const startBind = async () => { }; const confirmBind = async () => { - if (!bindScene.value) { - ElMessage.warning("请先获取绑定二维码"); - return; - } + // scene 可为空:在公众号内发送「验证码」获取时无需二维码,后端按验证码认领会话 if (!bindCode.value.trim()) { ElMessage.warning("请输入公众号回复的验证码"); return; @@ -1292,10 +1289,6 @@ watch(activeSubTab, (tab) => { overflow: hidden; } -.sub-tabs-container { - /* height: 520px; */ -} - /* Make tabs look extra sleek */ :deep(.el-tabs--left .el-tabs__header.is-left) { margin-right: 0;