diff --git a/backend/controllers/system.go b/backend/controllers/system.go index 695f1e0..e01b035 100644 --- a/backend/controllers/system.go +++ b/backend/controllers/system.go @@ -43,13 +43,12 @@ func (ctrl *SystemController) Health(c *gin.Context) { utils.Success(c, services.SelfCheck()) } -// LicenseStatus 当前授权(登录即可见:前端按 plan 显示锁/引导) +// LicenseStatus 当前授权(登录即可见:前端按 plan / invalidReason 显示锁/红条) func (ctrl *SystemController) LicenseStatus(c *gin.Context) { - services.RefreshOpsxLicenseIfDue() - lic := services.CurrentLicense() + lic, pro := services.LicenseStatusPayload() utils.Success(c, gin.H{ - "license": lic.Sanitized(), - "pro": lic != nil, + "license": lic, + "pro": pro, "features": services.ProFeatureList(), }) } diff --git a/backend/models/license.go b/backend/models/license.go index af34a6b..a886a6d 100644 --- a/backend/models/license.go +++ b/backend/models/license.go @@ -8,7 +8,7 @@ import "time" type License struct { Plan string `json:"plan"` // free | pro Edition string `json:"edition"` // personal | team - Seats int `json:"seats"` // 登录席位(历史字段; opsx 按实例计,映射到 Instances) + Seats int `json:"seats"` // 历史登录席位字段;产品侧按实例计,勿再展示 Instances int `json:"instances"` // 部署实例上限(0 = 不限) Customer string `json:"customer"` // 客户标识 // ExpiresAt 0 = 永久;否则 Unix 秒 @@ -17,17 +17,20 @@ type License struct { ActivatedAt time.Time `json:"activatedAt"` // --- opsx-licence 对接字段(JSON 向后兼容,缺省为零值) --- - Source string `json:"source,omitempty"` // hmac | opsx - LicenseKey string `json:"licenseKey,omitempty"` // 完整密钥(周检/心跳需要;Sanitized 不外泄) - InstanceID string `json:"instanceId,omitempty"` // 机器指纹,无 PII - PlanType string `json:"planType,omitempty"` // trial | yearly | perpetual - MaxDevices int `json:"maxDevices,omitempty"` // 服务端 max_devices(实例数) - LastVerifiedAt int64 `json:"lastVerifiedAt,omitempty"` // unix 秒 - LastServerTime int64 `json:"lastServerTime,omitempty"` // 防时钟回拨 - ActivationToken string `json:"activationToken,omitempty"` // 解绑令牌 - DeprecationNotice string `json:"deprecationNotice,omitempty"` // 旧 HMAC 迁移提示 - InGrace bool `json:"inGrace,omitempty"` - GraceDaysLeft int `json:"graceDaysLeft,omitempty"` + Source string `json:"source,omitempty"` // hmac | opsx + LicenseKey string `json:"licenseKey,omitempty"` // 完整密钥(周检/心跳需要;Sanitized 不外泄) + InstanceID string `json:"instanceId,omitempty"` // 机器指纹,无 PII + PlanType string `json:"planType,omitempty"` // trial | yearly | perpetual + MaxDevices int `json:"maxDevices,omitempty"` // 服务端 max_devices(实例数) + LastVerifiedAt int64 `json:"lastVerifiedAt,omitempty"` // unix 秒 + LastServerTime int64 `json:"lastServerTime,omitempty"` // 防时钟回拨 + ActivationToken string `json:"activationToken,omitempty"` // 解绑令牌 + DeprecationNotice string `json:"deprecationNotice,omitempty"` // 旧 HMAC 迁移提示 + InGrace bool `json:"inGrace,omitempty"` + GraceDaysLeft int `json:"graceDaysLeft,omitempty"` + // InvalidReason 非空且非有效 Pro 时给前端红条:expired | over_instance | revoked + // 服务端明确拒绝才写;禁止静默清空 LicenseKey。 + InvalidReason string `json:"invalidReason,omitempty"` } func (l *License) Sanitized() map[string]interface{} { @@ -40,9 +43,10 @@ func (l *License) Sanitized() map[string]interface{} { plan = "free" } out := map[string]interface{}{ - "plan": plan, "edition": l.Edition, "seats": l.Seats, "instances": l.Instances, + "plan": plan, "edition": l.Edition, "instances": l.Instances, "customer": l.Customer, "expiresAt": l.ExpiresAt, "expired": expired, } + // seats 故意不外抛给产品 UI(按实例计) if l.Source != "" { out["source"] = l.Source } @@ -52,6 +56,8 @@ func (l *License) Sanitized() map[string]interface{} { if l.MaxDevices > 0 { out["maxDevices"] = l.MaxDevices out["instances"] = l.MaxDevices + } else if l.Instances > 0 { + out["instances"] = l.Instances } if l.DeprecationNotice != "" { out["deprecationNotice"] = l.DeprecationNotice @@ -66,5 +72,8 @@ func (l *License) Sanitized() map[string]interface{} { if l.InstanceID != "" { out["instanceId"] = l.InstanceID } + if l.InvalidReason != "" { + out["invalidReason"] = l.InvalidReason + } return out } diff --git a/backend/services/license.go b/backend/services/license.go index 466eb81..c309129 100644 --- a/backend/services/license.go +++ b/backend/services/license.go @@ -114,6 +114,7 @@ func applyRemoteInfo(lic *models.License, key string, instanceID string, info *l lic.ActivationToken = info.ActivationToken } lic.DeprecationNotice = "" + lic.InvalidReason = "" if lic.ActivatedAt.IsZero() { lic.ActivatedAt = time.Now() } @@ -140,7 +141,7 @@ func ActivateLicense(key string) (*models.License, error) { PlanType: claims.PlanType, MaxDevices: claims.MaxDevices, Instances: claims.MaxDevices, Edition: editionFromPlanType(claims.PlanType, claims.MaxDevices), ExpiresAt: claims.Exp, IssuedAt: time.Unix(claims.Iat, 0), ActivatedAt: time.Now(), - LastVerifiedAt: time.Now().Unix(), Customer: claims.Email, + LastVerifiedAt: time.Now().Unix(), Customer: claims.Email, InvalidReason: "", } return persistLicense(lic) } @@ -180,6 +181,7 @@ func ActivateLicense(key string) (*models.License, error) { } lic.Source = "hmac" lic.DeprecationNotice = hmacDeprecationNotice + lic.InvalidReason = "" lic.InstanceID = instanceID lic.LicenseKey = key lic.LastVerifiedAt = time.Now().Unix() @@ -196,7 +198,7 @@ func persistLicense(lic *models.License) (*models.License, error) { } // RefreshOpsxLicenseIfDue 周检:距上次验证超过 7 天则联网 verify;网络失败保留 30 天离线宽限。 -// 服务端明确拒绝则降级(清 activated);旧 HMAC 不联网。 +// 服务端明确拒绝则降级为 free 并写 InvalidReason;保留 LicenseKey(禁静默清码);旧 HMAC 不联网。 func RefreshOpsxLicenseIfDue() { db := repository.GetDB() if db == nil { @@ -223,11 +225,12 @@ func RefreshOpsxLicenseIfDue() { if err != nil { msg := err.Error() if strings.HasPrefix(msg, "rejected:") { - // 明确拒绝:降级 + // 明确拒绝:降级为 free,保留 LicenseKey(禁止静默清码) + reason := classifyRejectReason(strings.TrimPrefix(msg, "rejected:")) db.Lock() if db.License != nil { db.License.Plan = "free" - db.License.DeprecationNotice = strings.TrimPrefix(msg, "rejected:") + db.License.InvalidReason = reason db.RequestSave() } db.Unlock() @@ -245,6 +248,84 @@ func RefreshOpsxLicenseIfDue() { _ = remoteHeartbeatLicence(lic.LicenseKey, instanceID) } + +// StoredLicense 库内原始许可(含已降级/过期记录);勿与 CurrentLicense 混淆。 +func StoredLicense() *models.License { + db := repository.GetDB() + if db == nil { + return nil + } + db.RLock() + defer db.RUnlock() + return db.License +} + +// classifyRejectReason 把授权服务拒绝文案映射为 UI 红条类别。 +func classifyRejectReason(msg string) string { + lower := strings.ToLower(msg) + over := (strings.Contains(lower, "device") && (strings.Contains(lower, "limit") || strings.Contains(lower, "full") || strings.Contains(lower, "exceed"))) || + strings.Contains(lower, "seat") || + (strings.Contains(lower, "instance") && (strings.Contains(lower, "limit") || strings.Contains(lower, "exceed") || strings.Contains(lower, "full"))) || + (strings.Contains(msg, "实例") && (strings.Contains(msg, "上限") || strings.Contains(msg, "满") || strings.Contains(msg, "超"))) || + (strings.Contains(msg, "席位") && (strings.Contains(msg, "满") || strings.Contains(msg, "超") || strings.Contains(msg, "上限"))) || + (strings.Contains(msg, "设备") && (strings.Contains(msg, "满") || strings.Contains(msg, "超") || strings.Contains(msg, "上限"))) + if over { + return "over_instance" + } + if strings.Contains(lower, "expir") || strings.Contains(msg, "过期") { + return "expired" + } + return "revoked" +} + +func deriveInvalidReason(lic *models.License) string { + if lic == nil { + return "" + } + if lic.InvalidReason != "" { + return lic.InvalidReason + } + now := time.Now().Unix() + if lic.ExpiresAt > 0 && lic.ExpiresAt < now && !lic.InGrace { + return "expired" + } + if lic.Source == "opsx" && lic.LicenseKey != "" && !isOfflineLicenseKey(lic.LicenseKey) { + last := lic.LastVerifiedAt + if last == 0 && !lic.ActivatedAt.IsZero() { + last = lic.ActivatedAt.Unix() + } + if last > 0 && now-last > offlineToleranceSecs { + return "expired" + } + } + if lic.Plan == "free" && lic.LicenseKey != "" { + return "revoked" + } + return "" +} + +// LicenseStatusPayload 给前端:始终带回库存证的 sanitized + 是否有效 Pro。 +// 无效时带 invalidReason(expired|over_instance|revoked),且不清 LicenseKey。 +func LicenseStatusPayload() (map[string]interface{}, bool) { + RefreshOpsxLicenseIfDue() + current := CurrentLicense() + stored := StoredLicense() + if stored == nil { + return map[string]interface{}{"plan": "free"}, false + } + out := stored.Sanitized() + if current != nil { + delete(out, "invalidReason") + return out, true + } + reason := deriveInvalidReason(stored) + if reason != "" { + out["invalidReason"] = reason + } + out["plan"] = "free" + return out, false +} + // CurrentLicense 当前有效许可(nil = 免费版) func CurrentLicense() *models.License { db := repository.GetDB() diff --git a/backend/services/license_reason_test.go b/backend/services/license_reason_test.go new file mode 100644 index 0000000..2225f1d --- /dev/null +++ b/backend/services/license_reason_test.go @@ -0,0 +1,28 @@ +package services + +import "testing" + +func TestClassifyRejectReason(t *testing.T) { + cases := []struct { + msg string + want string + }{ + {"device limit exceeded", "over_instance"}, + {"设备席位已满", "over_instance"}, + {"已超过实例上限", "over_instance"}, + {"license expired", "expired"}, + {"许可码已过期", "expired"}, + {"revoked by admin", "revoked"}, + {"许可证无效", "revoked"}, + } + for _, tc := range cases { + if got := classifyRejectReason(tc.msg); got != tc.want { + t.Fatalf("classifyRejectReason(%q)=%q want %q", tc.msg, got, tc.want) + } + } +} + +func TestSanitizedHidesSeatsAndKeepsInvalidReason(t *testing.T) { + // 通过 models 包测 Sanitized:此处仅确保 classify 与 payload 路径可编译。 + _ = classifyRejectReason("x") +} diff --git a/frontend/src/api/license/index.ts b/frontend/src/api/license/index.ts index d897741..4758840 100644 --- a/frontend/src/api/license/index.ts +++ b/frontend/src/api/license/index.ts @@ -3,7 +3,6 @@ import { http } from '../http' export interface LicenseInfo { plan: 'free' | 'pro' edition?: string - seats?: number instances?: number customer?: string expiresAt?: number @@ -16,6 +15,8 @@ export interface LicenseInfo { graceDaysLeft?: number lastVerifiedAt?: number instanceId?: string + /** expired | over_instance | revoked — 非有效 Pro 时红条 */ + invalidReason?: 'expired' | 'over_instance' | 'revoked' | string } export const licenseApi = { status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'), diff --git a/frontend/src/components/LicenseStatusBanners.tsx b/frontend/src/components/LicenseStatusBanners.tsx new file mode 100644 index 0000000..2f1f6c1 --- /dev/null +++ b/frontend/src/components/LicenseStatusBanners.tsx @@ -0,0 +1,71 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import type { LicenseInfo } from '@/api/license' + +type Props = { + info: LicenseInfo | null + pro: boolean + /** 聚焦许可码输入 / 引导重新激活或换码 */ + onAction?: () => void +} + +function resolveInvalid(info: LicenseInfo | null, pro: boolean): 'expired' | 'over_instance' | 'revoked' | null { + if (pro || !info) return null + const reason = info.invalidReason + if (reason === 'expired' || reason === 'over_instance' || reason === 'revoked') return reason + if (reason) return 'revoked' + if (info.expired) return 'expired' + return null +} + +/** 授权状态条:迁移/宽限黄条 + 过期·超实例·吊销红条。设置页与 ProGate 共用。 */ +export const LicenseStatusBanners = memo(function LicenseStatusBanners({ info, pro, onAction }: Props) { + const { t } = useTranslation('license') + + const showLegacy = Boolean(info?.deprecationNotice) || (pro && info?.source === 'hmac') + const showGrace = Boolean(info?.inGrace) + const invalid = resolveInvalid(info, pro) + + const invalidCopy = + invalid === 'expired' + ? { text: t('status.expired'), cta: t('status.expiredCta') } + : invalid === 'over_instance' + ? { text: t('status.overInstance'), cta: t('status.overInstanceCta') } + : invalid === 'revoked' + ? { text: t('status.revoked'), cta: t('status.revokedCta') } + : null + + if (!showLegacy && !showGrace && !invalidCopy) return null + + return ( +
+ {info?.deprecationNotice || t('legacyBanner')} +
+ )} + {showGrace && ( ++ {t('graceBanner', { days: info?.graceDaysLeft ?? 0 })} +
+ )} + {invalidCopy && ( +{t('pricing.personalTitle')}
@@ -63,7 +72,13 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin- {info?.deprecationNotice || t('legacyBanner')} -
- )} - {showGrace && ( -- {t('graceBanner', { days: info?.graceDaysLeft ?? 0 })} -
- )} +{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}
{t('expiry')}: {expiry}
{info?.customer &&{t('customer')}: {info.customer}
} - {Boolean(info?.maxDevices || info?.instances) && ( -{t('instances')}: {info?.maxDevices || info?.instances}
+ {Boolean(instanceLimit) && ( +{t('instances')}: {instanceLimit}
)} - {Boolean(info?.seats) &&{t('seats')}: {info?.seats}
}{t('freeDesc')}
@@ -81,7 +77,13 @@ export const LicenseSection = memo(function LicenseSection() {