fix: restore durable recycle-bin permanent deletion

This commit is contained in:
WuYongJie 2026-10-09 12:49:44 +08:00
parent aa81054b8e
commit df0e765f3e
24 changed files with 1265 additions and 237 deletions

View File

@ -103,7 +103,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql" && \ test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql" && \
test -f "/app/prisma/migrations/20260924_add_certificate_tested_on/migration.sql" test -f "/app/prisma/migrations/20260924_add_certificate_tested_on/migration.sql"
RUN test -f "/app/prisma/migrations/20261008_permission_group_department_identity/migration.sql" && \ RUN test -f "/app/prisma/migrations/20261008_permission_group_department_identity/migration.sql" && \
test -f "/app/prisma/migrations/20261008_team_function_configs/migration.sql" test -f "/app/prisma/migrations/20261008_team_function_configs/migration.sql" && \
test -f "/app/prisma/migrations/20261009_material_purge_outbox/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。

View File

@ -10,6 +10,7 @@ import MaterialManagement from '@/components/admin/MaterialManagement'
import CategoryManagement from '@/components/admin/CategoryManagement' import CategoryManagement from '@/components/admin/CategoryManagement'
import DataStatisticsTab from '@/components/admin/DataStatisticsTab' import DataStatisticsTab from '@/components/admin/DataStatisticsTab'
import PublicTrafficTab from '@/components/admin/PublicTrafficTab' import PublicTrafficTab from '@/components/admin/PublicTrafficTab'
import ToastBubble from '@/components/ToastBubble'
// 类型定义 // 类型定义
interface Material { interface Material {
@ -266,7 +267,7 @@ export default function AdminPage() {
}, [isLoadingMoreMaterials, hasMoreMaterials, currentMaterialsPage, showRecycleBin, materialSearchTerm]) }, [isLoadingMoreMaterials, hasMoreMaterials, currentMaterialsPage, showRecycleBin, materialSearchTerm])
// 加载状态 // 加载状态
if (status === 'loading' || loading) { if (status === 'loading' || (loading && activeTab !== 'materials')) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<div className="text-xl text-[#86898e]">加载中...</div> <div className="text-xl text-[#86898e]">加载中...</div>
@ -284,13 +285,7 @@ export default function AdminPage() {
{/* Toast通知 */} {/* Toast通知 */}
{toast && ( {toast && (
<div className={`fixed right-4 top-20 z-50 border border-[#e7e8ea] px-5 py-3 text-sm ${ <ToastBubble message={toast.message} variant={toast.type} topOffsetClass="top-20" />
toast.type === 'success'
? 'bg-[#f1fbf5] text-[#157a4b]'
: 'bg-[#fff5f4] text-[#a23c36]'
} animate-fade-in`}>
{toast.message}
</div>
)} )}
<main className="container mx-auto max-w-7xl px-4 py-10 sm:py-14"> <main className="container mx-auto max-w-7xl px-4 py-10 sm:py-14">
@ -410,6 +405,7 @@ export default function AdminPage() {
{/* 素材管理 Tab */} {/* 素材管理 Tab */}
{canManageSystemTabs && activeTab === 'materials' && ( {canManageSystemTabs && activeTab === 'materials' && (
<MaterialManagement <MaterialManagement
isLoadingMaterials={loading}
materials={materials} materials={materials}
setMaterials={setMaterials} setMaterials={setMaterials}
selectedMaterials={selectedMaterials} selectedMaterials={selectedMaterials}

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isSystemAdmin } from '@/lib/department-permission' import { isSystemAdmin } from '@/lib/department-permission'
import { createMaterialLog } from '@/lib/material-log' import { createMaterialLog } from '@/lib/material-log'
import { enqueueMaterialPurge, ensureMaterialPurgeWorkerStarted, MaterialPurgeError } from '@/lib/material-purge'
// UUID 格式校验正则 // UUID 格式校验正则
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@ -27,6 +28,9 @@ export async function POST(request: NextRequest) {
} }
const { ids, permanent } = await request.json() const { ids, permanent } = await request.json()
if (permanent !== undefined && typeof permanent !== 'boolean') {
return NextResponse.json({ error: '删除方式无效' }, { status: 400 })
}
const operatorId = (session.user as any).id const operatorId = (session.user as any).id
if (!operatorId) { if (!operatorId) {
@ -34,7 +38,7 @@ export async function POST(request: NextRequest) {
} }
// 校验 ids 是数组且非空 // 校验 ids 是数组且非空
if (!Array.isArray(ids) || ids.length === 0) { if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return NextResponse.json({ error: '请提供要删除的素材ID列表' }, { status: 400 }) return NextResponse.json({ error: '请提供要删除的素材ID列表' }, { status: 400 })
} }
@ -46,11 +50,10 @@ export async function POST(request: NextRequest) {
}, { status: 400 }) }, { status: 400 })
} }
if (permanent) { if (permanent === true) {
return NextResponse.json({ const jobs = await enqueueMaterialPurge(ids, operatorId)
error: '批量永久删除已暂停:请使用可恢复删除;待持久化清理任务上线后再开放物理删除', ensureMaterialPurgeWorkerStarted()
code: 'PERMANENT_DELETE_DISABLED', return NextResponse.json({ success: true, jobs }, { status: 202 })
}, { status: 409 })
} else { } else {
// ========== 软删除:仅标记 deletedAt ========== // ========== 软删除:仅标记 deletedAt ==========
@ -124,6 +127,7 @@ export async function POST(request: NextRequest) {
}) })
} }
} catch (error) { } catch (error) {
if (error instanceof MaterialPurgeError) return NextResponse.json({ error: error.message }, { status: error.status })
console.error('[BatchDelete] Error:', error) console.error('[BatchDelete] Error:', error)
return NextResponse.json({ error: '批量删除失败' }, { status: 500 }) return NextResponse.json({ error: '批量删除失败' }, { status: 500 })
} }

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isSystemAdmin } from '@/lib/department-permission'
import { prisma } from '@/lib/prisma'
import { ensureMaterialPurgeWorkerStarted, MaterialPurgeError, retryMaterialPurge, toPurgeJobView } from '@/lib/material-purge'
export const dynamic = 'force-dynamic'
async function operator() {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) return null
return isSystemAdmin((session.user as any).role) ? (session.user as any).id as string : null
}
export async function GET(request: NextRequest) {
if (!await operator()) return NextResponse.json({ error: '无权限' }, { status: 403 })
const ids = request.nextUrl.searchParams.getAll('id')
if (ids.length > 100 || ids.some(id => !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))) {
return NextResponse.json({ error: '清理任务无效' }, { status: 400 })
}
try {
const [pending, tracked] = await Promise.all([
prisma.materialPurgeJob.findMany({ where: { status: { not: 'completed' } }, orderBy: { createdAt: 'asc' }, take: 100 }),
ids.length ? prisma.materialPurgeJob.findMany({ where: { id: { in: ids } } }) : Promise.resolve([]),
])
const jobs = [...tracked, ...pending.filter(job => !tracked.some(item => item.id === job.id))]
return NextResponse.json({ jobs: jobs.map(toPurgeJobView) }, { headers: { 'Cache-Control': 'no-store' } })
} catch {
return NextResponse.json({ error: '暂时无法读取清理进度,请重试' }, { status: 503 })
}
}
export async function POST(request: NextRequest) {
const operatorId = await operator()
if (!operatorId) return NextResponse.json({ error: '无权限' }, { status: 403 })
try {
const { jobId } = await request.json()
const job = await retryMaterialPurge(jobId, operatorId)
ensureMaterialPurgeWorkerStarted()
return NextResponse.json({ job }, { status: 202 })
} catch (error) {
if (error instanceof MaterialPurgeError) return NextResponse.json({ error: error.message }, { status: error.status })
return NextResponse.json({ error: '暂时无法重试,请稍后再试' }, { status: 503 })
}
}

View File

