514 lines
17 KiB
Go
514 lines
17 KiB
Go
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)
|
||
}
|