Files
yunzerwebsiteallinone/go/services/wechatmp/bind.go
T
2026-09-16 18:06:18 +08:00

352 lines
11 KiB
Go
Raw 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("二维码已过期,请重新获取")
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)
}