feat: add task detail image approval and insertion flow

This commit is contained in:
chenpei 2026-08-28 19:20:01 +08:00
parent 378ae08ec4
commit 71b8f30d5b
29 changed files with 5598 additions and 477 deletions

View File

@ -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

View File

@ -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)
}

View File

@ -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 },

View File

@ -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)
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 = <T,>(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: () => <header data-testid="header" />,
}))
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 }) => <div data-testid="material-detail-header">{title}</div>,
}))
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) => (
<div data-testid="mock-product-package-detail">
<button type="button" onClick={() => props.onRequestReplace?.({ id: 'main-1', fileName: 'main-1.jpg' })}>
触发替换
</button>
<button type="button" onClick={() => props.onRequestRename?.({ id: 'main-2', fileName: 'main-2.jpg' })}>
触发重命名
</button>
<button type="button" onClick={() => props.onRequestDelete?.({ id: 'main-3', fileName: 'main-3.jpg' })}>
触发删除
</button>
<button type="button" onClick={() => props.onRequestAppend?.('main')}>
触发追加主图
</button>
<button
type="button"
onClick={() => props.onRequestInsert?.({ group: 'detail', afterAssetId: 'detail-1', beforeAssetId: 'detail-2' })}
>
触发插入详情
</button>
<div data-testid="pending-asset-ids">{(props.pendingAssetIds || []).join(',')}</div>
</div>
),
}))
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(<SharePage />)
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(<SharePage />)
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(<SharePage />)
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(<SharePage />)
expect(await screen.findByText('变更已提交主管确认')).toBeInTheDocument()
expect(screen.getByText('经理:赵主管')).toBeInTheDocument()
expect(screen.getByText('待主管确认')).toBeInTheDocument()
})
})

View File

