849 lines
24 KiB
TypeScript
849 lines
24 KiB
TypeScript
import { prisma } from '@/lib/prisma';
|
||
import { generateFingerprint, getInstanceId } from './fingerprint-service';
|
||
import {
|
||
verifyLicenseSignature,
|
||
encryptLocal,
|
||
decryptLocal,
|
||
signLocalIntegrity,
|
||
verifyLocalIntegrity,
|
||
} from './crypto-service';
|
||
import { initializeSystem as initializeSystemCore } from '../system-init';
|
||
import type {
|
||
LicenseConfig,
|
||
SignedLicense,
|
||
ActivateResponse,
|
||
VerifyResponse,
|
||
HeartbeatResponse,
|
||
FeatureKey,
|
||
LocalLicenseData,
|
||
} from './types';
|
||
import { LICENSE_CONFIGS } from './types';
|
||
import { buildLicenseServerLicenseApiUrl } from './server-config';
|
||
import { buildSignedLicenseRequest } from './request-signature';
|
||
import { syncPremiumModules } from './premium-module-service';
|
||
import { validateSignedLicenseBinding } from './license-binding';
|
||
|
||
// 宽限期(7天)
|
||
const GRACE_PERIOD_DAYS = 7;
|
||
|
||
// 心跳间隔(1小时)
|
||
const HEARTBEAT_INTERVAL = 60 * 60 * 1000;
|
||
|
||
// 容忍正常 NTP 校时的小幅波动,超过后视为疑似回拨绕过租约
|
||
const CLOCK_ROLLBACK_TOLERANCE_MS = 10 * 60 * 1000;
|
||
const CLOCK_STATE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||
|
||
// 缓存的许可证配置
|
||
let cachedLicense: LicenseConfig | null = null;
|
||
let lastVerified: Date | null = null;
|
||
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||
|
||
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,
|
||
projectCode: _projectCode,
|
||
licenseKey: _licenseKey,
|
||
instanceId: _instanceId,
|
||
fingerprintHash: _fingerprintHash,
|
||
activationId: _activationId,
|
||
...config
|
||
} = license.data;
|
||
return {
|
||
...config,
|
||
leaseExpiresAt: config.leaseExpiresAt ?? null,
|
||
};
|
||
}
|
||
|
||
function buildClockIntegrityPayload(localData: LocalLicenseData): string {
|
||
return JSON.stringify({
|
||
instanceId: localData.instanceId,
|
||
licenseKey: localData.licenseKey,
|
||
licenseData: localData.licenseData,
|
||
lastVerified: localData.lastVerified,
|
||
lastSeenAt: localData.lastSeenAt ?? null,
|
||
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? null,
|
||
});
|
||
}
|
||
|
||
function withClockIntegrity(localData: LocalLicenseData): LocalLicenseData {
|
||
const dataWithDefaults: LocalLicenseData = {
|
||
...localData,
|
||
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? null,
|
||
};
|
||
|
||
return {
|
||
...dataWithDefaults,
|
||
clockIntegrity: signLocalIntegrity(buildClockIntegrityPayload(dataWithDefaults)),
|
||
};
|
||
}
|
||
|
||
function hasClockState(localData: LocalLicenseData): boolean {
|
||
return Boolean(
|
||
localData.lastSeenAt ||
|
||
localData.clockRollbackDetectedAt ||
|
||
localData.clockIntegrity
|
||
);
|
||
}
|
||
|
||
function isClockStateIntact(localData: LocalLicenseData): boolean {
|
||
if (!hasClockState(localData)) {
|
||
return true;
|
||
}
|
||
|
||
if (!localData.lastSeenAt || !localData.clockIntegrity) {
|
||
return false;
|
||
}
|
||
|
||
return verifyLocalIntegrity(
|
||
buildClockIntegrityPayload({
|
||
...localData,
|
||
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? null,
|
||
}),
|
||
localData.clockIntegrity
|
||
);
|
||
}
|
||
|
||
async function persistLocalLicenseData(localData: LocalLicenseData): Promise<void> {
|
||
try {
|
||
await prisma.systemConfig.update({
|
||
where: { key: 'license' },
|
||
data: { value: JSON.stringify(localData) },
|
||
});
|
||
} catch (error) {
|
||
console.warn('[License] 本地许可证时间状态写入失败:', error);
|
||
}
|
||
}
|
||
|
||
async function enforceLocalClockState(localData: LocalLicenseData): Promise<LocalLicenseData | null> {
|
||
if (!isClockStateIntact(localData)) {
|
||
console.warn('[License] 本地许可证时间状态校验失败,已拒绝使用');
|
||
return null;
|
||
}
|
||
|
||
const nowMs = Date.now();
|
||
const nowIso = new Date(nowMs).toISOString();
|
||
const lastVerifiedMs = new Date(localData.lastVerified).getTime();
|
||
|
||
if (Number.isNaN(lastVerifiedMs)) {
|
||
console.warn('[License] 本地许可证验证时间格式异常,已拒绝使用');
|
||
return null;
|
||
}
|
||
|
||
if (nowMs + CLOCK_ROLLBACK_TOLERANCE_MS < lastVerifiedMs) {
|
||
const rollbackState = withClockIntegrity({
|
||
...localData,
|
||
lastSeenAt: localData.lastSeenAt ?? localData.lastVerified,
|
||
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? nowIso,
|
||
});
|
||
await persistLocalLicenseData(rollbackState);
|
||
console.warn('[License] 本地许可证验证时间晚于当前系统时间,已拒绝使用');
|
||
return null;
|
||
}
|
||
|
||
if (!localData.lastSeenAt) {
|
||
const initialized = withClockIntegrity({
|
||
...localData,
|
||
lastSeenAt: nowIso,
|
||
clockRollbackDetectedAt: null,
|
||
});
|
||
await persistLocalLicenseData(initialized);
|
||
return initialized;
|
||
}
|
||
|
||
const lastSeenMs = new Date(localData.lastSeenAt).getTime();
|
||
if (Number.isNaN(lastSeenMs)) {
|
||
console.warn('[License] 本地许可证时间状态格式异常,已拒绝使用');
|
||
return null;
|
||
}
|
||
|
||
if (nowMs + CLOCK_ROLLBACK_TOLERANCE_MS < lastSeenMs) {
|
||
const rollbackState = withClockIntegrity({
|
||
...localData,
|
||
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? nowIso,
|
||
});
|
||
await persistLocalLicenseData(rollbackState);
|
||
console.warn('[License] 检测到系统时间明显回拨,已拒绝使用本地许可证');
|
||
return null;
|
||
}
|
||
|
||
const shouldRefreshLastSeen =
|
||
nowMs > lastSeenMs &&
|
||
nowMs - lastSeenMs >= CLOCK_STATE_REFRESH_INTERVAL_MS;
|
||
const shouldClearRollback = Boolean(localData.clockRollbackDetectedAt);
|
||
|
||
if (shouldRefreshLastSeen || shouldClearRollback || !localData.clockIntegrity) {
|
||
const refreshed = withClockIntegrity({
|
||
...localData,
|
||
lastSeenAt: shouldRefreshLastSeen ? nowIso : localData.lastSeenAt,
|
||
clockRollbackDetectedAt: null,
|
||
});
|
||
await persistLocalLicenseData(refreshed);
|
||
return refreshed;
|
||
}
|
||
|
||
return localData;
|
||
}
|
||
|
||
function normalizeLocalLicenseData(localData: LocalLicenseData): LocalLicenseData | null {
|
||
if (
|
||
!localData ||
|
||
typeof localData.licenseKey !== 'string' ||
|
||
typeof localData.licenseData !== 'string' ||
|
||
typeof localData.lastVerified !== 'string'
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const decrypted = decryptLocal(localData.licenseData);
|
||
const signedLicense = JSON.parse(decrypted) as SignedLicense;
|
||
|
||
if (!signedLicense?.data || !signedLicense?.signature) {
|
||
return null;
|
||
}
|
||
|
||
if (!verifyLicenseSignature(signedLicense.data, signedLicense.signature)) {
|
||
return null;
|
||
}
|
||
|
||
const bindingResult = validateSignedLicenseBinding(signedLicense, {
|
||
licenseKey: localData.licenseKey,
|
||
instanceId: localData.instanceId,
|
||
});
|
||
if (!bindingResult.valid) {
|
||
console.warn('[License] 本地许可证绑定校验失败:', bindingResult.error);
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
...localData,
|
||
cachedConfig: extractLicenseConfig(signedLicense),
|
||
};
|
||
} catch (error) {
|
||
console.warn('[License] 本地许可证校验失败:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function getCurrentLicenseConfig(): Promise<LicenseConfig | null> {
|
||
const localLicense = await getLocalLicense();
|
||
const currentConfig = localLicense?.cachedConfig ?? null;
|
||
|
||
if (!localLicense || !currentConfig || isLicenseConfigExpired(currentConfig)) {
|
||
cachedLicense = null;
|
||
lastVerified = localLicense?.lastVerified ? new Date(localLicense.lastVerified) : null;
|
||
return null;
|
||
}
|
||
|
||
cachedLicense = currentConfig;
|
||
lastVerified = new Date(localLicense.lastVerified);
|
||
return currentConfig;
|
||
}
|
||
|
||
/**
|
||
* 激活许可证
|
||
*
|
||
* ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API
|
||
* 实际的激活逻辑已移至 app/api/license/activate/route.ts(后端代理)
|
||
*
|
||
* 安全改进:
|
||
* - 客户端不再直接访问许可证服务器
|
||
* - 硬件指纹在后端生成(更安全)
|
||
* - 许可证服务器地址完全隐藏
|
||
*/
|
||
export async function activate(licenseKey: string): Promise<{ success: boolean; error?: string }> {
|
||
try {
|
||
// ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器)
|
||
const response = await fetch('/api/license/activate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
licenseKey,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
return { success: false, error: data.error || '激活失败' };
|
||
}
|
||
|
||
// 重新加载许可证(因为后端已保存)
|
||
await loadLicenseFromLocal();
|
||
|
||
// 更新缓存
|
||
if (cachedLicense) {
|
||
lastVerified = new Date();
|
||
}
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
console.error('激活失败:', error);
|
||
return { success: false, error: '网络错误,请检查网络连接' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 激活免费试用版
|
||
*
|
||
* ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API
|
||
* 实际的注册逻辑已移至 app/api/license/activate-free/route.ts(后端代理)
|
||
*
|
||
* 安全改进:
|
||
* - 客户端不再直接访问许可证服务器
|
||
* - 硬件指纹在后端生成(更安全)
|
||
* - 许可证服务器地址完全隐藏
|
||
*
|
||
* @param customerName 客户名称(用于许可证系统后台管理)
|
||
* @param contactInfo 联系方式(手机/微信/邮箱)
|
||
*/
|
||
export async function activateFreeTrial(customerName: string, contactInfo: string = ''): Promise<{ success: boolean; error?: string }> {
|
||
try {
|
||
// ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器)
|
||
const response = await fetch('/api/license/activate-free', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
customerName,
|
||
contactInfo,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
return { success: false, error: data.error || '激活失败,请检查网络连接' };
|
||
}
|
||
|
||
// 重新加载许可证(因为后端已保存)
|
||
await loadLicenseFromLocal();
|
||
|
||
// 更新缓存
|
||
if (cachedLicense) {
|
||
lastVerified = new Date();
|
||
}
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
console.error('免费试用激活失败:', error);
|
||
return { success: false, error: '网络错误,请检查网络连接后重试' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化系统(创建默认管理员、部门、店铺等)
|
||
*/
|
||
export async function initializeSystem() {
|
||
return initializeSystemCore();
|
||
}
|
||
|
||
/**
|
||
* 验证许可证
|
||
*
|
||
* ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API
|
||
* 实际的验证逻辑已移至 app/api/license/verify/route.ts(后端代理)
|
||
*
|
||
* 安全改进:
|
||
* - 客户端不再直接访问许可证服务器
|
||
* - 硬件指纹在后端生成(更安全)
|
||
* - 许可证服务器地址完全隐藏
|
||
*/
|
||
export async function verify(): Promise<{ valid: boolean; error?: string; revoked?: boolean }> {
|
||
try {
|
||
// 先检查本地缓存
|
||
const localLicense = await getLocalLicense();
|
||
|
||
if (!localLicense) {
|
||
return { valid: false, error: '未找到许可证' };
|
||
}
|
||
|
||
// ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器)
|
||
const response = await fetch('/api/license/verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!data.valid) {
|
||
// 检查是否是被吊销
|
||
if (data.revoked) {
|
||
console.log('[License] 许可证已被吊销:', data.error);
|
||
cachedLicense = null;
|
||
return { valid: false, error: data.error || '许可证已被吊销', revoked: true };
|
||
}
|
||
|
||
// 其他错误,检查宽限期
|
||
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
|
||
cachedLicense = localLicense.cachedConfig;
|
||
return { valid: true };
|
||
}
|
||
return { valid: false, error: data.error || '验证失败' };
|
||
}
|
||
|
||
// 重新加载许可证(因为后端已更新)
|
||
await loadLicenseFromLocal();
|
||
|
||
// 更新内存缓存
|
||
if (cachedLicense) {
|
||
lastVerified = new Date();
|
||
}
|
||
|
||
return { valid: true };
|
||
} catch (error) {
|
||
console.error('验证失败:', error);
|
||
|
||
// 网络错误时检查宽限期
|
||
const localLicense = await getLocalLicense();
|
||
if (
|
||
localLicense &&
|
||
!isLicenseConfigExpired(localLicense.cachedConfig) &&
|
||
isWithinGracePeriod(localLicense.lastVerified)
|
||
) {
|
||
cachedLicense = localLicense.cachedConfig;
|
||
return { valid: true };
|
||
}
|
||
|
||
return { valid: false, error: '网络错误' };
|
||
}
|
||
}
|
||
|
||
export async function runLicenseVerification(): Promise<{ valid: boolean; error?: string; revoked?: boolean }> {
|
||
try {
|
||
const localLicense = await getLocalLicense();
|
||
|
||
if (!localLicense) {
|
||
return { valid: false, error: '未找到许可证' };
|
||
}
|
||
|
||
const fingerprint = await generateFingerprint();
|
||
const signedRequest = buildSignedLicenseRequest('verify', {
|
||
instanceId: fingerprint.instanceId,
|
||
fingerprint,
|
||
});
|
||
|
||
const response = await fetch(buildLicenseServerLicenseApiUrl('verify'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(signedRequest),
|
||
});
|
||
|
||
const data: VerifyResponse = await response.json();
|
||
|
||
if (!data.valid || !data.license) {
|
||
const isRevoked = response.status === 403 || response.status === 404;
|
||
|
||
if (isRevoked) {
|
||
console.log('[License] 许可证已被吊销:', data.error);
|
||
await clearLocalLicense();
|
||
return {
|
||
valid: false,
|
||
error: data.error || '许可证已被吊销',
|
||
revoked: true,
|
||
};
|
||
}
|
||
|
||
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
|
||
return { valid: true };
|
||
}
|
||
|
||
return { valid: false, error: data.error || '验证失败' };
|
||
}
|
||
|
||
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
|
||
return { valid: false, error: '许可证签名验证失败' };
|
||
}
|
||
|
||
await updateLocalLicense(data.license);
|
||
const moduleSyncResult = await syncPremiumModules();
|
||
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
|
||
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
|
||
}
|
||
|
||
return { valid: true };
|
||
} catch (error) {
|
||
console.error('验证失败:', error);
|
||
|
||
const localLicense = await getLocalLicense();
|
||
if (
|
||
localLicense &&
|
||
!isLicenseConfigExpired(localLicense.cachedConfig) &&
|
||
isWithinGracePeriod(localLicense.lastVerified)
|
||
) {
|
||
return { valid: true };
|
||
}
|
||
|
||
return { valid: false, error: '网络错误' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送心跳
|
||
*
|
||
* ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API
|
||
* 实际的心跳逻辑已移至 app/api/license/heartbeat/route.ts(后端代理)
|
||
*
|
||
* 安全改进:
|
||
* - 客户端不再直接访问许可证服务器
|
||
* - 硬件指纹在后端生成(更安全)
|
||
* - 许可证服务器地址完全隐藏
|
||
*
|
||
* @returns 如果许可证被吊销返回 true
|
||
*/
|
||
export async function heartbeat(): Promise<boolean> {
|
||
try {
|
||
const localLicense = await getLocalLicense();
|
||
if (!localLicense) {
|
||
return false;
|
||
}
|
||
|
||
// ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器)
|
||
const response = await fetch('/api/license/heartbeat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
// 检查是否被吊销
|
||
if (data.revoked) {
|
||
console.log('[License] 心跳检测到许可证已被吊销');
|
||
cachedLicense = null;
|
||
return true; // 返回 true 表示被吊销
|
||
}
|
||
|
||
if (data.acknowledged) {
|
||
// 重新加载许可证(因为后端已更新)
|
||
await loadLicenseFromLocal();
|
||
lastVerified = new Date();
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
console.error('心跳发送失败:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export async function runLicenseHeartbeat(): Promise<{ acknowledged: boolean; error?: string; revoked?: boolean }> {
|
||
try {
|
||
const localLicense = await getLocalLicense();
|
||
if (!localLicense) {
|
||
return { acknowledged: false, error: '未找到许可证' };
|
||
}
|
||
|
||
const fingerprint = await generateFingerprint();
|
||
const [userCount, materialCount] = await Promise.all([
|
||
prisma.user.count(),
|
||
prisma.material.count(),
|
||
]);
|
||
|
||
const signedRequest = buildSignedLicenseRequest('heartbeat', {
|
||
instanceId: fingerprint.instanceId,
|
||
fingerprint,
|
||
usage: { userCount, materialCount },
|
||
});
|
||
|
||
const response = await fetch(buildLicenseServerLicenseApiUrl('heartbeat'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(signedRequest),
|
||
});
|
||
|
||
if (response.status === 403 || response.status === 404) {
|
||
console.log('[License] 心跳检测到许可证已被吊销');
|
||
await clearLocalLicense();
|
||
return {
|
||
acknowledged: false,
|
||
revoked: true,
|
||
error: '许可证已被吊销',
|
||
};
|
||
}
|
||
|
||
const data: HeartbeatResponse = await response.json();
|
||
|
||
if (data.acknowledged && data.license) {
|
||
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
|
||
return {
|
||
acknowledged: false,
|
||
error: '许可证签名验证失败',
|
||
};
|
||
}
|
||
|
||
await updateLocalLicense(data.license);
|
||
const moduleSyncResult = await syncPremiumModules();
|
||
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
|
||
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
|
||
}
|
||
}
|
||
|
||
return {
|
||
acknowledged: data.acknowledged || false,
|
||
};
|
||
} catch (error) {
|
||
console.error('心跳发送失败:', error);
|
||
return {
|
||
acknowledged: false,
|
||
error: '网络错误',
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取当前许可证配置
|
||
*/
|
||
export function getLicense(): LicenseConfig | null {
|
||
if (isLicenseConfigExpired(cachedLicense)) {
|
||
cachedLicense = null;
|
||
return null;
|
||
}
|
||
|
||
return cachedLicense;
|
||
}
|
||
|
||
/**
|
||
* 检查功能是否可用
|
||
*/
|
||
export async function isFeatureEnabled(feature: FeatureKey): Promise<boolean> {
|
||
const currentLicense = await getCurrentLicenseConfig();
|
||
return Boolean(currentLicense?.features[feature]);
|
||
}
|
||
|
||
/**
|
||
* 检查是否达到限制
|
||
*/
|
||
export async function checkLimit(type: 'users' | 'materials'): Promise<{ allowed: boolean; current: number; max: number }> {
|
||
const currentLicense = await getCurrentLicenseConfig();
|
||
if (!currentLicense) {
|
||
return { allowed: false, current: 0, max: 0 };
|
||
}
|
||
|
||
const max = type === 'users' ? currentLicense.limits.maxUsers : currentLicense.limits.maxMaterials;
|
||
|
||
// -1 表示无限制
|
||
if (max === -1) {
|
||
return { allowed: true, current: 0, max: -1 };
|
||
}
|
||
|
||
const current = type === 'users'
|
||
? await prisma.user.count()
|
||
: await prisma.material.count();
|
||
|
||
return {
|
||
allowed: current < max,
|
||
current,
|
||
max,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取许可证类型
|
||
*/
|
||
export async function getLicenseType(): Promise<string> {
|
||
const currentLicense = await getCurrentLicenseConfig();
|
||
return currentLicense?.type || 'FREE';
|
||
}
|
||
|
||
/**
|
||
* 检查许可证是否已过期
|
||
*/
|
||
export function isExpired(): boolean {
|
||
return isLicenseConfigExpired(cachedLicense);
|
||
}
|
||
|
||
/**
|
||
* 启动心跳定时器
|
||
*/
|
||
export function startHeartbeat(): void {
|
||
if (heartbeatTimer) {
|
||
clearInterval(heartbeatTimer);
|
||
}
|
||
heartbeatTimer = setInterval(() => {
|
||
void runLicenseHeartbeat();
|
||
}, HEARTBEAT_INTERVAL);
|
||
}
|
||
|
||
/**
|
||
* 停止心跳定时器
|
||
*/
|
||
export function stopHeartbeat(): void {
|
||
if (heartbeatTimer) {
|
||
clearInterval(heartbeatTimer);
|
||
heartbeatTimer = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化许可证服务
|
||
*/
|
||
export async function initLicenseService(): Promise<void> {
|
||
const result = await runLicenseVerification();
|
||
if (result.valid) {
|
||
startHeartbeat();
|
||
}
|
||
}
|
||
|
||
// ============ 内部辅助函数 ============
|
||
|
||
export async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise<void> {
|
||
if (!verifyLicenseSignature(license.data, license.signature)) {
|
||
throw new Error('许可证签名验证失败');
|
||
}
|
||
|
||
const instanceId = await getInstanceId();
|
||
const bindingResult = validateSignedLicenseBinding(license, {
|
||
licenseKey,
|
||
instanceId,
|
||
});
|
||
if (!bindingResult.valid) {
|
||
throw new Error(bindingResult.error);
|
||
}
|
||
|
||
const currentConfig = extractLicenseConfig(license);
|
||
const nowIso = new Date().toISOString();
|
||
|
||
const localData: LocalLicenseData = {
|
||
instanceId,
|
||
licenseKey,
|
||
licenseData: encryptLocal(JSON.stringify(license)),
|
||
lastVerified: nowIso,
|
||
cachedConfig: currentConfig,
|
||
lastSeenAt: nowIso,
|
||
clockRollbackDetectedAt: null,
|
||
};
|
||
const signedLocalData = withClockIntegrity(localData);
|
||
|
||
await prisma.systemConfig.upsert({
|
||
where: { key: 'license' },
|
||
update: { value: JSON.stringify(signedLocalData) },
|
||
create: { key: 'license', value: JSON.stringify(signedLocalData) },
|
||
});
|
||
|
||
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
|
||
lastVerified = new Date(signedLocalData.lastVerified);
|
||
}
|
||
|
||
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 bindingResult = validateSignedLicenseBinding(license, {
|
||
licenseKey: existing.licenseKey,
|
||
instanceId: existing.instanceId,
|
||
});
|
||
if (!bindingResult.valid) {
|
||
throw new Error(bindingResult.error);
|
||
}
|
||
|
||
const currentConfig = extractLicenseConfig(license);
|
||
const nowIso = new Date().toISOString();
|
||
|
||
const localData: LocalLicenseData = {
|
||
...existing,
|
||
licenseData: encryptLocal(JSON.stringify(license)),
|
||
lastVerified: nowIso,
|
||
cachedConfig: currentConfig,
|
||
lastSeenAt: nowIso,
|
||
clockRollbackDetectedAt: null,
|
||
};
|
||
const signedLocalData = withClockIntegrity(localData);
|
||
|
||
await prisma.systemConfig.update({
|
||
where: { key: 'license' },
|
||
data: { value: JSON.stringify(signedLocalData) },
|
||
});
|
||
|
||
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
|
||
lastVerified = new Date(signedLocalData.lastVerified);
|
||
}
|
||
|
||
/**
|
||
* 从本地加载许可证并更新内存缓存
|
||
*/
|
||
async function loadLicenseFromLocal(): Promise<void> {
|
||
const localLicense = await getLocalLicense();
|
||
if (localLicense) {
|
||
cachedLicense = localLicense.cachedConfig;
|
||
lastVerified = new Date(localLicense.lastVerified);
|
||
} else {
|
||
cachedLicense = null;
|
||
lastVerified = null;
|
||
}
|
||
}
|
||
|
||
export async function getLocalLicense(): Promise<LocalLicenseData | null> {
|
||
try {
|
||
const config = await prisma.systemConfig.findUnique({
|
||
where: { key: 'license' },
|
||
});
|
||
|
||
if (!config) return null;
|
||
|
||
const parsed = JSON.parse(config.value) as LocalLicenseData;
|
||
const normalized = normalizeLocalLicenseData(parsed);
|
||
if (!normalized) return null;
|
||
|
||
return await enforceLocalClockState(normalized);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function getLocalLicenseReadOnly(): Promise<LocalLicenseData | null> {
|
||
try {
|
||
const config = await prisma.systemConfig.findUnique({
|
||
where: { key: 'license' },
|
||
});
|
||
|
||
if (!config) return null;
|
||
|
||
const parsed = JSON.parse(config.value) as LocalLicenseData;
|
||
const normalized = normalizeLocalLicenseData(parsed);
|
||
if (!normalized || !isClockStateIntact(normalized)) return null;
|
||
|
||
return normalized;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除本地许可证缓存(许可证被吊销时调用)
|
||
*/
|
||
export async function clearLocalLicense(): Promise<void> {
|
||
try {
|
||
await prisma.systemConfig.delete({
|
||
where: { key: 'license' },
|
||
});
|
||
console.log('[License] 本地许可证缓存已清除');
|
||
} catch (error) {
|
||
console.error('[License] 清除本地许可证缓存失败:', error);
|
||
}
|
||
}
|
||
|
||
function isWithinGracePeriod(lastVerifiedStr: string): boolean {
|
||
const lastVerified = new Date(lastVerifiedStr);
|
||
const gracePeriodEnd = new Date(lastVerified.getTime() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000);
|
||
return new Date() < gracePeriodEnd;
|
||
}
|