201 lines
6.6 KiB
Go
201 lines
6.6 KiB
Go
/**
|
||
* 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, "已取消")
|
||
}
|