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.
This commit is contained in:
opsx committed 2026-11-01 03:08:37 +08:00
1 parent af0298e994
commit 62d9b81a70
10 files changed
+265 -45

No files matched your search

+4 -5
View File
@@ -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(),
})
}
+22 -13
View File
@@ -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
}
+85 -4
View File
@@ -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()
+28
View File
@@ -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")
}
+2 -1
View File
@@ -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'),
@@ -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 (
<div className="space-y-2">
{showLegacy && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
{info?.deprecationNotice || t('legacyBanner')}
</p>
)}
{showGrace && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
{t('graceBanner', { days: info?.graceDaysLeft ?? 0 })}
</p>
)}
{invalidCopy && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<span>{invalidCopy.text}</span>
{onAction && (
<Button
type="button"
size="sm"
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10"
onClick={onAction}
>
{invalidCopy.cta}
</Button>
)}
</div>
)}
</div>
)
})
+18 -3
View File
@@ -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<HTMLInputElement>(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 (
<div className="mx-auto max-w-2xl space-y-4 pt-8">
<LicenseStatusBanners info={info} pro={pro} onAction={focusKey} />
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
@@ -50,6 +58,7 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
</li>
))}
</ul>
{/* 价卡暂留;product/plan 建好后再对齐 trial/年付/永久 */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-4">
<p className="text-sm font-semibold">{t('pricing.personalTitle')}</p>
@@ -63,7 +72,13 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
</div>
</div>
<div className="flex gap-2">
<Input value={key} onChange={e => setKey(e.target.value)} placeholder={t('gate.keyPlaceholder')} className="font-mono text-sm" />
<Input
ref={inputRef}
value={key}
onChange={e => setKey(e.target.value)}
placeholder={t('gate.keyPlaceholder')}
className="font-mono text-sm"
/>
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>{t('gate.activate')}</Button>
<Button variant="outline" onClick={() => navigate('/settings')}>{t('gate.toSettings')}</Button>
</div>
+8 -1
View File
@@ -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.',
+8 -1
View File
@@ -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 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。',
@@ -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<HTMLInputElement>(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 (
<Card>
@@ -54,26 +60,16 @@ export const LicenseSection = memo(function LicenseSection() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{showLegacy && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
{info?.deprecationNotice || t('legacyBanner')}
</p>
)}
{showGrace && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
{t('graceBanner', { days: info?.graceDaysLeft ?? 0 })}
</p>
)}
<LicenseStatusBanners info={info} pro={pro} onAction={isAdmin ? focusKey : undefined} />
<div className="text-sm">
{pro ? (
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
<p>{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}</p>
<p>{t('expiry')}: {expiry}</p>
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
{Boolean(info?.maxDevices || info?.instances) && (
<p>{t('instances')}: {info?.maxDevices || info?.instances}</p>
{Boolean(instanceLimit) && (
<p>{t('instances')}: {instanceLimit}</p>
)}
{Boolean(info?.seats) && <p>{t('seats')}: {info?.seats}</p>}
</div>
) : (
<p className="text-muted-foreground">{t('freeDesc')}</p>
@@ -81,7 +77,13 @@ export const LicenseSection = memo(function LicenseSection() {
</div>
{isAdmin && (
<div className="flex gap-2">
<Input value={key} onChange={e => setKey(e.target.value)} placeholder={t('keyPlaceholder')} className="font-mono text-sm" />
<Input
ref={inputRef}
value={key}
onChange={e => setKey(e.target.value)}
placeholder={t('keyPlaceholder')}
className="font-mono text-sm"
/>
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
{activating ? t('activating') : t('activate')}
</Button>