批量优化功能
This commit is contained in:
@@ -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 扫码(关注/已关注)后的统一处理:写入验证码会话 + 回复验证码
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user