diff --git a/app/api/license/activate/__tests__/route.test.ts b/app/api/license/activate/__tests__/route.test.ts index 47e54c7..eba2b29 100644 --- a/app/api/license/activate/__tests__/route.test.ts +++ b/app/api/license/activate/__tests__/route.test.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import { POST } from '../route' import { verifyLicenseSignature } from '@/lib/license/crypto-service' import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service' +import { syncPremiumModules } from '@/lib/license/premium-module-service' jest.mock('@/lib/license/fingerprint-service', () => ({ generateFingerprint: jest.fn().mockResolvedValue({ @@ -26,10 +27,15 @@ jest.mock('@/lib/license/license-service', () => ({ startHeartbeat: jest.fn(), })) +jest.mock('@/lib/license/premium-module-service', () => ({ + syncPremiumModules: jest.fn(), +})) + const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction const mockInitializeSystem = initializeSystem as jest.MockedFunction const mockStartHeartbeat = startHeartbeat as jest.MockedFunction +const mockSyncPremiumModules = syncPremiumModules as jest.MockedFunction const signedLicense = { data: { @@ -85,6 +91,7 @@ describe('POST /api/license/activate', () => { process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai' process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32) mockVerifyLicenseSignature.mockReturnValue(true) + mockSyncPremiumModules.mockResolvedValue({ synced: true }) mockInitializeSystem.mockResolvedValue({ admin: { created: false, @@ -113,6 +120,7 @@ describe('POST /api/license/activate', () => { expect(data.success).toBe(true) expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense) expect(mockVerifyLicenseSignature).toHaveBeenCalledWith(signedLicense.data, signedLicense.signature) + expect(mockSyncPremiumModules).toHaveBeenCalled() expect(mockStartHeartbeat).toHaveBeenCalled() expect(setCookieHeader).toContain('license_activated=true') expect(setCookieHeader).toContain('HttpOnly') @@ -185,6 +193,7 @@ describe('POST /api/license/activate', () => { expect(rejectedResponse.status).toBe(400) expect(rejectedData.error).toBe('许可证无效') expect(mockSaveLicenseLocally).not.toHaveBeenCalled() + expect(mockSyncPremiumModules).not.toHaveBeenCalled() mockActivateSuccess() mockVerifyLicenseSignature.mockReturnValueOnce(false) @@ -195,6 +204,23 @@ describe('POST /api/license/activate', () => { expect(invalidSignatureResponse.status).toBe(400) expect(invalidSignatureData.error).toBe('许可证签名验证失败') expect(mockSaveLicenseLocally).not.toHaveBeenCalled() + expect(mockSyncPremiumModules).not.toHaveBeenCalled() + }) + + test('付费模块清单同步失败不应阻断许可证激活', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + mockActivateSuccess() + mockSyncPremiumModules.mockResolvedValueOnce({ synced: false, error: 'network down' }) + + const response = await POST(createMockRequest()) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense) + expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 付费模块清单同步失败:', 'network down') + + consoleWarnSpy.mockRestore() }) test('HTTPS 或代理 HTTPS 时 cookie 带 Secure', async () => { diff --git a/app/api/license/activate/route.ts b/app/api/license/activate/route.ts index b2f9f76..b6e1310 100644 --- a/app/api/license/activate/route.ts +++ b/app/api/license/activate/route.ts @@ -4,6 +4,7 @@ import { verifyLicenseSignature } from '@/lib/license/crypto-service'; import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'; import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config'; import { buildSignedLicenseRequest } from '@/lib/license/request-signature'; +import { syncPremiumModules } from '@/lib/license/premium-module-service'; import type { ActivateResponse } from '@/lib/license/types'; export async function POST(request: NextRequest) { @@ -53,6 +54,10 @@ export async function POST(request: NextRequest) { // ✅ 在后端保存许可证 await saveLicenseLocally(licenseKey, data.license); + const moduleSyncResult = await syncPremiumModules(); + if (!moduleSyncResult.synced && !moduleSyncResult.skipped) { + console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error); + } // 初始化系统(创建默认管理员、部门、店铺等) const initResult = await initializeSystem(); diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index c970ac4..97fb90f 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -406,8 +406,9 @@ - `license-server/lib/abuse-detection.ts`、`license-server/app/api/license/[projectCode]/register-free/route.ts`、`activate/verify/heartbeat`:免费版新增同设备重复注册、多实例/多指纹复用阻断;付费版多设备疑似复用先告警,不直接误伤客户。 - `license-server/app/api/license/[projectCode]/register-free/route.ts`:免费注册复用已有激活前会检查许可证禁用和过期状态;首条心跳记录统一写入硬件指纹哈希,避免后续被误判为多设备。 - `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/lib/premium-module-manifest.ts`:新增付费模块清单接口,免费版拒绝,付费版按许可证类型返回签名 manifest,并在硬件指纹明显不匹配时拒绝并告警。 -- `lib/license/premium-module-service.ts`:主应用在验证和心跳续证成功后同步付费模块 manifest,本地加密缓存并校验 RSA 签名、租约和当前许可证类型,避免降级后继续使用旧 PRO 模块清单。 -- `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉。 +- `app/api/license/activate/route.ts`、`app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`、`lib/license/premium-module-service.ts`:主应用在付费激活、验证和心跳续证成功后同步付费模块 manifest,本地加密缓存并校验 RSA 签名、租约和当前许可证类型,避免降级后继续使用旧 PRO 模块清单。 +- `lib/license/check-limits.ts`:自定义模板和内网穿透已升级为“许可证功能开关 + 签名付费模块清单”双门槛;没有验证服务器下发的模块凭证时,即使本地许可证字段被篡改为开启,也不会通过服务端功能检查。 +- `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉;同时锁定高价值付费能力必须依赖 `hasPremiumModule()`。 - `lib/__tests__/license-service.test.ts`:新增测试覆盖许可证过期、在线租约过期、高级功能不可用、数量限制不放行、本地许可证从 Pro 降级到 Free 后业务检查立即按 Free 生效,以及旧许可证缺少 `leaseExpiresAt` 时的兼容行为。 - `lib/__tests__/license-types.test.ts`、`app/api/license/check/__tests__/route.test.ts`:新增测试锁定默认租约天数、租约截断逻辑,以及租约过期时不恢复激活 cookie。 - `lib/__tests__/license-request-signature.test.ts`、`license-server/lib/__tests__/request-signature.test.ts`:新增测试覆盖签名生成、篡改拒绝、重复 `nonce` 拒绝、过期拒绝和缺少服务端共享密钥时拒绝静默降级。 @@ -435,7 +436,7 @@ - 已完成:本地许可证读取已加入轻量时间回拨检测;免费版和租约能力不能再靠把系统时间往回调来继续走本地宽限期。 - 已完成:验证服务器默认入口接入 Nginx 限流,免费版重复注册/多设备复用会阻断,付费版异常多设备以告警为主。 - 已完成:主应用关键授权入口已有防漏口测试,后续改注册、上传、模板、部门、系统设置、内网穿透接口时会更早暴露许可证检查缺失。 -- 已完成:付费模块 manifest 基础链路已落地,适合低配置验证服务器和约 5M 带宽;当前只传输小清单,不在验证服务器承担重计算或大文件实时分发。 +- 已完成:付费模块 manifest 基础链路已落地,适合低配置验证服务器和约 5M 带宽;自定义模板和内网穿透已经接入签名模块凭证门槛,当前只传输小清单,不在验证服务器承担重计算或大文件实时分发。 - 后续增强:真正把付费功能核心文件从镜像中拆出、加密分发、下载缓存和模块加载仍需单独推进;当前本轮完成的是签名清单、权限校验、缓存校验和后续拆包的接口地基。 ### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复) diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index 099f48c..cb8e7e0 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -90,7 +90,7 @@ GET /api/version/{projectCode}/agent - `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。 - `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。 -- `verify`、`heartbeat` 在续证成功后会尝试同步 `premium-modules` 付费模块清单;免费版会跳过,不访问远端模块清单接口。 +- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块清单;免费版会跳过,不访问远端模块清单接口。 - `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。 - `info` 用于读取当前许可证类型、用量和功能开关。 @@ -107,6 +107,7 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/... - 远端 `POST /api/license/{projectCode}/generate` 是付费激活码生成接口,当前要求 `X-API-Key`、`timestamp` 和 `sign`,不应被普通前端直接调用。 - 远端 `POST /api/license/{projectCode}/premium-modules` 是付费模块清单接口,要求同一套 HMAC 请求签名;免费版、禁用、过期或硬件指纹明显不匹配会被拒绝。 - `premium-modules` 当前返回的是签名 manifest(模块清单),用于后续真正拆分付费核心文件时复用权限、签名、缓存和过期链路;当前不直接承载大文件下载,适合低配置、低带宽验证服务器。 +- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签的 `premium-modules` 清单;许可证功能开关开启但没有对应模块凭证时,服务端仍会拒绝。 - `activate / verify / heartbeat / register-free / premium-modules` 当前会校验请求 HMAC 签名;返回的许可证和付费模块清单还会分别做服务端签名,主应用本地必须验签后再缓存。 ## 5. 后台管理入口 diff --git a/lib/__tests__/check-limits.test.ts b/lib/__tests__/check-limits.test.ts index 7e39c9c..ffad1af 100644 --- a/lib/__tests__/check-limits.test.ts +++ b/lib/__tests__/check-limits.test.ts @@ -21,11 +21,21 @@ jest.mock('../license/license-service', () => ({ getLicenseType: jest.fn(), })); +jest.mock('../license/premium-module-service', () => ({ + hasPremiumModule: jest.fn(), + PREMIUM_MODULE_IDS: { + CUSTOM_TEMPLATES_CORE: 'custom-templates-core', + INTRANET_PENETRATION_CORE: 'intranet-penetration-core', + }, +})); + import { checkLimit, isFeatureEnabled, getLicenseType } from '../license/license-service'; +import { hasPremiumModule } from '../license/premium-module-service'; const mockCheckLimit = checkLimit as jest.MockedFunction; const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction; const mockGetLicenseType = getLicenseType as jest.MockedFunction; +const mockHasPremiumModule = hasPremiumModule as jest.MockedFunction; describe('canCreateUser', () => { beforeEach(() => { @@ -115,13 +125,22 @@ describe('功能开关检查', () => { describe('canUseCustomTemplates', () => { it('当功能启用时应该返回 true', async () => { mockIsFeatureEnabled.mockResolvedValue(true); + mockHasPremiumModule.mockResolvedValue(true); expect(await canUseCustomTemplates()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates'); + expect(mockHasPremiumModule).toHaveBeenCalledWith('custom-templates-core'); }); it('当功能禁用时应该返回 false', async () => { mockIsFeatureEnabled.mockResolvedValue(false); expect(await canUseCustomTemplates()).toBe(false); + expect(mockHasPremiumModule).not.toHaveBeenCalled(); + }); + + it('当缺少付费模块凭证时应该返回 false', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + mockHasPremiumModule.mockResolvedValue(false); + expect(await canUseCustomTemplates()).toBe(false); }); }); @@ -141,13 +160,22 @@ describe('功能开关检查', () => { describe('canUseIntranetPenetration', () => { it('当功能启用时应该返回 true', async () => { mockIsFeatureEnabled.mockResolvedValue(true); + mockHasPremiumModule.mockResolvedValue(true); expect(await canUseIntranetPenetration()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration'); + expect(mockHasPremiumModule).toHaveBeenCalledWith('intranet-penetration-core'); }); it('当功能禁用时应该返回 false', async () => { mockIsFeatureEnabled.mockResolvedValue(false); expect(await canUseIntranetPenetration()).toBe(false); + expect(mockHasPremiumModule).not.toHaveBeenCalled(); + }); + + it('当缺少付费模块凭证时应该返回 false', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + mockHasPremiumModule.mockResolvedValue(false); + expect(await canUseIntranetPenetration()).toBe(false); }); }); diff --git a/lib/__tests__/license-route-coverage.test.ts b/lib/__tests__/license-route-coverage.test.ts index 2f47ba6..e8caa98 100644 --- a/lib/__tests__/license-route-coverage.test.ts +++ b/lib/__tests__/license-route-coverage.test.ts @@ -73,4 +73,12 @@ describe('许可证业务入口覆盖', () => { throw new Error(reason); } }); + + it('高价值付费能力必须同时检查签名模块凭证', () => { + const source = readProjectFile('lib/license/check-limits.ts'); + + expect(source).toContain('hasPremiumModule'); + expect(source).toContain('PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE'); + expect(source).toContain('PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE'); + }); }); diff --git a/lib/license/check-limits.ts b/lib/license/check-limits.ts index d7107ce..b567b19 100644 --- a/lib/license/check-limits.ts +++ b/lib/license/check-limits.ts @@ -4,6 +4,7 @@ */ import { checkLimit, isFeatureEnabled, getLicenseType } from './license-service'; +import { hasPremiumModule, PREMIUM_MODULE_IDS } from './premium-module-service'; export interface LimitCheckResult { allowed: boolean; @@ -56,7 +57,8 @@ export async function canUploadMaterial(): Promise { * 检查自定义模板功能是否可用 */ export async function canUseCustomTemplates(): Promise { - return await isFeatureEnabled('customTemplates'); + if (!(await isFeatureEnabled('customTemplates'))) return false; + return await hasPremiumModule(PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE); } /** @@ -70,7 +72,8 @@ export async function canUseDepartmentIsolation(): Promise { * 检查内网穿透功能是否可用 */ export async function canUseIntranetPenetration(): Promise { - return await isFeatureEnabled('intranetPenetration'); + if (!(await isFeatureEnabled('intranetPenetration'))) return false; + return await hasPremiumModule(PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE); } /** diff --git a/lib/license/premium-module-service.ts b/lib/license/premium-module-service.ts index 64d97c7..301b3f6 100644 --- a/lib/license/premium-module-service.ts +++ b/lib/license/premium-module-service.ts @@ -34,6 +34,11 @@ interface CachedPremiumModuleManifest { const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest'; +export const PREMIUM_MODULE_IDS = { + CUSTOM_TEMPLATES_CORE: 'custom-templates-core', + INTRANET_PENETRATION_CORE: 'intranet-penetration-core', +} as const; + function isPaidLicense(type: LicenseType): type is Exclude { return type === 'STANDARD' || type === 'PRO'; }