package middleware import ( "net/http" "sync" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/model" ) const ( PhaseNormal = "normal" PhaseGrace = "grace" // expired 0–7 days: writable, show banner PhaseReadOnly = "readonly" // expired 7–15 days: read-only PhaseLocked = "locked" // expired 15+ days: no login ) var ( graceWindow = 7 * 24 * time.Hour readOnlyWindow = 15 * 24 * time.Hour ) // CalcLicensePhase computes the degradation phase based on expires_at. // nil expiresAt = perpetual license = normal. func CalcLicensePhase(expiresAt *time.Time) string { if expiresAt == nil { return PhaseNormal } elapsed := time.Since(*expiresAt) if elapsed <= 0 { return PhaseNormal } if elapsed <= graceWindow { return PhaseGrace } if elapsed <= readOnlyWindow { return PhaseReadOnly } return PhaseLocked } // GetLicensePhase returns the phase derived from the JWT claim (login-time snapshot). // Kept for display/diagnostic use; enforcement uses the live DB phase (see LicenseGuard). func GetLicensePhase(c *gin.Context) string { v, _ := c.Get(CtxLicenseExpiresAt) ptr, _ := v.(*int64) if ptr == nil { return PhaseNormal } t := time.Unix(*ptr, 0) return CalcLicensePhase(&t) } // licensePhaseCacheTTL 控制实时 phase 查库的缓存时长: // 既避免每个写请求都查库,又保证 DB 改动(续费/过期/被改)在 TTL 内生效。 // 与前端心跳(30s)同量级,用户感知一致。 const licensePhaseCacheTTL = 30 * time.Second type licensePhaseEntry struct { expiresAt *time.Time // nil = 永久授权 / 无有效授权(按 normal 处理见下) hasActive bool // 是否存在有效授权记录 revoked bool // 曾配置授权但当前全部被停用/吊销(is_active=0)→ 锁定写操作 fetchedAt time.Time } var licensePhaseCache sync.Map // shopID(uint64) -> licensePhaseEntry // InvalidateLicensePhase 清除某店的 phase 缓存,使授权变更(激活/续费/停用)即时生效, // 不必等 30s TTL 自然过期。授权服务在激活/签发后调用。 func InvalidateLicensePhase(shopID uint64) { licensePhaseCache.Delete(shopID) } // liveLicensePhase 按当前 DB 的有效授权实时计算 phase(带 30s 每店缓存)。 // - 有有效授权:按其 expires_at 计算 phase。 // - 曾有授权但当前全部被停用(is_active=0):视为被吊销 → 锁定(管理员主动收回授权即时生效)。 // - 从未配置任何授权(如未启用授权体系/试用签发失败):回退到 token 中的 lic_exp 快照,避免误锁。 func liveLicensePhase(db *gorm.DB, c *gin.Context) string { shopID := GetShopID(c) var entry licensePhaseEntry if v, ok := licensePhaseCache.Load(shopID); ok { entry = v.(licensePhaseEntry) } if entry.fetchedAt.IsZero() || time.Since(entry.fetchedAt) > licensePhaseCacheTTL { var lic model.License // 必须与 LicenseService.ShopInfo(/license/info 展示)一致: // 取最新创建(id DESC)的有效授权,否则展示与拦截可能选到不同记录, // 出现「展示已过期但仍可写」。 err := db.Where("shop_id = ? AND is_active = ?", shopID, true). Order("id DESC").First(&lic).Error if err == nil { entry = licensePhaseEntry{expiresAt: lic.ExpiresAt, hasActive: true, fetchedAt: time.Now()} } else { // 无有效授权:区分「曾配置过但被停用/吊销」与「从未配置」。 // 过期授权 is_active 仍为 1(走上面分支按 phase 降级),故此处无 active 记录 // 只可能是管理员主动停用(is_active=0)或确实未配置。 var anyCount int64 db.Model(&model.License{}).Where("shop_id = ?", shopID).Count(&anyCount) entry = licensePhaseEntry{hasActive: false, revoked: anyCount > 0, fetchedAt: time.Now()} } licensePhaseCache.Store(shopID, entry) } if entry.revoked { // 曾有授权但当前全部被停用 → 收回写权限,与「锁定」一致。 return PhaseLocked } if !entry.hasActive { // 从未配置有效授权:回退到登录时 token 里的快照,避免误判 return GetLicensePhase(c) } return CalcLicensePhase(entry.expiresAt) } // LicenseGuard blocks write operations when the shop's license is expired (readonly/locked). // 以**当前 DB** 的授权状态实时判定 phase(带 30s 缓存),而非信任登录时嵌入 token 的快照, // 这样续费/过期/被改动后无需重登即可在 ~30s 内对写操作生效。 // License routes (/license/*) must be mounted outside this middleware so users can // view status and activate a new key even when locked. func LicenseGuard(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { phase := liveLicensePhase(db, c) switch phase { case PhaseLocked: c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "授权已锁定,请续费或激活新授权码", "phase": PhaseLocked, }) case PhaseReadOnly: if c.Request.Method != http.MethodGet { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "授权已过期,当前为只读模式", "phase": PhaseReadOnly, }) return } c.Next() default: c.Next() } } }