Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
575 lines
20 KiB
TypeScript
575 lines
20 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import { lookup as mimeTypeLookup } from 'mime-types'
|
||
import path from 'path'
|
||
import { readFile, unlink } from 'fs/promises'
|
||
|
||
import { authOptions } from '@/lib/auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import {
|
||
CERTIFICATE_REPORT_ASSET_TYPE,
|
||
CERTIFICATE_REPORT_MATERIAL_TYPE,
|
||
CERTIFICATE_REPORT_UPLOAD_LIMITS,
|
||
detectFileKindFromName,
|
||
isSupportedCertificateReportFileName,
|
||
} from '@/lib/certificate-report'
|
||
import {
|
||
buildCertificateReportPublicDisplayPath,
|
||
uploadPublicDisplayFileToS3,
|
||
uploadPublicDisplayToS3,
|
||
} from '@/lib/public-display'
|
||
import { cleanupTempFiles } from '@/lib/task-material-processor'
|
||
import { createMaterialLog } from '@/lib/material-log'
|
||
import { deleteFromS3 } from '@/lib/s3'
|
||
import { generatePdfPreview } from '@/lib/pdf-processor'
|
||
import { inspectBoundedRegularFile } from '@/lib/bounded-file-reader'
|
||
import { getOwnedUploadedFile } from '@/lib/upload-sessions'
|
||
import {
|
||
acquireTaskMaterialCompletionLock,
|
||
acquireTaskMaterialUploadSessionLocks,
|
||
type MaterialUploadStorageLockRelease,
|
||
} from '@/lib/material-upload-cross-process-lock'
|
||
import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard'
|
||
|
||
interface UploadedReportFile {
|
||
uploadId: string
|
||
fileName: string
|
||
fileSize: number
|
||
sortOrder: number
|
||
fileKind: 'pdf' | 'image'
|
||
}
|
||
|
||
interface CertificateReportAssetSnapshot {
|
||
assetType: string
|
||
fileUrl: string
|
||
fileName: string | null
|
||
fileSize: bigint | number | string
|
||
}
|
||
|
||
interface CertificateReportCompletionSnapshot {
|
||
id: string
|
||
userId: string
|
||
materialType: string
|
||
taskStatus: string | null
|
||
previewUrl: string
|
||
zipFileName: string
|
||
zipFilePath: string
|
||
zipFileSize: bigint | number | string
|
||
deletedAt: Date | null
|
||
assets: CertificateReportAssetSnapshot[]
|
||
}
|
||
|
||
type CertificateReportCompletionInspection =
|
||
| { state: 'completed'; snapshot: CertificateReportCompletionSnapshot }
|
||
| { state: 'completed_incomplete'; snapshot: CertificateReportCompletionSnapshot }
|
||
| { state: 'other'; snapshot: CertificateReportCompletionSnapshot | null }
|
||
| { state: 'unknown'; error: unknown }
|
||
|
||
class CertificateReportRequestError extends Error {
|
||
constructor(message: string, readonly status = 400) {
|
||
super(message)
|
||
this.name = 'CertificateReportRequestError'
|
||
}
|
||
}
|
||
|
||
function sanitizeFileNameForStorage(fileName: string): string {
|
||
return String(fileName || '')
|
||
.trim()
|
||
.replace(/[\/\\:*?"<>|]/g, '_')
|
||
}
|
||
|
||
function toPositiveBigInt(value: unknown): bigint | null {
|
||
try {
|
||
const normalized = BigInt(value as bigint | number | string)
|
||
return normalized > BigInt(0) ? normalized : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function hasCompleteCertificateReportEvidence(
|
||
material: CertificateReportCompletionSnapshot,
|
||
): boolean {
|
||
if (material.taskStatus !== 'completed') {
|
||
return false
|
||
}
|
||
|
||
const previewUrl = String(material.previewUrl || '').trim()
|
||
const summaryFileName = String(material.zipFileName || '').trim()
|
||
const summarySize = toPositiveBigInt(material.zipFileSize)
|
||
const reportAssets = Array.isArray(material.assets) ? material.assets : []
|
||
if (!previewUrl || !summaryFileName || !summarySize || reportAssets.length === 0) {
|
||
return false
|
||
}
|
||
|
||
let assetSizeTotal = BigInt(0)
|
||
for (const asset of reportAssets) {
|
||
if (
|
||
asset.assetType !== CERTIFICATE_REPORT_ASSET_TYPE.PDF
|
||
&& asset.assetType !== CERTIFICATE_REPORT_ASSET_TYPE.IMAGE
|
||
) {
|
||
return false
|
||
}
|
||
if (!String(asset.fileUrl || '').trim() || !String(asset.fileName || '').trim()) {
|
||
return false
|
||
}
|
||
const assetSize = toPositiveBigInt(asset.fileSize)
|
||
if (!assetSize) {
|
||
return false
|
||
}
|
||
assetSizeTotal += assetSize
|
||
}
|
||
|
||
return assetSizeTotal === summarySize
|
||
}
|
||
|
||
async function readCertificateReportCompletionSnapshot(
|
||
materialId: string,
|
||
): Promise<CertificateReportCompletionSnapshot | null> {
|
||
return prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
materialType: true,
|
||
taskStatus: true,
|
||
previewUrl: true,
|
||
zipFileName: true,
|
||
zipFilePath: true,
|
||
zipFileSize: true,
|
||
deletedAt: true,
|
||
assets: {
|
||
where: {
|
||
assetType: {
|
||
in: [CERTIFICATE_REPORT_ASSET_TYPE.PDF, CERTIFICATE_REPORT_ASSET_TYPE.IMAGE],
|
||
},
|
||
},
|
||
select: {
|
||
assetType: true,
|
||
fileUrl: true,
|
||
fileName: true,
|
||
fileSize: true,
|
||
},
|
||
},
|
||
},
|
||
}) as Promise<CertificateReportCompletionSnapshot | null>
|
||
}
|
||
|
||
async function inspectCertificateReportCompletionAfterError(
|
||
materialId: string,
|
||
): Promise<CertificateReportCompletionInspection> {
|
||
try {
|
||
const snapshot = await readCertificateReportCompletionSnapshot(materialId)
|
||
if (snapshot?.taskStatus === 'completed') {
|
||
return hasCompleteCertificateReportEvidence(snapshot)
|
||
? { state: 'completed', snapshot }
|
||
: { state: 'completed_incomplete', snapshot }
|
||
}
|
||
return { state: 'other', snapshot }
|
||
} catch (error) {
|
||
return { state: 'unknown', error }
|
||
}
|
||
}
|
||
|
||
function buildErrorResponse(error: unknown): NextResponse {
|
||
const message = error instanceof Error ? error.message : '完成上传失败'
|
||
if (error instanceof MaterialUploadRequestError) {
|
||
const headers = error.retryAfterSeconds
|
||
? { 'Retry-After': String(error.retryAfterSeconds) }
|
||
: undefined
|
||
return NextResponse.json(
|
||
{ error: message, code: error.code },
|
||
{ status: error.status, headers },
|
||
)
|
||
}
|
||
return NextResponse.json(
|
||
{ error: message },
|
||
{ status: error instanceof CertificateReportRequestError ? error.status : 500 },
|
||
)
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
const tempFiles: string[] = []
|
||
const uploadIds: string[] = []
|
||
const uploadedAssetUrls: string[] = []
|
||
const localPreviewPaths = new Set<string>()
|
||
let previewUrlToCleanup = ''
|
||
let materialCommitted = false
|
||
let committedMaterialId = ''
|
||
let authenticatedUserId = ''
|
||
let releaseCompletionLock: MaterialUploadStorageLockRelease | null = null
|
||
let releaseUploadSessionLocks: MaterialUploadStorageLockRelease | null = null
|
||
|
||
try {
|
||
const session = await getServerSession(authOptions)
|
||
if (!session?.user) {
|
||
return NextResponse.json({ error: '请先登录' }, { status: 401 })
|
||
}
|
||
const userId = String((session.user as any).id || '').trim()
|
||
authenticatedUserId = userId
|
||
|
||
const body = await request.json()
|
||
const materialId = String(body.materialId || '').trim()
|
||
committedMaterialId = materialId
|
||
const rawFiles = Array.isArray(body.files) ? body.files : []
|
||
if (rawFiles.length > CERTIFICATE_REPORT_UPLOAD_LIMITS.maxFiles) {
|
||
throw new CertificateReportRequestError(
|
||
`检测报告最多上传 ${CERTIFICATE_REPORT_UPLOAD_LIMITS.maxFiles} 个文件,请分批提交`,
|
||
413,
|
||
)
|
||
}
|
||
const files: UploadedReportFile[] = rawFiles
|
||
.map((item: any, index: number): UploadedReportFile => ({
|
||
uploadId: String(item.uploadId || '').trim(),
|
||
fileName: String(item.fileName || '').trim(),
|
||
fileSize: Number(item.fileSize || 0),
|
||
sortOrder: Number.isFinite(Number(item.sortOrder)) ? Number(item.sortOrder) : index,
|
||
fileKind: detectFileKindFromName(item.fileName),
|
||
}))
|
||
.filter((item: UploadedReportFile) => item.uploadId && item.fileName)
|
||
.sort((left: UploadedReportFile, right: UploadedReportFile) => left.sortOrder - right.sortOrder)
|
||
|
||
if (!materialId || files.length === 0) {
|
||
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 })
|
||
}
|
||
if (files.length !== rawFiles.length) {
|
||
throw new CertificateReportRequestError('检测报告文件参数不完整,请重新上传')
|
||
}
|
||
if (files.some((item) => !Number.isSafeInteger(item.sortOrder) || item.sortOrder < 0)) {
|
||
throw new CertificateReportRequestError('检测报告文件顺序无效,请重新上传')
|
||
}
|
||
if (new Set(files.map((item) => item.uploadId)).size !== files.length) {
|
||
throw new CertificateReportRequestError('检测报告上传会话编号重复,请重新选择文件')
|
||
}
|
||
const unsupportedFile = files.find(
|
||
(item: UploadedReportFile) => !isSupportedCertificateReportFileName(item.fileName),
|
||
)
|
||
if (unsupportedFile) {
|
||
throw new CertificateReportRequestError(
|
||
`检测报告仅支持 PDF、JPEG、PNG、WebP:${unsupportedFile.fileName}`,
|
||
)
|
||
}
|
||
|
||
uploadIds.push(...files.map((item) => item.uploadId))
|
||
|
||
// 加锁前只做鉴权和类型检查。真正决定状态必须在跨容器锁内重新读取。
|
||
const initialMaterial = await prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
materialType: true,
|
||
deletedAt: true,
|
||
},
|
||
})
|
||
if (!initialMaterial || initialMaterial.deletedAt) {
|
||
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
|
||
}
|
||
if (initialMaterial.userId !== userId) {
|
||
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
|
||
}
|
||
if (initialMaterial.materialType !== CERTIFICATE_REPORT_MATERIAL_TYPE) {
|
||
return NextResponse.json({ error: '素材类型不匹配' }, { status: 400 })
|
||
}
|
||
|
||
releaseCompletionLock = await acquireTaskMaterialCompletionLock(materialId)
|
||
releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(uploadIds)
|
||
|
||
const material = await readCertificateReportCompletionSnapshot(materialId)
|
||
if (!material || material.deletedAt) {
|
||
throw new CertificateReportRequestError('素材不存在', 404)
|
||
}
|
||
if (material.userId !== userId || material.materialType !== CERTIFICATE_REPORT_MATERIAL_TYPE) {
|
||
throw new CertificateReportRequestError('素材归属或类型已变化,请刷新后重试', 409)
|
||
}
|
||
if (material.taskStatus === 'completed') {
|
||
if (!hasCompleteCertificateReportEvidence(material)) {
|
||
throw new CertificateReportRequestError(
|
||
'检测报告完成记录不完整,服务器已保留文件,请联系管理员核查',
|
||
409,
|
||
)
|
||
}
|
||
releaseUploadSessionLocks.assertHeld()
|
||
await cleanupTempFiles([], uploadIds).catch((cleanupError) => {
|
||
console.warn('[Certificate Report Complete] 幂等请求确认已完成,但源会话清理失败:', cleanupError)
|
||
})
|
||
return NextResponse.json({ success: true, materialId, alreadyCompleted: true })
|
||
}
|
||
|
||
const isLegacyEmptyUpload = (
|
||
material.taskStatus == null
|
||
&& !material.previewUrl
|
||
&& !material.zipFilePath
|
||
&& material.assets.length === 0
|
||
)
|
||
if (material.taskStatus !== 'uploading' && !isLegacyEmptyUpload) {
|
||
throw new CertificateReportRequestError('该检测报告正在处理或状态异常,请稍后刷新', 409)
|
||
}
|
||
if (material.assets.length > 0) {
|
||
throw new CertificateReportRequestError(
|
||
'检测报告上传记录已有未确认资产,服务器已保留文件,请联系管理员核查',
|
||
409,
|
||
)
|
||
}
|
||
|
||
const assetRecords: Array<{
|
||
materialId: string
|
||
assetType: string
|
||
fileUrl: string
|
||
fileName: string
|
||
fileSize: bigint
|
||
sortOrder: number
|
||
}> = []
|
||
|
||
let nextPreviewUrl = ''
|
||
let totalFileSize = 0
|
||
let firstFileName = ''
|
||
const resolvedFiles: Array<UploadedReportFile & { filePath: string; actualSize: number }> = []
|
||
|
||
for (const item of files) {
|
||
const filePath = await getOwnedUploadedFile(item.uploadId, userId)
|
||
if (!filePath) {
|
||
throw new CertificateReportRequestError(`找不到上传文件:${item.fileName}`)
|
||
}
|
||
|
||
const maxBytes = item.fileKind === 'pdf'
|
||
? CERTIFICATE_REPORT_UPLOAD_LIMITS.maxPdfBytes
|
||
: CERTIFICATE_REPORT_UPLOAD_LIMITS.maxImageBytes
|
||
let actualSize: number
|
||
try {
|
||
actualSize = (await inspectBoundedRegularFile(filePath, {
|
||
maxBytes,
|
||
expectedBytes: item.fileSize,
|
||
label: `检测报告文件 ${item.fileName}`,
|
||
})).size
|
||
} catch (error) {
|
||
throw new CertificateReportRequestError(
|
||
error instanceof Error ? error.message : `检测报告文件校验失败:${item.fileName}`,
|
||
413,
|
||
)
|
||
}
|
||
|
||
totalFileSize += actualSize
|
||
if (
|
||
!Number.isSafeInteger(totalFileSize)
|
||
|| totalFileSize > CERTIFICATE_REPORT_UPLOAD_LIMITS.maxTotalBytes
|
||
) {
|
||
throw new CertificateReportRequestError('检测报告文件总大小不能超过 1GB,请分批提交', 413)
|
||
}
|
||
|
||
tempFiles.push(filePath)
|
||
resolvedFiles.push({ ...item, filePath, actualSize })
|
||
if (!firstFileName) {
|
||
firstFileName = item.fileName
|
||
}
|
||
}
|
||
|
||
const targetRelativePaths = new Set<string>()
|
||
for (const item of resolvedFiles) {
|
||
const safeFileName = sanitizeFileNameForStorage(item.fileName)
|
||
const fileKind = item.fileKind
|
||
const contentType =
|
||
mimeTypeLookup(path.extname(item.fileName))?.toString()
|
||
|| (fileKind === 'pdf' ? 'application/pdf' : 'image/jpeg')
|
||
const targetRelativePath = buildCertificateReportPublicDisplayPath(
|
||
materialId,
|
||
fileKind === 'pdf' ? 'pdf' : 'image',
|
||
`${String(item.sortOrder).padStart(3, '0')}-${safeFileName}`,
|
||
)
|
||
if (targetRelativePaths.has(targetRelativePath)) {
|
||
throw new CertificateReportRequestError(
|
||
`检测报告文件名与顺序重复:${item.fileName}`,
|
||
)
|
||
}
|
||
targetRelativePaths.add(targetRelativePath)
|
||
|
||
const uploadedUrl = await uploadPublicDisplayFileToS3(
|
||
item.filePath,
|
||
targetRelativePath,
|
||
contentType,
|
||
item.actualSize,
|
||
)
|
||
uploadedAssetUrls.push(uploadedUrl)
|
||
assetRecords.push({
|
||
materialId,
|
||
assetType: fileKind === 'pdf'
|
||
? CERTIFICATE_REPORT_ASSET_TYPE.PDF
|
||
: CERTIFICATE_REPORT_ASSET_TYPE.IMAGE,
|
||
fileUrl: uploadedUrl,
|
||
fileName: item.fileName,
|
||
fileSize: BigInt(item.actualSize),
|
||
sortOrder: item.sortOrder,
|
||
})
|
||
|
||
if (!nextPreviewUrl) {
|
||
if (fileKind === 'image') {
|
||
nextPreviewUrl = uploadedUrl
|
||
} else {
|
||
const previewResult = await generatePdfPreview(item.filePath)
|
||
if (previewResult.success && previewResult.previewPath) {
|
||
localPreviewPaths.add(previewResult.previewPath)
|
||
const previewBuffer = await readFile(previewResult.previewPath)
|
||
const previewPath = buildCertificateReportPublicDisplayPath(
|
||
materialId,
|
||
'preview',
|
||
`report-preview-${Date.now()}-${safeFileName.replace(/\.pdf$/i, '.jpg')}`,
|
||
)
|
||
nextPreviewUrl = await uploadPublicDisplayToS3(previewBuffer, previewPath, 'image/jpeg')
|
||
previewUrlToCleanup = nextPreviewUrl
|
||
await unlink(previewResult.previewPath).catch(() => undefined)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!nextPreviewUrl || assetRecords.length !== resolvedFiles.length) {
|
||
throw new Error('检测报告预览或文件资产生成不完整,本次素材未保存')
|
||
}
|
||
|
||
releaseCompletionLock.assertHeld()
|
||
releaseUploadSessionLocks.assertHeld()
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.materialAsset.createMany({ data: assetRecords })
|
||
const finalizeResult = await tx.material.updateMany({
|
||
where: {
|
||
id: materialId,
|
||
userId,
|
||
materialType: CERTIFICATE_REPORT_MATERIAL_TYPE,
|
||
deletedAt: null,
|
||
OR: [
|
||
{ taskStatus: 'uploading' },
|
||
{ taskStatus: null, previewUrl: '', zipFilePath: '' },
|
||
],
|
||
},
|
||
data: {
|
||
previewUrl: nextPreviewUrl,
|
||
zipFileName: firstFileName,
|
||
zipFileSize: BigInt(totalFileSize),
|
||
taskStatus: 'completed',
|
||
} as any,
|
||
})
|
||
if (finalizeResult.count !== 1) {
|
||
throw new Error('检测报告完成状态已变化,已停止写入')
|
||
}
|
||
})
|
||
materialCommitted = true
|
||
|
||
// 只有 completed 事务确认后才消费源上传会话。清理失败不能反向删除正式资产。
|
||
await cleanupTempFiles(tempFiles, uploadIds).catch((cleanupError) => {
|
||
console.warn('[Certificate Report Complete] 清理源上传会话失败,但素材已保存:', cleanupError)
|
||
})
|
||
|
||
await createMaterialLog({
|
||
materialId,
|
||
userId,
|
||
action: 'UPLOAD',
|
||
changes: {
|
||
uploadType: {
|
||
oldValue: null,
|
||
newValue: CERTIFICATE_REPORT_MATERIAL_TYPE,
|
||
},
|
||
assetCount: {
|
||
oldValue: 0,
|
||
newValue: assetRecords.length,
|
||
},
|
||
},
|
||
}).catch((logError) => {
|
||
console.warn('[Certificate Report Complete] 上传日志记录失败,但素材已保存:', logError)
|
||
})
|
||
|
||
return NextResponse.json({ success: true, materialId })
|
||
} catch (error) {
|
||
console.error('[Certificate Report Complete Error]', error)
|
||
|
||
if (materialCommitted) {
|
||
return NextResponse.json({
|
||
success: true,
|
||
materialId: committedMaterialId || undefined,
|
||
warning: '素材已保存,但收尾步骤存在异常',
|
||
})
|
||
}
|
||
|
||
if (releaseCompletionLock && committedMaterialId) {
|
||
const completionState = await inspectCertificateReportCompletionAfterError(committedMaterialId)
|
||
if (completionState.state === 'completed') {
|
||
if (releaseUploadSessionLocks) {
|
||
try {
|
||
releaseUploadSessionLocks.assertHeld()
|
||
await cleanupTempFiles(tempFiles, uploadIds)
|
||
} catch (cleanupError) {
|
||
console.warn('[Certificate Report Complete] 数据库已完成,但源会话清理失败或锁已失效:', cleanupError)
|
||
}
|
||
}
|
||
return NextResponse.json({
|
||
success: true,
|
||
materialId: committedMaterialId,
|
||
warning: '素材已保存,但响应确认阶段出现异常',
|
||
})
|
||
}
|
||
if (completionState.state === 'completed_incomplete') {
|
||
console.error('[Certificate Report Complete] 数据库已标记完成,但关键资产证据不完整:', completionState)
|
||
return NextResponse.json(
|
||
{ error: '检测报告完成记录不完整,服务器已保留文件,请联系管理员核查' },
|
||
{ status: 409 },
|
||
)
|
||
}
|
||
if (completionState.state === 'unknown') {
|
||
console.error('[Certificate Report Complete] 无法确认数据库最终状态,保留全部文件:', completionState.error)
|
||
return NextResponse.json(
|
||
{ error: '完成状态暂时无法确认,服务器已保留上传文件,请勿重复提交' },
|
||
{ status: 500 },
|
||
)
|
||
}
|
||
|
||
const snapshot = completionState.snapshot
|
||
const isRetryableUploadState = Boolean(
|
||
snapshot
|
||
&& !snapshot.deletedAt
|
||
&& snapshot.userId === authenticatedUserId
|
||
&& snapshot.materialType === CERTIFICATE_REPORT_MATERIAL_TYPE
|
||
&& (snapshot.taskStatus === 'uploading' || snapshot.taskStatus == null),
|
||
)
|
||
if (isRetryableUploadState) {
|
||
const cleanupTargets = previewUrlToCleanup
|
||
? Array.from(new Set([...uploadedAssetUrls, previewUrlToCleanup]))
|
||
: Array.from(new Set(uploadedAssetUrls))
|
||
let cleanupLocksHeld = true
|
||
try {
|
||
releaseCompletionLock.assertHeld()
|
||
if (cleanupTargets.length > 0) {
|
||
if (!releaseUploadSessionLocks) {
|
||
cleanupLocksHeld = false
|
||
} else {
|
||
releaseUploadSessionLocks.assertHeld()
|
||
}
|
||
}
|
||
} catch (lockError) {
|
||
cleanupLocksHeld = false
|
||
console.error('[Certificate Report Cleanup] 完成锁已失效,保留派生文件:', lockError)
|
||
}
|
||
if (cleanupLocksHeld) {
|
||
for (const fileUrl of cleanupTargets) {
|
||
await deleteFromS3(fileUrl).catch((cleanupError) => {
|
||
console.error('[Certificate Report Cleanup] 删除本次派生 S3 文件失败:', cleanupError)
|
||
})
|
||
}
|
||
}
|
||
} else {
|
||
console.warn('[Certificate Report Complete] 素材状态已由其它流程接管,保留全部文件:', completionState)
|
||
}
|
||
}
|
||
|
||
return buildErrorResponse(error)
|
||
} finally {
|
||
for (const previewPath of Array.from(localPreviewPaths)) {
|
||
await unlink(previewPath).catch(() => undefined)
|
||
}
|
||
await releaseUploadSessionLocks?.().catch((releaseError) => {
|
||
console.error('[Certificate Report Complete] 释放上传会话锁失败:', releaseError)
|
||
})
|
||
await releaseCompletionLock?.().catch((releaseError) => {
|
||
console.error('[Certificate Report Complete] 释放素材完成锁失败:', releaseError)
|
||
})
|
||
}
|
||
}
|