Files
nl-im-service/internal/api/qrcode_handler.go
2026-08-24 15:29:53 +08:00

201 lines
6.6 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 api
* 作用App 扫码登录 PC 的 HTTP 接口。
*
* 流程:
* 1. PC未登录POST /qrcode/generate → 返回 qr_id + 二维码图片(base64)
* 2. PC 轮询 GET /qrcode/status?qr_id= → pending/scanned/confirmed/cancelled/expired
* 3. App已登录POST /qrcode/scan → 标记已扫码
* 4. App POST /qrcode/confirm → 签发 tokenPC 下次轮询取走并登录
* App POST /qrcode/cancel → 取消本次登录
*
* 鉴权划分generate/status 必须公开PC 此时未登录),
* scan/confirm/cancel 必须走 JWT 认证(确认人身份来自 token不可伪造
*/
package api
import (
"encoding/base64"
"time"
"xk-websocket-v2/internal/service"
"xk-websocket-v2/internal/utils"
"github.com/gin-gonic/gin"
qrcode "github.com/skip2/go-qrcode"
)
// 二维码内容前缀App 扫码后据此识别"这是本系统的登录码",其他内容一律不处理
const qrLoginContentPrefix = "nlim-login:"
// qrCodeReq scan/confirm/cancel 的公共请求体
type qrCodeReq struct {
QRID string `json:"qr_id" binding:"required"`
}
/**
* GenerateQRCodeHandler
* 功能生成扫码登录二维码PC 登录页调用,无需鉴权)
* 路径POST /api/qrcode/generate
* 返回qr_id、二维码 PNG 的 dataURL、有效秒数
* 说明图片由服务端生成前端无需引入二维码库qr_id 为 32 字节加密随机数,
* TTL 2 分钟自动过期,被刷接口时 Redis 只会堆积少量短命 key风险可控
*/
func GenerateQRCodeHandler(c *gin.Context) {
qrID, err := service.QRCodeLoginSvc.Generate()
if err != nil {
utils.InternalError(c, err.Error())
return
}
// 生成二维码 PNG256px中等纠错——内容短M 级足够且图案更稀疏易扫)
png, err := qrcode.Encode(qrLoginContentPrefix+qrID, qrcode.Medium, 256)
if err != nil {
utils.InternalError(c, "二维码图片生成失败")
return
}
utils.SuccessWithData(c, gin.H{
"qr_id": qrID,
"qr_image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(png),
"expires_in": 120,
}, "生成成功")
}
/**
* QRCodeStatusHandler
* 功能PC 长轮询二维码状态无需鉴权qr_id 即凭证)
* 路径GET /api/qrcode/status?qr_id=xxx&known=pending
* 说明:携带 known客户端已知状态时服务端最多 hold 25 秒,
* 状态一变化立即返回——相比 2 秒短轮询,请求数降一个量级且感知更即时。
* 25 秒小于前端 axios 超时(35s)与常见网关超时(60s),不会被中间层掐断。
* 不带 known 时立即返回当前状态(兼容行为)。
* 返回status 为 confirmed 时附带 token 与用户信息(一次性,取走即删)
*/
func QRCodeStatusHandler(c *gin.Context) {
qrID := c.Query("qr_id")
if qrID == "" {
utils.BadRequest(c, "缺少 qr_id 参数")
return
}
// 长轮询等待阶段(不消费 tokenhold 到状态变化/过期/超时/客户端断开
if known := c.Query("known"); known != "" {
session, expired, err := service.QRCodeLoginSvc.WaitForChange(c.Request.Context(), qrID, known, 25*time.Second)
if err != nil {
utils.InternalError(c, err.Error())
return
}
if expired {
utils.SuccessWithData(c, gin.H{"status": "expired"}, "二维码已过期")
return
}
// 非 confirmed 直接返回confirmed 落到下方的原子消费逻辑
// (必须重新走 DEL 仲裁,并发轮询时保证 token 只交付一次)
if session.Status != service.QRStatusConfirmed {
utils.SuccessWithData(c, gin.H{"status": session.Status}, "查询成功")
return
}
}
// 窥视 → 取用户 → 原子消费 三步走:
// 用户查询可能失败DB 抖动若先消费DEL再查询、查询失败
// 一次性 token 已被删除,手机确认成功而 PC 永远登录不上。
// 把可失败的准备工作全部放在消费之前,失败时 token 未消费PC 长轮询下一轮重试
session, err := service.QRCodeLoginSvc.GetStatus(qrID)
if err != nil {
utils.InternalError(c, err.Error())
return
}
if session == nil {
utils.SuccessWithData(c, gin.H{"status": "expired"}, "二维码已过期")
return
}
if session.Status != service.QRStatusConfirmed {
utils.SuccessWithData(c, gin.H{"status": session.Status}, "查询成功")
return
}
// 确认成功:先取用户信息(与账号密码登录的响应结构对齐,
// GetUserByID 内部已清除密码字段),此时 token 尚未消费、失败可重试
user, uErr := service.UserSvc.GetUserByID(session.ScannerID)
if uErr != nil {
utils.InternalError(c, "获取用户信息失败")
return
}
// 最后一步才原子消费token 唯一交付仍由 DEL 仲裁保证,
// 被并发轮询抢先deleted==0按已过期返回不会重复交付
consumed, expired, err := service.QRCodeLoginSvc.GetStatusAndConsume(qrID)
if err != nil {
utils.InternalError(c, err.Error())
return
}
if expired || consumed == nil {
utils.SuccessWithData(c, gin.H{"status": "expired"}, "二维码已过期")
return
}
utils.SuccessWithData(c, gin.H{
"status": consumed.Status,
"token": consumed.Token,
"user": user,
}, "查询成功")
}
/**
* ScanQRCodeHandler
* 功能App 扫码上报需鉴权PC 端随即显示"请在手机上确认"
* 路径POST /api/qrcode/scan
*/
func ScanQRCodeHandler(c *gin.Context) {
var req qrCodeReq
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误")
return
}
uid, _ := c.Get("user_id")
if err := service.QRCodeLoginSvc.Scan(req.QRID, uid.(string)); err != nil {
utils.BadRequest(c, err.Error())
return
}
utils.Success(c, "扫码成功")
}
/**
* ConfirmQRCodeHandler
* 功能App 确认登录(需鉴权且必须是扫码本人),为 PC 签发登录 token
* 路径POST /api/qrcode/confirm
*/
func ConfirmQRCodeHandler(c *gin.Context) {
var req qrCodeReq
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误")
return
}
uid, _ := c.Get("user_id")
if err := service.QRCodeLoginSvc.Confirm(req.QRID, uid.(string), utils.GenerateToken); err != nil {
utils.BadRequest(c, err.Error())
return
}
utils.Success(c, "确认成功")
}
/**
* CancelQRCodeHandler
* 功能App 取消本次扫码登录需鉴权PC 端提示后刷新二维码
* 路径POST /api/qrcode/cancel
*/
func CancelQRCodeHandler(c *gin.Context) {
var req qrCodeReq
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误")
return
}
uid, _ := c.Get("user_id")
if err := service.QRCodeLoginSvc.Cancel(req.QRID, uid.(string)); err != nil {
utils.BadRequest(c, err.Error())
return
}
utils.Success(c, "已取消")
}