收紧许可证激活与初始化安全
This commit is contained in:
parent
45d1ad3adf
commit
1a8087b406
13
.env.example
13
.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
|
||||
|
||||
10
Dockerfile
10
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
|
||||
|
||||
88
app/api/auth/register/__tests__/route.test.ts
Normal file
88
app/api/auth/register/__tests__/route.test.ts
Normal file
@ -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<string, unknown>) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
289
app/api/license/activate-free/__tests__/route.test.ts
Normal file
289
app/api/license/activate-free/__tests__/route.test.ts
Normal file
@ -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<typeof verifyLicenseSignature>;
|
||||
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>;
|
||||
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>;
|
||||
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>;
|
||||
|
||||
const 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<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
} = {}): 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<string, unknown> = {}) {
|
||||
(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();
|
||||
});
|
||||
});
|
||||
@ -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<string, any>;
|
||||
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 });
|
||||
// 验证必填字段
|
||||
if (!customerName || customerName.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ 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 快速检查)
|
||||
response.cookies.set('license_activated', 'true', {
|
||||
result.cookies.set('license_activated', 'true', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: isHttps,
|
||||
sameSite: 'lax',
|
||||
maxAge: 365 * 24 * 60 * 60, // 1年
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('免费试用激活失败:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -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<typeof activate>;
|
||||
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<typeof verifyLicenseSignature>
|
||||
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>
|
||||
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>
|
||||
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>
|
||||
|
||||
const 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<string, string>
|
||||
body?: Record<string, unknown>
|
||||
} = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
;(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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 });
|
||||
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
|
||||
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: 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 快速检查)
|
||||
response.cookies.set('license_activated', 'true', {
|
||||
result.cookies.set('license_activated', 'true', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: isHttps,
|
||||
sameSite: 'lax',
|
||||
maxAge: 365 * 24 * 60 * 60, // 1年
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('激活失败:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
54
app/api/license/check/__tests__/route.test.ts
Normal file
54
app/api/license/check/__tests__/route.test.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
39
app/api/license/check/route.ts
Normal file
39
app/api/license/check/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
65
app/api/license/heartbeat/route.ts
Normal file
65
app/api/license/heartbeat/route.ts
Normal file
@ -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: '网络错误'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
87
app/api/license/verify/route.ts
Normal file
87
app/api/license/verify/route.ts
Normal file
@ -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: '网络错误' });
|
||||
}
|
||||
}
|
||||
|
||||
109
app/api/system/init/__tests__/route.test.ts
Normal file
109
app/api/system/init/__tests__/route.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
@ -3,6 +3,8 @@
|
||||
* 测试内置模板配置和系统初始化功能
|
||||
*/
|
||||
|
||||
export {}
|
||||
|
||||
// 内置模板配置(从 route.ts 复制,用于测试验证)
|
||||
const BUILTIN_TEMPLATE_KEYS = [
|
||||
'3dmax-material',
|
||||
|
||||
@ -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' } // 向后兼容旧配置
|
||||
]
|
||||
}
|
||||
const session = await getServerSession(authOptions)
|
||||
const existingSuperAdmin = await prisma.user.findFirst({
|
||||
where: { isSuperAdmin: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
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()
|
||||
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 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 }
|
||||
if (!isAdminSession(session)) {
|
||||
if (existingSuperAdmin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '系统已初始化,后续初始化操作需要管理员登录',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
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 configuredInitToken = getSystemInitToken()
|
||||
if (!configuredInitToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '未配置 SYSTEM_INIT_TOKEN,禁止公开初始化',
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
if (!initToken || initToken !== configuredInitToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '初始化令牌无效',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 在事务外创建内置模板(使用统一的模块)
|
||||
const templatesResult = await ensureBuiltinTemplates()
|
||||
|
||||
const alreadyInitialized =
|
||||
!result.createdFlags.admin &&
|
||||
!result.createdFlags.department &&
|
||||
!result.createdFlags.category &&
|
||||
!result.createdFlags.scene &&
|
||||
templatesResult.created.length === 0
|
||||
const result = await initializeSystem()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: alreadyInitialized ? '系统已初始化' : '系统初始化成功',
|
||||
initialized: true,
|
||||
message: result.alreadyInitialized ? '系统已初始化' : '系统初始化成功',
|
||||
initialized: result.initialized,
|
||||
data: {
|
||||
admin: result.admin ? {
|
||||
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,
|
||||
}
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
90
app/api/user/change-password/__tests__/route.test.ts
Normal file
90
app/api/user/change-password/__tests__/route.test.ts
Normal file
@ -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<string, unknown>) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@ -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,7 +49,16 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||||
|
||||
if (!mustChangePassword && !oldPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: '请提供旧密码' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!mustChangePassword) {
|
||||
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password)
|
||||
|
||||
if (!isOldPasswordValid) {
|
||||
@ -52,6 +67,7 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查新密码是否与旧密码相同
|
||||
const isSamePassword = await bcrypt.compare(newPassword, user.password)
|
||||
@ -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: '密码修改成功'
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
427
lib/__tests__/license-service.test.ts
Normal file
427
lib/__tests__/license-service.test.ts
Normal file
@ -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<typeof verifyLicenseSignature>;
|
||||
|
||||
function createSignedLicense(overrides: Record<string, any> = {}) {
|
||||
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<string, any> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
24
lib/__tests__/password-policy.test.ts
Normal file
24
lib/__tests__/password-policy.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
165
lib/__tests__/system-init.test.ts
Normal file
165
lib/__tests__/system-init.test.ts
Normal file
@ -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<typeof ensureBuiltinTemplates>
|
||||
|
||||
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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<string> {
|
||||
// 优先尝试读取持久化的 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<string> {
|
||||
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<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试读取 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<string> {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
21
lib/license/server-config.ts
Normal file
21
lib/license/server-config.ts
Normal file
@ -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}`;
|
||||
}
|
||||
51
lib/password-policy.ts
Normal file
51
lib/password-policy.ts
Normal file
@ -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 }
|
||||
}
|
||||
280
lib/system-init.ts
Normal file
280
lib/system-init.ts
Normal file
@ -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<InitialAdminState | null> {
|
||||
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<boolean> {
|
||||
const state = await getInitialAdminState()
|
||||
return Boolean(state && state.userId === userId && state.mustChangePassword)
|
||||
}
|
||||
|
||||
export async function clearInitialAdminPasswordRequirement(userId: string): Promise<void> {
|
||||
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<InitializeSystemResult> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user