From 71b8f30d5b5f487d2ae1549b7d80406114b8f44d Mon Sep 17 00:00:00 2001 From: chenpei Date: Fri, 28 Aug 2026 19:20:01 +0800 Subject: [PATCH] feat: add task detail image approval and insertion flow --- Dockerfile | 4 +- .../materials/[id]/assets/[assetId]/route.ts | 45 +- app/api/materials/[id]/assets/batch/route.ts | 22 +- .../[id]/assets/change-requests/route.ts | 59 + .../[id]/decision/route.ts | 39 + .../task-detail-change-requests/[id]/route.ts | 33 + app/share/[id]/page.test.tsx | 351 +++- app/share/[id]/page.tsx | 590 +++++- .../[id]/page.test.tsx | 85 + app/task-detail-change-requests/[id]/page.tsx | 393 ++++ .../detail/ProductPackageDetail.test.tsx | 78 + components/detail/ProductPackageDetail.tsx | 171 +- docs/生产数据库安全增量迁移SOP-2026-08-12.md | 3 +- instrumentation.ts | 39 + .../task-detail-asset-batch-route.test.ts | 44 +- .../task-detail-asset-edit-batch.test.ts | 585 ++++++ .../task-detail-asset-edit-route.test.ts | 74 +- .../task-detail-change-approval.test.ts | 386 ++++ .../task-detail-change-request-routes.test.ts | 123 ++ .../task-detail-preview-runs.test.ts | 49 + lib/task-detail-asset-edit.ts | 1675 ++++++++++++++--- lib/task-detail-change-approval.ts | 722 +++++++ lib/task-detail-change-notifications.ts | 153 ++ lib/task-detail-change-staging.ts | 131 ++ .../migration.sql | 83 + prisma/schema.prisma | 78 + scripts/git-incremental-deploy.ps1 | 1 + scripts/safe-production-migrate.mjs | 39 +- scripts/safe-production-migrate.test.mjs | 20 + 29 files changed, 5598 insertions(+), 477 deletions(-) create mode 100644 app/api/materials/[id]/assets/change-requests/route.ts create mode 100644 app/api/task-detail-change-requests/[id]/decision/route.ts create mode 100644 app/api/task-detail-change-requests/[id]/route.ts create mode 100644 app/task-detail-change-requests/[id]/page.test.tsx create mode 100644 app/task-detail-change-requests/[id]/page.tsx create mode 100644 components/detail/ProductPackageDetail.test.tsx create mode 100644 lib/__tests__/task-detail-asset-edit-batch.test.ts create mode 100644 lib/__tests__/task-detail-change-approval.test.ts create mode 100644 lib/__tests__/task-detail-change-request-routes.test.ts create mode 100644 lib/task-detail-change-approval.ts create mode 100644 lib/task-detail-change-notifications.ts create mode 100644 lib/task-detail-change-staging.ts create mode 100644 prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql diff --git a/Dockerfile b/Dockerfile index 503bf2a..640ff33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,7 +87,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260826_add_ding_workflow_demand_type/migration.sql" && \ test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \ test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql" && \ - test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql" + test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql" && \ + test -f "/app/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -208,6 +209,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_ding_w COPY --from=builder --chown=root:root /app/prisma/migrations/20260827_add_ding_workflow_detail_fields ./prisma/migrations/20260827_add_ding_workflow_detail_fields COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_pack_video_early_access ./prisma/migrations/20260826_add_pack_video_early_access COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_pack_item_can_edit_assets ./prisma/migrations/20260828_add_pack_item_can_edit_assets +COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_task_detail_change_approvals ./prisma/migrations/20260828_add_task_detail_change_approvals COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/materials/[id]/assets/[assetId]/route.ts b/app/api/materials/[id]/assets/[assetId]/route.ts index be70be2..dfe3d92 100644 --- a/app/api/materials/[id]/assets/[assetId]/route.ts +++ b/app/api/materials/[id]/assets/[assetId]/route.ts @@ -14,10 +14,11 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { TaskDetailAssetEditError, - deleteTaskDetailImage, - renameTaskDetailImage, - replaceTaskDetailImage, } from '@/lib/task-detail-asset-edit' +import { + submitTaskDetailChange, + TaskDetailChangeApprovalError, +} from '@/lib/task-detail-change-approval' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -25,16 +26,16 @@ export const maxDuration = 10800 type RouteParams = { params: { id: string; assetId: string } } -function getSessionUser(session: unknown): { id: string; role?: string | null } | null { - const user = (session as { user?: { id?: string; role?: string } } | null)?.user +function getSessionUser(session: unknown): { id: string; role?: string | null; isSuperAdmin?: boolean } | null { + const user = (session as { user?: { id?: string; role?: string; isSuperAdmin?: boolean } } | null)?.user if (!user?.id) { return null } - return { id: user.id, role: user.role ?? null } + return { id: user.id, role: user.role ?? null, isSuperAdmin: Boolean(user.isSuperAdmin) } } function handleEditError(error: unknown) { - if (error instanceof TaskDetailAssetEditError) { + if (error instanceof TaskDetailAssetEditError || error instanceof TaskDetailChangeApprovalError) { return NextResponse.json( { success: false, error: error.message }, { status: error.statusCode }, @@ -67,15 +68,13 @@ export async function PATCH( ) } - const result = await renameTaskDetailImage({ - materialId: params.id, + const result = await submitTaskDetailChange(params.id, operator, [{ + kind: 'rename', assetId: params.assetId, - operatorUserId: operator.id, - operatorRole: operator.role, newFileName, - }) + }]) - return NextResponse.json({ success: true, data: result }) + return NextResponse.json({ success: true, ...result }) } catch (error) { return handleEditError(error) } @@ -101,15 +100,13 @@ export async function POST( ) } - const result = await replaceTaskDetailImage({ - materialId: params.id, + const result = await submitTaskDetailChange(params.id, operator, [{ + kind: 'replace', assetId: params.assetId, - operatorUserId: operator.id, - operatorRole: operator.role, uploadId, - }) + }]) - return NextResponse.json({ success: true, data: result }) + return NextResponse.json({ success: true, ...result }) } catch (error) { return handleEditError(error) } @@ -126,14 +123,12 @@ export async function DELETE( return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) } - const result = await deleteTaskDetailImage({ - materialId: params.id, + const result = await submitTaskDetailChange(params.id, operator, [{ + kind: 'delete', assetId: params.assetId, - operatorUserId: operator.id, - operatorRole: operator.role, - }) + }]) - return NextResponse.json({ success: true, data: result }) + return NextResponse.json({ success: true, ...result }) } catch (error) { return handleEditError(error) } diff --git a/app/api/materials/[id]/assets/batch/route.ts b/app/api/materials/[id]/assets/batch/route.ts index 27c4b09..709ad52 100644 --- a/app/api/materials/[id]/assets/batch/route.ts +++ b/app/api/materials/[id]/assets/batch/route.ts @@ -17,9 +17,12 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { TaskDetailAssetEditError, - batchEditTaskDetailImages, type BatchTaskDetailImageEditOperation, } from '@/lib/task-detail-asset-edit' +import { + submitTaskDetailChange, + TaskDetailChangeApprovalError, +} from '@/lib/task-detail-change-approval' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -27,12 +30,12 @@ export const maxDuration = 10800 type RouteParams = { params: { id: string } } -function getSessionUser(session: unknown): { id: string; role?: string | null } | null { - const user = (session as { user?: { id?: string; role?: string } } | null)?.user +function getSessionUser(session: unknown): { id: string; role?: string | null; isSuperAdmin?: boolean } | null { + const user = (session as { user?: { id?: string; role?: string; isSuperAdmin?: boolean } } | null)?.user if (!user?.id) { return null } - return { id: user.id, role: user.role ?? null } + return { id: user.id, role: user.role ?? null, isSuperAdmin: Boolean(user.isSuperAdmin) } } export async function POST(request: NextRequest, { params }: RouteParams) { @@ -78,16 +81,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ success: false, error: '批量操作不能为空' }, { status: 400 }) } - const result = await batchEditTaskDetailImages({ - materialId: params.id, - operatorUserId: operator.id, - operatorRole: operator.role, - operations, - }) + const result = await submitTaskDetailChange(params.id, operator, operations) - return NextResponse.json({ success: true, data: result }) + return NextResponse.json({ success: true, ...result }) } catch (error) { - if (error instanceof TaskDetailAssetEditError) { + if (error instanceof TaskDetailAssetEditError || error instanceof TaskDetailChangeApprovalError) { return NextResponse.json( { success: false, error: error.message }, { status: error.statusCode }, diff --git a/app/api/materials/[id]/assets/change-requests/route.ts b/app/api/materials/[id]/assets/change-requests/route.ts new file mode 100644 index 0000000..db68a49 --- /dev/null +++ b/app/api/materials/[id]/assets/change-requests/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import { authOptions } from '@/lib/auth' +import { + listTaskDetailChanges, + submitTaskDetailChange, + TaskDetailChangeApprovalError, +} from '@/lib/task-detail-change-approval' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + +type RouteParams = { params: { id: string } } + +function actor(session: unknown) { + const user = (session as { user?: { id?: string; role?: string; isSuperAdmin?: boolean } } | null)?.user + return user?.id + ? { id: user.id, role: user.role || null, isSuperAdmin: Boolean(user.isSuperAdmin) } + : null +} + +function errorResponse(error: unknown) { + if (error instanceof TaskDetailChangeApprovalError) { + return NextResponse.json({ success: false, error: error.message }, { status: error.statusCode }) + } + console.error('[task-detail-change] request error:', error) + return NextResponse.json({ success: false, error: '图片变更处理失败,请稍后重试' }, { status: 500 }) +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const current = actor(session) + if (!current) return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + const rows = await listTaskDetailChanges(params.id, current) + return NextResponse.json({ success: true, data: rows }) + } catch (error) { + return errorResponse(error) + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const current = actor(session) + if (!current) return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + const body = await request.json().catch(() => null) + const result = await submitTaskDetailChange( + params.id, + current, + (body as { operations?: unknown } | null)?.operations, + ) + return NextResponse.json({ success: true, ...result }) + } catch (error) { + return errorResponse(error) + } +} diff --git a/app/api/task-detail-change-requests/[id]/decision/route.ts b/app/api/task-detail-change-requests/[id]/decision/route.ts new file mode 100644 index 0000000..de1e465 --- /dev/null +++ b/app/api/task-detail-change-requests/[id]/decision/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import { authOptions } from '@/lib/auth' +import { + decideTaskDetailChange, + TaskDetailChangeApprovalError, +} from '@/lib/task-detail-change-approval' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + +type RouteParams = { params: { id: string } } + +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const user = session?.user as { id?: string; role?: string; isSuperAdmin?: boolean } | undefined + if (!user?.id) return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + const body = await request.json().catch(() => null) as { decision?: unknown } | null + const decision = String(body?.decision || '') + if (decision !== 'approve' && decision !== 'reject') { + return NextResponse.json({ success: false, error: '请选择批准或否决' }, { status: 400 }) + } + const data = await decideTaskDetailChange(params.id, { + id: user.id, + role: user.role || null, + isSuperAdmin: Boolean(user.isSuperAdmin), + }, decision) + return NextResponse.json({ success: true, data }) + } catch (error) { + if (error instanceof TaskDetailChangeApprovalError) { + return NextResponse.json({ success: false, error: error.message }, { status: error.statusCode }) + } + console.error('[task-detail-change] decision error:', error) + return NextResponse.json({ success: false, error: '确认图片变更失败,请稍后重试' }, { status: 500 }) + } +} diff --git a/app/api/task-detail-change-requests/[id]/route.ts b/app/api/task-detail-change-requests/[id]/route.ts new file mode 100644 index 0000000..386c012 --- /dev/null +++ b/app/api/task-detail-change-requests/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import { authOptions } from '@/lib/auth' +import { + getTaskDetailChangeRequest, + TaskDetailChangeApprovalError, +} from '@/lib/task-detail-change-approval' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: { id: string } } + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const user = session?.user as { id?: string; role?: string; isSuperAdmin?: boolean } | undefined + if (!user?.id) return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + const data = await getTaskDetailChangeRequest(params.id, { + id: user.id, + role: user.role || null, + isSuperAdmin: Boolean(user.isSuperAdmin), + }) + return NextResponse.json({ success: true, data }) + } catch (error) { + if (error instanceof TaskDetailChangeApprovalError) { + return NextResponse.json({ success: false, error: error.message }, { status: error.statusCode }) + } + console.error('[task-detail-change] detail error:', error) + return NextResponse.json({ success: false, error: '读取图片变更申请失败' }, { status: 500 }) + } +} diff --git a/app/share/[id]/page.test.tsx b/app/share/[id]/page.test.tsx index e3f07f3..561d9f3 100644 --- a/app/share/[id]/page.test.tsx +++ b/app/share/[id]/page.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import SharePage from './page' @@ -6,20 +6,73 @@ import SharePage from './page' const mockPush = jest.fn() const mockReplace = jest.fn() const mockBack = jest.fn() +const showToastMock = jest.fn() +const chunkUploadMock = jest.fn() const originalFetch = global.fetch -const createVideoWaitResponse = () => ({ - ok: false, - status: 403, - json: async () => ({ - error: '视频类素材上传后满 15 天才能查看详情', - code: 'VIDEO_WAIT_PERIOD', - details: '视频类素材上传后满 15 天才能预览和下载,当前还需约 12 天', - availableAt: '2026-09-01T00:00:00.000Z', - remainingDays: 12, - }), +const createJsonResponse = (body: T, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, }) +const createVideoWaitResponse = () => createJsonResponse({ + error: '视频类素材上传后满 15 天才能查看详情', + code: 'VIDEO_WAIT_PERIOD', + details: '视频类素材上传后满 15 天才能预览和下载,当前还需约 12 天', + availableAt: '2026-09-01T00:00:00.000Z', + remainingDays: 12, +}, 403) + +const createTaskDetailMaterialResponse = () => createJsonResponse({ + id: 'material-1', + userId: 'user-1', + previewUrl: '/preview.jpg', + productModel: 'GBSFY-SJZ0024', + categoryName: '顾家', + sceneName: '客厅', + materialType: '任务-详情', + authorName: '小李', + zipFileName: 'bundle.zip', + zipFileSize: '1024', + downloadCount: 0, + likeCount: 0, + displayImages: [], + createdAt: '2026-08-28T09:00:00.000Z', + capabilities: { + canFavorite: true, + canEdit: true, + canDelete: false, + }, + taskNo: 'TASK-001', + taskStatus: 'ready', + taskFetchedAt: null, + isFullSet: true, + canDownloadSource: false, + canManageTaskDetailAssets: true, + assets: [ + { id: 'main-1', assetType: 'main-preview', fileUrl: '/main-1.jpg', fileName: 'main-1.jpg', fileSize: 100, sortOrder: 0 }, + { id: 'main-2', assetType: 'main-preview', fileUrl: '/main-2.jpg', fileName: 'main-2.jpg', fileSize: 100, sortOrder: 1 }, + { id: 'main-3', assetType: 'main-preview', fileUrl: '/main-3.jpg', fileName: 'main-3.jpg', fileSize: 100, sortOrder: 2 }, + { id: 'sku-1', assetType: 'sku-preview', fileUrl: '/sku-1.jpg', fileName: 'sku-1.jpg', fileSize: 100, sortOrder: 0 }, + { id: 'detail-1', assetType: 'detail-preview', fileUrl: '/detail-1.jpg', fileName: 'detail-1.jpg', fileSize: 100, sortOrder: 0 }, + { id: 'detail-2', assetType: 'detail-preview', fileUrl: '/detail-2.jpg', fileName: 'detail-2.jpg', fileSize: 100, sortOrder: 1 }, + ], +}) + +jest.mock('@gsap/react', () => ({ + useGSAP: jest.fn(), +})) + +jest.mock('gsap', () => ({ + registerPlugin: jest.fn(), + matchMedia: () => ({ + add: jest.fn(), + revert: jest.fn(), + }), + fromTo: jest.fn(), +})) + jest.mock('next-auth/react', () => ({ useSession: () => ({ data: null, status: 'unauthenticated' }), })) @@ -43,8 +96,75 @@ jest.mock('@/components/Header', () => ({ default: () =>
, })) +jest.mock('@/components/VideoPlayerModal', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/SkuList', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/SkuEditModal', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/AudioVisualizer', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/DocumentAssetViewer', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/detail/MaterialDetailHeader', () => ({ + __esModule: true, + default: ({ title }: { title: string }) =>
{title}
, +})) + +jest.mock('@/components/detail/TaskPageDetail', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/detail/ThreeDOriginalDetail', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/detail/ProductPackageDetail', () => ({ + __esModule: true, + default: (props: any) => ( +
+ + + + + +
{(props.pendingAssetIds || []).join(',')}
+
+ ), +})) + jest.mock('@/hooks/useToast', () => ({ - useToast: () => ({ toast: null, showToast: jest.fn() }), + useToast: () => ({ toast: null, showToast: showToastMock }), })) jest.mock('@/components/ConfirmActionDialog', () => ({ @@ -54,6 +174,10 @@ jest.mock('@/components/ConfirmActionDialog', () => ({ }), })) +jest.mock('@/lib/chunk-upload', () => ({ + chunkUpload: (...args: unknown[]) => chunkUploadMock(...args), +})) + describe('video material share page', () => { beforeEach(() => { jest.clearAllMocks() @@ -110,3 +234,206 @@ describe('video material share page', () => { expect(await screen.findByRole('button', { name: '重新检查' })).toBeEnabled() }) }) + +describe('task detail share page change requests', () => { + beforeEach(() => { + jest.clearAllMocks() + showToastMock.mockReset() + chunkUploadMock.mockReset() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('serializes all operation kinds while keeping each request inside one image group', async () => { + const user = userEvent.setup() + chunkUploadMock + .mockResolvedValueOnce({ uploadId: 'upload-replace-1' }) + .mockResolvedValueOnce({ uploadId: 'upload-append-1' }) + .mockResolvedValueOnce({ uploadId: 'upload-append-2' }) + .mockResolvedValueOnce({ uploadId: 'upload-insert-1' }) + + const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url === '/api/materials/share?id=video-material-1') { + return createTaskDetailMaterialResponse() + } + if (url === '/api/materials/material-1/assets/change-requests' && init?.method === 'POST') { + return createJsonResponse({ success: true, mode: 'applied' }) + } + if (url === '/api/materials/material-1/assets/change-requests') { + return createJsonResponse({ success: true, data: [] }) + } + return createJsonResponse({ success: true }) + }) + global.fetch = fetchMock as jest.Mock + + render() + expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '触发替换' })) + await user.upload( + screen.getByTestId('task-detail-replace-input'), + new File(['replace'], 'replace-main.jpg', { type: 'image/jpeg' }), + ) + + await user.click(screen.getByRole('button', { name: '触发重命名' })) + const renameInput = await screen.findByRole('textbox', { name: '新文件名' }) + await user.clear(renameInput) + await user.type(renameInput, 'detail-2-renamed') + await user.click(screen.getByRole('button', { name: '加入待提交清单' })) + + await user.click(screen.getByRole('button', { name: '触发删除' })) + + await user.click(screen.getByRole('button', { name: '触发追加主图' })) + await user.upload( + screen.getByTestId('task-detail-batch-input'), + [ + new File(['append-1'], 'append-main-1.jpg', { type: 'image/jpeg' }), + new File(['append-2'], 'append-main-2.jpg', { type: 'image/jpeg' }), + ], + ) + + await user.click(await screen.findByRole('button', { name: '提交主管确认 5 项' })) + + await user.click(screen.getByRole('button', { name: '触发插入详情' })) + await user.upload( + screen.getByTestId('task-detail-batch-input'), + new File(['insert'], 'insert-detail.jpg', { type: 'image/jpeg' }), + ) + await user.click(await screen.findByRole('button', { name: '提交主管确认 1 项' })) + + const requestCalls = fetchMock.mock.calls.filter( + ([url, init]) => String(url) === '/api/materials/material-1/assets/change-requests' + && init?.method === 'POST', + ) + expect(requestCalls).toHaveLength(2) + expect(requestCalls[0]?.[1]).toMatchObject({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + const mainPayload = JSON.parse(String((requestCalls[0]?.[1] as RequestInit).body)) + expect(mainPayload).toEqual({ + operations: [ + { kind: 'replace', assetId: 'main-1', uploadId: 'upload-replace-1' }, + { kind: 'rename', assetId: 'main-2', newFileName: 'detail-2-renamed' }, + { kind: 'delete', assetId: 'main-3' }, + { kind: 'append', group: 'main', uploadId: 'upload-append-1' }, + { kind: 'append', group: 'main', uploadId: 'upload-append-2' }, + ], + }) + const detailPayload = JSON.parse(String((requestCalls[1]?.[1] as RequestInit).body)) + expect(detailPayload).toEqual({ + operations: [ + { + kind: 'insert', + group: 'detail', + uploadId: 'upload-insert-1', + afterAssetId: 'detail-1', + beforeAssetId: 'detail-2', + }, + ], + }) + + await waitFor(() => { + expect(showToastMock).toHaveBeenCalledWith( + '已直接应用 1 项变更,下载包已同步更新', + { variant: 'success' }, + ) + }) + }) + + test('shows pending approval details when server returns pending_approval mode', async () => { + const user = userEvent.setup() + const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url === '/api/materials/share?id=video-material-1') { + return createTaskDetailMaterialResponse() + } + if (url === '/api/materials/material-1/assets/change-requests' && init?.method === 'POST') { + return createJsonResponse({ + success: true, + mode: 'pending_approval', + request: { + id: 'request-1', + status: 'pending_manager_approval', + managerName: '王主管', + expiresAt: '2026-09-01T12:00:00.000Z', + }, + }) + } + if (url === '/api/materials/material-1/assets/change-requests') { + return createJsonResponse({ success: true, data: [] }) + } + return createJsonResponse({ success: true }) + }) + global.fetch = fetchMock as jest.Mock + + render() + expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '触发删除' })) + await user.click(await screen.findByRole('button', { name: '提交主管确认 1 项' })) + + expect(await screen.findByText('变更已提交主管确认')).toBeInTheDocument() + expect(screen.getByText('经理:王主管')).toBeInTheDocument() + expect(screen.getByText('到期:2026-09-01 20:00')).toBeInTheDocument() + expect(screen.getByText('待主管确认')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '查看审批详情' })) + expect(mockPush).toHaveBeenCalledWith('/task-detail-change-requests/request-1') + }) + + test('blocks switching image groups before opening another upload picker', async () => { + const user = userEvent.setup() + global.fetch = jest.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url === '/api/materials/share?id=video-material-1') return createTaskDetailMaterialResponse() + if (url === '/api/materials/material-1/assets/change-requests') { + return createJsonResponse({ success: true, data: [] }) + } + return createJsonResponse({ success: true }) + }) as jest.Mock + + render() + expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '触发删除' })) + await user.click(screen.getByRole('button', { name: '触发插入详情' })) + + expect(showToastMock).toHaveBeenCalledWith( + '待提交清单已有主图变更,请先提交或清空后再编辑详情图', + { variant: 'error' }, + ) + }) + + test('restores the latest pending manager approval after refresh', async () => { + const fetchMock = jest.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url === '/api/materials/share?id=video-material-1') { + return createTaskDetailMaterialResponse() + } + if (url === '/api/materials/material-1/assets/change-requests') { + return createJsonResponse({ + success: true, + data: [{ + id: 'request-restored', + status: 'pending_manager_approval', + managerName: '赵主管', + expiresAt: '2026-09-02T12:00:00.000Z', + }], + }) + } + return createJsonResponse({ success: true }) + }) + global.fetch = fetchMock as jest.Mock + + render() + + expect(await screen.findByText('变更已提交主管确认')).toBeInTheDocument() + expect(screen.getByText('经理:赵主管')).toBeInTheDocument() + expect(screen.getByText('待主管确认')).toBeInTheDocument() + }) +}) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index c1893ab..e9970bb 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -90,6 +90,62 @@ interface Material { }> } +type TaskDetailAppendGroup = 'main' | 'sku' + +type TaskDetailInsertIntent = { + kind: 'insert' + group: 'detail' + afterAssetId: string + beforeAssetId: string +} + +type TaskDetailBatchIntent = + | { kind: 'append'; group: TaskDetailAppendGroup } + | TaskDetailInsertIntent + +type TaskDetailPendingOpInput = + | { + kind: 'append' + group: TaskDetailAppendGroup + label: string + uploadId: string + } + | { + kind: 'insert' + group: 'detail' + label: string + uploadId: string + afterAssetId: string + beforeAssetId: string + } + | { + kind: 'replace' + assetId: string + label: string + uploadId: string + } + | { + kind: 'rename' + assetId: string + label: string + newFileName: string + } + | { + kind: 'delete' + assetId: string + label: string + } + +type TaskDetailPendingOp = TaskDetailPendingOpInput & { key: string } + +type TaskDetailChangeRequestNotice = { + requestId: string | null + mode: 'applied' | 'pending_approval' + status: string | null + managerName: string | null + expiresAt: string | null +} + interface VideoShareWaitState { message: string availableAt: string | null @@ -98,6 +154,164 @@ interface VideoShareWaitState { const VIDEO_SHARE_PROTECTION_DAYS = 15 +const TASK_DETAIL_GROUP_LABELS: Record<'main' | 'sku' | 'detail', string> = { + main: '主图', + sku: 'SKU 图', + detail: '详情图', +} + +const TASK_DETAIL_CHANGE_REQUEST_STATUS_META: Record = { + pending_approval: { + label: '待主管确认', + className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]', + }, + pending_manager_approval: { + label: '待主管确认', + className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]', + }, + applying: { + label: '正在生效', + className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]', + }, + approved: { + label: '已通过', + className: 'border-[#b9ead0] bg-[#ecfdf3] text-[#087a3c]', + }, + rejected: { + label: '已否决', + className: 'border-[#f3d29b] bg-[#fff8e8] text-[#8a5200]', + }, + expired: { + label: '已超时', + className: 'border-[#e2e8f0] bg-[#f8fafc] text-[#66758b]', + }, + apply_failed: { + label: '应用失败', + className: 'border-[#f5b9b9] bg-[#fff1f1] text-[#b42318]', + }, +} + +function readNestedText(record: Record, key: string): string | null { + const direct = record[key] + if (typeof direct === 'string' && direct.trim()) return direct.trim() + const nested = record[key] as Record | null + if (nested && typeof nested === 'object') { + const name = nested.name + if (typeof name === 'string' && name.trim()) return name.trim() + } + return null +} + +function getTaskDetailChangeRequestStatusMeta(status: unknown) { + const raw = String(status || '').trim().toLowerCase() + if (!raw) return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.pending_approval + if (raw === 'approved' || raw === 'applied') return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.approved + if (raw === 'rejected' || raw === 'reject') return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.rejected + if (raw === 'expired' || raw === 'timeout' || raw === 'timed_out') return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.expired + return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.pending_approval +} + +function formatTaskDetailDeadline(value: string | null) { + if (!value) return '未提供' + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + return format(parsed, 'yyyy-MM-dd HH:mm') +} + +function buildTaskDetailChangeRequestNotice(payload: unknown): TaskDetailChangeRequestNotice | null { + if (!payload || typeof payload !== 'object') return null + + const root = payload as Record + const candidate = + (root.data as Record | undefined)?.changeRequest + || root.changeRequest + || root.request + || root.data + || root + + if (!candidate || typeof candidate !== 'object') return null + const record = candidate as Record + const status = String(record.status || record.reviewStatus || '').trim().toLowerCase() + const mode = ( + String(root.mode || record.mode || '').trim() === 'pending_approval' + || status === 'pending_manager_approval' + || status === 'applying' + ) + ? 'pending_approval' + : 'applied' + const requestId = typeof record.id === 'string' + ? record.id + : typeof record.requestId === 'string' + ? record.requestId + : null + + return { + requestId, + mode, + status: + status + ? status + : mode === 'pending_approval' + ? 'pending_approval' + : null, + managerName: + readNestedText(record, 'manager') + || readNestedText(record, 'approver') + || readNestedText(record, 'reviewer') + || (typeof record.managerName === 'string' ? record.managerName : null) + || (typeof record.approverName === 'string' ? record.approverName : null) + || (typeof record.reviewerName === 'string' ? record.reviewerName : null), + expiresAt: + typeof record.expiresAt === 'string' + ? record.expiresAt + : typeof record.deadlineAt === 'string' + ? record.deadlineAt + : typeof record.expireAt === 'string' + ? record.expireAt + : null, + } +} + +function TaskDetailChangeRequestBanner({ + notice, + onOpenDetail, +}: { + notice: TaskDetailChangeRequestNotice + onOpenDetail: () => void +}) { + const statusMeta = getTaskDetailChangeRequestStatusMeta(notice.status) + + return ( +
+
+
+
+

+ {notice.mode === 'pending_approval' ? '变更已提交主管确认' : '变更已生效'} +

+ + {statusMeta.label} + +
+
+ 经理:{notice.managerName || '待分配'} + 到期:{formatTaskDetailDeadline(notice.expiresAt)} +
+
+ {notice.requestId && ( + + )} +
+
+ ) +} + gsap.registerPlugin(useGSAP) function parseVideoShareWaitState(value: unknown): VideoShareWaitState | null { @@ -315,9 +529,14 @@ export default function SharePage() { // 任务-详情单图编辑状态 const [taskDetailEditingAssetId, setTaskDetailEditingAssetId] = useState(null) - const [replaceUploadProgress, setReplaceUploadProgress] = useState(null) + const [taskDetailUploadProgress, setTaskDetailUploadProgress] = useState<{ + label: string + progress: number + } | null>(null) const replaceInputRef = useRef(null) + const taskDetailBatchInputRef = useRef(null) const replaceTargetAssetIdRef = useRef(null) + const taskDetailBatchIntentRef = useRef(null) const [renameDialogState, setRenameDialogState] = useState<{ assetId: string currentName: string @@ -325,18 +544,12 @@ export default function SharePage() { extension: string value: string error: string | null - saving: boolean - } | null>(null) - // 批量编辑攒批清单:用户的一组删除/改名/替换操作先攒在本地,点「应用」一次性提交 - const [taskDetailPendingOps, setTaskDetailPendingOps] = useState>([]) + saving: boolean + } | null>(null) + // 批量编辑攒批清单:先在前端整理一组变更,再统一提交给主管确认或直接应用。 + const [taskDetailPendingOps, setTaskDetailPendingOps] = useState([]) const [taskDetailApplying, setTaskDetailApplying] = useState(false) + const [taskDetailChangeRequestNotice, setTaskDetailChangeRequestNotice] = useState(null) const taskDetailPendingBarRef = useRef(null) // 应用变更的长等待动效:清单项逐项扫描推进 + 按钮呼吸,形成"正在逐项处理"的节奏感。 @@ -412,12 +625,34 @@ export default function SharePage() { useEffect(() => { // 分享页面允许未登录访问,立即开始加载,不等待session状态 // 使用setTimeout确保在页面渲染后立即开始加载 + setTaskDetailPendingOps([]) + setTaskDetailChangeRequestNotice(null) const timer = setTimeout(() => { fetchMaterial() }, 0) return () => clearTimeout(timer) }, [materialId]) + useEffect(() => { + if (!material?.id || !material.canManageTaskDetailAssets) return + let cancelled = false + void fetch(`/api/materials/${material.id}/assets/change-requests`, { cache: 'no-store' }) + .then(async (response) => { + if (!response.ok) return null + const payload = await response.json().catch(() => null) + const rows = Array.isArray(payload?.data) ? payload.data : [] + return rows[0] || null + }) + .then((row) => { + if (cancelled || !row) return + setTaskDetailChangeRequestNotice(buildTaskDetailChangeRequestNotice(row)) + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, [material?.id, material?.canManageTaskDetailAssets]) + useEffect(() => { // 监听滚动,控制返回顶部按钮显示 const handleScroll = () => { @@ -939,7 +1174,39 @@ export default function SharePage() { // ---- 任务-详情单图编辑 ---- + const getTaskDetailAssetGroup = (assetId: string): 'main' | 'sku' | 'detail' | null => { + const assetType = material?.assets?.find((asset) => asset.id === assetId)?.assetType + if (assetType === 'main-preview') return 'main' + if (assetType === 'sku-preview') return 'sku' + if (assetType === 'detail-preview') return 'detail' + return null + } + + const getTaskDetailPendingOpGroup = (op: TaskDetailPendingOpInput | TaskDetailPendingOp) => { + if (op.kind === 'append') return op.group + if (op.kind === 'insert') return 'detail' as const + return getTaskDetailAssetGroup(op.assetId) + } + + const canStartTaskDetailGroup = (group: 'main' | 'sku' | 'detail' | null) => { + if (!group) { + showToast('图片分组识别失败,请刷新后重试', { variant: 'error' }) + return false + } + const currentGroup = taskDetailPendingOps[0] + ? getTaskDetailPendingOpGroup(taskDetailPendingOps[0]) + : null + if (currentGroup && currentGroup !== group) { + showToast(`待提交清单已有${TASK_DETAIL_GROUP_LABELS[currentGroup]}变更,请先提交或清空后再编辑${TASK_DETAIL_GROUP_LABELS[group]}`, { + variant: 'error', + }) + return false + } + return true + } + const handleTaskDetailReplaceRequest = (asset: { id: string }) => { + if (!canStartTaskDetailGroup(getTaskDetailAssetGroup(asset.id))) return replaceTargetAssetIdRef.current = asset.id const input = replaceInputRef.current if (input) { @@ -948,6 +1215,129 @@ export default function SharePage() { } } + const queueTaskDetailPendingOp = ( + nextOp: TaskDetailPendingOpInput, + ) => { + if (!canStartTaskDetailGroup(getTaskDetailPendingOpGroup(nextOp))) return false + setTaskDetailPendingOps((prev) => { + const withKey: TaskDetailPendingOp = { + ...nextOp, + key: `task-detail-op-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + } as TaskDetailPendingOp + + if ('assetId' in nextOp) { + return [ + ...prev.filter((item) => !('assetId' in item) || item.assetId !== nextOp.assetId), + withKey, + ] + } + + return [...prev, withKey] + }) + return true + } + + const openTaskDetailAppendPicker = (group: TaskDetailAppendGroup) => { + if (!canStartTaskDetailGroup(group)) return + const input = taskDetailBatchInputRef.current + taskDetailBatchIntentRef.current = { kind: 'append', group } + if (input) { + input.value = '' + input.click() + } + } + + const openTaskDetailInsertPicker = (intent: Omit) => { + if (!canStartTaskDetailGroup('detail')) return + const input = taskDetailBatchInputRef.current + taskDetailBatchIntentRef.current = { kind: 'insert', ...intent } + if (input) { + input.value = '' + input.click() + } + } + + const queueTaskDetailUploads = async (files: File[], intent: TaskDetailBatchIntent) => { + if (!material || files.length === 0) return + + const uploadLabel = intent.kind === 'append' + ? `${TASK_DETAIL_GROUP_LABELS[intent.group]}追加图片` + : '详情图插入图片' + + setTaskDetailEditingAssetId( + intent.kind === 'append' + ? `append-${intent.group}` + : `insert-${intent.afterAssetId}-${intent.beforeAssetId}`, + ) + + let queuedCount = 0 + let failedCount = 0 + + try { + for (let index = 0; index < files.length; index += 1) { + const file = files[index] + if (file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) { + failedCount += 1 + showToast( + `${file.name} 超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB,已跳过`, + { variant: 'error' }, + ) + continue + } + + try { + const result = await chunkUpload(file, {}, { + skipComplete: true, + onProgress: (progress) => setTaskDetailUploadProgress({ + label: `正在上传${uploadLabel} ${index + 1}/${files.length}`, + progress, + }), + }) + const uploadId = (result as { uploadId?: string } | null)?.uploadId + if (!uploadId) { + throw new Error('上传未完成,请重试') + } + + if (intent.kind === 'append') { + if (!queueTaskDetailPendingOp({ + kind: 'append', + group: intent.group, + uploadId, + label: `追加到${TASK_DETAIL_GROUP_LABELS[intent.group]}末尾:${file.name}`, + })) continue + } else { + if (!queueTaskDetailPendingOp({ + kind: 'insert', + group: 'detail', + uploadId, + afterAssetId: intent.afterAssetId, + beforeAssetId: intent.beforeAssetId, + label: `插入到详情图之间:${file.name}`, + })) continue + } + + queuedCount += 1 + } catch (error) { + failedCount += 1 + showToast(error instanceof Error ? error.message : `${file.name} 上传失败,请稍后重试`, { + variant: 'error', + }) + } + } + + if (queuedCount > 0) { + const successMessage = failedCount > 0 + ? `已加入 ${queuedCount} 张,另有 ${failedCount} 张未进入待提交清单` + : `${uploadLabel}已加入待提交清单` + showToast(successMessage, { variant: 'success' }) + } + } finally { + setTaskDetailEditingAssetId(null) + setTaskDetailUploadProgress(null) + taskDetailBatchIntentRef.current = null + } + } + const uploadAndReplaceTaskDetailImage = async (file: File) => { const targetAssetId = replaceTargetAssetIdRef.current if (!targetAssetId || !material) return @@ -962,34 +1352,40 @@ export default function SharePage() { } setTaskDetailEditingAssetId(targetAssetId) - setReplaceUploadProgress(0) + setTaskDetailUploadProgress({ label: '正在上传替换图片', progress: 0 }) try { const result = await chunkUpload(file, {}, { skipComplete: true, - onProgress: (progress) => setReplaceUploadProgress(progress), + onProgress: (progress) => setTaskDetailUploadProgress({ + label: '正在上传替换图片', + progress, + }), }) const uploadId = (result as { uploadId?: string } | null)?.uploadId if (!uploadId) { throw new Error('上传未完成,请重试') } - queueTaskDetailPendingOp({ + const queued = queueTaskDetailPendingOp({ assetId: targetAssetId, kind: 'replace', label: `替换为 ${file.name}`, uploadId, }) - showToast('替换已加入待应用清单,全部操作完后点「应用变更」', { variant: 'success' }) + if (queued) { + showToast('替换已加入待提交清单,全部操作完后点「提交主管确认」', { variant: 'success' }) + } } catch (error) { showToast(error instanceof Error ? error.message : '替换失败,请稍后重试', { variant: 'error' }) } finally { setTaskDetailEditingAssetId(null) - setReplaceUploadProgress(null) + setTaskDetailUploadProgress(null) replaceTargetAssetIdRef.current = null } } const openTaskDetailRenameDialog = (asset: { id: string; fileName?: string }) => { + if (!canStartTaskDetailGroup(getTaskDetailAssetGroup(asset.id))) return const currentName = asset.fileName || '' // 预览行文件名不是包内条目名:剥掉 .webp 与历史「源名-preview」尾巴, // 扩展名由服务端按包内条目自动保留,前端不预设(预设 .webp 会误触扩展名校验)。 @@ -1022,66 +1418,93 @@ export default function SharePage() { return } - queueTaskDetailPendingOp({ + const queued = queueTaskDetailPendingOp({ assetId: renameDialogState.assetId, kind: 'rename', label: `重命名为 ${nextBaseName}${renameDialogState.extension}`, newFileName: nextBaseName, }) - showToast('重命名已加入待应用清单', { variant: 'success' }) - setRenameDialogState(null) + if (queued) { + showToast('重命名已加入待提交清单', { variant: 'success' }) + setRenameDialogState(null) + } } const handleTaskDetailDeleteRequest = async (asset: { id: string; fileName?: string }) => { if (!material) return - // 删除先进待应用清单(可撤销),与其他操作一起批量提交 - queueTaskDetailPendingOp({ + // 删除先进待提交清单(可撤销),与其他操作一起统一提交 + const queued = queueTaskDetailPendingOp({ assetId: asset.id, kind: 'delete', label: `删除 ${asset.fileName || '未命名图片'}`, }) - showToast('删除已加入待应用清单,撤销请点清单中的 ×', { variant: 'success' }) - } - - // 同一张图的新操作覆盖旧操作;清单按加入顺序提交 - const queueTaskDetailPendingOp = (op: { - assetId: string - kind: 'delete' | 'rename' | 'replace' - label: string - uploadId?: string - newFileName?: string - }) => { - setTaskDetailPendingOps((prev) => [ - ...prev.filter((item) => item.assetId !== op.assetId), - { ...op, key: `${op.assetId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` }, - ]) + if (queued) { + showToast('删除已加入待提交清单,撤销请点清单中的 ×', { variant: 'success' }) + } } const applyTaskDetailPendingOps = async () => { if (!material || taskDetailPendingOps.length === 0 || taskDetailApplying) return setTaskDetailApplying(true) try { - const response = await fetch(`/api/materials/${material.id}/assets/batch`, { + const response = await fetch(`/api/materials/${material.id}/assets/change-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - operations: taskDetailPendingOps.map((op) => ({ - kind: op.kind, - assetId: op.assetId, - ...(op.kind === 'replace' ? { uploadId: op.uploadId } : {}), - ...(op.kind === 'rename' ? { newFileName: op.newFileName } : {}), - })), + operations: taskDetailPendingOps.map((op) => { + switch (op.kind) { + case 'append': + return { kind: 'append', group: op.group, uploadId: op.uploadId } + case 'insert': + return { + kind: 'insert', + group: op.group, + uploadId: op.uploadId, + afterAssetId: op.afterAssetId, + beforeAssetId: op.beforeAssetId, + } + case 'replace': + return { kind: 'replace', assetId: op.assetId, uploadId: op.uploadId } + case 'rename': + return { kind: 'rename', assetId: op.assetId, newFileName: op.newFileName } + case 'delete': + return { kind: 'delete', assetId: op.assetId } + } + }), }), }) const data = await response.json().catch(() => null) if (!response.ok || !data?.success) { - throw new Error(data?.error || '应用变更失败,请稍后重试') + throw new Error(data?.error || '提交变更失败,请稍后重试') } - showToast(`已应用 ${taskDetailPendingOps.length} 项变更,下载包已同步更新`, { variant: 'success' }) + + const mode = String(data?.mode || data?.data?.mode || 'applied') === 'pending_approval' + ? 'pending_approval' + : 'applied' + + if (mode === 'pending_approval') { + const notice = buildTaskDetailChangeRequestNotice({ ...data, mode }) || { + requestId: null, + mode: 'pending_approval' as const, + status: 'pending_approval', + managerName: null, + expiresAt: null, + } + setTaskDetailChangeRequestNotice(notice) + showToast(`已提交 ${taskDetailPendingOps.length} 项变更,等待${notice.managerName || '主管'}确认`, { + variant: 'success', + }) + } else { + setTaskDetailChangeRequestNotice(null) + showToast(`已直接应用 ${taskDetailPendingOps.length} 项变更,下载包已同步更新`, { + variant: 'success', + }) + await fetchMaterial() + } + setTaskDetailPendingOps([]) - await fetchMaterial() } catch (error) { - showToast(error instanceof Error ? error.message : '应用变更失败,请稍后重试', { variant: 'error' }) + showToast(error instanceof Error ? error.message : '提交变更失败,请稍后重试', { variant: 'error' }) } finally { setTaskDetailApplying(false) } @@ -1172,13 +1595,28 @@ export default function SharePage() { {/* 任务-详情类型:使用专用详情组件 */} {mappedMaterialType === '任务-详情' ? (
+ {taskDetailChangeRequestNotice && ( + { + if (taskDetailChangeRequestNotice.requestId) { + router.push(`/task-detail-change-requests/${taskDetailChangeRequestNotice.requestId}`) + } + }} + /> + )} {Boolean(material.canManageTaskDetailAssets) && taskDetailPendingOps.length > 0 && (
-
-

- 待应用变更 {taskDetailPendingOps.length} 项 -

-
+
+
+

+ 待提交变更 {taskDetailPendingOps.length} 项 +

+

+ 替换和插入文件先留在临时区,点击提交后再进入主管确认,不会立即覆盖正式素材。 +

+
+
@@ -1207,7 +1645,15 @@ export default function SharePage() { ? 'mr-2 text-[#b42318]' : 'mr-2 text-[#6b7075]' }> - {op.kind === 'delete' ? '删除' : op.kind === 'rename' ? '改名' : '替换'} + {op.kind === 'delete' + ? '删除' + : op.kind === 'rename' + ? '改名' + : op.kind === 'replace' + ? '替换' + : op.kind === 'append' + ? '追加' + : '插入'} {op.label} @@ -1242,10 +1688,12 @@ export default function SharePage() { showToast={showToast} canManageAssets={Boolean(material.canManageTaskDetailAssets)} editingAssetId={taskDetailEditingAssetId} - pendingAssetIds={taskDetailPendingOps.map((op) => op.assetId)} + pendingAssetIds={taskDetailPendingOps.flatMap((op) => ('assetId' in op ? [op.assetId] : []))} onRequestReplace={handleTaskDetailReplaceRequest} onRequestRename={openTaskDetailRenameDialog} onRequestDelete={(asset) => void handleTaskDetailDeleteRequest(asset)} + onRequestAppend={(group) => openTaskDetailAppendPicker(group)} + onRequestInsert={(position) => openTaskDetailInsertPicker(position)} onDownload={async (type, assetId) => { try { let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}` @@ -1810,6 +2258,7 @@ export default function SharePage() { type="file" accept="image/*" className="hidden" + data-testid="task-detail-replace-input" onChange={(event) => { const file = event.target.files?.[0] if (file) { @@ -1819,6 +2268,23 @@ export default function SharePage() { }} /> + { + const files = Array.from(event.target.files || []) + const intent = taskDetailBatchIntentRef.current + if (intent && files.length > 0) { + void queueTaskDetailUploads(files, intent) + } + event.target.value = '' + }} + /> + {/* 任务-详情:重命名弹窗 */} {renameDialogState && (

重命名图片

- 只修改文件名,扩展名保持不变;保存后会重新打包下载文件。 + 只修改文件名,扩展名保持不变;提交后会进入待确认清单,不会立刻覆盖正式素材。

- 保存后自动沿用原扩展名 + 提交后自动沿用原扩展名
{renameDialogState.error && ( @@ -1878,7 +2344,7 @@ export default function SharePage() { onClick={() => void submitTaskDetailRename()} className="inline-flex h-9 items-center rounded-[8px] bg-[#1a1c1d] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#2a2d2f] disabled:opacity-50" > - {renameDialogState.saving ? '正在保存…' : '保存并重打包'} + {renameDialogState.saving ? '正在加入…' : '加入待提交清单'}
@@ -1887,8 +2353,8 @@ export default function SharePage() { {/* 统一的Toast提示 */} {toast && toast.show && ( - )} diff --git a/app/task-detail-change-requests/[id]/page.test.tsx b/app/task-detail-change-requests/[id]/page.test.tsx new file mode 100644 index 0000000..c721e26 --- /dev/null +++ b/app/task-detail-change-requests/[id]/page.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import TaskDetailChangeRequestPage from './page' + +const originalFetch = global.fetch + +const createJsonResponse = (body: T, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}) + +jest.mock('next/navigation', () => ({ + useParams: () => ({ id: 'request-1' }), +})) + +describe('TaskDetailChangeRequestPage', () => { + afterAll(() => { + global.fetch = originalFetch + }) + + test('loads request details and allows approver to approve', async () => { + const user = userEvent.setup() + let approved = false + const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url === '/api/task-detail-change-requests/request-1') { + return createJsonResponse({ + success: true, + data: { + id: 'request-1', + status: approved ? 'applied' : 'pending_manager_approval', + canDecide: !approved, + managerName: '王主管', + requesterName: '小李', + expiresAt: '2026-09-01T12:00:00.000Z', + decidedAt: approved ? '2026-08-28T10:30:00.000Z' : null, + material: { + productModel: 'GBSFY-SJZ0024详情', + materialType: '任务-详情', + taskNo: 'TASK-001', + }, + operations: [ + { kind: 'append', group: 'main', fileName: 'append-main-1.jpg' }, + { kind: 'insert', group: 'detail', afterAssetId: 'a1', beforeAssetId: 'a2', fileName: 'insert-detail.jpg' }, + ], + }, + }) + } + + if (url === '/api/task-detail-change-requests/request-1/decision') { + expect(init).toMatchObject({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision: 'approve' }), + }) + + approved = true + return createJsonResponse({ success: true, data: { status: 'applied' } }) + } + + return createJsonResponse({ error: 'unexpected request' }, 500) + }) + global.fetch = fetchMock as jest.Mock + + render() + + expect(await screen.findByRole('heading', { name: '审批详情' })).toBeInTheDocument() + expect(await screen.findByText('GBSFY-SJZ0024详情')).toBeInTheDocument() + expect(screen.getByText('小李')).toBeInTheDocument() + expect(screen.getAllByText('待主管确认').length).toBeGreaterThan(0) + expect(screen.getByText('追加到主图末尾')).toBeInTheDocument() + expect(screen.getByText('插入到详情图接缝(a1 → a2)')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '批准' })) + + await waitFor(() => { + expect(screen.getAllByText('已通过').length).toBeGreaterThan(0) + }) + expect(screen.getByText('处理时间')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '否决' })).not.toBeInTheDocument() + }) +}) diff --git a/app/task-detail-change-requests/[id]/page.tsx b/app/task-detail-change-requests/[id]/page.tsx new file mode 100644 index 0000000..2e5d8d8 --- /dev/null +++ b/app/task-detail-change-requests/[id]/page.tsx @@ -0,0 +1,393 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import { format } from 'date-fns' + +type TaskDetailChangeRequestDecision = 'approve' | 'reject' + +type TaskDetailChangeRequestStatus = 'pending_approval' | 'approved' | 'rejected' | 'expired' | 'apply_failed' + +type TaskDetailChangeRequestOperation = { + kind: string + label?: string | null + assetId?: string | null + group?: 'main' | 'sku' | 'detail' | null + afterAssetId?: string | null + beforeAssetId?: string | null + newFileName?: string | null +} + +type TaskDetailChangeRequestViewModel = { + id: string + status: TaskDetailChangeRequestStatus + canDecide: boolean + managerName: string | null + requesterName: string | null + requesterHandle: string | null + materialTitle: string + materialType: string | null + taskNo: string | null + expiresAt: string | null + decidedAt: string | null + operations: TaskDetailChangeRequestOperation[] +} + +const STATUS_META: Record = { + pending_approval: { + label: '待主管确认', + className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]', + }, + approved: { + label: '已通过', + className: 'border-[#b9ead0] bg-[#ecfdf3] text-[#087a3c]', + }, + rejected: { + label: '已否决', + className: 'border-[#f3d29b] bg-[#fff8e8] text-[#8a5200]', + }, + expired: { + label: '已超时', + className: 'border-[#e2e8f0] bg-[#f8fafc] text-[#66758b]', + }, + apply_failed: { + label: '应用失败', + className: 'border-[#f5b9b9] bg-[#fff1f1] text-[#b42318]', + }, +} + +const GROUP_LABELS: Record<'main' | 'sku' | 'detail', string> = { + main: '主图', + sku: 'SKU 图', + detail: '详情图', +} + +function readText(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function readNestedName(record: Record, key: string) { + const direct = readText(record[key]) + if (direct) return direct + const nested = record[key] as Record | null + if (nested && typeof nested === 'object') { + return readText(nested.name) || readText(nested.displayName) + } + return null +} + +function normalizeStatus(value: unknown): TaskDetailChangeRequestStatus { + const raw = String(value || '').trim().toLowerCase() + if (raw === 'approved' || raw === 'approve' || raw === 'applied') return 'approved' + if (raw === 'rejected' || raw === 'reject') return 'rejected' + if (raw === 'expired' || raw === 'timeout' || raw === 'timed_out') return 'expired' + if (raw === 'apply_failed') return 'apply_failed' + return 'pending_approval' +} + +function formatDateTime(value: string | null) { + if (!value) return '未提供' + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + return format(parsed, 'yyyy-MM-dd HH:mm') +} + +function normalizeChangeRequestPayload(payload: unknown): TaskDetailChangeRequestViewModel | null { + if (!payload || typeof payload !== 'object') return null + + const root = payload as Record + const candidate = + (root.data as Record | undefined)?.changeRequest + || root.changeRequest + || root.data + || root + + if (!candidate || typeof candidate !== 'object') return null + + const record = candidate as Record + const material = (record.material as Record | undefined) || {} + const requester = (record.requester as Record | undefined) + || (record.submittedBy as Record | undefined) + || {} + const operations = Array.isArray(record.operations) + ? record.operations.map((item) => { + const op = (item || {}) as Record + return { + kind: String(op.kind || ''), + label: readText(op.label), + assetId: readText(op.assetId), + group: (readText(op.group) as 'main' | 'sku' | 'detail' | null) || null, + afterAssetId: readText(op.afterAssetId), + beforeAssetId: readText(op.beforeAssetId), + newFileName: readText(op.newFileName), + } + }) + : [] + + return { + id: readText(record.id) || readText(record.requestId) || 'unknown-request', + status: normalizeStatus(record.status || record.reviewStatus || root.status), + canDecide: Boolean(record.canDecide), + managerName: + readNestedName(record, 'manager') + || readNestedName(record, 'approver') + || readNestedName(record, 'reviewer') + || readText(record.managerName) + || readText(record.approverName) + || readText(record.reviewerName), + requesterName: + readNestedName(requester, 'profile') + || readText(requester.displayName) + || readText(requester.realName) + || readText(requester.name) + || readText(record.requesterName), + requesterHandle: readText(requester.username) || readText(requester.handle), + materialTitle: + readText(material.title) + || readText(material.displayTitle) + || readText(material.productModel) + || readText(record.materialTitle) + || '未命名素材', + materialType: readText(material.materialType) || readText(record.materialType), + taskNo: readText(material.taskNo) || readText(record.taskNo), + expiresAt: readText(record.expiresAt) || readText(record.deadlineAt) || readText(record.expireAt), + decidedAt: readText(record.decidedAt) || readText(record.reviewedAt), + operations, + } +} + +function describeOperation(op: TaskDetailChangeRequestOperation, index: number) { + if (op.label) return op.label + + if (op.kind === 'append') { + const groupLabel = GROUP_LABELS[op.group || 'main'] + return `追加到${groupLabel}末尾` + } + if (op.kind === 'insert') { + return `插入到详情图接缝(${op.afterAssetId || '前图'} → ${op.beforeAssetId || '后图'})` + } + if (op.kind === 'replace') { + return `替换图片 ${op.assetId || `#${index + 1}`}` + } + if (op.kind === 'rename') { + return `重命名为 ${op.newFileName || '新文件名'}` + } + if (op.kind === 'delete') { + return `删除图片 ${op.assetId || `#${index + 1}`}` + } + + return `操作 ${index + 1}` +} + +export default function TaskDetailChangeRequestPage() { + const params = useParams() + const requestId = params?.id as string + const [record, setRecord] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busyDecision, setBusyDecision] = useState(null) + + const loadRequest = useCallback(async () => { + try { + setLoading(true) + setError(null) + const response = await fetch(`/api/task-detail-change-requests/${requestId}`, { + cache: 'no-store', + }) + const payload = await response.json().catch(() => null) + if (!response.ok) { + throw new Error((payload as { error?: string } | null)?.error || '加载审批详情失败') + } + const next = normalizeChangeRequestPayload(payload) + if (!next) throw new Error('审批详情格式不正确') + setRecord(next) + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : '加载审批详情失败') + } finally { + setLoading(false) + } + }, [requestId]) + + useEffect(() => { + if (requestId) { + void loadRequest() + } + }, [requestId, loadRequest]) + + const submitDecision = async (decision: TaskDetailChangeRequestDecision) => { + if (!record || busyDecision) return + + try { + setBusyDecision(decision) + setError(null) + const response = await fetch(`/api/task-detail-change-requests/${requestId}/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision }), + }) + const payload = await response.json().catch(() => null) + if (!response.ok) { + throw new Error((payload as { error?: string } | null)?.error || '提交审批结果失败') + } + await loadRequest() + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : '提交审批结果失败') + } finally { + setBusyDecision(null) + } + } + + const statusMeta = STATUS_META[record?.status || 'pending_approval'] + + return ( +
+
+
+

任务详情变更

+
+

审批详情

+ {!loading && record && ( + + {statusMeta.label} + + )} +
+

+ 这里显示提交人、素材信息、操作清单和到期时间。主管确认前,正式素材不会被直接覆盖。 +

+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ 正在加载审批详情… +
+ ) : !record ? ( +
+ 未找到这条审批记录。 +
+ ) : ( +
+
+
+

审批摘要

+
+
+
素材
+
{record.materialTitle}
+
+
+
素材类型
+
{record.materialType || '未提供'}
+
+
+
任务号
+
{record.taskNo || '未提供'}
+
+
+
提交人
+
+ {record.requesterName || '未提供'} + {record.requesterHandle ? ( + @{record.requesterHandle} + ) : null} +
+
+
+
+ + +
+ +
+
+

操作清单

+ {record.operations.length} 项 +
+ {record.operations.length === 0 ? ( +

这条审批记录还没有操作项。

+ ) : ( +
    + {record.operations.map((op, index) => ( +
  1. +
    +
    +

    {describeOperation(op, index)}

    +

    + {op.kind === 'append' + ? '追加图片会在主管通过后追加到分组末尾' + : op.kind === 'insert' + ? '插入图片会按当前接缝位置进入待处理链路' + : op.kind === 'replace' + ? '替换图片先写入临时区,等待主管确认' + : op.kind === 'rename' + ? '仅修改文件名,不会改变扩展名' + : '删除会在主管通过后从正式素材中移除'} +

    +
    + + {op.kind} + +
    +
  2. + ))} +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/components/detail/ProductPackageDetail.test.tsx b/components/detail/ProductPackageDetail.test.tsx new file mode 100644 index 0000000..3d36be2 --- /dev/null +++ b/components/detail/ProductPackageDetail.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import ProductPackageDetail from './ProductPackageDetail' + +const material = { + id: 'material-1', + taskNo: 'TASK-001', + productModel: 'GBSFY-SJZ0024', + categoryName: '顾家', + sceneName: '客厅', + taskStatus: 'ready', + assets: [ + { id: 'main-1', assetType: 'main-preview', fileUrl: '/main-1.jpg', fileName: 'main-1.jpg', fileSize: 100, sortOrder: 0 }, + { id: 'main-2', assetType: 'main-preview', fileUrl: '/main-2.jpg', fileName: 'main-2.jpg', fileSize: 100, sortOrder: 1 }, + { id: 'sku-1', assetType: 'sku-preview', fileUrl: '/sku-1.jpg', fileName: 'sku-1.jpg', fileSize: 100, sortOrder: 0 }, + { id: 'sku-2', assetType: 'sku-preview', fileUrl: '/sku-2.jpg', fileName: 'sku-2.jpg', fileSize: 100, sortOrder: 1 }, + { id: 'detail-1', assetType: 'detail-preview', fileUrl: '/detail-1.jpg', fileName: 'detail-1.jpg', fileSize: 100, sortOrder: 0 }, + { id: 'detail-2', assetType: 'detail-preview', fileUrl: '/detail-2.jpg', fileName: 'detail-2.jpg', fileSize: 100, sortOrder: 1 }, + { id: 'detail-3', assetType: 'detail-preview', fileUrl: '/detail-3.jpg', fileName: 'detail-3.jpg', fileSize: 100, sortOrder: 2 }, + ], +} + +describe('ProductPackageDetail task-detail editing affordances', () => { + test('renders append and insert controls with exact positions', async () => { + const user = userEvent.setup() + const onRequestAppend = jest.fn() + const onRequestInsert = jest.fn() + + render( + , + ) + + expect(screen.getByRole('button', { name: '编辑图片 main-1.jpg' })).toHaveClass('opacity-100') + expect(screen.getByRole('button', { name: '追加图片,加入主图末尾' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '追加图片,加入 SKU 末尾' })).toBeInTheDocument() + + const insertButtons = screen.getAllByRole('button', { name: /在详情图 \d 和详情图 \d 之间插入图片/ }) + expect(insertButtons).toHaveLength(2) + + await user.click(screen.getByRole('button', { name: '追加图片,加入主图末尾' })) + expect(onRequestAppend).toHaveBeenCalledWith('main') + + await user.click(insertButtons[0]) + expect(onRequestInsert).toHaveBeenCalledWith({ + group: 'detail', + afterAssetId: 'detail-1', + beforeAssetId: 'detail-2', + }) + }) + + test('empty main and sku groups still expose their append controls', () => { + render( + asset.assetType === 'detail-preview') }} + canDownloadSource={false} + onDownload={jest.fn()} + canManageAssets + onRequestAppend={jest.fn()} + />, + ) + + expect(screen.getByText('主图 (0)')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '追加图片,加入主图末尾' })).toBeInTheDocument() + expect(screen.getByText('SKU图 (0)')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '追加图片,加入 SKU 末尾' })).toBeInTheDocument() + }) +}) diff --git a/components/detail/ProductPackageDetail.tsx b/components/detail/ProductPackageDetail.tsx index 78b1ff4..fe3afa6 100644 --- a/components/detail/ProductPackageDetail.tsx +++ b/components/detail/ProductPackageDetail.tsx @@ -27,6 +27,14 @@ interface Asset { sortOrder: number; } +type AppendableGroup = 'main' | 'sku'; + +type DetailInsertPosition = { + group: 'detail'; + afterAssetId: string; + beforeAssetId: string; +}; + interface ProductPackageDetailProps { material: { id: string; @@ -51,10 +59,71 @@ interface ProductPackageDetailProps { onRequestReplace?: (asset: Asset) => void; onRequestRename?: (asset: Asset) => void; onRequestDelete?: (asset: Asset) => void; - /** 已加入待应用清单的资产 id,缩略图上显示待应用标记 */ + onRequestAppend?: (group: AppendableGroup) => void; + onRequestInsert?: (position: DetailInsertPosition) => void; + /** 已加入待提交清单的资产 id,缩略图上显示待提交标记 */ pendingAssetIds?: string[]; } +function AddImageButton({ + label, + hint, + onClick, + disabled, + className, +}: { + label: string; + hint: string; + onClick: () => void; + disabled: boolean; + className: string; +}) { + return ( + + ); +} + +function DetailInsertButton({ + disabled, + onClick, + afterIndex, + beforeIndex, +}: { + disabled: boolean; + onClick: () => void; + afterIndex: number; + beforeIndex: number; +}) { + return ( + + ); +} + function AssetEditMenu({ asset, busy, @@ -85,7 +154,7 @@ function AssetEditMenu({
)} @@ -336,35 +416,49 @@ export default function ProductPackageDetail({

详情图 ({detailImages.length})

{detailImages.map((img, index) => ( -
- {`详情图 -
- {index + 1} +
+
+ {`详情图 +
+ {index + 1} +
+ {canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && ( + <> + + {editingAssetId === img.id && ( + + 正在上传临时图片,请稍候… + + )} + {pendingAssetIds.includes(img.id) && ( + 待提交 + )} + + )}
- {canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && ( - <> - - {editingAssetId === img.id && ( - - 正在重新打包,请稍候… - - )} - {pendingAssetIds.includes(img.id) && ( - 待应用 - )} - + {canManageAssets && onRequestInsert && detailImages[index + 1] && ( + onRequestInsert({ + group: 'detail', + afterAssetId: img.id, + beforeAssetId: detailImages[index + 1].id, + })} + /> )}
))} @@ -413,7 +507,7 @@ export default function ProductPackageDetail({
{/* SKU图小卡片展示区 */} - {skuImages.length > 0 && ( + {(skuImages.length > 0 || (canManageAssets && onRequestAppend)) && (

SKU图 ({skuImages.length})

@@ -461,12 +555,21 @@ export default function ProductPackageDetail({ )} {pendingAssetIds.includes(img.id) && ( - 待应用 + 待提交 )} )}
))} + {canManageAssets && onRequestAppend && ( + onRequestAppend('sku')} + className="aspect-square w-full" + /> + )}
)} diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index aac0541..8dcd977 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -20,8 +20,9 @@ - `20260827_add_ding_workflow_detail_fields` - `20260826_add_pack_video_early_access` - `20260828_add_pack_item_can_edit_assets` +- `20260828_add_task_detail_change_approvals` -它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表,以及钉钉流程快照的需求类型、表单字段与索引,并在同一个事务中按钉钉唯一值做确定性回填。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。 +它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。 ## 强制前提 diff --git a/instrumentation.ts b/instrumentation.ts index d677f03..90b37be 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -71,6 +71,45 @@ export async function register() { console.error('[Instrumentation] 启动作作品过稿通知任务失败:', error) } + try { + const { ensureTaskDetailChangeNotificationWorkerStarted } = await import('./lib/task-detail-change-notifications') + const { + expireDueTaskDetailChanges, + recoverMissingTaskDetailChangeRequestNotifications, + recoverStaleApplyingTaskDetailChanges, + } = await import('./lib/task-detail-change-approval') + ensureTaskDetailChangeNotificationWorkerStarted() + const approvalGlobal = globalThis as typeof globalThis & { + __taskDetailChangeExpiryStarted?: boolean + } + if (!approvalGlobal.__taskDetailChangeExpiryStarted) { + approvalGlobal.__taskDetailChangeExpiryStarted = true + setInterval(() => { + void expireDueTaskDetailChanges().catch((error) => { + console.error('[Instrumentation] 图片变更审批超时扫描失败:', error) + }) + void recoverMissingTaskDetailChangeRequestNotifications().catch((error) => { + console.error('[Instrumentation] 图片变更审批缺失通知补偿失败:', error) + }) + void recoverStaleApplyingTaskDetailChanges().catch((error) => { + console.error('[Instrumentation] 图片变更审批应用中断恢复失败:', error) + }) + }, 60_000).unref?.() + } + void expireDueTaskDetailChanges().catch((error) => { + console.error('[Instrumentation] 图片变更审批首次超时扫描失败:', error) + }) + void recoverMissingTaskDetailChangeRequestNotifications().catch((error) => { + console.error('[Instrumentation] 图片变更审批首次缺失通知补偿失败:', error) + }) + void recoverStaleApplyingTaskDetailChanges().catch((error) => { + console.error('[Instrumentation] 图片变更审批首次应用中断恢复失败:', error) + }) + console.log('[Instrumentation] 图片变更审批通知与超时否决任务已启动') + } catch (error) { + console.error('[Instrumentation] 启动图片变更审批任务失败:', error) + } + // 组织架构/在职状态/离职联动每日同步:应用启动即调度(每日 02:00 Asia/Shanghai), // 不再依赖用户访问考核调研懒启动,容器重启后自动恢复。 try { diff --git a/lib/__tests__/task-detail-asset-batch-route.test.ts b/lib/__tests__/task-detail-asset-batch-route.test.ts index 6b478bb..4046595 100644 --- a/lib/__tests__/task-detail-asset-batch-route.test.ts +++ b/lib/__tests__/task-detail-asset-batch-route.test.ts @@ -4,9 +4,9 @@ import { getServerSession } from 'next-auth' import { POST } from '@/app/api/materials/[id]/assets/batch/route' import { - TaskDetailAssetEditError, - batchEditTaskDetailImages, -} from '@/lib/task-detail-asset-edit' + submitTaskDetailChange, + TaskDetailChangeApprovalError, +} from '@/lib/task-detail-change-approval' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) @@ -18,10 +18,19 @@ jest.mock('@/lib/task-detail-asset-edit', () => ({ this.statusCode = statusCode } }, - batchEditTaskDetailImages: jest.fn(), +})) +jest.mock('@/lib/task-detail-change-approval', () => ({ + TaskDetailChangeApprovalError: class TaskDetailChangeApprovalError extends Error { + statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.statusCode = statusCode + } + }, + submitTaskDetailChange: jest.fn(), })) -const mockedBatch = batchEditTaskDetailImages as jest.Mock +const mockedSubmit = submitTaskDetailChange as jest.Mock function requestWith(body: unknown) { return { json: async () => body } as any @@ -39,14 +48,14 @@ describe('任务-详情批量单图编辑路由', () => { { params: { id: 'm1' } }, ) expect(response.status).toBe(401) - expect(mockedBatch).not.toHaveBeenCalled() + expect(mockedSubmit).not.toHaveBeenCalled() }) test('operations 为空数组返回 400', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'ADMIN' } }) const response = await POST(requestWith({ operations: [] }), { params: { id: 'm1' } }) expect(response.status).toBe(400) - expect(mockedBatch).not.toHaveBeenCalled() + expect(mockedSubmit).not.toHaveBeenCalled() }) test('不支持的操作类型返回 400', async () => { @@ -56,7 +65,7 @@ describe('任务-详情批量单图编辑路由', () => { { params: { id: 'm1' } }, ) expect(response.status).toBe(400) - expect(mockedBatch).not.toHaveBeenCalled() + expect(mockedSubmit).not.toHaveBeenCalled() }) test('replace 缺少 uploadId 返回 400', async () => { @@ -70,7 +79,7 @@ describe('任务-详情批量单图编辑路由', () => { test('合法混合操作透传给批量引擎并返回结果', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'ADMIN' } }) - mockedBatch.mockResolvedValue({ success: true, appliedCount: 3, entryCount: 7 }) + mockedSubmit.mockResolvedValue({ mode: 'applied', data: { success: true, appliedCount: 3, entryCount: 7 } }) const response = await POST( requestWith({ @@ -86,22 +95,21 @@ describe('任务-详情批量单图编辑路由', () => { expect(response.status).toBe(200) const payload = await response.json() expect(payload.data.appliedCount).toBe(3) - expect(mockedBatch).toHaveBeenCalledWith({ - materialId: 'm1', - operatorUserId: 'u1', - operatorRole: 'ADMIN', - operations: [ + expect(mockedSubmit).toHaveBeenCalledWith( + 'm1', + { id: 'u1', role: 'ADMIN', isSuperAdmin: false }, + [ { kind: 'delete', assetId: 'a1' }, { kind: 'rename', assetId: 'a2', newFileName: '新名字.jpg' }, { kind: 'replace', assetId: 'a3', uploadId: 'up-1' }, ], - }) + ) }) test('业务错误透传状态码与文案', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) - mockedBatch.mockRejectedValue( - new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片', 400), + mockedSubmit.mockRejectedValue( + new TaskDetailChangeApprovalError('批量操作只支持同一分组(主图/SKU/详情)内的图片', 400), ) const response = await POST( @@ -115,7 +123,7 @@ describe('任务-详情批量单图编辑路由', () => { test('未知异常返回 500 且不泄漏内部信息', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) - mockedBatch.mockRejectedValue(new Error('internal detail')) + mockedSubmit.mockRejectedValue(new Error('internal detail')) const response = await POST( requestWith({ operations: [{ kind: 'delete', assetId: 'a1' }] }), diff --git a/lib/__tests__/task-detail-asset-edit-batch.test.ts b/lib/__tests__/task-detail-asset-edit-batch.test.ts new file mode 100644 index 0000000..9d6621f --- /dev/null +++ b/lib/__tests__/task-detail-asset-edit-batch.test.ts @@ -0,0 +1,585 @@ +/** @jest-environment node */ + +import { execFile } from 'child_process' +import { writeFileSync } from 'fs' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import os from 'os' +import path from 'path' +import sharp from 'sharp' + +jest.mock('child_process', () => ({ execFile: jest.fn() })) +jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() })) +jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn().mockResolvedValue(undefined) })) +jest.mock('@/lib/file-cleanup', () => ({ deleteFileIfUnreferenced: jest.fn().mockResolvedValue(undefined) })) +jest.mock('@/lib/material-upload-cross-process-lock', () => ({ acquireTaskMaterialCompletionLock: jest.fn() })) +jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn(() => true) })) +jest.mock('@/lib/task-detail-full-set', () => ({ deriveTaskDetailIsFullSet: jest.fn(() => false) })) +jest.mock('@/lib/external-auto-listing', () => ({ + queueTaskDetailBarcodeImagesUpdatedNotification: jest.fn(), + queueTaskDetailFullSetNotification: jest.fn(), +})) +jest.mock('@/lib/public-display', () => ({ + buildTaskDetailPublicDisplayPath: jest.fn((materialId: string, group: string, fileName: string) => `${materialId}/${group}/${fileName}`), + uploadPublicDisplayToS3: jest.fn(), +})) +jest.mock('@/lib/temp-cleanup', () => ({ cleanupConsumedUploadSession: jest.fn().mockResolvedValue(undefined) })) +jest.mock('@/lib/remote-upload-sync', () => ({ deleteStoredUploadFileFromRemote: jest.fn().mockResolvedValue(undefined) })) +jest.mock('@/lib/upload-sessions', () => ({ + getOwnedUploadSession: jest.fn(), + getUploadedFile: jest.fn(), + isSafeUploadId: jest.fn(() => true), +})) +jest.mock('@/lib/task-material-processor', () => ({ + createZipFromFiles: jest.fn(), + createZipFromZips: jest.fn(), +})) +jest.mock('@/lib/zip-entry-reader', () => ({ + createTaskDetailEditWorkspace: jest.fn(), + extractZipEntriesToDirectory: jest.fn(), + listZipEntries: jest.fn(), +})) +jest.mock('@/lib/upload-paths', () => ({ resolveStoredUploadPath: jest.fn((value: string) => value) })) +jest.mock('@/lib/prisma', () => ({ + prisma: { + material: { + findFirst: jest.fn(), + update: jest.fn(), + }, + materialAsset: { + findMany: jest.fn(), + count: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + create: jest.fn(), + createMany: jest.fn(), + }, + materialAssetBarcode: { + findMany: jest.fn(), + createMany: jest.fn(), + }, + taskDetailChangeRequest: { + updateMany: jest.fn(), + }, + $transaction: jest.fn(), + }, +})) + +import { canManageMaterial } from '@/lib/material-authorization' +import { queueTaskDetailBarcodeImagesUpdatedNotification } from '@/lib/external-auto-listing' +import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock' +import { prisma } from '@/lib/prisma' +import { uploadPublicDisplayToS3 } from '@/lib/public-display' +import { createZipFromFiles, createZipFromZips } from '@/lib/task-material-processor' +import { + ServerTrustedTaskDetailStagedFile, + TaskDetailAssetEditError, + batchEditTaskDetailImages, +} from '@/lib/task-detail-asset-edit' +import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' +import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions' +import { createTaskDetailEditWorkspace, extractZipEntriesToDirectory, listZipEntries } from '@/lib/zip-entry-reader' + +const mockExecFile = execFile as unknown as jest.Mock +const mockCanManageMaterial = canManageMaterial as jest.Mock +const mockAcquireLock = acquireTaskMaterialCompletionLock as jest.Mock +const mockUploadPreview = uploadPublicDisplayToS3 as jest.Mock +const mockCreateZipFromFiles = createZipFromFiles as jest.Mock +const mockCreateZipFromZips = createZipFromZips as jest.Mock +const mockCleanupConsumedUploadSession = cleanupConsumedUploadSession as jest.Mock +const mockGetOwnedUploadSession = getOwnedUploadSession as jest.Mock +const mockGetUploadedFile = getUploadedFile as jest.Mock +const mockCreateWorkspace = createTaskDetailEditWorkspace as jest.Mock +const mockExtractZipEntries = extractZipEntriesToDirectory as jest.Mock +const mockListZipEntries = listZipEntries as jest.Mock +const mockQueueSkuNotification = queueTaskDetailBarcodeImagesUpdatedNotification as jest.Mock + +type PreviewRow = { + id: string + assetType: string + fileUrl: string + fileName: string + sortOrder: number + skuCode?: string | null + createdAt?: Date | null +} + +describe('task-detail asset edit batch data-plane', () => { + let tempRoot: string + + beforeEach(async () => { + jest.clearAllMocks() + tempRoot = await mkdtemp(path.join(os.tmpdir(), 'task-detail-batch-')) + + mockCanManageMaterial.mockResolvedValue(true) + mockAcquireLock.mockResolvedValue(Object.assign(jest.fn().mockResolvedValue(undefined), { + assertHeld: jest.fn(), + })) + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'operator-1', + materialType: '任务-详情', + taskStatus: 'completed', + productModel: 'MODEL-1', + authorName: '作者A', + categoryName: '店铺A', + previewUrl: 'https://preview/existing-cover.webp', + isFullSet: false, + updatedAt: new Date('2026-08-28T08:00:00.000Z'), + }) + ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0) + ;(prisma.material.update as jest.Mock).mockResolvedValue(undefined) + ;(prisma.materialAsset.update as jest.Mock).mockResolvedValue(undefined) + ;(prisma.materialAsset.delete as jest.Mock).mockResolvedValue(undefined) + ;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 0 }) + ;(prisma.materialAsset.create as jest.Mock).mockResolvedValue(undefined) + ;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 0 }) + ;(prisma.materialAssetBarcode.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.materialAssetBarcode.createMany as jest.Mock).mockResolvedValue({ count: 0 }) + ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => callback({ + material: prisma.material, + materialAsset: prisma.materialAsset, + materialAssetBarcode: prisma.materialAssetBarcode, + taskDetailChangeRequest: prisma.taskDetailChangeRequest, + })) + mockUploadPreview.mockImplementation(async (_buffer: Buffer, key: string) => `https://preview/${key}`) + mockCreateZipFromFiles.mockResolvedValue({ + filePath: 'uploads/materials/group.zip', + fileName: 'group.zip', + fileSize: 123, + }) + mockCreateZipFromZips.mockResolvedValue({ + filePath: 'uploads/materials/all.zip', + fileName: 'all.zip', + fileSize: 456, + }) + mockCreateWorkspace.mockImplementation(async () => { + const dir = await mkdtemp(path.join(tempRoot, 'workspace-')) + return { + dir, + cleanup: () => rm(dir, { recursive: true, force: true }), + } + }) + }) + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }) + }) + + async function createSolidImage(fileName: string, width: number, height: number, format: 'png' | 'webp' = 'png') { + const filePath = path.join(tempRoot, fileName) + const buffer = await sharp({ + create: { + width, + height, + channels: 3, + background: { r: 180, g: 180, b: 180 }, + }, + })[format]().toBuffer() + await writeFile(filePath, buffer) + return { filePath, size: buffer.length } + } + + async function createAnimatedGif(fileName: string, width: number, height: number) { + const redFrame = Buffer.alloc(width * height * 4) + const blueFrame = Buffer.alloc(width * height * 4) + for (let index = 0; index < width * height; index += 1) { + redFrame[index * 4] = 255 + redFrame[index * 4 + 3] = 255 + blueFrame[index * 4 + 2] = 255 + blueFrame[index * 4 + 3] = 255 + } + const buffer = await sharp(Buffer.concat([redFrame, blueFrame]), { + raw: { width, height: height * 2, pageHeight: height, channels: 4 }, + }).gif({ loop: 0, delay: [120, 120] }).toBuffer() + const filePath = path.join(tempRoot, fileName) + await writeFile(filePath, buffer) + return { filePath, size: buffer.length, buffer } + } + + function configureUploadSources(files: Record) { + mockGetOwnedUploadSession.mockImplementation(async (uploadId: string) => { + const item = files[uploadId] + return item ? { fileName: item.fileName, fileSize: item.size } : null + }) + mockGetUploadedFile.mockImplementation(async (uploadId: string) => files[uploadId]?.path || null) + } + + function configureAssetQueries(options: { + previewRows: PreviewRow[] + zipRows: Array<{ id: string; assetType: string; fileUrl: string; fileName: string | null }> + extraFindMany?: unknown[] + }) { + const mock = prisma.materialAsset.findMany as jest.Mock + mock.mockReset() + mock.mockResolvedValueOnce(options.previewRows) + mock.mockResolvedValueOnce(options.zipRows) + for (const item of options.extraFindMany || []) { + mock.mockResolvedValueOnce(item) + } + } + + test('sku 追加支持多张按顺序提交,并保留 skuCode/barcodes', async () => { + const existingSource = await createSolidImage('sku-old.png', 80, 80) + const existingPreview = await createSolidImage('sku-old-preview.webp', 80, 80, 'webp') + const append1 = await createSolidImage('append-1.png', 90, 90) + const append2 = await createSolidImage('append-2.png', 100, 100) + + configureUploadSources({ + 'up-1': { path: append1.filePath, size: append1.size, fileName: 'append-1.png' }, + 'up-2': { path: append2.filePath, size: append2.size, fileName: 'append-2.png' }, + }) + configureAssetQueries({ + previewRows: [ + { id: 'sku-0', assetType: 'sku-preview', fileUrl: existingPreview.filePath, fileName: 'sku-old-preview.webp', sortOrder: 0, skuCode: 'OLD' }, + ], + zipRows: [ + { id: 'zip-sku', assetType: 'sku', fileUrl: path.join(tempRoot, 'sku.zip'), fileName: 'sku.zip' }, + { id: 'zip-all', assetType: 'all', fileUrl: path.join(tempRoot, 'all.zip'), fileName: 'all.zip' }, + ], + extraFindMany: [ + [ + { id: 'new-sku-1', fileUrl: 'https://preview/material-1/sku/append-1__srv-1.webp' }, + { id: 'new-sku-2', fileUrl: 'https://preview/material-1/sku/append-2__srv-2.webp' }, + ], + [ + { id: 'new-sku-1' }, + { id: 'new-sku-2' }, + ], + ], + }) + mockListZipEntries.mockResolvedValue([{ name: 'sku-old.png' }]) + mockExtractZipEntries.mockResolvedValue([ + { entryName: 'sku-old.png', filePath: existingSource.filePath, size: existingSource.size }, + ]) + + const result = await batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + approvalRequestId: 'request-atomic', + operations: [ + { kind: 'append', group: 'sku', uploadId: 'up-1', skuCode: 'SKU-1', barcodes: ['b-1'] }, + { kind: 'append', group: 'sku', uploadId: 'up-2', skuCode: 'SKU-2', barcodes: ['b-2', 'b-3'] }, + ], + }) + + expect(result).toMatchObject({ success: true, appliedCount: 2, entryCount: 3 }) + expect(mockCreateZipFromFiles).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ name: 'sku-old.png' }), + expect.objectContaining({ name: 'append-1.png' }), + expect.objectContaining({ name: 'append-2.png' }), + ]), + 'material-1', + 'MODEL-1', + '任务-详情', + 'SKU图', + '作者A', + '店铺A', + ) + expect((prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ skuCode: 'SKU-1' }), + expect.objectContaining({ skuCode: 'SKU-2' }), + ]), + ) + expect(mockCleanupConsumedUploadSession).toHaveBeenCalledWith('up-1') + expect(mockCleanupConsumedUploadSession).toHaveBeenCalledWith('up-2') + expect(mockQueueSkuNotification).toHaveBeenCalledWith('material-1', ['new-sku-1', 'new-sku-2']) + expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith({ + where: { + id: 'request-atomic', + materialId: 'material-1', + status: 'applying', + }, + data: expect.objectContaining({ status: 'applied', applyError: null }), + }) + }) + + test('详情插图要求 seam 两侧 preview 必须相邻', async () => { + const sourceA = await createSolidImage('detail-a.png', 80, 120) + const sourceB = await createSolidImage('detail-b.png', 80, 120) + const sourceC = await createSolidImage('detail-c.png', 80, 120) + const insertFile = await createSolidImage('insert-middle.png', 70, 70) + + configureUploadSources({ + insert: { path: insertFile.filePath, size: insertFile.size, fileName: 'insert-middle.png' }, + }) + configureAssetQueries({ + previewRows: [ + { id: 'a', assetType: 'detail-preview', fileUrl: sourceA.filePath, fileName: '1_01-preview.webp', sortOrder: 0 }, + { id: 'b', assetType: 'detail-preview', fileUrl: sourceB.filePath, fileName: '1_02-preview.webp', sortOrder: 1 }, + { id: 'c', assetType: 'detail-preview', fileUrl: sourceC.filePath, fileName: '1_03-preview.webp', sortOrder: 2 }, + ], + zipRows: [ + { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }, + ], + }) + mockListZipEntries.mockResolvedValue([{ name: '1_01.jpg' }, { name: '1_02.jpg' }, { name: '1_03.jpg' }]) + mockExtractZipEntries.mockResolvedValue([ + { entryName: '1_01.jpg', filePath: sourceA.filePath, size: sourceA.size }, + { entryName: '1_02.jpg', filePath: sourceB.filePath, size: sourceB.size }, + { entryName: '1_03.jpg', filePath: sourceC.filePath, size: sourceC.size }, + ]) + + await expect(batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'insert', afterAssetId: 'a', beforeAssetId: 'c', uploadId: 'insert' }, + ], + })).rejects.toThrow('相邻预览之间') + expect(mockCreateZipFromFiles).not.toHaveBeenCalled() + }) + + test('详情跨 run 插图支持 server-trusted staged file,不依赖 upload session', async () => { + const sourceA = await createSolidImage('cross-a.png', 80, 120) + const sourceB = await createSolidImage('cross-b.png', 80, 120) + const staged = await createSolidImage('cross-insert.png', 70, 90) + + configureAssetQueries({ + previewRows: [ + { id: 'a', assetType: 'detail-preview', fileUrl: sourceA.filePath, fileName: '1_01-preview.webp', sortOrder: 0 }, + { id: 'b', assetType: 'detail-preview', fileUrl: sourceB.filePath, fileName: '1_02-preview.webp', sortOrder: 1 }, + ], + zipRows: [ + { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }, + ], + }) + mockListZipEntries.mockResolvedValue([{ name: '1_01.jpg' }, { name: '1_02.jpg' }]) + mockExtractZipEntries.mockResolvedValue([ + { entryName: '1_01.jpg', filePath: sourceA.filePath, size: sourceA.size }, + { entryName: '1_02.jpg', filePath: sourceB.filePath, size: sourceB.size }, + ]) + + const stagedFile: ServerTrustedTaskDetailStagedFile = { + trusted: true, + filePath: staged.filePath, + fileName: 'cross-insert.png', + fileSize: staged.size, + } + + const result = await batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'insert', afterAssetId: 'a', beforeAssetId: 'b', stagedFile }, + ], + }) + + expect(result.entryCount).toBe(3) + expect(mockGetOwnedUploadSession).not.toHaveBeenCalled() + expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([ + '1_01.jpg', + 'cross-insert.png', + '1_02.jpg', + ]) + }) + + test('详情同 run 静态长图插图会拆成上半/插入/下半并重建 preview', async () => { + const longSource = await createSolidImage('long-source.png', 1000, 4000) + const preview1 = await createSolidImage('long-001.webp', 1000, 1500, 'webp') + const preview2 = await createSolidImage('long-002.webp', 1000, 1500, 'webp') + const preview3 = await createSolidImage('long-003.webp', 1000, 1000, 'webp') + const inserted = await createSolidImage('insert-static.png', 900, 600) + + configureUploadSources({ + insert: { path: inserted.filePath, size: inserted.size, fileName: 'insert-static.png' }, + }) + configureAssetQueries({ + previewRows: [ + { id: 'slice-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'long-001.webp', sortOrder: 0 }, + { id: 'slice-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'long-002.webp', sortOrder: 1 }, + { id: 'slice-3', assetType: 'detail-preview', fileUrl: preview3.filePath, fileName: 'long-003.webp', sortOrder: 2 }, + ], + zipRows: [ + { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }, + ], + }) + mockListZipEntries.mockResolvedValue([{ name: 'long.png' }]) + mockExtractZipEntries.mockResolvedValue([ + { entryName: 'long.png', filePath: longSource.filePath, size: longSource.size }, + ]) + + const result = await batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'insert', afterAssetId: 'slice-1', beforeAssetId: 'slice-2', uploadId: 'insert' }, + ], + }) + + expect(result.entryCount).toBe(3) + expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([ + 'long__top.png', + 'insert-static.png', + 'long__bottom.png', + ]) + expect((prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data).toHaveLength(4) + }) + + test('同一长图接缝一次选择多张图片时按选择顺序连续插入', async () => { + const longSource = await createSolidImage('multi-long-source.png', 1000, 3000) + const preview1 = await createSolidImage('multi-long-001.webp', 1000, 1500, 'webp') + const preview2 = await createSolidImage('multi-long-002.webp', 1000, 1500, 'webp') + const inserted1 = await createSolidImage('insert-first.png', 900, 500) + const inserted2 = await createSolidImage('insert-second.png', 900, 600) + + configureUploadSources({ + 'insert-1': { path: inserted1.filePath, size: inserted1.size, fileName: 'insert-first.png' }, + 'insert-2': { path: inserted2.filePath, size: inserted2.size, fileName: 'insert-second.png' }, + }) + configureAssetQueries({ + previewRows: [ + { id: 'multi-slice-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'multi-long-001.webp', sortOrder: 0 }, + { id: 'multi-slice-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'multi-long-002.webp', sortOrder: 1 }, + ], + zipRows: [ + { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'multi-detail.zip'), fileName: 'multi-detail.zip' }, + ], + }) + mockListZipEntries.mockResolvedValue([{ name: 'multi-long.png' }]) + mockExtractZipEntries.mockResolvedValue([ + { entryName: 'multi-long.png', filePath: longSource.filePath, size: longSource.size }, + ]) + + const result = await batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'insert', afterAssetId: 'multi-slice-1', beforeAssetId: 'multi-slice-2', uploadId: 'insert-1' }, + { kind: 'insert', afterAssetId: 'multi-slice-1', beforeAssetId: 'multi-slice-2', uploadId: 'insert-2' }, + ], + }) + + expect(result.entryCount).toBe(4) + expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([ + 'multi-long__top.png', + 'insert-first.png', + 'insert-second.png', + 'multi-long__bottom.png', + ]) + }) + + test('详情同 run GIF 长图插图走 ffmpeg 保留动画链路', async () => { + const longGif = await createAnimatedGif('long-run.gif', 100, 2000) + const topGif = await createAnimatedGif('long-run-top.gif', 100, 1500) + const bottomGif = await createAnimatedGif('long-run-bottom.gif', 100, 500) + const preview1 = await createSolidImage('gif-001.webp', 100, 1500, 'webp') + const preview2 = await createSolidImage('gif-002.webp', 100, 500, 'webp') + const inserted = await createSolidImage('insert-gif-static.png', 100, 300) + + configureUploadSources({ + insert: { path: inserted.filePath, size: inserted.size, fileName: 'insert-gif-static.png' }, + }) + configureAssetQueries({ + previewRows: [ + { id: 'gif-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'gif-run-001.webp', sortOrder: 0 }, + { id: 'gif-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'gif-run-002.webp', sortOrder: 1 }, + ], + zipRows: [ + { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail-gif.zip'), fileName: 'detail-gif.zip' }, + ], + }) + mockListZipEntries.mockResolvedValue([{ name: 'gif-run.gif' }]) + mockExtractZipEntries.mockResolvedValue([ + { entryName: 'gif-run.gif', filePath: longGif.filePath, size: longGif.size }, + ]) + + mockExecFile.mockImplementation((file: string, args: string[], options: any, callback: (error: Error | null, stdout?: string, stderr?: string) => void) => { + const outputPath = args[args.length - 1] + if (String(outputPath).endsWith('.gif')) { + const filters = args.join(' ') + writeFileSync(outputPath, filters.includes('crop=100:1500') ? topGif.buffer : bottomGif.buffer) + } else { + writeFileSync(outputPath, Buffer.from('RIFFxxxxWEBP')) + } + callback(null, '', '') + }) + + const result = await batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'insert', afterAssetId: 'gif-1', beforeAssetId: 'gif-2', uploadId: 'insert' }, + ], + }) + + expect(result.entryCount).toBe(3) + expect(mockExecFile.mock.calls.length).toBeGreaterThanOrEqual(4) + expect(mockExecFile.mock.calls.some((call) => String(call[1]).includes('libwebp_anim'))).toBe(true) + expect(mockExecFile.mock.calls.some((call) => String(call[1]).includes('crop='))).toBe(true) + expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([ + 'gif-run__top.gif', + 'insert-gif-static.png', + 'gif-run__bottom.gif', + ]) + }) + + test('追加后超过图片组上限会拒绝提交', async () => { + const existingRows: PreviewRow[] = [] + const entryNames: Array<{ name: string; filePath: string }> = [] + for (let index = 0; index < 300; index += 1) { + existingRows.push({ + id: `main-${index}`, + assetType: 'main-preview', + fileUrl: `https://preview/existing-${index}.webp`, + fileName: `existing-${index}-preview.webp`, + sortOrder: index, + }) + entryNames.push({ name: `existing-${index}.jpg`, filePath: path.join(tempRoot, `existing-${index}.jpg`) }) + } + const appended = await createSolidImage('append-over-limit.png', 80, 80) + + configureUploadSources({ + append: { path: appended.filePath, size: appended.size, fileName: 'append-over-limit.png' }, + }) + configureAssetQueries({ + previewRows: existingRows, + zipRows: [ + { id: 'zip-main', assetType: 'main', fileUrl: path.join(tempRoot, 'main.zip'), fileName: 'main.zip' }, + ], + }) + mockListZipEntries.mockResolvedValue(entryNames.map((item) => ({ name: item.name }))) + mockExtractZipEntries.mockResolvedValue(entryNames.map((item, index) => ({ + entryName: item.name, + filePath: item.filePath, + size: index + 1, + }))) + + await expect(batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'append', group: 'main', uploadId: 'append' }, + ], + })).rejects.toThrow('最多保留 300 张') + expect(mockCreateZipFromFiles).not.toHaveBeenCalled() + }) + + test('审批期间正式素材已更新时在持锁上下文中停止应用', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ + id: 'material-1', + userId: 'operator-1', + materialType: '任务-详情', + taskStatus: 'completed', + productModel: 'MODEL-1', + authorName: '作者A', + categoryName: '店铺A', + previewUrl: 'https://preview/existing-cover.webp', + isFullSet: false, + updatedAt: new Date('2026-08-28T08:01:00.000Z'), + }) + + await expect(batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + expectedMaterialUpdatedAt: new Date('2026-08-28T08:00:00.000Z'), + operations: [{ kind: 'append', group: 'main', uploadId: 'append' }], + })).rejects.toThrow('正式素材在审批期间已更新') + + expect(prisma.materialAsset.findMany).not.toHaveBeenCalled() + expect(mockCreateZipFromFiles).not.toHaveBeenCalled() + }) +}) diff --git a/lib/__tests__/task-detail-asset-edit-route.test.ts b/lib/__tests__/task-detail-asset-edit-route.test.ts index aa23475..5c545c6 100644 --- a/lib/__tests__/task-detail-asset-edit-route.test.ts +++ b/lib/__tests__/task-detail-asset-edit-route.test.ts @@ -7,11 +7,7 @@ import { PATCH, POST, } from '@/app/api/materials/[id]/assets/[assetId]/route' -import { - deleteTaskDetailImage, - renameTaskDetailImage, - replaceTaskDetailImage, -} from '@/lib/task-detail-asset-edit' +import { submitTaskDetailChange } from '@/lib/task-detail-change-approval' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) @@ -23,14 +19,19 @@ jest.mock('@/lib/task-detail-asset-edit', () => ({ this.statusCode = statusCode } }, - deleteTaskDetailImage: jest.fn(), - renameTaskDetailImage: jest.fn(), - replaceTaskDetailImage: jest.fn(), +})) +jest.mock('@/lib/task-detail-change-approval', () => ({ + TaskDetailChangeApprovalError: class TaskDetailChangeApprovalError extends Error { + statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.statusCode = statusCode + } + }, + submitTaskDetailChange: jest.fn(), })) -const mockedDelete = deleteTaskDetailImage as jest.Mock -const mockedRename = renameTaskDetailImage as jest.Mock -const mockedReplace = replaceTaskDetailImage as jest.Mock +const mockedSubmit = submitTaskDetailChange as jest.Mock describe('任务-详情单图编辑路由', () => { beforeEach(() => { @@ -47,68 +48,59 @@ describe('任务-详情单图编辑路由', () => { ]) { expect(response.status).toBe(401) } - expect(mockedRename).not.toHaveBeenCalled() - expect(mockedReplace).not.toHaveBeenCalled() - expect(mockedDelete).not.toHaveBeenCalled() + expect(mockedSubmit).not.toHaveBeenCalled() }) test('PATCH 缺少新文件名返回 400', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) const response = await PATCH({ json: async () => ({}) } as any, { params: { id: 'm1', assetId: 'a1' } }) expect(response.status).toBe(400) - expect(mockedRename).not.toHaveBeenCalled() + expect(mockedSubmit).not.toHaveBeenCalled() }) test('POST 缺少 uploadId 返回 400', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) const response = await POST({ json: async () => ({}) } as any, { params: { id: 'm1', assetId: 'a1' } }) expect(response.status).toBe(400) - expect(mockedReplace).not.toHaveBeenCalled() + expect(mockedSubmit).not.toHaveBeenCalled() }) test('登录用户正常调用并透传参数', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'USER' } }) - mockedRename.mockResolvedValue({ success: true }) - mockedReplace.mockResolvedValue({ success: true }) - mockedDelete.mockResolvedValue({ success: true }) + mockedSubmit.mockResolvedValue({ mode: 'applied', data: { success: true } }) await PATCH( { json: async () => ({ newFileName: '新名字' }) } as any, { params: { id: 'm1', assetId: 'a1' } }, ) - expect(mockedRename).toHaveBeenCalledWith({ - materialId: 'm1', - assetId: 'a1', - operatorUserId: 'u1', - operatorRole: 'USER', - newFileName: '新名字', - }) + expect(mockedSubmit).toHaveBeenCalledWith( + 'm1', + { id: 'u1', role: 'USER', isSuperAdmin: false }, + [{ kind: 'rename', assetId: 'a1', newFileName: '新名字' }], + ) await POST( { json: async () => ({ uploadId: 'upload-1' }) } as any, { params: { id: 'm1', assetId: 'a2' } }, ) - expect(mockedReplace).toHaveBeenCalledWith({ - materialId: 'm1', - assetId: 'a2', - operatorUserId: 'u1', - operatorRole: 'USER', - uploadId: 'upload-1', - }) + expect(mockedSubmit).toHaveBeenCalledWith( + 'm1', + { id: 'u1', role: 'USER', isSuperAdmin: false }, + [{ kind: 'replace', assetId: 'a2', uploadId: 'upload-1' }], + ) await DELETE({} as any, { params: { id: 'm1', assetId: 'a3' } }) - expect(mockedDelete).toHaveBeenCalledWith({ - materialId: 'm1', - assetId: 'a3', - operatorUserId: 'u1', - operatorRole: 'USER', - }) + expect(mockedSubmit).toHaveBeenCalledWith( + 'm1', + { id: 'u1', role: 'USER', isSuperAdmin: false }, + [{ kind: 'delete', assetId: 'a3' }], + ) }) test('权限类业务错误透传状态码与文案', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'intruder' } }) - const { TaskDetailAssetEditError: MockedError } = await import('@/lib/task-detail-asset-edit') - mockedDelete.mockRejectedValue(new (MockedError as any)('您没有权限编辑此素材', 403)) + const { TaskDetailChangeApprovalError: MockedError } = await import('@/lib/task-detail-change-approval') + mockedSubmit.mockRejectedValue(new (MockedError as any)('您没有权限编辑此素材', 403)) const response = await DELETE({} as any, { params: { id: 'm1', assetId: 'a1' } }) expect(response.status).toBe(403) diff --git a/lib/__tests__/task-detail-change-approval.test.ts b/lib/__tests__/task-detail-change-approval.test.ts new file mode 100644 index 0000000..aaa4072 --- /dev/null +++ b/lib/__tests__/task-detail-change-approval.test.ts @@ -0,0 +1,386 @@ +/** @jest-environment node */ + +import { prisma } from '@/lib/prisma' +import { getEmployeeByUserId } from '@/lib/employee-directory' +import { canManageMaterial } from '@/lib/material-authorization' +import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit' +import { + cleanupTaskDetailChangeStaging, + resolveTaskDetailStagedFile, + withTaskDetailStagedUploads, +} from '@/lib/task-detail-change-staging' +import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications' +import { + decideTaskDetailChange, + expireDueTaskDetailChanges, + recoverMissingTaskDetailChangeRequestNotifications, + recoverStaleApplyingTaskDetailChanges, + submitTaskDetailChange, +} from '@/lib/task-detail-change-approval' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + material: { findFirst: jest.fn(), findUnique: jest.fn() }, + performanceSurveyEmployeeProfile: { findUnique: jest.fn() }, + taskDetailChangeRequest: { + create: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/employee-directory', () => ({ + BINDING_STATUS: { ACTIVE: 'ACTIVE' }, + getEmployeeByUserId: jest.fn(), +})) + +jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() })) +jest.mock('@/lib/user-permission-inherit', () => ({ canUserEditTaskDetailAssets: jest.fn() })) +jest.mock('@/lib/department-permission', () => ({ + isSystemAdmin: jest.fn((role: string) => role === 'ADMIN' || role === 'SYSTEM_ADMIN'), +})) +jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn(() => true) })) +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', () => ({ batchEditTaskDetailImages: jest.fn() })) +jest.mock('@/lib/task-detail-change-notifications', () => ({ + queueTaskDetailChangeNotification: jest.fn(), +})) +jest.mock('@/lib/task-detail-change-staging', () => ({ + cleanupTaskDetailChangeStaging: jest.fn(), + resolveTaskDetailStagedFile: jest.fn(), + withTaskDetailStagedUploads: jest.fn(), +})) + +const material = { + id: 'material-1', + userId: 'owner-1', + materialType: '任务-详情', + taskStatus: 'completed', + taskNo: 'TASK-001', + productModel: 'SPU-001', + updatedAt: new Date('2026-08-28T00:00:00.000Z'), + assets: [ + { id: 'asset-1', assetType: 'main-preview' }, + { id: 'detail-1', assetType: 'detail-preview' }, + ], +} + +function employee(levelType: 'M' | 'P', dingtalkUserId = 'employee-ding') { + return { + employeeProfileId: `employee-${levelType}`, + userId: 'user-1', + displayName: levelType === 'M' ? '管理者' : '申请人', + realName: null, + flowerName: null, + jobNumber: 'JS001', + dingtalkUserId, + departmentName: '品牌中心', + departmentPath: '公司/品牌中心', + positionTitle: '设计师', + positionLevel: `${levelType}1`, + levelType, + isLeadership: levelType === 'M', + managerName: '直属主管', + managerDingtalkUserId: 'manager-ding', + employmentStatus: 'active', + } +} + +describe('任务详情图片变更主管审批', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material) + ;(canManageMaterial as jest.Mock).mockResolvedValue(true) + ;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true }) + ;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1') + ;(cleanupTaskDetailChangeStaging as jest.Mock).mockResolvedValue(undefined) + ;(resolveTaskDetailStagedFile as jest.Mock).mockReturnValue({ + trusted: true, + filePath: '/uploads/staged/new.png', + fileName: 'new.png', + fileSize: 10, + }) + }) + + test('M 级人员有权限时直接应用,不创建审批单', async () => { + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M')) + + const result = await submitTaskDetailChange('material-1', { + id: 'user-1', + role: 'USER', + }, [{ kind: 'delete', assetId: 'asset-1' }]) + + expect(result.mode).toBe('applied') + expect(batchEditTaskDetailImages).toHaveBeenCalledWith(expect.objectContaining({ + materialId: 'material-1', + operations: [{ kind: 'delete', assetId: 'asset-1' }], + })) + expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled() + }) + + test('P 级人员把替换文件转为持久暂存并创建 24 小时审批单', async () => { + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P')) + ;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({ + id: 'manager-employee', + displayName: '直属主管', + realName: null, + employmentState: 'ACTIVE', + dingtalkUserId: 'manager-ding', + jobNumber: 'M001', + departmentPath: '公司/品牌中心', + positionLevel: 'M1', + userBindings: [{ userId: 'manager-user' }], + }) + ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map([ + ['upload-1', { relativePath: 'task-detail-change-requests/request/new.png', fileName: 'new.png', fileSize: 10 }], + ]))) + ;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ + ...data, + material, + submittedAt: new Date(), + })) + + const result = await submitTaskDetailChange('material-1', { + id: 'user-1', + role: 'USER', + }, [{ kind: 'replace', assetId: 'asset-1', uploadId: 'upload-1' }]) + + expect(result.mode).toBe('pending_approval') + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() + expect(withTaskDetailStagedUploads).toHaveBeenCalledWith(expect.objectContaining({ + ownerUserId: 'user-1', + uploadIds: ['upload-1'], + })) + expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + status: 'pending_manager_approval', + managerDingtalkUserId: 'manager-ding', + operations: [expect.objectContaining({ + kind: 'replace', + assetId: 'asset-1', + stagedFile: expect.objectContaining({ fileName: 'new.png' }), + })], + }), + })) + }) + + test('P 级负责人仍需直属主管审批,不能因 isLeadership 绕过', async () => { + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue({ + ...employee('P'), + isLeadership: true, + }) + ;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({ + id: 'manager-employee', + displayName: '直属主管', + realName: null, + employmentState: 'ACTIVE', + dingtalkUserId: 'manager-ding', + jobNumber: 'M001', + departmentPath: '公司/品牌中心', + positionLevel: 'M1', + userBindings: [{ userId: 'manager-user' }], + }) + ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map())) + ;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ + ...data, + material, + submittedAt: new Date(), + })) + + const result = await submitTaskDetailChange('material-1', { + id: 'user-1', + role: 'USER', + }, [{ kind: 'delete', assetId: 'asset-1' }]) + + expect(result.mode).toBe('pending_approval') + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() + expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalled() + }) + + test('跨主图和详情图的变更在暂存前直接拒绝', async () => { + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P')) + + await expect(submitTaskDetailChange('material-1', { + id: 'user-1', + role: 'USER', + }, [ + { kind: 'delete', assetId: 'asset-1' }, + { kind: 'delete', assetId: 'detail-1' }, + ])).rejects.toThrow('一次只能提交同一图片分组') + + expect(withTaskDetailStagedUploads).not.toHaveBeenCalled() + expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled() + }) + + test('直属主管批准后才应用暂存操作', async () => { + const pending = { + id: 'request-1', + materialId: 'material-1', + requesterUserId: 'requester-user', + requesterName: '申请人', + managerDingtalkUserId: 'manager-ding', + status: 'pending_manager_approval', + expiresAt: new Date(Date.now() + 60_000), + baseMaterialUpdatedAt: material.updatedAt, + operations: [{ + kind: 'replace', + assetId: 'asset-1', + stagedFile: { relativePath: 'task-detail-change-requests/request-1/new.png', fileName: 'new.png', fileSize: 10 }, + }], + material, + requesterEmployee: { dingtalkUserId: 'requester-ding' }, + } + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding')) + ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock) + .mockResolvedValueOnce(pending) + .mockResolvedValueOnce({ ...pending, status: 'applied' }) + ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ updatedAt: material.updatedAt }) + ;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({}) + + const result = await decideTaskDetailChange('request-1', { + id: 'manager-user', + role: 'USER', + }, 'approve') + + expect(result.status).toBe('applied') + expect(batchEditTaskDetailImages).toHaveBeenCalledWith(expect.objectContaining({ + operatorUserId: 'requester-user', + expectedMaterialUpdatedAt: material.updatedAt, + approvalRequestId: 'request-1', + operations: [expect.objectContaining({ + kind: 'replace', + stagedFile: expect.objectContaining({ trusted: true, fileName: 'new.png' }), + })], + })) + expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-1') + }) + + test('系统管理员可以审计查看但不能代替直属主管批准', async () => { + const pending = { + id: 'request-manager-only', + materialId: 'material-1', + requesterUserId: 'requester-user', + managerDingtalkUserId: 'manager-ding', + status: 'pending_manager_approval', + expiresAt: new Date(Date.now() + 60_000), + material, + requesterEmployee: { dingtalkUserId: 'requester-ding' }, + } + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'other-manager-ding')) + ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue(pending) + + await expect(decideTaskDetailChange('request-manager-only', { + id: 'admin-user', + role: 'ADMIN', + }, 'approve')).rejects.toThrow('只有申请人的直属主管可以确认') + + expect(prisma.taskDetailChangeRequest.updateMany).not.toHaveBeenCalled() + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() + }) + + test('批准时正式素材已变化会停止应用、清理暂存并通知申请人', async () => { + const pending = { + id: 'request-stale', + materialId: 'material-1', + requesterUserId: 'requester-user', + requesterName: '申请人', + managerDingtalkUserId: 'manager-ding', + status: 'pending_manager_approval', + expiresAt: new Date(Date.now() + 60_000), + baseMaterialUpdatedAt: material.updatedAt, + operations: [{ kind: 'delete', assetId: 'asset-1' }], + material, + requesterEmployee: { dingtalkUserId: 'requester-ding' }, + } + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding')) + ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock) + .mockResolvedValueOnce(pending) + .mockResolvedValueOnce({ ...pending, status: 'apply_failed' }) + ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + updatedAt: new Date(material.updatedAt.getTime() + 1000), + }) + ;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({}) + + await expect(decideTaskDetailChange('request-stale', { + id: 'manager-user', + role: 'USER', + }, 'approve')).rejects.toThrow('正式素材在审批期间已更新') + + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() + expect(prisma.taskDetailChangeRequest.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ status: 'apply_failed' }), + })) + expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-stale') + expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'task_detail_change.apply_failed', + })) + }) + + test('超过 24 小时未处理的申请自动否决并清理暂存', async () => { + ;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-expired' }]) + ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) + ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue({ + id: 'request-expired', + status: 'expired', + requesterUserId: 'requester-user', + requesterName: '申请人', + requesterEmployee: { dingtalkUserId: 'requester-ding' }, + material, + }) + + await expect(expireDueTaskDetailChanges()).resolves.toBe(1) + expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-expired') + expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'task_detail_change.expired', + })) + }) + + test('审批单存在但主管通知 outbox 缺失时会补偿排队', async () => { + ;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ + id: 'request-missing-notice', + managerUserId: 'manager-user', + managerDingtalkUserId: 'manager-ding', + managerName: '直属主管', + requesterName: '申请人', + material, + operations: [{ kind: 'delete', assetId: 'asset-1' }], + expiresAt: new Date(Date.now() + 60_000), + }]) + + await expect(recoverMissingTaskDetailChangeRequestNotifications()).resolves.toBe(1) + expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'task_detail_change.requested', + businessKey: 'task-detail-change:request-missing-notice:requested', + })) + }) + + test('陈旧 applying 会转失败、清理暂存并通知申请人', async () => { + const now = new Date('2026-08-28T12:00:00.000Z') + ;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-stale-applying' }]) + ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) + ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue({ + id: 'request-stale-applying', + status: 'apply_failed', + requesterUserId: 'requester-user', + requesterName: '申请人', + requesterEmployee: { dingtalkUserId: 'requester-ding' }, + material, + }) + + await expect(recoverStaleApplyingTaskDetailChanges(now)).resolves.toBe(1) + expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ id: 'request-stale-applying', status: 'applying' }), + data: expect.objectContaining({ status: 'apply_failed' }), + })) + expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-stale-applying') + expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'task_detail_change.apply_failed', + })) + }) +}) diff --git a/lib/__tests__/task-detail-change-request-routes.test.ts b/lib/__tests__/task-detail-change-request-routes.test.ts new file mode 100644 index 0000000..d64926c --- /dev/null +++ b/lib/__tests__/task-detail-change-request-routes.test.ts @@ -0,0 +1,123 @@ +/** @jest-environment node */ + +import { getServerSession } from 'next-auth' + +import { + GET as listChanges, + POST as submitChange, +} from '@/app/api/materials/[id]/assets/change-requests/route' +import { GET as getChange } from '@/app/api/task-detail-change-requests/[id]/route' +import { POST as decideChange } from '@/app/api/task-detail-change-requests/[id]/decision/route' +import { + decideTaskDetailChange, + getTaskDetailChangeRequest, + listTaskDetailChanges, + submitTaskDetailChange, + TaskDetailChangeApprovalError, +} from '@/lib/task-detail-change-approval' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/task-detail-change-approval', () => ({ + TaskDetailChangeApprovalError: class TaskDetailChangeApprovalError extends Error { + statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.statusCode = statusCode + } + }, + decideTaskDetailChange: jest.fn(), + getTaskDetailChangeRequest: jest.fn(), + listTaskDetailChanges: jest.fn(), + submitTaskDetailChange: jest.fn(), +})) + +const actor = { id: 'user-1', role: 'USER', isSuperAdmin: false } + +function requestWith(body: unknown) { + return { json: async () => body } as any +} + +describe('任务详情图片变更审批 API', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('四个入口都拒绝未登录请求', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue(null) + + const responses = await Promise.all([ + listChanges({} as any, { params: { id: 'material-1' } }), + submitChange(requestWith({ operations: [] }), { params: { id: 'material-1' } }), + getChange({} as any, { params: { id: 'request-1' } }), + decideChange(requestWith({ decision: 'approve' }), { params: { id: 'request-1' } }), + ]) + + expect(responses.map((response) => response.status)).toEqual([401, 401, 401, 401]) + expect(submitTaskDetailChange).not.toHaveBeenCalled() + expect(decideTaskDetailChange).not.toHaveBeenCalled() + }) + + test('提交和列表使用当前登录人并返回审批结果', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: actor }) + ;(submitTaskDetailChange as jest.Mock).mockResolvedValue({ + mode: 'pending_approval', + request: { id: 'request-1', status: 'pending_manager_approval' }, + }) + ;(listTaskDetailChanges as jest.Mock).mockResolvedValue([{ id: 'request-1' }]) + const operations = [{ kind: 'delete', assetId: 'asset-1' }] + + const submitted = await submitChange(requestWith({ operations }), { params: { id: 'material-1' } }) + const listed = await listChanges({} as any, { params: { id: 'material-1' } }) + + expect(submitted.status).toBe(200) + await expect(submitted.json()).resolves.toMatchObject({ + success: true, + mode: 'pending_approval', + request: { id: 'request-1' }, + }) + expect(submitTaskDetailChange).toHaveBeenCalledWith('material-1', actor, operations) + expect(listTaskDetailChanges).toHaveBeenCalledWith('material-1', actor) + await expect(listed.json()).resolves.toEqual({ success: true, data: [{ id: 'request-1' }] }) + }) + + test('详情与批准接口透传审批服务结果', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: actor }) + ;(getTaskDetailChangeRequest as jest.Mock).mockResolvedValue({ id: 'request-1', canDecide: true }) + ;(decideTaskDetailChange as jest.Mock).mockResolvedValue({ status: 'applied' }) + + const detail = await getChange({} as any, { params: { id: 'request-1' } }) + const decision = await decideChange( + requestWith({ decision: 'approve' }), + { params: { id: 'request-1' } }, + ) + + await expect(detail.json()).resolves.toEqual({ success: true, data: { id: 'request-1', canDecide: true } }) + await expect(decision.json()).resolves.toEqual({ success: true, data: { status: 'applied' } }) + expect(decideTaskDetailChange).toHaveBeenCalledWith('request-1', actor, 'approve') + }) + + test('非法决定在路由层返回 400', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: actor }) + + const response = await decideChange( + requestWith({ decision: 'later' }), + { params: { id: 'request-1' } }, + ) + + expect(response.status).toBe(400) + expect(decideTaskDetailChange).not.toHaveBeenCalled() + }) + + test('审批业务错误保留状态码和用户可读文案', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: actor }) + ;(getTaskDetailChangeRequest as jest.Mock).mockRejectedValue( + new TaskDetailChangeApprovalError('您无权查看这条图片变更申请', 403), + ) + + const response = await getChange({} as any, { params: { id: 'request-1' } }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ success: false, error: '您无权查看这条图片变更申请' }) + }) +}) diff --git a/lib/__tests__/task-detail-preview-runs.test.ts b/lib/__tests__/task-detail-preview-runs.test.ts index b6aee14..e5b7d89 100644 --- a/lib/__tests__/task-detail-preview-runs.test.ts +++ b/lib/__tests__/task-detail-preview-runs.test.ts @@ -37,6 +37,14 @@ describe('任务-详情预览名解析', () => { }) }) + test('服务端长图切片命名可解析 sliceIndex', () => { + const name = buildServerPreviewFileName('IMG_2002', 3, 1) + expect(parseTaskDetailPreviewFileName(name)).toEqual({ + label: 'IMG_2002', + sliceIndex: 1, + }) + }) + test('非 webp 返回 null', () => { expect(parseTaskDetailPreviewFileName('photo.jpg')).toBeNull() }) @@ -65,6 +73,18 @@ describe('任务-详情预览行切分', () => { expect(runs![1].rows.map((item: any) => item.id)).toEqual(['c', 'd']) }) + test('历史同锚旧 __srv 无 slice 预览按相邻行兼容归并', () => { + const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp' + const runs = groupPreviewRowsIntoRuns([ + row('a', oldSrv, 0), + row('b', oldSrv, 1), + row('c', '1_03-preview.webp', 2), + ]) + expect(runs).not.toBeNull() + expect(runs!.length).toBe(2) + expect(runs![0].rows.map((item: any) => item.id)).toEqual(['a', 'b']) + }) + test('出现无法解析的名字时整体放弃切分', () => { expect(groupPreviewRowsIntoRuns([row('a', 'unknown.png')])).toBeNull() }) @@ -114,6 +134,19 @@ describe('run 与 ZIP 条目的名字匹配', () => { ])! expect(() => matchRunsToZipEntries(runs, ['cover.jpg', 'COVER.png'])).toThrow() }) + + test('真实形态:1_02 两个旧 __srv preview 对一个 ZIP 条目', () => { + const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp' + const runs = groupPreviewRowsIntoRuns([ + row('a', '1_01-preview.webp', 0), + row('b', oldSrv, 1), + row('c', oldSrv, 2), + row('d', '1_03-preview.webp', 3), + ])! + expect(runs.length).toBe(3) + expect(runs[1].rows.map((item: any) => item.id)).toEqual(['b', 'c']) + expect(matchRunsToZipEntries(runs, ['1_01.jpg', '1_02.jpg', '1_03.jpg'])).toEqual([0, 1, 2]) + }) }) describe('旧代预览名(detail-preview-{时间戳}-{序号}-...)兼容', () => { @@ -164,6 +197,22 @@ describe('旧代预览名(detail-preview-{时间戳}-{序号}-...)兼容', ( expect(aligned![2]).toBe(2) }) + test('混合旧代序号与历史无 slice 的 __srv 双切片时仍可对齐', () => { + const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp' + const rows = [ + row('a', legacyName(0), 0), + row('b', oldSrv, 1), + row('c', oldSrv, 2), + row('d', legacyName(2), 3), + ] + const runs = buildRunsWithLegacyIndex(rows) + expect(runs).not.toBeNull() + expect(runs!.length).toBe(3) + expect(runs![1].rows.map((item: any) => item.id)).toEqual(['b', 'c']) + const aligned = alignRunsToZipEntriesByOrder(runs!, ['1_01.jpg', '1_02.jpg', '1_03.jpg']) + expect(aligned).toEqual([0, 1, 2]) + }) + test('删除后再编辑:条目与序号同删,相对顺序对齐依然成立', () => { // 原 4 张删掉序号 1:剩条目 3 个、旧代序号 {0,2,3} const rows = [0, 2, 3].map((index) => row(`r${index}`, legacyName(index), index)) diff --git a/lib/task-detail-asset-edit.ts b/lib/task-detail-asset-edit.ts index c2f7cca..af0e923 100644 --- a/lib/task-detail-asset-edit.ts +++ b/lib/task-detail-asset-edit.ts @@ -33,7 +33,10 @@ import { listZipEntries, } from '@/lib/zip-entry-reader' import { resolveStoredUploadPath } from '@/lib/upload-paths' +import { execFile } from 'child_process' +import { readFile, stat, writeFile } from 'fs/promises' import path from 'path' +import { promisify } from 'util' /** * 任务-详情素材的单图编辑(替换 / 改名 / 删除)。 @@ -100,6 +103,7 @@ interface EditContext { currentPreviewUrl: string | null wasFullSet: boolean operatorUserId: string + approvalRequestId?: string psdCount: number group: TaskDetailEditGroup rowsByGroup: Record @@ -111,11 +115,20 @@ interface EditContext { targetEntryIndex: number } +export interface ServerTrustedTaskDetailStagedFile { + trusted: true + filePath: string + fileName: string + fileSize?: number +} + type EditMutation = | { kind: 'replace'; filePath: string; replacementFileName: string; buffer: Buffer } | { kind: 'rename'; newBaseName: string } | { kind: 'delete' } +const execFileAsync = promisify(execFile) + // ---------- 预览名解析、run 切分与条目匹配 ---------- interface ParsedPreviewName { @@ -124,13 +137,46 @@ interface ParsedPreviewName { sliceIndex: number | null } +interface ParsedServerPreviewName extends ParsedPreviewName { + suffix: string +} + +function parseServerPreviewFileName(fileName: string): ParsedServerPreviewName | null { + if (!/\.webp$/i.test(fileName)) { + return null + } + const withoutExtension = fileName.replace(/\.webp$/i, '') + const serverMatch = /^(.+)__srv-\d+-\d+(?:-slice(\d+))?-([a-z0-9]+)$/i.exec(withoutExtension) + if (!serverMatch) { + return null + } + return { + label: serverMatch[1], + sliceIndex: serverMatch[2] !== undefined ? Number(serverMatch[2]) : null, + suffix: withoutExtension.slice(serverMatch[1].length), + } +} + +function isLegacyServerPreviewWithoutSlice(fileName: string): boolean { + const parsed = parseServerPreviewFileName(fileName) + return Boolean(parsed && parsed.sliceIndex === null) +} + +function runContainsOnlyLegacyServerPreviewWithoutSlice(run: PreviewRun | undefined): boolean { + return Boolean( + run + && run.rows.length > 0 + && run.rows.every((row) => isLegacyServerPreviewWithoutSlice(String(row.fileName || ''))), + ) +} + /** * 解析预览文件名,提取"来源文件名"锚点。 * * ZIP 条目顺序在 archiver 并发打包下不保证等于上传顺序,位置映射不可靠, * 所以 run ↔ 条目一律用文件名锚定: * - 客户端命名:`{原始基础名}.webp` / 切片 `{原始基础名}-{三位序号}.webp` - * - 服务端生成:`{原始基础名}__srv-{13位时间戳}-{序号}-{随机串}.webp` + * - 服务端生成:`{原始基础名}__srv-{13位时间戳}-{序号}[-slice{N}]-{随机串}.webp` */ export function parseTaskDetailPreviewFileName(fileName: string): ParsedPreviewName | null { if (!/\.webp$/i.test(fileName)) { @@ -138,9 +184,9 @@ export function parseTaskDetailPreviewFileName(fileName: string): ParsedPreviewN } const withoutExtension = fileName.replace(/\.webp$/i, '') - const serverMatch = /^(.+)__srv-\d+-\d+-[a-z0-9]*$/i.exec(withoutExtension) - if (serverMatch) { - return { label: serverMatch[1], sliceIndex: null } + const server = parseServerPreviewFileName(fileName) + if (server) { + return { label: server.label, sliceIndex: server.sliceIndex } } const sliceMatch = /-(\d{3})$/.exec(withoutExtension) @@ -166,10 +212,15 @@ export function parseTaskDetailPreviewFileName(fileName: string): ParsedPreviewN export function buildServerPreviewFileName( sourceBaseName: string, entryIndex: number, + sliceIndex?: number | null, ): string { const timestamp = Date.now() const randomSuffix = Math.random().toString(36).substring(2, 9) - return `${sourceBaseName}__srv-${timestamp}-${entryIndex}-${randomSuffix}.webp` + const sliceSuffix = + Number.isInteger(sliceIndex) && Number(sliceIndex) >= 0 + ? `-slice${sliceIndex}` + : '' + return `${sourceBaseName}__srv-${timestamp}-${entryIndex}${sliceSuffix}-${randomSuffix}.webp` } /** @@ -275,16 +326,23 @@ export function groupPreviewRowsIntoRuns( } const lastRun = runs[runs.length - 1] - const continuesSliceSequence = + const continuesSliceSequence: boolean = parsed.sliceIndex !== null && parsed.label !== null && lastRun !== undefined && lastRun.key === `name:${parsed.label}` && expectedNextSlice === parsed.sliceIndex + const continuesLegacyServerCompat = + parsed.sliceIndex === null + && parsed.label !== null + && lastRun !== undefined + && lastRun.key === `name:${parsed.label}` + && isLegacyServerPreviewWithoutSlice(String(row.fileName || '')) + && runContainsOnlyLegacyServerPreviewWithoutSlice(lastRun) - if (continuesSliceSequence && lastRun) { + if ((continuesSliceSequence || continuesLegacyServerCompat) && lastRun) { lastRun.rows.push(row) - expectedNextSlice = parsed.sliceIndex! + 1 + expectedNextSlice = continuesSliceSequence ? parsed.sliceIndex! + 1 : null continue } @@ -331,6 +389,32 @@ export function buildRunsWithLegacyIndex( const parsed = parseTaskDetailPreviewFileName(fileName) if (!parsed) return null + const lastRun = runs[runs.length - 1] + const continuesSliceSequence: boolean = + parsed.sliceIndex !== null + && parsed.label !== null + && lastRun !== undefined + && lastRun.key === `name:${parsed.label}` + && lastRun.legacyIndex === null + && (() => { + const lastFileName = String(lastRun.rows[lastRun.rows.length - 1]?.fileName || '') + const lastParsed = parseTaskDetailPreviewFileName(lastFileName) + return lastParsed !== null && lastParsed.sliceIndex !== null && lastParsed.sliceIndex + 1 === parsed.sliceIndex + })() + const continuesLegacyServerCompat = + parsed.sliceIndex === null + && parsed.label !== null + && lastRun !== undefined + && lastRun.key === `name:${parsed.label}` + && lastRun.legacyIndex === null + && isLegacyServerPreviewWithoutSlice(fileName) + && runContainsOnlyLegacyServerPreviewWithoutSlice(lastRun) + + if ((continuesSliceSequence || continuesLegacyServerCompat) && lastRun) { + lastRun.rows.push(row) + continue + } + runs.push({ key: `name:${parsed.label ?? ''}`, rows: [row], @@ -465,9 +549,13 @@ async function assertCanManage( async function loadEditContext(options: { materialId: string - assetId: string + assetId?: string operatorUserId: string operatorRole?: string | null + group?: TaskDetailEditGroup + allowEmptyGroup?: boolean + expectedMaterialUpdatedAt?: Date | string + approvalRequestId?: string }): Promise { const material = await prisma.material.findFirst({ where: { id: options.materialId, deletedAt: null }, @@ -481,6 +569,7 @@ async function loadEditContext(options: { categoryName: true, previewUrl: true, isFullSet: true, + updatedAt: true, }, }) @@ -494,6 +583,16 @@ async function loadEditContext(options: { throw new TaskDetailAssetEditError('素材尚未完成上传,暂不能编辑图片') } + if (options.expectedMaterialUpdatedAt) { + const expected = new Date(options.expectedMaterialUpdatedAt) + if ( + Number.isNaN(expected.getTime()) + || material.updatedAt.getTime() !== expected.getTime() + ) { + throw new TaskDetailAssetEditError('正式素材在审批期间已更新,本申请已停止应用', 409) + } + } + await assertCanManage(material, options.operatorUserId, options.operatorRole) const [previewRows, zipAssetRows, psdCount] = await Promise.all([ @@ -557,15 +656,37 @@ async function loadEditContext(options: { ) } - const targetGroup = GROUPS.find((group) => - rowsByGroup[group].some((row) => row.id === options.assetId), - ) + const targetGroup = options.assetId + ? GROUPS.find((group) => rowsByGroup[group].some((row) => row.id === options.assetId)) + : options.group if (!targetGroup) { throw new TaskDetailAssetEditError('要编辑的图片不存在或不属于该素材', 404) } const groupZip = groupZipAssets[targetGroup] if (!groupZip?.fileUrl) { + if (options.allowEmptyGroup && !options.assetId && rowsByGroup[targetGroup].length === 0) { + return { + materialId: material.id, + materialType: material.materialType, + productModel: material.productModel, + authorName: material.authorName, + categoryName: material.categoryName, + currentPreviewUrl: material.previewUrl, + wasFullSet: Boolean(material.isFullSet), + operatorUserId: options.operatorUserId, + approvalRequestId: options.approvalRequestId, + psdCount, + group: targetGroup, + rowsByGroup, + groupZipAssets, + allZipAsset, + entryNames: [], + runs: [], + targetRunIndex: -1, + targetEntryIndex: -1, + } + } throw new TaskDetailAssetEditError('该分组的压缩包缺失,无法单独编辑图片') } @@ -614,13 +735,20 @@ async function loadEditContext(options: { ) } - const { runs, runEntryIndexes } = resolveRunsAndEntryIndexes() + const { runs, runEntryIndexes } = rowsByGroup[targetGroup].length === 0 && entryNames.length === 0 + ? { runs: [] as PreviewRun[], runEntryIndexes: [] as number[] } + : resolveRunsAndEntryIndexes() - const targetRunIndex = runs.findIndex((run) => - run.rows.some((row) => row.id === options.assetId), - ) - if (targetRunIndex < 0) { - throw new TaskDetailAssetEditError('要编辑的图片不存在或不属于该素材', 404) + let targetRunIndex = -1 + let targetEntryIndex = -1 + if (options.assetId) { + targetRunIndex = runs.findIndex((run) => + run.rows.some((row) => row.id === options.assetId), + ) + if (targetRunIndex < 0) { + throw new TaskDetailAssetEditError('要编辑的图片不存在或不属于该素材', 404) + } + targetEntryIndex = runEntryIndexes[targetRunIndex] } return { @@ -632,6 +760,7 @@ async function loadEditContext(options: { currentPreviewUrl: material.previewUrl, wasFullSet: Boolean(material.isFullSet), operatorUserId: options.operatorUserId, + approvalRequestId: options.approvalRequestId, psdCount, group: targetGroup, rowsByGroup, @@ -640,7 +769,7 @@ async function loadEditContext(options: { entryNames, runs, targetRunIndex, - targetEntryIndex: runEntryIndexes[targetRunIndex], + targetEntryIndex, } } @@ -658,28 +787,395 @@ async function loadSharp() { return sharpModule.default } +interface ResolvedMutationSource { + filePath: string + fileName: string + fileSize: number + buffer: Buffer + metadata: import('sharp').Metadata + animated: boolean +} + +type MutationSourceInput = { + uploadId?: string + stagedFile?: ServerTrustedTaskDetailStagedFile +} + +const IMAGE_FFMPEG_TIMEOUT_MS = 5 * 60 * 1000 +const REMOTE_PREVIEW_MAX_BYTES = 16 * 1024 * 1024 + +function buildTempFilePath(workspaceDir: string, prefix: string, extension: string): string { + const safeExtension = extension.startsWith('.') ? extension : `.${extension}` + return path.join( + workspaceDir, + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}${safeExtension}`, + ) +} + +function getLogicalImageHeight(metadata: import('sharp').Metadata): number { + const pages = Number(metadata.pages || 1) + const pageHeight = Number(metadata.pageHeight || 0) + if (pages > 1 && Number.isInteger(pageHeight) && pageHeight > 0) { + return pageHeight + } + return Number(metadata.height || 0) +} + +function normalizeEntryFileName(fileName: string, fallbackLabel: string): string { + const trimmed = String(fileName || '').trim() + const baseName = trimmed.split(/[\\/]/).pop()?.trim() || '' + if (!baseName) { + throw new TaskDetailAssetEditError(`${fallbackLabel}文件名无效,请重新选择文件`) + } + return baseName +} + +function ensureSingleMutationSource(source: MutationSourceInput, actionLabel: string) { + const hasUploadId = Boolean(String(source.uploadId || '').trim()) + const hasStaged = Boolean(source.stagedFile) + if (hasUploadId === hasStaged) { + throw new TaskDetailAssetEditError(`${actionLabel}来源无效,请重新选择文件`) + } +} + +async function inspectMutationSourceFile(options: { + filePath: string + fileName: string + fileSize: number + actionLabel: string +}): Promise { + if (options.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) { + throw new TaskDetailAssetEditError( + `${options.actionLabel}超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`, + ) + } + + const buffer = await readFile(options.filePath) + const sharp = await loadSharp() + let metadata: import('sharp').Metadata + try { + metadata = await withImageProcessingSlot(() => + sharp(buffer, { + animated: true, + limitInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, + sequentialRead: true, + failOn: 'error', + }).metadata(), + ) + } catch { + throw new TaskDetailAssetEditError(`${options.actionLabel}的文件不是有效图片,请更换文件后重试`) + } + + return { + filePath: options.filePath, + fileName: normalizeEntryFileName(options.fileName, options.actionLabel), + fileSize: options.fileSize, + buffer, + metadata, + animated: Number(metadata.pages || 1) > 1, + } +} + +async function resolveTrustedStagedFile(options: { + stagedFile: ServerTrustedTaskDetailStagedFile + actionLabel: string +}): Promise { + const stagedFile = options.stagedFile + if (!stagedFile || stagedFile.trusted !== true) { + throw new TaskDetailAssetEditError(`${options.actionLabel}来源未通过服务端校验,请重新发起`) + } + const filePath = String(stagedFile.filePath || '').trim() + const fileName = String(stagedFile.fileName || '').trim() + if (!filePath || !path.isAbsolute(filePath)) { + throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件路径无效,请重新发起`) + } + if (!fileName) { + throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件名无效,请重新发起`) + } + + let fileStats: Awaited> + try { + fileStats = await stat(filePath) + } catch { + throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件不存在,请重新发起`) + } + if (!fileStats.isFile()) { + throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件不可读取,请重新发起`) + } + + const declaredSize = Number(stagedFile.fileSize) + if (Number.isFinite(declaredSize) && declaredSize > 0 && declaredSize !== fileStats.size) { + throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件大小校验失败,请重新发起`) + } + + return inspectMutationSourceFile({ + filePath, + fileName, + fileSize: fileStats.size, + actionLabel: options.actionLabel, + }) +} + +async function resolveMutationSource( + options: MutationSourceInput & { operatorUserId: string; actionLabel: string }, +): Promise { + ensureSingleMutationSource(options, options.actionLabel) + if (options.stagedFile) { + return resolveTrustedStagedFile({ + stagedFile: options.stagedFile, + actionLabel: options.actionLabel, + }) + } + + const uploadId = String(options.uploadId || '').trim() + if (!uploadId || !isSafeUploadId(uploadId)) { + throw new TaskDetailAssetEditError(`${options.actionLabel}的上传会话无效,请重新选择文件`) + } + + const session = await getOwnedUploadSession(uploadId, options.operatorUserId) + if (!session) { + throw new TaskDetailAssetEditError(`${options.actionLabel}尚未上传完成或会话已过期,请重新上传`) + } + + const filePath = await getUploadedFile(uploadId) + if (!filePath) { + throw new TaskDetailAssetEditError(`${options.actionLabel}文件缺失,请重新上传`) + } + + return inspectMutationSourceFile({ + filePath, + fileName: session.fileName, + fileSize: session.fileSize, + actionLabel: options.actionLabel, + }) +} + +function getAnimatedSourceOutputExtension(fileName: string): '.gif' | '.webp' { + const extension = getExtension(fileName).toLowerCase() + return extension === '.gif' ? '.gif' : '.webp' +} + +function getStaticOutputExtension(fileName: string): '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif' { + const extension = getExtension(fileName).toLowerCase() + if (extension === '.jpg' || extension === '.jpeg' || extension === '.png' || extension === '.webp' || extension === '.gif') { + return extension as '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif' + } + throw new TaskDetailAssetEditError(`暂不支持处理 ${extension || '该格式'} 图片,请先转换成 JPG、PNG、WEBP 或 GIF`) +} + +async function encodeStaticCroppedImage(options: { + inputPath: string + outputPath: string + width: number + height: number + left: number + top: number + outputExtension: '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif' +}) { + const sharp = await loadSharp() + const pipeline = sharp(options.inputPath, { + limitInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, + sequentialRead: true, + failOn: 'error', + }).extract({ + left: options.left, + top: options.top, + width: options.width, + height: options.height, + }) + + if (options.outputExtension === '.jpg' || options.outputExtension === '.jpeg') { + await pipeline.jpeg({ quality: 95 }).toFile(options.outputPath) + return + } + if (options.outputExtension === '.png') { + await pipeline.png().toFile(options.outputPath) + return + } + if (options.outputExtension === '.gif') { + await pipeline.gif().toFile(options.outputPath) + return + } + await pipeline.webp({ quality: 95 }).toFile(options.outputPath) +} + +async function runFfmpeg(args: string[], actionLabel: string) { + try { + await execFileAsync('ffmpeg', args, { + timeout: IMAGE_FFMPEG_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + windowsHide: true, + } as any) + } catch (error) { + const message = error instanceof Error ? error.message : '未知错误' + if (/ENOENT/i.test(message)) { + throw new TaskDetailAssetEditError(`${actionLabel}失败:当前环境缺少 ffmpeg,无法可靠保留动图时序`) + } + throw new TaskDetailAssetEditError(`${actionLabel}失败:${message}`) + } +} + +async function renderAnimatedPreviewFile(options: { + inputPath: string + outputPath: string + outputWidth: number + outputHeight: number + cropTop: number + cropHeight: number +}) { + const filters = [ + `scale=${options.outputWidth}:${options.outputHeight}:flags=lanczos`, + `crop=${options.outputWidth}:${options.cropHeight}:0:${options.cropTop}`, + ].join(',') + await runFfmpeg( + [ + '-y', + '-i', + options.inputPath, + '-an', + '-vsync', + '0', + '-vf', + filters, + '-c:v', + 'libwebp_anim', + '-loop', + '0', + '-quality', + '75', + '-compression_level', + '6', + options.outputPath, + ], + '动图预览生成', + ) +} + +async function cropAnimatedSourceFile(options: { + inputPath: string + outputPath: string + width: number + height: number + top: number + outputExtension: '.gif' | '.webp' +}) { + const filters = `crop=${options.width}:${options.height}:0:${options.top}` + const args = [ + '-y', + '-i', + options.inputPath, + '-an', + '-vsync', + '0', + '-vf', + filters, + ] + if (options.outputExtension === '.gif') { + args.push('-gifflags', '+transdiff', options.outputPath) + } else { + args.push( + '-c:v', + 'libwebp_anim', + '-loop', + '0', + '-quality', + '80', + '-compression_level', + '6', + options.outputPath, + ) + } + await runFfmpeg(args, '动图长图拆分') +} + +async function generateAnimatedPreviews(options: { + source: ResolvedMutationSource + group: TaskDetailEditGroup + materialId: string + sourceBaseName: string + entryIndex: number + workspaceDir: string +}): Promise> { + const width = Number(options.source.metadata.width) + const height = getLogicalImageHeight(options.source.metadata) + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { + throw new TaskDetailAssetEditError('动图尺寸无效,无法生成预览') + } + + const scaleLimit = options.group === 'detail' + ? TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth + : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewWidth + const scale = width > scaleLimit ? scaleLimit / width : 1 + const outputWidth = Math.max(1, Math.round(width * scale)) + const outputHeight = Math.max(1, Math.round(height * scale)) + const sliceHeightLimit = options.group === 'detail' + ? TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight + : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewHeight + const sliceCount = options.group === 'detail' + ? Math.ceil(outputHeight / sliceHeightLimit) + : 1 + if (sliceCount > TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage) { + throw new TaskDetailAssetEditError(`动图预览切片超过 ${TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage} 张限制`) + } + + const previews: Array<{ fileName: string; fileUrl: string; size: number }> = [] + for (let sliceIndex = 0; sliceIndex < sliceCount; sliceIndex += 1) { + const cropTop = options.group === 'detail' ? sliceIndex * sliceHeightLimit : 0 + const cropHeight = options.group === 'detail' + ? Math.min(sliceHeightLimit, outputHeight - cropTop) + : outputHeight + const fileName = buildServerPreviewFileName( + options.sourceBaseName, + options.entryIndex, + sliceCount > 1 ? sliceIndex : null, + ) + const outputPath = buildTempFilePath(options.workspaceDir, 'animated-preview', '.webp') + await renderAnimatedPreviewFile({ + inputPath: options.source.filePath, + outputPath, + outputWidth, + outputHeight, + cropTop, + cropHeight, + }) + const previewBuffer = await readFile(outputPath) + const fileUrl = await uploadPublicDisplayToS3( + previewBuffer, + buildTaskDetailPublicDisplayPath(options.materialId, options.group, fileName), + 'image/webp', + ) + previews.push({ fileName, fileUrl, size: previewBuffer.length }) + } + return previews +} + /** 按完成链路的服务端兜底参数生成预览(800x1200 webp,长图切片)。 */ async function generateServerPreviews( - imageBuffer: Buffer, + source: ResolvedMutationSource, group: TaskDetailEditGroup, materialId: string, sourceBaseName: string, entryIndex: number, + workspaceDir: string, ): Promise> { - const { compressImage, sliceImage } = await import('@/lib/image-processor') - const sharp = await loadSharp() + if (source.animated) { + return generateAnimatedPreviews({ + source, + group, + materialId, + sourceBaseName, + entryIndex, + workspaceDir, + }) + } - if (imageBuffer.length > 200 * 1024 * 1024) { + const { compressImage, sliceImage } = await import('@/lib/image-processor') + + if (source.buffer.length > 200 * 1024 * 1024) { throw new TaskDetailAssetEditError('替换图片不能超过 200MB') } - const metadata = await withImageProcessingSlot(() => - sharp(imageBuffer, { - limitInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, - sequentialRead: true, - failOn: 'error', - }).metadata(), - ) + const metadata = source.metadata if ( (metadata.width || 0) > SERVER_PREVIEW_SOURCE_MAX_WIDTH @@ -691,7 +1187,7 @@ async function generateServerPreviews( const shouldSlice = group === 'detail' && (metadata.height || 0) > 1500 const buffers = shouldSlice ? await sliceImage!( - imageBuffer, + source.buffer, TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight, 200, TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth, @@ -703,7 +1199,7 @@ async function generateServerPreviews( }, ) : [ - await compressImage(imageBuffer, { + await compressImage(source.buffer, { maxWidth: 800, maxHeight: 1200, maxSizeKB: 200, @@ -717,7 +1213,11 @@ async function generateServerPreviews( const previews: Array<{ fileName: string; fileUrl: string; size: number }> = [] for (let sliceIndex = 0; sliceIndex < buffers.length; sliceIndex += 1) { - const fileName = buildServerPreviewFileName(sourceBaseName, entryIndex) + const fileName = buildServerPreviewFileName( + sourceBaseName, + entryIndex, + buffers.length > 1 ? sliceIndex : null, + ) const fileUrl = await uploadPublicDisplayToS3( buffers[sliceIndex], buildTaskDetailPublicDisplayPath(materialId, group, fileName), @@ -728,46 +1228,217 @@ async function generateServerPreviews( return previews } -async function resolveReplacementUpload(options: { - uploadId: string - operatorUserId: string -}): Promise<{ filePath: string; fileName: string; fileSize: number; buffer: Buffer }> { - const uploadId = String(options.uploadId || '').trim() - if (!uploadId || !isSafeUploadId(uploadId)) { - throw new TaskDetailAssetEditError('替换图片的上传会话无效,请重新选择文件') - } - - const session = await getOwnedUploadSession(uploadId, options.operatorUserId) - if (!session) { - throw new TaskDetailAssetEditError('替换图片尚未上传完成或会话已过期,请重新上传') - } - if (session.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) { - throw new TaskDetailAssetEditError( - `替换图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`, - ) - } - - const filePath = await getUploadedFile(uploadId) - if (!filePath) { - throw new TaskDetailAssetEditError('替换图片文件缺失,请重新上传') - } - - const { readFile } = await import('fs/promises') - const buffer = await readFile(filePath) - +async function loadPreviewRowMetadata(row: PreviewRowLike): Promise<{ width: number; height: number }> { const sharp = await loadSharp() - try { - await withImageProcessingSlot(() => + const target = String(row.fileUrl || '').trim() + if (!target) { + throw new TaskDetailAssetEditError('该预览切片缺少地址,无法确认插图位置。请删除后重新上传。') + } + + let metadata: import('sharp').Metadata + if (/^https?:\/\//i.test(target)) { + const response = await fetch(target) + if (!response.ok) { + throw new TaskDetailAssetEditError('预览切片读取失败,无法确认插图位置。请稍后重试。') + } + const declaredLength = Number(response.headers.get('content-length') || 0) + if (Number.isFinite(declaredLength) && declaredLength > REMOTE_PREVIEW_MAX_BYTES) { + throw new TaskDetailAssetEditError('预览切片过大,无法安全确认插图位置。请删除后重新上传。') + } + const buffer = Buffer.from(await response.arrayBuffer()) + metadata = await withImageProcessingSlot(() => sharp(buffer, { - limitInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, + animated: true, + limitInputPixels: TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels, + sequentialRead: true, + failOn: 'error', + }).metadata(), + ) + } else { + const localPath = path.isAbsolute(target) ? target : resolveStoredUploadPath(target) + metadata = await withImageProcessingSlot(() => + sharp(localPath, { + animated: true, + limitInputPixels: TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels, + sequentialRead: true, failOn: 'error', }).metadata(), ) - } catch { - throw new TaskDetailAssetEditError('替换的文件不是有效图片,请更换文件后重试') } - return { filePath, fileName: session.fileName, fileSize: session.fileSize, buffer } + const width = Number(metadata.width) + const height = Number(metadata.height) + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { + throw new TaskDetailAssetEditError('预览切片尺寸无效,无法确认插图位置。请删除后重新上传。') + } + return { width, height } +} + +function ensureUniqueEntryName(candidateName: string, existingNames: string[], conflictMessage: string): string { + const normalized = normalizeEntryFileName(candidateName, '新增') + if (existingNames.includes(normalized)) { + throw new TaskDetailAssetEditError(conflictMessage, 409) + } + return normalized +} + +function buildDerivedSplitEntryNames(originalName: string, existingNames: string[]): { top: string; bottom: string } { + const baseName = stripExtension(originalName) + const extension = getExtension(originalName) || '.png' + const occupied = new Set(existingNames.filter((name) => name !== originalName)) + const pick = (suffix: 'top' | 'bottom') => { + for (let serial = 1; serial <= 1000; serial += 1) { + const serialSuffix = serial === 1 ? '' : `-${serial}` + const candidate = `${baseName}__${suffix}${serialSuffix}${extension}` + if (!occupied.has(candidate)) { + occupied.add(candidate) + return candidate + } + } + throw new TaskDetailAssetEditError('长图拆分后的文件名无法安全生成,请先改名后再插图', 409) + } + return { top: pick('top'), bottom: pick('bottom') } +} + +function pickSourceSplitY(sourceHeight: number, previewScale: number, seamPreviewHeight: number): number { + const raw = seamPreviewHeight / previewScale + const candidates = Array.from(new Set([ + Math.floor(raw), + Math.round(raw), + Math.ceil(raw), + ])).filter((value) => value > 0 && value < sourceHeight) + if (candidates.length === 0) { + throw new TaskDetailAssetEditError('插图位置超出原图有效范围,无法安全处理。请删除后重新上传。') + } + + let best = candidates[0] + let bestDiff = Number.POSITIVE_INFINITY + for (const candidate of candidates) { + const previewHeight = Math.max(1, Math.round(candidate * previewScale)) + const diff = Math.abs(previewHeight - seamPreviewHeight) + if (diff < bestDiff) { + best = candidate + bestDiff = diff + } + } + if (bestDiff > 1) { + throw new TaskDetailAssetEditError('预览切片与原图缩放规则不一致,无法安全插图。请删除后重新上传。') + } + return best +} + +async function splitRunSourceAtDetailSeam(options: { + run: PreviewRun + entry: { path: string; name: string; size: number } + afterRowIndex: number + workspaceDir: string + existingEntryNames: string[] +}): Promise<{ top: ResolvedMutationSource & { entryName: string }; bottom: ResolvedMutationSource & { entryName: string } }> { + const previewMetadatas = await Promise.all(options.run.rows.map((row) => loadPreviewRowMetadata(row))) + const previewWidth = previewMetadatas[0]?.width ?? 0 + if (!Number.isInteger(previewWidth) || previewWidth < 1) { + throw new TaskDetailAssetEditError('预览切片宽度无效,无法确认插图位置。请删除后重新上传。') + } + if (previewMetadatas.some((item) => item.width !== previewWidth)) { + throw new TaskDetailAssetEditError('同一长图的预览切片宽度不一致,无法安全插图。请删除后重新上传。') + } + + const source = await inspectMutationSourceFile({ + filePath: options.entry.path, + fileName: options.entry.name, + fileSize: options.entry.size, + actionLabel: '原图拆分', + }) + const sourceWidth = Number(source.metadata.width) + const sourceHeight = getLogicalImageHeight(source.metadata) + if (!Number.isInteger(sourceWidth) || !Number.isInteger(sourceHeight) || sourceWidth < 1 || sourceHeight < 2) { + throw new TaskDetailAssetEditError('原图尺寸无效,无法安全插图。请删除后重新上传。') + } + + const previewScale = previewWidth / sourceWidth + if (!(previewScale > 0) || previewScale > 1) { + throw new TaskDetailAssetEditError('预览切片与原图缩放比例异常,无法安全插图。请删除后重新上传。') + } + + const totalPreviewHeight = previewMetadatas.reduce((sum, item) => sum + item.height, 0) + const expectedPreviewHeight = Math.max(1, Math.round(sourceHeight * previewScale)) + if (Math.abs(totalPreviewHeight - expectedPreviewHeight) > 1) { + throw new TaskDetailAssetEditError('预览切片总高度与原图缩放规则不一致,无法安全插图。请删除后重新上传。') + } + + const seamPreviewHeight = previewMetadatas + .slice(0, options.afterRowIndex + 1) + .reduce((sum, item) => sum + item.height, 0) + if (seamPreviewHeight <= 0 || seamPreviewHeight >= totalPreviewHeight) { + throw new TaskDetailAssetEditError('插图位置不在长图内部,无法安全处理。请重新选择位置。') + } + + const splitY = pickSourceSplitY(sourceHeight, previewScale, seamPreviewHeight) + const { top: topEntryName, bottom: bottomEntryName } = buildDerivedSplitEntryNames( + options.entry.name, + options.existingEntryNames, + ) + + const topExt = source.animated ? getAnimatedSourceOutputExtension(topEntryName) : getStaticOutputExtension(topEntryName) + const bottomExt = source.animated ? getAnimatedSourceOutputExtension(bottomEntryName) : getStaticOutputExtension(bottomEntryName) + const topPath = buildTempFilePath(options.workspaceDir, 'detail-split-top', topExt) + const bottomPath = buildTempFilePath(options.workspaceDir, 'detail-split-bottom', bottomExt) + + if (source.animated) { + await cropAnimatedSourceFile({ + inputPath: options.entry.path, + outputPath: topPath, + width: sourceWidth, + height: splitY, + top: 0, + outputExtension: topExt as '.gif' | '.webp', + }) + await cropAnimatedSourceFile({ + inputPath: options.entry.path, + outputPath: bottomPath, + width: sourceWidth, + height: sourceHeight - splitY, + top: splitY, + outputExtension: bottomExt as '.gif' | '.webp', + }) + } else { + await encodeStaticCroppedImage({ + inputPath: options.entry.path, + outputPath: topPath, + width: sourceWidth, + height: splitY, + left: 0, + top: 0, + outputExtension: topExt as '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif', + }) + await encodeStaticCroppedImage({ + inputPath: options.entry.path, + outputPath: bottomPath, + width: sourceWidth, + height: sourceHeight - splitY, + left: 0, + top: splitY, + outputExtension: bottomExt as '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif', + }) + } + + const topSource = await inspectMutationSourceFile({ + filePath: topPath, + fileName: topEntryName.replace(/\.[^/.]+$/, '') + topExt, + fileSize: (await stat(topPath)).size, + actionLabel: '长图上半段', + }) + const bottomSource = await inspectMutationSourceFile({ + filePath: bottomPath, + fileName: bottomEntryName.replace(/\.[^/.]+$/, '') + bottomExt, + fileSize: (await stat(bottomPath)).size, + actionLabel: '长图下半段', + }) + + return { + top: { ...topSource, entryName: normalizeEntryFileName(topSource.fileName, '长图上半段') }, + bottom: { ...bottomSource, entryName: normalizeEntryFileName(bottomSource.fileName, '长图下半段') }, + } } function buildFinalEntryName(originalName: string, replacementFileName: string): string { @@ -790,6 +1461,24 @@ function sanitizeNewBaseName(rawName: string): string { return trimmed } +function buildRenamedPreviewFileName(currentName: string, newBaseName: string): string | null { + const parsed = parseTaskDetailPreviewFileName(currentName) + if (!parsed?.label) { + return null + } + if (parsed.label.toLowerCase() === newBaseName.toLowerCase()) { + return currentName + } + const serverTail = parseServerPreviewFileName(currentName) + if (serverTail) { + return `${newBaseName}${serverTail.suffix}.webp` + } + const sliceSuffixMatch = /-\d{3}(?=\.webp$)/i.exec(currentName) + return sliceSuffixMatch + ? `${newBaseName}${sliceSuffixMatch[0]}.webp` + : `${newBaseName}.webp` +} + // ---------- 变更计划与执行 ---------- interface PlannedDbChanges { @@ -821,6 +1510,7 @@ async function planPreviewRowChanges( context: EditContext, mutation: EditMutation, finalEntryName: string, + workspaceDir: string, ): Promise { const { group, runs, targetRunIndex, targetEntryIndex } = context const targetRun = runs[targetRunIndex] @@ -840,12 +1530,19 @@ async function planPreviewRowChanges( planned.removedPreviewUrls.push({ url: row.fileUrl }) } } else if (mutation.kind === 'replace') { + const replacementSource = await inspectMutationSourceFile({ + filePath: mutation.filePath, + fileName: finalEntryName, + fileSize: mutation.buffer.length, + actionLabel: '替换图片', + }) const generated = await generateServerPreviews( - mutation.buffer, + replacementSource, group, context.materialId, stripExtension(finalEntryName), targetEntryIndex, + workspaceDir, ) const barcodes = await loadRunBarcodes(targetRun.rows.map((row) => row.id)) for (const row of targetRun.rows) { @@ -868,25 +1565,8 @@ async function planPreviewRowChanges( const newBaseName = stripExtension(finalEntryName) for (const row of targetRun.rows) { const currentName = String(row.fileName || '') - const parsed = parseTaskDetailPreviewFileName(currentName) - if (!parsed?.label) { - continue - } - if (parsed.label.toLowerCase() === newBaseName.toLowerCase()) { - continue - } - const serverTail = /^.*(__srv-\d+-\d+-[a-z0-9]*)$/i.exec( - currentName.replace(/\.webp$/i, ''), - ) - let nextName: string - if (serverTail) { - nextName = `${newBaseName}${serverTail[1]}.webp` - } else { - const sliceSuffixMatch = /-\d{3}(?=\.webp$)/i.exec(currentName) - nextName = sliceSuffixMatch - ? `${newBaseName}${sliceSuffixMatch[0]}.webp` - : `${newBaseName}.webp` - } + const nextName = buildRenamedPreviewFileName(currentName, newBaseName) + if (!nextName) continue if (nextName !== currentName) { planned.fileNameUpdates.push({ id: row.id, fileName: nextName }) } @@ -923,11 +1603,273 @@ async function loadRunBarcodes(assetIds: string[]): Promise { // ---------- 批量编辑:序列应用 + 一次重建 ---------- +interface ExistingBatchRowState { + kind: 'existing' + id: string + fileUrl: string + fileName: string + originalFileName: string + sortOrder: number + originalSortOrder: number + skuCode: string | null +} + +interface NewBatchRowState { + kind: 'new' + /** 同一长图被拆分后,继续定位用户最初看到的 preview seam。 */ + sourceAssetId?: string + fileUrl: string + fileName: string + fileSize: bigint + sortOrder: number + skuCode: string | null + barcodes: string[] +} + +type BatchRowState = ExistingBatchRowState | NewBatchRowState + +interface BatchRunState { + entry: { path: string; name: string; size: number } + rows: BatchRowState[] +} + +type PreparedBatchMutation = + | { kind: 'delete' } + | { kind: 'rename'; newBaseName: string } + | { kind: 'replace'; source: ResolvedMutationSource } + | { kind: 'append'; group: 'main' | 'sku'; source: ResolvedMutationSource; skuCode: string | null; barcodes: string[] } + | { kind: 'insert'; afterAssetId: string; beforeAssetId: string; source: ResolvedMutationSource } + interface PreparedBatchOperation { - assetId: string - mutation: EditMutation - /** replace 操作消费的上传会话,提交成功后清理 */ - uploadId?: string + assetId?: string + mutation: PreparedBatchMutation + /** 上传会话来源的操作在提交成功后清理 */ + cleanupUploadId?: string +} + +function createExistingBatchRowState(row: PreviewRowLike): ExistingBatchRowState { + const fileName = String(row.fileName || '') + return { + kind: 'existing', + id: row.id, + fileUrl: row.fileUrl, + fileName, + originalFileName: fileName, + sortOrder: row.sortOrder, + originalSortOrder: row.sortOrder, + skuCode: row.skuCode?.trim() || null, + } +} + +async function buildNewBatchRunState(options: { + source: ResolvedMutationSource + entryName: string + group: TaskDetailEditGroup + materialId: string + entryIndex: number + workspaceDir: string + skuCode?: string | null + barcodes?: string[] + sourceAssetIds?: string[] +}): Promise { + const previews = await generateServerPreviews( + options.source, + options.group, + options.materialId, + stripExtension(options.entryName), + options.entryIndex, + options.workspaceDir, + ) + if (options.sourceAssetIds && options.sourceAssetIds.length !== previews.length) { + throw new TaskDetailAssetEditError('长图拆分后的预览数量与原接缝不一致,无法继续批量插图,请刷新后重试') + } + return { + entry: { + path: options.source.filePath, + name: options.entryName, + size: options.source.fileSize, + }, + rows: previews.map((preview, index) => ({ + kind: 'new', + sourceAssetId: options.sourceAssetIds?.[index], + fileUrl: preview.fileUrl, + fileName: preview.fileName, + fileSize: BigInt(preview.size), + sortOrder: index, + skuCode: options.skuCode?.trim() || null, + barcodes: index === 0 ? Array.from(new Set((options.barcodes || []).filter(Boolean))) : [], + })), + } +} + +function flattenBatchRows(runs: BatchRunState[]): Array<{ runIndex: number; rowIndex: number; row: BatchRowState }> { + const flattened: Array<{ runIndex: number; rowIndex: number; row: BatchRowState }> = [] + for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { + for (let rowIndex = 0; rowIndex < runs[runIndex].rows.length; rowIndex += 1) { + flattened.push({ runIndex, rowIndex, row: runs[runIndex].rows[rowIndex] }) + } + } + return flattened +} + +function findBatchRowByAssetId( + runs: BatchRunState[], + assetId: string, +): { runIndex: number; rowIndex: number; row: ExistingBatchRowState } | null { + for (const located of flattenBatchRows(runs)) { + if (located.row.kind === 'existing' && located.row.id === assetId) { + return { + runIndex: located.runIndex, + rowIndex: located.rowIndex, + row: located.row, + } + } + } + return null +} + +function countSourceRunsForGroupRows(rows: PreviewRowLike[]): number { + if (rows.length === 0) return 0 + const anchoredRuns = groupPreviewRowsIntoRuns(rows) + if (anchoredRuns) return anchoredRuns.length + const legacyRuns = buildRunsWithLegacyIndex(rows) + if (legacyRuns) return legacyRuns.length + return rows.length +} + +function assertFinalBatchLimits( + context: EditContext, + group: TaskDetailEditGroup, + finalRuns: BatchRunState[], +) { + const finalPreviewCount = finalRuns.reduce((sum, run) => sum + run.rows.length, 0) + if (finalRuns.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) { + throw new TaskDetailAssetEditError( + `${GROUP_ZIP_LABELS[group]}最多保留 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批处理`, + ) + } + + let otherSourceCount = 0 + let otherPreviewCount = 0 + for (const otherGroup of GROUPS) { + if (otherGroup === group) continue + otherSourceCount += countSourceRunsForGroupRows(context.rowsByGroup[otherGroup]) + otherPreviewCount += context.rowsByGroup[otherGroup].length + } + + if (otherSourceCount + finalRuns.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount) { + throw new TaskDetailAssetEditError( + `本次图片最多保留 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张,请分批处理`, + ) + } + if (otherSourceCount + finalRuns.length + context.psdCount > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount) { + throw new TaskDetailAssetEditError( + `本次原图和源文件最多保留 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount} 个,请分批处理`, + ) + } + if (otherPreviewCount + finalPreviewCount > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount) { + throw new TaskDetailAssetEditError( + `本次预览图最多保留 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount} 张,请分批处理`, + ) + } +} + +function buildBatchDbChanges( + context: EditContext, + group: TaskDetailEditGroup, + finalRuns: BatchRunState[], +): { db: PlannedDbChanges; finalRowUrls: string[] } { + const db: PlannedDbChanges = { + previewRowsToDelete: [], + previewRowsToInsert: [], + sortOrderUpdates: [], + fileNameUpdates: [], + removedPreviewUrls: [], + } + + const survivingExistingIds = new Set() + const finalRowUrls: string[] = [] + let cursor = 0 + for (const run of finalRuns) { + for (const row of run.rows) { + row.sortOrder = cursor + finalRowUrls.push(row.fileUrl) + if (row.kind === 'existing') { + survivingExistingIds.add(row.id) + if (row.originalSortOrder !== cursor) { + db.sortOrderUpdates.push({ id: row.id, sortOrder: cursor }) + } + if (row.originalFileName !== row.fileName) { + db.fileNameUpdates.push({ id: row.id, fileName: row.fileName }) + } + } else { + db.previewRowsToInsert.push({ + assetType: GROUP_PREVIEW_TYPES[group], + fileUrl: row.fileUrl, + fileName: row.fileName, + fileSize: row.fileSize, + sortOrder: cursor, + skuCode: row.skuCode, + barcodes: row.barcodes, + }) + } + cursor += 1 + } + } + + for (const row of context.rowsByGroup[group]) { + if (!survivingExistingIds.has(row.id)) { + db.previewRowsToDelete.push(row.id) + db.removedPreviewUrls.push({ url: row.fileUrl }) + } + } + + return { db, finalRowUrls } +} + +function resolveInsertSeam( + runs: BatchRunState[], + afterAssetId: string, + beforeAssetId: string, +): { afterRunIndex: number; afterRowIndex: number; beforeRunIndex: number; beforeRowIndex: number; sameRun: boolean } { + const flattened = flattenBatchRows(runs) + const matchesAssetId = (row: BatchRowState, assetId: string) => ( + row.kind === 'existing' ? row.id === assetId : row.sourceAssetId === assetId + ) + const afterFlatIndex = flattened.findIndex((item) => matchesAssetId(item.row, afterAssetId)) + const beforeFlatIndex = flattened.findIndex((item) => matchesAssetId(item.row, beforeAssetId)) + if (afterFlatIndex < 0 || beforeFlatIndex < 0) { + throw new TaskDetailAssetEditError('插图位置不存在或已被本次批量操作改动,请刷新后重试', 404) + } + if (beforeFlatIndex !== afterFlatIndex + 1) { + throw new TaskDetailAssetEditError('插图位置必须落在相邻预览之间,请重新选择位置') + } + + const after = flattened[afterFlatIndex] + const before = flattened[beforeFlatIndex] + if (after.runIndex === before.runIndex) { + return { + afterRunIndex: after.runIndex, + afterRowIndex: after.rowIndex, + beforeRunIndex: before.runIndex, + beforeRowIndex: before.rowIndex, + sameRun: true, + } + } + if ( + before.runIndex !== after.runIndex + 1 + || after.rowIndex !== runs[after.runIndex].rows.length - 1 + || before.rowIndex !== 0 + ) { + throw new TaskDetailAssetEditError('插图位置跨越了不连续的图片分组,无法安全处理') + } + return { + afterRunIndex: after.runIndex, + afterRowIndex: after.rowIndex, + beforeRunIndex: before.runIndex, + beforeRowIndex: before.rowIndex, + sameRun: false, + } } /** @@ -939,163 +1881,231 @@ async function executeBatchRebuild( operations: PreparedBatchOperation[], ): Promise { const { group, entryNames } = context - const previewType = GROUP_PREVIEW_TYPES[group] const workspace = await createTaskDetailEditWorkspace() try { - const groupZip = context.groupZipAssets[group]! - const groupZipAbsolute = path.isAbsolute(groupZip.fileUrl) - ? groupZip.fileUrl - : resolveStoredUploadPath(groupZip.fileUrl) + const extracted = context.groupZipAssets[group]?.fileUrl + ? await extractZipEntriesToDirectory( + path.isAbsolute(context.groupZipAssets[group]!.fileUrl) + ? context.groupZipAssets[group]!.fileUrl + : resolveStoredUploadPath(context.groupZipAssets[group]!.fileUrl), + workspace.dir, + ) + : [] - const extracted = await extractZipEntriesToDirectory(groupZipAbsolute, workspace.dir) - - // 存活条目与存活 run 一一对应,随操作同步增删 - const entries: Array<{ path: string; name: string; size: number }> = extracted.map( - (item, index) => ({ - path: item.filePath, + const finalRuns: BatchRunState[] = context.runs.map((run, index) => ({ + entry: { + path: extracted[index]?.filePath || '', name: entryNames[index], - size: item.size, - }), - ) - const runs: PreviewRun[] = context.runs.map((run) => ({ ...run, rows: [...run.rows] })) + size: extracted[index]?.size || 0, + }, + rows: run.rows.map((row) => createExistingBatchRowState(row)), + })) - const db: PlannedDbChanges = { - previewRowsToDelete: [], - previewRowsToInsert: [], - sortOrderUpdates: [], - fileNameUpdates: [], - removedPreviewUrls: [], - } - // 被替换 run(rows 清空)→ 其新插入行在 previewRowsToInsert 中的区间,最终统一重排 - const replacedInsertsByRunIndex = new Map() const handledAssetIds = new Set() + const repeatedInsertBoundaries = new Map() for (const operation of operations) { - if (handledAssetIds.has(operation.assetId)) { - throw new TaskDetailAssetEditError('同一张图在一次批量提交中只能操作一次,请分开提交') + if (operation.assetId) { + if (handledAssetIds.has(operation.assetId)) { + throw new TaskDetailAssetEditError('同一张图在一次批量提交中只能操作一次,请分开提交') + } + handledAssetIds.add(operation.assetId) } - handledAssetIds.add(operation.assetId) - const runIndex = runs.findIndex((run) => run.rows.some((row) => row.id === operation.assetId)) - if (runIndex < 0) { + if (operation.mutation.kind === 'append') { + if (group !== operation.mutation.group) { + throw new TaskDetailAssetEditError('追加图片只支持当前分组,请刷新后重试') + } + const entryName = ensureUniqueEntryName( + operation.mutation.source.fileName, + finalRuns.map((run) => run.entry.name), + '追加图片的文件名与包内其他文件重名,请先改名后再追加', + ) + finalRuns.push(await buildNewBatchRunState({ + source: operation.mutation.source, + entryName, + group, + materialId: context.materialId, + entryIndex: finalRuns.length, + workspaceDir: workspace.dir, + skuCode: operation.mutation.skuCode, + barcodes: operation.mutation.barcodes, + })) + continue + } + + if (operation.mutation.kind === 'insert') { + if (group !== 'detail') { + throw new TaskDetailAssetEditError('插图仅支持详情图分组') + } + + const seamKey = `${operation.mutation.afterAssetId}\u0000${operation.mutation.beforeAssetId}` + const existingEntryNames = finalRuns.map((run) => run.entry.name) + const insertedEntryName = ensureUniqueEntryName( + operation.mutation.source.fileName, + existingEntryNames, + '插入图片的文件名与包内其他文件重名,请先改名后再插入', + ) + + const repeatedBoundary = repeatedInsertBoundaries.get(seamKey) + if (repeatedBoundary) { + const boundaryIndex = finalRuns.indexOf(repeatedBoundary) + if (boundaryIndex < 0) { + throw new TaskDetailAssetEditError('插图位置已被本次批量操作移除,请刷新后重试', 409) + } + const insertedRun = await buildNewBatchRunState({ + source: operation.mutation.source, + entryName: insertedEntryName, + group, + materialId: context.materialId, + entryIndex: boundaryIndex, + workspaceDir: workspace.dir, + }) + finalRuns.splice(boundaryIndex, 0, insertedRun) + continue + } + + const seam = resolveInsertSeam( + finalRuns, + operation.mutation.afterAssetId, + operation.mutation.beforeAssetId, + ) + + if (!seam.sameRun) { + const boundaryRun = finalRuns[seam.beforeRunIndex] + const insertedRun = await buildNewBatchRunState({ + source: operation.mutation.source, + entryName: insertedEntryName, + group, + materialId: context.materialId, + entryIndex: seam.afterRunIndex + 1, + workspaceDir: workspace.dir, + }) + finalRuns.splice(seam.beforeRunIndex, 0, insertedRun) + repeatedInsertBoundaries.set(seamKey, boundaryRun) + continue + } + + const targetRun = finalRuns[seam.afterRunIndex] + const sourceAssetIds = targetRun.rows.map((row) => ( + row.kind === 'existing' ? row.id : row.sourceAssetId || null + )) + if (sourceAssetIds.some((assetId) => !assetId)) { + throw new TaskDetailAssetEditError('长图接缝已被本次批量操作改变,请刷新后重试', 409) + } + const splitSources = await splitRunSourceAtDetailSeam({ + run: { + key: context.runs[0]?.key || targetRun.entry.name, + rows: targetRun.rows.map((row) => ({ + id: row.kind === 'existing' ? row.id : `new-${row.sortOrder}`, + assetType: GROUP_PREVIEW_TYPES[group], + fileUrl: row.fileUrl, + fileName: row.fileName, + sortOrder: row.sortOrder, + skuCode: row.skuCode, + })), + legacyIndex: null, + }, + entry: targetRun.entry, + afterRowIndex: seam.afterRowIndex, + workspaceDir: workspace.dir, + existingEntryNames, + }) + const topRun = await buildNewBatchRunState({ + source: splitSources.top, + entryName: splitSources.top.entryName, + group, + materialId: context.materialId, + entryIndex: seam.afterRunIndex, + workspaceDir: workspace.dir, + sourceAssetIds: sourceAssetIds.slice(0, seam.afterRowIndex + 1) as string[], + }) + const insertedRun = await buildNewBatchRunState({ + source: operation.mutation.source, + entryName: insertedEntryName, + group, + materialId: context.materialId, + entryIndex: seam.afterRunIndex + 1, + workspaceDir: workspace.dir, + }) + const bottomRun = await buildNewBatchRunState({ + source: splitSources.bottom, + entryName: splitSources.bottom.entryName, + group, + materialId: context.materialId, + entryIndex: seam.afterRunIndex + 2, + workspaceDir: workspace.dir, + sourceAssetIds: sourceAssetIds.slice(seam.beforeRowIndex) as string[], + }) + finalRuns.splice(seam.afterRunIndex, 1, topRun, insertedRun, bottomRun) + repeatedInsertBoundaries.set(seamKey, bottomRun) + continue + } + + const assetId = operation.assetId + if (!assetId) { + throw new TaskDetailAssetEditError('当前操作缺少目标图片,无法继续') + } + const located = findBatchRowByAssetId(finalRuns, assetId) + if (!located) { throw new TaskDetailAssetEditError('要编辑的图片不存在或已被本次批量操作移除', 404) } + const run = finalRuns[located.runIndex] if (operation.mutation.kind === 'delete') { - for (const row of runs[runIndex].rows) { - db.previewRowsToDelete.push(row.id) - db.removedPreviewUrls.push({ url: row.fileUrl }) - } - entries.splice(runIndex, 1) - runs.splice(runIndex, 1) + finalRuns.splice(located.runIndex, 1) continue } if (operation.mutation.kind === 'rename') { - const originalExtension = getExtension(entries[runIndex].name) - const finalEntryName = `${operation.mutation.newBaseName}${originalExtension}` - const conflict = entries.some((entry, index) => index !== runIndex && entry.name === finalEntryName) + const originalExtension = getExtension(run.entry.name) + const newBaseName = operation.mutation.newBaseName + const finalEntryName = `${newBaseName}${originalExtension}` + const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && candidate.entry.name === finalEntryName) if (conflict) { throw new TaskDetailAssetEditError(`改名后的「${finalEntryName}」与包内其他文件重名,请换一个名字`, 409) } - entries[runIndex] = { ...entries[runIndex], name: finalEntryName } - - // 同步预览行文件名里的来源锚(与单张 rename 分支同一套规则) - const newBaseName = operation.mutation.newBaseName - for (const row of runs[runIndex].rows) { - const currentName = String(row.fileName || '') - const parsed = parseTaskDetailPreviewFileName(currentName) - if (!parsed?.label) continue - if (parsed.label.toLowerCase() === newBaseName.toLowerCase()) continue - const serverTail = /^.*(__srv-\d+-\d+-[a-z0-9]*)$/i.exec( - currentName.replace(/\.webp$/i, ''), - ) - let nextName: string - if (serverTail) { - nextName = `${newBaseName}${serverTail[1]}.webp` - } else { - const sliceSuffixMatch = /-\d{3}(?=\.webp$)/i.exec(currentName) - nextName = sliceSuffixMatch - ? `${newBaseName}${sliceSuffixMatch[0]}.webp` - : `${newBaseName}.webp` - } - if (nextName !== currentName) { - db.fileNameUpdates.push({ id: row.id, fileName: nextName }) - } - } + run.entry = { ...run.entry, name: finalEntryName } + run.rows = run.rows.map((row) => { + const nextName = buildRenamedPreviewFileName(row.fileName, newBaseName) + if (!nextName || nextName === row.fileName) return row + return { ...row, fileName: nextName } + }) continue } - // replace - const finalEntryName = buildFinalEntryName(entries[runIndex].name, operation.mutation.replacementFileName) - const conflict = entries.some((entry, index) => index !== runIndex && entry.name === finalEntryName) + const finalEntryName = buildFinalEntryName(run.entry.name, operation.mutation.source.fileName) + const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && candidate.entry.name === finalEntryName) if (conflict) { throw new TaskDetailAssetEditError(`替换后的「${finalEntryName}」与包内其他文件重名,请先改名再替换`, 409) } - entries[runIndex] = { - path: operation.mutation.filePath, - name: finalEntryName, - size: 0, - } - - const generated = await generateServerPreviews( - operation.mutation.buffer, - group, - context.materialId, - stripExtension(finalEntryName), - runIndex, - ) const barcodes = group === 'sku' - ? await loadRunBarcodes(runs[runIndex].rows.map((row) => row.id)) + ? await loadRunBarcodes( + run.rows + .filter((row): row is ExistingBatchRowState => row.kind === 'existing') + .map((row) => row.id), + ) : [] - for (const row of runs[runIndex].rows) { - db.previewRowsToDelete.push(row.id) - db.removedPreviewUrls.push({ url: row.fileUrl }) - } - const startIndex = db.previewRowsToInsert.length - generated.forEach((preview, index) => { - db.previewRowsToInsert.push({ - assetType: previewType, - fileUrl: preview.fileUrl, - fileName: preview.fileName, - fileSize: BigInt(preview.size), - sortOrder: runIndex + index, - skuCode: runs[runIndex].rows[0]?.skuCode?.trim() || null, - barcodes: index === 0 ? barcodes : [], - }) + finalRuns[located.runIndex] = await buildNewBatchRunState({ + source: operation.mutation.source, + entryName: finalEntryName, + group, + materialId: context.materialId, + entryIndex: located.runIndex, + workspaceDir: workspace.dir, + skuCode: run.rows[0]?.skuCode || null, + barcodes, }) - replacedInsertsByRunIndex.set(runIndex, { startIndex, count: generated.length }) - runs[runIndex] = { ...runs[runIndex], rows: [] } } - // 最终统一重排:按 run 顺序展开"存活旧行 + 替换插入行",重写 sortOrder - const finalRowUrls: string[] = [] - let cursor = 0 - for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { - const replaced = replacedInsertsByRunIndex.get(runIndex) - if (replaced) { - for (let offset = 0; offset < replaced.count; offset += 1) { - const row = db.previewRowsToInsert[replaced.startIndex + offset] - row.sortOrder = cursor - finalRowUrls.push(row.fileUrl) - cursor += 1 - } - continue - } - for (const row of runs[runIndex].rows) { - if (row.sortOrder !== cursor) { - db.sortOrderUpdates.push({ id: row.id, sortOrder: cursor }) - } - finalRowUrls.push(row.fileUrl) - cursor += 1 - } - } + assertFinalBatchLimits(context, group, finalRuns) + const { db, finalRowUrls } = buildBatchDbChanges(context, group, finalRuns) let newGroupZip: RebuildOutcome['newGroupZip'] = null - if (entries.length > 0) { + if (finalRuns.length > 0) { newGroupZip = await createZipFromFiles( - entries, + finalRuns.map((run) => run.entry), context.materialId, context.productModel || 'product', '任务-详情', @@ -1140,19 +2150,19 @@ async function executeBatchRebuild( sku: context.rowsByGroup.sku.length, detail: context.rowsByGroup.detail.length, } - finalRowCountByGroup[group] = cursor + finalRowCountByGroup[group] = finalRowUrls.length const firstPreviewUrlByGroup: Record = { main: null, sku: null, detail: null, } - if (finalRowCountByGroup[group] > 0) { - firstPreviewUrlByGroup[group] = finalRowUrls[0] || null + if (finalRowUrls.length > 0) { + firstPreviewUrlByGroup[group] = finalRowUrls[0] } return { - nextEntryNames: entries.map((entry) => entry.name), + nextEntryNames: finalRuns.map((run) => run.entry.name), newGroupZip, newAllZip, db, @@ -1259,7 +2269,7 @@ async function executeRebuild( ) } - const db = await planPreviewRowChanges(context, mutation, finalEntryName) + const db = await planPreviewRowChanges(context, mutation, finalEntryName, workspace.dir) // 计算各组最终预览行数与首图地址(用于封面回退和整套判定) const finalRowCountByGroup: Record = { @@ -1329,18 +2339,30 @@ async function commitRebuild( const insertedBarcodeLinks: Array<{ assetId: string; barcode: string }> = [] const result = await prisma.$transaction(async (tx) => { - const groupZipAsset = context.groupZipAssets[group]! + const groupZipAsset = context.groupZipAssets[group] if (outcome.newGroupZip) { - await tx.materialAsset.update({ - where: { id: groupZipAsset.id }, - data: { - fileUrl: outcome.newGroupZip.filePath, - fileName: outcome.newGroupZip.fileName, - fileSize: BigInt(outcome.newGroupZip.fileSize), - }, - }) - } else { + const groupZipData = { + fileUrl: outcome.newGroupZip.filePath, + fileName: outcome.newGroupZip.fileName, + fileSize: BigInt(outcome.newGroupZip.fileSize), + } + if (groupZipAsset) { + await tx.materialAsset.update({ + where: { id: groupZipAsset.id }, + data: groupZipData, + }) + } else { + await tx.materialAsset.create({ + data: { + materialId: context.materialId, + assetType: group, + sortOrder: 0, + ...groupZipData, + }, + }) + } + } else if (groupZipAsset) { await tx.materialAsset.delete({ where: { id: groupZipAsset.id } }) } @@ -1452,6 +2474,24 @@ async function commitRebuild( }, }) + if (context.approvalRequestId) { + const completed = await tx.taskDetailChangeRequest.updateMany({ + where: { + id: context.approvalRequestId, + materialId: context.materialId, + status: 'applying', + }, + data: { + status: 'applied', + appliedAt: new Date(), + applyError: null, + }, + }) + if (completed.count !== 1) { + throw new TaskDetailAssetEditError('审批单状态已变化,正式素材未应用,请刷新后重试', 409) + } + } + return { nextIsFullSet } }) @@ -1464,8 +2504,14 @@ async function commitRebuild( assetOperation: { oldValue: null, newValue: operationLabel }, group: { oldValue: null, newValue: group }, entryName: { - oldValue: context.entryNames[context.targetEntryIndex], - newValue: outcome.nextEntryNames[context.targetEntryIndex] ?? null, + oldValue: + context.targetEntryIndex >= 0 && context.targetEntryIndex < context.entryNames.length + ? context.entryNames[context.targetEntryIndex] + : null, + newValue: + context.targetEntryIndex >= 0 && context.targetEntryIndex < outcome.nextEntryNames.length + ? outcome.nextEntryNames[context.targetEntryIndex] + : null, }, entryCount: { oldValue: context.entryNames.length, @@ -1525,44 +2571,53 @@ export interface ReplaceTaskDetailImageInput { assetId: string operatorUserId: string operatorRole?: string | null - uploadId: string + uploadId?: string + stagedFile?: ServerTrustedTaskDetailStagedFile } export async function replaceTaskDetailImage(input: ReplaceTaskDetailImageInput) { return runEditOperation(input.materialId, async () => { const context = await loadEditContext(input) - const upload = await resolveReplacementUpload({ + const source = await resolveMutationSource({ uploadId: input.uploadId, + stagedFile: input.stagedFile, operatorUserId: input.operatorUserId, + actionLabel: '替换图片', }) const outcome = await executeRebuild(context, { kind: 'replace', - filePath: upload.filePath, - replacementFileName: upload.fileName, - buffer: upload.buffer, + filePath: source.filePath, + replacementFileName: source.fileName, + buffer: source.buffer, }) const { nextIsFullSet } = await commitRebuild(context, outcome, 'REPLACE_IMAGE') await cleanupAfterCommit(context, outcome) - await cleanupConsumedUploadSession(input.uploadId.trim()).catch(() => undefined) + if (input.uploadId) { + await cleanupConsumedUploadSession(input.uploadId.trim()).catch(() => undefined) + } if (!context.wasFullSet && nextIsFullSet) { void queueTaskDetailFullSetNotification(context.materialId) } if (context.group === 'sku' && outcome.db.previewRowsToInsert.length > 0) { - const insertedAssets = await prisma.materialAsset.findMany({ - where: { - materialId: context.materialId, - assetType: GROUP_PREVIEW_TYPES.sku, - fileUrl: { in: outcome.db.previewRowsToInsert.map((row) => row.fileUrl) }, - }, - select: { id: true }, - }) - void queueTaskDetailBarcodeImagesUpdatedNotification( - context.materialId, - insertedAssets.map((row) => row.id), - ) + try { + const insertedAssets = await prisma.materialAsset.findMany({ + where: { + materialId: context.materialId, + assetType: GROUP_PREVIEW_TYPES.sku, + fileUrl: { in: outcome.db.previewRowsToInsert.map((row) => row.fileUrl) }, + }, + select: { id: true }, + }) + void queueTaskDetailBarcodeImagesUpdatedNotification( + context.materialId, + insertedAssets.map((row) => row.id), + ) + } catch (error) { + console.error('[task-detail-asset-edit] SKU 图片更新通知排队失败:', error) + } } return { @@ -1638,12 +2693,30 @@ export async function deleteTaskDetailImage(input: DeleteTaskDetailImageInput) { export type BatchTaskDetailImageEditOperation = | { kind: 'delete'; assetId: string } | { kind: 'rename'; assetId: string; newFileName: string } - | { kind: 'replace'; assetId: string; uploadId: string } + | { kind: 'replace'; assetId: string; uploadId?: string; stagedFile?: ServerTrustedTaskDetailStagedFile } + | { + kind: 'append' + group: 'main' | 'sku' + uploadId?: string + stagedFile?: ServerTrustedTaskDetailStagedFile + skuCode?: string | null + barcodes?: string[] + } + | { + kind: 'insert' + afterAssetId: string + beforeAssetId: string + uploadId?: string + stagedFile?: ServerTrustedTaskDetailStagedFile + } export interface BatchTaskDetailImageEditInput { materialId: string operatorUserId: string operatorRole?: string | null + expectedMaterialUpdatedAt?: Date | string + /** 仅审批服务传入;与素材更新在同一事务内落为 applied。 */ + approvalRequestId?: string operations: BatchTaskDetailImageEditOperation[] } @@ -1664,17 +2737,43 @@ export async function batchEditTaskDetailImages(input: BatchTaskDetailImageEditI } return runEditOperation(input.materialId, async () => { + const seed = operations[0] const context = await loadEditContext({ materialId: input.materialId, - assetId: operations[0].assetId, operatorUserId: input.operatorUserId, operatorRole: input.operatorRole, + assetId: + seed.kind === 'insert' + ? seed.afterAssetId + : seed.kind === 'append' + ? undefined + : seed.assetId, + group: seed.kind === 'append' ? seed.group : seed.kind === 'insert' ? 'detail' : undefined, + allowEmptyGroup: seed.kind === 'append', + expectedMaterialUpdatedAt: input.expectedMaterialUpdatedAt, + approvalRequestId: input.approvalRequestId, }) const validAssetIds = new Set( context.runs.flatMap((run) => run.rows.map((row) => row.id)), ) for (const operation of operations) { + if (operation.kind === 'append') { + if (operation.group !== context.group) { + throw new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片') + } + continue + } + if (operation.kind === 'insert') { + if ( + context.group !== 'detail' + || !validAssetIds.has(operation.afterAssetId) + || !validAssetIds.has(operation.beforeAssetId) + ) { + throw new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片') + } + continue + } if (!validAssetIds.has(operation.assetId)) { throw new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片') } @@ -1706,19 +2805,53 @@ export async function batchEditTaskDetailImages(input: BatchTaskDetailImageEditI }) continue } - const upload = await resolveReplacementUpload({ + if (operation.kind === 'replace') { + const source = await resolveMutationSource({ + uploadId: operation.uploadId, + stagedFile: operation.stagedFile, + operatorUserId: input.operatorUserId, + actionLabel: '替换图片', + }) + prepared.push({ + assetId: operation.assetId, + mutation: { kind: 'replace', source }, + cleanupUploadId: operation.uploadId?.trim() || undefined, + }) + continue + } + if (operation.kind === 'append') { + const source = await resolveMutationSource({ + uploadId: operation.uploadId, + stagedFile: operation.stagedFile, + operatorUserId: input.operatorUserId, + actionLabel: '追加图片', + }) + prepared.push({ + mutation: { + kind: 'append', + group: operation.group, + source, + skuCode: operation.skuCode?.trim() || null, + barcodes: Array.from(new Set((operation.barcodes || []).filter(Boolean))), + }, + cleanupUploadId: operation.uploadId?.trim() || undefined, + }) + continue + } + const source = await resolveMutationSource({ uploadId: operation.uploadId, + stagedFile: operation.stagedFile, operatorUserId: input.operatorUserId, + actionLabel: '插入图片', }) prepared.push({ - assetId: operation.assetId, mutation: { - kind: 'replace', - filePath: upload.filePath, - replacementFileName: upload.fileName, - buffer: upload.buffer, + kind: 'insert', + afterAssetId: operation.afterAssetId, + beforeAssetId: operation.beforeAssetId, + source, }, - uploadId: operation.uploadId.trim(), + cleanupUploadId: operation.uploadId?.trim() || undefined, }) } @@ -1727,8 +2860,8 @@ export async function batchEditTaskDetailImages(input: BatchTaskDetailImageEditI await cleanupAfterCommit(context, outcome) for (const operation of prepared) { - if (operation.uploadId) { - await cleanupConsumedUploadSession(operation.uploadId).catch(() => undefined) + if (operation.cleanupUploadId) { + await cleanupConsumedUploadSession(operation.cleanupUploadId).catch(() => undefined) } } @@ -1737,18 +2870,22 @@ export async function batchEditTaskDetailImages(input: BatchTaskDetailImageEditI } if (context.group === 'sku' && outcome.db.previewRowsToInsert.length > 0) { - const insertedAssets = await prisma.materialAsset.findMany({ - where: { - materialId: context.materialId, - assetType: GROUP_PREVIEW_TYPES.sku, - fileUrl: { in: outcome.db.previewRowsToInsert.map((row) => row.fileUrl) }, - }, - select: { id: true }, - }) - void queueTaskDetailBarcodeImagesUpdatedNotification( - context.materialId, - insertedAssets.map((row) => row.id), - ) + try { + const insertedAssets = await prisma.materialAsset.findMany({ + where: { + materialId: context.materialId, + assetType: GROUP_PREVIEW_TYPES.sku, + fileUrl: { in: outcome.db.previewRowsToInsert.map((row) => row.fileUrl) }, + }, + select: { id: true }, + }) + void queueTaskDetailBarcodeImagesUpdatedNotification( + context.materialId, + insertedAssets.map((row) => row.id), + ) + } catch (error) { + console.error('[task-detail-asset-edit] SKU 图片更新通知排队失败:', error) + } } return { diff --git a/lib/task-detail-change-approval.ts b/lib/task-detail-change-approval.ts new file mode 100644 index 0000000..8ebf7b9 --- /dev/null +++ b/lib/task-detail-change-approval.ts @@ -0,0 +1,722 @@ +import { randomUUID } from 'crypto' + +import { getEmployeeByUserId, BINDING_STATUS } from '@/lib/employee-directory' +import { canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit' +import { canManageMaterial } from '@/lib/material-authorization' +import { isProductPackageType } from '@/lib/material-type-mapper' +import { prisma } from '@/lib/prisma' +import { getPublicAppOrigin } from '@/lib/public-app-url' +import { buildDingtalkEntryPath } from '@/lib/safe-redirect' +import { isSystemAdmin } from '@/lib/department-permission' +import { + cleanupTaskDetailChangeStaging, + resolveTaskDetailStagedFile, + withTaskDetailStagedUploads, + type TaskDetailStagedFile, +} from '@/lib/task-detail-change-staging' +import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications' +import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit' + +const REQUEST_STATUS = { + PENDING: 'pending_manager_approval', + APPLYING: 'applying', + APPLIED: 'applied', + REJECTED: 'rejected', + EXPIRED: 'expired', + APPLY_FAILED: 'apply_failed', +} as const + +const APPROVAL_WINDOW_MS = 24 * 60 * 60 * 1000 +const STALE_APPLYING_MS = 30 * 60 * 1000 +const MAX_OPERATIONS = 100 + +export class TaskDetailChangeApprovalError extends Error { + constructor(message: string, public statusCode = 400) { + super(message) + this.name = 'TaskDetailChangeApprovalError' + } +} + +export type TaskDetailChangeOperationInput = + | { kind: 'delete'; assetId: string } + | { kind: 'rename'; assetId: string; newFileName: string } + | { kind: 'replace'; assetId: string; uploadId: string } + | { kind: 'append'; group: 'main' | 'sku'; uploadId: string } + | { + kind: 'insert' + group: 'detail' + uploadId: string + afterAssetId: string + beforeAssetId: string + } + +type StoredOperation = Omit & { + stagedFile?: TaskDetailStagedFile +} + +type Actor = { + id: string + role?: string | null + isSuperAdmin?: boolean +} + +function clean(value: unknown) { + const text = String(value ?? '').trim() + return text || null +} + +function normalizeOperations(value: unknown): TaskDetailChangeOperationInput[] { + const rows = Array.isArray(value) ? value : [] + if (rows.length === 0) throw new TaskDetailChangeApprovalError('图片变更不能为空') + if (rows.length > MAX_OPERATIONS) { + throw new TaskDetailChangeApprovalError(`一次最多提交 ${MAX_OPERATIONS} 项图片变更`) + } + + const operations: TaskDetailChangeOperationInput[] = [] + const targetAssetIds = new Set() + for (const raw of rows) { + const row = raw as Record + const kind = clean(row.kind) + if (kind === 'delete') { + const assetId = clean(row.assetId) + if (!assetId) throw new TaskDetailChangeApprovalError('删除操作缺少图片标识') + if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改') + targetAssetIds.add(assetId) + operations.push({ kind, assetId }) + continue + } + if (kind === 'rename') { + const assetId = clean(row.assetId) + const newFileName = clean(row.newFileName) + if (!assetId || !newFileName) throw new TaskDetailChangeApprovalError('改名操作参数不完整') + if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改') + targetAssetIds.add(assetId) + operations.push({ kind, assetId, newFileName }) + continue + } + if (kind === 'replace') { + const assetId = clean(row.assetId) + const uploadId = clean(row.uploadId) + if (!assetId || !uploadId) throw new TaskDetailChangeApprovalError('替换操作参数不完整') + if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改') + targetAssetIds.add(assetId) + operations.push({ kind, assetId, uploadId }) + continue + } + if (kind === 'append') { + const group = clean(row.group) + const uploadId = clean(row.uploadId) + if ((group !== 'main' && group !== 'sku') || !uploadId) { + throw new TaskDetailChangeApprovalError('追加图片操作参数不完整') + } + operations.push({ kind, group, uploadId }) + continue + } + if (kind === 'insert') { + const group = clean(row.group) + const uploadId = clean(row.uploadId) + const afterAssetId = clean(row.afterAssetId) + const beforeAssetId = clean(row.beforeAssetId) + if (group !== 'detail' || !uploadId || !afterAssetId || !beforeAssetId) { + throw new TaskDetailChangeApprovalError('详情图插入位置或图片参数不完整') + } + operations.push({ kind, group, uploadId, afterAssetId, beforeAssetId }) + continue + } + throw new TaskDetailChangeApprovalError(`不支持的图片变更类型:${kind || '空'}`) + } + return operations +} + +function assertSingleOperationGroup( + operations: TaskDetailChangeOperationInput[], + assets: Array<{ id: string; assetType: string }>, +) { + const assetGroups = new Map() + for (const asset of assets) { + if (asset.assetType === 'main-preview') assetGroups.set(asset.id, 'main') + if (asset.assetType === 'sku-preview') assetGroups.set(asset.id, 'sku') + if (asset.assetType === 'detail-preview') assetGroups.set(asset.id, 'detail') + } + const groups = new Set<'main' | 'sku' | 'detail'>() + for (const operation of operations) { + if (operation.kind === 'append') { + groups.add(operation.group) + continue + } + if (operation.kind === 'insert') { + if ( + assetGroups.get(operation.afterAssetId) !== 'detail' + || assetGroups.get(operation.beforeAssetId) !== 'detail' + ) { + throw new TaskDetailChangeApprovalError('详情图插入位置不存在或不属于该素材', 404) + } + groups.add('detail') + continue + } + const group = assetGroups.get(operation.assetId) + if (!group) { + throw new TaskDetailChangeApprovalError('要修改的图片不存在或不属于该素材', 404) + } + groups.add(group) + } + if (groups.size !== 1) { + throw new TaskDetailChangeApprovalError('一次只能提交同一图片分组(主图、SKU图或详情图)的变更,请分组提交') + } +} + +async function loadAuthorizedContext(materialId: string, actor: Actor) { + const [material, employee] = await Promise.all([ + prisma.material.findFirst({ + where: { id: materialId, deletedAt: null }, + select: { + id: true, + userId: true, + materialType: true, + taskStatus: true, + taskNo: true, + productModel: true, + updatedAt: true, + assets: { + where: { assetType: { in: ['main-preview', 'sku-preview', 'detail-preview'] } }, + select: { id: true, assetType: true }, + }, + }, + }), + getEmployeeByUserId(actor.id), + ]) + if (!material) throw new TaskDetailChangeApprovalError('素材不存在或已删除', 404) + if (!isProductPackageType(material.materialType)) { + throw new TaskDetailChangeApprovalError('只有任务-详情素材支持图片变更') + } + if (material.taskStatus !== 'completed') { + throw new TaskDetailChangeApprovalError('素材尚未完成上传,暂不能修改图片') + } + + let allowed = await canManageMaterial( + { id: actor.id, role: actor.role, isSuperAdmin: actor.isSuperAdmin }, + { id: material.id, userId: material.userId }, + ) + if (!allowed) allowed = await canUserEditTaskDetailAssets(actor.id) + if (!allowed) throw new TaskDetailChangeApprovalError('您没有权限修改此素材', 403) + return { material, employee } +} + +function employeeSnapshot(employee: NonNullable>>) { + return { + employeeProfileId: employee.employeeProfileId, + userId: employee.userId, + displayName: employee.displayName, + jobNumber: employee.jobNumber, + dingtalkUserId: employee.dingtalkUserId, + departmentName: employee.departmentName, + departmentPath: employee.departmentPath, + positionTitle: employee.positionTitle, + positionLevel: employee.positionLevel, + levelType: employee.levelType, + isLeadership: employee.isLeadership, + managerName: employee.managerName, + managerDingtalkUserId: employee.managerDingtalkUserId, + } +} + +async function resolveManager(employee: NonNullable>>) { + const managerDingtalkUserId = clean(employee.managerDingtalkUserId) + if (!managerDingtalkUserId) { + throw new TaskDetailChangeApprovalError('花名册没有记录直属主管,暂不能提交图片变更申请', 409) + } + const manager = await prisma.performanceSurveyEmployeeProfile.findUnique({ + where: { dingtalkUserId: managerDingtalkUserId }, + include: { + userBindings: { + where: { status: BINDING_STATUS.ACTIVE }, + select: { userId: true }, + take: 1, + }, + }, + }) + if (!manager || manager.employmentState !== 'ACTIVE') { + throw new TaskDetailChangeApprovalError('直属主管账号无有效在职档案,暂不能提交图片变更申请', 409) + } + return { + employee: manager, + userId: manager.userBindings[0]?.userId || null, + dingtalkUserId: managerDingtalkUserId, + name: manager.displayName || manager.realName || employee.managerName || '直属主管', + } +} + +function isDirectApplyActor(actor: Actor, employee: Awaited>) { + const positionLevel = employee?.positionLevel?.trim().toUpperCase() + return Boolean( + actor.isSuperAdmin + || isSystemAdmin(String(actor.role || '')) + || employee?.levelType?.toUpperCase() === 'M' + || positionLevel?.startsWith('M') + ) +} + +function actionUrl(requestId: string) { + return `${getPublicAppOrigin()}${buildDingtalkEntryPath(`/task-detail-change-requests/${requestId}`)}` +} + +async function queueManagerNotification(request: { + id: string + managerUserId: string | null + managerDingtalkUserId: string + managerName: string + requesterName: string + material: { taskNo: string | null; productModel: string } + operations: unknown[] + expiresAt: Date +}) { + await queueTaskDetailChangeNotification({ + requestId: request.id, + recipientUserId: request.managerUserId, + dingtalkUserId: request.managerDingtalkUserId, + recipientName: request.managerName, + eventType: 'task_detail_change.requested', + businessKey: `task-detail-change:${request.id}:requested`, + title: '任务详情图片变更待确认', + content: [ + '### ◆ 图片变更待确认', + '', + `**素材:** ${request.material.taskNo || request.material.productModel}`, + `**提交人:** ${request.requesterName}`, + `**变更数量:** ${request.operations.length} 项`, + `**截止时间:** ${request.expiresAt.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, + '', + '> 未确认前正式素材不会改变;超过截止时间将自动否决。', + ].join('\n'), + actionUrl: actionUrl(request.id), + actionTitle: '立即确认', + }) +} + +async function queueRequesterResultNotification(request: any, label: string) { + const dingtalkUserId = clean(request.requesterEmployee?.dingtalkUserId) + if (!dingtalkUserId) return + await queueTaskDetailChangeNotification({ + requestId: request.id, + recipientUserId: request.requesterUserId, + dingtalkUserId, + recipientName: request.requesterName, + eventType: `task_detail_change.${request.status}`, + businessKey: `task-detail-change:${request.id}:${request.status}`, + title: '任务详情图片变更结果', + content: `您的图片变更申请已${label}。\n\n> 素材:${request.material.taskNo || request.material.productModel}`, + actionUrl: actionUrl(request.id), + actionTitle: '查看结果', + }) +} + +function storeOperations( + operations: TaskDetailChangeOperationInput[], + staged: Map, +): StoredOperation[] { + return operations.map((operation) => { + if (!('uploadId' in operation)) return operation as StoredOperation + const stagedFile = staged.get(operation.uploadId) + if (!stagedFile) throw new TaskDetailChangeApprovalError('图片持久暂存结果缺失', 500) + const { uploadId: _uploadId, ...rest } = operation + return { ...rest, stagedFile } as StoredOperation + }) +} + +function applyOperations(requestId: string, operations: StoredOperation[]) { + return operations.map((operation) => { + if (!operation.stagedFile) return operation + return { + ...operation, + stagedFile: resolveTaskDetailStagedFile(requestId, operation.stagedFile), + } + }) +} + +export async function submitTaskDetailChange( + materialId: string, + actor: Actor, + rawOperations: unknown, +) { + const operations = normalizeOperations(rawOperations) + const { material, employee } = await loadAuthorizedContext(materialId, actor) + assertSingleOperationGroup(operations, material.assets) + if (isDirectApplyActor(actor, employee)) { + const data = await batchEditTaskDetailImages({ + materialId, + operatorUserId: actor.id, + operatorRole: actor.role, + operations: operations as any, + }) + return { mode: 'applied' as const, data } + } + if (!employee) { + throw new TaskDetailChangeApprovalError('当前账号没有匹配花名册,暂不能提交主管审批', 403) + } + const manager = await resolveManager(employee) + const requestId = randomUUID() + const expiresAt = new Date(Date.now() + APPROVAL_WINDOW_MS) + const uploadIds = operations.filter((item): item is Extract => ( + 'uploadId' in item + )).map((item) => item.uploadId) + + const request = await withTaskDetailStagedUploads({ + requestId, + ownerUserId: actor.id, + uploadIds, + commit: async (staged) => { + const storedOperations = storeOperations(operations, staged) + return prisma.taskDetailChangeRequest.create({ + data: { + id: requestId, + materialId, + requesterUserId: actor.id, + requesterEmployeeId: employee.employeeProfileId, + requesterName: employee.displayName, + requesterSnapshot: employeeSnapshot(employee) as any, + requesterPositionLevel: employee.positionLevel, + requesterLevelType: employee.levelType, + requesterIsLeadership: employee.isLeadership, + managerEmployeeId: manager.employee.id, + managerUserId: manager.userId, + managerDingtalkUserId: manager.dingtalkUserId, + managerName: manager.name, + managerSnapshot: { + id: manager.employee.id, + dingtalkUserId: manager.employee.dingtalkUserId, + jobNumber: manager.employee.jobNumber, + displayName: manager.name, + departmentPath: manager.employee.departmentPath, + positionLevel: manager.employee.positionLevel, + } as any, + status: REQUEST_STATUS.PENDING, + operations: storedOperations as any, + baseMaterialUpdatedAt: material.updatedAt, + expiresAt, + }, + include: { material: { select: { taskNo: true, productModel: true } } }, + }) + }, + }) + + await queueManagerNotification({ + ...request, + operations, + expiresAt, + }).catch((error) => { + console.error('[task-detail-change] 审批单已创建,但主管通知排队失败:', error) + }) + return { + mode: 'pending_approval' as const, + request: { + id: request.id, + status: request.status, + managerName: request.managerName, + expiresAt: request.expiresAt, + }, + } +} + +async function loadRequestForActor(id: string, actor: Actor) { + const [request, employee] = await Promise.all([ + prisma.taskDetailChangeRequest.findUnique({ + where: { id }, + include: { + material: { select: { id: true, taskNo: true, productModel: true, materialType: true, updatedAt: true } }, + requesterEmployee: { select: { dingtalkUserId: true } }, + }, + }), + getEmployeeByUserId(actor.id), + ]) + if (!request) throw new TaskDetailChangeApprovalError('图片变更申请不存在', 404) + const canDecide = Boolean( + employee?.dingtalkUserId && employee.dingtalkUserId === request.managerDingtalkUserId, + ) + const canView = canDecide + || request.requesterUserId === actor.id + || actor.isSuperAdmin + || isSystemAdmin(String(actor.role || '')) + if (!canView) throw new TaskDetailChangeApprovalError('您无权查看这条图片变更申请', 403) + return { request, employee, canDecide } +} + +export async function getTaskDetailChangeRequest(id: string, actor: Actor) { + const { request, canDecide } = await loadRequestForActor(id, actor) + const operations = (Array.isArray(request.operations) ? request.operations : []) as StoredOperation[] + return { + id: request.id, + status: request.status, + requesterName: request.requesterName, + managerName: request.managerName, + material: request.material, + operations: operations.map(({ stagedFile, ...operation }) => ({ + ...operation, + fileName: stagedFile?.fileName || null, + })), + submittedAt: request.submittedAt, + expiresAt: request.expiresAt, + decidedAt: request.decidedAt, + appliedAt: request.appliedAt, + applyError: request.applyError, + canDecide: canDecide && request.status === REQUEST_STATUS.PENDING && request.expiresAt > new Date(), + } +} + +export async function listTaskDetailChanges(materialId: string, actor: Actor) { + const employee = await getEmployeeByUserId(actor.id) + const privileged = Boolean(actor.isSuperAdmin || isSystemAdmin(String(actor.role || ''))) + const rows = await prisma.taskDetailChangeRequest.findMany({ + where: { + materialId, + ...(privileged + ? {} + : { + OR: [ + { requesterUserId: actor.id }, + ...(employee?.dingtalkUserId + ? [{ managerDingtalkUserId: employee.dingtalkUserId }] + : []), + ], + }), + }, + orderBy: { createdAt: 'desc' }, + take: 10, + select: { + id: true, + status: true, + requesterName: true, + managerName: true, + operations: true, + submittedAt: true, + expiresAt: true, + decidedAt: true, + appliedAt: true, + applyError: true, + }, + }) + return rows.map((row) => ({ + ...row, + operationCount: Array.isArray(row.operations) ? row.operations.length : 0, + operations: undefined, + })) +} + +export async function decideTaskDetailChange( + id: string, + actor: Actor, + decision: 'approve' | 'reject', +) { + const { request, canDecide } = await loadRequestForActor(id, actor) + if (!canDecide) throw new TaskDetailChangeApprovalError('只有申请人的直属主管可以确认', 403) + const now = new Date() + if (request.status !== REQUEST_STATUS.PENDING) { + throw new TaskDetailChangeApprovalError('该申请已经处理,不能重复确认', 409) + } + if (request.expiresAt <= now) { + const expired = await prisma.taskDetailChangeRequest.updateMany({ + where: { id, status: REQUEST_STATUS.PENDING }, + data: { status: REQUEST_STATUS.EXPIRED, decidedAt: now }, + }) + if (expired.count === 1) { + await cleanupTaskDetailChangeStaging(id).catch(() => undefined) + const final = await prisma.taskDetailChangeRequest.findUnique({ + where: { id }, + include: { + material: { select: { taskNo: true, productModel: true } }, + requesterEmployee: { select: { dingtalkUserId: true } }, + }, + }) + if (final) { + await queueRequesterResultNotification(final, '因超过 24 小时未确认而自动否决').catch(() => undefined) + } + } + throw new TaskDetailChangeApprovalError('该申请已超时并自动否决', 409) + } + + if (decision === 'reject') { + const updated = await prisma.taskDetailChangeRequest.updateMany({ + where: { id, status: REQUEST_STATUS.PENDING, expiresAt: { gt: now } }, + data: { + status: REQUEST_STATUS.REJECTED, + decidedAt: now, + decidedByUserId: actor.id, + }, + }) + if (updated.count !== 1) throw new TaskDetailChangeApprovalError('申请状态已变化,请刷新', 409) + await cleanupTaskDetailChangeStaging(id).catch(() => undefined) + const final = await prisma.taskDetailChangeRequest.findUnique({ + where: { id }, + include: { + material: { select: { taskNo: true, productModel: true } }, + requesterEmployee: { select: { dingtalkUserId: true } }, + }, + }) + if (final) await queueRequesterResultNotification(final, '被主管否决').catch(() => undefined) + return { status: REQUEST_STATUS.REJECTED } + } + + const claimed = await prisma.taskDetailChangeRequest.updateMany({ + where: { id, status: REQUEST_STATUS.PENDING, expiresAt: { gt: now } }, + data: { + status: REQUEST_STATUS.APPLYING, + decidedAt: now, + decidedByUserId: actor.id, + }, + }) + if (claimed.count !== 1) throw new TaskDetailChangeApprovalError('申请状态已变化,请刷新', 409) + + try { + const material = await prisma.material.findUnique({ + where: { id: request.materialId }, + select: { updatedAt: true }, + }) + if (!material || material.updatedAt.getTime() !== request.baseMaterialUpdatedAt.getTime()) { + throw new TaskDetailChangeApprovalError('正式素材在审批期间已更新,本申请已停止应用', 409) + } + const operations = applyOperations( + id, + (Array.isArray(request.operations) ? request.operations : []) as StoredOperation[], + ) + await batchEditTaskDetailImages({ + materialId: request.materialId, + operatorUserId: request.requesterUserId, + expectedMaterialUpdatedAt: request.baseMaterialUpdatedAt, + approvalRequestId: id, + operations: operations as any, + }) + await cleanupTaskDetailChangeStaging(id).catch(() => undefined) + const final = await prisma.taskDetailChangeRequest.findUnique({ + where: { id }, + include: { + material: { select: { taskNo: true, productModel: true } }, + requesterEmployee: { select: { dingtalkUserId: true } }, + }, + }) + if (final) await queueRequesterResultNotification(final, '由直属主管批准并生效').catch(() => undefined) + return { status: REQUEST_STATUS.APPLIED } + } catch (error) { + const message = error instanceof Error ? error.message : '批准后应用图片变更失败' + await prisma.taskDetailChangeRequest.update({ + where: { id }, + data: { status: REQUEST_STATUS.APPLY_FAILED, applyError: message }, + }) + await cleanupTaskDetailChangeStaging(id).catch(() => undefined) + const final = await prisma.taskDetailChangeRequest.findUnique({ + where: { id }, + include: { + material: { select: { taskNo: true, productModel: true } }, + requesterEmployee: { select: { dingtalkUserId: true } }, + }, + }) + if (final) { + await queueRequesterResultNotification(final, '在批准后应用失败,请重新提交').catch(() => undefined) + } + throw error + } +} + +export async function expireDueTaskDetailChanges(now = new Date()) { + const due = await prisma.taskDetailChangeRequest.findMany({ + where: { status: REQUEST_STATUS.PENDING, expiresAt: { lte: now } }, + select: { id: true }, + orderBy: { expiresAt: 'asc' }, + take: 50, + }) + let expired = 0 + for (const row of due) { + const changed = await prisma.taskDetailChangeRequest.updateMany({ + where: { id: row.id, status: REQUEST_STATUS.PENDING, expiresAt: { lte: now } }, + data: { status: REQUEST_STATUS.EXPIRED, decidedAt: now }, + }) + if (changed.count !== 1) continue + expired += 1 + await cleanupTaskDetailChangeStaging(row.id).catch(() => undefined) + const final = await prisma.taskDetailChangeRequest.findUnique({ + where: { id: row.id }, + include: { + material: { select: { taskNo: true, productModel: true } }, + requesterEmployee: { select: { dingtalkUserId: true } }, + }, + }) + if (final) await queueRequesterResultNotification(final, '因超过 24 小时未确认而自动否决').catch(() => undefined) + } + return expired +} + +/** + * 补偿“审批单已持久化、通知 outbox 落库前进程退出/数据库瞬时失败”的窄窗口。 + * 唯一键保证多实例并发扫描也只会生成一条待发送通知。 + */ +export async function recoverMissingTaskDetailChangeRequestNotifications() { + const rows = await prisma.taskDetailChangeRequest.findMany({ + where: { + status: REQUEST_STATUS.PENDING, + expiresAt: { gt: new Date() }, + notifications: { + none: { eventType: 'task_detail_change.requested' }, + }, + }, + orderBy: { createdAt: 'asc' }, + take: 20, + include: { + material: { select: { taskNo: true, productModel: true } }, + }, + }) + let queued = 0 + for (const request of rows) { + await queueManagerNotification({ + id: request.id, + managerUserId: request.managerUserId, + managerDingtalkUserId: request.managerDingtalkUserId, + managerName: request.managerName, + requesterName: request.requesterName, + material: request.material, + operations: Array.isArray(request.operations) ? request.operations : [], + expiresAt: request.expiresAt, + }) + queued += 1 + } + return queued +} + +/** + * 正式素材更新与 request=applied 在同一数据库事务提交;因此长时间停留在 + * applying 只可能是进程中断或处理超时。先转失败会让迟到事务因状态不匹配而回滚。 + */ +export async function recoverStaleApplyingTaskDetailChanges(now = new Date()) { + const staleBefore = new Date(now.getTime() - STALE_APPLYING_MS) + const rows = await prisma.taskDetailChangeRequest.findMany({ + where: { status: REQUEST_STATUS.APPLYING, updatedAt: { lte: staleBefore } }, + select: { id: true }, + orderBy: { updatedAt: 'asc' }, + take: 50, + }) + let failed = 0 + for (const row of rows) { + const changed = await prisma.taskDetailChangeRequest.updateMany({ + where: { + id: row.id, + status: REQUEST_STATUS.APPLYING, + updatedAt: { lte: staleBefore }, + }, + data: { + status: REQUEST_STATUS.APPLY_FAILED, + applyError: '批准后的图片应用进程中断或超时,请重新提交变更', + }, + }) + if (changed.count !== 1) continue + failed += 1 + await cleanupTaskDetailChangeStaging(row.id).catch(() => undefined) + const final = await prisma.taskDetailChangeRequest.findUnique({ + where: { id: row.id }, + include: { + material: { select: { taskNo: true, productModel: true } }, + requesterEmployee: { select: { dingtalkUserId: true } }, + }, + }) + if (final) { + await queueRequesterResultNotification(final, '因应用进程中断或超时而失败,请重新提交').catch(() => undefined) + } + } + return failed +} diff --git a/lib/task-detail-change-notifications.ts b/lib/task-detail-change-notifications.ts new file mode 100644 index 0000000..d071c1c --- /dev/null +++ b/lib/task-detail-change-notifications.ts @@ -0,0 +1,153 @@ +import { randomUUID } from 'crypto' + +import { prisma } from '@/lib/prisma' +import { submitYouyidianNotification } from '@/lib/youyidian-notification-center' + +const STATUS = { + PENDING: 'pending', + PROCESSING: 'processing', + RETRYING: 'retrying', + SUCCEEDED: 'succeeded', + EXHAUSTED: 'exhausted', +} as const + +const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000] +const RECOVERY_INTERVAL_MS = 60_000 +const STALE_PROCESSING_MS = 5 * 60_000 +const deliveryTimers = new Map() +let workerStarted = false + +type QueueInput = { + requestId: string + recipientUserId?: string | null + dingtalkUserId: string + recipientName: string + eventType: string + businessKey: string + title: string + content: string + actionUrl: string + actionTitle: string +} + +function schedule(id: string, delayMs = 0) { + const previous = deliveryTimers.get(id) + if (previous) clearTimeout(previous) + const timer = setTimeout(() => { + deliveryTimers.delete(id) + void processTaskDetailChangeNotification(id) + }, Math.max(0, delayMs)) + timer.unref?.() + deliveryTimers.set(id, timer) +} + +async function processTaskDetailChangeNotification(id: string) { + const claimed = await prisma.taskDetailChangeNotification.updateMany({ + where: { id, status: { in: [STATUS.PENDING, STATUS.RETRYING] } }, + data: { status: STATUS.PROCESSING }, + }) + if (claimed.count !== 1) return null + + const notification = await prisma.taskDetailChangeNotification.findUnique({ where: { id } }) + if (!notification) return null + try { + const result = await submitYouyidianNotification({ + idempotencyKey: `${notification.eventType}:${notification.businessKey}`, + recipientUserId: notification.dingtalkUserId, + title: notification.title, + content: notification.content, + actionUrl: notification.actionUrl, + actionTitle: notification.eventType === 'task_detail_change.requested' ? '立即确认' : '查看结果', + }) + await prisma.taskDetailChangeNotification.update({ + where: { id }, + data: { + status: STATUS.SUCCEEDED, + attemptCount: { increment: 1 }, + providerMessageId: result.id || null, + lastError: null, + nextRetryAt: null, + sentAt: new Date(), + completedAt: new Date(), + }, + }) + return { success: true } + } catch (error) { + const nextAttempt = notification.attemptCount + 1 + const exhausted = nextAttempt >= notification.maxAttempts + const retryDelay = RETRY_DELAYS_MS[Math.min(nextAttempt, RETRY_DELAYS_MS.length - 1)] + await prisma.taskDetailChangeNotification.update({ + where: { id }, + data: { + status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING, + attemptCount: { increment: 1 }, + lastError: error instanceof Error ? error.message : '统一通知中心投递失败', + nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelay), + completedAt: exhausted ? new Date() : null, + }, + }) + if (!exhausted) schedule(id, retryDelay) + return { success: false } + } +} + +async function recoverDueNotifications() { + const staleBefore = new Date(Date.now() - STALE_PROCESSING_MS) + await prisma.taskDetailChangeNotification.updateMany({ + where: { status: STATUS.PROCESSING, updatedAt: { lt: staleBefore } }, + data: { status: STATUS.RETRYING, nextRetryAt: new Date(), lastError: '通知处理超时,已自动重试' }, + }) + const rows = await prisma.taskDetailChangeNotification.findMany({ + where: { + status: { in: [STATUS.PENDING, STATUS.RETRYING] }, + OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }], + }, + select: { id: true }, + orderBy: { createdAt: 'asc' }, + take: 20, + }) + rows.forEach((row) => schedule(row.id)) + return rows.length +} + +export function ensureTaskDetailChangeNotificationWorkerStarted() { + if (workerStarted) return + workerStarted = true + const bootstrap = setTimeout(() => { + void recoverDueNotifications().catch((error) => { + console.error('[task-detail-change] 通知恢复失败:', error) + }) + }, 1000) + bootstrap.unref?.() + const recovery = setInterval(() => { + void recoverDueNotifications().catch((error) => { + console.error('[task-detail-change] 通知周期恢复失败:', error) + }) + }, RECOVERY_INTERVAL_MS) + recovery.unref?.() +} + +export async function queueTaskDetailChangeNotification(input: QueueInput) { + ensureTaskDetailChangeNotificationWorkerStarted() + const row = await prisma.taskDetailChangeNotification.upsert({ + where: { eventType_businessKey: { eventType: input.eventType, businessKey: input.businessKey } }, + update: {}, + create: { + id: randomUUID(), + requestId: input.requestId, + recipientUserId: input.recipientUserId || null, + dingtalkUserId: input.dingtalkUserId, + recipientName: input.recipientName, + eventType: input.eventType, + businessKey: input.businessKey, + title: input.title, + content: input.content, + actionUrl: input.actionUrl, + status: STATUS.PENDING, + nextRetryAt: new Date(), + }, + select: { id: true, status: true }, + }) + if ([STATUS.PENDING, STATUS.RETRYING].includes(row.status as any)) schedule(row.id) + return row.id +} diff --git a/lib/task-detail-change-staging.ts b/lib/task-detail-change-staging.ts new file mode 100644 index 0000000..995c977 --- /dev/null +++ b/lib/task-detail-change-staging.ts @@ -0,0 +1,131 @@ +import { constants as fsConstants } from 'fs' +import { copyFile, lstat, mkdir, rm } from 'fs/promises' +import path from 'path' + +import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock' +import { normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security' +import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' +import { getUploadRootDir } from '@/lib/upload-paths' +import { + getOwnedUploadSession, + getUploadedFile, + isSafeUploadId, +} from '@/lib/upload-sessions' + +const REQUEST_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const STAGING_FOLDER = 'task-detail-change-requests' + +export type TaskDetailStagedFile = { + relativePath: string + fileName: string + fileSize: number +} + +function getStagingRoot() { + return path.join(getUploadRootDir(), STAGING_FOLDER) +} + +function getRequestDirectory(requestId: string) { + if (!REQUEST_ID_PATTERN.test(String(requestId || '').trim())) { + throw new Error('图片变更申请标识无效') + } + const directory = resolveStrictChildPath(getStagingRoot(), requestId) + if (!directory) throw new Error('图片变更暂存目录无效') + return directory +} + +export async function withTaskDetailStagedUploads(input: { + requestId: string + ownerUserId: string + uploadIds: string[] + commit: (files: Map) => Promise +}): Promise { + const uploadIds = Array.from(new Set( + input.uploadIds.map((value) => String(value || '').trim()).filter(isSafeUploadId), + )) + if (uploadIds.length !== input.uploadIds.length) { + throw new Error('存在无效或重复的上传会话,请重新选择图片') + } + + const requestDirectory = getRequestDirectory(input.requestId) + await mkdir(getStagingRoot(), { recursive: true }) + const release = uploadIds.length > 0 + ? await acquireTaskMaterialUploadSessionLocks(uploadIds) + : null + let committed = false + try { + release?.assertHeld() + await mkdir(requestDirectory, { recursive: false }) + const staged = new Map() + + for (let index = 0; index < uploadIds.length; index += 1) { + release?.assertHeld() + const uploadId = uploadIds[index] + const session = await getOwnedUploadSession(uploadId, input.ownerUserId) + if (!session) throw new Error('图片上传会话已失效,请重新选择图片') + const sourcePath = await getUploadedFile(uploadId) + if (!sourcePath) throw new Error('已上传图片文件缺失,请重新选择图片') + const sourceStats = await lstat(sourcePath) + if (!sourceStats.isFile() || sourceStats.isSymbolicLink() || sourceStats.size <= 0) { + throw new Error('已上传图片文件状态异常,请重新选择图片') + } + + const safeName = normalizeSafeFileName(session.fileName) + if (!safeName) throw new Error('图片文件名无效') + const storedName = `${String(index).padStart(3, '0')}-${safeName}` + const targetPath = resolveStrictChildPath(requestDirectory, storedName) + if (!targetPath) throw new Error('图片暂存路径无效') + await copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL) + const targetStats = await lstat(targetPath) + if (!targetStats.isFile() || targetStats.isSymbolicLink() || targetStats.size !== sourceStats.size) { + throw new Error('图片持久暂存校验失败') + } + staged.set(uploadId, { + relativePath: path.posix.join(STAGING_FOLDER, input.requestId, storedName), + fileName: safeName, + fileSize: targetStats.size, + }) + } + + release?.assertHeld() + const result = await input.commit(staged) + committed = true + await Promise.allSettled(uploadIds.map((uploadId) => cleanupConsumedUploadSession(uploadId))) + return result + } finally { + try { + if (release) await release() + } finally { + if (!committed) { + await rm(requestDirectory, { recursive: true, force: true }).catch(() => undefined) + } + } + } +} + +export function resolveTaskDetailStagedFile( + requestId: string, + file: TaskDetailStagedFile, +): { trusted: true; filePath: string; fileName: string; fileSize: number } { + const requestDirectory = getRequestDirectory(requestId) + const expectedPrefix = `${STAGING_FOLDER}/${requestId}/` + const normalized = String(file?.relativePath || '').replace(/\\/g, '/') + if (!normalized.startsWith(expectedPrefix)) { + throw new Error('图片审批暂存引用超出申请范围') + } + const baseName = normalized.slice(expectedPrefix.length) + const filePath = resolveStrictChildPath(requestDirectory, baseName) + if (!filePath) throw new Error('图片审批暂存引用无效') + const fileName = normalizeSafeFileName(file.fileName) + if (!fileName) throw new Error('图片审批暂存文件名无效') + return { + trusted: true, + filePath, + fileName, + fileSize: Number(file.fileSize), + } +} + +export async function cleanupTaskDetailChangeStaging(requestId: string) { + await rm(getRequestDirectory(requestId), { recursive: true, force: true }) +} diff --git a/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql b/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql new file mode 100644 index 0000000..6b1792c --- /dev/null +++ b/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql @@ -0,0 +1,83 @@ +CREATE TABLE "task_detail_change_requests" ( + "id" TEXT NOT NULL, + "material_id" TEXT NOT NULL, + "requester_user_id" TEXT NOT NULL, + "requester_employee_id" TEXT NOT NULL, + "requester_name" TEXT NOT NULL, + "requester_snapshot" JSONB NOT NULL, + "requester_position_level" TEXT, + "requester_level_type" TEXT, + "requester_is_leadership" BOOLEAN NOT NULL DEFAULT false, + "manager_employee_id" TEXT NOT NULL, + "manager_user_id" TEXT, + "manager_dingtalk_user_id" TEXT NOT NULL, + "manager_name" TEXT NOT NULL, + "manager_snapshot" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending_manager_approval', + "operations" JSONB NOT NULL, + "base_material_updated_at" TIMESTAMP(3) NOT NULL, + "submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "decided_at" TIMESTAMP(3), + "decided_by_user_id" TEXT, + "decision_comment" TEXT, + "applied_at" TIMESTAMP(3), + "apply_error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "task_detail_change_requests_pkey" PRIMARY KEY ("id"), + CONSTRAINT "task_detail_change_requests_status_check" CHECK ("status" IN ( + 'pending_manager_approval', 'applying', 'applied', 'rejected', 'expired', 'apply_failed', 'cancelled' + )), + CONSTRAINT "task_detail_change_requests_material_fkey" FOREIGN KEY ("material_id") REFERENCES "materials"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "task_detail_change_requests_requester_user_fkey" FOREIGN KEY ("requester_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "task_detail_change_requests_manager_user_fkey" FOREIGN KEY ("manager_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "task_detail_change_requests_requester_employee_fkey" FOREIGN KEY ("requester_employee_id") REFERENCES "performance_survey_employee_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "task_detail_change_requests_manager_employee_fkey" FOREIGN KEY ("manager_employee_id") REFERENCES "performance_survey_employee_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +CREATE INDEX "task_detail_change_requests_material_created_idx" + ON "task_detail_change_requests"("material_id", "created_at"); +CREATE INDEX "task_detail_change_requests_requester_created_idx" + ON "task_detail_change_requests"("requester_user_id", "created_at"); +CREATE INDEX "task_detail_change_requests_manager_status_idx" + ON "task_detail_change_requests"("manager_dingtalk_user_id", "status"); +CREATE INDEX "task_detail_change_requests_status_expires_idx" + ON "task_detail_change_requests"("status", "expires_at"); +CREATE INDEX "task_detail_change_requests_status_updated_idx" + ON "task_detail_change_requests"("status", "updated_at"); + +CREATE TABLE "task_detail_change_notifications" ( + "id" TEXT NOT NULL, + "request_id" TEXT NOT NULL, + "recipient_user_id" TEXT, + "dingtalk_user_id" TEXT NOT NULL, + "recipient_name" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "business_key" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "action_url" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "attempt_count" INTEGER NOT NULL DEFAULT 0, + "max_attempts" INTEGER NOT NULL DEFAULT 5, + "next_retry_at" TIMESTAMP(3), + "provider_message_id" TEXT, + "last_error" TEXT, + "sent_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "task_detail_change_notifications_pkey" PRIMARY KEY ("id"), + CONSTRAINT "task_detail_change_notifications_status_check" CHECK ("status" IN ( + 'pending', 'processing', 'retrying', 'succeeded', 'exhausted', 'skipped' + )), + CONSTRAINT "task_detail_change_notifications_request_fkey" FOREIGN KEY ("request_id") REFERENCES "task_detail_change_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "task_detail_change_notifications_event_key" + ON "task_detail_change_notifications"("event_type", "business_key"); +CREATE INDEX "task_detail_change_notifications_request_created_idx" + ON "task_detail_change_notifications"("request_id", "created_at"); +CREATE INDEX "task_detail_change_notifications_status_retry_idx" + ON "task_detail_change_notifications"("status", "next_retry_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 13e07e5..095c185 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,6 +56,8 @@ model User { transferToDept Department? @relation("UserTransferDepartment", fields: [transferToDeptId], references: [id]) employeeBindings UserEmployeeBinding[] permissionInherits UserPermissionInherit[] + submittedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeRequesterUser") + managedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeManagerUser") @@index([status]) @@index([archivedAt]) @@ -128,6 +130,7 @@ model Material { transfers MaterialTransfer[] transcodeProgresses VideoTranscodeProgress[] @relation("MaterialTranscodeProgress") productPackageUploadJobs ProductPackageUploadJob[] + taskDetailChangeRequests TaskDetailChangeRequest[] user User @relation("MaterialOwner", fields: [userId], references: [id], onDelete: Cascade) taskDetailRequester User? @relation("TaskDetailRequester", fields: [taskDetailRequesterUserId], references: [id], onDelete: SetNull) custodyDepartment Department? @relation("MaterialCustodyDepartment", fields: [custodyDepartmentId], references: [id], onDelete: SetNull) @@ -650,6 +653,8 @@ model PerformanceSurveyEmployeeProfile { managedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewManager") finalWorkReviewProjects WorkReviewProject[] @relation("WorkReviewFinalReviewer") workReviews WorkReviewReview[] @relation("WorkReviewReviewer") + submittedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeRequesterEmployee") + managedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeManagerEmployee") @@index([displayName]) @@index([dingEmail]) @@ -1279,6 +1284,79 @@ model WorkReviewNotification { @@map("work_review_notifications") } +/// 任务详情图片变更申请。正式素材只在直属主管批准后才应用。 +model TaskDetailChangeRequest { + id String @id @default(uuid()) + materialId String @map("material_id") + requesterUserId String @map("requester_user_id") + requesterEmployeeId String @map("requester_employee_id") + requesterName String @map("requester_name") + requesterSnapshot Json @map("requester_snapshot") + requesterPositionLevel String? @map("requester_position_level") + requesterLevelType String? @map("requester_level_type") + requesterIsLeadership Boolean @default(false) @map("requester_is_leadership") + managerEmployeeId String @map("manager_employee_id") + managerUserId String? @map("manager_user_id") + managerDingtalkUserId String @map("manager_dingtalk_user_id") + managerName String @map("manager_name") + managerSnapshot Json @map("manager_snapshot") + status String @default("pending_manager_approval") + operations Json + baseMaterialUpdatedAt DateTime @map("base_material_updated_at") + submittedAt DateTime @default(now()) @map("submitted_at") + expiresAt DateTime @map("expires_at") + decidedAt DateTime? @map("decided_at") + decidedByUserId String? @map("decided_by_user_id") + decisionComment String? @map("decision_comment") + appliedAt DateTime? @map("applied_at") + applyError String? @map("apply_error") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + material Material @relation(fields: [materialId], references: [id], onDelete: Cascade) + requesterUser User @relation("TaskDetailChangeRequesterUser", fields: [requesterUserId], references: [id], onDelete: Restrict) + managerUser User? @relation("TaskDetailChangeManagerUser", fields: [managerUserId], references: [id], onDelete: SetNull) + requesterEmployee PerformanceSurveyEmployeeProfile @relation("TaskDetailChangeRequesterEmployee", fields: [requesterEmployeeId], references: [id], onDelete: Restrict) + managerEmployee PerformanceSurveyEmployeeProfile @relation("TaskDetailChangeManagerEmployee", fields: [managerEmployeeId], references: [id], onDelete: Restrict) + notifications TaskDetailChangeNotification[] + + @@index([materialId, createdAt], map: "task_detail_change_requests_material_created_idx") + @@index([requesterUserId, createdAt], map: "task_detail_change_requests_requester_created_idx") + @@index([managerDingtalkUserId, status], map: "task_detail_change_requests_manager_status_idx") + @@index([status, expiresAt], map: "task_detail_change_requests_status_expires_idx") + @@index([status, updatedAt], map: "task_detail_change_requests_status_updated_idx") + @@map("task_detail_change_requests") +} + +/// 任务详情变更审批通知 outbox;投递失败可恢复,不影响审批主状态。 +model TaskDetailChangeNotification { + id String @id @default(uuid()) + requestId String @map("request_id") + recipientUserId String? @map("recipient_user_id") + dingtalkUserId String @map("dingtalk_user_id") + recipientName String @map("recipient_name") + eventType String @map("event_type") + businessKey String @map("business_key") + title String + content String + actionUrl String @map("action_url") + status String @default("pending") + attemptCount Int @default(0) @map("attempt_count") + maxAttempts Int @default(5) @map("max_attempts") + nextRetryAt DateTime? @map("next_retry_at") + providerMessageId String? @map("provider_message_id") + lastError String? @map("last_error") + sentAt DateTime? @map("sent_at") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + request TaskDetailChangeRequest @relation(fields: [requestId], references: [id], onDelete: Cascade) + + @@unique([eventType, businessKey], map: "task_detail_change_notifications_event_key") + @@index([requestId, createdAt], map: "task_detail_change_notifications_request_created_idx") + @@index([status, nextRetryAt], map: "task_detail_change_notifications_status_retry_idx") + @@map("task_detail_change_notifications") +} + /// 钉钉审批流程的本地只读快照。 /// diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index fae0df2..cadc4af 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1507,6 +1507,7 @@ const expectedMigrationNames = new Set([ '20260827_add_ding_workflow_detail_fields', '20260826_add_pack_video_early_access', '20260828_add_pack_item_can_edit_assets', + '20260828_add_task_detail_change_approvals', ]); if (migrations.length !== expectedMigrationNames.size || migrations.some((item) => !expectedMigrationNames.has(item && item.name) diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index 57c7cd3..7f81b1b 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -23,6 +23,7 @@ const TARGET_MIGRATIONS = Object.freeze([ '20260827_add_ding_workflow_detail_fields', '20260826_add_pack_video_early_access', '20260828_add_pack_item_can_edit_assets', + '20260828_add_task_detail_change_approvals', ]) const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' @@ -265,7 +266,9 @@ async function queryEvidence(prisma, localMigrations) { OR (table_name = 'performance_survey_employee_profiles' AND column_name IN ('employment_state', 'resigned_at', 'handover_dingtalk_user_id')) OR (table_name = 'user_employee_bindings' AND column_name IN ('id', 'user_id', 'employee_profile_id', 'binding_source', 'status')) OR (table_name = 'ding_workflow_snapshots' AND column_name IN ('process_instance_id', 'status', 'is_ongoing', 'source_updated_at_raw', 'scheme_field_count', 'task_count', 'active_task_count', 'synced_at', 'brand', 'demand_type', 'requester_name', 'requester_department', 'project_type', 'active_tasks', 'form_fields')) - OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count'))) + OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count')) + OR (table_name = 'task_detail_change_requests' AND column_name IN ('id', 'material_id', 'requester_user_id', 'manager_dingtalk_user_id', 'status', 'operations', 'base_material_updated_at', 'expires_at')) + OR (table_name = 'task_detail_change_notifications' AND column_name IN ('id', 'request_id', 'event_type', 'business_key', 'status', 'next_retry_at'))) ORDER BY table_name, column_name`, ) const indexes = await prisma.$queryRawUnsafe( @@ -298,7 +301,10 @@ async function queryEvidence(prisma, localMigrations) { 'ding_workflow_snapshots_brand_idx', 'ding_workflow_snapshots_demand_type_idx', 'ding_workflow_snapshots_requester_department_idx', - 'ding_workflow_sync_states_lease_idx' + 'ding_workflow_sync_states_lease_idx', + 'task_detail_change_requests_status_expires_idx', + 'task_detail_change_requests_status_updated_idx', + 'task_detail_change_notifications_status_retry_idx' ) ORDER BY idx.relname`, ) @@ -344,6 +350,20 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { requireColumn('materials', 'transcode_lease_token', 'text', 'YES') requireColumn('materials', 'transcode_lease_expires_at', 'timestamp without time zone', 'YES') requireColumn('materials', 'uploadId', 'text', 'YES') + requireColumn('task_detail_change_requests', 'id', 'text', 'NO') + requireColumn('task_detail_change_requests', 'material_id', 'text', 'NO') + requireColumn('task_detail_change_requests', 'requester_user_id', 'text', 'NO') + requireColumn('task_detail_change_requests', 'manager_dingtalk_user_id', 'text', 'NO') + requireColumn('task_detail_change_requests', 'status', 'text', 'NO') + requireColumn('task_detail_change_requests', 'operations', 'jsonb', 'NO') + requireColumn('task_detail_change_requests', 'base_material_updated_at', 'timestamp without time zone', 'NO') + requireColumn('task_detail_change_requests', 'expires_at', 'timestamp without time zone', 'NO') + requireColumn('task_detail_change_notifications', 'id', 'text', 'NO') + requireColumn('task_detail_change_notifications', 'request_id', 'text', 'NO') + requireColumn('task_detail_change_notifications', 'event_type', 'text', 'NO') + requireColumn('task_detail_change_notifications', 'business_key', 'text', 'NO') + requireColumn('task_detail_change_notifications', 'status', 'text', 'NO') + requireColumn('task_detail_change_notifications', 'next_retry_at', 'timestamp without time zone', 'YES') requireColumn('materials', 'custody_department_id', 'text', 'YES') requireColumn('materials', 'custody_at', 'timestamp without time zone', 'YES') requireColumn('materials', 'custody_reason', 'text', 'YES') @@ -450,6 +470,21 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { 'ding_workflow_sync_states', ['lease_expires_at'], ) + assertExactBtreeIndex( + indexByName.get('task_detail_change_requests_status_expires_idx'), + 'task_detail_change_requests', + ['status', 'expires_at'], + ) + assertExactBtreeIndex( + indexByName.get('task_detail_change_requests_status_updated_idx'), + 'task_detail_change_requests', + ['status', 'updated_at'], + ) + assertExactBtreeIndex( + indexByName.get('task_detail_change_notifications_status_retry_idx'), + 'task_detail_change_notifications', + ['status', 'next_retry_at'], + ) const isTrustedBrandValue = (value, localDefault) => { const normalized = String(value || '').trim() diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index 07e5916..c4a8cdc 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -339,6 +339,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index ['20260827_add_ding_workflow_detail_fields', { checksum: 'l' }], ['20260826_add_pack_video_early_access', { checksum: 'm' }], ['20260828_add_pack_item_can_edit_assets', { checksum: 'n' }], + ['20260828_add_task_detail_change_approvals', { checksum: 'o' }], ]) const evidence = { migrationRows: [ @@ -356,6 +357,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index { migration_name: '20260827_add_ding_workflow_detail_fields', checksum: 'l', finished_at: 'now', rolled_back_at: null }, { migration_name: '20260826_add_pack_video_early_access', checksum: 'm', finished_at: 'now', rolled_back_at: null }, { migration_name: '20260828_add_pack_item_can_edit_assets', checksum: 'n', finished_at: 'now', rolled_back_at: null }, + { migration_name: '20260828_add_task_detail_change_approvals', checksum: 'o', finished_at: 'now', rolled_back_at: null }, ], columns: [ { table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' }, @@ -404,6 +406,20 @@ test('target evidence rejects nullable logo columns and structurally wrong index { table_name: 'ding_workflow_sync_states', column_name: 'lease_owner', data_type: 'text', is_nullable: 'YES' }, { table_name: 'ding_workflow_sync_states', column_name: 'lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' }, { table_name: 'ding_workflow_sync_states', column_name: 'current_ongoing_count', data_type: 'integer', is_nullable: 'NO' }, + { table_name: 'task_detail_change_requests', column_name: 'id', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_requests', column_name: 'material_id', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_requests', column_name: 'requester_user_id', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_requests', column_name: 'manager_dingtalk_user_id', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_requests', column_name: 'status', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_requests', column_name: 'operations', data_type: 'jsonb', is_nullable: 'NO' }, + { table_name: 'task_detail_change_requests', column_name: 'base_material_updated_at', data_type: 'timestamp without time zone', is_nullable: 'NO' }, + { table_name: 'task_detail_change_requests', column_name: 'expires_at', data_type: 'timestamp without time zone', is_nullable: 'NO' }, + { table_name: 'task_detail_change_notifications', column_name: 'id', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_notifications', column_name: 'request_id', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_notifications', column_name: 'event_type', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_notifications', column_name: 'business_key', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_notifications', column_name: 'status', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'task_detail_change_notifications', column_name: 'next_retry_at', data_type: 'timestamp without time zone', is_nullable: 'YES' }, { table_name: 'system_settings', column_name: 'headerLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" }, { table_name: 'system_settings', column_name: 'loginLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/login-logo.png'::text" }, { table_name: 'system_settings', column_name: 'logoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" }, @@ -429,6 +445,9 @@ test('target evidence rejects nullable logo columns and structurally wrong index { indexname: 'ding_workflow_snapshots_demand_type_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_demand_type_idx ON public.ding_workflow_snapshots USING btree (demand_type)' }, { indexname: 'ding_workflow_snapshots_requester_department_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_requester_department_idx ON public.ding_workflow_snapshots USING btree (requester_department)' }, { indexname: 'ding_workflow_sync_states_lease_idx', indexdef: 'CREATE INDEX ding_workflow_sync_states_lease_idx ON public.ding_workflow_sync_states USING btree (lease_expires_at)' }, + { indexname: 'task_detail_change_requests_status_expires_idx', indexdef: 'CREATE INDEX task_detail_change_requests_status_expires_idx ON public.task_detail_change_requests USING btree (status, expires_at)' }, + { indexname: 'task_detail_change_requests_status_updated_idx', indexdef: 'CREATE INDEX task_detail_change_requests_status_updated_idx ON public.task_detail_change_requests USING btree (status, updated_at)' }, + { indexname: 'task_detail_change_notifications_status_retry_idx', indexdef: 'CREATE INDEX task_detail_change_notifications_status_retry_idx ON public.task_detail_change_notifications USING btree (status, next_retry_at)' }, ], brandRows: [ { id: 'local', headerLogoUrl: '/brand/header-logo.png', loginLogoUrl: '/brand/login-logo.png' }, @@ -572,6 +591,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co '20260827_add_ding_workflow_detail_fields', '20260826_add_pack_video_early_access', '20260828_add_pack_item_can_edit_assets', + '20260828_add_task_detail_change_approvals', ]) { assert.match(dockerfile, new RegExp(migrationName)) assert.match(migratorSource, new RegExp(`'${migrationName}'`))