fix: enforce unique task detail zip entry names and gate submissions
This commit is contained in:
parent
924e54dbb2
commit
c43823863f
40
docs/任务详情图片来源映射修复-2026-09-29.md
Normal file
40
docs/任务详情图片来源映射修复-2026-09-29.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 任务详情图片来源映射修复(2026-09-29)
|
||||
|
||||
## 问题与边界
|
||||
|
||||
任务详情上传曾允许不同原图使用同一文件名。ZIP 打包时为同名文件追加 `-2` 等后缀,预览索引仍保存原文件名,导致审批应用阶段无法确定预览对应哪张原图。ZIP 中央目录顺序不保证等于上传顺序,不能按位置补配。
|
||||
|
||||
本次修复只改预览索引的 `fileName`,不改原图、ZIP、预览文件 URL、图片排序或审批结果。历史失败申请仍为终态,素材修复后需要重新提交申请。
|
||||
|
||||
## 新上传与提交门禁
|
||||
|
||||
- 完成上传时先确定每个分组内唯一的 ZIP 条目基础名,连不同扩展名但基础名相同的原图也区分开;预览索引使用同一基础名。
|
||||
- 同一张原图只能提交一张普通预览,或一套连续的切片预览。
|
||||
- 提交图片变更前执行与实际应用相同的 ZIP/预览对应检查。检查失败时不会暂存文件、创建审批单或通知主管;错误指向管理员核对来源关系。
|
||||
|
||||
## 历史扫描和修复
|
||||
|
||||
`scripts/audit-task-detail-source-mapping.cjs` 默认只读。工具根据预览 URL 中保存的原图上传序号、原预览名和 ZIP 去重命名规则重建对应关系;枚举扩展名后必须得到唯一的完整映射才允许改索引。ZIP 的物理顺序不参与推断。旧 URL 缺少来源序号、名称已改动、数量不符或映射不唯一时,输出人工核对清单。
|
||||
|
||||
常用命令(在具备生产数据库和 uploads 只读访问的应用容器中执行):
|
||||
|
||||
```sh
|
||||
node scripts/audit-task-detail-source-mapping.cjs --summary-only
|
||||
node scripts/audit-task-detail-source-mapping.cjs --material <material-id> --include-changes
|
||||
node scripts/audit-task-detail-source-mapping.cjs --apply --material <material-id> --summary-only
|
||||
node scripts/audit-task-detail-source-mapping.cjs --apply --all-verified --summary-only
|
||||
```
|
||||
|
||||
每组写入在数据库事务内执行,逐行核对旧文件名;素材或索引在扫描后变化时中止。`--apply` 不会尝试修复 `manual` 分组。
|
||||
|
||||
## 本轮生产只读审计与数据修复
|
||||
|
||||
- 扫描前:3,594 份已完成且未删除的任务详情素材中,111 份存在同组预览名重复。
|
||||
- 134 个分组得到唯一完整映射;先对 `GBSFY-YMJ0154` 的四条预览索引试修、回读确认,再批量修正其余 1,193 条,总计 1,197 条。
|
||||
- 修复前清单保存在忽略 Git 的 `.private/task-detail-source-mapping-audit-2026-09-29.json`,SHA-256 为 `55188D42288C925FC53DC0D5BEF1325CEE1279A525F479FAF25531138CBA5DD7`。
|
||||
- 修复后只读复扫:仍有 15 个分组无法唯一判定;可自动修正的分组为 0,待修正索引为 0。
|
||||
- 15 个分组需查看对应原图与预览内容后再建立明确的一对一关系。本工具不会按位置或模糊图像相似度自动处理它们。
|
||||
|
||||
## 验证与发布状态
|
||||
|
||||
相关 Jest 测试、TypeScript 类型检查和本地生产构建已通过。新上传、提交前拦截和新错误文案目前仍为本地代码,尚未提交 Git 或部署;生产已执行的仅是上面的历史索引修复。
|
||||
@ -18,6 +18,7 @@ import {
|
||||
cleanupOnError,
|
||||
cleanupTempFiles,
|
||||
createZipFromFiles,
|
||||
resolveProductPackageZipEntryNames,
|
||||
saveProductPackageAssetsAndComplete,
|
||||
} from '@/lib/task-material-processor'
|
||||
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
|
||||
@ -46,6 +47,13 @@ jest.mock('@/lib/product-package', () => ({
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/task-material-processor', () => ({
|
||||
resolveProductPackageZipEntryNames: jest.fn((names: string[]) => names),
|
||||
buildTaskDetailPreviewNameForZipEntry: jest.fn((entryName: string, preview: { kind?: string; sliceCount?: number; sliceIndex?: number }, index = 0) => {
|
||||
const base = entryName.replace(/\.[^.]+$/, '')
|
||||
return preview.kind === 'slice' && (preview.sliceCount || 0) > 1
|
||||
? `${base}-${String((preview.sliceIndex ?? index) + 1).padStart(3, '0')}.webp`
|
||||
: `${base}-preview.webp`
|
||||
}),
|
||||
cleanupOnError: jest.fn().mockResolvedValue(undefined),
|
||||
cleanupTempFiles: jest.fn().mockResolvedValue(undefined),
|
||||
createZipFromFiles: jest.fn()
|
||||
@ -178,7 +186,9 @@ describe('product-package-completion', () => {
|
||||
fileSize: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
kind: 'preview',
|
||||
kind: 'slice',
|
||||
sliceIndex: index,
|
||||
sliceCount: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage,
|
||||
}))
|
||||
expect(() => assertTaskDetailUploadAssetLimits({
|
||||
main: Array.from({ length: 34 }, (_, index) => ({
|
||||
@ -193,6 +203,18 @@ describe('product-package-completion', () => {
|
||||
})).toThrow('客户端预览图数量过多')
|
||||
})
|
||||
|
||||
test('同一原图不能提交多张普通预览冒充不同来源', () => {
|
||||
expect(() => assertTaskDetailUploadAssetLimits({
|
||||
main: [{
|
||||
uploadId: 'source-1', fileName: '同名.jpg', fileSize: 10,
|
||||
previewAssets: [
|
||||
{ uploadId: 'preview-1', fileName: '同名-preview.webp', fileSize: 1, kind: 'preview' },
|
||||
{ uploadId: 'preview-2', fileName: '同名-preview.webp', fileSize: 1, kind: 'preview' },
|
||||
],
|
||||
}],
|
||||
})).toThrow('预览来源数量无效')
|
||||
})
|
||||
|
||||
test('真实会话复核将源文件和预览图字节分别计算', async () => {
|
||||
const sourceBytes = 1024 * 1024 * 1024
|
||||
;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({
|
||||
@ -478,6 +500,30 @@ describe('product-package-completion', () => {
|
||||
expect(createMaterialLog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('同名原图完成时预览来源锚跟随 ZIP 最终条目名', async () => {
|
||||
;(resolveProductPackageZipEntryNames as jest.Mock).mockImplementation(
|
||||
jest.requireActual('@/lib/task-material-processor').resolveProductPackageZipEntryNames,
|
||||
)
|
||||
await completeProductPackageUpload({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
assets: { main: [
|
||||
{ fileUrl: 'https://img.example/first.jpg', fileName: '功能1.jpg', fileSize: 10 },
|
||||
{ fileUrl: 'https://img.example/second.jpg', fileName: '功能1.jpg', fileSize: 10 },
|
||||
{ fileUrl: 'https://img.example/third.png', fileName: '功能1.png', fileSize: 10 },
|
||||
] },
|
||||
})
|
||||
|
||||
const archiveFiles = (createZipFromFiles as jest.Mock).mock.calls[0][0]
|
||||
expect(archiveFiles.map((file: { name: string }) => file.name)).toEqual(['功能1.jpg', '功能1-2.jpg', '功能1-3.png'])
|
||||
const saved = (saveProductPackageAssetsAndComplete as jest.Mock).mock.calls[0][0]
|
||||
expect(saved.previewAssets.main.map((asset: { fileName: string }) => asset.fileName)).toEqual([
|
||||
expect.stringMatching(/^功能1__srv-.*\.webp$/),
|
||||
expect.stringMatching(/^功能1-2__srv-.*\.webp$/),
|
||||
expect.stringMatching(/^功能1-3__srv-.*\.webp$/),
|
||||
])
|
||||
})
|
||||
|
||||
test('任务详情直接完成时缺少出图方式应拒绝处理', async () => {
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
|
||||
id: 'material-1',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getEmployeeByUserId } from '@/lib/employee-directory'
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import { batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
|
||||
import { assertTaskDetailImageEditReady, batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
|
||||
import {
|
||||
cleanupTaskDetailChangeStaging,
|
||||
resolveTaskDetailStagedFile,
|
||||
@ -58,6 +58,7 @@ jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn((
|
||||
jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.example.com' }))
|
||||
jest.mock('@/lib/safe-redirect', () => ({ buildDingtalkEntryPath: (path: string) => path }))
|
||||
jest.mock('@/lib/task-detail-asset-edit', () => ({
|
||||
assertTaskDetailImageEditReady: jest.fn(),
|
||||
batchEditTaskDetailImages: jest.fn(),
|
||||
TaskDetailAssetEditError: class extends Error {
|
||||
constructor(message: string, public statusCode = 400) { super(message) }
|
||||
@ -129,6 +130,7 @@ describe('任务详情图片变更主管审批', () => {
|
||||
;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(canManageMaterial as jest.Mock).mockResolvedValue(true)
|
||||
;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true })
|
||||
;(assertTaskDetailImageEditReady as jest.Mock).mockResolvedValue(undefined)
|
||||
;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1')
|
||||
;(cleanupTaskDetailChangeStaging as jest.Mock).mockResolvedValue(undefined)
|
||||
;(resolveTaskDetailStagedFile as jest.Mock).mockReturnValue({
|
||||
@ -266,6 +268,20 @@ describe('任务详情图片变更主管审批', () => {
|
||||
expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('来源对应不可靠时提交前拒绝,不能创建审批或暂存文件', async () => {
|
||||
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P'))
|
||||
;(assertTaskDetailImageEditReady as jest.Mock).mockRejectedValue(
|
||||
new TaskDetailAssetEditError('部分图片缺少可靠的来源标识,无法确认对应关系。请删除后重新上传。'),
|
||||
)
|
||||
|
||||
await expect(submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER' }, [
|
||||
{ kind: 'append', group: 'main', uploadId: 'upload-1' },
|
||||
])).rejects.toThrow('请联系管理员核对原图与预览图的对应关系')
|
||||
expect(assertTaskDetailImageEditReady).toHaveBeenCalledWith(expect.objectContaining({ group: 'main' }))
|
||||
expect(withTaskDetailStagedUploads).not.toHaveBeenCalled()
|
||||
expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('直属主管批准后才应用暂存操作', async () => {
|
||||
const pending = {
|
||||
id: 'request-1',
|
||||
|
||||
51
lib/__tests__/task-detail-source-mapping-audit.test.ts
Normal file
51
lib/__tests__/task-detail-source-mapping-audit.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
const { planGroup } = require('../../scripts/audit-task-detail-source-mapping.cjs')
|
||||
|
||||
const row = (id: string, name: string, sourceIndex: number, sliceIndex = 0) => ({
|
||||
id,
|
||||
fileName: name,
|
||||
fileUrl: `https://example.test/client-1780000000000-${sourceIndex}-${sliceIndex}-${name}`,
|
||||
})
|
||||
|
||||
describe('historical task-detail source mapping audit', () => {
|
||||
test('uses upload source indexes and ZIP deduplication, never ZIP directory order', () => {
|
||||
const result = planGroup([
|
||||
row('a', '功能1-preview.webp', 0),
|
||||
row('b', '功能2-preview.webp', 1),
|
||||
row('c', '功能1-preview.webp', 2),
|
||||
row('d', '功能2-preview.webp', 3),
|
||||
], ['功能2-2.jpg', '功能1.jpg', '功能2.jpg', '功能1-2.jpg'])
|
||||
expect(result).toMatchObject({
|
||||
status: 'verified',
|
||||
changes: [
|
||||
{ id: 'c', after: '功能1-2-preview.webp' },
|
||||
{ id: 'd', after: '功能2-2-preview.webp' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('different source extensions with identical preview labels stay manual', () => {
|
||||
const result = planGroup([
|
||||
row('a', '功能1-preview.webp', 0),
|
||||
row('b', '功能1-preview.webp', 1),
|
||||
], ['功能1.jpg', '功能1.png'])
|
||||
expect(result).toMatchObject({ status: 'manual', reason: '存在多种可能的原图对应关系' })
|
||||
})
|
||||
|
||||
test('multiple preview slices remain attached to one ZIP member', () => {
|
||||
const result = planGroup([
|
||||
row('a', '详情-001.webp', 0, 0),
|
||||
row('b', '详情-002.webp', 0, 1),
|
||||
], ['详情.jpg'])
|
||||
expect(result).toMatchObject({ status: 'verified', changes: [], sources: 1 })
|
||||
})
|
||||
|
||||
test('missing upload source identity stays manual', () => {
|
||||
const result = planGroup([
|
||||
{ id: 'a', fileName: '功能1-preview.webp', fileUrl: 'https://example.test/old.webp' },
|
||||
row('b', '功能1-preview.webp', 1),
|
||||
], ['功能1.jpg', '功能1-2.jpg'])
|
||||
expect(result).toMatchObject({ status: 'manual' })
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import {
|
||||
generatePreviewImages,
|
||||
buildTaskDetailPreviewNameForZipEntry,
|
||||
resolveProductPackageZipEntryNames,
|
||||
resolveOwnedTaskMaterialSourceFiles,
|
||||
resolveUniqueZipEntryName,
|
||||
TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES,
|
||||
@ -12,6 +14,7 @@ import { compressImage } from '@/lib/image-processor'
|
||||
import { readBoundedRegularFile } from '@/lib/bounded-file-reader'
|
||||
import { lstat } from 'fs/promises'
|
||||
import { getOwnedUploadedFile } from '@/lib/upload-sessions'
|
||||
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
@ -126,7 +129,7 @@ describe('task-material-processor', () => {
|
||||
})).rejects.toMatchObject({ status: 413 })
|
||||
})
|
||||
|
||||
test('任务图片源文件拒绝重复 uploadId 和超过 60 张', async () => {
|
||||
test('任务图片源文件拒绝重复 uploadId 和超过分组上限', async () => {
|
||||
await expect(resolveOwnedTaskMaterialSourceFiles({
|
||||
userId: 'user-1',
|
||||
assets: [
|
||||
@ -137,11 +140,11 @@ describe('task-material-processor', () => {
|
||||
|
||||
await expect(resolveOwnedTaskMaterialSourceFiles({
|
||||
userId: 'user-1',
|
||||
assets: Array.from({ length: 61 }, (_, index) => ({
|
||||
assets: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup + 1 }, (_, index) => ({
|
||||
uploadId: `upload-${index}`,
|
||||
fileName: `${index}.png`,
|
||||
})),
|
||||
})).rejects.toThrow('最多上传 60 张')
|
||||
})).rejects.toThrow(`最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张`)
|
||||
})
|
||||
|
||||
test('resolveUniqueZipEntryName 应避免同名文件在解压时互相覆盖', () => {
|
||||
@ -153,4 +156,16 @@ describe('task-material-processor', () => {
|
||||
expect(resolveUniqueZipEntryName('主图/01.jpg', 3, usedNames)).toBe('主图_01.jpg')
|
||||
expect(resolveUniqueZipEntryName('', 4, usedNames)).toBe('file-005')
|
||||
})
|
||||
|
||||
test('同名原图的 ZIP 名和预览来源锚一致', () => {
|
||||
const names = resolveProductPackageZipEntryNames(['功能1.jpg', '功能1.jpg', '功能1.jpg'])
|
||||
expect(names).toEqual(['功能1.jpg', '功能1-2.jpg', '功能1-3.jpg'])
|
||||
expect(names.map((name) => buildTaskDetailPreviewNameForZipEntry(name, { kind: 'preview' })))
|
||||
.toEqual(['功能1-preview.webp', '功能1-2-preview.webp', '功能1-3-preview.webp'])
|
||||
expect(buildTaskDetailPreviewNameForZipEntry(names[1], {
|
||||
kind: 'slice', sliceCount: 2, sliceIndex: 1,
|
||||
})).toBe('功能1-2-002.webp')
|
||||
expect(resolveProductPackageZipEntryNames(['图.jpg', '图.png', '图-2.jpg']))
|
||||
.toEqual(['图.jpg', '图-2.png', '图-2-2.jpg'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,6 +5,8 @@ import {
|
||||
cleanupTempFiles,
|
||||
createZipFromFiles,
|
||||
createZipFromZips,
|
||||
buildTaskDetailPreviewNameForZipEntry,
|
||||
resolveProductPackageZipEntryNames,
|
||||
saveProductPackageAssetsAndComplete,
|
||||
type ProductPackageAssetGroups,
|
||||
type ProductPackagePsdInfo,
|
||||
@ -182,6 +184,12 @@ export function assertTaskDetailUploadAssetLimits(assets: ProductPackageAssetsIn
|
||||
}
|
||||
|
||||
const slicePreviews = previews.filter((preview) => preview.kind === 'slice')
|
||||
if (
|
||||
(slicePreviews.length > 0 && slicePreviews.length !== previews.length)
|
||||
|| (slicePreviews.length === 0 && previews.length > 1)
|
||||
) {
|
||||
throw new Error(`图片 ${asset.fileName} 的预览来源数量无效`)
|
||||
}
|
||||
if (slicePreviews.length > 0) {
|
||||
const expectedCount = slicePreviews[0].sliceCount
|
||||
if (!Number.isInteger(expectedCount) || expectedCount !== slicePreviews.length) {
|
||||
@ -500,6 +508,12 @@ export async function assertOwnedProductPackageUploadSessions(
|
||||
throw new Error(`图片 ${asset.fileName} 的预览切片超过 60 张限制`)
|
||||
}
|
||||
const slicePreviews = previews.filter((preview) => preview.kind === 'slice')
|
||||
if (
|
||||
(slicePreviews.length > 0 && slicePreviews.length !== previews.length)
|
||||
|| (slicePreviews.length === 0 && previews.length > 1)
|
||||
) {
|
||||
throw new Error(`图片 ${asset.fileName} 的预览来源数量无效`)
|
||||
}
|
||||
if (slicePreviews.length > 0) {
|
||||
const expectedCount = slicePreviews[0].sliceCount
|
||||
if (!Number.isInteger(expectedCount) || expectedCount !== slicePreviews.length) {
|
||||
@ -744,6 +758,7 @@ async function generatePreviewsFromAssets(
|
||||
materialId: string,
|
||||
assets: ProductPackageImageInput[] | undefined,
|
||||
type: 'main' | 'sku' | 'detail',
|
||||
sourceEntryNames: string[],
|
||||
uploadedPreviewUrls: string[],
|
||||
privatePreviewPaths: string[],
|
||||
isPrivate: boolean,
|
||||
@ -783,6 +798,9 @@ async function generatePreviewsFromAssets(
|
||||
|
||||
for (let index = 0; index < assets.length; index += 1) {
|
||||
const asset = assets[index]
|
||||
const sourceEntryName = sourceEntryNames[index]
|
||||
if (!sourceEntryName) throw new Error(`图片 ${asset.fileName} 缺少 ZIP 来源条目`)
|
||||
const sourceBaseName = path.parse(sourceEntryName).name
|
||||
|
||||
if (asset.previewAssets && asset.previewAssets.length > 0) {
|
||||
for (let sliceIndex = 0; sliceIndex < asset.previewAssets.length; sliceIndex += 1) {
|
||||
@ -811,7 +829,8 @@ async function generatePreviewsFromAssets(
|
||||
throw new Error(`客户端预览图 ${previewAsset.fileName} 内容不是有效 WebP`)
|
||||
}
|
||||
|
||||
const fileName = safePreviewFileName(previewAsset.fileName, index, sliceIndex)
|
||||
const canonicalName = buildTaskDetailPreviewNameForZipEntry(sourceEntryName, previewAsset, sliceIndex)
|
||||
const fileName = safePreviewFileName(canonicalName, index, sliceIndex)
|
||||
const previewUrl = await saveTaskDetailPreview(
|
||||
buffer,
|
||||
materialId,
|
||||
@ -885,7 +904,7 @@ async function generatePreviewsFromAssets(
|
||||
const timestamp = Date.now()
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 9)
|
||||
const sliceSuffix = buffers.length > 1 ? `-slice${sliceIndex}` : ''
|
||||
const fileName = `${type}-preview-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp`
|
||||
const fileName = `${sourceBaseName}__srv-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp`
|
||||
const previewUrl = await saveTaskDetailPreview(
|
||||
buffers[sliceIndex],
|
||||
materialId,
|
||||
@ -1344,6 +1363,12 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds, assembledFiles)
|
||||
const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds, assembledFiles)
|
||||
const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds, assembledFiles)
|
||||
const mainEntryNames = resolveProductPackageZipEntryNames(mainFiles.map((file) => file.name))
|
||||
const skuEntryNames = resolveProductPackageZipEntryNames(skuFiles.map((file) => file.name))
|
||||
const detailEntryNames = resolveProductPackageZipEntryNames(detailFiles.map((file) => file.name))
|
||||
for (const [files, names] of [[mainFiles, mainEntryNames], [skuFiles, skuEntryNames], [detailFiles, detailEntryNames]] as const) {
|
||||
files.forEach((file, index) => { file.name = names[index] })
|
||||
}
|
||||
await reportProgress('assembling', 30)
|
||||
|
||||
const previewAssets = {
|
||||
@ -1351,6 +1376,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
materialId,
|
||||
normalizedAssets.main,
|
||||
'main',
|
||||
mainEntryNames,
|
||||
uploadedPreviewUrls,
|
||||
privatePreviewPaths,
|
||||
Boolean(material.taskDetailPrivate),
|
||||
@ -1362,6 +1388,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
materialId,
|
||||
normalizedAssets.sku,
|
||||
'sku',
|
||||
skuEntryNames,
|
||||
uploadedPreviewUrls,
|
||||
privatePreviewPaths,
|
||||
Boolean(material.taskDetailPrivate),
|
||||
@ -1373,6 +1400,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
materialId,
|
||||
normalizedAssets.detail,
|
||||
'detail',
|
||||
detailEntryNames,
|
||||
uploadedPreviewUrls,
|
||||
privatePreviewPaths,
|
||||
Boolean(material.taskDetailPrivate),
|
||||
|
||||
@ -49,7 +49,7 @@ import { promisify } from 'util'
|
||||
* 预览行与 ZIP 条目的对应关系靠"来源文件名"锚定:archiver 并发打包
|
||||
* 不保证条目顺序等于上传顺序,位置映射不可靠;预览名(客户端命名或
|
||||
* 本模块生成的 __srv 命名)携带来源基础名,与条目名去扩展名后匹配。
|
||||
* 匹配不上就拒绝编辑,宁可让用户删除重传,也不能悄悄改错图。
|
||||
* 匹配不上就拒绝编辑,并交由来源映射核对,不能悄悄改错图。
|
||||
*/
|
||||
|
||||
export type TaskDetailEditGroup = 'main' | 'sku' | 'detail'
|
||||
@ -453,7 +453,7 @@ export function matchRunsToZipEntries(
|
||||
): number[] {
|
||||
if (runs.length !== entryNames.length) {
|
||||
throw new TaskDetailAssetEditError(
|
||||
'该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。',
|
||||
'该素材的图片索引与打包内容不一致,无法安全地单独编辑。请联系管理员核对来源对应关系。',
|
||||
)
|
||||
}
|
||||
|
||||
@ -500,12 +500,12 @@ export function matchRunsToZipEntries(
|
||||
|
||||
if (unassignedRuns.length !== unusedEntryIndexes.length) {
|
||||
throw new TaskDetailAssetEditError(
|
||||
'该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。',
|
||||
'该素材的图片索引与打包内容不一致,无法安全地单独编辑。请联系管理员核对来源对应关系。',
|
||||
)
|
||||
}
|
||||
if (unassignedRuns.length > 1) {
|
||||
throw new TaskDetailAssetEditError(
|
||||
'部分图片缺少可靠的来源标识,无法确认对应关系。请删除后重新上传。',
|
||||
'部分图片缺少可靠的来源标识,无法确认对应关系。请联系管理员核对来源对应关系。',
|
||||
)
|
||||
}
|
||||
if (unassignedRuns.length === 1) {
|
||||
@ -740,7 +740,7 @@ async function loadEditContext(options: {
|
||||
return { runs: legacyRuns, runEntryIndexes: aligned }
|
||||
}
|
||||
throw new TaskDetailAssetEditError(
|
||||
'该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。',
|
||||
'该素材的图片索引与打包内容不一致,无法安全地单独编辑。请联系管理员核对来源对应关系。',
|
||||
)
|
||||
}
|
||||
|
||||
@ -1165,6 +1165,16 @@ async function generateAnimatedPreviews(options: {
|
||||
return previews
|
||||
}
|
||||
|
||||
/** Read-only submission gate: the same ZIP/preview mapping used by approval application. */
|
||||
export async function assertTaskDetailImageEditReady(input: {
|
||||
materialId: string
|
||||
operatorUserId: string
|
||||
operatorRole?: string | null
|
||||
group: TaskDetailEditGroup
|
||||
}): Promise<void> {
|
||||
await loadEditContext({ ...input, allowEmptyGroup: true })
|
||||
}
|
||||
|
||||
/** 按完成链路的服务端兜底参数生成预览(800x1200 webp,长图切片)。 */
|
||||
async function generateServerPreviews(
|
||||
source: ResolvedMutationSource,
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
type TaskDetailStagedFile,
|
||||
} from '@/lib/task-detail-change-staging'
|
||||
import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications'
|
||||
import { batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
|
||||
import { assertTaskDetailImageEditReady, batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
|
||||
|
||||
const REQUEST_STATUS = {
|
||||
PENDING: 'pending_manager_approval',
|
||||
@ -135,7 +135,7 @@ function normalizeOperations(value: unknown): TaskDetailChangeOperationInput[] {
|
||||
function assertSingleOperationGroup(
|
||||
operations: TaskDetailChangeOperationInput[],
|
||||
assets: Array<{ id: string; assetType: string }>,
|
||||
) {
|
||||
): 'main' | 'sku' | 'detail' {
|
||||
const assetGroups = new Map<string, 'main' | 'sku' | 'detail'>()
|
||||
for (const asset of assets) {
|
||||
if (asset.assetType === 'main-preview') assetGroups.set(asset.id, 'main')
|
||||
@ -167,6 +167,7 @@ function assertSingleOperationGroup(
|
||||
if (groups.size !== 1) {
|
||||
throw new TaskDetailChangeApprovalError('一次只能提交同一图片分组(主图、SKU图或详情图)的变更,请分组提交')
|
||||
}
|
||||
return Array.from(groups)[0]
|
||||
}
|
||||
|
||||
async function loadAuthorizedContext(materialId: string, actor: Actor) {
|
||||
@ -359,7 +360,19 @@ export async function submitTaskDetailChange(
|
||||
) {
|
||||
const operations = normalizeOperations(rawOperations)
|
||||
const { material, employee } = await loadAuthorizedContext(materialId, actor)
|
||||
assertSingleOperationGroup(operations, material.assets)
|
||||
const group = assertSingleOperationGroup(operations, material.assets)
|
||||
try {
|
||||
await assertTaskDetailImageEditReady({
|
||||
materialId,
|
||||
operatorUserId: actor.id,
|
||||
operatorRole: actor.role,
|
||||
group,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof TaskDetailAssetEditError)) throw error
|
||||
const explanation = error.message.replace(/请删除后重新上传。?/g, '请联系管理员核对原图与预览图的对应关系。')
|
||||
throw new TaskDetailChangeApprovalError(`图片变更暂不能提交:${explanation}`, error.statusCode)
|
||||
}
|
||||
if (isDirectApplyActor(actor, employee)) {
|
||||
const data = await batchEditTaskDetailImages({
|
||||
materialId,
|
||||
|
||||
@ -140,6 +140,35 @@ export function resolveUniqueZipEntryName(
|
||||
return lastResortName;
|
||||
}
|
||||
|
||||
/** Use the exact archive names for both the ZIP and its preview index. */
|
||||
export function resolveProductPackageZipEntryNames(names: string[]): string[] {
|
||||
const usedBases = new Set<string>();
|
||||
return names.map((name, index) => {
|
||||
// Preview metadata and archive entries must use the same portable basename.
|
||||
const portableName = String(name || `file-${String(index + 1).padStart(3, '0')}`)
|
||||
.replace(/[\/\\:*?"<>|\0\r\n]/g, '_').trim();
|
||||
const extension = path.extname(portableName);
|
||||
const base = (extension ? portableName.slice(0, -extension.length) : portableName) || `file-${index + 1}`;
|
||||
let uniqueBase = base;
|
||||
for (let suffix = 2; usedBases.has(uniqueBase.toLowerCase()); suffix += 1) {
|
||||
uniqueBase = `${base}-${suffix}`;
|
||||
}
|
||||
usedBases.add(uniqueBase.toLowerCase());
|
||||
return `${uniqueBase}${extension}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildTaskDetailPreviewNameForZipEntry(
|
||||
entryName: string,
|
||||
preview: { kind?: string; sliceCount?: number; sliceIndex?: number },
|
||||
fallbackSliceIndex = 0,
|
||||
): string {
|
||||
const baseName = path.parse(entryName).name;
|
||||
return preview.kind === 'slice' && (preview.sliceCount || 0) > 1
|
||||
? `${baseName}-${String((preview.sliceIndex ?? fallbackSliceIndex) + 1).padStart(3, '0')}.webp`
|
||||
: `${baseName}-preview.webp`;
|
||||
}
|
||||
|
||||
interface SafeZipEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
|
||||
233
scripts/audit-task-detail-source-mapping.cjs
Normal file
233
scripts/audit-task-detail-source-mapping.cjs
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Read-only by default. Audits duplicate task-detail preview names against ZIP
|
||||
* members using the original client upload index embedded in preview URLs.
|
||||
* --apply --material <id> repairs one verified material; --apply --all-verified
|
||||
* repairs every uniquely proven material. No ZIP or image bytes are changed.
|
||||
*/
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
const GROUPS = ['main', 'sku', 'detail']
|
||||
const PREVIEW_TYPE = Object.fromEntries(GROUPS.map((group) => [group, `${group}-preview`]))
|
||||
const PYTHON_ZIP_NAMES = 'import json,sys,zipfile\nwith zipfile.ZipFile(sys.argv[1]) as z:\n print(json.dumps([i.filename for i in z.infolist() if not i.is_dir()], ensure_ascii=False))'
|
||||
|
||||
function stripExtension(name) {
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot > 0 ? name.slice(0, dot) : name
|
||||
}
|
||||
|
||||
function uniqueZipName(originalName, index, used) {
|
||||
const fallback = `file-${String(index + 1).padStart(3, '0')}`
|
||||
const safe = String(originalName || fallback).replace(/[\0\r\n]/g, ' ').replace(/[\/\\]+/g, '_').trim() || fallback
|
||||
const reserve = (candidate) => {
|
||||
const key = candidate.toLowerCase()
|
||||
if (used.has(key)) return false
|
||||
used.add(key)
|
||||
return true
|
||||
}
|
||||
if (reserve(safe)) return safe
|
||||
const ext = path.extname(safe)
|
||||
const base = (ext ? safe.slice(0, -ext.length) : safe) || fallback
|
||||
for (let n = 2; n < 10000; n += 1) {
|
||||
const candidate = `${base}-${n}${ext}`
|
||||
if (reserve(candidate)) return candidate
|
||||
}
|
||||
throw new Error('ZIP 条目重名次数超过可核验范围')
|
||||
}
|
||||
|
||||
function parseClientPreview(row) {
|
||||
let url
|
||||
try { url = decodeURIComponent(String(row.fileUrl || '')) } catch { return null }
|
||||
const source = /client-\d{13}-(\d+)-(\d+)-/.exec(url)
|
||||
const name = String(row.fileName || '')
|
||||
const preview = /^(.+)-preview\.webp$/i.exec(name)
|
||||
const slice = /^(.+)-(\d{3})\.webp$/i.exec(name)
|
||||
if (!source || (!preview && !slice)) return null
|
||||
// A renamed preview keeps its old object URL; upload-index reconstruction
|
||||
// cannot prove its current ZIP name and must leave it for manual review.
|
||||
if (!url.slice(source.index + source[0].length).startsWith(name)) return null
|
||||
const sourceIndex = Number(source[1])
|
||||
const urlSliceIndex = Number(source[2])
|
||||
const label = preview ? preview[1] : slice[1]
|
||||
const displaySlice = slice ? Number(slice[2]) : null
|
||||
if (!Number.isSafeInteger(sourceIndex) || !Number.isSafeInteger(urlSliceIndex)) return null
|
||||
if (displaySlice !== null && displaySlice !== urlSliceIndex + 1) return null
|
||||
if (displaySlice === null && urlSliceIndex !== 0) return null
|
||||
return { row, sourceIndex, urlSliceIndex, label, displaySlice }
|
||||
}
|
||||
|
||||
function planGroup(rows, entryNames) {
|
||||
if (entryNames.length === 0) return { status: 'manual', reason: 'ZIP 为空' }
|
||||
if (new Set(entryNames.map((name) => name.toLowerCase())).size !== entryNames.length) {
|
||||
return { status: 'manual', reason: 'ZIP 条目自身重名' }
|
||||
}
|
||||
const parsed = rows.map(parseClientPreview)
|
||||
if (parsed.some((row) => row === null)) return { status: 'manual', reason: '预览缺少上传来源序号' }
|
||||
const byIndex = new Map()
|
||||
for (const item of parsed) {
|
||||
const sourceRows = byIndex.get(item.sourceIndex) || []
|
||||
sourceRows.push(item)
|
||||
byIndex.set(item.sourceIndex, sourceRows)
|
||||
}
|
||||
if (byIndex.size !== entryNames.length) return { status: 'manual', reason: '原图序号与 ZIP 条目数量不一致' }
|
||||
const sources = []
|
||||
for (let index = 0; index < byIndex.size; index += 1) {
|
||||
const sourceRows = byIndex.get(index)
|
||||
if (!sourceRows) return { status: 'manual', reason: '原图序号不连续' }
|
||||
const labels = new Set(sourceRows.map((item) => item.label.toLowerCase()))
|
||||
const slices = sourceRows.map((item) => item.urlSliceIndex).sort((a, b) => a - b)
|
||||
if (labels.size !== 1 || slices.some((slice, position) => slice !== position)) {
|
||||
return { status: 'manual', reason: '同一原图的预览切片不连续或标签不一致' }
|
||||
}
|
||||
sources.push({ label: sourceRows[0].label, rows: sourceRows })
|
||||
}
|
||||
|
||||
const entryByLowerName = new Map(entryNames.map((name) => [name.toLowerCase(), name]))
|
||||
const extensions = Array.from(new Set(entryNames.map((name) => path.extname(name))))
|
||||
const solutions = new Map()
|
||||
const visit = (sourceIndex, used, chosen) => {
|
||||
if (solutions.size > 1) return
|
||||
if (sourceIndex === sources.length) {
|
||||
if (used.size === entryNames.length) {
|
||||
const signature = chosen.map((name) => name.toLowerCase()).join('\0')
|
||||
solutions.set(signature, chosen)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const extension of extensions) {
|
||||
const nextUsed = new Set(used)
|
||||
const generated = uniqueZipName(`${sources[sourceIndex].label}${extension}`, sourceIndex, nextUsed)
|
||||
const actual = entryByLowerName.get(generated.toLowerCase())
|
||||
if (actual) visit(sourceIndex + 1, nextUsed, [...chosen, actual])
|
||||
}
|
||||
}
|
||||
visit(0, new Set(), [])
|
||||
if (solutions.size !== 1) {
|
||||
return { status: 'manual', reason: solutions.size === 0 ? '无法由上传序号重建 ZIP 命名' : '存在多种可能的原图对应关系' }
|
||||
}
|
||||
const chosen = Array.from(solutions.values())[0]
|
||||
const changes = sources.flatMap((source, sourceIndex) => source.rows.flatMap((item) => {
|
||||
const base = stripExtension(chosen[sourceIndex])
|
||||
const nextName = item.displaySlice === null
|
||||
? `${base}-preview.webp`
|
||||
: `${base}-${String(item.displaySlice).padStart(3, '0')}.webp`
|
||||
return item.row.fileName === nextName ? [] : [{ id: item.row.id, before: item.row.fileName, after: nextName }]
|
||||
}))
|
||||
return { status: 'verified', changes, sources: sources.length }
|
||||
}
|
||||
|
||||
function resolveZipPath(fileUrl) {
|
||||
const root = path.resolve(process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'))
|
||||
const normalized = String(fileUrl || '').replace(/\\/g, '/')
|
||||
const relative = normalized.replace(/^\.?\/*uploads\//i, '')
|
||||
const absolute = path.resolve(path.isAbsolute(normalized) ? normalized : path.join(root, relative))
|
||||
if (absolute !== root && !absolute.startsWith(root + path.sep)) throw new Error('ZIP 路径不在上传目录内')
|
||||
return absolute
|
||||
}
|
||||
|
||||
function listZipNames(fileUrl) {
|
||||
const zipPath = resolveZipPath(fileUrl)
|
||||
if (!fs.statSync(zipPath).isFile()) throw new Error('ZIP 文件不存在')
|
||||
return JSON.parse(execFileSync('python3', ['-c', PYTHON_ZIP_NAMES, zipPath], {
|
||||
encoding: 'utf8', timeout: 15000, maxBuffer: 2 * 1024 * 1024,
|
||||
}))
|
||||
}
|
||||
|
||||
async function scanMaterial(db, materialId) {
|
||||
const material = await db.material.findUnique({
|
||||
where: { id: materialId },
|
||||
select: { id: true, materialType: true, taskStatus: true, deletedAt: true, updatedAt: true, assets: { select: {
|
||||
id: true, assetType: true, fileUrl: true, fileName: true, sortOrder: true,
|
||||
} } },
|
||||
})
|
||||
if (!material || material.materialType !== '任务-详情' || material.taskStatus !== 'completed' || material.deletedAt) return []
|
||||
const results = []
|
||||
for (const group of GROUPS) {
|
||||
const rows = material.assets.filter((asset) => asset.assetType === PREVIEW_TYPE[group])
|
||||
const nameCounts = new Map()
|
||||
rows.forEach((row) => nameCounts.set(String(row.fileName || '').toLowerCase(), (nameCounts.get(String(row.fileName || '').toLowerCase()) || 0) + 1))
|
||||
if (!Array.from(nameCounts.values()).some((count) => count > 1)) continue
|
||||
const zipAssets = material.assets.filter((asset) => asset.assetType === group)
|
||||
if (zipAssets.length !== 1) {
|
||||
results.push({ materialId, group, status: 'manual', reason: '分组 ZIP 数量不是一份' })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const plan = planGroup(rows, listZipNames(zipAssets[0].fileUrl))
|
||||
results.push({ materialId, group, expectedUpdatedAt: material.updatedAt, ...plan })
|
||||
} catch (error) {
|
||||
results.push({ materialId, group, status: 'manual', reason: error.message })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function applyVerifiedPlan(db, plan) {
|
||||
if (plan.status !== 'verified' || !plan.changes.length) return 0
|
||||
await db.$transaction(async (tx) => {
|
||||
const material = await tx.material.findUnique({ where: { id: plan.materialId }, select: { updatedAt: true } })
|
||||
if (!material || material.updatedAt.getTime() !== plan.expectedUpdatedAt.getTime()) {
|
||||
throw new Error('素材已更新,需重新扫描')
|
||||
}
|
||||
for (const change of plan.changes) {
|
||||
const updated = await tx.materialAsset.updateMany({
|
||||
where: { id: change.id, materialId: plan.materialId, assetType: PREVIEW_TYPE[plan.group], fileName: change.before },
|
||||
data: { fileName: change.after },
|
||||
})
|
||||
if (updated.count !== 1) throw new Error('预览索引已变化,需重新扫描')
|
||||
}
|
||||
})
|
||||
return plan.changes.length
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const apply = args.includes('--apply')
|
||||
const summaryOnly = args.includes('--summary-only')
|
||||
const includeChanges = args.includes('--include-changes')
|
||||
const allVerified = args.includes('--all-verified')
|
||||
const materialIndex = args.indexOf('--material')
|
||||
const singleMaterialId = materialIndex >= 0 ? args[materialIndex + 1] : null
|
||||
if (apply && !allVerified && !singleMaterialId) throw new Error('--apply 必须指定 --material <id> 或 --all-verified')
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
const db = new PrismaClient()
|
||||
try {
|
||||
const ids = singleMaterialId ? [{ material_id: singleMaterialId }] : await db.$queryRawUnsafe(`
|
||||
SELECT DISTINCT material_id FROM (
|
||||
SELECT a.material_id, a.asset_type, lower(a.file_name)
|
||||
FROM material_assets a JOIN materials m ON m.id = a.material_id
|
||||
WHERE m."materialType" = '任务-详情' AND m."deletedAt" IS NULL
|
||||
AND m.task_status = 'completed'
|
||||
AND a.asset_type IN ('main-preview', 'sku-preview', 'detail-preview')
|
||||
AND a.file_name IS NOT NULL
|
||||
GROUP BY a.material_id, a.asset_type, lower(a.file_name) HAVING count(*) > 1
|
||||
) candidates ORDER BY material_id`)
|
||||
const results = []
|
||||
for (const row of ids) {
|
||||
for (const result of await scanMaterial(db, row.material_id)) {
|
||||
if (apply && result.status === 'verified') result.appliedRows = await applyVerifiedPlan(db, result)
|
||||
results.push(result)
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
candidateMaterials: ids.length,
|
||||
verifiedGroups: results.filter((item) => item.status === 'verified').length,
|
||||
manualGroups: results.filter((item) => item.status === 'manual').length,
|
||||
plannedRows: results.reduce((sum, item) => sum + (item.changes?.length || 0), 0),
|
||||
appliedRows: results.reduce((sum, item) => sum + (item.appliedRows || 0), 0),
|
||||
manual: results.filter((item) => item.status === 'manual').map(({ materialId, group, reason }) => ({ materialId, group, reason })),
|
||||
...(summaryOnly ? {} : { verified: results.filter((item) => item.status === 'verified').map(({ materialId, group, changes }) => ({
|
||||
materialId, group, changedRows: changes.length,
|
||||
...(includeChanges ? { changes } : {}),
|
||||
})) }),
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await db.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseClientPreview, planGroup, uniqueZipName }
|
||||
if (require.main === module || process.env.TASK_DETAIL_AUDIT_RUN === '1') {
|
||||
main().catch((error) => { console.error(error.message); process.exitCode = 1 })
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user