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 }