@ -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<string, { label: string; className: string }> = {
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<string, unknown>, key: string): string | null {
const direct = record[key]
if (typeof direct === 'string' && direct.trim()) return direct.trim()
const nested = record[key] as Record<string, unknown> | 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<string, unknown>
const candidate =
(root.data as Record<string, unknown> | undefined)?.changeRequest
|| root.changeRequest
|| root.request
|| root.data
|| root
if (!candidate || typeof candidate !== 'object') return null
const record = candidate as Record<string, unknown>
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 (
<div className="mb-4 rounded-xl border border-[#e7e8ea] bg-white p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-[13px] font-semibold text-[#1a1c1d]">
{notice.mode === 'pending_approval' ? '变更已提交主管确认' : '变更已生效'}
</p>
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${statusMeta.className}`}>
{statusMeta.label}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-[#6b7075]">
<span>经理:{notice.managerName || '待分配'}</span>
<span>到期:{formatTaskDetailDeadline(notice.expiresAt)}</span>
</div>
</div>
{notice.requestId && (
<button
type="button"
onClick={onOpenDetail}
className="inline-flex min-h-10 items-center justify-center rounded-[8px] border border-[#e7e8ea] bg-white px-4 text-[13px] font-medium text-[#1a1c1d] transition-colors hover:bg-[#fafafa] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
>
查看审批详情
</button>
)}
</div>
</div>
)
}
gsap.registerPlugin(useGSAP)
function parseVideoShareWaitState(value: unknown): VideoShareWaitState | null {
@ -315,9 +529,14 @@ export default function SharePage() {
// 任务-详情单图编辑状态
const [taskDetailEditingAssetId, setTaskDetailEditingAssetId] = useState<string | null>(null)
const [replaceUploadProgress, setReplaceUploadProgress] = useState<number | null>(null)
const [taskDetailUploadProgress, setTaskDetailUploadProgress] = useState<{
label: string
progress: number
} | null>(null)
const replaceInputRef = useRef<HTMLInputElement | null>(null)
const taskDetailBatchInputRef = useRef<HTMLInputElement | null>(null)
const replaceTargetAssetIdRef = useRef<string | null>(null)
const taskDetailBatchIntentRef = useRef<TaskDetailBatchIntent | null>(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<Array<{
key: string
assetId: string
kind: 'delete' | 'rename' | 'replace'
label: string
uploadId?: string
newFileName?: string
}>>([])
saving: boolean
} | null>(null)
// 批量编辑攒批清单:先在前端整理一组变更,再统一提交给主管确认或直接应用。
const [taskDetailPendingOps, setTaskDetailPendingOps] = useState<TaskDetailPendingOp[]>([])
const [taskDetailApplying, setTaskDetailApplying] = useState(false)
const [taskDetailChangeRequestNotice, setTaskDetailChangeRequestNotice] = useState<TaskDetailChangeRequestNotice | null>(null)
const taskDetailPendingBarRef = useRef<HTMLDivElement | null>(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<TaskDetailInsertIntent, 'kind'>) => {
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 === '任务-详情' ? (
<div data-detail-preview className="mx-auto">
{taskDetailChangeRequestNotice && (
<TaskDetailChangeRequestBanner
notice={taskDetailChangeRequestNotice}
onOpenDetail={() => {
if (taskDetailChangeRequestNotice.requestId) {
router.push(`/task-detail-change-requests/${taskDetailChangeRequestNotice.requestId}`)
}
}}
/>
)}
{Boolean(material.canManageTaskDetailAssets) && taskDetailPendingOps.length > 0 && (
<div ref={taskDetailPendingBarRef} data-pending-bar className="mb-4 rounded-xl border border-[#e7e8ea] bg-white p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-[13px] font-semibold text-[#1a1c1d]">
待应用变更 {taskDetailPendingOps.length} 项
</p>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[13px] font-semibold text-[#1a1c1d]">
待提交变更 {taskDetailPendingOps.length} 项
</p>
<p className="mt-1 text-[12px] leading-5 text-[#6b7075]">
替换和插入文件先留在临时区,点击提交后再进入主管确认,不会立即覆盖正式素材。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={taskDetailApplying}
@ -1192,9 +1630,9 @@ export default function SharePage() {
disabled={taskDetailApplying}
data-apply-btn
onClick={() => void applyTaskDetailPendingOps()}
className="rounded-lg bg-[#1a1c1d] px-4 py-1.5 text-[12px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
className="min-h-10 rounded-lg bg-[#1a1c1d] px-4 py-1.5 text-[12px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
>
{taskDetailApplying ? '正在应用…' : `应用 ${taskDetailPendingOps.length} 项变更`}
{taskDetailApplying ? '正在提交…' : `提交主管确认 ${taskDetailPendingOps.length} 项`}
</button>
</div>
</div>
@ -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'
? '追加'
: '插入'}
</span>
{op.label}
</span>
@ -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() {
}}
/>
<input
ref={taskDetailBatchInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
data-testid="task-detail-batch-input"
onChange={(event) => {
const files = Array.from(event.target.files || [])
const intent = taskDetailBatchIntentRef.current
if (intent && files.length > 0) {
void queueTaskDetailUploads(files, intent)
}
event.target.value = ''
}}
/>
{/* 任务-详情:重命名弹窗 */}
{renameDialogState && (
<div
@ -1836,7 +2302,7 @@ export default function SharePage() {
>
<h3 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">重命名图片</h3>
<p className="mt-1 text-[12px] text-[#86898e]">
只修改文件名,扩展名保持不变;保存后会重新打包下载文件。
只修改文件名,扩展名保持不变;提交后会进入待确认清单,不会立刻覆盖正式素材。
</p>
<div className="mt-4 flex items-stretch">
<input
@ -1857,7 +2323,7 @@ export default function SharePage() {
aria-label="新文件名"
/>
<span className="flex min-h-10 items-center rounded-r-[8px] border border-[#e7e8ea] bg-[#fafafa] px-2.5 text-[13px] text-[#86898e]">
保存后自动沿用原扩展名
提交后自动沿用原扩展名
</span>
</div>
{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 ? '正在加入…' : '加入待提交清单'}
</button>
</div>
</div>
@ -1887,8 +2353,8 @@ export default function SharePage() {
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={replaceUploadProgress !== null
? `正在上传替换图片… ${replaceUploadProgress}%`
<ToastBubble message={taskDetailUploadProgress
? `${taskDetailUploadProgress.label}… ${taskDetailUploadProgress.progress}%`
: toast.message}
variant={toast.variant} />
)}

View File

@ -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 = <T,>(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(<TaskDetailChangeRequestPage />)
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()
})
})

View File

@ -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<TaskDetailChangeRequestStatus, { label: string; className: string }> = {
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<string, unknown>, key: string) {
const direct = readText(record[key])
if (direct) return direct
const nested = record[key] as Record<string, unknown> | 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<string, unknown>
const candidate =
(root.data as Record<string, unknown> | undefined)?.changeRequest
|| root.changeRequest
|| root.data
|| root
if (!candidate || typeof candidate !== 'object') return null
const record = candidate as Record<string, unknown>
const material = (record.material as Record<string, unknown> | undefined) || {}
const requester = (record.requester as Record<string, unknown> | undefined)
|| (record.submittedBy as Record<string, unknown> | undefined)
|| {}
const operations = Array.isArray(record.operations)
? record.operations.map((item) => {
const op = (item || {}) as Record<string, unknown>
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<TaskDetailChangeRequestViewModel | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busyDecision, setBusyDecision] = useState<TaskDetailChangeRequestDecision | null>(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 (
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<header className="mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[#66758b]">任务详情变更</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<h1 className="text-[28px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">审批详情</h1>
{!loading && record && (
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${statusMeta.className}`}>
{statusMeta.label}
</span>
)}
</div>
<p className="mt-2 max-w-3xl text-[14px] leading-6 text-[#6b7075]">
这里显示提交人、素材信息、操作清单和到期时间。主管确认前,正式素材不会被直接覆盖。
</p>
</header>
{error && (
<div className="mb-4 rounded-xl border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-[13px] text-[#b42318]">
{error}
</div>
)}
{loading ? (
<div className="rounded-xl border border-[#e7e8ea] bg-white px-5 py-10 text-sm text-[#6b7075]">
正在加载审批详情…
</div>
) : !record ? (
<div className="rounded-xl border border-[#e7e8ea] bg-white px-5 py-10 text-sm text-[#6b7075]">
未找到这条审批记录。
</div>
) : (
<div className="space-y-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,1fr)]">
<section className="rounded-xl border border-[#e7e8ea] bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">审批摘要</h2>
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-[12px] text-[#86898e]">素材</dt>
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{record.materialTitle}</dd>
</div>
<div>
<dt className="text-[12px] text-[#86898e]">素材类型</dt>
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{record.materialType || '未提供'}</dd>
</div>
<div>
<dt className="text-[12px] text-[#86898e]">任务号</dt>
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{record.taskNo || '未提供'}</dd>
</div>
<div>
<dt className="text-[12px] text-[#86898e]">提交人</dt>
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">
{record.requesterName || '未提供'}
{record.requesterHandle ? (
<span className="ml-2 text-[12px] font-normal text-[#86898e]">@{record.requesterHandle}</span>
) : null}
</dd>
</div>
</dl>
</section>
<aside className="rounded-xl border border-[#e7e8ea] bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">处理状态</h2>
<dl className="mt-4 space-y-4">
<div>
<dt className="text-[12px] text-[#86898e]">当前状态</dt>
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{statusMeta.label}</dd>
</div>
<div>
<dt className="text-[12px] text-[#86898e]">审批经理</dt>
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{record.managerName || '待分配'}</dd>
</div>
<div>
<dt className="text-[12px] text-[#86898e]">到期时间</dt>
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{formatDateTime(record.expiresAt)}</dd>
</div>
{record.decidedAt && (
<div>
<dt className="text-[12px] text-[#86898e]">处理时间</dt>
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{formatDateTime(record.decidedAt)}</dd>
</div>
)}
</dl>
{record.canDecide && record.status === 'pending_approval' && (
<div className="mt-5 flex flex-col gap-2 sm:flex-row">
<button
type="button"
disabled={Boolean(busyDecision)}
onClick={() => void submitDecision('approve')}
className="inline-flex min-h-11 flex-1 items-center justify-center rounded-[8px] bg-[#1a1c1d] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#2a2d2f] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:opacity-50"
>
{busyDecision === 'approve' ? '正在批准…' : '批准'}
</button>
<button
type="button"
disabled={Boolean(busyDecision)}
onClick={() => void submitDecision('reject')}
className="inline-flex min-h-11 flex-1 items-center justify-center rounded-[8px] border border-[#e7e8ea] bg-white px-4 text-[13px] font-medium text-[#1a1c1d] transition-colors hover:bg-[#fafafa] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:opacity-50"
>
{busyDecision === 'reject' ? '正在否决…' : '否决'}
</button>
</div>
)}
</aside>
</div>
<section className="rounded-xl border border-[#e7e8ea] bg-white p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">操作清单</h2>
<span className="text-[12px] text-[#86898e]">{record.operations.length} 项</span>
</div>
{record.operations.length === 0 ? (
<p className="mt-4 text-[14px] text-[#6b7075]">这条审批记录还没有操作项。</p>
) : (
<ol className="mt-4 space-y-2">
{record.operations.map((op, index) => (
<li
key={`${op.kind}-${op.assetId || op.afterAssetId || index}`}
className="rounded-[10px] border border-[#e7e8ea] bg-[#fafafa] px-4 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[13px] font-medium text-[#1a1c1d]">{describeOperation(op, index)}</p>
<p className="mt-1 text-[12px] text-[#86898e]">
{op.kind === 'append'
? '追加图片会在主管通过后追加到分组末尾'
: op.kind === 'insert'
? '插入图片会按当前接缝位置进入待处理链路'
: op.kind === 'replace'
? '替换图片先写入临时区,等待主管确认'
: op.kind === 'rename'
? '仅修改文件名,不会改变扩展名'
: '删除会在主管通过后从正式素材中移除'}
</p>
</div>
<span className="rounded-full border border-[#d7dde3] bg-white px-2 py-0.5 text-[11px] font-medium text-[#66758b]">
{op.kind}
</span>
</div>
</li>
))}
</ol>
)}
</section>
</div>
)}
</main>
</div>
)
}

