package middleware import ( "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "gorm.io/gorm" "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" ) type Claims struct { UserID uint64 `json:"user_id"` ShopID uint64 `json:"shop_id"` Role string `json:"role"` SID string `json:"sid,omitempty"` // 服务端会话标识(user_sessions.sid) LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual jwt.RegisteredClaims } const ( CtxUserID = "user_id" CtxShopID = "shop_id" CtxRole = "role" CtxSID = "sid" CtxLicenseExpiresAt = "lic_exp" ) // lastSeenThrottle 控制 last_seen_at 写频率,避免每请求一写。 const lastSeenThrottle = 30 * time.Second func JWT(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) return } tokenStr := strings.TrimPrefix(authHeader, "Bearer ") claims := &Claims{} token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { return []byte(config.C.JWT.Secret), nil }) if err != nil || !token.Valid { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } // 会话校验:带 sid 的 token 必须对应一条未撤销会话(支持踢人/登出/禁用即时失效)。 // 存量无 sid 的 token 过渡放行(其 access ≤60min 过期后会换到带 sid 的会话)。 if claims.SID != "" { // 一次查询同时取会话 + 用户启用状态:兜底「直接改库 is_active=0」也能即时下线。 var row struct { model.UserSession IsActive bool DeletedAt *time.Time } err := db.Table("user_sessions AS s"). Select("s.*, u.is_active AS is_active, u.deleted_at AS deleted_at"). Joins("LEFT JOIN users u ON u.id = s.user_id"). Where("s.sid = ?", claims.SID).First(&row).Error if err != nil || row.UserSession.RevokedAt != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session revoked", "code": "SESSION_REVOKED"}) return } if !row.IsActive || row.DeletedAt != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user disabled", "code": "USER_DISABLED"}) return } // 节流刷新 last_seen,用于在线状态判定 if time.Since(row.UserSession.LastSeenAt) > lastSeenThrottle { db.Model(&model.UserSession{}).Where("id = ?", row.UserSession.ID). Update("last_seen_at", time.Now()) } } c.Set(CtxUserID, claims.UserID) c.Set(CtxShopID, claims.ShopID) c.Set(CtxRole, claims.Role) c.Set(CtxSID, claims.SID) c.Set(CtxLicenseExpiresAt, claims.LicenseExpiresAt) c.Next() } } // GetSID 从 context 中获取当前会话 sid(可能为空:存量无 sid token)。 func GetSID(c *gin.Context) string { v, _ := c.Get(CtxSID) s, _ := v.(string) return s } // GetRole 从 context 中获取当前用户角色。 func GetRole(c *gin.Context) string { v, _ := c.Get(CtxRole) s, _ := v.(string) return s } // AdminOnly 仅管理员可访问 func AdminOnly() gin.HandlerFunc { return func(c *gin.Context) { role, _ := c.Get(CtxRole) if role != "admin" && role != "superadmin" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"}) return } c.Next() } } // ReadOnly 只读用户禁止写操作 func ReadOnly() gin.HandlerFunc { return func(c *gin.Context) { role, _ := c.Get(CtxRole) if role == "readonly" && c.Request.Method != "GET" { // code 供前端区分「角色只读」与「授权过期」(后者由 LicenseGuard 返回 phase) c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "readonly user", "code": "READONLY_USER", }) return } c.Next() } } // GetShopID 从 context 中安全获取 shop_id func GetShopID(c *gin.Context) uint64 { v, _ := c.Get(CtxShopID) id, _ := v.(uint64) return id } // GetUserID 从 context 中安全获取 user_id func GetUserID(c *gin.Context) uint64 { v, _ := c.Get(CtxUserID) id, _ := v.(uint64) return id } // SuperAdminOnly 仅超级管理员可访问 func SuperAdminOnly() gin.HandlerFunc { return func(c *gin.Context) { role, _ := c.Get(CtxRole) if role != "superadmin" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "superadmin only"}) return } c.Next() } }