From 62d9b81a70dedcf729d12ef8b266ecc84cd7d56b Mon Sep 17 00:00:00 2001 From: opsx Date: Sun, 1 Nov 2026 03:08:37 +0800 Subject: [PATCH] fix(license): shared invalid/grace banners + instance copy Surface expired/over_instance/revoked red banners with CTAs via a shared LicenseStatusBanners used by settings and ProGate. Hide seats in product UI; keep HMAC dual-path and never silent-clear keys. --- backend/controllers/system.go | 9 +- backend/models/license.go | 35 +++++--- backend/services/license.go | 89 ++++++++++++++++++- backend/services/license_reason_test.go | 28 ++++++ frontend/src/api/license/index.ts | 3 +- .../src/components/LicenseStatusBanners.tsx | 71 +++++++++++++++ frontend/src/components/ProGate.tsx | 21 ++++- frontend/src/i18n/locales/en/license.ts | 9 +- frontend/src/i18n/locales/zh/license.ts | 9 +- .../settings/components/LicenseSection.tsx | 36 ++++---- 10 files changed, 265 insertions(+), 45 deletions(-) create mode 100644 backend/services/license_reason_test.go create mode 100644 frontend/src/components/LicenseStatusBanners.tsx 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 ( +
+ {showLegacy && ( +

+ {info?.deprecationNotice || t('legacyBanner')} +

+ )} + {showGrace && ( +

+ {t('graceBanner', { days: info?.graceDaysLeft ?? 0 })} +

+ )} + {invalidCopy && ( +
+ {invalidCopy.text} + {onAction && ( + + )} +
+ )} +
+ ) +}) diff --git a/frontend/src/components/ProGate.tsx b/frontend/src/components/ProGate.tsx index c307aa1..71d21df 100644 --- a/frontend/src/components/ProGate.tsx +++ b/frontend/src/components/ProGate.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from 'react' +import { memo, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { useTranslation } from 'react-i18next' import { Lock, Sparkles } from 'lucide-react' @@ -6,16 +6,23 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' +import { LicenseStatusBanners } from '@/components/LicenseStatusBanners' import { licenseApi } from '@/api/license' import { useLicense } from '@/hooks/useLicense' /** Pro 功能升级引导卡:免费版进入 Pro 页面时整页替代展示 */ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: string }) { const { t } = useTranslation('license') - const { reload } = useLicense() + const { pro, info, reload } = useLicense() const navigate = useNavigate() const [key, setKey] = useState('') const [activating, setActivating] = useState(false) + const inputRef = useRef(null) + + const focusKey = () => { + inputRef.current?.focus() + inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } const activate = async () => { if (!key.trim()) return @@ -33,6 +40,7 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin return (
+ @@ -50,6 +58,7 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin ))} + {/* 价卡暂留;product/plan 建好后再对齐 trial/年付/永久 */}

{t('pricing.personalTitle')}

@@ -63,7 +72,13 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
- setKey(e.target.value)} placeholder={t('gate.keyPlaceholder')} className="font-mono text-sm" /> + setKey(e.target.value)} + placeholder={t('gate.keyPlaceholder')} + className="font-mono text-sm" + />
diff --git a/frontend/src/i18n/locales/en/license.ts b/frontend/src/i18n/locales/en/license.ts index bfdd967..18345f3 100644 --- a/frontend/src/i18n/locales/en/license.ts +++ b/frontend/src/i18n/locales/en/license.ts @@ -6,7 +6,6 @@ export default { edition: 'Edition', expiry: 'Valid until', customer: 'Licensed to', - seats: 'Seats', instances: 'Instance limit', permanent: 'perpetual', personalTitle: 'Personal', @@ -18,6 +17,14 @@ export default { activateFailed: 'Activation failed', legacyBanner: 'Legacy HMAC keys are in a migration window. Please switch to a LIC- / LICOFF- key. Old keys stay valid until the window ends — never silently invalidated.', graceBanner: 'Offline grace: {{days}} day(s) left. Reconnect to re-verify.', + status: { + expired: 'License expired. Pro features are locked.', + expiredCta: 'Re-activate', + overInstance: 'Instance limit exceeded', + overInstanceCta: 'Release another instance / change key', + revoked: 'License invalid or revoked', + revokedCta: 'Replace license key', + }, gate: { title: '{{feature}} is a Pro feature', desc: 'Upgrade to Pro to unlock the full analytics suite. The Free plan keeps log query, deployment management and user/LDAP administration.', diff --git a/frontend/src/i18n/locales/zh/license.ts b/frontend/src/i18n/locales/zh/license.ts index b30a0df..d34a5fc 100644 --- a/frontend/src/i18n/locales/zh/license.ts +++ b/frontend/src/i18n/locales/zh/license.ts @@ -6,7 +6,6 @@ export default { edition: '版本', expiry: '有效期', customer: '授权对象', - seats: '席位', instances: '实例上限', permanent: '永久', personalTitle: '个人版', @@ -18,6 +17,14 @@ export default { activateFailed: '激活失败', legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。', graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。', + status: { + expired: '授权已过期,Pro 功能已锁定', + expiredCta: '重新激活', + overInstance: '已超过实例上限', + overInstanceCta: '释放其它实例 / 换码', + revoked: '授权无效或已吊销', + revokedCta: '更换许可码', + }, gate: { title: '{{feature}} 是 Pro 功能', desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。', diff --git a/frontend/src/pages/settings/components/LicenseSection.tsx b/frontend/src/pages/settings/components/LicenseSection.tsx index 715bba5..6764458 100644 --- a/frontend/src/pages/settings/components/LicenseSection.tsx +++ b/frontend/src/pages/settings/components/LicenseSection.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from 'react' +import { memo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { KeyRound } from 'lucide-react' +import { LicenseStatusBanners } from '@/components/LicenseStatusBanners' import { licenseApi } from '@/api/license' import { useLicense } from '@/hooks/useLicense' import { getUser } from '@/lib/auth-storage' @@ -17,6 +18,12 @@ export const LicenseSection = memo(function LicenseSection() { const isAdmin = getUser()?.role === 'admin' const [key, setKey] = useState('') const [activating, setActivating] = useState(false) + const inputRef = useRef(null) + + const focusKey = () => { + inputRef.current?.focus() + inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } const activate = async () => { if (!key.trim()) return @@ -39,8 +46,7 @@ export const LicenseSection = memo(function LicenseSection() { : t('permanent') : null - const showLegacy = pro && (info?.source === 'hmac' || Boolean(info?.deprecationNotice)) - const showGrace = Boolean(info?.inGrace) + const instanceLimit = info?.maxDevices || info?.instances return ( @@ -54,26 +60,16 @@ export const LicenseSection = memo(function LicenseSection() {
- {showLegacy && ( -

- {info?.deprecationNotice || t('legacyBanner')} -

- )} - {showGrace && ( -

- {t('graceBanner', { days: info?.graceDaysLeft ?? 0 })} -

- )} +
{pro ? (

{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() {
{isAdmin && (
- setKey(e.target.value)} placeholder={t('keyPlaceholder')} className="font-mono text-sm" /> + setKey(e.target.value)} + placeholder={t('keyPlaceholder')} + className="font-mono text-sm" + />