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:
1 parent
af0298e994
commit
62d9b81a70
10 files changed
+265
-45
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user