gongxiangp2/lib/task-detail-upload-jobs.ts
chenpei 2b5220fdd7 fix: 任务-详情源文件不再误套 1GB 图片上限
PSD/压缩包按单文件 10GB、合计 30GB 单独限制;提交时续上传会话,避免大文件传完再排队后会话丢失。
2026-08-17 18:48:00 +08:00

876 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Prisma } from '@prisma/client'
import { randomUUID } from 'crypto'
import { prisma } from '@/lib/prisma'
import {
assertOwnedProductPackageUploadSessions,
assertTaskDetailUploadAssetLimits,
completeProductPackageUpload,
type ProductPackageAssetsInput,
type CompleteProductPackageUploadInput,
} from '@/lib/product-package-completion'
import { refreshOwnedUploadSessionTtls } from '@/lib/upload-sessions'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
import {
acquireTaskDetailUploadExecutionLock,
ensureTaskDetailUploadDrainCapability,
getTaskDetailUploadWorkerIdentity,
isTaskDetailUploadDraining,
removeTaskDetailUploadActiveMarker,
writeTaskDetailUploadActiveMarker,
} from '@/lib/task-detail-upload-runtime'
export const TASK_DETAIL_UPLOAD_JOB_STATUS = {
QUEUED: 'queued',
PROCESSING: 'processing',
COMPLETED: 'completed',
FAILED: 'failed',
} as const
export const TASK_DETAIL_UPLOAD_JOB_STAGE = {
QUEUED: 'queued',
VALIDATING: 'validating',
ASSEMBLING: 'assembling',
PREVIEWS: 'previews',
ZIPS: 'zips',
DATABASE: 'database',
CLEANUP: 'cleanup',
COMPLETED: 'completed',
FAILED: 'failed',
} as const
export type TaskDetailUploadJobStatus =
(typeof TASK_DETAIL_UPLOAD_JOB_STATUS)[keyof typeof TASK_DETAIL_UPLOAD_JOB_STATUS]
export interface CreateTaskDetailUploadJobInput {
materialId: string
taskNo?: string | null
dingtalkApprovalNo?: string | null
operatorUserId: string
assets: ProductPackageAssetsInput
options?: CompleteProductPackageUploadInput['options']
}
export interface TaskDetailUploadJobStatusView {
jobId: string
materialId: string
status: TaskDetailUploadJobStatus | string
stage: string
progress: number
error: string | null
createdAt: string
startedAt: string | null
completedAt: string | null
}
export class TaskDetailUploadQueueCapacityError extends Error {
readonly scope: 'user' | 'global'
readonly statusCode: 429 | 503
constructor(scope: 'user' | 'global') {
super(scope === 'user'
? `您已有 ${TASK_DETAIL_UPLOAD_LIMITS.maxQueuedJobsPerUser} 个任务等待处理,请稍后再提交`
: '当前上传处理队列已满,请稍后再提交')
this.name = 'TaskDetailUploadQueueCapacityError'
this.scope = scope
this.statusCode = scope === 'user' ? 429 : 503
}
}
const MAX_CONCURRENT_JOBS = 1
const DEFAULT_JOB_LEASE_MS = 5 * 60 * 1000
const DEFAULT_JOB_HEARTBEAT_MS = 30 * 1000
// 仅用于首次升级前没有 leaseToken 的旧 processing 任务。新任务只看数据库租约。
const DEFAULT_LEGACY_STALE_JOB_MS = 30 * 60 * 1000
const RECOVERY_BATCH_SIZE = 20
const WORKER_ADVISORY_LOCK_NAME = 'product-package-upload-worker'
let ensureTablePromise: Promise<void> | null = null
const pendingJobIds = new Set<string>()
let activeJobCount = 0
let pumpRunning = false
let globallyBackpressured = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
const GLOBAL_RETRY_DELAY_MS = 5_000
/**
* 在当前事务内获取任务-详情上传的 advisory lock。
*
* pg_advisory_xact_lock() 本身返回 PostgreSQL 的 void 类型。直接用
* Prisma $queryRaw 读取它会触发 `Failed to deserialize column of type 'void'`,
* 因此把函数放进子查询,只返回一个普通整数列给 Prisma;锁仍会在当前
* 事务结束时自动释放。
*/
export async function acquireTaskDetailUploadLock(
tx: Prisma.TransactionClient,
lockName: string,
) {
await tx.$queryRaw`
SELECT 1 AS locked
FROM (
SELECT pg_advisory_xact_lock(hashtext(${lockName}))
) AS lock_result
`
}
function getLegacyStaleJobMs() {
const value = Number(process.env.TASK_DETAIL_UPLOAD_STALE_MS)
return Number.isFinite(value) && value > 0 ? value : DEFAULT_LEGACY_STALE_JOB_MS
}
function getJobLeaseMs() {
const value = Number(process.env.TASK_DETAIL_UPLOAD_LEASE_MS)
return Number.isFinite(value) && value >= 60_000 ? value : DEFAULT_JOB_LEASE_MS
}
function getJobHeartbeatMs() {
const value = Number(process.env.TASK_DETAIL_UPLOAD_HEARTBEAT_MS)
return Number.isFinite(value) && value >= 1_000 ? value : DEFAULT_JOB_HEARTBEAT_MS
}
async function readDatabaseClock(client: any): Promise<Date> {
const rows = await client.$queryRawUnsafe('SELECT clock_timestamp() AS "now"')
const value = rows?.[0]?.now
const now = value instanceof Date ? value : new Date(value)
if (!value || Number.isNaN(now.getTime())) {
throw new Error('无法读取数据库当前时间,拒绝执行任务详情作业租约操作')
}
return now
}
export class TaskDetailUploadLeaseLostError extends Error {
constructor() {
super('任务详情后台作业租约已失效,旧进程结果已拒绝')
this.name = 'TaskDetailUploadLeaseLostError'
}
}
function toJsonValue(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
}
function toIso(value: Date | null | undefined) {
return value ? value.toISOString() : null
}
function toStatusView(job: {
id: string
materialId: string
status: string
stage: string
progress: number
errorMessage: string | null
createdAt: Date
startedAt: Date | null
completedAt: Date | null
}): TaskDetailUploadJobStatusView {
return {
jobId: job.id,
materialId: job.materialId,
status: job.status,
stage: job.stage,
progress: Math.max(0, Math.min(100, job.progress)),
error: job.errorMessage || null,
createdAt: job.createdAt.toISOString(),
startedAt: toIso(job.startedAt),
completedAt: toIso(job.completedAt),
}
}
/**
* 任务表由 reviewed Prisma migration 创建。请求路径不执行 DDL,避免滚动发布时
* 多个容器同时抢迁移锁;若迁移未执行,调用方会收到明确的数据库错误并阻止入队。
*/
export async function ensureProductPackageUploadJobTable() {
if (!ensureTablePromise) {
ensureTablePromise = Promise.resolve()
}
return ensureTablePromise
}
async function updateJobProgress(
jobId: string,
leaseToken: string,
update: { stage: string; progress: number; message?: string },
) {
const progress = Math.max(0, Math.min(99, Math.round(update.progress)))
const updated = await prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, WORKER_ADVISORY_LOCK_NAME)
const databaseNow = await readDatabaseClock(tx)
return tx.productPackageUploadJob.updateMany({
where: {
id: jobId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
leaseToken,
leaseExpiresAt: { gt: databaseNow },
},
data: {
stage: update.stage,
progress,
...(update.message ? { errorMessage: update.message } : {}),
},
})
})
if (updated.count !== 1) {
throw new TaskDetailUploadLeaseLostError()
}
}
async function claimTaskDetailUploadJob(jobId: string) {
const token = randomUUID()
const { workerId } = getTaskDetailUploadWorkerIdentity()
return prisma.$transaction(async (tx) => {
// 用事务级 advisory lock 串行化所有容器的“领取任务”动作,保证全局最多一个
// 任务进入 Sharp/ZIP 重处理,而不是只在单个 Node 进程内限流。
await acquireTaskDetailUploadLock(tx, WORKER_ADVISORY_LOCK_NAME)
const databaseNow = await readDatabaseClock(tx)
const legacyStaleBefore = new Date(databaseNow.getTime() - getLegacyStaleJobMs())
const activeCount = await tx.productPackageUploadJob.count({
where: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
OR: [
{ leaseToken: { not: null }, leaseExpiresAt: { gt: databaseNow } },
{ leaseToken: null, updatedAt: { gt: legacyStaleBefore } },
],
},
})
if (activeCount > 0) return { status: 'busy' as const }
const claimed = await tx.productPackageUploadJob.updateMany({
where: {
id: jobId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
},
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.VALIDATING,
progress: 5,
startedAt: databaseNow,
completedAt: null,
errorMessage: null,
workerId,
leaseToken: token,
leaseExpiresAt: new Date(databaseNow.getTime() + getJobLeaseMs()),
},
})
return claimed.count === 1
? { status: 'acquired' as const, token }
: { status: 'skip' as const }
})
}
async function isTaskDetailUploadLeaseCurrent(jobId: string, leaseToken: string) {
return prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, WORKER_ADVISORY_LOCK_NAME)
const databaseNow = await readDatabaseClock(tx)
return (await tx.productPackageUploadJob.count({
where: {
id: jobId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
leaseToken,
leaseExpiresAt: { gt: databaseNow },
},
})) === 1
})
}
async function renewTaskDetailUploadLease(jobId: string, leaseToken: string) {
const renewed = await prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, WORKER_ADVISORY_LOCK_NAME)
const databaseNow = await readDatabaseClock(tx)
return tx.productPackageUploadJob.updateMany({
where: {
id: jobId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
leaseToken,
leaseExpiresAt: { gt: databaseNow },
},
data: {
leaseExpiresAt: new Date(databaseNow.getTime() + getJobLeaseMs()),
},
})
})
return renewed.count === 1
}
async function releaseTaskDetailUploadLease(
jobId: string,
leaseToken: string,
resetToQueued = false,
) {
return prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, WORKER_ADVISORY_LOCK_NAME)
const released = await tx.productPackageUploadJob.updateMany({
where: { id: jobId, leaseToken },
data: {
workerId: null,
leaseToken: null,
leaseExpiresAt: null,
...(resetToQueued ? {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.QUEUED,
progress: 0,
startedAt: null,
completedAt: null,
} : {}),
},
})
return released.count === 1
})
}
async function reconcileCommittedTaskDetailUploadJob(jobId: string) {
const job = await prisma.productPackageUploadJob.findUnique({
where: { id: jobId },
select: {
id: true,
status: true,
materialId: true,
material: { select: { zipFilePath: true, taskStatus: true } },
},
})
if (!job) return true
if (job.status !== TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED && job.status !== TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING) {
return true
}
const assetCount = await prisma.materialAsset.count({ where: { materialId: job.materialId } })
if (job.material?.taskStatus !== 'completed' || !job.material.zipFilePath || assetCount === 0) return false
// 主资产事务已提交但容器可能在 job.completed 写入前被杀掉:不要拿已清理的
// upload session 重跑,直接把持久化任务补记为完成。
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId, status: { in: [TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED, TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING] } },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.COMPLETED,
progress: 100,
completedAt: new Date(),
errorMessage: null,
workerId: null,
leaseToken: null,
leaseExpiresAt: null,
},
})
return true
}
type TaskDetailUploadProcessOutcome = 'processed' | 'busy' | 'ignored'
async function processOneTaskDetailUploadJob(jobId: string): Promise<TaskDetailUploadProcessOutcome> {
await ensureTaskDetailUploadDrainCapability()
if (await isTaskDetailUploadDraining()) return 'busy'
if (await reconcileCommittedTaskDetailUploadJob(jobId)) return 'ignored'
let releaseExecutionLock: Awaited<ReturnType<typeof acquireTaskDetailUploadExecutionLock>>
try {
releaseExecutionLock = await acquireTaskDetailUploadExecutionLock()
} catch (error) {
// 获取不到共享 uploads 上的 flock 时必须保守背压,不允许降级成容器内并发。
console.error(`[TaskDetailUploadJob] execution lock unavailable job=${jobId}:`, error)
return 'busy'
}
try {
// 数据库租约只决定谁能写结果;独立 flock 覆盖从领取到清理的整个真实执行期,
// 避免旧进程心跳超时但 Sharp/ZIP 仍未停止时,新进程再开一套重处理。
releaseExecutionLock.assertHeld()
const claim = await claimTaskDetailUploadJob(jobId)
if (claim.status !== 'acquired') {
const pendingJob = await prisma.productPackageUploadJob.findUnique({
where: { id: jobId },
select: { status: true },
})
if (pendingJob?.status === TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED) {
return 'busy'
}
return 'ignored'
}
const leaseToken = claim.token
const job = await prisma.productPackageUploadJob.findUnique({ where: { id: jobId } })
if (!job) {
await releaseTaskDetailUploadLease(jobId, leaseToken).catch(() => undefined)
return 'ignored'
}
let leaseLost = false
let resetToQueuedAfterDrain = false
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
let heartbeatInFlight: Promise<void> = Promise.resolve()
let heartbeatStopped = false
const abortController = new AbortController()
const markExecutionLost = (error: unknown) => {
leaseLost = true
heartbeatStopped = true
if (!abortController.signal.aborted) {
abortController.abort(error instanceof Error ? error : new Error(String(error)))
}
}
const assertExecutionCurrent = async () => {
if (leaseLost || abortController.signal.aborted) {
throw new TaskDetailUploadLeaseLostError()
}
try {
releaseExecutionLock.assertHeld()
} catch (error) {
markExecutionLost(error)
throw new TaskDetailUploadLeaseLostError()
}
if (!(await isTaskDetailUploadLeaseCurrent(jobId, leaseToken))) {
const error = new TaskDetailUploadLeaseLostError()
markExecutionLost(error)
throw error
}
}
const scheduleHeartbeat = () => {
heartbeatTimer = setTimeout(() => {
heartbeatTimer = null
heartbeatInFlight = (async () => {
try {
releaseExecutionLock.assertHeld()
if (!(await renewTaskDetailUploadLease(jobId, leaseToken))) {
throw new TaskDetailUploadLeaseLostError()
}
await writeTaskDetailUploadActiveMarker(jobId, leaseToken)
} catch (heartbeatError) {
markExecutionLost(heartbeatError)
console.error(`[TaskDetailUploadJob] lease heartbeat failed job=${jobId}:`, heartbeatError)
}
})().finally(() => {
if (!heartbeatStopped) scheduleHeartbeat()
})
}, getJobHeartbeatMs())
;(heartbeatTimer as NodeJS.Timeout).unref?.()
}
const stopHeartbeat = async () => {
heartbeatStopped = true
if (heartbeatTimer) {
clearTimeout(heartbeatTimer)
heartbeatTimer = null
}
await heartbeatInFlight.catch(() => undefined)
}
try {
// active 标记必须先落盘。部署 drain 可能出现在领取租约与写 marker 之间,
// 所以写完后再次检查;命中就释放租约并保持 queued,绝不开始图片/ZIP处理。
await writeTaskDetailUploadActiveMarker(jobId, leaseToken)
if (await isTaskDetailUploadDraining()) {
resetToQueuedAfterDrain = true
return 'busy'
}
scheduleHeartbeat()
try {
await completeProductPackageUpload({
materialId: job.materialId,
taskNo: job.taskNo,
dingtalkApprovalNo: job.dingtalkApprovalNo,
operatorUserId: job.operatorUserId,
assets: job.assets as ProductPackageAssetsInput,
options: {
...(job.options && typeof job.options === 'object' ? job.options as Record<string, unknown> : {}),
preserveMaterialOnError: true,
signal: abortController.signal,
assertExecutionCurrent,
onProgress: (update) => updateJobProgress(jobId, leaseToken, update),
},
})
await assertExecutionCurrent()
} catch (error) {
await stopHeartbeat()
if (leaseLost || error instanceof TaskDetailUploadLeaseLostError) {
console.warn(`[TaskDetailUploadJob] stale worker stopped without writing result job=${jobId}`)
return 'processed'
}
try {
await assertExecutionCurrent()
} catch {
console.warn(`[TaskDetailUploadJob] stale worker stopped without writing failure job=${jobId}`)
return 'processed'
}
const errorMessage = error instanceof Error ? error.message : '任务-详情上传处理失败'
console.error(`[TaskDetailUploadJob] failed job=${jobId}:`, error)
try {
await prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, WORKER_ADVISORY_LOCK_NAME)
releaseExecutionLock.assertHeld()
const databaseNow = await readDatabaseClock(tx)
const failed = await tx.productPackageUploadJob.updateMany({
where: {
id: jobId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
leaseToken,
leaseExpiresAt: { gt: databaseNow },
},
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.FAILED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.FAILED,
errorMessage: errorMessage.slice(0, 2000),
completedAt: databaseNow,
workerId: null,
leaseToken: null,
leaseExpiresAt: null,
},
})
if (failed.count !== 1) throw new TaskDetailUploadLeaseLostError()
await tx.material.updateMany({
where: { id: job.materialId, taskStatus: { not: 'completed' } },
data: { taskStatus: 'failed' },
})
})
} catch (jobFailureRecordError) {
console.error(`[TaskDetailUploadJob] failed record write failed job=${jobId}:`, jobFailureRecordError)
}
return 'processed'
}
await stopHeartbeat()
try {
await assertExecutionCurrent()
} catch (error) {
console.warn(`[TaskDetailUploadJob] stale worker stopped before completed write job=${jobId}:`, error)
return 'processed'
}
try {
await prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, WORKER_ADVISORY_LOCK_NAME)
if (leaseLost || abortController.signal.aborted) throw new TaskDetailUploadLeaseLostError()
releaseExecutionLock.assertHeld()
const databaseNow = await readDatabaseClock(tx)
if (leaseLost || abortController.signal.aborted) throw new TaskDetailUploadLeaseLostError()
const completed = await tx.productPackageUploadJob.updateMany({
where: {
id: jobId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
leaseToken,
leaseExpiresAt: { gt: databaseNow },
},
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.COMPLETED,
progress: 100,
completedAt: databaseNow,
errorMessage: null,
workerId: null,
leaseToken: null,
leaseExpiresAt: null,
},
})
if (completed.count !== 1) throw new TaskDetailUploadLeaseLostError()
})
} catch (completionRecordError) {
// 素材处理已经成功时,不能把 job 反写成 failed。保留 processing,交给
// reconcile/周期恢复在数据库恢复后补记 completed。
console.error(`[TaskDetailUploadJob] completed record write failed job=${jobId}:`, completionRecordError)
if (completionRecordError instanceof TaskDetailUploadLeaseLostError) {
markExecutionLost(completionRecordError)
console.warn(`[TaskDetailUploadJob] stale worker skipped completed reconcile job=${jobId}`)
} else {
try {
await reconcileCommittedTaskDetailUploadJob(jobId)
} catch (reconcileError) {
console.error(`[TaskDetailUploadJob] completed record reconcile failed job=${jobId}:`, reconcileError)
}
}
}
} finally {
await stopHeartbeat()
await releaseTaskDetailUploadLease(
jobId,
leaseToken,
resetToQueuedAfterDrain || leaseLost,
).catch((error) => {
console.error(`[TaskDetailUploadJob] lease release failed job=${jobId}:`, error)
})
await removeTaskDetailUploadActiveMarker().catch((error) => {
console.error(`[TaskDetailUploadJob] active marker cleanup failed job=${jobId}:`, error)
})
}
return 'processed'
} finally {
await releaseExecutionLock().catch((error) => {
console.error(`[TaskDetailUploadJob] execution lock release failed job=${jobId}:`, error)
})
}
}
function scheduleGlobalQueueRetry() {
if (retryTimer) return
retryTimer = setTimeout(() => {
retryTimer = null
globallyBackpressured = false
void pumpTaskDetailUploadJobs()
}, GLOBAL_RETRY_DELAY_MS)
// 生产服务本身会常驻,但测试/CLI 不应被单个背压 timer 阻止退出。
;(retryTimer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.()
}
async function pumpTaskDetailUploadJobs() {
if (pumpRunning || globallyBackpressured) {
return
}
pumpRunning = true
try {
await ensureTaskDetailUploadDrainCapability()
if (await isTaskDetailUploadDraining()) {
globallyBackpressured = true
scheduleGlobalQueueRetry()
return
}
while (activeJobCount < MAX_CONCURRENT_JOBS && pendingJobIds.size > 0) {
const nextJobId = pendingJobIds.values().next().value as string | undefined
if (!nextJobId) {
break
}
pendingJobIds.delete(nextJobId)
activeJobCount += 1
void processOneTaskDetailUploadJob(nextJobId)
.then((outcome) => {
if (outcome === 'busy') {
pendingJobIds.add(nextJobId)
globallyBackpressured = true
scheduleGlobalQueueRetry()
}
})
.catch((error) => {
console.error(`[TaskDetailUploadJob] worker crashed job=${nextJobId}:`, error)
pendingJobIds.add(nextJobId)
globallyBackpressured = true
scheduleGlobalQueueRetry()
})
.finally(() => {
activeJobCount -= 1
if (!globallyBackpressured) {
void pumpTaskDetailUploadJobs()
}
})
}
} finally {
pumpRunning = false
}
}
export function triggerTaskDetailUploadJob(jobId: string) {
pendingJobIds.add(jobId)
void pumpTaskDetailUploadJobs().catch((error) => {
console.error('[TaskDetailUploadJob] queue pump failed:', error)
})
}
export async function createTaskDetailUploadJob(input: CreateTaskDetailUploadJobInput) {
assertTaskDetailUploadAssetLimits(input.assets)
await assertOwnedProductPackageUploadSessions(input.assets, input.operatorUserId)
const uploadIds = [
...(input.assets.main || []),
...(input.assets.sku || []),
...(input.assets.detail || []),
...(input.assets.psd || []),
].flatMap((asset) => [
String(asset.uploadId || '').trim(),
...((asset as { previewAssets?: Array<{ uploadId?: string }> }).previewAssets || [])
.map((preview) => String(preview.uploadId || '').trim()),
]).filter(Boolean)
await refreshOwnedUploadSessionTtls(uploadIds)
await ensureProductPackageUploadJobTable()
const job = await prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, 'product-package-upload-create')
const existing = await tx.productPackageUploadJob.findFirst({
where: {
materialId: input.materialId,
operatorUserId: input.operatorUserId,
status: {
in: [
TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
],
},
},
orderBy: { createdAt: 'desc' },
})
if (existing) return existing
const queuedForUser = await tx.productPackageUploadJob.count({
where: {
operatorUserId: input.operatorUserId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
},
})
if (queuedForUser >= TASK_DETAIL_UPLOAD_LIMITS.maxQueuedJobsPerUser) {
throw new TaskDetailUploadQueueCapacityError('user')
}
const queuedGlobally = await tx.productPackageUploadJob.count({
where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED },
})
if (queuedGlobally >= TASK_DETAIL_UPLOAD_LIMITS.maxQueuedJobsGlobal) {
throw new TaskDetailUploadQueueCapacityError('global')
}
const created = await tx.productPackageUploadJob.create({
data: {
materialId: input.materialId,
operatorUserId: input.operatorUserId,
taskNo: input.taskNo?.trim() || null,
dingtalkApprovalNo: input.dingtalkApprovalNo?.trim() || null,
assets: toJsonValue(input.assets),
options: input.options ? toJsonValue(input.options) : undefined,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.QUEUED,
progress: 0,
errorMessage: null,
},
})
// 素材列表/详情页可以据此识别“正在后台处理”的空素材,用户关闭上传页后
// 仍然能知道它不是一个普通的空卡片。完成函数会把状态推进到 completed。
await tx.material.update({
where: { id: input.materialId },
data: { taskStatus: 'uploading' },
})
return created
})
if (job.status === TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED) {
triggerTaskDetailUploadJob(job.id)
}
return toStatusView(job)
}
export async function getTaskDetailUploadJobStatus(jobId: string, operatorUserId: string) {
await ensureProductPackageUploadJobTable()
const job = await prisma.productPackageUploadJob.findFirst({
where: {
id: jobId,
operatorUserId,
},
})
return job ? toStatusView(job) : null
}
export async function getLatestTaskDetailUploadJobStatus(materialId: string, operatorUserId: string) {
await ensureProductPackageUploadJobTable()
const job = await prisma.productPackageUploadJob.findFirst({
where: {
materialId,
operatorUserId,
},
orderBy: { createdAt: 'desc' },
})
return job ? toStatusView(job) : null
}
/**
* 服务重启后恢复未完成队列。processing 会在心跳停止并超过 stale 窗口后标记失败,
* 不直接重跑可能已经写入部分资产的非幂等处理链路。
*/
export async function recoverTaskDetailUploadJobs() {
await ensureProductPackageUploadJobTable()
await ensureTaskDetailUploadDrainCapability()
if (await isTaskDetailUploadDraining()) {
return 0
}
const databaseNow = await readDatabaseClock(prisma)
const staleBefore = new Date(databaseNow.getTime() - getLegacyStaleJobMs())
const staleJobs = await prisma.productPackageUploadJob.findMany({
where: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
OR: [
{ leaseToken: { not: null }, leaseExpiresAt: { lte: databaseNow } },
{ leaseToken: { not: null }, leaseExpiresAt: null },
{ leaseToken: null, updatedAt: { lt: staleBefore } },
],
},
select: { id: true, materialId: true, leaseToken: true },
})
// 完成链路现在以素材锁、会话锁和单事务提交保证幂等。过期租约只回到 queued,
// 源上传会话和素材都保留;新 worker 会重新验证并安全收口,而不是要求用户重传。
for (const staleJob of staleJobs) {
if (await reconcileCommittedTaskDetailUploadJob(staleJob.id)) continue
await prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, WORKER_ADVISORY_LOCK_NAME)
const lockedNow = await readDatabaseClock(tx)
const lockedLegacyStaleBefore = new Date(lockedNow.getTime() - getLegacyStaleJobMs())
const released = await tx.productPackageUploadJob.updateMany({
where: {
id: staleJob.id,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
...(staleJob.leaseToken
? {
leaseToken: staleJob.leaseToken,
OR: [
{ leaseExpiresAt: { lte: lockedNow } },
{ leaseExpiresAt: null },
],
}
: { leaseToken: null, updatedAt: { lt: lockedLegacyStaleBefore } }),
},
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.QUEUED,
progress: 0,
errorMessage: '后台处理进程中断,系统已自动恢复排队',
startedAt: null,
completedAt: null,
workerId: null,
leaseToken: null,
leaseExpiresAt: null,
},
})
if (released.count === 1) {
await tx.material.updateMany({
where: { id: staleJob.materialId, taskStatus: { not: 'completed' } },
data: { taskStatus: 'uploading' },
})
}
})
}
const queuedJobs = await prisma.productPackageUploadJob.findMany({
where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED },
orderBy: { createdAt: 'asc' },
take: RECOVERY_BATCH_SIZE,
select: { id: true },
})
for (const job of queuedJobs) {
triggerTaskDetailUploadJob(job.id)
}
if (queuedJobs.length > 0) {
console.log(`[TaskDetailUploadJob] recovered ${queuedJobs.length} queued job(s)`)
}
return queuedJobs.length
}
/** @internal 仅供队列核心单元测试使用,不由任何 HTTP 路由调用。 */
export const __taskDetailUploadJobTesting = {
claimTaskDetailUploadJob,
reconcileCommittedTaskDetailUploadJob,
processOneTaskDetailUploadJob,
pumpTaskDetailUploadJobs,
getRuntimeState: () => ({
pendingJobIds: Array.from(pendingJobIds),
activeJobCount,
pumpRunning,
globallyBackpressured,
retryScheduled: retryTimer !== null,
}),
resetRuntimeState: () => {
if (retryTimer) clearTimeout(retryTimer)
retryTimer = null
globallyBackpressured = false
pendingJobIds.clear()
activeJobCount = 0
pumpRunning = false
ensureTablePromise = null
},
}