收紧许可证激活与初始化安全
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
|
APP_PUBLIC_URL=https://your-domain.example.com
|
||||||
NEXTAUTH_URL=https://your-domain.example.com
|
NEXTAUTH_URL=https://your-domain.example.com
|
||||||
NEXTAUTH_SECRET=replace-with-strong-secret
|
NEXTAUTH_SECRET=replace-with-strong-secret
|
||||||
|
LICENSE_SERVER_URL=https://your-license-server.example.com
|
||||||
|
LICENSE_PROJECT_CODE=flnxi-sucai
|
||||||
|
|
||||||
# Database settings
|
# Database settings
|
||||||
DATABASE_URL=postgresql://username:password@host:5432/database
|
DATABASE_URL=postgresql://username:password@host:5432/database
|
||||||
@ -41,6 +43,17 @@ PUBLIC_DOMAINS=portal.example.com
|
|||||||
ALLOWED_ORIGINS=https://portal.example.com
|
ALLOWED_ORIGINS=https://portal.example.com
|
||||||
IMAGE_REMOTE_HOSTS=s3.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)
|
# AI model (optional)
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
GEMINI_API_BASE_URL=https://api.huandutech.com/v1
|
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; \
|
rm -rf ./node_modules/canvas; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 创建上传目录并设置权限
|
# 创建上传目录和数据目录并设置权限
|
||||||
RUN mkdir -p /app/uploads && \
|
# - uploads: 用户上传的文件
|
||||||
chown -R nextjs:nodejs /app/uploads && \
|
# - data: 许可证实例 ID 等持久化数据
|
||||||
chmod -R 755 /app/uploads
|
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
|
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 { prisma } from '@/lib/prisma'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { canCreateUser } from '@/lib/license'
|
import { canCreateUser } from '@/lib/license'
|
||||||
|
import { validatePasswordStrength } from '@/lib/password-policy'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
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 (settings && !settings.enableRegistration) {
|
||||||
if (!inviteCode) {
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { activateFreeTrial } 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';
|
||||||
|
|
||||||
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 {
|
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) {
|
||||||
|
|
||||||
// 设置许可证激活 cookie(用于 middleware 快速检查)
|
|
||||||
response.cookies.set('license_activated', 'true', {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 365 * 24 * 60 * 60, // 1年
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: result.error },
|
{ success: false, error: '客户名称不能为空' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!contactInfo || contactInfo.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '联系方式不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
|
||||||
|
const fingerprint = await generateFingerprint();
|
||||||
|
|
||||||
|
// 确保指纹有效
|
||||||
|
if (!fingerprint.machineId || !fingerprint.instanceId) {
|
||||||
|
console.error('[License] Fingerprint验证失败:', fingerprint);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '系统信息获取失败,请重试' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保macAddress不是无效值
|
||||||
|
if (fingerprint.macAddress === '00:00:00:00:00:00') {
|
||||||
|
console.warn('[License] MAC地址无效,使用备用值');
|
||||||
|
fingerprint.macAddress = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
|
||||||
|
const response = await fetch(buildLicenseServerLicenseApiUrl('register-free'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fingerprint,
|
||||||
|
customerName,
|
||||||
|
contactInfo,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = '激活失败,请检查网络连接';
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMessage = errorData.error || errorMessage;
|
||||||
|
console.error('[License] 免费版注册失败:', sanitizeLicenseServerLogPayload(errorData));
|
||||||
|
} catch {
|
||||||
|
errorMessage = response.statusText || errorMessage;
|
||||||
|
console.error('[License] 免费版注册失败:', response.status, response.statusText);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: errorMessage },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
console.error('[License] 免费版注册失败:', sanitizeLicenseServerLogPayload(data));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: data.error || '激活失败,请检查网络连接' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activationCode = data.data?.activationCode;
|
||||||
|
const signedLicense = data.license;
|
||||||
|
|
||||||
|
if (!activationCode || !signedLicense?.data || !signedLicense?.signature) {
|
||||||
|
console.error('[License] 免费版注册返回数据不完整:', sanitizeLicenseServerLogPayload(data));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '许可证服务返回数据异常,请稍后重试' },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 免费版也必须使用许可证服务端签名,避免保存不可验证的本地许可证
|
||||||
|
if (!verifyLicenseSignature(signedLicense.data, signedLicense.signature)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '许可证签名验证失败' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[License] 免费版激活码:', maskSecret(activationCode), data.data.isExisting ? '(已存在)' : '(新注册)');
|
||||||
|
|
||||||
|
// ✅ 在后端保存许可证
|
||||||
|
await saveLicenseLocally(activationCode, signedLicense);
|
||||||
|
|
||||||
|
// 初始化系统(创建默认管理员、部门、店铺等)
|
||||||
|
const initResult = await initializeSystem();
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
startHeartbeat();
|
||||||
|
|
||||||
|
const result = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
initialAdmin: initResult.admin?.created
|
||||||
|
? {
|
||||||
|
email: initResult.admin.email,
|
||||||
|
username: initResult.admin.username,
|
||||||
|
password: initResult.admin.passwordSource === 'generated' ? initResult.admin.initialPassword || null : null,
|
||||||
|
mustChangePassword: initResult.admin.mustChangePassword,
|
||||||
|
passwordSource: initResult.admin.passwordSource || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动检测协议:根据请求的协议决定 cookie 的 secure 属性
|
||||||
|
const isHttps = request.headers.get('x-forwarded-proto') === 'https'
|
||||||
|
|| request.url.startsWith('https://');
|
||||||
|
|
||||||
|
// 设置许可证激活 cookie(用于 middleware 快速检查)
|
||||||
|
result.cookies.set('license_activated', 'true', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isHttps,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 365 * 24 * 60 * 60, // 1年
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('免费试用激活失败:', error);
|
console.error('免费试用激活失败:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,191 +1,206 @@
|
|||||||
/**
|
import { NextRequest } from 'next/server'
|
||||||
* 许可证激活 API 路由测试
|
import { POST } from '../route'
|
||||||
* 测试 app/api/license/activate/route.ts
|
import { verifyLicenseSignature } from '@/lib/license/crypto-service'
|
||||||
*/
|
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'
|
||||||
import { NextRequest } from 'next/server';
|
|
||||||
import { POST } from '../route';
|
|
||||||
|
|
||||||
// Mock lib/license 模块
|
jest.mock('@/lib/license/fingerprint-service', () => ({
|
||||||
jest.mock('@/lib/license', () => ({
|
generateFingerprint: jest.fn().mockResolvedValue({
|
||||||
activate: jest.fn(),
|
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', () => {
|
describe('POST /api/license/activate', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks()
|
||||||
});
|
global.fetch = jest.fn()
|
||||||
|
mockVerifyLicenseSignature.mockReturnValue(true)
|
||||||
|
mockInitializeSystem.mockResolvedValue({
|
||||||
|
admin: {
|
||||||
|
created: false,
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
})
|
||||||
|
|
||||||
describe('请求验证', () => {
|
test('缺少激活码时应返回 400', async () => {
|
||||||
it('缺少 licenseKey 时应返回 400 错误', async () => {
|
const response = await POST(createMockRequest({ body: { licenseKey: '' } }))
|
||||||
const request = new NextRequest('http://localhost/api/license/activate', {
|
const data = await response.json()
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await POST(request);
|
expect(response.status).toBe(400)
|
||||||
const data = await response.json();
|
expect(data.success).toBe(false)
|
||||||
|
expect(data.error).toBe('请输入激活码')
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
test('激活成功时保存签名许可证并设置 cookie', async () => {
|
||||||
expect(data.success).toBe(false);
|
mockActivateSuccess()
|
||||||
expect(data.error).toBe('请输入许可证密钥');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('licenseKey 为空字符串时应返回 400 错误', async () => {
|
const response = await POST(createMockRequest())
|
||||||
const request = new NextRequest('http://localhost/api/license/activate', {
|
const data = await response.json()
|
||||||
method: 'POST',
|
const setCookieHeader = response.headers.get('set-cookie')
|
||||||
body: JSON.stringify({ licenseKey: '' }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await POST(request);
|
expect(response.status).toBe(200)
|
||||||
const data = await response.json();
|
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);
|
test('环境变量来源的初始管理员密码不应在激活响应中回显', async () => {
|
||||||
expect(data.success).toBe(false);
|
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('激活成功', () => {
|
const response = await POST(createMockRequest())
|
||||||
it('激活成功时应返回 success: true', async () => {
|
const data = await response.json()
|
||||||
mockActivate.mockResolvedValueOnce({ success: true });
|
|
||||||
|
|
||||||
const request = new NextRequest('http://localhost/api/license/activate', {
|
expect(response.status).toBe(200)
|
||||||
method: 'POST',
|
expect(data.initialAdmin).toEqual({
|
||||||
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
|
email: 'admin@local.flnxi',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
username: 'admin',
|
||||||
});
|
password: null,
|
||||||
|
mustChangePassword: true,
|
||||||
|
passwordSource: 'environment',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const response = await POST(request);
|
test('系统生成的一次性初始管理员密码仍只在首次激活响应中返回', async () => {
|
||||||
const data = await response.json();
|
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);
|
const response = await POST(createMockRequest())
|
||||||
expect(data.success).toBe(true);
|
const data = await response.json()
|
||||||
expect(mockActivate).toHaveBeenCalledWith('ABCD-1234-EFGH-5678');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('激活成功时应设置 license_activated cookie', async () => {
|
expect(response.status).toBe(200)
|
||||||
mockActivate.mockResolvedValueOnce({ success: true });
|
expect(data.initialAdmin.password).toBe('Generated-Password-123')
|
||||||
|
expect(data.initialAdmin.passwordSource).toBe('generated')
|
||||||
|
})
|
||||||
|
|
||||||
const request = new NextRequest('http://localhost/api/license/activate', {
|
test('许可证服务器拒绝或签名验证失败时不保存本地许可证', async () => {
|
||||||
method: 'POST',
|
mockActivateSuccess({
|
||||||
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
|
success: false,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
error: '许可证无效',
|
||||||
});
|
license: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const response = await POST(request);
|
const rejectedResponse = await POST(createMockRequest())
|
||||||
const setCookieHeader = response.headers.get('set-cookie');
|
const rejectedData = await rejectedResponse.json()
|
||||||
|
|
||||||
expect(setCookieHeader).toBeTruthy();
|
expect(rejectedResponse.status).toBe(400)
|
||||||
expect(setCookieHeader).toContain('license_activated=true');
|
expect(rejectedData.error).toBe('许可证无效')
|
||||||
expect(setCookieHeader).toContain('HttpOnly');
|
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
|
||||||
expect(setCookieHeader).toContain('Path=/');
|
|
||||||
// SameSite 值可能是 Lax 或 lax(大小写不敏感)
|
|
||||||
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cookie 应设置 1 年有效期', async () => {
|
mockActivateSuccess()
|
||||||
mockActivate.mockResolvedValueOnce({ success: true });
|
mockVerifyLicenseSignature.mockReturnValueOnce(false)
|
||||||
|
|
||||||
const request = new NextRequest('http://localhost/api/license/activate', {
|
const invalidSignatureResponse = await POST(createMockRequest())
|
||||||
method: 'POST',
|
const invalidSignatureData = await invalidSignatureResponse.json()
|
||||||
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await POST(request);
|
expect(invalidSignatureResponse.status).toBe(400)
|
||||||
const setCookieHeader = response.headers.get('set-cookie');
|
expect(invalidSignatureData.error).toBe('许可证签名验证失败')
|
||||||
|
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
// 1年 = 365 * 24 * 60 * 60 = 31536000 秒
|
test('HTTPS 或代理 HTTPS 时 cookie 带 Secure', async () => {
|
||||||
expect(setCookieHeader).toContain('Max-Age=31536000');
|
mockActivateSuccess()
|
||||||
});
|
const httpsResponse = await POST(createMockRequest({
|
||||||
});
|
url: 'https://example.com/api/license/activate',
|
||||||
|
}))
|
||||||
|
expect(httpsResponse.headers.get('set-cookie')).toContain('Secure')
|
||||||
|
|
||||||
describe('激活失败', () => {
|
mockActivateSuccess()
|
||||||
it('许可证无效时应返回 400 错误', async () => {
|
const forwardedHttpsResponse = await POST(createMockRequest({
|
||||||
mockActivate.mockResolvedValueOnce({ success: false, error: '许可证无效' });
|
url: 'http://localhost:3000/api/license/activate',
|
||||||
|
headers: { 'x-forwarded-proto': 'https' },
|
||||||
const request = new NextRequest('http://localhost/api/license/activate', {
|
}))
|
||||||
method: 'POST',
|
expect(forwardedHttpsResponse.headers.get('set-cookie')).toContain('Secure')
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,38 +1,92 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { licenseKey } = await request.json();
|
const body = await request.json();
|
||||||
|
// 支持两种参数名:activationCode(新)和 licenseKey(旧)
|
||||||
|
const licenseKey = body.activationCode || body.licenseKey;
|
||||||
|
|
||||||
if (!licenseKey) {
|
if (!licenseKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: '请输入许可证密钥' },
|
{ success: false, error: '请输入激活码' },
|
||||||
{ status: 400 }
|
{ 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',
|
||||||
// 设置许可证激活 cookie(用于 middleware 快速检查)
|
headers: { 'Content-Type': 'application/json' },
|
||||||
response.cookies.set('license_activated', 'true', {
|
body: JSON.stringify({
|
||||||
httpOnly: true,
|
licenseKey,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
fingerprint,
|
||||||
sameSite: 'lax',
|
timestamp: Date.now(),
|
||||||
maxAge: 365 * 24 * 60 * 60, // 1年
|
}),
|
||||||
path: '/',
|
});
|
||||||
});
|
|
||||||
|
const data: ActivateResponse = await response.json();
|
||||||
return response;
|
|
||||||
} else {
|
if (!data.success || !data.license) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: result.error },
|
{ success: false, error: data.error || '激活失败' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 在后端验证签名
|
||||||
|
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '许可证签名验证失败' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 在后端保存许可证
|
||||||
|
await saveLicenseLocally(licenseKey, data.license);
|
||||||
|
|
||||||
|
// 初始化系统(创建默认管理员、部门、店铺等)
|
||||||
|
const initResult = await initializeSystem();
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
startHeartbeat();
|
||||||
|
|
||||||
|
const result = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
initialAdmin: initResult.admin?.created
|
||||||
|
? {
|
||||||
|
email: initResult.admin.email,
|
||||||
|
username: initResult.admin.username,
|
||||||
|
password: initResult.admin.passwordSource === 'generated' ? initResult.admin.initialPassword || null : null,
|
||||||
|
mustChangePassword: initResult.admin.mustChangePassword,
|
||||||
|
passwordSource: initResult.admin.passwordSource || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动检测协议:根据请求的协议决定 cookie 的 secure 属性
|
||||||
|
// - HTTP 请求:secure = false(内网部署)
|
||||||
|
// - HTTPS 请求:secure = true(Pro 版外网穿透)
|
||||||
|
const isHttps = request.headers.get('x-forwarded-proto') === 'https'
|
||||||
|
|| request.url.startsWith('https://');
|
||||||
|
|
||||||
|
// 设置许可证激活 cookie(用于 middleware 快速检查)
|
||||||
|
result.cookies.set('license_activated', 'true', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isHttps,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 365 * 24 * 60 * 60, // 1年
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('激活失败:', error);
|
console.error('激活失败:', error);
|
||||||
return NextResponse.json(
|
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 复制,用于测试验证)
|
// 内置模板配置(从 route.ts 复制,用于测试验证)
|
||||||
const BUILTIN_TEMPLATE_KEYS = [
|
const BUILTIN_TEMPLATE_KEYS = [
|
||||||
'3dmax-material',
|
'3dmax-material',
|
||||||
|
|||||||
@ -1,138 +1,88 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
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 { prisma } from '@/lib/prisma'
|
||||||
import bcrypt from 'bcryptjs'
|
import { BUILTIN_TEMPLATE_KEYS } from '@/lib/system-integrity'
|
||||||
import {
|
import { getSystemInitToken, initializeSystem } from '@/lib/system-init'
|
||||||
BUILTIN_TEMPLATE_KEYS,
|
|
||||||
ensureBuiltinTemplates,
|
|
||||||
} from '@/lib/system-integrity'
|
|
||||||
|
|
||||||
// 系统初始化:创建默认部门、店铺、管理员账户、内置模板
|
function isAdminSession(session: Session | null) {
|
||||||
export async function POST(_req: NextRequest) {
|
const user = session?.user
|
||||||
|
return Boolean(user && (user.role === 'ADMIN' || user.isSuperAdmin))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// 从环境变量读取初始管理员配置,如果没有则使用默认值(向后兼容)
|
const session = await getServerSession(authOptions)
|
||||||
const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com'
|
const existingSuperAdmin = await prisma.user.findFirst({
|
||||||
const initAdminPassword = process.env.INIT_ADMIN_PASSWORD || '88888888'
|
where: { isSuperAdmin: true },
|
||||||
const initAdminUsername = initAdminEmail.split('@')[0] // 从邮箱提取用户名
|
select: { id: true },
|
||||||
|
|
||||||
// 使用事务确保所有数据都创建成功或已存在
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
|
||||||
// 1. 确保默认管理员账户存在
|
|
||||||
// 优先查找环境变量配置的邮箱,如果没有则查找旧的 'admin'
|
|
||||||
let admin = await tx.user.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ email: initAdminEmail },
|
|
||||||
{ email: 'admin' } // 向后兼容旧配置
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let adminCreated = false
|
|
||||||
if (!admin) {
|
|
||||||
// 检查是否已有任何用户(避免在已有用户的情况下创建默认管理员)
|
|
||||||
const userCount = await tx.user.count()
|
|
||||||
if (userCount === 0) {
|
|
||||||
// 只有在没有任何用户时才创建初始管理员
|
|
||||||
const hashedPassword = await bcrypt.hash(initAdminPassword, 10)
|
|
||||||
admin = await tx.user.create({
|
|
||||||
data: {
|
|
||||||
email: initAdminEmail,
|
|
||||||
username: initAdminUsername,
|
|
||||||
password: hashedPassword,
|
|
||||||
role: 'ADMIN',
|
|
||||||
isSuperAdmin: true, // 初始管理员设为超级管理员
|
|
||||||
isActive: true,
|
|
||||||
canDownload: true,
|
|
||||||
department: '系统管理'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
adminCreated = true
|
|
||||||
} else {
|
|
||||||
// 如果已有用户,返回第一个管理员用户(如果有)
|
|
||||||
admin = await tx.user.findFirst({
|
|
||||||
where: { role: 'ADMIN' }
|
|
||||||
}) || await tx.user.findFirst()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureDepartment = async () => {
|
|
||||||
const existing = await tx.department.findFirst({ where: { name: '默认部门' } })
|
|
||||||
if (existing) return { entity: existing, created: false }
|
|
||||||
const created = await tx.department.create({ data: { name: '默认部门' } })
|
|
||||||
return { entity: created, created: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureCategory = async () => {
|
|
||||||
const existing = await tx.category.findFirst({ where: { name: '默认店铺' } })
|
|
||||||
if (existing) return { entity: existing, created: false }
|
|
||||||
const created = await tx.category.create({ data: { name: '默认店铺' } })
|
|
||||||
return { entity: created, created: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureScene = async () => {
|
|
||||||
const existing = await tx.scene.findFirst({ where: { name: '默认场景' } })
|
|
||||||
if (existing) return { entity: existing, created: false }
|
|
||||||
const created = await tx.scene.create({ data: { name: '默认场景' } })
|
|
||||||
return { entity: created, created: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const departmentResult = await ensureDepartment()
|
|
||||||
const categoryResult = await ensureCategory()
|
|
||||||
const sceneResult = await ensureScene()
|
|
||||||
|
|
||||||
// 注意:内置模板在事务外创建,因为 ensureBuiltinTemplates 有自己的事务处理
|
|
||||||
// 这里只返回占位结果,实际创建在事务后执行
|
|
||||||
const templatesPlaceholder = {
|
|
||||||
total: BUILTIN_TEMPLATE_KEYS.length,
|
|
||||||
created: 0,
|
|
||||||
createdTemplates: [] as string[],
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
admin,
|
|
||||||
defaultDepartment: departmentResult.entity,
|
|
||||||
defaultCategory: categoryResult.entity,
|
|
||||||
defaultScene: sceneResult.entity,
|
|
||||||
templates: templatesPlaceholder,
|
|
||||||
createdFlags: {
|
|
||||||
admin: adminCreated,
|
|
||||||
department: departmentResult.created,
|
|
||||||
category: categoryResult.created,
|
|
||||||
scene: sceneResult.created,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 在事务外创建内置模板(使用统一的模块)
|
let initToken = req.headers.get('x-system-init-token')?.trim() || ''
|
||||||
const templatesResult = await ensureBuiltinTemplates()
|
if (!initToken) {
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
if (typeof body?.initToken === 'string') {
|
||||||
|
initToken = body.initToken.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const alreadyInitialized =
|
if (!isAdminSession(session)) {
|
||||||
!result.createdFlags.admin &&
|
if (existingSuperAdmin) {
|
||||||
!result.createdFlags.department &&
|
return NextResponse.json(
|
||||||
!result.createdFlags.category &&
|
{
|
||||||
!result.createdFlags.scene &&
|
success: false,
|
||||||
templatesResult.created.length === 0
|
error: '系统已初始化,后续初始化操作需要管理员登录',
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredInitToken = getSystemInitToken()
|
||||||
|
if (!configuredInitToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: '未配置 SYSTEM_INIT_TOKEN,禁止公开初始化',
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initToken || initToken !== configuredInitToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: '初始化令牌无效',
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await initializeSystem()
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: alreadyInitialized ? '系统已初始化' : '系统初始化成功',
|
message: result.alreadyInitialized ? '系统已初始化' : '系统初始化成功',
|
||||||
initialized: true,
|
initialized: result.initialized,
|
||||||
data: {
|
data: {
|
||||||
admin: result.admin ? {
|
admin: result.admin
|
||||||
email: result.admin.email,
|
? {
|
||||||
username: result.admin.username,
|
email: result.admin.email,
|
||||||
password: result.createdFlags.admin ? '(已使用配置的密码)' : '(未创建新账号)'
|
username: result.admin.username,
|
||||||
} : null,
|
created: result.admin.created,
|
||||||
defaultDepartment: result.defaultDepartment.name,
|
upgraded: result.admin.upgraded,
|
||||||
defaultCategory: result.defaultCategory.name,
|
mustChangePassword: result.admin.mustChangePassword,
|
||||||
defaultScene: result.defaultScene.name,
|
initialPassword: result.admin.passwordSource === 'generated' ? result.admin.initialPassword || null : null,
|
||||||
templates: {
|
passwordSource: result.admin.passwordSource || null,
|
||||||
total: BUILTIN_TEMPLATE_KEYS.length,
|
}
|
||||||
created: templatesResult.created.length,
|
: null,
|
||||||
createdTemplates: templatesResult.created,
|
defaultDepartment: result.defaultDepartment,
|
||||||
errors: templatesResult.errors,
|
defaultCategory: result.defaultCategory,
|
||||||
}
|
defaultScene: result.defaultScene,
|
||||||
}
|
templates: result.templates,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('系统初始化失败:', error)
|
console.error('系统初始化失败:', error)
|
||||||
@ -140,39 +90,31 @@ export async function POST(_req: NextRequest) {
|
|||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: '系统初始化失败',
|
error: '系统初始化失败',
|
||||||
details: error.message
|
details: error.message,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查系统是否已初始化
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// 优先查找环境变量配置的管理员,如果没有则查找旧的 'admin'
|
|
||||||
const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com'
|
|
||||||
const admin = await prisma.user.findFirst({
|
const admin = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [{ isSuperAdmin: true }, { role: 'ADMIN' }],
|
||||||
{ email: initAdminEmail },
|
},
|
||||||
{ email: 'admin' }, // 向后兼容
|
|
||||||
{ role: 'ADMIN' } // 或者查找任何管理员账号
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasDepartment = await prisma.department.findFirst()
|
const hasDepartment = await prisma.department.findFirst()
|
||||||
const hasCategory = await prisma.category.findFirst()
|
const hasCategory = await prisma.category.findFirst()
|
||||||
const hasScene = await prisma.scene.findFirst()
|
const hasScene = await prisma.scene.findFirst()
|
||||||
|
|
||||||
// 检查内置模板是否完整
|
|
||||||
const builtinTemplateCount = await prisma.uploadTemplate.count({
|
const builtinTemplateCount = await prisma.uploadTemplate.count({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
in: BUILTIN_TEMPLATE_KEYS.map(key => `builtin-${key}`)
|
in: BUILTIN_TEMPLATE_KEYS.map((key) => `builtin-${key}`),
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
const hasAllTemplates = builtinTemplateCount === BUILTIN_TEMPLATE_KEYS.length
|
const hasAllTemplates = builtinTemplateCount === BUILTIN_TEMPLATE_KEYS.length
|
||||||
|
|
||||||
@ -184,17 +126,17 @@ export async function GET() {
|
|||||||
hasScene: !!hasScene,
|
hasScene: !!hasScene,
|
||||||
hasAllTemplates,
|
hasAllTemplates,
|
||||||
templateCount: builtinTemplateCount,
|
templateCount: builtinTemplateCount,
|
||||||
expectedTemplateCount: BUILTIN_TEMPLATE_KEYS.length
|
expectedTemplateCount: BUILTIN_TEMPLATE_KEYS.length,
|
||||||
|
publicInitAvailable: !admin && Boolean(getSystemInitToken()),
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('检查初始化状态失败:', error)
|
console.error('检查初始化状态失败:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
initialized: false,
|
initialized: false,
|
||||||
error: error.message
|
error: error.message,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ 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 { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import {
|
||||||
|
clearInitialAdminPasswordRequirement,
|
||||||
|
isInitialAdminPasswordChangeRequired,
|
||||||
|
} from '@/lib/system-init'
|
||||||
|
import { validatePasswordStrength } from '@/lib/password-policy'
|
||||||
|
|
||||||
// 修改密码
|
// 修改密码
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@ -18,16 +23,17 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const { oldPassword, newPassword } = await req.json()
|
const { oldPassword, newPassword } = await req.json()
|
||||||
|
|
||||||
if (!oldPassword || !newPassword) {
|
if (!newPassword) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '请提供旧密码和新密码' },
|
{ error: '请提供新密码' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
const passwordValidation = validatePasswordStrength(newPassword)
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '新密码长度至少为6位' },
|
{ error: passwordValidation.error || '新密码不符合安全要求' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -43,16 +49,26 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证旧密码
|
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||||||
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password)
|
|
||||||
|
|
||||||
if (!isOldPasswordValid) {
|
if (!mustChangePassword && !oldPassword) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '旧密码错误' },
|
{ error: '请提供旧密码' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mustChangePassword) {
|
||||||
|
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password)
|
||||||
|
|
||||||
|
if (!isOldPasswordValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '旧密码错误' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查新密码是否与旧密码相同
|
// 检查新密码是否与旧密码相同
|
||||||
const isSamePassword = await bcrypt.compare(newPassword, user.password)
|
const isSamePassword = await bcrypt.compare(newPassword, user.password)
|
||||||
if (isSamePassword) {
|
if (isSamePassword) {
|
||||||
@ -69,6 +85,10 @@ export async function POST(req: NextRequest) {
|
|||||||
data: { password: hashedNewPassword }
|
data: { password: hashedNewPassword }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (mustChangePassword) {
|
||||||
|
await clearInitialAdminPasswordRequirement(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '密码修改成功'
|
message: '密码修改成功'
|
||||||
|
|||||||
@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { isInitialAdminPasswordChangeRequired } from '@/lib/system-init'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
import bcrypt from 'bcryptjs'
|
|
||||||
|
|
||||||
// 检查用户是否使用默认密码
|
// 兼容旧接口命名:当前检查的是“系统初始化后是否仍要求首次改密”
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
@ -29,27 +29,11 @@ export async function GET(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只针对初始管理员账号(admin@flnxi.com)检查默认密码
|
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||||||
// 从环境变量读取初始管理员邮箱,如果没有则使用默认值
|
|
||||||
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)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
isDefaultPassword,
|
isDefaultPassword: mustChangePassword,
|
||||||
|
mustChangePassword,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username
|
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
|
* 获取 machine-id
|
||||||
* Linux: /etc/machine-id 或 /var/lib/dbus/machine-id
|
* Linux: /etc/machine-id 或 /var/lib/dbus/machine-id
|
||||||
* Windows: 使用 hostname + 网卡信息生成
|
* Windows: 使用 hostname + 网卡信息生成
|
||||||
* Docker: 使用容器 ID 或挂载的 machine-id
|
* Docker: 优先使用持久化的标识符,避免容器重建时变化
|
||||||
*/
|
*/
|
||||||
async function getMachineId(): Promise<string> {
|
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
|
// 尝试读取 Linux machine-id
|
||||||
const machineIdPaths = [
|
const machineIdPaths = [
|
||||||
'/etc/machine-id',
|
'/etc/machine-id',
|
||||||
@ -24,6 +37,16 @@ async function getMachineId(): Promise<string> {
|
|||||||
try {
|
try {
|
||||||
const machineId = fs.readFileSync(filePath, 'utf8').trim();
|
const machineId = fs.readFileSync(filePath, 'utf8').trim();
|
||||||
if (machineId) {
|
if (machineId) {
|
||||||
|
// 保存到持久化文件,以便下次使用
|
||||||
|
const dir = path.dirname(persistentMachineIdFile);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(persistentMachineIdFile, machineId);
|
||||||
|
} catch {
|
||||||
|
// 写入失败不影响使用
|
||||||
|
}
|
||||||
return machineId;
|
return machineId;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -31,18 +54,7 @@ async function getMachineId(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试读取 Docker 容器 ID
|
// Windows 或其他系统:使用 hostname + 网卡信息生成稳定的标识符
|
||||||
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 + 网卡信息生成
|
|
||||||
const hostname = os.hostname();
|
const hostname = os.hostname();
|
||||||
const networkInterfaces = os.networkInterfaces();
|
const networkInterfaces = os.networkInterfaces();
|
||||||
const macs: string[] = [];
|
const macs: string[] = [];
|
||||||
@ -58,7 +70,20 @@ async function getMachineId(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = `${hostname}-${macs.sort().join('-')}`;
|
const data = `${hostname}-${macs.sort().join('-')}`;
|
||||||
return crypto.createHash('md5').update(data).digest('hex');
|
const generatedId = crypto.createHash('md5').update(data).digest('hex');
|
||||||
|
|
||||||
|
// 保存到持久化文件
|
||||||
|
const dir = path.dirname(persistentMachineIdFile);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(persistentMachineIdFile, generatedId);
|
||||||
|
} catch {
|
||||||
|
// 写入失败不影响使用
|
||||||
|
}
|
||||||
|
|
||||||
|
return generatedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,10 +91,10 @@ async function getMachineId(): Promise<string> {
|
|||||||
*/
|
*/
|
||||||
function getPrimaryMacAddress(): string {
|
function getPrimaryMacAddress(): string {
|
||||||
const networkInterfaces = os.networkInterfaces();
|
const networkInterfaces = os.networkInterfaces();
|
||||||
|
|
||||||
// 优先查找常见的网卡名称
|
// 优先查找常见的网卡名称
|
||||||
const preferredNames = ['eth0', 'en0', 'ens33', 'enp0s3', 'Ethernet', 'Wi-Fi'];
|
const preferredNames = ['eth0', 'en0', 'ens33', 'enp0s3', 'Ethernet', 'Wi-Fi'];
|
||||||
|
|
||||||
for (const name of preferredNames) {
|
for (const name of preferredNames) {
|
||||||
const interfaces = networkInterfaces[name];
|
const interfaces = networkInterfaces[name];
|
||||||
if (interfaces) {
|
if (interfaces) {
|
||||||
@ -119,7 +144,7 @@ async function getOrCreateInstanceId(): Promise<string> {
|
|||||||
// 生成新的实例 ID
|
// 生成新的实例 ID
|
||||||
const instanceId = crypto.randomBytes(16).toString('hex');
|
const instanceId = crypto.randomBytes(16).toString('hex');
|
||||||
fs.writeFileSync(INSTANCE_ID_FILE, instanceId);
|
fs.writeFileSync(INSTANCE_ID_FILE, instanceId);
|
||||||
|
|
||||||
return instanceId;
|
return instanceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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