View File

@ -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(
<ProductPackageDetail
material={material}
canDownloadSource={false}
onDownload={jest.fn()}
canManageAssets
onRequestReplace={jest.fn()}
onRequestRename={jest.fn()}
onRequestDelete={jest.fn()}
onRequestAppend={onRequestAppend}
onRequestInsert={onRequestInsert}
/>,
)
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(
<ProductPackageDetail
material={{ ...material, assets: material.assets.filter((asset) => 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()
})
})

View File

@ -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 (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label={`${label},${hint}`}
className={`${className} flex items-center justify-center rounded-[8px] border border-dashed border-[#cfd6dd] bg-[#fafafa] text-center text-[#4f6077] transition-colors hover:border-[#1a1c1d] hover:bg-white hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`}
>
<span className="flex flex-col items-center gap-1 px-2">
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" aria-hidden="true">
<path d="M10 4v12M4 10h12" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
</svg>
<span className="text-[12px] font-medium tracking-[-0.02em]">{label}</span>
<span className="text-[10px] leading-4 text-[#86898e]">{hint}</span>
</span>
</button>
);
}
function DetailInsertButton({
disabled,
onClick,
afterIndex,
beforeIndex,
}: {
disabled: boolean;
onClick: () => void;
afterIndex: number;
beforeIndex: number;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label={`在详情图 ${afterIndex} 和详情图 ${beforeIndex} 之间插入图片`}
className="my-2 flex w-full items-center justify-center gap-2 rounded-[8px] border border-dashed border-[#cfd6dd] bg-[#fafafa] px-3 py-2 text-[12px] font-medium tracking-[-0.02em] text-[#4f6077] transition-colors hover:border-[#1a1c1d] hover:bg-white hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" aria-hidden="true">
<path d="M10 4v12M4 10h12" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
</svg>
<span>在这里插入图片</span>
</button>
);
}
function AssetEditMenu({
asset,
busy,
@ -85,7 +154,7 @@ function AssetEditMenu({
<button
type="button"
aria-label={`编辑图片 ${asset.fileName}`}
className="flex h-6 w-6 items-center justify-center rounded-[6px] border border-[#e7e8ea] bg-white/95 text-[#1a1c1d] opacity-0 transition-opacity hover:border-[#1a1c1d] group-hover:opacity-100 focus-visible:opacity-100"
className="flex h-7 w-7 items-center justify-center rounded-[6px] border border-[#e7e8ea] bg-white/95 text-[#1a1c1d] opacity-100 transition-opacity hover:border-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-1 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@ -149,6 +218,8 @@ export default function ProductPackageDetail({
onRequestReplace,
onRequestRename,
onRequestDelete,
onRequestAppend,
onRequestInsert,
pendingAssetIds = [],
}: ProductPackageDetailProps) {
// 当前预览的图片类型和索引
@ -258,7 +329,7 @@ export default function ProductPackageDetail({
)}
{/* 主图小卡片展示区 */}
{mainImages.length > 0 && (
{(mainImages.length > 0 || (canManageAssets && onRequestAppend)) && (
<div className={`${detailPanelClass} p-4`}>
<h4 className={detailSectionTitleClass}>主图 ({mainImages.length})</h4>
<div className="flex flex-wrap gap-2">
@ -320,12 +391,21 @@ export default function ProductPackageDetail({
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
)}
{pendingAssetIds.includes(img.id) && (
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
)}
</>
)}
</div>
))}
{canManageAssets && onRequestAppend && (
<AddImageButton
label="追加图片"
hint="加入主图末尾"
disabled={Boolean(editingAssetId)}
onClick={() => onRequestAppend('main')}
className="h-[84px] w-[84px] flex-shrink-0"
/>
)}
</div>
</div>
)}
@ -336,35 +416,49 @@ export default function ProductPackageDetail({
<h4 className={detailSectionTitleClass}>详情图 ({detailImages.length})</h4>
<div className="space-y-0">
{detailImages.map((img, index) => (
<div key={img.id} className="group relative">
<img
src={img.fileUrl}
alt={`详情图 ${index + 1}`}
className="w-full block"
style={{ display: 'block' }}
/>
<div className="absolute left-2 top-2 bg-[#1a1c1d] px-2 py-1 text-[11px] text-white">
{index + 1}
<div key={img.id}>
<div className="group relative">
<img
src={img.fileUrl}
alt={`详情图 ${index + 1}`}
className="w-full block"
style={{ display: 'block' }}
/>
<div className="absolute left-2 top-2 bg-[#1a1c1d] px-2 py-1 text-[11px] text-white">
{index + 1}
</div>
{canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && (
<>
<AssetEditMenu
asset={img}
busy={editingAssetId === img.id}
disabled={Boolean(editingAssetId)}
onRequestReplace={onRequestReplace}
onRequestRename={onRequestRename}
onRequestDelete={onRequestDelete}
/>
{editingAssetId === img.id && (
<span className="pointer-events-none absolute inset-x-0 top-0 z-0 flex items-center justify-center bg-white/60 py-2 text-[12px] text-[#6b7075]">
正在上传临时图片,请稍候…
</span>
)}
{pendingAssetIds.includes(img.id) && (
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
)}
</>
)}
</div>
{canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && (
<>
<AssetEditMenu
asset={img}
busy={editingAssetId === img.id}
disabled={Boolean(editingAssetId)}
onRequestReplace={onRequestReplace}
onRequestRename={onRequestRename}
onRequestDelete={onRequestDelete}
/>
{editingAssetId === img.id && (
<span className="pointer-events-none absolute inset-x-0 top-0 z-0 flex items-center justify-center bg-white/60 py-2 text-[12px] text-[#6b7075]">
正在重新打包,请稍候…
</span>
)}
{pendingAssetIds.includes(img.id) && (
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
)}
</>
{canManageAssets && onRequestInsert && detailImages[index + 1] && (
<DetailInsertButton
disabled={Boolean(editingAssetId)}
afterIndex={index + 1}
beforeIndex={index + 2}
onClick={() => onRequestInsert({
group: 'detail',
afterAssetId: img.id,
beforeAssetId: detailImages[index + 1].id,
})}
/>
)}
</div>
))}
@ -413,7 +507,7 @@ export default function ProductPackageDetail({
</div>
{/* SKU图小卡片展示区 */}
{skuImages.length > 0 && (
{(skuImages.length > 0 || (canManageAssets && onRequestAppend)) && (
<div className={`${detailPanelClass} p-4`}>
<h4 className={detailSectionTitleClass}>SKU图 ({skuImages.length})</h4>
<div className="grid grid-cols-4 gap-2">
@ -461,12 +555,21 @@ export default function ProductPackageDetail({
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
)}
{pendingAssetIds.includes(img.id) && (
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
)}
</>
)}
</div>
))}
{canManageAssets && onRequestAppend && (
<AddImageButton
label="追加图片"
hint="加入 SKU 末尾"
disabled={Boolean(editingAssetId)}
onClick={() => onRequestAppend('sku')}
className="aspect-square w-full"
/>
)}
</div>
</div>
)}

View File

@ -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;执行失败时停止发布,恢复依赖执行前的数据库备份。
## 强制前提

View File

@ -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 {

View File

@ -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' }] }),

View File

@ -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<string, { path: string; size: number; fileName: string }>) {
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()
})
})

