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("二维码已过期,请重新获取") ErrCodeNotPending = 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 ConfirmVerifyCodeByCode(bindType, bindID, bindTid, code) } 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) } // 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) { 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 } // 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) 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 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) == "" { 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) }