diff --git a/.env.example b/.env.example index 7aab866..e984815 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,11 @@ APP_PORT=4320 MINIO_API_PORT=9000 MINIO_CONSOLE_PORT=9001 +# Upload limits, in bytes. Defaults: single file 10GB, total request 10GB, text fields 2MB. +MAX_UPLOAD_FILE_SIZE_BYTES=10737418240 +MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 +MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 + # Database settings DATABASE_URL=postgresql://username:password@host:5432/database POSTGRES_USER=postgres diff --git a/app/admin/_components/template-manager/__tests__/CustomTemplateSection.test.tsx b/app/admin/_components/template-manager/__tests__/CustomTemplateSection.test.tsx index 26ae95c..360501b 100644 --- a/app/admin/_components/template-manager/__tests__/CustomTemplateSection.test.tsx +++ b/app/admin/_components/template-manager/__tests__/CustomTemplateSection.test.tsx @@ -80,7 +80,7 @@ describe('CustomTemplateSection 授权提示', () => { renderSection(false) expect(screen.getByText('当前许可证未包含自定义模板功能,请联系管理员处理授权')).toBeInTheDocument() - expect(screen.getByTestId('pro-badge')).toBeInTheDocument() + expect(screen.getByText('授权受限')).toBeInTheDocument() fireEvent.click(screen.getByText('授权后可新建')) diff --git a/app/admin/_hooks/usePermissions.ts b/app/admin/_hooks/usePermissions.ts index dba668a..f1562b3 100644 --- a/app/admin/_hooks/usePermissions.ts +++ b/app/admin/_hooks/usePermissions.ts @@ -38,25 +38,15 @@ export default function usePermissions({ [currentUserRecord, sessionUser], ) - const currentRole = (sessionUser?.role ?? currentUserRecord?.role) as - | 'ADMIN' - | 'USER' - | undefined - - const currentIsAdminRole = useMemo( - () => currentIsSuperAdmin || currentRole === 'ADMIN' || currentUserRecord?.role === 'ADMIN', - [currentIsSuperAdmin, currentRole, currentUserRecord?.role], - ) - const currentManagedDepartment = useMemo( () => currentUserRecord?.managedDepartment ?? sessionUser?.managedDepartment, [currentUserRecord?.managedDepartment, sessionUser?.managedDepartment], ) const currentDepartmentScope = useMemo(() => { - const fallback = (currentUserRecord?.department || '').trim() || undefined - return currentManagedDepartment || fallback - }, [currentManagedDepartment, currentUserRecord?.department]) + const trimmed = (currentManagedDepartment || '').trim() + return trimmed || undefined + }, [currentManagedDepartment]) const departmentDescendantsMap = useMemo( () => buildDepartmentDescendants(departments), @@ -69,11 +59,6 @@ export default function usePermissions({ if (currentIsSuperAdmin) return true if (target.isSuperAdmin) return false - const targetDept = (target.department || '').trim() - if (!targetDept && currentIsAdminRole) { - return true - } - if (currentDepartmentScope) { return isDepartmentWithinScope( currentDepartmentScope, @@ -86,7 +71,6 @@ export default function usePermissions({ }, [ currentDepartmentScope, - currentIsAdminRole, currentIsSuperAdmin, currentUserId, departmentDescendantsMap, diff --git a/app/api/admin/material-logs/__tests__/route.test.ts b/app/api/admin/material-logs/__tests__/route.test.ts new file mode 100644 index 0000000..a416b4f --- /dev/null +++ b/app/api/admin/material-logs/__tests__/route.test.ts @@ -0,0 +1,74 @@ +import { NextRequest } from 'next/server' + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('@/lib/admin-permissions', () => ({ + isCurrentSuperAdmin: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + $queryRaw: jest.fn(), + }, +})) + +const { getServerSession } = require('next-auth') +const { isCurrentSuperAdmin } = require('@/lib/admin-permissions') +const { prisma } = require('@/lib/prisma') + +import { GET } from '../route' + +describe('GET /api/admin/material-logs', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('非最高权限账号不能查看素材日志', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + isCurrentSuperAdmin.mockResolvedValue(false) + + const response = await GET(new NextRequest('http://localhost/api/admin/material-logs')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.$queryRaw).not.toHaveBeenCalled() + }) + + it('最高权限账号可以查看素材日志且不执行运行期 DDL', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + isCurrentSuperAdmin.mockResolvedValue(true) + prisma.$queryRaw.mockResolvedValue([ + { + id: 'log-1', + action: 'UPLOAD', + materialId: 'material-1', + userId: 'user-1', + timestamp: new Date('2026-05-28T10:00:00.000Z'), + changes: null, + materialSnapshot: { productModel: '旧素材' }, + username: '张三', + email: 'user@example.com', + productModel: '当前素材', + }, + ]) + + const response = await GET(new NextRequest('http://localhost/api/admin/material-logs')) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.logs).toHaveLength(1) + expect(data.logs[0]).toMatchObject({ + action: '上传素材', + materialName: '当前素材', + username: '张三', + }) + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/api/admin/material-logs/route.ts b/app/api/admin/material-logs/route.ts index 84143f6..c3e958b 100644 --- a/app/api/admin/material-logs/route.ts +++ b/app/api/admin/material-logs/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { ensureMaterialLogsTable } from '@/lib/material-log' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' export const dynamic = 'force-dynamic' @@ -14,12 +14,10 @@ export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any).role !== 'ADMIN') { - return NextResponse.json({ error: '无权限' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可查看素材日志' }, { status: 403 }) } - await ensureMaterialLogsTable() - const { searchParams } = new URL(req.url) const limit = parseInt(searchParams.get('limit') || '100') const offset = parseInt(searchParams.get('offset') || '0') diff --git a/app/api/admin/users/__tests__/organization-permissions.test.ts b/app/api/admin/users/__tests__/organization-permissions.test.ts index 5120a65..2fee368 100644 --- a/app/api/admin/users/__tests__/organization-permissions.test.ts +++ b/app/api/admin/users/__tests__/organization-permissions.test.ts @@ -398,6 +398,33 @@ describe('admin user organization permissions', () => { }) }) + it('最高权限账号把用户降为普通用户时会清空管理部门', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN', email: 'super@example.com' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ id: 'user-2', email: 'user2@example.com', department: '设计部' }) + .mockResolvedValueOnce({ isSuperAdmin: false }) + prisma.user.update.mockResolvedValue({ + id: 'user-2', + email: 'user2@example.com', + username: 'user2', + role: 'USER', + }) + + const response = await setRole( + new NextRequest('http://localhost/api/admin/users/set-role', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', role: 'USER' }), + }) + ) + + expect(response.status).toBe(200) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { role: 'USER', managedDepartment: null }, + }) + }) + it('普通管理员不能通过旧接口启用或禁用用户', async () => { getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) @@ -676,7 +703,7 @@ describe('admin user organization permissions', () => { expect(data.message).toContain('已取消') expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: 'user-2' }, - data: { managedDepartment: null }, + data: { managedDepartment: null, role: 'USER' }, }) }) }) diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 218efd7..c372c8e 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -53,7 +53,7 @@ export async function GET(request: NextRequest) { let scopeWhere: Prisma.UserWhereInput | undefined if (!currentUser.isSuperAdmin) { - const scopeDepartment = (currentUser.managedDepartment || currentUser.department || '').trim() + const scopeDepartment = (currentUser.managedDepartment || '').trim() if (scopeDepartment) { const departments = await prisma.department.findMany({ select: { name: true } }) diff --git a/app/api/admin/users/set-department-manager/route.ts b/app/api/admin/users/set-department-manager/route.ts index 53c9a09..d7c14ab 100644 --- a/app/api/admin/users/set-department-manager/route.ts +++ b/app/api/admin/users/set-department-manager/route.ts @@ -116,7 +116,8 @@ export async function DELETE(req: NextRequest) { const user = await prisma.user.update({ where: { id: userId }, data: { - managedDepartment: null + managedDepartment: null, + role: 'USER', } }) diff --git a/app/api/admin/users/set-role/route.ts b/app/api/admin/users/set-role/route.ts index 1595c04..c8c2b39 100644 --- a/app/api/admin/users/set-role/route.ts +++ b/app/api/admin/users/set-role/route.ts @@ -76,7 +76,9 @@ export async function POST(req: NextRequest) { // 更新用户角色 const updatedUser = await prisma.user.update({ where: { id: userId }, - data: { role }, + data: role === 'USER' + ? { role, managedDepartment: null } + : { role }, }) return NextResponse.json({ diff --git a/app/api/materials/__tests__/template-fields-routes.test.ts b/app/api/materials/__tests__/template-fields-routes.test.ts index 1280ac5..37901b0 100644 --- a/app/api/materials/__tests__/template-fields-routes.test.ts +++ b/app/api/materials/__tests__/template-fields-routes.test.ts @@ -44,6 +44,7 @@ jest.mock('@/lib/image-processor', () => ({ jest.mock('@/lib/audio-processor', () => ({ parseAudioMetadata: jest.fn(), + parseAudioMetadataFromFile: jest.fn(), isAudioFile: jest.fn(() => false), })) @@ -60,7 +61,7 @@ const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { createMaterialLog } = require('@/lib/material-log') const { uploadToS3, uploadStreamToS3 } = require('@/lib/s3') -const { parseAudioMetadata, isAudioFile } = require('@/lib/audio-processor') +const { parseAudioMetadata, parseAudioMetadataFromFile, isAudioFile } = require('@/lib/audio-processor') const { canUploadMaterial } = require('@/lib/license') const { validateTemplateId, getTemplateWithFields } = require('@/lib/template-validator') @@ -123,6 +124,46 @@ function createMockRequest( } as any } +async function createMultipartUploadRequest( + fields: Record, + files: Record +) { + const boundary = `----flnxi-test-${Date.now()}` + const chunks: Buffer[] = [] + const append = (value: string | Buffer) => { + chunks.push(typeof value === 'string' ? Buffer.from(value) : value) + } + + Object.entries(fields).forEach(([key, value]) => { + append(`--${boundary}\r\n`) + append(`Content-Disposition: form-data; name="${key}"\r\n\r\n`) + append(`${value}\r\n`) + }) + + for (const [key, fileOrFiles] of Object.entries(files)) { + const fileList = Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles] + for (const file of fileList) { + append(`--${boundary}\r\n`) + append(`Content-Disposition: form-data; name="${key}"; filename="${file.name}"\r\n`) + append(`Content-Type: ${file.type || 'application/octet-stream'}\r\n\r\n`) + append(Buffer.from(await file.arrayBuffer())) + append('\r\n') + } + } + + append(`--${boundary}--\r\n`) + const body = Buffer.concat(chunks) + + return new NextRequest('http://localhost/api/materials/upload', { + method: 'POST', + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + 'content-length': String(body.length), + }, + body, + }) +} + function buildUploadRequest(overrides: Partial> = {}) { return createMockRequest({ productModel: '稳定材质', @@ -204,6 +245,7 @@ describe('素材模板字段链路测试', () => { uploadToS3.mockResolvedValue('s3://bucket/buffer-upload') uploadStreamToS3.mockResolvedValue('s3://bucket/stream-upload') parseAudioMetadata.mockResolvedValue(null) + parseAudioMetadataFromFile.mockResolvedValue(null) isAudioFile.mockImplementation((mimeType: string) => mimeType.startsWith('audio/')) validateTemplateId.mockResolvedValue({ @@ -339,40 +381,48 @@ describe('素材模板字段链路测试', () => { expect(data.material.zipFileSize).toBe(String(zipFile.size)) }) - test('音频原始素材暂时保留 Buffer 上传以解析时长', async () => { + test('音频原始素材应走临时文件流式上传并从文件解析时长', async () => { const audioFile = createMockFile('demo.mp3', 'audio/mpeg', 'audio-data') - parseAudioMetadata.mockResolvedValueOnce({ + parseAudioMetadataFromFile.mockResolvedValueOnce({ duration: 65, durationFormatted: '01:05', format: 'mp3', }) const response = await uploadMaterial( - buildUploadRequest({ - materialType: '音频', - materialKind: 'AUDIO', - categoryName: '音效', - sceneName: '提示音', - zipFile: audioFile, - templateFields: JSON.stringify({ + await createMultipartUploadRequest( + { productModel: '稳定材质', - customCode: 'CODE-AUDIO', - }), - }) + materialType: '音频', + materialKind: 'AUDIO', + categoryName: '音效', + sceneName: '提示音', + templateId: 'template-1', + templateFields: JSON.stringify({ + productModel: '稳定材质', + customCode: 'CODE-AUDIO', + }), + }, + { + zipFile: audioFile, + } + ) ) expect(response.status).toBe(201) - expect(uploadStreamToS3).not.toHaveBeenCalled() - expect(uploadToS3).toHaveBeenCalledWith( - expect.any(Buffer), + expect(uploadStreamToS3).toHaveBeenCalledWith( + expect.any(Object), expect.stringMatching(/^material-.+\.mp3$/), - 'audio/mpeg' + 'audio/mpeg', + audioFile.size ) - expect(parseAudioMetadata).toHaveBeenCalledWith(expect.any(Buffer), 'audio/mpeg') + expect(uploadToS3).not.toHaveBeenCalled() + expect(parseAudioMetadataFromFile).toHaveBeenCalledWith(expect.stringContaining('flnxi-upload-')) + expect(parseAudioMetadata).not.toHaveBeenCalled() const createArgs = prisma.material.create.mock.calls[0][0] expect(createArgs.data.audioDuration).toBe('01:05') - expect(createArgs.data.audioUrl).toBe('s3://bucket/buffer-upload') + expect(createArgs.data.audioUrl).toBe('s3://bucket/stream-upload') expect(createArgs.data.zipFileSize).toBe(BigInt(audioFile.size)) }) @@ -1000,6 +1050,42 @@ describe('素材模板字段链路测试', () => { ) }) + test('旧 JSON 更新入口拒绝没有管理部门的普通 ADMIN 编辑别人素材', async () => { + getServerSession.mockResolvedValueOnce({ + user: { id: 'admin-no-scope', role: 'ADMIN' }, + }) + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'admin-no-scope', + role: 'ADMIN', + isSuperAdmin: false, + department: '设计部', + managedDepartment: null, + }) + prisma.material.findUnique.mockResolvedValueOnce({ + ...existingMaterialForJsonUpdate, + userId: 'owner-1', + user: { + id: 'owner-1', + department: '设计部', + }, + }) + + const response = await updateMaterialById( + buildJsonUpdateRequest({ + templateFields: { + productModel: '更新后的产品', + customCode: 'ADMIN-NO-SCOPE', + }, + }), + { params: { id: 'material-1' } } + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toBe('您没有权限编辑此素材') + expect(prisma.material.update).not.toHaveBeenCalled() + }) + test('旧 JSON 更新入口拒绝普通管理员编辑管理范围外素材', async () => { prisma.material.findUnique.mockResolvedValueOnce({ ...existingMaterialForJsonUpdate, diff --git a/app/api/materials/premium/route.ts b/app/api/materials/premium/route.ts index e3a0c74..18fb4ca 100644 --- a/app/api/materials/premium/route.ts +++ b/app/api/materials/premium/route.ts @@ -56,10 +56,10 @@ export async function POST(req: NextRequest) { } if (!operator.isSuperAdmin) { - const operatorScope = (operator.managedDepartment || operator.department || '').trim() + const operatorScope = (operator.managedDepartment || '').trim() if (!operatorScope) { return NextResponse.json( - { error: '尚未配置所属组织,无法设置精品' }, + { error: '尚未配置管理组织,无法设置精品' }, { status: 403 } ) } @@ -157,4 +157,3 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '更新精品状态失败' }, { status: 500 }) } } - diff --git a/app/api/materials/restore/route.ts b/app/api/materials/restore/route.ts index c009fbe..0f2014e 100644 --- a/app/api/materials/restore/route.ts +++ b/app/api/materials/restore/route.ts @@ -68,7 +68,7 @@ export async function POST(req: NextRequest) { const isOwnMaterial = material.userId === operatorId if (!isOwnMaterial) { - const operatorScope = (operator.managedDepartment || operator.department || '').trim() + const operatorScope = (operator.managedDepartment || '').trim() const targetDepartment = (material.user?.department || '').trim() if (!operatorScope) { diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index bfc3e7f..658ebee 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -527,7 +527,7 @@ export async function DELETE(req: NextRequest) { if (!isOwnMaterial) { // 检查是否有权限管理素材上传者所在的部门 - const operatorScope = (operator?.managedDepartment || operator?.department || '').trim() + const operatorScope = (operator?.managedDepartment || '').trim() const targetDepartment = (material.user?.department || '').trim() if (!operatorScope) { diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index 1efd59a..dd0f5f6 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -5,13 +5,17 @@ import { prisma } from '@/lib/prisma' import { createMaterialLog } from '@/lib/material-log' import { uploadToS3, uploadStreamToS3, deleteFromS3, isS3Url } from '@/lib/s3' import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor' -import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor' +import { parseAudioMetadata, parseAudioMetadataFromFile, isAudioFile } from '@/lib/audio-processor' import { validateTemplateId } from '@/lib/template-validator' import { validateSubmittedTemplateFields } from '@/lib/template-field-validation' import { prepareMaterialData, compressMetadata, checkMetadataSize } from '@/lib/field-data-storage' import { canUploadMaterial } from '@/lib/license' import { enqueueVideoTranscodeJob } from '@/lib/video-transcode-service' +import formidable from 'formidable' import { Readable } from 'stream' +import { createReadStream } from 'fs' +import { mkdtemp, readFile, rm, unlink } from 'fs/promises' +import os from 'os' import path from 'path' import { randomBytes } from 'crypto' @@ -74,7 +78,180 @@ const VIDEO_PENDING_PLACEHOLDER = (() => { return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() +type UploadFileHandle = { + name: string + type: string + size: number + filepath?: string + createReadStream: () => Readable + readBuffer: () => Promise +} + +type ParsedUploadForm = { + getText: (key: string) => string | null + getFile: (key: string) => UploadFileHandle | null + getFiles: (key: string) => UploadFileHandle[] + cleanup: () => Promise +} + +const DEFAULT_MAX_UPLOAD_FILE_SIZE = 10 * 1024 * 1024 * 1024 // 10GB +const DEFAULT_MAX_UPLOAD_FIELD_SIZE = 2 * 1024 * 1024 // 2MB + +function getNumberEnv(name: string, fallback: number) { + const value = Number(process.env[name]) + return Number.isFinite(value) && value > 0 ? value : fallback +} + +function firstTextValue(value: unknown): string | null { + if (Array.isArray(value)) { + return firstTextValue(value[0]) + } + if (typeof value !== 'string') { + return null + } + return value +} + +function createHandleFromWebFile(file: File): UploadFileHandle { + return { + name: file.name, + type: file.type || 'application/octet-stream', + size: file.size || 0, + createReadStream: () => Readable.fromWeb(file.stream() as any), + readBuffer: async () => Buffer.from(await file.arrayBuffer()), + } +} + +function createHandleFromFormidableFile(file: formidable.File): UploadFileHandle { + return { + name: file.originalFilename || file.newFilename || 'upload.bin', + type: file.mimetype || 'application/octet-stream', + size: file.size || 0, + filepath: file.filepath, + createReadStream: () => createReadStream(file.filepath), + readBuffer: () => readFile(file.filepath), + } +} + +async function parseLegacyFormData(req: NextRequest): Promise { + const formData = await req.formData() as FormData & { + get?: (key: string) => FormDataEntryValue | null + getAll?: (key: string) => FormDataEntryValue[] + } + + if (typeof formData.forEach !== 'function') { + return { + getText: (key) => { + const value = formData.get?.(key) + return typeof value === 'string' ? value : null + }, + getFile: (key) => { + const value = formData.get?.(key) + return value && typeof value !== 'string' ? createHandleFromWebFile(value) : null + }, + getFiles: (key) => { + const values = formData.getAll?.(key) || [] + return values + .filter((value): value is File => value instanceof File) + .map(createHandleFromWebFile) + }, + cleanup: async () => {}, + } + } + + const textFields = new Map() + const fileFields = new Map() + + formData.forEach((value, key) => { + if (typeof value === 'string') { + textFields.set(key, [...(textFields.get(key) || []), value]) + return + } + + fileFields.set(key, [...(fileFields.get(key) || []), createHandleFromWebFile(value)]) + }) + + return { + getText: (key) => textFields.get(key)?.[0] ?? null, + getFile: (key) => fileFields.get(key)?.[0] ?? null, + getFiles: (key) => fileFields.get(key) || [], + cleanup: async () => {}, + } +} + +async function parseStreamingMultipartForm(req: NextRequest): Promise { + if (!req.body) { + return parseLegacyFormData(req) + } + + const uploadDir = await mkdtemp(path.join(os.tmpdir(), 'flnxi-upload-')) + const form = formidable({ + uploadDir, + keepExtensions: true, + multiples: true, + maxFileSize: getNumberEnv('MAX_UPLOAD_FILE_SIZE_BYTES', DEFAULT_MAX_UPLOAD_FILE_SIZE), + maxTotalFileSize: getNumberEnv('MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES', DEFAULT_MAX_UPLOAD_FILE_SIZE), + maxFieldsSize: getNumberEnv('MAX_UPLOAD_FIELD_SIZE_BYTES', DEFAULT_MAX_UPLOAD_FIELD_SIZE), + allowEmptyFiles: true, + minFileSize: 0, + }) + + const nodeStream = Readable.fromWeb(req.body as any) as Readable & { + headers?: Record + method?: string + url?: string + } + nodeStream.headers = Object.fromEntries(req.headers.entries()) + nodeStream.method = req.method + nodeStream.url = req.nextUrl?.pathname || '/api/materials/upload' + + try { + const [fields, files] = await form.parse(nodeStream as any) + const textFields = new Map() + const fileFields = new Map() + + Object.entries(fields).forEach(([key, value]) => { + const text = firstTextValue(value) + if (text !== null) { + textFields.set(key, text) + } + }) + + Object.entries(files).forEach(([key, value]) => { + const handles = (Array.isArray(value) ? value : value ? [value] : []) + .filter((file): file is formidable.File => Boolean(file)) + .map(createHandleFromFormidableFile) + + if (handles.length) { + fileFields.set(key, handles) + } + }) + + return { + getText: (key) => textFields.get(key) ?? null, + getFile: (key) => fileFields.get(key)?.[0] ?? null, + getFiles: (key) => fileFields.get(key) || [], + cleanup: async () => { + await rm(uploadDir, { recursive: true, force: true }) + }, + } + } catch (error) { + await rm(uploadDir, { recursive: true, force: true }) + throw error + } +} + +async function parseUploadForm(req: NextRequest): Promise { + const contentType = req.headers?.get?.('content-type') || '' + if (contentType.includes('multipart/form-data')) { + return parseStreamingMultipartForm(req) + } + + return parseLegacyFormData(req) +} + export async function POST(req: NextRequest) { + let parsedUploadForm: ParsedUploadForm | null = null const uploadedArtifactUrls: string[] = [] const trackUploadedArtifact = (url: string) => { if (url) { @@ -93,7 +270,6 @@ export async function POST(req: NextRequest) { return } - const { unlink } = await import('fs/promises') await unlink(fileUrl) }) ) @@ -132,24 +308,24 @@ export async function POST(req: NextRequest) { ) } - console.log('[Upload] Parsing form data...') - const formData = await req.formData() - const normalizeInput = (value: FormDataEntryValue | null) => { + console.log('[Upload] Parsing multipart form...') + parsedUploadForm = await parseUploadForm(req) + const normalizeInput = (value: string | null) => { if (!value) return null const trimmed = value.toString().trim() return trimmed ? trimmed : null } - const previewImage = formData.get('previewImage') as File // 向后兼容:第一张预览图 - const previewImages = formData.getAll('previewImages') as File[] // 多预览图(最多5张) - const displayImage = formData.get('displayImage') as File // PSD素材的展示图片 - const zipFile = formData.get('zipFile') as File - const productModel = formData.get('productModel') as string - const categoryName = formData.get('categoryName') as string - const sceneName = formData.get('sceneName') as string - const platformName = formData.get('platformName') as string || '' // 平台分类(可选) - const materialType = (formData.get('materialType') as string) || '3DMAX' - const rawMaterialKind = (formData.get('materialKind') as string) || '' + const previewImage = parsedUploadForm.getFile('previewImage') // 向后兼容:第一张预览图 + const previewImages = parsedUploadForm.getFiles('previewImages') // 多预览图(最多5张) + const displayImage = parsedUploadForm.getFile('displayImage') // PSD素材的展示图片 + const zipFile = parsedUploadForm.getFile('zipFile') + const productModel = parsedUploadForm.getText('productModel') || '' + const categoryName = parsedUploadForm.getText('categoryName') || '' + const sceneName = parsedUploadForm.getText('sceneName') || '' + const platformName = parsedUploadForm.getText('platformName') || '' // 平台分类(可选) + const materialType = parsedUploadForm.getText('materialType') || '3DMAX' + const rawMaterialKind = parsedUploadForm.getText('materialKind') || '' const allowedMaterialKinds: MaterialKind[] = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] const normalizedKind = rawMaterialKind ? rawMaterialKind.toUpperCase() @@ -167,24 +343,24 @@ export async function POST(req: NextRequest) { : 'IMAGE' const isVideoMaterial = materialKind === 'VIDEO' const isAudioMaterial = materialKind === 'AUDIO' - const renderer = (formData.get('renderer') as string) || 'CR' - const brandName = normalizeInput(formData.get('brandName')) - const seriesName = normalizeInput(formData.get('seriesName')) - const skuCode = normalizeInput(formData.get('skuCode')) - const materialTexture = normalizeInput(formData.get('materialTexture')) - const renderTime = normalizeInput(formData.get('renderTime')) - const imageResolution = normalizeInput(formData.get('imageResolution')) - const colorMode = normalizeInput(formData.get('colorMode')) - let audioDuration = normalizeInput(formData.get('audioDuration')) - const audioCategory = normalizeInput(formData.get('audioCategory')) - const audioScene = normalizeInput(formData.get('audioScene')) + const renderer = parsedUploadForm.getText('renderer') || 'CR' + const brandName = normalizeInput(parsedUploadForm.getText('brandName')) + const seriesName = normalizeInput(parsedUploadForm.getText('seriesName')) + const skuCode = normalizeInput(parsedUploadForm.getText('skuCode')) + const materialTexture = normalizeInput(parsedUploadForm.getText('materialTexture')) + const renderTime = normalizeInput(parsedUploadForm.getText('renderTime')) + const imageResolution = normalizeInput(parsedUploadForm.getText('imageResolution')) + const colorMode = normalizeInput(parsedUploadForm.getText('colorMode')) + let audioDuration = normalizeInput(parsedUploadForm.getText('audioDuration')) + const audioCategory = normalizeInput(parsedUploadForm.getText('audioCategory')) + const audioScene = normalizeInput(parsedUploadForm.getText('audioScene')) // 作者名称:优先使用表单提交的值(如音频元数据中的艺术家),否则使用上传者用户名 - const submittedAuthorName = normalizeInput(formData.get('authorName')) + const submittedAuthorName = normalizeInput(parsedUploadForm.getText('authorName')) let audioUrl: string | null = null // 音频文件URL,用于在线播放 // 模板相关字段 - const templateId = formData.get('templateId') as string | null - const templateFieldsStr = formData.get('templateFields') as string | null + const templateId = parsedUploadForm.getText('templateId') + const templateFieldsStr = parsedUploadForm.getText('templateFields') let templateFields: Record = {} if (templateFieldsStr) { try { @@ -381,7 +557,7 @@ export async function POST(req: NextRequest) { console.log(`[Upload] Processing preview image ${i + 1}/${imagesToProcess.length}... (${img.type}, ${(img.size / 1024).toFixed(2)}KB)`) try { - const originalBuffer = Buffer.from(await img.arrayBuffer()) + const originalBuffer = await img.readBuffer() console.log(`[Upload] Preview image ${i + 1} buffer size: ${originalBuffer.length} bytes`) const processedBuffer = await processImage(originalBuffer, { @@ -440,7 +616,7 @@ export async function POST(req: NextRequest) { console.log('[Upload] Processing PSD display image...') try { // 读取展示图片 - const displayImageBuffer = Buffer.from(await displayImage.arrayBuffer()) + const displayImageBuffer = await displayImage.readBuffer() // 切割图片(按1500px高度) console.log('[Upload] Slicing display image (max height: 1500px)...') @@ -488,17 +664,21 @@ export async function POST(req: NextRequest) { // 如果是音频文件,自动解析音频时长并记录音频URL if (isAudioFile(contentType)) { - // 音频元数据解析当前仍依赖 Buffer,后续再改为临时文件/流式解析。 - const zipBuffer = Buffer.from(await zipFile.arrayBuffer()) - zipFileSize = BigInt(zipBuffer.length) - zipFilePath = await uploadToS3(zipBuffer, zipFileName, contentType) + zipFilePath = await uploadStreamToS3( + zipFile.createReadStream(), + zipFileName, + contentType, + zipFile.size + ) trackUploadedArtifact(zipFilePath) console.log('[Upload] Audio file uploaded to bucket:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`) console.log('[Upload] Audio file detected, parsing metadata...') // 记录音频文件URL用于在线播放 audioUrl = zipFilePath try { - const audioMetadata = await parseAudioMetadata(zipBuffer, contentType) + const audioMetadata = zipFile.filepath + ? await parseAudioMetadataFromFile(zipFile.filepath) + : await parseAudioMetadata(await zipFile.readBuffer(), contentType) if (audioMetadata) { // 使用解析的时长覆盖表单提交的值 audioDuration = audioMetadata.durationFormatted @@ -515,8 +695,7 @@ export async function POST(req: NextRequest) { // 音频解析失败不影响上传,继续处理 } } else { - const fileStream = Readable.fromWeb(zipFile.stream() as any) - zipFilePath = await uploadStreamToS3(fileStream, zipFileName, contentType, zipFile.size) + zipFilePath = await uploadStreamToS3(zipFile.createReadStream(), zipFileName, contentType, zipFile.size) trackUploadedArtifact(zipFilePath) console.log('[Upload] File uploaded to bucket via stream:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`) } @@ -922,5 +1101,11 @@ export async function POST(req: NextRequest) { }, { status: 500 } )) + } finally { + try { + await parsedUploadForm?.cleanup() + } catch (cleanupError) { + console.error('[Upload] Failed to cleanup temporary upload files:', cleanupError) + } } } diff --git a/app/api/system-settings/route.ts b/app/api/system-settings/route.ts index 3afdf6f..0362ab1 100644 --- a/app/api/system-settings/route.ts +++ b/app/api/system-settings/route.ts @@ -44,7 +44,6 @@ function getPublicSystemSettings(settings: SystemSettingsRow) { createdAt: settings.createdAt, headerLogoUrl: normalizeLogoUrl(settings.headerLogoUrl), loginLogoUrl: normalizeLogoUrl(settings.loginLogoUrl), - enableRegistration: settings.enableRegistration, } } diff --git a/components/AudioVisualizer.tsx b/components/AudioVisualizer.tsx index bf117b3..a37f60e 100644 --- a/components/AudioVisualizer.tsx +++ b/components/AudioVisualizer.tsx @@ -50,7 +50,7 @@ function DefaultAudioCover({ size }: { size: 'sm' | 'md' | 'lg' }) { ) } -// 圆形音频频谱可视化器 +// 音频频谱可视化器 export default function AudioVisualizer({ audioElement, isPlaying, @@ -70,9 +70,9 @@ export default function AudioVisualizer({ // 尺寸配置 const sizeConfig = { - sm: { width: 160, height: 160, bars: 64, innerRadius: 25, maxBarHeight: 35 }, - md: { width: 280, height: 280, bars: 96, innerRadius: 45, maxBarHeight: 65 }, - lg: { width: 400, height: 400, bars: 128, innerRadius: 70, maxBarHeight: 95 }, + sm: { width: 160, height: 120, bars: 36, maxBarHeight: 52 }, + md: { width: 240, height: 180, bars: 48, maxBarHeight: 76 }, + lg: { width: 320, height: 220, bars: 64, maxBarHeight: 96 }, } const config = sizeConfig[size] @@ -121,7 +121,7 @@ export default function AudioVisualizer({ } }, [audioElement]) - // 绘制低调的放射状频谱 + // 绘制低调的条形频谱 const draw = useCallback(() => { const canvas = canvasRef.current const analyser = analyserRef.current @@ -134,33 +134,20 @@ export default function AudioVisualizer({ const dataArray = new Uint8Array(bufferLength) analyser.getByteFrequencyData(dataArray) - const { width, height, bars, innerRadius, maxBarHeight } = config - const centerX = width / 2 + const { width, height, bars, maxBarHeight } = config const centerY = height / 2 const isDarkMode = document.documentElement.classList.contains('dark') // 清除画布 ctx.clearRect(0, 0, width, height) - // 绘制中性背景圆 - const bgGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius + maxBarHeight + 20) - if (isDarkMode) { - bgGradient.addColorStop(0, 'rgba(30, 41, 59, 0.98)') - bgGradient.addColorStop(0.7, 'rgba(15, 23, 42, 0.96)') - bgGradient.addColorStop(1, 'rgba(15, 23, 42, 0.9)') - } else { - bgGradient.addColorStop(0, 'rgba(248, 250, 252, 0.98)') - bgGradient.addColorStop(0.7, 'rgba(241, 245, 249, 0.94)') - bgGradient.addColorStop(1, 'rgba(226, 232, 240, 0.9)') - } - ctx.fillStyle = bgGradient - ctx.beginPath() - ctx.arc(centerX, centerY, innerRadius + maxBarHeight + 15, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = isDarkMode ? 'rgba(15, 23, 42, 1)' : 'rgba(248, 250, 252, 1)' + ctx.fillRect(0, 0, width, height) - // 绘制频谱线条 - const angleStep = (Math.PI * 2) / bars + // 绘制频谱条 const usableData = Math.floor(bufferLength * 0.7) + const gap = size === 'sm' ? 2 : 3 + const barWidth = Math.max(2, (width - (bars - 1) * gap) / bars) for (let i = 0; i < bars; i++) { // 使用多个频率数据点的平均值来平滑 @@ -168,62 +155,19 @@ export default function AudioVisualizer({ const nextIndex = Math.min(dataIndex + 1, usableData - 1) const value = (dataArray[dataIndex] + dataArray[nextIndex]) / 2 - const barHeight = Math.max(3, (value / 255) * maxBarHeight) - - const angle = i * angleStep - Math.PI / 2 // 从顶部开始 - - // 计算线条的起点和终点 - const x1 = centerX + Math.cos(angle) * innerRadius - const y1 = centerY + Math.sin(angle) * innerRadius - const x2 = centerX + Math.cos(angle) * (innerRadius + barHeight) - const y2 = centerY + Math.sin(angle) * (innerRadius + barHeight) - + const barHeight = Math.max(6, (value / 255) * maxBarHeight) + const x = i * (barWidth + gap) + const y = centerY - barHeight / 2 const intensity = value / 255 - // 绘制线条 - ctx.beginPath() - ctx.strokeStyle = isDarkMode + ctx.fillStyle = isDarkMode ? `rgba(148, 163, 184, ${0.34 + intensity * 0.42})` - : `rgba(37, 99, 235, ${0.28 + intensity * 0.42})` - ctx.lineWidth = 1.5 - ctx.lineCap = 'round' - ctx.moveTo(x1, y1) - ctx.lineTo(x2, y2) - ctx.stroke() + : `rgba(71, 85, 105, ${0.26 + intensity * 0.42})` + ctx.fillRect(x, y, barWidth, barHeight) } - // 绘制中心圆 - const centerGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius - 5) - if (isDarkMode) { - centerGradient.addColorStop(0, 'rgba(15, 23, 42, 1)') - centerGradient.addColorStop(0.7, 'rgba(30, 41, 59, 1)') - centerGradient.addColorStop(1, 'rgba(51, 65, 85, 0.96)') - } else { - centerGradient.addColorStop(0, 'rgba(255, 255, 255, 1)') - centerGradient.addColorStop(0.7, 'rgba(248, 250, 252, 1)') - centerGradient.addColorStop(1, 'rgba(226, 232, 240, 0.96)') - } - ctx.fillStyle = centerGradient - ctx.beginPath() - ctx.arc(centerX, centerY, innerRadius - 5, 0, Math.PI * 2) - ctx.fill() - - // 绘制中心圆边框 - ctx.strokeStyle = isDarkMode ? 'rgba(100, 116, 139, 0.75)' : 'rgba(148, 163, 184, 0.55)' - ctx.lineWidth = 1.5 - ctx.beginPath() - ctx.arc(centerX, centerY, innerRadius - 5, 0, Math.PI * 2) - ctx.stroke() - - // 绘制中心音符图标 - ctx.fillStyle = isDarkMode ? 'rgba(203, 213, 225, 0.9)' : 'rgba(71, 85, 105, 0.9)' - ctx.font = `${innerRadius * 0.5}px sans-serif` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText('♪', centerX, centerY) - animationRef.current = requestAnimationFrame(draw) - }, [config]) + }, [config, size]) // 处理播放状态变化 useEffect(() => { @@ -267,13 +211,13 @@ export default function AudioVisualizer({ }, []) const containerClasses = { - sm: 'w-40 h-40', - md: 'w-[280px] h-[280px]', - lg: 'w-[400px] h-[400px]', + sm: 'w-40 h-[120px]', + md: 'w-[240px] h-[180px]', + lg: 'w-[320px] h-[220px]', } return ( -
+
{/* 封面图层 */}
) : ( diff --git a/components/MaterialPreviewModal.tsx b/components/MaterialPreviewModal.tsx index 9638b31..f404539 100644 --- a/components/MaterialPreviewModal.tsx +++ b/components/MaterialPreviewModal.tsx @@ -614,9 +614,9 @@ export default function MaterialPreviewModal({ )}
- {(description || (!material && title)) && ( + {description && (

- {description || '该素材使用统一预览弹窗展示'} + {description}

)} {isVideoMaterial && currentVideoStatus !== 'done' && ( @@ -642,7 +642,7 @@ export default function MaterialPreviewModal({ className="w-full justify-center h-11" onClick={onClose} > - 返回上一页 + 关闭
)} diff --git a/components/__tests__/AudioVisualizer.test.tsx b/components/__tests__/AudioVisualizer.test.tsx index f60708a..61ce3a1 100644 --- a/components/__tests__/AudioVisualizer.test.tsx +++ b/components/__tests__/AudioVisualizer.test.tsx @@ -103,17 +103,17 @@ describe('AudioVisualizer 组件', () => { const { container: smContainer } = render( ) - expect(smContainer.firstChild).toHaveClass('w-40', 'h-40') + expect(smContainer.firstChild).toHaveClass('w-40', 'h-[120px]') const { container: mdContainer } = render( ) - expect(mdContainer.firstChild).toHaveClass('w-[280px]', 'h-[280px]') + expect(mdContainer.firstChild).toHaveClass('w-[240px]', 'h-[180px]') const { container: lgContainer } = render( ) - expect(lgContainer.firstChild).toHaveClass('w-[400px]', 'h-[400px]') + expect(lgContainer.firstChild).toHaveClass('w-[320px]', 'h-[220px]') }) it('应该应用自定义 className', () => { @@ -195,8 +195,8 @@ describe('AudioVisualizer 组件', () => { ) const canvas = container.querySelector('canvas') - expect(canvas).toHaveAttribute('width', '280') - expect(canvas).toHaveAttribute('height', '280') + expect(canvas).toHaveAttribute('width', '240') + expect(canvas).toHaveAttribute('height', '180') }) it('小尺寸 canvas 应该有正确的尺寸', () => { @@ -205,7 +205,7 @@ describe('AudioVisualizer 组件', () => { ) const canvas = container.querySelector('canvas') expect(canvas).toHaveAttribute('width', '160') - expect(canvas).toHaveAttribute('height', '160') + expect(canvas).toHaveAttribute('height', '120') }) it('大尺寸 canvas 应该有正确的尺寸', () => { @@ -213,8 +213,8 @@ describe('AudioVisualizer 组件', () => { ) const canvas = container.querySelector('canvas') - expect(canvas).toHaveAttribute('width', '400') - expect(canvas).toHaveAttribute('height', '400') + expect(canvas).toHaveAttribute('width', '320') + expect(canvas).toHaveAttribute('height', '220') }) }) diff --git a/components/ui/__tests__/ActivationModal.test.tsx b/components/ui/__tests__/ActivationModal.test.tsx index 737a212..ef13c65 100644 --- a/components/ui/__tests__/ActivationModal.test.tsx +++ b/components/ui/__tests__/ActivationModal.test.tsx @@ -90,15 +90,15 @@ describe('ActivationModal 组件', () => { expect(screen.getByText('立即激活')).toBeInTheDocument() }) - it('应该显示购买链接', () => { + it('应该显示私有化许可证提示', () => { render( ) - expect(screen.getByText('还没有激活码?')).toBeInTheDocument() - expect(screen.getByText('前往购买 →')).toBeInTheDocument() + expect(screen.getByText('如需许可证,请联系系统交付人员或管理员。')).toBeInTheDocument() + expect(screen.queryByText('前往购买 →')).not.toBeInTheDocument() }) }) @@ -280,16 +280,16 @@ describe('ActivationModal 组件', () => { }) }) - describe('购买链接', () => { - it('点击购买链接应该打开新窗口', () => { + describe('私有化许可证口径', () => { + it('不应该提供公网购买跳转', () => { render( ) - fireEvent.click(screen.getByText('前往购买 →')) - expect(mockOpen).toHaveBeenCalledWith('https://www.flnxi.com/pricing', '_blank') + expect(screen.queryByText('前往购买 →')).not.toBeInTheDocument() + expect(mockOpen).not.toHaveBeenCalled() }) }) }) diff --git a/docker-images/README.md b/docker-images/README.md index c5ba1b1..3cd8338 100644 --- a/docker-images/README.md +++ b/docker-images/README.md @@ -12,11 +12,11 @@ docker-images/ ├── node/ # Node.js 基础镜像 │ └── node-18-alpine.tar # 镜像文件 ├── minio-mc/ # MinIO Client 镜像 -│ ├── minio-mc-RELEASE.2025-04-16T18-13-26Z.tar # 镜像文件 +│ ├── minio-mc-latest.tar # 历史本地缓存,不可作为正式离线交付版本 │ └── VERSION.txt # 版本信息 ``` -当前目录下没有 `postgres/` 和 `minio/` 镜像包。生产 Compose 默认可走官方运行时镜像;客户离线或受控网络交付时,应在 `.env` 中显式填写 `POSTGRES_IMAGE`、`MINIO_IMAGE`、`MINIO_MC_IMAGE` 到当前可用的私有 Registry 版本镜像。 +当前目录下没有 `postgres/` 和 `minio/` 镜像包。`minio-mc/` 下现存的是 `minio/mc:latest` 历史缓存,只能用于本机排查,不能作为正式离线包事实。客户离线或受控网络交付时,应重新导出固定版本镜像,并在 `.env` 中显式填写 `POSTGRES_IMAGE`、`MINIO_IMAGE`、`MINIO_MC_IMAGE` 到当前可用的私有 Registry 版本镜像。 ## 使用方法 @@ -24,10 +24,10 @@ docker-images/ ```powershell # Windows PowerShell -docker load -i docker-images\minio-mc\minio-mc-RELEASE.2025-04-16T18-13-26Z.tar +docker load -i docker-images\minio-mc\<固定版本镜像文件>.tar # Linux/Mac -docker load -i docker-images/minio-mc/minio-mc-RELEASE.2025-04-16T18-13-26Z.tar +docker load -i docker-images/minio-mc/<固定版本镜像文件>.tar ``` ### 2. 在内部构建脚本中使用 @@ -54,7 +54,7 @@ echo "RELEASE.2025-04-16T18-13-26Z - $(Get-Date -Format 'yyyy-MM-dd')" > docker- | 镜像名称 | 版本 | 文件路径 | 大小 | 更新时间 | 用途 | |---------|------|---------|------|---------|------| | node | 18-alpine | `node/node-18-alpine.tar` | ~42.86 MiB | 未记录 | 素材系统构建基础镜像 | -| minio/mc | RELEASE.2025-04-16T18-13-26Z | `minio-mc/minio-mc-RELEASE.2025-04-16T18-13-26Z.tar` | ~28.47 MiB | 见 `minio-mc/VERSION.txt` | MinIO 客户端 | +| minio/mc | latest(历史缓存,不可交付) | `minio-mc/minio-mc-latest.tar` | ~85 MiB | 见 `minio-mc/VERSION.txt` | MinIO 客户端本机缓存 | ## 重要约定 diff --git a/docker-images/minio-mc/VERSION.txt b/docker-images/minio-mc/VERSION.txt index 27ce57f..89aee2c 100644 --- a/docker-images/minio-mc/VERSION.txt +++ b/docker-images/minio-mc/VERSION.txt @@ -1,4 +1,4 @@ -latest - 2025-11-23 +latest - 2025-11-23(历史本地缓存,不作为正式离线交付版本) Image: minio/mc:latest Digest: sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 Size: ~85MB diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index c31f4c8..ab4af15 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -29,6 +29,8 @@ docker-compose.prod.yml `APP_IMAGE` 是生产启动必填项。这样做是为了让客户现场明确运行哪个版本,避免安装或升级时误拉旧仓库、旧测试镜像或 `latest`。 +根目录 `docker-compose.yml` 是本地/历史维护入口,不是客户正式安装入口。客户交付、NAS 和 Linux 私有化部署统一以 `docker-compose.prod.yml + .env` 为准。 + 当前不会由该 Compose 文件直接拉起: - Nginx / Traefik 等反向代理 @@ -67,6 +69,9 @@ S3_BUCKET=flnxi-materials LICENSE_SERVER_URL=https://your-license-server.example.com LICENSE_PROJECT_CODE=flnxi-sucai LICENSE_REQUEST_SIGNING_SECRET= +MAX_UPLOAD_FILE_SIZE_BYTES=10737418240 +MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 +MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 ``` 说明: @@ -77,6 +82,7 @@ LICENSE_REQUEST_SIGNING_SECRET= - `POSTGRES_IMAGE`、`MINIO_IMAGE`、`MINIO_MC_IMAGE` 建议同样固定版本;离线或受控网络交付时,应写成私有 Registry 里的固定版本镜像。 - `LICENSE_PROJECT_CODE` 必须和 `license-server` 中项目表的 `projects.code` 一致。 - `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。 +- `MAX_UPLOAD_FILE_SIZE_BYTES` 是单个素材文件上限;`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES` 是一次上传请求总上限;`MAX_UPLOAD_FIELD_SIZE_BYTES` 是表单文字字段总上限。示例值分别是 10GB、10GB、2MB,低配 NAS 可按客户机器内存和磁盘调小。 - 不要把真实密码、许可证地址、对象存储密钥写入 Git。 ### 2.3 HTTP Registry 前置检查 @@ -102,7 +108,17 @@ Docker Desktop 需要在设置里的 Docker Engine 配置同样的 `insecure-reg ## 3. 标准部署步骤 -### 3.1 准备环境文件 +### 3.1 区分首装和升级 + +当前交付链路分三段看,不能混成一个命令: + +- 发布方构建和推送镜像:使用 `deploy-registry.ps1` 的发布动作,产出固定版本镜像。 +- 客户首次安装:在目标机器准备部署目录、`docker-compose.prod.yml`、`.env`、数据卷和固定版本镜像,然后启动 Compose。 +- 已有客户升级:使用 `deploy-registry.ps1 -Action DeployApp` 写入新 `APP_IMAGE`,并执行备份、拉取、迁移、健康检查和失败回滚。 + +`deploy-registry.ps1 -Action DeployApp` 默认假设远端已经有部署目录、生产 Compose 和 `.env`,它是升级/远程更新入口,不是单独完整的首装向导。 + +### 3.2 准备环境文件 在目标机器部署目录准备 `.env`: @@ -112,7 +128,7 @@ cp .env.example .env 再按实际环境补齐数据库、MinIO、NextAuth、许可证和访问地址。 -### 3.2 启动服务 +### 3.3 启动服务 正式客户交付先通过 `deploy-registry.ps1` 完成版本化镜像发布,并把远端 `.env` 里的 `APP_IMAGE` 写到目标版本,再执行下面的启动命令。 @@ -120,13 +136,11 @@ cp .env.example .env docker compose -f docker-compose.prod.yml up -d ``` -如果需要本机重新构建镜像: +生产 Compose 只读取 `APP_IMAGE`,不包含 `build:`。如果需要重新构建镜像,应在发布方机器使用 `deploy-registry.ps1` 或内部构建脚本先生成版本镜像,再更新 `.env`。 -```bash -docker compose -f docker-compose.prod.yml up -d --build -``` +离线或受控网络交付时,启动前还要确认 `APP_IMAGE`、`POSTGRES_IMAGE`、`MINIO_IMAGE`、`MINIO_MC_IMAGE` 都已经在目标机器 Docker 或客户私有 Registry 中存在。否则 `docker compose up -d` 会因为无法拉取镜像而失败。 -### 3.3 初始化数据库 +### 3.4 初始化数据库 主应用容器入口脚本 `scripts/docker-entrypoint.sh` 会在启动时自动执行: @@ -147,7 +161,7 @@ docker exec npm run verify:template-data 如是全新环境,继续按 `docs/初次部署与出厂设置指南.md` 走系统初始化流程。 -### 3.4 验证服务 +### 3.5 验证服务 ```bash docker compose -f docker-compose.prod.yml ps diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index e636d54..a92d70c 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -42,7 +42,9 @@ - `app/api/system-settings/route.ts`:现在会按会话区分公开字段、普通管理员字段和最高权限账号字段。 - `app/api/system-settings/route.ts`:普通管理员响应会移除 `s3AccessKey` 和 `s3SecretKey`,最高权限账号才返回这两个字段。 - `app/api/system-settings/route.ts`:GET 现在只读,缺少设置记录时只返回内存默认值,不再创建默认行;旧 Logo 也只在响应中整理,不再通过 GET 写回数据库。 -- `app/api/system-settings/__tests__/route.test.ts`:测试锁定未登录只返回公开字段、普通管理员不返回对象存储密钥、最高权限账号才返回对象存储密钥,并确认 GET 不再补写对象存储密钥、不创建默认设置、不回写 Logo、不执行运行期 DDL。 +- `app/api/system-settings/route.ts`:未登录公开设置不再返回 `enableRegistration` 等功能开关;登录页和注册页不再从公开接口暴露普通自助注册策略,带邀请码注册仍由注册接口按数据库设置和邀请链接校验。 +- `app/api/system-settings/__tests__/route.test.ts`:测试锁定未登录只返回公开字段、普通管理员不返回对象存储密钥、最高权限账号才返回对象存储密钥,并确认 GET 不再补写对象存储密钥、不创建默认设置、不回写 Logo、不执行运行期 DDL、不公开功能开关。 +- `app/api/auth/register/__tests__/route.test.ts`:已有测试锁定关闭公开注册时无邀请码拒绝、有有效邀请码仍可注册。 - `prisma/schema.prisma:320`:底层表结构仍明文保存 `s3SecretKey`,所以密钥读取/修改继续按最高权限账号处理。 建议动作: @@ -302,6 +304,9 @@ - `app/api/admin/users/set-department-manager/route.ts`:设置/取消部门管理员现在必须是最高权限账号;不能把最高权限账号设置成部门管理员或调整其部门管理员状态。 - `app/api/departments/route.ts`:创建、重命名、删除部门结构现在必须是最高权限账号;普通管理员不会进入许可证检查和数据库写入逻辑。 - `app/api/admin/users/route.ts`:用户列表 GET 现在按最高权限/部门范围/本人范围过滤;分页总数和搜索条件使用同一权限范围,避免普通管理员看全量用户或通过总数泄露全量。 +- `lib/material-permissions.ts`:部门管理员素材管理范围只认 `managedDepartment`,不再用用户当前 `department` 兜底,避免取消部门管理员后仍继承部门管理权。 +- `app/api/admin/users/set-department-manager/route.ts`、`app/api/admin/users/set-role/route.ts`:取消部门管理员或把用户降级为普通用户时会同步清空 `managedDepartment`。 +- `app/admin/_hooks/usePermissions.ts`:前端管理范围和后端保持一致,不再用当前部门模拟管理部门。 - `app/api/admin/upload-templates/route.ts`:创建上传模板现在必须是最高权限账号。 - `app/api/admin/upload-templates/[id]/route.ts`:更新、删除上传模板现在必须是最高权限账号;模板读取仍保留给管理员,方便后台只读查看。 - `app/api/admin/upload-templates/[id]/migrate/route.ts`:模板素材迁移现在必须是最高权限账号。 @@ -339,6 +344,7 @@ - `app/api/materials/share/__tests__/route.test.ts`、`app/api/materials/share/tokens/__tests__/route.test.ts`、`app/api/materials/share/tokens/[id]/__tests__/route.test.ts`:补充部门管理员范围内可操作、普通管理员跨部门不可操作的分享链接权限测试。 - `app/api/category-fields/__tests__/route.test.ts`、`app/api/template-categories/__tests__/route.test.ts`、`app/api/home-filter-fields/__tests__/route-auth.test.ts`、`app/api/system-settings/__tests__/route.test.ts`、`app/api/materials/download/__tests__/route.test.ts`、`app/api/material-rankings/__tests__/route.test.ts`、`lib/__tests__/public-share-settings.test.ts`:补充 GET 读接口不触发字段池同步、运行期 DDL、默认设置创建、旧 Logo 回写、未登录分类枚举、分类选项不误改未加入分类字段或失败下载计数的测试。 - `app/api/system/init/__tests__/route.test.ts`:补充已有最高权限账号后普通管理员不能触发初始化、首次初始化必须携带初始化令牌、正确令牌仍可完成首次初始化的测试。 +- `app/api/admin/users/__tests__/organization-permissions.test.ts`、`app/api/materials/__tests__/template-fields-routes.test.ts`:补充取消部门管理员、降级普通用户和素材管理范围只认 `managedDepartment` 的回归测试。 验证: @@ -361,7 +367,7 @@ - 已完成:组织结构、用户归属、统一用户更新、用户列表范围、模板写操作、模板迁移、素材永久删除、过期清理、系统维护、内网穿透维护、素材恢复、全局字典、分类字段、模板选项和转码恢复维护的敏感权限已阶段性收紧。 - 已完成:邀请链接管理已收紧到最高权限账号;普通管理员不能创建、启停、删除或查看邀请链接列表,前端入口也不再显示给普通管理员。 -- 仍需继续:取消部门管理员当前只清 `managedDepartment`,不会自动把 `role: ADMIN` 降回 `USER`;这涉及“全局管理员”和“部门管理员”模型拆分,建议后续单独设计。 +- 已完成验证:`department` 只表示用户所属部门,`managedDepartment` 才表示管理范围;取消部门管理员会同时清空 `managedDepartment` 并把角色降回 `USER`,把用户角色降为 `USER` 时也会清空旧管理部门,避免旧权限复活。验证命令:`npm test -- --runTestsByPath "app/api/admin/users/__tests__/organization-permissions.test.ts" --runInBand`、`npm test -- --runTestsByPath "app/api/materials/__tests__/template-fields-routes.test.ts" "app/api/materials/__tests__/delete-storage-safety.test.ts" --runInBand`、`npm run type-check`。 - 已完成验证:素材编辑入口和后台素材导出已接入统一素材管理范围;当前负责人、个人归属人、最高权限账号和管理部门范围内管理员可管理对应素材,历史上传者只保留追溯含义,不再自动代表当前管理权;普通管理员不能跨管理部门编辑或导出全量素材。验证命令:`npm test -- --runTestsByPath "app/api/materials/__tests__/template-fields-routes.test.ts" "app/api/admin/materials/export/__tests__/route.test.ts" --runInBand`、`npm run type-check`。 - 已完成验证:素材分享链接创建、列表查看和撤销已接入统一素材管理范围;普通管理员只能操作管理部门范围内素材的分享链接,不能跨部门创建、查看或撤销。验证命令:`npm test -- --runTestsByPath "app/api/materials/share/__tests__/route.test.ts" "app/api/materials/share/tokens/__tests__/route.test.ts" "app/api/materials/share/tokens/[id]/__tests__/route.test.ts" --runInBand`、`npm run type-check`。 - 已完成验证:店铺/场景分类新增已收紧到最高权限账号;普通用户、普通管理员和部门管理员不能新增全局店铺/场景字典项,上传流程仍可读取已有选项。验证命令:`npm test -- --runTestsByPath "app/api/admin/__tests__/global-dictionaries-superadmin.test.ts" --runInBand`、`npm run type-check`。 @@ -571,9 +577,14 @@ - `prisma/schema.prisma`:`MaterialLog` 不再通过外键强依赖 `Material`,`materialId` 作为审计字段保留。 - `prisma/schema.prisma`:新增 `materialSnapshot`,用于保存素材硬删除后的最小展示信息。 - `prisma/migrations/20260525010000_preserve_material_audit_logs/migration.sql`:为老库补 `materialSnapshot`,回填现有日志快照,并移除 `material_logs_materialId_fkey`。 -- `lib/material-log.ts`:运行时兜底建表会添加 `materialSnapshot`,并删除旧的素材级联外键,避免老库又补回级联删除。 -- `app/api/admin/material-logs/route.ts`:改为 `LEFT JOIN` 查询日志,素材已硬删除时优先显示快照,否则显示“已删除素材”。 -- `lib/__tests__/material-log.test.ts`:覆盖兜底建表不再创建素材级联外键,并验证日志会保存素材快照。 +- `lib/material-log.ts`:日志表结构已交给 Prisma migration 管理,运行期兼容入口不再建表/改表,避免普通读取或写日志顺手执行 DDL。 +- `app/api/admin/material-logs/route.ts`:改为最高权限账号可查看;使用 `LEFT JOIN` 查询日志,素材已硬删除时优先显示快照,否则显示“已删除素材”。 +- `lib/__tests__/material-log.test.ts`:覆盖运行期不再兜底建表/改表,并验证日志会保存素材快照。 +- `app/api/admin/material-logs/__tests__/route.test.ts`:覆盖非最高权限账号不能查看素材日志,最高权限账号查看日志时不会触发运行期建表/改表。 + +验证: + +- `npm test -- --runTestsByPath "lib/__tests__/material-log.test.ts" "app/api/admin/material-logs/__tests__/route.test.ts" --runInBand`:通过。 建议动作: @@ -608,7 +619,7 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 - 后续增强:任务抢锁目前仍是单应用进程优先的轻量方案;如果客户部署多副本或独立 worker,再升级成更严格的数据库锁/worker 模型。 - 后续增强:管理后台可把失败原因和手动重试按钮做成可视化入口。 -### P1-11 大文件上传、播放、下载有整文件进内存问题(已完成第二轮流式收口) +### P1-11 大文件上传、播放、下载有整文件进内存问题(已完成第三轮流式收口) 客户影响: @@ -616,7 +627,7 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 原始问题: -- 上传入口使用 `req.formData()`,完整 multipart 请求仍由 Next.js 解析,入口层还不是彻底流式。 +- 上传入口曾使用 `req.formData()`,完整 multipart 请求会先由 Next.js 解析,入口层还不是彻底流式。 - 上传路由曾对原始素材统一 `arrayBuffer()`,大视频/大包会在服务端二次复制成完整 `Buffer` 后再上传。 - 旧视频转码逻辑会把 S3 源视频拼成完整 `Buffer` 后写临时文件,转码产物也 `readFile()` 后再上传。 - legacy 本地文件播放、下载、素材代理曾有 `readFile()` 路径。 @@ -624,7 +635,10 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 修复现状: - `lib/s3.ts`:新增 `uploadStreamToS3()`,支持把 Node stream 直接作为 S3 上传 Body,并保留原 Buffer 上传接口用于小文件/图片。 -- `app/api/materials/upload/route.ts`:非音频原始素材改为 `zipFile.stream()` + `uploadStreamToS3()`,大视频、大压缩包、PSD 原始文件不再在上传路由里二次复制成完整 `Buffer` 后上传。 +- `app/api/materials/upload/route.ts`:真实 multipart 请求已改为 `formidable` 临时文件解析,不再在入口层调用 `req.formData()`;测试兼容路径仍保留旧 FormData 包装。 +- `app/api/materials/upload/route.ts`:原始素材和音频素材都改为临时文件 `createReadStream()` + `uploadStreamToS3()`,大视频、大压缩包、PSD 原始文件和大音频不再在上传路由里二次复制成完整 `Buffer` 后上传。 +- `app/api/materials/upload/route.ts`:上传处理结束后会清理 formidable 临时文件,避免客户机器长期运行后堆积临时文件。 +- `lib/audio-processor.ts`:新增 `parseAudioMetadataFromFile()`,音频时长优先从临时文件解析;只有测试兼容路径里没有临时文件时才回退到 Buffer 解析。 - `lib/video-processor.ts`:S3 源视频下载改为 `pipeline(readable, createWriteStream(tempPath))`,不再 `chunks + Buffer.concat`。 - `lib/video-processor.ts`:540P 预览、10 秒片段和封面图改为 `createReadStream()` 上传,不再先 `readFile()` 成完整 Buffer。 - `lib/local-file-stream.ts`:新增本地文件流式响应 helper,支持完整文件、普通 Range、后缀 Range 和非法 Range `416`。 @@ -632,16 +646,21 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 - `app/api/materials/download/route.ts`:legacy 本地下载改为 `createReadStream()`,普通下载和 Range 下载都不再先 `readFile()`。 - `app/api/materials/asset/route.ts`:legacy 本地素材代理改为流式响应,不再整文件读入内存。 - `lib/__tests__/s3.test.ts`:新增流式上传测试,锁定 `Body` 使用 stream 且传递 `ContentLength`。 -- `app/api/materials/__tests__/template-fields-routes.test.ts`:新增上传路由测试,锁定非音频原始素材走 `uploadStreamToS3()`;音频因时长解析暂时仍走 Buffer,并明确保留该边界。 +- `app/api/materials/__tests__/template-fields-routes.test.ts`:新增上传路由测试,锁定非音频和音频原始素材都走 `uploadStreamToS3()`;音频生产路径从临时文件解析时长,不再为了时长解析整文件进 Buffer。 +- `lib/__tests__/audio-processor.test.ts`:新增临时文件路径解析音频元数据测试。 - `lib/__tests__/local-file-stream.test.ts`:新增本地文件完整响应、Range、后缀 Range、非法 Range 测试。 - `app/api/materials/download/__tests__/route.test.ts`:新增 legacy 本地下载和 Range 下载测试。 +验证: + +- `npm test -- --runTestsByPath "app/api/materials/__tests__/template-fields-routes.test.ts" "lib/__tests__/audio-processor.test.ts" --runInBand`:通过。 +- `npm test -- --runTestsByPath "app/api/materials/__tests__/template-fields-routes.test.ts" "app/api/materials/__tests__/delete-storage-safety.test.ts" --runInBand`:通过。 + 建议动作: -- 后续必须继续:上传入口从 Next.js `formData()` 改成流式 multipart 或浏览器直传 MinIO/S3;这是 P1-11 最大剩余项。 -- 后续继续:音频元数据解析仍依赖 `Buffer`,需要改成临时文件/流式解析后,音频原始文件才能彻底走流式上传。 -- 后续继续:预览图和 PSD 展示图仍会进入内存处理,但已有图片体积限制;后续可按客户文件规模决定是否做更细的切片/临时文件方案。 -- 限制单文件大小,并按客户机器等级配置。 +- 仍需继续:预览图和 PSD 展示图仍会进入内存处理;预览图已有 50MB 限制,PSD 展示图后续应补更明确的大小/像素限制或改为 sharp 从临时文件逐片处理。 +- 仍需继续:当前 S3 流式上传仍是单次 `PutObject`;如果客户会上传数 GB 文件,后续应升级成分片上传、进度、失败重试和断点续传。 +- 仍需继续:上传大小上限当前可通过 `MAX_UPLOAD_FILE_SIZE_BYTES`、`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES`、`MAX_UPLOAD_FIELD_SIZE_BYTES` 配置,正式交付前应按客户机器等级写入安装说明。 ### P1-12 模板迁移不是事务,失败会半迁移(已修复:小批量事务化) @@ -777,14 +796,14 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 - `发布管理.ps1`:历史内部发布控制台已默认禁止执行,且清空旧默认服务器、旧 Harbor 地址和旧许可证服务器地址;确需排查历史链路必须显式追加确认参数。 - `license-server/deploy.ps1`:许可证服务历史部署脚本已默认禁止执行,且必须显式传入目标服务器和 SSH key。 - `flnxi-agent/README.md`:Node 版 Agent 第一屏已改为历史实现说明,不再写成新客户默认安装入口。 -- `docker-images/minio-mc/VERSION.txt`:本地离线缓存仍记录 `minio/mc:latest`,这是后续需要重新导出或归档的缓存状态,不应作为当前部署事实。 -- `docs/deployment/部署说明.md`:部署说明已经切到版本化 Registry 发布口径,并明确客户现场使用 `release + 版本号`。 +- `docker-images/README.md`、`docker-images/minio-mc/VERSION.txt`:本地 `minio/mc:latest` 缓存已标为历史本地缓存和不可交付版本,避免被误认为当前离线包事实。 +- `docs/deployment/部署说明.md`:部署说明已经切到版本化 Registry 发布口径,明确客户现场使用 `release + 版本号`,并把发布方构建、客户首次安装、已有客户升级拆开说明。 建议动作: - 客户部署只认版本 tag,不用 latest 作为部署依据。 - 客户交付包默认排除 `deploy-new-fixed.ps1`、`发布管理.ps1`、`license-server/deploy.ps1`、`flnxi-agent/` 和旧 `.bak` 文件;仓库内保留时必须继续保持默认禁止执行。 -- 重新生成或归档 `docker-images/minio-mc/` 的旧 `latest` 本地缓存,避免离线交付时误读。 +- 正式做离线包前,重新生成或归档 `docker-images/minio-mc/` 的旧 `latest` 本地缓存,避免把历史缓存直接发给客户。 - 已完成第一轮:主发布脚本默认覆盖备份、拉镜像、迁移阻断、运行镜像/版本校验、健康检查和失败回滚。 - 仍需继续:外部数据库、外部对象存储、大数据量客户环境和离线包升级需要单独演练和说明。 @@ -1081,7 +1100,8 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器 - `components/UploadModal.tsx`:上传进度提示、封面标签、音频解析状态和进度条已从蓝色强调、圆头进度条、`transition-all` 收为中性文字、普通标签和矩形进度条;调试日志里的 emoji 也已移除。 - `components/UploadModal.tsx:1661`、`components/UploadModal.tsx:1812`:必填字段不再使用蓝色渐变背景和大阴影,改为边框/浅底提示。 - `components/EditMaterialModal.tsx`:编辑弹窗必填标识和必填输入框已是中性样式;本轮继续把预览图上传区从大圆角展示模块收为普通 `rounded-lg` 表单区,并缩短说明文案。 -- `components/AudioVisualizer.tsx`:音频默认封面和播放频谱已先从 Apple Music 式深色渐变、光晕、毛玻璃改成中性封面和低调频谱。 +- `components/AudioVisualizer.tsx`:音频默认封面和播放频谱已从 Apple Music 式深色渐变、光晕、毛玻璃、大圆形舞台频谱,收为中性封面、普通 `rounded-lg` 容器和更紧凑的条形频谱。验证命令:`npm test -- --runTestsByPath "components/__tests__/AudioVisualizer.test.tsx" --runInBand`。 +- `components/MaterialPreviewModal.tsx`:没有真实说明时不再显示“统一预览弹窗展示”占位文案,底部关闭按钮从“返回上一页”改为“关闭”,避免误导用户以为会跳转页面。 - `components/MaterialPreviewModal.tsx`:音频、视频、图片预览外壳已从大圆角/阴影/舞台式容器收为普通 `rounded-lg` 预览区;视频预览生成中状态改为普通空状态。 - `components/MaterialPreviewModal.tsx`:右侧详情标题不再优先显示素材 ID,标题字号从侧栏里的大标题收为 `text-lg`;转码状态说明已缩短。 - `components/MaterialPreviewModal.tsx`:全图查看层已去掉毛玻璃背景和圆形悬浮关闭按钮,改为普通遮罩和矩形图标按钮。 @@ -1136,6 +1156,7 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器 - `components/ui/UpgradeModal.tsx`:授权提示弹窗已改成左侧小锁图标、限制提示文案、普通描述行和普通按钮,不再显示“升级后可享受”,也不再使用大圆形图标或大圆角内容块。 - `components/ui/RestrictedToggle.tsx`:受限开关提示已改为“当前许可证未包含此功能”。 - `components/ui/ActivationModal.tsx`:手动激活弹窗已移除“前往购买”官网跳转,改为联系系统交付人员或管理员;弹窗和输入控件同步收为普通圆角、轻阴影和中性色按钮。 +- `components/ui/__tests__/ActivationModal.test.tsx`:测试已同步私有化许可证口径,不再期待官网购买链接或公网跳转。 - `components/ui/StorageEditConfirmModal.tsx`:对象存储编辑二次确认弹窗已从大圆角、重阴影和蓝紫焦点态收为普通安全确认表单。 - `components/LicenseInfo.tsx`:许可证信息面板已去掉紫色/蓝色胶囊标签、emoji 到期提醒和“购买授权”官网外链,改为中性状态标签、矩形使用量条和“联系系统管理员或交付人员”的私有化交付口径。 - `lib/license/check-limits.ts`:用户数和素材数超限提示已改为联系管理员处理授权。 @@ -1459,13 +1480,12 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器 - `deploy-new-fixed.ps1`、`发布管理.ps1`、`license-server/deploy.ps1` 已加默认停止闸门;不追加明确确认参数时不会继续执行旧服务器、旧 Harbor、许可证部署或 Docker 清理逻辑。 - `flnxi-agent/README.md` 已把 Node 版 Agent 定位改成历史实现说明,不再把 Docker 运行命令写成新客户推荐安装方式。 -仍需继续: - -- `deploy-registry.ps1` 仍是未跟踪文件状态,正式交付前要确认纳入版本管理或纳入交付包。 +- 已完成:`deploy-registry.ps1` 已纳入版本管理;之前“仍是未跟踪文件状态”的记录已过期。 +- 已完成:`docs/deployment/部署说明.md` 已把发布方构建、客户首次安装、已有客户升级拆成三段,删除生产 Compose 的误导性 `--build` 命令,并明确根目录 `docker-compose.yml` 不是客户正式安装入口。 - `scripts/build-material-image.ps1` 已加历史/内部备用说明;如要用它推送旧 Harbor,必须显式追加 `-AcknowledgeLegacyHarborPush`,避免误当客户发布入口。 - `scripts/README.md` 已补充旧 Harbor / `latest` 脚本不能直接用于客户安装或升级。 - `docker-images/README.md` 已改成离线/受控网络备用说明,不再说生产 Compose 默认从 Harbor 拉运行时镜像。 -- `docker-images/minio-mc/VERSION.txt` 和本地 tar 包仍是旧 `latest` 缓存状态,正式做离线包前需要重新导出固定版本或直接归档旧缓存。 +- `docker-images/README.md` 和 `docker-images/minio-mc/VERSION.txt` 已把现存 `minio/mc:latest` 标成“历史本地缓存,不作为正式离线交付版本”;正式做离线包前仍需要重新导出固定版本或直接归档旧缓存。 - 镜像体积偏大不是单一文件误打包,运行镜像里 canvas 运行库、字体、ffmpeg、Prisma CLI 等都是体积来源,需要单独做镜像瘦身专项。 建议动作: diff --git a/jest.setup.js b/jest.setup.js index a59240e..8343cfb 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -22,4 +22,10 @@ jest.mock('music-metadata', () => ({ container: 'unknown', }, }), + parseFile: jest.fn().mockResolvedValue({ + format: { + duration: 0, + container: 'unknown', + }, + }), }), { virtual: true }) diff --git a/lib/__tests__/audio-processor.test.ts b/lib/__tests__/audio-processor.test.ts index 2b44f06..17b7eae 100644 --- a/lib/__tests__/audio-processor.test.ts +++ b/lib/__tests__/audio-processor.test.ts @@ -4,12 +4,15 @@ */ // Get the mocked parseBuffer from jest.setup.js -const mockParseBuffer = jest.requireMock('music-metadata').parseBuffer as jest.Mock +const musicMetadataMock = jest.requireMock('music-metadata') +const mockParseBuffer = musicMetadataMock.parseBuffer as jest.Mock +const mockParseFile = musicMetadataMock.parseFile as jest.Mock import { formatDuration, isAudioFile, parseAudioMetadata, + parseAudioMetadataFromFile, SUPPORTED_AUDIO_FORMATS, AudioMetadata, } from '../audio-processor' @@ -191,6 +194,42 @@ describe('音频处理模块', () => { }) }) + describe('parseAudioMetadataFromFile', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('应该从临时文件路径解析音频元数据', async () => { + mockParseFile.mockResolvedValue({ + format: { + duration: 75, + container: 'MP3', + bitrate: 192000, + sampleRate: 48000, + numberOfChannels: 2, + }, + } as any) + + const result = await parseAudioMetadataFromFile('/tmp/audio.mp3') + + expect(mockParseFile).toHaveBeenCalledWith('/tmp/audio.mp3') + expect(result).toEqual({ + duration: 75, + durationFormatted: '01:15', + format: 'MP3', + bitrate: 192000, + sampleRate: 48000, + channels: 2, + }) + }) + + it('文件解析失败时应返回 null', async () => { + mockParseFile.mockRejectedValue(new Error('bad file')) + + await expect(parseAudioMetadataFromFile('/tmp/bad.mp3')).resolves.toBeNull() + }) + }) + describe('AudioMetadata 类型验证', () => { it('应该有正确的类型结构', () => { const mockMetadata: AudioMetadata = { diff --git a/lib/__tests__/material-log.test.ts b/lib/__tests__/material-log.test.ts index 47ed0c8..827b921 100644 --- a/lib/__tests__/material-log.test.ts +++ b/lib/__tests__/material-log.test.ts @@ -17,16 +17,10 @@ describe('material log persistence safety', () => { jest.resetAllMocks() }) - it('兜底建表会移除素材级联删除外键', async () => { + it('运行期不再兜底建表或改表', async () => { await ensureMaterialLogsTable() - const executedSql = prisma.$executeRawUnsafe.mock.calls.map(([sql]: [string]) => sql).join('\n') - - expect(executedSql).toContain('ADD COLUMN IF NOT EXISTS "materialSnapshot" JSONB') - expect(executedSql).toContain('DROP CONSTRAINT IF EXISTS "material_logs_materialId_fkey"') - expect(executedSql).not.toContain( - 'FOREIGN KEY ("materialId") REFERENCES "materials"("id") ON DELETE CASCADE' - ) + expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled() }) it('创建日志时会保存素材快照', async () => { @@ -48,5 +42,6 @@ describe('material log persistence safety', () => { }, }), }) + expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled() }) }) diff --git a/lib/audio-processor.ts b/lib/audio-processor.ts index 6d87a75..4f134e3 100644 --- a/lib/audio-processor.ts +++ b/lib/audio-processor.ts @@ -7,7 +7,8 @@ * @created 2025-12-14 */ -import { parseBuffer } from 'music-metadata' +import { parseBuffer, parseFile } from 'music-metadata' +import type { IAudioMetadata } from 'music-metadata' /** * 音频元数据 @@ -30,6 +31,19 @@ export function formatDuration(seconds: number): string { return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` } +function toAudioMetadata(metadata: IAudioMetadata): AudioMetadata { + const duration = metadata.format.duration || 0 + + return { + duration, + durationFormatted: formatDuration(duration), + format: metadata.format.container || 'unknown', + bitrate: metadata.format.bitrate, + sampleRate: metadata.format.sampleRate, + channels: metadata.format.numberOfChannels, + } +} + /** * 解析音频文件获取元数据 * @@ -43,23 +57,28 @@ export async function parseAudioMetadata( ): Promise { try { const metadata = await parseBuffer(buffer, { mimeType }) - - const duration = metadata.format.duration || 0 - - return { - duration, - durationFormatted: formatDuration(duration), - format: metadata.format.container || 'unknown', - bitrate: metadata.format.bitrate, - sampleRate: metadata.format.sampleRate, - channels: metadata.format.numberOfChannels, - } + return toAudioMetadata(metadata) } catch (error) { console.error('[AudioProcessor] Failed to parse audio metadata:', error) return null } } +/** + * 从本地临时文件解析音频元数据,避免大音频整文件进入内存 + */ +export async function parseAudioMetadataFromFile( + filePath: string +): Promise { + try { + const metadata = await parseFile(filePath) + return toAudioMetadata(metadata) + } catch (error) { + console.error('[AudioProcessor] Failed to parse audio metadata from file:', error) + return null + } +} + /** * 检查文件是否为音频文件 */ diff --git a/lib/material-log.ts b/lib/material-log.ts index a48fcab..ed3912c 100644 --- a/lib/material-log.ts +++ b/lib/material-log.ts @@ -10,58 +10,11 @@ type MaterialLogChanges = Record< type MaterialSnapshot = Record -let materialLogsTableEnsured = false - -async function executeSafely(sql: string) { - try { - await prisma.$executeRawUnsafe(sql) - } catch (error) { - console.error('[MaterialLog] Failed to execute SQL:', error) - } -} - +/** + * 历史兼容入口:日志表结构由 Prisma migration 负责,运行期不再建表/改表。 + */ export async function ensureMaterialLogsTable() { - if (materialLogsTableEnsured) return - - await executeSafely(` - CREATE TABLE IF NOT EXISTS "material_logs" ( - "id" TEXT PRIMARY KEY, - "materialId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "action" TEXT NOT NULL, - "changes" JSONB, - "materialSnapshot" JSONB, - "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `) - - await executeSafely(`ALTER TABLE "material_logs" ADD COLUMN IF NOT EXISTS "materialSnapshot" JSONB`) - - await executeSafely( - `CREATE INDEX IF NOT EXISTS "material_logs_materialId_idx" ON "material_logs" ("materialId")` - ) - await executeSafely(`CREATE INDEX IF NOT EXISTS "material_logs_userId_idx" ON "material_logs" ("userId")`) - await executeSafely( - `CREATE INDEX IF NOT EXISTS "material_logs_timestamp_idx" ON "material_logs" ("timestamp")` - ) - - await executeSafely(`ALTER TABLE "material_logs" DROP CONSTRAINT IF EXISTS "material_logs_materialId_fkey"`) - - await executeSafely(` - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'material_logs_userId_fkey' - ) THEN - ALTER TABLE "material_logs" - ADD CONSTRAINT "material_logs_userId_fkey" - FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - END IF; - END - $$; - `) - - materialLogsTableEnsured = true + return } interface CreateMaterialLogParams { @@ -80,7 +33,6 @@ export async function createMaterialLog({ materialSnapshot = null, }: CreateMaterialLogParams) { try { - await ensureMaterialLogsTable() await (prisma as any).materialLog.create({ data: { materialId, diff --git a/lib/material-permissions.ts b/lib/material-permissions.ts index 9af0744..fd90dcf 100644 --- a/lib/material-permissions.ts +++ b/lib/material-permissions.ts @@ -84,7 +84,7 @@ export async function canManageMaterial( return false } - const scopeDepartment = normalizeDepartment(actor.managedDepartment || actor.department) + const scopeDepartment = normalizeDepartment(actor.managedDepartment) const targetDepartment = normalizeDepartment(material.user?.department) if (!scopeDepartment || !targetDepartment) { return false @@ -110,7 +110,7 @@ export async function buildMaterialManagementScopeWhere( return {} } - const scopeDepartment = normalizeDepartment(actor.managedDepartment || actor.department) + const scopeDepartment = normalizeDepartment(actor.managedDepartment) const ownWhere: Prisma.MaterialWhereInput[] = [ { userId: actor.id }, { ownerType: 'USER', ownerId: actor.id },