import { randomUUID } from 'crypto' import { getEmployeeByUserId, BINDING_STATUS } from '@/lib/employee-directory' import { canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit' import { canManageMaterial } from '@/lib/material-authorization' import { isProductPackageType } from '@/lib/material-type-mapper' import { prisma } from '@/lib/prisma' import { getPublicAppOrigin } from '@/lib/public-app-url' import { buildDingtalkEntryPath } from '@/lib/safe-redirect' import { isSystemAdmin } from '@/lib/department-permission' import { cleanupTaskDetailChangeStaging, resolveTaskDetailStagedFile, withTaskDetailStagedUploads, type TaskDetailStagedFile, } from '@/lib/task-detail-change-staging' import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications' import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit' const REQUEST_STATUS = { PENDING: 'pending_manager_approval', APPLYING: 'applying', APPLIED: 'applied', REJECTED: 'rejected', EXPIRED: 'expired', APPLY_FAILED: 'apply_failed', } as const const APPROVAL_WINDOW_MS = 24 * 60 * 60 * 1000 const STALE_APPLYING_MS = 30 * 60 * 1000 const MAX_OPERATIONS = 100 export class TaskDetailChangeApprovalError extends Error { constructor(message: string, public statusCode = 400) { super(message) this.name = 'TaskDetailChangeApprovalError' } } export type TaskDetailChangeOperationInput = | { kind: 'delete'; assetId: string } | { kind: 'rename'; assetId: string; newFileName: string } | { kind: 'replace'; assetId: string; uploadId: string } | { kind: 'append'; group: 'main' | 'sku'; uploadId: string } | { kind: 'insert' group: 'detail' uploadId: string afterAssetId: string beforeAssetId: string } type StoredOperation = Omit & { stagedFile?: TaskDetailStagedFile } type Actor = { id: string role?: string | null isSuperAdmin?: boolean } function clean(value: unknown) { const text = String(value ?? '').trim() return text || null } function normalizeOperations(value: unknown): TaskDetailChangeOperationInput[] { const rows = Array.isArray(value) ? value : [] if (rows.length === 0) throw new TaskDetailChangeApprovalError('图片变更不能为空') if (rows.length > MAX_OPERATIONS) { throw new TaskDetailChangeApprovalError(`一次最多提交 ${MAX_OPERATIONS} 项图片变更`) } const operations: TaskDetailChangeOperationInput[] = [] const targetAssetIds = new Set() for (const raw of rows) { const row = raw as Record const kind = clean(row.kind) if (kind === 'delete') { const assetId = clean(row.assetId) if (!assetId) throw new TaskDetailChangeApprovalError('删除操作缺少图片标识') if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改') targetAssetIds.add(assetId) operations.push({ kind, assetId }) continue } if (kind === 'rename') { const assetId = clean(row.assetId) const newFileName = clean(row.newFileName) if (!assetId || !newFileName) throw new TaskDetailChangeApprovalError('改名操作参数不完整') if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改') targetAssetIds.add(assetId) operations.push({ kind, assetId, newFileName }) continue } if (kind === 'replace') { const assetId = clean(row.assetId) const uploadId = clean(row.uploadId) if (!assetId || !uploadId) throw new TaskDetailChangeApprovalError('替换操作参数不完整') if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改') targetAssetIds.add(assetId) operations.push({ kind, assetId, uploadId }) continue } if (kind === 'append') { const group = clean(row.group) const uploadId = clean(row.uploadId) if ((group !== 'main' && group !== 'sku') || !uploadId) { throw new TaskDetailChangeApprovalError('追加图片操作参数不完整') } operations.push({ kind, group, uploadId }) continue } if (kind === 'insert') { const group = clean(row.group) const uploadId = clean(row.uploadId) const afterAssetId = clean(row.afterAssetId) const beforeAssetId = clean(row.beforeAssetId) if (group !== 'detail' || !uploadId || !afterAssetId || !beforeAssetId) { throw new TaskDetailChangeApprovalError('详情图插入位置或图片参数不完整') } operations.push({ kind, group, uploadId, afterAssetId, beforeAssetId }) continue } throw new TaskDetailChangeApprovalError(`不支持的图片变更类型:${kind || '空'}`) } return operations } function assertSingleOperationGroup( operations: TaskDetailChangeOperationInput[], assets: Array<{ id: string; assetType: string }>, ) { const assetGroups = new Map() for (const asset of assets) { if (asset.assetType === 'main-preview') assetGroups.set(asset.id, 'main') if (asset.assetType === 'sku-preview') assetGroups.set(asset.id, 'sku') if (asset.assetType === 'detail-preview') assetGroups.set(asset.id, 'detail') } const groups = new Set<'main' | 'sku' | 'detail'>() for (const operation of operations) { if (operation.kind === 'append') { groups.add(operation.group) continue } if (operation.kind === 'insert') { if ( assetGroups.get(operation.afterAssetId) !== 'detail' || assetGroups.get(operation.beforeAssetId) !== 'detail' ) { throw new TaskDetailChangeApprovalError('详情图插入位置不存在或不属于该素材', 404) } groups.add('detail') continue } const group = assetGroups.get(operation.assetId) if (!group) { throw new TaskDetailChangeApprovalError('要修改的图片不存在或不属于该素材', 404) } groups.add(group) } if (groups.size !== 1) { throw new TaskDetailChangeApprovalError('一次只能提交同一图片分组(主图、SKU图或详情图)的变更,请分组提交') } } async function loadAuthorizedContext(materialId: string, actor: Actor) { const [material, employee] = await Promise.all([ prisma.material.findFirst({ where: { id: materialId, deletedAt: null }, select: { id: true, userId: true, materialType: true, taskStatus: true, taskNo: true, productModel: true, updatedAt: true, assets: { where: { assetType: { in: ['main-preview', 'sku-preview', 'detail-preview'] } }, select: { id: true, assetType: true }, }, }, }), getEmployeeByUserId(actor.id), ]) if (!material) throw new TaskDetailChangeApprovalError('素材不存在或已删除', 404) if (!isProductPackageType(material.materialType)) { throw new TaskDetailChangeApprovalError('只有任务-详情素材支持图片变更') } if (material.taskStatus !== 'completed') { throw new TaskDetailChangeApprovalError('素材尚未完成上传,暂不能修改图片') } let allowed = await canManageMaterial( { id: actor.id, role: actor.role, isSuperAdmin: actor.isSuperAdmin }, { id: material.id, userId: material.userId }, ) if (!allowed) allowed = await canUserEditTaskDetailAssets(actor.id) if (!allowed) throw new TaskDetailChangeApprovalError('您没有权限修改此素材', 403) return { material, employee } } function employeeSnapshot(employee: NonNullable>>) { return { employeeProfileId: employee.employeeProfileId, userId: employee.userId, displayName: employee.displayName, jobNumber: employee.jobNumber, dingtalkUserId: employee.dingtalkUserId, departmentName: employee.departmentName, departmentPath: employee.departmentPath, positionTitle: employee.positionTitle, positionLevel: employee.positionLevel, levelType: employee.levelType, isLeadership: employee.isLeadership, managerName: employee.managerName, managerDingtalkUserId: employee.managerDingtalkUserId, } } async function resolveManager(employee: NonNullable>>) { const managerDingtalkUserId = clean(employee.managerDingtalkUserId) if (!managerDingtalkUserId) { throw new TaskDetailChangeApprovalError('花名册没有记录直属主管,暂不能提交图片变更申请', 409) } const manager = await prisma.performanceSurveyEmployeeProfile.findUnique({ where: { dingtalkUserId: managerDingtalkUserId }, include: { userBindings: { where: { status: BINDING_STATUS.ACTIVE }, select: { userId: true }, take: 1, }, }, }) if (!manager || manager.employmentState !== 'ACTIVE') { throw new TaskDetailChangeApprovalError('直属主管账号无有效在职档案,暂不能提交图片变更申请', 409) } return { employee: manager, userId: manager.userBindings[0]?.userId || null, dingtalkUserId: managerDingtalkUserId, name: manager.displayName || manager.realName || employee.managerName || '直属主管', } } function isDirectApplyActor(actor: Actor, employee: Awaited>) { const positionLevel = employee?.positionLevel?.trim().toUpperCase() return Boolean( actor.isSuperAdmin || isSystemAdmin(String(actor.role || '')) || employee?.levelType?.toUpperCase() === 'M' || positionLevel?.startsWith('M') ) } function actionUrl(requestId: string) { return `${getPublicAppOrigin()}${buildDingtalkEntryPath(`/task-detail-change-requests/${requestId}`)}` } async function queueManagerNotification(request: { id: string managerUserId: string | null managerDingtalkUserId: string managerName: string requesterName: string material: { taskNo: string | null; productModel: string } operations: unknown[] expiresAt: Date }) { await queueTaskDetailChangeNotification({ requestId: request.id, recipientUserId: request.managerUserId, dingtalkUserId: request.managerDingtalkUserId, recipientName: request.managerName, eventType: 'task_detail_change.requested', businessKey: `task-detail-change:${request.id}:requested`, title: '任务详情图片变更待确认', content: [ '### ◆ 图片变更待确认', '', `**素材:** ${request.material.taskNo || request.material.productModel}`, `**提交人:** ${request.requesterName}`, `**变更数量:** ${request.operations.length} 项`, `**截止时间:** ${request.expiresAt.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, '', '> 未确认前正式素材不会改变;超过截止时间将自动否决。', ].join('\n'), actionUrl: actionUrl(request.id), actionTitle: '立即确认', }) } async function queueRequesterResultNotification(request: any, label: string) { const dingtalkUserId = clean(request.requesterEmployee?.dingtalkUserId) if (!dingtalkUserId) return await queueTaskDetailChangeNotification({ requestId: request.id, recipientUserId: request.requesterUserId, dingtalkUserId, recipientName: request.requesterName, eventType: `task_detail_change.${request.status}`, businessKey: `task-detail-change:${request.id}:${request.status}`, title: '任务详情图片变更结果', content: `您的图片变更申请已${label}。\n\n> 素材:${request.material.taskNo || request.material.productModel}`, actionUrl: actionUrl(request.id), actionTitle: '查看结果', }) } function storeOperations( operations: TaskDetailChangeOperationInput[], staged: Map, ): StoredOperation[] { return operations.map((operation) => { if (!('uploadId' in operation)) return operation as StoredOperation const stagedFile = staged.get(operation.uploadId) if (!stagedFile) throw new TaskDetailChangeApprovalError('图片持久暂存结果缺失', 500) const { uploadId: _uploadId, ...rest } = operation return { ...rest, stagedFile } as StoredOperation }) } function applyOperations(requestId: string, operations: StoredOperation[]) { return operations.map((operation) => { if (!operation.stagedFile) return operation return { ...operation, stagedFile: resolveTaskDetailStagedFile(requestId, operation.stagedFile), } }) } export async function submitTaskDetailChange( materialId: string, actor: Actor, rawOperations: unknown, ) { const operations = normalizeOperations(rawOperations) const { material, employee } = await loadAuthorizedContext(materialId, actor) assertSingleOperationGroup(operations, material.assets) if (isDirectApplyActor(actor, employee)) { const data = await batchEditTaskDetailImages({ materialId, operatorUserId: actor.id, operatorRole: actor.role, operations: operations as any, }) return { mode: 'applied' as const, data } } if (!employee) { throw new TaskDetailChangeApprovalError('当前账号没有匹配花名册,暂不能提交主管审批', 403) } const manager = await resolveManager(employee) const requestId = randomUUID() const expiresAt = new Date(Date.now() + APPROVAL_WINDOW_MS) const uploadIds = operations.filter((item): item is Extract => ( 'uploadId' in item )).map((item) => item.uploadId) const request = await withTaskDetailStagedUploads({ requestId, ownerUserId: actor.id, uploadIds, commit: async (staged) => { const storedOperations = storeOperations(operations, staged) return prisma.taskDetailChangeRequest.create({ data: { id: requestId, materialId, requesterUserId: actor.id, requesterEmployeeId: employee.employeeProfileId, requesterName: employee.displayName, requesterSnapshot: employeeSnapshot(employee) as any, requesterPositionLevel: employee.positionLevel, requesterLevelType: employee.levelType, requesterIsLeadership: employee.isLeadership, managerEmployeeId: manager.employee.id, managerUserId: manager.userId, managerDingtalkUserId: manager.dingtalkUserId, managerName: manager.name, managerSnapshot: { id: manager.employee.id, dingtalkUserId: manager.employee.dingtalkUserId, jobNumber: manager.employee.jobNumber, displayName: manager.name, departmentPath: manager.employee.departmentPath, positionLevel: manager.employee.positionLevel, } as any, status: REQUEST_STATUS.PENDING, operations: storedOperations as any, baseMaterialUpdatedAt: material.updatedAt, expiresAt, }, include: { material: { select: { taskNo: true, productModel: true } } }, }) }, }) await queueManagerNotification({ ...request, operations, expiresAt, }).catch((error) => { console.error('[task-detail-change] 审批单已创建,但主管通知排队失败:', error) }) return { mode: 'pending_approval' as const, request: { id: request.id, status: request.status, managerName: request.managerName, expiresAt: request.expiresAt, }, } } async function loadRequestForActor(id: string, actor: Actor) { const [request, employee] = await Promise.all([ prisma.taskDetailChangeRequest.findUnique({ where: { id }, include: { material: { select: { id: true, taskNo: true, productModel: true, materialType: true, updatedAt: true } }, requesterEmployee: { select: { dingtalkUserId: true } }, }, }), getEmployeeByUserId(actor.id), ]) if (!request) throw new TaskDetailChangeApprovalError('图片变更申请不存在', 404) const canDecide = Boolean( employee?.dingtalkUserId && employee.dingtalkUserId === request.managerDingtalkUserId, ) const canView = canDecide || request.requesterUserId === actor.id || actor.isSuperAdmin || isSystemAdmin(String(actor.role || '')) if (!canView) throw new TaskDetailChangeApprovalError('您无权查看这条图片变更申请', 403) return { request, employee, canDecide } } export async function getTaskDetailChangeRequest(id: string, actor: Actor) { const { request, canDecide } = await loadRequestForActor(id, actor) const operations = (Array.isArray(request.operations) ? request.operations : []) as StoredOperation[] return { id: request.id, status: request.status, requesterName: request.requesterName, managerName: request.managerName, material: request.material, operations: operations.map(({ stagedFile, ...operation }) => ({ ...operation, fileName: stagedFile?.fileName || null, })), submittedAt: request.submittedAt, expiresAt: request.expiresAt, decidedAt: request.decidedAt, appliedAt: request.appliedAt, applyError: request.applyError, canDecide: canDecide && request.status === REQUEST_STATUS.PENDING && request.expiresAt > new Date(), } } export async function listTaskDetailChanges(materialId: string, actor: Actor) { const employee = await getEmployeeByUserId(actor.id) const privileged = Boolean(actor.isSuperAdmin || isSystemAdmin(String(actor.role || ''))) const rows = await prisma.taskDetailChangeRequest.findMany({ where: { materialId, ...(privileged ? {} : { OR: [ { requesterUserId: actor.id }, ...(employee?.dingtalkUserId ? [{ managerDingtalkUserId: employee.dingtalkUserId }] : []), ], }), }, orderBy: { createdAt: 'desc' }, take: 10, select: { id: true, status: true, requesterName: true, managerName: true, operations: true, submittedAt: true, expiresAt: true, decidedAt: true, appliedAt: true, applyError: true, }, }) return rows.map((row) => ({ ...row, operationCount: Array.isArray(row.operations) ? row.operations.length : 0, operations: undefined, })) } export async function decideTaskDetailChange( id: string, actor: Actor, decision: 'approve' | 'reject', ) { const { request, canDecide } = await loadRequestForActor(id, actor) if (!canDecide) throw new TaskDetailChangeApprovalError('只有申请人的直属主管可以确认', 403) const now = new Date() if (request.status !== REQUEST_STATUS.PENDING) { throw new TaskDetailChangeApprovalError('该申请已经处理,不能重复确认', 409) } if (request.expiresAt <= now) { const expired = await prisma.taskDetailChangeRequest.updateMany({ where: { id, status: REQUEST_STATUS.PENDING }, data: { status: REQUEST_STATUS.EXPIRED, decidedAt: now }, }) if (expired.count === 1) { await cleanupTaskDetailChangeStaging(id).catch(() => undefined) const final = await prisma.taskDetailChangeRequest.findUnique({ where: { id }, include: { material: { select: { taskNo: true, productModel: true } }, requesterEmployee: { select: { dingtalkUserId: true } }, }, }) if (final) { await queueRequesterResultNotification(final, '因超过 24 小时未确认而自动否决').catch(() => undefined) } } throw new TaskDetailChangeApprovalError('该申请已超时并自动否决', 409) } if (decision === 'reject') { const updated = await prisma.taskDetailChangeRequest.updateMany({ where: { id, status: REQUEST_STATUS.PENDING, expiresAt: { gt: now } }, data: { status: REQUEST_STATUS.REJECTED, decidedAt: now, decidedByUserId: actor.id, }, }) if (updated.count !== 1) throw new TaskDetailChangeApprovalError('申请状态已变化,请刷新', 409) await cleanupTaskDetailChangeStaging(id).catch(() => undefined) const final = await prisma.taskDetailChangeRequest.findUnique({ where: { id }, include: { material: { select: { taskNo: true, productModel: true } }, requesterEmployee: { select: { dingtalkUserId: true } }, }, }) if (final) await queueRequesterResultNotification(final, '被主管否决').catch(() => undefined) return { status: REQUEST_STATUS.REJECTED } } const claimed = await prisma.taskDetailChangeRequest.updateMany({ where: { id, status: REQUEST_STATUS.PENDING, expiresAt: { gt: now } }, data: { status: REQUEST_STATUS.APPLYING, decidedAt: now, decidedByUserId: actor.id, }, }) if (claimed.count !== 1) throw new TaskDetailChangeApprovalError('申请状态已变化,请刷新', 409) try { const material = await prisma.material.findUnique({ where: { id: request.materialId }, select: { updatedAt: true }, }) if (!material || material.updatedAt.getTime() !== request.baseMaterialUpdatedAt.getTime()) { throw new TaskDetailChangeApprovalError('正式素材在审批期间已更新,本申请已停止应用', 409) } const operations = applyOperations( id, (Array.isArray(request.operations) ? request.operations : []) as StoredOperation[], ) await batchEditTaskDetailImages({ materialId: request.materialId, operatorUserId: request.requesterUserId, expectedMaterialUpdatedAt: request.baseMaterialUpdatedAt, approvalRequestId: id, operations: operations as any, }) await cleanupTaskDetailChangeStaging(id).catch(() => undefined) const final = await prisma.taskDetailChangeRequest.findUnique({ where: { id }, include: { material: { select: { taskNo: true, productModel: true } }, requesterEmployee: { select: { dingtalkUserId: true } }, }, }) if (final) await queueRequesterResultNotification(final, '由直属主管批准并生效').catch(() => undefined) return { status: REQUEST_STATUS.APPLIED } } catch (error) { const message = error instanceof Error ? error.message : '批准后应用图片变更失败' await prisma.taskDetailChangeRequest.update({ where: { id }, data: { status: REQUEST_STATUS.APPLY_FAILED, applyError: message }, }) await cleanupTaskDetailChangeStaging(id).catch(() => undefined) const final = await prisma.taskDetailChangeRequest.findUnique({ where: { id }, include: { material: { select: { taskNo: true, productModel: true } }, requesterEmployee: { select: { dingtalkUserId: true } }, }, }) if (final) { await queueRequesterResultNotification(final, '在批准后应用失败,请重新提交').catch(() => undefined) } throw error } } export async function expireDueTaskDetailChanges(now = new Date()) { const due = await prisma.taskDetailChangeRequest.findMany({ where: { status: REQUEST_STATUS.PENDING, expiresAt: { lte: now } }, select: { id: true }, orderBy: { expiresAt: 'asc' }, take: 50, }) let expired = 0 for (const row of due) { const changed = await prisma.taskDetailChangeRequest.updateMany({ where: { id: row.id, status: REQUEST_STATUS.PENDING, expiresAt: { lte: now } }, data: { status: REQUEST_STATUS.EXPIRED, decidedAt: now }, }) if (changed.count !== 1) continue expired += 1 await cleanupTaskDetailChangeStaging(row.id).catch(() => undefined) const final = await prisma.taskDetailChangeRequest.findUnique({ where: { id: row.id }, include: { material: { select: { taskNo: true, productModel: true } }, requesterEmployee: { select: { dingtalkUserId: true } }, }, }) if (final) await queueRequesterResultNotification(final, '因超过 24 小时未确认而自动否决').catch(() => undefined) } return expired } /** * 补偿“审批单已持久化、通知 outbox 落库前进程退出/数据库瞬时失败”的窄窗口。 * 唯一键保证多实例并发扫描也只会生成一条待发送通知。 */ export async function recoverMissingTaskDetailChangeRequestNotifications() { const rows = await prisma.taskDetailChangeRequest.findMany({ where: { status: REQUEST_STATUS.PENDING, expiresAt: { gt: new Date() }, notifications: { none: { eventType: 'task_detail_change.requested' }, }, }, orderBy: { createdAt: 'asc' }, take: 20, include: { material: { select: { taskNo: true, productModel: true } }, }, }) let queued = 0 for (const request of rows) { await queueManagerNotification({ id: request.id, managerUserId: request.managerUserId, managerDingtalkUserId: request.managerDingtalkUserId, managerName: request.managerName, requesterName: request.requesterName, material: request.material, operations: Array.isArray(request.operations) ? request.operations : [], expiresAt: request.expiresAt, }) queued += 1 } return queued } /** * 正式素材更新与 request=applied 在同一数据库事务提交;因此长时间停留在 * applying 只可能是进程中断或处理超时。先转失败会让迟到事务因状态不匹配而回滚。 */ export async function recoverStaleApplyingTaskDetailChanges(now = new Date()) { const staleBefore = new Date(now.getTime() - STALE_APPLYING_MS) const rows = await prisma.taskDetailChangeRequest.findMany({ where: { status: REQUEST_STATUS.APPLYING, updatedAt: { lte: staleBefore } }, select: { id: true }, orderBy: { updatedAt: 'asc' }, take: 50, }) let failed = 0 for (const row of rows) { const changed = await prisma.taskDetailChangeRequest.updateMany({ where: { id: row.id, status: REQUEST_STATUS.APPLYING, updatedAt: { lte: staleBefore }, }, data: { status: REQUEST_STATUS.APPLY_FAILED, applyError: '批准后的图片应用进程中断或超时,请重新提交变更', }, }) if (changed.count !== 1) continue failed += 1 await cleanupTaskDetailChangeStaging(row.id).catch(() => undefined) const final = await prisma.taskDetailChangeRequest.findUnique({ where: { id: row.id }, include: { material: { select: { taskNo: true, productModel: true } }, requesterEmployee: { select: { dingtalkUserId: true } }, }, }) if (final) { await queueRequesterResultNotification(final, '因应用进程中断或超时而失败,请重新提交').catch(() => undefined) } } return failed }