481 lines
13 KiB
TypeScript
481 lines
13 KiB
TypeScript
import { prisma } from '@/lib/prisma';
|
||
import { generateFingerprint, getInstanceId } from './fingerprint-service';
|
||
import { verifyLicenseSignature, encryptLocal, decryptLocal } 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';
|
||
|
||
// 宽限期(7天)
|
||
const GRACE_PERIOD_DAYS = 7;
|
||
|
||
// 心跳间隔(1小时)
|
||
const HEARTBEAT_INTERVAL = 60 * 60 * 1000;
|
||
|
||
// 缓存的许可证配置
|
||
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 extractLicenseConfig(license: SignedLicense): LicenseConfig {
|
||
const { issuedAt: _issuedAt, ...config } = license.data;
|
||
return config;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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 (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 && isWithinGracePeriod(localLicense.lastVerified)) {
|
||
cachedLicense = localLicense.cachedConfig;
|
||
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 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(heartbeat, HEARTBEAT_INTERVAL);
|
||
}
|
||
|
||
/**
|
||
* 停止心跳定时器
|
||
*/
|
||
export function stopHeartbeat(): void {
|
||
if (heartbeatTimer) {
|
||
clearInterval(heartbeatTimer);
|
||
heartbeatTimer = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化许可证服务
|
||
*/
|
||
export async function initLicenseService(): Promise<void> {
|
||
const result = await verify();
|
||
if (result.valid) {
|
||
startHeartbeat();
|
||
}
|
||
}
|
||
|
||
// ============ 内部辅助函数 ============
|
||
|
||
export async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise<void> {
|
||
const instanceId = await getInstanceId();
|
||
const currentConfig = extractLicenseConfig(license);
|
||
|
||
const localData: LocalLicenseData = {
|
||
instanceId,
|
||
licenseKey,
|
||
licenseData: encryptLocal(JSON.stringify(license)),
|
||
lastVerified: new Date().toISOString(),
|
||
cachedConfig: currentConfig,
|
||
};
|
||
|
||
await prisma.systemConfig.upsert({
|
||
where: { key: 'license' },
|
||
update: { value: JSON.stringify(localData) },
|
||
create: { key: 'license', value: JSON.stringify(localData) },
|
||
});
|
||
|
||
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
|
||
lastVerified = new Date(localData.lastVerified);
|
||
}
|
||
|
||
export async function updateLocalLicense(license: SignedLicense): Promise<void> {
|
||
const existing = await getLocalLicense();
|
||
if (!existing) return;
|
||
|
||
const currentConfig = extractLicenseConfig(license);
|
||
|
||
const localData: LocalLicenseData = {
|
||
...existing,
|
||
licenseData: encryptLocal(JSON.stringify(license)),
|
||
lastVerified: new Date().toISOString(),
|
||
cachedConfig: currentConfig,
|
||
};
|
||
|
||
await prisma.systemConfig.update({
|
||
where: { key: 'license' },
|
||
data: { value: JSON.stringify(localData) },
|
||
});
|
||
|
||
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
|
||
lastVerified = new Date(localData.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;
|
||
return normalizeLocalLicenseData(parsed);
|
||
} 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;
|
||
}
|