批量优化功能

This commit is contained in:
2026-09-30 00:57:36 +08:00
parent ca4220f92c
commit e3db229f41
43 changed files with 2959 additions and 207 deletions
+34 -4
View File
@@ -398,14 +398,29 @@ func HandleInbound(cfg *Config, msg *InboundMessage) string {
_, _ = UpsertSubscribe(openid, now)
scene := strings.TrimPrefix(strings.TrimSpace(msg.EventKey), "qrscene_")
if scene != "" {
// 带参二维码:先看是否为扫码登录会话(自动回复确认链接)
if reply, ok := HandleScanLoginEvent(scene, openid); ok {
return reply
}
return handleScanScene(cfg, scene, openid)
}
return "欢迎关注云泽平台!\n如需接收系统提醒、公告推送,请回到系统页面点击「绑定微信」,使用微信扫码获取专属验证码;已扫码但未收到时可发送「验证码」重新获取。"
// 普通关注:已绑定直接给「确认登录」链接,未绑定提示先绑定
return DirectLoginReply(openid)
case "scan":
return handleScanScene(cfg, strings.TrimSpace(msg.EventKey), openid)
scene := strings.TrimSpace(msg.EventKey)
if reply, ok := HandleScanLoginEvent(scene, openid); ok {
return reply
}
return handleScanScene(cfg, scene, openid)
case "unsubscribe":
_ = MarkUnsubscribe(openid, now)
return ""
case "click":
// 自定义菜单点击:键值为登录入口时直接回复确认登录链接
if isLoginMenuKey(msg.EventKey) {
return DirectLoginReply(openid)
}
return ""
}
case "text":
return handleTextIntent(openid, msg.Content)
@@ -414,11 +429,16 @@ func HandleInbound(cfg *Config, msg *InboundMessage) string {
}
// handleTextIntent 处理公众号文本消息:
// - 发送「登录」:已绑定用户获取一次性登录链接(见 login.go);
// - 发送「验证码」等关键词:返回该微信号的验证码(复用未核销会话;没有则在公众号侧
// 新建「待认领」会话),用户回系统页面输入即完成绑定,无需先扫码;
// - 其余文本:给出绑定引导。
func handleTextIntent(openid, content string) string {
if !isBindKeyword(strings.TrimSpace(content)) {
text := strings.TrimSpace(content)
if isLoginKeyword(text) {
return DirectLoginReply(openid)
}
if !isBindKeyword(text) {
return textBindGuide()
}
// 1) 复用该微信号未核销、未过期的会话(扫码会话或此前公众号发起的会话)
@@ -457,7 +477,17 @@ func isBindKeyword(text string) bool {
// textBindGuide 非关键词文本的绑定引导
func textBindGuide() string {
return "如需绑定账号:请在系统页面点击「绑定微信」,然后任选一种方式——① 扫描页面上的二维码;② 直接在本公众号发送「验证码」。\n两种方式都会回复 6 位验证码,回到系统页面输入即可完成绑定。"
return "如需绑定账号:请在系统页面点击「绑定微信」,然后直接在本公众号发送「验证码」," +
"收到 6 位验证码后回到系统页面输入即可完成绑定。\n绑定后在本公众号发送「登录」可获取一次性登录链接。"
}
// BindGuides 系统页面「绑定微信」卡片的操作指引(未认证公众号无带参二维码,仅引导发码取码)
func BindGuides() []string {
return []string{
"① 用微信扫码关注本公众号(或微信搜索公众号名称进入会话)",
"② 在公众号会话中直接发送文字「验证码」",
"③ 公众号回复 6 位验证码,填入本页输入框完成绑定",
}
}
// handleScanScene 扫码(关注/已关注)后的统一处理:写入验证码会话 + 回复验证码
+36 -3
View File
@@ -35,6 +35,8 @@ type Config struct {
Verified bool
Enabled bool
Remark string
FollowQrcode string // 关注二维码(data URL),绑定页展示供扫码关注
}
// ErrNotConfigured 未配置或未启用
@@ -56,9 +58,10 @@ func LoadConfig() (*Config, error) {
Token: strings.TrimSpace(row.Token),
EncryptMode: strings.TrimSpace(row.EncryptMode),
TemplateID: strings.TrimSpace(row.TemplateID),
Verified: row.Verified == 1,
Enabled: row.Enabled == 1,
Remark: row.Remark,
Verified: row.Verified == 1,
Enabled: row.Enabled == 1,
Remark: row.Remark,
FollowQrcode: strings.TrimSpace(row.FollowQrcode),
}
if cfg.EncryptMode == "" {
cfg.EncryptMode = EncryptModePlain
@@ -171,6 +174,36 @@ func SaveConfig(in SaveInput) error {
return err
}
// SaveFollowQrcode 保存/清空关注二维码(data URL,空串表示删除)
func SaveFollowQrcode(dataURL string) error {
dataURL = strings.TrimSpace(dataURL)
now := time.Now()
var row models.WechatMpConfig
err := models.Orm.QueryTable(new(models.WechatMpConfig)).OrderBy("id").One(&row)
if err != nil {
if err == orm.ErrNoRows {
// 尚无配置行:仅存二维码,其余字段留空
row = models.WechatMpConfig{FollowQrcode: dataURL, EncryptMode: EncryptModePlain, CreateTime: now}
_, err = models.Orm.Insert(&row)
return err
}
return err
}
_, err = models.Orm.QueryTable(new(models.WechatMpConfig)).
Filter("id", row.ID).
Update(map[string]interface{}{"follow_qrcode": dataURL, "update_time": now})
return err
}
// FollowQrcodeURL 读取关注二维码(未配置时返回空串)
func FollowQrcodeURL() string {
cfg, err := LoadConfig()
if err != nil {
return ""
}
return cfg.FollowQrcode
}
// CallbackURL 微信服务器回调地址(供公众号后台「服务器配置」填写)。
// 优先读取 app.conf 的 wechat_mp_callback_base,其次回落 payment_callback_base(同为对外 API 域名)。
func CallbackURL() string {
+142
View File
@@ -0,0 +1,142 @@
package wechatmp
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/url"
"strings"
"time"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// 公众号微信登录(免认证方案):
// 用户在公众号内发送「登录」→ 后端校验绑定关系并生成一次性登录令牌 →
// 被动回复登录链接 → 用户在微信内打开链接 → 前端 /mp-login 页面
// 用令牌调用 /api/wechat/mp/login 换取正式登录态。
// 未认证公众号无法使用网页授权(snsapi_*),一次性链接是替代方案。
// =============================================================
// DefaultLoginTokenTTL 登录令牌默认有效期
const DefaultLoginTokenTTL = 5 * time.Minute
var (
ErrLoginTokenInvalid = errors.New("登录链接无效或已过期,请在公众号重新发送「登录」")
ErrLoginTokenUsed = errors.New("登录链接已被使用,请在公众号重新发送「登录」")
ErrLoginTokenExpired = errors.New("登录链接已过期,请在公众号重新发送「登录」")
)
func randomTokenHex(nBytes int) string {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
return ""
}
return hex.EncodeToString(b)
}
// LoginLinkURL 由一次性令牌拼出前端登录页链接。
// 页面基址读取 app.conf 的 wechat_mp_login_base(前端应用对外可访问地址,
// 如 https://back.yunzer.cn),缺省回落 wechat_mp_callback_base / payment_callback_base。
// 未配置时返回空串,由调用方给出提示。
func LoginLinkURL(token string) string {
base, _ := beego.AppConfig.String("wechat_mp_login_base")
if strings.TrimSpace(base) == "" {
base, _ = beego.AppConfig.String("wechat_mp_callback_base")
}
if strings.TrimSpace(base) == "" {
base, _ = beego.AppConfig.String("payment_callback_base")
}
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" {
return ""
}
return base + "/#/mp-login?token=" + url.QueryEscape(token)
}
// CreateLoginToken 为已绑定的粉丝生成一次性登录令牌。
func CreateLoginToken(f *models.WechatMpFollower, ttl time.Duration) (token string, expireAt time.Time, err error) {
if f == nil || strings.TrimSpace(f.BindType) == "" || f.BindID == 0 {
return "", time.Time{}, errors.New("该微信尚未绑定账号")
}
if ttl <= 0 {
ttl = DefaultLoginTokenTTL
}
token = randomTokenHex(32)
if token == "" {
return "", time.Time{}, errors.New("生成登录令牌失败")
}
now := time.Now()
expireAt = now.Add(ttl)
row := &models.WechatMpLoginToken{
Token: token,
OpenID: f.OpenID,
BindType: f.BindType,
BindID: f.BindID,
BindTid: f.BindTid,
ExpireAt: expireAt,
CreateTime: now,
}
if _, err := models.Orm.Insert(row); err != nil {
return "", time.Time{}, err
}
return token, expireAt, nil
}
// ConsumeLoginToken 核销一次性登录令牌(CAS 防重放),返回令牌携带的绑定信息。
func ConsumeLoginToken(token string) (*models.WechatMpLoginToken, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, ErrLoginTokenInvalid
}
var row models.WechatMpLoginToken
if err := models.Orm.QueryTable(new(models.WechatMpLoginToken)).
Filter("token", token).One(&row); err != nil {
return nil, ErrLoginTokenInvalid
}
if row.UsedAt != nil {
return nil, ErrLoginTokenUsed
}
if row.ExpireAt.Before(time.Now()) {
return nil, ErrLoginTokenExpired
}
now := time.Now()
affected, err := models.Orm.QueryTable(new(models.WechatMpLoginToken)).
Filter("id", row.ID).
Filter("used_at__isnull", true).
Update(map[string]interface{}{"used_at": now})
if err != nil {
return nil, err
}
if affected == 0 {
return nil, ErrLoginTokenUsed
}
return &row, nil
}
// isLoginKeyword 判断文本是否为「登录」意图
func isLoginKeyword(text string) bool {
switch strings.TrimSpace(text) {
case "登录", "登陸", "login", "Login", "微信登录", "微信登陆":
return true
}
return false
}
// isLoginMenuKey 判断自定义菜单点击(CLICK 事件)的 EventKey 是否为「登录」入口。
// 菜单在公众号后台手动配置即可(未认证公众号同样支持),
// 用户点菜单 → CLICK 事件 → 自动回复确认登录链接,免输入。
func isLoginMenuKey(key string) bool {
k := strings.TrimSpace(key)
if k == "" {
return false
}
switch strings.ToLower(k) {
case "login", "登录", "扫码登录", "微信登录":
return true
}
return false
}
+103
View File
@@ -0,0 +1,103 @@
package wechatmp
import (
"errors"
"net/url"
"strings"
"github.com/beego/beego/v2/core/logs"
)
// =============================================================
// 公众号自定义菜单:发布「扫码登录」菜单(点击事件 → CLICK 事件 → 自动回复
// 确认登录链接,用户不用输入任何内容)。
// 注意:菜单接口(menu/create)与带参二维码同属认证后能力,
// 未认证公众号会返回 48001,届时需改用「公众号内发送登录」路径。
// =============================================================
// LoginMenuKey 登录菜单的事件键值(与 isLoginMenuKey 保持一致)
const LoginMenuKey = "LOGIN"
type mpMenuButton struct {
Type string `json:"type,omitempty"`
Name string `json:"name"`
Key string `json:"key,omitempty"`
URL string `json:"url,omitempty"`
SubButton []mpMenuButton `json:"sub_button,omitempty"`
}
type mpMenuGetResp struct {
Menu struct {
Button []mpMenuButton `json:"button"`
} `json:"menu"`
// 个性化菜单:命中条件时会覆盖默认菜单(公众号底部看到的可能来自这里)
ConditionalMenu []map[string]interface{} `json:"conditionalmenu"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
// PublishMenuResult 发布结果(供页面展示与排查)
type PublishMenuResult struct {
Buttons []mpMenuButton `json:"button"`
AlreadyExists bool `json:"already_exists"`
ConditionalMenuCount int `json:"conditional_menu_count"`
}
type mpMenuCreateResp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
// PublishLoginMenu 在现有菜单基础上追加「扫码登录」菜单并发布。
// - 已存在登录菜单:不再重复发布(AlreadyExists=true);
// - 一级菜单满 3 个:返回错误提示;
// - 存在个性化菜单:一并返回条数(个性化菜单会覆盖默认菜单,需到公众号后台处理)。
//
// 注意:微信客户端有菜单缓存,发布后通常需重新关注或等待(最长 24 小时)才可见。
func PublishLoginMenu(cfg *Config) (*PublishMenuResult, error) {
token, err := GetAccessToken(cfg)
if err != nil {
return nil, err
}
// 1) 读取现有菜单;46003(菜单不存在)视为空菜单
var cur mpMenuGetResp
if gerr := httpGetJSON(apiBase+"/cgi-bin/menu/get?access_token="+url.QueryEscape(token), &cur); gerr != nil {
return nil, gerr
}
buttons := cur.Menu.Button
if cur.ErrCode != 0 && cur.ErrCode != 46003 {
return nil, &APIError{API: "读取自定义菜单", Code: cur.ErrCode, Msg: cur.ErrMsg}
}
logs.Info("[wechat-mp] 当前默认菜单 %d 项,个性化菜单 %d 条", len(buttons), len(cur.ConditionalMenu))
for i := range buttons {
if strings.EqualFold(strings.TrimSpace(buttons[i].Key), LoginMenuKey) {
return &PublishMenuResult{
Buttons: buttons,
AlreadyExists: true,
ConditionalMenuCount: len(cur.ConditionalMenu),
}, nil
}
}
if len(buttons) >= 3 {
return nil, errors.New("一级菜单已达 3 个上限,请到公众号后台精简后再发布")
}
// 2) 追加登录菜单并发布
buttons = append(buttons, mpMenuButton{Type: "click", Name: "扫码登录", Key: LoginMenuKey})
var created mpMenuCreateResp
if perr := httpPostJSON(apiBase+"/cgi-bin/menu/create?access_token="+url.QueryEscape(token),
map[string]interface{}{"button": buttons}, &created); perr != nil {
return nil, perr
}
if created.ErrCode != 0 {
return nil, &APIError{API: "发布自定义菜单", Code: created.ErrCode, Msg: created.ErrMsg}
}
logs.Info("[wechat-mp] 菜单发布成功,当前 %d 项", len(buttons))
return &PublishMenuResult{
Buttons: buttons,
ConditionalMenuCount: len(cur.ConditionalMenu),
}, nil
}
+232
View File
@@ -0,0 +1,232 @@
package wechatmp
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"server/models"
)
// =============================================================
// 公众号扫码登录(带参二维码方案):
// PC 端 /auth/login 扫码面板发起会话并轮询 → 展示带参二维码(scene=会话标识)→
// 用户微信扫码:已关注触发 SCAN 事件 / 未关注关注后触发 subscribe 事件,
// 服务号【自动】回复「确认登录」一次性链接(无需用户输入任何内容)→
// 用户点击链接(/mp-login 兑换,链接携带 scene)→ 精确认领 PC 会话 →
// PC 端轮询到确认后建立 UAC 会话自动登录。全程无极验 / 无登录码。
// 注:带参二维码需认证公众号;未认证(48001)时回退普通关注二维码,
// 用户在公众号发送「登录」换取确认链接。
// =============================================================
// ScanLoginTTL 扫码登录会话有效期
const ScanLoginTTL = 5 * time.Minute
var ErrScanLoginInvalid = errors.New("扫码登录会话不存在或已过期,请刷新二维码重试")
// StartScanLogin 发起 PC 扫码登录会话,并生成带参二维码。
// 带参二维码生成失败(未认证公众号 48001 等)时回退普通关注二维码:
// qrURL 为空、notice 返回回退原因,前端需明确提示用户走「发送登录」路径。
func StartScanLogin() (scene, qrURL, notice string, expireAt time.Time, err error) {
scene = randomScene()
now := time.Now()
expireAt = now.Add(ScanLoginTTL)
row := &models.WechatMpScanLogin{
Scene: scene,
Code: "-",
ExpireAt: expireAt,
CreateTime: now,
}
if _, err = models.Orm.Insert(row); err != nil {
return "", "", "", time.Time{}, err
}
cfg, cerr := LoadEnabledConfig()
if cerr == nil {
qr, qerr := CreateQRCode(cfg, scene, int(ScanLoginTTL.Seconds())+120)
if qerr == nil {
return scene, qr, "", expireAt, nil
}
var apiErr *APIError
if errors.As(qerr, &apiErr) {
notice = "公众号未返回带参二维码(" + apiErr.Error() + ")"
if hint := apiErr.Explain(); hint != "" {
notice += ":" + hint
}
} else {
notice = "生成带参二维码失败:" + qerr.Error()
}
} else {
notice = "公众号未配置或未启用,无法生成带参二维码"
}
notice += "。已回退普通二维码:已关注的用户扫码不会触发消息,请在公众号内发送「登录」获取确认链接。"
return scene, "", notice, expireAt, nil
}
// GetScanLogin 查询扫码登录会话(过期时惰性标记)
func GetScanLogin(scene string) (*models.WechatMpScanLogin, error) {
scene = strings.TrimSpace(scene)
if scene == "" {
return nil, ErrScanLoginInvalid
}
var row models.WechatMpScanLogin
if err := models.Orm.QueryTable(new(models.WechatMpScanLogin)).
Filter("scene", scene).One(&row); err != nil {
return nil, ErrScanLoginInvalid
}
if row.Status == models.WechatScanLoginPending && row.ExpireAt.Before(time.Now()) {
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.WechatMpScanLogin)).
Filter("id", row.ID).
Update(map[string]interface{}{"status": models.WechatScanLoginExpired, "update_time": now})
row.Status = models.WechatScanLoginExpired
}
return &row, nil
}
// HandleScanLoginEvent 带参二维码扫码事件(SCAN / subscribe 带 qrscene):
// scene 属于等待中的扫码登录会话时,自动回复「确认登录」链接(已绑定)
// 或绑定提示(未绑定),并返回 true;不属于登录会话返回 false 交由绑定流程处理。
func HandleScanLoginEvent(scene, openid string) (string, bool) {
scene = strings.TrimSpace(scene)
if scene == "" {
return "", false
}
row, err := GetScanLogin(scene)
if err != nil || row.Status != models.WechatScanLoginPending {
return "", false
}
f, ferr := GetFollowerByOpenID(openid)
if ferr != nil || f == nil || strings.TrimSpace(f.BindType) == "" || f.BindID == 0 {
return "当前微信尚未绑定账号,无法扫码登录:请先在系统页面完成「绑定微信」。", true
}
if f.BindType != models.WechatBindTypeTenantUser {
return "该微信绑定的是平台账号,暂不支持扫码登录。", true
}
token, _, terr := CreateLoginToken(f, ScanLoginTTL)
if terr != nil {
return "系统繁忙,请稍后重新扫码。", true
}
now := time.Now()
if _, uerr := models.Orm.QueryTable(new(models.WechatMpScanLogin)).
Filter("id", row.ID).
Update(map[string]interface{}{
"openid": openid,
"login_token": token,
"update_time": now,
}); uerr != nil {
return "系统繁忙,请稍后重新扫码。", true
}
link := LoginLinkURL(token) + "&scene=" + url.QueryEscape(scene)
minutes := 1
if m := int(time.Until(now.Add(ScanLoginTTL)).Minutes()); m > 1 {
minutes = m
}
return fmt.Sprintf("【扫码登录确认】\n点击以下链接确认登录(%d 分钟内有效,仅可使用一次):\n%s\n\n确认后您的电脑将自动登录。如非本人操作,请忽略本条消息。", minutes, link), true
}
// DirectLoginReply 判断该微信的绑定状态并返回对应回复:
// - 已绑定:直接给出「确认登录」一次性链接(点击后 web 直接登录);
// - 未绑定:提示先去系统页面完成绑定。
//
// 用于普通关注(无 scene)与公众号内发送「登录」的兜底路径。
func DirectLoginReply(openid string) string {
f, err := GetFollowerByOpenID(openid)
if err != nil || f == nil || strings.TrimSpace(f.BindType) == "" || f.BindID == 0 {
return "当前微信尚未绑定账号:请先在系统页面「绑定微信」处完成绑定(关注后发送「验证码」,再到页面填码)。"
}
if f.BindType != models.WechatBindTypeTenantUser {
return "该微信绑定的是平台账号,暂不支持扫码登录。"
}
token, expireAt, terr := CreateLoginToken(f, ScanLoginTTL)
if terr != nil {
return "系统繁忙,请稍后重新发送「登录」。"
}
link := LoginLinkURL(token)
if link == "" {
return "管理员尚未配置登录页地址(app.conf 的 wechat_mp_login_base),请联系管理员。"
}
minutes := 1
if m := int(time.Until(expireAt).Minutes()); m > 1 {
minutes = m
}
return fmt.Sprintf("【确认登录】\n点击以下链接直接登录(%d 分钟内有效,仅可使用一次):\n%s\n\n如非本人操作,请忽略本条消息。", minutes, link)
}
// ConfirmScanLoginByToken 兑换确认链接时精确认领 PC 扫码会话(scene + 令牌双重匹配,CAS)
func ConfirmScanLoginByToken(scene, loginToken, bindType string, bindID, bindTid uint64) {
scene = strings.TrimSpace(scene)
if scene == "" || strings.TrimSpace(loginToken) == "" {
return
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.WechatMpScanLogin)).
Filter("scene", scene).
Filter("login_token", strings.TrimSpace(loginToken)).
Filter("status", models.WechatScanLoginPending).
Update(map[string]interface{}{
"status": models.WechatScanLoginOK,
"bind_type": bindType,
"bind_id": bindID,
"bind_tid": bindTid,
"update_time": now,
})
}
// ConfirmOldestPendingScanLogin 无 scene 的兜底认领(「登录」文本路径):
// 认领最早一条待确认 PC 会话;没有等待中的会话时静默跳过。
func ConfirmOldestPendingScanLogin(bindType string, bindID, bindTid uint64) {
now := time.Now()
var row models.WechatMpScanLogin
err := models.Orm.QueryTable(new(models.WechatMpScanLogin)).
Filter("status", models.WechatScanLoginPending).
Filter("expire_at__gt", now).
OrderBy("id").
One(&row)
if err != nil {
return
}
models.Orm.QueryTable(new(models.WechatMpScanLogin)).
Filter("id", row.ID).
Filter("status", models.WechatScanLoginPending).
Update(map[string]interface{}{
"status": models.WechatScanLoginOK,
"bind_type": bindType,
"bind_id": bindID,
"bind_tid": bindTid,
"update_time": now,
})
}
// PickScanLogin PC 端领取确认结果:CAS 把「已确认」置为「已领取」,防止重复领取
func PickScanLogin(scene string) (*models.WechatMpScanLogin, error) {
row, err := GetScanLogin(scene)
if err != nil {
return nil, err
}
if row.Status == models.WechatScanLoginPending {
return nil, errors.New("等待微信确认:请在公众号内点击「确认登录」链接")
}
if row.Status == models.WechatScanLoginExpired {
return nil, ErrScanLoginInvalid
}
if row.Status != models.WechatScanLoginOK || row.BindID == 0 {
return nil, errors.New("该二维码已完成登录,请刷新后重新扫码")
}
now := time.Now()
affected, err := models.Orm.QueryTable(new(models.WechatMpScanLogin)).
Filter("id", row.ID).
Filter("status", models.WechatScanLoginOK).
Update(map[string]interface{}{"status": models.WechatScanLoginPicked, "update_time": now})
if err != nil {
return nil, err
}
if affected == 0 {
return nil, errors.New("该二维码已完成登录,请刷新后重新扫码")
}
return row, nil
}