Files
waooplus/src/lib/task/service.ts
saturn 9aff44e37a refactor: analysis workflow architecture
fix: NEXTAUTH_URL

fix: prevent project model edits from affecting default model
2026-03-16 21:48:57 +08:00

637 lines
18 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 { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { withPrismaRetry } from '@/lib/prisma-retry'
import { rollbackTaskBilling } from '@/lib/billing'
import { locales } from '@/i18n/routing'
import { TASK_STATUS, type CreateTaskInput, type TaskBillingInfo, type TaskStatus } from './types'
const ACTIVE_STATUSES: TaskStatus[] = [TASK_STATUS.QUEUED, TASK_STATUS.PROCESSING]
const taskModel = prisma.task
/**
* 校验 BullMQ Job 是否仍然活着。
* 检查失败时(如 Redis 不可用)安全降级为 true不阻塞正常创建流程。
*/
async function verifyJobAlive(taskId: string): Promise<boolean> {
try {
const { isJobAlive } = await import('./reconcile')
return await isJobAlive(taskId)
} catch {
// Redis 异常等不可控情况 → 降级信任 DB 状态
return true
}
}
function isPrismaKnownError(error: unknown): error is { code?: string } {
return typeof error === 'object' && error !== null && 'code' in error
}
function isActiveStatus(status: string) {
return status === TASK_STATUS.QUEUED || status === TASK_STATUS.PROCESSING
}
function toObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
function normalizeLocale(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim().toLowerCase()
if (!normalized) return null
for (const locale of locales) {
if (normalized === locale || normalized.startsWith(`${locale}-`)) {
return locale
}
}
return null
}
function hasTaskLocale(payload: unknown): boolean {
const payloadObject = toObject(payload)
const payloadMeta = toObject(payloadObject.meta)
const locale = normalizeLocale(payloadMeta.locale) || normalizeLocale(payloadObject.locale)
return locale !== null
}
function toNullableJson(value?: Prisma.InputJsonValue | Record<string, unknown> | TaskBillingInfo | null) {
if (value === undefined) return undefined
if (value === null) return Prisma.JsonNull
return value as Prisma.InputJsonValue
}
function parseTaskBillingInfo(raw: unknown): TaskBillingInfo | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
if (!('billable' in raw)) return null
const billable = (raw as { billable?: unknown }).billable
if (typeof billable !== 'boolean') return null
return raw as TaskBillingInfo
}
function needsRollback(info: TaskBillingInfo | null): info is Extract<TaskBillingInfo, { billable: true }> {
if (!info || !info.billable) return false
if (!info.freezeId) return false
if (info.modeSnapshot === 'OFF' || info.modeSnapshot === 'SHADOW') return false
if (info.status === 'settled' || info.status === 'rolled_back') return false
return true
}
type TaskBillingRollbackResult = {
attempted: boolean
rolledBack: boolean
billingInfo: TaskBillingInfo | null
}
function resolveCompensationFailure(
rollback: TaskBillingRollbackResult,
fallbackCode: string,
fallbackMessage: string,
) {
if (!rollback.attempted || rollback.rolledBack) {
return {
errorCode: fallbackCode,
errorMessage: fallbackMessage,
}
}
return {
errorCode: 'BILLING_COMPENSATION_FAILED',
errorMessage: `${fallbackMessage}; billing rollback failed`,
}
}
async function failTaskWithMissingLocale(task: {
id: string
billingInfo: unknown
}) {
const rollbackResult = await rollbackTaskBillingForTask({
taskId: task.id,
billingInfo: task.billingInfo,
})
const failure = resolveCompensationFailure(
rollbackResult,
'TASK_LOCALE_REQUIRED',
'task locale is missing',
)
await taskModel.update({
where: { id: task.id },
data: {
status: TASK_STATUS.FAILED,
errorCode: failure.errorCode,
errorMessage: failure.errorMessage,
finishedAt: new Date(),
heartbeatAt: null,
dedupeKey: null,
},
})
}
export async function rollbackTaskBillingForTask(params: {
taskId: string
billingInfo?: unknown
}): Promise<TaskBillingRollbackResult> {
const current =
params.billingInfo === undefined
? await taskModel.findUnique({
where: { id: params.taskId },
select: { billingInfo: true },
})
: { billingInfo: params.billingInfo }
const billingInfo = parseTaskBillingInfo(current?.billingInfo ?? null)
if (!needsRollback(billingInfo)) {
return {
attempted: false,
rolledBack: true,
billingInfo,
}
}
const nextInfo = (await rollbackTaskBilling({
id: params.taskId,
billingInfo,
})) as TaskBillingInfo
await updateTaskBillingInfo(params.taskId, nextInfo)
return {
attempted: true,
rolledBack: nextInfo.billable ? nextInfo.status === 'rolled_back' : true,
billingInfo: nextInfo,
}
}
export async function createTask(input: CreateTaskInput) {
const model = taskModel
if (input.dedupeKey) {
const existing = await model.findFirst({
where: {
dedupeKey: input.dedupeKey,
},
orderBy: { createdAt: 'desc' },
})
if (existing) {
if (isActiveStatus(existing.status)) {
if (!hasTaskLocale(existing.payload)) {
await failTaskWithMissingLocale(existing)
} else {
// 校验 BullMQ Job 是否真的还活着,防止 DB 与队列状态脱节导致永久卡死
const jobAlive = await verifyJobAlive(existing.id)
if (jobAlive) {
return { task: existing, deduped: true as const }
}
const rollbackResult = await rollbackTaskBillingForTask({
taskId: existing.id,
billingInfo: existing.billingInfo,
})
const failure = resolveCompensationFailure(
rollbackResult,
'RECONCILE_ORPHAN',
'Queue job lost, replaced by new task',
)
// Job 已死terminal / missing→ 终止孤儿任务,释放 dedupeKey继续创建新任务
await model.update({
where: { id: existing.id },
data: {
status: TASK_STATUS.FAILED,
errorCode: failure.errorCode,
errorMessage: failure.errorMessage,
finishedAt: new Date(),
heartbeatAt: null,
dedupeKey: null,
},
})
}
} else {
// dedupeKey is unique in DB. Release terminal-task key so a new task can be created.
await model.update({
where: { id: existing.id },
data: { dedupeKey: null },
})
}
}
}
const createData = {
userId: input.userId,
projectId: input.projectId,
episodeId: input.episodeId || null,
type: input.type,
targetType: input.targetType,
targetId: input.targetId,
status: TASK_STATUS.QUEUED,
progress: 0,
attempt: 0,
maxAttempts: input.maxAttempts ?? 5,
priority: input.priority ?? 0,
dedupeKey: input.dedupeKey || null,
payload: toNullableJson(input.payload ?? null),
billingInfo: toNullableJson(input.billingInfo ?? null),
queuedAt: new Date(),
}
try {
const task = await model.create({ data: createData })
return { task, deduped: false as const }
} catch (error: unknown) {
if (input.dedupeKey && isPrismaKnownError(error) && error.code === 'P2002') {
const collided = await model.findFirst({
where: { dedupeKey: input.dedupeKey },
orderBy: { createdAt: 'desc' },
})
if (collided) {
if (isActiveStatus(collided.status)) {
if (!hasTaskLocale(collided.payload)) {
await failTaskWithMissingLocale(collided)
} else {
// P2002 竞态路径:同样校验 BullMQ Job 状态
const jobAlive = await verifyJobAlive(collided.id)
if (jobAlive) {
return { task: collided, deduped: true as const }
}
const rollbackResult = await rollbackTaskBillingForTask({
taskId: collided.id,
billingInfo: collided.billingInfo,
})
const failure = resolveCompensationFailure(
rollbackResult,
'RECONCILE_ORPHAN',
'Queue job lost, replaced by new task',
)
await model.update({
where: { id: collided.id },
data: {
status: TASK_STATUS.FAILED,
errorCode: failure.errorCode,
errorMessage: failure.errorMessage,
finishedAt: new Date(),
heartbeatAt: null,
dedupeKey: null,
},
})
}
} else {
await model.update({
where: { id: collided.id },
data: { dedupeKey: null },
})
}
const task = await model.create({ data: createData })
return { task, deduped: false as const }
}
}
throw error
}
}
export async function getTaskById(taskId: string) {
return await taskModel.findUnique({ where: { id: taskId } })
}
export async function queryTasks(filters: {
projectId?: string
targetType?: string
targetId?: string
status?: TaskStatus[]
type?: string[]
limit?: number
}) {
return await taskModel.findMany({
where: {
...(filters.projectId ? { projectId: filters.projectId } : {}),
...(filters.targetType ? { targetType: filters.targetType } : {}),
...(filters.targetId ? { targetId: filters.targetId } : {}),
...(filters.status?.length ? { status: { in: filters.status } } : {}),
...(filters.type?.length ? { type: { in: filters.type } } : {}),
},
orderBy: { createdAt: 'desc' },
take: filters.limit ?? 50,
})
}
export async function getActiveTasksForTarget(params: {
targetType: string
targetId: string
projectId?: string
}) {
return await taskModel.findMany({
where: {
targetType: params.targetType,
targetId: params.targetId,
...(params.projectId ? { projectId: params.projectId } : {}),
status: { in: [...ACTIVE_STATUSES] },
},
orderBy: { createdAt: 'desc' },
})
}
export async function markTaskEnqueueFailed(taskId: string, error: string) {
return await taskModel.update({
where: { id: taskId },
data: {
enqueueAttempts: { increment: 1 },
lastEnqueueError: error.slice(0, 500),
},
})
}
export async function markTaskEnqueued(taskId: string) {
return await taskModel.update({
where: { id: taskId },
data: {
enqueuedAt: new Date(),
lastEnqueueError: null,
},
})
}
export async function updateTaskBillingInfo(taskId: string, billingInfo: TaskBillingInfo | null) {
return await taskModel.update({
where: { id: taskId },
data: {
billingInfo: toNullableJson(billingInfo as unknown as Prisma.InputJsonValue),
},
})
}
export async function updateTaskPayload(taskId: string, payload: Record<string, unknown> | null) {
return await taskModel.update({
where: { id: taskId },
data: {
payload: toNullableJson(payload as unknown as Prisma.InputJsonValue),
},
})
}
function activeTaskWhere(taskId: string) {
return {
id: taskId,
status: { in: [...ACTIVE_STATUSES] },
}
}
export async function isTaskActive(taskId: string) {
const task = await withPrismaRetry(() =>
taskModel.findUnique({
where: { id: taskId },
select: { status: true },
})
)
if (!task) return false
return isActiveStatus(task.status)
}
export async function tryMarkTaskProcessing(taskId: string, externalId?: string | null) {
const result = await taskModel.updateMany({
where: activeTaskWhere(taskId),
data: {
status: TASK_STATUS.PROCESSING,
startedAt: new Date(),
heartbeatAt: new Date(),
externalId: externalId || null,
attempt: { increment: 1 },
},
})
return result.count > 0
}
export async function trySetTaskExternalId(taskId: string, externalId: string) {
const value = typeof externalId === 'string' ? externalId.trim() : ''
if (!value) return false
const result = await taskModel.updateMany({
where: {
...activeTaskWhere(taskId),
OR: [
{ externalId: null },
{ externalId: '' },
],
},
data: {
externalId: value,
},
})
return result.count > 0
}
export async function touchTaskHeartbeat(taskId: string) {
const result = await taskModel.updateMany({
where: activeTaskWhere(taskId),
data: { heartbeatAt: new Date() },
})
return result.count > 0
}
export async function tryUpdateTaskProgress(taskId: string, progress: number, payload?: Record<string, unknown> | null) {
const result = await taskModel.updateMany({
where: activeTaskWhere(taskId),
data: {
progress,
...(payload ? { payload: toNullableJson(payload) } : {}),
},
})
return result.count > 0
}
export async function tryMarkTaskCompleted(taskId: string, resultPayload?: Record<string, unknown> | null) {
const result = await taskModel.updateMany({
where: activeTaskWhere(taskId),
data: {
status: TASK_STATUS.COMPLETED,
progress: 100,
result: toNullableJson(resultPayload ?? null),
finishedAt: new Date(),
heartbeatAt: null,
},
})
return result.count > 0
}
export async function tryMarkTaskFailed(taskId: string, errorCode: string, errorMessage: string) {
const result = await taskModel.updateMany({
where: activeTaskWhere(taskId),
data: {
status: TASK_STATUS.FAILED,
errorCode: errorCode.slice(0, 80),
errorMessage: errorMessage.slice(0, 2000),
finishedAt: new Date(),
heartbeatAt: null,
},
})
return result.count > 0
}
export async function tryMarkTaskCanceled(taskId: string, errorCode: string, errorMessage: string) {
const result = await taskModel.updateMany({
where: activeTaskWhere(taskId),
data: {
status: TASK_STATUS.CANCELED,
errorCode: errorCode.slice(0, 80),
errorMessage: errorMessage.slice(0, 2000),
finishedAt: new Date(),
heartbeatAt: null,
},
})
return result.count > 0
}
export async function markTaskProcessing(taskId: string, externalId?: string | null) {
return await tryMarkTaskProcessing(taskId, externalId)
}
export async function updateTaskProgress(taskId: string, progress: number, payload?: Record<string, unknown> | null) {
return await tryUpdateTaskProgress(taskId, progress, payload)
}
export async function markTaskCompleted(taskId: string, result?: Record<string, unknown> | null) {
return await tryMarkTaskCompleted(taskId, result)
}
export async function markTaskFailed(taskId: string, errorCode: string, errorMessage: string) {
return await tryMarkTaskFailed(taskId, errorCode, errorMessage)
}
export async function markTaskCanceled(taskId: string, errorCode: string, errorMessage: string) {
return await tryMarkTaskCanceled(taskId, errorCode, errorMessage)
}
export async function cancelTask(taskId: string, reason = 'Task cancelled by user') {
const snapshot = await taskModel.findUnique({
where: { id: taskId },
select: {
id: true,
status: true,
billingInfo: true,
},
})
if (!snapshot) {
return {
task: null,
cancelled: false,
}
}
const active = isActiveStatus(snapshot.status)
const rollbackResult = active
? await rollbackTaskBillingForTask({
taskId: taskId,
billingInfo: snapshot.billingInfo,
})
: {
attempted: false,
rolledBack: true,
billingInfo: parseTaskBillingInfo(snapshot.billingInfo),
}
const failure = resolveCompensationFailure(rollbackResult, 'TASK_CANCELLED', reason)
const cancelled = await tryMarkTaskCanceled(taskId, failure.errorCode, failure.errorMessage)
const task = await taskModel.findUnique({ where: { id: taskId } })
return {
task,
cancelled,
}
}
export async function sweepStaleTasks(params: {
processingThresholdMs: number
limit?: number
}) {
const limit = Math.max(1, params.limit || 200)
const processingBefore = new Date(Date.now() - Math.max(1, params.processingThresholdMs))
const staleProcessing = await taskModel.findMany({
where: {
status: TASK_STATUS.PROCESSING,
OR: [
{ heartbeatAt: { lt: processingBefore } },
{
heartbeatAt: null,
startedAt: { lt: processingBefore },
},
{
heartbeatAt: null,
startedAt: null,
updatedAt: { lt: processingBefore },
},
],
},
orderBy: { updatedAt: 'asc' },
take: limit,
select: {
id: true,
userId: true,
projectId: true,
episodeId: true,
type: true,
targetType: true,
targetId: true,
billingInfo: true,
},
})
if (staleProcessing.length === 0) return []
const finishedAt = new Date()
const timedOut: Array<typeof staleProcessing[number] & {
errorCode: string
errorMessage: string
}> = []
for (const task of staleProcessing) {
const rollbackResult = await rollbackTaskBillingForTask({
taskId: task.id,
billingInfo: task.billingInfo,
})
const failure = resolveCompensationFailure(
rollbackResult,
'WATCHDOG_TIMEOUT',
'Task heartbeat timeout',
)
const updated = await taskModel.updateMany({
where: {
id: task.id,
status: TASK_STATUS.PROCESSING,
},
data: {
status: TASK_STATUS.FAILED,
errorCode: failure.errorCode,
errorMessage: failure.errorMessage,
finishedAt,
heartbeatAt: null,
},
})
if (updated.count > 0) {
timedOut.push({
...task,
errorCode: failure.errorCode,
errorMessage: failure.errorMessage,
})
}
}
return timedOut
}
export async function dismissFailedTasks(taskIds: string[], userId: string) {
if (taskIds.length === 0) return 0
const result = await taskModel.updateMany({
where: {
id: { in: taskIds },
userId,
status: TASK_STATUS.FAILED,
},
data: {
status: TASK_STATUS.DISMISSED,
},
})
return result.count
}