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;