From 1a8087b406b119433ac530ab7ef9cb677ea332d2 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 25 May 2026 16:10:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E8=AE=B8=E5=8F=AF=E8=AF=81?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E4=B8=8E=E5=88=9D=E5=A7=8B=E5=8C=96=E5=AE=89?= =?UTF-8?q?=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 + Dockerfile | 10 +- app/api/auth/register/__tests__/route.test.ts | 88 ++++ app/api/auth/register/route.ts | 9 + .../activate-free/__tests__/route.test.ts | 289 ++++++++++++ app/api/license/activate-free/route.ts | 179 +++++++- .../license/activate/__tests__/route.test.ts | 347 +++++++------- app/api/license/activate/route.ts | 92 +++- app/api/license/check/__tests__/route.test.ts | 54 +++ app/api/license/check/route.ts | 39 ++ app/api/license/heartbeat/route.ts | 65 +++ app/api/license/verify/route.ts | 87 ++++ app/api/system/init/__tests__/route.test.ts | 109 +++++ .../system/init/__tests__/system-init.test.ts | 2 + app/api/system/init/route.ts | 226 ++++----- .../change-password/__tests__/route.test.ts | 90 ++++ app/api/user/change-password/route.ts | 36 +- app/api/user/check-default-password/route.ts | 26 +- lib/__tests__/license-service.test.ts | 427 ++++++++++++++++++ lib/__tests__/password-policy.test.ts | 24 + lib/__tests__/system-init.test.ts | 165 +++++++ lib/license/fingerprint-service.ts | 59 ++- lib/license/server-config.ts | 21 + lib/password-policy.ts | 51 +++ lib/system-init.ts | 280 ++++++++++++ 25 files changed, 2392 insertions(+), 396 deletions(-) create mode 100644 app/api/auth/register/__tests__/route.test.ts create mode 100644 app/api/license/activate-free/__tests__/route.test.ts create mode 100644 app/api/license/check/__tests__/route.test.ts create mode 100644 app/api/license/check/route.ts create mode 100644 app/api/license/heartbeat/route.ts create mode 100644 app/api/license/verify/route.ts create mode 100644 app/api/system/init/__tests__/route.test.ts create mode 100644 app/api/user/change-password/__tests__/route.test.ts create mode 100644 lib/__tests__/license-service.test.ts create mode 100644 lib/__tests__/password-policy.test.ts create mode 100644 lib/__tests__/system-init.test.ts create mode 100644 lib/license/server-config.ts create mode 100644 lib/password-policy.ts create mode 100644 lib/system-init.ts diff --git a/.env.example b/.env.example index 79c54cd..e85dcc9 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ NODE_ENV=development APP_PUBLIC_URL=https://your-domain.example.com NEXTAUTH_URL=https://your-domain.example.com NEXTAUTH_SECRET=replace-with-strong-secret +LICENSE_SERVER_URL=https://your-license-server.example.com +LICENSE_PROJECT_CODE=flnxi-sucai # Database settings DATABASE_URL=postgresql://username:password@host:5432/database @@ -41,6 +43,17 @@ PUBLIC_DOMAINS=portal.example.com ALLOWED_ORIGINS=https://portal.example.com IMAGE_REMOTE_HOSTS=s3.example.com +# License system security (REQUIRED for license validation) +# RSA public key for license signature verification (PEM format, replace \n with \\n) +LICENSE_PUBLIC_KEY= +# Local encryption key for secure storage (minimum 32 characters) +LOCAL_ENCRYPTION_KEY= +# Optional: one-time token for manual POST /api/system/init before any admin exists +SYSTEM_INIT_TOKEN= +# Optional: explicit initial admin identity; password omitted means generate a one-time password during initialization +INIT_ADMIN_EMAIL=admin@local.flnxi +INIT_ADMIN_PASSWORD= + # AI model (optional) GEMINI_API_KEY= GEMINI_API_BASE_URL=https://api.huandutech.com/v1 diff --git a/Dockerfile b/Dockerfile index 52c9b33..273563f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -278,10 +278,12 @@ RUN if [ -f "./node_modules/canvas/package.json" ] && grep -q "canvas-placeholde rm -rf ./node_modules/canvas; \ fi -# 创建上传目录并设置权限 -RUN mkdir -p /app/uploads && \ - chown -R nextjs:nodejs /app/uploads && \ - chmod -R 755 /app/uploads +# 创建上传目录和数据目录并设置权限 +# - uploads: 用户上传的文件 +# - data: 许可证实例 ID 等持久化数据 +RUN mkdir -p /app/uploads /app/data && \ + chown -R nextjs:nodejs /app/uploads /app/data && \ + chmod -R 755 /app/uploads /app/data # 复制启动脚本 COPY --from=builder --chown=nextjs:nodejs /app/scripts/docker-entrypoint.sh ./docker-entrypoint.sh diff --git a/app/api/auth/register/__tests__/route.test.ts b/app/api/auth/register/__tests__/route.test.ts new file mode 100644 index 0000000..6663713 --- /dev/null +++ b/app/api/auth/register/__tests__/route.test.ts @@ -0,0 +1,88 @@ +import { NextRequest } from 'next/server' +import { POST } from '../route' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + systemSettings: { + findFirst: jest.fn(), + }, + invitation: { + findUnique: jest.fn(), + }, + user: { + findFirst: jest.fn(), + }, + $transaction: jest.fn(), + }, +})) + +jest.mock('@/lib/license', () => ({ + canCreateUser: jest.fn(), +})) + +jest.mock('bcryptjs', () => ({ + hash: jest.fn(async (value: string) => `hashed:${value}`), +})) + +const { prisma } = require('@/lib/prisma') +const { canCreateUser } = require('@/lib/license') +const bcrypt = require('bcryptjs') + +function createRequest(body: Record) { + return new NextRequest('http://localhost/api/auth/register', { + method: 'POST', + body: JSON.stringify({ + email: 'user@example.com', + username: 'user', + department: '默认部门', + password: 'Flnxi-2026-Safe', + ...body, + }), + }) +} + +describe('POST /api/auth/register', () => { + beforeEach(() => { + jest.resetAllMocks() + canCreateUser.mockResolvedValue({ allowed: true }) + prisma.systemSettings.findFirst.mockResolvedValue({ enableRegistration: true }) + prisma.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async (callback: any) => callback({ + user: { + count: jest.fn().mockResolvedValue(1), + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + username: 'user', + department: '默认部门', + role: 'USER', + createdAt: new Date('2026-05-25T00:00:00.000Z'), + }), + }, + invitation: { + update: jest.fn(), + }, + })) + }) + + test('注册时拒绝弱密码', async () => { + const response = await POST(createRequest({ password: '123456' })) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('至少') + expect(prisma.user.findFirst).not.toHaveBeenCalled() + expect(bcrypt.hash).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + test('强密码注册仍可成功', async () => { + const response = await POST(createRequest({ password: 'Flnxi-2026-Safe' })) + const data = await response.json() + + expect(response.status).toBe(201) + expect(data.message).toBe('注册成功') + expect(bcrypt.hash).toHaveBeenCalledWith('Flnxi-2026-Safe', 10) + }) +}) diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 9842aa3..8afa12c 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import bcrypt from 'bcryptjs' import { canCreateUser } from '@/lib/license' +import { validatePasswordStrength } from '@/lib/password-policy' export async function POST(req: NextRequest) { try { @@ -33,6 +34,14 @@ export async function POST(req: NextRequest) { ) } + const passwordValidation = validatePasswordStrength(password) + if (!passwordValidation.valid) { + return NextResponse.json( + { error: passwordValidation.error || '密码不符合安全要求' }, + { status: 400 } + ) + } + // 如果注册功能关闭,检查是否有有效的邀请码 if (settings && !settings.enableRegistration) { if (!inviteCode) { diff --git a/app/api/license/activate-free/__tests__/route.test.ts b/app/api/license/activate-free/__tests__/route.test.ts new file mode 100644 index 0000000..3ccb7b8 --- /dev/null +++ b/app/api/license/activate-free/__tests__/route.test.ts @@ -0,0 +1,289 @@ +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'; + +jest.mock('@/lib/license/fingerprint-service', () => ({ + generateFingerprint: jest.fn().mockResolvedValue({ + machineId: 'machine-1', + hostname: 'nas-1', + macAddress: 'AA:BB:CC:DD:EE:FF', + instanceId: 'instance-1', + }), +})); + +jest.mock('@/lib/license/server-config', () => ({ + buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/register-free'), +})); + +jest.mock('@/lib/license/crypto-service', () => ({ + verifyLicenseSignature: jest.fn(), +})); + +jest.mock('@/lib/license/license-service', () => ({ + saveLicenseLocally: jest.fn(), + initializeSystem: jest.fn(), + startHeartbeat: jest.fn(), +})); + +const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction; +const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction; +const mockInitializeSystem = initializeSystem as jest.MockedFunction; +const mockStartHeartbeat = startHeartbeat as jest.MockedFunction; + +const signedFreeLicense = { + data: { + type: 'FREE', + limits: { maxUsers: 10, maxMaterials: 100 }, + features: { + customTemplates: false, + systemSettings: false, + departmentIsolation: false, + apiAccess: false, + intranetPenetration: false, + }, + expiresAt: null, + issuedAt: '2026-05-24T00:00:00.000Z', + }, + signature: 'signed-free-license', + issuedAt: '2026-05-24T00:00:00.000Z', +}; + +function createMockRequest(options: { + url?: string; + headers?: Record; + body?: Record; +} = {}): NextRequest { + const url = options.url || 'http://localhost:3000/api/license/activate-free'; + const headers = new Headers(options.headers || {}); + + return new NextRequest(url, { + method: 'POST', + headers, + body: JSON.stringify({ + customerName: '测试公司', + contactInfo: '13800000000', + ...options.body, + }), + }); +} + +function mockRegisterFreeSuccess(overrides: Record = {}) { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + license: signedFreeLicense, + data: { + activationCode: 'FLNXI-FREE-TEST-1234-5678-ABCD', + isExisting: false, + }, + ...overrides, + }), + }); +} + +describe('POST /api/license/activate-free', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + mockVerifyLicenseSignature.mockReturnValue(true); + mockInitializeSystem.mockResolvedValue({ + admin: { + created: false, + }, + } as any); + }); + + it('保存许可证服务端返回的签名免费许可证,不再写空签名', async () => { + mockRegisterFreeSuccess(); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockSaveLicenseLocally).toHaveBeenCalledWith( + 'FLNXI-FREE-TEST-1234-5678-ABCD', + signedFreeLicense + ); + expect(mockSaveLicenseLocally.mock.calls[0][1].signature).toBe('signed-free-license'); + expect(mockVerifyLicenseSignature).toHaveBeenCalledWith( + signedFreeLicense.data, + signedFreeLicense.signature + ); + expect(mockStartHeartbeat).toHaveBeenCalled(); + }); + + it('许可证服务没有返回签名许可证时拒绝激活,避免保存不可读取的本地许可证', async () => { + mockRegisterFreeSuccess({ license: undefined }); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(502); + expect(data.success).toBe(false); + expect(data.error).toContain('许可证服务返回数据异常'); + expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + expect(mockInitializeSystem).not.toHaveBeenCalled(); + expect(mockStartHeartbeat).not.toHaveBeenCalled(); + }); + + it('许可证签名验证失败时拒绝激活', async () => { + mockRegisterFreeSuccess(); + mockVerifyLicenseSignature.mockReturnValue(false); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('许可证签名验证失败'); + expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + expect(mockInitializeSystem).not.toHaveBeenCalled(); + expect(mockStartHeartbeat).not.toHaveBeenCalled(); + }); + + it('应该设置 license_activated cookie', async () => { + mockRegisterFreeSuccess(); + + const response = await POST(createMockRequest()); + const setCookieHeader = response.headers.get('set-cookie'); + + expect(setCookieHeader).toContain('license_activated=true'); + expect(setCookieHeader).toContain('HttpOnly'); + expect(setCookieHeader).toContain('Path=/'); + expect(setCookieHeader).toContain('Max-Age=31536000'); + expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax'); + }); + + it('初始管理员密码来自环境变量时不应在激活响应中回显明文', async () => { + mockRegisterFreeSuccess(); + mockInitializeSystem.mockResolvedValueOnce({ + admin: { + created: true, + email: 'admin@local.flnxi', + username: 'admin', + initialPassword: 'Env-Password-Should-Not-Return', + mustChangePassword: true, + passwordSource: 'environment', + }, + } as any); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.initialAdmin).toEqual({ + email: 'admin@local.flnxi', + username: 'admin', + password: null, + mustChangePassword: true, + passwordSource: 'environment', + }); + }); + + it('系统生成的一次性初始管理员密码仍只在首次激活响应中返回', async () => { + mockRegisterFreeSuccess(); + mockInitializeSystem.mockResolvedValueOnce({ + admin: { + created: true, + email: 'admin@local.flnxi', + username: 'admin', + initialPassword: 'Generated-Password-123', + mustChangePassword: true, + passwordSource: 'generated', + }, + } as any); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.initialAdmin.password).toBe('Generated-Password-123'); + expect(data.initialAdmin.passwordSource).toBe('generated'); + }); + + it('免费版激活日志不应输出完整激活码', async () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + mockRegisterFreeSuccess(); + + const response = await POST(createMockRequest()); + const data = await response.json(); + const logOutput = JSON.stringify(consoleLogSpy.mock.calls); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(logOutput).toContain('FLNX****ABCD'); + expect(logOutput).not.toContain('FLNXI-FREE-TEST-1234-5678-ABCD'); + + consoleLogSpy.mockRestore(); + }); + + it('HTTP 请求时 cookie 不带 Secure,HTTPS 或代理 HTTPS 时带 Secure', async () => { + mockRegisterFreeSuccess(); + const httpResponse = await POST(createMockRequest({ + url: 'http://localhost:3000/api/license/activate-free', + })); + expect(httpResponse.headers.get('set-cookie')).not.toContain('Secure'); + + mockRegisterFreeSuccess(); + const httpsResponse = await POST(createMockRequest({ + url: 'https://example.com/api/license/activate-free', + })); + expect(httpsResponse.headers.get('set-cookie')).toContain('Secure'); + + mockRegisterFreeSuccess(); + const forwardedHttpsResponse = await POST(createMockRequest({ + url: 'http://localhost:3000/api/license/activate-free', + headers: { 'x-forwarded-proto': 'https' }, + })); + expect(forwardedHttpsResponse.headers.get('set-cookie')).toContain('Secure'); + }); + + it('许可证服务器返回失败时应该返回 400', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: false, + error: '试用已过期', + }), + }); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('试用已过期'); + expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + }); + + it('许可证服务器网络失败时应该返回 400', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: async () => ({ error: '许可证服务不可用' }), + }); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('许可证服务不可用'); + expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + }); + + it('缺少客户名称或联系方式时返回 400', async () => { + const missingNameResponse = await POST(createMockRequest({ body: { customerName: '' } })); + expect(missingNameResponse.status).toBe(400); + + const missingContactResponse = await POST(createMockRequest({ body: { contactInfo: '' } })); + expect(missingContactResponse.status).toBe(400); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/license/activate-free/route.ts b/app/api/license/activate-free/route.ts index 57a99b5..fda638d 100644 --- a/app/api/license/activate-free/route.ts +++ b/app/api/license/activate-free/route.ts @@ -1,29 +1,170 @@ -import { NextResponse } from 'next/server'; -import { activateFreeTrial } from '@/lib/license'; +import { NextRequest, NextResponse } from 'next/server'; +import { generateFingerprint } from '@/lib/license/fingerprint-service'; +import { verifyLicenseSignature } from '@/lib/license/crypto-service'; +import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'; +import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config'; -export async function POST() { +function maskSecret(value: unknown): string { + if (typeof value !== 'string' || !value) return '****'; + if (value.length <= 8) return '****'; + return `${value.slice(0, 4)}****${value.slice(-4)}`; +} + +function sanitizeLicenseServerLogPayload(payload: unknown) { + if (!payload || typeof payload !== 'object') return payload; + + const raw = payload as Record; + return { + ...raw, + data: raw.data + ? { + ...raw.data, + activationCode: raw.data.activationCode ? maskSecret(raw.data.activationCode) : raw.data.activationCode, + } + : raw.data, + license: raw.license + ? { + data: raw.license.data ? '[present]' : raw.license.data, + signature: raw.license.signature ? '[redacted]' : raw.license.signature, + } + : raw.license, + }; +} + +export async function POST(request: NextRequest) { try { - const result = await activateFreeTrial(); + const body = await request.json().catch(() => ({})); + const customerName = body.customerName?.trim(); + const contactInfo = body.contactInfo?.trim(); - if (result.success) { - const response = NextResponse.json({ success: true }); - - // 设置许可证激活 cookie(用于 middleware 快速检查) - response.cookies.set('license_activated', 'true', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 365 * 24 * 60 * 60, // 1年 - path: '/', - }); - - return response; - } else { + // 验证必填字段 + if (!customerName || customerName.length === 0) { return NextResponse.json( - { success: false, error: result.error }, + { success: false, error: '客户名称不能为空' }, { status: 400 } ); } + + if (!contactInfo || contactInfo.length === 0) { + return NextResponse.json( + { success: false, error: '联系方式不能为空' }, + { status: 400 } + ); + } + + // ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端) + const fingerprint = await generateFingerprint(); + + // 确保指纹有效 + if (!fingerprint.machineId || !fingerprint.instanceId) { + console.error('[License] Fingerprint验证失败:', fingerprint); + return NextResponse.json( + { success: false, error: '系统信息获取失败,请重试' }, + { status: 500 } + ); + } + + // 确保macAddress不是无效值 + if (fingerprint.macAddress === '00:00:00:00:00:00') { + console.warn('[License] MAC地址无效,使用备用值'); + fingerprint.macAddress = 'unknown'; + } + + // ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址) + const response = await fetch(buildLicenseServerLicenseApiUrl('register-free'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fingerprint, + customerName, + contactInfo, + timestamp: Date.now(), + }), + }); + + if (!response.ok) { + let errorMessage = '激活失败,请检查网络连接'; + try { + const errorData = await response.json(); + errorMessage = errorData.error || errorMessage; + console.error('[License] 免费版注册失败:', sanitizeLicenseServerLogPayload(errorData)); + } catch { + errorMessage = response.statusText || errorMessage; + console.error('[License] 免费版注册失败:', response.status, response.statusText); + } + return NextResponse.json( + { success: false, error: errorMessage }, + { status: 400 } + ); + } + + const data = await response.json(); + + if (!data.success) { + console.error('[License] 免费版注册失败:', sanitizeLicenseServerLogPayload(data)); + return NextResponse.json( + { success: false, error: data.error || '激活失败,请检查网络连接' }, + { status: 400 } + ); + } + + const activationCode = data.data?.activationCode; + const signedLicense = data.license; + + if (!activationCode || !signedLicense?.data || !signedLicense?.signature) { + console.error('[License] 免费版注册返回数据不完整:', sanitizeLicenseServerLogPayload(data)); + return NextResponse.json( + { success: false, error: '许可证服务返回数据异常,请稍后重试' }, + { status: 502 } + ); + } + + // ✅ 免费版也必须使用许可证服务端签名,避免保存不可验证的本地许可证 + if (!verifyLicenseSignature(signedLicense.data, signedLicense.signature)) { + return NextResponse.json( + { success: false, error: '许可证签名验证失败' }, + { status: 400 } + ); + } + + console.log('[License] 免费版激活码:', maskSecret(activationCode), data.data.isExisting ? '(已存在)' : '(新注册)'); + + // ✅ 在后端保存许可证 + await saveLicenseLocally(activationCode, signedLicense); + + // 初始化系统(创建默认管理员、部门、店铺等) + const initResult = await initializeSystem(); + + // 启动心跳 + startHeartbeat(); + + const result = NextResponse.json({ + success: true, + initialAdmin: initResult.admin?.created + ? { + email: initResult.admin.email, + username: initResult.admin.username, + password: initResult.admin.passwordSource === 'generated' ? initResult.admin.initialPassword || null : null, + mustChangePassword: initResult.admin.mustChangePassword, + passwordSource: initResult.admin.passwordSource || null, + } + : null, + }); + + // 自动检测协议:根据请求的协议决定 cookie 的 secure 属性 + const isHttps = request.headers.get('x-forwarded-proto') === 'https' + || request.url.startsWith('https://'); + + // 设置许可证激活 cookie(用于 middleware 快速检查) + result.cookies.set('license_activated', 'true', { + httpOnly: true, + secure: isHttps, + sameSite: 'lax', + maxAge: 365 * 24 * 60 * 60, // 1年 + path: '/', + }); + + return result; } catch (error) { console.error('免费试用激活失败:', error); return NextResponse.json( diff --git a/app/api/license/activate/__tests__/route.test.ts b/app/api/license/activate/__tests__/route.test.ts index 1766147..ba33440 100644 --- a/app/api/license/activate/__tests__/route.test.ts +++ b/app/api/license/activate/__tests__/route.test.ts @@ -1,191 +1,206 @@ -/** - * 许可证激活 API 路由测试 - * 测试 app/api/license/activate/route.ts - */ -import { NextRequest } from 'next/server'; -import { POST } from '../route'; +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' -// Mock lib/license 模块 -jest.mock('@/lib/license', () => ({ - activate: jest.fn(), -})); +jest.mock('@/lib/license/fingerprint-service', () => ({ + generateFingerprint: jest.fn().mockResolvedValue({ + machineId: 'machine-1', + hostname: 'nas-1', + macAddress: 'AA:BB:CC:DD:EE:FF', + instanceId: 'instance-1', + }), +})) -import { activate } from '@/lib/license'; +jest.mock('@/lib/license/server-config', () => ({ + buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/activate'), +})) -const mockActivate = activate as jest.MockedFunction; +jest.mock('@/lib/license/crypto-service', () => ({ + verifyLicenseSignature: jest.fn(), +})) + +jest.mock('@/lib/license/license-service', () => ({ + saveLicenseLocally: jest.fn(), + initializeSystem: jest.fn(), + startHeartbeat: jest.fn(), +})) + +const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction +const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction +const mockInitializeSystem = initializeSystem as jest.MockedFunction +const mockStartHeartbeat = startHeartbeat as jest.MockedFunction + +const signedLicense = { + data: { + type: 'PRO', + limits: { maxUsers: 100, maxMaterials: 10000 }, + features: { + customTemplates: true, + systemSettings: true, + departmentIsolation: true, + apiAccess: true, + intranetPenetration: true, + }, + expiresAt: null, + issuedAt: '2026-05-24T00:00:00.000Z', + }, + signature: 'signed-license', + issuedAt: '2026-05-24T00:00:00.000Z', +} + +function createMockRequest(options: { + url?: string + headers?: Record + body?: Record +} = {}) { + const url = options.url || 'http://localhost:3000/api/license/activate' + const headers = new Headers(options.headers || {}) + + return new NextRequest(url, { + method: 'POST', + headers, + body: JSON.stringify({ + licenseKey: 'ABCD-1234-EFGH-5678', + ...options.body, + }), + }) +} + +function mockActivateSuccess(overrides: Record = {}) { + ;(global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + license: signedLicense, + ...overrides, + }), + }) +} describe('POST /api/license/activate', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + global.fetch = jest.fn() + mockVerifyLicenseSignature.mockReturnValue(true) + mockInitializeSystem.mockResolvedValue({ + admin: { + created: false, + }, + } as any) + }) - describe('请求验证', () => { - it('缺少 licenseKey 时应返回 400 错误', async () => { - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({}), - headers: { 'Content-Type': 'application/json' }, - }); + test('缺少激活码时应返回 400', async () => { + const response = await POST(createMockRequest({ body: { licenseKey: '' } })) + const data = await response.json() - const response = await POST(request); - const data = await response.json(); + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toBe('请输入激活码') + expect(global.fetch).not.toHaveBeenCalled() + }) - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.error).toBe('请输入许可证密钥'); - }); + test('激活成功时保存签名许可证并设置 cookie', async () => { + mockActivateSuccess() - it('licenseKey 为空字符串时应返回 400 错误', async () => { - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({ licenseKey: '' }), - headers: { 'Content-Type': 'application/json' }, - }); + const response = await POST(createMockRequest()) + const data = await response.json() + const setCookieHeader = response.headers.get('set-cookie') - const response = await POST(request); - const data = await response.json(); + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense) + expect(mockVerifyLicenseSignature).toHaveBeenCalledWith(signedLicense.data, signedLicense.signature) + expect(mockStartHeartbeat).toHaveBeenCalled() + expect(setCookieHeader).toContain('license_activated=true') + expect(setCookieHeader).toContain('HttpOnly') + expect(setCookieHeader).toContain('Max-Age=31536000') + }) - expect(response.status).toBe(400); - expect(data.success).toBe(false); - }); - }); + test('环境变量来源的初始管理员密码不应在激活响应中回显', async () => { + mockActivateSuccess() + mockInitializeSystem.mockResolvedValueOnce({ + admin: { + created: true, + email: 'admin@local.flnxi', + username: 'admin', + initialPassword: 'Env-Password-Should-Not-Return', + mustChangePassword: true, + passwordSource: 'environment', + }, + } as any) - describe('激活成功', () => { - it('激活成功时应返回 success: true', async () => { - mockActivate.mockResolvedValueOnce({ success: true }); + const response = await POST(createMockRequest()) + const data = await response.json() - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }), - headers: { 'Content-Type': 'application/json' }, - }); + expect(response.status).toBe(200) + expect(data.initialAdmin).toEqual({ + email: 'admin@local.flnxi', + username: 'admin', + password: null, + mustChangePassword: true, + passwordSource: 'environment', + }) + }) - const response = await POST(request); - const data = await response.json(); + test('系统生成的一次性初始管理员密码仍只在首次激活响应中返回', async () => { + mockActivateSuccess() + mockInitializeSystem.mockResolvedValueOnce({ + admin: { + created: true, + email: 'admin@local.flnxi', + username: 'admin', + initialPassword: 'Generated-Password-123', + mustChangePassword: true, + passwordSource: 'generated', + }, + } as any) - expect(response.status).toBe(200); - expect(data.success).toBe(true); - expect(mockActivate).toHaveBeenCalledWith('ABCD-1234-EFGH-5678'); - }); + const response = await POST(createMockRequest()) + const data = await response.json() - it('激活成功时应设置 license_activated cookie', async () => { - mockActivate.mockResolvedValueOnce({ success: true }); + expect(response.status).toBe(200) + expect(data.initialAdmin.password).toBe('Generated-Password-123') + expect(data.initialAdmin.passwordSource).toBe('generated') + }) - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }), - headers: { 'Content-Type': 'application/json' }, - }); + test('许可证服务器拒绝或签名验证失败时不保存本地许可证', async () => { + mockActivateSuccess({ + success: false, + error: '许可证无效', + license: undefined, + }) - const response = await POST(request); - const setCookieHeader = response.headers.get('set-cookie'); + const rejectedResponse = await POST(createMockRequest()) + const rejectedData = await rejectedResponse.json() - expect(setCookieHeader).toBeTruthy(); - expect(setCookieHeader).toContain('license_activated=true'); - expect(setCookieHeader).toContain('HttpOnly'); - expect(setCookieHeader).toContain('Path=/'); - // SameSite 值可能是 Lax 或 lax(大小写不敏感) - expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax'); - }); + expect(rejectedResponse.status).toBe(400) + expect(rejectedData.error).toBe('许可证无效') + expect(mockSaveLicenseLocally).not.toHaveBeenCalled() - it('cookie 应设置 1 年有效期', async () => { - mockActivate.mockResolvedValueOnce({ success: true }); + mockActivateSuccess() + mockVerifyLicenseSignature.mockReturnValueOnce(false) - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }), - headers: { 'Content-Type': 'application/json' }, - }); + const invalidSignatureResponse = await POST(createMockRequest()) + const invalidSignatureData = await invalidSignatureResponse.json() - const response = await POST(request); - const setCookieHeader = response.headers.get('set-cookie'); + expect(invalidSignatureResponse.status).toBe(400) + expect(invalidSignatureData.error).toBe('许可证签名验证失败') + expect(mockSaveLicenseLocally).not.toHaveBeenCalled() + }) - // 1年 = 365 * 24 * 60 * 60 = 31536000 秒 - expect(setCookieHeader).toContain('Max-Age=31536000'); - }); - }); + test('HTTPS 或代理 HTTPS 时 cookie 带 Secure', async () => { + mockActivateSuccess() + const httpsResponse = await POST(createMockRequest({ + url: 'https://example.com/api/license/activate', + })) + expect(httpsResponse.headers.get('set-cookie')).toContain('Secure') - describe('激活失败', () => { - it('许可证无效时应返回 400 错误', async () => { - mockActivate.mockResolvedValueOnce({ success: false, error: '许可证无效' }); - - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({ licenseKey: 'INVALID-KEY-1234' }), - headers: { 'Content-Type': 'application/json' }, - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.error).toBe('许可证无效'); - }); - - it('许可证已被使用时应返回相应错误', async () => { - mockActivate.mockResolvedValueOnce({ success: false, error: '许可证已被其他设备激活' }); - - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({ licenseKey: 'USED-KEY-1234-5678' }), - headers: { 'Content-Type': 'application/json' }, - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.success).toBe(false); - expect(data.error).toBe('许可证已被其他设备激活'); - }); - - it('激活失败时不应设置 cookie', async () => { - mockActivate.mockResolvedValueOnce({ success: false, error: '许可证无效' }); - - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({ licenseKey: 'INVALID-KEY-1234' }), - headers: { 'Content-Type': 'application/json' }, - }); - - const response = await POST(request); - const setCookieHeader = response.headers.get('set-cookie'); - - expect(setCookieHeader).toBeNull(); - }); - }); - - describe('服务器错误处理', () => { - it('activate 函数抛出异常时应返回 500 错误', async () => { - mockActivate.mockRejectedValueOnce(new Error('数据库连接失败')); - - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }), - headers: { 'Content-Type': 'application/json' }, - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data.success).toBe(false); - expect(data.error).toBe('服务器错误'); - }); - - it('JSON 解析错误时应返回 500 错误', async () => { - const request = new NextRequest('http://localhost/api/license/activate', { - method: 'POST', - body: 'invalid json', - headers: { 'Content-Type': 'application/json' }, - }); - - const response = await POST(request); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data.success).toBe(false); - }); - }); -}); + mockActivateSuccess() + const forwardedHttpsResponse = await POST(createMockRequest({ + url: 'http://localhost:3000/api/license/activate', + headers: { 'x-forwarded-proto': 'https' }, + })) + expect(forwardedHttpsResponse.headers.get('set-cookie')).toContain('Secure') + }) +}) diff --git a/app/api/license/activate/route.ts b/app/api/license/activate/route.ts index b6121a4..65d7a71 100644 --- a/app/api/license/activate/route.ts +++ b/app/api/license/activate/route.ts @@ -1,38 +1,92 @@ import { NextRequest, NextResponse } from 'next/server'; -import { activate } from '@/lib/license'; +import { generateFingerprint } from '@/lib/license/fingerprint-service'; +import { verifyLicenseSignature } from '@/lib/license/crypto-service'; +import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'; +import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config'; +import type { ActivateResponse } from '@/lib/license/types'; export async function POST(request: NextRequest) { try { - const { licenseKey } = await request.json(); + const body = await request.json(); + // 支持两种参数名:activationCode(新)和 licenseKey(旧) + const licenseKey = body.activationCode || body.licenseKey; if (!licenseKey) { return NextResponse.json( - { success: false, error: '请输入许可证密钥' }, + { success: false, error: '请输入激活码' }, { status: 400 } ); } - const result = await activate(licenseKey); + // ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端) + const fingerprint = await generateFingerprint(); - if (result.success) { - const response = NextResponse.json({ success: true }); - - // 设置许可证激活 cookie(用于 middleware 快速检查) - response.cookies.set('license_activated', 'true', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 365 * 24 * 60 * 60, // 1年 - path: '/', - }); - - return response; - } else { + // ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址) + const response = await fetch(buildLicenseServerLicenseApiUrl('activate'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + licenseKey, + fingerprint, + timestamp: Date.now(), + }), + }); + + const data: ActivateResponse = await response.json(); + + if (!data.success || !data.license) { return NextResponse.json( - { success: false, error: result.error }, + { success: false, error: data.error || '激活失败' }, { status: 400 } ); } + + // ✅ 在后端验证签名 + if (!verifyLicenseSignature(data.license.data, data.license.signature)) { + return NextResponse.json( + { success: false, error: '许可证签名验证失败' }, + { status: 400 } + ); + } + + // ✅ 在后端保存许可证 + await saveLicenseLocally(licenseKey, data.license); + + // 初始化系统(创建默认管理员、部门、店铺等) + const initResult = await initializeSystem(); + + // 启动心跳 + startHeartbeat(); + + const result = NextResponse.json({ + success: true, + initialAdmin: initResult.admin?.created + ? { + email: initResult.admin.email, + username: initResult.admin.username, + password: initResult.admin.passwordSource === 'generated' ? initResult.admin.initialPassword || null : null, + mustChangePassword: initResult.admin.mustChangePassword, + passwordSource: initResult.admin.passwordSource || null, + } + : null, + }); + + // 自动检测协议:根据请求的协议决定 cookie 的 secure 属性 + // - HTTP 请求:secure = false(内网部署) + // - HTTPS 请求:secure = true(Pro 版外网穿透) + const isHttps = request.headers.get('x-forwarded-proto') === 'https' + || request.url.startsWith('https://'); + + // 设置许可证激活 cookie(用于 middleware 快速检查) + result.cookies.set('license_activated', 'true', { + httpOnly: true, + secure: isHttps, + sameSite: 'lax', + maxAge: 365 * 24 * 60 * 60, // 1年 + path: '/', + }); + + return result; } catch (error) { console.error('激活失败:', error); return NextResponse.json( diff --git a/app/api/license/check/__tests__/route.test.ts b/app/api/license/check/__tests__/route.test.ts new file mode 100644 index 0000000..ec1c1c9 --- /dev/null +++ b/app/api/license/check/__tests__/route.test.ts @@ -0,0 +1,54 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/license/license-service', () => ({ + getLocalLicense: jest.fn(), +})) + +const { getLocalLicense } = require('@/lib/license/license-service') + +import { GET } from '../route' + +describe('GET /api/license/check', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('本地许可证通过验签时应恢复激活状态并写入 cookie', async () => { + 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, + }, + }) + + const response = await GET(new NextRequest('https://localhost/api/license/check')) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.activated).toBe(true) + expect(response.cookies.get('license_activated')?.value).toBe('true') + }) + + test('本地许可证为空或验签失败时应返回未激活', async () => { + getLocalLicense.mockResolvedValue(null) + + 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() + }) +}) diff --git a/app/api/license/check/route.ts b/app/api/license/check/route.ts new file mode 100644 index 0000000..8d925bd --- /dev/null +++ b/app/api/license/check/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getLocalLicense } from '@/lib/license/license-service'; + +/** + * 检查数据库中是否有已激活的许可证 + * 用于恢复 cookie(当 cookie 丢失但数据库中仍有许可证时) + */ +export async function GET(request: NextRequest) { + try { + const license = await getLocalLicense(); + if (!license) { + return NextResponse.json({ activated: false }); + } + + const response = NextResponse.json({ activated: true }); + + // 自动检测协议:根据请求的协议决定 cookie 的 secure 属性 + const isHttps = request.headers.get('x-forwarded-proto') === 'https' + || request.url.startsWith('https://'); + + // 恢复 cookie + response.cookies.set('license_activated', 'true', { + httpOnly: true, + secure: isHttps, + sameSite: 'lax', + maxAge: 365 * 24 * 60 * 60, // 1年 + path: '/', + }); + + return response; + } catch (error) { + console.error('检查许可证状态失败:', error); + return NextResponse.json( + { activated: false, error: '服务器错误' }, + { status: 500 } + ); + } +} + diff --git a/app/api/license/heartbeat/route.ts b/app/api/license/heartbeat/route.ts new file mode 100644 index 0000000..349fb0b --- /dev/null +++ b/app/api/license/heartbeat/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server'; +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 type { HeartbeatResponse } from '@/lib/license/types'; + +export async function POST(request: NextRequest) { + try { + const localLicense = await getLocalLicense(); + if (!localLicense) { + return NextResponse.json({ acknowledged: false, error: '未找到许可证' }); + } + + // ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端) + const fingerprint = await generateFingerprint(); + + // 获取使用统计 + const [userCount, materialCount] = await Promise.all([ + prisma.user.count(), + prisma.material.count(), + ]); + + // ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址) + const response = await fetch(buildLicenseServerLicenseApiUrl('heartbeat'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + instanceId: fingerprint.instanceId, + fingerprint, + usage: { userCount, materialCount }, + timestamp: Date.now(), + }), + }); + + // 检查是否被吊销 + if (response.status === 403 || response.status === 404) { + console.log('[License] 心跳检测到许可证已被吊销'); + await clearLocalLicense(); + return NextResponse.json({ + acknowledged: false, + revoked: true, + error: '许可证已被吊销' + }); + } + + const data: HeartbeatResponse = await response.json(); + + if (data.acknowledged && data.license) { + // ✅ 在后端更新本地缓存 + await updateLocalLicense(data.license); + } + + return NextResponse.json({ + acknowledged: data.acknowledged || false + }); + } catch (error) { + console.error('心跳发送失败:', error); + return NextResponse.json({ + acknowledged: false, + error: '网络错误' + }); + } +} + diff --git a/app/api/license/verify/route.ts b/app/api/license/verify/route.ts new file mode 100644 index 0000000..0f33a6f --- /dev/null +++ b/app/api/license/verify/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { generateFingerprint } from '@/lib/license/fingerprint-service'; +import { updateLocalLicense, getLocalLicense, clearLocalLicense } 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'; + +// 宽限期(7天) +const GRACE_PERIOD_DAYS = 7; +const GRACE_PERIOD_MS = GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000; + +function isWithinGracePeriod(lastVerified: Date | string | null): boolean { + if (!lastVerified) return false; + const lastVerifiedDate = typeof lastVerified === 'string' ? new Date(lastVerified) : lastVerified; + const now = new Date(); + return (now.getTime() - lastVerifiedDate.getTime()) < GRACE_PERIOD_MS; +} + +export async function POST(request: NextRequest) { + try { + // 先检查本地缓存 + const localLicense = await getLocalLicense(); + + if (!localLicense) { + return NextResponse.json({ valid: false, error: '未找到许可证' }); + } + + // ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端) + const fingerprint = await generateFingerprint(); + + // ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址) + const response = await fetch(buildLicenseServerLicenseApiUrl('verify'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + instanceId: fingerprint.instanceId, + fingerprint, + timestamp: Date.now(), + }), + }); + + 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 NextResponse.json({ + valid: false, + error: data.error || '许可证已被吊销', + revoked: true + }); + } + + // 其他错误(如网络问题),检查宽限期 + if (isWithinGracePeriod(localLicense.lastVerified)) { + return NextResponse.json({ valid: true }); + } + return NextResponse.json({ valid: false, error: data.error || '验证失败' }); + } + + // ✅ 在后端验证签名 + if (!verifyLicenseSignature(data.license.data, data.license.signature)) { + return NextResponse.json({ valid: false, error: '许可证签名验证失败' }); + } + + // ✅ 在后端更新本地缓存 + await updateLocalLicense(data.license); + + return NextResponse.json({ valid: true }); + } catch (error) { + console.error('验证失败:', error); + + // 网络错误时检查宽限期 + const localLicense = await getLocalLicense(); + if (localLicense && isWithinGracePeriod(localLicense.lastVerified)) { + return NextResponse.json({ valid: true }); + } + + return NextResponse.json({ valid: false, error: '网络错误' }); + } +} + diff --git a/app/api/system/init/__tests__/route.test.ts b/app/api/system/init/__tests__/route.test.ts new file mode 100644 index 0000000..4b12438 --- /dev/null +++ b/app/api/system/init/__tests__/route.test.ts @@ -0,0 +1,109 @@ +import { NextRequest } from 'next/server' +import { POST } from '../route' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findFirst: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/system-integrity', () => ({ + BUILTIN_TEMPLATE_KEYS: ['3dmax-material'], +})) + +jest.mock('@/lib/system-init', () => ({ + getSystemInitToken: jest.fn(() => 'init-token'), + initializeSystem: jest.fn(), +})) + +const { getServerSession } = require('next-auth') +const { prisma } = require('@/lib/prisma') +const { initializeSystem } = require('@/lib/system-init') + +describe('POST /api/system/init', () => { + beforeEach(() => { + jest.resetAllMocks() + getServerSession.mockResolvedValue({ + user: { + id: 'admin-1', + role: 'ADMIN', + isSuperAdmin: true, + }, + }) + prisma.user.findFirst.mockResolvedValue({ id: 'admin-1' }) + }) + + test('环境变量来源的初始管理员密码不应在初始化响应中回显', async () => { + initializeSystem.mockResolvedValue({ + alreadyInitialized: false, + initialized: true, + admin: { + email: 'admin@local.flnxi', + username: 'admin', + created: true, + upgraded: false, + mustChangePassword: true, + initialPassword: 'Env-Password-Should-Not-Return', + passwordSource: 'environment', + }, + defaultDepartment: '默认部门', + defaultCategory: '默认店铺', + defaultScene: '默认场景', + templates: { + total: 1, + created: 0, + createdTemplates: [], + errors: [], + }, + }) + + const response = await POST(new NextRequest('http://localhost/api/system/init', { method: 'POST' })) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data.admin.initialPassword).toBeNull() + expect(data.data.admin.passwordSource).toBe('environment') + }) + + test('系统生成的一次性管理员密码仍可在首次初始化响应中返回', async () => { + initializeSystem.mockResolvedValue({ + alreadyInitialized: false, + initialized: true, + admin: { + email: 'admin@local.flnxi', + username: 'admin', + created: true, + upgraded: false, + mustChangePassword: true, + initialPassword: 'Generated-Password-123', + passwordSource: 'generated', + }, + defaultDepartment: '默认部门', + defaultCategory: '默认店铺', + defaultScene: '默认场景', + templates: { + total: 1, + created: 0, + createdTemplates: [], + errors: [], + }, + }) + + const response = await POST(new NextRequest('http://localhost/api/system/init', { method: 'POST' })) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data.admin.initialPassword).toBe('Generated-Password-123') + expect(data.data.admin.passwordSource).toBe('generated') + }) +}) diff --git a/app/api/system/init/__tests__/system-init.test.ts b/app/api/system/init/__tests__/system-init.test.ts index 8aa4397..ede7f4d 100644 --- a/app/api/system/init/__tests__/system-init.test.ts +++ b/app/api/system/init/__tests__/system-init.test.ts @@ -3,6 +3,8 @@ * 测试内置模板配置和系统初始化功能 */ +export {} + // 内置模板配置(从 route.ts 复制,用于测试验证) const BUILTIN_TEMPLATE_KEYS = [ '3dmax-material', diff --git a/app/api/system/init/route.ts b/app/api/system/init/route.ts index 633ce71..a0f5bc6 100644 --- a/app/api/system/init/route.ts +++ b/app/api/system/init/route.ts @@ -1,138 +1,88 @@ import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import type { Session } from 'next-auth' +import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import bcrypt from 'bcryptjs' -import { - BUILTIN_TEMPLATE_KEYS, - ensureBuiltinTemplates, -} from '@/lib/system-integrity' +import { BUILTIN_TEMPLATE_KEYS } from '@/lib/system-integrity' +import { getSystemInitToken, initializeSystem } from '@/lib/system-init' -// 系统初始化:创建默认部门、店铺、管理员账户、内置模板 -export async function POST(_req: NextRequest) { +function isAdminSession(session: Session | null) { + const user = session?.user + return Boolean(user && (user.role === 'ADMIN' || user.isSuperAdmin)) +} + +export async function POST(req: NextRequest) { try { - // 从环境变量读取初始管理员配置,如果没有则使用默认值(向后兼容) - const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com' - const initAdminPassword = process.env.INIT_ADMIN_PASSWORD || '88888888' - const initAdminUsername = initAdminEmail.split('@')[0] // 从邮箱提取用户名 - - // 使用事务确保所有数据都创建成功或已存在 - const result = await prisma.$transaction(async (tx) => { - // 1. 确保默认管理员账户存在 - // 优先查找环境变量配置的邮箱,如果没有则查找旧的 'admin' - let admin = await tx.user.findFirst({ - where: { - OR: [ - { email: initAdminEmail }, - { email: 'admin' } // 向后兼容旧配置 - ] - } - }) - - let adminCreated = false - if (!admin) { - // 检查是否已有任何用户(避免在已有用户的情况下创建默认管理员) - const userCount = await tx.user.count() - if (userCount === 0) { - // 只有在没有任何用户时才创建初始管理员 - const hashedPassword = await bcrypt.hash(initAdminPassword, 10) - admin = await tx.user.create({ - data: { - email: initAdminEmail, - username: initAdminUsername, - password: hashedPassword, - role: 'ADMIN', - isSuperAdmin: true, // 初始管理员设为超级管理员 - isActive: true, - canDownload: true, - department: '系统管理' - } - }) - adminCreated = true - } else { - // 如果已有用户,返回第一个管理员用户(如果有) - admin = await tx.user.findFirst({ - where: { role: 'ADMIN' } - }) || await tx.user.findFirst() - } - } - - const ensureDepartment = async () => { - const existing = await tx.department.findFirst({ where: { name: '默认部门' } }) - if (existing) return { entity: existing, created: false } - const created = await tx.department.create({ data: { name: '默认部门' } }) - return { entity: created, created: true } - } - - const ensureCategory = async () => { - const existing = await tx.category.findFirst({ where: { name: '默认店铺' } }) - if (existing) return { entity: existing, created: false } - const created = await tx.category.create({ data: { name: '默认店铺' } }) - return { entity: created, created: true } - } - - const ensureScene = async () => { - const existing = await tx.scene.findFirst({ where: { name: '默认场景' } }) - if (existing) return { entity: existing, created: false } - const created = await tx.scene.create({ data: { name: '默认场景' } }) - return { entity: created, created: true } - } - - const departmentResult = await ensureDepartment() - const categoryResult = await ensureCategory() - const sceneResult = await ensureScene() - - // 注意:内置模板在事务外创建,因为 ensureBuiltinTemplates 有自己的事务处理 - // 这里只返回占位结果,实际创建在事务后执行 - const templatesPlaceholder = { - total: BUILTIN_TEMPLATE_KEYS.length, - created: 0, - createdTemplates: [] as string[], - } - - return { - admin, - defaultDepartment: departmentResult.entity, - defaultCategory: categoryResult.entity, - defaultScene: sceneResult.entity, - templates: templatesPlaceholder, - createdFlags: { - admin: adminCreated, - department: departmentResult.created, - category: categoryResult.created, - scene: sceneResult.created, - } - } + const session = await getServerSession(authOptions) + const existingSuperAdmin = await prisma.user.findFirst({ + where: { isSuperAdmin: true }, + select: { id: true }, }) - // 在事务外创建内置模板(使用统一的模块) - const templatesResult = await ensureBuiltinTemplates() + let initToken = req.headers.get('x-system-init-token')?.trim() || '' + if (!initToken) { + const body = await req.json().catch(() => ({})) + if (typeof body?.initToken === 'string') { + initToken = body.initToken.trim() + } + } - const alreadyInitialized = - !result.createdFlags.admin && - !result.createdFlags.department && - !result.createdFlags.category && - !result.createdFlags.scene && - templatesResult.created.length === 0 + if (!isAdminSession(session)) { + if (existingSuperAdmin) { + return NextResponse.json( + { + success: false, + error: '系统已初始化,后续初始化操作需要管理员登录', + }, + { status: 403 } + ) + } + + const configuredInitToken = getSystemInitToken() + if (!configuredInitToken) { + return NextResponse.json( + { + success: false, + error: '未配置 SYSTEM_INIT_TOKEN,禁止公开初始化', + }, + { status: 503 } + ) + } + + if (!initToken || initToken !== configuredInitToken) { + return NextResponse.json( + { + success: false, + error: '初始化令牌无效', + }, + { status: 401 } + ) + } + } + + const result = await initializeSystem() return NextResponse.json({ success: true, - message: alreadyInitialized ? '系统已初始化' : '系统初始化成功', - initialized: true, + message: result.alreadyInitialized ? '系统已初始化' : '系统初始化成功', + initialized: result.initialized, data: { - admin: result.admin ? { - email: result.admin.email, - username: result.admin.username, - password: result.createdFlags.admin ? '(已使用配置的密码)' : '(未创建新账号)' - } : null, - defaultDepartment: result.defaultDepartment.name, - defaultCategory: result.defaultCategory.name, - defaultScene: result.defaultScene.name, - templates: { - total: BUILTIN_TEMPLATE_KEYS.length, - created: templatesResult.created.length, - createdTemplates: templatesResult.created, - errors: templatesResult.errors, - } - } + admin: result.admin + ? { + email: result.admin.email, + username: result.admin.username, + created: result.admin.created, + upgraded: result.admin.upgraded, + mustChangePassword: result.admin.mustChangePassword, + initialPassword: result.admin.passwordSource === 'generated' ? result.admin.initialPassword || null : null, + passwordSource: result.admin.passwordSource || null, + } + : null, + defaultDepartment: result.defaultDepartment, + defaultCategory: result.defaultCategory, + defaultScene: result.defaultScene, + templates: result.templates, + }, }) } catch (error: any) { console.error('系统初始化失败:', error) @@ -140,39 +90,31 @@ export async function POST(_req: NextRequest) { { success: false, error: '系统初始化失败', - details: error.message + details: error.message, }, { status: 500 } ) } } -// 检查系统是否已初始化 export async function GET() { try { - // 优先查找环境变量配置的管理员,如果没有则查找旧的 'admin' - const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com' const admin = await prisma.user.findFirst({ where: { - OR: [ - { email: initAdminEmail }, - { email: 'admin' }, // 向后兼容 - { role: 'ADMIN' } // 或者查找任何管理员账号 - ] - } + OR: [{ isSuperAdmin: true }, { role: 'ADMIN' }], + }, }) const hasDepartment = await prisma.department.findFirst() const hasCategory = await prisma.category.findFirst() const hasScene = await prisma.scene.findFirst() - - // 检查内置模板是否完整 + const builtinTemplateCount = await prisma.uploadTemplate.count({ where: { id: { - in: BUILTIN_TEMPLATE_KEYS.map(key => `builtin-${key}`) - } - } + in: BUILTIN_TEMPLATE_KEYS.map((key) => `builtin-${key}`), + }, + }, }) const hasAllTemplates = builtinTemplateCount === BUILTIN_TEMPLATE_KEYS.length @@ -184,17 +126,17 @@ export async function GET() { hasScene: !!hasScene, hasAllTemplates, templateCount: builtinTemplateCount, - expectedTemplateCount: BUILTIN_TEMPLATE_KEYS.length + expectedTemplateCount: BUILTIN_TEMPLATE_KEYS.length, + publicInitAvailable: !admin && Boolean(getSystemInitToken()), }) } catch (error: any) { console.error('检查初始化状态失败:', error) return NextResponse.json( { initialized: false, - error: error.message + error: error.message, }, { status: 500 } ) } } - diff --git a/app/api/user/change-password/__tests__/route.test.ts b/app/api/user/change-password/__tests__/route.test.ts new file mode 100644 index 0000000..12f721b --- /dev/null +++ b/app/api/user/change-password/__tests__/route.test.ts @@ -0,0 +1,90 @@ +import { NextRequest } from 'next/server' +import { POST } from '../route' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }, +})) + +jest.mock('bcryptjs', () => ({ + compare: jest.fn(), + hash: jest.fn(async (value: string) => `hashed:${value}`), +})) + +jest.mock('@/lib/system-init', () => ({ + isInitialAdminPasswordChangeRequired: jest.fn(), + clearInitialAdminPasswordRequirement: jest.fn(), +})) + +const { getServerSession } = require('next-auth') +const { prisma } = require('@/lib/prisma') +const bcrypt = require('bcryptjs') +const { + isInitialAdminPasswordChangeRequired, + clearInitialAdminPasswordRequirement, +} = require('@/lib/system-init') + +function createRequest(body: Record) { + return new NextRequest('http://localhost/api/user/change-password', { + method: 'POST', + body: JSON.stringify(body), + }) +} + +describe('POST /api/user/change-password', () => { + beforeEach(() => { + jest.resetAllMocks() + getServerSession.mockResolvedValue({ + user: { + email: 'admin@local.flnxi', + }, + }) + prisma.user.findUnique.mockResolvedValue({ + id: 'admin-1', + email: 'admin@local.flnxi', + password: 'old-hash', + }) + isInitialAdminPasswordChangeRequired.mockResolvedValue(false) + bcrypt.compare.mockResolvedValue(false) + }) + + test('拒绝弱新密码且不查询用户密码', async () => { + const response = await POST(createRequest({ + oldPassword: 'old-password', + newPassword: '123456', + })) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('至少') + expect(prisma.user.findUnique).not.toHaveBeenCalled() + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + test('首次登录强制改密时允许省略旧密码,但仍要求强密码', async () => { + isInitialAdminPasswordChangeRequired.mockResolvedValue(true) + + const response = await POST(createRequest({ + newPassword: 'Flnxi-2026-Safe', + })) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(bcrypt.hash).toHaveBeenCalledWith('Flnxi-2026-Safe', 10) + expect(prisma.user.update).toHaveBeenCalled() + expect(clearInitialAdminPasswordRequirement).toHaveBeenCalledWith('admin-1') + }) +}) diff --git a/app/api/user/change-password/route.ts b/app/api/user/change-password/route.ts index 9c88d5b..7bcc576 100644 --- a/app/api/user/change-password/route.ts +++ b/app/api/user/change-password/route.ts @@ -3,6 +3,11 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import bcrypt from 'bcryptjs' +import { + clearInitialAdminPasswordRequirement, + isInitialAdminPasswordChangeRequired, +} from '@/lib/system-init' +import { validatePasswordStrength } from '@/lib/password-policy' // 修改密码 export async function POST(req: NextRequest) { @@ -18,16 +23,17 @@ export async function POST(req: NextRequest) { const { oldPassword, newPassword } = await req.json() - if (!oldPassword || !newPassword) { + if (!newPassword) { return NextResponse.json( - { error: '请提供旧密码和新密码' }, + { error: '请提供新密码' }, { status: 400 } ) } - if (newPassword.length < 6) { + const passwordValidation = validatePasswordStrength(newPassword) + if (!passwordValidation.valid) { return NextResponse.json( - { error: '新密码长度至少为6位' }, + { error: passwordValidation.error || '新密码不符合安全要求' }, { status: 400 } ) } @@ -43,16 +49,26 @@ export async function POST(req: NextRequest) { ) } - // 验证旧密码 - const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password) + const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id) - if (!isOldPasswordValid) { + if (!mustChangePassword && !oldPassword) { return NextResponse.json( - { error: '旧密码错误' }, + { error: '请提供旧密码' }, { status: 400 } ) } + if (!mustChangePassword) { + const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password) + + if (!isOldPasswordValid) { + return NextResponse.json( + { error: '旧密码错误' }, + { status: 400 } + ) + } + } + // 检查新密码是否与旧密码相同 const isSamePassword = await bcrypt.compare(newPassword, user.password) if (isSamePassword) { @@ -69,6 +85,10 @@ export async function POST(req: NextRequest) { data: { password: hashedNewPassword } }) + if (mustChangePassword) { + await clearInitialAdminPasswordRequirement(user.id) + } + return NextResponse.json({ success: true, message: '密码修改成功' diff --git a/app/api/user/check-default-password/route.ts b/app/api/user/check-default-password/route.ts index 9eacc29..bc8bce5 100644 --- a/app/api/user/check-default-password/route.ts +++ b/app/api/user/check-default-password/route.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { isInitialAdminPasswordChangeRequired } from '@/lib/system-init' export const dynamic = 'force-dynamic' -import bcrypt from 'bcryptjs' -// 检查用户是否使用默认密码 +// 兼容旧接口命名:当前检查的是“系统初始化后是否仍要求首次改密” export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions) @@ -29,27 +29,11 @@ export async function GET(req: NextRequest) { ) } - // 只针对初始管理员账号(admin@flnxi.com)检查默认密码 - // 从环境变量读取初始管理员邮箱,如果没有则使用默认值 - const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com' - - // 只有当前用户是初始管理员账号时才检查默认密码 - const isInitialAdmin = user.email === initAdminEmail || user.email === 'admin@flnxi.com' - - if (!isInitialAdmin) { - // 非初始管理员账号,不需要检查默认密码 - return NextResponse.json({ - isDefaultPassword: false, - email: user.email, - username: user.username - }) - } - - // 检查密码是否为默认密码 88888888 - const isDefaultPassword = await bcrypt.compare('88888888', user.password) + const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id) return NextResponse.json({ - isDefaultPassword, + isDefaultPassword: mustChangePassword, + mustChangePassword, email: user.email, username: user.username }) diff --git a/lib/__tests__/license-service.test.ts b/lib/__tests__/license-service.test.ts new file mode 100644 index 0000000..68bc622 --- /dev/null +++ b/lib/__tests__/license-service.test.ts @@ -0,0 +1,427 @@ +/** + * 许可证服务模块测试 + * 测试 lib/license/license-service.ts 中的核心功能 + */ + +// Mock 依赖模块 +jest.mock('@/lib/prisma', () => ({ + prisma: { + systemConfig: { + findUnique: jest.fn(), + upsert: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + user: { + count: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + material: { + count: jest.fn(), + }, + department: { + findFirst: jest.fn(), + create: jest.fn(), + }, + category: { + findFirst: jest.fn(), + create: jest.fn(), + }, + scene: { + findFirst: jest.fn(), + create: jest.fn(), + }, + $transaction: jest.fn((fn) => fn({ + user: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + department: { + findFirst: jest.fn(), + create: jest.fn(), + }, + category: { + findFirst: jest.fn(), + create: jest.fn(), + }, + scene: { + findFirst: jest.fn(), + create: jest.fn(), + }, + })), + }, +})); + +jest.mock('../license/fingerprint-service', () => ({ + generateFingerprint: jest.fn(), + getInstanceId: jest.fn(), +})); + +jest.mock('../license/crypto-service', () => ({ + verifyLicenseSignature: jest.fn(), + encryptLocal: jest.fn((data) => `encrypted:${data}`), + decryptLocal: jest.fn((data) => data.replace('encrypted:', '')), +})); + +jest.mock('@/lib/system-integrity', () => ({ + ensureBuiltinTemplates: jest.fn(), +})); + +// Mock global fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +import { prisma } from '@/lib/prisma'; +import { verifyLicenseSignature } from '../license/crypto-service'; +import { LICENSE_CONFIGS } from '../license/types'; + +const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction; + +function createSignedLicense(overrides: Record = {}) { + const issuedAt = new Date().toISOString(); + + return { + data: { + type: 'FREE', + limits: { maxUsers: 10, maxMaterials: 100 }, + features: { + customTemplates: false, + systemSettings: false, + departmentIsolation: false, + apiAccess: false, + intranetPenetration: false, + }, + expiresAt: null, + issuedAt, + ...overrides, + }, + signature: 'valid-signature', + issuedAt, + }; +} + +function createStoredLocalLicense(overrides: Record = {}) { + const signedLicense = overrides.signedLicense || createSignedLicense(overrides.signedLicenseData || {}); + + return { + instanceId: 'test-instance-id', + licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD', + licenseData: `encrypted:${JSON.stringify(signedLicense)}`, + 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, + }, + signedLicense: undefined, + signedLicenseData: undefined, + ...overrides, + }; +} + +describe('license-service', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockReset(); + mockVerifyLicenseSignature.mockReturnValue(true); + }); + + describe('activateFreeTrial', () => { + beforeEach(() => { + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null); + }); + + it('应该成功激活免费试用版', async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + success: true, + }), + }); + + const { activateFreeTrial } = await import('../license/license-service'); + const result = await activateFreeTrial('测试公司'); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + '/api/license/activate-free', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + ); + }); + + it('服务器注册失败时应该返回失败(必须联网)', async () => { + // Mock 服务器注册失败 + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const { activateFreeTrial } = await import('../license/license-service'); + const result = await activateFreeTrial('测试公司'); + + // 现在必须联网,网络错误应该返回失败 + expect(result.success).toBe(false); + expect(result.error).toBe('网络错误,请检查网络连接后重试'); + }); + + it('服务器返回失败时应该返回错误信息', async () => { + // Mock 服务器返回失败 + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + success: false, + error: '服务器繁忙,请稍后重试' + }), + }); + + const { activateFreeTrial } = await import('../license/license-service'); + const result = await activateFreeTrial('测试公司'); + + expect(result.success).toBe(false); + expect(result.error).toBe('服务器繁忙,请稍后重试'); + }); + + it('应该向本地激活接口发送客户信息', async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + success: true, + }), + }); + + const { activateFreeTrial } = await import('../license/license-service'); + await activateFreeTrial('测试公司', '13800000000'); + + const fetchCall = mockFetch.mock.calls[0]; + if (fetchCall) { + const body = JSON.parse(fetchCall[1].body); + expect(body.customerName).toBe('测试公司'); + expect(body.contactInfo).toBe('13800000000'); + } + }); + }); + + describe('getLicenseType', () => { + it('未激活时应该返回 FREE', async () => { + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null); + + const { getLicenseType } = await import('../license/license-service'); + const type = await getLicenseType(); + + expect(type).toBe('FREE'); + }); + }); + + describe('isFeatureEnabled', () => { + it('未激活时应该返回 false', async () => { + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null); + + const { isFeatureEnabled } = await import('../license/license-service'); + const enabled = await isFeatureEnabled('customTemplates'); + + expect(enabled).toBe(false); + }); + + it('本地许可证过期后应拒绝功能使用', async () => { + const expiredAt = 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: expiredAt, + }, + })), + }); + + 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('materials')).resolves.toEqual({ allowed: false, current: 0, max: 0 }); + }); + + it('业务功能检查每次以本地已验签许可证为准,降级后不继续信旧缓存', async () => { + const proLicense = createStoredLocalLicense({ + signedLicenseData: { + type: 'PRO', + limits: { maxUsers: -1, maxMaterials: -1 }, + features: { + customTemplates: true, + systemSettings: true, + departmentIsolation: true, + apiAccess: true, + intranetPenetration: true, + }, + expiresAt: null, + }, + }); + const freeLicense = createStoredLocalLicense({ + signedLicenseData: { + type: 'FREE', + limits: { maxUsers: 10, maxMaterials: 100 }, + features: { + customTemplates: false, + systemSettings: false, + departmentIsolation: false, + apiAccess: false, + intranetPenetration: false, + }, + expiresAt: null, + }, + }); + + (prisma.systemConfig.findUnique as jest.Mock) + .mockResolvedValueOnce({ key: 'license', value: JSON.stringify(proLicense) }) + .mockResolvedValueOnce({ key: 'license', value: JSON.stringify(freeLicense) }); + + const { isFeatureEnabled } = await import('../license/license-service'); + + await expect(isFeatureEnabled('customTemplates')).resolves.toBe(true); + await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false); + }); + }); + + describe('checkLimit', () => { + it('未激活时应该返回不允许', async () => { + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null); + + const { checkLimit } = await import('../license/license-service'); + const result = await checkLimit('users'); + + expect(result.allowed).toBe(false); + }); + }); + + describe('verify (许可证吊销检测)', () => { + const mockLocalLicense = createStoredLocalLicense(); + + it('许可证被吊销时应该返回 revoked: true', async () => { + // Mock 本地有许可证 + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ + key: 'license', + value: JSON.stringify(mockLocalLicense), + }); + + // Mock 本地验证接口返回已吊销 + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + valid: false, + revoked: true, + error: '许可证已被禁用' + }), + }); + + const { verify } = await import('../license/license-service'); + const result = await verify(); + + expect(result.valid).toBe(false); + expect(result.revoked).toBe(true); + expect(result.error).toBe('许可证已被禁用'); + }); + + it('许可证被删除时应该返回 revoked: true', async () => { + // Mock 本地有许可证 + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ + key: 'license', + value: JSON.stringify(mockLocalLicense), + }); + + // Mock 本地验证接口返回已吊销 + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + valid: false, + revoked: true, + error: '许可证不存在' + }), + }); + + const { verify } = await import('../license/license-service'); + const result = await verify(); + + expect(result.valid).toBe(false); + expect(result.revoked).toBe(true); + expect(result.error).toBe('许可证不存在'); + }); + + it('未找到本地许可证时应该返回错误', async () => { + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null); + + const { verify } = await import('../license/license-service'); + const result = await verify(); + + expect(result.valid).toBe(false); + expect(result.error).toBe('未找到许可证'); + }); + }); + + describe('本地许可证验签收紧', () => { + it('应优先使用验签后的许可证内容,而不是本地 cachedConfig', async () => { + const tamperedLocalLicense = createStoredLocalLicense({ + cachedConfig: { + type: 'PRO', + limits: { maxUsers: -1, maxMaterials: -1 }, + features: { + customTemplates: true, + systemSettings: true, + departmentIsolation: true, + apiAccess: true, + intranetPenetration: true, + }, + expiresAt: null, + }, + }); + + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ + key: 'license', + value: JSON.stringify(tamperedLocalLicense), + }); + + const { getLicenseType, isFeatureEnabled } = await import('../license/license-service'); + + expect(await getLicenseType()).toBe('FREE'); + expect(await isFeatureEnabled('customTemplates')).toBe(false); + }); + + it('本地签名无效时应视为无许可证', async () => { + mockVerifyLicenseSignature.mockReturnValue(false); + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ + key: 'license', + value: JSON.stringify(createStoredLocalLicense()), + }); + + const { getLocalLicense } = await import('../license/license-service'); + const localLicense = await getLocalLicense(); + + expect(localLicense).toBeNull(); + }); + }); +}); + +describe('LICENSE_CONFIGS 免费版配置', () => { + it('FREE 版本应该有正确的限制', () => { + expect(LICENSE_CONFIGS.FREE.limits.maxUsers).toBe(10); + expect(LICENSE_CONFIGS.FREE.limits.maxMaterials).toBe(100); + }); + + it('FREE 版本应该禁用高级功能', () => { + expect(LICENSE_CONFIGS.FREE.features.customTemplates).toBe(false); + expect(LICENSE_CONFIGS.FREE.features.departmentIsolation).toBe(false); + expect(LICENSE_CONFIGS.FREE.features.intranetPenetration).toBe(false); + }); +}); diff --git a/lib/__tests__/password-policy.test.ts b/lib/__tests__/password-policy.test.ts new file mode 100644 index 0000000..eb82554 --- /dev/null +++ b/lib/__tests__/password-policy.test.ts @@ -0,0 +1,24 @@ +import { validatePasswordStrength } from '../password-policy' + +describe('validatePasswordStrength', () => { + test('拒绝空密码、短密码和常见弱密码', () => { + expect(validatePasswordStrength('').valid).toBe(false) + expect(validatePasswordStrength('Ab1!').error).toBe('密码长度至少为 10 位') + expect(validatePasswordStrength('admin123').valid).toBe(false) + }) + + test('拒绝字符类别不足的密码', () => { + const result = validatePasswordStrength('abcdefghij') + + expect(result.valid).toBe(false) + expect(result.error).toContain('至少 3 类') + }) + + test('拒绝全部重复字符', () => { + expect(validatePasswordStrength('!!!!!!!!!!').valid).toBe(false) + }) + + test('允许长度和复杂度达标的密码', () => { + expect(validatePasswordStrength('Flnxi-2026-Safe').valid).toBe(true) + }) +}) diff --git a/lib/__tests__/system-init.test.ts b/lib/__tests__/system-init.test.ts new file mode 100644 index 0000000..b16fa59 --- /dev/null +++ b/lib/__tests__/system-init.test.ts @@ -0,0 +1,165 @@ +jest.mock('@/lib/prisma', () => ({ + prisma: { + systemConfig: { + findUnique: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + $transaction: jest.fn(), + }, +})) + +jest.mock('bcryptjs', () => ({ + hash: jest.fn(async (value: string) => `hashed:${value}`), +})) + +jest.mock('@/lib/system-integrity', () => ({ + BUILTIN_TEMPLATE_KEYS: ['3dmax-material', 'psd-material'], + ensureBuiltinTemplates: jest.fn(), +})) + +import { prisma } from '@/lib/prisma' +import bcrypt from 'bcryptjs' +import { ensureBuiltinTemplates } from '@/lib/system-integrity' +import { + clearInitialAdminPasswordRequirement, + initializeSystem, + isInitialAdminPasswordChangeRequired, +} from '../system-init' + +const mockEnsureBuiltinTemplates = ensureBuiltinTemplates as jest.MockedFunction + +describe('system-init', () => { + beforeEach(() => { + jest.clearAllMocks() + delete process.env.INIT_ADMIN_EMAIL + delete process.env.INIT_ADMIN_PASSWORD + + mockEnsureBuiltinTemplates.mockResolvedValue({ + created: ['builtin-3dmax-material'], + errors: [], + } as never) + }) + + it('未配置初始密码时应生成一次性密码并要求首次改密', async () => { + const tx = { + user: { + findFirst: jest.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null), + update: jest.fn(), + create: jest.fn().mockResolvedValue({ + id: 'admin-1', + email: 'admin@local.flnxi', + username: 'admin', + role: 'ADMIN', + isSuperAdmin: true, + }), + }, + department: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ name: '默认部门' }), + }, + category: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ name: '默认店铺' }), + }, + scene: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ name: '默认场景' }), + }, + } + + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) + ;(prisma.systemConfig.upsert as jest.Mock).mockResolvedValue({}) + + const result = await initializeSystem() + + expect(result.initialized).toBe(true) + expect(result.admin?.created).toBe(true) + expect(result.admin?.mustChangePassword).toBe(true) + expect(result.admin?.email).toBe('admin@local.flnxi') + expect(result.admin?.passwordSource).toBe('generated') + expect(result.admin?.initialPassword).toBeTruthy() + expect(prisma.systemConfig.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: 'initial_admin_state' }, + }) + ) + }) + + it('使用环境变量初始密码时不应把明文密码回传给接口层', async () => { + process.env.INIT_ADMIN_PASSWORD = 'Env-Password-Should-Not-Return' + const tx = { + user: { + findFirst: jest.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null), + update: jest.fn(), + create: jest.fn().mockResolvedValue({ + id: 'admin-1', + email: 'admin@local.flnxi', + username: 'admin', + role: 'ADMIN', + isSuperAdmin: true, + }), + }, + department: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ name: '默认部门' }), + }, + category: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ name: '默认店铺' }), + }, + scene: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ name: '默认场景' }), + }, + } + + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) + ;(prisma.systemConfig.upsert as jest.Mock).mockResolvedValue({}) + + const result = await initializeSystem() + + expect(bcrypt.hash).toHaveBeenCalledWith('Env-Password-Should-Not-Return', 10) + expect(result.admin?.created).toBe(true) + expect(result.admin?.passwordSource).toBe('environment') + expect(result.admin?.initialPassword).toBeUndefined() + }) + + it('应根据 initial_admin_state 判断是否需要首次改密', async () => { + ;(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ + key: 'initial_admin_state', + value: JSON.stringify({ + userId: 'user-1', + email: 'admin@local.flnxi', + mustChangePassword: true, + createdAt: new Date().toISOString(), + }), + }) + + await expect(isInitialAdminPasswordChangeRequired('user-1')).resolves.toBe(true) + await expect(isInitialAdminPasswordChangeRequired('user-2')).resolves.toBe(false) + }) + + it('首次改密完成后应清除 initial_admin_state', async () => { + ;(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ + key: 'initial_admin_state', + value: JSON.stringify({ + userId: 'user-1', + email: 'admin@local.flnxi', + mustChangePassword: true, + createdAt: new Date().toISOString(), + }), + }) + ;(prisma.systemConfig.delete as jest.Mock).mockResolvedValue({}) + + await clearInitialAdminPasswordRequirement('user-1') + + expect(prisma.systemConfig.delete).toHaveBeenCalledWith({ + where: { key: 'initial_admin_state' }, + }) + }) +}) diff --git a/lib/license/fingerprint-service.ts b/lib/license/fingerprint-service.ts index 9eb4262..e60774f 100644 --- a/lib/license/fingerprint-service.ts +++ b/lib/license/fingerprint-service.ts @@ -11,9 +11,22 @@ const INSTANCE_ID_FILE = path.join(process.cwd(), 'data', '.instance-id'); * 获取 machine-id * Linux: /etc/machine-id 或 /var/lib/dbus/machine-id * Windows: 使用 hostname + 网卡信息生成 - * Docker: 使用容器 ID 或挂载的 machine-id + * Docker: 优先使用持久化的标识符,避免容器重建时变化 */ async function getMachineId(): Promise { + // 优先尝试读取持久化的 machine-id 文件(与 instance-id 类似) + const persistentMachineIdFile = path.join(process.cwd(), 'data', '.machine-id'); + try { + if (fs.existsSync(persistentMachineIdFile)) { + const machineId = fs.readFileSync(persistentMachineIdFile, 'utf8').trim(); + if (machineId) { + return machineId; + } + } + } catch { + // 读取失败,继续使用其他方法 + } + // 尝试读取 Linux machine-id const machineIdPaths = [ '/etc/machine-id', @@ -24,6 +37,16 @@ async function getMachineId(): Promise { try { const machineId = fs.readFileSync(filePath, 'utf8').trim(); if (machineId) { + // 保存到持久化文件,以便下次使用 + const dir = path.dirname(persistentMachineIdFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + try { + fs.writeFileSync(persistentMachineIdFile, machineId); + } catch { + // 写入失败不影响使用 + } return machineId; } } catch { @@ -31,18 +54,7 @@ async function getMachineId(): Promise { } } - // 尝试读取 Docker 容器 ID - try { - const cgroupContent = fs.readFileSync('/proc/self/cgroup', 'utf8'); - const match = cgroupContent.match(/docker[/-]([a-f0-9]{64})/); - if (match) { - return match[1].substring(0, 32); - } - } catch { - // 不在 Docker 容器中 - } - - // Windows 或其他系统:使用 hostname + 网卡信息生成 + // Windows 或其他系统:使用 hostname + 网卡信息生成稳定的标识符 const hostname = os.hostname(); const networkInterfaces = os.networkInterfaces(); const macs: string[] = []; @@ -58,7 +70,20 @@ async function getMachineId(): Promise { } const data = `${hostname}-${macs.sort().join('-')}`; - return crypto.createHash('md5').update(data).digest('hex'); + const generatedId = crypto.createHash('md5').update(data).digest('hex'); + + // 保存到持久化文件 + const dir = path.dirname(persistentMachineIdFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + try { + fs.writeFileSync(persistentMachineIdFile, generatedId); + } catch { + // 写入失败不影响使用 + } + + return generatedId; } /** @@ -66,10 +91,10 @@ async function getMachineId(): Promise { */ function getPrimaryMacAddress(): string { const networkInterfaces = os.networkInterfaces(); - + // 优先查找常见的网卡名称 const preferredNames = ['eth0', 'en0', 'ens33', 'enp0s3', 'Ethernet', 'Wi-Fi']; - + for (const name of preferredNames) { const interfaces = networkInterfaces[name]; if (interfaces) { @@ -119,7 +144,7 @@ async function getOrCreateInstanceId(): Promise { // 生成新的实例 ID const instanceId = crypto.randomBytes(16).toString('hex'); fs.writeFileSync(INSTANCE_ID_FILE, instanceId); - + return instanceId; } diff --git a/lib/license/server-config.ts b/lib/license/server-config.ts new file mode 100644 index 0000000..df41f5c --- /dev/null +++ b/lib/license/server-config.ts @@ -0,0 +1,21 @@ +const DEFAULT_LICENSE_SERVER_URL = 'https://license.flnxi.com'; + +export function getLicenseServerUrl(): string { + return process.env.LICENSE_SERVER_URL || DEFAULT_LICENSE_SERVER_URL; +} + +export function getLicenseProjectCode(): string { + const projectCode = process.env.LICENSE_PROJECT_CODE?.trim(); + + if (!projectCode) { + throw new Error('LICENSE_PROJECT_CODE 环境变量未配置'); + } + + return projectCode; +} + +export function buildLicenseServerLicenseApiUrl(action: string): string { + const baseUrl = getLicenseServerUrl(); + const projectCode = encodeURIComponent(getLicenseProjectCode()); + return `${baseUrl}/api/license/${projectCode}/${action}`; +} diff --git a/lib/password-policy.ts b/lib/password-policy.ts new file mode 100644 index 0000000..ea996ae --- /dev/null +++ b/lib/password-policy.ts @@ -0,0 +1,51 @@ +const COMMON_WEAK_PASSWORDS = new Set([ + '123456', + '12345678', + '123456789', + '1234567890', + 'password', + 'password123', + 'admin123', + 'admin888', + '88888888', + 'qwerty123', + 'abc123456', + 'flnxi123', +]) + +export const PASSWORD_POLICY_DESCRIPTION = '密码至少 10 位,并包含大写字母、小写字母、数字、符号中的至少 3 类' + +export function validatePasswordStrength(password: string | undefined | null): { + valid: boolean + error?: string +} { + if (!password) { + return { valid: false, error: '请提供密码' } + } + + if (password.length < 10) { + return { valid: false, error: '密码长度至少为 10 位' } + } + + const normalized = password.toLowerCase() + if (COMMON_WEAK_PASSWORDS.has(normalized)) { + return { valid: false, error: '密码过于常见,请换一个更安全的密码' } + } + + if (/^(.)\1+$/.test(password)) { + return { valid: false, error: '密码不能全部使用同一个字符' } + } + + const categories = [ + /[a-z]/.test(password), + /[A-Z]/.test(password), + /\d/.test(password), + /[^a-zA-Z0-9]/.test(password), + ].filter(Boolean).length + + if (categories < 3) { + return { valid: false, error: PASSWORD_POLICY_DESCRIPTION } + } + + return { valid: true } +} diff --git a/lib/system-init.ts b/lib/system-init.ts new file mode 100644 index 0000000..f217e5c --- /dev/null +++ b/lib/system-init.ts @@ -0,0 +1,280 @@ +import { randomBytes } from 'crypto' +import bcrypt from 'bcryptjs' +import { prisma } from './prisma' +import { BUILTIN_TEMPLATE_KEYS, ensureBuiltinTemplates } from './system-integrity' + +const INITIAL_ADMIN_STATE_KEY = 'initial_admin_state' +const DEFAULT_INIT_ADMIN_EMAIL = 'admin@local.flnxi' + +type PasswordSource = 'environment' | 'generated' + +type InitialAdminState = { + userId: string + email: string + mustChangePassword: boolean + createdAt: string +} + +export type InitialAdminInfo = { + email: string + username: string + created: boolean + upgraded: boolean + mustChangePassword: boolean + initialPassword?: string + passwordSource?: PasswordSource +} + +export type InitializeSystemResult = { + initialized: true + alreadyInitialized: boolean + admin: InitialAdminInfo | null + defaultDepartment: string + defaultCategory: string + defaultScene: string + templates: { + total: number + created: number + createdTemplates: string[] + errors: string[] + } +} + +function generateInitialAdminPassword(): string { + return randomBytes(18).toString('base64url') +} + +export function getInitAdminEmail(): string { + const configuredEmail = process.env.INIT_ADMIN_EMAIL?.trim() + return configuredEmail || DEFAULT_INIT_ADMIN_EMAIL +} + +export function getSystemInitToken(): string { + return process.env.SYSTEM_INIT_TOKEN?.trim() || '' +} + +function resolveInitialAdminPassword(): { + password: string + source: PasswordSource +} { + const configuredPassword = process.env.INIT_ADMIN_PASSWORD?.trim() + if (configuredPassword) { + return { + password: configuredPassword, + source: 'environment', + } + } + + return { + password: generateInitialAdminPassword(), + source: 'generated', + } +} + +async function saveInitialAdminState(userId: string, email: string) { + const payload: InitialAdminState = { + userId, + email, + mustChangePassword: true, + createdAt: new Date().toISOString(), + } + + await prisma.systemConfig.upsert({ + where: { key: INITIAL_ADMIN_STATE_KEY }, + update: { value: JSON.stringify(payload) }, + create: { + key: INITIAL_ADMIN_STATE_KEY, + value: JSON.stringify(payload), + }, + }) +} + +export async function getInitialAdminState(): Promise { + try { + const config = await prisma.systemConfig.findUnique({ + where: { key: INITIAL_ADMIN_STATE_KEY }, + }) + + if (!config) { + return null + } + + const parsed = JSON.parse(config.value) as InitialAdminState + if ( + !parsed || + typeof parsed.userId !== 'string' || + typeof parsed.email !== 'string' || + typeof parsed.mustChangePassword !== 'boolean' + ) { + return null + } + + return parsed + } catch (error) { + console.warn('[SystemInit] 读取初始管理员状态失败:', error) + return null + } +} + +export async function isInitialAdminPasswordChangeRequired(userId: string): Promise { + const state = await getInitialAdminState() + return Boolean(state && state.userId === userId && state.mustChangePassword) +} + +export async function clearInitialAdminPasswordRequirement(userId: string): Promise { + const state = await getInitialAdminState() + if (!state || state.userId !== userId) { + return + } + + try { + await prisma.systemConfig.delete({ + where: { key: INITIAL_ADMIN_STATE_KEY }, + }) + } catch (error) { + console.warn('[SystemInit] 清除初始管理员改密标记失败:', error) + } +} + +export async function initializeSystem(): Promise { + const initAdminEmail = getInitAdminEmail() + const initAdminUsername = initAdminEmail.split('@')[0] || 'admin' + + const result = await prisma.$transaction(async (tx) => { + let adminCreated = false + let adminUpgraded = false + let initialPassword: string | null = null + let passwordSource: PasswordSource | null = null + + let admin = await tx.user.findFirst({ + where: { isSuperAdmin: true }, + }) + + if (!admin) { + admin = await tx.user.findFirst({ + where: { + OR: [ + { email: initAdminEmail }, + { email: 'admin' }, + { username: initAdminUsername }, + ], + }, + }) + + if (admin) { + if (!admin.isSuperAdmin || admin.role !== 'ADMIN') { + admin = await tx.user.update({ + where: { id: admin.id }, + data: { isSuperAdmin: true, role: 'ADMIN' }, + }) + adminUpgraded = true + } + } else { + const resolvedPassword = resolveInitialAdminPassword() + initialPassword = resolvedPassword.password + passwordSource = resolvedPassword.source + + const hashedPassword = await bcrypt.hash(resolvedPassword.password, 10) + admin = await tx.user.create({ + data: { + email: initAdminEmail, + username: initAdminUsername, + password: hashedPassword, + role: 'ADMIN', + isSuperAdmin: true, + isActive: true, + canDownload: true, + department: '系统管理', + }, + }) + adminCreated = true + } + } + + const ensureDepartment = async () => { + const existing = await tx.department.findFirst({ + where: { name: '默认部门' }, + }) + if (existing) return { entity: existing, created: false } + const created = await tx.department.create({ data: { name: '默认部门' } }) + return { entity: created, created: true } + } + + const ensureCategory = async () => { + const existing = await tx.category.findFirst({ + where: { name: '默认店铺' }, + }) + if (existing) return { entity: existing, created: false } + const created = await tx.category.create({ data: { name: '默认店铺' } }) + return { entity: created, created: true } + } + + const ensureScene = async () => { + const existing = await tx.scene.findFirst({ + where: { name: '默认场景' }, + }) + if (existing) return { entity: existing, created: false } + const created = await tx.scene.create({ data: { name: '默认场景' } }) + return { entity: created, created: true } + } + + const departmentResult = await ensureDepartment() + const categoryResult = await ensureCategory() + const sceneResult = await ensureScene() + + return { + admin, + initialPassword, + passwordSource, + createdFlags: { + admin: adminCreated, + adminUpgraded, + department: departmentResult.created, + category: categoryResult.created, + scene: sceneResult.created, + }, + defaultDepartment: departmentResult.entity.name, + defaultCategory: categoryResult.entity.name, + defaultScene: sceneResult.entity.name, + } + }) + + if (result.createdFlags.admin && result.admin) { + await saveInitialAdminState(result.admin.id, result.admin.email) + } + + const templatesResult = await ensureBuiltinTemplates() + + const alreadyInitialized = + !result.createdFlags.admin && + !result.createdFlags.adminUpgraded && + !result.createdFlags.department && + !result.createdFlags.category && + !result.createdFlags.scene && + templatesResult.created.length === 0 + + return { + initialized: true, + alreadyInitialized, + admin: result.admin + ? { + email: result.admin.email, + username: result.admin.username, + created: result.createdFlags.admin, + upgraded: result.createdFlags.adminUpgraded, + mustChangePassword: result.createdFlags.admin, + initialPassword: result.passwordSource === 'generated' ? result.initialPassword || undefined : undefined, + passwordSource: result.passwordSource || undefined, + } + : null, + defaultDepartment: result.defaultDepartment, + defaultCategory: result.defaultCategory, + defaultScene: result.defaultScene, + templates: { + total: BUILTIN_TEMPLATE_KEYS.length, + created: templatesResult.created.length, + createdTemplates: templatesResult.created, + errors: templatesResult.errors, + }, + } +}