增加微信通知功能

This commit is contained in:
2026-09-16 18:06:18 +08:00
parent b48688cbab
commit f386899625
23 changed files with 3539 additions and 18 deletions
+351
View File
@@ -0,0 +1,351 @@
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)
}