Files
yunzerwebsiteallinone/go/services/wechatmp/scanlogin.go
T
2026-09-30 00:57:36 +08:00

233 lines
8.9 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 (
"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
}