接入付费模块凭证门槛

This commit is contained in:
WuYongJie 2026-05-26 16:58:44 +08:00
parent 3c9591cdeb
commit 2f6f051daf
8 changed files with 83 additions and 6 deletions

View File

@ -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<typeof verifyLicenseSignature>
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>
const mockSyncPremiumModules = syncPremiumModules as jest.MockedFunction<typeof syncPremiumModules>
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 () => {

View File

@ -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();

View File

@ -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 免费版激活保存空签名,后续读取疑似失败(已修复)

View File

@ -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. 后台管理入口

View File

@ -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<typeof checkLimit>;
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>;
const mockGetLicenseType = getLicenseType as jest.MockedFunction<typeof getLicenseType>;
const mockHasPremiumModule = hasPremiumModule as jest.MockedFunction<typeof hasPremiumModule>;
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);
});
});

View File

@ -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');
});
});

View File

@ -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<LimitCheckResult> {
* 检查自定义模板功能是否可用
*/
export async function canUseCustomTemplates(): Promise<boolean> {
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<boolean> {
* 检查内网穿透功能是否可用
*/
export async function canUseIntranetPenetration(): Promise<boolean> {
return await isFeatureEnabled('intranetPenetration');
if (!(await isFeatureEnabled('intranetPenetration'))) return false;
return await hasPremiumModule(PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE);
}
/**

View File

@ -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<LicenseType, 'FREE'> {
return type === 'STANDARD' || type === 'PRO';
}