夯实上传权限和交付地基
This commit is contained in:
parent
4d9e2e1af9
commit
39ae249ce7
@ -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
|
||||
|
||||
@ -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('授权后可新建'))
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
74
app/api/admin/material-logs/__tests__/route.test.ts
Normal file
74
app/api/admin/material-logs/__tests__/route.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
|
||||
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 } })
|
||||
|
||||
@ -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',
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<string, string>,
|
||||
files: Record<string, File | File[]>
|
||||
) {
|
||||
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<Record<string, MockFormDataValue>> = {}) {
|
||||
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,
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<Buffer>
|
||||
}
|
||||
|
||||
type ParsedUploadForm = {
|
||||
getText: (key: string) => string | null
|
||||
getFile: (key: string) => UploadFileHandle | null
|
||||
getFiles: (key: string) => UploadFileHandle[]
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
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<ParsedUploadForm> {
|
||||
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<string, string[]>()
|
||||
const fileFields = new Map<string, UploadFileHandle[]>()
|
||||
|
||||
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<ParsedUploadForm> {
|
||||
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<string, string>
|
||||
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<string, string>()
|
||||
const fileFields = new Map<string, UploadFileHandle[]>()
|
||||
|
||||
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<ParsedUploadForm> {
|
||||
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<string, any> = {}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,7 +44,6 @@ function getPublicSystemSettings(settings: SystemSettingsRow) {
|
||||
createdAt: settings.createdAt,
|
||||
headerLogoUrl: normalizeLogoUrl(settings.headerLogoUrl),
|
||||
loginLogoUrl: normalizeLogoUrl(settings.loginLogoUrl),
|
||||
enableRegistration: settings.enableRegistration,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className={cn('relative overflow-hidden rounded-2xl', containerClasses[size], className)}>
|
||||
<div className={cn('relative overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700', containerClasses[size], className)}>
|
||||
{/* 封面图层 */}
|
||||
<div
|
||||
className={cn(
|
||||
@ -286,7 +230,7 @@ export default function AudioVisualizer({
|
||||
<img
|
||||
src={coverImage}
|
||||
alt="Audio cover"
|
||||
className="w-full h-full object-cover rounded-2xl"
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<DefaultAudioCover size={size} />
|
||||
|
||||
@ -614,9 +614,9 @@ export default function MaterialPreviewModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(description || (!material && title)) && (
|
||||
{description && (
|
||||
<p className={cn('text-sm leading-relaxed', textSecondary)}>
|
||||
{description || '该素材使用统一预览弹窗展示'}
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{isVideoMaterial && currentVideoStatus !== 'done' && (
|
||||
@ -642,7 +642,7 @@ export default function MaterialPreviewModal({
|
||||
className="w-full justify-center h-11"
|
||||
onClick={onClose}
|
||||
>
|
||||
返回上一页
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -103,17 +103,17 @@ describe('AudioVisualizer 组件', () => {
|
||||
const { container: smContainer } = render(
|
||||
<AudioVisualizer audioElement={null} isPlaying={false} size="sm" />
|
||||
)
|
||||
expect(smContainer.firstChild).toHaveClass('w-40', 'h-40')
|
||||
expect(smContainer.firstChild).toHaveClass('w-40', 'h-[120px]')
|
||||
|
||||
const { container: mdContainer } = render(
|
||||
<AudioVisualizer audioElement={null} isPlaying={false} size="md" />
|
||||
)
|
||||
expect(mdContainer.firstChild).toHaveClass('w-[280px]', 'h-[280px]')
|
||||
expect(mdContainer.firstChild).toHaveClass('w-[240px]', 'h-[180px]')
|
||||
|
||||
const { container: lgContainer } = render(
|
||||
<AudioVisualizer audioElement={null} isPlaying={false} size="lg" />
|
||||
)
|
||||
expect(lgContainer.firstChild).toHaveClass('w-[400px]', 'h-[400px]')
|
||||
expect(lgContainer.firstChild).toHaveClass('w-[320px]', 'h-[220px]')
|
||||
})
|
||||
|
||||
it('应该应用自定义 className', () => {
|
||||
@ -195,8 +195,8 @@ describe('AudioVisualizer 组件', () => {
|
||||
<AudioVisualizer audioElement={null} isPlaying={false} size="md" />
|
||||
)
|
||||
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 组件', () => {
|
||||
<AudioVisualizer audioElement={null} isPlaying={false} size="lg" />
|
||||
)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -90,15 +90,15 @@ describe('ActivationModal 组件', () => {
|
||||
expect(screen.getByText('立即激活')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('应该显示购买链接', () => {
|
||||
it('应该显示私有化许可证提示', () => {
|
||||
render(
|
||||
<ActivationModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<ActivationModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('前往购买 →'))
|
||||
expect(mockOpen).toHaveBeenCalledWith('https://www.flnxi.com/pricing', '_blank')
|
||||
expect(screen.queryByText('前往购买 →')).not.toBeInTheDocument()
|
||||
expect(mockOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 客户端本机缓存 |
|
||||
|
||||
## 重要约定
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
latest - 2025-11-23
|
||||
latest - 2025-11-23(历史本地缓存,不作为正式离线交付版本)
|
||||
Image: minio/mc:latest
|
||||
Digest: sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727
|
||||
Size: ~85MB
|
||||
|
||||
@ -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=<generate-a-shared-license-request-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=<generate-a-shared-license-request-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 <APP_CONTAINER_NAME> npm run verify:template-data
|
||||
|
||||
如是全新环境,继续按 `docs/初次部署与出厂设置指南.md` 走系统初始化流程。
|
||||
|
||||
### 3.4 验证服务
|
||||
### 3.5 验证服务
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
@ -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 等都是体积来源,需要单独做镜像瘦身专项。
|
||||
|
||||
建议动作:
|
||||
|
||||
@ -22,4 +22,10 @@ jest.mock('music-metadata', () => ({
|
||||
container: 'unknown',
|
||||
},
|
||||
}),
|
||||
parseFile: jest.fn().mockResolvedValue({
|
||||
format: {
|
||||
duration: 0,
|
||||
container: 'unknown',
|
||||
},
|
||||
}),
|
||||
}), { virtual: true })
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<AudioMetadata | null> {
|
||||
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<AudioMetadata | null> {
|
||||
try {
|
||||
const metadata = await parseFile(filePath)
|
||||
return toAudioMetadata(metadata)
|
||||
} catch (error) {
|
||||
console.error('[AudioProcessor] Failed to parse audio metadata from file:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否为音频文件
|
||||
*/
|
||||
|
||||
@ -10,58 +10,11 @@ type MaterialLogChanges = Record<
|
||||
|
||||
type MaterialSnapshot = Record<string, unknown>
|
||||
|
||||
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,
|
||||
|
||||
@ -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 },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user