) : (
- 暂无预览图
+
+ {material.videoProcessingStatus === 'failed'
+ ? '预览生成失败'
+ : material.videoProcessingStatus === 'pending' || material.videoProcessingStatus === 'processing'
+ ? '预览生成中'
+ : '暂无预览图'}
+
)}
diff --git a/components/upload/special-batch-uploaders.pool.test.ts b/components/upload/special-batch-uploaders.pool.test.ts
new file mode 100644
index 0000000..3521176
--- /dev/null
+++ b/components/upload/special-batch-uploaders.pool.test.ts
@@ -0,0 +1,314 @@
+/** @jest-environment jsdom */
+
+import { chunkUpload } from '@/lib/chunk-upload'
+
+import {
+ completeBatchUpload,
+ uploadBatch3D,
+ uploadBatchBrandVI,
+ uploadBatchMusic,
+} from './special-batch-uploaders'
+
+jest.mock('@/lib/chunk-upload', () => ({
+ chunkUpload: jest.fn(),
+}))
+
+type FileItem = { file: File; productModel: string }
+type BatchResult = { productModel: string; status: string }
+type RunBatch = (files: FileItem[]) => Promise
+
+function flushMicrotasks(count = 10) {
+ return (async () => {
+ for (let index = 0; index < count; index += 1) {
+ await Promise.resolve()
+ }
+ })()
+}
+
+async function assertBoundedFilePool(
+ runBatch: RunBatch,
+ expectedStatus = 'processing',
+ expectedConcurrency = 3,
+) {
+ const files = Array.from({ length: 10 }, (_, index) => ({
+ file: new File([`file-${index}`], `file-${index}.bin`, { type: 'application/octet-stream' }),
+ productModel: `SPU-${index}`,
+ }))
+
+ let chunkCallCount = 0
+ ;(chunkUpload as jest.Mock).mockImplementation(async () => ({
+ uploadId: `upload-${chunkCallCount++}`,
+ confirmMaterialCreated: jest.fn().mockResolvedValue(undefined),
+ }))
+
+ let activeCompleteRequests = 0
+ let maxActiveCompleteRequests = 0
+ let completeCallCount = 0
+ const materialCreateUploadIds: string[] = []
+ const pendingCompleteResponses: Array<{
+ uploadId: string
+ resolve: (response: unknown) => void
+ }> = []
+
+ const fetchMock = jest.fn((url: string, init?: RequestInit) => {
+ if (url === '/api/materials/upload/complete') {
+ const body = JSON.parse(String(init?.body)) as { uploadId: string }
+ completeCallCount += 1
+ activeCompleteRequests += 1
+ maxActiveCompleteRequests = Math.max(maxActiveCompleteRequests, activeCompleteRequests)
+
+ return new Promise((resolve) => {
+ pendingCompleteResponses.push({
+ uploadId: body.uploadId,
+ resolve: (response) => {
+ activeCompleteRequests -= 1
+ resolve(response)
+ },
+ })
+ })
+ }
+
+ if (url === '/api/materials/upload') {
+ materialCreateUploadIds.push(String((init?.body as FormData).get('uploadId')))
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({ material: { id: 'material-id' } }),
+ })
+ }
+
+ return Promise.reject(new Error(`unexpected URL: ${url}`))
+ })
+ global.fetch = fetchMock as unknown as typeof fetch
+
+ const batchPromise = runBatch(files)
+ await flushMicrotasks(20)
+
+ expect(chunkUpload).toHaveBeenCalledTimes(expectedConcurrency)
+ expect(completeCallCount).toBe(expectedConcurrency)
+ expect(maxActiveCompleteRequests).toBeLessThanOrEqual(expectedConcurrency)
+
+ let safety = 0
+ while ((completeCallCount < files.length || pendingCompleteResponses.length > 0) && safety < 100) {
+ const pending = pendingCompleteResponses.shift()
+ if (pending) {
+ pending.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ existingFilePath: `uploads/${pending.uploadId}.bin`,
+ fileName: `${pending.uploadId}.bin`,
+ }),
+ })
+ }
+ await flushMicrotasks(10)
+ safety += 1
+ }
+
+ expect(completeCallCount).toBe(files.length)
+ const results = await batchPromise
+ expect(results).toHaveLength(files.length)
+ expect(results.map((result) => result.productModel)).toEqual(files.map((file) => file.productModel))
+ expect(results.every((result) => result.status === expectedStatus)).toBe(true)
+ expect(activeCompleteRequests).toBe(0)
+ expect(maxActiveCompleteRequests).toBe(expectedConcurrency)
+ expect(materialCreateUploadIds).toEqual(
+ Array.from({ length: files.length }, (_, index) => `upload-${index}`),
+ )
+}
+
+describe('特殊批量上传的文件级并发池', () => {
+ const originalFetch = global.fetch
+ let consoleLog: jest.SpyInstance
+ let consoleWarn: jest.SpyInstance
+ let consoleError: jest.SpyInstance
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined)
+ consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ global.fetch = originalFetch
+ consoleLog.mockRestore()
+ consoleWarn.mockRestore()
+ consoleError.mockRestore()
+ })
+
+ test('音乐 10 个文件最多同时处理 3 个 complete 请求', async () => {
+ await assertBoundedFilePool((files) => uploadBatchMusic(files, '', '', jest.fn()))
+ })
+
+ test('品牌 VI 10 个文件严格逐个处理,避免 PDF 解码与服务端排队叠加', async () => {
+ await assertBoundedFilePool(
+ (files) => uploadBatchBrandVI(files, '', jest.fn()),
+ 'processing',
+ 1,
+ )
+ })
+
+ test('3D 场景 10 个文件也最多同时处理 3 个 complete 请求', async () => {
+ await assertBoundedFilePool(
+ (files) => uploadBatch3D(files, '', '', '', '', jest.fn()),
+ 'success',
+ )
+ })
+
+ test('complete 遇到 503 时用同一 uploadId 重试并恢复', async () => {
+ const completeBodies: Array<{ uploadId: string; originalFileName: string }> = []
+ const fetchMock = jest.fn((url: string, init?: RequestInit) => {
+ if (url === '/api/materials/upload/complete') {
+ completeBodies.push(JSON.parse(String(init?.body)))
+ if (completeBodies.length === 1) {
+ return Promise.resolve({
+ ok: false,
+ status: 503,
+ headers: { get: () => '0' },
+ text: async () => 'temporarily busy',
+ })
+ }
+
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ existingFilePath: 'uploads/recovered.mp3',
+ fileName: 'recovered.mp3',
+ }),
+ })
+ }
+
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({ material: { id: 'material-recovered' } }),
+ })
+ })
+ global.fetch = fetchMock as unknown as typeof fetch
+
+ const result = await completeBatchUpload('upload-same-id', {
+ materialType: '音乐',
+ productModel: 'SPU-RECOVER',
+ fileName: 'song.mp3',
+ })
+
+ expect(result.material.id).toBe('material-recovered')
+ expect(completeBodies).toHaveLength(2)
+ expect(completeBodies[0].uploadId).toBe('upload-same-id')
+ expect(completeBodies[1].uploadId).toBe('upload-same-id')
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
+ '/api/materials/upload/complete',
+ '/api/materials/upload/complete',
+ '/api/materials/upload',
+ ])
+ })
+
+ test('complete 遇到磁盘空间不足 507 时不重试放大压力', async () => {
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 507,
+ headers: { get: () => null },
+ text: async () => 'disk full',
+ })
+ global.fetch = fetchMock as unknown as typeof fetch
+
+ await expect(
+ completeBatchUpload('upload-storage-low', {
+ materialType: '音乐',
+ productModel: 'SPU-LOW',
+ fileName: 'song.mp3',
+ }),
+ ).rejects.toThrow('分片合并失败: 507')
+
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ test('第二阶段响应丢失时只查 owner-scoped 状态,不盲目再次 POST', async () => {
+ const fetchMock = jest.fn((url: string, init?: RequestInit) => {
+ if (url === '/api/materials/upload/complete') {
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ existingFilePath: 'uploads/temp/upload-response-lost/merged-song.mp3',
+ fileName: 'song.mp3',
+ }),
+ })
+ }
+
+ if (url === '/api/materials/upload' && init?.method === 'POST') {
+ const formData = init.body as FormData
+ expect(formData.get('uploadId')).toBe('upload-response-lost')
+ return Promise.reject(new TypeError('connection closed after request'))
+ }
+
+ if (url === '/api/materials/upload?uploadId=upload-response-lost') {
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ status: 'completed',
+ material: { id: 'material-already-created' },
+ }),
+ })
+ }
+
+ return Promise.reject(new Error(`unexpected URL: ${url}`))
+ })
+ global.fetch = fetchMock as unknown as typeof fetch
+
+ const result = await completeBatchUpload('upload-response-lost', {
+ materialType: '音乐',
+ productModel: 'SPU-LOST',
+ fileName: 'song.mp3',
+ })
+
+ expect(result.material.id).toBe('material-already-created')
+ expect(fetchMock.mock.calls.filter(([url, init]) => (
+ url === '/api/materials/upload' && (init as RequestInit | undefined)?.method === 'POST'
+ ))).toHaveLength(1)
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
+ '/api/materials/upload/complete',
+ '/api/materials/upload',
+ '/api/materials/upload?uploadId=upload-response-lost',
+ ])
+ })
+
+ test('临时目录已删的断点重放通过状态确认,不再创建第二份素材', async () => {
+ const fetchMock = jest.fn((url: string) => {
+ if (url === '/api/materials/upload/complete') {
+ return Promise.resolve({
+ ok: false,
+ status: 404,
+ text: async () => 'session missing',
+ })
+ }
+ if (url === '/api/materials/upload?uploadId=upload-temp-deleted') {
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ status: 'completed',
+ material: { id: 'material-existing' },
+ }),
+ })
+ }
+ return Promise.reject(new Error(`unexpected URL: ${url}`))
+ })
+ global.fetch = fetchMock as unknown as typeof fetch
+
+ const result = await completeBatchUpload('upload-temp-deleted', {
+ materialType: '品牌VI灵感',
+ productModel: 'SPU-EXISTING',
+ fileName: 'brand.pdf',
+ })
+
+ expect(result.material.id).toBe('material-existing')
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
+ '/api/materials/upload/complete',
+ '/api/materials/upload?uploadId=upload-temp-deleted',
+ ])
+ })
+})
diff --git a/components/upload/special-batch-uploaders.ts b/components/upload/special-batch-uploaders.ts
index f640847..20fafaf 100644
--- a/components/upload/special-batch-uploaders.ts
+++ b/components/upload/special-batch-uploaders.ts
@@ -85,27 +85,202 @@ async function base64ToBlob(base64Data: string): Promise {
})
}
+// complete 接口服务端只允许 1 个处理中请求并排队少量后续请求。
+// 文件级并发保持很小,单个文件内部的 chunkUpload 并发仍由其自身策略控制。
+const SPECIAL_BATCH_FILE_CONCURRENCY = 3
+const BRAND_VI_FILE_CONCURRENCY = 1
+const COMPLETE_MAX_ATTEMPTS = 3
+const MATERIAL_CREATE_TIMEOUT_MS = 15 * 60 * 1000
+// PDF 工具自身最多运行 10 分钟;单文件串行后仍需覆盖一个在途请求和排队开销。
+const BRAND_VI_MATERIAL_CREATE_TIMEOUT_MS = 30 * 60 * 1000
+const MATERIAL_STATUS_REQUEST_TIMEOUT_MS = 15 * 1000
+const MATERIAL_STATUS_POLL_INTERVAL_MS = 2 * 1000
+
+async function mapWithConcurrency(
+ items: T[],
+ concurrency: number,
+ mapper: (item: T, index: number) => Promise,
+): Promise {
+ if (items.length === 0) {
+ return []
+ }
+
+ const results = new Array(items.length)
+ let nextIndex = 0
+
+ const worker = async () => {
+ while (true) {
+ const index = nextIndex
+ nextIndex += 1
+
+ if (index >= items.length) {
+ return
+ }
+
+ results[index] = await mapper(items[index], index)
+ }
+ }
+
+ const workerCount = Math.min(Math.max(1, concurrency), items.length)
+ await Promise.all(Array.from({ length: workerCount }, () => worker()))
+ return results
+}
+
+function isRetryableCompleteStatus(status: number): boolean {
+ // 507 是上传盘不足,501 是服务不支持;立即重试只会放大压力。
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504
+}
+
+function getRetryAfterDelayMs(value: string | null | undefined): number | null {
+ if (!value) {
+ return null
+ }
+
+ const seconds = Number(value)
+ if (Number.isFinite(seconds) && seconds >= 0) {
+ return Math.ceil(seconds * 1000)
+ }
+
+ const retryAt = Date.parse(value)
+ return Number.isNaN(retryAt) ? null : Math.max(0, retryAt - Date.now())
+}
+
+function waitForRetry(delayMs: number): Promise {
+ if (delayMs <= 0) {
+ return Promise.resolve()
+ }
+
+ return new Promise((resolve) => setTimeout(resolve, delayMs))
+}
+
+function getDefaultRetryDelayMs(attempt: number): number {
+ return Math.min(2_000, 500 * (2 ** Math.max(0, attempt - 1)))
+}
+
+type MaterialUploadStatusResult = {
+ status?: 'completed' | 'processing' | 'not_found'
+ material?: Record
+ error?: string
+}
+
+async function recoverAmbiguousMaterialCreation(
+ uploadId: string,
+ waitTimeoutMs: number,
+ originalError: unknown,
+): Promise> {
+ const deadline = Date.now() + waitTimeoutMs
+
+ while (true) {
+ let response: Response
+ try {
+ response = await fetchWithTimeout(
+ `/api/materials/upload?uploadId=${encodeURIComponent(uploadId)}`,
+ {
+ method: 'GET',
+ timeout: MATERIAL_STATUS_REQUEST_TIMEOUT_MS,
+ },
+ )
+ } catch (statusError) {
+ throw new Error(
+ `创建素材请求中断,且状态查询失败;已保留上传断点,请稍后重试确认(${
+ statusError instanceof Error ? statusError.message : '网络错误'
+ })`,
+ )
+ }
+
+ if (response.status === 404) {
+ throw new Error('创建素材请求中断,服务器尚未确认建档;已保留上传断点,请稍后重试')
+ }
+
+ const statusResult = await response.json().catch(() => ({})) as MaterialUploadStatusResult
+ if (!response.ok) {
+ throw new Error(
+ statusResult.error || `创建素材请求中断,状态查询失败: ${response.status}`,
+ )
+ }
+
+ if (statusResult.status === 'completed' && statusResult.material) {
+ console.log(`[CompleteBatchUpload] 状态查询确认建档完成: ${uploadId}`)
+ return {
+ message: '上传成功',
+ material: statusResult.material,
+ replayed: true,
+ }
+ }
+
+ if (statusResult.status !== 'processing' || Date.now() >= deadline) {
+ const reason = originalError instanceof Error ? originalError.message : '网络连接中断'
+ throw new Error(`创建素材结果尚未确认(${reason});已保留上传断点,请稍后重试`)
+ }
+
+ await waitForRetry(Math.min(MATERIAL_STATUS_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())))
+ }
+}
+
export async function completeBatchUpload(uploadId: string, metadata: Record) {
const fileName = metadata.fileName || metadata.originalFileName || 'unknown'
console.log(`[CompleteBatchUpload] 开始处理: ${fileName}, uploadId: ${uploadId}`)
try {
// 第一步:合并分片(设置5分钟超时)
- console.log(`[CompleteBatchUpload] 调用complete端点: ${uploadId}`)
- const completeResponse = await fetchWithTimeout('/api/materials/upload/complete', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- uploadId,
- originalFileName: fileName,
- }),
- timeout: 300000, // 5分钟超时
- })
+ let completeResponse: Response | null = null
+ for (let attempt = 1; attempt <= COMPLETE_MAX_ATTEMPTS; attempt += 1) {
+ console.log(`[CompleteBatchUpload] 调用complete端点: ${uploadId}(第${attempt}次)`)
+ try {
+ completeResponse = await fetchWithTimeout('/api/materials/upload/complete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ uploadId,
+ originalFileName: fileName,
+ }),
+ timeout: 300000, // 5分钟超时
+ })
+ } catch (error) {
+ if (attempt >= COMPLETE_MAX_ATTEMPTS) {
+ throw error
+ }
+ const delayMs = getDefaultRetryDelayMs(attempt)
+ console.warn(
+ `[CompleteBatchUpload] complete请求中断,将用同一uploadId重试: ${uploadId}, `
+ + `等待${delayMs}ms`,
+ error,
+ )
+ await waitForRetry(delayMs)
+ continue
+ }
+
+ if (completeResponse.ok) {
+ break
+ }
- if (!completeResponse.ok) {
const errorText = await completeResponse.text().catch(() => 'Unknown error')
- console.error(`[CompleteBatchUpload] complete端点返回错误: ${completeResponse.status}, ${errorText}`)
- throw new Error(`分片合并失败: ${completeResponse.status}`)
+ const canRetry = isRetryableCompleteStatus(completeResponse.status) && attempt < COMPLETE_MAX_ATTEMPTS
+ if (!canRetry) {
+ if (completeResponse.status === 404) {
+ // 建档成功后会删除临时目录;若浏览器在删除 IndexedDB 前退出,
+ // 下一次恢复会先看到 complete=404。此时只查 owner-scoped 结果。
+ return await recoverAmbiguousMaterialCreation(
+ uploadId,
+ 0,
+ new Error('上传临时目录已清理'),
+ )
+ }
+ console.error(`[CompleteBatchUpload] complete端点返回错误: ${completeResponse.status}, ${errorText}`)
+ throw new Error(`分片合并失败: ${completeResponse.status}`)
+ }
+
+ const retryAfter = getRetryAfterDelayMs(completeResponse.headers?.get?.('Retry-After'))
+ ?? getDefaultRetryDelayMs(attempt)
+ console.warn(
+ `[CompleteBatchUpload] complete端点暂时不可用,将重试: ${completeResponse.status}, `
+ + `等待${retryAfter}ms`,
+ )
+ await waitForRetry(retryAfter)
+ }
+
+ if (!completeResponse || !completeResponse.ok) {
+ throw new Error('分片合并失败')
}
const completeResult = await completeResponse.json()
@@ -161,13 +336,25 @@ export async function completeBatchUpload(uploadId: string, metadata: Record ({ error: `HTTP ${uploadResponse.status}` }))
@@ -201,56 +388,56 @@ export async function uploadBatchMusic(
renderer: renderer,
}
- const results = await Promise.all(
- files.map(async (item, index) => {
- try {
- const metadata = {
- ...baseMetadata,
- productModel: item.productModel,
- fileName: item.file.name,
- }
+ const results = await mapWithConcurrency(files, SPECIAL_BATCH_FILE_CONCURRENCY, async (item, index) => {
+ try {
+ const metadata = {
+ ...baseMetadata,
+ productModel: item.productModel,
+ fileName: item.file.name,
+ }
- const result = await chunkUpload(item.file, metadata, {
- // chunkSize使用默认配置:内网20MB,公网10MB
- onProgress: (progress) => {
- const totalProgress = Math.round((100 * index + progress) / files.length)
- onProgress(totalProgress)
- },
- skipComplete: true,
- maxRetries: 5,
- })
+ const result = await chunkUpload(item.file, metadata, {
+ // chunkSize使用默认配置:内网20MB,公网10MB
+ onProgress: (progress) => {
+ const totalProgress = Math.round((100 * index + progress) / files.length)
+ onProgress(totalProgress)
+ },
+ skipComplete: true,
+ retainStateOnSkipComplete: true,
+ maxRetries: 5,
+ })
- // 等待completeBatchUpload完成,确保素材记录被创建
- if (result?.uploadId) {
- try {
- await completeBatchUpload(result.uploadId, metadata)
- } catch (err) {
- console.error(`[Upload] 音乐文件 ${item.file.name} 后台处理失败:`, err)
- throw err // 重新抛出错误,让上层知道失败
- }
- }
-
- return {
- tempId: `pending-${Date.now()}-${index}`,
- uploadId: result?.uploadId || null,
- productModel: item.productModel,
- fileName: item.file.name,
- fileSize: item.file.size,
- status: 'processing',
- }
- } catch (error) {
- console.error(`[Upload] 音乐文件 ${item.file.name} 上传失败:`, error)
- return {
- tempId: `pending-${Date.now()}-${index}`,
- uploadId: null,
- productModel: item.productModel,
- fileName: item.file.name,
- fileSize: item.file.size,
- status: 'failed',
+ // 等待completeBatchUpload完成,确保素材记录被创建
+ if (result?.uploadId) {
+ try {
+ await completeBatchUpload(result.uploadId, metadata)
+ await result.confirmMaterialCreated?.()
+ } catch (err) {
+ console.error(`[Upload] 音乐文件 ${item.file.name} 后台处理失败:`, err)
+ throw err // 重新抛出错误,让上层知道失败
}
}
- })
- )
+
+ return {
+ tempId: `pending-${Date.now()}-${index}`,
+ uploadId: result?.uploadId || null,
+ productModel: item.productModel,
+ fileName: item.file.name,
+ fileSize: item.file.size,
+ status: 'processing' as const,
+ }
+ } catch (error) {
+ console.error(`[Upload] 音乐文件 ${item.file.name} 上传失败:`, error)
+ return {
+ tempId: `pending-${Date.now()}-${index}`,
+ uploadId: null,
+ productModel: item.productModel,
+ fileName: item.file.name,
+ fileSize: item.file.size,
+ status: 'failed' as const,
+ }
+ }
+ })
return results
}
@@ -313,6 +500,7 @@ export async function uploadBatchVideo(
onProgress(totalProgress)
},
skipComplete: true,
+ retainStateOnSkipComplete: true,
maxRetries: 5,
})
@@ -320,6 +508,7 @@ export async function uploadBatchVideo(
if (result?.uploadId) {
try {
await completeBatchUpload(result.uploadId, metadata)
+ await result.confirmMaterialCreated?.()
} catch (err) {
console.error(`[Upload] 视频文件 ${item.file.name} 后台处理失败:`, err)
throw err // 重新抛出错误,让上层知道失败
@@ -369,8 +558,10 @@ export async function uploadBatch3D(
renderer: renderer,
}
- const results = await Promise.all(
- files.map(async (item, index) => {
+ const results = await mapWithConcurrency(
+ files,
+ SPECIAL_BATCH_FILE_CONCURRENCY,
+ async (item, index) => {
try {
const metadata: any = {
...baseMetadata,
@@ -391,6 +582,7 @@ export async function uploadBatch3D(
onProgress(totalProgress)
},
skipComplete: true,
+ retainStateOnSkipComplete: true,
maxRetries: 5,
})
@@ -398,6 +590,7 @@ export async function uploadBatch3D(
// 等待completeBatchUpload完成,确保素材记录被创建
try {
await completeBatchUpload(result.uploadId, metadata)
+ await result.confirmMaterialCreated?.()
} catch (err) {
console.error(`[Upload] 3D场景文件 ${item.file.name} 后台处理失败:`, err)
throw err // 重新抛出错误,让上层知道失败
@@ -423,7 +616,7 @@ export async function uploadBatch3D(
status: 'failed',
}
}
- })
+ },
)
return results
@@ -448,71 +641,79 @@ export async function uploadBatchBuyerShow(
renderer: renderer,
}
- const results = await Promise.all(
- batches.map(async (item, index) => {
- try {
- const metadata: any = {
- ...baseMetadata,
- productModel: item.productModel,
- }
+ const results: Array<{
+ tempId: string
+ uploadId: string | null
+ productModel: string
+ status: 'processing' | 'failed'
+ }> = []
- // 买家秀类型直接调用upload接口创建素材记录(不需要分片上传)
- const formData = new FormData()
- formData.append('productModel', item.productModel)
- formData.append('categoryName', metadata.categoryName)
- formData.append('sceneName', metadata.sceneName)
- formData.append('platformName', metadata.platformName || '')
- formData.append('productCategoryName', metadata.productCategoryName || '')
- const materialType = metadata.materialType
- formData.append('materialType', materialType)
-
- // 统一处理renderer字段:根据素材类型判断是否需要传递
- const rendererInfo = shouldPassRenderer(materialType, metadata.renderer)
- if (rendererInfo.shouldPass) {
- formData.append('renderer', rendererInfo.value)
- }
-
- // 添加预览图和模特图(预览图需要压缩)
- if (item.images.length > 0) {
- const compressedPreview = await compressPreviewImage(item.images[0])
- formData.append('previewImage', compressedPreview)
- // 其他图片作为模特图
- for (let i = 1; i < item.images.length; i++) {
- formData.append('modelImages', item.images[i])
- }
- }
+ // 一个买家秀批次可能包含多张原图。逐批提交可避免多个大 multipart
+ // 同时进入 Node.js 并占满内存;单批内仍保留原图质量和原有字段语义。
+ for (let index = 0; index < batches.length; index += 1) {
+ const item = batches[index]
+ try {
+ const metadata: any = {
+ ...baseMetadata,
+ productModel: item.productModel,
+ }
- const response = await fetch('/api/materials/upload', {
- method: 'POST',
- body: formData,
- })
+ // 买家秀类型直接调用upload接口创建素材记录(不需要分片上传)
+ const formData = new FormData()
+ formData.append('productModel', item.productModel)
+ formData.append('categoryName', metadata.categoryName)
+ formData.append('sceneName', metadata.sceneName)
+ formData.append('platformName', metadata.platformName || '')
+ formData.append('productCategoryName', metadata.productCategoryName || '')
+ const materialType = metadata.materialType
+ formData.append('materialType', materialType)
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}))
- throw new Error(errorData.error || `上传失败: ${response.status}`)
- }
+ // 统一处理renderer字段:根据素材类型判断是否需要传递
+ const rendererInfo = shouldPassRenderer(materialType, metadata.renderer)
+ if (rendererInfo.shouldPass) {
+ formData.append('renderer', rendererInfo.value)
+ }
- const result = await response.json()
- const totalProgress = Math.round(((index + 1) * 100) / batches.length)
- onProgress(totalProgress)
-
- return {
- tempId: `pending-${Date.now()}-${index}`,
- uploadId: result.material?.id || null,
- productModel: item.productModel,
- status: 'processing',
- }
- } catch (error) {
- console.error(`[Upload] 买家秀批次 ${item.productModel} 上传失败:`, error)
- return {
- tempId: `pending-${Date.now()}-${index}`,
- uploadId: null,
- productModel: item.productModel,
- status: 'failed',
+ // 添加预览图和模特图(预览图需要压缩)
+ if (item.images.length > 0) {
+ const compressedPreview = await compressPreviewImage(item.images[0])
+ formData.append('previewImage', compressedPreview)
+ // 其他图片作为模特图
+ for (let i = 1; i < item.images.length; i++) {
+ formData.append('modelImages', item.images[i])
}
}
- })
- )
+
+ const response = await fetch('/api/materials/upload', {
+ method: 'POST',
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || `上传失败: ${response.status}`)
+ }
+
+ const result = await response.json()
+ const totalProgress = Math.round(((index + 1) * 100) / batches.length)
+ onProgress(totalProgress)
+
+ results.push({
+ tempId: `pending-${Date.now()}-${index}`,
+ uploadId: result.material?.id || null,
+ productModel: item.productModel,
+ status: 'processing',
+ })
+ } catch (error) {
+ console.error(`[Upload] 买家秀批次 ${item.productModel} 上传失败:`, error)
+ results.push({
+ tempId: `pending-${Date.now()}-${index}`,
+ uploadId: null,
+ productModel: item.productModel,
+ status: 'failed',
+ })
+ }
+ }
return results
}
@@ -536,71 +737,79 @@ export async function uploadBatchXiaohongshu(
renderer: renderer,
}
- const results = await Promise.all(
- batches.map(async (item, index) => {
- try {
- const metadata: any = {
- ...baseMetadata,
- productModel: item.productModel,
- }
+ const results: Array<{
+ tempId: string
+ uploadId: string | null
+ productModel: string
+ status: 'processing' | 'failed'
+ }> = []
- // 小红书类型直接调用upload接口创建素材记录(不需要分片上传)
- const formData = new FormData()
- formData.append('productModel', item.productModel)
- formData.append('categoryName', metadata.categoryName)
- formData.append('sceneName', metadata.sceneName)
- formData.append('platformName', metadata.platformName || '')
- formData.append('productCategoryName', metadata.productCategoryName || '')
- const materialType = metadata.materialType
- formData.append('materialType', materialType)
-
- // 统一处理renderer字段:根据素材类型判断是否需要传递
- const rendererInfo = shouldPassRenderer(materialType, metadata.renderer)
- if (rendererInfo.shouldPass) {
- formData.append('renderer', rendererInfo.value)
- }
-
- // 添加预览图和模特图(预览图需要压缩)
- if (item.images.length > 0) {
- const compressedPreview = await compressPreviewImage(item.images[0])
- formData.append('previewImage', compressedPreview)
- // 其他图片作为模特图
- for (let i = 1; i < item.images.length; i++) {
- formData.append('modelImages', item.images[i])
- }
- }
+ // 与买家秀保持同一安全边界:逐批提交,避免多个图片 multipart
+ // 同时解析;某一批失败不会阻止后续批次继续。
+ for (let index = 0; index < batches.length; index += 1) {
+ const item = batches[index]
+ try {
+ const metadata: any = {
+ ...baseMetadata,
+ productModel: item.productModel,
+ }
- const response = await fetch('/api/materials/upload', {
- method: 'POST',
- body: formData,
- })
+ // 小红书类型直接调用upload接口创建素材记录(不需要分片上传)
+ const formData = new FormData()
+ formData.append('productModel', item.productModel)
+ formData.append('categoryName', metadata.categoryName)
+ formData.append('sceneName', metadata.sceneName)
+ formData.append('platformName', metadata.platformName || '')
+ formData.append('productCategoryName', metadata.productCategoryName || '')
+ const materialType = metadata.materialType
+ formData.append('materialType', materialType)
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}))
- throw new Error(errorData.error || `上传失败: ${response.status}`)
- }
+ // 统一处理renderer字段:根据素材类型判断是否需要传递
+ const rendererInfo = shouldPassRenderer(materialType, metadata.renderer)
+ if (rendererInfo.shouldPass) {
+ formData.append('renderer', rendererInfo.value)
+ }
- const result = await response.json()
- const totalProgress = Math.round(((index + 1) * 100) / batches.length)
- onProgress(totalProgress)
-
- return {
- tempId: `pending-${Date.now()}-${index}`,
- uploadId: result.material?.id || null,
- productModel: item.productModel,
- status: 'processing',
- }
- } catch (error) {
- console.error(`[Upload] 小红书批次 ${item.productModel} 上传失败:`, error)
- return {
- tempId: `pending-${Date.now()}-${index}`,
- uploadId: null,
- productModel: item.productModel,
- status: 'failed',
+ // 添加预览图和模特图(预览图需要压缩)
+ if (item.images.length > 0) {
+ const compressedPreview = await compressPreviewImage(item.images[0])
+ formData.append('previewImage', compressedPreview)
+ // 其他图片作为模特图
+ for (let i = 1; i < item.images.length; i++) {
+ formData.append('modelImages', item.images[i])
}
}
- })
- )
+
+ const response = await fetch('/api/materials/upload', {
+ method: 'POST',
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || `上传失败: ${response.status}`)
+ }
+
+ const result = await response.json()
+ const totalProgress = Math.round(((index + 1) * 100) / batches.length)
+ onProgress(totalProgress)
+
+ results.push({
+ tempId: `pending-${Date.now()}-${index}`,
+ uploadId: result.material?.id || null,
+ productModel: item.productModel,
+ status: 'processing',
+ })
+ } catch (error) {
+ console.error(`[Upload] 小红书批次 ${item.productModel} 上传失败:`, error)
+ results.push({
+ tempId: `pending-${Date.now()}-${index}`,
+ uploadId: null,
+ productModel: item.productModel,
+ status: 'failed',
+ })
+ }
+ }
return results
}
@@ -622,56 +831,56 @@ export async function uploadBatchBrandVI(
productCategoryName: productCategoryName || '',
}
- const results = await Promise.all(
- files.map(async (item, index) => {
- try {
- const metadata = {
- ...baseMetadata,
- productModel: item.productModel,
- fileName: item.file.name,
- }
+ const results = await mapWithConcurrency(files, BRAND_VI_FILE_CONCURRENCY, async (item, index) => {
+ try {
+ const metadata = {
+ ...baseMetadata,
+ productModel: item.productModel,
+ fileName: item.file.name,
+ }
- const result = await chunkUpload(item.file, metadata, {
- // chunkSize使用默认配置:内网20MB,公网10MB
- onProgress: (progress) => {
- const totalProgress = Math.round((100 * index + progress) / files.length)
- onProgress(totalProgress)
- },
- skipComplete: true,
- maxRetries: 5,
- })
+ const result = await chunkUpload(item.file, metadata, {
+ // chunkSize使用默认配置:内网20MB,公网10MB
+ onProgress: (progress) => {
+ const totalProgress = Math.round((100 * index + progress) / files.length)
+ onProgress(totalProgress)
+ },
+ skipComplete: true,
+ retainStateOnSkipComplete: true,
+ maxRetries: 5,
+ })
- // 等待completeBatchUpload完成,确保素材记录被创建
- if (result?.uploadId) {
- try {
- await completeBatchUpload(result.uploadId, metadata)
- } catch (err) {
- console.error(`[Upload] PDF文件 ${item.file.name} 后台处理失败:`, err)
- throw err // 重新抛出错误,让上层知道失败
- }
- }
-
- return {
- tempId: `pending-${Date.now()}-${index}`,
- uploadId: result?.uploadId || null,
- productModel: item.productModel,
- fileName: item.file.name,
- fileSize: item.file.size,
- status: 'processing',
- }
- } catch (error) {
- console.error(`[Upload] PDF文件 ${item.file.name} 上传失败:`, error)
- return {
- tempId: `pending-${Date.now()}-${index}`,
- uploadId: null,
- productModel: item.productModel,
- fileName: item.file.name,
- fileSize: item.file.size,
- status: 'failed',
+ // 等待completeBatchUpload完成,确保素材记录被创建
+ if (result?.uploadId) {
+ try {
+ await completeBatchUpload(result.uploadId, metadata)
+ await result.confirmMaterialCreated?.()
+ } catch (err) {
+ console.error(`[Upload] PDF文件 ${item.file.name} 后台处理失败:`, err)
+ throw err // 重新抛出错误,让上层知道失败
}
}
- })
- )
+
+ return {
+ tempId: `pending-${Date.now()}-${index}`,
+ uploadId: result?.uploadId || null,
+ productModel: item.productModel,
+ fileName: item.file.name,
+ fileSize: item.file.size,
+ status: 'processing' as const,
+ }
+ } catch (error) {
+ console.error(`[Upload] PDF文件 ${item.file.name} 上传失败:`, error)
+ return {
+ tempId: `pending-${Date.now()}-${index}`,
+ uploadId: null,
+ productModel: item.productModel,
+ fileName: item.file.name,
+ fileSize: item.file.size,
+ status: 'failed' as const,
+ }
+ }
+ })
return results
}
diff --git a/components/upload/special-batch-uploaders.video-preview.test.ts b/components/upload/special-batch-uploaders.video-preview.test.ts
index 5a9a936..07324ba 100644
--- a/components/upload/special-batch-uploaders.video-preview.test.ts
+++ b/components/upload/special-batch-uploaders.video-preview.test.ts
@@ -2,8 +2,13 @@
import { chunkUpload } from '@/lib/chunk-upload'
-import { completeBatchUpload, uploadBatchVideo } from './special-batch-uploaders'
-import { compressVideoPreviewImage } from './image-processor'
+import {
+ completeBatchUpload,
+ uploadBatchBuyerShow,
+ uploadBatchVideo,
+ uploadBatchXiaohongshu,
+} from './special-batch-uploaders'
+import { compressPreviewImage, compressVideoPreviewImage } from './image-processor'
jest.mock('@/lib/chunk-upload', () => ({
chunkUpload: jest.fn(),
@@ -127,3 +132,88 @@ describe('uploadBatchVideo preview image contract', () => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})
+
+describe('image-only batch upload memory boundary', () => {
+ const originalFetch = global.fetch
+
+ beforeEach(() => {
+ ;(compressPreviewImage as jest.Mock).mockImplementation(async (file: File) => file)
+ })
+
+ afterEach(() => {
+ global.fetch = originalFetch
+ jest.clearAllMocks()
+ })
+
+ test('买家秀必须等上一批请求完成后才提交下一批', async () => {
+ const firstResponse = createDeferred()
+ const fetchMock = jest.fn()
+ .mockReturnValueOnce(firstResponse.promise)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ material: { id: 'buyer-2' } }),
+ })
+ global.fetch = fetchMock as unknown as typeof fetch
+
+ const progress: number[] = []
+ const uploadPromise = uploadBatchBuyerShow(
+ [
+ { productModel: 'BUYER-1', images: [new File(['one'], 'one.jpg', { type: 'image/jpeg' })] },
+ { productModel: 'BUYER-2', images: [new File(['two'], 'two.jpg', { type: 'image/jpeg' })] },
+ ],
+ '',
+ '',
+ '',
+ '',
+ (value) => progress.push(value),
+ )
+
+ for (let index = 0; index < 10 && fetchMock.mock.calls.length < 1; index += 1) {
+ await Promise.resolve()
+ }
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+
+ firstResponse.resolve({
+ ok: true,
+ json: async () => ({ material: { id: 'buyer-1' } }),
+ })
+ const results = await uploadPromise
+
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(results.map((item) => item.productModel)).toEqual(['BUYER-1', 'BUYER-2'])
+ expect(results.map((item) => item.status)).toEqual(['processing', 'processing'])
+ expect(progress).toEqual([50, 100])
+ })
+
+ test('小红书某一批失败后仍按顺序继续下一批', async () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ const fetchMock = jest.fn()
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 413,
+ json: async () => ({ error: '图片批次过大' }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ material: { id: 'redbook-2' } }),
+ })
+ global.fetch = fetchMock as unknown as typeof fetch
+
+ const results = await uploadBatchXiaohongshu(
+ [
+ { productModel: 'RED-1', images: [new File(['one'], 'one.jpg', { type: 'image/jpeg' })] },
+ { productModel: 'RED-2', images: [new File(['two'], 'two.jpg', { type: 'image/jpeg' })] },
+ ],
+ '',
+ '',
+ '',
+ '',
+ jest.fn(),
+ )
+
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(results.map((item) => item.productModel)).toEqual(['RED-1', 'RED-2'])
+ expect(results.map((item) => item.status)).toEqual(['failed', 'processing'])
+ consoleError.mockRestore()
+ })
+})
diff --git a/components/work-review/WorkReviewDialogs.tsx b/components/work-review/WorkReviewDialogs.tsx
index c92bf42..28154a1 100644
--- a/components/work-review/WorkReviewDialogs.tsx
+++ b/components/work-review/WorkReviewDialogs.tsx
@@ -1,5 +1,7 @@
'use client'
+// LEGACY_READ_ONLY: 旧域名仅用于展示历史审核素材。
+
import Image from 'next/image'
import {
type ReactNode,
diff --git a/components/work-review/WorkReviewImageGallery.tsx b/components/work-review/WorkReviewImageGallery.tsx
index 2655ba5..c858659 100644
--- a/components/work-review/WorkReviewImageGallery.tsx
+++ b/components/work-review/WorkReviewImageGallery.tsx
@@ -1,5 +1,7 @@
'use client'
+// LEGACY_READ_ONLY: 旧域名仅用于展示历史审核素材。
+
import Image from 'next/image'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
diff --git a/components/work-review/WorkReviewProjectCard.tsx b/components/work-review/WorkReviewProjectCard.tsx
index c962ba2..e815f99 100644
--- a/components/work-review/WorkReviewProjectCard.tsx
+++ b/components/work-review/WorkReviewProjectCard.tsx
@@ -1,5 +1,7 @@
'use client'
+// LEGACY_READ_ONLY: 旧域名仅用于展示历史审核素材。
+
import Image from 'next/image'
import { useEffect, useMemo, useState } from 'react'
import type {
diff --git a/components/work-review/WorkReviewReviewerWorkspace.tsx b/components/work-review/WorkReviewReviewerWorkspace.tsx
index 6e9a787..db1bae4 100644
--- a/components/work-review/WorkReviewReviewerWorkspace.tsx
+++ b/components/work-review/WorkReviewReviewerWorkspace.tsx
@@ -1,5 +1,7 @@
'use client'
+// LEGACY_READ_ONLY: 旧域名仅用于展示历史审核素材。
+
import Image from 'next/image'
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
diff --git a/docker-compose.shadow.yml b/docker-compose.shadow.yml
index 67071f6..14bfdb3 100644
--- a/docker-compose.shadow.yml
+++ b/docker-compose.shadow.yml
@@ -82,10 +82,16 @@ services:
- DINGTALK_ROBOT_CODE=${DINGTALK_ROBOT_CODE:-}
- DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL=${DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL:-work_notice}
- DINGTALK_ROBOT_OTO_TEST_USER_IDS=${DINGTALK_ROBOT_OTO_TEST_USER_IDS:-}
- - VIDEO_TRANSCODE_CODEC=libx265
- - VIDEO_TRANSCODE_PRESET=medium
- - VIDEO_TRANSCODE_CRF=22
- - VIDEO_AUDIO_BITRATE=160k
+ # 视频转码配置:默认 720P H.265、10 秒片段;单实例单任务、单线程
+ - VIDEO_PREVIEW_RESOLUTION=720
+ - VIDEO_CLIP_DURATION=10
+ - VIDEO_PREVIEW_CRF=20
+ - VIDEO_CLIP_CRF=24
+ - VIDEO_TRANSCODE_THREADS=1
+ - VIDEO_TRANSCODE_MAX_CONCURRENT=1
+ - VIDEO_TRANSCODE_TIMEOUT=3600000
+ - VIDEO_MAX_TEMP_FILE_SIZE=10737418240
+ - VIDEO_TRANSCODE_RUNTIME_DIR=/app/uploads/.video-transcode-runtime
# 下载 Token 密钥(用于外部接口文件下载鉴权)
- DOWNLOAD_TOKEN_SECRET=${SHADOW_DOWNLOAD_TOKEN_SECRET:?SHADOW_DOWNLOAD_TOKEN_SECRET is required}
# Redis配置(影子系统使用独立的 Redis 实例)
diff --git a/docker-compose.yml b/docker-compose.yml
index 0c6c47e..0e3efeb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,6 @@
# 当前生产发布不使用本文件作为入口。
-# 生产发布以 scripts/git-incremental-deploy.ps1 为准;本文件仅作本地/单机/基础环境参考。
+# 生产发布以 scripts/git-incremental-deploy.ps1 为准;本文件仅作单机/基础环境参考。
+# 本文件仍按生产数据安全边界配置:必须显式传入真实 uploads 宿主机挂载,禁止回落到 ./uploads。
# 不要按此文件推断当前线上容器启动参数、uploads 挂载或健康检查。
services:
app:
@@ -30,6 +31,7 @@ services:
start_period: 40s
environment:
- NODE_ENV=production
+ - UPLOAD_DIR=/app/uploads
- DATABASE_URL=${DATABASE_URL:?DATABASE_URL is required}
- DATABASE_DRIVER=node
- KEY_VAULTS_SECRET=${KEY_VAULTS_SECRET:?KEY_VAULTS_SECRET is required}
@@ -67,18 +69,28 @@ services:
- YYD_NOTIFICATION_CENTER_PROJECT_CODE=${YYD_NOTIFICATION_CENTER_PROJECT_CODE:-material_library}
- YYD_NOTIFICATION_CENTER_PROJECT_SECRET=${YYD_NOTIFICATION_CENTER_PROJECT_SECRET:-}
- YYD_NOTIFICATION_CENTER_TEST_USER_IDS=${YYD_NOTIFICATION_CENTER_TEST_USER_IDS:-}
- # 视频转码配置
- - VIDEO_TRANSCODE_CODEC=libx265
- - VIDEO_TRANSCODE_PRESET=medium
- - VIDEO_TRANSCODE_CRF=22
- - VIDEO_AUDIO_BITRATE=160k
+ # 视频转码配置:默认 720P H.265、10 秒片段;单实例单任务、单线程
+ - VIDEO_PREVIEW_RESOLUTION=720
+ - VIDEO_CLIP_DURATION=10
+ - VIDEO_PREVIEW_CRF=20
+ - VIDEO_CLIP_CRF=24
+ - VIDEO_TRANSCODE_THREADS=1
+ - VIDEO_TRANSCODE_MAX_CONCURRENT=1
+ - VIDEO_TRANSCODE_TIMEOUT=3600000
+ - VIDEO_MAX_TEMP_FILE_SIZE=10737418240
+ - VIDEO_TRANSCODE_RUNTIME_DIR=/app/uploads/.video-transcode-runtime
# 下载 Token 密钥(用于外部接口文件下载鉴权)
- DOWNLOAD_TOKEN_SECRET=${DOWNLOAD_TOKEN_SECRET:?DOWNLOAD_TOKEN_SECRET is required}
# Redis配置(用于上传会话存储)
- USE_REDIS=true
- REDIS_URL=redis://redis:6379
volumes:
- - ./uploads:/app/uploads
+ - type: bind
+ source: ${GONGXIANGP2_UPLOADS_MOUNT:?GONGXIANGP2_UPLOADS_MOUNT must point to the verified host uploads mount}
+ target: /app/uploads
+ bind:
+ # 路径写错或真实挂载未准备好时必须失败,不能静默创建空目录继续运行。
+ create_host_path: false
networks:
- app-network
depends_on:
diff --git a/docs/AI生图平台第一层桶缓存落地方案.md b/docs/AI生图平台第一层桶缓存落地方案.md
index 19fbd0f..ce8a7b2 100644
--- a/docs/AI生图平台第一层桶缓存落地方案.md
+++ b/docs/AI生图平台第一层桶缓存落地方案.md
@@ -70,7 +70,7 @@ AI 生图平台
- 展示图对象优先写入 `/gongxiangpan/public-display/`
- `uploads` 保留为历史混合区,不作为默认展示资源路径
-- 源文件 / ZIP / PSD / 视频源等下载型对象继续落在生产机 uploads:`/mnt/PoolOne/apps/youyidian-3d/uploads`
+- 源文件 / ZIP / PSD / 视频源等下载型对象继续落在生产机独立 ZFS uploads 数据集:`/mnt/PoolOne/youyidian-uploads`
- `s3.1080p.hk/jsxiangche` 只保留极少数老素材兼容,不作为新素材或新预览写入目标
- 当前不再以 `X-Cache-Status` 作为正式验证标准
- 当前优先验证 `s3.jiansun.vip -> 218.93.9.38 -> 192.168.58.20:9000` 是否可达
diff --git a/docs/Git增量部署方案.md b/docs/Git增量部署方案.md
index 119c0e2..94ad935 100644
--- a/docs/Git增量部署方案.md
+++ b/docs/Git增量部署方案.md
@@ -25,7 +25,7 @@
默认部署:
-- Git 仓库:`http://git.1080p.hk/WuYongJie/gongxiangp2.git`
+- Git 仓库:`https://git.1080p.hk/WuYongJie/gongxiangp2.git`(证书必须有效,禁止跳过 TLS 校验)
- 分支:`main`
- 构建机:`192.168.40.206`
- 生产机:`192.168.58.20`
@@ -44,7 +44,8 @@
- 构建机能访问 Git 仓库和生产机 `192.168.58.20`。
- 构建机本地 Registry 端口 `5000` 可用。
- 已通过环境变量或参数提供构建机密码:`BUILD_SERVER_PASSWORD` 或 `-BuildServerPassword`。
-- 私有 Git 仓库认证通过 `GIT_DEPLOY_USERNAME` / `GIT_DEPLOY_PASSWORD`,或 `GIT_AUTH_HEADER` 提供;不要把密码写进仓库 URL。
+- 已分别固定构建机和生产机的 SSH SHA256 主机指纹;OpenSSH 模式还必须使用预置、只读的 `known_hosts` 文件。
+- 私有 Git 仓库认证必须预先配置在维护机和构建机的只读 credential helper 中。脚本拒绝 `GIT_DEPLOY_PASSWORD`、`GIT_AUTH_HEADER`、明文 HTTP 和 URL 内嵌凭据。
## 安全边界
@@ -52,6 +53,7 @@
- 不在生产机上执行 `npm install` 或 `next build`。
- 不改生产机的 `.env` 和 uploads 目录。
- 不把 Git 密码写入仓库或远端 URL。
+- Git HTTPS 证书失效、SSH 指纹不匹配或仓库地址偏离权威地址时,发布必须停止;不得使用 `-k` 或关闭主机校验绕过。
- 生产容器启动失败时,默认尝试回滚到旧镜像。
- 清理策略只清理无用镜像/容器,默认保留 Docker build cache。
@@ -79,11 +81,13 @@ DryRun 不会改代码、构建镜像或重启生产容器。
- `production-`:可回滚的确定版本
- `production-latest`:正式环境最新版本
-影子环境对应:
+影子环境的历史标签为:
- `shadow-`
- `shadow-latest`
+当前通用发布脚本会在任何 Git 扫描或远程操作前拒绝 `Target=shadow`,因为影子环境尚未证明 uploads、后台 runtime 和 Redis 与生产完全隔离。不得把这些历史标签说明理解为可执行发布入口。
+
## 回滚思路
脚本启动新容器前会记录旧容器镜像。如果新容器健康检查失败,默认会重新用旧镜像启动容器。
diff --git a/docs/NETWORK_TOPOLOGY.md b/docs/NETWORK_TOPOLOGY.md
index 22612f7..fb045df 100644
--- a/docs/NETWORK_TOPOLOGY.md
+++ b/docs/NETWORK_TOPOLOGY.md
@@ -77,10 +77,12 @@
- 首期不做历史对象全量迁移。
- 新生成展示图直接写 `public-display`,不再指望 `uploads` 承担正式展示路径。
-### 3.3 明确不作为展示路径的大对象
+### 3.3 不纳入 `public-display` 展示图/缓存规则的大对象
-- 视频源文件
-- 视频预览大文件
+视频预览、预览片段和封面仍然会写入 S3 并通过对应的媒体 URL 提供播放;本节只说明它们不使用图片展示资源的 `public-display` 前缀和缓存规则。
+
+- 视频源文件(canonical 源仍在生产机 uploads/NAS)
+- 视频预览大文件(对象仍在 S3,但不走 `public-display` 前缀)
- 压缩包
- 其它下载型源文件
diff --git a/docs/存储域名与品牌资源事故复盘-2026-08-11.md b/docs/存储域名与品牌资源事故复盘-2026-08-11.md
new file mode 100644
index 0000000..cf54a32
--- /dev/null
+++ b/docs/存储域名与品牌资源事故复盘-2026-08-11.md
@@ -0,0 +1,81 @@
+# 存储域名与品牌资源事故复盘(2026-08-11)
+
+## 结论
+
+生产 Header Logo 的旧地址不是 2026 年近期修复首次写入的,而是 2025 年遗留配置。近期系统设置安全重构虽然没有制造这条历史数据,但沿用了过期默认值,也没有识别“展示代理地址可能被管理页再次写回数据库”的问题,因此同类故障仍可能复发。
+
+本次治理把存储引用分成两类:
+
+- `LEGACY_READ_ONLY`:只允许读取、代理或迁移历史素材。
+- 当前写入:默认值、新上传、恢复默认、系统设置和新数据库记录只能使用本地 `/brand/...` 或当前 `s3.jiansun.vip/gongxiangpan/...`。
+
+2026-08-12 13:13(Asia/Shanghai)重新只读核查当前正式数据库,并与公开 `/api/system-settings` 结果逐字段比对:唯一设置行的 Header / Login Logo 已是 `https://s3.jiansun.vip/gongxiangpan/...` 当前可信桶地址,用户指出的旧精确 Header URL 已不在现有行中;两个公网对象均返回 HTTP 200、`image/png`,浏览器实际解码为 800×800,并在正式首页显示为 40×40 图标。因此“现有行仍保存旧地址”已不再是当前事实。
+
+但这不等于数据库治理已经完成:同一只读核查显示 `headerLogoUrl` / `loginLogoUrl` 的数据库列默认值仍是退役 `http://s3.1080p.hk/...`,本轮三条目标 migration 也尚无已完成记录。仓库 migration 会在已有 `system_settings` 历史基线上补齐列、修正未来默认值,并且只把品牌两列中匹配退役前缀的值改为本地 `/brand/...`;当前可信桶中的合法自定义 Logo 不会被覆盖。仓库更早的历史 migration 本身不能从空数据库完整重放,本轮没有把这件事包装成“全新建库已修复”。
+
+## 误导来源
+
+1. Prisma 的 Logo 默认值仍指向旧域名。
+2. 系统设置行自动创建时仍使用旧默认值。
+3. 后台“恢复默认”按钮会重新保存旧地址。
+4. 管理员 GET 返回展示代理 URL,页面保存其它设置时可能把代理 URL 当原值写回。
+5. favicon 上游失败后返回透明 1×1 图片,接口看似成功但用户看不到 Logo。
+6. Header、登录、注册、完善资料页没有稳定的本地图片回退。
+7. 在线历史恢复接口会把历史桶 URL 直接写入新素材。
+8. 旧项目记忆把已经下线的资源域名描述为新素材正式入口。
+9. 现有存储迁移文档只核对素材预览,没有把 Header、登录 Logo、favicon、后台默认值和 Schema 默认值列入验收。
+10. Git 历史中曾存在包含旧生产事实与敏感配置的报告;工作树删除不能消除历史风险,需单独纳入秘密扫描和凭据风险登记,不能在当前修复中贸然改写远端历史。
+
+### Git 时间线证据
+
+- `3fff87c`(2025-10-27)引入 Logo 分离上传;生产旧 Header 文件名中的时间戳也落在这一天。
+- `2b7cd7f`(2025-12-27)的历史数据库报告已记录用户本次指出的精确 Header URL,证明它不是 2026-07 的安全修复首次写入。
+- `3cba791` / `7c913b6`(2026-07-30/31)的系统设置脱敏修复没有主动更新生产 Logo,但仍复制了旧 Schema 默认值、API 默认值和后台重置值。这是本轮必须承认的遗漏:修复了凭据泄漏,却没有同时清理品牌资源的旧写入路径。
+- `d69eb6e`(2026-08-07)只处理视频手动预览图,不涉及 Logo,也没有解决视频队列跨部署恢复。
+
+因此,事故来源不是“某一次新提交把当前生产行直接改成旧 Header”,而是历史脏值长期存在,加上后续修复没有铲除所有复发入口。两部分都必须处理,不能只修数据库或只加页面回退。
+
+| 文件 / 资料 | 造成的错误判断或复发风险 | 本次处理 |
+| --- | --- | --- |
+| `prisma/schema.prisma` | 新建设置行时可能自动填入旧 Logo | 改为本地品牌默认值并新增 migration |
+| `app/api/system-settings/route.ts` | 设置不存在时写旧默认;管理员读取展示代理地址后可能再写回 | canonical 与 preview 分离,PUT 严格校验 |
+| `app/admin/settings/page.tsx` | “恢复默认”重新选择旧域名 | 改为 `/brand/...` |
+| `app/api/favicon/route.ts` | 旧地址失败后用透明 1×1 冒充成功 | 改为真实本地 favicon |
+| `components/Header.tsx`、登录/注册/完善资料页 | 远端图坏时没有稳定回退,alt 被挤成竖排坏图文字 | 统一使用一次性回退组件 |
+| `app/api/system-settings/upload-logo/route.ts` | 只信浏览器 MIME、未限制大小、上传后未确认对象已持久化且元数据一致,权限口径也不一致 | 限制字节/像素/尺寸并完整解码、核对 MIME、带凭据 HEAD 核对大小和类型后再写库、统一系统管理员权限 |
+| `app/api/restore-data/route.ts` | 在线流程直接把旧桶 URL 写进新素材 | 永久停用在线写入入口 |
+| `.kiro/steering/项目记忆库.md` | 把已下线资源域名写成新素材正式入口,并用“老素材不改”覆盖了系统 Logo | 更新当前拓扑,明确 Logo 不属于老素材 |
+| `.kiro/specs/production-system-analysis.md` | 2025 年快照外观像当前正式说明 | 顶部标记为历史快照并指向当前文档 |
+| 存储迁移文档 | 只验收素材,遗漏站点品牌资源和设置往返 | 补齐迁移门禁 |
+| Git 历史中的旧生产报告 | 既会误导当前判断,也存在历史秘密风险 | 登记风险;当前不贸然改写远端历史 |
+
+## 已采取的代码门禁
+
+- 构建前执行 `npm run storage:legacy-boundary`。
+- 旧域名只允许出现在明确列出的历史只读兼容代码、兼容性测试和不可修改的历史 migration 中。
+- 在线历史恢复 API 永久停用;历史恢复改走显式来源、显式目标、默认 dry-run、先迁移对象再写库的离线流程。
+- 品牌资源使用镜像内本地文件作为最终回退。
+
+该门禁由两层组成:普通构建会扫描进入 Docker 构建上下文的源码、数据库 migration 和常见配置;正式增量发布会在工作站完整仓库上先扫描全部受支持源码/脚本,再允许联网构建。普通说明文档、未知扩展名及 Git 历史仍需独立审阅与秘密扫描,不能把规则匹配当成语义证明或历史清理工具。
+
+## 合法的历史兼容范围
+
+以下用途仍可识别旧域名,但不得生成新的旧域名数据:
+
+- 媒体代理与 URL 改写
+- 工作审核和考核调研中的历史素材展示
+- 历史资源网络探测
+- 旧对象迁移到当前桶的离线工具
+- 对应的兼容性测试与不可变历史 migration
+
+新增允许项必须在代码评审中说明用途并加入显式 allowlist,不能用“老素材兼容”作为默认值、上传目标、重置值或新写入的理由。
+
+## 后续验收
+
+1. 当前行值和首页显示已验收;仍须执行受控 migration,并确认三个目标 migration 均有成功记录、Logo 两列默认值已改为 `/brand/...`,再逐行复核只存在本地或当前可信桶地址。
+2. Logo 配置 URL 404、超时或返回 HTML 时,所有入口仍显示可见本地品牌图。
+3. 管理员只改颜色/维护通知并保存,不得改变数据库中的 canonical Logo URL。
+4. 新建系统设置行和“恢复默认”均不得出现旧域名。
+5. 构建门禁能阻止旧域名重新进入非只读代码;唯一受控写引用只能是本 migration 的精确品牌数据清洗 SQL。
+6. 当前桶切换或存储迁移必须一并核对素材预览、品牌资源、favicon、设置 API、默认值和重置值。
+7. Logo 上传验收必须再从公网 URL 实际 GET,确认 HTTP 200、图片 Content-Type 和浏览器可解码;带凭据 HEAD 成功不能替代用户侧可见性验收。
diff --git a/docs/生产图片视频内存事故与处理记录-2026-07-31.md b/docs/生产图片视频内存事故与处理记录-2026-07-31.md
index b0bb2dc..8f34ea2 100644
--- a/docs/生产图片视频内存事故与处理记录-2026-07-31.md
+++ b/docs/生产图片视频内存事故与处理记录-2026-07-31.md
@@ -18,7 +18,7 @@
| 时间(北京时间) | 生产证据 | 业务对应 |
| --- | --- | --- |
| 7 月 30 日 15:18 | `libvips worker` 触发 `global_oom`;内核杀掉 `next-server`,其匿名 RSS 约 1.405GiB | 同时段任务详情完成接口进入 `sharp.metadata()`、`sliceImage/compressImage`,Nginx 随后出现 502 |
-| 7 月 30 日 16:18 | 同一应用容器同时有两个 `ffmpeg`,RSS 约 642MiB 和 372–408MiB;之后 MinIO 也被杀 | 同时段连续创建淘系视频任务;转码队列未配置时默认并发为 2 |
+| 7 月 30 日 16:18 | 同一应用容器同时有两个 `ffmpeg`,RSS 约 642MiB 和 372–408MiB;之后 MinIO 也被杀 | **事故时旧实现**未配置转码并发,默认并发为 2;后续已固定为同一容器 1 条任务、FFmpeg 1 线程(见下文) |
| 7 月 30 日 17:39 | `libvips worker` 再次触发 `global_oom`;同时还有一个约 325MiB 的 `ffmpeg`,最终 `next-server` 被杀 | 图片任务详情和抖音视频任务叠加,随后大量 502/504 |
三次事件都显示:宿主机没有 Swap,且是 `global_oom`,不是应用容器自己的 cgroup OOM。
diff --git a/docs/生产存储桶接入信息.md b/docs/生产存储桶接入信息.md
index d93dd0a..15d53ab 100644
--- a/docs/生产存储桶接入信息.md
+++ b/docs/生产存储桶接入信息.md
@@ -6,7 +6,7 @@
## 1. 当前结论
- 新素材预览 / 展示文件:写入生产 MinIO 新桶路径,公开 URL 统一为 `https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/...`。
-- 源文件 / 下载型文件:写入生产服务器 uploads 目录:`/mnt/PoolOne/apps/youyidian-3d/uploads`。
+- 源文件 / 下载型文件:写入生产服务器独立 ZFS uploads 数据集:`/mnt/PoolOne/youyidian-uploads`。历史路径 `/mnt/PoolOne/apps/youyidian-3d/uploads` 仅作人工明确的兼容选项,不再是默认值。
- 旧 `1080p.hk` 桶:只用于极少数老素材兼容,不作为新素材写入目标。
- `/gongxiangpan/uploads/` 是历史混合区,不再作为新展示资源的默认路径。
@@ -77,7 +77,7 @@ https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/...
生产 uploads 目录:
```text
-/mnt/PoolOne/apps/youyidian-3d/uploads
+/mnt/PoolOne/youyidian-uploads
```
容器内路径:
@@ -110,13 +110,13 @@ https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/...
- Windows 维护机读取 `Z:\...` 源素材。
- 预览图上传到新桶 `gongxiangpan` 的 `由壹点AI/public-display/...`。
-- ZIP / PSD / 源文件先在本机生成,再通过远程同步写入生产机 `/mnt/PoolOne/apps/youyidian-3d/uploads`,并校验远程文件大小。
+- ZIP / PSD / 源文件先在本机生成,再通过远程同步写入生产机 `/mnt/PoolOne/youyidian-uploads`,并校验远程文件大小。
正式 `--apply` 如果在 Windows 上连接生产库,必须同时配置:
```env
HISTORY_IMPORT_REMOTE_UPLOADS_HOST=prod
-HISTORY_IMPORT_REMOTE_UPLOADS_DIR=/mnt/PoolOne/apps/youyidian-3d/uploads
+HISTORY_IMPORT_REMOTE_UPLOADS_DIR=/mnt/PoolOne/youyidian-uploads
```
没有远程 uploads 同步时,脚本必须阻止正式导入。否则会把数据库写到生产库,但 ZIP / PSD 留在本机 `uploads`,线上下载会 404。
@@ -131,7 +131,19 @@ HISTORY_IMPORT_REMOTE_UPLOADS_DIR=/mnt/PoolOne/apps/youyidian-3d/uploads
`s3.yydd.ai` 不再作为默认对接口径,只用于识别历史链接。
-### 6.3 历史混合区
+### 6.3 品牌资源迁移门禁
+
+存储切换不能只核对素材预览。每次迁移或域名切换必须同时核对:
+
+- `SystemSettings.headerLogoUrl` 与 `loginLogoUrl`
+- 首页 Header、登录页、注册页、完善资料页
+- `/api/favicon`
+- 后台“恢复默认”值与 Prisma 默认值
+- 管理员 GET → 不修改 Logo → PUT 往返不会把 `/api/proxy-media?...` 写回数据库
+
+上述配置只能使用镜像内 `/brand/...` 或当前 `https://s3.jiansun.vip/gongxiangpan/...`;旧域名仅允许历史素材只读兼容。
+
+### 6.4 历史混合区
`/gongxiangpan/uploads/` 保留历史兼容,不再作为新展示图默认路径。
@@ -152,7 +164,7 @@ ssh root@192.168.58.20 "docker inspect minio --format '{{json .Mounts}}'"
核对 uploads 目录:
```powershell
-ssh root@192.168.58.20 "ls -la /mnt/PoolOne/apps/youyidian-3d/uploads | head -20"
+ssh root@192.168.58.20 "ls -la /mnt/PoolOne/youyidian-uploads | head -20"
```
核对资源域名解析:
diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md
new file mode 100644
index 0000000..2eff074
--- /dev/null
+++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md
@@ -0,0 +1,381 @@
+# 生产数据库安全增量迁移 SOP(2026-08-12)
+
+## 结论
+
+本次应用发布依赖四份增量 migration。普通蓝绿部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
+
+目标 migration:
+
+- `20260811_add_video_transcode_lease`
+- `20260811_update_brand_logo_defaults`
+- `20260812_add_product_package_upload_job_lease`
+- `20260812_align_material_upload_idempotency_index`
+
+它们只新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引。索引迁移会先检测未删除素材中的重复 `uploadId`,发现重复就直接失败,不会自行改写或删除素材。迁移不会批量修改素材数据,也不会覆盖当前可信桶中的合法自定义 Logo。没有自动 down migration。执行失败时停止发布;恢复依赖执行前的数据库备份。
+
+## 强制前提
+
+1. 使用将要发布的**精确 40 位 Git SHA**;在新建的隔离检出目录构建,不清理或复用现有工作树。
+2. 已用 `pg_dump --format=custom` 创建生产数据库备份,并在隔离数据库做过真实恢复验证;备份产物与 receipt 一起只读挂载。
+3. 明确生产数据库的配置 host/port/database/user,以及数据库实际返回的 `inet_server_addr()` / `inet_server_port()`;不允许从旧文档猜测。
+4. 本轮首次旧镜像发布必须先把 runner 与 migrator 固定到同一个精确 SHA/digest;先按 `scripts/legacy-worker-bootstrap-deploy.ps1` 完成 Audit,再 Apply 进入外部实测 503 维护态,并证明旧容器已停止但仍保留可回退。只有这些证据全部成立,才允许设置 `CONFIRM_MAINTENANCE_WINDOW=NO_WRITES_DURING_SNAPSHOT`。
+5. 不使用历史 `execute-migration.ps1/.sh`;它们不登记本轮 Prisma migration。
+
+不能以“当前无人使用”“已经通知大家”或只改一个维护开关代替 503 和旧容器停止证据。正确顺序固定为:
+
+1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
+2. legacy bootstrap `Audit`;
+3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
+4. 在同一维护窗内运行本文的真实备份、隔离恢复、四条 migration 和迁移后审计;
+5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
+
+顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
+
+## 1. 构建并传输精确 runner 与 migrator
+
+runner 和 migrator 必须来自同一个 `TARGET_SHA`,但它们是两个不同的镜像,必须分别记录 image ID/digest。普通发布脚本的 `-SkipDeploy` 只构建并推送 runner,**不会**构建 migrator。
+
+### 1.1 先准备 runner
+
+在正式维护窗之前,用普通发布脚本的生产参数和 `-SkipDeploy` 构建、推送 runner。脚本会固定 Git SHA、基础镜像 digest、最终 image ID 和 registry manifest digest,但不会连接生产机或改流量。记录它输出的 runner `repo@sha256:...` 和 image ID;生产机只能按该 digest 预拉取并复核,不能按可变 tag 猜测。
+
+### 1.2 独立构建 migrator
+
+在构建机使用一次性的隔离 clone。下面不会执行 `reset --hard` 或 `clean`,因此不会破坏构建机已有工作区:
+
+```bash
+set -Eeuo pipefail
+TARGET_SHA=<40位目标SHA>
+SOURCE_REPO=<仓库地址>
+BUILD_ROOT="$(mktemp -d /root/gongxiangp2-migrator-build.XXXXXX)"
+trap 'rm -rf -- "$BUILD_ROOT"' EXIT
+git clone --no-checkout --branch main --single-branch "$SOURCE_REPO" "$BUILD_ROOT/repo"
+git -C "$BUILD_ROOT/repo" checkout --detach "$TARGET_SHA"
+test "$(git -C "$BUILD_ROOT/repo" rev-parse HEAD)" = "$TARGET_SHA"
+test -z "$(git -C "$BUILD_ROOT/repo" status --porcelain --untracked-files=all)"
+cd "$BUILD_ROOT/repo"
+DOCKER_BUILDKIT=1 docker build \
+ --target migrator \
+ --build-arg VCS_REF="$TARGET_SHA" \
+ -t "youyidian-3d-migrator:$TARGET_SHA" .
+docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
+ "youyidian-3d-migrator:$TARGET_SHA"
+MIGRATOR_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "youyidian-3d-migrator:$TARGET_SHA")"
+MIGRATOR_BAKED_SHA="$(docker run --rm --network none --read-only \
+ --entrypoint cat "youyidian-3d-migrator:$TARGET_SHA" /app/DEPLOYMENT_COMMIT)"
+test "$MIGRATOR_BAKED_SHA" = "$TARGET_SHA"
+printf '%s\n' "$MIGRATOR_IMAGE_ID"
+```
+
+revision label 和 baked SHA 必须精确等于目标 SHA。`MIGRATOR_IMAGE_ID` 是独立于 runner 的值,二者不得混用。
+
+### 1.3 通过已固定主机指纹的 SSH 传输 migrator
+
+不能经未加密 HTTP registry 或临时关闭 SSH 主机校验。下面使用构建机上已经由发布脚本预置并核验过的生产 `known_hosts`;实际用户名、主机名和文件路径按现场受控配置填写:
+
+```bash
+set -Eeuo pipefail
+MIGRATOR_IMAGE="youyidian-3d-migrator:$TARGET_SHA"
+MIGRATOR_ARCHIVE="$(mktemp /root/gongxiangp2-migrator-${TARGET_SHA}.XXXXXX.tar)"
+trap 'rm -f -- "$MIGRATOR_ARCHIVE"' EXIT
+docker save --output "$MIGRATOR_ARCHIVE" "$MIGRATOR_IMAGE"
+MIGRATOR_ARCHIVE_SHA256="$(sha256sum "$MIGRATOR_ARCHIVE" | awk '{print $1}')"
+test -n "$MIGRATOR_ARCHIVE_SHA256"
+
+SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=yes \
+ -o UserKnownHostsFile=/root/.ssh/gongxiangp2-production-known_hosts)
+PRODUCTION_SSH=<受控用户@生产主机>
+REMOTE_ARCHIVE="/root/gongxiangp2-migrator-${TARGET_SHA}.tar"
+
+scp "${SSH_OPTS[@]}" -- "$MIGRATOR_ARCHIVE" "$PRODUCTION_SSH:$REMOTE_ARCHIVE"
+ssh "${SSH_OPTS[@]}" "$PRODUCTION_SSH" bash -s -- \
+ "$REMOTE_ARCHIVE" "$MIGRATOR_ARCHIVE_SHA256" "$MIGRATOR_IMAGE" \
+ "$MIGRATOR_IMAGE_ID" "$TARGET_SHA" <<'PRODUCTION_VERIFY'
+set -Eeuo pipefail
+archive="$1"; expected_archive_sha="$2"; image="$3"; expected_image_id="$4"; expected_sha="$5"
+trap 'rm -f -- "$archive"' EXIT
+[ -f "$archive" ] && [ ! -L "$archive" ]
+[ "$(stat -c '%a' "$archive")" = "600" ]
+[ "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_archive_sha" ]
+docker load --input "$archive" >/tmp/gongxiangp2-migrator-load.log
+[ "$(docker image inspect --format '{{.Id}}' "$image")" = "$expected_image_id" ]
+[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image")" = "$expected_sha" ]
+[ "$(docker run --rm --network none --read-only --entrypoint cat "$image" /app/DEPLOYMENT_COMMIT)" = "$expected_sha" ]
+PRODUCTION_VERIFY
+```
+
+`scp` 目标目录必须预先由 root 创建且不可由其他账号写入;传输完成后生产端会重新核对 archive SHA、migrator image ID、OCI revision 和 baked commit。任一不一致都在进入维护窗前停止。
+
+## 2. 用工具生成可核验的备份凭证
+
+禁止手写或补填 receipt。`scripts/create-verified-production-backup.mjs` 会在同一次受控运行中自动完成:
+
+1. 只读核对生产库与隔离库的配置身份和数据库实际身份;
+2. 在生产库读取迁移表、素材、用户、系统设置、产品包作业的有界摘要;
+3. 用 `pg_dump --format=custom` 写入本次唯一的 `.partial` 文件,刷新到磁盘后原子改名;
+4. 再读一次生产摘要。维护窗内前后摘要不同就停止,不生成 receipt;
+5. 在第二道明确授权下,只清空符合命名规则的隔离库 `public` schema,再用 `pg_restore` 恢复同一备份;
+6. 在 30 秒 statement timeout 和每表最多 32 条样本的边界内,对比关键表计数与样本摘要;
+7. 全部一致后,原子生成 receipt,并输出 receipt 自身 SHA-256。
+
+### 2.1 准备真正隔离的恢复目标
+
+隔离目标必须同时满足:
+
+- 数据库名匹配 `gongxiangp2_restore_<唯一小写后缀>`;
+- 数据库名与生产库不同;
+- 配置 host/port 与生产不同;
+- `inet_server_addr()` / `inet_server_port()` 返回的实际 PostgreSQL 服务器也与生产不同;
+- 只供本次恢复验证,账号允许在这个隔离库内重建 `public` schema,不拥有生产库写权限。
+
+因此,**同一台生产 PostgreSQL 上另建一个数据库也不合格**。先进入维护窗口,停止会修改关键业务表的入口;工具的前后摘要只是近似验证,不能代替维护窗口。
+
+### 2.2 准备两份 root-only 环境文件
+
+实际密码、证书路径和连接值不写在命令、Git、聊天或运行日志里。由密码管理工具在生产机生成两份 `0600` 环境文件;下面只列变量名,不展示值:
+
+- 生产连接:`PRODUCTION_PGHOST`、`PRODUCTION_PGPORT`、`PRODUCTION_PGDATABASE`、`PRODUCTION_PGUSER`,以及 `PRODUCTION_PGPASSWORD` 或 `PRODUCTION_PGPASSFILE`;
+- 生产期望身份:`EXPECTED_DATABASE_HOST`、`EXPECTED_DATABASE_PORT`、`EXPECTED_DATABASE_NAME`、`EXPECTED_DATABASE_USER`、`EXPECTED_DATABASE_SERVER_ADDRESS`、`EXPECTED_DATABASE_SERVER_PORT`;
+- 隔离连接:`ISOLATED_PGHOST`、`ISOLATED_PGPORT`、`ISOLATED_PGDATABASE`、`ISOLATED_PGUSER`,以及 `ISOLATED_PGPASSWORD` 或 `ISOLATED_PGPASSFILE`;
+- 隔离期望身份:`EXPECTED_ISOLATED_DATABASE_HOST`、`EXPECTED_ISOLATED_DATABASE_PORT`、`EXPECTED_ISOLATED_DATABASE_NAME`、`EXPECTED_ISOLATED_DATABASE_USER`、`EXPECTED_ISOLATED_DATABASE_SERVER_ADDRESS`、`EXPECTED_ISOLATED_DATABASE_SERVER_PORT`。
+
+若现场要求 TLS,可在各自前缀下增加 `PGSSLMODE`、`PGSSLROOTCERT`、`PGSSLCERT`、`PGSSLKEY`。`PGPASSFILE` 和证书若是宿主机路径,还要只读挂载到容器内,并在环境文件中填写容器内路径。
+
+期望的 server address/port 必须来自当次现场只读查询。使用已安全注入的标准 `PG*` 环境变量执行,不在命令行传密码或连接 URL:
+
+```bash
+psql --no-psqlrc --no-align --tuples-only --set=ON_ERROR_STOP=1 <<'SQL'
+SELECT current_database(), current_user,
+ COALESCE(inet_server_addr()::text, ''),
+ COALESCE(inet_server_port()::text, '');
+SQL
+```
+
+### 2.3 先运行备份工具的只读审计
+
+下面的网络必须能访问生产 PostgreSQL 和真正隔离的 PostgreSQL,但隔离账号不得拥有生产写权限:
+
+```bash
+set -Eeuo pipefail
+TARGET_SHA=<40位目标SHA>
+MIGRATOR_IMAGE="youyidian-3d-migrator:$TARGET_SHA"
+PRODUCTION_BACKUP_ENV=/root/secure/gongxiangp2-production-backup.env
+ISOLATED_RESTORE_ENV=/root/secure/gongxiangp2-isolated-restore.env
+
+test "$(stat -c '%a' "$PRODUCTION_BACKUP_ENV")" = 600
+test "$(stat -c '%a' "$ISOLATED_RESTORE_ENV")" = 600
+docker run --rm \
+ --network <可同时访问两套数据库的受控网络> \
+ --env-file "$PRODUCTION_BACKUP_ENV" \
+ --env-file "$ISOLATED_RESTORE_ENV" \
+ -e EXPECTED_COMMIT_SHA="$TARGET_SHA" \
+ --entrypoint node \
+ "$MIGRATOR_IMAGE" \
+ scripts/create-verified-production-backup.mjs --audit
+```
+
+审计模式只执行客户端版本探测和两套身份查询,不创建 dump、不清空隔离库、不恢复、不生成 receipt。输出只包含去密后的身份;任何字段不一致都先修正环境文件,不能绕过。
+
+### 2.4 显式创建并验证备份
+
+为本次发布创建一个全新的宿主机目录。工具会拒绝符号链接、已有同名文件和非目录;不会复用历史 receipt:
+
+```bash
+set -Eeuo pipefail
+BACKUP_BATCH="$(date -u +%Y%m%dT%H%M%SZ)-${TARGET_SHA}"
+HOST_BACKUP_DIR="/root/gongxiangp2-db-backup-receipt/$BACKUP_BATCH"
+HOST_RESULT_DIR="/root/gongxiangp2-migration-audit/$BACKUP_BATCH"
+HOST_RESULT_FILE="$HOST_RESULT_DIR/verified-backup-result.txt"
+MIGRATOR_UID="$(docker run --rm --entrypoint id "$MIGRATOR_IMAGE" -u)"
+install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" "$HOST_BACKUP_DIR"
+install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" "$HOST_RESULT_DIR"
+test ! -L "$HOST_BACKUP_DIR"
+test -z "$(find "$HOST_BACKUP_DIR" -mindepth 1 -maxdepth 1 -print -quit)"
+test ! -e "$HOST_RESULT_FILE"
+
+docker run --rm \
+ --network <可同时访问两套数据库的受控网络> \
+ --env-file "$PRODUCTION_BACKUP_ENV" \
+ --env-file "$ISOLATED_RESTORE_ENV" \
+ -e EXPECTED_COMMIT_SHA="$TARGET_SHA" \
+ -e BACKUP_OUTPUT_DIR=/backup \
+ -e ALLOW_VERIFIED_BACKUP=CREATE_AND_RESTORE \
+ -e ALLOW_ISOLATED_DATABASE_RESET=RESET_VERIFIED_ISOLATED_TARGET \
+ -e CONFIRM_MAINTENANCE_WINDOW=NO_WRITES_DURING_SNAPSHOT \
+ -v "$HOST_BACKUP_DIR:/backup" \
+ --entrypoint node \
+ "$MIGRATOR_IMAGE" \
+ scripts/create-verified-production-backup.mjs --create-and-restore \
+ | tee "$HOST_RESULT_FILE"
+```
+
+只有末尾出现 `Production backup was created, restored to the isolated server and verified.` 才算成功。保存工具输出的 `artifactPath`、`artifactSize`、`artifactSha256`、`receiptPath`、`receiptSize`、`receiptSha256`。receipt 自动绑定:
+
+- 精确 `sourceCommit`;
+- 生产 configured/server database fingerprint;
+- `/backup/...dump` 容器路径、字节数、SHA-256 和起止时间;
+- 隔离库 fingerprint、恢复时间和同一 artifact SHA-256;
+- 生产前后与恢复库的关键表计数、有限样本摘要及整体摘要 SHA-256。
+
+`pg_dump`、`pg_restore`、摘要查询、身份核对或原子写入任一步失败,都不会产生 `.receipt.json`。可能已生成但未通过恢复验证的 `.dump` 只能作为失败现场证据,不能交给 migrator。运行 migrator 时把同一个 `HOST_BACKUP_DIR` 以 `/backup:ro` 挂载;工具会重新读取完整 artifact 和 receipt,核对类型、路径、字节数、SHA-256 与有效期。receipt 最多允许 `BACKUP_MAX_AGE_MINUTES` 指定的时长(范围 1–1440 分钟)。
+
+在退出维护窗前,先从 helper 的最终 JSON 结果提取本批次精确路径和 SHA;不要按时间猜文件名,也不要把上级备份目录整体挂进 migrator。下面的 `node` 只读取结果和 receipt,不接触数据库:
+
+```bash
+readarray -t BACKUP_EVIDENCE < <(node --input-type=commonjs - "$HOST_RESULT_FILE" "$HOST_BACKUP_DIR" <<'NODE'
+const fs = require('node:fs')
+const path = require('node:path')
+
+const [resultPath, hostBackupDir] = process.argv.slice(2)
+const resultText = fs.readFileSync(resultPath, 'utf8')
+const marker = '\nProduction backup was created, restored to the isolated server and verified.\n'
+const markerIndex = resultText.lastIndexOf(marker)
+if (markerIndex < 0) throw new Error('helper success marker is missing')
+const resultJson = resultText.slice(0, markerIndex).trim()
+if (!resultJson.startsWith('{') || !resultJson.endsWith('}')) throw new Error('helper final JSON is missing')
+const result = JSON.parse(resultJson)
+
+const entries = fs.readdirSync(hostBackupDir, { withFileTypes: true })
+const receiptNames = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.receipt.json')).map((entry) => entry.name)
+const dumpNames = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.dump')).map((entry) => entry.name)
+if (receiptNames.length !== 1 || dumpNames.length !== 1 || entries.length !== 2) {
+ throw new Error('batch directory must contain exactly one regular receipt and one regular dump')
+}
+for (const name of [receiptNames[0], dumpNames[0]]) {
+ const candidate = path.join(hostBackupDir, name)
+ const stat = fs.lstatSync(candidate)
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('backup evidence must be regular non-symlink files')
+}
+const receiptPath = path.join(hostBackupDir, receiptNames[0])
+const dumpPath = path.join(hostBackupDir, dumpNames[0])
+const receiptBytes = fs.readFileSync(receiptPath)
+const receipt = JSON.parse(receiptBytes.toString('utf8'))
+const crypto = require('node:crypto')
+const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
+const receiptSha256 = sha256(receiptBytes)
+const dumpStat = fs.lstatSync(dumpPath)
+const dumpHash = crypto.createHash('sha256')
+const dumpStream = fs.createReadStream(dumpPath)
+dumpStream.on('data', (chunk) => dumpHash.update(chunk))
+dumpStream.once('error', (error) => { throw error })
+dumpStream.once('end', () => {
+ const dumpSha256 = dumpHash.digest('hex')
+ if (
+ result.receiptPath !== `/backup/${receiptNames[0]}`
+ || result.receiptSize !== receiptBytes.length
+ || result.receiptSha256 !== receiptSha256
+ || result.artifactPath !== `/backup/${dumpNames[0]}`
+ || result.artifactSize !== dumpStat.size
+ || result.artifactSha256 !== dumpSha256
+ || receipt.backup.artifactPath !== result.artifactPath
+ || receipt.backup.artifactSize !== result.artifactSize
+ || receipt.backup.artifactSha256 !== result.artifactSha256
+ ) throw new Error('helper output, receipt and artifact evidence do not match')
+ process.stdout.write(`${receiptNames[0]}\n${receiptSha256}\n`)
+})
+NODE
+)
+test "${#BACKUP_EVIDENCE[@]}" -eq 2
+BACKUP_RECEIPT_BASENAME="${BACKUP_EVIDENCE[0]}"
+EXPECTED_BACKUP_RECEIPT_SHA256="${BACKUP_EVIDENCE[1]}"
+test -n "$BACKUP_RECEIPT_BASENAME"
+test -n "$EXPECTED_BACKUP_RECEIPT_SHA256"
+```
+
+> 当前状态(2026-08-12):代码和伪 PostgreSQL 测试已就绪,但尚未对真实生产库执行 `pg_dump`、隔离恢复或任何 migration。本文步骤不是已完成证明。
+
+## 3. 先做只读审计
+
+下例只展示参数结构;真实值从生产现场读取,不写进 Git:
+
+```bash
+PREFLIGHT_AUDIT_FILE="$HOST_RESULT_DIR/preflight-$(date -u +%Y%m%dT%H%M%SZ).json"
+MIGRATION_AUDIT_FILE="$HOST_RESULT_DIR/applied-$(date -u +%Y%m%dT%H%M%SZ).json"
+test ! -e "$PREFLIGHT_AUDIT_FILE"
+test ! -e "$MIGRATION_AUDIT_FILE"
+docker run --rm \
+ --network youyidian-3d_app-network \
+ --env-file /mnt/PoolOne/apps/youyidian-3d/.env \
+ -e EXPECTED_COMMIT_SHA=<40位目标SHA> \
+ -e MIGRATION_AUDIT_OUTPUT="/audit/$(basename "$PREFLIGHT_AUDIT_FILE")" \
+ -v "$HOST_RESULT_DIR:/audit" \
+ --entrypoint node \
+ youyidian-3d-migrator:<40位目标SHA> \
+ scripts/safe-production-migrate.mjs --audit
+test -f "$PREFLIGHT_AUDIT_FILE" && test ! -L "$PREFLIGHT_AUDIT_FILE"
+test "$(stat -c '%a' "$PREFLIGHT_AUDIT_FILE")" = "600"
+```
+
+审计只挂载前一步已按 migrator UID 创建的本批次 `HOST_RESULT_DIR`,不挂载其 root 所有的上级目录。审计输出不得包含数据库密码。确认目标库、账号、服务器地址和当前 migration 状态,若出现失败 migration 或本轮以外的未执行 migration,立即停止。
+
+先确认 migrator 容器内的 `node` 用户 UID,并预创建只属于本次发布的审计目录;目录必须可写,已存在的审计文件不得复用:
+
+```bash
+MIGRATOR_UID="$(docker run --rm --entrypoint id youyidian-3d-migrator:<40位目标SHA> -u)"
+install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" /root/gongxiangp2-migration-audit/<发布批次>
+```
+
+## 4. 带备份凭证执行
+
+把备份 receipt 只读挂载到容器,显式提供生产身份:
+
+```bash
+docker run --rm \
+ --network youyidian-3d_app-network \
+ --env-file /mnt/PoolOne/apps/youyidian-3d/.env \
+ -e ALLOW_PRODUCTION_MIGRATIONS=APPLY \
+ -e EXPECTED_COMMIT_SHA=<40位目标SHA> \
+ -e EXPECTED_DATABASE_HOST= \
+ -e EXPECTED_DATABASE_PORT= \
+ -e EXPECTED_DATABASE_NAME=<数据库名> \
+ -e EXPECTED_DATABASE_USER=<数据库账号> \
+ -e EXPECTED_DATABASE_SERVER_ADDRESS= \
+ -e EXPECTED_DATABASE_SERVER_PORT= \
+ -e BACKUP_RECEIPT_PATH="/backup/$BACKUP_RECEIPT_BASENAME" \
+ -e BACKUP_ARTIFACT_ROOT=/backup \
+ -e BACKUP_MAX_AGE_MINUTES=240 \
+ -e EXPECTED_BACKUP_RECEIPT_SHA256="$EXPECTED_BACKUP_RECEIPT_SHA256" \
+ -e MIGRATION_AUDIT_OUTPUT="/audit/$(basename "$MIGRATION_AUDIT_FILE")" \
+ -v "$HOST_BACKUP_DIR:/backup:ro" \
+ -v "$HOST_RESULT_DIR:/audit" \
+ "$MIGRATOR_IMAGE" --apply
+```
+
+工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对四条 migration 的数据库 checksum、完成状态、字段类型/可空性、租约索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。
+
+apply 成功后立即固定审计文件本身;后续普通部署只能读取这一个文件,不能手工重写:
+
+```bash
+test -f "$MIGRATION_AUDIT_FILE" && test ! -L "$MIGRATION_AUDIT_FILE"
+chmod 0600 "$MIGRATION_AUDIT_FILE"
+MIGRATION_AUDIT_SHA256="$(sha256sum "$MIGRATION_AUDIT_FILE" | awk '{print $1}')"
+test -n "$MIGRATION_AUDIT_SHA256"
+```
+
+## 5. 放行条件
+
+只有以下全部成立,才进入应用首次安全上线步骤:
+
+- `applied.json` 的 `verified=true`;
+- 四条 target migration 均 `completed=true`,source/database checksum 一致;
+- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
+- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
+- 再次运行 `--audit` 仍通过;
+- 备份 receipt、迁移审计文件、目标 SHA 和 migrator image ID 被记录在当次发布记录中。
+
+任意一项不成立:不部署应用、不手工修改 `_prisma_migrations`、不批量改业务状态;先保留现场并依据备份制定恢复方案。
+
+## 6. 用同一 SHA 执行普通蓝绿部署
+
+保持 bootstrap 维护入口和全局部署锁不变,使用第 1 步固定的 runner,并把第 4 步真实生成的审计文件与 SHA-256 显式交给普通部署脚本:
+
+```powershell
+.\scripts\git-incremental-deploy.ps1 `
+ -Target production `
+ -Commit '<与 runner/migrator 完全相同的 40 位 SHA>' `
+ -MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-.json' `
+ -MigrationAuditSha256 '<该文件的 64 位小写 SHA-256>' `
+ -KeepOldImages
+```
+
+脚本会在候选创建前核对审计文件的路径、普通文件属性、`0600` 权限和哈希;再把其中的数据库实际身份与候选容器真实连接逐项比较。切流前还必须通过 S3 认证、数据库配置 Logo 与镜像内品牌图完整解码。缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。
diff --git a/docs/素材库平台预览缓存方案.md b/docs/素材库平台预览缓存方案.md
index 60af04a..5e04d45 100644
--- a/docs/素材库平台预览缓存方案.md
+++ b/docs/素材库平台预览缓存方案.md
@@ -49,7 +49,7 @@
- 视频大文件
- 压缩包
- 源文件下载
-- 生产机 uploads 源文件目录:`/mnt/PoolOne/apps/youyidian-3d/uploads`
+- 生产机 uploads 源文件独立 ZFS 数据集:`/mnt/PoolOne/youyidian-uploads`
## 4. 历史缓存策略
diff --git a/docs/统一流量访问规则.md b/docs/统一流量访问规则.md
index 04cb992..7e9930a 100644
--- a/docs/统一流量访问规则.md
+++ b/docs/统一流量访问规则.md
@@ -9,7 +9,7 @@
- 服务端访问 MinIO 继续走内网 `192.168.58.20:9000`。
- 展示型图片统一写入 `/gongxiangpan/public-display/`。
- `/gongxiangpan/uploads/` 保留为混合区,不再默认承担展示资源职责。
-- 源文件 / 下载型文件默认落在生产机 uploads:`/mnt/PoolOne/apps/youyidian-3d/uploads`。
+- 源文件 / 下载型文件默认落在生产机独立 ZFS uploads 数据集:`/mnt/PoolOne/youyidian-uploads`。
- `1080p.hk` 旧桶只用于极少数老素材兼容,不作为新素材写入目标。
- `180.97.215.57` 已停用,不再作为正式资源入口或缓存入口。
@@ -27,7 +27,8 @@
### 3.1 公网用户
- 打开网站:走 `https://sc.jiansun.vip`
-- 看图:走 `https://s3.jiansun.vip/gongxiangpan/public-display/...`
+- 看展示图:走 `https://s3.jiansun.vip/gongxiangpan/public-display/...`
+- 看视频预览 / 片段 / 封面:走 S3 中对应的媒体 URL,不要求位于 `public-display`
- 下载大文件:继续走业务下载接口或原有下载链路
### 3.2 内网服务端
@@ -49,7 +50,9 @@
| 白底图预览 | `/gongxiangpan/public-display/white-background/...` | 是 |
| 任务-详情预览 | `/gongxiangpan/public-display/task-detail/...` | 是 |
| 通用预览 / 展示图 | `/gongxiangpan/public-display/materials/...` | 是 |
+| Header / 登录 Logo | 仓库内置 `/brand/...`;可选当前桶 `gongxiangpan/由壹点AI/...` | 是 |
| 历史混合对象 | `/gongxiangpan/uploads/...` | 否,默认不按展示缓存处理 |
+| 视频预览 / 片段 / 封面 | S3 `gongxiangpan/由壹点AI/...`(不走 `public-display`) | 否,按媒体预览规则访问 |
| 压缩包 / 视频源文件 / 大下载对象 | 生产机 uploads 目录 | 否 |
## 5. 展示资源规则
@@ -60,10 +63,10 @@
- 现有素材库预览图
- 现有 `product-package/*-preview` 图片对象
-### 5.2 明确不作为展示资源处理的对象
+### 5.2 不纳入 `public-display` 展示图/缓存规则的对象
-- 视频预览大文件
-- 视频源文件
+- 视频预览大文件(对象仍在 S3,通过媒体预览 URL 访问)
+- 视频源文件(canonical 源仍在生产机 uploads/NAS)
- 压缩包
- 其它下载型大文件
@@ -96,6 +99,12 @@
- `2320` / `2330` 不再返回正式应用或正式 MinIO 内容
+### 7.5 品牌资源
+
+- Header、登录页和 favicon 在远端对象不可用时必须回退到镜像内 `/brand/...` 的可见图片,不能返回透明像素冒充成功。
+- 系统设置数据库只能保存 `/brand/...` 或 `https://s3.jiansun.vip/gongxiangpan/...`,不得保存代理包装地址、旧域名或第三方地址。
+- 后台“恢复默认”、Schema 默认值、设置行自动创建都必须回到本地品牌资源。
+
## 8. 推荐排查顺序
当用户反馈“公网慢”时,先按下面顺序判断:
diff --git a/docs/视频转码配置说明.md b/docs/视频转码配置说明.md
index 935b9b9..5991384 100644
--- a/docs/视频转码配置说明.md
+++ b/docs/视频转码配置说明.md
@@ -1,309 +1,146 @@
-# 视频转码配置说明
+# 视频转码配置与运行说明
-## 概述
+> 本文描述当前代码实现。数据库字段 `videoPreview540Url` 是历史命名,当前默认产物实际为 720P,不能按字段名推断分辨率。
-由壹点3D素材平台支持视频素材的自动转码功能,可以将上传的视频转换为适合在线预览的格式。
+## 当前结论
-## 环境要求
+- 上传的正式源视频优先保存在生产机 `UPLOAD_DIR` 对应的 `uploads` 目录;预览视频、短片和封面写入当前 S3 存储桶。
+- 当前编码器是代码中固定的 `libx265`(H.265/HEVC),不是可由 `VIDEO_TRANSCODE_CODEC` 切换的配置项。
+- 每个应用实例最多并行处理 1 条视频;数据库租约和 PostgreSQL 事务锁保证蓝绿容器之间同一素材只有一个有效执行者。
+- 应用启动时立即扫描未完成任务,之后每 60 秒重扫。Redis 用于上传会话和其它业务,不承担视频转码队列持久化。
+- 正式源视频在成功、失败、重试和部署中断后都必须保留。任务只允许清理自己创建的临时文件。
+- 转码产物从磁盘流式上传到 S3,不能先整体读入 Node.js 内存。
+- 本地 canonical 源视频直接只读使用,不复制到系统临时盘;远端兼容输入和任务产物只能写入 `UPLOAD_DIR` 所在的 NAS 工作目录,并在开始前按该挂载点空间 fail-closed。
+- FFmpeg 使用 `spawn(..., shell: false)` 直接启动;超时、租约丢失或 drain 时终止进程组,任意非零退出码都算失败。
+- 普通素材建档请求最多 128MiB;源文件达到 5MiB 必须走分片,单个分片最多 20MiB,逻辑源文件最多 10GiB。分片合并前必须确认 uploads/NAS 至少可容纳两份源文件并保留 2GiB 余量。
-### 1. FFmpeg
+## 实际转码产物
-视频转码需要 FFmpeg 工具。在 Docker 环境中,FFmpeg 已经在 Dockerfile 中安装:
+| 产物 | 当前实现 | 数据库字段 |
+| --- | --- | --- |
+| 主预览 | 默认高度 720、H.265、`medium`、AAC 128k | `videoPreview540Url`(仅字段名保留 540) |
+| 首页短片 | 默认 10 秒、高度 360、H.265、`ultrafast`、无音轨 | `videoPreviewClipUrl` |
+| 卡片封面 | 默认高度 720、JPEG;用户已上传封面时不重复截图 | `videoCoverUrl` / `previewUrl` |
-```dockerfile
-RUN apk add --no-cache ffmpeg
-```
+只有三类必需产物均已生成并上传、当前数据库租约仍有效时,任务才可以写成 `done`。FFmpeg 任意非零退出码、输出缺失、上传失败或租约失效都不得伪装成完成。转码期间用户后补的手动封面或首页预览优先,完成事务只能填补当时仍为空的字段,不能用任务开始时的旧快照覆盖用户结果。
-### 2. 环境变量配置
+## 当前有效环境变量
-在 `docker-compose.yml` 或 `.env` 文件中配置以下环境变量:
+| 变量 | 默认值 | 作用与边界 |
+| --- | --- | --- |
+| `VIDEO_PREVIEW_RESOLUTION` | `720` | 主预览和自动封面的目标高度 |
+| `VIDEO_CLIP_DURATION` | `10` | 首页短片秒数 |
+| `VIDEO_PREVIEW_CRF` | `20` | 主预览 H.265 质量,越小画质越高、文件越大 |
+| `VIDEO_CLIP_CRF` | `24` | 首页短片 H.265 质量 |
+| `VIDEO_TRANSCODE_TIMEOUT` | `3600000` | 单个 FFmpeg 命令超时,单位毫秒 |
+| `VIDEO_MAX_TEMP_FILE_SIZE` | `10737418240` | 临时输入最大字节数,默认 10GB |
+| `VIDEO_TRANSCODE_THREADS` | `1` | FFmpeg/x265 线程上限;代码钳制在 1–2 |
+| `VIDEO_TRANSCODE_MAX_CONCURRENT` | `1` | 共享应用容器的并发数;代码强制最高为 1 |
+| `VIDEO_TRANSCODE_RUNTIME_DIR` | `UPLOAD_DIR/.video-transcode-runtime` | 当前容器 capability、drain 和 active marker 目录 |
-```bash
-# 视频转码编码器(推荐使用 libx265,压缩率更高)
-VIDEO_TRANSCODE_CODEC=libx265
+旧文档曾列出的 `VIDEO_TRANSCODE_CODEC`、`VIDEO_TRANSCODE_PRESET`、`VIDEO_TRANSCODE_CRF`、`VIDEO_AUDIO_BITRATE` 和“Redis 持久化视频队列”并不是当前视频链路的有效控制面,不得据此调整生产。
-# 转码预设(速度与质量的平衡)
-# 可选值:ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
-# 推荐:medium(平衡速度和质量)
-VIDEO_TRANSCODE_PRESET=medium
+## 状态与恢复
-# 恒定质量因子(CRF,控制视频质量)
-# 范围:0-51,数值越小质量越高,文件越大
-# 推荐:22-28(22 为高质量,28 为较低质量)
-VIDEO_TRANSCODE_CRF=22
+合法状态为:
-# 音频比特率
-# 推荐:128k-192k(128k 为标准质量,192k 为高质量)
-VIDEO_AUDIO_BITRATE=160k
-```
+- `pending`:已建档,等待领取或等待周期恢复。
+- `processing`:某个未过期数据库租约正在处理。
+- `done`:数据库已写入完整预览结果。
+- `failed`:已确认处理失败,需要用户明确重试或人工检查源文件。
-### 3. Redis 配置(可选)
+应用通过 `instrumentation.ts` 启动恢复器;恢复器每 60 秒扫描:
-如果需要使用 Redis 管理转码队列(推荐用于生产环境),需要配置:
+- 没有有效租约的 `pending` 视频;
+- 租约已过期的 `processing` 视频;
+- 历史版本留下且心跳已静默的 `processing` 视频。
-```bash
-# 启用 Redis
-USE_REDIS=true
+恢复扫描失败只记录错误,下一轮继续;扫描之间不重叠。不得使用“批量把所有 processing 改回 pending”的裸 SQL,这会绕过租约、源文件核验和在途 FFmpeg,可能造成重复转码或覆盖结果。
-# Redis 连接地址
-REDIS_URL=redis://localhost:6379
-```
+## 上传入口的内存与磁盘边界
-**注意**:
-- 如果不配置 Redis,系统会使用内存队列(重启后队列会丢失)
-- 生产环境强烈推荐使用 Redis,确保转码任务不会因为重启而丢失
+- `/api/materials/upload` 必须在解析 multipart 前校验 `Content-Type`、`Content-Encoding` 和 `Content-Length`;缺失长度返回 `411`,超过 128MiB 返回 `413`。
+- 直传 `zipFile` 必须小于 5MiB。达到 5MiB 后走 `init → chunk → complete → existingFilePath → 建档`,大文件网络传输仍使用分片并发,不会被普通建档的内存处理槽串行化。
+- 普通建档/图片处理每个应用实例最多同时处理 1 个请求,另保留 8 个短队列位置;繁忙时返回带 `Retry-After` 的 `503`,避免多个大 multipart 与 Sharp 同时占满 Node.js 内存。
+- 单分片最多 20MiB;初始化会话记录固定分片布局,每个位置的期望字节数必须精确匹配。断点续传复用原会话布局;服务器边读边计数,超限立即取消并删除半文件,不能靠大量“各自合法”的分片突破会话总大小。
+- 服务器在 multipart 解析前进入最多 8 路的分片槽,并在每块落盘前再次确认 NAS 至少保留 2GiB 安全余量;每次请求先写唯一 partial,完整校验后原子替换 `chunk-N`。这与浏览器最高并发一致,可继续利用 300M 带宽,同时避免解析内存和重复请求写盘竞态。
+- `init` 拒绝超过 10GiB 的逻辑源文件;无法确认 uploads/NAS 剩余空间时保守返回 `503`,空间不足返回 `507`,不得继续写盘。`complete` 全局只合并 1 个文件,合并前再次要求可用空间至少为文件大小加 2GiB;写入唯一 partial、同步并原子改名,异常必清半成品。
+- `complete` 必须幂等:同一用户的成功结果即使在公网响应途中丢失,也能从受控元数据恢复归属并返回同一个 merged 文件,不得因分片已清理而要求整文件重传。
+- 买家秀和小红书批次逐条提交,避免多个图片 multipart 同时解码;小红书附加图片必须进入 `displayImages`,不能静默丢弃。
+- 本机稳定代理对主上传精确限制为 `129m`、分片精确限制为 `21m`、其余上传 JSON 子路由限制为 `2m`,相关上传路由关闭 `proxy_request_buffering`。这样活动上传会持续建立到应用容器的连接,蓝绿 drain 不会把仍在 Nginx 缓冲但尚未转发的请求误判为空闲。
-## 转码流程
+## API 权限与用途
-### 1. 上传视频
-
-用户上传视频素材时,系统会:
-1. 保存原始视频文件到 S3
-2. 创建素材记录,设置 `videoProcessingStatus = 'pending'`
-3. 将转码任务加入队列
-
-### 2. 转码处理
-
-转码队列会自动处理待转码的视频:
-1. 从队列中取出任务
-2. 下载原始视频
-3. 使用 FFmpeg 转码:
- - 生成 540p 预览视频(`videoPreview540Url`)
- - 生成 10 秒预览片段(`videoPreviewClipUrl`)
- - 提取视频封面(`videoCoverUrl`)
-4. 上传转码后的文件到 S3
-5. 更新素材记录,设置 `videoProcessingStatus = 'completed'`
-
-### 3. 转码失败处理
-
-如果转码失败:
-1. 系统会记录错误信息到 `transcodeLastError` 字段
-2. 增加重试次数(`transcodeAttempts`)
-3. 如果重试次数超过限制(默认 3 次),标记为 `failed`
-4. 用户可以在前端手动重试转码
-
-## API 接口
-
-### 查询转码状态
+### 查询单条状态
```http
-GET /api/materials/transcode?materialId=xxx
+GET /api/materials/transcode?materialId=<素材ID>
```
-**响应示例**:
-```json
-{
- "status": "processing",
- "progress": 45,
- "message": "正在转码中..."
-}
-```
+- 必须登录并拥有该素材的查看权限。
+- 普通用户看不到其他素材 ID、全队列详情或后端错误全文。
+- 首页只做只读轮询,不会因为看到队列暂时为空就静默发起强制重试。
-### 启动转码任务
+### 明确启动或重试
```http
POST /api/materials/transcode
Content-Type: application/json
{
- "materialId": "xxx"
+ "materialId": "<素材ID>",
+ "forceRetry": true
}
```
-**响应示例**:
-```json
-{
- "message": "转码任务已启动",
- "status": "processing"
-}
-```
+- 普通用户只能操作自己的素材;系统管理员可按权限处理其它素材。
+- 正在处理的素材返回 `409`,客户端不能绕过有效租约并行重试。
+- 自动恢复由后台调度器负责,不依赖管理员打开这个接口。
-## 转码参数说明
+## 发布与数据安全门禁
-### VIDEO_TRANSCODE_CODEC
+生产发布前必须:
-**libx264**(H.264):
-- 优点:兼容性好,几乎所有设备都支持
-- 缺点:文件较大
-- 适用场景:需要最大兼容性的场景
+1. 先构建并固定同一 40 位 Git SHA 的 runner、migrator、镜像 digest 与生产机 image ID;此时不启动候选、不改数据库。
+2. 第一次从不支持新 drain 协议的旧镜像升级必须使用独立的 `scripts/legacy-worker-bootstrap-deploy.ps1`。先执行只读 `Mode=Audit`;只有同时提供 `-Apply -AllowLegacyDrainBootstrap` 才能进入已证明的 503 维护交接,不能删除门禁强推。
+3. 只有旧容器已停止并保留、503 维护入口已验证后,才在同一维护窗执行生产备份、隔离恢复验证及四份 additive migration;部署脚本不会偷偷迁移数据库,也不能以“当前没人使用”代替维护门禁。
+4. 候选容器切流前只读确认租约列、索引、品牌默认值、uploadId 幂等索引和全部 migration checksum 精确匹配。
+5. 常态发布时旧容器进入 drain 后,等待等待队列和 active marker 同时归零;状态未知时拒绝停止旧容器。
+6. 保留旧容器、旧镜像、数据库备份和正式源视频,直到 canary 全链路通过。
+7. 候选容器必须带 drain 启动,在数据库、品牌资源、能力和切流门禁全部通过后才恢复后台任务;切流提交后旧槽继续保持 drain。
+8. 未完成独立 uploads/runtime/Redis 隔离前,通用增量脚本拒绝 `Target=shadow`;正式发布发现运行中的影子容器也必须先人工确认无共享 FFmpeg 风险。
+9. 通用脚本禁止自动执行历史 direct 容器到稳定代理的拓扑迁移,也禁止用未验证旧镜像做 direct 回滚;检测到该现场时必须在任何切流或停容器前 fail-closed,改走单独受控 SOP。
-**libx265**(H.265/HEVC):
-- 优点:压缩率高,文件小,画质好
-- 缺点:编码速度较慢,部分老设备不支持
-- 适用场景:追求高画质和小文件的场景(推荐)
+### 首次旧镜像过渡的职责边界
-### VIDEO_TRANSCODE_PRESET
+一次性 bootstrap 只负责旧直连现场到稳定 503 维护入口的安全交接,不负责候选发布:
-速度从快到慢:
-- `ultrafast`:最快,质量最差
-- `superfast`:很快,质量较差
-- `veryfast`:快,质量一般
-- `faster`:较快,质量较好
-- `fast`:快,质量好
-- `medium`:平衡(推荐)
-- `slow`:慢,质量很好
-- `slower`:很慢,质量极好
-- `veryslow`:最慢,质量最好
+- 必须固定旧容器完整 ID、旧 image ID/RepoDigest、旧容器网络 upstream、完整 `docker port` 端点集合、uploads ZFS source/target/statfs,以及目标镜像 digest/image ID/40 位 Git SHA。
+- 在 deployment flock 与 proxy reconciliation flock 内重复核验全部 pins;发现已有蓝绿槽、稳定代理、影子容器或状态歧义立即退出。
+- 通过 IPv4/IPv6 规则阻断新连接,让已建立连接自然退出;同时检查宿主 `ss` 与通过宿主 `nsenter` 进入固定旧容器 network namespace 后的服务端口 3000,两侧都必须连续采样两次 `ESTABLISHED=0`。不能只看宿主到容器 IP 的连接,因为 Docker DNAT 且未启用 userland-proxy 时可能漏掉仍在上传的公网连接。
+- 冻结前后都只读核对视频 `pending/processing=0`、任务详情 job `queued/processing=0`,并列出和拒绝任何 `taskStatus=uploading` 素材;同时核对旧容器和整台宿主机没有 FFmpeg。
+- 先预置候选蓝槽的两类 drain marker,但绝不写 capability;真实 capability 只能由目标容器用当前实例 ID 发布。
+- `docker pause` 后再次用固定目标镜像的 Node 单次入口查库;确认 idle 后只能用 `docker stop --signal SIGKILL --time 0` 停止,避免普通 stop 为发送优雅退出信号而恢复 paused 容器、让旧用户代码短暂重跑。停止状态必须精确为 `exited`,并再次用固定目标镜像查数据库、检查宿主机无 FFmpeg,全部通过后才改名保留旧容器。不得删除旧容器或旧镜像。
+- 成功只表示入口处于明确 503 维护状态。随后仍需运行普通 `git-incremental-deploy.ps1 -Target production -Commit <同一SHA>`;所有候选门禁照常执行。
+- 自动恢复只允许在入口仍是 `legacy-bootstrap-maintenance` 且没有运行中的蓝绿容器时进行。候选可能接收过流量时保持维护状态并人工判定,不能盲启旧容器。
-**推荐配置**:
-- 开发环境:`fast` 或 `faster`(快速测试)
-- 生产环境:`medium`(平衡速度和质量)
-- 高质量需求:`slow`(追求极致画质)
+完整取证命令、PowerShell 参数模板和恢复命令见《视频预览任务中断事故复盘(2026-08-11)》的“首次生产过渡门禁”。
-### VIDEO_TRANSCODE_CRF
+验收一条小视频时需要同时确认:
-CRF(Constant Rate Factor)控制视频质量:
-- `0`:无损(文件极大,不推荐)
-- `18-22`:高质量(推荐用于重要视频)
-- `23-28`:标准质量(推荐用于一般视频)
-- `29-35`:较低质量(不推荐)
-- `51`:最低质量(不推荐)
+- 数据库状态为 `done`,三类预览 URL 均非空;
+- 三个 S3 对象实际 HEAD 200、类型和大小正确,浏览器可解码;
+- 卡片在 15 天保护期内仍显示处理状态,并在完成后自动出现封面;
+- `uploads` 中 canonical 源视频仍存在,字节数未变化;
+- 当前只有一个有效租约和一个 FFmpeg 进程;
+- 部署过程中有活动转码时,旧容器没有被提前停止。
-**推荐配置**:
-- 高质量:`20-22`
-- 标准质量:`23-25`(推荐)
-- 节省空间:`26-28`
+## 故障排查顺序
-### VIDEO_AUDIO_BITRATE
+1. 查素材的 `videoProcessingStatus`、租约 token/过期时间、三阶段进度和 `transcodeLastError`。
+2. 核对 canonical 源文件是否存在、大小是否与数据库一致,并用 FFprobe 验证可解析。
+3. 查当前容器 active marker、drain 状态、FFmpeg 进程和启动恢复日志。
+4. 查三个预览对象是否存在;孤立对象不能在数据库引用不明时直接删除。
+5. 只有源文件安全、没有有效租约且没有在途 FFmpeg 时,才允许按精确素材 ID 重试。
-音频比特率控制音频质量:
-- `96k`:低质量(不推荐)
-- `128k`:标准质量
-- `160k`:较高质量(推荐)
-- `192k`:高质量
-- `256k`:极高质量(文件较大)
-
-## 生产环境配置示例
-
-### 正式系统(docker-compose.yml)
-
-```yaml
-environment:
- # 视频转码配置
- - VIDEO_TRANSCODE_CODEC=libx265
- - VIDEO_TRANSCODE_PRESET=medium
- - VIDEO_TRANSCODE_CRF=22
- - VIDEO_AUDIO_BITRATE=160k
- # Redis 配置(可选)
- - USE_REDIS=true
- - REDIS_URL=redis://redis:6379
-```
-
-### 影子系统(docker-compose.shadow.yml)
-
-影子系统已经配置了完整的视频转码环境,包括:
-- 独立的 Redis 实例(端口 6380)
-- 完整的转码环境变量
-- 独立的 uploads 目录
-
-## 监控和排查
-
-### 查看转码统计
-
-管理员可以访问以下 API 查看转码统计:
-
-```http
-GET /api/admin/video-transcode-stats
-```
-
-**响应示例**:
-```json
-{
- "pending": 5,
- "processing": 2,
- "completed": 150,
- "failed": 3,
- "recentFailures": [
- {
- "id": "xxx",
- "productModel": "产品名称",
- "transcodeLastError": "FFmpeg 转码失败:...",
- "transcodeAttempts": 3,
- "createdAt": "2025-12-20T10:00:00Z"
- }
- ]
-}
-```
-
-### 常见问题
-
-#### 1. 转码失败:FFmpeg not found
-
-**原因**:Docker 镜像中没有安装 FFmpeg
-
-**解决**:
-1. 确认 Dockerfile 中已添加 `ffmpeg` 到 apk 安装列表
-2. 重新构建镜像:`docker compose build`
-3. 重启容器:`docker compose up -d`
-
-#### 2. 转码速度很慢
-
-**原因**:转码预设太慢(如 `slow` 或 `slower`)
-
-**解决**:
-1. 调整 `VIDEO_TRANSCODE_PRESET` 为 `medium` 或 `fast`
-2. 重启容器使配置生效
-
-#### 3. 转码后文件太大
-
-**原因**:CRF 值太小(质量太高)
-
-**解决**:
-1. 调整 `VIDEO_TRANSCODE_CRF` 为 `25-28`
-2. 或使用 `libx265` 编码器(压缩率更高)
-
-#### 4. 转码队列卡住
-
-**原因**:
-- 转码任务失败但状态未更新
-- Redis 连接失败
-- 内存队列因重启丢失
-
-**解决**:
-1. 查看转码统计 API,确认是否有 `processing` 状态的任务
-2. 如果有卡住的任务,可以手动重置状态:
- ```sql
- UPDATE materials
- SET "videoProcessingStatus" = 'pending'
- WHERE "videoProcessingStatus" = 'processing';
- ```
-3. 重启容器,队列会自动恢复
-
-## 性能优化建议
-
-### 1. 使用 Redis
-
-生产环境强烈推荐使用 Redis:
-- 转码任务持久化,重启不丢失
-- 支持分布式部署(多个转码节点)
-- 更好的队列管理和监控
-
-### 2. 调整转码参数
-
-根据实际需求调整参数:
-- 如果用户主要在移动端观看:使用 `libx265` + `CRF 25`
-- 如果追求极致画质:使用 `libx265` + `CRF 20` + `slow`
-- 如果需要快速转码:使用 `libx264` + `CRF 23` + `fast`
-
-### 3. 分离转码服务
-
-如果转码任务很多,可以考虑:
-- 部署独立的转码服务器
-- 使用消息队列(如 RabbitMQ)管理任务
-- 使用 GPU 加速转码(需要支持 NVENC 的 FFmpeg)
-
-## 总结
-
-视频转码功能已经完整实现,包括:
-- ✅ FFmpeg 已在 Dockerfile 中安装
-- ✅ 环境变量已在 docker-compose.yml 中配置
-- ✅ 转码 API 已实现(查询状态、启动任务)
-- ✅ 转码队列已实现(支持 Redis 和内存队列)
-- ✅ 错误处理和重试机制已实现
-- ✅ 管理员监控 API 已实现
-
-**下一步**:
-1. 重新构建 Docker 镜像(包含 FFmpeg)
-2. 部署到正式环境
-3. 测试视频上传和转码功能
-4. 监控转码队列状态
+详细事故原因、生产证据和首次上线步骤见《视频预览任务中断事故复盘(2026-08-11)》。
diff --git a/docs/视频预览任务中断事故复盘-2026-08-11.md b/docs/视频预览任务中断事故复盘-2026-08-11.md
new file mode 100644
index 0000000..0b30e04
--- /dev/null
+++ b/docs/视频预览任务中断事故复盘-2026-08-11.md
@@ -0,0 +1,183 @@
+# 视频预览任务中断事故复盘(2026-08-11)
+
+## 结论
+
+本次“新视频一直没有封面”不是浏览器、源视频格式、对象存储或内存爆掉造成的。直接原因是:发布切换只等待网页连接结束,却没有等待后台视频队列结束;旧容器在 FFmpeg 转码过程中被正常停止,新容器又没有在启动时自动接回数据库里的未完成任务。
+
+页面还有第二层问题:15 天保护规则会有意隐藏 `videoSourceUrl`,首页却把“是否有源地址”当成启动状态轮询的条件。因此后台即使稍后生成了封面,已经打开的页面也可能一直显示旧占位,直到用户手动刷新。
+
+排查还发现同一稳定性链路的第三层风险:旧上传入口会在没有总量门禁时直接解析 multipart 并整体复制源文件;Nginx 又允许约 1GB 请求并先缓冲到临时盘。大请求可能同时推高 Node.js 内存和系统盘占用,而且缓冲期间没有旧容器 upstream 连接,发布脚本会误以为上传已经结束。
+
+## 生产证据
+
+- 截图中的 11 条素材在 10:21–10:24 已成功建档并进入队列,不是漏上传。
+- 旧容器 10:58 开始处理第一条,720P 已上传、10 秒片段处理到约 72% 时,容器在发布切换中被正常停止;不是 OOM,也不是进程崩溃。
+- 新容器启动后没有执行视频自动恢复。数据库留下 1 条 `processing` 和 10 条 `pending`,心跳停在旧容器停止时刻。
+- 11/11 正式源视频仍在,字节数与数据库一致,FFprobe 均可正常解析。
+- 本轮按精确名单安全恢复了 16 条可恢复素材的封面、720P 预览和短片,并逐条核对数据库、对象存储与源文件;已知缺失源文件的模型 290 未被盲目重试或伪造结果。
+
+## 误导链路与复发入口
+
+| 文件 | 隐患 | 用户可见结果 |
+| --- | --- | --- |
+| `scripts/git-incremental-deploy.ps1` | 旧版 drain 只看 HTTP/TCP 连接,不知道后台 FFmpeg 和等待队列 | 网页请求结束后就停止旧容器,后台任务被截断 |
+| `app/api/materials/upload/route.ts` | 上传后异步入队;入队失败时旧逻辑还会删除正式源视频 | 数据库有素材,但源文件可能被删,无法安全重做 |
+| `lib/video-transcode-queue.ts` | 主要状态在单个 Node 进程内;数据库记录不是跨容器唯一执行租约 | 重启后丢队列,蓝绿重叠时还可能重复执行 |
+| `app/api/materials/transcode/route.ts` | 恢复只在系统管理员访问接口时懒触发 | 普通上传者无法让新容器接回中断任务 |
+| `instrumentation.ts` | 过去只恢复“任务-详情”后台任务,没有恢复视频 | 服务健康但视频队列静默停住 |
+| `lib/video-processor.ts` | 旧逻辑在 S3 预览上传后、数据库完成回写前删除正式源视频 | 此窗口崩溃会留下“预览未入库、源文件也没了” |
+| `lib/video-download-policy.ts` | 15 天保护期隐藏源视频和预览视频地址,这是正确的访问控制 | 页面不能依赖这些字段判断后台任务状态 |
+| `app/page.tsx` | 首页过去只有 `videoSourceUrl` 存在才轮询;上传后只刷新一次 | 保护期内的卡片不会自动拿到新封面 |
+| `components/home/MaterialCard.tsx` | 没源地址就隐藏进度,15 天遮罩还盖住失败/重试 | 用户只看到“15天后可查看详情”,看不到真正失败原因 |
+| `app/api/materials/upload/route.ts` 与 `/chunk` | 旧版缺少请求总量、分片、逻辑源和写盘硬上限 | 大请求可能拖高内存或写满上传盘,半文件残留 |
+| 本机 Nginx 上传规则 | 旧版允许约 1GB 且默认先缓冲请求 | 系统临时盘承压;发布 drain 看不到尚未转发的上传 |
+| 买家秀/小红书批量上传 | 多批次曾并发发送;小红书附加图片虽发送却未被后端处理 | 多个大 multipart 同时占内存,附加图片静默丢失 |
+
+## 永久修复
+
+### 后台任务
+
+- 用 PostgreSQL advisory transaction lock 加素材级 token/过期时间,保证蓝绿容器之间同一素材只有一个有效执行者。
+- 成功、失败、续租、进度和释放都必须匹配当前租约;旧容器租约失效后会中止 FFmpeg,不能回写覆盖新容器结果。
+- 应用启动立即恢复,随后每 60 秒重扫;数据库暂时不可用时下一轮继续,不再依赖管理员打开页面。
+- 正常完成和入队失败都不再删除 canonical 源视频;只清理本次任务创建的临时副本。
+- FFmpeg 不可用或三类输出不完整时明确失败,禁止把空预览误标为 `done`。
+- 本地源视频直接只读使用,远端兼容输入和中间产物落在 uploads/NAS 的任务目录;磁盘状态未知或空间不足时不启动 FFmpeg,避免把大视频复制到系统盘。
+- 预览视频、片段和封面从文件流式上传,不再整体读入 Node.js 内存;FFmpeg 任意非零退出、超时或被取消都明确失败。
+- 转码完成时重新锁定并读取数据库最新值,只填仍为空的封面/首页预览字段,不覆盖用户在排队或转码期间后补的手动结果。
+
+### 发布切换
+
+- 新 drain 协议由当前容器实例写入 capability;共享盘残留的旧文件不能冒充当前容器。
+- 旧容器有活动任务、状态未知或协议不匹配时一律拒绝停止;失败路径会恢复旧队列。
+- 候选容器带 drain 启动,所有切流门禁通过后才允许恢复视频任务;切流提交后旧槽保持 drain,避免回滚清理逻辑把旧 worker 再次唤醒。
+- 意外残留的 direct 容器也必须走同一套 capability、drain、active 验证;禁止直接 `rm -f`。未完成独立隔离前,影子部署入口 fail-closed。
+- 第一次从旧镜像升级时旧容器不支持新协议,自动部署会按设计 fail-closed,必须按本文“首次过渡”单独执行,不能删门禁强推。
+
+### 页面
+
+- 首页根据 `pending/processing` 状态轮询,不再依赖被 15 天规则隐藏的源视频地址。
+- 多卡片请求错峰,常规间隔改为 15 秒,429 按 `Retry-After` 退避。
+- 正常排队可能超过 30 分钟,页面不再用固定时长停止轮询;只在完成、失败、素材离开当前列表或页面关闭时清理。
+- 处理中、失败和重试按钮位于 15 天遮罩之上,并增加实时状态和进度语义。
+- 完成后重新获取素材列表,从标准列表接口取得经过权限策略和 URL 重写的封面地址。
+
+### 上传与内存保护
+
+- 普通 multipart 在解析前强制验证长度,最大 128MiB;源文件达到 5MiB 必须走分片。
+- 分片单块最大 20MiB,初始化时固定分片布局,每个位置的声明与实际字节数都必须精确匹配;超限或不一致立即删除半文件,不能用很多合法小请求突破会话总大小。断点续传始终沿用原会话布局,不会因公网/内网切换改用另一种切片方式。
+- 服务端在解析 multipart 分片前先进入最多 8 路的处理槽,每次重试写入唯一临时文件,完整校验后原子替换 `chunk-N`;既保留上传吞吐,也避免并发解析占满内存或同一分片交错写坏。
+- 逻辑源最大 10GiB;初始化前确认 uploads/NAS 可同时容纳分片与合并文件并保留安全余量,状态未知时拒绝开始。合并阶段全局单槽并再次复查“完整文件大小 + 2GiB”,写入唯一 partial、同步落盘后才原子改名,任何失败都会清理本次半成品。
+- `complete` 是幂等操作:首次合并成功但公网响应丢失时,可依据当前用户的 `meta.json` 和已校验 merged 文件返回同一结果,不要求重新上传整个源文件。
+- 普通图片/建档处理限制同时活跃数并设置有界等待队列;大文件分片网络上传保持原有并发能力。
+- 买家秀和小红书改为逐批提交;小红书附加图片进入标准展示图字段,不再被静默忽略。
+- 本机代理按主上传 `129m`、分片 `21m`、其余上传 JSON 子路由 `2m` 精确分层,关闭上传请求缓冲,使上传期间 upstream 连接始终可被蓝绿 drain 观察。
+- 通用部署脚本检测到历史 direct 拓扑时在任何变更前拒绝继续;一次性 direct→proxy 迁移必须另走停新上传、确认连接和后台任务均为空的受控流程。
+
+## 数据安全边界
+
+- 任何重做前先确认正式源文件存在、大小一致且可解析;缺源素材不得盲排。
+- 修复预览时先做不可变保全副本并核对 SHA256;验收通过前不清理保全文件。
+- 不把 S3 HEAD 成功、健康接口 200 或数据库状态 `done` 单独当作成功;必须同时核对数据库字段、三类对象 HEAD、实际解码和 canonical 源文件。
+- 新增 migration 只增加可空租约列和索引,不删除或改写现有素材数据;生产执行前仍需数据库备份和迁移记录核对。
+
+## 首次生产过渡门禁
+
+第一次从不支持 drain capability 的旧镜像升级时,不能删除普通发布脚本的门禁,也不能在共享盘伪造 capability 文件。唯一允许的入口是 `scripts/legacy-worker-bootstrap-deploy.ps1`;它默认只做审计,写操作必须同时显式提供 `-Apply` 和 `-AllowLegacyDrainBootstrap`。
+
+### 过渡前准备
+
+1. 明确获得生产迁移和部署授权,确定短时 503 维护窗口,并冻结唯一的 40 位目标 Git SHA。
+2. 在触碰流量和数据库前,先用普通发布脚本的 `-SkipDeploy` 路径构建并推送同一 SHA 的 runner;migrator 必须按《生产数据库安全增量迁移 SOP(2026-08-12)》独立构建,并通过已固定主机指纹的 SSH 传到生产机。runner 与 migrator 分别记录自己的 image ID/digest、OCI revision 和 baked commit,二者必须来自同一 SHA,但不能误写成同一个 digest。在生产机只预拉取/导入镜像,不启动应用。维护窗口内不得再临时找镜像或改目标提交。
+3. 在生产机只读取得下面全部现场值;任何一项为空、存在多值歧义或随后发生变化,都停止过渡:
+
+```bash
+legacy=youyidian-3d-material
+docker inspect -f '{{.Id}}' "$legacy"
+docker inspect -f '{{.Image}}' "$legacy"
+docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$(docker inspect -f '{{.Image}}' "$legacy")"
+docker inspect -f '{{with index .NetworkSettings.Networks "youyidian-3d_app-network"}}{{.IPAddress}}{{end}}:3000' "$legacy"
+docker port "$legacy" 3000/tcp
+findmnt -rn -o TARGET,SOURCE,FSTYPE -T /mnt/PoolOne/youyidian-uploads
+stat -f -c '%i:%t' -- /mnt/PoolOne/youyidian-uploads
+```
+
+4. 把上述值和目标镜像值逐项填入一个 PowerShell splat;不要把密码、Token 或 `.env` 内容写入命令、文档或 Git:
+
+```powershell
+$bootstrap = @{
+ TargetCommit = '<40位小写Git SHA>'
+ LegacyContainerId = '<64位旧容器ID,不是短ID>'
+ LegacyImageId = 'sha256:<64位旧镜像ID>'
+ LegacyImageRepoDigest = '<旧镜像仓库>@sha256:<64位digest>'
+ LegacyUpstream = '<旧容器IPv4>:3000'
+ UploadsStatfsSignature = ''
+ PublishedEndpoints = '0.0.0.0:4320,[::]:4320' # 必须按 docker port 实际输出填写
+ TargetImageReference = '<目标镜像仓库>@sha256:<64位digest>'
+ TargetImageId = 'sha256:<生产机上的64位目标镜像ID>'
+ TargetImageRepoDigest = '<目标镜像仓库>@sha256:<64位digest>'
+}
+```
+
+如果线上仍使用兼容挂载 `/mnt/PoolOne/apps/youyidian-3d/uploads`,还必须显式传入 `UploadsMount`、`UploadsMountTarget=/mnt/PoolOne` 和 `UploadsMountSource=PoolOne`;不得让脚本从旧容器自动推断挂载路径。
+
+### 只读审计与维护交接
+
+先执行只读审计。它会核对完整容器 ID、image ID/RepoDigest、当前 upstream、全部发布端点、ZFS source/target/statfs、目标镜像 digest/提交号,并列出数据库中所有非终态视频、任务详情 job 和 `taskStatus=uploading` 素材;任一计数非零即失败。
+
+```powershell
+.\scripts\legacy-worker-bootstrap-deploy.ps1 @bootstrap -Mode Audit
+```
+
+审计通过并再次确认维护窗口后,才允许进入一次性维护交接:
+
+```powershell
+.\scripts\legacy-worker-bootstrap-deploy.ps1 @bootstrap `
+ -Mode EnterMaintenance `
+ -Apply `
+ -AllowLegacyDrainBootstrap
+```
+
+该步骤在生产机同时持有 bootstrap deployment flock 和代理 reconciliation flock;用带唯一注释的 IPv4/IPv6 防火墙规则只阻断新连接,并同时取得两套连接证据:宿主侧 `ss`,以及宿主 `nsenter` 进入固定旧容器 network namespace 后对服务端口 3000 的 `ss`。两侧都必须连续两次 `ESTABLISHED=0`;只看宿主到容器 IP 的连接在 Docker DNAT 且未启用 userland-proxy 时可能漏掉仍在上传的公网连接。随后再次检查数据库三类非终态计数和宿主机/旧容器 FFmpeg,再 `docker pause` 旧容器并用固定目标镜像的非应用 Node 入口只读查询数据库,以关闭“检查完又启动任务”的竞态。全部通过后使用 `docker stop --signal SIGKILL --time 0`:普通 stop 会在发送优雅退出信号后恢复 paused 容器,存在旧用户代码短暂重跑的窗口,因此这里不能使用普通 stop。脚本必须确认旧容器精确进入 `exited`,再用固定目标镜像复查数据库三类计数并确认宿主机无 FFmpeg,才允许改名保存旧容器;旧镜像不删除。蓝槽的视频和任务详情 drain marker 会预先创建,但 capability 必须由新容器启动后自行发布。
+
+成功后的入口会明确返回 HTTP 503、`Retry-After` 和 `X-Material-Proxy-Target: legacy-bootstrap-maintenance`。这是短维护交接点,不是部署成功。只有到此时,写请求和旧后台 worker 才被技术手段停止;不能以“当前没人使用”代替这个门禁,也不能在 bootstrap 前重建生产索引。
+
+保持 503 维护状态,按《生产数据库安全增量迁移 SOP(2026-08-12)》依次执行:机器生成的生产备份、隔离库恢复、源库与恢复库有界摘要一致性验证、四份 additive migration,以及 migration checksum/列/索引/default 的只读后验审计。任何一步失败都停止,保留旧容器、旧镜像、备份和维护入口;不得继续切流。
+
+上述数据库证据全部通过后,才用普通脚本发布同一个精确 SHA,并保留旧镜像:
+
+```powershell
+.\scripts\git-incremental-deploy.ps1 `
+ -Target production `
+ -Commit $bootstrap.TargetCommit `
+ -MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-.json' `
+ -MigrationAuditSha256 '<审计文件的64位小写SHA-256>' `
+ -KeepOldImages
+```
+
+普通脚本仍必须独立验证候选镜像内置提交号、数据库 schema/migration、真实视频与任务详情 capability、预置 drain、健康接口和切流目标。bootstrap 不会创建候选容器,也不会绕过普通门禁。
+
+### 失败恢复边界
+
+- bootstrap 在维护交接提交前的可确认安全状态失败,会在同一进程内恢复精确旧容器 ID 和原直连入口;旧容器或旧镜像绝不 `rm`。一旦已发出 SIGKILL,但停止状态、停止后的数据库 idle 或宿主机无 FFmpeg 不能被证明,脚本会保持入口阻断、保留旧容器并要求人工恢复,不会在证据未知时自动重启旧 worker。
+- 普通部署失败且入口仍明确是 `legacy-bootstrap-maintenance`、蓝绿容器都未运行时,可用相同 pins 执行下面的恢复命令。恢复脚本会再次核对保存容器的完整 ID/image ID/RepoDigest;只要候选可能已经接收流量,就拒绝自动恢复,避免两个应用世界同时写生产数据。
+
+```powershell
+.\scripts\legacy-worker-bootstrap-deploy.ps1 @bootstrap `
+ -Mode RestoreLegacy `
+ -Apply `
+ -AllowLegacyDrainBootstrap
+```
+
+- 如果恢复门禁拒绝执行,保留维护入口、保存的旧容器和候选现场,先人工判定真实代理目标及是否已有写请求;不能强制启动旧容器。
+- 首个新容器上线后验证自动恢复、60 秒重扫、租约唯一性、源文件保留、健康接口和公网真实页面。再用一条小视频做 canary:上传、排队、生成三类预览、卡片自动出现封面、源文件仍在;任一步失败立即停止继续放量,不回滚 additive 数据列。
+
+## 验收清单
+
+- 服务重启前上传的未完成视频能被新容器自动接回。
+- 同一素材不存在两个有效租约或两个 FFmpeg。
+- 部署期间有后台任务时旧容器不会被停止。
+- 部署期间有活动上传或分片写入时,旧容器不会被误判为空闲。
+- 转码成功、失败、重试和部署中断后 canonical 源视频都仍存在。
+- 15 天保护期不暴露源视频,但卡片仍显示处理/失败状态并在完成后自动出现封面。
+- 已知缺源素材保持明确失败/待人工恢复,不进入批量自动重试。
diff --git a/docs/部署脚本安全说明.md b/docs/部署脚本安全说明.md
index 281b238..b019b41 100644
--- a/docs/部署脚本安全说明.md
+++ b/docs/部署脚本安全说明.md
@@ -14,19 +14,19 @@
生产 uploads 目录:
```text
-/mnt/PoolOne/apps/youyidian-3d/uploads
+/mnt/PoolOne/youyidian-uploads
```
容器挂载:
```bash
--v /mnt/PoolOne/apps/youyidian-3d/uploads:/app/uploads
+-v /mnt/PoolOne/youyidian-uploads:/app/uploads
```
部署脚本的边界:
-- 只检查该目录是否存在。
-- 只把该目录挂载进应用容器。
+- 在创建候选容器前验证该路径确实是预期 ZFS 数据集,不是系统盘上的同名目录壳。
+- 验证容器内 `/app/uploads` 与宿主机数据集的文件系统标识一致,再允许切流。
- 不对该目录执行 `rm`、`mv`、`cp`、`find -delete` 或清空操作。
- 不改变该目录内已有源文件、视频文件、压缩包和上传临时数据。
@@ -58,7 +58,7 @@
- 新素材预览 / 展示文件:写入新 MinIO 桶,公开 URL 统一为 `https://s3.jiansun.vip/gongxiangpan/public-display/...`。
- 旧 `1080p.hk` 桶:只用于极少数老素材兼容,不作为新素材写入目标。
-- 源文件 / 下载型文件:落在生产机 uploads 目录,即 `/mnt/PoolOne/apps/youyidian-3d/uploads`。
+- 源文件 / 下载型文件:落在生产机独立 ZFS uploads 数据集,即 `/mnt/PoolOne/youyidian-uploads`。
## 5. 构建机操作范围
@@ -170,7 +170,7 @@ ssh root@192.168.58.20 'curl -fsS http://127.0.0.1:4320/api/health'
检查 uploads 目录是否还在:
```bash
-ssh root@192.168.58.20 'ls -la /mnt/PoolOne/apps/youyidian-3d/uploads | head -20'
+ssh root@192.168.58.20 'ls -la /mnt/PoolOne/youyidian-uploads | head -20'
```
检查 Docker 镜像和容器:
diff --git a/docs/部署规范.md b/docs/部署规范.md
index 522fc1a..59d0e70 100644
--- a/docs/部署规范.md
+++ b/docs/部署规范.md
@@ -25,14 +25,13 @@
默认参数:
-- Git 仓库:`http://git.1080p.hk/WuYongJie/gongxiangp2.git`
+- Git 仓库:`https://git.1080p.hk/WuYongJie/gongxiangp2.git`(只允许有效 TLS,不得降级到 HTTP)
- 分支:`main`
- 构建机:`192.168.40.206`
- 生产机:`192.168.58.20`
- 正式容器:`youyidian-3d-material`
- 正式端口:`4320`
-- 影子容器:`youyidian-3d-shadow`
-- 影子端口:`5320`
+- 影子发布:当前通用脚本已 fail-closed 禁用;必须使用 uploads/runtime/Redis 完全隔离的专用入口后才能恢复。
`WuYongJie/gongxiangp2` 是当前唯一权威仓库。`lunanting/gongxiangp2` 仅作为历史仓库识别,不得再用于构建或发布。
@@ -47,9 +46,10 @@
- PowerShell 5.1+。
- PuTTY 工具:`plink.exe`、`pscp.exe`。
- 构建机 SSH 凭据,通过 `BUILD_SERVER_PASSWORD` 或 `-BuildServerPassword` 提供。
-- Git 仓库认证,通过 `GIT_DEPLOY_USERNAME` / `GIT_DEPLOY_PASSWORD` 或 `GIT_AUTH_HEADER` 提供。
+- 构建机、生产机的固定 SSH SHA256 主机指纹;OpenSSH 模式还要提供受控 `known_hosts` 文件。
+- 维护机和构建机已配置只读 Git credential helper。部署脚本禁止把 Git 密码/Header 写入参数、临时脚本或进程命令行。
-不要把 Git 密码写进仓库 URL,也不要把临时部署密码写入文档或提交到仓库。
+不要把 Git 密码写进仓库 URL,也不要把临时部署密码写入文档或提交到仓库。HTTPS 证书过期、主机指纹不匹配或权威仓库不可验证时,必须先修基础设施,不能关闭校验强行发布。
### 2.2 部署命令
@@ -65,7 +65,7 @@
.\scripts\git-incremental-deploy.ps1
```
-部署影子环境:
+影子环境(当前禁止通过通用脚本执行;下面命令会明确失败):
```powershell
.\scripts\git-incremental-deploy.ps1 -Target shadow
@@ -85,7 +85,7 @@
## 3. 参数说明
-- `-Target`:部署目标,`production` 或 `shadow`。
+- `-Target`:当前只允许 `production`;`shadow` 会在任何 Git/远程操作前 fail-closed。
- `-RepoUrl`:Git 仓库地址,默认使用 `WuYongJie/gongxiangp2`。
- `-Branch`:部署分支,默认 `main`。
- `-Commit`:指定提交;为空时部署目标分支最新提交。
@@ -93,7 +93,8 @@
- `-NoCache`:Docker 构建不使用缓存。
- `-KeepOldImages`:跳过自动 Docker 镜像清理。
- `-AggressiveBuildCacheCleanup`:额外清理较旧构建缓存;默认不启用。
-- `-SkipBuild` / `-SkipPush` / `-SkipDeploy`:只用于排障,不作为常规发布方式。
+- `-SkipBuild` / `-SkipPush`:只允许同时配合 `-SkipDeploy` 做本地排障;真实生产发布禁止跳过构建或推送。
+- `-SkipDeploy`:只构建/验证,不触碰生产流量。
## 4. 安全边界
@@ -102,7 +103,7 @@
- 不修改生产机 `.env` 文件。
- 不删除、不移动、不覆盖生产机 uploads 目录内容。
- 不直接操作 PostgreSQL、Redis 或 MinIO 容器。
-- 生产机只替换应用容器,并保留 `/mnt/PoolOne/apps/youyidian-3d/uploads:/app/uploads` 挂载。
+- 生产机只替换应用容器,并保留 `/mnt/PoolOne/youyidian-uploads:/app/uploads` 独立 ZFS 数据集挂载。
- 新容器健康检查失败时,脚本默认用旧镜像回滚应用容器。
详细安全说明见:[部署脚本安全说明](./部署脚本安全说明.md)。
@@ -114,8 +115,7 @@
```text
production- # 正式环境确定版本
production-latest # 正式环境最新版本
-shadow- # 影子环境确定版本
-shadow-latest # 影子环境最新版本
+shadow-* # 历史命名;通用发布入口当前不再生成
```
Registry UI:
@@ -193,7 +193,7 @@ ssh root@192.168.58.20 'curl -fsS http://127.0.0.1:4320/api/health'
### uploads 目录检查
```bash
-ssh root@192.168.58.20 'ls -la /mnt/PoolOne/apps/youyidian-3d/uploads | head -20'
+ssh root@192.168.58.20 'ls -la /mnt/PoolOne/youyidian-uploads | head -20'
```
## 9. 版本历史
diff --git a/instrumentation.ts b/instrumentation.ts
index bb08442..623521a 100644
--- a/instrumentation.ts
+++ b/instrumentation.ts
@@ -44,5 +44,23 @@ export async function register() {
// 迁移未执行或数据库暂不可用时只记录;周期恢复已提前注册,会继续重试。
console.error('[Instrumentation] 任务-详情后台队列恢复失败:', error)
}
+
+ const videoRecoveryGlobal = globalThis as typeof globalThis & {
+ __videoTranscodeRecoveryScheduler?: {
+ initial: Promise
+ }
+ }
+ if (!videoRecoveryGlobal.__videoTranscodeRecoveryScheduler) {
+ const { createVideoTranscodeRecoveryScheduler } = await import('./lib/video-transcode-recovery')
+ const scheduler = createVideoTranscodeRecoveryScheduler()
+ videoRecoveryGlobal.__videoTranscodeRecoveryScheduler = scheduler
+ try {
+ const recovered = await scheduler.initial
+ console.log(`[Instrumentation] 视频转码启动恢复完成,重新入队 ${recovered} 个任务`)
+ } catch (error) {
+ // 周期调度器已先注册;数据库瞬时不可用时,60 秒后会继续重试。
+ console.error('[Instrumentation] 视频转码启动恢复失败,已保留周期重试:', error)
+ }
+ }
}
}
diff --git a/lib/__tests__/bounded-file-reader.test.ts b/lib/__tests__/bounded-file-reader.test.ts
new file mode 100644
index 0000000..1d4ab89
--- /dev/null
+++ b/lib/__tests__/bounded-file-reader.test.ts
@@ -0,0 +1,97 @@
+/** @jest-environment node */
+
+import { mkdir, mkdtemp, rm, symlink, writeFile } from 'fs/promises'
+import os from 'os'
+import path from 'path'
+
+import {
+ readBoundedRegularFile,
+ readBoundedResponseBuffer,
+} from '@/lib/bounded-file-reader'
+
+describe('bounded-file-reader', () => {
+ let root: string
+
+ beforeEach(async () => {
+ root = await mkdtemp(path.join(os.tmpdir(), 'bounded-file-reader-'))
+ })
+
+ afterEach(async () => {
+ await rm(root, { recursive: true, force: true })
+ })
+
+ test('在分配 Buffer 前拒绝超限、申报不一致和符号链接文件', async () => {
+ const filePath = path.join(root, 'source.bin')
+ await writeFile(filePath, Buffer.from('12345'))
+
+ await expect(readBoundedRegularFile(filePath, {
+ maxBytes: 4,
+ label: '测试文件',
+ })).rejects.toThrow('超过 1MB 限制')
+
+ await expect(readBoundedRegularFile(filePath, {
+ maxBytes: 10,
+ expectedBytes: 4,
+ label: '测试文件',
+ })).rejects.toThrow('实际大小与上传记录不一致')
+
+ const symlinkTarget = path.join(root, 'source-link-target')
+ const symlinkPath = path.join(root, 'source-link')
+ await mkdir(symlinkTarget)
+ await symlink(symlinkTarget, symlinkPath, process.platform === 'win32' ? 'junction' : 'dir')
+ await expect(readBoundedRegularFile(symlinkPath, {
+ maxBytes: 10,
+ expectedBytes: 5,
+ label: '测试文件',
+ })).rejects.toThrow('不是可读取的普通文件')
+ })
+
+ test('普通文件仅在实际大小与申报一致时返回完整内容', async () => {
+ const filePath = path.join(root, 'source.bin')
+ await writeFile(filePath, Buffer.from('bounded'))
+
+ await expect(readBoundedRegularFile(filePath, {
+ maxBytes: 64,
+ expectedBytes: 7,
+ label: '测试文件',
+ })).resolves.toEqual(Buffer.from('bounded'))
+ })
+
+ test('远程响应无 Content-Length 时仍按可信大小硬停,且不使用 arrayBuffer', async () => {
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array([1, 2]))
+ controller.enqueue(new Uint8Array([3, 4]))
+ controller.close()
+ },
+ })
+ const response = new Response(stream)
+
+ await expect(readBoundedResponseBuffer(response, {
+ maxBytes: 4,
+ expectedBytes: 3,
+ label: '远程文件',
+ })).rejects.toThrow('响应超过申报大小')
+ })
+
+ test('远程响应先校验 Content-Length,并按申报大小返回单个 Buffer', async () => {
+ const response = new Response(Buffer.from('remote'), {
+ headers: { 'Content-Length': '6' },
+ })
+
+ await expect(readBoundedResponseBuffer(response, {
+ maxBytes: 10,
+ expectedBytes: 6,
+ label: '远程文件',
+ })).resolves.toEqual(Buffer.from('remote'))
+
+ const mismatched = new Response(Buffer.from('remote'), {
+ headers: { 'Content-Length': '6' },
+ })
+ await expect(readBoundedResponseBuffer(mismatched, {
+ maxBytes: 10,
+ expectedBytes: 5,
+ label: '远程文件',
+ })).rejects.toThrow('响应大小与上传记录不一致')
+ })
+})
diff --git a/lib/__tests__/chunk-upload.test.ts b/lib/__tests__/chunk-upload.test.ts
index c458195..4c82562 100644
--- a/lib/__tests__/chunk-upload.test.ts
+++ b/lib/__tests__/chunk-upload.test.ts
@@ -88,9 +88,10 @@ function createIndexedDbMock(): IDBFactory {
class MockXMLHttpRequest {
static instances: MockXMLHttpRequest[] = []
+ static defaultStatus = 413
timeout = 0
- status = 413
+ status = MockXMLHttpRequest.defaultStatus
responseText = ''
upload = {
onloadstart: null as (() => void) | null,
@@ -129,6 +130,7 @@ describe('chunkUpload', () => {
beforeEach(() => {
jest.clearAllMocks()
MockXMLHttpRequest.instances = []
+ MockXMLHttpRequest.defaultStatus = 413
Object.defineProperty(global, 'indexedDB', {
configurable: true,
@@ -236,4 +238,139 @@ describe('chunkUpload', () => {
expect(getOptimalConcurrency).not.toHaveBeenCalled()
expect(MockXMLHttpRequest.instances).toHaveLength(1)
})
+
+ test('断点续传沿用服务端既有分片布局,不受公网内网切换影响', async () => {
+ ;(global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ uploadId: 'upload-resume-layout' }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ message: '清理成功' }),
+ })
+
+ const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
+
+ MockXMLHttpRequest.defaultStatus = 500
+ await expect(chunkUpload(
+ file,
+ { materialType: '任务-详情' },
+ { chunkSize: 2, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 },
+ )).rejects.toThrow('失败')
+
+ await expect(getAllPendingUploads()).resolves.toEqual([
+ expect.objectContaining({
+ uploadId: 'upload-resume-layout',
+ chunkSize: 2,
+ totalChunks: 3,
+ }),
+ ])
+
+ MockXMLHttpRequest.defaultStatus = 413
+ await expect(chunkUpload(
+ file,
+ { materialType: '任务-详情' },
+ // 模拟网络探测从公网2字节布局切到内网4字节布局。
+ { chunkSize: 4, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 },
+ )).rejects.toThrow('上传分片超过入口服务器限制')
+
+ const resumedRequest = MockXMLHttpRequest.instances[1]
+ expect(resumedRequest.headers['X-Chunk-Size']).toBe('2')
+ expect(resumedRequest.headers['X-Chunk-Index']).toBe('0')
+ expect(global.fetch).toHaveBeenCalledTimes(2)
+ })
+
+ test('特殊批次跳过 complete 时保留断点,建档确认后才删除', async () => {
+ ;(global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ uploadId: 'upload-retained-special-batch' }),
+ })
+ MockXMLHttpRequest.defaultStatus = 200
+
+ const file = new File(['hello'], 'brand-vi.pdf', { type: 'application/pdf' })
+ const result = await chunkUpload(
+ file,
+ { materialType: '品牌VI灵感' },
+ {
+ chunkSize: 5,
+ chunkConcurrency: 1,
+ skipComplete: true,
+ retainStateOnSkipComplete: true,
+ },
+ )
+
+ await expect(getAllPendingUploads()).resolves.toEqual([
+ expect.objectContaining({
+ uploadId: 'upload-retained-special-batch',
+ uploadedChunks: new Set([0]),
+ }),
+ ])
+
+ await result.confirmMaterialCreated()
+ await expect(getAllPendingUploads()).resolves.toEqual([])
+ })
+
+ test('同一文件并发选择两次时拒绝第二条,避免共用 uploadId 覆盖分片', async () => {
+ ;(global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: '测试结束首条上传' }),
+ })
+
+ const file = new File(['hello'], 'duplicate.psb', {
+ type: 'application/octet-stream',
+ lastModified: 123456,
+ })
+
+ const firstUpload = chunkUpload(
+ file,
+ { materialType: '任务-详情' },
+ { maxRetries: 1, retryDelay: 0 },
+ )
+
+ await expect(chunkUpload(
+ file,
+ { materialType: '任务-详情' },
+ { maxRetries: 1, retryDelay: 0 },
+ )).rejects.toThrow('正在上传,请勿在同一批次重复选择')
+
+ await expect(firstUpload).rejects.toThrow('测试结束首条上传')
+ expect(global.fetch).toHaveBeenCalledTimes(1)
+ })
+
+ test('普通 complete 响应丢失时保留断点且不调用 destructive cleanup', async () => {
+ ;(global.fetch as jest.Mock)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ uploadId: 'upload-response-lost' }),
+ })
+ .mockRejectedValueOnce(new TypeError('network connection lost'))
+ MockXMLHttpRequest.defaultStatus = 200
+
+ const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
+
+ await expect(chunkUpload(
+ file,
+ { materialType: '3D场景' },
+ {
+ chunkSize: 5,
+ chunkConcurrency: 1,
+ maxRetries: 1,
+ retryDelay: 0,
+ },
+ )).rejects.toThrow('已保留上传断点')
+
+ await expect(getAllPendingUploads()).resolves.toEqual([
+ expect.objectContaining({
+ uploadId: 'upload-response-lost',
+ uploadedChunks: new Set([0]),
+ }),
+ ])
+ expect(global.fetch).toHaveBeenCalledTimes(2)
+ expect(global.fetch).not.toHaveBeenCalledWith(
+ '/api/materials/upload/cleanup',
+ expect.anything(),
+ )
+ })
})
diff --git a/lib/__tests__/image-processing-limiter.test.ts b/lib/__tests__/image-processing-limiter.test.ts
index 2c26f38..c6bfdd7 100644
--- a/lib/__tests__/image-processing-limiter.test.ts
+++ b/lib/__tests__/image-processing-limiter.test.ts
@@ -1,8 +1,25 @@
/** @jest-environment node */
+import { EventEmitter } from 'events'
+import { mkdtemp, rm } from 'fs/promises'
+import os from 'os'
+import path from 'path'
+
describe('image-processing-limiter', () => {
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ const originalNodeEnv = process.env.NODE_ENV
+ const originalUploadDir = process.env.UPLOAD_DIR
+
afterEach(() => {
delete process.env.IMAGE_PROCESSING_MAX_CONCURRENT
+ const mutableEnv = process.env as Record
+ if (originalNodeEnv === undefined) delete mutableEnv.NODE_ENV
+ else mutableEnv.NODE_ENV = originalNodeEnv
+ if (originalUploadDir === undefined) delete process.env.UPLOAD_DIR
+ else process.env.UPLOAD_DIR = originalUploadDir
+ if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
+ jest.dontMock('child_process')
+ jest.resetModules()
})
test('并发为 2 时同时释放两个任务,名额仍先直接转交给既有等待者', async () => {
@@ -60,4 +77,92 @@ describe('image-processing-limiter', () => {
expect(peak).toBe(2)
})
+
+ function createFlockChild() {
+ const child = new EventEmitter() as EventEmitter & {
+ stdin: EventEmitter & { end: jest.Mock }
+ stdout: EventEmitter
+ stderr: EventEmitter
+ exitCode: number | null
+ signalCode: NodeJS.Signals | null
+ kill: jest.Mock
+ }
+ child.stdin = Object.assign(new EventEmitter(), { end: jest.fn() })
+ child.stdout = new EventEmitter()
+ child.stderr = new EventEmitter()
+ child.exitCode = null
+ child.signalCode = null
+ child.kill = jest.fn(() => true)
+ child.stdin.end.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.exitCode = 0
+ child.emit('close', 0, null)
+ })
+ })
+ queueMicrotask(() => child.stdout.emit('data', 'IMAGE_PROCESSING_LOCK_READY\n'))
+ return child
+ }
+
+ test('生产 Linux 使用共享 uploads 上的同一把 exclusive flock,且忽略进程内并发配置', async () => {
+ const uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'image-processing-lock-'))
+ const children: ReturnType[] = []
+ const spawnMock = jest.fn(() => {
+ const child = createFlockChild()
+ children.push(child)
+ return child
+ })
+ jest.doMock('child_process', () => ({ spawn: spawnMock }))
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
+ ;(process.env as Record).NODE_ENV = 'production'
+ process.env.UPLOAD_DIR = uploadRoot
+ process.env.IMAGE_PROCESSING_MAX_CONCURRENT = '2'
+
+ try {
+ const { withImageProcessingSlot, __imageProcessingLimiterTesting } = await import(
+ '@/lib/image-processing-limiter'
+ )
+ await withImageProcessingSlot(async () => withImageProcessingSlot(async () => 'done'))
+
+ const expectedLockPath = path.join(uploadRoot, '.locks', 'image-processing-global.lock')
+ expect(__imageProcessingLimiterTesting.imageProcessingLockPath()).toBe(expectedLockPath)
+ expect(spawnMock).toHaveBeenCalledWith(
+ 'flock',
+ expect.arrayContaining(['--exclusive', expectedLockPath]),
+ expect.objectContaining({ shell: false }),
+ )
+ expect(spawnMock).toHaveBeenCalledTimes(1)
+ expect(children[0].stdin.end).toHaveBeenCalledTimes(1)
+ } finally {
+ await rm(uploadRoot, { recursive: true, force: true })
+ }
+ })
+
+ test('生产 flock 在任务期间丢失时失败关闭,不返回本次图片结果', async () => {
+ const uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'image-processing-lock-lost-'))
+ let child: ReturnType
+ jest.doMock('child_process', () => ({
+ spawn: jest.fn(() => {
+ child = createFlockChild()
+ return child
+ }),
+ }))
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
+ ;(process.env as Record).NODE_ENV = 'production'
+ process.env.UPLOAD_DIR = uploadRoot
+
+ try {
+ const { withImageProcessingSlot, ImageProcessingLockLostError } = await import(
+ '@/lib/image-processing-limiter'
+ )
+ await expect(withImageProcessingSlot(async () => {
+ expect(child!).toBeDefined()
+ child.exitCode = 1
+ child.emit('close', 1, null)
+ await new Promise((resolve) => setImmediate(resolve))
+ return 'must-not-escape'
+ })).rejects.toBeInstanceOf(ImageProcessingLockLostError)
+ } finally {
+ await rm(uploadRoot, { recursive: true, force: true })
+ }
+ })
})
diff --git a/lib/__tests__/manual-transcode-script-safety.test.ts b/lib/__tests__/manual-transcode-script-safety.test.ts
new file mode 100644
index 0000000..351a41b
--- /dev/null
+++ b/lib/__tests__/manual-transcode-script-safety.test.ts
@@ -0,0 +1,17 @@
+import { readFile } from 'fs/promises'
+import path from 'path'
+
+describe('旧手工转码脚本安全边界', () => {
+ test('脚本必须保持禁用,不能直接改数据库或启动进程内队列', async () => {
+ const source = await readFile(
+ path.join(process.cwd(), 'scripts/manual-trigger-transcode.ts'),
+ 'utf8',
+ )
+
+ expect(source).toContain('已停用')
+ expect(source).toContain('process.exitCode = 1')
+ expect(source).not.toContain('PrismaClient')
+ expect(source).not.toContain('enqueueVideoTranscodeTask')
+ expect(source).not.toContain('prisma.material.update')
+ })
+})
diff --git a/lib/__tests__/material-canonical-source.test.ts b/lib/__tests__/material-canonical-source.test.ts
new file mode 100644
index 0000000..e4f37d8
--- /dev/null
+++ b/lib/__tests__/material-canonical-source.test.ts
@@ -0,0 +1,90 @@
+/** @jest-environment node */
+
+import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
+import os from 'os'
+import path from 'path'
+
+import {
+ validateCanonicalMaterialSource,
+} from '@/lib/material-canonical-source'
+
+describe('canonical 音乐/视频源文件完整性校验', () => {
+ let uploadRoot = ''
+ const previousUploadDir = process.env.UPLOAD_DIR
+
+ beforeEach(async () => {
+ uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'canonical-material-source-'))
+ process.env.UPLOAD_DIR = uploadRoot
+ })
+
+ afterEach(async () => {
+ if (previousUploadDir === undefined) {
+ delete process.env.UPLOAD_DIR
+ } else {
+ process.env.UPLOAD_DIR = previousUploadDir
+ }
+ await rm(uploadRoot, { recursive: true, force: true })
+ })
+
+ test('受信根内的非空普通文件且实际大小一致时通过', async () => {
+ const sourcePath = path.join(uploadRoot, 'video-source.mp4')
+ const source = Buffer.from('canonical-video-source')
+ await writeFile(sourcePath, source)
+
+ await expect(validateCanonicalMaterialSource({
+ storedPath: sourcePath,
+ expectedSize: BigInt(source.length),
+ })).resolves.toEqual({
+ sourcePath,
+ size: BigInt(source.length),
+ })
+ })
+
+ test('stat 失败时拒绝提交', async () => {
+ const missingPath = path.join(uploadRoot, 'missing-source.mp4')
+
+ await expect(validateCanonicalMaterialSource({
+ storedPath: missingPath,
+ expectedSize: BigInt(10),
+ })).rejects.toMatchObject({
+ code: 'CANONICAL_SOURCE_STAT_FAILED',
+ })
+ })
+
+ test('实际大小与已知 zipFileSize 不一致时拒绝提交', async () => {
+ const sourcePath = path.join(uploadRoot, 'audio-source.mp3')
+ await writeFile(sourcePath, Buffer.from('actual-audio'))
+
+ await expect(validateCanonicalMaterialSource({
+ storedPath: sourcePath,
+ expectedSize: BigInt(1),
+ })).rejects.toMatchObject({
+ code: 'CANONICAL_SOURCE_SIZE_MISMATCH',
+ })
+ })
+
+ test('目录和受信根外路径不能伪装成 canonical 普通文件', async () => {
+ const directoryPath = path.join(uploadRoot, 'not-a-file')
+ await mkdir(directoryPath)
+
+ await expect(validateCanonicalMaterialSource({
+ storedPath: directoryPath,
+ expectedSize: BigInt(1),
+ })).rejects.toMatchObject({
+ code: 'CANONICAL_SOURCE_NOT_REGULAR',
+ })
+
+ const outsidePath = path.join(path.dirname(uploadRoot), 'outside-source.mp4')
+ await writeFile(outsidePath, Buffer.from('outside'))
+ try {
+ await expect(validateCanonicalMaterialSource({
+ storedPath: outsidePath,
+ expectedSize: BigInt(7),
+ })).rejects.toMatchObject({
+ code: 'CANONICAL_SOURCE_PATH_UNTRUSTED',
+ })
+ } finally {
+ await rm(outsidePath, { force: true })
+ }
+ })
+})
diff --git a/lib/__tests__/material-edit-routes.test.ts b/lib/__tests__/material-edit-routes.test.ts
index d83e330..e691aba 100644
--- a/lib/__tests__/material-edit-routes.test.ts
+++ b/lib/__tests__/material-edit-routes.test.ts
@@ -123,11 +123,21 @@ describe('material edit routes', () => {
return formData
}
- test('PUT /api/materials/[id] 在没有全局 File 时仍可正常处理表单更新', async () => {
- const request = new NextRequest('http://localhost/api/materials/material-1', {
+ function createPutRequest(url: string, formData: FormData) {
+ return new NextRequest(url, {
method: 'PUT',
- body: createFormData(),
+ headers: {
+ 'content-length': '1024',
+ },
+ body: formData,
})
+ }
+
+ test('PUT /api/materials/[id] 在没有全局 File 时仍可正常处理表单更新', async () => {
+ const request = createPutRequest(
+ 'http://localhost/api/materials/material-1',
+ createFormData(),
+ )
const response = await putMaterialById(request, {
params: { id: 'material-1' },
@@ -148,10 +158,7 @@ describe('material edit routes', () => {
const formData = createFormData()
formData.append('id', 'material-1')
- const request = new NextRequest('http://localhost/api/materials/update', {
- method: 'PUT',
- body: formData,
- })
+ const request = createPutRequest('http://localhost/api/materials/update', formData)
const response = await putMaterialUpdate(request)
const body = await response.json()
@@ -171,10 +178,7 @@ describe('material edit routes', () => {
name: '/api/materials/[id]',
put: async (formData: FormData) =>
putMaterialById(
- new NextRequest('http://localhost/api/materials/material-1', {
- method: 'PUT',
- body: formData,
- }),
+ createPutRequest('http://localhost/api/materials/material-1', formData),
{ params: { id: 'material-1' } },
),
},
@@ -183,10 +187,7 @@ describe('material edit routes', () => {
put: async (formData: FormData) => {
formData.append('id', 'material-1')
return putMaterialUpdate(
- new NextRequest('http://localhost/api/materials/update', {
- method: 'PUT',
- body: formData,
- }),
+ createPutRequest('http://localhost/api/materials/update', formData),
)
},
},
@@ -394,10 +395,10 @@ describe('material edit routes', () => {
})
test('PUT 路由应透传任务详情出图方式字段', async () => {
- const request = new NextRequest('http://localhost/api/materials/material-1', {
- method: 'PUT',
- body: createFormData(),
- })
+ const request = createPutRequest(
+ 'http://localhost/api/materials/material-1',
+ createFormData(),
+ )
await putMaterialById(request, {
params: { id: 'material-1' },
@@ -420,14 +421,11 @@ describe('material edit routes', () => {
if (legacyRoute) {
formData.append('id', 'material-1')
}
- const request = new NextRequest(
+ const request = createPutRequest(
legacyRoute
? 'http://localhost/api/materials/update'
: 'http://localhost/api/materials/material-1',
- {
- method: 'PUT',
- body: formData,
- },
+ formData,
)
if (legacyRoute) {
diff --git a/lib/__tests__/material-permanent-delete-disabled.test.ts b/lib/__tests__/material-permanent-delete-disabled.test.ts
new file mode 100644
index 0000000..aa4f1ab
--- /dev/null
+++ b/lib/__tests__/material-permanent-delete-disabled.test.ts
@@ -0,0 +1,18 @@
+/** @jest-environment node */
+
+import { readFile } from 'fs/promises'
+import path from 'path'
+
+describe('material permanent delete safety gate', () => {
+ test.each([
+ 'app/api/materials/route.ts',
+ 'app/api/admin/materials/batch-delete/route.ts',
+ ])('%s 在持久化 purge/outbox 上线前禁止物理删除', async (relativePath) => {
+ const source = await readFile(path.join(process.cwd(), relativePath), 'utf8')
+
+ expect(source).toContain("code: 'PERMANENT_DELETE_DISABLED'")
+ expect(source).not.toContain('deleteFiles(')
+ expect(source).not.toContain('deleteMaterialRelatedRecords(')
+ expect(source).not.toContain('prisma.material.delete(')
+ })
+})
diff --git a/lib/__tests__/material-query.test.ts b/lib/__tests__/material-query.test.ts
index 5922d2e..9a51aa0 100644
--- a/lib/__tests__/material-query.test.ts
+++ b/lib/__tests__/material-query.test.ts
@@ -1,8 +1,11 @@
import { prisma } from '@/lib/prisma'
import {
+ SPECIAL_INIT_MATERIAL_TYPES,
+ buildIncompleteSpecialInitShellWhere,
buildMaterialSearchOrConditions,
buildMaterialWhereFromSearchParams,
createMaterialQueryContext,
+ isProvablyIncompleteSpecialInitShell,
} from '@/lib/material-query'
import {
getUserViewableMaterialTypes,
@@ -114,7 +117,7 @@ describe('material search conditions', () => {
expect(getUserViewableMaterialTypes).not.toHaveBeenCalled()
})
- test('正常列表应隐藏没有任何文件的任务-页面空记录', async () => {
+ test('正常列表应统一隐藏专用 init 产生的无文件未完成空壳', async () => {
;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-页面'])
const result = await buildMaterialWhereFromSearchParams(
@@ -123,12 +126,84 @@ describe('material search conditions', () => {
)
expect(result.empty).toBe(false)
- expect(result.where.NOT).toEqual({
+ expect(result.where.NOT).toEqual(buildIncompleteSpecialInitShellWhere())
+ })
+
+ test.each(SPECIAL_INIT_MATERIAL_TYPES)(
+ '%s 的 uploading 无文件记录应判定为不可见空壳',
+ (materialType) => {
+ expect(isProvablyIncompleteSpecialInitShell({
+ materialType,
+ taskStatus: 'uploading',
+ previewUrl: '',
+ zipFilePath: '',
+ zipFileName: '',
+ displayImages: [],
+ assets: [],
+ })).toBe(true)
+ },
+ )
+
+ test.each(SPECIAL_INIT_MATERIAL_TYPES)(
+ '%s 的 completed 记录即使字段异常为空也不应被空壳规则隐藏',
+ (materialType) => {
+ expect(isProvablyIncompleteSpecialInitShell({
+ materialType,
+ taskStatus: 'completed',
+ previewUrl: '',
+ zipFilePath: '',
+ zipFileName: '',
+ displayImages: [],
+ assets: [],
+ })).toBe(false)
+ },
+ )
+
+ test.each([
+ ['previewUrl', { previewUrl: '/uploads/preview.jpg' }],
+ ['zipFilePath', { zipFilePath: '/uploads/source.zip' }],
+ ['zipFileName', { zipFileName: 'https://storage.example/source.zip' }],
+ ['displayImages', { displayImages: ['/uploads/display.jpg'] }],
+ ['assets', { assets: [{ id: 'asset-1' }] }],
+ ])('有真实 %s 证据的 uploading 记录应继续可见', (_label, evidence) => {
+ expect(isProvablyIncompleteSpecialInitShell({
materialType: '任务-页面',
+ taskStatus: 'uploading',
previewUrl: '',
zipFilePath: '',
- assets: { none: {} },
- })
+ zipFileName: '',
+ displayImages: [],
+ assets: [],
+ ...evidence,
+ })).toBe(false)
+ })
+
+ test('product-package init 的 null 状态只有具备专属初始化标记时才隐藏', () => {
+ expect(isProvablyIncompleteSpecialInitShell({
+ materialType: '任务-详情',
+ taskStatus: null,
+ previewUrl: '',
+ zipFilePath: '',
+ zipFileName: '',
+ displayImages: [],
+ assets: [],
+ taskDetailSourceType: 'AI',
+ taskDetailRequesterUserId: 'requester-1',
+ })).toBe(true)
+ })
+
+ test('历史任务详情的有效 null 状态没有新 init 双标记时不应误隐藏', () => {
+ expect(isProvablyIncompleteSpecialInitShell({
+ materialType: '任务-详情',
+ taskStatus: null,
+ previewUrl: '',
+ zipFilePath: '',
+ zipFileName: '',
+ displayImages: [],
+ assets: [],
+ taskDetailSourceType: null,
+ taskDetailRequesterUserId: null,
+ })).toBe(false)
})
test('回收站查询不应套用任务-页面空记录隐藏条件', async () => {
diff --git a/lib/__tests__/material-update.test.ts b/lib/__tests__/material-update.test.ts
index a3c2d93..4f9ffd1 100644
--- a/lib/__tests__/material-update.test.ts
+++ b/lib/__tests__/material-update.test.ts
@@ -1,7 +1,8 @@
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import { compressImage, sanitizeFileName } from '@/lib/image-processor'
-import { deleteFromS3, uploadToS3 } from '@/lib/s3'
+import { uploadToS3 } from '@/lib/s3'
+import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
@@ -12,6 +13,7 @@ jest.mock('@/lib/prisma', () => ({
material: {
findFirst: jest.fn(),
findMany: jest.fn(),
+ findUnique: jest.fn(),
update: jest.fn(),
},
user: {
@@ -26,10 +28,13 @@ jest.mock('@/lib/material-log', () => ({
}))
jest.mock('@/lib/s3', () => ({
- deleteFromS3: jest.fn(),
uploadToS3: jest.fn(),
}))
+jest.mock('@/lib/file-cleanup', () => ({
+ deleteFileIfUnreferenced: jest.fn(),
+}))
+
jest.mock('@/lib/image-processor', () => ({
compressImage: jest.fn(),
sanitizeFileName: jest.fn(),
@@ -79,7 +84,7 @@ describe('material-update shared rules', () => {
;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed'))
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue('https://new-preview.example/cover.webp')
;(uploadToS3 as jest.Mock).mockResolvedValue('https://new-preview.example/cover.webp')
- ;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined)
+ ;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({ success: true })
})
test('切换到标准模型时应自动回写通用店铺和场景', async () => {
@@ -993,10 +998,139 @@ describe('material-update shared rules', () => {
expect('error' in result).toBe(false)
expect(uploadPublicDisplayToS3).toHaveBeenCalledWith(
Buffer.from('compressed'),
- expect.stringMatching(/^materials\/preview-\d+-cover\.png$/),
+ expect.stringMatching(/^materials\/preview-\d+-[0-9a-f-]{36}-cover\.png$/),
'image/webp',
)
expect(uploadToS3).not.toHaveBeenCalled()
- expect(deleteFromS3).toHaveBeenCalledWith('https://old-preview.example/cover.webp')
+ expect(deleteFileIfUnreferenced).toHaveBeenCalledWith(
+ 'https://old-preview.example/cover.webp',
+ { excludeMaterialIds: ['material-1'] },
+ )
+ })
+
+ test('不同素材同毫秒上传同名预览也必须使用不同对象键', async () => {
+ jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
+ ;(prisma.material.findFirst as jest.Mock)
+ .mockResolvedValueOnce(baseMaterial)
+ .mockResolvedValueOnce({ ...baseMaterial, id: 'material-2' })
+ ;(prisma.material.update as jest.Mock).mockImplementation(async ({ where, data }) => ({
+ ...baseMaterial,
+ id: where.id,
+ ...data,
+ }))
+ ;(uploadPublicDisplayToS3 as jest.Mock)
+ .mockResolvedValueOnce('https://new-preview.example/one.webp')
+ .mockResolvedValueOnce('https://new-preview.example/two.webp')
+
+ const input = {
+ productModel: 'SPU-001',
+ categoryName: '新店铺',
+ sceneName: '新场景',
+ platformName: 'Amazon',
+ productCategoryName: '',
+ materialType: '3D场景',
+ renderer: 'CR',
+ authorName: '新作者',
+ userId: 'user-1',
+ }
+ const makePreview = () => ({
+ name: 'cover.png',
+ size: 7,
+ arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('preview')),
+ } as any)
+
+ await updateMaterialWithSharedRules({
+ materialId: 'material-1', operatorUserId: 'user-1', input, previewImage: makePreview(),
+ })
+ await updateMaterialWithSharedRules({
+ materialId: 'material-2', operatorUserId: 'user-1', input, previewImage: makePreview(),
+ })
+
+ const firstKey = (uploadPublicDisplayToS3 as jest.Mock).mock.calls[0][1]
+ const secondKey = (uploadPublicDisplayToS3 as jest.Mock).mock.calls[1][1]
+ expect(firstKey).not.toBe(secondKey)
+ expect(firstKey).toMatch(/^materials\/preview-1700000000000-[0-9a-f-]{36}-cover\.png$/)
+ expect(secondKey).toMatch(/^materials\/preview-1700000000000-[0-9a-f-]{36}-cover\.png$/)
+ })
+
+ test('数据库明确未提交新预览时只清本次新对象并保留旧图', async () => {
+ ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
+ ;(prisma.material.update as jest.Mock).mockRejectedValue(new Error('database unavailable'))
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValue(baseMaterial)
+
+ const previewImage = {
+ name: 'cover.png',
+ size: 7,
+ arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('preview')),
+ } as any
+
+ await expect(updateMaterialWithSharedRules({
+ materialId: 'material-1',
+ operatorUserId: 'user-1',
+ operatorRole: 'USER',
+ input: {
+ productModel: 'SPU-001',
+ categoryName: '新店铺',
+ sceneName: '新场景',
+ platformName: 'Amazon',
+ productCategoryName: '',
+ materialType: '3D场景',
+ renderer: 'CR',
+ authorName: '新作者',
+ userId: 'user-1',
+ },
+ previewImage,
+ })).rejects.toThrow('database unavailable')
+
+ expect(deleteFileIfUnreferenced).toHaveBeenCalledWith(
+ 'https://new-preview.example/cover.webp',
+ { excludeMaterialIds: ['material-1'] },
+ )
+ expect(deleteFileIfUnreferenced).not.toHaveBeenCalledWith(
+ 'https://old-preview.example/cover.webp',
+ expect.anything(),
+ )
+ })
+
+ test('数据库提交确认丢失但回读已是新预览时不得删除新对象', async () => {
+ ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
+ ;(prisma.material.update as jest.Mock).mockRejectedValue(new Error('commit acknowledgement lost'))
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
+ ...baseMaterial,
+ previewUrl: 'https://new-preview.example/cover.webp',
+ productModel: 'SPU-001',
+ })
+
+ const result = await updateMaterialWithSharedRules({
+ materialId: 'material-1',
+ operatorUserId: 'user-1',
+ operatorRole: 'USER',
+ input: {
+ productModel: 'SPU-001',
+ categoryName: '新店铺',
+ sceneName: '新场景',
+ platformName: 'Amazon',
+ productCategoryName: '',
+ materialType: '3D场景',
+ renderer: 'CR',
+ authorName: '新作者',
+ userId: 'user-1',
+ },
+ previewImage: {
+ name: 'cover.png',
+ size: 7,
+ arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('preview')),
+ } as any,
+ })
+
+ expect('error' in result).toBe(false)
+ expect(deleteFileIfUnreferenced).not.toHaveBeenCalledWith(
+ 'https://new-preview.example/cover.webp',
+ expect.anything(),
+ )
+ expect(deleteFileIfUnreferenced).toHaveBeenCalledWith(
+ 'https://old-preview.example/cover.webp',
+ { excludeMaterialIds: ['material-1'] },
+ )
})
})
diff --git a/lib/__tests__/material-upload-chunk-limit.test.ts b/lib/__tests__/material-upload-chunk-limit.test.ts
new file mode 100644
index 0000000..4c69a83
--- /dev/null
+++ b/lib/__tests__/material-upload-chunk-limit.test.ts
@@ -0,0 +1,366 @@
+/** @jest-environment node */
+
+import { mkdtemp, readFile, readdir, rm, stat } from 'fs/promises'
+import os from 'os'
+import path from 'path'
+import { NextRequest } from 'next/server'
+
+import { POST } from '@/app/api/materials/upload/chunk/route'
+import { materialUploadChunkRequestLimiter } from '@/lib/material-upload-request-guard'
+import { getAuthenticatedUploadUserId } from '@/lib/upload-auth'
+import { getUploadSession, updateChunkWithStats } from '@/lib/upload-sessions'
+import {
+ beginUploadCompletionSeal,
+ cancelUploadSessionSeal,
+ completeUploadCompletionSeal,
+} from '@/lib/material-upload-session-seal'
+import { acquireMaterialUploadStorageLock } from '@/lib/material-upload-cross-process-lock'
+
+jest.mock('@/lib/upload-auth', () => ({
+ getAuthenticatedUploadUserId: jest.fn(),
+}))
+
+jest.mock('@/lib/upload-sessions', () => ({
+ getUploadSession: jest.fn(),
+ updateChunkWithStats: jest.fn(),
+}))
+
+const MEBIBYTE = 1024 * 1024
+
+function streamFromChunks(chunks: Uint8Array[]): ReadableStream {
+ let index = 0
+ return new ReadableStream({
+ pull(controller) {
+ const chunk = chunks[index]
+ index += 1
+ if (!chunk) {
+ controller.close()
+ return
+ }
+ controller.enqueue(chunk)
+ },
+ })
+}
+
+function createRawChunkRequest(
+ chunks: Uint8Array[],
+ declaredLength: number,
+ includeSizeHint = true,
+) {
+ const headers = new Headers({
+ 'content-length': String(declaredLength),
+ 'content-type': 'application/octet-stream',
+ 'x-file-name': 'video.mp4',
+ 'x-upload-id': 'upload-test-1',
+ 'x-chunk-index': '0',
+ })
+ if (includeSizeHint) {
+ headers.set('x-chunk-size', String(declaredLength))
+ }
+
+ return new NextRequest('http://localhost/api/materials/upload/chunk', {
+ method: 'POST',
+ headers,
+ body: streamFromChunks(chunks),
+ duplex: 'half',
+ } as any)
+}
+
+describe('material upload chunk hard limit', () => {
+ let uploadRoot: string
+
+ beforeEach(async () => {
+ jest.clearAllMocks()
+ uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-chunk-limit-'))
+ process.env.UPLOAD_DIR = uploadRoot
+ ;(getAuthenticatedUploadUserId as jest.Mock).mockResolvedValue('user-1')
+ ;(getUploadSession as jest.Mock).mockResolvedValue({
+ fileName: 'video.mp4',
+ fileSize: 40 * MEBIBYTE,
+ totalChunks: 2,
+ chunkSize: 20 * MEBIBYTE,
+ chunks: new Map(),
+ createdAt: Date.now(),
+ userId: 'user-1',
+ })
+ ;(updateChunkWithStats as jest.Mock).mockResolvedValue(undefined)
+ })
+
+ afterEach(async () => {
+ delete process.env.UPLOAD_DIR
+ await rm(uploadRoot, { recursive: true, force: true })
+ })
+
+ test('20MB raw chunk streams to disk and records the exact size', async () => {
+ const request = createRawChunkRequest(
+ [new Uint8Array(10 * MEBIBYTE), new Uint8Array(10 * MEBIBYTE)],
+ 20 * MEBIBYTE,
+ )
+
+ const response = await POST(request)
+ const body = await response.json()
+ const chunkPath = path.join(uploadRoot, 'temp', 'upload-test-1', 'chunk-0')
+
+ expect(response.status).toBe(200)
+ expect(body.receivedBytes).toBe(20 * MEBIBYTE)
+ expect((await stat(chunkPath)).size).toBe(20 * MEBIBYTE)
+ expect(updateChunkWithStats).toHaveBeenCalledWith(
+ 'upload-test-1',
+ 0,
+ chunkPath,
+ 20 * MEBIBYTE,
+ )
+ })
+
+ test('流式实际内容超过20MB时立即失败并删除已经写入的半文件', async () => {
+ const request = createRawChunkRequest(
+ [new Uint8Array(10 * MEBIBYTE), new Uint8Array(10 * MEBIBYTE), new Uint8Array(1)],
+ 20 * MEBIBYTE,
+ )
+
+ const response = await POST(request)
+ const body = await response.json()
+ const chunkPath = path.join(uploadRoot, 'temp', 'upload-test-1', 'chunk-0')
+
+ expect(response.status).toBe(413)
+ expect(body.code).toBe('CHUNK_TOO_LARGE')
+ await expect(stat(chunkPath)).rejects.toMatchObject({ code: 'ENOENT' })
+ expect(updateChunkWithStats).not.toHaveBeenCalled()
+ })
+
+ test('分片大小必须与会话声明的当前位置精确一致', async () => {
+ ;(getUploadSession as jest.Mock).mockResolvedValueOnce({
+ fileName: 'small-video.mp4',
+ fileSize: 10 * MEBIBYTE,
+ totalChunks: 1,
+ chunkSize: 20 * MEBIBYTE,
+ chunks: new Map(),
+ createdAt: Date.now(),
+ userId: 'user-1',
+ })
+ const request = createRawChunkRequest(
+ [new Uint8Array(20 * MEBIBYTE)],
+ 20 * MEBIBYTE,
+ )
+
+ const response = await POST(request)
+ const body = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(body.code).toBe('CHUNK_SIZE_MISMATCH')
+ await expect(
+ stat(path.join(uploadRoot, 'temp', 'upload-test-1', 'chunk-0')),
+ ).rejects.toMatchObject({ code: 'ENOENT' })
+ expect(updateChunkWithStats).not.toHaveBeenCalled()
+ })
+
+ test('multipart 请求在 formData 解析前先等待八路并发槽', async () => {
+ const releases: Array<() => void> = []
+ for (let index = 0; index < 8; index += 1) {
+ releases.push(await materialUploadChunkRequestLimiter.acquire())
+ }
+
+ const formData = jest.fn(async () => new FormData())
+ const request = {
+ headers: new Headers({
+ 'content-length': '128',
+ 'content-type': 'multipart/form-data; boundary=test',
+ }),
+ formData,
+ } as unknown as NextRequest
+
+ const responsePromise = POST(request)
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ expect(formData).not.toHaveBeenCalled()
+
+ releases.shift()?.()
+ const response = await responsePromise
+ expect(formData).toHaveBeenCalledTimes(1)
+ expect(response.status).toBe(400)
+
+ releases.forEach((release) => release())
+ expect(materialUploadChunkRequestLimiter.snapshot()).toEqual({ active: 0, waiting: 0 })
+ })
+
+ test('同一分片并发重试只会原子替换完整文件,不会交错写坏', async () => {
+ ;(getUploadSession as jest.Mock).mockResolvedValue({
+ fileName: 'tiny-video.mp4',
+ fileSize: 4,
+ totalChunks: 1,
+ chunkSize: 4,
+ chunks: new Map(),
+ createdAt: Date.now(),
+ userId: 'user-1',
+ })
+
+ let firstController: ReadableStreamDefaultController | null = null
+ const firstBody = new ReadableStream({
+ start(controller) {
+ firstController = controller
+ controller.enqueue(new Uint8Array([1, 1]))
+ },
+ })
+ const firstRequest = new NextRequest('http://localhost/api/materials/upload/chunk', {
+ method: 'POST',
+ headers: {
+ 'content-length': '4',
+ 'content-type': 'application/octet-stream',
+ 'x-file-name': 'tiny-video.mp4',
+ 'x-upload-id': 'upload-test-1',
+ 'x-chunk-index': '0',
+ 'x-chunk-size': '4',
+ },
+ body: firstBody,
+ duplex: 'half',
+ } as any)
+
+ const firstResponsePromise = POST(firstRequest)
+ const tempDir = path.join(uploadRoot, 'temp', 'upload-test-1')
+ await waitFor(async () => (
+ (await readdir(tempDir).catch(() => [])).some((name) => name.includes('.partial-'))
+ ))
+
+ const secondResponse = await POST(createRawChunkRequest(
+ [new Uint8Array([2, 2, 2, 2])],
+ 4,
+ ))
+ expect(secondResponse.status).toBe(200)
+
+ firstController!.enqueue(new Uint8Array([1, 1]))
+ firstController!.close()
+ const firstResponse = await firstResponsePromise
+ expect(firstResponse.status).toBe(200)
+
+ const finalChunk = await readFile(path.join(tempDir, 'chunk-0'))
+ expect(Array.from(finalChunk)).toEqual([1, 1, 1, 1])
+ expect((await readdir(tempDir)).filter((name) => name.includes('.partial-'))).toEqual([])
+ })
+
+ test('complete 封存后迟到分片不得发布 chunk 或复活陈旧会话', async () => {
+ ;(getUploadSession as jest.Mock).mockResolvedValue({
+ fileName: 'tiny-video.mp4',
+ fileSize: 4,
+ totalChunks: 1,
+ chunkSize: 4,
+ chunks: new Map(),
+ createdAt: Date.now(),
+ userId: 'user-1',
+ })
+
+ let lateController: ReadableStreamDefaultController | null = null
+ const lateBody = new ReadableStream({
+ start(controller) {
+ lateController = controller
+ controller.enqueue(new Uint8Array([1, 1]))
+ },
+ })
+ const lateRequest = new NextRequest('http://localhost/api/materials/upload/chunk', {
+ method: 'POST',
+ headers: {
+ 'content-length': '4',
+ 'content-type': 'application/octet-stream',
+ 'x-file-name': 'tiny-video.mp4',
+ 'x-upload-id': 'upload-test-1',
+ 'x-chunk-index': '0',
+ 'x-chunk-size': '4',
+ },
+ body: lateBody,
+ duplex: 'half',
+ } as any)
+
+ const lateResponsePromise = POST(lateRequest)
+ const tempDir = path.join(uploadRoot, 'temp', 'upload-test-1')
+ await waitFor(async () => (
+ (await readdir(tempDir).catch(() => [])).some((name) => name.includes('.partial-'))
+ ))
+
+ // 模拟 complete 已在跨容器独占锁内完成。迟到请求仍持有之前读取的
+ // 陈旧 session,但写完网络流后必须看到共享盘上的 completed seal。
+ const seal = await beginUploadCompletionSeal('upload-test-1')
+ expect(seal.state).toBe('sealing')
+ if (seal.state !== 'sealing') {
+ throw new Error('测试无法创建上传完成标记')
+ }
+ await completeUploadCompletionSeal(seal.handle)
+
+ lateController!.enqueue(new Uint8Array([1, 1]))
+ lateController!.close()
+ const lateResponse = await lateResponsePromise
+ const body = await lateResponse.json()
+
+ expect(lateResponse.status).toBe(409)
+ expect(body.code).toBe('UPLOAD_SESSION_SEALED')
+ await expect(readFile(path.join(tempDir, 'chunk-0'))).rejects.toMatchObject({
+ code: 'ENOENT',
+ })
+ expect((await readdir(tempDir)).filter((name) => name.includes('.partial-'))).toEqual([])
+ expect(updateChunkWithStats).not.toHaveBeenCalled()
+ })
+
+ test('取消 tombstone 写入后,在途迟到分片不得发布或复活会话', async () => {
+ ;(getUploadSession as jest.Mock).mockResolvedValue({
+ fileName: 'tiny-video.mp4',
+ fileSize: 4,
+ totalChunks: 1,
+ chunkSize: 4,
+ chunks: new Map(),
+ createdAt: Date.now(),
+ userId: 'user-1',
+ })
+
+ let lateController: ReadableStreamDefaultController | null = null
+ const lateBody = new ReadableStream({
+ start(controller) {
+ lateController = controller
+ controller.enqueue(new Uint8Array([3, 3]))
+ },
+ })
+ const lateRequest = new NextRequest('http://localhost/api/materials/upload/chunk', {
+ method: 'POST',
+ headers: {
+ 'content-length': '4',
+ 'content-type': 'application/octet-stream',
+ 'x-file-name': 'tiny-video.mp4',
+ 'x-upload-id': 'upload-test-1',
+ 'x-chunk-index': '0',
+ 'x-chunk-size': '4',
+ },
+ body: lateBody,
+ duplex: 'half',
+ } as any)
+
+ const lateResponsePromise = POST(lateRequest)
+ const tempDir = path.join(uploadRoot, 'temp', 'upload-test-1')
+ await waitFor(async () => (
+ (await readdir(tempDir).catch(() => [])).some((name) => name.includes('.partial-'))
+ ))
+
+ const release = await acquireMaterialUploadStorageLock('exclusive')
+ await cancelUploadSessionSeal('upload-test-1')
+ await release()
+
+ lateController!.enqueue(new Uint8Array([3, 3]))
+ lateController!.close()
+ const response = await lateResponsePromise
+ const body = await response.json()
+
+ expect(response.status).toBe(409)
+ expect(body.code).toBe('UPLOAD_SESSION_CANCELLED')
+ await expect(readFile(path.join(tempDir, 'chunk-0'))).rejects.toMatchObject({
+ code: 'ENOENT',
+ })
+ expect((await readdir(tempDir)).filter((name) => name.includes('.partial-'))).toEqual([])
+ expect(updateChunkWithStats).not.toHaveBeenCalled()
+ })
+})
+
+async function waitFor(predicate: () => Promise, timeoutMs = 2_000): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ if (await predicate()) {
+ return
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ }
+ throw new Error('等待条件超时')
+}
diff --git a/lib/__tests__/material-upload-chunk-policy.test.ts b/lib/__tests__/material-upload-chunk-policy.test.ts
new file mode 100644
index 0000000..19c38f0
--- /dev/null
+++ b/lib/__tests__/material-upload-chunk-policy.test.ts
@@ -0,0 +1,40 @@
+/** @jest-environment node */
+
+import {
+ getExpectedUploadChunkBytes,
+ resolveUploadChunkSize,
+} from '@/lib/material-upload-chunk-policy'
+
+const MEBIBYTE = 1024 * 1024
+
+describe('material upload chunk policy', () => {
+ test('accepts the declared browser layout and calculates the final remainder', () => {
+ const fileSize = 25 * MEBIBYTE
+ const chunkSize = 10 * MEBIBYTE
+ const totalChunks = 3
+
+ expect(resolveUploadChunkSize(fileSize, totalChunks, chunkSize)).toBe(chunkSize)
+ expect(getExpectedUploadChunkBytes({
+ fileSize,
+ totalChunks,
+ chunkSize,
+ chunkIndex: 0,
+ })).toBe(10 * MEBIBYTE)
+ expect(getExpectedUploadChunkBytes({
+ fileSize,
+ totalChunks,
+ chunkSize,
+ chunkIndex: 2,
+ })).toBe(5 * MEBIBYTE)
+ })
+
+ test('keeps already-open legacy 10/20MB browser uploads compatible', () => {
+ expect(resolveUploadChunkSize(25 * MEBIBYTE, 3)).toBe(10 * MEBIBYTE)
+ expect(resolveUploadChunkSize(25 * MEBIBYTE, 2)).toBe(20 * MEBIBYTE)
+ })
+
+ test('rejects a tiny declared file spread across many disk-writing chunks', () => {
+ expect(resolveUploadChunkSize(1, 1024)).toBeNull()
+ expect(resolveUploadChunkSize(1, 1024, 1)).toBeNull()
+ })
+})
diff --git a/lib/__tests__/material-upload-consumption-journal.test.ts b/lib/__tests__/material-upload-consumption-journal.test.ts
new file mode 100644
index 0000000..7104d81
--- /dev/null
+++ b/lib/__tests__/material-upload-consumption-journal.test.ts
@@ -0,0 +1,116 @@
+/** @jest-environment node */
+
+import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
+import os from 'os'
+import path from 'path'
+
+import {
+ recoverMaterialUploadConsumptionJournal,
+ removeMaterialUploadConsumptionJournal,
+ writeMaterialUploadConsumptionJournal,
+} from '@/lib/material-upload-consumption-journal'
+
+describe('普通素材上传消费 journal', () => {
+ let uploadRoot = ''
+ const previousUploadDir = process.env.UPLOAD_DIR
+
+ beforeEach(async () => {
+ uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-upload-journal-'))
+ process.env.UPLOAD_DIR = uploadRoot
+ })
+
+ afterEach(async () => {
+ if (previousUploadDir === undefined) {
+ delete process.env.UPLOAD_DIR
+ } else {
+ process.env.UPLOAD_DIR = previousUploadDir
+ }
+ await rm(uploadRoot, { recursive: true, force: true })
+ })
+
+ test('rename 后模拟进程退出,重启读取 journal 能找回同一正式源', async () => {
+ const uploadId = 'upload-crash-recovery'
+ const sessionDirectory = path.join(uploadRoot, 'temp', uploadId)
+ const mergedPath = path.join(sessionDirectory, 'merged-source.mp4')
+ const finalPath = path.join(uploadRoot, 'video-crash-recovery.mp4')
+ await mkdir(sessionDirectory, { recursive: true })
+ await writeFile(mergedPath, Buffer.from('video-source'))
+
+ const record = await writeMaterialUploadConsumptionJournal({
+ uploadId,
+ ownerId: 'user-1',
+ mergedPath,
+ finalPath,
+ storedFinalPath: finalPath,
+ fileName: 'video-crash-recovery.mp4',
+ size: Buffer.byteLength('video-source'),
+ })
+ // 故障注入点:rename 已成功,但还没有任何 Material 数据库记录。
+ await rename(mergedPath, finalPath)
+
+ const recovered = await recoverMaterialUploadConsumptionJournal({
+ uploadId,
+ ownerId: 'user-1',
+ })
+
+ expect(recovered).toEqual({
+ record,
+ sourcePath: finalPath,
+ sourceLocation: 'final',
+ })
+ expect(await readFile(recovered!.sourcePath, 'utf8')).toBe('video-source')
+ })
+
+ test('owner 不匹配时不能发现或消费其他用户的恢复记录', async () => {
+ const uploadId = 'upload-owner-scope'
+ const sessionDirectory = path.join(uploadRoot, 'temp', uploadId)
+ const mergedPath = path.join(sessionDirectory, 'merged-source.zip')
+ const finalPath = path.join(uploadRoot, 'material-owner-scope.zip')
+ await mkdir(sessionDirectory, { recursive: true })
+ await writeFile(mergedPath, Buffer.from('archive'))
+ await writeMaterialUploadConsumptionJournal({
+ uploadId,
+ ownerId: 'user-1',
+ mergedPath,
+ finalPath,
+ storedFinalPath: finalPath,
+ fileName: 'material-owner-scope.zip',
+ size: Buffer.byteLength('archive'),
+ })
+
+ await expect(recoverMaterialUploadConsumptionJournal({
+ uploadId,
+ ownerId: 'user-2',
+ })).resolves.toBeNull()
+ })
+
+ test('正式源大小变化时拒绝恢复并保留 journal 供人工核验', async () => {
+ const uploadId = 'upload-size-mismatch'
+ const sessionDirectory = path.join(uploadRoot, 'temp', uploadId)
+ const mergedPath = path.join(sessionDirectory, 'merged-source.zip')
+ const finalPath = path.join(uploadRoot, 'material-size-mismatch.zip')
+ await mkdir(sessionDirectory, { recursive: true })
+ await writeFile(mergedPath, Buffer.from('archive'))
+ await writeMaterialUploadConsumptionJournal({
+ uploadId,
+ ownerId: 'user-1',
+ mergedPath,
+ finalPath,
+ storedFinalPath: finalPath,
+ fileName: 'material-size-mismatch.zip',
+ size: Buffer.byteLength('archive'),
+ })
+ await rename(mergedPath, finalPath)
+ await writeFile(finalPath, Buffer.from('changed-size'))
+
+ await expect(recoverMaterialUploadConsumptionJournal({
+ uploadId,
+ ownerId: 'user-1',
+ })).rejects.toThrow('上传源文件恢复记录异常')
+ await expect(removeMaterialUploadConsumptionJournal({
+ uploadId,
+ ownerId: 'user-1',
+ expectedFinalPath: finalPath,
+ })).resolves.toBe(true)
+ })
+})
diff --git a/lib/__tests__/material-upload-cross-process-lock.test.ts b/lib/__tests__/material-upload-cross-process-lock.test.ts
new file mode 100644
index 0000000..403882d
--- /dev/null
+++ b/lib/__tests__/material-upload-cross-process-lock.test.ts
@@ -0,0 +1,171 @@
+/** @jest-environment node */
+
+import {
+ acquireMaterialUploadArchiveLock,
+ acquireMaterialUploadStorageLock,
+ acquireTaskMaterialCompletionLock,
+ acquireTaskMaterialUploadSessionLocks,
+ InProcessMaterialUploadReadWriteLock,
+ resolveScopedLockFileName,
+} from '@/lib/material-upload-cross-process-lock'
+
+describe('material upload shared/exclusive storage lock', () => {
+ test('多个分片可共享读锁,但合并必须等所有分片释放', async () => {
+ const lock = new InProcessMaterialUploadReadWriteLock()
+ const releaseReaderOne = await lock.acquire('shared', 1_000)
+ const releaseReaderTwo = await lock.acquire('shared', 1_000)
+ let writerAcquired = false
+ const writerPromise = lock.acquire('exclusive', 1_000).then((release) => {
+ writerAcquired = true
+ return release
+ })
+
+ await Promise.resolve()
+ expect(writerAcquired).toBe(false)
+ expect(lock.snapshot()).toEqual({
+ activeReaders: 2,
+ writerActive: false,
+ waiting: 1,
+ })
+
+ await releaseReaderOne()
+ await Promise.resolve()
+ expect(writerAcquired).toBe(false)
+
+ await releaseReaderTwo()
+ const releaseWriter = await writerPromise
+ expect(writerAcquired).toBe(true)
+ expect(lock.snapshot().writerActive).toBe(true)
+
+ await releaseWriter()
+ expect(lock.snapshot()).toEqual({
+ activeReaders: 0,
+ writerActive: false,
+ waiting: 0,
+ })
+ })
+
+ test('写者排队后新分片不能插队,避免大文件合并永久饥饿', async () => {
+ const lock = new InProcessMaterialUploadReadWriteLock()
+ const releaseInitialReader = await lock.acquire('shared', 1_000)
+ const order: string[] = []
+ const writerPromise = lock.acquire('exclusive', 1_000).then((release) => {
+ order.push('writer')
+ return release
+ })
+ const lateReaderPromise = lock.acquire('shared', 1_000).then((release) => {
+ order.push('reader')
+ return release
+ })
+
+ await releaseInitialReader()
+ const releaseWriter = await writerPromise
+ expect(order).toEqual(['writer'])
+ await releaseWriter()
+
+ const releaseLateReader = await lateReaderPromise
+ expect(order).toEqual(['writer', 'reader'])
+ await releaseLateReader()
+ })
+
+ test('等待超时会返回可重试的结构化错误且不会污染队列', async () => {
+ const lock = new InProcessMaterialUploadReadWriteLock()
+ const releaseWriter = await lock.acquire('exclusive', 1_000)
+
+ await expect(lock.acquire('shared', 10)).rejects.toMatchObject({
+ status: 503,
+ code: 'UPLOAD_STORAGE_BUSY',
+ })
+ expect(lock.snapshot().waiting).toBe(0)
+
+ await releaseWriter()
+ const releaseReader = await lock.acquire('shared', 1_000)
+ await releaseReader()
+ })
+})
+
+describe('task material completion lock', () => {
+ test('同一素材互斥、不同素材可并行,释放后可重试', async () => {
+ const releaseMaterialOne = await acquireTaskMaterialCompletionLock('material-1', {
+ timeoutMs: 1_000,
+ })
+ const releaseMaterialTwo = await acquireTaskMaterialCompletionLock('material-2', {
+ timeoutMs: 1_000,
+ })
+
+ await expect(
+ acquireTaskMaterialCompletionLock('material-1', { timeoutMs: 10 }),
+ ).rejects.toMatchObject({
+ status: 409,
+ code: 'TASK_MATERIAL_COMPLETION_BUSY',
+ })
+
+ await releaseMaterialTwo()
+ await releaseMaterialOne()
+
+ const releaseRetry = await acquireTaskMaterialCompletionLock('material-1', {
+ timeoutMs: 1_000,
+ })
+ releaseRetry.assertHeld()
+ await releaseRetry()
+ })
+
+ test('非法素材编号在创建锁文件前拒绝', async () => {
+ await expect(acquireTaskMaterialCompletionLock('../material')).rejects.toMatchObject({
+ status: 400,
+ code: 'TASK_MATERIAL_ID_INVALID',
+ })
+ })
+
+ test('生产锁名使用 64-bit 哈希,不再只有 256 个桶', () => {
+ const names = new Set(
+ Array.from({ length: 2_000 }, (_, index) => (
+ resolveScopedLockFileName('task-material', `material-${index}`)
+ )),
+ )
+ expect(names.size).toBe(2_000)
+ expect(Array.from(names)[0]).toMatch(/^task-material-[a-f0-9]{16}\.lock$/)
+ })
+
+ test('重叠 uploadId 的不同素材不能并发消费源文件', async () => {
+ const releaseFirst = await acquireTaskMaterialUploadSessionLocks(
+ ['upload-b', 'upload-a'],
+ { timeoutMs: 1_000 },
+ )
+
+ await expect(
+ acquireTaskMaterialUploadSessionLocks(['upload-a'], { timeoutMs: 10 }),
+ ).rejects.toMatchObject({
+ status: 409,
+ code: 'TASK_MATERIAL_UPLOAD_SESSION_BUSY',
+ })
+
+ await releaseFirst()
+ const releaseRetry = await acquireTaskMaterialUploadSessionLocks(
+ ['upload-a'],
+ { timeoutMs: 1_000 },
+ )
+ releaseRetry.assertHeld()
+ await releaseRetry()
+ })
+})
+
+describe('material upload archive lock', () => {
+ test('ZIP 之间串行,但不阻塞分片的 shared 发布锁', async () => {
+ const releaseArchive = await acquireMaterialUploadArchiveLock({ timeoutMs: 1_000 })
+
+ await expect(
+ acquireMaterialUploadArchiveLock({ timeoutMs: 10 }),
+ ).rejects.toMatchObject({
+ status: 503,
+ code: 'UPLOAD_STORAGE_BUSY',
+ })
+
+ const releaseChunkPublish = await acquireMaterialUploadStorageLock('shared', {
+ timeoutMs: 1_000,
+ })
+ releaseChunkPublish.assertHeld()
+ await releaseChunkPublish()
+ await releaseArchive()
+ })
+})
diff --git a/lib/__tests__/material-upload-request-guard.test.ts b/lib/__tests__/material-upload-request-guard.test.ts
new file mode 100644
index 0000000..b4fd086
--- /dev/null
+++ b/lib/__tests__/material-upload-request-guard.test.ts
@@ -0,0 +1,133 @@
+/** @jest-environment node */
+
+import {
+ MATERIAL_UPLOAD_REQUEST_LIMITS,
+ MaterialUploadRequestError,
+ MaterialUploadRequestLimiter,
+ validateMaterialUploadFormData,
+ validateMaterialUploadRequestHeaders,
+} from '@/lib/material-upload-request-guard'
+
+type FakeFile = {
+ name: string
+ size: number
+ arrayBuffer: () => Promise
+}
+
+function fakeFile(name: string, size: number): FakeFile {
+ return {
+ name,
+ size,
+ arrayBuffer: jest.fn(async () => new ArrayBuffer(0)),
+ }
+}
+
+function fakeFormData(entries: Array<[string, string | FakeFile]>): FormData {
+ return {
+ entries: () => entries[Symbol.iterator](),
+ get: (field: string) => entries.find(([key]) => key === field)?.[1] ?? null,
+ } as unknown as FormData
+}
+
+function expectGuardError(action: () => unknown, status: number, code: string) {
+ try {
+ action()
+ throw new Error('expected guard error')
+ } catch (error) {
+ expect(error).toBeInstanceOf(MaterialUploadRequestError)
+ expect(error).toMatchObject({ status, code })
+ }
+}
+
+describe('material upload request guard', () => {
+ test('requires a bounded multipart Content-Length before parsing', () => {
+ expectGuardError(
+ () => validateMaterialUploadRequestHeaders(new Headers({
+ 'content-type': 'multipart/form-data; boundary=test',
+ })),
+ 411,
+ 'CONTENT_LENGTH_REQUIRED',
+ )
+
+ expect(validateMaterialUploadRequestHeaders(new Headers({
+ 'content-length': String(MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes),
+ 'content-type': 'multipart/form-data; boundary=test',
+ }))).toBe(MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes)
+
+ expectGuardError(
+ () => validateMaterialUploadRequestHeaders(new Headers({
+ 'content-length': String(MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes + 1),
+ 'content-type': 'multipart/form-data; boundary=test',
+ })),
+ 413,
+ 'UPLOAD_REQUEST_TOO_LARGE',
+ )
+ })
+
+ test('forces a 5MB direct source file onto the chunk upload path', () => {
+ const source = fakeFile('source.zip', 5 * 1024 * 1024)
+ expectGuardError(
+ () => validateMaterialUploadFormData(fakeFormData([['zipFile', source]])),
+ 413,
+ 'CHUNK_UPLOAD_REQUIRED',
+ )
+ expect(source.arrayBuffer).not.toHaveBeenCalled()
+ })
+
+ test('rejects excessive file counts and image field limits before decoding', () => {
+ const tooMany = Array.from({ length: 51 }, (_, index) => (
+ ['modelImages', fakeFile(`${index}.jpg`, 1)] as [string, FakeFile]
+ ))
+ expectGuardError(
+ () => validateMaterialUploadFormData(fakeFormData(tooMany)),
+ 413,
+ 'TOO_MANY_UPLOAD_FILES',
+ )
+
+ expectGuardError(
+ () => validateMaterialUploadFormData(fakeFormData([
+ ['previewImage', fakeFile('cover.png', 8 * 1024 * 1024 + 1)],
+ ])),
+ 413,
+ 'UPLOAD_FILE_TOO_LARGE',
+ )
+
+ expectGuardError(
+ () => validateMaterialUploadFormData(fakeFormData([
+ ['modelImages', fakeFile('gallery.jpg', 16 * 1024 * 1024 + 1)],
+ ])),
+ 413,
+ 'UPLOAD_FILE_TOO_LARGE',
+ )
+ })
+
+ test('allows an existing merged source with bounded image metadata request', () => {
+ expect(() => validateMaterialUploadFormData(fakeFormData([
+ ['existingFilePath', 'uploads/temp/upload-1/merged.mp4'],
+ ['previewImage', fakeFile('cover.jpg', 1024)],
+ ]))).not.toThrow()
+ })
+})
+
+describe('material upload request limiter', () => {
+ test('hands the only slot to one waiter and releases it on every completion', async () => {
+ const limiter = new MaterialUploadRequestLimiter(1, 1)
+ const releaseFirst = await limiter.acquire()
+ const secondAcquire = limiter.acquire()
+
+ expect(limiter.snapshot()).toEqual({ active: 1, waiting: 1 })
+ await expect(limiter.acquire()).rejects.toMatchObject({
+ status: 503,
+ code: 'UPLOAD_BUSY',
+ retryAfterSeconds: 3,
+ })
+
+ releaseFirst()
+ const releaseSecond = await secondAcquire
+ expect(limiter.snapshot()).toEqual({ active: 1, waiting: 0 })
+
+ releaseSecond()
+ releaseSecond()
+ expect(limiter.snapshot()).toEqual({ active: 0, waiting: 0 })
+ })
+})
diff --git a/lib/__tests__/material-upload-safe-cleanup.test.ts b/lib/__tests__/material-upload-safe-cleanup.test.ts
new file mode 100644
index 0000000..092b9ed
--- /dev/null
+++ b/lib/__tests__/material-upload-safe-cleanup.test.ts
@@ -0,0 +1,196 @@
+/** @jest-environment node */
+
+import {
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ stat,
+ writeFile,
+} from 'fs/promises'
+import os from 'os'
+import path from 'path'
+
+import {
+ beginUploadCompletionSeal,
+ completeUploadCompletionSeal,
+ readUploadSessionSeal,
+} from '@/lib/material-upload-session-seal'
+import {
+ cleanupConsumedUploadSession,
+ safelyCleanupMaterialUploadSession,
+} from '@/lib/temp-cleanup'
+import { uploadSessions, type UploadSession } from '@/lib/upload-sessions'
+
+describe('safe material upload cleanup protocol', () => {
+ let uploadRoot: string
+ const uploadId = 'safe-cleanup-upload-1'
+
+ beforeEach(async () => {
+ uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-safe-cleanup-'))
+ process.env.UPLOAD_DIR = uploadRoot
+ process.env.USE_REDIS = 'false'
+ uploadSessions.clear()
+ })
+
+ afterEach(async () => {
+ uploadSessions.clear()
+ delete process.env.UPLOAD_DIR
+ delete process.env.USE_REDIS
+ await rm(uploadRoot, { recursive: true, force: true })
+ })
+
+ test('completed + valid merged is never deleted by public cancellation', async () => {
+ const { tempDir, mergedPath } = await createUploadDirectory(uploadId)
+ seedMemorySession(uploadId)
+ const seal = await beginUploadCompletionSeal(uploadId)
+ expect(seal.state).toBe('sealing')
+ if (seal.state !== 'sealing') throw new Error('missing seal handle')
+ await completeUploadCompletionSeal(seal.handle)
+
+ const result = await safelyCleanupMaterialUploadSession({
+ uploadId,
+ intent: 'cancel',
+ expectedOwnerId: 'user-1',
+ })
+
+ expect(result).toMatchObject({
+ success: false,
+ status: 'protected_completed',
+ code: 'UPLOAD_SESSION_COMPLETED',
+ httpStatus: 409,
+ })
+ await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
+ await expect(stat(tempDir)).resolves.toMatchObject({})
+ expect(uploadSessions.has(uploadId)).toBe(true)
+ })
+
+ test('sealing + valid merged is retained by public and scheduled cleanup', async () => {
+ const { mergedPath } = await createUploadDirectory(uploadId)
+ seedMemorySession(uploadId)
+ await beginUploadCompletionSeal(uploadId)
+
+ const result = await safelyCleanupMaterialUploadSession({
+ uploadId,
+ intent: 'cancel',
+ expectedOwnerId: 'user-1',
+ })
+
+ expect(result.status).toBe('protected_sealing')
+ await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
+ expect(uploadSessions.has(uploadId)).toBe(true)
+ })
+
+ test('scheduled cleanup skips sealing even before merged publication', async () => {
+ const { tempDir } = await createUploadDirectory(uploadId, false)
+ await beginUploadCompletionSeal(uploadId)
+
+ const result = await safelyCleanupMaterialUploadSession({
+ uploadId,
+ intent: 'sweep',
+ minAgeMs: 8 * 60 * 60 * 1000,
+ })
+
+ expect(result.status).toBe('protected_sealing')
+ await expect(stat(tempDir)).resolves.toMatchObject({})
+ })
+
+ test('state I/O ambiguity retains directory and session with a 503 result', async () => {
+ const { tempDir, mergedPath } = await createUploadDirectory(uploadId)
+ seedMemorySession(uploadId)
+ const sealDir = path.join(uploadRoot, '.locks', 'completed-upload-sessions')
+ await mkdir(path.join(sealDir, `${uploadId}.json`), { recursive: true })
+
+ const result = await safelyCleanupMaterialUploadSession({
+ uploadId,
+ intent: 'cancel',
+ expectedOwnerId: 'user-1',
+ })
+
+ expect(result).toMatchObject({
+ success: false,
+ status: 'unavailable',
+ code: 'UPLOAD_SESSION_SEAL_INVALID',
+ httpStatus: 503,
+ })
+ await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
+ await expect(stat(tempDir)).resolves.toMatchObject({})
+ expect(uploadSessions.has(uploadId)).toBe(true)
+ })
+
+ test('cancellable session persists cancelled tombstone before deleting state and directory', async () => {
+ const { tempDir } = await createUploadDirectory(uploadId, false)
+ seedMemorySession(uploadId)
+
+ const result = await safelyCleanupMaterialUploadSession({
+ uploadId,
+ intent: 'cancel',
+ expectedOwnerId: 'user-1',
+ })
+
+ expect(result).toMatchObject({ success: true, status: 'deleted' })
+ expect(uploadSessions.has(uploadId)).toBe(false)
+ await expect(stat(tempDir)).rejects.toMatchObject({ code: 'ENOENT' })
+ await expect(readUploadSessionSeal(uploadId)).resolves.toMatchObject({
+ state: 'cancelled',
+ })
+ await expect(beginUploadCompletionSeal(uploadId)).rejects.toMatchObject({
+ status: 409,
+ code: 'UPLOAD_SESSION_CANCELLED',
+ })
+ })
+
+ test('internal consumed cleanup removes completed assembled + temp data but retains seal', async () => {
+ const { tempDir } = await createUploadDirectory(uploadId, false)
+ const assembledPath = path.join(uploadRoot, 'assembled', `${uploadId}-source.bin`)
+ await mkdir(path.dirname(assembledPath), { recursive: true })
+ await writeFile(assembledPath, 'data')
+ seedMemorySession(uploadId)
+ const seal = await beginUploadCompletionSeal(uploadId)
+ if (seal.state !== 'sealing') throw new Error('missing seal handle')
+ await completeUploadCompletionSeal(seal.handle)
+
+ const result = await cleanupConsumedUploadSession(uploadId)
+
+ expect(result).toMatchObject({ success: true, status: 'deleted' })
+ await expect(stat(tempDir)).rejects.toMatchObject({ code: 'ENOENT' })
+ await expect(stat(assembledPath)).rejects.toMatchObject({ code: 'ENOENT' })
+ await expect(readUploadSessionSeal(uploadId)).resolves.toMatchObject({
+ state: 'completed',
+ })
+ })
+
+ async function createUploadDirectory(id: string, withMerged = true) {
+ const tempDir = path.join(uploadRoot, 'temp', id)
+ const mergedPath = path.join(tempDir, 'merged-source.bin')
+ await mkdir(tempDir, { recursive: true })
+ await writeFile(path.join(tempDir, 'meta.json'), JSON.stringify({
+ fileName: 'source.bin',
+ fileSize: 4,
+ totalChunks: 1,
+ chunkSize: 4,
+ userId: 'user-1',
+ createdAt: Date.now() - 10 * 60 * 60 * 1000,
+ }))
+ if (withMerged) {
+ await writeFile(mergedPath, 'data')
+ } else {
+ await writeFile(path.join(tempDir, 'chunk-0'), 'data')
+ }
+ return { tempDir, mergedPath }
+ }
+
+ function seedMemorySession(id: string) {
+ const now = Date.now() - 10 * 60 * 60 * 1000
+ const session: UploadSession = {
+ fileName: 'source.bin',
+ fileSize: 4,
+ totalChunks: 1,
+ chunkSize: 4,
+ chunks: new Map([[0, path.join(uploadRoot, 'temp', id, 'chunk-0')]]),
+ createdAt: now,
+ userId: 'user-1',
+ }
+ uploadSessions.set(id, session)
+ }
+})
diff --git a/lib/__tests__/material-upload-temp-cleanup-redis.test.ts b/lib/__tests__/material-upload-temp-cleanup-redis.test.ts
new file mode 100644
index 0000000..26fc6ac
--- /dev/null
+++ b/lib/__tests__/material-upload-temp-cleanup-redis.test.ts
@@ -0,0 +1,97 @@
+/** @jest-environment node */
+
+import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'fs/promises'
+import os from 'os'
+import path from 'path'
+
+const mockRedisGet = jest.fn()
+const mockRedisDel = jest.fn()
+
+jest.mock('@/lib/upload-sessions-redis', () => ({
+ redis: {
+ get: mockRedisGet,
+ del: mockRedisDel,
+ },
+}))
+
+import {
+ runCleanup,
+ safelyCleanupMaterialUploadSession,
+} from '@/lib/temp-cleanup'
+import { readUploadSessionSeal } from '@/lib/material-upload-session-seal'
+
+describe('scheduled material upload cleanup Redis fail-closed', () => {
+ let uploadRoot: string
+
+ beforeEach(async () => {
+ jest.clearAllMocks()
+ uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-cleanup-redis-'))
+ process.env.UPLOAD_DIR = uploadRoot
+ process.env.USE_REDIS = 'true'
+ mockRedisGet.mockRejectedValue(new Error('redis unavailable'))
+
+ const uploadId = 'expired-redis-unknown'
+ const tempDir = path.join(uploadRoot, 'temp', uploadId)
+ const oldDate = new Date(Date.now() - 10 * 60 * 60 * 1000)
+ await mkdir(tempDir, { recursive: true })
+ await writeFile(path.join(tempDir, 'meta.json'), JSON.stringify({
+ fileName: 'source.bin',
+ fileSize: 4,
+ totalChunks: 1,
+ chunkSize: 4,
+ userId: 'user-1',
+ createdAt: oldDate.getTime(),
+ }))
+ await writeFile(path.join(tempDir, 'chunk-0'), 'data')
+ await utimes(tempDir, oldDate, oldDate)
+ })
+
+ afterEach(async () => {
+ delete process.env.UPLOAD_DIR
+ delete process.env.USE_REDIS
+ await rm(uploadRoot, { recursive: true, force: true })
+ })
+
+ test('Redis read failure skips the expired directory without deleting state', async () => {
+ const result = await runCleanup()
+ const tempDir = path.join(uploadRoot, 'temp', 'expired-redis-unknown')
+
+ expect(result).toMatchObject({
+ cleaned: 0,
+ skippedUnavailable: 1,
+ })
+ await expect(stat(tempDir)).resolves.toMatchObject({})
+ expect(mockRedisDel).not.toHaveBeenCalled()
+ })
+
+ test('Redis delete failure leaves directory behind after durable cancellation tombstone', async () => {
+ mockRedisGet.mockResolvedValue(JSON.stringify({
+ fileName: 'source.bin',
+ fileSize: 4,
+ totalChunks: 1,
+ chunkSize: 4,
+ chunks: [],
+ createdAt: Date.now() - 10 * 60 * 60 * 1000,
+ userId: 'user-1',
+ }))
+ mockRedisDel.mockRejectedValue(new Error('redis delete unavailable'))
+
+ const result = await safelyCleanupMaterialUploadSession({
+ uploadId: 'expired-redis-unknown',
+ intent: 'cancel',
+ expectedOwnerId: 'user-1',
+ })
+ const tempDir = path.join(uploadRoot, 'temp', 'expired-redis-unknown')
+
+ expect(result).toMatchObject({
+ success: false,
+ status: 'unavailable',
+ code: 'UPLOAD_CLEANUP_REDIS_UNAVAILABLE',
+ httpStatus: 503,
+ })
+ await expect(stat(tempDir)).resolves.toMatchObject({})
+ await expect(readUploadSessionSeal('expired-redis-unknown')).resolves.toMatchObject({
+ state: 'cancelled',
+ })
+ })
+})
diff --git a/lib/__tests__/materials-route-ai-generated.test.ts b/lib/__tests__/materials-route-ai-generated.test.ts
index 394512c..754be54 100644
--- a/lib/__tests__/materials-route-ai-generated.test.ts
+++ b/lib/__tests__/materials-route-ai-generated.test.ts
@@ -5,7 +5,13 @@ import { getServerSession } from 'next-auth'
import { GET } from '@/app/api/materials/route'
import { prisma } from '@/lib/prisma'
-import { getUserViewableMaterialTypes, isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
+import {
+ canUserViewMaterial,
+ getUserViewableMaterialTypes,
+ isSuperAdmin,
+ isSystemAdmin,
+} from '@/lib/department-permission'
+import { buildIncompleteSpecialInitShellWhere } from '@/lib/material-query'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@@ -24,9 +30,11 @@ jest.mock('@/lib/prisma', () => ({
},
materialLike: {
findMany: jest.fn(),
+ findFirst: jest.fn(),
},
materialFavorite: {
findMany: jest.fn(),
+ findFirst: jest.fn(),
},
materialAsset: {
findMany: jest.fn(),
@@ -44,6 +52,7 @@ jest.mock('@/lib/department-permission', () => ({
isSystemAdmin: jest.fn(),
getUserViewableMaterialTypes: jest.fn(),
isSuperAdmin: jest.fn(),
+ canUserViewMaterial: jest.fn(),
}))
jest.mock('@/lib/s3', () => ({
@@ -99,6 +108,7 @@ describe('materials route ai generated', () => {
},
})
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
+ ;(canUserViewMaterial as jest.Mock).mockResolvedValue(true)
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-详情'])
;(prisma.material.count as jest.Mock).mockResolvedValue(1)
@@ -142,6 +152,8 @@ describe('materials route ai generated', () => {
;(prisma.materialLike.findMany as jest.Mock).mockResolvedValue([])
;(prisma.materialFavorite.findMany as jest.Mock).mockResolvedValue([])
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([])
+ ;(prisma.materialLike.findFirst as jest.Mock).mockResolvedValue(null)
+ ;(prisma.materialFavorite.findFirst as jest.Mock).mockResolvedValue(null)
})
test('列表接口应返回 isAiGenerated 标记,供首页展示 AI生成 标签', async () => {
@@ -153,6 +165,9 @@ describe('materials route ai generated', () => {
expect(response.status).toBe(200)
expect(prisma.material.findMany).toHaveBeenCalledWith(
expect.objectContaining({
+ where: expect.objectContaining({
+ NOT: buildIncompleteSpecialInitShellWhere(),
+ }),
select: expect.objectContaining({
isAiGenerated: true,
taskDetailSourceType: true,
@@ -285,4 +300,58 @@ describe('materials route ai generated', () => {
)
expect(body.materials[0].previewUrl).toBe('https://example.com/task-page-preview.webp')
})
+
+ test('uploadId 状态查询仍应直接返回 uploading 空壳,不套用普通列表隐藏条件', async () => {
+ ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
+ id: 'uploading-shell-1',
+ userId: 'user-1',
+ previewUrl: '',
+ productModel: 'SPU-UPLOADING',
+ reportTitle: null,
+ categoryName: '通用',
+ sceneName: '通用',
+ platformName: '',
+ productCategoryName: '',
+ materialType: '任务-页面',
+ isAiGenerated: false,
+ taskDetailSourceType: null,
+ taskDetailRequesterUserId: null,
+ taskDetailRequesterName: null,
+ renderer: '',
+ authorName: '张三',
+ zipFileName: '',
+ zipFileSize: BigInt(0),
+ downloadCount: 0,
+ likeCount: 0,
+ createdAt: new Date('2026-08-12T00:00:00.000Z'),
+ videoSourceUrl: null,
+ displayImages: [],
+ videoPreview540Url: null,
+ videoPreviewClipUrl: null,
+ videoCoverUrl: null,
+ videoDuration: null,
+ videoProcessingStatus: null,
+ user: {
+ username: 'zhangsan',
+ realName: '张三',
+ avatar: null,
+ },
+ assets: [],
+ })
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials?uploadId=uploading-shell-1'),
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.material.id).toBe('uploading-shell-1')
+ expect(prisma.material.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { uploadId: 'uploading-shell-1', deletedAt: null },
+ }),
+ )
+ expect(prisma.material.count).not.toHaveBeenCalled()
+ expect(prisma.material.findMany).not.toHaveBeenCalled()
+ })
})
diff --git a/lib/__tests__/materials-upload-task-other-route.test.ts b/lib/__tests__/materials-upload-task-other-route.test.ts
index e11458e..85a7ed8 100644
--- a/lib/__tests__/materials-upload-task-other-route.test.ts
+++ b/lib/__tests__/materials-upload-task-other-route.test.ts
@@ -7,8 +7,9 @@ import { compressImage, sanitizeFileName } from '@/lib/image-processor'
import { createMaterialLog } from '@/lib/material-log'
import { prisma } from '@/lib/prisma'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
-import { assignTaskNoOnComplete } from '@/lib/product-package'
+import { assignTaskNoOnComplete, createTaskMaterialOnComplete } from '@/lib/product-package'
import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task'
+import { deleteFiles } from '@/lib/file-cleanup'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@@ -56,6 +57,14 @@ jest.mock('@/lib/video-transcode-task', () => ({
enqueueVideoTranscodeTask: jest.fn(),
}))
+jest.mock('@/lib/material-canonical-source', () => ({
+ ...jest.requireActual('@/lib/material-canonical-source'),
+ validateCanonicalMaterialSource: jest.fn().mockResolvedValue({
+ sourcePath: 'C:\\uploads\\video-source.mp4',
+ size: BigInt(10),
+ }),
+}))
+
jest.mock('@/lib/image-processor', () => ({
compressImage: jest.fn(),
sanitizeFileName: jest.fn((value: string) => value),
@@ -86,6 +95,7 @@ jest.mock('@/lib/product-package', () => ({
validateTaskNo: jest.fn(() => true),
isTaskNoExists: jest.fn().mockResolvedValue(false),
assignTaskNoOnComplete: jest.fn().mockResolvedValue(null),
+ createTaskMaterialOnComplete: jest.fn(),
purgeDeletedMaterialsByTaskNo: jest.fn().mockResolvedValue({
deletedMaterials: 0,
deletedFiles: 0,
@@ -127,6 +137,20 @@ describe('materials upload route - 任务-其他', () => {
username: '安乔',
},
})
+ ;(createTaskMaterialOnComplete as jest.Mock).mockResolvedValue({
+ id: 'material-task-other-1',
+ previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp',
+ productModel: 'TASK202603310001',
+ categoryName: '通用',
+ sceneName: '通用',
+ materialType: '任务-其他',
+ zipFileName: 'source-package.zip',
+ zipFilePath: 'uploads/material-1.zip',
+ zipFileSize: BigInt(7),
+ displayImages: [],
+ userId: 'user-1',
+ user: { username: '安乔' },
+ })
;(prisma.material.update as jest.Mock).mockResolvedValue({})
;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 2 })
;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed-preview'))
@@ -139,6 +163,10 @@ describe('materials upload route - 任务-其他', () => {
function createRequest(formData: FormData) {
return {
+ headers: new Headers({
+ 'content-length': '1024',
+ 'content-type': 'multipart/form-data; boundary=jest-boundary',
+ }),
formData: jest.fn().mockResolvedValue(formData),
} as any
}
@@ -174,6 +202,58 @@ describe('materials upload route - 任务-其他', () => {
expect(prisma.material.create).not.toHaveBeenCalled()
})
+ test('缺少 Content-Length 时必须在解析 multipart 前拒绝', async () => {
+ const request = {
+ headers: new Headers({
+ 'content-type': 'multipart/form-data; boundary=jest-boundary',
+ }),
+ formData: jest.fn(),
+ } as any
+
+ const response = await POST(request)
+ const body = await response.json()
+
+ expect(response.status).toBe(411)
+ expect(body.code).toBe('CONTENT_LENGTH_REQUIRED')
+ expect(request.formData).not.toHaveBeenCalled()
+ })
+
+ test('超过 128MB 的请求必须在解析 multipart 前拒绝', async () => {
+ const request = {
+ headers: new Headers({
+ 'content-length': String(128 * 1024 * 1024 + 1),
+ 'content-type': 'multipart/form-data; boundary=jest-boundary',
+ }),
+ formData: jest.fn(),
+ } as any
+
+ const response = await POST(request)
+ const body = await response.json()
+
+ expect(response.status).toBe(413)
+ expect(body.code).toBe('UPLOAD_REQUEST_TOO_LARGE')
+ expect(request.formData).not.toHaveBeenCalled()
+ })
+
+ test('达到 5MB 的主文件必须在读取 buffer 前要求分片上传', async () => {
+ const formData = new FormData()
+ const file = new File(
+ [new Uint8Array(5 * 1024 * 1024)],
+ 'must-use-chunks.zip',
+ { type: 'application/zip' },
+ )
+ const arrayBufferSpy = jest.spyOn(file, 'arrayBuffer')
+ formData.append('zipFile', file)
+
+ const response = await POST(createRequest(formData))
+ const body = await response.json()
+
+ expect(response.status).toBe(413)
+ expect(body.code).toBe('CHUNK_UPLOAD_REQUIRED')
+ expect(arrayBufferSpy).not.toHaveBeenCalled()
+ expect(prisma.material.create).not.toHaveBeenCalled()
+ })
+
test('普通上传入口必须拒绝任务-详情,避免绕过需求运营必填规则', async () => {
const formData = new FormData()
formData.append('materialType', '任务-详情')
@@ -186,6 +266,18 @@ describe('materials upload route - 任务-其他', () => {
expect(prisma.material.create).not.toHaveBeenCalled()
})
+ test('普通上传入口必须拒绝任务-页面,避免生成缺少专用页面资产的完成记录', async () => {
+ const formData = new FormData()
+ formData.append('materialType', '任务-页面')
+
+ const response = await POST(createRequest(formData))
+ const body = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(body.error).toBe('任务-页面必须使用专用上传入口')
+ expect(prisma.material.create).not.toHaveBeenCalled()
+ })
+
test('缺少预览图时应立即返回失败', async () => {
const formData = buildTaskOtherFormData()
formData.delete('previewImage')
@@ -218,9 +310,9 @@ describe('materials upload route - 任务-其他', () => {
expect(response.status).toBe(201)
expect(body.message).toBe('上传成功')
- expect(prisma.material.create).toHaveBeenCalledWith(
+ expect(createTaskMaterialOnComplete).toHaveBeenCalledWith(
expect.objectContaining({
- data: expect.objectContaining({
+ materialData: expect.objectContaining({
productModel: 'TASK202603310001',
categoryName: '通用',
sceneName: '通用',
@@ -228,26 +320,17 @@ describe('materials upload route - 任务-其他', () => {
zipFileName: 'source-package.zip',
zipFilePath: expect.stringContaining('uploads'),
}),
- }),
- )
- expect(prisma.materialAsset.createMany).toHaveBeenCalledWith(
- expect.objectContaining({
- data: expect.arrayContaining([
- expect.objectContaining({
- assetType: 'preview',
- }),
- expect.objectContaining({
- assetType: 'zip',
- fileName: 'source-package.zip',
- }),
+ assets: expect.arrayContaining([
+ expect.objectContaining({ assetType: 'preview' }),
+ expect.objectContaining({ assetType: 'zip', fileName: 'source-package.zip' }),
]),
+ taskNo: 'TASK202603310001',
+ dingtalkApprovalNo: '202508211445000157693',
}),
)
- expect(assignTaskNoOnComplete).toHaveBeenCalledWith(
- 'material-task-other-1',
- 'TASK202603310001',
- '202508211445000157693',
- )
+ expect(prisma.material.create).not.toHaveBeenCalled()
+ expect(prisma.materialAsset.createMany).not.toHaveBeenCalled()
+ expect(assignTaskNoOnComplete).not.toHaveBeenCalled()
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({
@@ -329,4 +412,109 @@ describe('materials upload route - 任务-其他', () => {
true,
)
})
+
+ test.each([
+ '任务-3D原图',
+ '任务-AI原图',
+ '任务-实拍原图',
+ '任务-视频截图',
+ ])('%s 也必须通过素材与资产原子事务完成', async (materialType) => {
+ const formData = new FormData()
+ formData.append('materialType', materialType)
+ formData.append('taskNo', 'TASK202603310001')
+ formData.append('productModel', 'SPU-ORIGINAL-001')
+ formData.append('categoryName', '客厅')
+ formData.append('sceneName', '家居')
+ formData.append(
+ 'zipFile',
+ new File([Buffer.from('zipdata')], 'source-package.zip', { type: 'application/zip' }),
+ )
+
+ const response = await POST(createRequest(formData))
+
+ expect(response.status).toBe(201)
+ expect(createTaskMaterialOnComplete).toHaveBeenCalledWith(expect.objectContaining({
+ materialData: expect.objectContaining({
+ materialType,
+ productModel: 'SPU-ORIGINAL-001',
+ }),
+ assets: expect.arrayContaining([
+ expect.objectContaining({ assetType: 'zip' }),
+ ]),
+ taskNo: 'TASK202603310001',
+ }))
+ expect(prisma.material.create).not.toHaveBeenCalled()
+ expect(prisma.materialAsset.createMany).not.toHaveBeenCalled()
+ expect(assignTaskNoOnComplete).not.toHaveBeenCalled()
+ })
+
+ test('任务已完成后审计日志失败仍返回成功且绝不删除源文件', async () => {
+ ;(createMaterialLog as jest.Mock).mockRejectedValueOnce(new Error('audit db unavailable'))
+
+ const response = await POST(createRequest(buildTaskOtherFormData()))
+ const body = await response.json()
+
+ expect(response.status).toBe(201)
+ expect(body.message).toBe('上传成功')
+ expect(createTaskMaterialOnComplete).toHaveBeenCalled()
+ expect(assignTaskNoOnComplete).not.toHaveBeenCalled()
+ expect(deleteFiles).not.toHaveBeenCalled()
+ expect(prisma.material.delete).not.toHaveBeenCalled()
+ })
+
+ test('事务结果无法向数据库复核时必须保留源文件和预览文件', async () => {
+ ;(createTaskMaterialOnComplete as jest.Mock).mockRejectedValueOnce(
+ new Error('commit acknowledgement lost'),
+ )
+ ;(prisma.material.findUnique as jest.Mock).mockRejectedValueOnce(
+ new Error('database unavailable'),
+ )
+
+ const response = await POST(createRequest(buildTaskOtherFormData()))
+
+ expect(response.status).toBe(500)
+ expect(deleteFiles).not.toHaveBeenCalled()
+ expect(prisma.material.delete).not.toHaveBeenCalled()
+ })
+
+ test('小红书批量上传的附加图片必须写入素材展示图', async () => {
+ const formData = new FormData()
+ formData.append('materialType', '小红书种草')
+ formData.append('productModel', 'RED-BOOK-001')
+ formData.append('categoryName', '通用')
+ formData.append('sceneName', '通用')
+ formData.append(
+ 'previewImage',
+ new File([Buffer.from('cover')], 'cover.png', { type: 'image/png' }),
+ )
+ formData.append(
+ 'modelImages',
+ new File([Buffer.from('gallery-1')], 'gallery-1.png', { type: 'image/png' }),
+ )
+ formData.append(
+ 'modelImages',
+ new File([Buffer.from('gallery-2')], 'gallery-2.png', { type: 'image/png' }),
+ )
+
+ ;(uploadPublicDisplayToS3 as jest.Mock)
+ .mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/cover.webp')
+ .mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-1.png')
+ .mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-2.png')
+
+ const response = await POST(createRequest(formData))
+
+ expect(response.status).toBe(201)
+ expect(prisma.material.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ materialType: '小红书种草',
+ previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/cover.webp',
+ displayImages: [
+ 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-1.png',
+ 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-2.png',
+ ],
+ }),
+ }),
+ )
+ })
})
diff --git a/lib/__tests__/multipart-request-guard.test.ts b/lib/__tests__/multipart-request-guard.test.ts
new file mode 100644
index 0000000..8f73423
--- /dev/null
+++ b/lib/__tests__/multipart-request-guard.test.ts
@@ -0,0 +1,247 @@
+/** @jest-environment node */
+
+import {
+ assertBoundedMultipartHeaders,
+ assertMultipartFiles,
+ MultipartRequestError,
+} from '@/lib/multipart-request-guard'
+
+type FakeFile = {
+ name: string
+ size: number
+ type: string
+ arrayBuffer: () => Promise
+}
+
+function fakeFile(name: string, size: number, type = 'image/png'): FakeFile {
+ return {
+ name,
+ size,
+ type,
+ arrayBuffer: jest.fn(async () => new ArrayBuffer(0)),
+ }
+}
+
+function fakeFormData(entries: Array<[string, string | FakeFile]>): FormData {
+ return {
+ entries: () => entries[Symbol.iterator](),
+ } as unknown as FormData
+}
+
+function expectGuardError(
+ action: () => unknown,
+ status: number,
+ code: string,
+): void {
+ try {
+ action()
+ throw new Error('expected guard error')
+ } catch (error) {
+ expect(error).toBeInstanceOf(MultipartRequestError)
+ expect(error).toMatchObject({ status, code })
+ expect((error as Error).message).toMatch(/[\u4e00-\u9fff]/)
+ }
+}
+
+describe('assertBoundedMultipartHeaders', () => {
+ const validHeaders = (length = '12'): Headers => new Headers({
+ 'content-type': 'multipart/form-data; boundary=upload-boundary',
+ 'content-length': length,
+ })
+
+ test('requires multipart/form-data and a non-empty boundary', () => {
+ expect(assertBoundedMultipartHeaders(validHeaders(), 100)).toBe(12)
+ expect(assertBoundedMultipartHeaders(new Headers({
+ 'content-type': 'multipart/form-data; boundary="quoted-boundary"',
+ 'content-length': '12',
+ }), 100)).toBe(12)
+
+ expectGuardError(
+ () => assertBoundedMultipartHeaders(new Headers({
+ 'content-type': 'application/json',
+ 'content-length': '12',
+ }), 100),
+ 415,
+ 'MULTIPART_REQUIRED',
+ )
+ expectGuardError(
+ () => assertBoundedMultipartHeaders(new Headers({
+ 'content-type': 'multipart/form-data',
+ 'content-length': '12',
+ }), 100),
+ 415,
+ 'MULTIPART_BOUNDARY_REQUIRED',
+ )
+ expectGuardError(
+ () => assertBoundedMultipartHeaders(new Headers({
+ 'content-type': 'multipart/form-data; boundary=""',
+ 'content-length': '12',
+ }), 100),
+ 415,
+ 'MULTIPART_BOUNDARY_REQUIRED',
+ )
+ })
+
+ test('allows an empty or identity content-encoding, but rejects compression', () => {
+ expect(assertBoundedMultipartHeaders(new Headers({
+ ...Object.fromEntries(validHeaders()),
+ 'content-encoding': '',
+ }), 100)).toBe(12)
+ expect(assertBoundedMultipartHeaders(new Headers({
+ ...Object.fromEntries(validHeaders()),
+ 'content-encoding': 'identity',
+ }), 100)).toBe(12)
+ expectGuardError(
+ () => assertBoundedMultipartHeaders(new Headers({
+ ...Object.fromEntries(validHeaders()),
+ 'content-encoding': 'gzip',
+ }), 100),
+ 415,
+ 'CONTENT_ENCODING_UNSUPPORTED',
+ )
+ })
+
+ test.each([
+ ['', 'missing'],
+ ['abc', 'non-decimal'],
+ ['+12', 'signed'],
+ ['12.0', 'fractional'],
+ ['0', 'zero'],
+ [String(Number.MAX_SAFE_INTEGER) + '0', 'unsafe integer'],
+ ])('returns a recognizable 411 for %s Content-Length (%s)', (length) => {
+ const headers = validHeaders(length)
+ if (!length) headers.delete('content-length')
+ expectGuardError(
+ () => assertBoundedMultipartHeaders(headers, 100),
+ 411,
+ 'LENGTH_REQUIRED',
+ )
+ })
+
+ test('returns 413 when the trusted request length is above the bound', () => {
+ expectGuardError(
+ () => assertBoundedMultipartHeaders(validHeaders('101'), 100),
+ 413,
+ 'REQUEST_TOO_LARGE',
+ )
+ })
+})
+
+describe('assertMultipartFiles', () => {
+ const policy = {
+ fields: {
+ logo: {
+ maxCount: 1,
+ maxFileBytes: 100,
+ allowedMimeTypes: ['image/png', 'image/jpeg'],
+ },
+ gallery: {
+ maxCount: 2,
+ maxFileBytes: 80,
+ maxTotalBytes: 120,
+ allowedMimeTypes: ['image/*'],
+ },
+ },
+ maxFiles: 3,
+ maxTotalBytes: 200,
+ } as const
+
+ test('walks all entries, ignores text fields, and returns a summary', () => {
+ const logo = fakeFile('logo.png', 20)
+ const gallery = fakeFile('gallery.jpg', 30, 'image/jpeg')
+ const summary = assertMultipartFiles(fakeFormData([
+ ['type', 'header'],
+ ['description', 'text is not a file'],
+ ['logo', logo],
+ ['gallery', gallery],
+ ]), policy)
+
+ expect(summary.totalFiles).toBe(2)
+ expect(summary.totalBytes).toBe(50)
+ expect(summary.files).toEqual([logo, gallery])
+ expect(summary.filesByField).toEqual({ logo: [logo], gallery: [gallery] })
+ })
+
+ test('rejects a file in an unapproved field, including fields after text entries', () => {
+ expectGuardError(
+ () => assertMultipartFiles(fakeFormData([
+ ['description', 'text'],
+ ['unexpected', fakeFile('unexpected.bin', 1)],
+ ]), policy),
+ 400,
+ 'INVALID_FILE_FIELD',
+ )
+ })
+
+ test('enforces per-field and total file counts for repeated entries', () => {
+ expectGuardError(
+ () => assertMultipartFiles(fakeFormData([
+ ['logo', fakeFile('one.png', 1)],
+ ['logo', fakeFile('two.png', 1)],
+ ]), policy),
+ 413,
+ 'TOO_MANY_FILES',
+ )
+ expectGuardError(
+ () => assertMultipartFiles(fakeFormData([
+ ['logo', fakeFile('one.png', 1)],
+ ['gallery', fakeFile('two.png', 1)],
+ ['gallery', fakeFile('three.png', 1)],
+ ['gallery', fakeFile('four.png', 1)],
+ ]), policy),
+ 413,
+ 'TOO_MANY_FILES',
+ )
+ })
+
+ test('rejects empty files before any file bytes are read', () => {
+ const empty = fakeFile('empty.png', 0)
+ expectGuardError(
+ () => assertMultipartFiles(fakeFormData([['logo', empty]]), policy),
+ 400,
+ 'EMPTY_FILE',
+ )
+ expect(empty.arrayBuffer).not.toHaveBeenCalled()
+ })
+
+ test('enforces single-file and per-field/overall byte limits', () => {
+ expectGuardError(
+ () => assertMultipartFiles(fakeFormData([
+ ['logo', fakeFile('too-large.png', 101)],
+ ]), policy),
+ 413,
+ 'FILE_TOO_LARGE',
+ )
+ expectGuardError(
+ () => assertMultipartFiles(fakeFormData([
+ ['gallery', fakeFile('one.png', 70)],
+ ['gallery', fakeFile('two.png', 51)],
+ ]), policy),
+ 413,
+ 'FILES_TOO_LARGE',
+ )
+ expectGuardError(
+ () => assertMultipartFiles(fakeFormData([
+ ['logo', fakeFile('logo.png', 100)],
+ ['gallery', fakeFile('one.png', 80)],
+ ['gallery', fakeFile('two.png', 21)],
+ ]), policy),
+ 413,
+ 'FILES_TOO_LARGE',
+ )
+ })
+
+ test('enforces the allowed MIME list without rejecting text values', () => {
+ expectGuardError(
+ () => assertMultipartFiles(fakeFormData([
+ ['logo', fakeFile('logo.gif', 10, 'image/gif')],
+ ]), policy),
+ 415,
+ 'MIME_TYPE_UNSUPPORTED',
+ )
+ expect(() => assertMultipartFiles(fakeFormData([
+ ['description', 'image/gif is only text here'],
+ ['gallery', fakeFile('gallery.webp', 10, 'image/webp')],
+ ]), policy)).not.toThrow()
+ })
+})
diff --git a/lib/__tests__/multipart-route-coverage.test.ts b/lib/__tests__/multipart-route-coverage.test.ts
new file mode 100644
index 0000000..b8e25cb
--- /dev/null
+++ b/lib/__tests__/multipart-route-coverage.test.ts
@@ -0,0 +1,75 @@
+/** @jest-environment node */
+
+import { readFile } from 'fs/promises'
+import path from 'path'
+
+const guardedRoutes = [
+ 'app/api/upload/s3-original/route.ts',
+ 'app/api/upload/s3/route.ts',
+ 'app/api/upload/preview/route.ts',
+ 'app/api/materials/[id]/skus/upload/route.ts',
+ 'app/api/user/avatar/route.ts',
+ 'app/api/materials/[id]/route.ts',
+ 'app/api/materials/update/route.ts',
+ 'app/api/system-settings/upload-logo/route.ts',
+] as const
+
+const preBoundedLegacyRoutes = [
+ 'app/api/upload/psd/route.ts',
+ 'app/api/materials/[id]/white-background/route.ts',
+ 'app/api/external/standard-model-white-background/by-barcode/route.ts',
+ 'app/api/external/standard-model-white-background/by-spu/route.ts',
+] as const
+
+describe('multipart routes are bounded before parsing', () => {
+ test.each(guardedRoutes)('%s checks headers and acquires a slot before formData', async (routePath) => {
+ const source = await readFile(path.join(process.cwd(), routePath), 'utf8')
+ const headerGuard = source.indexOf('assertBoundedMultipartHeaders(')
+ const acquireSlot = source.indexOf('boundedMultipartRequestLimiter.acquire()')
+ const parseBody = source.indexOf('request.formData()')
+ const parsedFiles = source.indexOf('assertMultipartFiles(', parseBody)
+
+ expect(headerGuard).toBeGreaterThan(0)
+ expect(acquireSlot).toBeGreaterThan(headerGuard)
+ expect(parseBody).toBeGreaterThan(acquireSlot)
+ expect(parsedFiles).toBeGreaterThan(parseBody)
+ expect(source).toContain('releaseRequestSlot?.()')
+ })
+
+ test.each(preBoundedLegacyRoutes)('%s acquires a slot after its existing size check', async (routePath) => {
+ const source = await readFile(path.join(process.cwd(), routePath), 'utf8')
+ const headerGuard = source.indexOf("headers.get('content-length')")
+ const acquireSlot = source.indexOf('boundedMultipartRequestLimiter.acquire()')
+ const parseBody = source.indexOf('request.formData()')
+
+ expect(headerGuard).toBeGreaterThan(0)
+ expect(acquireSlot).toBeGreaterThan(headerGuard)
+ expect(parseBody).toBeGreaterThan(acquireSlot)
+ expect(source).toContain('releaseRequestSlot?.()')
+ })
+
+ test('stable proxy uses a small fallback and explicit upload route ceilings', async () => {
+ const source = (await readFile(
+ path.join(process.cwd(), 'scripts/git-incremental-deploy.ps1'),
+ 'utf8',
+ )).replace(/\r\n/g, '\n')
+
+ expect(source).toContain('client_max_body_size 16m;')
+ expect(source).not.toContain('client_max_body_size 1000m;')
+
+ const requiredFragments = [
+ 'location = /api/upload/preview {\n client_max_body_size 53m;',
+ 'location ~ "^/api/upload/s3(?:-original)?\\$" {\n client_max_body_size 103m;',
+ 'location = /api/materials/update {\n client_max_body_size 11m;',
+ 'location ~ "^/api/(?:user/avatar|system-settings/upload-logo)\\$" {\n client_max_body_size 7m;',
+ '/skus/upload)\\$" {\n client_max_body_size 131m;',
+ '/white-background|api/external/standard-model-white-background/(?:by-spu|by-barcode))\\$" {\n client_max_body_size 106m;',
+ ]
+ for (const fragment of requiredFragments) {
+ expect(source).toContain(fragment)
+ }
+
+ const boundedLocations = source.match(/client_max_body_size (?:7|11|53|103|106|131)m;[\s\S]{0,180}?proxy_request_buffering off;/g) || []
+ expect(boundedLocations.length).toBeGreaterThanOrEqual(7)
+ })
+})
diff --git a/lib/__tests__/pdf-processor-limiter.test.ts b/lib/__tests__/pdf-processor-limiter.test.ts
new file mode 100644
index 0000000..2f8ebd7
--- /dev/null
+++ b/lib/__tests__/pdf-processor-limiter.test.ts
@@ -0,0 +1,162 @@
+/** @jest-environment node */
+
+const mockExecFile = jest.fn()
+const mockReadFile = jest.fn()
+const mockUnlink = jest.fn()
+const mockSharp = jest.fn()
+const mockEnsureEnoughDiskSpace = jest.fn()
+
+jest.mock('child_process', () => ({
+ ...jest.requireActual('child_process'),
+ execFile: mockExecFile,
+}))
+
+jest.mock('fs/promises', () => ({
+ readFile: mockReadFile,
+ unlink: mockUnlink,
+ writeFile: jest.fn(),
+ open: jest.fn(),
+}))
+
+jest.mock('sharp', () => ({
+ __esModule: true,
+ default: mockSharp,
+}))
+
+jest.mock('@/lib/sharp-runtime', () => ({
+ configureSharpRuntime: jest.fn(),
+}))
+
+jest.mock('@/lib/disk-space', () => ({
+ ensureEnoughDiskSpace: mockEnsureEnoughDiskSpace,
+}))
+
+async function waitUntil(condition: () => boolean): Promise {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ if (condition()) {
+ return
+ }
+ await new Promise((resolve) => setImmediate(resolve))
+ }
+
+ throw new Error('等待并发测试状态超时')
+}
+
+describe('generatePdfPreview image-processing limiter', () => {
+ afterEach(() => {
+ delete process.env.IMAGE_PROCESSING_MAX_CONCURRENT
+ })
+
+ test('两个 PDF 预览的 pdftoppm 与 Sharp 完整重处理段最大并发为 1', async () => {
+ process.env.IMAGE_PROCESSING_MAX_CONCURRENT = '1'
+ jest.resetModules()
+ jest.clearAllMocks()
+
+ const events: string[] = []
+ const execReleases: Array<() => void> = []
+ const sharpWriteReleases: Array<() => void> = []
+ let activeHeavyJobs = 0
+ let peakHeavyJobs = 0
+ let sourceImageCount = 0
+ let finalImageCount = 0
+
+ mockEnsureEnoughDiskSpace.mockResolvedValue(undefined)
+ mockReadFile.mockResolvedValue(Buffer.from('pdf-preview-image'))
+ mockUnlink.mockResolvedValue(undefined)
+
+ mockExecFile.mockImplementation((...args: unknown[]) => {
+ const callback = args[args.length - 1] as (
+ error: Error | null,
+ result: { stdout: string; stderr: string }
+ ) => void
+ const jobNumber = execReleases.length + 1
+
+ activeHeavyJobs += 1
+ peakHeavyJobs = Math.max(peakHeavyJobs, activeHeavyJobs)
+ events.push(`pdftoppm:start:${jobNumber}`)
+ execReleases.push(() => callback(null, { stdout: '', stderr: '' }))
+
+ return {} as never
+ })
+
+ mockSharp.mockImplementation((input: Buffer | string) => {
+ if (Buffer.isBuffer(input)) {
+ const jobIndex = sourceImageCount
+ sourceImageCount += 1
+ const instance: any = {}
+
+ instance.metadata = jest.fn().mockResolvedValue({ width: 800, height: 600 })
+ instance.extract = jest.fn().mockReturnValue(instance)
+ instance.resize = jest.fn().mockReturnValue(instance)
+ instance.jpeg = jest.fn().mockReturnValue(instance)
+ instance.toFile = jest.fn(() => {
+ events.push(`sharp:write:${jobIndex + 1}`)
+ return new Promise((resolve) => {
+ sharpWriteReleases[jobIndex] = resolve
+ })
+ })
+
+ return instance
+ }
+
+ const jobNumber = finalImageCount + 1
+ finalImageCount += 1
+ return {
+ metadata: jest.fn().mockImplementation(async () => {
+ events.push(`sharp:complete:${jobNumber}`)
+ activeHeavyJobs -= 1
+ return { width: 800, height: 600 }
+ }),
+ }
+ })
+
+ const { generatePdfPreview } = await import('@/lib/pdf-processor')
+
+ const firstPreview = generatePdfPreview('first.pdf', 'first-preview.jpg')
+ const secondPreview = generatePdfPreview('second.pdf', 'second-preview.jpg')
+
+ await waitUntil(() => execReleases.length === 1)
+ expect(mockExecFile).toHaveBeenCalledTimes(1)
+ expect(activeHeavyJobs).toBe(1)
+
+ execReleases[0]()
+ await waitUntil(() => sharpWriteReleases.length === 1)
+
+ // 第一个 pdftoppm 已结束,但 Sharp 仍在写盘;第二个 PDF 仍必须排队。
+ expect(mockExecFile).toHaveBeenCalledTimes(1)
+ expect(activeHeavyJobs).toBe(1)
+
+ sharpWriteReleases[0]()
+ await expect(firstPreview).resolves.toEqual({
+ success: true,
+ previewPath: 'first-preview.jpg',
+ width: 800,
+ height: 600,
+ })
+
+ await waitUntil(() => execReleases.length === 2)
+ expect(activeHeavyJobs).toBe(1)
+
+ execReleases[1]()
+ await waitUntil(() => sharpWriteReleases.length === 2)
+ sharpWriteReleases[1]()
+
+ await expect(secondPreview).resolves.toEqual({
+ success: true,
+ previewPath: 'second-preview.jpg',
+ width: 800,
+ height: 600,
+ })
+
+ expect(peakHeavyJobs).toBe(1)
+ expect(activeHeavyJobs).toBe(0)
+ expect(events).toEqual([
+ 'pdftoppm:start:1',
+ 'sharp:write:1',
+ 'sharp:complete:1',
+ 'pdftoppm:start:2',
+ 'sharp:write:2',
+ 'sharp:complete:2',
+ ])
+ })
+})
diff --git a/lib/__tests__/performance-survey-work-item-upload.test.ts b/lib/__tests__/performance-survey-work-item-upload.test.ts
index 56ee4bb..748c273 100644
--- a/lib/__tests__/performance-survey-work-item-upload.test.ts
+++ b/lib/__tests__/performance-survey-work-item-upload.test.ts
@@ -10,7 +10,9 @@ import {
} from '@/lib/performance-survey-service'
import { prisma } from '@/lib/prisma'
import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync'
-import { uploadFileToS3 } from '@/lib/s3'
+import { deleteFromS3, uploadFileToS3 } from '@/lib/s3'
+import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock'
+import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
import {
deleteOwnedUploadSession,
getOwnedUploadedFile,
@@ -49,6 +51,14 @@ jest.mock('@/lib/s3', () => ({
uploadToS3: jest.fn(),
}))
+jest.mock('@/lib/material-upload-cross-process-lock', () => ({
+ acquireTaskMaterialUploadSessionLocks: jest.fn(),
+}))
+
+jest.mock('@/lib/temp-cleanup', () => ({
+ cleanupConsumedUploadSession: jest.fn(),
+}))
+
jest.mock('@/lib/upload-sessions', () => ({
deleteOwnedUploadSession: jest.fn(),
getOwnedUploadSession: jest.fn(),
@@ -76,9 +86,17 @@ const employee = {
isLeadership: false,
}
+function makeReleaseLock() {
+ return Object.assign(jest.fn(async () => undefined), {
+ assertHeld: jest.fn(),
+ })
+}
+
describe('performance survey processed work item upload', () => {
let tempRoot = ''
let webpPath = ''
+ let transactionClient: any
+ let releaseUploadSessionLocks: ReturnType
beforeEach(async () => {
jest.clearAllMocks()
@@ -124,8 +142,12 @@ describe('performance survey processed work item upload', () => {
;(getOwnedUploadedFile as jest.Mock).mockResolvedValue(webpPath)
;(uploadFileToS3 as jest.Mock).mockImplementation(async (_filePath, fileName) => `https://s3.example/${fileName}`)
;(deleteOwnedUploadSession as jest.Mock).mockResolvedValue(true)
+ ;(cleanupConsumedUploadSession as jest.Mock).mockResolvedValue({ success: true })
+ ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue(null)
+ releaseUploadSessionLocks = makeReleaseLock()
+ ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadSessionLocks)
- const transactionClient = {
+ transactionClient = {
$queryRawUnsafe: jest.fn().mockResolvedValue([{ status: 'draft', dueAt: null }]),
performanceSurveyWorkItem: {
count: jest.fn().mockResolvedValue(0),
@@ -175,7 +197,195 @@ describe('performance survey processed work item upload', () => {
slices: [expect.objectContaining({ width: 1600, height: 900 })],
}),
])
- expect(deleteOwnedUploadSession).toHaveBeenCalledWith('upload-1', 'user-1')
+ expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-1'])
+ expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1')
+ expect(deleteOwnedUploadSession).not.toHaveBeenCalled()
+ expect(releaseUploadSessionLocks).toHaveBeenCalledTimes(1)
+ expect((acquireTaskMaterialUploadSessionLocks as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(
+ (getOwnedUploadSession as jest.Mock).mock.invocationCallOrder[0],
+ )
+ expect((cleanupConsumedUploadSession as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(
+ releaseUploadSessionLocks.mock.invocationCallOrder[0],
+ )
+ })
+
+ test('treats a committed transaction with a lost acknowledgement as success and preserves S3', async () => {
+ let committedData: any
+ transactionClient.performanceSurveyWorkItem.create.mockImplementation(async ({ data }: any) => {
+ committedData = data
+ ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue({
+ id: data.id,
+ surveyId: data.surveyId,
+ targetId: data.targetId,
+ submitterUserId: data.submitterUserId,
+ title: data.title,
+ description: data.description,
+ imageUrls: data.imageUrls,
+ status: data.status,
+ submittedAt: data.submittedAt,
+ })
+ throw new Error('transaction response lost')
+ })
+
+ const result = await createPerformanceSurveyWorkItemFromRequest(
+ session,
+ 'survey-1',
+ makeProcessedImageRequest('upload-1'),
+ )
+
+ expect(result).toEqual(expect.objectContaining({
+ id: committedData.id,
+ imageGroups: committedData.imageUrls,
+ }))
+ expect(prisma.performanceSurveyWorkItem.findUnique).toHaveBeenCalledWith(expect.objectContaining({
+ where: { id: committedData.id },
+ }))
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1')
+ })
+
+ test('fails closed and preserves S3 and source session when database outcome is unknown', async () => {
+ transactionClient.performanceSurveyWorkItem.create.mockRejectedValue(new Error('connection lost'))
+ ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockRejectedValue(new Error('database unavailable'))
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ await expect(createPerformanceSurveyWorkItemFromRequest(
+ session,
+ 'survey-1',
+ makeProcessedImageRequest('upload-1'),
+ )).rejects.toMatchObject({
+ status: 503,
+ message: expect.stringContaining('服务器已保留图片和上传会话'),
+ })
+
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
+ expect(deleteOwnedUploadSession).not.toHaveBeenCalled()
+ consoleError.mockRestore()
+ })
+
+ test('requires the persisted work item to contain the complete expected image groups', async () => {
+ let committedId = ''
+ transactionClient.performanceSurveyWorkItem.create.mockImplementation(async ({ data }: any) => {
+ committedId = data.id
+ ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue({
+ id: data.id,
+ surveyId: data.surveyId,
+ targetId: data.targetId,
+ submitterUserId: data.submitterUserId,
+ title: data.title,
+ description: data.description,
+ imageUrls: [],
+ status: data.status,
+ submittedAt: data.submittedAt,
+ })
+ throw new Error('transaction response lost')
+ })
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ await expect(createPerformanceSurveyWorkItemFromRequest(
+ session,
+ 'survey-1',
+ makeProcessedImageRequest('upload-1'),
+ )).rejects.toMatchObject({ status: 503 })
+
+ expect(committedId).not.toBe('')
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
+ consoleError.mockRestore()
+ })
+
+ test('deletes only this attempt S3 after the database explicitly confirms no commit and preserves source', async () => {
+ transactionClient.performanceSurveyWorkItem.create.mockRejectedValue(new Error('transaction rolled back'))
+ ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue(null)
+
+ await expect(createPerformanceSurveyWorkItemFromRequest(
+ session,
+ 'survey-1',
+ makeProcessedImageRequest('upload-1'),
+ )).rejects.toThrow('transaction rolled back')
+
+ expect(deleteFromS3).toHaveBeenCalledTimes(1)
+ expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
+ expect(deleteOwnedUploadSession).not.toHaveBeenCalled()
+ })
+
+ test('sorts and locks all uploadIds before resolve, database commit, and successful cleanup', async () => {
+ const request = new Request('https://example.test/upload', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ targetId: 'target-1',
+ processedImages: [
+ makeProcessedImageDeclaration('upload-b', { sourceName: '2.webp', sourceIndex: 0 }),
+ makeProcessedImageDeclaration('upload-a', { sourceName: '1.webp', sourceIndex: 1 }),
+ ],
+ }),
+ })
+
+ await createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', request)
+
+ expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-a', 'upload-b'])
+ expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(2)
+ expect((cleanupConsumedUploadSession as jest.Mock).mock.calls.map(([uploadId]) => uploadId)).toEqual([
+ 'upload-a',
+ 'upload-b',
+ ])
+ })
+
+ test('serializes concurrent reuse of one uploadId through cleanup before the next resolve', async () => {
+ let tail = Promise.resolve()
+ ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockImplementation(async () => {
+ const predecessor = tail
+ let releaseNext!: () => void
+ tail = new Promise((resolve) => {
+ releaseNext = resolve
+ })
+ await predecessor
+ let active = true
+ return Object.assign(jest.fn(async () => {
+ active = false
+ releaseNext()
+ }), {
+ assertHeld: jest.fn(() => {
+ if (!active) throw new Error('lock released')
+ }),
+ })
+ })
+
+ let notifyCleanupStarted!: () => void
+ const cleanupStarted = new Promise((resolve) => {
+ notifyCleanupStarted = resolve
+ })
+ let finishCleanup!: () => void
+ ;(cleanupConsumedUploadSession as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => {
+ notifyCleanupStarted()
+ finishCleanup = () => resolve({ success: true })
+ }))
+ ;(getOwnedUploadSession as jest.Mock)
+ .mockResolvedValueOnce({ fileName: 'slice.webp', fileSize: 30, userId: 'user-1' })
+ .mockResolvedValueOnce(null)
+
+ const first = createPerformanceSurveyWorkItemFromRequest(
+ session,
+ 'survey-1',
+ makeProcessedImageRequest('upload-1'),
+ )
+ await cleanupStarted
+
+ const second = createPerformanceSurveyWorkItemFromRequest(
+ session,
+ 'survey-1',
+ makeProcessedImageRequest('upload-1'),
+ )
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(getOwnedUploadSession).toHaveBeenCalledTimes(1)
+
+ finishCleanup()
+ await expect(first).resolves.toEqual(expect.objectContaining({ id: 'work-item-1' }))
+ await expect(second).rejects.toThrow('图片上传已过期')
+ expect(uploadFileToS3).toHaveBeenCalledTimes(1)
+ expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(1)
})
test('rejects client dimensions that do not match the actual WebP header', async () => {
@@ -224,3 +434,35 @@ describe('performance survey processed work item upload', () => {
)
})
})
+
+function makeProcessedImageDeclaration(
+ uploadId: string,
+ overrides: Partial<{
+ sourceName: string
+ sourceIndex: number
+ }> = {},
+) {
+ return {
+ uploadId,
+ mimeType: 'image/webp',
+ width: 1600,
+ height: 900,
+ kind: 'slice',
+ sourceName: overrides.sourceName || '1.webp',
+ sourceIndex: overrides.sourceIndex ?? 0,
+ sliceIndex: 0,
+ sliceCount: 1,
+ }
+}
+
+function makeProcessedImageRequest(uploadId: string) {
+ return new Request('https://example.test/upload', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ targetId: 'target-1',
+ title: '案例 1',
+ processedImages: [makeProcessedImageDeclaration(uploadId)],
+ }),
+ })
+}
diff --git a/lib/__tests__/product-package-abort-route-security.test.ts b/lib/__tests__/product-package-abort-route-security.test.ts
index 685c957..077cda1 100644
--- a/lib/__tests__/product-package-abort-route-security.test.ts
+++ b/lib/__tests__/product-package-abort-route-security.test.ts
@@ -3,15 +3,8 @@
import { POST } from '@/app/api/materials/upload/product-package/abort/route'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
-import {
- deleteOwnedUploadSession,
- getOwnedUploadSession,
-} from '@/lib/upload-sessions'
-import { isPathFreeOfSymbolicLinks } from '@/lib/path-security'
-import {
- rm,
- unlink,
-} from 'fs/promises'
+import { getOwnedUploadSession } from '@/lib/upload-sessions'
+import { safelyCleanupMaterialUploadSession } from '@/lib/temp-cleanup'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@@ -38,24 +31,14 @@ jest.mock('@/lib/prisma', () => ({
}))
jest.mock('@/lib/upload-sessions', () => ({
- deleteOwnedUploadSession: jest.fn(),
getOwnedUploadSession: jest.fn(),
isSafeUploadId: jest.fn((uploadId: string) =>
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(String(uploadId || '').trim()),
),
}))
-jest.mock('@/lib/path-security', () => {
- const actual = jest.requireActual('@/lib/path-security')
- return {
- ...actual,
- isPathFreeOfSymbolicLinks: jest.fn(),
- }
-})
-
-jest.mock('fs/promises', () => ({
- rm: jest.fn(),
- unlink: jest.fn(),
+jest.mock('@/lib/temp-cleanup', () => ({
+ safelyCleanupMaterialUploadSession: jest.fn(),
}))
describe('product-package abort route security', () => {
@@ -88,12 +71,12 @@ describe('product-package abort route security', () => {
createdAt: Date.now(),
userId: 'user-1',
})
- ;(deleteOwnedUploadSession as jest.Mock).mockResolvedValue(true)
- ;(isPathFreeOfSymbolicLinks as jest.Mock).mockResolvedValue(true)
- ;(unlink as jest.Mock).mockRejectedValue(
- Object.assign(new Error('not found'), { code: 'ENOENT' }),
- )
- ;(rm as jest.Mock).mockResolvedValue(undefined)
+ ;(safelyCleanupMaterialUploadSession as jest.Mock).mockResolvedValue({
+ status: 'deleted',
+ success: true,
+ deletedFiles: 2,
+ freedSpace: 1024,
+ })
})
function createRequest(body: unknown) {
@@ -110,8 +93,7 @@ describe('product-package abort route security', () => {
expect(response.status).toBe(400)
expect(getOwnedUploadSession).not.toHaveBeenCalled()
- expect(unlink).not.toHaveBeenCalled()
- expect(rm).not.toHaveBeenCalled()
+ expect(safelyCleanupMaterialUploadSession).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
@@ -134,9 +116,7 @@ describe('product-package abort route security', () => {
expect(response.status).toBe(400)
expect(getOwnedUploadSession).toHaveBeenCalledTimes(2)
- expect(unlink).not.toHaveBeenCalled()
- expect(rm).not.toHaveBeenCalled()
- expect(deleteOwnedUploadSession).not.toHaveBeenCalled()
+ expect(safelyCleanupMaterialUploadSession).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
@@ -184,33 +164,33 @@ describe('product-package abort route security', () => {
expect(getOwnedUploadSession).toHaveBeenCalledTimes(244)
expect(maxActiveSessionChecks).toBeGreaterThan(1)
expect(maxActiveSessionChecks).toBeLessThanOrEqual(12)
- expect(unlink).toHaveBeenCalledTimes(244)
- expect(rm).toHaveBeenCalledTimes(244)
- expect(deleteOwnedUploadSession).toHaveBeenCalledTimes(244)
+ expect(safelyCleanupMaterialUploadSession).toHaveBeenCalledTimes(244)
})
- test('会话文件名导致组装路径越界时不得执行任何删除', async () => {
- ;(getOwnedUploadSession as jest.Mock).mockResolvedValueOnce({
- fileName: `x${'\\..'.repeat(6)}\\sentinel.txt`,
- fileSize: 1024,
- totalChunks: 1,
- chunks: new Map(),
- createdAt: Date.now(),
- userId: 'user-1',
+ test('存在活动后台任务时不得删除素材或清理上传会话', async () => {
+ ;(prisma.productPackageUploadJob.findFirst as jest.Mock).mockResolvedValueOnce({
+ id: 'job-active',
+ status: 'processing',
})
const response = await POST(createRequest({
materialId: 'material-1',
uploadIds: ['upload-1'],
}))
+ const body = await response.json()
- expect(response.status).toBe(400)
- expect(unlink).not.toHaveBeenCalled()
- expect(rm).not.toHaveBeenCalled()
+ expect(response.status).toBe(200)
+ expect(body).toEqual(expect.objectContaining({
+ success: true,
+ deleted: false,
+ reason: 'job_processing',
+ jobId: 'job-active',
+ }))
expect(prisma.material.delete).not.toHaveBeenCalled()
+ expect(safelyCleanupMaterialUploadSession).not.toHaveBeenCalled()
})
- test('全部会话预校验通过后才清理受控目录并删除素材', async () => {
+ test('全部会话预校验通过后才通过统一取消协议删除素材会话', async () => {
const response = await POST(createRequest({
materialId: 'material-1',
uploadIds: ['upload-1', 'upload-2'],
@@ -226,14 +206,51 @@ describe('product-package abort route security', () => {
})
expect(getOwnedUploadSession).toHaveBeenNthCalledWith(1, 'upload-1', 'user-1')
expect(getOwnedUploadSession).toHaveBeenNthCalledWith(2, 'upload-2', 'user-1')
- expect(unlink).toHaveBeenCalledTimes(2)
- expect(rm).toHaveBeenCalledTimes(2)
expect(prisma.material.delete).toHaveBeenCalledWith({ where: { id: 'material-1' } })
- expect(deleteOwnedUploadSession).toHaveBeenNthCalledWith(1, 'upload-1', 'user-1')
- expect(deleteOwnedUploadSession).toHaveBeenNthCalledWith(2, 'upload-2', 'user-1')
+ expect(safelyCleanupMaterialUploadSession).toHaveBeenNthCalledWith(1, {
+ uploadId: 'upload-1',
+ intent: 'cancel',
+ expectedOwnerId: 'user-1',
+ })
+ expect(safelyCleanupMaterialUploadSession).toHaveBeenNthCalledWith(2, {
+ uploadId: 'upload-2',
+ intent: 'cancel',
+ expectedOwnerId: 'user-1',
+ })
+ })
- for (const [targetPath] of (rm as jest.Mock).mock.calls) {
- expect(targetPath).toContain(`uploads${require('path').sep}temp${require('path').sep}upload-`)
- }
+ test('Redis或锁状态不可用时返回503并保留该会话证据', async () => {
+ ;(safelyCleanupMaterialUploadSession as jest.Mock)
+ .mockResolvedValueOnce({
+ status: 'deleted',
+ success: true,
+ deletedFiles: 2,
+ freedSpace: 1024,
+ })
+ .mockResolvedValueOnce({
+ status: 'unavailable',
+ success: false,
+ deletedFiles: 0,
+ freedSpace: 0,
+ code: 'UPLOAD_CLEANUP_STATE_UNAVAILABLE',
+ httpStatus: 503,
+ error: 'Redis不可用',
+ })
+
+ const response = await POST(createRequest({
+ materialId: 'material-1',
+ uploadIds: ['upload-1', 'upload-2'],
+ }))
+ const body = await response.json()
+
+ expect(response.status).toBe(503)
+ expect(body).toEqual(expect.objectContaining({
+ success: false,
+ deleted: true,
+ cleanedUploadIds: 1,
+ pendingUploadIds: ['upload-2'],
+ code: 'UPLOAD_CANCEL_CLEANUP_INCOMPLETE',
+ }))
+ expect(prisma.material.delete).toHaveBeenCalledTimes(1)
})
})
diff --git a/lib/__tests__/product-package-completion.test.ts b/lib/__tests__/product-package-completion.test.ts
index 3a65dfb..37013ee 100644
--- a/lib/__tests__/product-package-completion.test.ts
+++ b/lib/__tests__/product-package-completion.test.ts
@@ -17,10 +17,9 @@ import {
cleanupOnError,
cleanupTempFiles,
createZipFromFiles,
- saveProductPackageAssets,
+ saveProductPackageAssetsAndComplete,
} from '@/lib/task-material-processor'
import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions'
-import { assignTaskNoOnComplete } from '@/lib/product-package'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
@@ -41,7 +40,6 @@ jest.mock('@/lib/prisma', () => ({
}))
jest.mock('@/lib/product-package', () => ({
- assignTaskNoOnComplete: jest.fn().mockResolvedValue(null),
validateTaskNo: jest.fn(() => true),
}))
@@ -53,7 +51,7 @@ jest.mock('@/lib/task-material-processor', () => ({
.mockResolvedValueOnce({ filePath: 'zip-sku', fileName: 'sku.zip', fileSize: 22 })
.mockResolvedValueOnce({ filePath: 'zip-detail', fileName: 'detail.zip', fileSize: 33 }),
createZipFromZips: jest.fn().mockResolvedValue({ filePath: 'zip-all', fileName: 'all.zip', fileSize: 66 }),
- saveProductPackageAssets: jest.fn().mockResolvedValue({
+ saveProductPackageAssetsAndComplete: jest.fn().mockResolvedValue({
createdSkuPreviewAssets: [
{
id: 'sku-preview-1',
@@ -366,10 +364,13 @@ describe('product-package-completion', () => {
dingtalkApprovalNo: '202508211445000157693',
isFullSet: true,
})
- expect(assignTaskNoOnComplete).toHaveBeenCalledWith(
- 'material-1',
- null,
- '202508211445000157693',
+ expect(saveProductPackageAssetsAndComplete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ materialId: 'material-1',
+ taskNo: null,
+ dingtalkApprovalNo: '202508211445000157693',
+ isFullSet: true,
+ }),
)
expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1')
expect(queueTaskDetailBarcodeImagesUpdatedNotification).toHaveBeenCalledWith('material-1', [
@@ -631,7 +632,7 @@ describe('product-package-completion', () => {
},
})).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' }))
- const psdInfos = (saveProductPackageAssets as jest.Mock).mock.calls.at(-1)?.[3]
+ const psdInfos = (saveProductPackageAssetsAndComplete as jest.Mock).mock.calls.at(-1)?.[0]?.psdInfos
expect(psdInfos).toHaveLength(2)
expect(psdInfos[0].filePath).not.toBe(psdInfos[1].filePath)
await expect(readFile(path.join(uploadRoot, 'materials', path.basename(psdInfos[0].filePath)), 'utf8'))
@@ -648,7 +649,7 @@ describe('product-package-completion', () => {
}
})
- test('PSD 持久化失败时也会清理已验证的上传会话', async () => {
+ test('PSD 持久化失败时保留已验证的上传会话供重试', async () => {
const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-failure-'))
const previousUploadDir = process.env.UPLOAD_DIR
process.env.UPLOAD_DIR = uploadRoot
@@ -674,7 +675,9 @@ describe('product-package-completion', () => {
},
})).rejects.toThrow('PSD 文件缺失或未上传完成')
- expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toContain('psd-failed')
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
+ expect(cleanupTempFiles).not.toHaveBeenCalled()
} finally {
if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = previousUploadDir
@@ -682,7 +685,7 @@ describe('product-package-completion', () => {
}
})
- test('数据库资产已保存但完成标记失败时会补偿并清理文件', async () => {
+ test('原子完成事务失败时不补偿半成品数据库,也不清理用户源会话', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
materialType: '任务-详情',
@@ -699,7 +702,7 @@ describe('product-package-completion', () => {
categoryName: '旗舰店',
user: { realName: '张三', username: 'zhangsan' },
})
- ;(assignTaskNoOnComplete as jest.Mock).mockRejectedValueOnce(new Error('任务号已存在'))
+ ;(saveProductPackageAssetsAndComplete as jest.Mock).mockRejectedValueOnce(new Error('任务号已存在'))
await expect(completeProductPackageUpload({
materialId: 'material-1',
@@ -711,7 +714,241 @@ describe('product-package-completion', () => {
},
})).rejects.toThrow('任务号已存在')
- expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { materialId: 'material-1' } })
- expect(prisma.$transaction).toHaveBeenCalled()
+ expect(prisma.materialAsset.deleteMany).not.toHaveBeenCalled()
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
+ })
+
+ test('事务提交回执丢失时重读完整资产,确认已完成后按成功返回并清理源会话', async () => {
+ ;(createZipFromFiles as jest.Mock).mockResolvedValueOnce({
+ filePath: 'materials/main-ack.zip',
+ fileName: 'main-ack.zip',
+ fileSize: 11,
+ })
+ const createZipFromZips = jest.requireMock('@/lib/task-material-processor').createZipFromZips as jest.Mock
+ createZipFromZips.mockResolvedValueOnce({
+ filePath: 'materials/all-ack.zip',
+ fileName: 'all-ack.zip',
+ fileSize: 22,
+ })
+ ;(saveProductPackageAssetsAndComplete as jest.Mock).mockImplementationOnce(async (input) => {
+ const mainPreview = input.previewAssets.main[0]
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
+ taskStatus: 'completed',
+ taskNo: null,
+ dingtalkApprovalNo: null,
+ previewUrl: input.cardPreviewUrl,
+ zipFilePath: input.zipInfos.all.filePath,
+ zipFileName: input.zipInfos.all.fileName,
+ zipFileSize: BigInt(input.zipInfos.all.fileSize),
+ isFullSet: input.isFullSet,
+ assets: [
+ {
+ id: 'main-preview-1',
+ assetType: 'main-preview',
+ fileUrl: mainPreview.fileUrl,
+ fileName: mainPreview.fileName,
+ fileSize: BigInt(0),
+ sortOrder: 0,
+ skuCode: null,
+ createdAt: new Date('2026-08-12T00:00:00.000Z'),
+ updatedAt: new Date('2026-08-12T00:00:00.000Z'),
+ barcodes: [],
+ },
+ {
+ id: 'main-zip-1',
+ assetType: 'main',
+ fileUrl: input.zipInfos.main.filePath,
+ fileName: input.zipInfos.main.fileName,
+ fileSize: BigInt(input.zipInfos.main.fileSize),
+ sortOrder: 0,
+ skuCode: null,
+ createdAt: new Date('2026-08-12T00:00:00.000Z'),
+ updatedAt: new Date('2026-08-12T00:00:00.000Z'),
+ barcodes: [],
+ },
+ {
+ id: 'all-zip-1',
+ assetType: 'all',
+ fileUrl: input.zipInfos.all.filePath,
+ fileName: input.zipInfos.all.fileName,
+ fileSize: BigInt(input.zipInfos.all.fileSize),
+ sortOrder: 0,
+ skuCode: null,
+ createdAt: new Date('2026-08-12T00:00:00.000Z'),
+ updatedAt: new Date('2026-08-12T00:00:00.000Z'),
+ barcodes: [],
+ },
+ ],
+ })
+ throw new Error('COMMIT acknowledgement lost')
+ })
+
+ await expect(completeProductPackageUpload({
+ materialId: 'material-1',
+ taskNo: null,
+ operatorUserId: 'user-1',
+ assets: {
+ main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
+ },
+ })).resolves.toEqual(expect.objectContaining({
+ materialId: 'material-1',
+ warning: '素材已保存,但响应确认阶段出现异常',
+ }))
+
+ expect(cleanupTempFiles).toHaveBeenCalled()
+ expect(cleanupOnError).not.toHaveBeenCalled()
+ })
+
+ test('事务结果无法重读确认时保留源文件和全部派生物', async () => {
+ ;(createZipFromFiles as jest.Mock).mockResolvedValueOnce({
+ filePath: 'materials/main-unknown.zip',
+ fileName: 'main-unknown.zip',
+ fileSize: 11,
+ })
+ ;(saveProductPackageAssetsAndComplete as jest.Mock).mockImplementationOnce(async () => {
+ ;(prisma.material.findUnique as jest.Mock).mockRejectedValueOnce(new Error('database unavailable'))
+ throw new Error('COMMIT acknowledgement lost')
+ })
+
+ await expect(completeProductPackageUpload({
+ materialId: 'material-1',
+ taskNo: null,
+ operatorUserId: 'user-1',
+ assets: {
+ main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
+ },
+ })).rejects.toThrow('完成状态暂时无法确认')
+
+ expect(cleanupTempFiles).not.toHaveBeenCalled()
+ expect(cleanupOnError).not.toHaveBeenCalled()
+ })
+
+ test('执行租约守卫在最终事务前失效时不提交且不清理源会话', async () => {
+ const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-product-lease-'))
+ const previousUploadDir = process.env.UPLOAD_DIR
+ process.env.UPLOAD_DIR = uploadRoot
+ try {
+ const sourcePath = path.join(uploadRoot, 'source.jpg')
+ const sourceBuffer = Buffer.from('lease-guard-source')
+ await writeFile(sourcePath, sourceBuffer)
+ ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
+ userId: 'user-1',
+ fileSize: sourceBuffer.length,
+ })
+ ;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath)
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
+ id: 'material-1',
+ userId: 'user-1',
+ materialType: '任务-详情',
+ taskDetailSourceType: '实拍',
+ taskStatus: 'uploading',
+ previewUrl: '',
+ zipFilePath: '',
+ productModel: 'SPU-001',
+ categoryName: '旗舰店',
+ user: { realName: '张三', username: 'zhangsan' },
+ })
+ ;(createZipFromFiles as jest.Mock).mockResolvedValue({
+ filePath: 'materials/main-lease.zip',
+ fileName: 'main-lease.zip',
+ fileSize: 11,
+ })
+ let executionCurrent = true
+ const assertExecutionCurrent = jest.fn(async () => {
+ if (!executionCurrent) throw new Error('job lease lost')
+ })
+
+ await expect(completeProductPackageUpload({
+ materialId: 'material-1',
+ taskNo: null,
+ operatorUserId: 'user-1',
+ assets: {
+ main: [{ uploadId: 'source-lease', fileName: 'source.jpg', fileSize: sourceBuffer.length }],
+ },
+ options: {
+ assertExecutionCurrent,
+ onProgress: ({ stage, progress }) => {
+ if (stage === 'database' && progress === 85) executionCurrent = false
+ },
+ },
+ })).rejects.toThrow('job lease lost')
+
+ expect(saveProductPackageAssetsAndComplete).not.toHaveBeenCalled()
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
+ await expect(readFile(sourcePath, 'utf8')).resolves.toBe('lease-guard-source')
+ } finally {
+ if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
+ else process.env.UPLOAD_DIR = previousUploadDir
+ await rm(uploadRoot, { recursive: true, force: true })
+ }
+ })
+
+ test('中止信号在最终事务前触发时不提交且不消费源会话', async () => {
+ const controller = new AbortController()
+ ;(createZipFromFiles as jest.Mock).mockResolvedValue({
+ filePath: 'materials/main-aborted.zip',
+ fileName: 'main-aborted.zip',
+ fileSize: 11,
+ })
+
+ await expect(completeProductPackageUpload({
+ materialId: 'material-1',
+ taskNo: null,
+ operatorUserId: 'user-1',
+ assets: {
+ main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
+ },
+ options: {
+ signal: controller.signal,
+ onProgress: ({ stage, progress }) => {
+ if (stage === 'database' && progress === 85) {
+ controller.abort(new Error('worker drained'))
+ }
+ },
+ },
+ })).rejects.toThrow('worker drained')
+
+ expect(saveProductPackageAssetsAndComplete).not.toHaveBeenCalled()
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
+ expect(cleanupTempFiles).not.toHaveBeenCalled()
+ })
+
+ test('PSD 在原子事务失败前只复制不移动,源文件仍可原地重试', async () => {
+ const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-copy-'))
+ const previousUploadDir = process.env.UPLOAD_DIR
+ process.env.UPLOAD_DIR = uploadRoot
+ try {
+ const sourcePath = path.join(uploadRoot, 'source.psd')
+ await writeFile(sourcePath, 'retryable-psd')
+ ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ userId: 'user-1', fileSize: 13 })
+ ;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath)
+ ;(createZipFromFiles as jest.Mock).mockResolvedValue({
+ filePath: 'materials/main-psd.zip',
+ fileName: 'main-psd.zip',
+ fileSize: 11,
+ })
+ ;(saveProductPackageAssetsAndComplete as jest.Mock).mockRejectedValueOnce(new Error('transaction rolled back'))
+
+ await expect(completeProductPackageUpload({
+ materialId: 'material-1',
+ taskNo: null,
+ operatorUserId: 'user-1',
+ assets: {
+ main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
+ psd: [{ uploadId: 'psd-retry', fileName: 'source.psd', fileSize: 13 }],
+ },
+ })).rejects.toThrow('transaction rolled back')
+
+ await expect(readFile(sourcePath, 'utf8')).resolves.toBe('retryable-psd')
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
+ expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
+ } finally {
+ if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
+ else process.env.UPLOAD_DIR = previousUploadDir
+ await rm(uploadRoot, { recursive: true, force: true })
+ }
})
})
diff --git a/lib/__tests__/product-package-init-route.test.ts b/lib/__tests__/product-package-init-route.test.ts
index 7d4135b..5d8eed9 100644
--- a/lib/__tests__/product-package-init-route.test.ts
+++ b/lib/__tests__/product-package-init-route.test.ts
@@ -118,7 +118,7 @@ describe('product-package init route', () => {
expect(response.status).toBe(200)
expect(validateTaskNo).toHaveBeenCalledWith('TASK202604230001')
expect(isTaskNoExists).toHaveBeenCalledWith('TASK202604230001')
- expect(purgeDeletedMaterialsByTaskNo).toHaveBeenCalledWith('TASK202604230001')
+ expect(purgeDeletedMaterialsByTaskNo).not.toHaveBeenCalled()
expect(prisma.material.create).toHaveBeenCalledWith({
data: expect.objectContaining({
taskDetailSourceType: '渲染',
diff --git a/lib/__tests__/product-package-task-no-lock.test.ts b/lib/__tests__/product-package-task-no-lock.test.ts
index 63b2e99..aed81b7 100644
--- a/lib/__tests__/product-package-task-no-lock.test.ts
+++ b/lib/__tests__/product-package-task-no-lock.test.ts
@@ -3,8 +3,15 @@
import {
acquireTaskNoAssignmentLock,
assignTaskNoOnComplete,
+ createTaskMaterialOnComplete,
+ createTaskOtherMaterialOnComplete,
+ finalizeTaskMaterialOnComplete,
+ purgeDeletedMaterialsByTaskNo,
} from '@/lib/product-package'
import { prisma } from '@/lib/prisma'
+import { saveProductPackageAssetsAndComplete } from '@/lib/task-material-processor'
+import { collectMaterialFiles } from '@/lib/material-cleanup'
+import { deleteFiles } from '@/lib/file-cleanup'
jest.mock('@/lib/prisma', () => ({
prisma: {
@@ -12,15 +19,29 @@ jest.mock('@/lib/prisma', () => ({
material: {
findFirst: jest.fn(),
findMany: jest.fn(),
+ deleteMany: jest.fn(),
},
+ materialAsset: { createMany: jest.fn() },
},
}))
+jest.mock('@/lib/material-cleanup', () => ({
+ collectMaterialFiles: jest.fn(),
+}))
+
+jest.mock('@/lib/file-cleanup', () => ({
+ deleteFiles: jest.fn(),
+}))
+
describe('任务号完成写入并发锁', () => {
beforeEach(() => {
jest.clearAllMocks()
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
+ ;(prisma.material.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
+ ;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 0 })
+ ;(collectMaterialFiles as jest.Mock).mockResolvedValue({ fileUrls: [], directories: [] })
+ ;(deleteFiles as jest.Mock).mockResolvedValue({ deletedCount: 0, failedCount: 0 })
})
test('advisory lock 只返回 Prisma 可反序列化的整数列', async () => {
@@ -79,4 +100,378 @@ describe('任务号完成写入并发锁', () => {
expect(tx.material.update).toHaveBeenCalledTimes(1)
expect(tx.$queryRaw).toHaveBeenCalledTimes(2)
})
+
+ test('任务图片完成在行锁后验证 owner/type/status,再与资产写入同事务完成', async () => {
+ const order: string[] = []
+ const tx = {
+ $queryRaw: jest.fn(async () => [{
+ taskStatus: 'uploading',
+ previewUrl: '',
+ zipFilePath: '',
+ deletedAt: null,
+ userId: 'user-1',
+ materialType: '任务-页面',
+ }]),
+ material: {
+ update: jest.fn(async () => {
+ order.push('completed')
+ return { id: 'material-1' }
+ }),
+ },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
+
+ await finalizeTaskMaterialOnComplete({
+ materialId: 'material-1',
+ expectedTaskStatuses: ['uploading', null],
+ expectedUserId: 'user-1',
+ expectedMaterialType: '任务-页面',
+ applyCompletion: async () => {
+ order.push('assets')
+ },
+ })
+
+ const queryMock = tx.$queryRaw as jest.Mock
+ const sql = Array.from(queryMock.mock.calls[0][0] as TemplateStringsArray).join('?')
+ expect(sql).toContain('FOR UPDATE')
+ expect(sql).toContain('"task_status" AS "taskStatus"')
+ expect(order).toEqual(['assets', 'completed'])
+ expect(tx.material.update).toHaveBeenCalledWith({
+ where: { id: 'material-1' },
+ data: { taskStatus: 'completed' },
+ })
+ })
+
+ test('owner 或素材类型变化时事务整体拒绝,资产回调不会执行', async () => {
+ const applyCompletion = jest.fn()
+ const tx = {
+ $queryRaw: jest.fn(async () => [{
+ taskStatus: 'uploading',
+ previewUrl: '',
+ zipFilePath: '',
+ deletedAt: null,
+ userId: 'other-user',
+ materialType: '任务-页面',
+ }]),
+ material: { update: jest.fn() },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
+
+ await expect(finalizeTaskMaterialOnComplete({
+ materialId: 'material-1',
+ expectedTaskStatuses: ['uploading'],
+ expectedUserId: 'user-1',
+ expectedMaterialType: '任务-页面',
+ applyCompletion,
+ })).rejects.toThrow('素材完成状态已变化')
+ expect(applyCompletion).not.toHaveBeenCalled()
+ expect(tx.material.update).not.toHaveBeenCalled()
+ })
+
+ test('任务详情旧资产替换、预览与 ZIP 字段、任务号和 completed 在同一事务提交', async () => {
+ const order: string[] = []
+ const tx = {
+ $queryRaw: jest.fn(async () => [{
+ taskStatus: 'uploading',
+ previewUrl: '',
+ zipFilePath: '',
+ deletedAt: null,
+ userId: 'user-1',
+ materialType: '任务-详情',
+ }]),
+ materialAsset: {
+ deleteMany: jest.fn(async () => { order.push('delete-old-assets') }),
+ createMany: jest.fn(async () => { order.push('create-assets') }),
+ create: jest.fn(async () => {
+ order.push('create-sku-preview')
+ return {
+ id: 'sku-asset-1',
+ fileUrl: 'https://s3.example/sku.webp',
+ fileName: 'sku.webp',
+ sortOrder: 1,
+ createdAt: new Date('2026-08-12T00:00:00.000Z'),
+ updatedAt: new Date('2026-08-12T00:00:00.000Z'),
+ }
+ }),
+ },
+ materialAssetBarcode: {
+ createMany: jest.fn(async () => { order.push('create-barcodes') }),
+ },
+ material: {
+ update: jest.fn(async ({ data }: { data: Record }) => {
+ if (data.taskStatus === 'completed') order.push('completed')
+ else if ('previewUrl' in data) order.push('card-fields')
+ else order.push('zip-fields')
+ return { id: 'material-1' }
+ }),
+ },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
+
+ await saveProductPackageAssetsAndComplete({
+ materialId: 'material-1',
+ previewAssets: {
+ main: [{ fileUrl: 'https://s3.example/main.webp', fileName: 'main.webp' }],
+ sku: [{
+ fileUrl: 'https://s3.example/sku.webp',
+ fileName: 'sku.webp',
+ skuCode: 'SKU-1',
+ barcodes: ['690000000001'],
+ }],
+ detail: [],
+ },
+ zipInfos: {
+ main: { filePath: 'materials/main.zip', fileName: 'main.zip', fileSize: 10 },
+ all: { filePath: 'materials/all.zip', fileName: 'all.zip', fileSize: 20 },
+ },
+ cardPreviewUrl: 'https://s3.example/main.webp',
+ isFullSet: true,
+ taskNo: null,
+ dingtalkApprovalNo: '202508211445000157693',
+ userId: 'user-1',
+ materialType: '任务-详情',
+ })
+
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1)
+ expect(order).toEqual([
+ 'delete-old-assets',
+ 'create-assets',
+ 'zip-fields',
+ 'create-sku-preview',
+ 'create-barcodes',
+ 'card-fields',
+ 'completed',
+ ])
+ expect(tx.materialAsset.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ skuCode: 'SKU-1' }),
+ }))
+ expect(tx.material.update).toHaveBeenLastCalledWith({
+ where: { id: 'material-1' },
+ data: {
+ dingtalkApprovalNo: '202508211445000157693',
+ taskStatus: 'completed',
+ },
+ })
+ })
+
+ test('任务详情事务中资产写入故障时不会继续写 completed', async () => {
+ const tx = {
+ $queryRaw: jest.fn(async () => [{
+ taskStatus: 'uploading',
+ previewUrl: '',
+ zipFilePath: '',
+ deletedAt: null,
+ userId: 'user-1',
+ materialType: '任务-详情',
+ }]),
+ materialAsset: {
+ deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
+ createMany: jest.fn().mockRejectedValue(new Error('asset insert fault')),
+ create: jest.fn(),
+ },
+ materialAssetBarcode: { createMany: jest.fn() },
+ material: { update: jest.fn() },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
+
+ await expect(saveProductPackageAssetsAndComplete({
+ materialId: 'material-1',
+ previewAssets: {
+ main: [{ fileUrl: 'https://s3.example/main.webp', fileName: 'main.webp' }],
+ sku: [],
+ detail: [],
+ },
+ zipInfos: {
+ all: { filePath: 'materials/all.zip', fileName: 'all.zip', fileSize: 20 },
+ },
+ cardPreviewUrl: 'https://s3.example/main.webp',
+ isFullSet: false,
+ taskNo: null,
+ userId: 'user-1',
+ materialType: '任务-详情',
+ })).rejects.toThrow('asset insert fault')
+
+ expect(tx.material.update).not.toHaveBeenCalled()
+ expect(tx.materialAsset.create).not.toHaveBeenCalled()
+ expect(tx.materialAssetBarcode.createMany).not.toHaveBeenCalled()
+ })
+
+ test('任务-其他只有在素材和资产事务完整成功后才盘点旧软删数据', async () => {
+ const order: string[] = []
+ const tx = {
+ $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
+ material: {
+ findFirst: jest.fn().mockResolvedValue(null),
+ create: jest.fn(async () => {
+ order.push('material')
+ return { id: 'new-material', user: { username: 'tester' } }
+ }),
+ },
+ materialAsset: {
+ createMany: jest.fn(async () => {
+ order.push('assets')
+ return { count: 2 }
+ }),
+ },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => {
+ const result = await callback(tx)
+ order.push('commit')
+ return result
+ })
+ ;(prisma.material.findMany as jest.Mock).mockImplementationOnce(async () => {
+ order.push('audit')
+ return []
+ })
+
+ await createTaskOtherMaterialOnComplete({
+ materialData: { productModel: 'TASK202604230001', userId: 'user-1' },
+ assets: [{
+ assetType: 'preview',
+ fileUrl: 'https://s3.example/preview.webp',
+ fileName: 'preview.webp',
+ fileSize: BigInt(1),
+ sortOrder: 0,
+ }],
+ taskNo: 'TASK202604230001',
+ })
+
+ expect(order).toEqual(['material', 'assets', 'commit', 'audit'])
+ expect(tx.material.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ taskStatus: 'completed', taskNo: 'TASK202604230001' }),
+ }))
+ })
+
+ test('任务-其他资产写入失败时事务拒绝且绝不清理旧软删素材', async () => {
+ const tx = {
+ $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
+ material: {
+ findFirst: jest.fn().mockResolvedValue(null),
+ create: jest.fn().mockResolvedValue({ id: 'new-material' }),
+ },
+ materialAsset: {
+ createMany: jest.fn().mockRejectedValue(new Error('asset insert failed')),
+ },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
+
+ await expect(createTaskOtherMaterialOnComplete({
+ materialData: { productModel: 'TASK202604230001', userId: 'user-1' },
+ assets: [{
+ assetType: 'zip',
+ fileUrl: 'uploads/source.zip',
+ fileName: 'source.zip',
+ fileSize: BigInt(1),
+ sortOrder: 0,
+ }],
+ taskNo: 'TASK202604230001',
+ })).rejects.toThrow('asset insert failed')
+
+ expect(prisma.material.findMany).not.toHaveBeenCalled()
+ })
+
+ test('通用入口的任务原图资产写入失败时事务拒绝且绝不写 completed', async () => {
+ const tx = {
+ $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
+ material: {
+ findFirst: jest.fn().mockResolvedValue(null),
+ create: jest.fn().mockResolvedValue({ id: 'new-original-material' }),
+ },
+ materialAsset: {
+ createMany: jest.fn().mockRejectedValue(new Error('original asset insert failed')),
+ },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
+
+ await expect(createTaskMaterialOnComplete({
+ materialData: {
+ productModel: 'SPU-ORIGINAL-001',
+ materialType: '任务-3D原图',
+ userId: 'user-1',
+ },
+ assets: [{
+ assetType: 'zip',
+ fileUrl: 'uploads/original-source.zip',
+ fileName: 'original-source.zip',
+ fileSize: BigInt(1),
+ sortOrder: 0,
+ }],
+ taskNo: 'TASK202604230001',
+ })).rejects.toThrow('original asset insert failed')
+
+ expect(tx.material.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({
+ materialType: '任务-3D原图',
+ taskNo: 'TASK202604230001',
+ taskStatus: 'completed',
+ }),
+ }))
+ expect(prisma.material.findMany).not.toHaveBeenCalled()
+ })
+
+ test('旧软删素材可能共享文件 URL 时物理 GC 保持禁用', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ ;(prisma.material.findMany as jest.Mock).mockResolvedValueOnce([{
+ id: 'old-material',
+ materialType: '任务-详情',
+ deletedAt: new Date(),
+ }])
+ ;(collectMaterialFiles as jest.Mock).mockResolvedValueOnce({
+ fileUrls: ['uploads/old-a.zip', 'uploads/old-b.zip'],
+ directories: [],
+ })
+ ;(deleteFiles as jest.Mock).mockResolvedValueOnce({ deletedCount: 1, failedCount: 1 })
+
+ const result = await purgeDeletedMaterialsByTaskNo('TASK202604230001')
+
+ expect(result).toEqual({ deletedMaterials: 0, deletedFiles: 0, failedFiles: 0 })
+ expect(collectMaterialFiles).not.toHaveBeenCalled()
+ expect(deleteFiles).not.toHaveBeenCalled()
+ expect(prisma.material.deleteMany).not.toHaveBeenCalled()
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('自动物理 GC 已禁用'),
+ expect.objectContaining({
+ taskNo: 'TASK202604230001',
+ deferredMaterials: 1,
+ materialIds: ['old-material'],
+ }),
+ )
+ warnSpy.mockRestore()
+ })
+
+ test('新素材完成后的旧数据盘点遇到数据库故障时也不删数据或改判完成结果', async () => {
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ const tx = {
+ $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
+ material: {
+ findFirst: jest.fn().mockResolvedValue(null),
+ update: jest.fn().mockResolvedValue({ id: 'new-material' }),
+ },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
+ ;(prisma.material.findMany as jest.Mock).mockRejectedValueOnce(new Error('database unavailable'))
+ ;(prisma.material.deleteMany as jest.Mock).mockRejectedValueOnce(new Error('must not run'))
+
+ await expect(assignTaskNoOnComplete('new-material', 'TASK202604230001')).resolves.toEqual({
+ deletedMaterials: 0,
+ deletedFiles: 0,
+ failedFiles: 1,
+ })
+
+ expect(tx.material.update).toHaveBeenCalledWith(expect.objectContaining({
+ where: { id: 'new-material' },
+ data: expect.objectContaining({
+ taskNo: 'TASK202604230001',
+ taskStatus: 'completed',
+ }),
+ }))
+ expect(collectMaterialFiles).not.toHaveBeenCalled()
+ expect(deleteFiles).not.toHaveBeenCalled()
+ expect(prisma.material.deleteMany).not.toHaveBeenCalled()
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('自动物理 GC 仍保持禁用'),
+ expect.objectContaining({ taskNo: 'TASK202604230001' }),
+ )
+ errorSpy.mockRestore()
+ })
})
diff --git a/lib/__tests__/regenerate-pdf-preview-route.test.ts b/lib/__tests__/regenerate-pdf-preview-route.test.ts
new file mode 100644
index 0000000..471097e
--- /dev/null
+++ b/lib/__tests__/regenerate-pdf-preview-route.test.ts
@@ -0,0 +1,270 @@
+/** @jest-environment node */
+
+import path from 'path'
+import { lstat, readFile, realpath } from 'fs/promises'
+
+import { NextRequest } from 'next/server'
+import { getServerSession } from 'next-auth'
+
+import { POST } from '@/app/api/admin/regenerate-pdf-preview/route'
+import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
+import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
+import { processPdfUpload } from '@/lib/pdf-upload-handler'
+import { prisma } from '@/lib/prisma'
+import { resolveStoredUploadPath } from '@/lib/upload-paths'
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}))
+
+jest.mock('@/lib/auth', () => ({ authOptions: {} }))
+
+jest.mock('@/lib/department-permission', () => ({
+ isSuperAdmin: jest.fn(),
+ hasSystemAdminPermission: jest.fn(),
+}))
+
+jest.mock('@/lib/image-processing-limiter', () => ({
+ withImageProcessingSlot: jest.fn(),
+}))
+
+jest.mock('@/lib/pdf-upload-handler', () => ({
+ processPdfUpload: jest.fn(),
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ },
+}))
+
+const uploadRoot = path.resolve('D:\\trusted-uploads')
+const sourcePath = path.join(uploadRoot, 'materials', 'brand-vi.pdf')
+
+jest.mock('@/lib/upload-paths', () => ({
+ getUploadRootDir: jest.fn(() => path.resolve('D:\\trusted-uploads')),
+ resolveStoredUploadPath: jest.fn(() => path.join(
+ path.resolve('D:\\trusted-uploads'),
+ 'materials',
+ 'brand-vi.pdf',
+ )),
+}))
+
+jest.mock('fs/promises', () => ({
+ ...jest.requireActual('fs/promises'),
+ lstat: jest.fn(),
+ realpath: jest.fn(),
+ readFile: jest.fn(),
+}))
+
+type MockStatsOptions = {
+ directory?: boolean
+ symlink?: boolean
+ size?: number
+ dev?: number
+ ino?: number
+ mtimeMs?: number
+}
+
+function mockStats(options: MockStatsOptions = {}) {
+ return {
+ isDirectory: () => options.directory === true,
+ isSymbolicLink: () => options.symlink === true,
+ isFile: () => options.directory !== true && options.symlink !== true,
+ size: options.size ?? 1024,
+ dev: options.dev ?? 1,
+ ino: options.ino ?? 2,
+ mtimeMs: options.mtimeMs ?? 3,
+ }
+}
+
+function createRequest(
+ body: unknown = { materialId: 'material-1' },
+ headers: Record = {},
+) {
+ return new NextRequest('http://localhost/api/admin/regenerate-pdf-preview', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ ...headers,
+ },
+ body: JSON.stringify(body),
+ })
+}
+
+describe('admin regenerate-pdf-preview route', () => {
+ const originalAdminApiKey = process.env.ADMIN_API_KEY
+ let imageLockHeld = false
+ let consoleErrorSpy: jest.SpyInstance
+
+ beforeAll(() => {
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ imageLockHeld = false
+ process.env.ADMIN_API_KEY = 'configured-admin-key'
+ ;(resolveStoredUploadPath as jest.Mock).mockReturnValue(sourcePath)
+
+ ;(getServerSession as jest.Mock).mockResolvedValue({
+ user: { id: 'admin-1', role: 'ADMIN' },
+ })
+ ;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
+ ;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
+ id: 'material-1',
+ materialType: '品牌VI灵感',
+ zipFilePath: 'uploads/materials/brand-vi.pdf',
+ })
+ ;(prisma.material.update as jest.Mock).mockResolvedValue({ id: 'material-1' })
+ ;(processPdfUpload as jest.Mock).mockImplementation(async () => {
+ if (!imageLockHeld) throw new Error('PDF content was read outside image lock')
+ return { success: true, previewUrl: 'https://preview.example/new.jpg' }
+ })
+ ;(withImageProcessingSlot as jest.Mock).mockImplementation(
+ async (task: () => Promise) => {
+ imageLockHeld = true
+ try {
+ return await task()
+ } finally {
+ imageLockHeld = false
+ }
+ },
+ )
+ ;(lstat as jest.Mock).mockImplementation(async (filePath: string) => (
+ path.resolve(filePath) === uploadRoot
+ ? mockStats({ directory: true, dev: 1, ino: 1 })
+ : mockStats()
+ ))
+ ;(realpath as jest.Mock).mockImplementation(async (filePath: string) => path.resolve(filePath))
+ })
+
+ afterAll(() => {
+ consoleErrorSpy.mockRestore()
+ if (originalAdminApiKey === undefined) {
+ delete process.env.ADMIN_API_KEY
+ } else {
+ process.env.ADMIN_API_KEY = originalAdminApiKey
+ }
+ })
+
+ test('普通登录用户不能重建 PDF 预览', async () => {
+ ;(getServerSession as jest.Mock).mockResolvedValue({
+ user: { id: 'user-1', role: 'USER' },
+ })
+ ;(hasSystemAdminPermission as jest.Mock).mockReturnValue(false)
+
+ const response = await POST(createRequest())
+
+ expect(response.status).toBe(403)
+ expect(isSuperAdmin).toHaveBeenCalledWith('user-1')
+ expect(prisma.material.findUnique).not.toHaveBeenCalled()
+ expect(processPdfUpload).not.toHaveBeenCalled()
+ })
+
+ test('服务器未配置 API key 时,任意请求头都不能绕过登录', async () => {
+ delete process.env.ADMIN_API_KEY
+ ;(getServerSession as jest.Mock).mockResolvedValue(null)
+
+ const response = await POST(createRequest(
+ { materialId: 'material-1' },
+ { 'x-api-key': 'attacker-controlled-value' },
+ ))
+
+ expect(response.status).toBe(401)
+ expect(getServerSession).toHaveBeenCalledTimes(1)
+ expect(prisma.material.findUnique).not.toHaveBeenCalled()
+ })
+
+ test('已配置且匹配的 API key 可以调用,并且全部 PDF 内容处理都在全局锁内', async () => {
+ ;(getServerSession as jest.Mock).mockRejectedValue(new Error('valid API key should skip session'))
+
+ const response = await POST(createRequest(
+ { materialId: 'material-1' },
+ { 'x-api-key': 'configured-admin-key' },
+ ))
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.previewUrl).toBe('https://preview.example/new.jpg')
+ expect(getServerSession).not.toHaveBeenCalled()
+ expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
+ expect(processPdfUpload).toHaveBeenCalledWith(sourcePath, '')
+ expect(prisma.material.update).toHaveBeenCalledWith({
+ where: { id: 'material-1' },
+ data: { previewUrl: 'https://preview.example/new.jpg' },
+ })
+ })
+
+ test('超大 PDF 只检查元数据,不读取文件内容或进入处理锁', async () => {
+ ;(lstat as jest.Mock).mockImplementation(async (filePath: string) => (
+ path.resolve(filePath) === uploadRoot
+ ? mockStats({ directory: true, dev: 1, ino: 1 })
+ : mockStats({ size: 128 * 1024 * 1024 + 1 })
+ ))
+
+ const response = await POST(createRequest())
+
+ expect(response.status).toBe(413)
+ expect(lstat).toHaveBeenCalledWith(sourcePath)
+ expect(realpath).not.toHaveBeenCalled()
+ expect(readFile).not.toHaveBeenCalled()
+ expect(withImageProcessingSlot).not.toHaveBeenCalled()
+ expect(processPdfUpload).not.toHaveBeenCalled()
+ })
+
+ test('UPLOAD_DIR 之外的绝对路径会在访问文件前被拒绝', async () => {
+ ;(resolveStoredUploadPath as jest.Mock).mockReturnValue(path.resolve('D:\\outside', 'secret.pdf'))
+
+ const response = await POST(createRequest())
+
+ expect(response.status).toBe(400)
+ expect(lstat).not.toHaveBeenCalled()
+ expect(realpath).not.toHaveBeenCalled()
+ expect(processPdfUpload).not.toHaveBeenCalled()
+ })
+
+ test('位于 UPLOAD_DIR 内的符号链接 PDF 会被拒绝', async () => {
+ ;(lstat as jest.Mock).mockImplementation(async (filePath: string) => (
+ path.resolve(filePath) === uploadRoot
+ ? mockStats({ directory: true, dev: 1, ino: 1 })
+ : mockStats({ symlink: true })
+ ))
+
+ const response = await POST(createRequest())
+
+ expect(response.status).toBe(400)
+ expect(realpath).not.toHaveBeenCalled()
+ expect(withImageProcessingSlot).not.toHaveBeenCalled()
+ expect(processPdfUpload).not.toHaveBeenCalled()
+ })
+
+ test('父目录符号链接解析到 UPLOAD_DIR 外时会被拒绝', async () => {
+ ;(realpath as jest.Mock).mockImplementation(async (filePath: string) => (
+ path.resolve(filePath) === uploadRoot
+ ? uploadRoot
+ : path.resolve('D:\\outside', 'brand-vi.pdf')
+ ))
+
+ const response = await POST(createRequest())
+
+ expect(response.status).toBe(400)
+ expect(withImageProcessingSlot).not.toHaveBeenCalled()
+ expect(processPdfUpload).not.toHaveBeenCalled()
+ })
+
+ test('请求体超过 2 KiB 时在数据库查询前拒绝', async () => {
+ const response = await POST(createRequest({
+ materialId: 'material-1',
+ padding: 'x'.repeat(3 * 1024),
+ }))
+
+ expect(response.status).toBe(413)
+ expect(prisma.material.findUnique).not.toHaveBeenCalled()
+ expect(lstat).not.toHaveBeenCalled()
+ })
+})
diff --git a/lib/__tests__/regenerate-preview-route.test.ts b/lib/__tests__/regenerate-preview-route.test.ts
index bc4438f..3329cb9 100644
--- a/lib/__tests__/regenerate-preview-route.test.ts
+++ b/lib/__tests__/regenerate-preview-route.test.ts
@@ -1,38 +1,45 @@
/** @jest-environment node */
-import { POST } from '@/app/api/admin/regenerate-preview/route'
+import AdmZip from 'adm-zip'
import { getServerSession } from 'next-auth'
+
+import { POST } from '@/app/api/admin/regenerate-preview/route'
+import { readBoundedRegularFile } from '@/lib/bounded-file-reader'
+import { sliceImage } from '@/lib/image-processor'
+import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
+import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock'
import { prisma } from '@/lib/prisma'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
+import { deleteFromS3, verifyUploadedS3Object } from '@/lib/s3'
-jest.mock('next-auth', () => ({
- getServerSession: jest.fn(),
-}))
-
-jest.mock('@/lib/auth', () => ({
- authOptions: {},
-}))
-
+jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
+jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn().mockResolvedValue(false),
hasSystemAdminPermission: jest.fn((role: string, isSuperAdmin: boolean) =>
isSuperAdmin || role === 'ADMIN' || role === 'SYSTEM_ADMIN'),
}))
-
jest.mock('fs/promises', () => ({
- readFile: jest.fn(),
- unlink: jest.fn(),
- mkdir: jest.fn(),
- rm: jest.fn(),
- readdir: jest.fn(),
+ mkdtemp: jest.fn().mockResolvedValue('/uploads/temp/regenerate-material-1-unique'),
+ rm: jest.fn().mockResolvedValue(undefined),
+}))
+jest.mock('@/lib/bounded-file-reader', () => ({ readBoundedRegularFile: jest.fn() }))
+jest.mock('@/lib/path-security', () => ({
+ ensureSafeDirectoryWithinRoot: jest.fn().mockResolvedValue('/uploads/temp'),
+ isPathFreeOfSymbolicLinks: jest.fn().mockResolvedValue(true),
+ isPathInAllowedDirs: jest.fn().mockReturnValue(true),
+}))
+jest.mock('@/lib/upload-paths', () => ({
+ getUploadRootDir: jest.fn().mockReturnValue('/uploads'),
+ resolveStoredUploadPath: jest.fn().mockReturnValue('/uploads/materials/detail.zip'),
+}))
+jest.mock('@/lib/material-upload-cross-process-lock', () => ({
+ acquireTaskMaterialCompletionLock: jest.fn(),
}))
-
jest.mock('@/lib/prisma', () => ({
prisma: {
- material: {
- findUnique: jest.fn(),
- update: jest.fn(),
- },
+ $transaction: jest.fn(),
+ material: { findUnique: jest.fn(), update: jest.fn() },
materialAsset: {
findMany: jest.fn(),
deleteMany: jest.fn(),
@@ -40,102 +47,222 @@ jest.mock('@/lib/prisma', () => ({
},
},
}))
-
-jest.mock('sharp', () =>
- jest.fn(() => ({
- metadata: jest.fn().mockResolvedValue({ width: 1200, height: 2400 }),
- })),
-)
-
+jest.mock('sharp', () => jest.fn(() => ({
+ metadata: jest.fn().mockResolvedValue({ width: 1200, height: 2400, pages: 1 }),
+})))
jest.mock('@/lib/image-processor', () => ({
+ assertProcessableMetadata: jest.fn(),
sliceImage: jest.fn(),
}))
-
+jest.mock('@/lib/image-processing-limiter', () => ({
+ withImageProcessingSlot: jest.fn((task: () => Promise) => task()),
+}))
jest.mock('@/lib/public-display', () => ({
...jest.requireActual('@/lib/public-display'),
uploadPublicDisplayToS3: jest.fn(),
}))
-
-jest.mock('adm-zip', () =>
- jest.fn().mockImplementation(() => ({
- extractAllTo: jest.fn(),
- })),
-)
+jest.mock('@/lib/s3', () => ({
+ deleteFromS3: jest.fn(),
+ verifyUploadedS3Object: jest.fn(),
+}))
describe('admin regenerate-preview route', () => {
- const fsPromises = jest.requireMock('fs/promises') as {
- readFile: jest.Mock
- mkdir: jest.Mock
- rm: jest.Mock
- readdir: jest.Mock
- }
- const { sliceImage } = jest.requireMock('@/lib/image-processor') as {
- sliceImage: jest.Mock
- }
- let dateNowSpy: jest.SpyInstance
+ const releaseMaterialLock = Object.assign(jest.fn().mockResolvedValue(undefined), {
+ assertHeld: jest.fn(),
+ })
+ const uploadedUrls = [
+ 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/new-1.webp',
+ 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/new-2.webp',
+ ]
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
+ ;(acquireTaskMaterialCompletionLock as jest.Mock).mockResolvedValue(releaseMaterialLock)
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
+ previewUrl: 'https://s3.jiansun.vip/old.webp',
})
- ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([
- {
- materialId: 'material-1',
- assetType: 'detail',
- fileUrl: 'uploads/materials/detail.zip',
- },
+ ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([{
+ materialId: 'material-1',
+ assetType: 'detail',
+ fileUrl: 'uploads/materials/detail.zip',
+ sortOrder: 0,
+ }])
+
+ const zip = new AdmZip()
+ zip.addFile('safe/detail.png', Buffer.from('bounded-detail-image'))
+ ;(readBoundedRegularFile as jest.Mock).mockResolvedValue(zip.toBuffer())
+ ;(sliceImage as jest.Mock).mockResolvedValue([
+ Buffer.from('slice-1'),
+ Buffer.from('slice-2'),
])
- ;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 2 })
+ ;(uploadPublicDisplayToS3 as jest.Mock)
+ .mockResolvedValueOnce(uploadedUrls[0])
+ .mockResolvedValueOnce(uploadedUrls[1])
+ ;(verifyUploadedS3Object as jest.Mock).mockResolvedValue(undefined)
+ ;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined)
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (action) => action({
+ material: prisma.material,
+ materialAsset: prisma.materialAsset,
+ }))
+ ;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 2 })
;(prisma.material.update as jest.Mock).mockResolvedValue(undefined)
- fsPromises.mkdir.mockResolvedValue(undefined)
- fsPromises.rm.mockResolvedValue(undefined)
- fsPromises.readdir.mockResolvedValue(['detail.png'])
- fsPromises.readFile.mockResolvedValue(Buffer.from('detail-image'))
- sliceImage.mockResolvedValue([Buffer.from('slice-1'), Buffer.from('slice-2')])
- ;(uploadPublicDisplayToS3 as jest.Mock)
- .mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/detail-preview-1.webp')
- .mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/detail-preview-2.webp')
- dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1712222222222)
})
- afterEach(() => {
- dateNowSpy.mockRestore()
- })
+ function request(materialId = 'material-1') {
+ const body = JSON.stringify({ materialId })
+ return new Request('http://localhost/api/admin/regenerate-preview', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'content-length': String(Buffer.byteLength(body)),
+ },
+ body,
+ }) as any
+ }
- test('管理员重建详情预览时应写入 public-display task-detail 前缀', async () => {
- const request = {
- json: jest.fn().mockResolvedValue({ materialId: 'material-1' }),
- } as any
+ function rawRequest(
+ body: string,
+ headers: Record = {},
+ ) {
+ return new Request('http://localhost/api/admin/regenerate-preview', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'content-length': String(Buffer.byteLength(body)),
+ ...headers,
+ },
+ body,
+ }) as any
+ }
- const response = await POST(request)
+ test('新对象全部验证后,在同一事务替换详情预览资产和卡片封面', async () => {
+ const response = await POST(request())
const body = await response.json()
expect(response.status).toBe(200)
- expect(body.success).toBe(true)
- expect(uploadPublicDisplayToS3).toHaveBeenNthCalledWith(
+ expect(body).toMatchObject({ success: true, previewCount: 2, previewUrls: uploadedUrls })
+ expect(acquireTaskMaterialCompletionLock).toHaveBeenCalledWith('material-1')
+ expect(readBoundedRegularFile).toHaveBeenCalledWith('/uploads/materials/detail.zip', {
+ maxBytes: 64 * 1024 * 1024,
+ label: '详情图压缩包',
+ })
+ expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
+ expect(sliceImage).toHaveBeenCalledWith(
+ Buffer.from('bounded-detail-image'),
+ 1500,
+ 200,
+ )
+ expect(verifyUploadedS3Object).toHaveBeenNthCalledWith(
1,
- Buffer.from('slice-1'),
- 'task-detail/material-1/detail/detail-preview-1712222222222-0-slice0.webp',
+ uploadedUrls[0],
+ Buffer.byteLength('slice-1'),
'image/webp',
)
- expect(uploadPublicDisplayToS3).toHaveBeenNthCalledWith(
+ expect((verifyUploadedS3Object as jest.Mock).mock.invocationCallOrder[1])
+ .toBeLessThan((prisma.$transaction as jest.Mock).mock.invocationCallOrder[0])
+ expect(verifyUploadedS3Object).toHaveBeenNthCalledWith(
2,
- Buffer.from('slice-2'),
- 'task-detail/material-1/detail/detail-preview-1712222222222-1-slice1.webp',
+ uploadedUrls[1],
+ Buffer.byteLength('slice-2'),
'image/webp',
)
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1)
+ expect((prisma.materialAsset.deleteMany as jest.Mock).mock.invocationCallOrder[0])
+ .toBeLessThan((prisma.materialAsset.createMany as jest.Mock).mock.invocationCallOrder[0])
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
- data: {
- previewUrl:
- 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/detail-preview-1.webp',
- },
+ data: { previewUrl: uploadedUrls[0] },
})
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(releaseMaterialLock).toHaveBeenCalledTimes(1)
+ })
+
+ test('ZIP 包含路径穿越条目时拒绝处理且不改旧资产', async () => {
+ const zip = new AdmZip()
+ zip.addFile('xx/escape.png', Buffer.from('evil'))
+ const unsafeZip = zip.toBuffer()
+ const safeName = Buffer.from('xx/escape.png')
+ const unsafeName = Buffer.from('../escape.png')
+ let offset = 0
+ while ((offset = unsafeZip.indexOf(safeName, offset)) >= 0) {
+ unsafeName.copy(unsafeZip, offset)
+ offset += unsafeName.length
+ }
+ ;(readBoundedRegularFile as jest.Mock).mockResolvedValue(unsafeZip)
+
+ const response = await POST(request())
+
+ expect(response.status).toBe(400)
+ expect(sliceImage).not.toHaveBeenCalled()
+ expect(uploadPublicDisplayToS3).not.toHaveBeenCalled()
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+ })
+
+ test('ZIP 本体超过上限时直接失败,不打开、不处理也不改数据库', async () => {
+ ;(readBoundedRegularFile as jest.Mock).mockRejectedValueOnce(
+ new Error('详情图压缩包超过 64MB 限制'),
+ )
+
+ const response = await POST(request())
+
+ expect(response.status).toBe(400)
+ expect(sliceImage).not.toHaveBeenCalled()
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ })
+
+ test('事务明确失败时只清理本次新对象,不删除旧资产或源 ZIP', async () => {
+ ;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('db rejected'))
+ ;(prisma.material.findUnique as jest.Mock)
+ .mockResolvedValueOnce({ id: 'material-1', previewUrl: 'https://s3.jiansun.vip/old.webp' })
+ .mockResolvedValueOnce({ previewUrl: 'https://s3.jiansun.vip/old.webp' })
+ ;(prisma.materialAsset.findMany as jest.Mock)
+ .mockResolvedValueOnce([{
+ materialId: 'material-1',
+ assetType: 'detail',
+ fileUrl: 'uploads/materials/detail.zip',
+ sortOrder: 0,
+ }])
+ .mockResolvedValueOnce([{
+ fileUrl: 'https://s3.jiansun.vip/old-preview.webp',
+ sortOrder: 0,
+ }])
+
+ const response = await POST(request())
+ const body = await response.json()
+
+ expect(response.status).toBe(500)
+ expect(body.error).toBe('预览重建失败,请稍后重试')
+ expect(body.error).not.toContain('db rejected')
+ expect(deleteFromS3).toHaveBeenCalledTimes(2)
+ expect(deleteFromS3).toHaveBeenCalledWith(uploadedUrls[0])
+ expect(deleteFromS3).toHaveBeenCalledWith(uploadedUrls[1])
+ expect(deleteFromS3).not.toHaveBeenCalledWith('uploads/materials/detail.zip')
+ })
+
+ test('事务响应丢失但数据库已提交时视为成功,绝不删除已引用的新对象', async () => {
+ ;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('commit response lost'))
+ ;(prisma.material.findUnique as jest.Mock)
+ .mockResolvedValueOnce({ id: 'material-1', previewUrl: 'https://s3.jiansun.vip/old.webp' })
+ .mockResolvedValueOnce({ previewUrl: uploadedUrls[0] })
+ ;(prisma.materialAsset.findMany as jest.Mock)
+ .mockResolvedValueOnce([{
+ materialId: 'material-1',
+ assetType: 'detail',
+ fileUrl: 'uploads/materials/detail.zip',
+ sortOrder: 0,
+ }])
+ .mockResolvedValueOnce(uploadedUrls.map((fileUrl, sortOrder) => ({ fileUrl, sortOrder })))
+
+ const response = await POST(request())
+
+ expect(response.status).toBe(200)
+ expect(deleteFromS3).not.toHaveBeenCalled()
})
test('普通登录用户不得重建其他素材的预览', async () => {
@@ -143,12 +270,33 @@ describe('admin regenerate-preview route', () => {
user: { id: 'user-1', role: 'USER' },
})
- const response = await POST({
- json: jest.fn().mockResolvedValue({ materialId: 'material-1' }),
- } as any)
+ const response = await POST(request())
expect(response.status).toBe(403)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(uploadPublicDisplayToS3).not.toHaveBeenCalled()
})
+
+ test.each([
+ ['gzip 请求体', rawRequest('{"materialId":"material-1"}', { 'content-encoding': 'gzip' }), 415],
+ ['额外字段', rawRequest('{"materialId":"material-1","admin":true}'), 400],
+ ['声明长度小于实际值', rawRequest('{"materialId":"material-1"}', { 'content-length': '1' }), 413],
+ ])('%s 在数据库和图像处理前被拒绝', async (_label, unsafeRequest, expectedStatus) => {
+ const response = await POST(unsafeRequest as any)
+ expect(response.status).toBe(expectedStatus)
+ expect(prisma.material.findUnique).not.toHaveBeenCalled()
+ expect(withImageProcessingSlot).not.toHaveBeenCalled()
+ })
+
+ test('缺少 Content-Length 的大流在解析前 fail-closed', async () => {
+ const largeBody = JSON.stringify({ materialId: 'x'.repeat(3 * 1024) })
+ const unsafeRequest = rawRequest(largeBody)
+ unsafeRequest.headers.delete('content-length')
+
+ const response = await POST(unsafeRequest as any)
+
+ expect(response.status).toBe(411)
+ expect(prisma.material.findUnique).not.toHaveBeenCalled()
+ expect(withImageProcessingSlot).not.toHaveBeenCalled()
+ })
})
diff --git a/lib/__tests__/s3-delete.test.ts b/lib/__tests__/s3-delete.test.ts
index 00057b7..2a78d86 100644
--- a/lib/__tests__/s3-delete.test.ts
+++ b/lib/__tests__/s3-delete.test.ts
@@ -1,5 +1,7 @@
/** @jest-environment node */
+export {}
+
const sendMock = jest.fn()
const deleteObjectCommandMock = jest.fn((input) => ({ input }))
diff --git a/lib/__tests__/s3-head.test.ts b/lib/__tests__/s3-head.test.ts
new file mode 100644
index 0000000..fc4920b
--- /dev/null
+++ b/lib/__tests__/s3-head.test.ts
@@ -0,0 +1,90 @@
+/** @jest-environment node */
+
+export {}
+
+const sendMock = jest.fn()
+const headObjectCommandMock = jest.fn((input) => ({ input }))
+
+jest.mock('@aws-sdk/client-s3', () => ({
+ S3Client: jest.fn(() => ({ send: sendMock })),
+ PutObjectCommand: jest.fn((input) => ({ input })),
+ DeleteObjectCommand: jest.fn((input) => ({ input })),
+ GetObjectCommand: jest.fn((input) => ({ input })),
+ HeadObjectCommand: headObjectCommandMock,
+}))
+
+describe('verifyUploadedS3Object', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ process.env.S3_BUCKET = 'gongxiangpan'
+ process.env.S3_PUBLIC_DOMAIN = 'https://s3.jiansun.vip/gongxiangpan'
+ process.env.S3_ENDPOINT = 'https://s3.jiansun.vip'
+ process.env.S3_REGION = 'us-east-1'
+ process.env.S3_ACCESS_KEY_ID = 'test-key'
+ process.env.S3_SECRET_ACCESS_KEY = 'test-secret'
+ process.env.S3_FILE_PATH = '由壹点AI/'
+ })
+
+ test('使用受控 Bucket/Key 做 HEAD,并接受匹配的大小和类型', async () => {
+ sendMock.mockResolvedValue({
+ ContentLength: 7975,
+ ContentType: 'image/png; charset=binary',
+ })
+ jest.resetModules()
+ const { verifyUploadedS3Object } = await import('@/lib/s3')
+
+ await verifyUploadedS3Object(
+ 'https://s3.jiansun.vip/gongxiangpan/%E7%94%B1%E5%A3%B9%E7%82%B9AI/brand/header.png',
+ 7975,
+ 'image/png',
+ )
+
+ expect(headObjectCommandMock).toHaveBeenCalledWith({
+ Bucket: 'gongxiangpan',
+ Key: '由壹点AI/brand/header.png',
+ })
+ expect(sendMock).toHaveBeenCalledTimes(1)
+ })
+
+ test('对象大小不一致时拒绝通过', async () => {
+ sendMock.mockResolvedValue({ ContentLength: 1, ContentType: 'image/png' })
+ jest.resetModules()
+ const { verifyUploadedS3Object } = await import('@/lib/s3')
+
+ await expect(
+ verifyUploadedS3Object(
+ 'https://s3.jiansun.vip/gongxiangpan/%E7%94%B1%E5%A3%B9%E7%82%B9AI/brand/header.png',
+ 7975,
+ 'image/png',
+ ),
+ ).rejects.toThrow('上传对象大小校验失败')
+ })
+
+ test('对象类型不一致时拒绝通过', async () => {
+ sendMock.mockResolvedValue({ ContentLength: 7975, ContentType: 'text/html' })
+ jest.resetModules()
+ const { verifyUploadedS3Object } = await import('@/lib/s3')
+
+ await expect(
+ verifyUploadedS3Object(
+ 'https://s3.jiansun.vip/gongxiangpan/%E7%94%B1%E5%A3%B9%E7%82%B9AI/brand/header.png',
+ 7975,
+ 'image/png',
+ ),
+ ).rejects.toThrow('上传对象类型校验失败')
+ })
+
+ test.each([
+ 'https://attacker.example.com/gongxiangpan/由壹点AI/brand/header.png',
+ 'https://s3.jiansun.vip/gongxiangpan/other-project/header.png',
+ ])('非受控地址或业务前缀不会发出 HEAD: %s', async (url) => {
+ jest.resetModules()
+ const { verifyUploadedS3Object } = await import('@/lib/s3')
+
+ await expect(
+ verifyUploadedS3Object(url, 7975, 'image/png'),
+ ).rejects.toThrow()
+ expect(headObjectCommandMock).not.toHaveBeenCalled()
+ expect(sendMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/lib/__tests__/search-suggestions-special-init-visibility.test.ts b/lib/__tests__/search-suggestions-special-init-visibility.test.ts
new file mode 100644
index 0000000..c07b185
--- /dev/null
+++ b/lib/__tests__/search-suggestions-special-init-visibility.test.ts
@@ -0,0 +1,78 @@
+/** @jest-environment node */
+
+import { NextRequest } from 'next/server'
+import { getServerSession } from 'next-auth'
+
+import { GET } from '@/app/api/search/suggestions/route'
+import { prisma } from '@/lib/prisma'
+import {
+ getUserViewableMaterialTypes,
+ isSuperAdmin,
+ isSystemAdmin,
+} from '@/lib/department-permission'
+import { buildIncompleteSpecialInitShellWhere } from '@/lib/material-query'
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}))
+
+jest.mock('@/lib/auth', () => ({
+ authOptions: {},
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: {
+ findMany: jest.fn(),
+ },
+ materialSku: {
+ findMany: jest.fn(),
+ },
+ user: {
+ findMany: jest.fn(),
+ },
+ },
+}))
+
+jest.mock('@/lib/department-permission', () => ({
+ getUserViewableMaterialTypes: jest.fn(),
+ isSuperAdmin: jest.fn(),
+ isSystemAdmin: jest.fn(),
+}))
+
+describe('search suggestions special init visibility', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ;(getServerSession as jest.Mock).mockResolvedValue({
+ user: {
+ id: 'user-1',
+ role: 'USER',
+ department: '设计部',
+ },
+ })
+ ;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue([
+ '任务-页面',
+ '任务-详情',
+ ])
+ ;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
+ ;(isSystemAdmin as unknown as jest.Mock).mockReturnValue(false)
+ ;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
+ ;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([])
+ ;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
+ })
+
+ test('SPU、作者和场景建议都复用普通列表的空壳隐藏条件', async () => {
+ const response = await GET(
+ new NextRequest('http://localhost/api/search/suggestions?q=SPU'),
+ )
+
+ expect(response.status).toBe(200)
+ expect(prisma.material.findMany).toHaveBeenCalledTimes(3)
+ for (const [query] of (prisma.material.findMany as jest.Mock).mock.calls) {
+ expect(query.where.AND[0]).toEqual(expect.objectContaining({
+ deletedAt: null,
+ NOT: buildIncompleteSpecialInitShellWhere(),
+ }))
+ }
+ })
+})
diff --git a/lib/__tests__/standard-model-white-background-service.test.ts b/lib/__tests__/standard-model-white-background-service.test.ts
new file mode 100644
index 0000000..86a185a
--- /dev/null
+++ b/lib/__tests__/standard-model-white-background-service.test.ts
@@ -0,0 +1,325 @@
+/** @jest-environment node */
+
+import { prisma } from '@/lib/prisma'
+import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
+import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview'
+import { uploadPublicDisplayToS3 } from '@/lib/public-display'
+import { deleteFromS3 } from '@/lib/s3'
+import { assertWhiteBackgroundUploadFiles } from '@/lib/standard-model-white-background-upload-guard'
+import {
+ cleanupStandardModelWhiteBackgroundForSku,
+ uploadStandardModelWhiteBackground,
+} from '@/lib/standard-model-white-background-service'
+
+jest.mock('fs', () => ({
+ promises: {
+ mkdir: jest.fn().mockResolvedValue(undefined),
+ open: jest.fn(),
+ rename: jest.fn().mockResolvedValue(undefined),
+ unlink: jest.fn().mockResolvedValue(undefined),
+ },
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: {
+ findUnique: jest.fn(),
+ },
+ materialSku: {
+ findFirst: jest.fn(),
+ },
+ materialAsset: {
+ findMany: jest.fn(),
+ },
+ $transaction: jest.fn(),
+ },
+}))
+
+jest.mock('@/lib/image-processor', () => ({
+ sanitizeFileName: jest.fn((value: string) => value),
+}))
+
+jest.mock('@/lib/file-cleanup', () => ({
+ deleteFileIfUnreferenced: jest.fn().mockResolvedValue({ success: true }),
+}))
+
+jest.mock('@/lib/material-log', () => ({
+ createMaterialLog: jest.fn().mockResolvedValue(undefined),
+}))
+
+jest.mock('@/lib/white-background-preview', () => ({
+ buildWhiteBackgroundWebpVariants: jest.fn(),
+}))
+
+jest.mock('@/lib/public-display', () => ({
+ ...jest.requireActual('@/lib/public-display'),
+ uploadPublicDisplayToS3: jest.fn(),
+}))
+
+jest.mock('@/lib/s3', () => ({
+ deleteFromS3: jest.fn().mockResolvedValue(undefined),
+}))
+
+jest.mock('@/lib/standard-model-white-background-upload-guard', () => ({
+ ...jest.requireActual('@/lib/standard-model-white-background-upload-guard'),
+ assertWhiteBackgroundUploadFiles: jest.fn().mockResolvedValue(undefined),
+}))
+
+type StoredAsset = {
+ id: string
+ materialId: string
+ assetType: string
+ fileUrl: string
+ fileName: string
+ fileSize: bigint
+ sortOrder: number
+}
+
+function deferred() {
+ let resolve!: () => void
+ const promise = new Promise((accept) => {
+ resolve = accept
+ })
+ return { promise, resolve }
+}
+
+function createFile(name: string, contents: string): File {
+ const bytes = Uint8Array.from(Buffer.from(contents))
+ return {
+ name,
+ size: bytes.byteLength,
+ arrayBuffer: async () => bytes.buffer,
+ } as unknown as File
+}
+
+function createInput(contents: string) {
+ return {
+ materialId: 'material-1',
+ skuId: 'sku-1',
+ skuName: 'SKU001',
+ sourceType: 'AI' as const,
+ operatorUserId: 'user-1',
+ fixedImages: {
+ rightFront45: createFile('right-front.png', contents),
+ },
+ }
+}
+
+describe('standard-model-white-background service concurrency safety', () => {
+ const fsPromises = jest.requireMock('fs').promises as {
+ mkdir: jest.Mock
+ open: jest.Mock
+ rename: jest.Mock
+ unlink: jest.Mock
+ }
+ let storedAssets: StoredAsset[]
+ let nextAssetId: number
+ let consoleErrorSpy: jest.SpyInstance
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ storedAssets = []
+ nextAssetId = 1
+
+ fsPromises.open.mockImplementation(async () => ({
+ writeFile: jest.fn().mockResolvedValue(undefined),
+ sync: jest.fn().mockResolvedValue(undefined),
+ close: jest.fn().mockResolvedValue(undefined),
+ }))
+ fsPromises.mkdir.mockResolvedValue(undefined)
+ fsPromises.rename.mockResolvedValue(undefined)
+ fsPromises.unlink.mockResolvedValue(undefined)
+
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
+ id: 'material-1',
+ materialType: '标准模型',
+ productCategoryName: '鞋柜',
+ categoryName: '',
+ })
+ ;(prisma.materialSku.findFirst as jest.Mock).mockResolvedValue({
+ id: 'sku-1',
+ skuName: 'SKU001',
+ })
+ ;(prisma.materialAsset.findMany as jest.Mock).mockImplementation(async (args: any) => {
+ const requestedFileUrls = args?.where?.fileUrl?.in as string[] | undefined
+ const selected = requestedFileUrls
+ ? storedAssets.filter((asset) => requestedFileUrls.includes(asset.fileUrl))
+ : storedAssets
+ return selected.map((asset) => ({ ...asset }))
+ })
+ ;(buildWhiteBackgroundWebpVariants as jest.Mock).mockImplementation(async (source: Buffer) => ({
+ original: Buffer.from(`original-${source.toString()}`),
+ preview: Buffer.from(`preview-${source.toString()}`),
+ }))
+ ;(uploadPublicDisplayToS3 as jest.Mock).mockImplementation(
+ async (_file: Buffer, relativePath: string) => `https://s3.example/${relativePath}`,
+ )
+ ;(assertWhiteBackgroundUploadFiles as jest.Mock).mockResolvedValue(undefined)
+ ;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({ success: true })
+ ;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore()
+ })
+
+ function createTransactionClient() {
+ return {
+ materialAsset: {
+ deleteMany: jest.fn(async ({ where }: any) => {
+ const assetTypes = (where?.assetType?.in || []) as string[]
+ storedAssets = assetTypes.length > 0
+ ? storedAssets.filter((asset) => !assetTypes.includes(asset.assetType))
+ : []
+ return { count: 0 }
+ }),
+ createMany: jest.fn(async ({ data }: any) => {
+ for (const asset of data as Omit[]) {
+ storedAssets.push({
+ ...asset,
+ id: `asset-${nextAssetId++}`,
+ })
+ }
+ return { count: data.length }
+ }),
+ },
+ materialSku: {
+ update: jest.fn().mockResolvedValue({ id: 'sku-1' }),
+ },
+ material: {
+ updateMany: jest.fn().mockResolvedValue({ count: 1 }),
+ },
+ }
+ }
+
+ test('same material and SKU serialize; a later failed attempt only deletes its unique outputs', async () => {
+ const firstTransactionStarted = deferred()
+ const allowFirstTransaction = deferred()
+ let transactionCount = 0
+
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => {
+ transactionCount += 1
+ if (transactionCount === 1) {
+ firstTransactionStarted.resolve()
+ await allowFirstTransaction.promise
+ return callback(createTransactionClient())
+ }
+ throw new Error('second transaction failed')
+ })
+
+ const firstUpload = uploadStandardModelWhiteBackground(createInput('first'))
+ await firstTransactionStarted.promise
+
+ const secondUpload = uploadStandardModelWhiteBackground(createInput('second'))
+ await Promise.resolve()
+ await Promise.resolve()
+
+ // 第二个请求已经发起,但在同一 materialId+skuId 的独占锁外等待,
+ // 尚不能读取旧资产或开始图片处理。
+ expect(prisma.material.findUnique).toHaveBeenCalledTimes(1)
+ expect(buildWhiteBackgroundWebpVariants).toHaveBeenCalledTimes(1)
+
+ allowFirstTransaction.resolve()
+ const [firstResult, secondResult] = await Promise.allSettled([firstUpload, secondUpload])
+
+ expect(firstResult.status).toBe('fulfilled')
+ expect(secondResult).toMatchObject({
+ status: 'rejected',
+ reason: expect.objectContaining({ message: 'second transaction failed' }),
+ })
+
+ const uploadedObjectPaths = (uploadPublicDisplayToS3 as jest.Mock).mock.calls.map(
+ (call) => call[1] as string,
+ )
+ expect(uploadedObjectPaths).toHaveLength(2)
+ expect(uploadedObjectPaths[0]).not.toBe(uploadedObjectPaths[1])
+ expect(uploadedObjectPaths[0]).toMatch(/--[0-9a-f-]{36}\.webp$/)
+ expect(uploadedObjectPaths[1]).toMatch(/--[0-9a-f-]{36}\.webp$/)
+
+ const successfulUrl = `https://s3.example/${uploadedObjectPaths[0]}`
+ const failedAttemptUrl = `https://s3.example/${uploadedObjectPaths[1]}`
+ expect(deleteFromS3).toHaveBeenCalledTimes(1)
+ expect(deleteFromS3).toHaveBeenCalledWith(failedAttemptUrl)
+ expect(deleteFromS3).not.toHaveBeenCalledWith(successfulUrl)
+
+ expect(storedAssets).toHaveLength(1)
+ const successfulFileName = uploadedObjectPaths[0].split('/').at(-1)!
+ const failedLocalPath = uploadedObjectPaths[1].split('/').at(-1)!
+ expect(storedAssets[0].fileUrl).toBe(
+ `uploads/skus/material-1/white-background/sku-1/${successfulFileName}`,
+ )
+ expect(fsPromises.unlink).toHaveBeenCalledWith(
+ expect.stringContaining(failedLocalPath),
+ )
+ expect(fsPromises.unlink).not.toHaveBeenCalledWith(
+ expect.stringContaining(successfulFileName),
+ )
+ })
+
+ test('a transaction response lost after commit is re-read and does not delete committed outputs', async () => {
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => {
+ await callback(createTransactionClient())
+ throw new Error('connection lost after commit')
+ })
+
+ const result = await uploadStandardModelWhiteBackground(createInput('commit-ambiguous'))
+
+ expect(result.whiteBackgroundUploaded).toBe(true)
+ expect(storedAssets).toHaveLength(1)
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(fsPromises.unlink).not.toHaveBeenCalled()
+ })
+
+ test('cleanup for the same material and SKU waits for an active upload lock', async () => {
+ const uploadTransactionStarted = deferred()
+ const allowUploadTransaction = deferred()
+ let transactionCount = 0
+
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => {
+ transactionCount += 1
+ if (transactionCount === 1) {
+ uploadTransactionStarted.resolve()
+ await allowUploadTransaction.promise
+ }
+ return callback(createTransactionClient())
+ })
+
+ const upload = uploadStandardModelWhiteBackground(createInput('before-cleanup'))
+ await uploadTransactionStarted.promise
+ const cleanup = cleanupStandardModelWhiteBackgroundForSku({
+ materialId: 'material-1',
+ skuId: 'sku-1',
+ operatorUserId: 'user-2',
+ reason: 'test',
+ })
+
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(prisma.materialAsset.findMany).toHaveBeenCalledTimes(1)
+
+ allowUploadTransaction.resolve()
+ const [, cleanupResult] = await Promise.all([upload, cleanup])
+
+ expect(cleanupResult.deletedAssetCount).toBe(1)
+ expect(storedAssets).toEqual([])
+ expect(prisma.$transaction).toHaveBeenCalledTimes(2)
+ })
+
+ test('unknown transaction outcome preserves unique outputs for later reconciliation', async () => {
+ ;(prisma.materialAsset.findMany as jest.Mock).mockImplementation(async (args: any) => {
+ if (args?.where?.fileUrl?.in) {
+ throw new Error('database unavailable during commit check')
+ }
+ return []
+ })
+ ;(prisma.$transaction as jest.Mock).mockRejectedValue(new Error('transaction response lost'))
+
+ await expect(
+ uploadStandardModelWhiteBackground(createInput('unknown-commit')),
+ ).rejects.toThrow('transaction response lost')
+
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(fsPromises.unlink).not.toHaveBeenCalled()
+ })
+})
diff --git a/lib/__tests__/taobao-image-proxy-route.test.ts b/lib/__tests__/taobao-image-proxy-route.test.ts
index 892f6ec..3c3d8aa 100644
--- a/lib/__tests__/taobao-image-proxy-route.test.ts
+++ b/lib/__tests__/taobao-image-proxy-route.test.ts
@@ -2,6 +2,7 @@
import { GET } from '@/app/api/taobao-detail-plus/image-proxy/route'
import { getServerSession } from 'next-auth'
+import sharp from 'sharp'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@@ -11,9 +12,6 @@ jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
-const TINY_PNG_BASE64 =
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9VE3d2kAAAAASUVORK5CYII='
-
describe('taobao image proxy route', () => {
beforeEach(() => {
jest.clearAllMocks()
@@ -56,15 +54,23 @@ describe('taobao image proxy route', () => {
})
test('允许的图片地址应返回 png 数据', async () => {
- const upstreamBuffer = Buffer.from(TINY_PNG_BASE64, 'base64')
- ;(global.fetch as jest.Mock).mockResolvedValue({
- ok: true,
- arrayBuffer: async () =>
- upstreamBuffer.buffer.slice(
- upstreamBuffer.byteOffset,
- upstreamBuffer.byteOffset + upstreamBuffer.byteLength,
- ),
- })
+ const upstreamBuffer = await sharp({
+ create: {
+ width: 1,
+ height: 1,
+ channels: 4,
+ background: { r: 255, g: 255, b: 255, alpha: 1 },
+ },
+ }).png().toBuffer()
+ ;(global.fetch as jest.Mock).mockResolvedValue(
+ new Response(Uint8Array.from(upstreamBuffer), {
+ status: 200,
+ headers: {
+ 'content-length': String(upstreamBuffer.length),
+ 'content-type': 'image/png',
+ },
+ }),
+ )
const response = await GET(
createRequest(
diff --git a/lib/__tests__/task-detail-upload-jobs.test.ts b/lib/__tests__/task-detail-upload-jobs.test.ts
index b84c0cb..6d10685 100644
--- a/lib/__tests__/task-detail-upload-jobs.test.ts
+++ b/lib/__tests__/task-detail-upload-jobs.test.ts
@@ -11,10 +11,18 @@ import {
triggerTaskDetailUploadJob,
} from '@/lib/task-detail-upload-jobs'
import { completeProductPackageUpload } from '@/lib/product-package-completion'
+import {
+ acquireTaskDetailUploadExecutionLock,
+ ensureTaskDetailUploadDrainCapability,
+ isTaskDetailUploadDraining,
+ removeTaskDetailUploadActiveMarker,
+ writeTaskDetailUploadActiveMarker,
+} from '@/lib/task-detail-upload-runtime'
jest.mock('@/lib/prisma', () => ({
prisma: {
$transaction: jest.fn(),
+ $queryRawUnsafe: jest.fn(),
productPackageUploadJob: {
findFirst: jest.fn(),
findUnique: jest.fn(),
@@ -33,12 +41,24 @@ jest.mock('@/lib/prisma', () => ({
},
}))
+jest.mock('@/lib/task-detail-upload-runtime', () => ({
+ acquireTaskDetailUploadExecutionLock: jest.fn(),
+ ensureTaskDetailUploadDrainCapability: jest.fn().mockResolvedValue(undefined),
+ getTaskDetailUploadWorkerIdentity: jest.fn(() => ({ workerId: 'worker-test', instanceId: 'instance-test' })),
+ isTaskDetailUploadDraining: jest.fn().mockResolvedValue(false),
+ removeTaskDetailUploadActiveMarker: jest.fn().mockResolvedValue(undefined),
+ writeTaskDetailUploadActiveMarker: jest.fn().mockResolvedValue(undefined),
+}))
+
jest.mock('@/lib/product-package-completion', () => ({
assertTaskDetailUploadAssetLimits: jest.fn(),
completeProductPackageUpload: jest.fn(),
}))
const now = new Date('2026-08-01T00:00:00.000Z')
+const releaseExecutionLock = Object.assign(jest.fn(async () => undefined), {
+ assertHeld: jest.fn(),
+})
function job(overrides: Record = {}) {
return {
@@ -64,14 +84,18 @@ function job(overrides: Record = {}) {
function transactionClient(options: { activeCount?: number; claimedCount?: number } = {}) {
return {
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
+ $queryRawUnsafe: jest.fn().mockResolvedValue([{ now }]),
productPackageUploadJob: {
- count: jest.fn().mockResolvedValue(options.activeCount ?? 0),
+ count: jest.fn()
+ .mockResolvedValueOnce(options.activeCount ?? 0)
+ .mockResolvedValue(1),
updateMany: jest.fn().mockResolvedValue({ count: options.claimedCount ?? 1 }),
findFirst: jest.fn(),
create: jest.fn(),
},
material: {
update: jest.fn(),
+ updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
} as any
}
@@ -83,17 +107,26 @@ async function flushMicrotasks(rounds = 8) {
describe('task-detail upload queue', () => {
beforeEach(() => {
jest.clearAllMocks()
+ releaseExecutionLock.mockReset().mockResolvedValue(undefined)
+ releaseExecutionLock.assertHeld.mockReset()
__taskDetailUploadJobTesting.resetRuntimeState()
;(prisma.productPackageUploadJob.findMany as jest.Mock).mockResolvedValue([])
;(prisma.productPackageUploadJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.material.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0)
;(completeProductPackageUpload as jest.Mock).mockResolvedValue(undefined)
+ ;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([{ now }])
+ ;(ensureTaskDetailUploadDrainCapability as jest.Mock).mockResolvedValue(undefined)
+ ;(acquireTaskDetailUploadExecutionLock as jest.Mock).mockResolvedValue(releaseExecutionLock)
+ ;(isTaskDetailUploadDraining as jest.Mock).mockResolvedValue(false)
+ ;(writeTaskDetailUploadActiveMarker as jest.Mock).mockResolvedValue(undefined)
+ ;(removeTaskDetailUploadActiveMarker as jest.Mock).mockResolvedValue(undefined)
})
afterEach(() => {
__taskDetailUploadJobTesting.resetRuntimeState()
jest.useRealTimers()
+ jest.restoreAllMocks()
})
test('advisory lock 只返回 Prisma 可反序列化的整数列', async () => {
@@ -139,7 +172,7 @@ describe('task-detail upload queue', () => {
])('等待队列 %s 容量满时拒绝创建', async (scope, counts, errorType, statusCode) => {
const tx = transactionClient()
tx.productPackageUploadJob.findFirst.mockResolvedValue(null)
- tx.productPackageUploadJob.count.mockImplementation(async () => counts.shift() ?? 0)
+ tx.productPackageUploadJob.count.mockReset().mockImplementation(async () => counts.shift() ?? 0)
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
await expect(createTaskDetailUploadJob({
@@ -161,8 +194,10 @@ describe('task-detail upload queue', () => {
.mockImplementationOnce(async (callback) => callback(firstTx))
.mockImplementationOnce(async (callback) => callback(secondTx))
- await expect(__taskDetailUploadJobTesting.claimTaskDetailUploadJob('job-1')).resolves.toBe(true)
- await expect(__taskDetailUploadJobTesting.claimTaskDetailUploadJob('job-2')).resolves.toBe(false)
+ await expect(__taskDetailUploadJobTesting.claimTaskDetailUploadJob('job-1')).resolves.toEqual(
+ expect.objectContaining({ status: 'acquired', token: expect.any(String) }),
+ )
+ await expect(__taskDetailUploadJobTesting.claimTaskDetailUploadJob('job-2')).resolves.toEqual({ status: 'busy' })
expect(firstTx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'job-1', status: 'queued' },
@@ -211,19 +246,123 @@ describe('task-detail upload queue', () => {
await flushMicrotasks()
await jest.advanceTimersByTimeAsync(30_000)
- expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
- where: { id: 'job-1', status: 'processing' },
- data: { updatedAt: expect.any(Date) },
+ expect(tx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
+ where: expect.objectContaining({
+ id: 'job-1',
+ status: 'processing',
+ leaseToken: expect.any(String),
+ }),
+ data: expect.objectContaining({ leaseExpiresAt: expect.any(Date) }),
}))
finish()
await expect(processing).resolves.toBe('processed')
- expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
+ expect(tx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'completed', progress: 100 }),
}))
+ expect(acquireTaskDetailUploadExecutionLock).toHaveBeenCalledTimes(1)
+ expect(releaseExecutionLock.assertHeld).toHaveBeenCalled()
+ expect(releaseExecutionLock).toHaveBeenCalledTimes(1)
+ })
+
+ test('active marker 落盘后才出现 drain 时释放租约并保持 queued,不开始重处理', async () => {
+ const tx = transactionClient({ activeCount: 0, claimedCount: 1 })
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
+ ;(prisma.productPackageUploadJob.findUnique as jest.Mock)
+ .mockResolvedValueOnce({
+ id: 'job-1',
+ status: 'queued',
+ materialId: 'material-1',
+ material: { taskStatus: 'uploading', zipFilePath: '' },
+ })
+ .mockResolvedValueOnce(job())
+ ;(isTaskDetailUploadDraining as jest.Mock)
+ .mockResolvedValueOnce(false)
+ .mockResolvedValueOnce(true)
+
+ await expect(
+ __taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1'),
+ ).resolves.toBe('busy')
+
+ expect(writeTaskDetailUploadActiveMarker).toHaveBeenCalledTimes(1)
+ expect(completeProductPackageUpload).not.toHaveBeenCalled()
+ expect(tx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
+ where: expect.objectContaining({ id: 'job-1', leaseToken: expect.any(String) }),
+ data: expect.objectContaining({
+ status: 'queued',
+ stage: 'queued',
+ leaseToken: null,
+ leaseExpiresAt: null,
+ }),
+ }))
+ expect(removeTaskDetailUploadActiveMarker).toHaveBeenCalledTimes(1)
+ expect(releaseExecutionLock).toHaveBeenCalledTimes(1)
+ })
+
+ test('heartbeat 续租失败后中止作业,不写 completed/failed 也不改素材失败状态', async () => {
+ jest.useFakeTimers()
+ const tx = transactionClient({ activeCount: 0, claimedCount: 1 })
+ tx.productPackageUploadJob.updateMany
+ .mockResolvedValueOnce({ count: 1 }) // claim
+ .mockResolvedValueOnce({ count: 0 }) // heartbeat lease renew lost
+ .mockResolvedValue({ count: 0 })
+ jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
+ ;(prisma.productPackageUploadJob.findUnique as jest.Mock)
+ .mockResolvedValueOnce({
+ id: 'job-1',
+ status: 'queued',
+ materialId: 'material-1',
+ material: { taskStatus: 'uploading', zipFilePath: '' },
+ })
+ .mockResolvedValueOnce(job())
+ let finish!: () => void
+ ;(completeProductPackageUpload as jest.Mock).mockImplementation(() => new Promise((resolve) => {
+ finish = resolve
+ }))
+
+ const processing = __taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1')
+ await flushMicrotasks()
+ expect(releaseExecutionLock).not.toHaveBeenCalled()
+ await jest.advanceTimersByTimeAsync(30_000)
+ finish()
+ await expect(processing).resolves.toBe('processed')
+
+ const statusWrites = tx.productPackageUploadJob.updateMany.mock.calls
+ .map(([input]: [any]) => input?.data?.status)
+ .filter((status: unknown) => status === 'completed' || status === 'failed')
+ expect(statusWrites).toEqual([])
+ expect(prisma.material.updateMany).not.toHaveBeenCalled()
+ expect(tx.material.updateMany).not.toHaveBeenCalled()
+ const processorOptions = (completeProductPackageUpload as jest.Mock).mock.calls[0][0].options
+ expect(processorOptions.signal.aborted).toBe(true)
+ expect(releaseExecutionLock).toHaveBeenCalledTimes(1)
+ })
+
+ test('共享 uploads 执行锁不可用时只背压重试,不领租约也不运行处理器', async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ ;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValueOnce({
+ id: 'job-1',
+ status: 'queued',
+ materialId: 'material-1',
+ material: { taskStatus: 'uploading', zipFilePath: '' },
+ })
+ ;(acquireTaskDetailUploadExecutionLock as jest.Mock).mockRejectedValueOnce(
+ new Error('flock unavailable'),
+ )
+
+ await expect(
+ __taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1'),
+ ).resolves.toBe('busy')
+
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+ expect(completeProductPackageUpload).not.toHaveBeenCalled()
+ expect(writeTaskDetailUploadActiveMarker).not.toHaveBeenCalled()
})
test('处理失败会写 failed 并同步素材状态', async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => undefined)
const tx = transactionClient({ activeCount: 0, claimedCount: 1 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.productPackageUploadJob.findUnique as jest.Mock)
@@ -240,22 +379,22 @@ describe('task-detail upload queue', () => {
__taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1'),
).resolves.toBe('processed')
- expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
+ expect(tx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: 'failed',
stage: 'failed',
errorMessage: '恶意预览被拒绝',
}),
}))
- expect(prisma.material.updateMany).toHaveBeenCalledWith({
+ expect(tx.material.updateMany).toHaveBeenCalledWith({
where: { id: 'material-1', taskStatus: { not: 'completed' } },
data: { taskStatus: 'failed' },
})
})
- test('stale processing 标记失败,queued 任务恢复入内存队列', async () => {
+ test('leaseToken 有值但 leaseExpiresAt 为 null 时在 DB clock + advisory lock 内 CAS 回收', async () => {
;(prisma.productPackageUploadJob.findMany as jest.Mock)
- .mockResolvedValueOnce([{ id: 'stale-1', materialId: 'material-stale' }])
+ .mockResolvedValueOnce([{ id: 'stale-1', materialId: 'material-stale', leaseToken: 'expired-token' }])
.mockResolvedValueOnce([])
;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValue({
id: 'stale-1',
@@ -264,15 +403,28 @@ describe('task-detail upload queue', () => {
material: { taskStatus: 'uploading', zipFilePath: '' },
})
+ const recoveryTx = transactionClient()
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(recoveryTx))
+
await expect(recoverTaskDetailUploadJobs()).resolves.toBe(0)
- expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
- where: { id: 'stale-1', status: 'processing' },
- data: expect.objectContaining({ status: 'failed', stage: 'failed' }),
+ expect(recoveryTx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
+ where: expect.objectContaining({
+ id: 'stale-1',
+ status: 'processing',
+ leaseToken: 'expired-token',
+ OR: [
+ { leaseExpiresAt: { lte: now } },
+ { leaseExpiresAt: null },
+ ],
+ }),
+ data: expect.objectContaining({ status: 'queued', stage: 'queued', leaseToken: null }),
}))
- expect(prisma.material.updateMany).toHaveBeenCalledWith(expect.objectContaining({
+ expect(recoveryTx.$queryRaw).toHaveBeenCalled()
+ expect(recoveryTx.$queryRawUnsafe).toHaveBeenCalledWith('SELECT clock_timestamp() AS "now"')
+ expect(recoveryTx.material.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'material-stale', taskStatus: { not: 'completed' } },
- data: { taskStatus: 'failed' },
+ data: { taskStatus: 'uploading' },
}))
})
diff --git a/lib/__tests__/task-detail-upload-runtime.test.ts b/lib/__tests__/task-detail-upload-runtime.test.ts
new file mode 100644
index 0000000..fc4b2d8
--- /dev/null
+++ b/lib/__tests__/task-detail-upload-runtime.test.ts
@@ -0,0 +1,123 @@
+/** @jest-environment node */
+
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import path from 'path'
+
+import {
+ __taskDetailUploadRuntimeTesting,
+ acquireTaskDetailUploadExecutionLock,
+ ensureTaskDetailUploadDrainCapability,
+ isTaskDetailUploadDraining,
+ removeTaskDetailUploadActiveMarker,
+ TASK_DETAIL_UPLOAD_DRAIN_PROTOCOL,
+ TaskDetailUploadExecutionLockLostError,
+ TaskDetailUploadExecutionLockUnavailableError,
+ writeTaskDetailUploadActiveMarker,
+} from '@/lib/task-detail-upload-runtime'
+
+const ORIGINAL_ENV = {
+ UPLOAD_DIR: process.env.UPLOAD_DIR,
+ TASK_DETAIL_UPLOAD_RUNTIME_DIR: process.env.TASK_DETAIL_UPLOAD_RUNTIME_DIR,
+ TASK_DETAIL_UPLOAD_WORKER_ID: process.env.TASK_DETAIL_UPLOAD_WORKER_ID,
+ TASK_DETAIL_UPLOAD_INSTANCE_ID: process.env.TASK_DETAIL_UPLOAD_INSTANCE_ID,
+}
+
+let tempRoot: string | null = null
+
+describe('task-detail upload runtime', () => {
+ beforeEach(async () => {
+ tempRoot = await mkdtemp(path.join(tmpdir(), 'task-detail-runtime-'))
+ process.env.UPLOAD_DIR = path.join(tempRoot, 'uploads')
+ process.env.TASK_DETAIL_UPLOAD_RUNTIME_DIR = path.join(tempRoot, 'runtime')
+ process.env.TASK_DETAIL_UPLOAD_WORKER_ID = 'worker-test'
+ process.env.TASK_DETAIL_UPLOAD_INSTANCE_ID = 'instance-test'
+ })
+
+ afterEach(async () => {
+ if (tempRoot) await rm(tempRoot, { recursive: true, force: true })
+ tempRoot = null
+ restoreEnv('UPLOAD_DIR', ORIGINAL_ENV.UPLOAD_DIR)
+ restoreEnv('TASK_DETAIL_UPLOAD_RUNTIME_DIR', ORIGINAL_ENV.TASK_DETAIL_UPLOAD_RUNTIME_DIR)
+ restoreEnv('TASK_DETAIL_UPLOAD_WORKER_ID', ORIGINAL_ENV.TASK_DETAIL_UPLOAD_WORKER_ID)
+ restoreEnv('TASK_DETAIL_UPLOAD_INSTANCE_ID', ORIGINAL_ENV.TASK_DETAIL_UPLOAD_INSTANCE_ID)
+ jest.useRealTimers()
+ jest.restoreAllMocks()
+ })
+
+ test('capability/active/drain 使用共享运行时路径,execution lock 固定在 uploads', async () => {
+ await ensureTaskDetailUploadDrainCapability()
+ const capability = await readFile(__taskDetailUploadRuntimeTesting.capabilityPath(), 'utf8')
+ expect(capability).toContain(TASK_DETAIL_UPLOAD_DRAIN_PROTOCOL)
+ expect(capability).toContain('workerId=worker-test')
+ expect(await isTaskDetailUploadDraining()).toBe(false)
+
+ await writeTaskDetailUploadActiveMarker('job-1', 'lease-1')
+ const active = JSON.parse(await readFile(__taskDetailUploadRuntimeTesting.activePath(), 'utf8'))
+ expect(active).toEqual(expect.objectContaining({
+ protocol: TASK_DETAIL_UPLOAD_DRAIN_PROTOCOL,
+ workerId: 'worker-test',
+ instanceId: 'instance-test',
+ jobId: 'job-1',
+ leaseToken: 'lease-1',
+ }))
+
+ await mkdir(path.dirname(__taskDetailUploadRuntimeTesting.drainPath()), { recursive: true })
+ await writeFile(__taskDetailUploadRuntimeTesting.drainPath(), 'drain\n', 'utf8')
+ expect(await isTaskDetailUploadDraining()).toBe(true)
+ expect(__taskDetailUploadRuntimeTesting.executionLockPath()).toBe(
+ path.join(process.env.UPLOAD_DIR!, '.locks', 'task-detail-upload-execution.lock'),
+ )
+
+ await removeTaskDetailUploadActiveMarker()
+ await expect(readFile(__taskDetailUploadRuntimeTesting.activePath(), 'utf8')).rejects.toMatchObject({
+ code: 'ENOENT',
+ })
+ })
+
+ test('drain 路径发生非 ENOENT I/O 错误时必须 fail-closed', async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ const invalidRuntimeRoot = path.join(tempRoot!, 'runtime-as-file')
+ await writeFile(invalidRuntimeRoot, 'not a directory', 'utf8')
+ process.env.TASK_DETAIL_UPLOAD_RUNTIME_DIR = invalidRuntimeRoot
+
+ await expect(isTaskDetailUploadDraining()).resolves.toBe(true)
+ })
+
+ test('非生产执行锁也严格串行,释放后下一个才能进入', async () => {
+ const first = await acquireTaskDetailUploadExecutionLock({ timeoutMs: 1_000 })
+ let secondResolved = false
+ const secondPromise = acquireTaskDetailUploadExecutionLock({ timeoutMs: 1_000 }).then((release) => {
+ secondResolved = true
+ return release
+ })
+
+ await Promise.resolve()
+ expect(secondResolved).toBe(false)
+ first.assertHeld()
+ await first()
+ expect(() => first.assertHeld()).toThrow(TaskDetailUploadExecutionLockLostError)
+
+ const second = await secondPromise
+ expect(secondResolved).toBe(true)
+ second.assertHeld()
+ await second()
+ })
+
+ test('执行锁等待超时时拒绝降级并发', async () => {
+ jest.useFakeTimers()
+ const first = await acquireTaskDetailUploadExecutionLock({ timeoutMs: 1_000 })
+ const waiting = acquireTaskDetailUploadExecutionLock({ timeoutMs: 1_000 })
+ const assertion = expect(waiting).rejects.toBeInstanceOf(
+ TaskDetailUploadExecutionLockUnavailableError,
+ )
+ await jest.advanceTimersByTimeAsync(1_000)
+ await assertion
+ await first()
+ })
+})
+
+function restoreEnv(name: string, value: string | undefined) {
+ if (value === undefined) delete process.env[name]
+ else process.env[name] = value
+}
diff --git a/lib/__tests__/task-image-complete-route-safety.test.ts b/lib/__tests__/task-image-complete-route-safety.test.ts
new file mode 100644
index 0000000..2123ca3
--- /dev/null
+++ b/lib/__tests__/task-image-complete-route-safety.test.ts
@@ -0,0 +1,66 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+const routeCases = [
+ ['3d-original', '任务-3D原图', '3d-original'],
+ ['ai-original', '任务-AI原图', 'ai-original'],
+ ['real-photo', '任务-实拍原图', 'real-photo'],
+ ['video-screenshot', '任务-视频截图', 'video-screenshot'],
+ ['task-page', '任务-页面', 'task-page'],
+] as const;
+
+function read(relativePath: string): string {
+ return fs.readFileSync(path.resolve(process.cwd(), relativePath), 'utf8').replace(/\s+/g, ' ');
+}
+
+describe.each(routeCases)('%s upload route safety', (routeName, materialType, fallbackProductModel) => {
+ test('init persists uploading and complete delegates to the shared fail-closed state machine', () => {
+ const initSource = read(`app/api/materials/upload/${routeName}/init/route.ts`);
+ const completeSource = read(`app/api/materials/upload/${routeName}/complete/route.ts`);
+
+ expect(initSource).toContain(`materialType: '${materialType}'`);
+ expect(initSource).toContain("taskStatus: 'uploading'");
+ expect(completeSource).toContain('completeTaskImageMaterial({');
+ expect(completeSource).toContain(`materialType: '${materialType}'`);
+ expect(completeSource).toContain(`fallbackProductModel: '${fallbackProductModel}'`);
+ expect(completeSource).not.toContain("taskStatus: 'processing'");
+ expect(completeSource).not.toContain('cleanupOnError(');
+ });
+});
+
+describe('shared task image completion safety', () => {
+ const completionSource = read('lib/task-image-material-completion.ts');
+ const processorSource = read('lib/task-material-processor.ts');
+ const finalizerSource = read('lib/product-package.ts');
+
+ test('holds a per-material cross-process lock while database stays retryable', () => {
+ expect(completionSource).toContain('acquireTaskMaterialCompletionLock(materialId)');
+ expect(completionSource).toContain('releaseCompletionLock.assertHeld()');
+ expect(completionSource).not.toContain("data: { taskStatus: 'processing' }");
+ expect(completionSource).toContain("material.taskStatus !== 'uploading'");
+ });
+
+ test('uses real lstat limits and a long low-compression ZIP policy', () => {
+ expect(processorSource).toContain('resolveOwnedTaskMaterialSourceFiles');
+ expect(processorSource).toContain('fileStats.isSymbolicLink()');
+ expect(processorSource).toContain('TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes');
+ expect(processorSource).toContain('TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes');
+ expect(completionSource).toContain('TASK_MATERIAL_ZIP_TIMEOUT_MS');
+ expect(completionSource).toContain('TASK_MATERIAL_ZIP_COMPRESSION_LEVEL');
+ });
+
+ test('atomically writes assets, task fields, and completed status', () => {
+ expect(completionSource).toContain('saveTaskMaterialAssetsAndComplete({');
+ expect(processorSource).toContain('finalizeTaskMaterialOnComplete({');
+ expect(finalizerSource).toContain('await applyCompletion?.(tx)');
+ expect(finalizerSource).toContain('taskStatus: TASK_STATUS.COMPLETED');
+ });
+
+ test('failure cleanup only removes this attempt derived files and preserves sources', () => {
+ expect(completionSource).toMatch(
+ /cleanupOnError\( uploadedPreviewUrls, zipFilePath, \[\], null, \[\], false,/,
+ );
+ expect(completionSource).toContain('服务器已保留上传文件');
+ expect(processorSource).toContain('onPreviewUploaded');
+ });
+});
diff --git a/lib/__tests__/task-image-material-completion.test.ts b/lib/__tests__/task-image-material-completion.test.ts
new file mode 100644
index 0000000..e31d010
--- /dev/null
+++ b/lib/__tests__/task-image-material-completion.test.ts
@@ -0,0 +1,241 @@
+/** @jest-environment node */
+
+import { completeTaskImageMaterial } from '@/lib/task-image-material-completion';
+import { prisma } from '@/lib/prisma';
+import { createMaterialLog } from '@/lib/material-log';
+import {
+ acquireTaskMaterialCompletionLock,
+ acquireTaskMaterialUploadSessionLocks,
+} from '@/lib/material-upload-cross-process-lock';
+import { inspectTaskMaterialCompletionAfterError } from '@/lib/task-material-completion-safety';
+import {
+ cleanupOnError,
+ cleanupTempFiles,
+ createZipFromFiles,
+ generatePreviewImages,
+ resolveOwnedTaskMaterialSourceFiles,
+ saveTaskMaterialAssetsAndComplete,
+} from '@/lib/task-material-processor';
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: { material: { findUnique: jest.fn() } },
+}));
+jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() }));
+jest.mock('@/lib/material-upload-cross-process-lock', () => ({
+ acquireTaskMaterialCompletionLock: jest.fn(),
+ acquireTaskMaterialUploadSessionLocks: jest.fn(),
+}));
+jest.mock('@/lib/task-material-completion-safety', () => ({
+ inspectTaskMaterialCompletionAfterError: jest.fn(),
+}));
+jest.mock('@/lib/task-material-processor', () => {
+ class TaskMaterialSourceValidationError extends Error {
+ constructor(message: string, readonly status: number = 400) {
+ super(message);
+ }
+ }
+ return {
+ cleanupOnError: jest.fn(),
+ cleanupTempFiles: jest.fn(),
+ createZipFromFiles: jest.fn(),
+ generatePreviewImages: jest.fn(),
+ resolveOwnedTaskMaterialSourceFiles: jest.fn(),
+ saveTaskMaterialAssetsAndComplete: jest.fn(),
+ TaskMaterialSourceValidationError,
+ TASK_MATERIAL_ZIP_TIMEOUT_MS: 1_800_000,
+ TASK_MATERIAL_ZIP_COMPRESSION_LEVEL: 0,
+ };
+});
+
+const releaseLock = Object.assign(jest.fn(async () => undefined), {
+ assertHeld: jest.fn(),
+});
+const releaseUploadLocks = Object.assign(jest.fn(async () => undefined), {
+ assertHeld: jest.fn(),
+});
+
+const baseMaterial = {
+ id: 'material-1',
+ userId: 'user-1',
+ materialType: '任务-页面',
+ productModel: 'SJ0371',
+ taskStatus: 'uploading',
+ previewUrl: '',
+ zipFilePath: '',
+ deletedAt: null,
+ assets: [],
+};
+
+describe('completeTaskImageMaterial', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ releaseLock.mockClear();
+ releaseLock.assertHeld.mockClear();
+ releaseUploadLocks.mockClear();
+ releaseUploadLocks.assertHeld.mockClear();
+ (acquireTaskMaterialCompletionLock as jest.Mock).mockResolvedValue(releaseLock);
+ (acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadLocks);
+ (prisma.material.findUnique as jest.Mock)
+ .mockResolvedValueOnce(baseMaterial)
+ .mockResolvedValueOnce(baseMaterial);
+ (resolveOwnedTaskMaterialSourceFiles as jest.Mock).mockResolvedValue({
+ files: [{ path: 'assembled/source.jpg', name: 'source.jpg', size: 10 }],
+ uploadIds: ['upload-1'],
+ tempFiles: ['assembled/source.jpg'],
+ });
+ (createZipFromFiles as jest.Mock).mockResolvedValue({
+ filePath: 'materials/source.zip',
+ fileName: 'source.zip',
+ fileSize: 20,
+ });
+ (generatePreviewImages as jest.Mock).mockImplementation(
+ async (_items, _type, _options, onUploaded) => {
+ onUploaded?.('https://s3.example/preview.webp');
+ return ['https://s3.example/preview.webp'];
+ },
+ );
+ (saveTaskMaterialAssetsAndComplete as jest.Mock).mockResolvedValue(undefined);
+ (cleanupTempFiles as jest.Mock).mockResolvedValue(undefined);
+ (createMaterialLog as jest.Mock).mockResolvedValue(undefined);
+ (cleanupOnError as jest.Mock).mockResolvedValue(undefined);
+ (inspectTaskMaterialCompletionAfterError as jest.Mock).mockResolvedValue({
+ state: 'other',
+ taskStatus: 'uploading',
+ });
+ });
+
+ test('holds the lock through derived files and atomic database completion', async () => {
+ const result = await runCompletion();
+
+ expect(result).toEqual({ materialId: 'material-1' });
+ expect(acquireTaskMaterialCompletionLock).toHaveBeenCalledWith('material-1');
+ expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-1']);
+ expect(resolveOwnedTaskMaterialSourceFiles).toHaveBeenCalledWith({
+ assets: [{ uploadId: 'upload-1', fileName: 'source.jpg', fileSize: 1 }],
+ userId: 'user-1',
+ });
+ expect(createZipFromFiles).toHaveBeenCalledWith(
+ expect.any(Array),
+ 'material-1',
+ 'SJ0371',
+ '任务-页面',
+ 'all',
+ undefined,
+ undefined,
+ 1_800_000,
+ 0,
+ );
+ expect(releaseLock.assertHeld).toHaveBeenCalled();
+ expect(saveTaskMaterialAssetsAndComplete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ materialId: 'material-1',
+ previewUrls: ['https://s3.example/preview.webp'],
+ }),
+ );
+ expect(cleanupTempFiles).toHaveBeenCalledWith(
+ ['assembled/source.jpg'],
+ ['upload-1'],
+ );
+ expect(releaseLock).toHaveBeenCalled();
+ expect(releaseUploadLocks).toHaveBeenCalled();
+ });
+
+ test('a complete record is idempotent and retries controlled source cleanup under locks', async () => {
+ (prisma.material.findUnique as jest.Mock)
+ .mockReset()
+ .mockResolvedValueOnce({ ...baseMaterial, taskStatus: 'completed' })
+ .mockResolvedValueOnce({
+ ...baseMaterial,
+ taskStatus: 'completed',
+ previewUrl: 'https://s3.example/preview.webp',
+ zipFilePath: 'materials/source.zip',
+ assets: [{ assetType: 'preview' }, { assetType: 'zip' }],
+ });
+ const result = await runCompletion();
+
+ expect(result).toEqual({ materialId: 'material-1', alreadyCompleted: true });
+ expect(acquireTaskMaterialCompletionLock).toHaveBeenCalledWith('material-1');
+ expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-1']);
+ expect(cleanupTempFiles).toHaveBeenCalledWith([], ['upload-1']);
+ expect(createZipFromFiles).not.toHaveBeenCalled();
+ });
+
+ test('completed without preview, ZIP, or asset evidence is rejected without cleanup', async () => {
+ (prisma.material.findUnique as jest.Mock)
+ .mockReset()
+ .mockResolvedValueOnce({ ...baseMaterial, taskStatus: 'completed' })
+ .mockResolvedValueOnce({
+ ...baseMaterial,
+ taskStatus: 'completed',
+ previewUrl: '',
+ zipFilePath: '',
+ assets: [],
+ });
+
+ await expect(runCompletion()).rejects.toMatchObject({ status: 409 });
+ expect(cleanupTempFiles).not.toHaveBeenCalled();
+ expect(createZipFromFiles).not.toHaveBeenCalled();
+ });
+
+ test('partial preview uploads are tracked and removed without deleting source sessions', async () => {
+ (generatePreviewImages as jest.Mock).mockImplementation(
+ async (_items, _type, _options, onUploaded) => {
+ onUploaded?.('https://s3.example/partial.webp');
+ throw new Error('第二张压缩失败');
+ },
+ );
+
+ await expect(runCompletion()).rejects.toThrow('第二张压缩失败');
+ expect(cleanupOnError).toHaveBeenCalledWith(
+ ['https://s3.example/partial.webp'],
+ 'materials/source.zip',
+ [],
+ null,
+ [],
+ false,
+ );
+ expect(cleanupTempFiles).not.toHaveBeenCalled();
+ expect(releaseLock).toHaveBeenCalled();
+ });
+
+ test('a committed-but-unacknowledged transaction returns success and preserves files', async () => {
+ (saveTaskMaterialAssetsAndComplete as jest.Mock).mockRejectedValue(new Error('connection lost'));
+ (inspectTaskMaterialCompletionAfterError as jest.Mock).mockResolvedValue({
+ state: 'completed',
+ taskStatus: 'completed',
+ });
+
+ const result = await runCompletion();
+ expect(result).toEqual({
+ materialId: 'material-1',
+ warning: '素材已保存,但响应确认阶段出现异常',
+ });
+ expect(cleanupOnError).not.toHaveBeenCalled();
+ expect(cleanupTempFiles).toHaveBeenCalledWith(
+ ['assembled/source.jpg'],
+ ['upload-1'],
+ );
+ });
+
+ test('an unknown database outcome fails closed and preserves every file', async () => {
+ (createZipFromFiles as jest.Mock).mockRejectedValue(new Error('disk error'));
+ (inspectTaskMaterialCompletionAfterError as jest.Mock).mockResolvedValue({
+ state: 'unknown',
+ error: new Error('database unavailable'),
+ });
+
+ await expect(runCompletion()).rejects.toThrow('服务器已保留上传文件');
+ expect(cleanupOnError).not.toHaveBeenCalled();
+ expect(cleanupTempFiles).not.toHaveBeenCalled();
+ });
+});
+
+function runCompletion() {
+ return completeTaskImageMaterial({
+ materialId: 'material-1',
+ userId: 'user-1',
+ materialType: '任务-页面',
+ fallbackProductModel: 'task-page',
+ assets: [{ uploadId: 'upload-1', fileName: 'source.jpg', fileSize: 1 }],
+ });
+}
diff --git a/lib/__tests__/task-material-completion-safety.test.ts b/lib/__tests__/task-material-completion-safety.test.ts
new file mode 100644
index 0000000..c8aa2ab
--- /dev/null
+++ b/lib/__tests__/task-material-completion-safety.test.ts
@@ -0,0 +1,73 @@
+/** @jest-environment node */
+
+import { prisma } from '@/lib/prisma'
+import { inspectTaskMaterialCompletionAfterError } from '@/lib/task-material-completion-safety'
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: { findUnique: jest.fn() },
+ },
+}))
+
+describe('task material completion acknowledgement safety', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test.each([
+ ['processing', 'processing'],
+ ['failed', 'other'],
+ [null, 'other'],
+ ] as const)('数据库状态 %s 映射为 %s', async (taskStatus, expectedState) => {
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
+ taskStatus,
+ previewUrl: '',
+ zipFilePath: '',
+ assets: [],
+ })
+
+ await expect(inspectTaskMaterialCompletionAfterError('material-1')).resolves.toMatchObject({
+ state: expectedState,
+ })
+ })
+
+ test('只有预览、ZIP 与两类资产证据齐全才映射为 completed', async () => {
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
+ taskStatus: 'completed',
+ previewUrl: 'https://s3.example/preview.webp',
+ zipFilePath: 'materials/source.zip',
+ assets: [{ assetType: 'preview' }, { assetType: 'zip' }],
+ })
+
+ await expect(inspectTaskMaterialCompletionAfterError('material-1')).resolves.toMatchObject({
+ state: 'completed',
+ })
+ })
+
+ test.each([
+ ['', 'materials/source.zip', [{ assetType: 'preview' }, { assetType: 'zip' }]],
+ ['https://s3.example/preview.webp', '', [{ assetType: 'preview' }, { assetType: 'zip' }]],
+ ['https://s3.example/preview.webp', 'materials/source.zip', [{ assetType: 'preview' }]],
+ ])('已完成但证据不全时映射为 completed_incomplete', async (previewUrl, zipFilePath, assets) => {
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
+ taskStatus: 'completed',
+ previewUrl,
+ zipFilePath,
+ assets,
+ })
+
+ await expect(inspectTaskMaterialCompletionAfterError('material-1')).resolves.toMatchObject({
+ state: 'completed_incomplete',
+ })
+ })
+
+ test('查询失败时返回 unknown,调用方必须保留文件', async () => {
+ const error = new Error('database unavailable')
+ ;(prisma.material.findUnique as jest.Mock).mockRejectedValue(error)
+
+ await expect(inspectTaskMaterialCompletionAfterError('material-1')).resolves.toEqual({
+ state: 'unknown',
+ error,
+ })
+ })
+})
diff --git a/lib/__tests__/task-material-processor-zip-atomic.test.ts b/lib/__tests__/task-material-processor-zip-atomic.test.ts
new file mode 100644
index 0000000..dcbdd84
--- /dev/null
+++ b/lib/__tests__/task-material-processor-zip-atomic.test.ts
@@ -0,0 +1,400 @@
+/** @jest-environment node */
+
+import {
+ mkdtemp,
+ mkdir,
+ readdir,
+ readFile,
+ rm,
+ stat,
+ symlink,
+ writeFile,
+} from 'fs/promises'
+import { appendFileSync } from 'fs'
+import AdmZip from 'adm-zip'
+import os from 'os'
+import path from 'path'
+
+import {
+ createZipFromFiles,
+ createZipFromZips,
+} from '@/lib/task-material-processor'
+import {
+ deleteStoredUploadFileFromRemote,
+ syncStoredUploadFileToRemote,
+} from '@/lib/remote-upload-sync'
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {},
+}))
+
+jest.mock('@/lib/s3', () => ({
+ deleteFromS3: jest.fn(),
+}))
+
+jest.mock('@/lib/public-display', () => ({
+ buildMaterialPublicDisplayPath: jest.fn(),
+ uploadPublicDisplayToS3: jest.fn(),
+}))
+
+jest.mock('@/lib/image-processor', () => ({
+ compressImage: jest.fn(),
+}))
+
+jest.mock('@/lib/remote-upload-sync', () => ({
+ deleteStoredUploadFileFromRemote: jest.fn().mockResolvedValue(undefined),
+ syncStoredUploadFileToRemote: jest.fn(),
+}))
+
+// Keep the ZIP test focused on the real filesystem/archive path. Disk reservation
+// has its own unit tests and would otherwise make this test depend on free space.
+jest.mock('@/lib/material-upload-merge', () => ({
+ acquireUploadMergeDiskReservation: jest.fn().mockResolvedValue(() => undefined),
+}))
+
+const syncRemoteMock = syncStoredUploadFileToRemote as jest.Mock
+const deleteRemoteMock = deleteStoredUploadFileFromRemote as jest.Mock
+
+type SyncObservation = {
+ localFilePath: string
+ storedPath: string
+ fileSize: number
+ expectedSize?: number
+ partialNamesAtSync: string[]
+}
+
+describe('task material ZIP atomic filesystem behavior', () => {
+ let uploadRoot: string
+ let previousUploadDir: string | undefined
+ let syncObservations: SyncObservation[]
+
+ const materialsDir = () => path.join(uploadRoot, 'materials')
+
+ beforeEach(async () => {
+ previousUploadDir = process.env.UPLOAD_DIR
+ uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'task-material-zip-'))
+ process.env.UPLOAD_DIR = uploadRoot
+ syncObservations = []
+ jest.clearAllMocks()
+
+ syncRemoteMock.mockImplementation(
+ async (localFilePath: string, storedPath: string, expectedSize?: number) => {
+ // This callback is intentionally a real stat/readdir: the production
+ // function must rename the final ZIP before remote sync is invoked.
+ const fileStats = await stat(localFilePath)
+ const names = await readdir(path.dirname(localFilePath))
+ syncObservations.push({
+ localFilePath,
+ storedPath,
+ fileSize: fileStats.size,
+ expectedSize,
+ partialNamesAtSync: names.filter((name) => name.includes('.partial-')),
+ })
+ return storedPath
+ },
+ )
+ })
+
+ afterEach(async () => {
+ syncRemoteMock.mockReset()
+ deleteRemoteMock.mockReset()
+ if (previousUploadDir === undefined) {
+ delete process.env.UPLOAD_DIR
+ } else {
+ process.env.UPLOAD_DIR = previousUploadDir
+ }
+ await rm(uploadRoot, { recursive: true, force: true })
+ })
+
+ async function assertFinalZip(result: { filePath: string; fileName: string; fileSize: number }) {
+ const finalPath = path.join(materialsDir(), result.fileName)
+ const finalStats = await stat(finalPath)
+ const finalBytes = await readFile(finalPath)
+ const names = await readdir(materialsDir())
+
+ expect(result.filePath).toBe(`uploads/materials/${result.fileName}`)
+ expect(finalStats.isFile()).toBe(true)
+ expect(finalStats.size).toBeGreaterThan(0)
+ expect(result.fileSize).toBe(finalStats.size)
+ expect(finalBytes.subarray(0, 2).toString()).toBe('PK')
+ expect(names.filter((name) => name.includes('.partial-'))).toEqual([])
+
+ return finalPath
+ }
+
+ async function assertNoZipResidue() {
+ const names = await readdir(materialsDir()).catch(() => [])
+ expect(names.filter((name) => name.endsWith('.zip') || name.includes('.partial-'))).toEqual([])
+ }
+
+ test('createZipFromFiles 写出非空最终 ZIP,并在同步时已完成原子改名', async () => {
+ const sourcePath = path.join(uploadRoot, 'temp', 'source.txt')
+ const sourceBytes = Buffer.from('task-material-source')
+ await mkdir(path.dirname(sourcePath), { recursive: true })
+ await writeFile(sourcePath, sourceBytes)
+
+ const result = await createZipFromFiles(
+ [{ path: sourcePath, name: 'source.txt', size: sourceBytes.length }],
+ 'material-files-success',
+ 'MODEL-FILES',
+ '任务-详情',
+ )
+
+ const finalPath = await assertFinalZip(result)
+ const archive = new AdmZip(finalPath)
+ expect(archive.getEntry('source.txt')?.getData()).toEqual(sourceBytes)
+ expect(syncRemoteMock).toHaveBeenCalledTimes(1)
+ expect(syncObservations).toEqual([
+ {
+ localFilePath: finalPath,
+ storedPath: result.filePath,
+ fileSize: result.fileSize,
+ expectedSize: result.fileSize,
+ partialNamesAtSync: [],
+ },
+ ])
+ })
+
+ test('createZipFromZips 写出非空最终 ZIP,并在同步时已完成原子改名', async () => {
+ const sourceZipPath = path.join(uploadRoot, 'materials', 'source.zip')
+ await mkdir(path.dirname(sourceZipPath), { recursive: true })
+ // The function validates the source as a regular file; the nested archive
+ // contents are intentionally outside this test's scope.
+ await writeFile(sourceZipPath, Buffer.from('PK\x03\x04nested-zip-source'))
+
+ const result = await createZipFromZips(
+ [{ path: sourceZipPath, name: 'source.zip' }],
+ 'material-zips-success',
+ 'MODEL-ZIPS',
+ '任务-详情',
+ )
+
+ const finalPath = await assertFinalZip(result)
+ const archive = new AdmZip(finalPath)
+ expect(archive.getEntry('source.zip')?.getData()).toEqual(
+ Buffer.from('PK\x03\x04nested-zip-source'),
+ )
+ expect(syncRemoteMock).toHaveBeenCalledTimes(1)
+ expect(syncObservations).toEqual([
+ {
+ localFilePath: finalPath,
+ storedPath: result.filePath,
+ fileSize: result.fileSize,
+ expectedSize: result.fileSize,
+ partialNamesAtSync: [],
+ },
+ ])
+ })
+
+ test('createZipFromFiles 对目录、符号链接和缺失输入失败且不留下 ZIP 或 partial', async () => {
+ const directoryPath = path.join(uploadRoot, 'invalid-directory')
+ await mkdir(directoryPath, { recursive: true })
+
+ const symlinkTarget = path.join(uploadRoot, 'symlink-target')
+ const symlinkPath = path.join(uploadRoot, 'symlink-input')
+ await mkdir(symlinkTarget, { recursive: true })
+ await symlink(
+ symlinkTarget,
+ symlinkPath,
+ process.platform === 'win32' ? 'junction' : 'dir',
+ )
+
+ const cases = [
+ { label: '目录', inputPath: directoryPath },
+ { label: '符号链接', inputPath: symlinkPath },
+ { label: '缺失文件', inputPath: path.join(uploadRoot, 'missing-input') },
+ ]
+
+ for (let index = 0; index < cases.length; index += 1) {
+ const item = cases[index]
+ await expect(
+ createZipFromFiles(
+ [{ path: item.inputPath, name: `${item.label}.bin`, size: 1 }],
+ `material-files-failure-${index}`,
+ 'MODEL-INVALID',
+ '任务-详情',
+ ),
+ ).rejects.toThrow()
+ await assertNoZipResidue()
+ }
+
+ expect(syncRemoteMock).not.toHaveBeenCalled()
+ })
+
+ test('createZipFromZips 对符号链接和缺失输入失败且不留下 ZIP 或 partial', async () => {
+ const symlinkTarget = path.join(uploadRoot, 'zip-symlink-target')
+ const symlinkPath = path.join(uploadRoot, 'zip-symlink-input')
+ await mkdir(symlinkTarget, { recursive: true })
+ await symlink(
+ symlinkTarget,
+ symlinkPath,
+ process.platform === 'win32' ? 'junction' : 'dir',
+ )
+
+ const cases = [
+ { label: '符号链接', inputPath: symlinkPath },
+ { label: '缺失文件', inputPath: path.join(uploadRoot, 'missing-source.zip') },
+ ]
+
+ for (let index = 0; index < cases.length; index += 1) {
+ const item = cases[index]
+ await expect(
+ createZipFromZips(
+ [{ path: item.inputPath, name: `${item.label}.zip` }],
+ `material-zips-failure-${index}`,
+ 'MODEL-INVALID',
+ '任务-详情',
+ ),
+ ).rejects.toThrow()
+ await assertNoZipResidue()
+ }
+
+ expect(syncRemoteMock).not.toHaveBeenCalled()
+ })
+
+ test('输入在 archiver 取样后消失时失败,且不留下正式 ZIP 或 partial', async () => {
+ const sourcePath = path.join(uploadRoot, 'temp', 'disappearing-source.bin')
+ await mkdir(path.dirname(sourcePath), { recursive: true })
+ await writeFile(sourcePath, Buffer.from('will-disappear'))
+
+ // archiver uses callback-style fs.lstat after the production preflight
+ // (which uses fs/promises.lstat). Delete the source between those two
+ // observations to deterministically exercise a stream/input disappearance.
+ const nodeFs = require('fs') as typeof import('fs')
+ const originalLstat = nodeFs.lstat.bind(nodeFs)
+ const lstatMock = jest.spyOn(nodeFs, 'lstat') as jest.Mock
+ let removed = false
+ lstatMock.mockImplementation((filePath: string, ...args: unknown[]) => {
+ const callback = args[args.length - 1] as (...callbackArgs: unknown[]) => void
+ originalLstat(filePath, (...callbackArgs: unknown[]) => {
+ if (!removed && path.resolve(filePath) === path.resolve(sourcePath)) {
+ removed = true
+ void (async () => {
+ await rm(sourcePath, { force: true })
+ callback(...callbackArgs)
+ })()
+ return
+ }
+ callback(...callbackArgs)
+ })
+ })
+
+ try {
+ await expect(
+ createZipFromFiles(
+ [{ path: sourcePath, name: 'disappearing-source.bin', size: 14 }],
+ 'material-disappearing-input',
+ 'MODEL-DISAPPEARING',
+ '任务-详情',
+ ),
+ ).rejects.toThrow()
+ } finally {
+ lstatMock.mockRestore()
+ }
+
+ expect(removed).toBe(true)
+ await assertNoZipResidue()
+ expect(syncRemoteMock).not.toHaveBeenCalled()
+ })
+
+ test('ZIP 输出超过预留上限时失败并清理 partial', async () => {
+ const sourcePath = path.join(uploadRoot, 'temp', 'growing-source.bin')
+ await mkdir(path.dirname(sourcePath), { recursive: true })
+ await writeFile(sourcePath, Buffer.from('x'))
+
+ // The preflight sees one byte, reserving 16MiB of overhead. Grow the file
+ // immediately after archiver's later callback-style lstat so the real
+ // archive stream exceeds that bound. Compression level 0 makes the size
+ // relationship deterministic.
+ const oversizedPayload = Buffer.alloc(16 * 1024 * 1024 + 1024, 0x61)
+ const nodeFs = require('fs') as typeof import('fs')
+ const originalLstat = nodeFs.lstat.bind(nodeFs)
+ const lstatMock = jest.spyOn(nodeFs, 'lstat') as jest.Mock
+ let expanded = false
+ lstatMock.mockImplementation((filePath: string, ...args: unknown[]) => {
+ const callback = args[args.length - 1] as (...callbackArgs: unknown[]) => void
+ originalLstat(filePath, (...callbackArgs: unknown[]) => {
+ if (!expanded && path.resolve(filePath) === path.resolve(sourcePath)) {
+ expanded = true
+ appendFileSync(sourcePath, oversizedPayload)
+ }
+ callback(...callbackArgs)
+ })
+ })
+
+ try {
+ await expect(
+ createZipFromFiles(
+ [{ path: sourcePath, name: 'growing-source.bin', size: 1 }],
+ 'material-output-over-limit',
+ 'MODEL-OVER-LIMIT',
+ '任务-详情',
+ 'all',
+ undefined,
+ undefined,
+ 30000,
+ 0,
+ ),
+ ).rejects.toThrow()
+ } finally {
+ lstatMock.mockRestore()
+ }
+
+ expect(expanded).toBe(true)
+ await assertNoZipResidue()
+ expect(syncRemoteMock).not.toHaveBeenCalled()
+ })
+
+ test('同名 ZIP 并发创建时只保留一个成功结果,失败方不删除成功方', async () => {
+ const sourceA = path.join(uploadRoot, 'temp', 'same-name-a.txt')
+ const sourceB = path.join(uploadRoot, 'temp', 'same-name-b.txt')
+ await mkdir(path.dirname(sourceA), { recursive: true })
+ await writeFile(sourceA, Buffer.from('first concurrent source'))
+ await writeFile(sourceB, Buffer.from('second concurrent source'))
+
+ const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1711111111111)
+ let settled: PromiseSettledResult<{
+ filePath: string
+ fileName: string
+ fileSize: number
+ }>[]
+ try {
+ settled = await Promise.allSettled([
+ createZipFromFiles(
+ [{ path: sourceA, name: 'same-name-a.txt', size: 23 }],
+ 'material-same-name',
+ 'MODEL-SAME-NAME',
+ '任务-详情',
+ ),
+ createZipFromFiles(
+ [{ path: sourceB, name: 'same-name-b.txt', size: 24 }],
+ 'material-same-name',
+ 'MODEL-SAME-NAME',
+ '任务-详情',
+ ),
+ ])
+ } finally {
+ dateNowSpy.mockRestore()
+ }
+
+ const fulfilled = settled.filter(
+ (item): item is PromiseFulfilledResult<{
+ filePath: string
+ fileName: string
+ fileSize: number
+ }> => item.status === 'fulfilled',
+ )
+ const rejected = settled.filter((item) => item.status === 'rejected')
+
+ expect(fulfilled).toHaveLength(1)
+ expect(rejected).toHaveLength(1)
+ const result = fulfilled[0].value
+ const finalPath = await assertFinalZip(result)
+ expect(await stat(finalPath)).toMatchObject({ isFile: expect.any(Function) })
+ expect((await readdir(materialsDir())).filter((name) => name.endsWith('.zip'))).toEqual([
+ result.fileName,
+ ])
+ expect(syncRemoteMock).toHaveBeenCalledTimes(1)
+ expect(syncObservations[0]?.localFilePath).toBe(finalPath)
+ })
+})
diff --git a/lib/__tests__/task-material-processor.test.ts b/lib/__tests__/task-material-processor.test.ts
index a7a3378..0d7d064 100644
--- a/lib/__tests__/task-material-processor.test.ts
+++ b/lib/__tests__/task-material-processor.test.ts
@@ -1,19 +1,29 @@
/** @jest-environment node */
-import { generatePreviewImages, resolveUniqueZipEntryName } from '@/lib/task-material-processor'
+import {
+ generatePreviewImages,
+ resolveOwnedTaskMaterialSourceFiles,
+ resolveUniqueZipEntryName,
+ TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES,
+} from '@/lib/task-material-processor'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
import { uploadToS3 } from '@/lib/s3'
import { compressImage } from '@/lib/image-processor'
+import { readBoundedRegularFile } from '@/lib/bounded-file-reader'
+import { lstat } from 'fs/promises'
+import { getOwnedUploadedFile } from '@/lib/upload-sessions'
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
mkdir: jest.fn(),
unlink: jest.fn(),
rm: jest.fn(),
+ lstat: jest.fn(),
}))
jest.mock('@/lib/upload-sessions', () => ({
getUploadedFile: jest.fn(),
+ getOwnedUploadedFile: jest.fn(),
}))
jest.mock('@/lib/upload-sessions-redis', () => ({
@@ -24,6 +34,10 @@ jest.mock('@/lib/image-processor', () => ({
compressImage: jest.fn(),
}))
+jest.mock('@/lib/bounded-file-reader', () => ({
+ readBoundedRegularFile: jest.fn(),
+}))
+
jest.mock('@/lib/public-display', () => ({
...jest.requireActual('@/lib/public-display'),
uploadPublicDisplayToS3: jest.fn(),
@@ -35,9 +49,6 @@ jest.mock('@/lib/s3', () => ({
}))
describe('task-material-processor', () => {
- const fsPromises = jest.requireMock('fs/promises') as {
- readFile: jest.Mock
- }
const { getUploadedFile } = jest.requireMock('@/lib/upload-sessions') as {
getUploadedFile: jest.Mock
}
@@ -46,7 +57,7 @@ describe('task-material-processor', () => {
beforeEach(() => {
jest.clearAllMocks()
- fsPromises.readFile.mockResolvedValue(Buffer.from('source-image'))
+ ;(readBoundedRegularFile as jest.Mock).mockResolvedValue(Buffer.from('source-image'))
getUploadedFile.mockResolvedValue('/tmp/uploaded/image.png')
;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed-image'))
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue(
@@ -78,9 +89,61 @@ describe('task-material-processor', () => {
expect.stringMatching(/^materials\/preview-1711111111111-0-[a-z0-9]+\.webp$/),
'image/webp',
)
+ expect(readBoundedRegularFile).toHaveBeenCalledWith(
+ '/tmp/uploaded/image.png',
+ expect.objectContaining({
+ maxBytes: 100 * 1024 * 1024,
+ label: '预览源图片 origin.png',
+ }),
+ )
expect(uploadToS3).not.toHaveBeenCalled()
})
+ test('generatePreviewImages 使用调用方已验证路径时不再二次解析无 owner 会话', async () => {
+ await generatePreviewImages(
+ [{ uploadId: 'upload-1', fileName: 'origin.png', filePath: '/verified/origin.png' }],
+ '任务-页面',
+ )
+
+ expect(getUploadedFile).not.toHaveBeenCalled()
+ expect(readBoundedRegularFile).toHaveBeenCalledWith(
+ '/verified/origin.png',
+ expect.any(Object),
+ )
+ })
+
+ test('任务图片源文件以磁盘实际大小为准并拒绝客户端伪造小尺寸', async () => {
+ ;(getOwnedUploadedFile as jest.Mock).mockResolvedValue('/verified/oversized.png')
+ ;(lstat as jest.Mock).mockResolvedValue({
+ isSymbolicLink: () => false,
+ isFile: () => true,
+ size: TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES + 1,
+ })
+
+ await expect(resolveOwnedTaskMaterialSourceFiles({
+ userId: 'user-1',
+ assets: [{ uploadId: 'upload-1', fileName: 'oversized.png', fileSize: 1 }],
+ })).rejects.toMatchObject({ status: 413 })
+ })
+
+ test('任务图片源文件拒绝重复 uploadId 和超过 60 张', async () => {
+ await expect(resolveOwnedTaskMaterialSourceFiles({
+ userId: 'user-1',
+ assets: [
+ { uploadId: 'duplicate', fileName: 'one.png' },
+ { uploadId: 'duplicate', fileName: 'two.png' },
+ ],
+ })).rejects.toThrow('上传会话编号重复')
+
+ await expect(resolveOwnedTaskMaterialSourceFiles({
+ userId: 'user-1',
+ assets: Array.from({ length: 61 }, (_, index) => ({
+ uploadId: `upload-${index}`,
+ fileName: `${index}.png`,
+ })),
+ })).rejects.toThrow('最多上传 60 张')
+ })
+
test('resolveUniqueZipEntryName 应避免同名文件在解压时互相覆盖', () => {
const usedNames = new Set()
diff --git a/lib/__tests__/task-page-upload-routes.test.ts b/lib/__tests__/task-page-upload-routes.test.ts
index 760a0c8..e33028f 100644
--- a/lib/__tests__/task-page-upload-routes.test.ts
+++ b/lib/__tests__/task-page-upload-routes.test.ts
@@ -1,63 +1,38 @@
/** @jest-environment node */
-import { NextRequest } from 'next/server'
-import { getServerSession } from 'next-auth'
+import { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
-import { POST as initTaskPage } from '@/app/api/materials/upload/task-page/init/route'
-import { POST as completeTaskPage } from '@/app/api/materials/upload/task-page/complete/route'
-import { prisma } from '@/lib/prisma'
-import { cleanupOnError, createZipFromFiles } from '@/lib/task-material-processor'
-import { getOwnedUploadedFile } from '@/lib/upload-sessions'
-import { stat } from 'fs/promises'
-import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
+import { POST as initTaskPage } from '@/app/api/materials/upload/task-page/init/route';
+import { POST as completeTaskPage } from '@/app/api/materials/upload/task-page/complete/route';
+import { prisma } from '@/lib/prisma';
+import { completeTaskImageMaterial } from '@/lib/task-image-material-completion';
-jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
-jest.mock('@/lib/auth', () => ({ authOptions: {} }))
+jest.mock('next-auth', () => ({ getServerSession: jest.fn() }));
+jest.mock('@/lib/auth', () => ({ authOptions: {} }));
jest.mock('@/lib/prisma', () => ({
prisma: {
user: { findUnique: jest.fn() },
- material: {
- create: jest.fn(),
- findFirst: jest.fn(),
- findUnique: jest.fn(),
- updateMany: jest.fn(),
- },
+ material: { create: jest.fn() },
},
-}))
-jest.mock('@/lib/product-package', () => ({
- assignTaskNoOnComplete: jest.fn(),
- purgeDeletedMaterialsByTaskNo: jest.fn(),
- validateTaskNo: jest.fn(() => true),
-}))
-jest.mock('@/lib/dingtalk-approval-no', () => ({
- getDingtalkApprovalNoError: jest.fn(() => null),
-}))
-jest.mock('@/lib/task-material-processor', () => ({
- createZipFromFiles: jest.fn(),
- generatePreviewImages: jest.fn(),
- cleanupTempFiles: jest.fn(),
- cleanupOnError: jest.fn(),
- saveAssets: jest.fn(),
-}))
-jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() }))
-jest.mock('@/lib/upload-sessions', () => ({ getOwnedUploadedFile: jest.fn() }))
-jest.mock('fs/promises', () => ({ stat: jest.fn() }))
+}));
+jest.mock('@/lib/task-image-material-completion', () => ({
+ completeTaskImageMaterial: jest.fn(),
+}));
describe('任务-页面上传接口', () => {
beforeEach(() => {
- jest.clearAllMocks()
- jest.spyOn(console, 'log').mockImplementation(() => undefined)
- jest.spyOn(console, 'error').mockImplementation(() => undefined)
- ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
- ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ username: '张三' })
- ;(prisma.material.create as jest.Mock).mockResolvedValue({ id: 'material-1' })
- ;(prisma.material.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
- ;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 10 })
- })
+ jest.clearAllMocks();
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ (getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } });
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({ username: '张三' });
+ (prisma.material.create as jest.Mock).mockResolvedValue({ id: 'material-1' });
+ (completeTaskImageMaterial as jest.Mock).mockResolvedValue({ materialId: 'material-1' });
+ });
afterEach(() => {
- jest.restoreAllMocks()
- })
+ jest.restoreAllMocks();
+ });
test('初始化接口应返回顶层 materialId,并标记为 uploading', async () => {
const response = await initTaskPage(
@@ -70,11 +45,11 @@ describe('任务-页面上传接口', () => {
sceneName: '书房',
}),
}),
- )
- const body = await response.json()
+ );
+ const body = await response.json();
- expect(response.status).toBe(200)
- expect(body).toEqual({ success: true, materialId: 'material-1' })
+ expect(response.status).toBe(200);
+ expect(body).toEqual({ success: true, materialId: 'material-1' });
expect(prisma.material.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
@@ -82,118 +57,50 @@ describe('任务-页面上传接口', () => {
taskStatus: 'uploading',
}),
}),
- )
- })
+ );
+ });
- test('完成接口不能覆盖其它素材类型', async () => {
- ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
- id: 'material-1',
- userId: 'user-1',
- materialType: '任务-详情',
- })
+ test('完成接口把固定素材类型、用户和原始资产交给共享安全状态机', async () => {
+ const assets = [{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }];
+ const response = await completeTaskPage(makeCompleteRequest(assets));
- const response = await completeTaskPage(
- makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }]),
- )
-
- expect(response.status).toBe(400)
- expect(getOwnedUploadedFile).not.toHaveBeenCalled()
- })
-
- test('打包失败时应把上传会话一并交给统一清理', async () => {
- ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
- id: 'material-1',
+ expect(response.status).toBe(200);
+ expect(completeTaskImageMaterial).toHaveBeenCalledWith({
+ materialId: 'material-1',
userId: 'user-1',
materialType: '任务-页面',
- productModel: 'SJ0371',
- })
- ;(getOwnedUploadedFile as jest.Mock).mockResolvedValue('uploads/temp/upload-1/merged.jpg')
- ;(createZipFromFiles as jest.Mock).mockRejectedValue(new Error('打包失败'))
+ fallbackProductModel: 'task-page',
+ assets,
+ taskNo: undefined,
+ dingtalkApprovalNo: undefined,
+ });
+ });
- const response = await completeTaskPage(
- makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }]),
- )
+ test('共享状态机返回 alreadyCompleted 时保持幂等成功', async () => {
+ (completeTaskImageMaterial as jest.Mock).mockResolvedValue({
+ materialId: 'material-1',
+ alreadyCompleted: true,
+ });
+ const response = await completeTaskPage(makeCompleteRequest([]));
+ const body = await response.json();
- expect(response.status).toBe(500)
- expect(cleanupOnError).toHaveBeenCalledWith(
- [],
- null,
- ['uploads/temp/upload-1/merged.jpg'],
- 'material-1',
- ['upload-1'],
- true,
- { taskStatus: 'processing' },
- )
- })
+ expect(response.status).toBe(200);
+ expect(body).toMatchObject({ success: true, alreadyCompleted: true });
+ });
- test('重复完成请求应返回 409,且不能进入清理删除素材', async () => {
- ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
- id: 'material-1',
- userId: 'user-1',
- materialType: '任务-页面',
- taskStatus: 'completed',
- })
- ;(prisma.material.updateMany as jest.Mock).mockResolvedValue({ count: 0 })
-
- const response = await completeTaskPage(
- makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }]),
- )
-
- expect(response.status).toBe(409)
- expect(cleanupOnError).not.toHaveBeenCalled()
- expect(getOwnedUploadedFile).not.toHaveBeenCalled()
- })
-
- test('应按磁盘实际大小在 readFile 前拒绝超大图片并清理本请求', async () => {
- ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
- id: 'material-1',
- userId: 'user-1',
- materialType: '任务-页面',
- taskStatus: 'uploading',
- productModel: 'SJ0371',
- })
- ;(getOwnedUploadedFile as jest.Mock).mockResolvedValue('uploads/temp/upload-1/merged.jpg')
- ;(stat as jest.Mock).mockResolvedValue({
- isFile: () => true,
- size: TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes + 1,
- })
-
- const response = await completeTaskPage(
- makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'large.jpg', fileSize: 1 }]),
- )
-
- expect(response.status).toBe(413)
- expect(createZipFromFiles).not.toHaveBeenCalled()
- expect(cleanupOnError).toHaveBeenCalledWith(
- [],
- null,
- ['uploads/temp/upload-1/merged.jpg'],
- 'material-1',
- ['upload-1'],
- true,
- { taskStatus: 'processing' },
- )
- })
-
- test('素材查询失败时不能按请求中的 materialId 执行删除清理', async () => {
- ;(prisma.material.findUnique as jest.Mock).mockRejectedValue(new Error('database unavailable'))
-
- const response = await completeTaskPage(
- makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }]),
- )
-
- expect(response.status).toBe(500)
- expect(cleanupOnError).not.toHaveBeenCalled()
- })
-})
+ test('共享状态机的限额和并发错误状态应原样返回', async () => {
+ (completeTaskImageMaterial as jest.Mock).mockRejectedValue(
+ Object.assign(new Error('图片超过限制'), { status: 413 }),
+ );
+ const response = await completeTaskPage(makeCompleteRequest([]));
+ expect(response.status).toBe(413);
+ });
+});
function makeCompleteRequest(assets: Array<{ uploadId: string; fileName: string; fileSize: number }>) {
return new NextRequest('http://localhost/api/materials/upload/task-page/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- materialId: 'material-1',
- assets: { main: assets },
- }),
- })
+ body: JSON.stringify({ materialId: 'material-1', assets: { main: assets } }),
+ });
}
diff --git a/lib/__tests__/task-upload-init-source-preservation.test.ts b/lib/__tests__/task-upload-init-source-preservation.test.ts
new file mode 100644
index 0000000..065feb6
--- /dev/null
+++ b/lib/__tests__/task-upload-init-source-preservation.test.ts
@@ -0,0 +1,40 @@
+/** @jest-environment node */
+
+import { readFileSync } from 'fs'
+import path from 'path'
+
+const INIT_ROUTES = [
+ 'app/api/materials/upload/3d-original/init/route.ts',
+ 'app/api/materials/upload/ai-original/init/route.ts',
+ 'app/api/materials/upload/real-photo/init/route.ts',
+ 'app/api/materials/upload/video-screenshot/init/route.ts',
+ 'app/api/materials/upload/task-page/init/route.ts',
+ 'app/api/materials/upload/product-package/init/route.ts',
+]
+
+describe('任务素材 init 不永久清理旧数据', () => {
+ test.each(INIT_ROUTES)('%s 只能校验并创建 uploading,不得调用旧素材 purge', (relativePath) => {
+ const source = readFileSync(path.join(process.cwd(), relativePath), 'utf8')
+
+ expect(source).not.toContain('purgeDeletedMaterialsByTaskNo')
+ expect(source).not.toContain('deleteFiles(')
+ expect(source).not.toContain('prisma.material.delete')
+ })
+
+ test('通用上传失败清理必须先条件回滚数据库,再删除派生文件', () => {
+ const source = readFileSync(
+ path.join(process.cwd(), 'app/api/materials/upload/route.ts'),
+ 'utf8',
+ )
+ const conditionalDatabaseDelete = source.indexOf(
+ 'const deleteResult = await prisma.material.deleteMany',
+ )
+ const derivedFileDelete = source.indexOf(
+ 'const cleanupResult = await deleteFiles(filesToDelete)',
+ conditionalDatabaseDelete,
+ )
+
+ expect(conditionalDatabaseDelete).toBeGreaterThan(-1)
+ expect(derivedFileDelete).toBeGreaterThan(conditionalDatabaseDelete)
+ })
+})
diff --git a/lib/__tests__/transcode-route-authorization.test.ts b/lib/__tests__/transcode-route-authorization.test.ts
index 6738e11..e9ebe82 100644
--- a/lib/__tests__/transcode-route-authorization.test.ts
+++ b/lib/__tests__/transcode-route-authorization.test.ts
@@ -35,7 +35,6 @@ jest.mock('@/lib/video-transcode-task', () => ({
jest.mock('@/lib/video-transcode-queue', () => ({
VIDEO_MATERIAL_TYPES: ['淘系-主图视频'],
- cleanupTranscodeResidues: jest.fn(),
transcodeQueue: {
add: jest.fn(),
getStatus: jest.fn(() => ({
@@ -61,6 +60,7 @@ function createRequest() {
describe('transcode route authorization', () => {
beforeEach(() => {
jest.clearAllMocks()
+ ;(transcodeQueue.add as jest.Mock).mockResolvedValue(undefined)
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'owner-1', role: 'USER', isSuperAdmin: false },
})
@@ -106,7 +106,7 @@ describe('transcode route authorization', () => {
expect(transcodeQueue.add).not.toHaveBeenCalled()
})
- test('系统管理员可以恢复队列并启动任意素材的转码任务', async () => {
+ test('系统管理员可以启动任意素材的转码任务,恢复由 instrumentation 周期负责', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1', role: 'SYSTEM_ADMIN', isSuperAdmin: false },
})
@@ -123,7 +123,32 @@ describe('transcode route authorization', () => {
const response = await POST(createRequest())
expect(response.status).toBe(200)
- expect(transcodeQueue.recoverOnStartup).toHaveBeenCalledTimes(1)
+ expect(transcodeQueue.recoverOnStartup).not.toHaveBeenCalled()
expect(transcodeQueue.add).toHaveBeenCalledTimes(1)
})
+
+ test('POST 只等待持久化入队,不等待后台转码任务完成', async () => {
+ const responsePromise = POST(createRequest())
+
+ await expect(responsePromise).resolves.toMatchObject({ status: 200 })
+ expect(transcodeQueue.add).toHaveBeenCalledTimes(1)
+ })
+
+ test('processing 状态即使 forceRetry 也不能绕过后台租约恢复并启动第二个任务', async () => {
+ ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
+ id: 'material-1',
+ userId: 'owner-1',
+ videoSourceUrl: 'uploads/materials/video.mp4',
+ videoCoverUrl: null,
+ videoProcessingStatus: 'processing',
+ transcodeLeaseToken: null,
+ transcodeLeaseExpiresAt: null,
+ materialType: '淘系-主图视频',
+ })
+
+ const response = await POST(createRequest())
+
+ expect(response.status).toBe(409)
+ expect(transcodeQueue.add).not.toHaveBeenCalled()
+ })
})
diff --git a/lib/__tests__/upload-sessions-security.test.ts b/lib/__tests__/upload-sessions-security.test.ts
index 19844f8..c4a72ec 100644
--- a/lib/__tests__/upload-sessions-security.test.ts
+++ b/lib/__tests__/upload-sessions-security.test.ts
@@ -11,11 +11,13 @@ import {
writeFile,
} from 'fs/promises'
import {
+ getUploadSession,
getUploadedFile,
saveUploadSession,
uploadSessions,
type UploadSession,
} from '@/lib/upload-sessions'
+import { cleanupTempFiles } from '@/lib/task-material-processor'
function createSession(fileName: string, fileSize: number): UploadSession {
return {
@@ -112,6 +114,23 @@ describe('upload-sessions 最终落盘安全', () => {
).rejects.toThrow('上传会话包含不安全的标识或文件名')
})
+ test('业务完成清理会同时移除进程内会话,不能只删除 Redis', async () => {
+ const uploadId = 'upload-unified-session-cleanup'
+ await writeSingleChunk(uploadId, Buffer.from('safe'))
+ await saveUploadSession(uploadId, createSession('source.jpg', 4))
+ await expect(getUploadSession(uploadId)).resolves.not.toBeNull()
+
+ // 受控清理只能消费已完成的上传:先原子合并并写入 completed
+ // tombstone,再删除 assembled/temp 和内存 + Redis 会话。
+ await expect(getUploadedFile(uploadId)).resolves.toEqual(
+ path.join(uploadRoot, 'assembled', `${uploadId}-source.jpg`),
+ )
+
+ await cleanupTempFiles([], [uploadId])
+
+ await expect(getUploadSession(uploadId)).resolves.toBeNull()
+ })
+
test('最终目标是符号链接时不会跟随或覆盖链接目标', async () => {
const uploadId = 'upload-symlink-target'
const content = Buffer.from('attack')
diff --git a/lib/__tests__/video-material-types.test.ts b/lib/__tests__/video-material-types.test.ts
index 4128e83..fcba433 100644
--- a/lib/__tests__/video-material-types.test.ts
+++ b/lib/__tests__/video-material-types.test.ts
@@ -6,8 +6,10 @@ import {
TAOXI_MAIN_VIDEO_TYPE,
isBatchVideoMaterialType,
isSingleVideoUploadMaterialType,
+ isVideoTranscodeInFlight,
isVideoMaterialType,
normalizeVideoMaterialType,
+ shouldPollVideoTranscode,
} from '@/lib/video-material-types'
describe('video-material-types', () => {
@@ -47,4 +49,14 @@ describe('video-material-types', () => {
expect(isSingleVideoUploadMaterialType(DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE)).toBe(true)
expect(isSingleVideoUploadMaterialType(TAOXI_GUANGGUANG_VIDEO_TYPE)).toBe(false)
})
+
+ test('视频轮询只依赖处理状态,不依赖会被15天策略隐藏的源地址', () => {
+ expect(isVideoTranscodeInFlight('pending')).toBe(true)
+ expect(isVideoTranscodeInFlight('processing')).toBe(true)
+ expect(isVideoTranscodeInFlight('done')).toBe(false)
+ expect(isVideoTranscodeInFlight('failed')).toBe(false)
+ expect(shouldPollVideoTranscode(TAOXI_GUANGGUANG_VIDEO_TYPE, 'pending')).toBe(true)
+ expect(shouldPollVideoTranscode(DOUYIN_QIANCHUAN_VIDEO_TYPE, 'processing')).toBe(true)
+ expect(shouldPollVideoTranscode('音乐', 'processing')).toBe(false)
+ })
})
diff --git a/lib/__tests__/video-processor-output-cleanup.test.ts b/lib/__tests__/video-processor-output-cleanup.test.ts
new file mode 100644
index 0000000..54fe466
--- /dev/null
+++ b/lib/__tests__/video-processor-output-cleanup.test.ts
@@ -0,0 +1,99 @@
+/** @jest-environment node */
+
+import { EventEmitter } from 'events'
+import { mkdtemp, rm, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import path from 'path'
+import { spawn } from 'child_process'
+import { processVideoTranscode } from '@/lib/video-processor'
+import { deleteFromS3, uploadFileToS3 } from '@/lib/s3'
+
+jest.mock('child_process', () => {
+ const { promisify } = jest.requireActual('util') as typeof import('util')
+ const execFile = jest.fn()
+ Object.defineProperty(execFile, promisify.custom, {
+ value: async (_file: string, args: string[]) => (
+ args.includes('format=duration')
+ ? { stdout: '12\n', stderr: '' }
+ : { stdout: '', stderr: '' }
+ ),
+ })
+ return {
+ execFile,
+ spawn: jest.fn(),
+ }
+})
+
+jest.mock('@/lib/s3', () => ({
+ deleteFromS3: jest.fn(),
+ uploadFileToS3: jest.fn(),
+ getS3KeyFromUrl: jest.fn(),
+ getS3ObjectStream: jest.fn(),
+ isHttpUrl: jest.fn(() => false),
+ isS3PublicUrl: jest.fn(() => false),
+}))
+
+jest.mock('@/lib/disk-space', () => ({
+ checkDiskSpace: jest.fn().mockResolvedValue({
+ total: 100 * 1024 * 1024 * 1024,
+ available: 100 * 1024 * 1024 * 1024,
+ }),
+}))
+
+function createSuccessfulFFmpegProcess() {
+ const child = new EventEmitter() as any
+ child.pid = 424242
+ child.stdout = new EventEmitter()
+ child.stderr = new EventEmitter()
+ child.kill = jest.fn(() => true)
+ queueMicrotask(() => child.emit('close', 0, null))
+ return child
+}
+
+describe('video processor uploaded-output cleanup', () => {
+ let uploadRoot = ''
+ const previousUploadDir = process.env.UPLOAD_DIR
+
+ beforeEach(async () => {
+ jest.clearAllMocks()
+ uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiangp2-video-output-'))
+ process.env.UPLOAD_DIR = uploadRoot
+ ;(spawn as unknown as jest.Mock).mockImplementation(() => createSuccessfulFFmpegProcess())
+ })
+
+ afterEach(async () => {
+ await rm(uploadRoot, { recursive: true, force: true })
+ if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
+ else process.env.UPLOAD_DIR = previousUploadDir
+ })
+
+ test('后续片段上传失败时删除已经上传的唯一预览对象,且清理失败不覆盖原错误', async () => {
+ const sourcePath = path.join(uploadRoot, 'source.mp4')
+ await writeFile(sourcePath, Buffer.from('test-video-source'))
+ ;(uploadFileToS3 as jest.Mock)
+ .mockResolvedValueOnce('https://s3.jiansun.vip/video-preview-unique.mp4')
+ .mockRejectedValueOnce(new Error('clip upload failed'))
+ ;(deleteFromS3 as jest.Mock).mockRejectedValueOnce(new Error('cleanup temporarily unavailable'))
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
+
+ await expect(processVideoTranscode({
+ inputPath: sourcePath,
+ outputDir: './uploads/transcoded',
+ materialId: 'material-output-cleanup',
+ needsCoverImage: false,
+ })).rejects.toThrow('clip upload failed')
+
+ expect(uploadFileToS3).toHaveBeenCalledTimes(2)
+ expect(deleteFromS3).toHaveBeenCalledTimes(1)
+ expect(deleteFromS3).toHaveBeenCalledWith('https://s3.jiansun.vip/video-preview-unique.mp4')
+ const uploadedNames = (uploadFileToS3 as jest.Mock).mock.calls.map((call) => call[1] as string)
+ expect(uploadedNames[0]).toMatch(/^video-preview-\d+p-material-output-cleanup-[0-9a-f-]{36}\.mp4$/)
+ expect(uploadedNames[1]).toMatch(/^video-clip-\d+s-material-output-cleanup-[0-9a-f-]{36}\.mp4$/)
+ const previewIdentity = uploadedNames[0].match(/([0-9a-f-]{36})\.mp4$/)?.[1]
+ const clipIdentity = uploadedNames[1].match(/([0-9a-f-]{36})\.mp4$/)?.[1]
+ expect(previewIdentity).toBeTruthy()
+ expect(clipIdentity).toBe(previewIdentity)
+
+ consoleError.mockRestore()
+ })
+})
diff --git a/lib/__tests__/video-source-preservation.test.ts b/lib/__tests__/video-source-preservation.test.ts
new file mode 100644
index 0000000..d9b7df6
--- /dev/null
+++ b/lib/__tests__/video-source-preservation.test.ts
@@ -0,0 +1,198 @@
+/** @jest-environment node */
+
+import { readFile } from 'fs/promises'
+import path from 'path'
+import { EventEmitter } from 'events'
+import { spawn } from 'child_process'
+import {
+ TRANSCODE_CONFIG,
+ buildClipFFmpegArgs,
+ buildPreviewFFmpegArgs,
+ execFFmpegWithTimeout,
+ resolveTranscodeOutputRoot,
+} from '@/lib/video-processor'
+
+jest.mock('child_process', () => ({
+ execFile: jest.fn(),
+ spawn: jest.fn(),
+}))
+
+function createMockFFmpegProcess() {
+ const child = new EventEmitter() as any
+ child.pid = 424242
+ child.stdout = new EventEmitter()
+ child.stderr = new EventEmitter()
+ child.kill = jest.fn(() => true)
+ return child
+}
+
+describe('canonical video source preservation', () => {
+ test('视频处理器只清理任务临时文件,不再删除 canonical uploads 源文件', async () => {
+ const source = await readFile(path.join(process.cwd(), 'lib/video-processor.ts'), 'utf8')
+
+ expect(source).not.toContain('deleteLocalSourceIfNeeded')
+ expect(source).not.toContain("import('./file-cleanup')")
+ expect(source).toContain('canonical 源文件是用户上传的唯一原件')
+ expect(source).toContain('Local canonical source opened read-only')
+ expect(source).not.toContain('copyFile')
+ expect(source).not.toContain('tmpdir()')
+ expect(source).toContain("await rm(workDir, { recursive: true, force: true })")
+ expect(source).toContain("/^video-task-[a-zA-Z0-9_-]+$/")
+ })
+
+ test('通用上传在数据库提交前和成功响应前都严格复核 canonical 原件', async () => {
+ const [routeSource, validatorSource] = await Promise.all([
+ readFile(path.join(process.cwd(), 'app/api/materials/upload/route.ts'), 'utf8'),
+ readFile(path.join(process.cwd(), 'lib/material-canonical-source.ts'), 'utf8'),
+ ])
+
+ expect(validatorSource).toContain('isPathFreeOfSymbolicLinks(uploadRoot, sourcePath)')
+ expect(validatorSource).toContain('pathStats.isSymbolicLink() || !pathStats.isFile()')
+ expect(validatorSource).toContain('constants.O_NOFOLLOW')
+ expect(validatorSource).toContain('openedStats.size !== expectedSize')
+
+ const preCommitValidation = routeSource.indexOf(
+ 'await validateCanonicalSourceForMaterial({',
+ )
+ const materialCreate = routeSource.indexOf('await prisma.material.create({')
+ const postCommitValidation = routeSource.indexOf(
+ "phase: 'post-commit'",
+ materialCreate,
+ )
+ const journalRemoval = routeSource.indexOf(
+ 'await removeMaterialUploadConsumptionJournal({',
+ materialCreate,
+ )
+
+ expect(preCommitValidation).toBeGreaterThan(-1)
+ expect(preCommitValidation).toBeLessThan(materialCreate)
+ expect(postCommitValidation).toBeGreaterThan(materialCreate)
+ expect(postCommitValidation).toBeLessThan(journalRemoval)
+ expect(routeSource).toContain("phase: 'idempotent-replay'")
+ expect(routeSource).toContain("code: 'CANONICAL_SOURCE_INTEGRITY_FAILED'")
+ })
+
+ test('生成的视频与封面从临时文件流式上传,不把大文件整体读入 Node 内存', async () => {
+ const source = await readFile(path.join(process.cwd(), 'lib/video-processor.ts'), 'utf8')
+
+ expect(source).toContain('uploadFileToS3,')
+ expect(source).toContain('getS3KeyFromUrl,')
+ expect(source).toContain('deleteFromS3,')
+ expect(source).toContain("uploadFileToS3(previewPath, preview540FileName, 'video/mp4')")
+ expect(source).toContain("uploadFileToS3(clipPath, clipFileName, 'video/mp4')")
+ expect(source).toContain("uploadFileToS3(coverPath, coverFileName, 'image/jpeg')")
+ expect(source).not.toMatch(/readFile\(temp(?:540|Clip|Cover)Path\)/)
+ })
+
+ test('FFmpeg 非零退出码即使带有进度日志也必须失败', async () => {
+ const child = createMockFFmpegProcess()
+ ;(spawn as unknown as jest.Mock).mockReturnValueOnce(child)
+ const result = execFFmpegWithTimeout(['-i', 'broken-input'], 1000)
+ child.stderr.emit('data', Buffer.from('Duration: 00:00:10.00 frame=12 conversion failed'))
+ child.emit('close', 1, null)
+
+ await expect(result).rejects.toThrow('FFmpeg exited with code 1')
+ expect(child.kill).not.toHaveBeenCalled()
+ expect(spawn).toHaveBeenCalledWith('ffmpeg', ['-i', 'broken-input'], expect.objectContaining({ shell: false }))
+ })
+
+ test('租约取消会终止 FFmpeg 进程组,并在 5 秒后使用 SIGKILL 兜底', async () => {
+ jest.useFakeTimers()
+ const child = createMockFFmpegProcess()
+ const processKill = jest.spyOn(process, 'kill').mockImplementation(() => true)
+ ;(spawn as unknown as jest.Mock).mockReturnValueOnce(child)
+ const controller = new AbortController()
+ const result = execFFmpegWithTimeout(['-i', 'source.mp4'], 60_000, undefined, controller.signal)
+
+ controller.abort(new Error('lease lost'))
+ if (process.platform === 'win32') {
+ expect(child.kill).toHaveBeenCalledWith('SIGTERM')
+ } else {
+ expect(processKill).toHaveBeenCalledWith(-child.pid, 'SIGTERM')
+ }
+ jest.advanceTimersByTime(5000)
+ if (process.platform === 'win32') {
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ } else {
+ expect(processKill).toHaveBeenCalledWith(-child.pid, 'SIGKILL')
+ }
+ child.emit('close', null, 'SIGKILL')
+
+ await expect(result).rejects.toThrow('lease lost')
+ processKill.mockRestore()
+ jest.useRealTimers()
+ })
+
+ test('操作系统拒绝终止信号时不会从 Abort 回调抛出未捕获异常', async () => {
+ const child = createMockFFmpegProcess()
+ const killError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' })
+ child.kill.mockImplementation(() => { throw killError })
+ const processKill = jest.spyOn(process, 'kill').mockImplementation(() => { throw killError })
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
+ ;(spawn as unknown as jest.Mock).mockReturnValueOnce(child)
+ const controller = new AbortController()
+ const result = execFFmpegWithTimeout(['-i', 'source.mp4'], 60_000, undefined, controller.signal)
+
+ expect(() => controller.abort(new Error('lease lost'))).not.toThrow()
+ child.emit('close', null, 'SIGTERM')
+ await expect(result).rejects.toThrow('lease lost')
+
+ expect(consoleError).toHaveBeenCalledWith(
+ '[VideoProcessor] Failed to send SIGTERM to FFmpeg:',
+ killError,
+ )
+ consoleError.mockRestore()
+ processKill.mockRestore()
+ })
+
+ test('x265 输入解码、输出编码器和内部线程池都受到线程上限约束', async () => {
+ for (const args of [
+ buildPreviewFFmpegArgs('input with spaces.mp4', 'preview.mp4'),
+ buildClipFFmpegArgs('input with spaces.mp4', 'clip.mp4'),
+ ]) {
+ expect(args.slice(0, 4)).toEqual(['-threads', String(TRANSCODE_CONFIG.threads), '-i', 'input with spaces.mp4'])
+ const codecIndex = args.indexOf('libx265')
+ const outputThreadsIndex = args.indexOf('-threads', codecIndex)
+ expect(outputThreadsIndex).toBeGreaterThan(codecIndex)
+ expect(args[outputThreadsIndex + 1]).toBe(String(TRANSCODE_CONFIG.threads))
+ expect(args).toContain(`pools=${TRANSCODE_CONFIG.threads}:frame-threads=${TRANSCODE_CONFIG.threads}`)
+ }
+ })
+
+ test('工作目录只能位于配置的 UPLOAD_DIR,且远端下载同时检查声明长度和流式字节数', async () => {
+ const previousUploadDir = process.env.UPLOAD_DIR
+ const uploadRoot = path.join(process.cwd(), 'uploads-test-root')
+ process.env.UPLOAD_DIR = uploadRoot
+ try {
+ expect(resolveTranscodeOutputRoot('./uploads/transcoded')).toBe(path.join(uploadRoot, 'transcoded'))
+ expect(() => resolveTranscodeOutputRoot(path.resolve(process.cwd(), 'outside'))).toThrow('must stay inside UPLOAD_DIR')
+ } finally {
+ if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
+ else process.env.UPLOAD_DIR = previousUploadDir
+ }
+
+ const source = await readFile(path.join(process.cwd(), 'lib/video-processor.ts'), 'utf8')
+ expect(source).toContain("res.headers.get('content-length')")
+ expect(source).toContain('createByteLimitTransform(TRANSCODE_CONFIG.maxTempFileSize)')
+ expect(source).toContain("fetch(remoteUrl, { signal: abortSignal })")
+ expect(source).toContain("{ timeout: 30000, encoding: 'utf8', signal: abortSignal }")
+ expect(source).toContain('if (!space) {')
+ expect(source).toContain('无法确认 uploads/NAS 转码工作目录的剩余空间')
+ })
+
+ test('转码入队失败时保留源视频并维持周期扫描可恢复的 pending 状态', async () => {
+ const source = await readFile(path.join(process.cwd(), 'app/api/materials/upload/route.ts'), 'utf8')
+ const enqueueFailureBlock = source.slice(
+ source.indexOf('.catch(async (e: any) => {'),
+ source.indexOf('return NextResponse.json', source.indexOf('.catch(async (e: any) => {')),
+ )
+
+ expect(enqueueFailureBlock).toContain("videoProcessingStatus: 'pending'")
+ expect(enqueueFailureBlock).toContain('60 秒周期扫描会重新装载该任务')
+ expect(enqueueFailureBlock).toContain('transcodeLastError: fullErrorMsg.substring(0, 1000)')
+ expect(enqueueFailureBlock).not.toContain("videoProcessingStatus: 'failed'")
+ expect(enqueueFailureBlock).toContain('canonical 源视频必须保留')
+ expect(enqueueFailureBlock).not.toContain('deleteLocalFile')
+ expect(enqueueFailureBlock).not.toContain('Removed source video file')
+ })
+})
diff --git a/lib/__tests__/video-transcode-deploy-drain.test.ts b/lib/__tests__/video-transcode-deploy-drain.test.ts
new file mode 100644
index 0000000..77616b4
--- /dev/null
+++ b/lib/__tests__/video-transcode-deploy-drain.test.ts
@@ -0,0 +1,972 @@
+/** @jest-environment node */
+
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
+import { existsSync, readFileSync } from 'fs'
+import { spawnSync } from 'child_process'
+import os from 'os'
+import path from 'path'
+
+function extractBashFunction(source: string, name: string): string {
+ const match = source.match(new RegExp(`(?:^|\\n)${name}\\(\\) \\{[\\s\\S]*?\\n\\}`, 'm'))
+ if (!match) {
+ throw new Error(`Bash function not found: ${name}`)
+ }
+ return match[0].trimStart()
+}
+
+function findBash(): string | null {
+ const candidates = process.platform === 'win32'
+ ? [
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
+ 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
+ ]
+ : ['/bin/bash', '/usr/bin/bash']
+ return candidates.find(existsSync) || null
+}
+
+function toBashPath(value: string): string {
+ if (process.platform !== 'win32') return value
+ const match = value.match(/^([A-Za-z]):[\\/](.*)$/)
+ if (!match) return value.replace(/\\/g, '/')
+ return `/${match[1].toLowerCase()}/${match[2].replace(/\\/g, '/')}`
+}
+
+function runBashGate(
+ bash: string,
+ functions: string,
+ invocation: string,
+ variables: Record,
+) {
+ return spawnSync(bash, ['-c', `set -u\n${functions}\n${invocation}`], {
+ cwd: process.cwd(),
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ MSYS2_ARG_CONV_EXCL: '*',
+ MSYS_NO_PATHCONV: '1',
+ ...variables,
+ },
+ })
+}
+
+describe('production video transcode drain gate', () => {
+ let script: string
+
+ beforeAll(async () => {
+ script = await readFile(path.join(process.cwd(), 'scripts/git-incremental-deploy.ps1'), 'utf8')
+ })
+
+ test('先设置旧容器两类后台 worker drain 并确认 inactive,再启动候选容器', () => {
+ const drain = script.indexOf('request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"')
+ const taskDetailDrain = script.indexOf('request_task_detail_upload_drain "$TRANSCODE_DRAIN_SOURCE"', drain)
+ const candidate = script.indexOf('log "Starting candidate container $NEXT_CONTAINER"')
+
+ expect(drain).toBeGreaterThan(0)
+ expect(taskDetailDrain).toBeGreaterThan(drain)
+ expect(candidate).toBeGreaterThan(taskDetailDrain)
+ expect(script).toContain('wait_for_video_transcode_drain "$name"')
+ expect(script).toContain('wait_for_task_detail_upload_drain "$name"')
+ })
+
+ test('蓝绿入口存在时仍逐个排空意外运行的直连容器,且绝不强制删除', () => {
+ const discoverCurrent = script.indexOf('if ! CURRENT_CONTAINER="$(discover_current_container)"; then')
+ const collectDirect = script.indexOf('if [ -n "$LEGACY_IMAGE" ] && container_running "$CONTAINER_NAME"; then', discoverCurrent)
+ const drainLoop = script.indexOf('for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do', collectDirect)
+ const verifyLease = script.indexOf('verify_transcode_lease_schema "$TRANSCODE_DRAIN_SOURCE"', drainLoop)
+ const requestDrain = script.indexOf('request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"', verifyLease)
+ const candidate = script.indexOf('log "Starting candidate container $NEXT_CONTAINER"', requestDrain)
+ const finalDirectRemoval = script.lastIndexOf('remove_video_container_safely "$CONTAINER_NAME"')
+
+ expect(collectDirect).toBeGreaterThan(discoverCurrent)
+ expect(drainLoop).toBeGreaterThan(collectDirect)
+ expect(verifyLease).toBeGreaterThan(drainLoop)
+ expect(requestDrain).toBeGreaterThan(verifyLease)
+ expect(candidate).toBeGreaterThan(requestDrain)
+ expect(finalDirectRemoval).toBeGreaterThan(candidate)
+ expect(script).toContain('TRANSCODE_DRAIN_SOURCES="${TRANSCODE_DRAIN_SOURCES} ${CONTAINER_NAME}"')
+ expect(script).toContain('Legacy direct container could not be proven inactive and was preserved')
+ expect(script).not.toContain('docker rm -f "$CONTAINER_NAME"')
+ })
+
+ test('正式发布发现任何运行中的影子容器时,在触碰生产入口前 fail-closed', () => {
+ const shadowDiscovery = script.indexOf("RUNNING_CONTAINER_METADATA=\"$(docker ps --format")
+ const shadowFailure = script.indexOf('Running shadow container(s) detected; production deployment is blocked', shadowDiscovery)
+ const directTopologyGate = script.indexOf('LEGACY_IMAGE="$(docker inspect', shadowFailure)
+ const proxyMutation = script.indexOf('mkdir -p "$PROXY_ROOT"', directTopologyGate)
+ const currentDiscovery = script.indexOf('CURRENT_CONTAINER="$(discover_current_container)"', proxyMutation)
+
+ expect(shadowDiscovery).toBeGreaterThan(0)
+ expect(shadowFailure).toBeGreaterThan(shadowDiscovery)
+ expect(directTopologyGate).toBeGreaterThan(shadowFailure)
+ expect(proxyMutation).toBeGreaterThan(directTopologyGate)
+ expect(currentDiscovery).toBeGreaterThan(proxyMutation)
+ expect(script).toContain('if [ "$TARGET" = "production" ]; then')
+ expect(script).toContain('$1 == "youyidian-3d-material-shadow"')
+ expect(script).toContain('$1 ~ /^youyidian-3d-shadow($|[-_])/')
+ expect(script).toContain('{{.Label "com.docker.compose.service"}}')
+ expect(script).toContain('$2 == "app-shadow"')
+ expect(script).toContain('Cannot inspect running containers for shadow topology')
+ expect(script).toContain('until they are stopped and verified')
+ })
+
+ test('uploads 路径必须显式落入受控 allowlist,默认锁定当前正式 ZFS 数据集', () => {
+ expect(script).toContain("$defaultUploadsMount = '/mnt/PoolOne/youyidian-uploads'")
+ expect(script).toContain("'/mnt/PoolOne/apps/youyidian-3d/uploads'")
+ expect(script).toContain('[string]$UploadsMount = $env:GONGXIANGP2_UPLOADS_MOUNT')
+ expect(script).toContain('$trustedUploadsMounts -cnotcontains $UploadsMount')
+ expect(script).toContain("'__UPLOADS_MOUNT__' = ConvertTo-ShellSingleQuoted $UploadsMount")
+ expect(script).not.toContain("UPLOADS_MOUNT='/mnt/PoolOne/apps/youyidian-3d/uploads'")
+ })
+
+ test('findmnt 模拟拒绝根盘目录壳、多行或未受信 tuple,并接受两条显式路径', () => {
+ const bash = findBash()
+ expect(bash).not.toBeNull()
+ if (!bash) return
+
+ const helper = extractBashFunction(script, 'uploads_findmnt_metadata_is_trusted')
+ const invoke = 'uploads_findmnt_metadata_is_trusted "$CASE_PATH" "$CASE_MOUNT" "$CASE_ROOT"'
+ const run = (casePath: string, mount: string, root = '/ /dev/sda2 ext4') => runBashGate(
+ bash,
+ helper,
+ invoke,
+ { CASE_PATH: casePath, CASE_MOUNT: mount, CASE_ROOT: root },
+ )
+
+ expect(run(
+ '/mnt/PoolOne/youyidian-uploads',
+ '/mnt/PoolOne/youyidian-uploads PoolOne/youyidian-uploads zfs',
+ ).status).toBe(0)
+ expect(run(
+ '/mnt/PoolOne/apps/youyidian-3d/uploads',
+ '/mnt/PoolOne PoolOne zfs',
+ ).status).toBe(0)
+ expect(run(
+ '/mnt/PoolOne/youyidian-uploads',
+ '/ /dev/sda2 ext4',
+ ).status).not.toBe(0)
+ expect(run(
+ '/mnt/PoolOne/youyidian-uploads',
+ '/mnt/PoolOne/youyidian-uploads PoolOne/youyidian-uploads ext4',
+ ).status).not.toBe(0)
+ expect(run(
+ '/mnt/PoolOne/youyidian-uploads',
+ '/mnt/PoolOne/youyidian-uploads PoolOne/youyidian-uploads zfs\n/ /dev/sda2 ext4',
+ ).status).not.toBe(0)
+ })
+
+ test('Docker Mounts 模拟拒绝 volume、错 source、只读和重复 /app/uploads', () => {
+ const bash = findBash()
+ expect(bash).not.toBeNull()
+ if (!bash) return
+
+ const helper = extractBashFunction(script, 'uploads_container_mount_metadata_is_trusted')
+ const invoke = 'uploads_container_mount_metadata_is_trusted "$EXPECTED_SOURCE" "$CASE_METADATA"'
+ const expectedSource = '/mnt/PoolOne/youyidian-uploads'
+ const run = (metadata: string) => runBashGate(
+ bash,
+ helper,
+ invoke,
+ { EXPECTED_SOURCE: expectedSource, CASE_METADATA: metadata },
+ )
+
+ expect(run(`/app/uploads\tbind\t${expectedSource}\ttrue`).status).toBe(0)
+ expect(run('/app/uploads\tvolume\tuploads-volume\ttrue').status).not.toBe(0)
+ expect(run('/app/uploads\tbind\t/mnt/PoolOne/apps/youyidian-3d/uploads\ttrue').status).not.toBe(0)
+ expect(run(`/app/uploads\tbind\t${expectedSource}\tfalse`).status).not.toBe(0)
+ expect(run(
+ `/app/uploads\tbind\t${expectedSource}\ttrue\n/app/uploads\tbind\t${expectedSource}\ttrue`,
+ ).status).not.toBe(0)
+ })
+
+ test('statfs 模拟必须拒绝宿主根盘签名和宿主/容器不一致', () => {
+ const bash = findBash()
+ expect(bash).not.toBeNull()
+ if (!bash) return
+
+ const helpers = [
+ extractBashFunction(script, 'host_statfs_signature_is_trusted'),
+ extractBashFunction(script, 'statfs_signatures_match'),
+ ].join('\n')
+ const runHost = (uploads: string, root: string) => runBashGate(
+ bash,
+ helpers,
+ 'host_statfs_signature_is_trusted "$UPLOADS_SIGNATURE" "$ROOT_SIGNATURE"',
+ { UPLOADS_SIGNATURE: uploads, ROOT_SIGNATURE: root },
+ )
+ const runContainer = (host: string, container: string) => runBashGate(
+ bash,
+ helpers,
+ 'statfs_signatures_match "$HOST_SIGNATURE" "$CONTAINER_SIGNATURE"',
+ { HOST_SIGNATURE: host, CONTAINER_SIGNATURE: container },
+ )
+
+ expect(runHost('a1b2:2fc12fc1', '9abc:ef53').status).toBe(0)
+ expect(runHost('9abc:ef53', '9abc:ef53').status).not.toBe(0)
+ expect(runContainer('a1b2:2fc12fc1', 'a1b2:2fc12fc1').status).toBe(0)
+ expect(runContainer('a1b2:2fc12fc1', 'ffff:2fc12fc1').status).not.toBe(0)
+ })
+
+ test('宿主门禁先于所有容器/流量动作,候选严格 create→inspect→UPLOAD_DIR→start→statfs→health→复验→切流', () => {
+ const hostGate = script.indexOf('if ! verify_host_uploads_mount; then')
+ const firstContainerDiscovery = script.indexOf('RUNNING_CONTAINER_METADATA="$(docker ps --format', hostGate)
+ const firstLegacyInspect = script.indexOf('LEGACY_IMAGE="$(docker inspect', hostGate)
+ const drainLoop = script.indexOf('for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do', firstLegacyInspect)
+ const existingStorage = script.indexOf('verify_container_uploads_storage "$TRANSCODE_DRAIN_SOURCE"', drainLoop)
+ const existingDrain = script.indexOf('request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"', existingStorage)
+ const create = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"')
+ const mountInspect = script.indexOf('verify_container_uploads_mount_metadata "$NEXT_CONTAINER"', create)
+ const uploadDirInspect = script.indexOf('verify_container_upload_dir_config "$NEXT_CONTAINER" 1', mountInspect)
+ const start = script.indexOf('docker start "$NEXT_CONTAINER"', uploadDirInspect)
+ const statfs = script.indexOf('verify_container_uploads_statfs "$NEXT_CONTAINER"', start)
+ const uploadDirRuntime = script.indexOf('verify_container_upload_dir_runtime "$NEXT_CONTAINER" 1', statfs)
+ const health = script.indexOf('wait_for_container_health "$NEXT_CONTAINER"', uploadDirRuntime)
+ const writeProxy = script.indexOf('write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"', health)
+ const finalStorage = script.indexOf('verify_container_uploads_storage "$NEXT_CONTAINER"', writeProxy)
+ const switchTraffic = script.indexOf('if ! switch_proxy; then', finalStorage)
+
+ expect(hostGate).toBeGreaterThan(0)
+ expect(firstContainerDiscovery).toBeGreaterThan(hostGate)
+ expect(firstLegacyInspect).toBeGreaterThan(hostGate)
+ expect(existingStorage).toBeGreaterThan(drainLoop)
+ expect(existingDrain).toBeGreaterThan(existingStorage)
+ expect(create).toBeGreaterThan(existingDrain)
+ expect(mountInspect).toBeGreaterThan(create)
+ expect(uploadDirInspect).toBeGreaterThan(mountInspect)
+ expect(start).toBeGreaterThan(uploadDirInspect)
+ expect(statfs).toBeGreaterThan(start)
+ expect(uploadDirRuntime).toBeGreaterThan(statfs)
+ expect(health).toBeGreaterThan(uploadDirRuntime)
+ expect(finalStorage).toBeGreaterThan(writeProxy)
+ expect(switchTraffic).toBeGreaterThan(finalStorage)
+
+ for (const name of [
+ 'uploads_findmnt_metadata_is_trusted',
+ 'host_statfs_signature_is_trusted',
+ 'statfs_signatures_match',
+ 'uploads_container_mount_metadata_is_trusted',
+ 'verify_host_uploads_mount',
+ 'verify_container_uploads_mount_metadata',
+ 'verify_container_uploads_statfs',
+ 'verify_container_upload_dir_config',
+ 'verify_container_upload_dir_runtime',
+ 'restore_unreloaded_proxy_config',
+ ]) {
+ expect(extractBashFunction(script, name)).not.toContain('|| true')
+ }
+ const rejectCandidate = extractBashFunction(script, 'fail_candidate_before_traffic')
+ expect(rejectCandidate).not.toContain('|| true')
+ expect(rejectCandidate.indexOf('restore_unreloaded_proxy_config')).toBeLessThan(
+ rejectCandidate.indexOf('remove_video_container_safely "$NEXT_CONTAINER"'),
+ )
+ })
+
+ test('应用上传根目录必须由容器参数钉死到已验证的 /app/uploads', () => {
+ const createContainer = extractBashFunction(script, 'create_container')
+ const verifyConfig = extractBashFunction(script, 'verify_container_upload_dir_config')
+ const verifyRuntime = extractBashFunction(script, 'verify_container_upload_dir_runtime')
+
+ expect(createContainer.indexOf('--env-file "$ENV_FILE_PATH"')).toBeLessThan(
+ createContainer.indexOf('-e "UPLOAD_DIR=/app/uploads"'),
+ )
+ expect(verifyConfig).toContain('Container ${name} must declare exactly UPLOAD_DIR=/app/uploads')
+ expect(verifyConfig).toContain('Existing container ${name} has an unsafe UPLOAD_DIR override')
+ expect(verifyRuntime).toContain('if (resolved !== "/app/uploads") process.exit(1)')
+ expect(script).toContain('verify_container_uploads_storage "$NEXT_CONTAINER" 1')
+ })
+
+ test('上传路径关闭请求缓冲,并按 128MiB 上传与 20MiB 分片设置独立余量', () => {
+ const exactStart = script.indexOf('location = /api/materials/upload {')
+ const chunkStart = script.indexOf('location = /api/materials/upload/chunk {', exactStart)
+ const uploadPrefixStart = script.indexOf('location ^~ /api/materials/upload/ {', chunkStart)
+ const fallbackStart = script.indexOf('location / {', uploadPrefixStart)
+ const exactLocation = script.slice(exactStart, chunkStart)
+ const chunkLocation = script.slice(chunkStart, uploadPrefixStart)
+ const uploadPrefixLocation = script.slice(uploadPrefixStart, fallbackStart)
+ const globalLimitMatch = script.match(/http \{[\s\S]*?client_max_body_size (\d+)m;/)
+ const exactLimitMatch = exactLocation.match(/client_max_body_size (\d+)m;/)
+ const chunkLimitMatch = chunkLocation.match(/client_max_body_size (\d+)m;/)
+ const uploadPrefixLimitMatch = uploadPrefixLocation.match(/client_max_body_size (\d+)m;/)
+
+ expect(exactStart).toBeGreaterThan(0)
+ expect(chunkStart).toBeGreaterThan(exactStart)
+ expect(uploadPrefixStart).toBeGreaterThan(chunkStart)
+ expect(fallbackStart).toBeGreaterThan(uploadPrefixStart)
+ expect(exactLocation).toContain('proxy_request_buffering off;')
+ expect(chunkLocation).toContain('proxy_request_buffering off;')
+ expect(uploadPrefixLocation).toContain('proxy_request_buffering off;')
+ expect(Number(exactLimitMatch?.[1])).toBe(129)
+ expect(Number(chunkLimitMatch?.[1])).toBe(21)
+ expect(Number(uploadPrefixLimitMatch?.[1])).toBe(2)
+ expect(Number(globalLimitMatch?.[1])).toBe(16)
+ })
+
+ test('公开钉钉免登入口在代理层限制为 64KiB', () => {
+ const loginStart = script.indexOf('location = /api/dingtalk/h5-login {')
+ const nextLocation = script.indexOf('location = /api/materials/upload {', loginStart)
+ const loginLocation = script.slice(loginStart, nextLocation)
+
+ expect(loginStart).toBeGreaterThan(0)
+ expect(nextLocation).toBeGreaterThan(loginStart)
+ expect(loginLocation).toContain('client_max_body_size 64k;')
+ expect(loginLocation).toContain('proxy_request_buffering off;')
+ })
+
+ test('候选切流前必须同时验证健康依赖和精确部署提交', () => {
+ const readiness = script.indexOf('health_payload_is_ready() {')
+ const candidateHealth = script.indexOf('wait_for_container_health "$NEXT_CONTAINER" "$COMMIT_SHA"')
+ const firstSwitch = script.indexOf('switch_proxy; then', candidateHealth)
+
+ expect(readiness).toBeGreaterThan(0)
+ expect(candidateHealth).toBeGreaterThan(readiness)
+ expect(firstSwitch).toBeGreaterThan(candidateHealth)
+ expect(script).toContain("grep -Fq -- '\"status\":\"healthy\"'")
+ expect(script).toContain("grep -Fq -- '\"database\":{\"status\":\"ok\"'")
+ expect(script).toContain("grep -Fq -- '\"redis\":{\"status\":\"ok\"'")
+ expect(script).toContain('"\\"deploymentCommit\\":\\"${expected_commit}\\""')
+ expect(script).toContain('verify_proxy_target "$NEXT_CONTAINER" "$COMMIT_SHA"')
+ expect(script).toContain('container_healthy "$NEXT_CONTAINER" "$COMMIT_SHA"')
+ expect(script).toContain('listen [::]:${PORT} ipv6only=on;')
+ expect(script).toContain('runtime_proxy_container_at_url "http://[::1]:${PORT}/api/health"')
+ expect(script).toContain("curl --noproxy '*' -g -fsS --max-time 8 \"http://[::1]:${PORT}/api/health\"")
+ })
+
+ test('公网验收必须证明候选、精确提交、健康依赖、S3 与两张 Logo', () => {
+ const publicProbe = script.indexOf('Checking public FRP, authenticated S3, and Logo decode acceptance under the deployment lock')
+ const committedTarget = script.indexOf('COMMITTED_PUBLIC_TARGET="$(tr -d', publicProbe)
+ const payloadGate = script.indexOf('health_payload_is_ready /tmp/git-deploy-public-health.json "$COMMIT_SHA"', committedTarget)
+ const targetGate = script.indexOf('[ "$public_target_count" = "1" ] && [ "$public_target" = "$COMMITTED_PUBLIC_TARGET" ]', payloadGate)
+ const s3Gate = script.indexOf('https://s3.jiansun.vip/minio/health/live', targetGate)
+ const settings = script.indexOf('$PUBLIC_APP_URL/api/system-settings', s3Gate)
+ const configuredLogoValidation = script.indexOf("settings.headerLogoUrl, settings.loginLogoUrl", settings)
+ const configuredLogoFetch = script.indexOf('done {
+ const candidateHealth = script.indexOf('wait_for_container_health "$NEXT_CONTAINER" "$COMMIT_SHA"')
+ const databaseIdentity = script.indexOf('verify_candidate_database_identity "$NEXT_CONTAINER"', candidateHealth)
+ const brandAndStorage = script.indexOf('verify_candidate_brand_and_storage_dependencies "$NEXT_CONTAINER" "$NEXT_IP"', databaseIdentity)
+ const capability = script.indexOf('verify_video_transcode_capability "$NEXT_CONTAINER"', brandAndStorage)
+ const switchTraffic = script.indexOf('if ! switch_proxy; then', capability)
+
+ expect(candidateHealth).toBeGreaterThan(0)
+ expect(databaseIdentity).toBeGreaterThan(candidateHealth)
+ expect(brandAndStorage).toBeGreaterThan(databaseIdentity)
+ expect(capability).toBeGreaterThan(brandAndStorage)
+ expect(switchTraffic).toBeGreaterThan(capability)
+ expect(script).toContain('new HeadBucketCommand({ Bucket: process.env.S3_BUCKET })')
+ expect(script).toContain('verify_candidate_baked_brand_assets "$name"')
+ expect(script).toContain('verify_candidate_configured_brand_assets "$name" "$candidate_ip"')
+ })
+
+ test('构建机在任何拉代码或发布动作前必须证明 Node 可用', () => {
+ const preflight = script.indexOf('start_remote_step "Preflight"')
+ const requireNode = script.indexOf('require_cmd node', preflight)
+ const gitSync = script.indexOf('start_remote_step "Git Sync"', requireNode)
+
+ expect(preflight).toBeGreaterThan(0)
+ expect(requireNode).toBeGreaterThan(preflight)
+ expect(gitSync).toBeGreaterThan(requireNode)
+ })
+
+ test('生产镜像来源绑定 exact SHA,运行时环境变量不能冒充新代码', () => {
+ const dockerfile = readFileSync(path.join(process.cwd(), 'Dockerfile'), 'utf8')
+ const buildArg = script.indexOf('--build-arg "VCS_REF=$COMMIT_SHA"')
+ const productionLabel = script.indexOf('Production image revision label matches $COMMIT_SHA')
+ const candidateLabel = script.indexOf('verify_image_revision_label "$REGISTRY_COMMIT" "$COMMIT_SHA"')
+ const candidateFile = script.indexOf('verify_container_baked_revision "$NEXT_CONTAINER" "$COMMIT_SHA"')
+ const candidateHealth = script.indexOf('wait_for_container_health "$NEXT_CONTAINER" "$COMMIT_SHA"')
+
+ expect(script).toContain('Production deployment requires a freshly built and pushed exact-SHA image')
+ expect(buildArg).toBeGreaterThan(0)
+ expect(productionLabel).toBeGreaterThan(buildArg)
+ expect(candidateLabel).toBeGreaterThan(productionLabel)
+ expect(candidateFile).toBeGreaterThan(candidateLabel)
+ expect(candidateHealth).toBeGreaterThan(candidateFile)
+ expect(dockerfile).toContain('LABEL org.opencontainers.image.revision=$VCS_REF')
+ expect(dockerfile).toContain('/app/DEPLOYMENT_COMMIT')
+ expect(dockerfile).toContain('chmod 0444 /app/DEPLOYMENT_COMMIT')
+ })
+
+ test('Git 与 SSH 发布通道禁止明文凭据、盲信主机和宽泛临时脚本权限', () => {
+ expect(script).toContain("[string]$RepoUrl = 'https://git.1080p.hk/WuYongJie/gongxiangp2.git'")
+ expect(script).toContain('Inline Git credentials are forbidden')
+ expect(script).toContain("$trustedRepoUrl = 'https://git.1080p.hk/WuYongJie/gongxiangp2.git'")
+ expect(script).toContain('StrictHostKeyChecking=yes')
+ expect(script).toContain('-hostkey $BuildServerHostKey')
+ expect(script).toContain('Production host key fingerprint does not match the pinned value')
+ expect(script).not.toContain('StrictHostKeyChecking=no')
+ expect(script).not.toContain('http.extraHeader')
+ expect(script).not.toContain('__GIT_AUTH_HEADER__')
+ expect(script).toContain('mkdir -m 700')
+ expect(script).toContain("chmod 600 '$remoteTemp'")
+ expect(script).toContain("test ! -L '$remoteTemp'")
+ expect(script).toContain("test -O '$remoteTemp'")
+ })
+
+ test('远端构建目录只能使用已验证的专用仓库,绝不删除未知目录', () => {
+ expect(script).toContain("$trustedRemoteBuildDir = '/root/youyidian-3d-git-build'")
+ expect(script).toContain('[ "$REMOTE_BUILD_DIR" = \'/root/youyidian-3d-git-build\' ]')
+ expect(script).toContain('Pinned build directory is not the expected Git worktree; refusing destructive cleanup')
+ expect(script).toContain('[ "$(realpath -e "$REMOTE_BUILD_DIR")" = "$REMOTE_BUILD_DIR" ]')
+ expect(script).toContain('[ "$existing_origin" = "$REPO_URL" ]')
+ expect(script).not.toContain('rm -rf "$REMOTE_BUILD_DIR"')
+ expect(script).not.toContain('git remote set-url origin "$REPO_URL"')
+ })
+
+ test('生产按不可变 manifest digest 拉取并二次核对构建机 image ID', () => {
+ const captureId = script.indexOf('EXPECTED_IMAGE_ID="$(docker image inspect')
+ const captureDigest = script.indexOf('EXPECTED_MANIFEST_DIGEST="$(curl -fsSI', captureId)
+ const immutableReference = script.indexOf('REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}@${EXPECTED_MANIFEST_DIGEST}"', captureDigest)
+ const remoteCompare = script.indexOf('ACTUAL_PROD_IMAGE_ID="$(prod_ssh', immutableReference)
+ const payloadCompare = script.indexOf('ACTUAL_IMAGE_ID="$(docker image inspect', remoteCompare)
+ const createCandidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"', payloadCompare)
+
+ expect(captureId).toBeGreaterThan(0)
+ expect(captureDigest).toBeGreaterThan(captureId)
+ expect(immutableReference).toBeGreaterThan(captureDigest)
+ expect(remoteCompare).toBeGreaterThan(immutableReference)
+ expect(payloadCompare).toBeGreaterThan(remoteCompare)
+ expect(createCandidate).toBeGreaterThan(payloadCompare)
+ expect(script).toContain('Production image ID differs from the image built on the pinned build server')
+ expect(script).toContain('[ "$image_id" = "$EXPECTED_IMAGE_ID" ]')
+ })
+
+ test('Docker 构建保留 Sharp/SWC optional 平台包且迁移 SQL 位于最终 context allowlist', () => {
+ const dockerfile = readFileSync(path.join(process.cwd(), 'Dockerfile'), 'utf8')
+ const dockerignore = readFileSync(path.join(process.cwd(), '.dockerignore'), 'utf8')
+ const finalSqlIgnore = dockerignore.lastIndexOf('*.sql')
+ const finalMigrationInclude = dockerignore.lastIndexOf('!prisma/migrations/**')
+
+ expect(dockerfile).toContain('npm ci --prefer-offline')
+ expect(dockerfile).not.toContain('--omit=optional')
+ expect(dockerfile).toContain("const sharp=require('sharp')")
+ expect(dockerfile).toContain('runner brand image decode smoke passed')
+ expect(dockerfile).toContain("['header-logo.png','login-logo.png','favicon.png']")
+ expect(finalMigrationInclude).toBeGreaterThan(finalSqlIgnore)
+ })
+
+ ;(process.platform === 'win32' ? test : test.skip)(
+ 'Target=shadow 在任何 Git 扫描或远程操作前 fail-closed',
+ () => {
+ const deployScript = path.join(process.cwd(), 'scripts', 'git-incremental-deploy.ps1')
+ const powershell = path.join(
+ process.env.SystemRoot || 'C:\\Windows',
+ 'System32',
+ 'WindowsPowerShell',
+ 'v1.0',
+ 'powershell.exe',
+ )
+ const deploy = spawnSync(powershell, [
+ '-NoProfile',
+ '-ExecutionPolicy', 'Bypass',
+ '-File', deployScript,
+ '-Target', 'shadow',
+ '-DryRun',
+ ], { cwd: process.cwd(), encoding: 'utf8', timeout: 30_000 })
+ const output = `${deploy.stdout}\n${deploy.stderr}`
+
+ expect(deploy.status).not.toBe(0)
+ expect(output).toContain('Target=shadow is disabled fail-closed')
+ expect(output).toContain('uploads/runtime/Redis isolation')
+ expect(output).not.toContain('Resolving and scanning the exact Git tree')
+ expect(output).not.toContain('Connecting to build server')
+ },
+ 35_000,
+ )
+
+ test('旧镜像不能证明协议能力时 fail-closed,候选容器不会启动', () => {
+ expect(script).toContain("grep -Fqx -- 'VIDEO_TRANSCODE_DRAIN_PROTOCOL_V1'")
+ expect(script).toContain('-e "VIDEO_TRANSCODE_INSTANCE_ID=$transcode_instance_id"')
+ expect(script).toContain('grep -Fqx -- "instanceId=$instance_id"')
+ expect(script).toContain('if ! verify_video_transcode_capability "$NEXT_CONTAINER"; then')
+ expect(script).toContain('candidate was not started (first rollout fails closed)')
+ expect(script).toContain('apply the additive Prisma migration before deployment')
+ })
+
+ test('即使没有旧容器,候选容器也必须独立验证租约字段后才能切流', () => {
+ const candidateSchema = script.indexOf('verify_transcode_lease_schema "$NEXT_CONTAINER"')
+ const switchTraffic = script.indexOf('write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"')
+
+ expect(candidateSchema).toBeGreaterThan(0)
+ expect(switchTraffic).toBeGreaterThan(candidateSchema)
+ expect(script).toContain('materials_transcode_lease_idx')
+ expect(script).toContain('20260811_add_video_transcode_lease')
+ expect(script).toContain('finished_at IS NOT NULL AND rolled_back_at IS NULL')
+ expect(script).toContain('transcode_lease_token");')
+ expect(script).toContain('token.data_type === "text" && token.is_nullable === "YES"')
+ expect(script).toContain('expires.data_type === "timestamp without time zone" && expires.is_nullable === "YES"')
+ expect(script).toContain('String(migrations[0].checksum || "") === sourceChecksum')
+ expect(script).toContain('!/\\bUNIQUE\\b|\\bWHERE\\b\/i.test(indexDefinition)')
+ expect(script).toContain('!columnsOk || !indexOk || !migrationOk')
+ expect(script).toContain('traffic was not switched')
+ })
+
+ test('旧域名扫描绑定精确目标 SHA,并将同一 SHA 固定给远程构建', () => {
+ const fetchTarget = script.indexOf('"+refs/heads/${TargetBranch}:refs/preflight/branch"')
+ const resolveTarget = script.indexOf("'rev-parse', '--verify', 'refs/preflight/branch^{commit}'")
+ const checkoutTarget = script.indexOf("'worktree', 'add', '--detach', $targetCheckout, $targetCommit")
+ const scanTarget = script.indexOf('& $nodeCommand.Source $scannerPath', checkoutTarget)
+ const preflight = script.indexOf("Write-Info 'Resolving and scanning the exact Git tree that will be deployed...'")
+ const bindCommit = script.indexOf("$tokens['__COMMIT__'] = ConvertTo-ShellSingleQuoted $resolvedTargetCommit")
+ const renderRemote = script.indexOf('$remoteScript = $remoteTemplate')
+ const remoteDeploy = script.indexOf('$exitCode = Invoke-BuildServerScript -ScriptText $remoteScript')
+ const remoteTemplateStart = script.indexOf("$remoteTemplate = @'")
+ const remoteTemplateEnd = script.indexOf("\n'@", remoteTemplateStart)
+ const remoteTemplate = script.slice(remoteTemplateStart, remoteTemplateEnd)
+
+ expect(script).toContain("Join-Path $PSScriptRoot 'check-legacy-storage-boundaries.mjs'")
+ expect(script).toContain('Get-Command node -ErrorAction SilentlyContinue')
+ expect(script).toContain('Legacy storage boundary preflight failed for target $targetCommit with exit code')
+ expect(fetchTarget).toBeGreaterThan(0)
+ expect(resolveTarget).toBeGreaterThan(fetchTarget)
+ expect(checkoutTarget).toBeGreaterThan(resolveTarget)
+ expect(scanTarget).toBeGreaterThan(checkoutTarget)
+ expect(preflight).toBeGreaterThan(0)
+ expect(bindCommit).toBeGreaterThan(preflight)
+ expect(renderRemote).toBeGreaterThan(bindCommit)
+ expect(remoteDeploy).toBeGreaterThan(renderRemote)
+ expect(remoteTemplate).not.toContain('Invoke-LegacyStorageBoundaryPreflight')
+ expect(script.indexOf('function Invoke-LegacyStorageBoundaryPreflight')).toBeLessThan(remoteTemplateStart)
+ expect(remoteTemplate).toContain('TARGET_REF="$COMMIT_INPUT"')
+ expect(remoteTemplate).toContain('[ "$COMMIT_SHA" = "$COMMIT_INPUT" ]')
+ })
+
+ ;(process.platform === 'win32' ? test : test.skip)(
+ '分支最新树通过时,-Commit 指向含旧域名的旧提交仍会在远程操作前拒绝',
+ async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'video-deploy-target-test-'))
+ const repository = path.join(tempRoot, 'repository')
+ const badSource = path.join(repository, 'app', 'deploy-target.ts')
+ const scanner = path.join(process.cwd(), 'scripts', 'check-legacy-storage-boundaries.mjs')
+ const deployScript = path.join(process.cwd(), 'scripts', 'git-incremental-deploy.ps1')
+ const powershell = path.join(
+ process.env.SystemRoot || 'C:\\Windows',
+ 'System32',
+ 'WindowsPowerShell',
+ 'v1.0',
+ 'powershell.exe',
+ )
+ const runGit = (...args: string[]) => {
+ const result = spawnSync('git', args, { cwd: repository, encoding: 'utf8' })
+ if (result.status !== 0) {
+ throw new Error(`git ${args.join(' ')} failed: ${result.stdout}\n${result.stderr}`)
+ }
+ return result.stdout.trim()
+ }
+
+ try {
+ await mkdir(path.dirname(badSource), { recursive: true })
+ runGit('init')
+ runGit('config', 'user.name', 'Codex Test')
+ runGit('config', 'user.email', 'codex-test@example.invalid')
+ await writeFile(badSource, "export const unsafeLogo = 'https://s3.1080p.hk/bucket/logo.png'\n", 'utf8')
+ runGit('add', '.')
+ runGit('-c', 'commit.gpgsign=false', 'commit', '-m', 'unsafe old target')
+ const oldCommit = runGit('rev-parse', 'HEAD')
+
+ await writeFile(badSource, "export const safeLogo = '/brand/header-logo.png'\n", 'utf8')
+ runGit('add', '.')
+ runGit('-c', 'commit.gpgsign=false', 'commit', '-m', 'safe branch head')
+ runGit('branch', '-M', 'main')
+
+ const latestScan = spawnSync(process.execPath, [scanner], { cwd: repository, encoding: 'utf8' })
+ expect(latestScan.status).toBe(0)
+
+ const deploy = spawnSync(powershell, [
+ '-NoProfile',
+ '-ExecutionPolicy', 'Bypass',
+ '-File', deployScript,
+ '-RepoUrl', repository,
+ '-Branch', 'main',
+ '-Commit', oldCommit,
+ '-DryRun',
+ '-BuildServerPassword', 'local-test-only',
+ '-BuildServerProxyCommand', 'must-not-run',
+ ], { cwd: process.cwd(), encoding: 'utf8', timeout: 60_000 })
+ const output = `${deploy.stdout}\n${deploy.stderr}`
+
+ expect(deploy.status).not.toBe(0)
+ expect(output).toContain('app/deploy-target.ts')
+ expect(output).toContain('Legacy storage boundary preflight failed for target')
+ expect(output).not.toContain('Cannot find plink.exe')
+ }
+ finally {
+ await rm(tempRoot, { recursive: true, force: true })
+ }
+ },
+ 70_000,
+ )
+
+ test('候选容器切流前必须验证品牌与上传幂等数据库门禁', () => {
+ const dockerfile = readFileSync(path.join(process.cwd(), 'Dockerfile'), 'utf8')
+ const leaseSchema = script.indexOf('verify_transcode_lease_schema "$NEXT_CONTAINER"')
+ const brandDefaults = script.indexOf('verify_brand_logo_defaults "$NEXT_CONTAINER"')
+ const uploadIdempotency = script.indexOf('verify_material_upload_idempotency_schema "$NEXT_CONTAINER"')
+ const switchTraffic = script.indexOf('write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"')
+
+ expect(brandDefaults).toBeGreaterThan(leaseSchema)
+ expect(uploadIdempotency).toBeGreaterThan(brandDefaults)
+ expect(switchTraffic).toBeGreaterThan(uploadIdempotency)
+ expect(script).toContain('normalize(defaults.get("headerLogoUrl")) === "/brand/header-logo.png"')
+ expect(script).toContain('normalize(defaults.get("loginLogoUrl")) === "/brand/login-logo.png"')
+ expect(script).toContain('!defaults.has("logoUrl") || normalize(defaults.get("logoUrl")) === "/brand/header-logo.png"')
+ expect(script).toContain('SELECT id, \\"headerLogoUrl\\", \\"loginLogoUrl\\" FROM \\"system_settings\\" ORDER BY id')
+ expect(script).toContain('parsed.hostname === "s3.jiansun.vip"')
+ expect(script).toContain('parsed.pathname.startsWith("/gongxiangpan/")')
+ expect(script).toContain('const rowsOk = brandRows.every')
+ expect(script).toContain('!migrationOk || !rowsOk')
+ expect(script).toContain('20260811_update_brand_logo_defaults')
+ expect(script).toContain('20251118000000_add_upload_id_to_materials')
+ expect(script).toContain('materials_uploadId_key')
+ expect(script).toContain('column.data_type === "text"')
+ expect(script).toContain('column.is_nullable === "YES"')
+ expect(script).toContain('index.access_method === "btree"')
+ expect(script).toContain('Number(index.indnkeyatts) === 1')
+ expect(script).toContain('keyColumns.length === 1 && keyColumns[0] === "uploadId"')
+ expect(script).toContain('normalizedPredicate === "uploadidisnotnullanddeletedatisnull"')
+ expect(script).toContain('migrationByName.get(historicalMigrationName) === historicalSourceChecksum')
+ expect(script).toContain('migrationByName.get(normalizationMigrationName) === normalizationSourceChecksum')
+ expect(script).toContain('20260812_align_material_upload_idempotency_index')
+ expect(dockerfile).toContain('/app/prisma/migrations/20251118000000_add_upload_id_to_materials/migration.sql')
+ expect(dockerfile).toContain('COPY --from=builder --chown=root:root /app/prisma/migrations/20251118000000_add_upload_id_to_materials')
+ expect(script).toContain('finished_at IS NOT NULL AND rolled_back_at IS NULL')
+ expect(script).toContain('traffic was not switched')
+ })
+
+ test('候选容器带预置 drain 启动,全部门禁和稳定切流后才恢复 worker', () => {
+ const preDrain = script.indexOf(': > "$(transcode_drain_file "$NEXT_CONTAINER")"')
+ const preTaskDetailDrain = script.indexOf(': > "$(task_detail_drain_file "$NEXT_CONTAINER")"', preDrain)
+ const createCandidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"')
+ const inspectCandidate = script.indexOf('verify_container_uploads_mount_metadata "$NEXT_CONTAINER"', createCandidate)
+ const startCandidate = script.indexOf('docker start "$NEXT_CONTAINER"', inspectCandidate)
+ const health = script.indexOf('wait_for_container_health "$NEXT_CONTAINER"', startCandidate)
+ const lease = script.indexOf('verify_transcode_lease_schema "$NEXT_CONTAINER"', health)
+ const taskDetailLease = script.indexOf('verify_task_detail_upload_lease_schema "$NEXT_CONTAINER"', lease)
+ const brand = script.indexOf('verify_brand_logo_defaults "$NEXT_CONTAINER"', taskDetailLease)
+ const uploadIdempotency = script.indexOf('verify_material_upload_idempotency_schema "$NEXT_CONTAINER"', brand)
+ const capability = script.indexOf('verify_video_transcode_capability "$NEXT_CONTAINER"', uploadIdempotency)
+ const taskDetailCapability = script.indexOf('verify_task_detail_upload_capability "$NEXT_CONTAINER"', capability)
+ const taskDetailInactive = script.indexOf('wait_for_task_detail_upload_drain "$NEXT_CONTAINER" 2', taskDetailCapability)
+ const proxyConfig = script.indexOf('write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"', taskDetailInactive)
+ const resumeCandidate = script.indexOf('resume_video_transcode "$NEXT_CONTAINER"', proxyConfig)
+ const resumeTaskDetail = script.indexOf('resume_task_detail_upload "$NEXT_CONTAINER"', resumeCandidate)
+ const workerCommit = script.indexOf('BACKGROUND_WORKERS_COMMITTED=1', resumeTaskDetail)
+ const oldStop = script.indexOf('docker stop --time 30 "$CURRENT_CONTAINER"', workerCommit)
+
+ expect(preDrain).toBeGreaterThan(0)
+ expect(preTaskDetailDrain).toBeGreaterThan(preDrain)
+ expect(createCandidate).toBeGreaterThan(preTaskDetailDrain)
+ expect(inspectCandidate).toBeGreaterThan(createCandidate)
+ expect(startCandidate).toBeGreaterThan(inspectCandidate)
+ expect(health).toBeGreaterThan(startCandidate)
+ expect(lease).toBeGreaterThan(health)
+ expect(taskDetailLease).toBeGreaterThan(lease)
+ expect(brand).toBeGreaterThan(taskDetailLease)
+ expect(uploadIdempotency).toBeGreaterThan(brand)
+ expect(capability).toBeGreaterThan(uploadIdempotency)
+ expect(taskDetailCapability).toBeGreaterThan(capability)
+ expect(taskDetailInactive).toBeGreaterThan(taskDetailCapability)
+ expect(proxyConfig).toBeGreaterThan(taskDetailInactive)
+ expect(resumeCandidate).toBeGreaterThan(proxyConfig)
+ expect(resumeTaskDetail).toBeGreaterThan(resumeCandidate)
+ expect(workerCommit).toBeGreaterThan(resumeTaskDetail)
+ expect(oldStop).toBeGreaterThan(workerCommit)
+ expect(script).toContain('remove_video_container_safely "$NEXT_CONTAINER"')
+ expect(script).not.toContain('docker rm -f "$NEXT_CONTAINER"')
+ })
+
+ test('worker 提交后任何晚期失败都不会解除旧槽 drain', () => {
+ const trap = script.slice(
+ script.indexOf('resume_transcode_after_failed_deploy() {'),
+ script.indexOf('trap resume_transcode_after_failed_deploy EXIT'),
+ )
+
+ expect(trap).toContain('[ "$BACKGROUND_WORKERS_COMMITTED" != "1" ]')
+ expect(trap).toContain('for target in $TRANSCODE_DRAIN_TARGETS')
+ expect(trap).toContain('for target in $TASK_DETAIL_DRAIN_TARGETS')
+ expect(trap).toContain('previous background-worker drain markers remain enabled')
+ expect(script.indexOf('BACKGROUND_WORKERS_COMMITTED=1')).toBeLessThan(
+ script.indexOf('systemctl start "$PROXY_RECONCILE_SERVICE"'),
+ )
+ const switchBlock = script.indexOf('SWITCH_REPORTED_FAILURE=0')
+ const trafficMaySwitch = script.indexOf('BACKGROUND_WORKERS_COMMITTED=1', switchBlock)
+ const switchTraffic = script.indexOf('if ! switch_proxy; then', trafficMaySwitch)
+ expect(trafficMaySwitch).toBeGreaterThan(switchBlock)
+ expect(switchTraffic).toBeGreaterThan(trafficMaySwitch)
+ const abortStart = script.indexOf('abort_after_uncertain_switch() {')
+ const abortEnd = script.indexOf('# The historical shadow topology', abortStart)
+ const abort = script.slice(abortStart, abortEnd)
+ expect(abort).toContain('if [ "${PROXY_LOCK_HELD:-0}" != "1" ]')
+ expect(abort).toContain('flock -w 30 8')
+ expect(script).toContain('abort_after_uncertain_switch "Proxy reconciler failed its post-deployment verification"')
+ })
+
+ test('普通发布与首次 bootstrap 共用全局锁并只消费当前未消费 handoff', () => {
+ const lock = script.indexOf('flock -w 30 9')
+ const handoffGate = script.indexOf('verify_bootstrap_handoff_if_present')
+ const candidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"', handoffGate)
+ const consume = script.indexOf('mv -- "$READY_BOOTSTRAP_HANDOFF/handoff.ready" "$READY_BOOTSTRAP_HANDOFF/consumed"', candidate)
+
+ expect(lock).toBeGreaterThan(0)
+ expect(handoffGate).toBeGreaterThan(lock)
+ expect(candidate).toBeGreaterThan(handoffGate)
+ expect(consume).toBeGreaterThan(candidate)
+ expect(script).toContain('[ ! -e "$handoff/consumed" ] || continue')
+ expect(script).toContain('The maintenance entry requires exactly one unconsumed bootstrap handoff')
+ expect(script).toContain('A normal blue-green entry cannot consume a stale bootstrap handoff')
+ expect(script).toContain('verify_ready_bootstrap_handoff_evidence "$READY_BOOTSTRAP_HANDOFF"')
+ expect(script).toContain('maintenance_entry_is_ready_after_restore || fail "Bootstrap maintenance entry is not an exact dual-stack 503 handoff"')
+ expect(script).toContain('Preserved legacy container is not the exact stopped handoff identity')
+ expect(script).toContain('uploads_container_mount_metadata_is_trusted "$UPLOADS_MOUNT" "$mount_metadata"')
+ })
+
+ test('maintenance 缺 handoff 会拒绝,已消费的旧 SHA 不阻塞后续蓝绿发布', async () => {
+ const bash = findBash()
+ if (!bash) return
+ const root = await mkdtemp(path.join(os.tmpdir(), 'deploy-handoff-test-'))
+ const attempt = path.join(root, 'attempt-1')
+ const exactSha = 'a'.repeat(40)
+ const nextSha = 'b'.repeat(40)
+ const legacyId = '1'.repeat(64)
+ const imageId = `sha256:${'2'.repeat(64)}`
+ const repoDigest = `registry.example/app@sha256:${'3'.repeat(64)}`
+ const preserved = `youyidian-3d-material-legacy-${exactSha.slice(0, 12)}-${legacyId.slice(0, 12)}`
+ const uploadsSignature = 'abcd:1234'
+ const support = `
+fail() { printf '%s\\n' "$*" >&2; exit 91; }
+runtime_proxy_container() { printf '%s\\n' "\${MOCK_RUNTIME_TARGET:-}"; }
+is_proxy_running() { [ "\${MOCK_PROXY_RUNNING:-0}" = "1" ]; }
+maintenance_entry_is_ready_after_restore() { [ "\${MOCK_MAINTENANCE_READY:-0}" = "1" ]; }
+stat() {
+ if [ "$1" = "-c" ] && [ "$2" = "%a" ]; then
+ if [ "$3" = "\${MOCK_HANDOFF_DIR}" ]; then printf '700\\n'; else printf '600\\n'; fi
+ return 0
+ fi
+ command stat "$@"
+}
+docker() {
+ if [ "$1" = "inspect" ] && [ "$2" = "--format" ]; then
+ case "$3" in
+ *Mounts*) printf '/app/uploads\\tbind\\t%s\\ttrue\\n' "\${MOCK_MOUNT_SOURCE}" ;;
+ *) printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "\${MOCK_CONTAINER_ID}" "\${MOCK_IMAGE_ID}" "\${MOCK_STATUS}" "\${MOCK_RUNNING}" "\${MOCK_PAUSED}" ;;
+ esac
+ return 0
+ fi
+ if [ "$1" = "image" ] && [ "$2" = "inspect" ]; then
+ printf '%s\\n' "\${MOCK_REPO_DIGEST}"
+ return 0
+ fi
+ if [ "$1" = "port" ]; then
+ printf '0.0.0.0:%s\\n[::]:%s\\n' "\${PORT}" "\${PORT}"
+ return 0
+ fi
+ return 97
+}
+`
+ const functions = [
+ support,
+ extractBashFunction(script, 'container_uploads_mount_metadata'),
+ extractBashFunction(script, 'uploads_container_mount_metadata_is_trusted'),
+ extractBashFunction(script, 'verify_ready_bootstrap_handoff_evidence'),
+ extractBashFunction(script, 'verify_bootstrap_handoff_if_present'),
+ ].join('\n')
+ const baseVariables = {
+ TARGET: 'production', BOOTSTRAP_ROOT: toBashPath(root), COMMIT_SHA: exactSha,
+ READY_BOOTSTRAP_HANDOFF: '', MOCK_PROXY_RUNNING: '1', MOCK_RUNTIME_TARGET: '',
+ MOCK_MAINTENANCE_READY: '1', MOCK_CONTAINER_ID: legacyId, MOCK_IMAGE_ID: imageId,
+ MOCK_STATUS: 'exited', MOCK_RUNNING: 'false', MOCK_PAUSED: 'false',
+ MOCK_REPO_DIGEST: repoDigest, MOCK_MOUNT_SOURCE: '/mnt/PoolOne/youyidian-uploads',
+ CONTAINER_NAME: 'youyidian-3d-material', HOST_UPLOADS_STATFS_SIGNATURE: uploadsSignature,
+ UPLOADS_MOUNT: '/mnt/PoolOne/youyidian-uploads', PORT: '4320',
+ MOCK_HANDOFF_DIR: toBashPath(attempt),
+ }
+
+ try {
+ const missing = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present', {
+ ...baseVariables, BOOTSTRAP_ROOT: toBashPath(path.join(root, 'missing')),
+ })
+ expect(missing.status).not.toBe(0)
+
+ await mkdir(attempt, { recursive: true })
+ const evidence = {
+ 'target-commit': exactSha,
+ 'legacy-container-id': legacyId,
+ 'legacy-image-id': imageId,
+ 'legacy-image-repo-digest': repoDigest,
+ 'legacy-upstream': '172.18.0.4:3000',
+ 'uploads-statfs-signature': uploadsSignature,
+ 'preserved-container': preserved,
+ 'handoff.ready': 'ready',
+ }
+ for (const [name, value] of Object.entries(evidence)) {
+ await writeFile(path.join(attempt, name), `${value}\n`, 'utf8')
+ }
+ const ready = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present; printf "%s" "$READY_BOOTSTRAP_HANDOFF"', {
+ ...baseVariables,
+ })
+ expect(ready.status).toBe(0)
+ expect(ready.stdout).toContain('attempt-1')
+
+ for (const overrides of [
+ { MOCK_MAINTENANCE_READY: '0' },
+ { MOCK_STATUS: 'running', MOCK_RUNNING: 'true' },
+ { MOCK_PAUSED: 'true' },
+ { MOCK_CONTAINER_ID: '4'.repeat(64) },
+ { MOCK_IMAGE_ID: `sha256:${'5'.repeat(64)}` },
+ { MOCK_REPO_DIGEST: `registry.example/app@sha256:${'6'.repeat(64)}` },
+ { MOCK_MOUNT_SOURCE: '/wrong/uploads' },
+ ]) {
+ const rejected = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present', {
+ ...baseVariables, ...overrides,
+ })
+ expect(rejected.status).not.toBe(0)
+ }
+
+ await rm(path.join(attempt, 'legacy-upstream'))
+ const incomplete = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present', baseVariables)
+ expect(incomplete.status).not.toBe(0)
+ await writeFile(path.join(attempt, 'legacy-upstream'), '172.18.0.4:3000\n', 'utf8')
+
+ await rm(path.join(attempt, 'handoff.ready'))
+ await writeFile(path.join(attempt, 'consumed'), 'ready\n', 'utf8')
+ const later = runBashGate(bash, functions, 'verify_bootstrap_handoff_if_present; printf "%s" "$READY_BOOTSTRAP_HANDOFF"', {
+ ...baseVariables, COMMIT_SHA: nextSha, MOCK_RUNTIME_TARGET: 'youyidian-3d-material-blue',
+ })
+ expect(later.status).toBe(0)
+ expect(later.stdout).toBe('')
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+ }, 30_000)
+
+ test('迁移审计由隔离 root 只读解析,并在候选创建前绑定数据库实际身份', () => {
+ const audit = script.indexOf('verify_migration_audit_evidence')
+ const rootParser = script.indexOf('--read-only --user 0:0 --cap-drop ALL --security-opt no-new-privileges', audit)
+ const identity = script.indexOf('verify_candidate_database_identity "$NEXT_CONTAINER"', rootParser)
+ const switchTraffic = script.indexOf('if ! switch_proxy; then', identity)
+
+ expect(audit).toBeGreaterThan(0)
+ expect(rootParser).toBeGreaterThan(audit)
+ expect(identity).toBeGreaterThan(rootParser)
+ expect(switchTraffic).toBeGreaterThan(identity)
+ expect(script).toContain('for await (const chunk of process.stdin)')
+ expect(script).not.toContain('process.stdin.on("end", async () => {')
+ })
+
+ test('候选数据库身份 Node 门禁能消费有效 stdin,坏 JSON 必须失败', () => {
+ const functionStart = script.indexOf('verify_candidate_database_identity() {')
+ const nodeStartMarker = "node -e '\n"
+ const nodeStart = script.indexOf(nodeStartMarker, functionStart) + nodeStartMarker.length
+ const nodeEnd = script.indexOf("\n' < \"$MIGRATION_AUDIT_FILE\"", nodeStart)
+ const nodeSource = script.slice(nodeStart, nodeEnd)
+ const prismaMock = `
+const Module = require('module');
+const originalLoad = Module._load;
+Module._load = function(request, parent, isMain) {
+ if (request === '@prisma/client') return { PrismaClient: class {
+ async $queryRawUnsafe() { return [{ database: 'prod', user: 'app', server_address: '10.0.0.8', server_port: '5432' }]; }
+ async $disconnect() {}
+ } };
+ return originalLoad.call(this, request, parent, isMain);
+};
+`
+ const audit = JSON.stringify({ database: {
+ configuredHost: 'db.internal', configuredPort: '5432', configuredDatabase: 'prod', configuredUser: 'app',
+ actualDatabase: 'prod', actualUser: 'app', serverAddress: '10.0.0.8', serverPort: '5432',
+ } })
+ const env = { ...process.env, DATABASE_URL: 'postgresql://app:secret@db.internal:5432/prod' }
+ const valid = spawnSync(process.execPath, ['-e', `${prismaMock}\n${nodeSource}`], { input: audit, encoding: 'utf8', env })
+ const invalid = spawnSync(process.execPath, ['-e', `${prismaMock}\n${nodeSource}`], { input: '{bad', encoding: 'utf8', env })
+
+ expect(functionStart).toBeGreaterThan(0)
+ expect(nodeEnd).toBeGreaterThan(nodeStart)
+ expect(valid.status).toBe(0)
+ expect(invalid.status).not.toBe(0)
+ })
+
+ test('Logo 解码 Node 门禁接受真实 PNG,损坏输入必须失败', () => {
+ const functionStart = script.indexOf('decode_image_stream_with_candidate() {')
+ const nodeStartMarker = "node -e '\n"
+ const nodeStart = script.indexOf(nodeStartMarker, functionStart) + nodeStartMarker.length
+ const nodeEnd = script.indexOf("\n'\n}", nodeStart)
+ const nodeSource = script.slice(nodeStart, nodeEnd)
+ const validPng = readFileSync(path.join(process.cwd(), 'public', 'brand', 'header-logo.png'))
+ const valid = spawnSync(process.execPath, ['-e', nodeSource], { input: validPng, encoding: 'buffer' })
+ const invalid = spawnSync(process.execPath, ['-e', nodeSource], { input: Buffer.from('not-an-image'), encoding: 'buffer' })
+
+ expect(functionStart).toBeGreaterThan(0)
+ expect(nodeEnd).toBeGreaterThan(nodeStart)
+ expect(valid.status).toBe(0)
+ expect(invalid.status).not.toBe(0)
+ })
+
+ test('部署失败会恢复旧容器 drain,停止旧容器前再次核对 active 标记', () => {
+ expect(script).toContain('trap resume_transcode_after_failed_deploy EXIT')
+ expect(script).toContain('for target in $TRANSCODE_DRAIN_TARGETS')
+ expect(script).toContain('resume_video_transcode "$target"')
+ expect(script).toContain('for target in $TASK_DETAIL_DRAIN_TARGETS')
+ expect(script).toContain('resume_task_detail_upload "$target"')
+ const videoIdle = script.indexOf('wait_for_video_transcode_drain "$CURRENT_CONTAINER" 2 &&')
+ const taskDetailIdle = script.indexOf('wait_for_task_detail_upload_drain "$CURRENT_CONTAINER" 2 &&', videoIdle)
+ const stop = script.indexOf('docker stop --time 30 "$CURRENT_CONTAINER"', taskDetailIdle)
+ expect(videoIdle).toBeGreaterThan(0)
+ expect(taskDetailIdle).toBeGreaterThan(videoIdle)
+ expect(stop).toBeGreaterThan(taskDetailIdle)
+ })
+
+ test('stable proxy 缺失且存在 legacy direct 时,在任何容器或流量动作前 fail-closed', () => {
+ const topologyGate = script.indexOf('# This general deployment entry must never perform the one-time direct-to-proxy')
+ const inspectLegacy = script.indexOf('LEGACY_IMAGE="$(docker inspect', topologyGate)
+ const checkProxy = script.indexOf('if is_proxy_running; then', inspectLegacy)
+ const failClosed = script.indexOf('Use the controlled one-time direct-to-proxy migration SOP', checkProxy)
+ const proxyMutation = script.indexOf('mkdir -p "$PROXY_ROOT"', failClosed)
+ const drainExisting = script.indexOf('request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"', proxyMutation)
+ const removeInactiveSlot = script.indexOf('remove_video_container_safely "$NEXT_CONTAINER"', drainExisting)
+ const startCandidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"', removeInactiveSlot)
+ const switchTraffic = script.indexOf('if ! switch_proxy; then', startCandidate)
+ const stopPrevious = script.indexOf('docker stop --time 30 "$CURRENT_CONTAINER"', switchTraffic)
+
+ expect(topologyGate).toBeGreaterThan(0)
+ expect(inspectLegacy).toBeGreaterThan(topologyGate)
+ expect(checkProxy).toBeGreaterThan(inspectLegacy)
+ expect(failClosed).toBeGreaterThan(checkProxy)
+ expect(proxyMutation).toBeGreaterThan(failClosed)
+ expect(drainExisting).toBeGreaterThan(proxyMutation)
+ expect(removeInactiveSlot).toBeGreaterThan(drainExisting)
+ expect(startCandidate).toBeGreaterThan(removeInactiveSlot)
+ expect(switchTraffic).toBeGreaterThan(startCandidate)
+ expect(stopPrevious).toBeGreaterThan(switchTraffic)
+ expect(script).toContain('no container or traffic was changed')
+ })
+
+ test('通用脚本不包含 automatic direct→proxy migration 或 direct rollback', () => {
+ const rollbackStart = script.indexOf('rollback_to_previous_entry() {')
+ const rollbackEnd = script.indexOf('abort_after_uncertain_switch() {', rollbackStart)
+ const rollback = script.slice(rollbackStart, rollbackEnd)
+ const operationalStart = script.indexOf('# This general deployment entry must never perform the one-time direct-to-proxy')
+ const operational = script.slice(operationalStart)
+
+ expect(rollback).toContain('[ "$PROXY_WAS_RUNNING" = "1" ]')
+ expect(rollback).not.toContain('[ "$PROXY_WAS_RUNNING" != "1" ]')
+ expect(rollback).not.toContain('create_container "$CONTAINER_NAME"')
+ expect(rollback).not.toContain('stop_proxy')
+ expect(operational).not.toContain('migrating public entry from direct container to proxy')
+ expect(operational).not.toContain('proxy migration was not attempted')
+ })
+})
diff --git a/lib/__tests__/video-transcode-lease.test.ts b/lib/__tests__/video-transcode-lease.test.ts
new file mode 100644
index 0000000..034accf
--- /dev/null
+++ b/lib/__tests__/video-transcode-lease.test.ts
@@ -0,0 +1,526 @@
+/** @jest-environment node */
+
+import { access, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import path from 'path'
+import { VideoTranscodeQueue } from '@/lib/video-transcode-queue'
+
+type MaterialState = {
+ id: string
+ materialType: string
+ videoSourceUrl: string | null
+ videoCoverUrl: string | null
+ videoPreview540Url?: string | null
+ videoProcessingStatus: string | null
+ transcodeLeaseToken: string | null
+ transcodeLeaseExpiresAt: Date | null
+ transcodeLastError?: string | null
+ deletedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+function createPrismaHarness(
+ initial: MaterialState[],
+ databaseClock: () => Date = () => new Date(),
+) {
+ const materials = new Map(initial.map((item) => [item.id, { ...item }]))
+ const progress = new Map()
+ let transactionTail = Promise.resolve()
+
+ const matches = (material: MaterialState, where: any): boolean => {
+ if (!where) return true
+ if (where.id !== undefined && material.id !== where.id) return false
+ if (typeof where.videoProcessingStatus === 'string' && material.videoProcessingStatus !== where.videoProcessingStatus) return false
+ if (where.videoProcessingStatus?.not !== undefined && material.videoProcessingStatus === where.videoProcessingStatus.not) return false
+ if (where.deletedAt === null && material.deletedAt !== null) return false
+ if (where.materialType?.in && !where.materialType.in.includes(material.materialType)) return false
+ if (where.videoSourceUrl?.not === null && material.videoSourceUrl === null) return false
+ if (where.transcodeLeaseToken === null && material.transcodeLeaseToken !== null) return false
+ if (typeof where.transcodeLeaseToken === 'string' && material.transcodeLeaseToken !== where.transcodeLeaseToken) return false
+ if (where.transcodeLeaseToken?.not === null && material.transcodeLeaseToken === null) return false
+ if (where.transcodeLeaseExpiresAt === null && material.transcodeLeaseExpiresAt !== null) return false
+ if (where.transcodeLeaseExpiresAt?.gt && (!material.transcodeLeaseExpiresAt || material.transcodeLeaseExpiresAt <= where.transcodeLeaseExpiresAt.gt)) return false
+ if (where.transcodeLeaseExpiresAt?.lte && (!material.transcodeLeaseExpiresAt || material.transcodeLeaseExpiresAt > where.transcodeLeaseExpiresAt.lte)) return false
+ if (where.updatedAt instanceof Date && material.updatedAt.getTime() !== where.updatedAt.getTime()) return false
+ if (where.OR && !where.OR.some((part: any) => matches(material, part))) return false
+ return true
+ }
+
+ const prisma: any = {
+ $queryRawUnsafe: jest.fn(async (sql: string) => {
+ if (sql.includes('clock_timestamp()')) {
+ return [{ now: databaseClock() }]
+ }
+ return []
+ }),
+ $transaction: jest.fn((callback: (tx: any) => Promise) => {
+ let release!: () => void
+ const previous = transactionTail
+ transactionTail = new Promise((resolve) => { release = resolve })
+ return previous.then(() => callback(prisma)).finally(release)
+ }),
+ material: {
+ findFirst: jest.fn(async ({ where }: any) => Array.from(materials.values()).find((item) => matches(item, where)) || null),
+ findUnique: jest.fn(async ({ where }: any) => materials.get(where.id) || null),
+ findMany: jest.fn(async ({ where }: any) => Array.from(materials.values())
+ .filter((item) => matches(item, where))
+ .map((item) => ({ ...item, transcodeProgresses: [] }))),
+ count: jest.fn(async ({ where }: any) => Array.from(materials.values()).filter((item) => matches(item, where)).length),
+ updateMany: jest.fn(async ({ where, data }: any) => {
+ let count = 0
+ for (const item of Array.from(materials.values())) {
+ if (matches(item, where)) {
+ Object.assign(item, data, { updatedAt: new Date() })
+ count += 1
+ }
+ }
+ return { count }
+ }),
+ },
+ videoTranscodeProgress: {
+ upsert: jest.fn(async ({ where, create, update }: any) => {
+ const key = `${where.materialId_stage.materialId}:${where.materialId_stage.stage}`
+ const current = progress.get(key)
+ const next = current ? { ...current, ...update } : { ...create }
+ progress.set(key, next)
+ return next
+ }),
+ findMany: jest.fn(async () => Array.from(progress.entries()).map(([key, row]) => {
+ const materialId = key.split(':')[0]
+ return { ...row, materialId, material: materials.get(materialId) }
+ })),
+ },
+ }
+
+ return { prisma, materials, progress }
+}
+
+function material(id: string): MaterialState {
+ return {
+ id,
+ materialType: '淘系-主图视频',
+ videoSourceUrl: `/uploads/${id}.mp4`,
+ videoCoverUrl: null,
+ videoProcessingStatus: 'pending',
+ transcodeLeaseToken: null,
+ transcodeLeaseExpiresAt: null,
+ deletedAt: null,
+ createdAt: new Date('2026-08-11T00:00:00Z'),
+ updatedAt: new Date('2026-08-11T00:00:00Z'),
+ }
+}
+
+async function waitUntil(predicate: () => boolean, timeoutMs = 5_000) {
+ const deadline = Date.now() + timeoutMs
+ while (!predicate()) {
+ if (Date.now() > deadline) throw new Error('waitUntil timeout')
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ }
+}
+
+function isQueueIdle(queue: VideoTranscodeQueue): boolean {
+ const status = queue.getStatus()
+ return status.queueLength === 0 && status.processingCount === 0
+}
+
+async function wakeAndDrainQueueForTest(queue: VideoTranscodeQueue): Promise {
+ await waitUntil(() => !(queue as any).pumping, 2_000)
+ const retryTimer = (queue as any).retryTimer as ReturnType | null
+ if (retryTimer) {
+ clearTimeout(retryTimer)
+ ;(queue as any).retryTimer = null
+ }
+ await (queue as any).pump()
+ try {
+ await waitUntil(() => isQueueIdle(queue), 5_000)
+ } catch {
+ throw new Error(`queue did not become idle: ${JSON.stringify({
+ status: queue.getDetailedStatus(),
+ pumping: (queue as any).pumping,
+ retryScheduled: Boolean((queue as any).retryTimer),
+ })}`)
+ }
+}
+
+async function stopDrainedQueueForTest(queue: VideoTranscodeQueue): Promise {
+ await waitUntil(() => !(queue as any).pumping, 2_000)
+ const retryTimer = (queue as any).retryTimer as ReturnType | null
+ if (retryTimer) {
+ clearTimeout(retryTimer)
+ ;(queue as any).retryTimer = null
+ }
+ ;((queue as any).queue as unknown[]).length = 0
+ await waitUntil(() => isQueueIdle(queue))
+}
+
+describe('video transcode database lease', () => {
+ let runtimeRoot: string
+
+ beforeEach(async () => {
+ runtimeRoot = await mkdtemp(path.join(tmpdir(), 'video-lease-test-'))
+ })
+
+ afterEach(async () => {
+ await rm(runtimeRoot, { recursive: true, force: true })
+ })
+
+ test('add 在任务完成前返回,转码继续在后台执行', async () => {
+ const harness = createPrismaHarness([material('a')])
+ const queue = new VideoTranscodeQueue({
+ prisma: harness.prisma,
+ workerId: 'worker-a',
+ instanceId: 'worker-a-instance-1',
+ runtimeRoot,
+ heartbeatIntervalMs: 20,
+ })
+ let finish!: () => void
+ const taskFinished = new Promise((resolve) => { finish = resolve })
+ let started = false
+
+ await queue.add('a', '/uploads/a.mp4', true, async () => {
+ started = true
+ await taskFinished
+ })
+
+ await waitUntil(() => started)
+ expect(queue.getStatus().processingCount).toBe(1)
+ const capability = await readFile(path.join(runtimeRoot, 'capabilities', 'worker-a.v1'), 'utf8')
+ expect(capability).toContain('workerId=worker-a\n')
+ expect(capability).toContain('instanceId=worker-a-instance-1\n')
+ const activeMarker = path.join(runtimeRoot, 'active', 'worker-a.json')
+ const firstHeartbeat = (await stat(activeMarker)).mtimeMs
+ await new Promise((resolve) => setTimeout(resolve, 60))
+ expect((await stat(activeMarker)).mtimeMs).toBeGreaterThan(firstHeartbeat)
+ finish()
+ await waitUntil(() => isQueueIdle(queue))
+ await expect(access(activeMarker)).rejects.toBeDefined()
+ })
+
+ test('两个容器共享数据库时,全局最多只有一个有效租约', async () => {
+ const harness = createPrismaHarness([material('a'), material('b')])
+ const queueA = new VideoTranscodeQueue({ prisma: harness.prisma, workerId: 'blue', runtimeRoot })
+ const queueB = new VideoTranscodeQueue({ prisma: harness.prisma, workerId: 'green', runtimeRoot })
+ const releases = new Map void>()
+ const starts: string[] = []
+ let active = 0
+ let maxActive = 0
+ const task = async (id: string) => {
+ starts.push(id)
+ active += 1
+ maxActive = Math.max(maxActive, active)
+ await new Promise((resolve) => releases.set(id, resolve))
+ active -= 1
+ }
+
+ await Promise.all([
+ queueA.add('a', '/uploads/a.mp4', true, task),
+ queueB.add('b', '/uploads/b.mp4', true, task),
+ ])
+
+ await waitUntil(() => starts.length === 1)
+ expect(maxActive).toBe(1)
+ releases.get(starts[0])?.()
+ await waitUntil(() => starts.length === 2, 6_000)
+ expect(maxActive).toBe(1)
+ releases.get(starts[1])?.()
+ await waitUntil(() => active === 0 && isQueueIdle(queueA) && isQueueIdle(queueB))
+ }, 10_000)
+
+ test('续租与新领取共用 advisory lock,等待中的旧续租不会在新租约后复活', async () => {
+ const renewing = material('renewing')
+ renewing.videoProcessingStatus = 'processing'
+ renewing.transcodeLeaseToken = 'renew-token'
+ renewing.transcodeLeaseExpiresAt = new Date(Date.now() + 30_000)
+ const harness = createPrismaHarness([renewing, material('next-material')])
+ const queue = new VideoTranscodeQueue({
+ prisma: harness.prisma,
+ workerId: 'renew-acquire-race',
+ runtimeRoot,
+ leaseDurationMs: 60_000,
+ })
+
+ const originalUpdateMany = harness.prisma.material.updateMany.getMockImplementation()!
+ const originalFindFirst = harness.prisma.material.findFirst.getMockImplementation()!
+ let allowRenewUpdate!: () => void
+ const renewUpdateGate = new Promise((resolve) => { allowRenewUpdate = resolve })
+ let markRenewUpdateStarted!: () => void
+ const renewUpdateStarted = new Promise((resolve) => { markRenewUpdateStarted = resolve })
+ const events: string[] = []
+
+ harness.prisma.material.updateMany.mockImplementation(async (args: any) => {
+ if (args.where?.id === 'renewing' && args.where?.transcodeLeaseToken === 'renew-token') {
+ events.push('renew-update-start')
+ markRenewUpdateStarted()
+ await renewUpdateGate
+ events.push('renew-update-finish')
+ }
+ return originalUpdateMany(args)
+ })
+ harness.prisma.material.findFirst.mockImplementation(async (args: any) => {
+ events.push('acquire-active-scan')
+ return originalFindFirst(args)
+ })
+
+ const renewPromise = queue.renewLease('renewing', 'renew-token')
+ await renewUpdateStarted
+ const acquirePromise = (queue as any).tryAcquireGlobalLease('next-material')
+
+ // 第二个事务已提交给 Prisma,但 advisory lock 仍由续租事务持有,
+ // 所以领取逻辑尚不能扫描或写入另一份租约。
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(events).toEqual(['renew-update-start'])
+
+ allowRenewUpdate()
+ await expect(renewPromise).resolves.toBe(true)
+ await expect(acquirePromise).resolves.toEqual({ status: 'busy' })
+
+ expect(events).toEqual([
+ 'renew-update-start',
+ 'renew-update-finish',
+ 'acquire-active-scan',
+ ])
+ expect(harness.materials.get('renewing')?.transcodeLeaseToken).toBe('renew-token')
+ expect(harness.materials.get('next-material')?.transcodeLeaseToken).toBeNull()
+ const leaseSql = harness.prisma.$queryRawUnsafe.mock.calls.map(([sql]: [string]) => sql)
+ expect(leaseSql).toEqual([
+ 'SELECT 1 AS locked FROM pg_advisory_xact_lock($1)',
+ 'SELECT clock_timestamp() AS "now"',
+ 'SELECT 1 AS locked FROM pg_advisory_xact_lock($1)',
+ 'SELECT clock_timestamp() AS "now"',
+ ])
+ })
+
+ test('仅回收已过期租约,保留 canonical 源文件和现有预览引用', async () => {
+ // 数据库比应用机快很多:旧实现用应用时钟预筛会把这条租约误当成未来有效,
+ // 新实现必须仍按数据库 clock_timestamp() 正确回收。
+ const databaseNow = new Date('2099-08-12T00:00:00Z')
+ const expired = material('expired')
+ expired.videoProcessingStatus = 'processing'
+ expired.transcodeLeaseToken = 'old-token'
+ expired.transcodeLeaseExpiresAt = new Date(databaseNow.getTime() - 60_000)
+ expired.videoPreview540Url = 'https://s3.jiansun.vip/existing-preview.mp4'
+ const harness = createPrismaHarness([expired], () => databaseNow)
+ harness.progress.set('expired:preview', { status: 'waiting', stage: 'preview', priority: 0, createdAt: new Date() })
+ const queue = new VideoTranscodeQueue({ prisma: harness.prisma, workerId: 'recovery', runtimeRoot })
+ await mkdir(path.join(runtimeRoot, 'drain'), { recursive: true })
+ await writeFile(path.join(runtimeRoot, 'drain', 'recovery'), '')
+
+ const recovered = await queue.runRecoveryScan(jest.fn(), { requeuePending: true })
+ const after = harness.materials.get('expired')!
+
+ expect(recovered).toBe(1)
+ expect(after.transcodeLeaseToken).toBeNull()
+ expect(after.transcodeLeaseExpiresAt).toBeNull()
+ expect(after.videoProcessingStatus).toBe('pending')
+ expect(after.videoSourceUrl).toBe('/uploads/expired.mp4')
+ expect(after.videoPreview540Url).toBe('https://s3.jiansun.vip/existing-preview.mp4')
+
+ // 解除测试 drain,让已重新入队的后台任务真正排空;不能把 retry timer 留到用例结束后。
+ await rm(path.join(runtimeRoot, 'drain', 'recovery'), { force: true })
+ await wakeAndDrainQueueForTest(queue)
+ })
+
+ test('应用时钟快于数据库时,不会把数据库仍有效的租约重置为 pending', async () => {
+ const databaseNow = new Date('2020-08-12T00:00:00Z')
+ const active = material('database-active')
+ active.videoProcessingStatus = 'processing'
+ active.transcodeLeaseToken = 'database-valid-token'
+ active.transcodeLeaseExpiresAt = new Date(databaseNow.getTime() + 60_000)
+ const harness = createPrismaHarness([active], () => databaseNow)
+ const queue = new VideoTranscodeQueue({
+ prisma: harness.prisma,
+ workerId: 'clock-skew-worker',
+ runtimeRoot,
+ })
+ const taskFn = jest.fn(async () => {})
+
+ await queue.add('database-active', '/uploads/database-active.mp4', true, taskFn)
+
+ expect(taskFn).not.toHaveBeenCalled()
+ expect(queue.getStatus()).toMatchObject({ queueLength: 0, processingCount: 0 })
+ expect(harness.materials.get('database-active')).toMatchObject({
+ videoProcessingStatus: 'processing',
+ transcodeLeaseToken: 'database-valid-token',
+ transcodeLeaseExpiresAt: active.transcodeLeaseExpiresAt,
+ })
+ })
+
+ test('续租失败会向正在运行的 ffmpeg 链路发送取消信号', async () => {
+ const harness = createPrismaHarness([material('abort-me')])
+ const queue = new VideoTranscodeQueue({
+ prisma: harness.prisma,
+ workerId: 'abort-worker',
+ runtimeRoot,
+ heartbeatIntervalMs: 20,
+ })
+ let started = false
+ let aborted = false
+
+ await queue.add('abort-me', '/uploads/abort-me.mp4', true, async (_id, _url, _cover, _token, signal) => {
+ started = true
+ await new Promise((resolve) => {
+ signal.addEventListener('abort', () => {
+ aborted = true
+ resolve()
+ }, { once: true })
+ })
+ })
+
+ await waitUntil(() => started)
+ harness.materials.get('abort-me')!.transcodeLeaseToken = 'taken-by-another-worker'
+ await waitUntil(() => aborted)
+ await waitUntil(() => isQueueIdle(queue))
+ })
+
+ test('心跳瞬时异常会终止执行并把当前 token 的任务恢复为 pending', async () => {
+ const harness = createPrismaHarness([material('heartbeat-error')])
+ const queue = new VideoTranscodeQueue({
+ prisma: harness.prisma,
+ workerId: 'heartbeat-error-worker',
+ runtimeRoot,
+ heartbeatIntervalMs: 20,
+ })
+ const renewSpy = jest.spyOn(queue, 'renewLease').mockRejectedValueOnce(new Error('database heartbeat unavailable'))
+ let started = false
+ let aborted = false
+
+ await queue.add('heartbeat-error', '/uploads/heartbeat-error.mp4', true, async (_id, _url, _cover, _token, signal) => {
+ started = true
+ await new Promise((resolve) => signal.addEventListener('abort', () => {
+ aborted = true
+ resolve()
+ }, { once: true }))
+ })
+
+ await waitUntil(() => started)
+ await waitUntil(() => aborted)
+ await waitUntil(() => isQueueIdle(queue))
+
+ expect(renewSpy).toHaveBeenCalled()
+ expect(harness.materials.get('heartbeat-error')).toMatchObject({
+ videoProcessingStatus: 'pending',
+ transcodeLeaseToken: null,
+ transcodeLeaseExpiresAt: null,
+ videoSourceUrl: '/uploads/heartbeat-error.mp4',
+ })
+ expect(harness.prisma.material.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ videoProcessingStatus: 'failed' }),
+ }))
+
+ const recoveredTask = jest.fn(async () => {})
+ await expect(queue.runRecoveryScan(recoveredTask)).resolves.toBeGreaterThanOrEqual(1)
+ await waitUntil(() => isQueueIdle(queue))
+ expect(recoveredTask).toHaveBeenCalled()
+ })
+
+ test('active 标记写入时才出现 drain,也不会调用 taskFn', async () => {
+ const harness = createPrismaHarness([material('drain-race')])
+ const queue = new VideoTranscodeQueue({
+ prisma: harness.prisma,
+ workerId: 'drain-race-worker',
+ runtimeRoot,
+ heartbeatIntervalMs: 20,
+ })
+ const taskFn = jest.fn(async () => {})
+ const originalWriteActiveMarker = (queue as any).writeActiveMarker.bind(queue)
+ let injectDrain = true
+ ;(queue as any).writeActiveMarker = async (materialId: string, leaseToken: string) => {
+ await originalWriteActiveMarker(materialId, leaseToken)
+ if (injectDrain) {
+ injectDrain = false
+ await mkdir(path.join(runtimeRoot, 'drain'), { recursive: true })
+ await writeFile(path.join(runtimeRoot, 'drain', 'drain-race-worker'), '')
+ }
+ }
+
+ await queue.add('drain-race', '/uploads/drain-race.mp4', true, taskFn)
+ await waitUntil(() => queue.getStatus().queueLength === 1 && queue.getStatus().processingCount === 0)
+
+ expect(taskFn).not.toHaveBeenCalled()
+ expect(harness.materials.get('drain-race')).toMatchObject({
+ videoProcessingStatus: 'pending',
+ transcodeLeaseToken: null,
+ transcodeLeaseExpiresAt: null,
+ })
+ await expect(access(path.join(runtimeRoot, 'active', 'drain-race-worker.json'))).rejects.toBeDefined()
+
+ // 任务在 drain 期间应保持排队;测试结束前显式取消本地测试队列和 retry timer,
+ // 避免后台日志越过 Jest 用例边界。数据库中的 pending 事实仍保留给下次恢复扫描。
+ await stopDrainedQueueForTest(queue)
+ expect(taskFn).not.toHaveBeenCalled()
+ })
+
+ test.each(['EACCES', 'EIO', 'ESTALE'])(
+ 'drain 标记读取返回 %s 时 fail-closed,不领取或启动任务',
+ async (errorCode) => {
+ const harness = createPrismaHarness([material(`drain-${errorCode}`)])
+ const drainAccess = jest.fn(async () => {
+ throw Object.assign(new Error(`simulated ${errorCode}`), { code: errorCode })
+ })
+ const queue = new VideoTranscodeQueue({
+ prisma: harness.prisma,
+ workerId: `drain-${errorCode}-worker`,
+ runtimeRoot,
+ drainAccess,
+ })
+ const taskFn = jest.fn(async () => {})
+
+ await queue.add(`drain-${errorCode}`, `/uploads/drain-${errorCode}.mp4`, true, taskFn)
+ await waitUntil(() => !(queue as any).pumping)
+
+ expect(drainAccess).toHaveBeenCalled()
+ expect(taskFn).not.toHaveBeenCalled()
+ expect(queue.getStatus()).toMatchObject({ queueLength: 1, processingCount: 0 })
+ expect(harness.materials.get(`drain-${errorCode}`)).toMatchObject({
+ videoProcessingStatus: 'pending',
+ transcodeLeaseToken: null,
+ })
+ await stopDrainedQueueForTest(queue)
+ },
+ )
+
+ test('drain 标记读取仅 ENOENT 表示未排空,允许正常领取并完成任务', async () => {
+ const harness = createPrismaHarness([material('drain-enoent')])
+ const drainAccess = jest.fn(async () => {
+ throw Object.assign(new Error('simulated ENOENT'), { code: 'ENOENT' })
+ })
+ const queue = new VideoTranscodeQueue({
+ prisma: harness.prisma,
+ workerId: 'drain-enoent-worker',
+ runtimeRoot,
+ drainAccess,
+ })
+ const taskFn = jest.fn(async () => {})
+
+ await queue.add('drain-enoent', '/uploads/drain-enoent.mp4', true, taskFn)
+ await waitUntil(() => isQueueIdle(queue))
+
+ expect(drainAccess).toHaveBeenCalled()
+ expect(taskFn).toHaveBeenCalledTimes(1)
+ expect(harness.materials.get('drain-enoent')).toMatchObject({
+ transcodeLeaseToken: null,
+ })
+ })
+
+ test('租约释放后旧 token 的阶段进度写入也会被拒绝', async () => {
+ const leased = material('stale-progress')
+ leased.videoProcessingStatus = 'processing'
+ leased.transcodeLeaseToken = 'old-token'
+ leased.transcodeLeaseExpiresAt = new Date(Date.now() + 60_000)
+ const harness = createPrismaHarness([leased])
+ const queue = new VideoTranscodeQueue({ prisma: harness.prisma, workerId: 'stale-worker', runtimeRoot })
+
+ await expect(queue.releaseLease('stale-progress', 'old-token')).resolves.toBe(true)
+ await expect(queue.updateProgress('stale-progress', 'old-token', 100, 'preview')).resolves.toBe(false)
+ expect(harness.progress.size).toBe(0)
+ expect(harness.prisma.$queryRawUnsafe).toHaveBeenCalledWith(
+ 'SELECT 1 AS locked FROM pg_advisory_xact_lock($1)',
+ expect.any(Number),
+ )
+ expect(harness.prisma.$queryRawUnsafe.mock.calls.every(
+ ([sql]: [string]) => !sql.startsWith('SELECT pg_advisory_xact_lock'),
+ )).toBe(true)
+ })
+})
diff --git a/lib/__tests__/video-transcode-recovery.test.ts b/lib/__tests__/video-transcode-recovery.test.ts
new file mode 100644
index 0000000..a42f1b7
--- /dev/null
+++ b/lib/__tests__/video-transcode-recovery.test.ts
@@ -0,0 +1,57 @@
+/** @jest-environment node */
+
+import {
+ createVideoTranscodeRecoveryScheduler,
+ VIDEO_TRANSCODE_RECOVERY_INTERVAL_MS,
+} from '@/lib/video-transcode-recovery'
+
+describe('video transcode startup recovery scheduler', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ test('创建后立即扫描,并按 60 秒周期继续扫描', async () => {
+ const scan = jest.fn().mockResolvedValue(0)
+ const scheduler = createVideoTranscodeRecoveryScheduler({ scan })
+
+ await scheduler.initial
+ expect(scan).toHaveBeenCalledTimes(1)
+
+ await jest.advanceTimersByTimeAsync(VIDEO_TRANSCODE_RECOVERY_INTERVAL_MS)
+ expect(scan).toHaveBeenCalledTimes(2)
+ scheduler.stop()
+ })
+
+ test('本轮失败不会取消定时器,下一轮会重试', async () => {
+ const onError = jest.fn()
+ const scan = jest.fn()
+ .mockRejectedValueOnce(new Error('database unavailable'))
+ .mockResolvedValueOnce(2)
+ const scheduler = createVideoTranscodeRecoveryScheduler({ scan, onError })
+
+ await expect(scheduler.initial).rejects.toThrow('database unavailable')
+ expect(onError).toHaveBeenCalledTimes(1)
+
+ await jest.advanceTimersByTimeAsync(VIDEO_TRANSCODE_RECOVERY_INTERVAL_MS)
+ expect(scan).toHaveBeenCalledTimes(2)
+ scheduler.stop()
+ })
+
+ test('上一轮未结束时不启动重叠扫描', async () => {
+ let release!: (value: number) => void
+ const pending = new Promise((resolve) => { release = resolve })
+ const scan = jest.fn(() => pending)
+ const scheduler = createVideoTranscodeRecoveryScheduler({ scan })
+
+ await jest.advanceTimersByTimeAsync(VIDEO_TRANSCODE_RECOVERY_INTERVAL_MS * 2)
+ expect(scan).toHaveBeenCalledTimes(1)
+
+ release(0)
+ await scheduler.initial
+ scheduler.stop()
+ })
+})
diff --git a/lib/__tests__/video-transcode-task-lease.test.ts b/lib/__tests__/video-transcode-task-lease.test.ts
new file mode 100644
index 0000000..a3cd3dc
--- /dev/null
+++ b/lib/__tests__/video-transcode-task-lease.test.ts
@@ -0,0 +1,433 @@
+/** @jest-environment node */
+
+import {
+ executeTranscodeTask,
+ StaleTranscodeLeaseError,
+ TranscodeCommitStateUnknownError,
+} from '@/lib/video-transcode-task'
+import { prisma } from '@/lib/prisma'
+import { processVideoTranscode } from '@/lib/video-processor'
+import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: {
+ findUnique: jest.fn(),
+ updateMany: jest.fn(),
+ },
+ $queryRaw: jest.fn(),
+ $transaction: jest.fn(),
+ },
+}))
+
+jest.mock('@/lib/video-processor', () => ({
+ processVideoTranscode: jest.fn(),
+}))
+
+jest.mock('@/lib/file-cleanup', () => ({
+ deleteFileIfUnreferenced: jest.fn(),
+}))
+
+jest.mock('@/lib/video-transcode-queue', () => ({
+ transcodeQueue: {
+ updateProgress: jest.fn().mockResolvedValue(true),
+ },
+}))
+
+describe('executeTranscodeTask lease fencing', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
+ videoSourceUrl: 'https://s3.jiansun.vip/source.mp4',
+ videoCoverUrl: null,
+ previewUrl: '',
+ })
+ ;(prisma.$queryRaw as jest.Mock).mockResolvedValue([{
+ videoCoverUrl: null,
+ previewUrl: '',
+ }])
+ ;(prisma.$transaction as jest.Mock).mockImplementation(
+ async (callback: (tx: typeof prisma) => Promise) => callback(prisma),
+ )
+ ;(processVideoTranscode as jest.Mock).mockResolvedValue({
+ preview540Url: 'https://s3.jiansun.vip/preview.mp4',
+ clipUrl: 'https://s3.jiansun.vip/clip.mp4',
+ coverUrl: 'https://s3.jiansun.vip/cover.jpg',
+ duration: 12,
+ })
+ ;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({ success: true, skipped: false })
+ })
+
+ test('租约在完成前失效时,旧 token 不能写入完成结果或失败状态', async () => {
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 0 })
+
+ await expect(executeTranscodeTask(
+ 'material-1',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'stale-token',
+ )).rejects.toBeInstanceOf(StaleTranscodeLeaseError)
+
+ expect(prisma.material.updateMany).toHaveBeenCalledTimes(2)
+ expect(prisma.material.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ videoProcessingStatus: 'failed' }),
+ }))
+ expect(deleteFileIfUnreferenced).toHaveBeenCalledTimes(3)
+ expect(deleteFileIfUnreferenced).toHaveBeenCalledWith('https://s3.jiansun.vip/preview.mp4')
+ expect(deleteFileIfUnreferenced).toHaveBeenCalledWith('https://s3.jiansun.vip/clip.mp4')
+ expect(deleteFileIfUnreferenced).toHaveBeenCalledWith('https://s3.jiansun.vip/cover.jpg')
+ })
+
+ test('开始写入就发现 token 失效时立即停止,不运行 ffmpeg', async () => {
+ ;(prisma.material.updateMany as jest.Mock).mockResolvedValueOnce({ count: 0 })
+
+ await expect(executeTranscodeTask(
+ 'material-2',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'expired-token',
+ )).rejects.toBeInstanceOf(StaleTranscodeLeaseError)
+
+ expect(processVideoTranscode).not.toHaveBeenCalled()
+ })
+
+ test('完成事务响应丢失但精确回读一致时视为已提交并保留生成物', async () => {
+ const commitAckLost = new Error('connection closed after commit')
+ let transactionCall = 0
+ ;(prisma.$transaction as jest.Mock).mockImplementation(
+ async (callback: (tx: typeof prisma) => Promise) => {
+ transactionCall += 1
+ const value = await callback(prisma)
+ if (transactionCall === 2) throw commitAckLost
+ return value
+ },
+ )
+ ;(prisma.material.findUnique as jest.Mock)
+ .mockResolvedValueOnce({
+ videoSourceUrl: 'https://s3.jiansun.vip/source.mp4',
+ videoCoverUrl: null,
+ previewUrl: '',
+ })
+ .mockResolvedValueOnce({
+ videoProcessingStatus: 'done',
+ videoPreview540Url: 'https://s3.jiansun.vip/preview.mp4',
+ videoPreviewClipUrl: 'https://s3.jiansun.vip/clip.mp4',
+ videoCoverUrl: 'https://s3.jiansun.vip/cover.jpg',
+ previewUrl: 'https://s3.jiansun.vip/cover.jpg',
+ videoDuration: 12,
+ transcodeLastError: null,
+ transcodeAttempts: 1,
+ })
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 1 })
+
+ await expect(executeTranscodeTask(
+ 'material-commit-ack-lost',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'valid-token',
+ )).resolves.toBeUndefined()
+
+ expect(prisma.material.findUnique).toHaveBeenCalledTimes(2)
+ expect(deleteFileIfUnreferenced).not.toHaveBeenCalled()
+ expect(prisma.material.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ videoProcessingStatus: 'failed' }),
+ }))
+ })
+
+ test('完成事务报错且回读只有部分结果时保留生成物并抛出可对账错误', async () => {
+ let transactionCall = 0
+ ;(prisma.$transaction as jest.Mock).mockImplementation(
+ async (callback: (tx: typeof prisma) => Promise) => {
+ transactionCall += 1
+ const value = await callback(prisma)
+ if (transactionCall === 2) throw new Error('commit response unavailable')
+ return value
+ },
+ )
+ ;(prisma.material.findUnique as jest.Mock)
+ .mockResolvedValueOnce({
+ videoSourceUrl: 'https://s3.jiansun.vip/source.mp4',
+ videoCoverUrl: null,
+ previewUrl: '',
+ })
+ .mockResolvedValueOnce({
+ videoProcessingStatus: 'processing',
+ videoPreview540Url: 'https://s3.jiansun.vip/preview.mp4',
+ videoPreviewClipUrl: null,
+ videoCoverUrl: null,
+ previewUrl: '',
+ videoDuration: null,
+ transcodeLastError: null,
+ transcodeAttempts: 1,
+ })
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 1 })
+
+ await expect(executeTranscodeTask(
+ 'material-partial-commit-state',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'valid-token',
+ )).rejects.toBeInstanceOf(TranscodeCommitStateUnknownError)
+
+ expect(deleteFileIfUnreferenced).not.toHaveBeenCalled()
+ expect(prisma.material.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ videoProcessingStatus: 'failed' }),
+ }))
+ })
+
+ test('完成事务报错且主库回读也失败时保留生成物,不猜测为未提交', async () => {
+ let transactionCall = 0
+ ;(prisma.$transaction as jest.Mock).mockImplementation(
+ async (callback: (tx: typeof prisma) => Promise) => {
+ transactionCall += 1
+ const value = await callback(prisma)
+ if (transactionCall === 2) throw new Error('commit response unavailable')
+ return value
+ },
+ )
+ ;(prisma.material.findUnique as jest.Mock)
+ .mockResolvedValueOnce({
+ videoSourceUrl: 'https://s3.jiansun.vip/source.mp4',
+ videoCoverUrl: null,
+ previewUrl: '',
+ })
+ .mockRejectedValueOnce(new Error('database read unavailable'))
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 1 })
+
+ await expect(executeTranscodeTask(
+ 'material-unknown-commit-state',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'valid-token',
+ )).rejects.toBeInstanceOf(TranscodeCommitStateUnknownError)
+
+ expect(deleteFileIfUnreferenced).not.toHaveBeenCalled()
+ expect(prisma.material.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ videoProcessingStatus: 'failed' }),
+ }))
+ })
+
+ test('FFmpeg 没有生成完整预览时不能把素材误标为 done', async () => {
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 1 })
+ ;(processVideoTranscode as jest.Mock).mockResolvedValueOnce({
+ preview540Url: null,
+ clipUrl: null,
+ coverUrl: null,
+ duration: null,
+ })
+
+ await expect(executeTranscodeTask(
+ 'material-3',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'valid-token',
+ )).rejects.toThrow('视频转码输出不完整')
+
+ expect(prisma.material.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ videoProcessingStatus: 'failed' }),
+ }))
+ expect(prisma.material.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ videoProcessingStatus: 'done' }),
+ }))
+ })
+
+ test('已有用户封面时跳过重新截图,但首页 previewUrl 仍沿用该封面', async () => {
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
+ videoSourceUrl: 'https://s3.jiansun.vip/source.mp4',
+ videoCoverUrl: 'https://s3.jiansun.vip/user-cover.jpg',
+ previewUrl: '',
+ })
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 1 })
+ ;(prisma.$queryRaw as jest.Mock).mockResolvedValue([{
+ videoCoverUrl: 'https://s3.jiansun.vip/user-cover.jpg',
+ previewUrl: '',
+ }])
+ ;(processVideoTranscode as jest.Mock).mockResolvedValueOnce({
+ preview540Url: 'https://s3.jiansun.vip/preview.mp4',
+ clipUrl: 'https://s3.jiansun.vip/clip.mp4',
+ coverUrl: null,
+ duration: 12,
+ })
+
+ await expect(executeTranscodeTask(
+ 'material-4',
+ 'https://s3.jiansun.vip/source.mp4',
+ false,
+ 'valid-token',
+ )).resolves.toBeUndefined()
+
+ expect(processVideoTranscode).toHaveBeenCalledWith(expect.objectContaining({ needsCoverImage: false }))
+ expect(prisma.material.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({
+ data: expect.objectContaining({
+ videoProcessingStatus: 'done',
+ previewUrl: 'https://s3.jiansun.vip/user-cover.jpg',
+ }),
+ }))
+ })
+
+ test('转码期间用户补写封面和首页预览时,完成事务只写转码结果且不覆盖用户内容', async () => {
+ let finishProcessing!: (value: {
+ preview540Url: string
+ clipUrl: string
+ coverUrl: string
+ duration: number
+ }) => void
+ let markProcessingStarted!: () => void
+ const processingStarted = new Promise((resolve) => {
+ markProcessingStarted = resolve
+ })
+ const processingResult = new Promise((resolve) => {
+ finishProcessing = resolve
+ })
+ ;(processVideoTranscode as jest.Mock).mockImplementationOnce(() => {
+ markProcessingStarted()
+ return processingResult
+ })
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 1 })
+
+ const taskPromise = executeTranscodeTask(
+ 'material-concurrent-user-media',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'valid-token',
+ )
+
+ await processingStarted
+ expect(processVideoTranscode).toHaveBeenCalledTimes(1)
+
+ // 模拟 FFmpeg 运行期间用户手动补写了两项内容;完成事务必须读取这份最新值。
+ ;(prisma.$queryRaw as jest.Mock).mockResolvedValueOnce([{
+ videoCoverUrl: 'https://s3.jiansun.vip/manual-cover-during-transcode.jpg',
+ previewUrl: 'https://s3.jiansun.vip/manual-preview-during-transcode.jpg',
+ }])
+ finishProcessing({
+ preview540Url: 'https://s3.jiansun.vip/preview.mp4',
+ clipUrl: 'https://s3.jiansun.vip/clip.mp4',
+ coverUrl: 'https://s3.jiansun.vip/automatic-cover.jpg',
+ duration: 12,
+ })
+
+ await expect(taskPromise).resolves.toBeUndefined()
+
+ expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
+ expect(prisma.material.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({
+ where: expect.objectContaining({
+ transcodeLeaseToken: 'valid-token',
+ }),
+ data: expect.objectContaining({
+ videoProcessingStatus: 'done',
+ videoPreview540Url: 'https://s3.jiansun.vip/preview.mp4',
+ videoPreviewClipUrl: 'https://s3.jiansun.vip/clip.mp4',
+ videoDuration: 12,
+ }),
+ }))
+ const completionData = (prisma.material.updateMany as jest.Mock).mock.calls.at(-1)?.[0]?.data
+ expect(completionData.videoCoverUrl).toBeUndefined()
+ expect(completionData.previewUrl).toBeUndefined()
+ })
+
+ test('排队期间用户已补手动封面时,以开始处理时最新值为准并跳过自动截图', async () => {
+ ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
+ videoSourceUrl: 'https://s3.jiansun.vip/source.mp4',
+ videoCoverUrl: 'https://s3.jiansun.vip/manual-cover-before-start.jpg',
+ previewUrl: '',
+ })
+ ;(prisma.$queryRaw as jest.Mock).mockResolvedValue([{
+ videoCoverUrl: 'https://s3.jiansun.vip/manual-cover-before-start.jpg',
+ previewUrl: '',
+ }])
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 1 })
+ ;(processVideoTranscode as jest.Mock).mockResolvedValueOnce({
+ preview540Url: 'https://s3.jiansun.vip/preview.mp4',
+ clipUrl: 'https://s3.jiansun.vip/clip.mp4',
+ coverUrl: null,
+ duration: 12,
+ })
+
+ await expect(executeTranscodeTask(
+ 'material-cover-added-while-queued',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'valid-token',
+ )).resolves.toBeUndefined()
+
+ expect(processVideoTranscode).toHaveBeenCalledWith(expect.objectContaining({ needsCoverImage: false }))
+ const completionData = (prisma.material.updateMany as jest.Mock).mock.calls.at(-1)?.[0]?.data
+ expect(completionData.videoCoverUrl).toBeUndefined()
+ expect(completionData.previewUrl).toBe('https://s3.jiansun.vip/manual-cover-before-start.jpg')
+ })
+
+ test('应用时钟即使快十年,任务状态写入仍只使用数据库时钟租约门禁', async () => {
+ jest.useFakeTimers().setSystemTime(new Date('2036-08-11T00:00:00Z'))
+ try {
+ ;(prisma.material.updateMany as jest.Mock)
+ .mockResolvedValueOnce({ count: 1 })
+ .mockResolvedValueOnce({ count: 1 })
+
+ await expect(executeTranscodeTask(
+ 'material-clock-skew',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'database-valid-token',
+ )).resolves.toBeUndefined()
+
+ const leaseQueries = (prisma.$queryRaw as jest.Mock).mock.calls.map((call) => (
+ Array.from(call[0] as TemplateStringsArray).join(' ')
+ ))
+ expect(leaseQueries).toHaveLength(2)
+ expect(leaseQueries.every((sql) => sql.includes('clock_timestamp()'))).toBe(true)
+ for (const [args] of (prisma.material.updateMany as jest.Mock).mock.calls) {
+ expect(args.where).not.toHaveProperty('transcodeLeaseExpiresAt')
+ }
+ } finally {
+ jest.useRealTimers()
+ }
+ })
+
+ test('心跳中断信号不会重试或把仍有源文件的任务写成 failed', async () => {
+ const controller = new AbortController()
+ let beginProcessing!: () => void
+ const processingStarted = new Promise((resolve) => { beginProcessing = resolve })
+ ;(prisma.material.updateMany as jest.Mock).mockResolvedValueOnce({ count: 1 })
+ ;(processVideoTranscode as jest.Mock).mockImplementationOnce(async ({ abortSignal }) => {
+ beginProcessing()
+ await new Promise((resolve) => abortSignal.addEventListener('abort', () => resolve(), { once: true }))
+ throw new Error('ffmpeg aborted after heartbeat failure')
+ })
+
+ const task = executeTranscodeTask(
+ 'material-heartbeat-interrupted',
+ 'https://s3.jiansun.vip/source.mp4',
+ true,
+ 'still-owned-token',
+ controller.signal,
+ )
+ await processingStarted
+ controller.abort(new Error('heartbeat unavailable'))
+
+ await expect(task).rejects.toBeInstanceOf(StaleTranscodeLeaseError)
+ expect(processVideoTranscode).toHaveBeenCalledTimes(1)
+ expect(prisma.material.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ videoProcessingStatus: 'failed' }),
+ }))
+ expect(prisma.material.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ transcodeAttempts: 2 }),
+ }))
+ })
+})
diff --git a/lib/__tests__/white-background-upload-route.test.ts b/lib/__tests__/white-background-upload-route.test.ts
index fd66319..65846c1 100644
--- a/lib/__tests__/white-background-upload-route.test.ts
+++ b/lib/__tests__/white-background-upload-route.test.ts
@@ -36,7 +36,8 @@ jest.mock('@/lib/standard-model-white-background-upload-guard', () => {
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn().mockResolvedValue(undefined),
- writeFile: jest.fn().mockResolvedValue(undefined),
+ open: jest.fn(),
+ rename: jest.fn().mockResolvedValue(undefined),
unlink: jest.fn().mockResolvedValue(undefined),
},
}))
@@ -87,9 +88,15 @@ jest.mock('@/lib/s3', () => ({
describe('white-background upload route', () => {
const fsPromises = jest.requireMock('fs').promises as {
mkdir: jest.Mock
- writeFile: jest.Mock
+ open: jest.Mock
+ rename: jest.Mock
unlink: jest.Mock
}
+ let atomicFileHandle: {
+ writeFile: jest.Mock
+ sync: jest.Mock
+ close: jest.Mock
+ }
let txMaterialUpdateMany: jest.Mock
let consoleErrorSpy: jest.SpyInstance
@@ -97,7 +104,13 @@ describe('white-background upload route', () => {
jest.clearAllMocks()
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
fsPromises.mkdir.mockResolvedValue(undefined)
- fsPromises.writeFile.mockResolvedValue(undefined)
+ atomicFileHandle = {
+ writeFile: jest.fn().mockResolvedValue(undefined),
+ sync: jest.fn().mockResolvedValue(undefined),
+ close: jest.fn().mockResolvedValue(undefined),
+ }
+ fsPromises.open.mockResolvedValue(atomicFileHandle)
+ fsPromises.rename.mockResolvedValue(undefined)
fsPromises.unlink.mockResolvedValue(undefined)
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
@@ -180,13 +193,25 @@ describe('white-background upload route', () => {
expect(buildWhiteBackgroundWebpVariants).toHaveBeenCalledTimes(1)
expect(uploadPublicDisplayToS3).toHaveBeenCalledWith(
Buffer.from('preview-webp'),
- 'white-background/material-1/sku-1/XG014799-鞋柜-右前45°.webp',
+ expect.stringMatching(
+ /^white-background\/material-1\/sku-1\/XG014799-鞋柜-右前45°--[0-9a-f-]{36}\.webp$/,
+ ),
'image/webp',
)
- expect(fsPromises.writeFile).toHaveBeenCalledTimes(1)
- expect(fsPromises.writeFile).toHaveBeenCalledWith(
- expect.stringContaining('uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°.webp'),
- Buffer.from('original-webp'),
+ expect(fsPromises.open).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°--[0-9a-f-]{36}\.webp\.partial-[0-9a-f-]{36}$/,
+ ),
+ 'wx',
+ 0o640,
+ )
+ expect(atomicFileHandle.writeFile).toHaveBeenCalledWith(Buffer.from('original-webp'))
+ expect(atomicFileHandle.sync).toHaveBeenCalledTimes(1)
+ expect(fsPromises.rename).toHaveBeenCalledWith(
+ expect.stringContaining('.webp.partial-'),
+ expect.stringMatching(
+ /uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°--[0-9a-f-]{36}\.webp$/,
+ ),
)
expect(prisma.$transaction).toHaveBeenCalled()
expect(txMaterialUpdateMany).toHaveBeenCalledWith({
@@ -309,7 +334,9 @@ describe('white-background upload route', () => {
expect(response.status).toBe(500)
expect(fsPromises.unlink).toHaveBeenCalledTimes(1)
expect(fsPromises.unlink).toHaveBeenCalledWith(
- expect.stringContaining('uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°.webp'),
+ expect.stringMatching(
+ /uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°--[0-9a-f-]{36}\.webp$/,
+ ),
)
expect(deleteFromS3).toHaveBeenCalledWith(
'https://s3.jiansun.vip/gongxiangpan/public-display/white-background/material-1/sku-1/XG014799-鞋柜-右前45°.webp',
diff --git a/lib/__tests__/work-review-upload-safety.test.ts b/lib/__tests__/work-review-upload-safety.test.ts
new file mode 100644
index 0000000..9279374
--- /dev/null
+++ b/lib/__tests__/work-review-upload-safety.test.ts
@@ -0,0 +1,420 @@
+/** @jest-environment node */
+
+import { mkdtemp, rm, writeFile } from 'fs/promises'
+import os from 'os'
+import path from 'path'
+import type { Session } from 'next-auth'
+import {
+ WorkReviewApiError,
+ createWorkReviewProject,
+ createWorkReviewVersion,
+} from '@/lib/work-review-service'
+import { prisma } from '@/lib/prisma'
+import { getPerformanceSurveyContext } from '@/lib/performance-survey-service'
+import { deleteFromS3, uploadFileToS3 } from '@/lib/s3'
+import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock'
+import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
+import { getOwnedUploadedFile, getOwnedUploadSession } from '@/lib/upload-sessions'
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ user: { findFirst: jest.fn() },
+ performanceSurveyTemplateVersion: { findMany: jest.fn() },
+ performanceSurveyEmployeeProfile: { findFirst: jest.fn(), findMany: jest.fn() },
+ workReviewProject: { create: jest.fn(), findUnique: jest.fn() },
+ $transaction: jest.fn(),
+ },
+}))
+
+jest.mock('@/lib/performance-survey-service', () => ({
+ getPerformanceSurveyContext: jest.fn(),
+}))
+
+jest.mock('@/lib/performance-survey-report-sync', () => ({
+ getPerformanceSurveyAccountProfileWhere: jest.fn(async () => ({})),
+}))
+
+jest.mock('@/lib/work-review-notifications', () => ({
+ ensureWorkReviewNotificationWorkerStarted: jest.fn(),
+ queueWorkReviewNotification: jest.fn(async () => undefined),
+}))
+
+jest.mock('@/lib/public-app-url', () => ({
+ getPublicAppOrigin: jest.fn(() => 'https://example.test'),
+}))
+
+jest.mock('@/lib/s3', () => ({
+ deleteFromS3: jest.fn(async () => undefined),
+ uploadFileToS3: jest.fn(),
+}))
+
+jest.mock('@/lib/material-upload-cross-process-lock', () => ({
+ acquireTaskMaterialUploadSessionLocks: jest.fn(),
+}))
+
+jest.mock('@/lib/temp-cleanup', () => ({
+ cleanupConsumedUploadSession: jest.fn(),
+}))
+
+jest.mock('@/lib/upload-sessions', () => ({
+ getOwnedUploadSession: jest.fn(),
+ getOwnedUploadedFile: jest.fn(),
+}))
+
+function vp8xHeader(width: number, height: number) {
+ const buffer = Buffer.alloc(30)
+ buffer.write('RIFF', 0, 'ascii')
+ buffer.write('WEBP', 8, 'ascii')
+ buffer.write('VP8X', 12, 'ascii')
+ buffer.writeUIntLE(width - 1, 24, 3)
+ buffer.writeUIntLE(height - 1, 27, 3)
+ return buffer
+}
+
+function makeReleaseLock() {
+ return Object.assign(jest.fn(async () => undefined), {
+ assertHeld: jest.fn(),
+ })
+}
+
+function makeProcessedImage(
+ uploadId: string,
+ options: { sourceName?: string; sourceIndex?: number } = {},
+) {
+ return {
+ uploadId,
+ mimeType: 'image/webp',
+ width: 1600,
+ height: 900,
+ kind: 'slice',
+ sourceName: options.sourceName || `${uploadId}.webp`,
+ sourceIndex: options.sourceIndex ?? 0,
+ sliceIndex: 0,
+ sliceCount: 1,
+ }
+}
+
+const session = { user: { id: 'user-1' } } as Session
+const submitter = {
+ id: 'submitter-employee',
+ displayName: '提交人',
+ dingtalkUserId: 'ding-submitter',
+ departmentName: '设计部',
+ departmentPath: '集团/设计部',
+ positionTitle: '设计师',
+ positionLevel: 'P4',
+ levelType: 'P',
+ sourcePayload: { materialUserId: 'user-1' },
+}
+const manager = {
+ id: 'manager-employee',
+ displayName: '评审人',
+ dingtalkUserId: 'ding-manager',
+ sourcePayload: { materialUserId: 'manager-user' },
+}
+const finalReviewer = {
+ id: 'final-employee',
+ displayName: '元泽',
+ dingtalkUserId: 'ding-final',
+ sourcePayload: { materialUserId: 'final-user' },
+}
+
+function makeProjectBody(uploadId = 'upload-work') {
+ return {
+ title: '测试作品',
+ workDate: '2026-08-12',
+ reviewerEmployeeId: manager.id,
+ processedImages: [makeProcessedImage(uploadId)],
+ }
+}
+
+function buildPersistedProject(data: any) {
+ const version = data.versions.create
+ return {
+ ...data,
+ status: 'pending_review',
+ latestVersion: version.version,
+ createdAt: new Date('2026-08-12T01:00:00Z'),
+ updatedAt: new Date('2026-08-12T01:00:00Z'),
+ submitterEmployee: submitter,
+ versions: [{
+ ...version,
+ projectId: data.id,
+ status: 'pending_review',
+ submittedAt: new Date('2026-08-12T01:00:00Z'),
+ completedAt: null,
+ createdAt: new Date('2026-08-12T01:00:00Z'),
+ updatedAt: new Date('2026-08-12T01:00:00Z'),
+ reviews: [],
+ }],
+ }
+}
+
+describe('work review processed image persistence safety', () => {
+ let tempRoot = ''
+ let webpPath = ''
+ let releaseUploadSessionLocks: ReturnType
+
+ beforeEach(async () => {
+ jest.clearAllMocks()
+ tempRoot = await mkdtemp(path.join(os.tmpdir(), 'work-review-upload-'))
+ process.env.UPLOAD_DIR = tempRoot
+ webpPath = path.join(tempRoot, 'slice.webp')
+ await writeFile(webpPath, vp8xHeader(1600, 900))
+
+ ;(getPerformanceSurveyContext as jest.Mock).mockResolvedValue({
+ user: { id: 'user-1', username: 'tester' },
+ employee: submitter,
+ isSystemAdmin: false,
+ })
+ ;(prisma.performanceSurveyTemplateVersion.findMany as jest.Mock).mockResolvedValue([])
+ ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([finalReviewer])
+ ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(manager)
+ ;(prisma.user.findFirst as jest.Mock).mockImplementation(async ({ where }: any) => {
+ const serialized = JSON.stringify(where)
+ return serialized.includes('ding-final')
+ ? { id: 'final-user', dingtalkUserId: 'ding-final' }
+ : { id: 'manager-user', dingtalkUserId: 'ding-manager' }
+ })
+ ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
+ fileName: 'slice.webp',
+ fileSize: 30,
+ userId: 'user-1',
+ })
+ ;(getOwnedUploadedFile as jest.Mock).mockResolvedValue(webpPath)
+ ;(uploadFileToS3 as jest.Mock).mockImplementation(
+ async (_filePath, fileName) => `https://s3.example/${fileName}`,
+ )
+ ;(cleanupConsumedUploadSession as jest.Mock).mockResolvedValue({ success: true })
+ releaseUploadSessionLocks = makeReleaseLock()
+ ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadSessionLocks)
+ ;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(null)
+ ;(prisma.workReviewProject.create as jest.Mock).mockImplementation(
+ async ({ data }) => buildPersistedProject(data),
+ )
+ })
+
+ afterEach(async () => {
+ await rm(tempRoot, { recursive: true, force: true })
+ })
+
+ test('treats a committed project with a lost acknowledgement as success and preserves both image sets', async () => {
+ let persisted: any
+ ;(prisma.workReviewProject.create as jest.Mock).mockImplementation(async ({ data }) => {
+ persisted = buildPersistedProject(data)
+ ;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(persisted)
+ throw new Error('create response lost')
+ })
+
+ const result = await createWorkReviewProject(session, {
+ ...makeProjectBody('upload-work'),
+ processedReferenceImages: [makeProcessedImage('upload-reference')],
+ })
+
+ expect(result.id).toBe(persisted.id)
+ expect(result.versions[0].imageGroups).toHaveLength(1)
+ expect(result.versions[0].referenceImageGroups).toHaveLength(1)
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(2)
+ expect((cleanupConsumedUploadSession as jest.Mock).mock.calls.map(([uploadId]) => uploadId)).toEqual([
+ 'upload-reference',
+ 'upload-work',
+ ])
+ })
+
+ test('fails closed and preserves S3 and source sessions when the commit readback is unavailable', async () => {
+ ;(prisma.workReviewProject.create as jest.Mock).mockRejectedValue(new Error('connection lost'))
+ ;(prisma.workReviewProject.findUnique as jest.Mock).mockRejectedValue(new Error('database unavailable'))
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ await expect(createWorkReviewProject(session, makeProjectBody())).rejects.toEqual(
+ new WorkReviewApiError(
+ '作品保存状态暂时无法确认,服务器已保留图片和上传会话,请勿重复提交',
+ 503,
+ ),
+ )
+
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
+ consoleError.mockRestore()
+ })
+
+ test('preserves S3 when a persisted version does not contain the complete reference image evidence', async () => {
+ ;(prisma.workReviewProject.create as jest.Mock).mockImplementation(async ({ data }) => {
+ const persisted = buildPersistedProject(data)
+ persisted.versions[0].imageUrls.referenceImageGroups = []
+ ;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(persisted)
+ throw new Error('create response lost')
+ })
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ await expect(createWorkReviewProject(session, {
+ ...makeProjectBody(),
+ processedReferenceImages: [makeProcessedImage('upload-reference')],
+ })).rejects.toMatchObject({ status: 503 })
+
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
+ consoleError.mockRestore()
+ })
+
+ test('deletes only this attempt S3 after an exact readback confirms no project commit', async () => {
+ ;(prisma.workReviewProject.create as jest.Mock).mockRejectedValue(new Error('rolled back'))
+ ;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(null)
+
+ await expect(createWorkReviewProject(session, makeProjectBody())).rejects.toThrow('rolled back')
+
+ expect(deleteFromS3).toHaveBeenCalledTimes(1)
+ expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
+ })
+
+ test('locks all work and reference uploadIds in sorted order before resolve and cleanup', async () => {
+ await createWorkReviewProject(session, {
+ ...makeProjectBody('upload-z'),
+ processedReferenceImages: [makeProcessedImage('upload-a')],
+ })
+
+ expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-a', 'upload-z'])
+ expect((acquireTaskMaterialUploadSessionLocks as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(
+ (getOwnedUploadSession as jest.Mock).mock.invocationCallOrder[0],
+ )
+ expect((cleanupConsumedUploadSession as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(
+ releaseUploadSessionLocks.mock.invocationCallOrder[0],
+ )
+ })
+
+ test('serializes cross-request reuse through consumed cleanup before the next resolve', async () => {
+ let tail = Promise.resolve()
+ ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockImplementation(async () => {
+ const predecessor = tail
+ let releaseNext!: () => void
+ tail = new Promise((resolve) => {
+ releaseNext = resolve
+ })
+ await predecessor
+ let active = true
+ return Object.assign(jest.fn(async () => {
+ active = false
+ releaseNext()
+ }), {
+ assertHeld: jest.fn(() => {
+ if (!active) throw new Error('lock released')
+ }),
+ })
+ })
+
+ let finishCleanup!: () => void
+ let reportCleanupStarted!: () => void
+ const cleanupStarted = new Promise((resolve) => {
+ reportCleanupStarted = resolve
+ })
+ ;(cleanupConsumedUploadSession as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => {
+ reportCleanupStarted()
+ finishCleanup = () => resolve({ success: true })
+ }))
+ ;(getOwnedUploadSession as jest.Mock)
+ .mockResolvedValueOnce({ fileName: 'slice.webp', fileSize: 30, userId: 'user-1' })
+ .mockResolvedValueOnce(null)
+
+ const first = createWorkReviewProject(session, makeProjectBody('upload-shared'))
+ await cleanupStarted
+ const second = createWorkReviewProject(session, makeProjectBody('upload-shared'))
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(getOwnedUploadSession).toHaveBeenCalledTimes(1)
+
+ finishCleanup()
+ await expect(first).resolves.toEqual(expect.objectContaining({ title: '测试作品' }))
+ await expect(second).rejects.toThrow('图片上传已过期')
+ expect(uploadFileToS3).toHaveBeenCalledTimes(1)
+ expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(1)
+ })
+
+ test('deletes attempt S3 when a new-version readback proves the stable version ID was not committed', async () => {
+ const existing = {
+ id: 'project-existing',
+ submitterUserId: 'user-1',
+ status: 'revision_required',
+ latestVersion: 1,
+ versions: [{ id: 'old-version', version: 1 }],
+ }
+ ;(prisma.workReviewProject.findUnique as jest.Mock)
+ .mockResolvedValueOnce(existing)
+ .mockResolvedValue(existing)
+ ;(prisma.$transaction as jest.Mock).mockRejectedValue(new Error('transaction rolled back'))
+
+ await expect(createWorkReviewVersion(session, existing.id, {
+ expectedLatestVersion: 1,
+ processedImages: [makeProcessedImage('upload-work')],
+ })).rejects.toThrow('transaction rolled back')
+
+ expect(deleteFromS3).toHaveBeenCalledTimes(1)
+ expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
+ })
+
+ test('confirms a lost new-version transaction by the stable version ID and complete image payload', async () => {
+ const existing = {
+ id: 'project-existing',
+ submitterUserId: 'user-1',
+ status: 'revision_required',
+ latestVersion: 1,
+ versions: [{ id: 'old-version', version: 1 }],
+ }
+ ;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValueOnce(existing)
+
+ let newVersionData: any
+ const tx = {
+ $queryRaw: jest.fn().mockResolvedValue([{ status: 'revision_required', latest_version: 1 }]),
+ workReviewVersion: {
+ create: jest.fn(async ({ data }) => {
+ newVersionData = data
+ return data
+ }),
+ },
+ workReviewProject: { update: jest.fn() },
+ }
+ ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => {
+ await callback(tx)
+ const persisted = {
+ ...existing,
+ title: '测试作品',
+ description: null,
+ workDate: new Date('2026-08-12T00:00:00Z'),
+ submitterEmployeeId: submitter.id,
+ submitterName: submitter.displayName,
+ submitterSnapshot: {},
+ managerEmployeeId: manager.id,
+ managerUserId: 'manager-user',
+ managerDingtalkUserId: 'ding-manager',
+ managerName: manager.displayName,
+ finalReviewerEmployeeId: finalReviewer.id,
+ finalReviewerUserId: 'final-user',
+ finalReviewerDingtalkUserId: 'ding-final',
+ finalReviewerName: finalReviewer.displayName,
+ status: 'pending_review',
+ latestVersion: 2,
+ createdAt: new Date('2026-08-12T00:00:00Z'),
+ updatedAt: new Date('2026-08-12T01:00:00Z'),
+ submitterEmployee: submitter,
+ versions: [{
+ ...newVersionData,
+ status: 'pending_review',
+ submittedAt: new Date('2026-08-12T01:00:00Z'),
+ completedAt: null,
+ reviews: [],
+ }],
+ }
+ ;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(persisted)
+ throw new Error('transaction response lost')
+ })
+
+ const result = await createWorkReviewVersion(session, existing.id, {
+ expectedLatestVersion: 1,
+ processedImages: [makeProcessedImage('upload-work')],
+ })
+
+ expect(result.latestVersion).toBe(2)
+ expect(result.versions[0].id).toBe(newVersionData.id)
+ expect(deleteFromS3).not.toHaveBeenCalled()
+ expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-work')
+ })
+})
diff --git a/lib/bounded-file-reader.ts b/lib/bounded-file-reader.ts
new file mode 100644
index 0000000..867c359
--- /dev/null
+++ b/lib/bounded-file-reader.ts
@@ -0,0 +1,146 @@
+import { lstat, open } from 'fs/promises'
+
+export interface BoundedFileReadOptions {
+ maxBytes: number
+ expectedBytes?: number
+ label?: string
+}
+
+interface ValidatedRegularFile {
+ size: number
+}
+
+function assertSafeByteLimit(value: number, name: string): void {
+ if (!Number.isSafeInteger(value) || value <= 0) {
+ throw new Error(`${name}无效`)
+ }
+}
+
+function validateRegularFileSize(
+ size: number,
+ options: BoundedFileReadOptions,
+): ValidatedRegularFile {
+ const label = options.label || '文件'
+ assertSafeByteLimit(options.maxBytes, `${label}大小上限`)
+ if (options.expectedBytes !== undefined) {
+ assertSafeByteLimit(options.expectedBytes, `${label}申报大小`)
+ }
+
+ if (!Number.isSafeInteger(size) || size <= 0) {
+ throw new Error(`${label}为空或大小无效`)
+ }
+ if (size > options.maxBytes) {
+ throw new Error(`${label}超过 ${Math.ceil(options.maxBytes / 1024 / 1024)}MB 限制`)
+ }
+ if (options.expectedBytes !== undefined && size !== options.expectedBytes) {
+ throw new Error(`${label}实际大小与上传记录不一致,请重新上传`)
+ }
+
+ return { size }
+}
+
+/**
+ * 在真正分配 Buffer 前检查路径与实际文件大小。调用方不能只信浏览器传入的
+ * fileSize;上传会话最高可到数 GiB,直接 readFile 会让 Node 进程被 OOM 杀死。
+ */
+export async function inspectBoundedRegularFile(
+ filePath: string,
+ options: BoundedFileReadOptions,
+): Promise {
+ const label = options.label || '文件'
+ const pathStats = await lstat(filePath)
+ if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
+ throw new Error(`${label}不是可读取的普通文件`)
+ }
+
+ return validateRegularFileSize(pathStats.size, options)
+}
+
+/**
+ * 通过已打开的 FileHandle 读取,打开后再次 fstat,避免检查路径后文件被替换
+ * 或扩大的窗口。读取完成后还会核对字节数,任何不确定状态都失败关闭。
+ */
+export async function readBoundedRegularFile(
+ filePath: string,
+ options: BoundedFileReadOptions,
+): Promise {
+ await inspectBoundedRegularFile(filePath, options)
+ const label = options.label || '文件'
+ const handle = await open(filePath, 'r')
+
+ try {
+ const openedStats = await handle.stat()
+ if (!openedStats.isFile()) {
+ throw new Error(`${label}不是可读取的普通文件`)
+ }
+ const validated = validateRegularFileSize(openedStats.size, options)
+ const buffer = await handle.readFile()
+ if (buffer.length !== validated.size) {
+ throw new Error(`${label}读取期间发生变化,请重新上传`)
+ }
+ return buffer
+ } finally {
+ await handle.close()
+ }
+}
+
+/**
+ * 受限读取 fetch 响应。先核对 Content-Length,再按声明大小预分配单个 Buffer,
+ * 不使用 arrayBuffer/Buffer.concat,防止未知长度响应造成无上限内存增长。
+ */
+export async function readBoundedResponseBuffer(
+ response: Response,
+ options: BoundedFileReadOptions,
+): Promise {
+ const label = options.label || '远程文件'
+ assertSafeByteLimit(options.maxBytes, `${label}大小上限`)
+ if (options.expectedBytes === undefined) {
+ throw new Error(`${label}缺少可信大小,拒绝载入内存`)
+ }
+ assertSafeByteLimit(options.expectedBytes, `${label}申报大小`)
+ if (options.expectedBytes > options.maxBytes) {
+ throw new Error(`${label}超过 ${Math.ceil(options.maxBytes / 1024 / 1024)}MB 限制`)
+ }
+
+ const rawContentLength = response.headers.get('content-length')
+ if (rawContentLength !== null) {
+ const contentLength = Number(rawContentLength)
+ if (!Number.isSafeInteger(contentLength) || contentLength !== options.expectedBytes) {
+ throw new Error(`${label}响应大小与上传记录不一致`)
+ }
+ }
+ if (!response.body) {
+ throw new Error(`${label}响应内容为空`)
+ }
+
+ const output = Buffer.allocUnsafe(options.expectedBytes)
+ const reader = response.body.getReader()
+ let offset = 0
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) {
+ break
+ }
+ if (!value || value.byteLength === 0) {
+ continue
+ }
+ if (offset + value.byteLength > options.expectedBytes) {
+ throw new Error(`${label}响应超过申报大小`)
+ }
+ output.set(value, offset)
+ offset += value.byteLength
+ }
+ } catch (error) {
+ await reader.cancel(error).catch(() => undefined)
+ throw error
+ } finally {
+ reader.releaseLock()
+ }
+
+ if (offset !== options.expectedBytes) {
+ throw new Error(`${label}响应不完整`)
+ }
+ return output
+}
diff --git a/lib/brand-assets.ts b/lib/brand-assets.ts
new file mode 100644
index 0000000..2555548
--- /dev/null
+++ b/lib/brand-assets.ts
@@ -0,0 +1,45 @@
+export const DEFAULT_HEADER_LOGO_URL = '/brand/header-logo.png'
+export const DEFAULT_LOGIN_LOGO_URL = '/brand/login-logo.png'
+export const DEFAULT_FAVICON_URL = '/brand/favicon.png'
+
+export const DEFAULT_SITE_NAME = '由壹点3D素材'
+export const BRAND_LOGO_FALLBACK_TEXT = '由壹点'
+
+const LOCAL_BRAND_ASSET_PATTERN = /^\/brand\/[A-Za-z0-9][A-Za-z0-9._/-]*$/
+
+/**
+ * 系统设置只保存可长期访问的品牌资源地址。
+ * 展示用的 /api/proxy-media 地址是临时派生值,绝不能写回数据库。
+ */
+export function isAllowedBrandLogoUrl(value: string): boolean {
+ const trimmed = value.trim()
+ if (!trimmed || trimmed.includes('..') || trimmed.includes('\\')) {
+ return false
+ }
+
+ if (LOCAL_BRAND_ASSET_PATTERN.test(trimmed)) {
+ return true
+ }
+
+ try {
+ const parsed = new URL(trimmed)
+ return (
+ parsed.protocol === 'https:' &&
+ parsed.hostname === 's3.jiansun.vip' &&
+ parsed.port === '' &&
+ parsed.username === '' &&
+ parsed.password === '' &&
+ parsed.pathname.startsWith('/gongxiangpan/') &&
+ parsed.search === '' &&
+ parsed.hash === ''
+ )
+ } catch {
+ return false
+ }
+}
+
+export function normalizeBrandLogoUrl(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const trimmed = value.trim()
+ return isAllowedBrandLogoUrl(trimmed) ? trimmed : null
+}
diff --git a/lib/brand-image-validation.ts b/lib/brand-image-validation.ts
new file mode 100644
index 0000000..38601b5
--- /dev/null
+++ b/lib/brand-image-validation.ts
@@ -0,0 +1,74 @@
+import sharp from 'sharp'
+
+export const MAX_BRAND_IMAGE_BYTES = 5 * 1024 * 1024
+export const MAX_BRAND_IMAGE_PIXELS = 4_194_304
+export const MAX_BRAND_IMAGE_DIMENSION = 4096
+
+export type ValidatedBrandImage = {
+ extension: 'png' | 'jpg' | 'webp'
+ contentType: 'image/png' | 'image/jpeg' | 'image/webp'
+ width: number
+ height: number
+}
+
+const FORMAT_DETAILS: Record> = {
+ png: { extension: 'png', contentType: 'image/png' },
+ jpeg: { extension: 'jpg', contentType: 'image/jpeg' },
+ webp: { extension: 'webp', contentType: 'image/webp' },
+}
+
+/**
+ * 品牌图属于高可见度系统配置,不能只看 magic bytes。
+ * 这里会真正解码整张图,阻止截断文件、伪造 MIME、动画和解压炸弹进入数据库。
+ */
+export async function validateBrandImage(
+ buffer: Buffer,
+ declaredContentType?: string | null,
+): Promise {
+ if (buffer.length <= 0 || buffer.length > MAX_BRAND_IMAGE_BYTES) {
+ throw new Error('品牌图片必须是 5MB 以内的有效文件')
+ }
+
+ const image = sharp(buffer, {
+ failOn: 'error',
+ limitInputPixels: MAX_BRAND_IMAGE_PIXELS,
+ animated: false,
+ })
+ const metadata = await image.metadata()
+ const format = metadata.format ? FORMAT_DETAILS[metadata.format] : undefined
+ const width = metadata.width || 0
+ const height = metadata.height || 0
+
+ if (!format) {
+ throw new Error('品牌图片只支持 PNG、JPEG 或 WebP')
+ }
+ if (
+ width < 1 ||
+ height < 1 ||
+ width > MAX_BRAND_IMAGE_DIMENSION ||
+ height > MAX_BRAND_IMAGE_DIMENSION ||
+ width * height > MAX_BRAND_IMAGE_PIXELS
+ ) {
+ throw new Error('品牌图片尺寸无效或像素过大')
+ }
+ if ((metadata.pages || 1) > 1) {
+ throw new Error('品牌图片不支持动画格式')
+ }
+
+ const normalizedDeclaredType = String(declaredContentType || '')
+ .split(';', 1)[0]
+ .trim()
+ .toLowerCase()
+ if (normalizedDeclaredType && normalizedDeclaredType !== format.contentType) {
+ throw new Error('品牌图片内容与声明格式不一致')
+ }
+
+ // metadata() 主要读取头信息;raw().toBuffer() 强制完整解码,截断图片会在这里失败。
+ await image.clone().raw().toBuffer()
+
+ return {
+ ...format,
+ width,
+ height,
+ }
+}
diff --git a/lib/certificate-report.ts b/lib/certificate-report.ts
index f6d3df0..145590d 100644
--- a/lib/certificate-report.ts
+++ b/lib/certificate-report.ts
@@ -5,6 +5,15 @@ export const CERTIFICATE_REPORT_ASSET_TYPE = {
IMAGE: 'report-image',
} as const
+export const CERTIFICATE_REPORT_UPLOAD_LIMITS = {
+ maxFiles: 20,
+ maxImageBytes: 100 * 1024 * 1024,
+ maxPdfBytes: 512 * 1024 * 1024,
+ maxTotalBytes: 1024 * 1024 * 1024,
+} as const
+
+const CERTIFICATE_REPORT_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp'])
+
export type CertificateReportAssetType =
(typeof CERTIFICATE_REPORT_ASSET_TYPE)[keyof typeof CERTIFICATE_REPORT_ASSET_TYPE]
@@ -55,3 +64,14 @@ export function detectFileKindFromName(fileName: string | null | undefined): Cer
}
return 'image'
}
+
+export function isSupportedCertificateReportFileName(fileName: string | null | undefined): boolean {
+ const normalized = String(fileName || '').trim().toLowerCase()
+ if (normalized.endsWith('.pdf')) {
+ return true
+ }
+
+ const extensionIndex = normalized.lastIndexOf('.')
+ const extension = extensionIndex >= 0 ? normalized.slice(extensionIndex) : ''
+ return CERTIFICATE_REPORT_IMAGE_EXTENSIONS.has(extension)
+}
diff --git a/lib/chunk-upload.ts b/lib/chunk-upload.ts
index cbc5e7e..7efb388 100644
--- a/lib/chunk-upload.ts
+++ b/lib/chunk-upload.ts
@@ -2,6 +2,7 @@
// 支持断点续传,自动重试,静默处理错误
import { getOptimalChunkSize, getChunkTimeout, getOptimalConcurrency } from './chunk-upload-config'
+import { resolveUploadChunkSize } from './material-upload-chunk-policy'
interface ChunkUploadOptions {
chunkSize?: number // 每块大小(字节),默认根据网络环境自动选择(内网20MB,公网10MB)
@@ -17,6 +18,7 @@ interface ChunkUploadOptions {
) => void
onRequest?: (xhr: XMLHttpRequest | null) => void // 暴露当前的XHR请求以便取消
skipComplete?: boolean // 如果为true,分片上传完成后立即返回,不调用completeUpload(用于后台异步处理)
+ retainStateOnSkipComplete?: boolean // 仅特殊批次使用:建档确认前保留 IndexedDB 断点
isCancelled?: () => boolean // 取消检查回调,返回 true 时停止上传
onActiveXhrChange?: (xhrs: Set) => void // 暴露所有活跃的XHR请求集合(用于批量取消)
requestHeaders?: Record // 供钉钉等独立登录态透传认证头
@@ -37,6 +39,7 @@ interface ChunkUploadState {
interface UploadHttpError extends Error {
status?: number
retryable?: boolean
+ outcomeUncertain?: boolean
}
function createUploadHttpError(message: string, status?: number, retryable: boolean = true): UploadHttpError {
@@ -46,6 +49,21 @@ function createUploadHttpError(message: string, status?: number, retryable: bool
return error
}
+function createUncertainUploadError(message: string): UploadHttpError {
+ const error = createUploadHttpError(message, undefined, true)
+ error.outcomeUncertain = true
+ return error
+}
+
+function isUploadOutcomeUncertain(error: unknown): boolean {
+ return Boolean(
+ error
+ && typeof error === 'object'
+ && 'outcomeUncertain' in error
+ && (error as UploadHttpError).outcomeUncertain,
+ )
+}
+
function isRetryableUploadError(error: unknown): boolean {
if (error && typeof error === 'object' && 'retryable' in error) {
return Boolean((error as UploadHttpError).retryable)
@@ -162,12 +180,13 @@ async function initUpload(
fileName: string,
fileSize: number,
totalChunks: number,
+ chunkSize: number,
requestHeaders: Record = {},
): Promise {
const response = await fetch('/api/materials/upload/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...requestHeaders },
- body: JSON.stringify({ fileName, fileSize, totalChunks }),
+ body: JSON.stringify({ fileName, fileSize, totalChunks, chunkSize }),
})
if (!response.ok) {
@@ -348,25 +367,66 @@ export async function completeUpload(
throw createUploadHttpError(errorMessage, response.status, retryable)
}
- return response.json()
+ try {
+ return await response.json()
+ } catch {
+ // 2xx 已由服务器返回,但响应体在链路中截断时,服务端可能已经完成合并。
+ throw createUncertainUploadError('完成响应不完整,服务器处理结果尚未确认')
+ }
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error) {
if (error.name === 'AbortError') {
- throw new Error('请求超时,请重试上传')
+ throw createUncertainUploadError('完成请求超时,服务器处理结果尚未确认')
}
if (error.message === 'UPLOAD_SESSION_EXPIRED') {
throw error
}
+ if ('retryable' in error || isUploadOutcomeUncertain(error)) {
+ throw error
+ }
}
-
- throw error
+
+ // fetch 的网络错误没有可靠的“服务端未执行”语义,必须按结果不确定处理。
+ throw createUncertainUploadError(
+ error instanceof Error
+ ? `完成请求中断:${error.message}`
+ : '完成请求中断,服务器处理结果尚未确认',
+ )
}
}
// 主上传函数
+const activeFileUploadIds = new Set()
+
export async function chunkUpload(
+ file: File,
+ metadata: Record,
+ options: ChunkUploadOptions = {},
+): Promise {
+ const fileId = file?.name?.trim() && Number.isFinite(file.size)
+ ? generateFileId(file)
+ : ''
+
+ if (fileId && activeFileUploadIds.has(fileId)) {
+ throw new Error(`文件 ${file.name} 正在上传,请勿在同一批次重复选择`)
+ }
+
+ if (fileId) {
+ activeFileUploadIds.add(fileId)
+ }
+
+ try {
+ return await runChunkUpload(file, metadata, options)
+ } finally {
+ if (fileId) {
+ activeFileUploadIds.delete(fileId)
+ }
+ }
+}
+
+async function runChunkUpload(
file: File,
metadata: Record,
options: ChunkUploadOptions = {}
@@ -390,13 +450,14 @@ export async function chunkUpload(
}
const {
- chunkSize = defaultChunkSize, // 根据网络环境自动选择(内网20MB,公网10MB)
+ chunkSize: requestedChunkSize = defaultChunkSize, // 根据网络环境自动选择(内网20MB,公网10MB)
onProgress,
maxRetries = 5,
retryDelay = 1000,
onStageChange,
onRequest,
skipComplete = false, // 默认不跳过complete
+ retainStateOnSkipComplete = false,
isCancelled, // 取消检查回调
onActiveXhrChange, // 活跃XHR集合回调
requestHeaders = {},
@@ -408,10 +469,57 @@ export async function chunkUpload(
onStageChange?.('uploading', { message: '正在上传素材文件…' })
const fileId = generateFileId(file)
- const totalChunks = Math.ceil(file.size / chunkSize)
+ let chunkSize = Number(requestedChunkSize)
+ let totalChunks = Math.ceil(file.size / chunkSize)
+ if (resolveUploadChunkSize(file.size, totalChunks, chunkSize) !== chunkSize) {
+ throw new Error('上传文件大小或分片配置无效(单文件最多10GB、单分片最多20MB)')
+ }
// 加载已有状态
let state = await loadUploadState(fileId)
+
+ if (state) {
+ const storedUploadedChunks = state.uploadedChunks instanceof Set
+ ? state.uploadedChunks
+ : null
+ const storedChunkSize = resolveUploadChunkSize(
+ file.size,
+ Number(state.totalChunks),
+ state.chunkSize,
+ )
+ const storedStateMatchesFile = state.fileName === file.name
+ && state.fileSize === file.size
+ && storedUploadedChunks !== null
+ && Array.from(storedUploadedChunks).every((index) => (
+ Number.isInteger(index)
+ && index >= 0
+ && index < state!.totalChunks
+ ))
+
+ if (state.uploadId && storedStateMatchesFile && storedChunkSize) {
+ // 服务端会话的分片布局在 init 时已经固定。网络从公网切到内网(或反向)
+ // 时仍沿用已保存的 10/20MiB 布局,不能拿新探测值切旧会话。
+ chunkSize = storedChunkSize
+ totalChunks = state.totalChunks
+ } else if (state.uploadId || !storedStateMatchesFile || !storedChunkSize) {
+ // 无法安全证明旧状态与当前文件/布局一致时,清理旧会话并重新初始化,
+ // 避免把错误切片继续写进服务端既有会话。
+ if (state.uploadId) {
+ fetch('/api/materials/upload/cleanup', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ uploadId: state.uploadId }),
+ }).catch(() => undefined)
+ }
+ await deleteUploadState(fileId).catch(() => undefined)
+ state = null
+ } else {
+ // 尚未拿到服务端 uploadId,不存在可恢复的远端进度,按本次网络配置重建。
+ state.chunkSize = chunkSize
+ state.totalChunks = totalChunks
+ state.uploadedChunks.clear()
+ }
+ }
if (!state) {
// 初始化新上传
@@ -436,7 +544,7 @@ export async function chunkUpload(
// 初始化上传(如果需要)
if (!state.uploadId) {
try {
- state.uploadId = await initUpload(file.name, file.size, totalChunks, requestHeaders)
+ state.uploadId = await initUpload(file.name, file.size, totalChunks, chunkSize, requestHeaders)
await saveUploadState(state)
} catch (error) {
if (!isRetryableUploadError(error)) {
@@ -449,7 +557,7 @@ export async function chunkUpload(
while (retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, retryDelay))
try {
- state.uploadId = await initUpload(file.name, file.size, totalChunks, requestHeaders)
+ state.uploadId = await initUpload(file.name, file.size, totalChunks, chunkSize, requestHeaders)
await saveUploadState(state)
break
} catch (e) {
@@ -723,7 +831,7 @@ export async function chunkUpload(
console.warn(`[ChunkUpload] ⚠️ 会话刷新失败(${refreshResult.error}),重新初始化上传...`)
// 刷新失败,需要重新初始化
- state.uploadId = await initUpload(file.name, file.size, totalChunks, requestHeaders)
+ state.uploadId = await initUpload(file.name, file.size, totalChunks, chunkSize, requestHeaders)
state.uploadedChunks.clear()
completedBytes = 0
await saveUploadState(state)
@@ -779,15 +887,24 @@ export async function chunkUpload(
// 如果设置了skipComplete,分片上传完成后立即返回,不调用completeUpload
if (skipComplete) {
console.log('[ChunkUpload] 分片上传完成,跳过complete处理(将在后台异步处理)')
- try {
- await deleteUploadState(fileId)
- } catch (cleanupError) {
- console.warn('[ChunkUpload] 清理上传状态失败(将忽略并继续):', cleanupError)
+ if (!retainStateOnSkipComplete) {
+ try {
+ await deleteUploadState(fileId)
+ } catch (cleanupError) {
+ console.warn('[ChunkUpload] 清理上传状态失败(将忽略并继续):', cleanupError)
+ }
}
return {
uploadId: state.uploadId,
metadata,
chunksUploaded: true,
+ ...(retainStateOnSkipComplete
+ ? {
+ confirmMaterialCreated: async () => {
+ await deleteUploadState(fileId)
+ },
+ }
+ : {}),
}
}
@@ -838,20 +955,15 @@ export async function chunkUpload(
if (retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, retryDelay * retries))
} else {
- // 达到最大重试次数,清理状态以避免残留
- await deleteUploadState(fileId).catch(() => {
- console.warn('[ChunkUpload] 清理上传状态失败(忽略)')
- })
- // 清理后端临时文件
- if (state.uploadId) {
- console.log('[ChunkUpload] 上传失败,调用后端清理临时文件...')
- fetch('/api/materials/upload/cleanup', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ uploadId: state.uploadId })
- }).catch(() => {})
- }
- throw new Error('完成上传失败,已重试多次')
+ // 5xx、超时和网络错误都不能证明服务端没有完成合并。保留本地断点,
+ // 且绝不 destructive cleanup,避免删除一个已经成功生成的 merged 文件。
+ const detail = error instanceof Error ? error.message : '服务器处理结果尚未确认'
+ const suffix = isUploadOutcomeUncertain(error)
+ ? '服务器可能已经完成处理'
+ : '服务暂时不可用'
+ throw createUncertainUploadError(
+ `${detail};${suffix},已保留上传断点,请稍后重试确认`,
+ )
}
}
}
diff --git a/lib/image-processing-limiter.ts b/lib/image-processing-limiter.ts
index a8dd6a0..e0a56c6 100644
--- a/lib/image-processing-limiter.ts
+++ b/lib/image-processing-limiter.ts
@@ -1,5 +1,38 @@
+import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
+import { lstat, mkdir, open } from 'fs/promises'
+import path from 'path'
+import { AsyncLocalStorage } from 'async_hooks'
+
+import { getUploadRootDir } from '@/lib/upload-paths'
+
const DEFAULT_MAX_CONCURRENT_IMAGE_JOBS = 1
const MAX_ALLOWED_IMAGE_JOBS = 2
+const DEFAULT_IMAGE_LOCK_TIMEOUT_MS = 60_000
+const IMAGE_LOCK_EXIT_TIMEOUT_MS = 5_000
+const IMAGE_LOCK_READY_MARKER = 'IMAGE_PROCESSING_LOCK_READY\n'
+const IMAGE_LOCK_FILE_NAME = 'image-processing-global.lock'
+
+export class ImageProcessingLockUnavailableError extends Error {
+ constructor(message = '图片处理服务正忙,请稍后重试') {
+ super(message)
+ this.name = 'ImageProcessingLockUnavailableError'
+ }
+}
+
+export class ImageProcessingLockLostError extends Error {
+ constructor() {
+ super('图片处理锁意外中断,本次结果已安全拒绝')
+ this.name = 'ImageProcessingLockLostError'
+ }
+}
+
+type ImageProcessingLockRelease = (() => Promise) & {
+ assertHeld: () => void
+}
+
+const imageProcessingLockContext = new AsyncLocalStorage<{
+ assertHeld: () => void
+}>()
function readMaxConcurrentImageJobs(): number {
const configured = Number.parseInt(process.env.IMAGE_PROCESSING_MAX_CONCURRENT || '', 10)
@@ -14,36 +47,230 @@ const maxConcurrentImageJobs = readMaxConcurrentImageJobs()
let activeImageJobs = 0
const waitingImageJobs: Array<() => void> = []
-async function acquireImageProcessingSlot(): Promise {
- if (activeImageJobs < maxConcurrentImageJobs) {
+async function acquireInProcessImageProcessingSlot(): Promise {
+ if (activeImageJobs >= maxConcurrentImageJobs) {
+ await new Promise((resolve) => waitingImageJobs.push(resolve))
+ } else {
activeImageJobs += 1
- return
}
- await new Promise((resolve) => waitingImageJobs.push(resolve))
+ let released = false
+ const release = async () => {
+ if (released) return
+ released = true
+ const next = waitingImageJobs.shift()
+ if (next) {
+ // 当前名额直接交给队首等待者,避免释放与重新获取之间被后来请求插队。
+ next()
+ return
+ }
+ activeImageJobs = Math.max(0, activeImageJobs - 1)
+ }
+ release.assertHeld = () => {
+ if (released) throw new ImageProcessingLockLostError()
+ }
+ return release
}
-function releaseImageProcessingSlot(): void {
- const next = waitingImageJobs.shift()
- if (next) {
- // 将当前占用的名额直接交给队首等待者。这里不能先减 active 再让等待者加回,
- // 否则两者之间的新请求可能抢到这个瞬时空位,突破配置的并发上限。
- next()
- return
+function imageProcessingLockPath(): string {
+ // UPLOAD_DIR 是蓝绿容器共同挂载的目录,不能使用各容器自己的 /tmp。
+ return path.join(getUploadRootDir(), '.locks', IMAGE_LOCK_FILE_NAME)
+}
+
+async function prepareImageProcessingLockFile(): Promise {
+ const lockPath = imageProcessingLockPath()
+ const lockDirectory = path.dirname(lockPath)
+ await mkdir(lockDirectory, { recursive: true, mode: 0o750 })
+
+ const directoryStats = await lstat(lockDirectory)
+ if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) {
+ throw new ImageProcessingLockUnavailableError('图片处理锁目录异常,请联系管理员')
}
- activeImageJobs = Math.max(0, activeImageJobs - 1)
+ try {
+ const handle = await open(lockPath, 'wx', 0o600)
+ await handle.close()
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException | undefined)?.code !== 'EEXIST') throw error
+ }
+
+ const lockStats = await lstat(lockPath)
+ if (!lockStats.isFile() || lockStats.isSymbolicLink()) {
+ throw new ImageProcessingLockUnavailableError('图片处理锁文件异常,请联系管理员')
+ }
+ return lockPath
+}
+
+async function acquireLinuxImageProcessingLock(): Promise {
+ const lockPath = await prepareImageProcessingLockFile()
+ const child = spawn(
+ 'flock',
+ [
+ '--exclusive',
+ '--timeout',
+ String(Math.ceil(DEFAULT_IMAGE_LOCK_TIMEOUT_MS / 1_000)),
+ '--conflict-exit-code',
+ '75',
+ '--no-fork',
+ lockPath,
+ 'sh',
+ '-c',
+ `printf '${IMAGE_LOCK_READY_MARKER}'; cat >/dev/null`,
+ ],
+ {
+ shell: false,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ },
+ )
+ return waitForImageProcessingFlock(child)
+}
+
+function waitForImageProcessingFlock(
+ child: ChildProcessWithoutNullStreams,
+): Promise {
+ return new Promise((resolve, reject) => {
+ let stdout = ''
+ let stderr = ''
+ let settled = false
+ let released = false
+ let lostError: Error | null = null
+ const timer = setTimeout(() => {
+ if (settled) return
+ settled = true
+ void terminateImageLockProcess(child).finally(() => {
+ reject(new ImageProcessingLockUnavailableError())
+ })
+ }, DEFAULT_IMAGE_LOCK_TIMEOUT_MS + 1_000)
+ timer.unref?.()
+
+ const fail = (error: unknown) => {
+ if (settled) return
+ settled = true
+ clearTimeout(timer)
+ void terminateImageLockProcess(child).finally(() => {
+ console.error('[ImageProcessingLimiter] flock 获取失败:', {
+ error,
+ stderr: stderr.slice(0, 1_000),
+ })
+ reject(new ImageProcessingLockUnavailableError())
+ })
+ }
+ const recordLost = (source: string, error?: unknown) => {
+ if (!settled || released || lostError) return
+ lostError = new ImageProcessingLockLostError()
+ console.error('[ImageProcessingLimiter] 已获取的 flock 意外丢失:', {
+ source,
+ error,
+ stderr: stderr.slice(0, 1_000),
+ })
+ }
+
+ child.stdin.on('error', (error) => recordLost('stdin', error))
+ child.stdout.on('error', (error) => recordLost('stdout', error))
+ child.stderr.on('error', (error) => recordLost('stderr', error))
+ child.stderr.on('data', (chunk) => {
+ stderr += String(chunk)
+ if (stderr.length > 2_000) stderr = stderr.slice(-2_000)
+ })
+ child.on('error', (error) => settled ? recordLost('process', error) : fail(error))
+ child.on('close', (code, signal) => {
+ if (!settled) {
+ fail(code === 75
+ ? new ImageProcessingLockUnavailableError()
+ : new Error(`flock 提前退出: code=${code}, signal=${signal}`))
+ } else if (!released) {
+ recordLost('close', new Error(`code=${code}, signal=${signal}`))
+ }
+ })
+ child.stdout.on('data', (chunk) => {
+ if (settled) return
+ stdout += String(chunk)
+ if (!stdout.includes(IMAGE_LOCK_READY_MARKER)) {
+ if (stdout.length > IMAGE_LOCK_READY_MARKER.length * 4) {
+ fail(new Error('flock 未返回预期就绪标记'))
+ }
+ return
+ }
+
+ settled = true
+ clearTimeout(timer)
+ const release = async () => {
+ if (released) return
+ released = true
+ await terminateImageLockProcess(child)
+ }
+ release.assertHeld = () => {
+ if (released || lostError || child.exitCode !== null || child.signalCode !== null) {
+ throw lostError || new ImageProcessingLockLostError()
+ }
+ }
+ resolve(release)
+ })
+ })
+}
+
+async function terminateImageLockProcess(child: ChildProcessWithoutNullStreams): Promise {
+ if (child.exitCode !== null || child.signalCode !== null) return
+ const closed = new Promise((resolve) => child.once('close', () => resolve()))
+ child.stdin.end()
+ if (await waitForImageLockPromise(closed, IMAGE_LOCK_EXIT_TIMEOUT_MS)) return
+ child.kill('SIGTERM')
+ if (await waitForImageLockPromise(closed, IMAGE_LOCK_EXIT_TIMEOUT_MS)) return
+ child.kill('SIGKILL')
+ await waitForImageLockPromise(closed, IMAGE_LOCK_EXIT_TIMEOUT_MS)
+}
+
+async function waitForImageLockPromise(promise: Promise, timeoutMs: number): Promise {
+ let timer: ReturnType | null = null
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(false), timeoutMs)
+ timer.unref?.()
+ })
+ const result = await Promise.race([promise.then(() => true), timeout])
+ if (timer) clearTimeout(timer)
+ return result
+}
+
+async function acquireImageProcessingSlot(): Promise {
+ if (process.platform === 'linux' && process.env.NODE_ENV === 'production') {
+ // 生产环境固定整机并发 1;不得用环境变量把蓝绿总并发放大。
+ return acquireLinuxImageProcessingLock()
+ }
+ return acquireInProcessImageProcessingSlot()
}
/**
- * 跨请求限制 Sharp 任务数。单个请求内串行并不能阻止多个请求同时解码大图,
- * 所以白底图、预览图和长图处理都必须共用这个进程级门闩。
+ * 所有 Sharp 重处理共用的门闩。生产 Linux 会在共享 uploads 上持有独立 OS
+ * flock;进程崩溃后内核自动释放。任务完成前后都确认锁仍在,锁状态不明时
+ * 失败关闭,调用方不得提交本次派生结果。
*/
export async function withImageProcessingSlot(task: () => Promise): Promise {
- await acquireImageProcessingSlot()
+ const inheritedLock = imageProcessingLockContext.getStore()
+ if (inheritedLock) {
+ // image-processor 的高层函数可能在已持锁的安全流程内调用另一个受限函数。
+ // 同一异步调用链复用锁,既避免自锁,也不允许脱离父任务后继续使用旧锁。
+ inheritedLock.assertHeld()
+ const result = await task()
+ inheritedLock.assertHeld()
+ return result
+ }
+
+ const release = await acquireImageProcessingSlot()
try {
- return await task()
+ release.assertHeld()
+ const result = await imageProcessingLockContext.run(
+ { assertHeld: release.assertHeld },
+ task,
+ )
+ release.assertHeld()
+ return result
} finally {
- releaseImageProcessingSlot()
+ await release()
}
}
+
+/** @internal 仅供定向测试确认共享锁位置。 */
+export const __imageProcessingLimiterTesting = {
+ imageProcessingLockPath,
+}
diff --git a/lib/material-canonical-source.ts b/lib/material-canonical-source.ts
new file mode 100644
index 0000000..cd531e0
--- /dev/null
+++ b/lib/material-canonical-source.ts
@@ -0,0 +1,162 @@
+import { constants } from 'fs'
+import { lstat, open } from 'fs/promises'
+import path from 'path'
+
+import { isPathFreeOfSymbolicLinks } from './path-security'
+import { getUploadRootDir, resolveStoredUploadPath } from './upload-paths'
+
+export type CanonicalMaterialSourceValidationCode =
+ | 'CANONICAL_SOURCE_PATH_UNTRUSTED'
+ | 'CANONICAL_SOURCE_STAT_FAILED'
+ | 'CANONICAL_SOURCE_NOT_REGULAR'
+ | 'CANONICAL_SOURCE_EMPTY'
+ | 'CANONICAL_SOURCE_SIZE_INVALID'
+ | 'CANONICAL_SOURCE_SIZE_MISMATCH'
+
+export class CanonicalMaterialSourceValidationError extends Error {
+ readonly code: CanonicalMaterialSourceValidationCode
+ readonly cause?: unknown
+
+ constructor(
+ code: CanonicalMaterialSourceValidationCode,
+ message: string,
+ cause?: unknown,
+ ) {
+ super(message)
+ this.name = 'CanonicalMaterialSourceValidationError'
+ this.code = code
+ this.cause = cause
+ }
+}
+
+function parseExpectedSize(value: bigint | number | string): bigint {
+ try {
+ const parsed = typeof value === 'bigint' ? value : BigInt(value)
+ if (parsed <= BigInt(0)) {
+ throw new Error('expected size must be positive')
+ }
+ return parsed
+ } catch (error) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_SIZE_INVALID',
+ 'canonical 源文件的已知大小无效',
+ error,
+ )
+ }
+}
+
+function resolveTrustedCanonicalSourcePath(storedPath: string): {
+ uploadRoot: string
+ sourcePath: string
+} {
+ const uploadRoot = path.resolve(getUploadRootDir())
+ const sourcePath = path.resolve(resolveStoredUploadPath(String(storedPath || '').trim()))
+ const relativePath = path.relative(uploadRoot, sourcePath)
+
+ if (
+ !String(storedPath || '').trim()
+ || !relativePath
+ || relativePath === '..'
+ || relativePath.startsWith(`..${path.sep}`)
+ || path.isAbsolute(relativePath)
+ ) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_PATH_UNTRUSTED',
+ 'canonical 源文件不在受信任的 uploads 根目录内',
+ )
+ }
+
+ return { uploadRoot, sourcePath }
+}
+
+/**
+ * 在 Material 提交前及成功响应前验证本地 canonical 原件。
+ *
+ * 校验同时使用 lstat 与只读文件描述符 fstat:前者拒绝符号链接和越界路径,
+ * 后者确认实际打开的对象仍是同一个非空普通文件,降低检查后被替换的风险。
+ */
+export async function validateCanonicalMaterialSource(input: {
+ storedPath: string
+ expectedSize: bigint | number | string
+}): Promise<{ sourcePath: string; size: bigint }> {
+ const expectedSize = parseExpectedSize(input.expectedSize)
+ const { uploadRoot, sourcePath } = resolveTrustedCanonicalSourcePath(input.storedPath)
+
+ let pathStats
+ try {
+ pathStats = await lstat(sourcePath, { bigint: true })
+ } catch (error) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_STAT_FAILED',
+ '无法读取 canonical 源文件状态',
+ error,
+ )
+ }
+
+ if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_NOT_REGULAR',
+ 'canonical 源文件不是普通非符号链接文件',
+ )
+ }
+
+ if (!(await isPathFreeOfSymbolicLinks(uploadRoot, sourcePath))) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_PATH_UNTRUSTED',
+ 'canonical 源文件路径包含符号链接或已离开受信任根目录',
+ )
+ }
+
+ if (pathStats.size <= BigInt(0)) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_EMPTY',
+ 'canonical 源文件为空',
+ )
+ }
+ if (pathStats.size !== expectedSize) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_SIZE_MISMATCH',
+ 'canonical 源文件实际大小与已知大小不一致',
+ )
+ }
+
+ let handle
+ try {
+ handle = await open(
+ sourcePath,
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0),
+ )
+ const openedStats = await handle.stat({ bigint: true })
+ if (!openedStats.isFile()) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_NOT_REGULAR',
+ '实际打开的 canonical 源对象不是普通文件',
+ )
+ }
+ if (openedStats.size <= BigInt(0)) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_EMPTY',
+ '实际打开的 canonical 源文件为空',
+ )
+ }
+ if (openedStats.size !== expectedSize || openedStats.size !== pathStats.size) {
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_SIZE_MISMATCH',
+ '实际打开的 canonical 源文件大小已发生变化',
+ )
+ }
+ } catch (error) {
+ if (error instanceof CanonicalMaterialSourceValidationError) {
+ throw error
+ }
+ throw new CanonicalMaterialSourceValidationError(
+ 'CANONICAL_SOURCE_STAT_FAILED',
+ '无法安全打开 canonical 源文件',
+ error,
+ )
+ } finally {
+ await handle?.close().catch(() => undefined)
+ }
+
+ return { sourcePath, size: expectedSize }
+}
diff --git a/lib/material-query.ts b/lib/material-query.ts
index dc11cf9..df63f1b 100644
--- a/lib/material-query.ts
+++ b/lib/material-query.ts
@@ -5,10 +5,12 @@ import {
isSystemAdmin,
} from './department-permission'
import { filterSelectableMaterialTypes } from './material-type-options'
+import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from './task-detail-source-type'
import {
getCompatibleVideoMaterialTypes,
normalizeVideoMaterialType,
} from './video-material-types'
+import type { Prisma } from '@prisma/client'
export type MaterialFilterDimension =
| 'materialType'
@@ -62,6 +64,112 @@ const MATERIAL_TYPE_COMPATIBILITY: Record = {
'C4D': ['C4D模型', 'C4D'],
}
+/**
+ * 这些类型的专用 init 接口会先创建 Material,完成接口随后才写入预览、ZIP 和资产。
+ * 普通列表必须隐藏上传尚未完成的空壳,但不能靠“taskStatus 不是 completed”做宽泛判断,
+ * 否则会误伤 taskStatus 为空的历史有效数据。
+ */
+export const SPECIAL_INIT_MATERIAL_TYPES = [
+ '任务-3D原图',
+ '任务-AI原图',
+ '任务-实拍原图',
+ '任务-视频截图',
+ '任务-页面',
+ '证书-检测报告',
+ '任务-详情',
+] as const
+
+const PROVABLY_INCOMPLETE_TASK_STATUSES = [
+ 'uploading',
+ 'processing',
+ 'failed',
+] as const
+
+export interface SpecialInitShellEvidence {
+ materialType?: string | null
+ taskStatus?: string | null
+ previewUrl?: string | null
+ zipFilePath?: string | null
+ zipFileName?: string | null
+ displayImages?: unknown
+ assets?: unknown
+ taskDetailSourceType?: string | null
+ taskDetailRequesterUserId?: string | null
+}
+
+function hasNonEmptyString(value: unknown): boolean {
+ return typeof value === 'string' && value.trim().length > 0
+}
+
+/**
+ * 与数据库过滤策略相同的纯函数,便于逐类型验证显示边界。
+ * 这里宁可保留证据不完整的旧记录,也不把“没有预览”直接等同于“上传空壳”。
+ */
+export function isProvablyIncompleteSpecialInitShell(
+ material: SpecialInitShellEvidence,
+): boolean {
+ if (!SPECIAL_INIT_MATERIAL_TYPES.includes(
+ String(material.materialType || '').trim() as typeof SPECIAL_INIT_MATERIAL_TYPES[number],
+ )) {
+ return false
+ }
+
+ const hasDisplayImages = Array.isArray(material.displayImages)
+ ? material.displayImages.length > 0
+ : hasNonEmptyString(material.displayImages)
+ const hasAssets = Array.isArray(material.assets)
+ ? material.assets.length > 0
+ : Boolean(material.assets)
+ if (
+ hasNonEmptyString(material.previewUrl)
+ || hasNonEmptyString(material.zipFilePath)
+ || hasNonEmptyString(material.zipFileName)
+ || hasDisplayImages
+ || hasAssets
+ ) {
+ return false
+ }
+
+ if (PROVABLY_INCOMPLETE_TASK_STATUSES.includes(
+ String(material.taskStatus || '').trim() as typeof PROVABLY_INCOMPLETE_TASK_STATUSES[number],
+ )) {
+ return true
+ }
+
+ // product-package/init 当前不会写 taskStatus;只有同时具备其两项专属初始化
+ // 标记时,才把“任务-详情 + null 状态 + 无资产”认定为新链路空壳。
+ return material.materialType === '任务-详情'
+ && material.taskStatus == null
+ && TASK_DETAIL_SOURCE_TYPE_OPTIONS.includes(
+ String(material.taskDetailSourceType || '').trim() as typeof TASK_DETAIL_SOURCE_TYPE_OPTIONS[number],
+ )
+ && hasNonEmptyString(material.taskDetailRequesterUserId)
+}
+
+export function buildIncompleteSpecialInitShellWhere(): Prisma.MaterialWhereInput {
+ return {
+ AND: [
+ { materialType: { in: [...SPECIAL_INIT_MATERIAL_TYPES] } },
+ { previewUrl: '' },
+ { zipFilePath: '' },
+ { zipFileName: '' },
+ { displayImages: { isEmpty: true } },
+ { assets: { none: {} } },
+ {
+ OR: [
+ { taskStatus: { in: [...PROVABLY_INCOMPLETE_TASK_STATUSES] } },
+ {
+ materialType: '任务-详情',
+ taskStatus: null,
+ taskDetailSourceType: { in: [...TASK_DETAIL_SOURCE_TYPE_OPTIONS] },
+ taskDetailRequesterUserId: { not: null },
+ },
+ ],
+ },
+ ],
+ }
+}
+
function unique(values: string[]): string[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
@@ -276,15 +384,10 @@ export async function buildMaterialWhereFromSearchParams(
where.deletedAt = { not: null }
} else {
where.deletedAt = null
- // “任务-页面”会先创建素材记录,再上传并生成预览图。历史上传中断留下的
- // 空记录没有预览、ZIP 或任何资产,不应出现在正常素材列表和筛选计数中。
- // 这里只隐藏可确认没有文件的空壳,不删除数据,也不影响回收站查看。
- where.NOT = {
- materialType: '任务-页面',
- previewUrl: '',
- zipFilePath: '',
- assets: { none: {} },
- }
+ // 专用上传会先创建 Material 再完成文件落库。统一查询层隐藏可证明未完成、
+ // 且完全没有文件证据的空壳,因此列表、筛选计数和搜索建议保持一致。
+ // 按 ID / uploadId 的状态查询不经过这里,上传重试和状态恢复仍可访问原记录。
+ where.NOT = buildIncompleteSpecialInitShellWhere()
}
const category = searchParams.get('category') || ''
diff --git a/lib/material-update.ts b/lib/material-update.ts
index 360719d..018f6df 100644
--- a/lib/material-update.ts
+++ b/lib/material-update.ts
@@ -1,7 +1,10 @@
+import { randomUUID } from 'crypto'
+
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
-import { deleteFromS3 } from '@/lib/s3'
import { compressImage, sanitizeFileName } from '@/lib/image-processor'
+import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
+import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock'
import {
buildMaterialPublicDisplayPath,
uploadPublicDisplayToS3,
@@ -149,25 +152,17 @@ async function resolvePreviewUrl(
})
const sanitizedName = sanitizeFileName(previewImage.name)
- const fileName = `preview-${Date.now()}-${sanitizedName}`
+ const fileName = `preview-${Date.now()}-${randomUUID()}-${sanitizedName}`
const nextPreviewUrl = await uploadPublicDisplayToS3(
compressedBuffer,
buildMaterialPublicDisplayPath(fileName),
'image/webp',
)
- if (currentPreviewUrl) {
- try {
- await deleteFromS3(currentPreviewUrl)
- } catch (error) {
- console.error('[material-update] 删除旧预览图失败:', error)
- }
- }
-
return nextPreviewUrl
}
-export async function updateMaterialWithSharedRules(options: UpdateMaterialOptions) {
+async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOptions) {
const { materialId, operatorUserId, operatorRole, input, previewImage } = options
const editable = await ensureEditableMaterial(materialId, operatorUserId, operatorRole)
@@ -374,10 +369,47 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
updateData.userId = nextUserId
}
- const updatedMaterial = await prisma.material.update({
- where: { id: materialId },
- data: updateData as any,
- })
+ const uploadedPreviewUrl = previewImage && previewImage.size > 0 && previewUrl !== material.previewUrl
+ ? previewUrl
+ : null
+ let updatedMaterial: any
+ try {
+ updatedMaterial = await prisma.material.update({
+ where: { id: materialId },
+ data: updateData as any,
+ })
+ } catch (error) {
+ if (!uploadedPreviewUrl) throw error
+
+ // 数据库可能已经提交,只是连接在返回确认前中断。只有明确读到旧值时才清理
+ // 本次新对象;读取失败时偏向保留,绝不删除数据库可能已经引用的图片。
+ let observed: any = null
+ try {
+ observed = await prisma.material.findUnique({ where: { id: materialId } })
+ } catch (verificationError) {
+ console.error('[material-update] 更新结果无法确认,保留新预览图等待对账:', verificationError)
+ throw error
+ }
+
+ if (observed?.previewUrl === uploadedPreviewUrl) {
+ updatedMaterial = observed
+ } else {
+ await deleteFileIfUnreferenced(uploadedPreviewUrl, {
+ excludeMaterialIds: [materialId],
+ }).catch((cleanupError) => {
+ console.error('[material-update] 清理未提交的新预览图失败:', cleanupError)
+ })
+ throw error
+ }
+ }
+
+ if (uploadedPreviewUrl && material.previewUrl && material.previewUrl !== uploadedPreviewUrl) {
+ await deleteFileIfUnreferenced(material.previewUrl, {
+ excludeMaterialIds: [materialId],
+ }).catch((error) => {
+ console.error('[material-update] 数据库提交后清理旧预览图失败,已保留数据库新值:', error)
+ })
+ }
const changes = buildMaterialChanges(material, updateData)
if (Object.keys(changes).length > 0) {
@@ -398,3 +430,15 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
changes,
}
}
+
+export async function updateMaterialWithSharedRules(options: UpdateMaterialOptions) {
+ const release = await acquireTaskMaterialCompletionLock(options.materialId)
+ try {
+ release.assertHeld()
+ const result = await updateMaterialWithSharedRulesLocked(options)
+ release.assertHeld()
+ return result
+ } finally {
+ await release()
+ }
+}
diff --git a/lib/material-upload-chunk-policy.ts b/lib/material-upload-chunk-policy.ts
new file mode 100644
index 0000000..05ac22c
--- /dev/null
+++ b/lib/material-upload-chunk-policy.ts
@@ -0,0 +1,77 @@
+const MEBIBYTE = 1024 * 1024
+
+export const MAX_LOGICAL_UPLOAD_BYTES = 10 * 1024 * MEBIBYTE
+export const MAX_UPLOAD_CHUNK_BYTES = 20 * MEBIBYTE
+export const MAX_UPLOAD_CHUNKS = Math.ceil(MAX_LOGICAL_UPLOAD_BYTES / (10 * MEBIBYTE))
+
+const LEGACY_BROWSER_CHUNK_SIZES = [10 * MEBIBYTE, 20 * MEBIBYTE]
+
+/**
+ * Resolve and validate the physical chunk size used for one upload session.
+ * Older already-open browser pages did not send chunkSize to init; only the two
+ * historically supported 10/20 MiB layouts are inferred for those sessions.
+ */
+export function resolveUploadChunkSize(
+ fileSize: number,
+ totalChunks: number,
+ rawChunkSize?: unknown,
+): number | null {
+ if (
+ !Number.isSafeInteger(fileSize)
+ || fileSize <= 0
+ || fileSize > MAX_LOGICAL_UPLOAD_BYTES
+ || !Number.isInteger(totalChunks)
+ || totalChunks <= 0
+ || totalChunks > MAX_UPLOAD_CHUNKS
+ ) {
+ return null
+ }
+
+ const hasExplicitChunkSize = rawChunkSize !== undefined
+ && rawChunkSize !== null
+ && String(rawChunkSize).trim() !== ''
+
+ if (hasExplicitChunkSize) {
+ const chunkSize = Number(rawChunkSize)
+ if (
+ !Number.isSafeInteger(chunkSize)
+ || chunkSize <= 0
+ || chunkSize > MAX_UPLOAD_CHUNK_BYTES
+ || Math.ceil(fileSize / chunkSize) !== totalChunks
+ ) {
+ return null
+ }
+ return chunkSize
+ }
+
+ return LEGACY_BROWSER_CHUNK_SIZES.find(
+ (chunkSize) => Math.ceil(fileSize / chunkSize) === totalChunks,
+ ) || null
+}
+
+export function getExpectedUploadChunkBytes(input: {
+ fileSize: number
+ totalChunks: number
+ chunkSize: number
+ chunkIndex: number
+}): number | null {
+ const { fileSize, totalChunks, chunkSize, chunkIndex } = input
+ if (
+ !Number.isInteger(chunkIndex)
+ || chunkIndex < 0
+ || chunkIndex >= totalChunks
+ || resolveUploadChunkSize(fileSize, totalChunks, chunkSize) !== chunkSize
+ ) {
+ return null
+ }
+
+ const expectedBytes = chunkIndex === totalChunks - 1
+ ? fileSize - chunkSize * (totalChunks - 1)
+ : chunkSize
+
+ return Number.isSafeInteger(expectedBytes)
+ && expectedBytes > 0
+ && expectedBytes <= MAX_UPLOAD_CHUNK_BYTES
+ ? expectedBytes
+ : null
+}
diff --git a/lib/material-upload-consumption-journal.ts b/lib/material-upload-consumption-journal.ts
new file mode 100644
index 0000000..27ed9b3
--- /dev/null
+++ b/lib/material-upload-consumption-journal.ts
@@ -0,0 +1,318 @@
+import { createHash, randomBytes } from 'crypto'
+import { lstat, open, readFile, rename, unlink } from 'fs/promises'
+import path from 'path'
+
+import {
+ ensureSafeDirectoryWithinRoot,
+ isPathFreeOfSymbolicLinks,
+ normalizeSafeFileName,
+ resolveStrictChildPath,
+} from './path-security'
+import { getUploadRootDir } from './upload-paths'
+
+const JOURNAL_DIRECTORY_NAME = '.material-upload-consumption'
+const JOURNAL_VERSION = 1
+const MAX_JOURNAL_BYTES = 16 * 1024
+const SAFE_UPLOAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
+
+export interface MaterialUploadConsumptionRecord {
+ version: 1
+ uploadId: string
+ ownerId: string
+ mergedPath: string
+ finalPath: string
+ storedFinalPath: string
+ fileName: string
+ size: number
+ createdAt: number
+}
+
+export interface RecoveredMaterialUploadConsumption {
+ record: MaterialUploadConsumptionRecord
+ sourcePath: string
+ sourceLocation: 'merged' | 'final'
+}
+
+export class MaterialUploadConsumptionJournalError extends Error {
+ constructor(message = '上传源文件恢复记录异常,请稍后重试') {
+ super(message)
+ this.name = 'MaterialUploadConsumptionJournalError'
+ }
+}
+
+function isSafeOwnerId(ownerId: string): boolean {
+ return (
+ ownerId.length > 0
+ && Buffer.byteLength(ownerId, 'utf8') <= 512
+ && !/[\x00-\x1f\x7f]/.test(ownerId)
+ )
+}
+
+function isPathInside(basePath: string, targetPath: string): boolean {
+ const relativePath = path.relative(path.resolve(basePath), path.resolve(targetPath))
+ return Boolean(
+ relativePath
+ && relativePath !== '..'
+ && !relativePath.startsWith(`..${path.sep}`)
+ && !path.isAbsolute(relativePath),
+ )
+}
+
+function resolveJournalPath(ownerId: string, uploadId: string): string {
+ const uploadRoot = getUploadRootDir()
+ const digest = createHash('sha256')
+ .update(ownerId)
+ .update('\0')
+ .update(uploadId)
+ .digest('hex')
+ const journalPath = resolveStrictChildPath(
+ path.join(uploadRoot, JOURNAL_DIRECTORY_NAME),
+ `${digest}.json`,
+ )
+ if (!journalPath) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+ return journalPath
+}
+
+function normalizeRecord(input: unknown): MaterialUploadConsumptionRecord {
+ const value = input as Partial | null
+ const uploadId = String(value?.uploadId || '').trim()
+ const ownerId = String(value?.ownerId || '').trim()
+ const mergedPath = path.resolve(String(value?.mergedPath || ''))
+ const finalPath = path.resolve(String(value?.finalPath || ''))
+ const storedFinalPath = String(value?.storedFinalPath || '').trim()
+ const fileName = normalizeSafeFileName(String(value?.fileName || ''))
+ const size = Number(value?.size)
+ const createdAt = Number(value?.createdAt)
+ const uploadRoot = path.resolve(getUploadRootDir())
+ const tempRoot = path.join(uploadRoot, 'temp')
+ const expectedSessionDir = path.join(tempRoot, uploadId)
+
+ if (
+ value?.version !== JOURNAL_VERSION
+ || !SAFE_UPLOAD_ID_PATTERN.test(uploadId)
+ || !isSafeOwnerId(ownerId)
+ || !fileName
+ || !Number.isSafeInteger(size)
+ || size <= 0
+ || !Number.isFinite(createdAt)
+ || createdAt <= 0
+ || path.dirname(mergedPath) !== expectedSessionDir
+ || !path.basename(mergedPath).startsWith('merged-')
+ || !isPathInside(tempRoot, mergedPath)
+ || path.dirname(finalPath) !== uploadRoot
+ || !normalizeSafeFileName(path.basename(finalPath))
+ || path.basename(finalPath) !== fileName
+ || path.resolve(storedFinalPath) !== finalPath
+ ) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+
+ return {
+ version: JOURNAL_VERSION,
+ uploadId,
+ ownerId,
+ mergedPath,
+ finalPath,
+ storedFinalPath,
+ fileName,
+ size,
+ createdAt,
+ }
+}
+
+async function readJournalFile(
+ ownerId: string,
+ uploadId: string,
+): Promise {
+ if (!SAFE_UPLOAD_ID_PATTERN.test(uploadId) || !isSafeOwnerId(ownerId)) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+
+ const journalPath = resolveJournalPath(ownerId, uploadId)
+ let stats
+ try {
+ stats = await lstat(journalPath)
+ } catch (error: any) {
+ if (error?.code === 'ENOENT') {
+ return null
+ }
+ throw new MaterialUploadConsumptionJournalError()
+ }
+ if (!stats.isFile() || stats.isSymbolicLink() || stats.size <= 0 || stats.size > MAX_JOURNAL_BYTES) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+
+ let record: MaterialUploadConsumptionRecord
+ try {
+ record = normalizeRecord(JSON.parse(await readFile(journalPath, 'utf8')))
+ } catch (error) {
+ if (error instanceof MaterialUploadConsumptionJournalError) {
+ throw error
+ }
+ throw new MaterialUploadConsumptionJournalError()
+ }
+ if (record.ownerId !== ownerId || record.uploadId !== uploadId) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+ return record
+}
+
+async function inspectRegularFile(
+ uploadRoot: string,
+ filePath: string,
+ expectedSize: number,
+): Promise<'missing' | 'valid' | 'invalid'> {
+ let stats
+ try {
+ stats = await lstat(filePath)
+ } catch (error: any) {
+ if (error?.code === 'ENOENT') {
+ return 'missing'
+ }
+ throw new MaterialUploadConsumptionJournalError()
+ }
+
+ if (
+ stats.isSymbolicLink()
+ || !stats.isFile()
+ || stats.size !== expectedSize
+ || !(await isPathFreeOfSymbolicLinks(uploadRoot, filePath))
+ ) {
+ return 'invalid'
+ }
+ return 'valid'
+}
+
+async function syncDirectory(directoryPath: string): Promise {
+ const directory = await open(directoryPath, 'r')
+ try {
+ await directory.sync()
+ } catch (error: any) {
+ // Windows 不支持目录 fsync;Linux 生产环境的其他错误必须上抛。
+ if (process.platform !== 'win32' || !['EINVAL', 'EPERM', 'ENOTSUP'].includes(error?.code)) {
+ throw error
+ }
+ } finally {
+ await directory.close()
+ }
+}
+
+export async function writeMaterialUploadConsumptionJournal(input: {
+ uploadId: string
+ ownerId: string
+ mergedPath: string
+ finalPath: string
+ storedFinalPath: string
+ fileName: string
+ size: number
+}): Promise {
+ const record = normalizeRecord({
+ version: JOURNAL_VERSION,
+ ...input,
+ createdAt: Date.now(),
+ })
+ const uploadRoot = path.resolve(getUploadRootDir())
+ if (await inspectRegularFile(uploadRoot, record.mergedPath, record.size) !== 'valid') {
+ throw new MaterialUploadConsumptionJournalError('上传源文件状态异常,未执行移动')
+ }
+ if (await inspectRegularFile(uploadRoot, record.finalPath, record.size) !== 'missing') {
+ throw new MaterialUploadConsumptionJournalError('上传目标文件状态异常,未执行移动')
+ }
+
+ const existing = await readJournalFile(record.ownerId, record.uploadId)
+ if (existing) {
+ const comparableExisting = { ...existing, createdAt: 0 }
+ const comparableRecord = { ...record, createdAt: 0 }
+ if (JSON.stringify(comparableExisting) !== JSON.stringify(comparableRecord)) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+ return existing
+ }
+
+ const journalDirectory = await ensureSafeDirectoryWithinRoot(
+ uploadRoot,
+ JOURNAL_DIRECTORY_NAME,
+ )
+ if (!journalDirectory) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+ const journalPath = resolveJournalPath(record.ownerId, record.uploadId)
+ const temporaryPath = resolveStrictChildPath(
+ journalDirectory,
+ `${path.basename(journalPath)}.${randomBytes(8).toString('hex')}.tmp`,
+ )
+ if (!temporaryPath) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+
+ const file = await open(temporaryPath, 'wx', 0o600)
+ try {
+ await file.writeFile(`${JSON.stringify(record)}\n`, 'utf8')
+ await file.sync()
+ } catch (error) {
+ await file.close().catch(() => undefined)
+ await unlink(temporaryPath).catch(() => undefined)
+ throw error
+ }
+ await file.close()
+
+ try {
+ await rename(temporaryPath, journalPath)
+ await syncDirectory(journalDirectory)
+ } catch (error) {
+ await unlink(temporaryPath).catch(() => undefined)
+ throw error
+ }
+ return record
+}
+
+export async function recoverMaterialUploadConsumptionJournal(input: {
+ ownerId: string
+ uploadId: string
+}): Promise {
+ const record = await readJournalFile(input.ownerId, input.uploadId)
+ if (!record) {
+ return null
+ }
+
+ const uploadRoot = path.resolve(getUploadRootDir())
+ const [mergedState, finalState] = await Promise.all([
+ inspectRegularFile(uploadRoot, record.mergedPath, record.size),
+ inspectRegularFile(uploadRoot, record.finalPath, record.size),
+ ])
+ if (mergedState === 'valid' && finalState === 'missing') {
+ return { record, sourcePath: record.mergedPath, sourceLocation: 'merged' }
+ }
+ if (mergedState === 'missing' && finalState === 'valid') {
+ return { record, sourcePath: record.finalPath, sourceLocation: 'final' }
+ }
+ throw new MaterialUploadConsumptionJournalError()
+}
+
+export async function syncMaterialUploadRootDirectory(): Promise {
+ await syncDirectory(getUploadRootDir())
+}
+
+export async function removeMaterialUploadConsumptionJournal(input: {
+ ownerId: string
+ uploadId: string
+ expectedFinalPath?: string
+}): Promise {
+ const record = await readJournalFile(input.ownerId, input.uploadId)
+ if (!record) {
+ return false
+ }
+ if (
+ input.expectedFinalPath
+ && path.resolve(input.expectedFinalPath) !== record.finalPath
+ ) {
+ throw new MaterialUploadConsumptionJournalError()
+ }
+
+ const journalPath = resolveJournalPath(record.ownerId, record.uploadId)
+ await unlink(journalPath)
+ await syncDirectory(path.dirname(journalPath))
+ return true
+}
diff --git a/lib/material-upload-cross-process-lock.ts b/lib/material-upload-cross-process-lock.ts
new file mode 100644
index 0000000..ecc8c12
--- /dev/null
+++ b/lib/material-upload-cross-process-lock.ts
@@ -0,0 +1,578 @@
+import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
+import { createHash } from 'crypto'
+import { lstat, mkdir, open } from 'fs/promises'
+import path from 'path'
+
+import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard'
+import { getUploadRootDir } from '@/lib/upload-paths'
+
+export type MaterialUploadStorageLockMode = 'shared' | 'exclusive'
+export type MaterialUploadStorageLockRelease = (() => Promise) & {
+ assertHeld: () => void
+}
+
+interface PendingLockRequest {
+ mode: MaterialUploadStorageLockMode
+ resolve: (release: MaterialUploadStorageLockRelease) => void
+ reject: (error: Error) => void
+ timer: ReturnType
+}
+
+const DEFAULT_LOCK_TIMEOUT_MS = 4 * 60 * 1000
+const TASK_MATERIAL_LOCK_TIMEOUT_MS = 1_000
+const TASK_MATERIAL_LOCK_HASH_LENGTH = 16
+const FLOCK_EXIT_TIMEOUT_MS = 5_000
+const LOCK_READY_MARKER = 'MATERIAL_UPLOAD_LOCK_READY\n'
+
+/**
+ * Windows 本地开发和 Jest 使用的进程内读写锁。生产 Linux 必须使用下方的
+ * flock;生产环境绝不会静默降级到这个锁,否则蓝绿容器仍可同时写满上传盘。
+ */
+export class InProcessMaterialUploadReadWriteLock {
+ private activeReaders = 0
+ private writerActive = false
+ private readonly waiting: PendingLockRequest[] = []
+
+ acquire(
+ mode: MaterialUploadStorageLockMode,
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
+ ): Promise {
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
+ return Promise.reject(new Error('上传存储锁超时时间无效'))
+ }
+
+ if (this.canAcquireImmediately(mode)) {
+ return Promise.resolve(this.grant(mode))
+ }
+
+ return new Promise((resolve, reject) => {
+ const request: PendingLockRequest = {
+ mode,
+ resolve,
+ reject,
+ timer: setTimeout(() => {
+ const index = this.waiting.indexOf(request)
+ if (index >= 0) {
+ this.waiting.splice(index, 1)
+ }
+ reject(createLockBusyError())
+ this.drainQueue()
+ }, timeoutMs),
+ }
+ request.timer.unref?.()
+ this.waiting.push(request)
+ })
+ }
+
+ snapshot(): { activeReaders: number; writerActive: boolean; waiting: number } {
+ return {
+ activeReaders: this.activeReaders,
+ writerActive: this.writerActive,
+ waiting: this.waiting.length,
+ }
+ }
+
+ private canAcquireImmediately(mode: MaterialUploadStorageLockMode): boolean {
+ if (mode === 'exclusive') {
+ return !this.writerActive && this.activeReaders === 0 && this.waiting.length === 0
+ }
+
+ // 已有写者排队后,新读者不能插队,否则持续分片流量会让合并永久饥饿。
+ return !this.writerActive && !this.waiting.some((request) => request.mode === 'exclusive')
+ }
+
+ private grant(mode: MaterialUploadStorageLockMode): MaterialUploadStorageLockRelease {
+ if (mode === 'exclusive') {
+ this.writerActive = true
+ } else {
+ this.activeReaders += 1
+ }
+
+ let released = false
+ const release = async () => {
+ if (released) {
+ return
+ }
+ released = true
+ if (mode === 'exclusive') {
+ this.writerActive = false
+ } else {
+ this.activeReaders = Math.max(0, this.activeReaders - 1)
+ }
+ this.drainQueue()
+ }
+ release.assertHeld = () => {
+ if (released) {
+ throw createLockLostError()
+ }
+ }
+ return release
+ }
+
+ private drainQueue(): void {
+ if (this.writerActive || this.waiting.length === 0) {
+ return
+ }
+
+ const first = this.waiting[0]
+ if (first.mode === 'exclusive') {
+ if (this.activeReaders !== 0) {
+ return
+ }
+ this.waiting.shift()
+ clearTimeout(first.timer)
+ first.resolve(this.grant('exclusive'))
+ return
+ }
+
+ while (this.waiting[0]?.mode === 'shared' && !this.writerActive) {
+ const reader = this.waiting.shift()!
+ clearTimeout(reader.timer)
+ reader.resolve(this.grant('shared'))
+ }
+ }
+}
+
+const inProcessLock = new InProcessMaterialUploadReadWriteLock()
+const inProcessArchiveLock = new InProcessMaterialUploadReadWriteLock()
+const inProcessTaskMaterialLocks = new Map()
+const inProcessTaskUploadSessionLocks = new Map()
+
+export async function acquireMaterialUploadStorageLock(
+ mode: MaterialUploadStorageLockMode,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
+ if (process.platform !== 'linux' || process.env.NODE_ENV !== 'production') {
+ return inProcessLock.acquire(mode, timeoutMs)
+ }
+
+ return acquireLinuxFlock(mode, timeoutMs, 'material-upload-storage.lock')
+}
+
+/**
+ * ZIP 生成在蓝绿容器间仍必须串行,但不能用全局存储写锁堵住
+ * 其他用户已经写完的分片发布。独立 archive 锁保证同时只有一个
+ * 大 ZIP 写入;ZIP 另外持有全局 shared 锁,与分片共存,但会阻止
+ * complete/清理在 ZIP 还未结束时进入。
+ */
+export async function acquireMaterialUploadArchiveLock(
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
+ if (process.platform !== 'linux' || process.env.NODE_ENV !== 'production') {
+ return inProcessArchiveLock.acquire('exclusive', timeoutMs)
+ }
+
+ return acquireLinuxFlock('exclusive', timeoutMs, 'material-upload-archive.lock')
+}
+
+/**
+ * 同一条任务素材的完成动作必须跨蓝绿容器互斥,但不能占用全局上传存储锁:
+ * 完成流程内部还会获取全局锁来合并分片和生成 ZIP,复用同一把锁会自锁。
+ */
+export async function acquireTaskMaterialCompletionLock(
+ materialId: string,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const normalizedMaterialId = String(materialId || '').trim()
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(normalizedMaterialId)) {
+ throw new MaterialUploadRequestError(
+ '素材编号无效,无法开始完成处理',
+ 400,
+ 'TASK_MATERIAL_ID_INVALID',
+ )
+ }
+
+ const timeoutMs = options.timeoutMs ?? TASK_MATERIAL_LOCK_TIMEOUT_MS
+ if (process.platform !== 'linux' || process.env.NODE_ENV !== 'production') {
+ let lock = inProcessTaskMaterialLocks.get(normalizedMaterialId)
+ if (!lock) {
+ lock = new InProcessMaterialUploadReadWriteLock()
+ inProcessTaskMaterialLocks.set(normalizedMaterialId, lock)
+ }
+ try {
+ const innerRelease = await lock.acquire('exclusive', timeoutMs)
+ let released = false
+ const release = async () => {
+ if (released) {
+ return
+ }
+ released = true
+ await innerRelease()
+ const snapshot = lock!.snapshot()
+ if (!snapshot.writerActive && snapshot.activeReaders === 0 && snapshot.waiting === 0) {
+ inProcessTaskMaterialLocks.delete(normalizedMaterialId)
+ }
+ }
+ release.assertHeld = () => innerRelease.assertHeld()
+ return release
+ } catch (error) {
+ throw normalizeTaskMaterialLockError(error)
+ }
+ }
+
+ try {
+ return await acquireLinuxFlock(
+ 'exclusive',
+ timeoutMs,
+ resolveScopedLockFileName('task-material', normalizedMaterialId),
+ )
+ } catch (error) {
+ throw normalizeTaskMaterialLockError(error)
+ }
+}
+
+/**
+ * 同一 uploadId 只能被一条任务素材完成流程消费。多个会话按固定顺序
+ * 取锁,防止不同 materialId 并发复用同一源文件时,一方清理掉另一方
+ * 正在读取的 assembled/temp 文件。
+ */
+export async function acquireTaskMaterialUploadSessionLocks(
+ uploadIds: string[],
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const normalizedUploadIds = Array.from(new Set(
+ uploadIds.map((uploadId) => String(uploadId || '').trim()),
+ )).sort()
+ if (
+ normalizedUploadIds.length === 0
+ || normalizedUploadIds.some((uploadId) => !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(uploadId))
+ ) {
+ throw new MaterialUploadRequestError(
+ '上传会话编号无效,无法安全完成素材',
+ 400,
+ 'TASK_MATERIAL_UPLOAD_SESSION_INVALID',
+ )
+ }
+
+ const timeoutMs = options.timeoutMs ?? TASK_MATERIAL_LOCK_TIMEOUT_MS
+ const releases: MaterialUploadStorageLockRelease[] = []
+ try {
+ for (const uploadId of normalizedUploadIds) {
+ if (process.platform !== 'linux' || process.env.NODE_ENV !== 'production') {
+ let lock = inProcessTaskUploadSessionLocks.get(uploadId)
+ if (!lock) {
+ lock = new InProcessMaterialUploadReadWriteLock()
+ inProcessTaskUploadSessionLocks.set(uploadId, lock)
+ }
+ const innerRelease = await lock.acquire('exclusive', timeoutMs)
+ const release = Object.assign(async () => {
+ await innerRelease()
+ const snapshot = lock!.snapshot()
+ if (!snapshot.writerActive && snapshot.activeReaders === 0 && snapshot.waiting === 0) {
+ inProcessTaskUploadSessionLocks.delete(uploadId)
+ }
+ }, {
+ assertHeld: () => innerRelease.assertHeld(),
+ })
+ releases.push(release)
+ } else {
+ releases.push(await acquireLinuxFlock(
+ 'exclusive',
+ timeoutMs,
+ resolveScopedLockFileName('task-upload', uploadId),
+ ))
+ }
+ }
+ } catch (error) {
+ for (const release of releases.reverse()) {
+ await release().catch(() => undefined)
+ }
+ throw normalizeTaskUploadSessionLockError(error)
+ }
+
+ let released = false
+ const releaseAll = async () => {
+ if (released) {
+ return
+ }
+ released = true
+ for (const release of releases.reverse()) {
+ await release()
+ }
+ }
+ releaseAll.assertHeld = () => {
+ if (released) {
+ throw createLockLostError()
+ }
+ releases.forEach((release) => release.assertHeld())
+ }
+ return releaseAll
+}
+
+export function resolveScopedLockFileName(scope: string, key: string): string {
+ const normalizedScope = String(scope || '').trim()
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/.test(normalizedScope)) {
+ throw new Error('上传锁作用域无效')
+ }
+ const digest = createHash('sha256')
+ .update(String(key || '').trim())
+ .digest('hex')
+ .slice(0, TASK_MATERIAL_LOCK_HASH_LENGTH)
+ return `${normalizedScope}-${digest}.lock`
+}
+
+async function acquireLinuxFlock(
+ mode: MaterialUploadStorageLockMode,
+ timeoutMs: number,
+ lockFileName: string,
+): Promise {
+ const lockPath = await prepareLockFile(lockFileName)
+ const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000))
+ const child = spawn(
+ 'flock',
+ [
+ mode === 'shared' ? '--shared' : '--exclusive',
+ '--timeout',
+ String(timeoutSeconds),
+ '--conflict-exit-code',
+ '75',
+ '--no-fork',
+ lockPath,
+ 'sh',
+ '-c',
+ `printf '${LOCK_READY_MARKER}'; cat >/dev/null`,
+ ],
+ {
+ shell: false,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ },
+ )
+
+ return waitForFlockReady(child, timeoutMs)
+}
+
+async function prepareLockFile(lockFileName: string): Promise {
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,191}$/.test(lockFileName)) {
+ throw new MaterialUploadRequestError(
+ '上传存储锁名称无效',
+ 503,
+ 'UPLOAD_STORAGE_LOCK_UNAVAILABLE',
+ 3,
+ )
+ }
+ const uploadRoot = getUploadRootDir()
+ const lockDirectory = path.join(uploadRoot, '.locks')
+ const lockPath = path.join(lockDirectory, lockFileName)
+ await mkdir(lockDirectory, { recursive: true, mode: 0o750 })
+
+ const directoryStats = await lstat(lockDirectory)
+ if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) {
+ throw new MaterialUploadRequestError(
+ '上传存储锁目录异常,请联系管理员',
+ 503,
+ 'UPLOAD_STORAGE_LOCK_UNAVAILABLE',
+ 3,
+ )
+ }
+
+ try {
+ const handle = await open(lockPath, 'wx', 0o600)
+ await handle.close()
+ } catch (error: any) {
+ if (error?.code !== 'EEXIST') {
+ throw error
+ }
+ }
+
+ const lockStats = await lstat(lockPath)
+ if (!lockStats.isFile() || lockStats.isSymbolicLink()) {
+ throw new MaterialUploadRequestError(
+ '上传存储锁文件异常,请联系管理员',
+ 503,
+ 'UPLOAD_STORAGE_LOCK_UNAVAILABLE',
+ 3,
+ )
+ }
+ return lockPath
+}
+
+function waitForFlockReady(
+ child: ChildProcessWithoutNullStreams,
+ timeoutMs: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ let stdout = ''
+ let stderr = ''
+ let settled = false
+ let released = false
+ let lostError: Error | null = null
+ const timeout = setTimeout(() => {
+ if (settled) {
+ return
+ }
+ settled = true
+ terminateLockProcess(child).finally(() => reject(createLockBusyError()))
+ }, timeoutMs + 1_000)
+ timeout.unref?.()
+
+ const fail = (error: unknown) => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timeout)
+ terminateLockProcess(child).finally(() => {
+ console.error('[MaterialUploadLock] flock 获取失败:', {
+ error,
+ stderr: stderr.slice(0, 1_000),
+ })
+ reject(
+ error instanceof MaterialUploadRequestError
+ ? error
+ : new MaterialUploadRequestError(
+ '上传存储正忙或锁服务不可用,请稍后重试',
+ 503,
+ 'UPLOAD_STORAGE_LOCK_UNAVAILABLE',
+ 3,
+ ),
+ )
+ })
+ }
+
+ const recordLostLock = (source: string, error?: unknown) => {
+ if (!settled || released || lostError) {
+ return
+ }
+ lostError = createLockLostError()
+ console.error('[MaterialUploadLock] 已获取的 flock 意外丢失:', {
+ source,
+ error,
+ stderr: stderr.slice(0, 1_000),
+ })
+ }
+
+ child.stdin.on('error', (error) => recordLostLock('stdin', error))
+ child.stdout.on('error', (error) => recordLostLock('stdout', error))
+ child.stderr.on('error', (error) => recordLostLock('stderr', error))
+ child.stderr.on('data', (chunk) => {
+ stderr += String(chunk)
+ if (stderr.length > 2_000) {
+ stderr = stderr.slice(-2_000)
+ }
+ })
+ child.on('error', (error) => {
+ if (settled) {
+ recordLostLock('process', error)
+ } else {
+ fail(error)
+ }
+ })
+ child.on('close', (code, signal) => {
+ if (!settled) {
+ fail(
+ code === 75
+ ? createLockBusyError()
+ : new Error(`flock 提前退出: code=${code}, signal=${signal}`),
+ )
+ } else if (!released) {
+ recordLostLock('close', new Error(`code=${code}, signal=${signal}`))
+ }
+ })
+ child.stdout.on('data', (chunk) => {
+ if (settled) {
+ return
+ }
+ stdout += String(chunk)
+ if (!stdout.includes(LOCK_READY_MARKER)) {
+ if (stdout.length > LOCK_READY_MARKER.length * 4) {
+ fail(new Error('flock 未返回预期就绪标记'))
+ }
+ return
+ }
+
+ settled = true
+ clearTimeout(timeout)
+ const release = async () => {
+ if (released) {
+ return
+ }
+ released = true
+ await terminateLockProcess(child)
+ }
+ release.assertHeld = () => {
+ if (released || lostError || child.exitCode !== null || child.signalCode !== null) {
+ throw lostError || createLockLostError()
+ }
+ }
+ resolve(release)
+ })
+ })
+}
+
+async function terminateLockProcess(child: ChildProcessWithoutNullStreams): Promise {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return
+ }
+
+ const closed = new Promise((resolve) => child.once('close', () => resolve()))
+ child.stdin.end()
+ if (await waitForPromise(closed, FLOCK_EXIT_TIMEOUT_MS)) {
+ return
+ }
+
+ child.kill('SIGTERM')
+ if (await waitForPromise(closed, FLOCK_EXIT_TIMEOUT_MS)) {
+ return
+ }
+ child.kill('SIGKILL')
+ await waitForPromise(closed, FLOCK_EXIT_TIMEOUT_MS)
+}
+
+async function waitForPromise(promise: Promise, timeoutMs: number): Promise {
+ let timer: ReturnType | undefined
+ const timedOut = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(false), timeoutMs)
+ timer.unref?.()
+ })
+ const completed = promise.then(() => true)
+ const result = await Promise.race([completed, timedOut])
+ if (timer) {
+ clearTimeout(timer)
+ }
+ return result
+}
+
+function createLockBusyError(): MaterialUploadRequestError {
+ return new MaterialUploadRequestError(
+ '上传存储正在处理其它大文件,请稍后重试',
+ 503,
+ 'UPLOAD_STORAGE_BUSY',
+ 3,
+ )
+}
+
+function createLockLostError(): MaterialUploadRequestError {
+ return new MaterialUploadRequestError(
+ '上传存储锁意外中断,本次写入已安全取消,请重试',
+ 503,
+ 'UPLOAD_STORAGE_LOCK_LOST',
+ 3,
+ )
+}
+
+function normalizeTaskMaterialLockError(error: unknown): Error {
+ if (error instanceof MaterialUploadRequestError && error.code === 'UPLOAD_STORAGE_BUSY') {
+ return new MaterialUploadRequestError(
+ '该素材正在处理,请勿重复提交',
+ 409,
+ 'TASK_MATERIAL_COMPLETION_BUSY',
+ 3,
+ )
+ }
+ return error instanceof Error ? error : new Error(String(error))
+}
+
+function normalizeTaskUploadSessionLockError(error: unknown): Error {
+ if (error instanceof MaterialUploadRequestError && error.code === 'UPLOAD_STORAGE_BUSY') {
+ return new MaterialUploadRequestError(
+ '上传文件正被另一条素材完成处理,请稍后重试',
+ 409,
+ 'TASK_MATERIAL_UPLOAD_SESSION_BUSY',
+ 1,
+ )
+ }
+ return error instanceof Error ? error : new Error(String(error))
+}
diff --git a/lib/material-upload-merge.ts b/lib/material-upload-merge.ts
new file mode 100644
index 0000000..f2412ad
--- /dev/null
+++ b/lib/material-upload-merge.ts
@@ -0,0 +1,158 @@
+import { randomUUID } from 'crypto'
+import { lstat, open, rename, rm } from 'fs/promises'
+import path from 'path'
+
+import { materialUploadStorageReservations } from '@/lib/material-upload-storage-reservation'
+
+const MEBIBYTE = 1024 * 1024
+const COPY_BUFFER_BYTES = MEBIBYTE
+
+export interface UploadChunkToMerge {
+ path: string
+ expectedBytes: number
+}
+
+/**
+ * 分片已经占据 fileSize 空间;合并前还需要再写一份 fileSize,并始终留下
+ * 2GiB 安全余量。检查失败时必须保守拒绝,不能在未知磁盘状态下继续写入。
+ */
+export async function acquireUploadMergeDiskReservation(
+ directory: string,
+ fileSize: number,
+): Promise<() => void> {
+ return materialUploadStorageReservations.acquire(directory, fileSize)
+}
+
+/**
+ * 使用固定 1MiB 缓冲区顺序复制到唯一临时文件,完整校验并 fsync 后才原子
+ * rename 为正式 merged 文件。任何异常只清理本次临时文件,不会留下半成品,
+ * 也不会覆盖已经验证成功的 merged 文件。
+ */
+export async function mergeUploadChunksAtomically(input: {
+ chunks: UploadChunkToMerge[]
+ expectedTotalBytes: number
+ outputPath: string
+ assertLockHeld?: () => void
+}): Promise {
+ const { chunks, expectedTotalBytes, outputPath } = input
+ if (
+ chunks.length === 0
+ || !Number.isSafeInteger(expectedTotalBytes)
+ || expectedTotalBytes <= 0
+ ) {
+ throw new Error('上传分片合并参数无效')
+ }
+
+ let validatedTotalBytes = 0
+ for (const chunk of chunks) {
+ if (!Number.isSafeInteger(chunk.expectedBytes) || chunk.expectedBytes <= 0) {
+ throw new Error(`上传分片大小无效: ${path.basename(chunk.path)}`)
+ }
+ const stats = await lstat(chunk.path)
+ if (
+ stats.isSymbolicLink()
+ || !stats.isFile()
+ || stats.size !== chunk.expectedBytes
+ ) {
+ throw new Error(`上传分片大小不匹配: ${path.basename(chunk.path)}`)
+ }
+ validatedTotalBytes += stats.size
+ if (
+ !Number.isSafeInteger(validatedTotalBytes)
+ || validatedTotalBytes > expectedTotalBytes
+ ) {
+ throw new Error('上传分片总大小超过会话声明')
+ }
+ }
+ if (validatedTotalBytes !== expectedTotalBytes) {
+ throw new Error('上传分片总大小与会话声明不一致')
+ }
+
+ const partialPath = `${outputPath}.partial-${randomUUID()}`
+ let outputHandle: Awaited> | null = null
+
+ try {
+ outputHandle = await open(partialPath, 'wx')
+ const copyBuffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES)
+ let copiedTotalBytes = 0
+
+ for (const chunk of chunks) {
+ const chunkHandle = await open(chunk.path, 'r')
+ let copiedChunkBytes = 0
+ try {
+ const openedStats = await chunkHandle.stat()
+ if (!openedStats.isFile() || openedStats.size !== chunk.expectedBytes) {
+ throw new Error(`上传分片在合并期间发生变化: ${path.basename(chunk.path)}`)
+ }
+
+ while (true) {
+ const { bytesRead } = await chunkHandle.read(
+ copyBuffer,
+ 0,
+ copyBuffer.length,
+ null,
+ )
+ if (bytesRead === 0) {
+ break
+ }
+
+ copiedChunkBytes += bytesRead
+ copiedTotalBytes += bytesRead
+ if (
+ copiedChunkBytes > chunk.expectedBytes
+ || copiedTotalBytes > expectedTotalBytes
+ ) {
+ throw new Error(`上传分片在合并期间变大: ${path.basename(chunk.path)}`)
+ }
+
+ let writtenOffset = 0
+ while (writtenOffset < bytesRead) {
+ const { bytesWritten } = await outputHandle.write(
+ copyBuffer,
+ writtenOffset,
+ bytesRead - writtenOffset,
+ null,
+ )
+ if (bytesWritten <= 0) {
+ throw new Error('上传文件合并写入失败')
+ }
+ writtenOffset += bytesWritten
+ }
+ }
+ } finally {
+ await chunkHandle.close()
+ }
+
+ if (copiedChunkBytes !== chunk.expectedBytes) {
+ throw new Error(`上传分片在合并期间变小: ${path.basename(chunk.path)}`)
+ }
+ }
+
+ if (copiedTotalBytes !== expectedTotalBytes) {
+ throw new Error('上传文件合并大小与会话声明不一致')
+ }
+
+ await outputHandle.sync()
+ await outputHandle.close()
+ outputHandle = null
+
+ const partialStats = await lstat(partialPath)
+ if (
+ partialStats.isSymbolicLink()
+ || !partialStats.isFile()
+ || partialStats.size !== expectedTotalBytes
+ ) {
+ throw new Error('上传文件合并结果校验失败')
+ }
+
+ input.assertLockHeld?.()
+ await rename(partialPath, outputPath)
+ return outputPath
+ } catch (error) {
+ if (outputHandle) {
+ await outputHandle.close().catch(() => undefined)
+ }
+ await rm(partialPath, { force: true }).catch(() => undefined)
+ throw error
+ }
+}
diff --git a/lib/material-upload-request-guard.ts b/lib/material-upload-request-guard.ts
new file mode 100644
index 0000000..315a52c
--- /dev/null
+++ b/lib/material-upload-request-guard.ts
@@ -0,0 +1,305 @@
+import { MAX_LOGICAL_UPLOAD_BYTES } from '@/lib/material-upload-chunk-policy'
+
+const MEBIBYTE = 1024 * 1024
+
+export const MATERIAL_UPLOAD_REQUEST_LIMITS = Object.freeze({
+ maxRequestBytes: 128 * MEBIBYTE,
+ maxLogicalSourceBytes: MAX_LOGICAL_UPLOAD_BYTES,
+ maxDirectSourceBytesExclusive: 5 * MEBIBYTE,
+ maxFileParts: 50,
+ maxPreviewImageBytes: 8 * MEBIBYTE,
+ maxPreviewImages: 19,
+ maxPreviewBytes: 32 * MEBIBYTE,
+ maxDisplayImageBytes: 50 * MEBIBYTE,
+ maxGalleryImageBytes: 16 * MEBIBYTE,
+ maxGalleryImagesPerField: 49,
+ maxGalleryBytes: 112 * MEBIBYTE,
+})
+
+const ALLOWED_FILE_FIELDS = new Set([
+ 'zipFile',
+ 'previewImage',
+ 'previewImages',
+ 'displayImage',
+ 'modelImages',
+ 'displayImages',
+])
+
+export class MaterialUploadRequestError extends Error {
+ constructor(
+ message: string,
+ public readonly status: number,
+ public readonly code: string,
+ public readonly retryAfterSeconds?: number,
+ ) {
+ super(message)
+ this.name = 'MaterialUploadRequestError'
+ }
+}
+
+export function validateMaterialUploadRequestHeaders(headers: Headers): number {
+ const contentType = (headers.get('content-type') || '').toLowerCase()
+ if (!contentType.includes('multipart/form-data')) {
+ throw new MaterialUploadRequestError(
+ '上传请求必须使用 multipart/form-data',
+ 415,
+ 'MULTIPART_REQUIRED',
+ )
+ }
+
+ const contentEncoding = (headers.get('content-encoding') || 'identity').trim().toLowerCase()
+ if (contentEncoding !== 'identity') {
+ throw new MaterialUploadRequestError(
+ '上传请求不支持压缩传输编码',
+ 415,
+ 'CONTENT_ENCODING_UNSUPPORTED',
+ )
+ }
+
+ const rawLength = headers.get('content-length')
+ if (!rawLength || !/^\d+$/.test(rawLength.trim())) {
+ throw new MaterialUploadRequestError(
+ '上传请求缺少有效的 Content-Length',
+ 411,
+ 'CONTENT_LENGTH_REQUIRED',
+ )
+ }
+
+ const contentLength = Number(rawLength)
+ if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
+ throw new MaterialUploadRequestError(
+ '上传请求的 Content-Length 无效',
+ 411,
+ 'CONTENT_LENGTH_REQUIRED',
+ )
+ }
+ if (contentLength > MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes) {
+ throw new MaterialUploadRequestError(
+ '本次上传内容超过 128MB,请减少图片数量或使用分片上传',
+ 413,
+ 'UPLOAD_REQUEST_TOO_LARGE',
+ )
+ }
+
+ return contentLength
+}
+
+function isFilePart(value: FormDataEntryValue): value is File {
+ return typeof value === 'object'
+ && value !== null
+ && typeof (value as File).name === 'string'
+ && typeof (value as File).size === 'number'
+ && typeof (value as File).arrayBuffer === 'function'
+}
+
+function sumFileBytes(files: File[]): number {
+ return files.reduce((sum, file) => sum + file.size, 0)
+}
+
+function assertFilesWithinLimit(files: File[], maxBytes: number, label: string): void {
+ const oversized = files.find((file) => file.size > maxBytes)
+ if (oversized) {
+ throw new MaterialUploadRequestError(
+ `${label}「${oversized.name}」过大`,
+ 413,
+ 'UPLOAD_FILE_TOO_LARGE',
+ )
+ }
+}
+
+export function validateMaterialUploadFormData(formData: FormData): void {
+ const filesByField = new Map()
+ let totalFileBytes = 0
+ let totalFileParts = 0
+
+ for (const [field, value] of Array.from(formData.entries())) {
+ if (!isFilePart(value)) {
+ continue
+ }
+ if (!ALLOWED_FILE_FIELDS.has(field)) {
+ throw new MaterialUploadRequestError(
+ `不支持的文件字段:${field}`,
+ 400,
+ 'UNKNOWN_FILE_FIELD',
+ )
+ }
+ if (!Number.isSafeInteger(value.size) || value.size <= 0) {
+ throw new MaterialUploadRequestError(
+ `文件「${value.name || field}」为空或大小无效`,
+ 400,
+ 'EMPTY_UPLOAD_FILE',
+ )
+ }
+
+ const fieldFiles = filesByField.get(field) || []
+ fieldFiles.push(value)
+ filesByField.set(field, fieldFiles)
+ totalFileParts += 1
+ totalFileBytes += value.size
+ }
+
+ if (totalFileParts > MATERIAL_UPLOAD_REQUEST_LIMITS.maxFileParts) {
+ throw new MaterialUploadRequestError(
+ `一次最多上传 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxFileParts} 个文件`,
+ 413,
+ 'TOO_MANY_UPLOAD_FILES',
+ )
+ }
+ if (totalFileBytes > MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes) {
+ throw new MaterialUploadRequestError(
+ '本次上传文件总大小超过 128MB',
+ 413,
+ 'UPLOAD_FILES_TOO_LARGE',
+ )
+ }
+
+ const zipFiles = filesByField.get('zipFile') || []
+ const previewImage = filesByField.get('previewImage') || []
+ const previewImages = filesByField.get('previewImages') || []
+ const displayImage = filesByField.get('displayImage') || []
+ const modelImages = filesByField.get('modelImages') || []
+ const displayImages = filesByField.get('displayImages') || []
+
+ if (zipFiles.length > 1 || previewImage.length > 1 || displayImage.length > 1) {
+ throw new MaterialUploadRequestError(
+ 'zipFile、previewImage 和 displayImage 均只能提交一个文件',
+ 400,
+ 'DUPLICATE_SINGLE_FILE_FIELD',
+ )
+ }
+ if (zipFiles.some((file) => file.size >= MATERIAL_UPLOAD_REQUEST_LIMITS.maxDirectSourceBytesExclusive)) {
+ throw new MaterialUploadRequestError(
+ '主素材文件达到 5MB,请使用分片上传',
+ 413,
+ 'CHUNK_UPLOAD_REQUIRED',
+ )
+ }
+
+ if (previewImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImages) {
+ throw new MaterialUploadRequestError(
+ `其他预览图最多 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImages} 张`,
+ 413,
+ 'TOO_MANY_PREVIEW_IMAGES',
+ )
+ }
+ const allPreviewImages = [...previewImage, ...previewImages]
+ assertFilesWithinLimit(
+ allPreviewImages,
+ MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImageBytes,
+ '预览图',
+ )
+ if (sumFileBytes(allPreviewImages) > MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewBytes) {
+ throw new MaterialUploadRequestError(
+ '预览图总大小超过 32MB',
+ 413,
+ 'PREVIEW_IMAGES_TOO_LARGE',
+ )
+ }
+
+ assertFilesWithinLimit(
+ displayImage,
+ MATERIAL_UPLOAD_REQUEST_LIMITS.maxDisplayImageBytes,
+ '展示原图',
+ )
+
+ if (
+ modelImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField
+ || displayImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField
+ ) {
+ throw new MaterialUploadRequestError(
+ `展示图片每组最多 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField} 张`,
+ 413,
+ 'TOO_MANY_GALLERY_IMAGES',
+ )
+ }
+ const galleryImages = [...modelImages, ...displayImages]
+ assertFilesWithinLimit(
+ galleryImages,
+ MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImageBytes,
+ '展示图片',
+ )
+ if (sumFileBytes(galleryImages) > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryBytes) {
+ throw new MaterialUploadRequestError(
+ '展示图片总大小超过 112MB',
+ 413,
+ 'GALLERY_IMAGES_TOO_LARGE',
+ )
+ }
+
+ if (zipFiles.length > 0 && String(formData.get('existingFilePath') || '').trim()) {
+ throw new MaterialUploadRequestError(
+ '不能同时提交上传文件和服务器合并文件',
+ 400,
+ 'SOURCE_FILE_CONFLICT',
+ )
+ }
+}
+
+type WaitingAcquire = {
+ resolve: (release: () => void) => void
+}
+
+export class MaterialUploadRequestLimiter {
+ private active = 0
+ private readonly waiting: WaitingAcquire[] = []
+
+ constructor(
+ private readonly maxActive = 1,
+ private readonly maxWaiting = 8,
+ ) {
+ if (!Number.isInteger(maxActive) || maxActive <= 0) {
+ throw new Error('maxActive 必须是正整数')
+ }
+ if (!Number.isInteger(maxWaiting) || maxWaiting < 0) {
+ throw new Error('maxWaiting 必须是非负整数')
+ }
+ }
+
+ async acquire(): Promise<() => void> {
+ if (this.active < this.maxActive) {
+ this.active += 1
+ return this.createRelease()
+ }
+ if (this.waiting.length >= this.maxWaiting) {
+ throw new MaterialUploadRequestError(
+ '当前上传处理繁忙,请稍后重试',
+ 503,
+ 'UPLOAD_BUSY',
+ 3,
+ )
+ }
+
+ return new Promise<() => void>((resolve) => {
+ this.waiting.push({ resolve })
+ })
+ }
+
+ snapshot(): { active: number; waiting: number } {
+ return { active: this.active, waiting: this.waiting.length }
+ }
+
+ private createRelease(): () => void {
+ let released = false
+ return () => {
+ if (released) {
+ return
+ }
+ released = true
+
+ const next = this.waiting.shift()
+ if (next) {
+ next.resolve(this.createRelease())
+ return
+ }
+ this.active = Math.max(0, this.active - 1)
+ }
+ }
+}
+
+export const materialUploadRequestLimiter = new MaterialUploadRequestLimiter(1, 8)
+// 与浏览器允许的最高分片并发保持一致。它只限制服务器同时写盘数量,
+// 不把大文件网络上传退化为单并发。
+export const materialUploadChunkRequestLimiter = new MaterialUploadRequestLimiter(8, 64)
+// 合并会额外生成一份完整源文件。全局单槽避免多个大文件同时复制,
+// 把瞬时磁盘占用控制在“一个合并文件 + 固定安全余量”以内。
+export const materialUploadMergeRequestLimiter = new MaterialUploadRequestLimiter(1, 8)
diff --git a/lib/material-upload-session-seal.ts b/lib/material-upload-session-seal.ts
new file mode 100644
index 0000000..237b733
--- /dev/null
+++ b/lib/material-upload-session-seal.ts
@@ -0,0 +1,390 @@
+import { randomUUID } from 'crypto'
+import { lstat, open, readFile, readdir, rename, rm } from 'fs/promises'
+import path from 'path'
+
+import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard'
+import { getUploadRootDir } from '@/lib/upload-paths'
+import {
+ ensureSafeDirectoryWithinRoot,
+ resolveStrictChildPath,
+} from '@/lib/path-security'
+
+const SAFE_UPLOAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
+const COMPLETED_SEAL_RETENTION_MS = 24 * 60 * 60 * 1000
+
+interface StoredUploadCompletionSeal {
+ version: 1
+ state: 'sealing' | 'completed' | 'cancelled'
+ token: string
+ createdAt: number
+ completedAt?: number
+ cancelledAt?: number
+}
+
+export type UploadSessionSealState = StoredUploadCompletionSeal['state']
+
+export interface UploadSessionSealSnapshot {
+ state: UploadSessionSealState
+ createdAt: number
+ completedAt?: number
+ cancelledAt?: number
+}
+
+export interface UploadCompletionSealHandle {
+ uploadId: string
+ token: string
+}
+
+export type BeginUploadCompletionSealResult =
+ | { state: 'completed' }
+ | { state: 'sealing'; handle: UploadCompletionSealHandle }
+
+/**
+ * 必须在 material upload 的跨容器独占锁内调用。
+ *
+ * sealing 标记会先于最终文件发布而写入共享 uploads。若进程在原子 rename 后、
+ * 写 completed 前崩溃,迟到分片仍会被挡住;下一次 complete 取得独占锁后可
+ * 根据最终文件继续收口,不会把旧会话复活。
+ */
+export async function beginUploadCompletionSeal(
+ uploadId: string,
+): Promise {
+ const normalizedUploadId = normalizeUploadId(uploadId)
+ const existing = await readStoredSeal(normalizedUploadId)
+ if (existing?.state === 'cancelled') {
+ throw createUploadSessionCancelledError()
+ }
+ if (existing?.state === 'completed') {
+ return { state: 'completed' }
+ }
+
+ const token = randomUUID()
+ await writeStoredSeal(normalizedUploadId, {
+ version: 1,
+ state: 'sealing',
+ token,
+ createdAt: Date.now(),
+ })
+ return {
+ state: 'sealing',
+ handle: { uploadId: normalizedUploadId, token },
+ }
+}
+
+export async function completeUploadCompletionSeal(
+ handle: UploadCompletionSealHandle,
+): Promise {
+ const uploadId = normalizeUploadId(handle.uploadId)
+ const current = await readStoredSeal(uploadId)
+ if (current?.state === 'cancelled') {
+ throw createUploadSessionCancelledError()
+ }
+ if (!current || current.state !== 'sealing' || current.token !== handle.token) {
+ throw new MaterialUploadRequestError(
+ '上传完成标记已变化,已停止发布结果',
+ 409,
+ 'UPLOAD_SESSION_SEAL_CHANGED',
+ )
+ }
+
+ await writeStoredSeal(uploadId, {
+ ...current,
+ state: 'completed',
+ completedAt: Date.now(),
+ })
+}
+
+export async function abortUploadCompletionSeal(
+ handle: UploadCompletionSealHandle,
+): Promise {
+ const uploadId = normalizeUploadId(handle.uploadId)
+ const current = await readStoredSeal(uploadId).catch(() => null)
+ if (!current || current.state !== 'sealing' || current.token !== handle.token) {
+ return
+ }
+
+ const sealPath = await resolveSealPath(uploadId, false)
+ if (sealPath) {
+ await rm(sealPath, { force: true })
+ }
+}
+
+/**
+ * 分片在短共享锁内、发布 chunk-N 之前调用。任何 sealing/completed 标记都
+ * 代表完成流程已经接管该 uploadId;此时必须删除本请求 partial 并拒绝更新
+ * 内存/Redis 会话。
+ */
+export async function assertUploadSessionAcceptingChunks(uploadId: string): Promise {
+ const normalizedUploadId = normalizeUploadId(uploadId)
+ const seal = await readStoredSeal(normalizedUploadId)
+ if (seal?.state === 'cancelled') {
+ throw createUploadSessionCancelledError()
+ }
+ if (seal) {
+ throw new MaterialUploadRequestError(
+ '上传会话已经进入完成阶段,迟到分片已拒绝',
+ 409,
+ 'UPLOAD_SESSION_SEALED',
+ )
+ }
+}
+
+/**
+ * cleanup 在 material upload 独占锁内调用。cancelled 是持久终态:一旦写入,
+ * complete 和迟到 chunk 都只能结构化失败,不能把已经取消的 uploadId 复活。
+ */
+export async function cancelUploadSessionSeal(
+ uploadId: string,
+): Promise<{ state: 'cancelled' } | { state: 'completed' }> {
+ const normalizedUploadId = normalizeUploadId(uploadId)
+ const existing = await readStoredSeal(normalizedUploadId)
+ if (existing?.state === 'completed') {
+ return { state: 'completed' }
+ }
+ if (existing?.state === 'cancelled') {
+ return { state: 'cancelled' }
+ }
+
+ const now = Date.now()
+ await writeStoredSeal(normalizedUploadId, {
+ version: 1,
+ state: 'cancelled',
+ token: randomUUID(),
+ createdAt: existing?.createdAt ?? now,
+ cancelledAt: now,
+ })
+ return { state: 'cancelled' }
+}
+
+export async function readUploadSessionSeal(
+ uploadId: string,
+): Promise {
+ const seal = await readStoredSeal(normalizeUploadId(uploadId))
+ return seal
+ ? {
+ state: seal.state,
+ createdAt: seal.createdAt,
+ completedAt: seal.completedAt,
+ cancelledAt: seal.cancelledAt,
+ }
+ : null
+}
+
+export async function cleanupExpiredUploadCompletionSeals(
+ now = Date.now(),
+): Promise {
+ const sealDir = await resolveSealDirectory(false)
+ if (!sealDir) {
+ return 0
+ }
+
+ let names: string[]
+ try {
+ names = await readdir(sealDir)
+ } catch (error: any) {
+ if (error?.code === 'ENOENT') {
+ return 0
+ }
+ throw error
+ }
+
+ let removed = 0
+ for (const name of names) {
+ if (!name.endsWith('.json')) {
+ continue
+ }
+ const uploadId = name.slice(0, -'.json'.length)
+ if (!SAFE_UPLOAD_ID_PATTERN.test(uploadId)) {
+ continue
+ }
+ const sealPath = resolveStrictChildPath(sealDir, name)
+ if (!sealPath) {
+ continue
+ }
+ try {
+ const stats = await lstat(sealPath)
+ if (
+ stats.isSymbolicLink()
+ || !stats.isFile()
+ || now - stats.mtimeMs < COMPLETED_SEAL_RETENTION_MS
+ ) {
+ continue
+ }
+ const seal = await readStoredSeal(uploadId)
+ if (!seal || seal.state === 'sealing') {
+ continue
+ }
+ // 终态标记只有在对应临时目录已经不存在后才可过期。否则先删 seal 会让
+ // 下一轮自动清理把仍待业务消费的 completed merged 文件误判为孤儿。
+ const uploadTempPath = resolveStrictChildPath(
+ getUploadRootDir(),
+ 'temp',
+ uploadId,
+ )
+ if (uploadTempPath) {
+ try {
+ await lstat(uploadTempPath)
+ continue
+ } catch (error: any) {
+ if (error?.code !== 'ENOENT') {
+ throw error
+ }
+ }
+ }
+ await rm(sealPath, { force: true })
+ removed += 1
+ } catch (error: any) {
+ if (error?.code !== 'ENOENT') {
+ console.warn('[UploadSessionSeal] 清理过期完成标记失败:', { sealPath, error })
+ }
+ }
+ }
+ return removed
+}
+
+function normalizeUploadId(uploadId: string): string {
+ const normalized = String(uploadId || '').trim()
+ if (!SAFE_UPLOAD_ID_PATTERN.test(normalized)) {
+ throw new MaterialUploadRequestError('uploadId 无效', 400, 'UPLOAD_ID_INVALID')
+ }
+ return normalized
+}
+
+async function resolveSealDirectory(create: boolean): Promise {
+ const uploadRoot = getUploadRootDir()
+ if (create) {
+ return ensureSafeDirectoryWithinRoot(
+ uploadRoot,
+ '.locks',
+ 'completed-upload-sessions',
+ )
+ }
+
+ const sealDir = resolveStrictChildPath(uploadRoot, '.locks', 'completed-upload-sessions')
+ if (!sealDir) {
+ return null
+ }
+ try {
+ const stats = await lstat(sealDir)
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
+ throw new MaterialUploadRequestError(
+ '上传完成标记目录异常,已停止写入',
+ 503,
+ 'UPLOAD_SESSION_SEAL_INVALID',
+ )
+ }
+ return sealDir
+ } catch (error: any) {
+ if (error?.code === 'ENOENT') {
+ return null
+ }
+ throw error
+ }
+}
+
+async function resolveSealPath(uploadId: string, createDirectory: boolean): Promise {
+ const sealDir = await resolveSealDirectory(createDirectory)
+ return sealDir ? resolveStrictChildPath(sealDir, `${uploadId}.json`) : null
+}
+
+async function readStoredSeal(uploadId: string): Promise {
+ const sealPath = await resolveSealPath(uploadId, false)
+ if (!sealPath) {
+ return null
+ }
+ try {
+ const stats = await lstat(sealPath)
+ if (stats.isSymbolicLink() || !stats.isFile() || stats.size > 4096) {
+ throw new MaterialUploadRequestError(
+ '上传完成标记异常,已停止写入',
+ 503,
+ 'UPLOAD_SESSION_SEAL_INVALID',
+ )
+ }
+ const parsed = JSON.parse(await readFile(sealPath, 'utf8')) as Partial
+ if (
+ parsed.version !== 1
+ || (
+ parsed.state !== 'sealing'
+ && parsed.state !== 'completed'
+ && parsed.state !== 'cancelled'
+ )
+ || typeof parsed.token !== 'string'
+ || !parsed.token
+ || !Number.isFinite(parsed.createdAt)
+ || (parsed.state === 'cancelled' && !Number.isFinite(parsed.cancelledAt))
+ ) {
+ throw new MaterialUploadRequestError(
+ '上传完成标记异常,已停止写入',
+ 503,
+ 'UPLOAD_SESSION_SEAL_INVALID',
+ )
+ }
+ return parsed as StoredUploadCompletionSeal
+ } catch (error: any) {
+ if (error?.code === 'ENOENT') {
+ return null
+ }
+ if (error instanceof MaterialUploadRequestError) {
+ throw error
+ }
+ throw new MaterialUploadRequestError(
+ '无法确认上传会话完成状态,已停止写入',
+ 503,
+ 'UPLOAD_SESSION_SEAL_UNAVAILABLE',
+ )
+ }
+}
+
+function createUploadSessionCancelledError(): MaterialUploadRequestError {
+ return new MaterialUploadRequestError(
+ '上传会话已取消,不能继续上传或完成',
+ 409,
+ 'UPLOAD_SESSION_CANCELLED',
+ )
+}
+
+async function writeStoredSeal(
+ uploadId: string,
+ seal: StoredUploadCompletionSeal,
+): Promise {
+ const sealPath = await resolveSealPath(uploadId, true)
+ if (!sealPath) {
+ throw new MaterialUploadRequestError(
+ '无法创建上传完成标记,已停止写入',
+ 503,
+ 'UPLOAD_SESSION_SEAL_UNAVAILABLE',
+ )
+ }
+
+ const partialPath = `${sealPath}.partial-${randomUUID()}`
+ let fileHandle: Awaited> | null = null
+ try {
+ fileHandle = await open(partialPath, 'wx', 0o600)
+ await fileHandle.writeFile(JSON.stringify(seal), 'utf8')
+ await fileHandle.sync()
+ await fileHandle.close()
+ fileHandle = null
+
+ // 生产 Linux/NAS 上 rename 会原子替换旧标记,不能先 rm 留出“无标记”
+ // 窗口;否则进程恰在两步之间退出时,迟到分片可能重新发布。Windows 仅
+ // 用于本地/Jest,且由同进程读写锁保护,需要先移除目标以兼容其语义。
+ if (process.platform === 'win32') {
+ await rm(sealPath, { force: true })
+ }
+ await rename(partialPath, sealPath)
+ } catch (error) {
+ if (fileHandle) {
+ await fileHandle.close().catch(() => undefined)
+ }
+ await rm(partialPath, { force: true }).catch(() => undefined)
+ if (error instanceof MaterialUploadRequestError) {
+ throw error
+ }
+ throw new MaterialUploadRequestError(
+ '无法持久化上传完成标记,已停止写入',
+ 503,
+ 'UPLOAD_SESSION_SEAL_UNAVAILABLE',
+ )
+ }
+}
diff --git a/lib/material-upload-storage-reservation.ts b/lib/material-upload-storage-reservation.ts
new file mode 100644
index 0000000..f30614b
--- /dev/null
+++ b/lib/material-upload-storage-reservation.ts
@@ -0,0 +1,103 @@
+import { statfs } from 'fs/promises'
+
+import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard'
+
+const MEBIBYTE = 1024 * 1024
+const GIBIBYTE = 1024 * MEBIBYTE
+
+export const UPLOAD_STORAGE_FREE_RESERVE_BYTES = 2 * GIBIBYTE
+
+type AvailableBytesReader = (directory: string) => Promise
+
+async function readAvailableBytes(directory: string): Promise {
+ const disk = await statfs(directory, { bigint: true })
+ return disk.bavail * disk.bsize
+}
+
+/**
+ * 记录尚未完成的磁盘写入额度。statfs 只能看到已经落盘的字节;如果不把
+ * 在途合并/分片也扣除,多个请求会同时通过同一份剩余空间快照。
+ */
+export class MaterialUploadStorageReservationManager {
+ private reservedBytes = BigInt(0)
+ private lockTail: Promise = Promise.resolve()
+
+ constructor(
+ private readonly minimumFreeBytes: number = UPLOAD_STORAGE_FREE_RESERVE_BYTES,
+ private readonly getAvailableBytes: AvailableBytesReader = readAvailableBytes,
+ ) {
+ if (!Number.isSafeInteger(minimumFreeBytes) || minimumFreeBytes < 0) {
+ throw new Error('minimumFreeBytes 必须是非负安全整数')
+ }
+ }
+
+ async acquire(directory: string, requestedBytes: number): Promise<() => void> {
+ if (!Number.isSafeInteger(requestedBytes) || requestedBytes <= 0) {
+ throw new MaterialUploadRequestError(
+ '上传写盘大小无效',
+ 400,
+ 'UPLOAD_FILE_SIZE_INVALID',
+ )
+ }
+
+ return this.withLock(async () => {
+ let availableBytes: bigint
+ try {
+ availableBytes = await this.getAvailableBytes(directory)
+ } catch (error) {
+ console.error('[MaterialUploadStorage] 无法确认上传盘剩余空间:', error)
+ throw new MaterialUploadRequestError(
+ '暂时无法确认上传存储空间,请稍后重试',
+ 503,
+ 'UPLOAD_STORAGE_UNKNOWN',
+ 3,
+ )
+ }
+
+ const requested = BigInt(requestedBytes)
+ const required = this.reservedBytes
+ + requested
+ + BigInt(this.minimumFreeBytes)
+ if (availableBytes < required) {
+ throw new MaterialUploadRequestError(
+ '上传存储空间不足,请联系管理员清理后重试',
+ 507,
+ 'UPLOAD_STORAGE_LOW',
+ )
+ }
+
+ this.reservedBytes += requested
+ let released = false
+ return () => {
+ if (released) {
+ return
+ }
+ released = true
+ this.reservedBytes = this.reservedBytes >= requested
+ ? this.reservedBytes - requested
+ : BigInt(0)
+ }
+ })
+ }
+
+ snapshot(): { reservedBytes: bigint } {
+ return { reservedBytes: this.reservedBytes }
+ }
+
+ private async withLock(action: () => Promise): Promise {
+ let unlock!: () => void
+ const previous = this.lockTail
+ this.lockTail = new Promise((resolve) => {
+ unlock = resolve
+ })
+
+ await previous
+ try {
+ return await action()
+ } finally {
+ unlock()
+ }
+ }
+}
+
+export const materialUploadStorageReservations = new MaterialUploadStorageReservationManager()
diff --git a/lib/multipart-request-guard.ts b/lib/multipart-request-guard.ts
new file mode 100644
index 0000000..c9a33d9
--- /dev/null
+++ b/lib/multipart-request-guard.ts
@@ -0,0 +1,494 @@
+/**
+ * Shared guards for multipart requests.
+ *
+ * These checks intentionally happen before `request.formData()` is called for
+ * the header guard. A multipart parser may allocate memory for the complete
+ * body, so accepting an unbounded request before parsing defeats the limit.
+ */
+
+export type MultipartHeaderSource =
+ | Headers
+ | Pick
+ | Record
+
+export type MultipartFieldRule = {
+ /** Maximum number of files for this field. Omit for no per-field count cap. */
+ maxCount?: number
+ /** Maximum bytes of each file for this field. */
+ maxFileBytes?: number
+ /** Alias for maxFileBytes, retained for concise route policies. */
+ maxBytes?: number
+ /** Maximum combined bytes for this field. */
+ maxTotalBytes?: number
+ /** Exact MIME types (or `type/*` wildcards) accepted for this field. */
+ allowedMimeTypes?: readonly string[]
+ /** Aliases accepted when a route uses a shorter policy name. */
+ allowedMimes?: readonly string[]
+ mimeTypes?: readonly string[]
+ [key: string]: unknown
+}
+
+export type MultipartFieldRuleInput = MultipartFieldRule | number | null | undefined
+export type MultipartFieldRules =
+ | Readonly>
+ | readonly string[]
+ | ReadonlySet
+
+/**
+ * Policy accepted by `assertMultipartFiles`.
+ *
+ * `fields` is the canonical shape. The other field-map names are kept as
+ * aliases so a route can adopt the helper without a large policy rewrite.
+ */
+export interface MultipartFilePolicy {
+ fields?: MultipartFieldRules
+ allowedFields?: MultipartFieldRules
+ allowedFileFields?: MultipartFieldRules
+ fieldPolicies?: MultipartFieldRules
+ rules?: MultipartFieldRules
+ fileFields?: MultipartFieldRules
+ filePolicies?: MultipartFieldRules
+ fieldLimits?: MultipartFieldRules
+ perField?: MultipartFieldRules
+ allowedFileFieldPolicies?: MultipartFieldRules
+ maxFilesPerField?: Readonly>
+
+ /** Maximum number of files across all fields. */
+ maxFiles?: number
+ maxTotalFiles?: number
+ maxFileCount?: number
+ maxTotalFileCount?: number
+ /** Maximum combined bytes across all files. */
+ maxTotalBytes?: number
+ maxTotalFileBytes?: number
+ totalBytes?: number
+ /** Optional global per-file cap and MIME allow-list. */
+ maxFileBytes?: number
+ maxSingleFileBytes?: number
+ allowedMimeTypes?: readonly string[]
+ allowedMimes?: readonly string[]
+ mimeTypes?: readonly string[]
+
+ /** Explicit escape hatch for a route that really accepts any field. */
+ allowAnyFileField?: boolean
+ [key: string]: unknown
+}
+
+export type MultipartRequestErrorCode =
+ | 'MULTIPART_REQUIRED'
+ | 'MULTIPART_BOUNDARY_REQUIRED'
+ | 'CONTENT_ENCODING_UNSUPPORTED'
+ | 'LENGTH_REQUIRED'
+ | 'REQUEST_TOO_LARGE'
+ | 'INVALID_FILE_FIELD'
+ | 'TOO_MANY_FILES'
+ | 'EMPTY_FILE'
+ | 'FILE_TOO_LARGE'
+ | 'FILES_TOO_LARGE'
+ | 'MIME_TYPE_UNSUPPORTED'
+
+export class MultipartRequestError extends Error {
+ readonly status: number
+ readonly code: MultipartRequestErrorCode
+ readonly details?: Readonly