Files
2026-09-20 00:19:08 +08:00

514 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}