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) }