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")
}