加入许可证在线租约校验
This commit is contained in:
parent
b267b36486
commit
07192cf779
@ -2,15 +2,17 @@ import { NextRequest } from 'next/server'
|
||||
|
||||
jest.mock('@/lib/license/license-service', () => ({
|
||||
getLocalLicense: jest.fn(),
|
||||
isLicenseConfigExpired: jest.fn(),
|
||||
}))
|
||||
|
||||
const { getLocalLicense } = require('@/lib/license/license-service')
|
||||
const { getLocalLicense, isLicenseConfigExpired } = require('@/lib/license/license-service')
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
describe('GET /api/license/check', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
isLicenseConfigExpired.mockReturnValue(false)
|
||||
})
|
||||
|
||||
test('本地许可证通过验签时应恢复激活状态并写入 cookie', async () => {
|
||||
@ -30,6 +32,7 @@ describe('GET /api/license/check', () => {
|
||||
intranetPenetration: false,
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
@ -39,6 +42,7 @@ describe('GET /api/license/check', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.activated).toBe(true)
|
||||
expect(response.cookies.get('license_activated')?.value).toBe('true')
|
||||
expect(isLicenseConfigExpired).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('本地许可证为空或验签失败时应返回未激活', async () => {
|
||||
@ -51,4 +55,34 @@ describe('GET /api/license/check', () => {
|
||||
expect(data.activated).toBe(false)
|
||||
expect(response.cookies.get('license_activated')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('本地许可证在线租约过期时不应恢复激活 cookie', async () => {
|
||||
isLicenseConfigExpired.mockReturnValue(true)
|
||||
getLocalLicense.mockResolvedValue({
|
||||
instanceId: 'test-instance-id',
|
||||
licenseKey: 'license-key',
|
||||
licenseData: 'encrypted-license',
|
||||
lastVerified: new Date().toISOString(),
|
||||
cachedConfig: {
|
||||
type: 'FREE',
|
||||
limits: { maxUsers: 10, maxMaterials: 100 },
|
||||
features: {
|
||||
customTemplates: false,
|
||||
systemSettings: false,
|
||||
departmentIsolation: false,
|
||||
apiAccess: false,
|
||||
intranetPenetration: false,
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const response = await GET(new NextRequest('http://localhost/api/license/check'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.activated).toBe(false)
|
||||
expect(response.cookies.get('license_activated')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLocalLicense } from '@/lib/license/license-service';
|
||||
import { getLocalLicense, isLicenseConfigExpired } from '@/lib/license/license-service';
|
||||
|
||||
/**
|
||||
* 检查数据库中是否有已激活的许可证
|
||||
@ -8,7 +8,7 @@ import { getLocalLicense } from '@/lib/license/license-service';
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const license = await getLocalLicense();
|
||||
if (!license) {
|
||||
if (!license || isLicenseConfigExpired(license.cachedConfig)) {
|
||||
return NextResponse.json({ activated: false });
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { generateFingerprint } from '@/lib/license/fingerprint-service';
|
||||
import { getLocalLicense, updateLocalLicense, clearLocalLicense } from '@/lib/license/license-service';
|
||||
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
|
||||
import type { HeartbeatResponse } from '@/lib/license/types';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@ -47,6 +48,13 @@ export async function POST(request: NextRequest) {
|
||||
const data: HeartbeatResponse = await response.json();
|
||||
|
||||
if (data.acknowledged && data.license) {
|
||||
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
|
||||
return NextResponse.json({
|
||||
acknowledged: false,
|
||||
error: '许可证签名验证失败',
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 在后端更新本地缓存
|
||||
await updateLocalLicense(data.license);
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ const { getLicense } = require('@/lib/license')
|
||||
const activeProLicense = {
|
||||
type: 'PRO',
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: '2026-07-25T00:00:00.000Z',
|
||||
limits: {
|
||||
maxUsers: 100,
|
||||
maxMaterials: 1000,
|
||||
@ -103,6 +104,7 @@ describe('GET /api/license/info', () => {
|
||||
expect(data.licenseKey).toBe('FLNX****0001')
|
||||
expect(data.usage.users.current).toBe(12)
|
||||
expect(data.usage.materials.current).toBe(345)
|
||||
expect(data.leaseExpiresAt).toBe('2026-07-25T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('最高权限账号可以看到完整激活码', async () => {
|
||||
|
||||
@ -39,6 +39,7 @@ export async function GET() {
|
||||
type: 'FREE' as const,
|
||||
status: 'active' as const,
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: null,
|
||||
licenseKey: null,
|
||||
usage: {
|
||||
users: {
|
||||
@ -81,7 +82,10 @@ export async function GET() {
|
||||
|
||||
// 判断状态
|
||||
let status: 'active' | 'expired' | 'grace' = 'active';
|
||||
if (license.expiresAt && new Date(license.expiresAt) < new Date()) {
|
||||
if (
|
||||
(license.expiresAt && new Date(license.expiresAt) < new Date()) ||
|
||||
(license.leaseExpiresAt && new Date(license.leaseExpiresAt) < new Date())
|
||||
) {
|
||||
status = 'expired';
|
||||
}
|
||||
|
||||
@ -89,6 +93,7 @@ export async function GET() {
|
||||
type: license.type,
|
||||
status,
|
||||
expiresAt: license.expiresAt,
|
||||
leaseExpiresAt: license.leaseExpiresAt,
|
||||
usage: {
|
||||
users: {
|
||||
current: userCount,
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateFingerprint } from '@/lib/license/fingerprint-service';
|
||||
import { updateLocalLicense, getLocalLicense, clearLocalLicense } from '@/lib/license/license-service';
|
||||
import {
|
||||
updateLocalLicense,
|
||||
getLocalLicense,
|
||||
clearLocalLicense,
|
||||
isLicenseConfigExpired,
|
||||
} from '@/lib/license/license-service';
|
||||
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
|
||||
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
|
||||
import type { VerifyResponse } from '@/lib/license/types';
|
||||
@ -57,7 +62,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 其他错误(如网络问题),检查宽限期
|
||||
if (isWithinGracePeriod(localLicense.lastVerified)) {
|
||||
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
return NextResponse.json({ valid: false, error: data.error || '验证失败' });
|
||||
@ -77,7 +82,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 网络错误时检查宽限期
|
||||
const localLicense = await getLocalLicense();
|
||||
if (localLicense && isWithinGracePeriod(localLicense.lastVerified)) {
|
||||
if (
|
||||
localLicense &&
|
||||
!isLicenseConfigExpired(localLicense.cachedConfig) &&
|
||||
isWithinGracePeriod(localLicense.lastVerified)
|
||||
) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ interface LicenseData {
|
||||
type: 'FREE' | 'STANDARD' | 'PRO';
|
||||
status: 'active' | 'expired' | 'grace';
|
||||
expiresAt: string | null;
|
||||
leaseExpiresAt: string | null;
|
||||
licenseKey: string | null;
|
||||
usage: {
|
||||
users: { current: number; max: number };
|
||||
|
||||
@ -387,24 +387,32 @@
|
||||
|
||||
客户影响:
|
||||
|
||||
许可证过期或降级后,某些功能可能因为本地缓存仍继续可用。商业化授权边界会变弱。当前已先在许可证底层检查入口收口:业务 API 调用的功能开关和数量限制每次都会读取本地已验签许可证,并检查是否过期,不再只相信当前进程里残留的旧缓存。
|
||||
许可证过期、降级或在线租约过期后,某些功能可能因为本地缓存仍继续可用。商业化授权边界会变弱。当前已先在许可证底层检查入口收口:业务 API 调用的功能开关和数量限制每次都会读取本地已验签许可证,并检查许可证总到期和在线租约到期,不再只相信当前进程里残留的旧缓存。
|
||||
|
||||
证据:
|
||||
|
||||
- `lib/license/license-service.ts`:新增当前有效许可证读取逻辑,`isFeatureEnabled()`、`checkLimit()`、`getLicenseType()` 都会从本地已验签许可证重新取当前配置,并把过期许可证视为不可用。
|
||||
- `lib/license/license-service.ts`:`saveLicenseLocally()`、`updateLocalLicense()` 会同步更新内存缓存;如果新许可证已过期,则不会继续保留可用缓存。
|
||||
- `lib/__tests__/license-service.test.ts`:新增测试覆盖许可证过期后高级功能不可用、数量限制不放行,以及本地许可证从 Pro 降级到 Free 后业务检查立即按 Free 生效。
|
||||
- `lib/license/types.ts`、`license-server/lib/types.ts`:签名许可证新增 `leaseExpiresAt` 在线租约字段;新发和续发许可证都会带租约。默认策略为免费版 3 天、标准版 30 天、专业版 60 天;如果许可证总到期早于默认租约,则租约会被截断到总到期时间。
|
||||
- `lib/license/license-service.ts`:`saveLicenseLocally()`、`updateLocalLicense()` 保存前会再次验签,并同步更新内存缓存;如果新许可证已过期或在线租约已过期,则不会继续保留可用缓存。
|
||||
- `lib/license/license-service.ts`、`app/api/license/verify/route.ts`:远端验证失败进入本地宽限期前,也会先检查签名许可证里的 `leaseExpiresAt`,避免租约已经过期还继续靠本地宽限期放行。
|
||||
- `app/api/license/check/route.ts`:恢复 `license_activated` cookie 前会检查租约有效性,不能再靠一份过期本地许可证恢复激活状态。
|
||||
- `app/api/license/heartbeat/route.ts`:心跳拿到验证服务器续发许可证后会先验签再写入本地缓存。
|
||||
- `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__/check-limits.test.ts`:功能检查 helper 已全部改为异步调用底层许可证检查。
|
||||
|
||||
验证:
|
||||
|
||||
- `npm test -- lib/__tests__/license-service.test.ts lib/__tests__/check-limits.test.ts --runInBand`
|
||||
- `npm test -- lib/__tests__/license-types.test.ts lib/__tests__/license-service.test.ts app/api/license/check/__tests__/route.test.ts app/api/license/info/__tests__/route.test.ts --runInBand`
|
||||
- `npx tsc --noEmit --project license-server/tsconfig.json`
|
||||
- `npm run type-check`
|
||||
|
||||
建议动作:
|
||||
|
||||
- 已完成:受限业务 API 继续通过 `canUseCustomTemplates()`、`canUseDepartmentIsolation()`、`canUseSystemSettings()`、`canUploadMaterial()`、`canCreateUser()` 等 helper 检查;这些 helper 底层不再信旧缓存。
|
||||
- 后续增强:吊销、宽限期、远端不可达时的产品策略仍需单独定规则;当前本轮先保证过期和降级不会因为本地旧缓存继续放行。
|
||||
- 已完成:免费版采用更短在线租约,适合主要防破解和防白嫖;付费版采用更长租约,优先避免误伤正常客户,同时仍能限制付费凭证长期滥用。
|
||||
- 后续增强:吊销、异常设备、时间回拨、验证服务器限流、模块包下载和付费核心文件拆分仍需单独推进;当前本轮先保证过期、降级和租约过期不会因为本地旧缓存继续放行。
|
||||
|
||||
### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复)
|
||||
|
||||
|
||||
@ -95,6 +95,7 @@ function createSignedLicense(overrides: Record<string, any> = {}) {
|
||||
intranetPenetration: false,
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
issuedAt,
|
||||
...overrides,
|
||||
},
|
||||
@ -122,6 +123,7 @@ function createStoredLocalLicense(overrides: Record<string, any> = {}) {
|
||||
intranetPenetration: false,
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
signedLicense: undefined,
|
||||
signedLicenseData: undefined,
|
||||
@ -256,6 +258,93 @@ describe('license-service', () => {
|
||||
await expect(checkLimit('materials')).resolves.toEqual({ allowed: false, current: 0, max: 0 });
|
||||
});
|
||||
|
||||
it('在线租约过期后应拒绝功能和额度使用', async () => {
|
||||
const expiredLeaseAt = new Date(Date.now() - 60_000).toISOString();
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
key: 'license',
|
||||
value: JSON.stringify(createStoredLocalLicense({
|
||||
signedLicenseData: {
|
||||
type: 'PRO',
|
||||
limits: { maxUsers: -1, maxMaterials: -1 },
|
||||
features: {
|
||||
customTemplates: true,
|
||||
systemSettings: true,
|
||||
departmentIsolation: true,
|
||||
apiAccess: true,
|
||||
intranetPenetration: true,
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: expiredLeaseAt,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const { isFeatureEnabled, getLicenseType, checkLimit } = await import('../license/license-service');
|
||||
|
||||
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
|
||||
await expect(getLicenseType()).resolves.toBe('FREE');
|
||||
await expect(checkLimit('users')).resolves.toEqual({ allowed: false, current: 0, max: 0 });
|
||||
});
|
||||
|
||||
it('付费许可证租约未过期时应继续允许付费功能', async () => {
|
||||
const futureLeaseAt = new Date(Date.now() + 60_000).toISOString();
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
key: 'license',
|
||||
value: JSON.stringify(createStoredLocalLicense({
|
||||
signedLicenseData: {
|
||||
type: 'PRO',
|
||||
limits: { maxUsers: -1, maxMaterials: -1 },
|
||||
features: {
|
||||
customTemplates: true,
|
||||
systemSettings: true,
|
||||
departmentIsolation: true,
|
||||
apiAccess: true,
|
||||
intranetPenetration: true,
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: futureLeaseAt,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const { isFeatureEnabled, checkLimit } = await import('../license/license-service');
|
||||
|
||||
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(true);
|
||||
await expect(checkLimit('materials')).resolves.toEqual({ allowed: true, current: 0, max: -1 });
|
||||
});
|
||||
|
||||
it('旧许可证没有在线租约字段时暂按兼容证处理', async () => {
|
||||
const legacyLicense = createSignedLicense({
|
||||
type: 'PRO',
|
||||
limits: { maxUsers: -1, maxMaterials: -1 },
|
||||
features: {
|
||||
customTemplates: true,
|
||||
systemSettings: true,
|
||||
departmentIsolation: true,
|
||||
apiAccess: true,
|
||||
intranetPenetration: true,
|
||||
},
|
||||
expiresAt: null,
|
||||
});
|
||||
delete (legacyLicense.data as Record<string, unknown>).leaseExpiresAt;
|
||||
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
key: 'license',
|
||||
value: JSON.stringify(createStoredLocalLicense({
|
||||
signedLicense: legacyLicense,
|
||||
})),
|
||||
});
|
||||
|
||||
const { isFeatureEnabled, getLocalLicense } = await import('../license/license-service');
|
||||
|
||||
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(true);
|
||||
await expect(getLocalLicense()).resolves.toMatchObject({
|
||||
cachedConfig: {
|
||||
leaseExpiresAt: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('业务功能检查每次以本地已验签许可证为准,降级后不继续信旧缓存', async () => {
|
||||
const proLicense = createStoredLocalLicense({
|
||||
signedLicenseData: {
|
||||
@ -368,6 +457,36 @@ describe('license-service', () => {
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('未找到许可证');
|
||||
});
|
||||
|
||||
it('在线租约过期后,网络失败也不应继续走本地宽限期', async () => {
|
||||
const expiredLeaseAt = new Date(Date.now() - 60_000).toISOString();
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
key: 'license',
|
||||
value: JSON.stringify(createStoredLocalLicense({
|
||||
lastVerified: new Date().toISOString(),
|
||||
signedLicenseData: {
|
||||
type: 'PRO',
|
||||
limits: { maxUsers: -1, maxMaterials: -1 },
|
||||
features: {
|
||||
customTemplates: true,
|
||||
systemSettings: true,
|
||||
departmentIsolation: true,
|
||||
apiAccess: true,
|
||||
intranetPenetration: true,
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: expiredLeaseAt,
|
||||
},
|
||||
})),
|
||||
});
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { verify } = await import('../license/license-service');
|
||||
const result = await verify();
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('网络错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('本地许可证验签收紧', () => {
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
LicenseType,
|
||||
LicenseConfig,
|
||||
LICENSE_CONFIGS,
|
||||
LICENSE_LEASE_DAYS,
|
||||
generateLicenseConfig,
|
||||
FeatureKey,
|
||||
} from '../license/types';
|
||||
@ -109,6 +110,7 @@ describe('generateLicenseConfig', () => {
|
||||
expect(config.type).toBe('FREE');
|
||||
expect(config.limits.maxUsers).toBe(10);
|
||||
expect(config.limits.maxMaterials).toBe(100);
|
||||
expect(config.leaseExpiresAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('应该生成 STANDARD 版本配置', () => {
|
||||
@ -148,6 +150,44 @@ describe('generateLicenseConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('在线租约处理', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-05-26T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该按版本生成不同的默认在线租约', () => {
|
||||
const freeConfig = generateLicenseConfig('FREE');
|
||||
const standardConfig = generateLicenseConfig('STANDARD');
|
||||
const proConfig = generateLicenseConfig('PRO');
|
||||
|
||||
expect(LICENSE_LEASE_DAYS.FREE).toBe(3);
|
||||
expect(freeConfig.leaseExpiresAt).toBe('2026-05-29T00:00:00.000Z');
|
||||
expect(standardConfig.leaseExpiresAt).toBe('2026-06-25T00:00:00.000Z');
|
||||
expect(proConfig.leaseExpiresAt).toBe('2026-07-25T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('许可证总到期早于默认租约时,应把租约截断到总到期时间', () => {
|
||||
const expiresAt = new Date('2026-05-27T00:00:00.000Z');
|
||||
const config = generateLicenseConfig('PRO', expiresAt);
|
||||
|
||||
expect(config.expiresAt).toBe('2026-05-27T00:00:00.000Z');
|
||||
expect(config.leaseExpiresAt).toBe('2026-05-27T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('传入显式租约时间时应使用该时间', () => {
|
||||
const leaseExpiresAt = new Date('2026-06-01T12:00:00.000Z');
|
||||
const config = generateLicenseConfig('STANDARD', null, leaseExpiresAt);
|
||||
|
||||
expect(config.expiresAt).toBeNull();
|
||||
expect(config.leaseExpiresAt).toBe('2026-06-01T12:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('返回值结构', () => {
|
||||
it('应该包含所有必需字段', () => {
|
||||
const config = generateLicenseConfig('STANDARD', new Date());
|
||||
@ -163,6 +203,7 @@ describe('generateLicenseConfig', () => {
|
||||
expect(config).toHaveProperty('features.apiAccess');
|
||||
expect(config).toHaveProperty('features.intranetPenetration');
|
||||
expect(config).toHaveProperty('expiresAt');
|
||||
expect(config).toHaveProperty('leaseExpiresAt');
|
||||
});
|
||||
|
||||
it('返回的配置应该是新对象,不影响原始配置', () => {
|
||||
@ -171,6 +212,7 @@ describe('generateLicenseConfig', () => {
|
||||
|
||||
expect(config1.expiresAt).not.toBe(config2.expiresAt);
|
||||
expect(LICENSE_CONFIGS.FREE).not.toHaveProperty('expiresAt');
|
||||
expect(LICENSE_CONFIGS.FREE).not.toHaveProperty('leaseExpiresAt');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -24,13 +24,28 @@ let cachedLicense: LicenseConfig | null = null;
|
||||
let lastVerified: Date | null = null;
|
||||
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function isLicenseConfigExpired(license: LicenseConfig | null): boolean {
|
||||
return Boolean(license?.expiresAt && new Date(license.expiresAt) < new Date());
|
||||
function isDateStringExpired(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
|
||||
const expiresAt = new Date(value).getTime();
|
||||
if (Number.isNaN(expiresAt)) return true;
|
||||
|
||||
return expiresAt < Date.now();
|
||||
}
|
||||
|
||||
export function isLicenseConfigExpired(license: LicenseConfig | null): boolean {
|
||||
return Boolean(
|
||||
license &&
|
||||
(isDateStringExpired(license.expiresAt) || isDateStringExpired(license.leaseExpiresAt))
|
||||
);
|
||||
}
|
||||
|
||||
function extractLicenseConfig(license: SignedLicense): LicenseConfig {
|
||||
const { issuedAt: _issuedAt, ...config } = license.data;
|
||||
return config;
|
||||
return {
|
||||
...config,
|
||||
leaseExpiresAt: config.leaseExpiresAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLocalLicenseData(localData: LocalLicenseData): LocalLicenseData | null {
|
||||
@ -214,7 +229,7 @@ export async function verify(): Promise<{ valid: boolean; error?: string; revoke
|
||||
}
|
||||
|
||||
// 其他错误,检查宽限期
|
||||
if (isWithinGracePeriod(localLicense.lastVerified)) {
|
||||
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
|
||||
cachedLicense = localLicense.cachedConfig;
|
||||
return { valid: true };
|
||||
}
|
||||
@ -235,7 +250,11 @@ export async function verify(): Promise<{ valid: boolean; error?: string; revoke
|
||||
|
||||
// 网络错误时检查宽限期
|
||||
const localLicense = await getLocalLicense();
|
||||
if (localLicense && isWithinGracePeriod(localLicense.lastVerified)) {
|
||||
if (
|
||||
localLicense &&
|
||||
!isLicenseConfigExpired(localLicense.cachedConfig) &&
|
||||
isWithinGracePeriod(localLicense.lastVerified)
|
||||
) {
|
||||
cachedLicense = localLicense.cachedConfig;
|
||||
return { valid: true };
|
||||
}
|
||||
@ -387,6 +406,10 @@ export async function initLicenseService(): Promise<void> {
|
||||
// ============ 内部辅助函数 ============
|
||||
|
||||
export async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise<void> {
|
||||
if (!verifyLicenseSignature(license.data, license.signature)) {
|
||||
throw new Error('许可证签名验证失败');
|
||||
}
|
||||
|
||||
const instanceId = await getInstanceId();
|
||||
const currentConfig = extractLicenseConfig(license);
|
||||
|
||||
@ -412,6 +435,10 @@ export async function updateLocalLicense(license: SignedLicense): Promise<void>
|
||||
const existing = await getLocalLicense();
|
||||
if (!existing) return;
|
||||
|
||||
if (!verifyLicenseSignature(license.data, license.signature)) {
|
||||
throw new Error('许可证签名验证失败');
|
||||
}
|
||||
|
||||
const currentConfig = extractLicenseConfig(license);
|
||||
|
||||
const localData: LocalLicenseData = {
|
||||
|
||||
@ -16,6 +16,7 @@ export interface LicenseConfig {
|
||||
intranetPenetration: boolean;
|
||||
};
|
||||
expiresAt: string | null; // ISO 日期字符串,null 表示永久
|
||||
leaseExpiresAt: string | null; // 在线租约到期时间,旧许可证可为 null
|
||||
}
|
||||
|
||||
// 签名后的许可证
|
||||
@ -91,8 +92,15 @@ export interface LocalLicenseData {
|
||||
// 功能键
|
||||
export type FeatureKey = keyof LicenseConfig['features'];
|
||||
|
||||
// 各版本默认在线租约:免费版更短,付费版更宽松
|
||||
export const LICENSE_LEASE_DAYS: Record<LicenseType, number> = {
|
||||
FREE: 3,
|
||||
STANDARD: 30,
|
||||
PRO: 60,
|
||||
};
|
||||
|
||||
// 各版本的默认配置
|
||||
export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt'>> = {
|
||||
export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt' | 'leaseExpiresAt'>> = {
|
||||
FREE: {
|
||||
type: 'FREE',
|
||||
limits: {
|
||||
@ -142,11 +150,21 @@ export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt
|
||||
*/
|
||||
export function generateLicenseConfig(
|
||||
type: LicenseType,
|
||||
expiresAt?: Date | null
|
||||
expiresAt?: Date | null,
|
||||
leaseExpiresAt?: Date
|
||||
): LicenseConfig {
|
||||
const baseConfig = LICENSE_CONFIGS[type];
|
||||
const defaultLeaseExpiresAt = new Date(
|
||||
Date.now() + LICENSE_LEASE_DAYS[type] * 24 * 60 * 60 * 1000
|
||||
);
|
||||
const resolvedLeaseExpiresAt = leaseExpiresAt || defaultLeaseExpiresAt;
|
||||
const boundedLeaseExpiresAt = expiresAt && resolvedLeaseExpiresAt && expiresAt < resolvedLeaseExpiresAt
|
||||
? expiresAt
|
||||
: resolvedLeaseExpiresAt;
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
expiresAt: expiresAt ? expiresAt.toISOString() : null,
|
||||
leaseExpiresAt: boundedLeaseExpiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ export interface LicenseConfig {
|
||||
intranetPenetration: boolean;
|
||||
};
|
||||
expiresAt: string | null; // ISO 日期字符串,null 表示永久
|
||||
leaseExpiresAt: string | null; // 在线租约到期时间,旧许可证可为 null
|
||||
}
|
||||
|
||||
// 签名后的许可证
|
||||
@ -92,8 +93,15 @@ export interface VersionCheckResponse {
|
||||
downloadSize: string | null;
|
||||
}
|
||||
|
||||
// 各版本默认在线租约:免费版更短,付费版更宽松
|
||||
export const LICENSE_LEASE_DAYS: Record<LicenseType, number> = {
|
||||
FREE: 3,
|
||||
STANDARD: 30,
|
||||
PRO: 60,
|
||||
};
|
||||
|
||||
// 各版本的默认配置
|
||||
export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt'>> = {
|
||||
export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt' | 'leaseExpiresAt'>> = {
|
||||
FREE: {
|
||||
type: 'FREE',
|
||||
limits: {
|
||||
@ -143,11 +151,21 @@ export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt
|
||||
*/
|
||||
export function generateLicenseConfig(
|
||||
type: LicenseType,
|
||||
expiresAt?: Date | null
|
||||
expiresAt?: Date | null,
|
||||
leaseExpiresAt?: Date
|
||||
): LicenseConfig {
|
||||
const baseConfig = LICENSE_CONFIGS[type];
|
||||
const defaultLeaseExpiresAt = new Date(
|
||||
Date.now() + LICENSE_LEASE_DAYS[type] * 24 * 60 * 60 * 1000
|
||||
);
|
||||
const resolvedLeaseExpiresAt = leaseExpiresAt || defaultLeaseExpiresAt;
|
||||
const boundedLeaseExpiresAt = expiresAt && resolvedLeaseExpiresAt && expiresAt < resolvedLeaseExpiresAt
|
||||
? expiresAt
|
||||
: resolvedLeaseExpiresAt;
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
expiresAt: expiresAt ? expiresAt.toISOString() : null,
|
||||
leaseExpiresAt: boundedLeaseExpiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user