接入付费模块凭证门槛
This commit is contained in:
parent
3c9591cdeb
commit
2f6f051daf
@ -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 () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 免费版激活保存空签名,后续读取疑似失败(已修复)
|
||||
|
||||
@ -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. 后台管理入口
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user