增加微信通知功能
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user