@ -5,6 +5,7 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { isSystemAdmin } from '@/lib/department-permission'; import { isSystemAdmin } from '@/lib/department-permission';
import { createMaterialLog } from '@/lib/material-log'; import { createMaterialLog } from '@/lib/material-log';
import { enqueueMaterialPurge, ensureMaterialPurgeWorkerStarted, MaterialPurgeError } from '@/lib/material-purge';
import { normalizeUrl, normalizeMaterial } from '@/lib/utils'; import { normalizeUrl, normalizeMaterial } from '@/lib/utils';
import { rewriteMediaUrlForClient } from '@/lib/url-rewrite'; import { rewriteMediaUrlForClient } from '@/lib/url-rewrite';
import { detectIsIntranet } from '@/lib/network-detection'; import { detectIsIntranet } from '@/lib/network-detection';
@ -926,6 +927,12 @@ export async function DELETE(request: NextRequest) {
); );
} }
if (permanent) {
const jobs = await enqueueMaterialPurge([id], (session.user as any).id);
ensureMaterialPurgeWorkerStarted();
return NextResponse.json({ success: true, jobs }, { status: 202 });
}
// 查询素材 // 查询素材
const material = await prisma.material.findUnique({ const material = await prisma.material.findUnique({
where: { id }, where: { id },
@ -988,14 +995,8 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ message: '删除成功(可恢复),文件已保留' }); return NextResponse.json({ message: '删除成功(可恢复),文件已保留' });
} }
// 永久删除
{
return NextResponse.json({
error: '永久删除已暂停:请先使用可恢复删除;待持久化清理任务上线后再开放物理删除',
code: 'PERMANENT_DELETE_DISABLED',
}, { status: 409 });
}
} catch (error: any) { } catch (error: any) {
if (error instanceof MaterialPurgeError) return NextResponse.json({ error: error.message }, { status: error.status });
// 缺少软删除列时必须失败关闭;绝不能悄悄退化成不可恢复的物理删除。 // 缺少软删除列时必须失败关闭;绝不能悄悄退化成不可恢复的物理删除。
if (error?.meta?.column?.includes('deletedAt')) { if (error?.meta?.column?.includes('deletedAt')) {
console.error('[Materials API] deletedAt field is unavailable; refusing destructive fallback'); console.error('[Materials API] deletedAt field is unavailable; refusing destructive fallback');

View File

@ -16,24 +16,25 @@ export default function ToastBubble({
zIndexClass = 'z-[200]', zIndexClass = 'z-[200]',
}: ToastBubbleProps) { }: ToastBubbleProps) {
return ( return (
<div className={`fixed ${topOffsetClass} inset-x-0 ${zIndexClass} flex justify-center pointer-events-none`}> <div className={`fixed ${topOffsetClass} inset-x-0 ${zIndexClass} flex justify-center px-4 pointer-events-none`}>
<div <div
className={`pointer-events-auto px-4 py-2 rounded-full shadow-lg border text-sm font-medium flex items-center gap-2 backdrop-blur-md ${ role={variant === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto min-w-0 max-w-[40rem] px-4 py-2 rounded-2xl shadow-lg border text-sm font-medium flex items-center gap-2 ${
variant === 'success' variant === 'success'
? 'bg-emerald-500/95 border-emerald-400 text-white' ? 'bg-emerald-700 border-emerald-700 text-white'
: 'bg-rose-500/95 border-rose-400 text-white' : 'bg-rose-700 border-rose-700 text-white'
}`} }`}
> >
{variant === 'success' ? ( {variant === 'success' ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg aria-hidden="true" className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
) : ( ) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg aria-hidden="true" className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
)} )}
<span>{message}</span> <span className="min-w-0 break-words">{message}</span>
</div> </div>
</div> </div>
) )

View File

@ -10,6 +10,8 @@
import { useRef, useEffect, useCallback, useDeferredValue, useState } from 'react' import { useRef, useEffect, useCallback, useDeferredValue, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useConfirmActionDialog, type ConfirmActionOptions } from '@/components/ConfirmActionDialog'
import { useMaterialPurgeJobs } from '@/components/admin/MaterialPurgeStatus'
import { format } from 'date-fns' import { format } from 'date-fns'
import type { Material } from '@/hooks/useAdminState' import type { Material } from '@/hooks/useAdminState'
import { parseFetchError, handleError } from '@/lib/error-handler' import { parseFetchError, handleError } from '@/lib/error-handler'
@ -20,6 +22,7 @@ import { isVideoMaterialType } from '@/lib/video-material-types'
const SEARCH_PAGE_SIZE = 50 const SEARCH_PAGE_SIZE = 50
interface MaterialManagementProps { interface MaterialManagementProps {
isLoadingMaterials?: boolean
materials: Material[] materials: Material[]
setMaterials: React.Dispatch<React.SetStateAction<Material[]>> setMaterials: React.Dispatch<React.SetStateAction<Material[]>>
selectedMaterials: Set<string> selectedMaterials: Set<string>
@ -41,6 +44,7 @@ interface MaterialManagementProps {
} }
export default function MaterialManagement({ export default function MaterialManagement({
isLoadingMaterials = false,
materials, materials,
setMaterials, setMaterials,
selectedMaterials, selectedMaterials,
@ -70,7 +74,35 @@ export default function MaterialManagement({
const [totalSearchCount, setTotalSearchCount] = useState(0) const [totalSearchCount, setTotalSearchCount] = useState(0)
// 批量操作加载状态 // 批量操作加载状态
const [isBatchProcessing, setIsBatchProcessing] = useState(false) const [actionProcessing, setIsBatchProcessing] = useState(false)
const isBatchProcessing = actionProcessing || isLoadingMaterials
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const { track: trackPurgeJobs, status: purgeStatus } = useMaterialPurgeJobs()
const actionOpen = useRef(false)
const confirmMaterialAction = async (options: ConfirmActionOptions) => {
if (actionOpen.current) return
actionOpen.current = true
try {
await confirmAction({ ...options, action: async reason => {
setIsBatchProcessing(true)
try { await options.action?.(reason) }
catch (error) { throw new Error(handleError(error).message) }
finally { setIsBatchProcessing(false) }
} })
} finally { actionOpen.current = false }
}
const removeRows = (ids: string[]) => {
setMaterials(previous => previous.filter(material => !ids.includes(material.id)))
setSelectedMaterials(previous => new Set(Array.from(previous).filter(id => !ids.includes(id))))
void fetchData()
}
const requireSuccess = async (response: Response) => {
if (!response.ok) throw new Error((await parseFetchError(response)).message)
return response.json()
}
// 使用 useDeferredValue 替代手动防抖,与前台保持一致 // 使用 useDeferredValue 替代手动防抖,与前台保持一致
const deferredSearchKeyword = useDeferredValue(materialSearchTerm) const deferredSearchKeyword = useDeferredValue(materialSearchTerm)
@ -189,75 +221,33 @@ export default function MaterialManagement({
} }
}, [isLoadingMoreSearch, hasMoreSearchResults, isInSearchMode, searchPage, showRecycleBin, deferredSearchKeyword, setMaterials, setDisplayedMaterialsCount]) }, [isLoadingMoreSearch, hasMoreSearchResults, isInSearchMode, searchPage, showRecycleBin, deferredSearchKeyword, setMaterials, setDisplayedMaterialsCount])
// 删除单个素材 // 删除操作在共享确认框内执行,失败保留上下文和选择。
const handleDeleteMaterial = async (id: string) => { const handleDeleteMaterial = async (id: string) => {
if (!confirm('确定要删除这个素材吗?')) return await confirmMaterialAction({ title: '移入回收站?', description: '素材之后可以恢复。', confirmLabel: '移入回收站', tone: 'danger', action: async () => {
await requireSuccess(await fetch(`/api/materials?id=${id}`, { method: 'DELETE' }))
try { removeRows([id])
const response = await fetch(`/api/materials?id=${id}`, { showToast('已移入回收站')
method: 'DELETE', } })
})
if (response.ok) {
setMaterials(prev => prev.filter(m => m.id !== id))
showToast('删除成功')
} else {
const error = await parseFetchError(response)
showToast(error.message, 'error')
}
} catch (error) {
console.error('Error deleting material:', error)
const { message } = handleError(error)
showToast(message, 'error')
}
} }
// 批量删除素材
const handleBatchDeleteMaterials = async () => { const handleBatchDeleteMaterials = async () => {
const selectedIds = Array.from(selectedMaterials) const selectedIds = Array.from(selectedMaterials)
if (selectedIds.length === 0) { if (!selectedIds.length) return
showToast('请先选择要删除的素材', 'error') const permanent = showRecycleBin
return await confirmMaterialAction({
} title: permanent ? `彻底删除 ${selectedIds.length} 条素材?` : `将 ${selectedIds.length} 条素材移入回收站?`,
description: permanent ? '删除后无法恢复,关联文件将一并清理。' : '素材之后可以恢复。',
const confirmMessage = showRecycleBin confirmLabel: permanent ? '彻底删除' : '移入回收站', loadingLabel: '正在提交…', tone: 'danger',
? `确定要永久删除选中的 ${selectedIds.length} 条素材吗?此操作不可恢复!` action: async () => {
: `确定要删除选中的 ${selectedIds.length} 条素材吗?素材将移至回收站。` const result = await requireSuccess(await fetch('/api/admin/materials/batch-delete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
if (!confirm(confirmMessage)) return body: JSON.stringify({ ids: selectedIds, permanent }),
}))
setIsBatchProcessing(true) if (permanent) trackPurgeJobs(result.jobs)
try { removeRows(selectedIds)
const response = await fetch('/api/admin/materials/batch-delete', { if (!permanent) showToast(`已将 ${selectedIds.length} 条素材移入回收站`)
method: 'POST', },
headers: { 'Content-Type': 'application/json' }, })
body: JSON.stringify({
ids: selectedIds,
permanent: showRecycleBin,
}),
})
if (response.ok) {
const result = await response.json()
setMaterials(prev => prev.filter(m => !selectedIds.includes(m.id)))
setSelectedMaterials(new Set())
// 显示详细信息(如果是永久删除,显示文件清理信息)
if (showRecycleBin && result.details) {
showToast(`批量永久删除完成:删除 ${result.details.deletedRecords} 条记录,清理 ${result.details.filesDeleted} 个文件`)
} else {
showToast(showRecycleBin ? '批量永久删除完成' : '批量删除完成')
}
} else {
const error = await parseFetchError(response)
showToast(error.message, 'error')
}
} catch (error) {
console.error('Batch delete materials error:', error)
const { message } = handleError(error)
showToast(message, 'error')
} finally {
setIsBatchProcessing(false)
}
} }
// 切换素材选择状态 // 切换素材选择状态
@ -292,54 +282,45 @@ export default function MaterialManagement({
} }
} }
// 恢复素材
const handleRestoreMaterial = async (id: string) => { const handleRestoreMaterial = async (id: string) => {
if (!confirm('确定要恢复该素材吗?')) return await confirmMaterialAction({ title: '恢复素材?', confirmLabel: '恢复', action: async () => {
await requireSuccess(await fetch('/api/materials/restore', {
try { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }),
const response = await fetch('/api/materials/restore', { }))
method: 'POST', removeRows([id])
headers: { 'Content-Type': 'application/json' }, showToast('已恢复素材')
body: JSON.stringify({ id }) } })
})
if (response.ok) {
setMaterials(prev => prev.filter(m => m.id !== id))
showToast('恢复成功')
fetchData()
} else {
const error = await parseFetchError(response)
showToast(error.message, 'error')
}
} catch (error) {
console.error('Restore material error:', error)
const { message } = handleError(error)
showToast(message, 'error')
}
} }
// 永久删除素材
const handlePermanentDelete = async (id: string) => { const handlePermanentDelete = async (id: string) => {
if (!confirm('确定要永久删除该素材吗?此操作不可恢复!')) return await confirmMaterialAction({ title: '彻底删除这条素材?', description: '删除后无法恢复,关联文件将一并清理。',
confirmLabel: '彻底删除', loadingLabel: '正在提交…', tone: 'danger', action: async () => {
const result = await requireSuccess(await fetch(`/api/materials?id=${id}&permanent=true`, { method: 'DELETE' }))
trackPurgeJobs(result.jobs)
removeRows([id])
},
})
}
try { const handleBatchRestore = async () => {
const response = await fetch(`/api/materials?id=${id}&permanent=true`, { let remaining = Array.from(selectedMaterials)
method: 'DELETE' const originalCount = remaining.length
}) await confirmMaterialAction({ title: `恢复 ${originalCount} 条素材?`, confirmLabel: '恢复', action: async () => {
const restored: string[] = []
if (response.ok) { let message = '请稍后重试'
setMaterials(prev => prev.filter(m => m.id !== id)) for (const id of remaining) {
showToast('永久删除成功') try {
fetchData() await requireSuccess(await fetch('/api/materials/restore', {
} else { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }),
const error = await parseFetchError(response) }))
showToast(error.message, 'error') restored.push(id)
} catch (error) { message = handleError(error).message }
} }
} catch (error) { remaining = remaining.filter(id => !restored.includes(id))
console.error('Permanent delete error:', error) removeRows(restored)
const { message } = handleError(error) if (remaining.length) throw new Error(`已恢复 ${originalCount - remaining.length} 条,${remaining.length} 条未恢复:${message}`)
showToast(message, 'error') showToast(`已恢复 ${originalCount} 条素材`)
} } })
} }
// 获取素材类型角标颜色 // 获取素材类型角标颜色
@ -399,7 +380,7 @@ export default function MaterialManagement({
}, [isInSearchMode, hasMoreSearchResults, isLoadingMoreSearch, loadMoreSearchResults, hasMoreMaterials, isLoadingMoreMaterials, loadMoreMaterials]) }, [isInSearchMode, hasMoreSearchResults, isLoadingMoreSearch, loadMoreSearchResults, hasMoreMaterials, isLoadingMoreMaterials, loadMoreMaterials])
return ( return (
<div className="card-apple overflow-hidden"> <div className="card-apple overflow-hidden" aria-busy={isBatchProcessing}>
{/* 顶部工具栏:状态切换 + 操作 + 搜索框 */} {/* 顶部工具栏:状态切换 + 操作 + 搜索框 */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm sticky top-0 z-20"> <div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm sticky top-0 z-20">
@ -409,7 +390,8 @@ export default function MaterialManagement({
<div className="bg-gray-100 dark:bg-gray-900/50 p-1 rounded-lg flex items-center shadow-inner border border-gray-200 dark:border-gray-700/50"> <div className="bg-gray-100 dark:bg-gray-900/50 p-1 rounded-lg flex items-center shadow-inner border border-gray-200 dark:border-gray-700/50">
<button <button
onClick={() => setShowRecycleBin(false)} onClick={() => setShowRecycleBin(false)}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${ disabled={actionProcessing}
className={`min-h-11 px-3 py-1.5 rounded-md text-xs font-bold transition-all ${
!showRecycleBin !showRecycleBin
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
@ -419,7 +401,8 @@ export default function MaterialManagement({
</button> </button>
<button <button
onClick={() => setShowRecycleBin(true)} onClick={() => setShowRecycleBin(true)}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all flex items-center gap-1.5 ${ disabled={actionProcessing}
className={`min-h-11 px-3 py-1.5 rounded-md text-xs font-bold transition-all flex items-center gap-1.5 ${
showRecycleBin showRecycleBin
? 'bg-white dark:bg-gray-700 text-red-600 dark:text-red-400 shadow-sm' ? 'bg-white dark:bg-gray-700 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
@ -437,20 +420,6 @@ export default function MaterialManagement({
</button> </button>
</div> </div>
{/* 回收站模式提示 */}
{showRecycleBin && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-xs text-red-600 dark:text-red-400">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="font-medium">回收站模式</span>
<span className="hidden sm:inline">- 显示已删除素材,删除操作为永久删除</span>
{totalMaterialsCount !== undefined && (
<span className="font-bold">(共 {totalMaterialsCount} 条)</span>
)}
</div>
)}
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700 hidden sm:block"></div> <div className="w-px h-6 bg-gray-200 dark:bg-gray-700 hidden sm:block"></div>
{/* 批量操作按钮组 */} {/* 批量操作按钮组 */}
@ -458,7 +427,7 @@ export default function MaterialManagement({
<button <button
onClick={toggleSelectAll} onClick={toggleSelectAll}
disabled={isBatchProcessing || materials.length === 0} disabled={isBatchProcessing || materials.length === 0}
className={`text-xs font-bold px-3 py-1.5 rounded-lg transition-colors border ${ className={`text-xs font-bold min-h-11 px-3 py-1.5 rounded-lg transition-colors border ${
materials.length > 0 && materials.slice(0, displayedMaterialsCount).every(m => selectedMaterials.has(m.id)) materials.length > 0 && materials.slice(0, displayedMaterialsCount).every(m => selectedMaterials.has(m.id))
? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800' ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'
@ -477,7 +446,7 @@ export default function MaterialManagement({
<button <button
onClick={handleBatchDeleteMaterials} onClick={handleBatchDeleteMaterials}
disabled={isBatchProcessing} disabled={isBatchProcessing}
className={`px-3 py-1.5 text-white text-xs font-bold rounded-lg transition-all flex items-center gap-1.5 shadow-sm active:scale-95 ${ className={`min-h-11 px-3 py-1.5 text-white text-xs font-bold rounded-lg transition-all flex items-center gap-1.5 shadow-sm active:scale-95 ${
isBatchProcessing isBatchProcessing
? 'bg-red-400 cursor-not-allowed' ? 'bg-red-400 cursor-not-allowed'
: showRecycleBin : showRecycleBin
@ -496,35 +465,8 @@ export default function MaterialManagement({
{showRecycleBin && !isBatchProcessing && ( {showRecycleBin && !isBatchProcessing && (
<button <button
onClick={async () => { onClick={handleBatchRestore}
if (!confirm(`确定要恢复选中的 ${selectedMaterials.size} 个素材吗?`)) return; className="min-h-11 px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white text-xs font-bold rounded-lg transition-all shadow-sm active:scale-95"
setIsBatchProcessing(true);
try {
let successCount = 0;
const ids = Array.from(selectedMaterials);
for (const id of ids) {
try {
const res = await fetch('/api/materials/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (res.ok) successCount++;
} catch (e) {
console.error(`恢复素材 ${id} 失败`, e);
}
}
setMaterials(prev => prev.filter(m => !selectedMaterials.has(m.id)));
setSelectedMaterials(new Set());
showToast(`成功恢复 ${successCount} 个素材`);
fetchData();
} catch (error) {
showToast('批量恢复部分失败', 'error');
} finally {
setIsBatchProcessing(false);
}
}}
className="px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white text-xs font-bold rounded-lg transition-all shadow-sm active:scale-95"
> >
恢复 恢复
</button> </button>
@ -545,6 +487,9 @@ export default function MaterialManagement({
</div> </div>
</div> </div>
{purgeStatus}
{confirmActionDialog}
{/* 卡片网格布局 */} {/* 卡片网格布局 */}
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6"> <div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
{materials.slice(0, displayedMaterialsCount).map((material) => { {materials.slice(0, displayedMaterialsCount).map((material) => {
@ -568,7 +513,9 @@ export default function MaterialManagement({
}`} }`}
> >
{/* 选择指示器 (更像原生选择) */} {/* 选择指示器 (更像原生选择) */}
<div className={`absolute z-20 top-2.5 right-2.5 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 ${ <button type="button" aria-label={`选择 ${material.productModel}`} aria-pressed={isSelected} disabled={isBatchProcessing}
onClick={event => { event.stopPropagation(); toggleMaterialSelection(material.id) }}
className={`absolute z-20 top-1 right-1 min-w-11 min-h-11 rounded-full flex items-center justify-center transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
isSelected isSelected
? 'bg-blue-500 text-white shadow-md scale-100' ? 'bg-blue-500 text-white shadow-md scale-100'
: 'bg-black/20 hover:bg-black/40 border border-white/30 backdrop-blur-sm' : 'bg-black/20 hover:bg-black/40 border border-white/30 backdrop-blur-sm'
@ -578,7 +525,7 @@ export default function MaterialManagement({
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
)} )}
</div> </button>
{/* 预览图/视频 */} {/* 预览图/视频 */}
<div className="relative w-full h-full"> <div className="relative w-full h-full">
@ -627,45 +574,49 @@ export default function MaterialManagement({
</div> </div>
{/* 操作按钮浮层 */} {/* 操作按钮浮层 */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all duration-300 flex flex-col items-center justify-center gap-2 p-4"> <div className="absolute inset-0 bg-black/40 backdrop-blur-[2px] opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100 transition-all duration-300 flex flex-col items-center justify-center gap-2 p-4">
{showRecycleBin ? ( {showRecycleBin ? (
<> <>
<button <button
disabled={isBatchProcessing}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleRestoreMaterial(material.id) handleRestoreMaterial(material.id)
}} }}
className="w-full py-2 bg-green-500 hover:bg-green-600 text-white text-xs font-bold rounded-lg shadow-lg transform hover:scale-105 transition-all" className="w-full min-h-11 py-2 bg-green-500 hover:bg-green-600 text-white text-xs font-bold rounded-lg shadow-lg transform hover:scale-105 transition-all"
> >
恢复 恢复
</button> </button>
<button <button
disabled={isBatchProcessing}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handlePermanentDelete(material.id) handlePermanentDelete(material.id)
}} }}
className="w-full py-2 bg-white/20 hover:bg-red-500/80 border border-white/30 text-white text-xs font-bold rounded-lg backdrop-blur-md shadow-lg transform hover:scale-105 transition-all" className="w-full min-h-11 py-2 bg-white/20 hover:bg-red-500/80 border border-white/30 text-white text-xs font-bold rounded-lg backdrop-blur-md shadow-lg transform hover:scale-105 transition-all"
> >
永久删除 彻底删除
</button> </button>
</> </>
) : ( ) : (
<> <>
<button <button
disabled={isBatchProcessing}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
router.push(`/edit/${material.id}`) router.push(`/edit/${material.id}`)
}} }}
className="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-bold rounded-lg shadow-lg transform hover:scale-105 transition-all" className="w-full min-h-11 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-bold rounded-lg shadow-lg transform hover:scale-105 transition-all"
> >
编辑 编辑
</button> </button>
<button <button
disabled={isBatchProcessing}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleDeleteMaterial(material.id) handleDeleteMaterial(material.id)
}} }}
className="w-full py-2 bg-white/20 hover:bg-red-500/80 border border-white/30 text-white text-xs font-bold rounded-lg backdrop-blur-md shadow-lg transform hover:scale-105 transition-all" className="w-full min-h-11 py-2 bg-white/20 hover:bg-red-500/80 border border-white/30 text-white text-xs font-bold rounded-lg backdrop-blur-md shadow-lg transform hover:scale-105 transition-all"
> >
移入回收站 移入回收站
</button> </button>
@ -697,7 +648,8 @@ export default function MaterialManagement({
</div> </div>
)} )}
{materials.length === 0 && ( {isLoadingMaterials && <div role="status" className="py-8 text-center text-gray-600">加载中…</div>}
{!isLoadingMaterials && materials.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无素材 暂无素材
</div> </div>

View File

@ -0,0 +1,33 @@
import React from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { useMaterialPurgeJobs } from './MaterialPurgeStatus'
const id = '11111111-1111-4111-8111-111111111111'
const job = { id, materialId: id, name: '测试素材', status: 'queued', totalFiles: 2, processedFiles: 0, sharedFiles: 0, error: null }
const response = (body: unknown, ok = true) => Promise.resolve({ ok, json: async () => body })
function Fixture() { const { status } = useMaterialPurgeJobs(); return <>{status}</> }
beforeEach(() => { jest.useFakeTimers(); global.fetch = jest.fn() })
afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks() })
test('刷新后重新接管的清理任务也显示完成结果', async () => {
;(fetch as jest.Mock).mockImplementationOnce(() => response({ jobs: [job] }))
.mockImplementation(() => response({ jobs: [{ ...job, status: 'completed', processedFiles: 2 }] }))
await act(async () => { render(<Fixture />) })
expect(screen.getByText('正在清理 1 条素材')).toBeInTheDocument()
await act(async () => { jest.advanceTimersByTime(3000) })
expect((fetch as jest.Mock).mock.calls[1][0]).toContain(`id=${id}`)
expect(screen.getByText('已彻底删除 1 条')).toBeInTheDocument()
})
test('后台进度轮询成功不能抹掉人工重试失败的提示', async () => {
const failed = { ...job, status: 'failed', error: '文件清理未完成' }
;(fetch as jest.Mock).mockImplementation((_url, options) => options?.method === 'POST'
? response({ error: '存储暂不可用,请稍后重试' }, false) : response({ jobs: [failed] }))
await act(async () => { render(<Fixture />) })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试清理' })) })
expect(screen.getByRole('alert')).toHaveTextContent('存储暂不可用,请稍后重试')
await act(async () => { jest.advanceTimersByTime(3000) })
expect(screen.getByRole('alert')).toHaveTextContent('存储暂不可用,请稍后重试')
expect(screen.getByRole('button', { name: '重试清理' })).toBeEnabled()
})

View File

@ -0,0 +1,116 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
export type PurgeJobView = {
id: string; materialId: string; name: string; status: string
totalFiles: number; processedFiles: number; sharedFiles: number; error: string | null
}
export function useMaterialPurgeJobs() {
const [jobs, setJobs] = useState<PurgeJobView[]>([])
const [error, setError] = useState<string | null>(null)
const [retryError, setRetryError] = useState<string | null>(null)
const [retrying, setRetrying] = useState<string | null>(null)
const tracked = useRef<string[]>([])
const mounted = useRef(false)
const dismissed = useRef(new Set<string>())
const loading = useRef(false)
const track = useCallback((next: PurgeJobView[]) => {
tracked.current = Array.from(new Set([...next.map(job => job.id), ...tracked.current])).slice(0, 100)
setJobs(previous => [...next, ...previous.filter(job => !next.some(item => item.id === job.id))])
}, [])
const refresh = useCallback(async () => {
if (loading.current) return
loading.current = true
try {
const params = new URLSearchParams()
tracked.current.forEach(id => params.append('id', id))
const response = await fetch(`/api/admin/materials/purge-jobs?${params}`, { cache: 'no-store' })
if (!response.ok) throw new Error('清理进度暂不可用')
const result = await response.json()
if (!mounted.current) return
// 页面刷新后接管正在处理的任务,也要跟踪它们的最终完成状态。
tracked.current = Array.from(new Set<string>([
...result.jobs.filter((job: PurgeJobView) => job.status !== 'completed').map((job: PurgeJobView) => job.id),
...tracked.current,
])).slice(0, 100)
setJobs(previous => {
const next: PurgeJobView[] = result.jobs.filter((job: PurgeJobView) => !dismissed.current.has(job.id))
// 请求开始之后新提交的任务,不能被旧的列表响应抹掉。
return [...next, ...previous.filter(job => tracked.current.includes(job.id) && !next.some(item => item.id === job.id))]
})
setError(null)
} catch {
if (mounted.current) setError('清理进度暂不可用')
} finally { loading.current = false }
}, [])
useEffect(() => {
mounted.current = true
void refresh()
const timer = setInterval(() => { if (!document.hidden) void refresh() }, 3000)
return () => { mounted.current = false; clearInterval(timer) }
}, [refresh])
const retry = async (id: string) => {
setRetrying(id)
setRetryError(null)
try {
const response = await fetch('/api/admin/materials/purge-jobs', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jobId: id }),
})
const result = await response.json()
if (!response.ok) throw new Error(result.error || '重试失败,请稍后再试')
track([result.job])
setError(null)
} catch (failure: any) { setRetryError(failure.message || '重试失败,请稍后再试') }
finally { setRetrying(null) }
}
const failed = jobs.filter(job => job.status === 'failed')
const pending = jobs.filter(job => job.status === 'queued' || job.status === 'processing')
const completed = jobs.filter(job => job.status === 'completed')
const shared = completed.reduce((sum, job) => sum + job.sharedFiles, 0)
const status = jobs.length || error || retryError ? (
<section aria-label="文件清理进度" className="border-b border-gray-200 bg-gray-50 px-4 py-3 sm:px-6">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-700" role="status" aria-live="polite">
{pending.length > 0 && <span className="inline-flex items-center gap-2">
<Loader2 aria-hidden="true" className="h-4 w-4 motion-safe:animate-spin" />
正在清理 {pending.length} 条素材
</span>}
{completed.length > 0 && <span className="inline-flex flex-wrap items-center gap-2 text-emerald-800">
<Check aria-hidden="true" className="h-4 w-4" />已彻底删除 {completed.length} 条
{shared > 0 && <span>(共用文件已保留)</span>}
<button aria-label="关闭已完成提示" className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-md hover:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-600" onClick={() => {
completed.forEach(job => dismissed.current.add(job.id))
tracked.current = tracked.current.filter(id => !dismissed.current.has(id))
setJobs(previous => previous.filter(job => job.status !== 'completed'))
}}><X aria-hidden="true" className="h-4 w-4" /></button>
</span>}
{error && <span className="inline-flex items-center gap-2 text-red-800">
{error}<button onClick={() => void refresh()} className="min-h-11 rounded-md px-3 font-medium underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-red-700">刷新进度</button>
</span>}
</div>
{retryError && <p role="alert" className="py-2 text-sm text-red-800">{retryError}</p>}
{failed.length > 0 && <ul className="max-h-64 overflow-y-auto" aria-label="需要重试的清理任务">
{failed.map(job => <li key={job.id} className="flex flex-col gap-2 py-2 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-2 text-red-800">
<AlertCircle aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 break-words"><span className="font-medium">{job.name}</span>:{job.error || '文件清理未完成'}
<span className="ml-2 text-gray-600">已处理 {job.processedFiles}/{job.totalFiles} 个文件</span>
</div>
</div>
<button disabled={retrying !== null} onClick={() => void retry(job.id)} className="min-h-11 shrink-0 rounded-lg border border-gray-300 bg-white px-4 font-medium text-gray-800 hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-600 disabled:opacity-50">
{retrying === job.id ? '正在重试…' : '重试清理'}
</button>
</li>)}
</ul>}
</section>
) : null
return { track, status }
}

View File

@ -0,0 +1,62 @@
# 回收站永久删除修复与验收
日期:2026-10-09。本文记录发布前的本地代码与隔离验收结果;验收阶段未删除生产回收站的真实素材。用户已授权提交与部署,实际发布状态以精确 Git 提交、生产镜像身份和迁移审计回执为准。原有任务详情相关未提交工作保持原状。
## 行为
- 素材管理中的单条/批量删除、恢复使用现有 `ConfirmActionDialog`。危险操作默认聚焦取消;支持键盘焦点循环、Escape 取消和焦点恢复。
- 提交失败留在确认框内,并保留素材选择。成功反馈使用共享居中气泡,限制宽度并提供 `status/alert` 语义。
- 彻底删除返回 202 和持久化清理任务,列表显示正在清理、完成或需重试。文件尚未清理完时不宣称操作完成;失败记录刷新后仍可读取。
- 批量恢复只移除成功项,失败项保留并可以续试。
## 删除一致性与存储边界
`enqueueMaterialPurge` 同一个数据库事务内检查回收站状态与在途处理,保存文件清单、素材快照、操作日志和清理审计,再删除素材记录及关联资产/SKU。任何失败整批回滚,请求路径不执行文件删除。最多提交 100 条素材,每条清单最多 2000 个文件,重复和并发提交按素材 ID 幂等。
上传作业创建/领取、视频转码领取与删除使用一致的数据库锁顺序;仍有上传作业、应用中的变更或转码租约的素材拒绝清理。恢复与删除由素材行锁协调。素材操作与下载日志不再随素材级联删除,历史导入查重改为显式关联仍在使用的素材。
旧历史素材备份脚本改为按素材 ID 显式查询两类日志,保持备份与证据预检的兼容;仅进行定向类型检查,没有执行该脚本或扩大历史删除范围。
后台从 PostgreSQL 领取任务,使用数据库时钟和租约令牌。租约 10 分钟、每 30 秒续期,每个文件的处理结果单独落库;进程中断后可接管过期任务。失败自动重试至三次,之后显示人工重试入口,保留失败、重试操作人和完成审计。应用启动与请求提交均可启动调度器,构建阶段不启动删除工作器。
只接受当前上传根目录或当前配置的 S3 桶、端点和项目前缀;清单绑定这些存储身份。根目录或端点变化后停止清理。拒绝越界、符号链接和非普通文件,不递归删除目录。属于素材 UUID 的目录仅枚举普通文件加入清单。本地文件大小与纳秒修改时间作为替换保护,时间戳以字符串保存,避免数据库 JSON 浮点往返误差。
删除前检查其他素材、资产、SKU、品牌知识、站点标识、作品图片与在途作业/变更的文件引用,包括回收站和软删除 SKU。保留共用文件。历史 HTTP 地址只能提供保守的引用保护,不增加物理删除权限;未受控地址的素材不会直接永久删除。
## 验证证据
| 验证 | 结果 |
| --- | --- |
| 相关 Jest 测试 | 38 个通过,覆盖权限、输入上限、请求不直接删除文件、路径/链接/替换保护、原素材/SKU 授权、刷新后完成状态和重试错误保留 |
| 安全迁移验证 | 13 个通过,新增清理表结构、唯一性、索引和日志不级联删除检查 |
| 实际 PostgreSQL 与 MinIO | 9 组场景通过;执行真实升级 SQL、整批回滚、审计失败回滚、在途处理拒绝、并发幂等、共用文件保护、过期租约恢复、部分失败续跑和对象前缀隔离 |
| 类型与客户端 | `npm run typecheck` 和 Prisma Client 69 项声明核对通过 |
| 构建 | `npm run build` 通过;存储历史只读边界检查通过。保留项目原有 lint/Browserslist 提示,未扩展修改范围 |
| 浏览器 | 本地真实删除/恢复 API;1280px 桌面与 390×844 手机视口。检查确认、409 失败上下文、居中气泡、单条/批量 202 清理中、刷新后失败记录、人工重试完成、取消焦点恢复;无页面控制台错误或横向溢出 |
隔离数据库使用独立 Docker 容器 `gongxiangp2-purge-qa-20261009`,仅绑定本机 55439。真实对象测试使用独立本机 MinIO 59000,未连接生产对象存储。验收脚本 `scripts/test-material-purge-integration.ts` 拒绝非指定本机验收数据库,每次建立新的独立数据库和临时文件目录。
本地 UI 预览为 `http://localhost:3114/admin`,仅身份和无关统计页使用隔离替身;删除、恢复、任务查询与重试调用本项目真实路由。页脚标注示例数据并提供复原入口。构建日志、请求记录、测试结果和截图保存在忽略目录 `tmp/material-purge-qa/`。
## Impeccable harden / polish / audit
本次页面按 Operate 模式处理,保留素材管理现有视觉语言,复用共享对话框和通知;不改造后台整体结构或添加装饰动画。
| 维度 | 范围评分 | 验证 |
| --- | --- | --- |
| 可访问性 | 3/4 | 共享确认框焦点管理、危险操作默认取消、错误/进度语义、相关操作 44px 触达;未宣称整站无障碍认证 |
| 性能 | 3/4 | 原分页与懒加载保持;状态查询限量、隐藏页面暂停轮询、请求不重叠;未进行大数据量负载测试 |
| 响应式 | 4/4 | 本次对话框、气泡和进度区域在两个视口中无溢出;手机动作可见并可操作 |
| 主题与一致性 | 3/4 | 复用现有产品组件,提高气泡前景对比;存量样式未全面重写 |
| 实现完整性 | 4/4 | 持久化链路与 UI 承诺一致,提交/完成区分,失败可见、可续试 |
| 合计 | 17/20 | 本次操作范围良好;主要原有噪音与无关样式不在修复范围 |
一次源码 detector 检查没有阻断问题,五个提示均为存量 10/11/13px 字号与主要描述部门壳的 DESIGN.md 字号阶梯不同。没有扩大到后台整体字体/主题重构。原始 JSON 为 `tmp/material-purge-qa/impeccable-audit.json`。
截图包括 `desktop-final-confirm.png`、`desktop-error.png`、`mobile-error.png`、`mobile-toast.png`、`desktop-cleanup-failed.png`、`mobile-cleanup-failed.png`、`desktop-retry-complete.png` 和 `mobile-retry-complete.png`。
## 后续发布
新增迁移 `20261009_material_purge_outbox` 已加入安全迁移与发布审计列表、构建文件存在性检查;完整迁移目录随 migrator 镜像提供。发布必须先通过现有备份/恢复验证和安全迁移流程,再启动新应用;不能只删除旧禁用判断或跳过迁移。
本地验收阶段尚未进行 Git 提交;提交后按项目规则同步真实 Git 元数据。线上验收应先使用专用可清理测试素材,真实回收站内容的永久删除仍由管理员明确确认。

View File

@ -10,6 +10,9 @@ export async function register() {
// 只在 Node.js 服务端运行(不在 Edge 运行时) // 只在 Node.js 服务端运行(不在 Edge 运行时)
if (process.env.NEXT_RUNTIME === 'nodejs') { if (process.env.NEXT_RUNTIME === 'nodejs') {
console.log('[Instrumentation] Next.js 服务端启动...') console.log('[Instrumentation] Next.js 服务端启动...')
const { ensureMaterialPurgeWorkerStarted } = await import('./lib/material-purge')
ensureMaterialPurgeWorkerStarted()
// 启动临时文件清理任务 // 启动临时文件清理任务
try { try {

View File

@ -1,18 +0,0 @@
/** @jest-environment node */
import { readFile } from 'fs/promises'
import path from 'path'
describe('material permanent delete safety gate', () => {
test.each([
'app/api/materials/route.ts',
'app/api/admin/materials/batch-delete/route.ts',
])('%s 在持久化 purge/outbox 上线前禁止物理删除', async (relativePath) => {
const source = await readFile(path.join(process.cwd(), relativePath), 'utf8')
expect(source).toContain("code: 'PERMANENT_DELETE_DISABLED'")
expect(source).not.toContain('deleteFiles(')
expect(source).not.toContain('deleteMaterialRelatedRecords(')
expect(source).not.toContain('prisma.material.delete(')
})
})

View File

@ -0,0 +1,60 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/admin/materials/batch-delete/route'
import { enqueueMaterialPurge, MaterialPurgeError } from '@/lib/material-purge'
import { readFile } from 'fs/promises'
import path from 'path'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() }))
jest.mock('@/lib/department-permission', () => ({ isSystemAdmin: (role: string) => ['ADMIN', 'SYSTEM_ADMIN'].includes(role) }))
jest.mock('@/lib/material-purge', () => ({
enqueueMaterialPurge: jest.fn(), ensureMaterialPurgeWorkerStarted: jest.fn(),
MaterialPurgeError: class extends Error { constructor(message: string, readonly status = 409) { super(message) } },
}))
const id = '11111111-1111-4111-8111-111111111111'
const request = (body: unknown) => new NextRequest('http://localhost/api/admin/materials/batch-delete', {
method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' },
})
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'admin', role: 'ADMIN' } })
})
test.each([null, { user: { id: 'user', role: 'USER' } }, { user: { id: 'department', role: 'DEPARTMENT_ADMIN' } }])('永久删除拒绝非系统管理员', async session => {
;(getServerSession as jest.Mock).mockResolvedValue(session)
expect((await POST(request({ ids: [id], permanent: true }))).status).toBe(403)
expect(enqueueMaterialPurge).not.toHaveBeenCalled()
})
test('只在任务可靠提交后返回 202 和真实任务,不宣称文件已经清理', async () => {
const jobs = [{ id: 'job', status: 'queued' }]
;(enqueueMaterialPurge as jest.Mock).mockResolvedValue(jobs)
const result = await POST(request({ ids: [id], permanent: true }))
expect(result.status).toBe(202)
expect(await result.json()).toEqual({ success: true, jobs })
expect(enqueueMaterialPurge).toHaveBeenCalledWith([id], 'admin')
})
test.each([{ ids: [id], permanent: 'true' }, { ids: ['invalid'], permanent: true }, { ids: Array(101).fill(id), permanent: true }])('拒绝错误删除方式和超限批量输入', async body => {
expect((await POST(request(body))).status).toBe(400)
expect(enqueueMaterialPurge).not.toHaveBeenCalled()
})
test('批量条件冲突返回可恢复的上下文错误', async () => {
;(enqueueMaterialPurge as jest.Mock).mockRejectedValue(new MaterialPurgeError('只能彻底删除回收站中的素材'))
const result = await POST(request({ ids: [id], permanent: true }))
expect(result.status).toBe(409)
expect(await result.json()).toEqual({ error: '只能彻底删除回收站中的素材' })
})
test.each(['app/api/materials/route.ts', 'app/api/admin/materials/batch-delete/route.ts'])('%s 的请求路径不直接做物理删除', async file => {
const source = await readFile(path.join(process.cwd(), file), 'utf8')
expect(source).toContain('enqueueMaterialPurge(')
expect(source).not.toMatch(/deleteFiles\(|deleteMaterialRelatedRecords\(|prisma\.material\.delete\(/)
})

View File

@ -0,0 +1,66 @@
/** @jest-environment node */
import { promises as fs } from 'fs'
import os from 'os'
import path from 'path'
import { collectPurgeFiles, identifyPurgeFile, removePurgeFile } from '@/lib/material-purge-files'
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/s3', () => ({
getTrustedS3MutationKey: (url: string) => {
const parsed = new URL(url)
if (parsed.origin !== 'http://storage.test' || !parsed.pathname.startsWith('/project/')) throw new Error('拒绝外部对象')
return parsed.pathname.slice(1)
},
deleteFromS3: jest.fn(),
}))
let root: string
let oldUpload: string | undefined
const material = (source: string) => ({ id: '11111111-1111-4111-8111-111111111111', previewUrl: source, zipFilePath: source,
displayImages: [], videoSourceUrl: null, videoPreview540Url: null, videoPreviewClipUrl: null, videoCoverUrl: null, assets: [], skus: [] })
beforeEach(async () => {
oldUpload = process.env.UPLOAD_DIR
root = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-purge-files-'))
process.env.UPLOAD_DIR = root
})
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true })
if (oldUpload === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = oldUpload
})
test('规范化等价的 uploads URL 并去重,重复删除缺失文件可续跑', async () => {
await fs.writeFile(path.join(root, 'asset.bin'), 'owned')
const input = material('uploads/asset.bin')
input.displayImages = ['/uploads/asset.bin'] as never[]
const files = await collectPurgeFiles(input)
expect(files).toHaveLength(1)
await removePurgeFile(files[0])
await expect(removePurgeFile(files[0])).resolves.toBeUndefined()
})
test.each(['uploads/../outside', '/etc/passwd', 'https://foreign.test/project/file', 'uploads/'])('拒绝不受控删除地址 %s', source => {
expect(() => identifyPurgeFile(source)).toThrow()
})
test('拒绝符号链接,不跟随链接删除文件', async () => {
const file = path.join(root, 'owned.bin')
await fs.writeFile(file, 'keep')
await fs.symlink(file, path.join(root, 'link.bin'))
await expect(collectPurgeFiles(material('uploads/link.bin'))).rejects.toThrow('符号链接')
expect(await fs.readFile(file, 'utf8')).toBe('keep')
})
test('任务入队后同路径文件被替换时拒绝删除', async () => {
const file = path.join(root, 'owned.bin')
await fs.writeFile(file, 'first')
const files = await collectPurgeFiles(material('uploads/owned.bin'))
await fs.writeFile(file, 'replacement content')
await expect(removePurgeFile(files[0])).rejects.toThrow('文件已发生变化')
expect(await fs.readFile(file, 'utf8')).toBe('replacement content')
})
test('没有物理文件的 data 预览不产生删除目标', async () => {
expect(await collectPurgeFiles(material('data:image/svg+xml;base64,Zm9v'))).toEqual([])
})

View File

@ -759,20 +759,15 @@ async function ensureCategoryAndSceneNames(categoryName: string, sceneName: stri
} }
async function hasImportedHistoryRecord(record: HistoryImportRecord) { async function hasImportedHistoryRecord(record: HistoryImportRecord) {
const existingByFingerprint = await prisma.materialLog.findFirst({ const existingByFingerprint = await prisma.$queryRaw<Array<{ id: string }>>`
where: { SELECT logs.id FROM "material_logs" AS logs
action: 'HISTORY_IMPORT', INNER JOIN "materials" AS materials ON materials.id = logs."materialId"
material: { WHERE logs.action = 'HISTORY_IMPORT' AND materials."deletedAt" IS NULL
deletedAt: null, AND position(${record.fingerprint} in logs.changes::text) > 0
}, LIMIT 1
changes: { `
contains: record.fingerprint,
} as any,
},
select: { id: true },
})
if (existingByFingerprint) { if (existingByFingerprint.length) {
return true return true
} }

195
lib/material-purge-files.ts Normal file
View File

@ -0,0 +1,195 @@
import { promises as fs } from 'fs'
import path from 'path'
import type { Prisma } from '@prisma/client'
import { deleteFromS3, getTrustedS3MutationKey } from '@/lib/s3'
import { getUploadRootDir } from '@/lib/upload-paths'
import { buildPublicDisplayUrl, buildWhiteBackgroundPublicDisplayPathFromAssetPath } from '@/lib/public-display'
export type PurgeFile = {
source: string
storage: 'local' | 's3'
key: string
bucket?: string
endpoint?: string
root?: string
fingerprint?: { size: number; mtimeNs: string } | null
outcome: 'pending' | 'deleted' | 'shared'
}
const MAX_FILES = 2000
/** 地址只决定当前项目的写入范围,历史域名不扩展删除权限。 */
export function identifyPurgeFile(source: string): Omit<PurgeFile, 'outcome'> | null {
const value = String(source || '').trim()
if (!value || value.startsWith('data:image/') || /^\/(?:brand|images|placeholder)[/.]/.test(value)) return null
if (/^https?:\/\//i.test(value)) {
const key = getTrustedS3MutationKey(value)
if (!process.env.S3_BUCKET) throw new Error('对象存储未配置,暂不能清理文件')
return { source: value, storage: 's3', key, bucket: process.env.S3_BUCKET,
endpoint: String(process.env.S3_ENDPOINT || '').split('#')[0].trim() }
}
const normalized = value.replace(/\\/g, '/')
if (/[\x00-\x1f\x7f]/.test(normalized) || normalized.split('/').includes('..')) {
throw new Error('文件地址不安全,已拒绝删除')
}
const root = path.resolve(getUploadRootDir())
const relative = normalized.replace(/^\.?\/?uploads\//, '')
const absolute = normalized !== relative
? path.resolve(root, relative)
: path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(process.cwd(), normalized)
const key = path.relative(root, absolute).split(path.sep).join('/')
if (!key || key === '..' || key.startsWith('../') || path.isAbsolute(key)) {
throw new Error('文件不在本项目上传目录,已拒绝删除')
}
return { source: value, storage: 'local', key, root }
}
export function purgeFileIdentity(file: Pick<PurgeFile, 'storage' | 'key' | 'bucket'>) {
const key = file.storage === 'local' && process.platform === 'win32' ? file.key.toLowerCase() : file.key
return `${file.storage}:${file.bucket || ''}:${key}`
}
async function inspectLocalFile(key: string, directory = false) {
const root = path.resolve(getUploadRootDir())
let current = root
// 每一层都拒绝符号链接;不递归删除目录,也不沿链接枚举文件。
const parts = ['', ...key.split('/')]
for (let index = 0; index < parts.length; index += 1) {
if (parts[index]) current = path.join(current, parts[index])
let stat
try { stat = await fs.lstat(current, { bigint: true }) } catch (error: any) {
if (error.code === 'ENOENT') return null
throw error
}
if (stat.isSymbolicLink()) throw new Error('文件路径包含符号链接,已拒绝删除')
if (index === parts.length - 1) {
if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error('文件清理目标类型不符,已拒绝删除')
// 纳秒时间戳用字符串保存,避免 JSON/数据库浮点往返导致误判。
return { size: Number(stat.size), mtimeNs: stat.mtimeNs.toString() }
}
if (!stat.isDirectory()) throw new Error('文件目录无效,已拒绝删除')
}
return null
}
export async function collectPurgeFiles(material: {
id: string; previewUrl: string; zipFilePath: string; displayImages: string[]
videoSourceUrl: string | null; videoPreview540Url: string | null
videoPreviewClipUrl: string | null; videoCoverUrl: string | null
assets: { fileUrl: string }[]; skus: { filePath: string; previewUrl: string | null }[]
}): Promise<PurgeFile[]> {
const sources = [material.previewUrl, material.zipFilePath, ...material.displayImages,
material.videoSourceUrl, material.videoPreview540Url, material.videoPreviewClipUrl, material.videoCoverUrl,
...material.assets.map(asset => asset.fileUrl),
...material.skus.flatMap(sku => [sku.filePath, sku.previewUrl])]
for (const asset of material.assets) {
const derived = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl)
if (derived) sources.push(buildPublicDisplayUrl(derived))
}
// 这些目录由素材 UUID 独占;列出文件写入清单,不做目录递归删除。
const root = path.resolve(getUploadRootDir())
async function walk(relative: string) {
const absolute = path.join(root, relative)
let entries
try {
if (!await inspectLocalFile(relative, true)) return
entries = await fs.readdir(absolute, { withFileTypes: true })
} catch (error: any) { if (error.code === 'ENOENT') return; throw error }
for (const entry of entries) {
if (entry.isSymbolicLink()) throw new Error('素材目录包含符号链接,已拒绝删除')
const child = `${relative}/${entry.name}`
if (entry.isDirectory()) await walk(child)
else if (entry.isFile()) sources.push(`uploads/${child}`)
else throw new Error('素材目录包含特殊文件,已拒绝删除')
if (sources.length > MAX_FILES) throw new Error('素材文件过多,请联系管理员分批清理')
}
}
// 先校验上传根目录本身,避免只检查最末级目录时沿根目录链接走出边界。
try {
const stat = await fs.lstat(root)
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('上传目录无效,已拒绝删除')
} catch (error: any) { if (error.code !== 'ENOENT') throw error }
await walk(`product-package/${material.id}`)
await walk(`skus/${material.id}`)
const files = new Map<string, PurgeFile>()
for (const source of sources) {
if (!source) continue
const file = identifyPurgeFile(source)
if (!file) continue
const identity = purgeFileIdentity(file)
if (!files.has(identity)) files.set(identity, {
...file, outcome: 'pending',
...(file.storage === 'local' ? { fingerprint: await inspectLocalFile(file.key) } : {}),
})
if (files.size > MAX_FILES) throw new Error('素材文件过多,请联系管理员分批清理')
}
return Array.from(files.values())
}
function stringsIn(value: unknown): string[] {
if (typeof value === 'string') return [value]
if (Array.isArray(value)) return value.flatMap(stringsIn)
if (value && typeof value === 'object') return Object.values(value).flatMap(stringsIn)
return []
}
/** 包含回收站与 SKU 软删除记录,保留其他素材恢复时仍需要的共用文件。 */
export async function findPurgeFileReferences(db: Prisma.TransactionClient): Promise<Set<string>> {
const [materials, assets, skus, knowledge, settings, workItems, versions, uploads, changes] = await Promise.all([
db.material.findMany({ select: { previewUrl: true, zipFilePath: true, displayImages: true,
videoSourceUrl: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true } }),
db.materialAsset.findMany({ select: { fileUrl: true } }),
db.materialSku.findMany({ select: { filePath: true, previewUrl: true } }),
db.brandKnowledge.findMany({ select: { filePath: true, previewImagePath: true, previewImageSlices: true } }),
db.systemSettings.findMany({ select: { headerLogoUrl: true, loginLogoUrl: true } }),
db.performanceSurveyWorkItem.findMany({ select: { imageUrls: true } }),
db.workReviewVersion.findMany({ select: { imageUrls: true } }),
db.productPackageUploadJob.findMany({ where: { status: { in: ['queued', 'processing'] } }, select: { assets: true } }),
db.taskDetailChangeRequest.findMany({ where: { status: { notIn: ['applied', 'rejected', 'expired', 'cancelled'] } }, select: { operations: true } }),
])
const identities = new Set<string>()
for (const source of stringsIn([materials, assets, skus, knowledge, settings, workItems, versions, uploads, changes])) {
try {
const file = identifyPurgeFile(source)
if (file) identities.add(purgeFileIdentity(file))
} catch {
// 外部只读引用不扩大写权限。路径相同的历史 HTTP 引用仍保护当前桶中的对象。
if (/^https?:\/\//i.test(source)) {
try {
let key = decodeURIComponent(new URL(source).pathname.slice(1))
const bucket = process.env.S3_BUCKET || ''
if (bucket && key.startsWith(`${bucket}/`)) key = key.slice(bucket.length + 1)
identities.add(purgeFileIdentity({ storage: 's3', bucket, key }))
} catch { /* 非文件字符串不构成引用 */ }
}
}
}
// 白底图的公网派生文件也可能被其他 SKU 的本地源文件引用。
for (const asset of assets) {
const derived = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl)
const source = derived && buildPublicDisplayUrl(derived)
if (source) {
const file = identifyPurgeFile(source)
if (file) identities.add(purgeFileIdentity(file))
}
}
return identities
}
export async function removePurgeFile(file: PurgeFile): Promise<void> {
const current = identifyPurgeFile(file.source)
if (!current || purgeFileIdentity(current) !== purgeFileIdentity(file) || current.root !== file.root || current.endpoint !== file.endpoint) {
throw new Error('存储配置已变化,已停止文件清理,请联系管理员')
}
if (file.storage === 's3') {
try { await deleteFromS3(file.source) }
catch { throw new Error('存储暂时无法访问,文件清理未完成,请重试') }
return
}
const stat = await inspectLocalFile(file.key)
if (!stat) return // 删除成功但写回之前进程中断时,重试仍可完成。
if (!file.fingerprint || stat.size !== file.fingerprint.size || stat.mtimeNs !== file.fingerprint.mtimeNs) {
throw new Error('文件已发生变化,已停止清理,请联系管理员')
}
await fs.unlink(path.join(getUploadRootDir(), file.key))
}

189
lib/material-purge.ts Normal file
View File

@ -0,0 +1,189 @@
import { randomUUID } from 'crypto'
import { Prisma, type MaterialPurgeJob } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { collectPurgeFiles, findPurgeFileReferences, purgeFileIdentity, removePurgeFile, type PurgeFile } from '@/lib/material-purge-files'
export class MaterialPurgeError extends Error {
constructor(message: string, readonly status = 409) { super(message); this.name = 'MaterialPurgeError' }
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const LEASE_MS = 10 * 60_000
const MAX_ATTEMPTS = 3
export function validatePurgeIds(input: unknown): string[] {
if (!Array.isArray(input) || input.length === 0 || input.length > 100 || input.some(id => typeof id !== 'string' || !UUID.test(id))) {
throw new MaterialPurgeError('请选择 1–100 条有效素材', 400)
}
return Array.from(new Set<string>(input.map(id => id.toLowerCase()))).sort()
}
export function toPurgeJobView(job: MaterialPurgeJob) {
const files = job.files as unknown as PurgeFile[]
const snapshot = job.materialSnapshot as { productModel?: string }
return {
id: job.id, materialId: job.materialId, name: snapshot.productModel || '素材',
status: job.status, totalFiles: files.length,
processedFiles: files.filter(file => file.outcome !== 'pending').length,
sharedFiles: files.filter(file => file.outcome === 'shared').length,
error: job.errorMessage, createdAt: job.createdAt.toISOString(),
}
}
async function lock(tx: Prisma.TransactionClient, name: string) {
await tx.$queryRaw`SELECT 1 AS locked FROM (SELECT pg_advisory_xact_lock(hashtext(${name}))) AS locked`
}
/** 清单、审计和素材记录删除必须一起提交;请求路径不做物理删除。 */
export async function enqueueMaterialPurge(input: unknown, operatorUserId: string) {
const ids = validatePurgeIds(input)
return prisma.$transaction(async tx => {
await lock(tx, 'material-purge-enqueue')
await lock(tx, 'product-package-upload-create')
await lock(tx, 'product-package-upload-worker')
await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(193582731)`
const jobs = await tx.materialPurgeJob.findMany({ where: { materialId: { in: ids } } })
const newIds = ids.filter(id => !jobs.some(job => job.materialId === id))
if (!newIds.length) return jobs.map(toPurgeJobView)
await tx.$queryRaw`SELECT id FROM materials WHERE id IN (${Prisma.join(newIds)}) ORDER BY id FOR UPDATE`
const materials = await tx.material.findMany({
where: { id: { in: newIds } }, include: { assets: true, skus: true },
})
if (materials.length !== newIds.length) throw new MaterialPurgeError('部分素材已不存在,请刷新后重试', 404)
if (materials.some(material => !material.deletedAt)) throw new MaterialPurgeError('只能彻底删除回收站中的素材')
const uploading = await tx.productPackageUploadJob.count({ where: { materialId: { in: newIds }, status: { in: ['queued', 'processing'] } } })
const applying = await tx.taskDetailChangeRequest.count({ where: { materialId: { in: newIds }, status: 'applying' } })
if (uploading || applying || materials.some(material => material.transcodeLeaseToken)) {
throw new MaterialPurgeError('部分素材仍在处理中,请处理完成后重试')
}
for (const material of materials) {
let files: PurgeFile[]
try { files = await collectPurgeFiles(material) } catch (error: any) {
throw new MaterialPurgeError(`${material.productModel || '素材'}:${error.message}`)
}
const snapshot = {
id: material.id, productModel: material.productModel, materialType: material.materialType,
taskNo: material.taskNo, ownerUserId: material.userId, deletedAt: material.deletedAt!.toISOString(),
}
const job = await tx.materialPurgeJob.create({ data: {
materialId: material.id, operatorUserId, materialSnapshot: snapshot,
files: files as unknown as Prisma.InputJsonValue,
} })
await tx.materialPurgeAudit.create({ data: { jobId: job.id, event: 'queued', operatorUserId } })
await tx.materialLog.create({ data: {
materialId: material.id, userId: operatorUserId, action: 'DELETE',
changes: JSON.stringify({
materialSnapshot: { oldValue: null, newValue: snapshot },
deleteMode: { oldValue: 'soft', newValue: 'permanent' },
purgeJobId: { oldValue: null, newValue: job.id },
}),
} })
// FK 清理关联资产、收藏等记录;操作/下载日志已由迁移解除素材级联关系。
await tx.material.delete({ where: { id: material.id } })
jobs.push(job)
}
return jobs.map(toPurgeJobView)
}, { timeout: 30_000 })
}
async function claimJob() {
return prisma.$transaction(async tx => {
const rows = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM material_purge_jobs
WHERE (status = 'queued' AND next_attempt_at <= clock_timestamp())
OR (status = 'processing' AND lease_expires_at <= clock_timestamp())
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`
if (!rows.length) return null
const leaseToken = randomUUID()
const [clock] = await tx.$queryRaw<{ now: Date }[]>`SELECT clock_timestamp() AS now`
const job = await tx.materialPurgeJob.update({ where: { id: rows[0].id }, data: {
status: 'processing', leaseToken, leaseExpiresAt: new Date(clock.now.getTime() + LEASE_MS),
attempts: { increment: 1 }, errorMessage: null,
} })
await tx.materialPurgeAudit.create({ data: { jobId: job.id, event: 'processing' } })
return job
})
}
async function updateOwnedJob(job: MaterialPurgeJob, data: Prisma.MaterialPurgeJobUpdateManyMutationInput) {
const updated = await prisma.$executeRaw`
UPDATE material_purge_jobs SET lease_expires_at = clock_timestamp() + interval '10 minutes'
WHERE id = ${job.id} AND status = 'processing' AND lease_token = ${job.leaseToken}
AND lease_expires_at > clock_timestamp()`
if (!updated) throw new Error('文件清理任务已移交,等待新的处理结果')
await prisma.$transaction(async tx => {
const result = await tx.materialPurgeJob.updateMany({
where: { id: job.id, status: 'processing', leaseToken: job.leaseToken }, data: { ...data, updatedAt: new Date() },
})
if (!result.count) throw new Error('文件清理任务已移交,等待新的处理结果')
if (data.status === 'completed') await tx.materialPurgeAudit.create({ data: { jobId: job.id, event: 'completed' } })
})
}
/** 每个文件的结果单独落库,重复删除缺失文件视为成功,可从进程中断处续跑。 */
export async function processNextMaterialPurge(): Promise<boolean> {
const job = await claimJob()
if (!job) return false
const files = job.files as unknown as PurgeFile[]
let leaseLost = false
const heartbeat = setInterval(() => {
void updateOwnedJob(job, {}).catch(() => { leaseLost = true })
}, 30_000)
heartbeat.unref?.()
try {
const references = await findPurgeFileReferences(prisma)
for (const file of files) {
if (file.outcome !== 'pending') continue
if (leaseLost) throw new Error('文件清理任务已移交,等待新的处理结果')
await updateOwnedJob(job, {})
if (references.has(purgeFileIdentity(file))) file.outcome = 'shared'
else { await removePurgeFile(file); file.outcome = 'deleted' }
await updateOwnedJob(job, { files: files as unknown as Prisma.InputJsonValue })
}
await updateOwnedJob(job, { status: 'completed', completedAt: new Date(), leaseToken: null, leaseExpiresAt: null })
} catch (error: any) {
const retry = job.attempts < MAX_ATTEMPTS
const message = error instanceof Error && /^(文件|存储|对象存储)/.test(error.message)
? error.message : '暂时无法清理文件,请稍后重试'
await prisma.$transaction(async tx => {
const result = await tx.materialPurgeJob.updateMany({
where: { id: job.id, status: 'processing', leaseToken: job.leaseToken },
data: {
status: retry ? 'queued' : 'failed', errorMessage: message,
nextAttemptAt: new Date(Date.now() + job.attempts * 30_000), leaseToken: null, leaseExpiresAt: null,
},
})
if (result.count) await tx.materialPurgeAudit.create({ data: { jobId: job.id, event: retry ? 'retry_scheduled' : 'failed', message } })
})
} finally { clearInterval(heartbeat) }
return true
}
export async function retryMaterialPurge(jobId: string, operatorUserId: string) {
if (typeof jobId !== 'string' || !UUID.test(jobId)) throw new MaterialPurgeError('清理任务无效', 400)
return prisma.$transaction(async tx => {
const result = await tx.materialPurgeJob.updateMany({ where: { id: jobId, status: 'failed' }, data: {
status: 'queued', attempts: 0, errorMessage: null, nextAttemptAt: new Date(),
} })
if (!result.count) throw new MaterialPurgeError('该任务已在处理或已经完成,请刷新状态')
await tx.materialPurgeAudit.create({ data: { jobId, event: 'manual_retry', operatorUserId } })
return toPurgeJobView(await tx.materialPurgeJob.findUniqueOrThrow({ where: { id: jobId } }))
})
}
export function ensureMaterialPurgeWorkerStarted() {
if (process.env.NEXT_BUILD === '1' || process.env.NEXT_PHASE === 'phase-production-build') return
const global = globalThis as typeof globalThis & { __materialPurgeWorkerStarted?: boolean }
if (global.__materialPurgeWorkerStarted) return
global.__materialPurgeWorkerStarted = true
let running = false
const pump = async () => {
if (running) return
running = true
try { for (let count = 0; count < 20 && await processNextMaterialPurge(); count += 1) { /* 限定每轮批量 */ } }
catch (error) { console.error('[MaterialPurge] 清理队列暂不可用,将在下一轮重试:', error) }
finally { running = false }
}
setInterval(() => { void pump() }, 5_000).unref?.()
void pump()
}

View File

@ -0,0 +1,37 @@
-- 日志的 materialId 保持历史标识,不随素材级联删除。
ALTER TABLE "material_logs" DROP CONSTRAINT IF EXISTS "material_logs_materialId_fkey";
ALTER TABLE "material_logs" DROP CONSTRAINT IF EXISTS "MaterialLog_materialId_fkey";
ALTER TABLE "material_download_logs" DROP CONSTRAINT IF EXISTS "material_download_logs_materialId_fkey";
ALTER TABLE "material_download_logs" DROP CONSTRAINT IF EXISTS "MaterialDownloadLog_materialId_fkey";
CREATE TABLE "material_purge_jobs" (
"id" TEXT NOT NULL PRIMARY KEY,
"material_id" TEXT NOT NULL,
"operator_user_id" TEXT NOT NULL,
"material_snapshot" JSONB NOT NULL,
"files" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'queued',
"attempts" INTEGER NOT NULL DEFAULT 0,
"error_message" TEXT,
"lease_token" TEXT,
"lease_expires_at" TIMESTAMP(3),
"next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"completed_at" TIMESTAMP(3),
CONSTRAINT "material_purge_jobs_status_check" CHECK ("status" IN ('queued', 'processing', 'failed', 'completed')),
CONSTRAINT "material_purge_jobs_attempts_check" CHECK ("attempts" >= 0)
);
CREATE UNIQUE INDEX "material_purge_jobs_material_id_key" ON "material_purge_jobs"("material_id");
CREATE INDEX "material_purge_jobs_status_next_attempt_at_idx" ON "material_purge_jobs"("status", "next_attempt_at");
CREATE TABLE "material_purge_audits" (
"id" TEXT NOT NULL PRIMARY KEY,
"job_id" TEXT NOT NULL,
"event" TEXT NOT NULL,
"operator_user_id" TEXT,
"message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "material_purge_audits_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "material_purge_jobs"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE INDEX "material_purge_audits_job_id_created_at_idx" ON "material_purge_audits"("job_id", "created_at");

View File

@ -138,10 +138,8 @@ model Material {
/// 不再每次去资产表现场判断是不是上传空壳。 /// 不再每次去资产表现场判断是不是上传空壳。
isIncompleteShell Boolean @default(false) @map("is_incomplete_shell") isIncompleteShell Boolean @default(false) @map("is_incomplete_shell")
assets MaterialAsset[] assets MaterialAsset[]
downloadLogs MaterialDownloadLog[]
favorites MaterialFavorite[] favorites MaterialFavorite[]
likes MaterialLike[] likes MaterialLike[]
logs MaterialLog[]
skus MaterialSku[] skus MaterialSku[]
transfers MaterialTransfer[] transfers MaterialTransfer[]
transcodeProgresses VideoTranscodeProgress[] @relation("MaterialTranscodeProgress") transcodeProgresses VideoTranscodeProgress[] @relation("MaterialTranscodeProgress")
@ -302,7 +300,6 @@ model MaterialDownloadLog {
materialId String materialId String
userId String userId String
downloadedAt DateTime @default(now()) downloadedAt DateTime @default(now())
material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([materialId]) @@index([materialId])
@ -319,7 +316,6 @@ model MaterialLog {
action String action String
changes String? changes String?
timestamp DateTime @default(now()) timestamp DateTime @default(now())
material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([materialId]) @@index([materialId])
@ -328,6 +324,41 @@ model MaterialLog {
@@map("material_logs") @@map("material_logs")
} }
// 永久删除的持久化凭证;不关联 Material,素材删除后仍保留清理清单与操作人。
model MaterialPurgeJob {
id String @id @default(uuid())
materialId String @unique @map("material_id")
operatorUserId String @map("operator_user_id")
materialSnapshot Json @map("material_snapshot")
files Json
status String @default("queued")
attempts Int @default(0)
errorMessage String? @map("error_message")
leaseToken String? @map("lease_token")
leaseExpiresAt DateTime? @map("lease_expires_at")
nextAttemptAt DateTime @default(now()) @map("next_attempt_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
audits MaterialPurgeAudit[]
@@index([status, nextAttemptAt])
@@map("material_purge_jobs")
}
model MaterialPurgeAudit {
id String @id @default(uuid())
jobId String @map("job_id")
event String
operatorUserId String? @map("operator_user_id")
message String?
createdAt DateTime @default(now()) @map("created_at")
job MaterialPurgeJob @relation(fields: [jobId], references: [id], onDelete: Restrict)
@@index([jobId, createdAt])
@@map("material_purge_audits")
}
model MaterialSku { model MaterialSku {
id String @id @default(uuid()) id String @id @default(uuid())
materialId String @map("material_id") materialId String @map("material_id")

View File

@ -1523,6 +1523,7 @@ const expectedMigrationNames = new Set([
'20260924_add_certificate_tested_on', '20260924_add_certificate_tested_on',
'20261008_permission_group_department_identity', '20261008_permission_group_department_identity',
'20261008_team_function_configs', '20261008_team_function_configs',
'20261009_material_purge_outbox',
]); ]);
if (migrations.length !== expectedMigrationNames.size if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name) || migrations.some((item) => !expectedMigrationNames.has(item && item.name)

View File

@ -75,7 +75,7 @@ async function loadCandidates(candidatePath: string) {
} }
async function loadMaterialSnapshot(materialId: string) { async function loadMaterialSnapshot(materialId: string) {
return prisma.material.findUnique({ const material = await prisma.material.findUnique({
where: { id: materialId }, where: { id: materialId },
include: { include: {
assets: { assets: {
@ -84,13 +84,21 @@ async function loadMaterialSnapshot(materialId: string) {
}, },
}, },
skus: true, skus: true,
logs: true,
likes: true, likes: true,
favorites: true, favorites: true,
downloadLogs: true,
transfers: true, transfers: true,
}, },
}) })
if (!material) return null
// Deletion history is retained independently from the material record.
const [logs, downloadLogs] = await Promise.all([
prisma.materialLog.findMany({ where: { materialId } }),
prisma.materialDownloadLog.findMany({ where: { materialId } }),
])
return { ...material, logs, downloadLogs }
} }
function hasHistoryEvidence(material: Awaited<ReturnType<typeof loadMaterialSnapshot>>, candidate: DeleteCandidate) { function hasHistoryEvidence(material: Awaited<ReturnType<typeof loadMaterialSnapshot>>, candidate: DeleteCandidate) {

View File

@ -39,6 +39,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20260924_add_certificate_tested_on', '20260924_add_certificate_tested_on',
'20261008_permission_group_department_identity', '20261008_permission_group_department_identity',
'20261008_team_function_configs', '20261008_team_function_configs',
'20261009_material_purge_outbox',
]) ])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@ -351,7 +352,7 @@ async function queryEvidence(prisma, localMigrations) {
OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell', 'task_detail_demand_type', 'task_detail_private', 'report_kind', 'certificate_expires_on', 'certificate_tested_on', 'certificate_number', 'certificate_usage_instructions', 'report_usage_rules')) OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell', 'task_detail_demand_type', 'task_detail_private', 'report_kind', 'certificate_expires_on', 'certificate_tested_on', 'certificate_number', 'certificate_usage_instructions', 'report_usage_rules'))
OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at')) OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at'))
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private')) OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits') OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits', 'material_purge_jobs', 'material_purge_audits')
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag')) OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at')) OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl')) OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
@ -418,7 +419,8 @@ async function queryEvidence(prisma, localMigrations) {
'performance_conversion_metric_date_idx', 'performance_conversion_metric_date_idx',
'performance_conversion_sync_states_lease_idx' 'performance_conversion_sync_states_lease_idx'
,'material_permission_pack_dept_unit_key', 'material_permission_pack_departments_pack_id_idx', ,'material_permission_pack_dept_unit_key', 'material_permission_pack_departments_pack_id_idx',
'team_function_configs_organization_unit_id_key', 'team_function_config_audits_config_id_created_at_idx' 'team_function_configs_organization_unit_id_key', 'team_function_config_audits_config_id_created_at_idx',
'material_purge_jobs_material_id_key', 'material_purge_jobs_status_next_attempt_at_idx', 'material_purge_audits_job_id_created_at_idx'
) )
ORDER BY idx.relname`, ORDER BY idx.relname`,
) )
@ -450,7 +452,7 @@ async function queryEvidence(prisma, localMigrations) {
LEFT JOIN pg_class ref ON ref.oid = con.confrelid LEFT JOIN pg_class ref ON ref.oid = con.confrelid
JOIN pg_namespace ns ON ns.oid = cls.relnamespace JOIN pg_namespace ns ON ns.oid = cls.relnamespace
WHERE ns.nspname = current_schema() WHERE ns.nspname = current_schema()
AND con.conname IN ( AND (con.conname IN (
'materials_private_optimization_check', 'materials_private_optimization_check',
'brand_knowledge_likes_knowledge_id_fkey', 'brand_knowledge_likes_knowledge_id_fkey',
'brand_knowledge_likes_user_id_fkey', 'brand_knowledge_likes_user_id_fkey',
@ -458,8 +460,9 @@ async function queryEvidence(prisma, localMigrations) {
'brand_knowledge_favorites_user_id_fkey' 'brand_knowledge_favorites_user_id_fkey'
,'material_permission_pack_departments_organization_unit_id_fkey', ,'material_permission_pack_departments_organization_unit_id_fkey',
'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey', 'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey',
'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check' 'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check',
) 'material_purge_audits_job_id_fkey'
) OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials'))
ORDER BY con.conname`, ORDER BY con.conname`,
) )
const brandRows = await prisma.$queryRawUnsafe( const brandRows = await prisma.$queryRawUnsafe(
@ -854,6 +857,38 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
} }
} }
assertOrganizationConfigurationEvidence(evidence) assertOrganizationConfigurationEvidence(evidence)
assertMaterialPurgeEvidence(evidence)
}
export function assertMaterialPurgeEvidence(evidence) {
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
const job = 'material_purge_jobs'
const audit = 'material_purge_audits'
for (const [table, column, type, nullable] of [
...['id', 'material_id', 'operator_user_id', 'status'].map(column => [job, column, 'text', 'NO']),
...['material_snapshot', 'files'].map(column => [job, column, 'jsonb', 'NO']),
[job, 'attempts', 'integer', 'NO'],
...['error_message', 'lease_token'].map(column => [job, column, 'text', 'YES']),
...['lease_expires_at', 'completed_at'].map(column => [job, column, 'timestamp without time zone', 'YES']),
...['next_attempt_at', 'created_at', 'updated_at'].map(column => [job, column, 'timestamp without time zone', 'NO']),
...['id', 'job_id', 'event'].map(column => [audit, column, 'text', 'NO']),
...['operator_user_id', 'message'].map(column => [audit, column, 'text', 'YES']),
[audit, 'created_at', 'timestamp without time zone', 'NO'],
]) {
const row = columns.get(`${table}.${column}`)
if (!row || row.data_type !== type || row.is_nullable !== nullable) fail(`Database column mismatch: ${table}.${column}`)
}
const uniqueName = 'material_purge_jobs_material_id_key'
assertExactUniqueBtreeIndex(evidence.indexes.find(row => row.indexname === uniqueName), job, uniqueName, ['material_id'])
for (const [table, name, fields] of [
[job, 'material_purge_jobs_status_next_attempt_at_idx', ['status', 'next_attempt_at']],
[audit, 'material_purge_audits_job_id_created_at_idx', ['job_id', 'created_at']],
]) assertExactBtreeIndex(evidence.indexes.find(row => row.indexname === name)?.indexdef, table, fields)
const fk = 'material_purge_audits_job_id_fkey'
assertExactForeignKeyConstraint(evidence.constraints.find(row => row.constraint_name === fk), audit, fk, 'job_id', job, 'id')
if (evidence.constraints.some(row => ['material_logs', 'material_download_logs'].includes(row.tablename) && row.referenced_table === 'materials')) {
fail('Material audit logs must survive permanent deletion; material foreign key is still present')
}
} }
export function assertOrganizationConfigurationEvidence(evidence) { export function assertOrganizationConfigurationEvidence(evidence) {

View File

@ -14,6 +14,7 @@ import {
assertExpectedDatabaseIdentity, assertExpectedDatabaseIdentity,
assertTargetMigrationEvidence, assertTargetMigrationEvidence,
assertOrganizationConfigurationEvidence, assertOrganizationConfigurationEvidence,
assertMaterialPurgeEvidence,
databaseFingerprintHash, databaseFingerprintHash,
normalizeColumnDefault, normalizeColumnDefault,
requireExactCommit, requireExactCommit,
@ -43,6 +44,38 @@ function organizationEvidence() {
} }
} }
function purgeEvidence() {
const job = 'material_purge_jobs'
const audit = 'material_purge_audits'
const column = (table_name, column_name, data_type, is_nullable) => ({ table_name, column_name, data_type, is_nullable })
return {
columns: [
...['id', 'material_id', 'operator_user_id', 'status'].map(name => column(job, name, 'text', 'NO')),
...['material_snapshot', 'files'].map(name => column(job, name, 'jsonb', 'NO')),
column(job, 'attempts', 'integer', 'NO'),
...['error_message', 'lease_token'].map(name => column(job, name, 'text', 'YES')),
...['lease_expires_at', 'completed_at'].map(name => column(job, name, 'timestamp without time zone', 'YES')),
...['next_attempt_at', 'created_at', 'updated_at'].map(name => column(job, name, 'timestamp without time zone', 'NO')),
...['id', 'job_id', 'event'].map(name => column(audit, name, 'text', 'NO')),
...['operator_user_id', 'message'].map(name => column(audit, name, 'text', 'YES')),
column(audit, 'created_at', 'timestamp without time zone', 'NO'),
],
indexes: [
{ tablename: job, indexname: 'material_purge_jobs_material_id_key', indisunique: true, indisprimary: false, indisvalid: true, indisready: true, access_method: 'btree', predicate: '', columns: ['material_id'] },
{ indexname: 'material_purge_jobs_status_next_attempt_at_idx', indexdef: 'CREATE INDEX material_purge_jobs_status_next_attempt_at_idx ON material_purge_jobs USING btree (status, next_attempt_at)' },
{ indexname: 'material_purge_audits_job_id_created_at_idx', indexdef: 'CREATE INDEX material_purge_audits_job_id_created_at_idx ON material_purge_audits USING btree (job_id, created_at)' },
],
constraints: [{ tablename: audit, constraint_name: 'material_purge_audits_job_id_fkey', contype: 'f', convalidated: true, referenced_table: job, columns: ['job_id'], referenced_columns: ['id'] }],
}
}
test('purge verification requires durable manifests, uniqueness, retry index and preserved material logs', () => {
const evidence = purgeEvidence()
assert.doesNotThrow(() => assertMaterialPurgeEvidence(evidence))
assert.throws(() => assertMaterialPurgeEvidence({ ...evidence, columns: evidence.columns.filter(row => row.column_name !== 'files') }), /column mismatch/)
assert.throws(() => assertMaterialPurgeEvidence({ ...evidence, constraints: [...evidence.constraints, { tablename: 'material_logs', referenced_table: 'materials' }] }), /logs must survive/)
})
test('organization configuration verification rejects missing identity uniqueness, invalid audit foreign key and nonpositive revision', () => { test('organization configuration verification rejects missing identity uniqueness, invalid audit foreign key and nonpositive revision', () => {
const evidence = organizationEvidence() const evidence = organizationEvidence()
assert.doesNotThrow(() => assertOrganizationConfigurationEvidence(evidence)) assert.doesNotThrow(() => assertOrganizationConfigurationEvidence(evidence))
@ -386,6 +419,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20260924_add_certificate_tested_on', { checksum: 'ab' }], ['20260924_add_certificate_tested_on', { checksum: 'ab' }],
['20261008_permission_group_department_identity', { checksum: 'ac' }], ['20261008_permission_group_department_identity', { checksum: 'ac' }],
['20261008_team_function_configs', { checksum: 'ad' }], ['20261008_team_function_configs', { checksum: 'ad' }],
['20261009_material_purge_outbox', { checksum: 'ae' }],
]) ])
const evidence = { const evidence = {
migrationRows: [ migrationRows: [
@ -656,8 +690,10 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ id: 'custom', headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/header.png', loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/login.png' }, { id: 'custom', headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/header.png', loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/login.png' },
], ],
} }
evidence.migrationRows.push({ migration_name: '20261009_material_purge_outbox', checksum: 'ae', finished_at: 'now', rolled_back_at: null })
const organization = organizationEvidence() const organization = organizationEvidence()
for (const key of ['columns', 'indexes', 'constraints']) evidence[key].push(...organization[key]) const purge = purgeEvidence()
for (const key of ['columns', 'indexes', 'constraints']) evidence[key].push(...organization[key], ...purge[key])
assert.doesNotThrow(() => assertTargetMigrationEvidence(migrations, evidence)) assert.doesNotThrow(() => assertTargetMigrationEvidence(migrations, evidence))
const nullableLogo = { const nullableLogo = {
...evidence, ...evidence,

View File

@ -0,0 +1,178 @@
/** 仅使用独立的本机 PostgreSQL 和临时上传目录,不读取生产配置。 */
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
async function main() {
const url = new URL(process.env.DATABASE_URL || '')
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.port === '55439' && /^\/material_purge(?:_migration)?_qa$/.test(url.pathname), '拒绝连接非独立验收数据库')
process.env.NEXT_BUILD = '1'
process.env.NODE_ENV = 'production'
const s3Enabled = process.argv.includes('--s3')
if (s3Enabled) {
Object.assign(process.env, { S3_ENDPOINT: 'http://127.0.0.1:59000', S3_PUBLIC_DOMAIN: 'http://127.0.0.1:59000/purge-qa',
S3_BUCKET: 'purge-qa', S3_FILE_PATH: 'project-purge-qa', S3_ACCESS_KEY_ID: 'purgeqa', S3_SECRET_ACCESS_KEY: 'purge_local_storage_qa' })
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-purge-integration-'))
process.env.UPLOAD_DIR = root
// 每次使用独立数据库,原生 SQL 与 Prisma 使用同一个 public schema。
const { PrismaClient } = await import('@prisma/client')
const database = `material_purge_qa_${randomUUID().replace(/-/g, '')}`
const setup = new PrismaClient({ datasourceUrl: url.toString() })
await setup.$executeRawUnsafe(`CREATE DATABASE "${database}"`)
await setup.$disconnect()
url.pathname = `/${database}`
url.searchParams.set('schema', 'public')
process.env.DATABASE_URL = url.toString()
const { spawnSync } = await import('node:child_process')
const pushed = spawnSync(process.execPath, ['node_modules/prisma/build/index.js', 'db', 'push', '--skip-generate'], { encoding: 'utf8', env: process.env })
assert.equal(pushed.status, 0, pushed.stderr)
// 重建迁移前的日志级联关系,在空的专用数据库中执行真实 reviewed SQL。
const migrationSql = await fs.readFile('prisma/migrations/20261009_material_purge_outbox/migration.sql', 'utf8')
const baselineSql = `DROP TABLE material_purge_audits; DROP TABLE material_purge_jobs;
ALTER TABLE material_logs ADD CONSTRAINT "material_logs_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES materials(id) ON DELETE CASCADE;
ALTER TABLE material_download_logs ADD CONSTRAINT "material_download_logs_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES materials(id) ON DELETE CASCADE;`
const migrated = spawnSync('docker', ['exec', '-i', 'gongxiangp2-purge-qa-20261009', 'psql', '-U', 'postgres', '-d', database, '-v', 'ON_ERROR_STOP=1'], {
encoding: 'utf8', input: `${baselineSql}\n${migrationSql}`,
})
assert.equal(migrated.status, 0, migrated.stderr)
const { prisma } = await import('../lib/prisma')
const { enqueueMaterialPurge, processNextMaterialPurge, retryMaterialPurge } = await import('../lib/material-purge')
const run = randomUUID()
const passed: string[] = []
passed.push('真实升级 SQL 创建清理表、重试索引与审计表,并解除历史日志的素材级联删除')
const admin = await prisma.user.create({ data: { email: `${run}@purge.test`, username: run, password: 'local-qa-only', role: 'ADMIN' } })
const make = async (name: string, extra: any = {}) => prisma.material.create({ data: {
productModel: `${run}-${name}`, previewUrl: 'data:image/svg+xml;base64,Zm9v', categoryName: '验收',
sceneName: '验收', materialType: '标准模型', authorName: '隔离验收', userId: admin.id, deletedAt: new Date(), ...extra,
} })
const file = async (name: string) => { await fs.writeFile(path.join(root, name), 'isolated content'); return `uploads/${name}` }
const exists = async (name: string) => fs.stat(path.join(root, name)).then(() => true, () => false)
try {
const active = await make('active', { deletedAt: null })
const bin = await make('atomic', { zipFilePath: await file('atomic.bin') })
await assert.rejects(enqueueMaterialPurge([bin.id, active.id], admin.id), /只能彻底删除/)
assert(await prisma.material.findUnique({ where: { id: bin.id } }))
assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: bin.id } }), 0)
assert(await exists('atomic.bin'))
passed.push('混合批量整批拒绝,素材/文件/任务均无部分提交')
await assert.rejects(enqueueMaterialPurge([bin.id], 'missing-operator'), /Foreign key/)
assert(await prisma.material.findUnique({ where: { id: bin.id } }))
assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: bin.id } }), 0)
passed.push('审计写入失败时整个删除事务回滚')
const uploading = await make('uploading')
await prisma.productPackageUploadJob.create({ data: { materialId: uploading.id, operatorUserId: admin.id, assets: [], status: 'queued', stage: 'queued' } })
await assert.rejects(enqueueMaterialPurge([uploading.id], admin.id), /仍在处理中/)
const transcoding = await make('transcoding', { transcodeLeaseToken: 'active-lease' })
await assert.rejects(enqueueMaterialPurge([transcoding.id], admin.id), /仍在处理中/)
passed.push('上传/转码未结束的素材拒绝进入清理队列')
const own = await make('single', { zipFilePath: await file('single.bin') })
await prisma.materialAsset.create({ data: { materialId: own.id, assetType: 'arbitrary-type', fileUrl: await file('asset.bin') } })
await prisma.materialSku.create({ data: { materialId: own.id, skuName: 'test', filePath: await file('sku.bin') } })
await prisma.materialLog.create({ data: { materialId: own.id, userId: admin.id, action: 'UPLOAD' } })
await prisma.materialDownloadLog.create({ data: { materialId: own.id, userId: admin.id } })
const requests = await Promise.all([enqueueMaterialPurge([own.id, own.id], admin.id), enqueueMaterialPurge([own.id], admin.id)])
assert.equal(requests[0][0].id, requests[1][0].id)
assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: own.id } }), 1)
assert.equal(await prisma.materialLog.count({ where: { materialId: own.id } }), 2)
assert.equal(await prisma.materialDownloadLog.count({ where: { materialId: own.id } }), 1)
assert.equal(await prisma.materialAsset.count({ where: { materialId: own.id } }), 0)
assert.equal(await prisma.materialSku.count({ where: { materialId: own.id } }), 0)
assert(await exists('single.bin'))
await processNextMaterialPurge()
assert(!await exists('single.bin') && !await exists('asset.bin') && !await exists('sku.bin'))
assert.equal((await prisma.materialPurgeJob.findUniqueOrThrow({ where: { materialId: own.id } })).status, 'completed')
passed.push('并发/重复提交幂等;关联记录清理,旧日志和下载日志保留;所有类型资产纳入清单')
const sharedSource = await file('shared.bin')
const keeper = await make('shared-keeper', { previewUrl: '/uploads/shared.bin' })
const sharer = await make('shared-delete', { zipFilePath: sharedSource })
await enqueueMaterialPurge([sharer.id], admin.id)
await processNextMaterialPurge()
assert(await exists('shared.bin'))
const sharedJob = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { materialId: sharer.id } })
assert.equal((sharedJob.files as any)[0].outcome, 'shared')
assert(await prisma.material.findUnique({ where: { id: keeper.id } }))
passed.push('URL 等价引用与回收站引用保护共用文件')
const batch = await Promise.all([make('batch-a', { zipFilePath: await file('batch-a.bin') }), make('batch-b', { zipFilePath: await file('batch-b.bin') })])
const batchJobs = await enqueueMaterialPurge(batch.map(material => material.id), admin.id)
assert.equal(batchJobs.length, 2)
// 模拟作业进程中断:持久化处理中状态和已过期租约,下一轮自动接管。
await prisma.materialPurgeJob.update({ where: { id: batchJobs[0].id }, data: { status: 'processing', leaseToken: 'interrupted-process', leaseExpiresAt: new Date(0) } })
await Promise.all([processNextMaterialPurge(), processNextMaterialPurge()])
assert(!await exists('batch-a.bin') && !await exists('batch-b.bin'))
assert.equal(await prisma.materialPurgeJob.count({ where: { id: { in: batchJobs.map(job => job.id) }, status: 'completed' } }), 2)
passed.push('批量作业并发领取不重复,重启后过期租约可接管并完成')
const broken = await make('retry', { zipFilePath: await file('retry-first.bin'), displayImages: [await file('retry-second.bin')] })
const [retryJob] = await enqueueMaterialPurge([broken.id], admin.id)
await fs.rename(path.join(root, 'retry-second.bin'), path.join(root, 'saved-second.bin'))
await fs.mkdir(path.join(root, 'retry-second.bin'))
for (let attempt = 0; attempt < 3; attempt += 1) {
await prisma.materialPurgeJob.update({ where: { id: retryJob.id }, data: { nextAttemptAt: new Date(0) } })
await processNextMaterialPurge()
}
let failed = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: retryJob.id } })
assert.equal(failed.status, 'failed')
assert(!await exists('retry-first.bin'))
assert.equal((failed.files as any)[0].outcome, 'deleted')
await fs.rmdir(path.join(root, 'retry-second.bin'))
await fs.rename(path.join(root, 'saved-second.bin'), path.join(root, 'retry-second.bin'))
await retryMaterialPurge(retryJob.id, admin.id)
await processNextMaterialPurge()
failed = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: retryJob.id } })
assert.equal(failed.status, 'completed')
assert(!await exists('retry-second.bin'))
const audits = await prisma.materialPurgeAudit.findMany({ where: { jobId: retryJob.id }, orderBy: { createdAt: 'asc' } })
assert(audits.some(audit => audit.event === 'failed'))
assert(audits.some(audit => audit.event === 'manual_retry' && audit.operatorUserId === admin.id))
passed.push('部分文件失败保存进度,自动重试耗尽后人工续跑;失败/重试操作人审计完整')
if (s3Enabled) {
const { S3Client, CreateBucketCommand, PutObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3')
const client = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: 'us-east-1', forcePathStyle: true,
credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID!, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY! }, maxAttempts: 1 })
const Bucket = process.env.S3_BUCKET!
try { await client.send(new CreateBucketCommand({ Bucket })) } catch (error: any) { if (error.name !== 'BucketAlreadyOwnedByYou') throw error }
const ownKey = `project-purge-qa/${run}/owned.bin`
const sharedKey = `project-purge-qa/${run}/shared.bin`
const foreignKey = `other-project/${run}/sentinel.bin`
for (const Key of [ownKey, sharedKey, foreignKey]) await client.send(new PutObjectCommand({ Bucket, Key, Body: 'isolated-object' }))
const source = (key: string) => `http://127.0.0.1:59000/${Bucket}/${key}`
const objectMaterial = await make('object-own', { zipFilePath: source(ownKey) })
await enqueueMaterialPurge([objectMaterial.id], admin.id)
await processNextMaterialPurge()
await assert.rejects(client.send(new HeadObjectCommand({ Bucket, Key: ownKey })), error => (error as any).$metadata.httpStatusCode === 404)
const objectKeeper = await make('object-keeper', { previewUrl: `${source(sharedKey)}?cache=keeper` })
const objectShared = await make('object-shared', { zipFilePath: source(sharedKey) })
await enqueueMaterialPurge([objectShared.id], admin.id)
await processNextMaterialPurge()
await client.send(new HeadObjectCommand({ Bucket, Key: sharedKey }))
assert(await prisma.material.findUnique({ where: { id: objectKeeper.id } }))
const objectForeign = await make('object-foreign', { zipFilePath: source(foreignKey) })
await assert.rejects(enqueueMaterialPurge([objectForeign.id], admin.id), /业务前缀之外/)
await client.send(new HeadObjectCommand({ Bucket, Key: foreignKey }))
assert(await prisma.material.findUnique({ where: { id: objectForeign.id } }))
passed.push('真实 MinIO 验证:本项目对象删除成功,共用对象保留,其他项目前缀拒绝删除')
client.destroy()
}
const output = { database: url.pathname.slice(1), passed, checks: passed.length, at: new Date().toISOString() }
await fs.mkdir('tmp/material-purge-qa', { recursive: true })
await fs.writeFile('tmp/material-purge-qa/integration.json', JSON.stringify(output, null, 2))
console.log(JSON.stringify(output, null, 2))
} finally {
await prisma.$disconnect()
// root 是本次 mkdtemp 创建的目录,仅移除这次隔离文件。
assert(path.dirname(root) === os.tmpdir() && path.basename(root).startsWith('gongxiangp2-purge-integration-'))
await fs.rm(root, { recursive: true, force: true })
}
}
main().catch(error => { console.error(error); process.exitCode = 1 })