/** * 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 → 签发 token,PC 下次轮询取走并登录 * 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 } // 生成二维码 PNG(256px,中等纠错——内容短,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 } // 长轮询等待阶段(不消费 token):hold 到状态变化/过期/超时/客户端断开 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, "已取消") }