View File

@ -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)

View File

@ -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',
}))
})
})

View File

@ -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: '您无权查看这条图片变更申请' })
})
})

View File

@ -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))

File diff suppressed because it is too large Load Diff

View File

@ -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<TaskDetailChangeOperationInput, 'uploadId'> & {
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<string>()
for (const raw of rows) {
const row = raw as Record<string, unknown>
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<string, 'main' | 'sku' | 'detail'>()
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<Awaited<ReturnType<typeof getEmployeeByUserId>>>) {
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<Awaited<ReturnType<typeof getEmployeeByUserId>>>) {
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<ReturnType<typeof getEmployeeByUserId>>) {
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<string, TaskDetailStagedFile>,
): 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<TaskDetailChangeOperationInput, { uploadId: string }> => (
'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
}

View File

@ -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<string, NodeJS.Timeout>()
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
}

View File

@ -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<T>(input: {
requestId: string
ownerUserId: string
uploadIds: string[]
commit: (files: Map<string, TaskDetailStagedFile>) => Promise<T>
}): Promise<T> {
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<string, TaskDetailStagedFile>()
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 })
}

View File

@ -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");

View File

@ -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")
}
/// 钉钉审批流程的本地只读快照。
///

View File

@ -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)

View File

@ -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()

View File

@ -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}'`))