From cec7c6eeaf1f8143c72d1c3c3c84a983a8aa7f67 Mon Sep 17 00:00:00 2001 From: opsx Date: Sun, 1 Nov 2026 03:16:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(license):=20=E9=80=80=E5=87=BA=E6=8E=88?= =?UTF-8?q?=E6=9D=83=20/=20=E9=87=8A=E6=94=BE=E6=9C=AC=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=EF=BC=88=E7=A1=AE=E8=AE=A4=E5=90=8E=20deactivate=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置→授权 Pro 态管理员可退出:在线 LIC- 先调 opsx deactivate(含 activation_token),成功才清本地;失败保留现状。LICOFF- 仅解本机并提示后台名额可能仍占。禁静默清码。 --- backend/controllers/system.go | 10 ++ backend/routers/router.go | 1 + backend/services/licence_client.go | 22 +++ backend/services/license.go | 69 ++++++++ backend/services/license_deactivate_test.go | 165 ++++++++++++++++++ docs/licensing-opsx-licence.md | 10 ++ frontend/src/api/license/index.ts | 2 + frontend/src/i18n/locales/en/license.ts | 8 + frontend/src/i18n/locales/zh/license.ts | 8 + .../settings/components/LicenseSection.tsx | 46 ++++- 10 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 backend/services/license_deactivate_test.go diff --git a/backend/controllers/system.go b/backend/controllers/system.go index e01b035..69a1e01 100644 --- a/backend/controllers/system.go +++ b/backend/controllers/system.go @@ -68,3 +68,13 @@ func (ctrl *SystemController) ActivateLicense(c *gin.Context) { } utils.Success(c, lic.Sanitized()) } + +// DeactivateLicense 退出授权 / 释放本实例(admin);成功才清本地,失败保留现状。 +func (ctrl *SystemController) DeactivateLicense(c *gin.Context) { + res, err := services.DeactivateLicense() + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, res) +} diff --git a/backend/routers/router.go b/backend/routers/router.go index 1aeef57..67b5114 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -169,6 +169,7 @@ func SetupRouter() *gin.Engine { system.GET("/self-check", systemCtrl.Health) system.GET("/license", systemCtrl.LicenseStatus) system.POST("/license/activate", middleware.AdminAuth(), systemCtrl.ActivateLicense) + system.POST("/license/deactivate", middleware.AdminAuth(), systemCtrl.DeactivateLicense) } // 审计日志:仅管理员可查(含他人操作记录) diff --git a/backend/services/licence_client.go b/backend/services/licence_client.go index 82d7e5b..51dd3ab 100644 --- a/backend/services/licence_client.go +++ b/backend/services/licence_client.go @@ -331,3 +331,25 @@ func remoteHeartbeatLicence(licenseKey, deviceID string) error { } return nil } + +func remoteDeactivateLicence(licenseKey, deviceID, activationToken string) error { + body := map[string]string{ + "license_key": licenseKey, + "device_id": deviceID, + } + if strings.TrimSpace(activationToken) != "" { + body["activation_token"] = strings.TrimSpace(activationToken) + } + env, err := postLicenceJSON("/v1/license/deactivate", body) + if err != nil { + return err + } + if envelopeCode(env) != 0 { + msg := envelopeMessage(env) + if msg == "" { + msg = "解绑失败" + } + return fmt.Errorf("%s", msg) + } + return nil +} diff --git a/backend/services/license.go b/backend/services/license.go index eef98f5..6df0f98 100644 --- a/backend/services/license.go +++ b/backend/services/license.go @@ -188,6 +188,75 @@ func ActivateLicense(key string) (*models.License, error) { return persistLicense(lic) } +// DeactivateResult 退出授权结果。OfflineLocalOnly 表示仅解本机(LICOFF-),后台名额可能仍占。 +type DeactivateResult struct { + OfflineLocalOnly bool `json:"offlineLocalOnly"` + Message string `json:"message,omitempty"` +} + +// DeactivateLicense 用户主动退出授权 / 释放本实例。 +// - 在线 LIC-:先 POST /v1/license/deactivate(带 activation_token),成功才清本地;失败保留现状。 +// - 离线 LICOFF-:只解本机绑定,并提示后台名额可能仍占。 +// - 旧 HMAC:仅清本地(无远端座位)。 +// 禁止在校验失败路径静默清码;本函数仅用户确认后调用。 +func DeactivateLicense() (*DeactivateResult, error) { + db := repository.GetDB() + if db == nil { + return nil, fmt.Errorf("数据库未就绪") + } + db.RLock() + lic := db.License + db.RUnlock() + if lic == nil { + return nil, fmt.Errorf("当前未激活授权") + } + // 允许对仍存证的 Pro(含宽限)或刚降级但仍占远端席位的 opsx 证做主动释放 + key := strings.TrimSpace(lic.LicenseKey) + if lic.Plan != "pro" && key == "" { + return nil, fmt.Errorf("当前未激活授权") + } + + token := lic.ActivationToken + instanceID := lic.InstanceID + if instanceID == "" { + instanceID = GetOrCreateInstanceID() + } + + result := &DeactivateResult{} + + if key != "" && isOfflineLicenseKey(key) { + clearStoredLicense() + result.OfflineLocalOnly = true + result.Message = "已解除本机离线授权。后台名额可能仍占用,如需释放请联系运营处理。" + return result, nil + } + + if key != "" && isOpsxLicenseKey(key) { + if err := remoteDeactivateLicence(key, instanceID, token); err != nil { + return nil, fmt.Errorf("远端解绑失败,本机授权未清除: %w", err) + } + clearStoredLicense() + result.Message = "已退出授权并释放本实例" + return result, nil + } + + // HMAC 或无 key 的本地 Pro + clearStoredLicense() + result.Message = "已清除本机授权" + return result, nil +} + +func clearStoredLicense() { + db := repository.GetDB() + if db == nil { + return + } + db.Lock() + db.License = nil + db.RequestSave() + db.Unlock() +} + func persistLicense(lic *models.License) (*models.License, error) { db := repository.GetDB() db.Lock() diff --git a/backend/services/license_deactivate_test.go b/backend/services/license_deactivate_test.go new file mode 100644 index 0000000..1ad62e9 --- /dev/null +++ b/backend/services/license_deactivate_test.go @@ -0,0 +1,165 @@ +package services + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "lokistack/config" + "lokistack/models" + "lokistack/repository" +) + +func withTempDB(t *testing.T) { + t.Helper() + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters") + t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters") + if err := repository.InitDB(); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = repository.Shutdown() }) +} + +func TestDeactivateLicenseHMACClearsLocal(t *testing.T) { + withTempDB(t) + key, err := GenerateLicenseKey("pro", "personal", "c", 1, 1, 0) + if err != nil { + t.Fatal(err) + } + if _, err := ActivateLicense(key); err != nil { + t.Fatal(err) + } + if CurrentLicense() == nil { + t.Fatal("expected pro before deactivate") + } + res, err := DeactivateLicense() + if err != nil { + t.Fatal(err) + } + if res.OfflineLocalOnly { + t.Fatal("HMAC should not be offlineLocalOnly") + } + if CurrentLicense() != nil || StoredLicense() != nil { + t.Fatal("expected local license cleared") + } +} + +func TestDeactivateLicenseOfflineLocalOnly(t *testing.T) { + withTempDB(t) + instanceID := GetOrCreateInstanceID() + // 无法签发真 LICOFF- 无私钥;直接写入库存模拟离线证 + db := repository.GetDB() + db.Lock() + db.License = &models.License{ + Plan: "pro", Source: "opsx", LicenseKey: "LICOFF-TEST-FAKE", + InstanceID: instanceID, ActivatedAt: time.Now(), IssuedAt: time.Now(), + } + db.RequestSave() + db.Unlock() + + res, err := DeactivateLicense() + if err != nil { + t.Fatal(err) + } + if !res.OfflineLocalOnly { + t.Fatal("expected offlineLocalOnly") + } + if !strings.Contains(res.Message, "后台") && !strings.Contains(strings.ToLower(res.Message), "ops") { + // 中文提示 + if res.Message == "" { + t.Fatal("expected offline note message") + } + } + if StoredLicense() != nil { + t.Fatal("expected cleared") + } +} + +func TestDeactivateLicenseOnlineKeepsStateOnRemoteFailure(t *testing.T) { + withTempDB(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"code": 1, "message": "解绑校验失败"}) + })) + defer srv.Close() + + cfg := config.GetConfig() + prev := cfg.LicenseAPIBase + cfg.LicenseAPIBase = srv.URL + defer func() { cfg.LicenseAPIBase = prev }() + + db := repository.GetDB() + db.Lock() + db.License = &models.License{ + Plan: "pro", Source: "opsx", LicenseKey: "LIC-KEEP-ON-FAIL", + InstanceID: GetOrCreateInstanceID(), ActivationToken: "tok", + ActivatedAt: time.Now(), IssuedAt: time.Now(), LastVerifiedAt: time.Now().Unix(), + } + db.RequestSave() + db.Unlock() + + _, err := DeactivateLicense() + if err == nil { + t.Fatal("expected remote failure") + } + if !strings.Contains(err.Error(), "远端解绑失败") { + t.Fatalf("unexpected err: %v", err) + } + stored := StoredLicense() + if stored == nil || stored.LicenseKey != "LIC-KEEP-ON-FAIL" || stored.Plan != "pro" { + t.Fatalf("must keep local state on failure: %+v", stored) + } +} + +func TestDeactivateLicenseOnlineSuccessClears(t *testing.T) { + withTempDB(t) + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/license/deactivate" { + t.Errorf("path %s", r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "message": "ok"}) + })) + defer srv.Close() + + cfg := config.GetConfig() + prev := cfg.LicenseAPIBase + cfg.LicenseAPIBase = srv.URL + defer func() { cfg.LicenseAPIBase = prev }() + + id := GetOrCreateInstanceID() + db := repository.GetDB() + db.Lock() + db.License = &models.License{ + Plan: "pro", Source: "opsx", LicenseKey: "LIC-OK", + InstanceID: id, ActivationToken: "act-token", + ActivatedAt: time.Now(), IssuedAt: time.Now(), LastVerifiedAt: time.Now().Unix(), + } + db.RequestSave() + db.Unlock() + + res, err := DeactivateLicense() + if err != nil { + t.Fatal(err) + } + if res.OfflineLocalOnly { + t.Fatal("online should not set offlineLocalOnly") + } + if gotBody["license_key"] != "LIC-OK" || gotBody["device_id"] != id || gotBody["activation_token"] != "act-token" { + t.Fatalf("bad deactivate body: %#v", gotBody) + } + if StoredLicense() != nil { + t.Fatal("expected cleared after success") + } +} + +func TestDeactivateLicenseEmpty(t *testing.T) { + withTempDB(t) + if _, err := DeactivateLicense(); err == nil { + t.Fatal("expected error when no license") + } +} diff --git a/docs/licensing-opsx-licence.md b/docs/licensing-opsx-licence.md index 6b10511..ade803d 100644 --- a/docs/licensing-opsx-licence.md +++ b/docs/licensing-opsx-licence.md @@ -72,3 +72,13 @@ curl -sS 'https://licence.rlwj.cn/api/v1/products/code/lokistack' | 绑定字段 | `device_id` | `instance_id`(同语义) | | 默认 Base | l123 主 / 国内备 | **国内主** / l123 备 | | 落盘 | `license.opsx` | JsonDB `license` 字段 | +| 退出授权 | `clear` → remote deactivate + 删本地 | 设置→授权「退出授权」→ `POST /system/license/deactivate`;在线成功才清本地;LICOFF- 仅解本机并提示后台名额可能仍占 | + +## 退出授权(本机) + +- UI:设置 → 授权,Pro 态管理员可见「退出授权 / 释放本实例」,二次确认。 +- 后端:`POST /api/v1/system/license/deactivate`(Admin)。 +- 在线 `LIC-`:调用 `POST {LICENSE_API_BASE}/v1/license/deactivate`,body 含 `license_key`、`device_id`(=instance_id)、`activation_token`(若有);**成功才清本地**,失败保留现状并报错。 +- 离线 `LICOFF-`:只清本地,响应 `offlineLocalOnly=true`,提示后台名额可能仍占。 +- 旧 HMAC:只清本地。 +- 禁止在周检/校验失败路径静默清码;仅用户确认后走本接口。 diff --git a/frontend/src/api/license/index.ts b/frontend/src/api/license/index.ts index 4758840..c584195 100644 --- a/frontend/src/api/license/index.ts +++ b/frontend/src/api/license/index.ts @@ -21,4 +21,6 @@ export interface LicenseInfo { export const licenseApi = { status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'), activate: (key: string) => http.post('/system/license/activate', { key }), + deactivate: () => + http.post<{ offlineLocalOnly?: boolean; message?: string }>('/system/license/deactivate'), } diff --git a/frontend/src/i18n/locales/en/license.ts b/frontend/src/i18n/locales/en/license.ts index 448dac5..930d3f2 100644 --- a/frontend/src/i18n/locales/en/license.ts +++ b/frontend/src/i18n/locales/en/license.ts @@ -21,6 +21,14 @@ export default { activating: 'Activating…', activated: 'Pro activated — all features unlocked', activateFailed: 'Activation failed', + deactivate: 'Deactivate / release this instance', + deactivateConfirmTitle: 'Deactivate license?', + deactivateConfirmDesc: 'This releases the seat for this instance and returns the machine to Free. Online keys notify the license server first; on failure local state is kept (never silently cleared).', + deactivateConfirm: 'Confirm deactivate', + deactivating: 'Deactivating…', + deactivated: 'License deactivated — this machine is Free again', + deactivateFailed: 'Deactivation failed', + deactivateOfflineNote: 'Local offline license unbound. The server seat may still be occupied — ask ops to clear it if needed.', 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: { diff --git a/frontend/src/i18n/locales/zh/license.ts b/frontend/src/i18n/locales/zh/license.ts index 59584be..3f55272 100644 --- a/frontend/src/i18n/locales/zh/license.ts +++ b/frontend/src/i18n/locales/zh/license.ts @@ -21,6 +21,14 @@ export default { activating: '激活中…', activated: 'Pro 已激活,全部功能已解锁', activateFailed: '激活失败', + deactivate: '退出授权 / 释放本实例', + deactivateConfirmTitle: '退出授权?', + deactivateConfirmDesc: '将释放本实例占用的授权名额,本机回到免费版。在线授权会先通知授权服务器;失败则保留现状,不会静默清码。', + deactivateConfirm: '确认退出', + deactivating: '退出中…', + deactivated: '已退出授权,本机已回到免费版', + deactivateFailed: '退出授权失败', + deactivateOfflineNote: '已解除本机离线授权。后台名额可能仍占用,如需释放请联系运营处理。', legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。', graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。', status: { diff --git a/frontend/src/pages/settings/components/LicenseSection.tsx b/frontend/src/pages/settings/components/LicenseSection.tsx index 0db1ce1..4861226 100644 --- a/frontend/src/pages/settings/components/LicenseSection.tsx +++ b/frontend/src/pages/settings/components/LicenseSection.tsx @@ -6,19 +6,22 @@ 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 { ConfirmDialog } from '@/components/ConfirmDialog' import { InstanceIdRow } from '@/components/InstanceIdRow' import { LicenseStatusBanners } from '@/components/LicenseStatusBanners' import { licenseApi } from '@/api/license' import { useLicense } from '@/hooks/useLicense' import { getUser } from '@/lib/auth-storage' -/** 授权区块:当前版本状态 + 许可码激活(仅管理员可操作) */ +/** 授权区块:当前版本状态 + 许可码激活 / 退出授权(仅管理员可操作) */ export const LicenseSection = memo(function LicenseSection() { const { t } = useTranslation('license') const { pro, info, reload } = useLicense() const isAdmin = getUser()?.role === 'admin' const [key, setKey] = useState('') const [activating, setActivating] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + const [deactivating, setDeactivating] = useState(false) const inputRef = useRef(null) const focusKey = () => { @@ -41,6 +44,24 @@ export const LicenseSection = memo(function LicenseSection() { } } + const deactivate = async () => { + setDeactivating(true) + try { + const res = await licenseApi.deactivate() + if (res?.offlineLocalOnly) { + toast.success(res.message || t('deactivateOfflineNote')) + } else { + toast.success(res?.message || t('deactivated')) + } + setConfirmOpen(false) + reload() + } catch (err) { + toast.error(err instanceof Error ? err.message : t('deactivateFailed')) + } finally { + setDeactivating(false) + } + } + const expiry = info?.expiresAt ? info.expiresAt > 0 ? new Date(info.expiresAt * 1000).toLocaleDateString() @@ -80,21 +101,40 @@ export const LicenseSection = memo(function LicenseSection() { {isAdmin && (

{t('activateHint')}

-
+
setKey(e.target.value)} placeholder={t('keyPlaceholder')} - className="font-mono text-sm" + className="min-w-[12rem] flex-1 font-mono text-sm" /> + {pro && ( + + )}
)} + void deactivate()} + /> ) })