package handler import ( "errors" "net/http" "sync" "time" "github.com/gin-gonic/gin" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/service" "github.com/wangjia/jiu/backend/internal/util" ) type AuthHandler struct { svc *service.AuthService licSvc *service.LicenseService // 用于心跳 /auth/ping 回带授权概况;测试构造可传 nil } func NewAuthHandler(svc *service.AuthService, licSvc *service.LicenseService) *AuthHandler { return &AuthHandler{svc: svc, licSvc: licSvc} } // Login POST /api/v1/auth/login func (h *AuthHandler) Login(c *gin.Context) { var req struct { ShopCode string `json:"shop_code" binding:"required"` Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` Platform string `json:"platform"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } dev := service.DeviceInfo{ DeviceID: req.DeviceID, DeviceName: req.DeviceName, Platform: req.Platform, IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), } pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password, dev) if err != nil { status := http.StatusUnauthorized if errors.Is(err, service.ErrTooManyAttempts) { status = http.StatusTooManyRequests } else if errors.Is(err, service.ErrPlatformNotAllowed) { status = http.StatusForbidden } else if errors.Is(err, service.ErrDeviceLimitReached) { c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "DEVICE_LIMIT"}) return } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "access_token": pair.AccessToken, "refresh_token": pair.RefreshToken, "expires_in": pair.ExpiresIn, "shop_id": pair.ShopID, "user": gin.H{ "id": user.ID, "username": user.Username, "real_name": user.RealName, "role": user.Role, }, }, }) } // Register POST /api/v1/public/register func (h *AuthHandler) Register(c *gin.Context) { var req service.RegisterInput if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } result, err := h.svc.Register(req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } util.RespondSuccess(c, result) } // Refresh POST /api/v1/auth/refresh func (h *AuthHandler) Refresh(c *gin.Context) { var req struct { RefreshToken string `json:"refresh_token" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } pair, err := h.svc.RefreshTokens(req.RefreshToken) if err != nil { // 被踢/登出/禁用 → 给前端明确 code,便于提示"账号已在其他设备登录/已失效" if errors.Is(err, service.ErrSessionRevoked) || errors.Is(err, service.ErrUserInactive) { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error(), "code": "SESSION_REVOKED"}) return } c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } util.RespondSuccess(c, pair) } // Logout POST /api/v1/auth/logout —— 撤销当前会话 func (h *AuthHandler) Logout(c *gin.Context) { if err := h.svc.Logout(middleware.GetSID(c)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } util.RespondSuccess(c, gin.H{"ok": true}) } // Ping POST /api/v1/auth/ping —— 心跳:会话已撤销则中间件直接 401(触发登出), // 否则刷新 last_seen 并**回带当前授权概况**,使客户端无需再单独轮询 /license/info // (到期/续费/被改动等变化随心跳即时反映到横幅/只读门禁)。 func (h *AuthHandler) Ping(c *gin.Context) { if h.licSvc == nil { util.RespondSuccess(c, gin.H{"ok": true}) return } view, err := h.licSvc.ShopInfoView(middleware.GetShopID(c)) if err != nil { // 授权查询出错不应让心跳失败(会话本身有效):省略 license 字段,客户端保留上次状态。 util.RespondSuccess(c, gin.H{"ok": true}) return } // view 可能为 nil(确无授权)→ license:null,与 /license/info 的 data:null 语义一致。 util.RespondSuccess(c, gin.H{"ok": true, "license": view}) } // ═══════════════ App → 网页免登录(一次性换票)═══════════════ // webTicketLast 签票限频:每用户 1 秒至多 1 张(票据本就要求已登录,此为防刷兜底)。 var webTicketLast sync.Map // userID → time.Time // WebTicket POST /api/v1/auth/web-ticket(需登录):签发一次性网页登录票据。 // App 拿到票后拼 https:///sso?t=&redirect=<路径> 打开系统浏览器。 func (h *AuthHandler) WebTicket(c *gin.Context) { shopID := middleware.GetShopID(c) userID := middleware.GetUserID(c) if last, ok := webTicketLast.Load(userID); ok && time.Since(last.(time.Time)) < time.Second { c.JSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"}) return } webTicketLast.Store(userID, time.Now()) ticket, ttl, err := h.svc.IssueWebTicket(shopID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "签发失败"}) return } c.JSON(http.StatusOK, gin.H{"data": gin.H{ "ticket": ticket, "expires_in": ttl, }}) } // WebTicketExchange POST /api/v1/auth/web-ticket/exchange(公开): // 网页端凭一次性票据兑换正常登录态(响应结构同 /auth/login,另带 shop_code // 供官网 localStorage 回填)。票据单次可用,无效/过期/重放一律 401。 func (h *AuthHandler) WebTicketExchange(c *gin.Context) { var req struct { Ticket string `json:"ticket" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } dev := service.DeviceInfo{ Platform: "web", IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), } pair, user, shop, err := h.svc.LoginWithWebTicket(req.Ticket, dev) if err != nil { if errors.Is(err, service.ErrTicketInvalid) || errors.Is(err, service.ErrInvalidCredentials) || errors.Is(err, service.ErrUserInactive) { c.JSON(http.StatusUnauthorized, gin.H{"error": "票据无效或已过期"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "兑换失败"}) return } c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "access_token": pair.AccessToken, "refresh_token": pair.RefreshToken, "expires_in": pair.ExpiresIn, "shop_id": pair.ShopID, "shop_code": shop.Code, "user": gin.H{ "id": user.ID, "username": user.Username, "real_name": user.RealName, "role": user.Role, }, }, }) }