Files
nl-im-service/internal/api/qrcode_handler.go

201 lines
6.6 KiB
Go
Raw Permalink Normal View History

2026-08-24 15:29:53 +08:00
/**
* 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, "已取消")
}