import { randomUUID } from 'crypto' import { open } from 'fs/promises' import type { Session } from 'next-auth' import { prisma } from '@/lib/prisma' import { getWorkReviewDashboardSyncState } from '@/lib/work-review-dashboard-sync' import { getPublicAppOrigin } from '@/lib/public-app-url' import { buildDingtalkEntryPath } from '@/lib/safe-redirect' import { getPerformanceSurveyContext, } from '@/lib/performance-survey-service' import { getPerformanceSurveyAccountProfileWhere, } from '@/lib/performance-survey-report-sync' import { ensureWorkReviewNotificationWorkerStarted, queueWorkReviewNotification, } from '@/lib/work-review-notifications' import { getRequiredWorkReviewRoles, getWorkReviewReviewMode, WORK_REVIEW_REVIEW_ROLES, type WorkReviewReviewRole, } from '@/lib/work-review-review-mode' import { serializePerformanceSurveyImagePayload, type PerformanceSurveyStoredImageGroup, } from '@/lib/performance-survey-image-processing' import { PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS, PerformanceSurveyProcessedImageError, readPerformanceSurveyWebpDimensions, validatePerformanceSurveyProcessedImages, type PerformanceSurveyProcessedImageDeclaration, } from '@/lib/performance-survey-processed-images' import { deleteFromS3, uploadFileToS3 } from '@/lib/s3' import { acquireTaskMaterialUploadSessionLocks, type MaterialUploadStorageLockRelease, } from '@/lib/material-upload-cross-process-lock' import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' import { getOwnedUploadedFile, getOwnedUploadSession, } from '@/lib/upload-sessions' import { canDeleteWorkReviewProject, canSubmitWorkReview, deriveCompletedWorkReviewStatus, deriveWorkReviewDecision, getWorkReviewEmployeeAliasKeys, getWorkReviewEmployeeIdentityKeys, getWorkReviewEmployeeMaterialUserIds, getWorkReviewReviewerSelectionError, isSameWorkReviewEmployeeAlias, isSameWorkReviewEmployeeIdentity, validateWorkReviewAnnotations, type NormalizedWorkReviewAnnotation, } from '@/lib/work-review-policy' import { buildFirstPassReviewSummary, normalizeFirstPassReviewStatus, type FirstPassReviewStatus, type FirstPassReviewSummary, } from '@/lib/work-review-metrics' import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope' const REVIEW_ROLES = WORK_REVIEW_REVIEW_ROLES const VERSION_STATUS = { PENDING_REVIEW: 'pending_review', REVISION_REQUIRED: 'revision_required', APPROVED: 'approved', } as const type ReviewRole = WorkReviewReviewRole type ResolvedReviewer = { employee: any userId: string | null dingtalkUserId: string | null name: string } type ResolvedProcessedImage = { declaration: PerformanceSurveyProcessedImageDeclaration filePath: string fileSize: number } type WorkReviewStoredImagePayload = { version: 2 workImageGroups: PerformanceSurveyStoredImageGroup[] referenceImageGroups: PerformanceSurveyStoredImageGroup[] } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } export function serializeWorkReviewImagePayload(value: unknown) { if (isRecord(value) && Number(value.version) === 2) { const workImages = serializePerformanceSurveyImagePayload(value.workImageGroups) const referenceImages = serializePerformanceSurveyImagePayload(value.referenceImageGroups) return { imageUrls: workImages.imageUrls, imageGroups: workImages.imageGroups, referenceImageUrls: referenceImages.imageUrls, referenceImageGroups: referenceImages.imageGroups, } } const workImages = serializePerformanceSurveyImagePayload(value) return { imageUrls: workImages.imageUrls, imageGroups: workImages.imageGroups, referenceImageUrls: [], referenceImageGroups: [], } } function buildWorkReviewImagePayload( workImageGroups: PerformanceSurveyStoredImageGroup[], referenceImageGroups: PerformanceSurveyStoredImageGroup[], ): WorkReviewStoredImagePayload { return { version: 2, workImageGroups, referenceImageGroups, } } type ImageCommentInput = { imageId: string imageIndex: number sourceName: string decision: typeof VERSION_STATUS.APPROVED | typeof VERSION_STATUS.REVISION_REQUIRED comment: string } export class WorkReviewApiError extends Error { status: number constructor(message: string, status = 400) { super(message) this.status = status } } function clean(value: unknown) { const text = String(value ?? '').trim() return text || null } function getRequiredReviewRoles(project: { id: string; managerEmployeeId: string }): ReviewRole[] { return getRequiredWorkReviewRoles(project) } function escapeDingtalkMarkdown(value: unknown) { return String(value ?? '') .replace(/\\/g, '\\\\') .replace(/([`*_\[\]#>])/g, '\\$1') .trim() } function getEmployeeName(employee: any) { return clean(employee?.flowerName) || clean(employee?.displayName) || clean(employee?.realName) || clean(employee?.jobNumber) || '未命名人员' } function getEmployeeSnapshot(employee: any) { return { id: employee.id, dingtalkUserId: employee.dingtalkUserId || null, dingEmail: employee.dingEmail || null, jobNumber: employee.jobNumber || null, flowerName: employee.flowerName || null, realName: employee.realName || null, displayName: employee.displayName, departmentName: employee.departmentName || null, departmentPath: employee.departmentPath || null, positionTitle: employee.positionTitle || null, positionLevel: employee.positionLevel || null, levelType: employee.levelType || null, managerName: employee.managerName || null, } } function parseWorkDate(value: unknown) { const text = clean(value) if (!text || !/^\d{4}-\d{2}-\d{2}$/.test(text)) { throw new WorkReviewApiError('请选择有效的作品日期') } const date = new Date(`${text}T00:00:00.000Z`) if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text) { throw new WorkReviewApiError('请选择有效的作品日期') } return date } function getWorkReviewActionUrl(projectId: string) { const path = `/work-review?project=${encodeURIComponent(projectId)}` return `${getPublicAppOrigin()}${buildDingtalkEntryPath(path)}` } async function resolveEmployeeReviewer(employee: any): Promise { const dingtalkUserId = clean(employee?.dingtalkUserId) const dingEmail = clean(employee?.dingEmail) const materialUserIds = getWorkReviewEmployeeMaterialUserIds(employee) const sourcePayload = employee?.sourcePayload const materialUserEmail = sourcePayload && typeof sourcePayload === 'object' && !Array.isArray(sourcePayload) ? clean((sourcePayload as Record).materialUserEmail) : null const emails = Array.from(new Set([dingEmail, materialUserEmail].filter(Boolean))) as string[] const user = dingtalkUserId || emails.length > 0 || materialUserIds.length > 0 ? await prisma.user.findFirst({ where: { OR: [ ...(materialUserIds.length > 0 ? [{ id: { in: materialUserIds } }] : []), ...(dingtalkUserId ? [{ dingtalkUserId }] : []), ...(emails.length > 0 ? [{ email: { in: emails } }] : []), ], }, select: { id: true, dingtalkUserId: true }, }) : null return { employee, userId: user?.id || null, dingtalkUserId: dingtalkUserId || clean(user?.dingtalkUserId), name: getEmployeeName(employee), } } async function resolveFinalReviewer() { const configuredVersions = await prisma.performanceSurveyTemplateVersion.findMany({ where: { status: 'published', template: { code: { in: ['visual_brand_tone', 'visual_brand_tone_without_copy'], }, }, }, select: { definition: true }, orderBy: [{ publishedAt: 'desc' }, { version: 'desc' }], take: 10, }) const configuredIds = Array.from(new Set(configuredVersions.flatMap((version) => { const definition = version.definition as any const stages = Array.isArray(definition?.workflow?.stages) ? definition.workflow.stages : [] return stages.flatMap((stage: any) => ( Array.isArray(stage?.reviewers) ? stage.reviewers .filter((reviewer: any) => ( reviewer?.source === 'fixed_employee' && (!reviewer?.employeeName || reviewer.employeeName === '元泽') )) .map((reviewer: any) => clean(reviewer.employeeId)) .filter(Boolean) : [] )) }))) as string[] if (configuredIds.length === 1) { const configuredEmployee = await prisma.performanceSurveyEmployeeProfile.findFirst({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { id: configuredIds[0] }, ], }, }) if (configuredEmployee) return resolveEmployeeReviewer(configuredEmployee) } const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { OR: [ { displayName: '元泽' }, { flowerName: '元泽' }, { realName: '元泽' }, ], }, ], }, take: 2, }) if (employees.length !== 1) { throw new WorkReviewApiError('无法唯一匹配元泽的人员档案,请先在考核调研人员管理中校准', 409) } return resolveEmployeeReviewer(employees[0]) } async function resolveSelectedReviewer( employeeId: unknown, submitterEmployee: any, submitterUserId: string, finalReviewer: ResolvedReviewer, ) { const reviewerEmployeeId = clean(employeeId) const selectionError = getWorkReviewReviewerSelectionError({ reviewerEmployeeId, submitterEmployeeId: submitterEmployee.id, finalReviewerEmployeeId: finalReviewer.employee.id, }) if (selectionError) throw new WorkReviewApiError(selectionError) const employee = await prisma.performanceSurveyEmployeeProfile.findFirst({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { id: reviewerEmployeeId }, ], }, }) if (!employee) { throw new WorkReviewApiError('所选评审人不存在或账号不可用,请重新选择', 409) } if ( isSameWorkReviewEmployeeIdentity(employee, submitterEmployee) || isSameWorkReviewEmployeeAlias(employee, submitterEmployee) ) { throw new WorkReviewApiError('评审人不能选择自己,请选择其他人员') } if ( isSameWorkReviewEmployeeIdentity(employee, finalReviewer.employee) || isSameWorkReviewEmployeeAlias(employee, finalReviewer.employee) ) { throw new WorkReviewApiError('元泽已是固定评审人,请选择另一名评审人') } const reviewer = await resolveEmployeeReviewer(employee) if (reviewer.userId && reviewer.userId === submitterUserId) { throw new WorkReviewApiError('评审人不能选择自己,请选择其他人员') } if (reviewer.userId && finalReviewer.userId && reviewer.userId === finalReviewer.userId) { throw new WorkReviewApiError('元泽已是固定评审人,请选择另一名评审人') } return reviewer } async function getContext(session: Session | null) { ensureWorkReviewNotificationWorkerStarted() const ctx = await getPerformanceSurveyContext(session) return { ...ctx, canSubmit: canSubmitWorkReview(ctx.employee), } } function getAccessibleWhere(ctx: Awaited>) { if (ctx.isSystemAdmin) return {} const employeeId = clean(ctx.employee?.id) return { OR: [ { submitterUserId: ctx.user.id }, ...(employeeId ? [ { submitterEmployeeId: employeeId }, { managerEmployeeId: employeeId }, { finalReviewerEmployeeId: employeeId }, ] : []), ], } } function normalizeReviewRole(value: unknown): ReviewRole { const role = clean(value) if (role === REVIEW_ROLES.MANAGER || role === REVIEW_ROLES.FINAL_REVIEWER) return role throw new WorkReviewApiError('评审区域无效') } function canReviewRole( ctx: Awaited>, project: { managerEmployeeId: string managerUserId: string | null finalReviewerEmployeeId: string finalReviewerUserId: string | null }, role: ReviewRole, ) { const employeeId = clean(ctx.employee?.id) if (role === REVIEW_ROLES.MANAGER) { return Boolean( (employeeId && employeeId === project.managerEmployeeId) || project.managerUserId === ctx.user.id, ) } return Boolean( (employeeId && employeeId === project.finalReviewerEmployeeId) || project.finalReviewerUserId === ctx.user.id, ) } function getPendingRoles( ctx: Awaited>, project: any, version: any, ) { const submittedRoles = new Set((version?.reviews || []).map((review: any) => review.reviewerRole)) return getRequiredReviewRoles(project).filter((role) => ( !submittedRoles.has(role) && canReviewRole(ctx, project, role) )) } function serializeProject(ctx: Awaited>, project: any) { const reviewMode = getWorkReviewReviewMode(project) const requiredReviewRoles = getRequiredReviewRoles(project) const versions = [...(project.versions || [])] .sort((left, right) => right.version - left.version) .map((version) => { const images = serializeWorkReviewImagePayload(version.imageUrls) return { id: version.id, version: version.version, submissionNote: version.submissionNote, status: version.status, submittedAt: version.submittedAt, completedAt: version.completedAt, imageUrls: images.imageUrls, imageGroups: images.imageGroups, referenceImageUrls: images.referenceImageUrls, referenceImageGroups: images.referenceImageGroups, reviews: [...(version.reviews || [])] .filter((review) => requiredReviewRoles.includes(review.reviewerRole)) .sort((left, right) => left.reviewerRole.localeCompare(right.reviewerRole)) .map((review) => ({ id: review.id, reviewerRole: review.reviewerRole, reviewerName: review.reviewerName, decision: review.decision, imageComments: review.imageComments, generalComment: review.generalComment, reviewedAt: review.reviewedAt, })), } }) const latest = versions[0] || null return { id: project.id, title: project.title, description: project.description, workDate: project.workDate, status: project.status, latestVersion: project.latestVersion, submitter: { id: project.submitterEmployeeId, name: project.submitterName, snapshot: project.submitterSnapshot, }, manager: { id: project.managerEmployeeId, name: project.managerName, }, finalReviewer: { id: project.finalReviewerEmployeeId, name: project.finalReviewerName, }, createdAt: project.createdAt, updatedAt: project.updatedAt, reviewMode, requiredReviewRoles, versions, permissions: { canResubmit: Boolean( ctx.canSubmit && project.submitterUserId === ctx.user.id && project.status === VERSION_STATUS.REVISION_REQUIRED, ), pendingReviewRoles: latest ? getPendingRoles(ctx, project, latest) : [], }, } } export async function getWorkReviewBoard(session: Session | null, params?: URLSearchParams) { const ctx = await getContext(session) const status = clean(params?.get('status')) const query = clean(params?.get('query')) const projectId = clean(params?.get('project')) const page = projectId ? 1 : Math.max(1, Number(params?.get('page') || 1) || 1) const pageSize = Math.min(50, Math.max(1, Number(params?.get('pageSize') || 20) || 20)) const filters: any[] = [getAccessibleWhere(ctx)] if (projectId) { filters.push({ id: projectId }) } else if (status && Object.values(VERSION_STATUS).includes(status as any)) { filters.push({ status }) } if (!projectId && query) { filters.push({ OR: [ { title: { contains: query, mode: 'insensitive' } }, { submitterName: { contains: query, mode: 'insensitive' } }, ], }) } const where = filters.length === 1 ? filters[0] : { AND: filters } const [total, projects] = await prisma.$transaction([ prisma.workReviewProject.count({ where }), prisma.workReviewProject.findMany({ where, include: { versions: { include: { reviews: true }, orderBy: { version: 'desc' }, }, }, orderBy: [{ workDate: 'desc' }, { updatedAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), ]) const items = projects.map((project) => serializeProject(ctx, project)) return { me: { user: { id: ctx.user.id, name: getEmployeeName(ctx.employee) || ctx.user.username, avatar: ctx.user.avatar, role: ctx.user.role, }, employee: ctx.employee ? getEmployeeSnapshot(ctx.employee) : null, permissions: { canSubmit: ctx.canSubmit, canDeleteProjects: canDeleteWorkReviewProject(ctx.user), isSystemAdmin: ctx.isSystemAdmin, isManager: Boolean(ctx.isLeadership), isFinalReviewer: Boolean( ctx.employee && items.some((item) => item.finalReviewer.id === ctx.employee.id), ), }, }, items, pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)), }, } } export type OngoingWorkReviewProjectListItem = { id: string title: string description: string | null status: 'pending_review' | 'revision_required' workDate: string latestVersion: number submitterName: string managerName: string finalReviewerName: string updatedAt: string } export type OngoingWorkReviewProjectPage = { items: OngoingWorkReviewProjectListItem[] pagination: { page: number pageSize: number total: number totalPages: number } } export type FirstPassReviewProjectListItem = { id: string title: string /** Present in API responses; optional for compatibility with older callers. */ description?: string | null currentStatus: string latestVersion: number submitterName: string managerName: string finalReviewerName: string workDate: string /** Present in API responses; optional for compatibility with older callers. */ createdAt?: string updatedAt: string firstVersionStatus: FirstPassReviewStatus firstVersionSubmittedAt: string firstVersionCompletedAt: string | null /** Actual V1 review decisions without image comments or other large fields. */ firstVersionReviews?: Array<{ reviewerRole: string reviewerName: string decision: string reviewedAt: string }> } export type FirstPassReviewProjectPage = { summary: FirstPassReviewSummary items: FirstPassReviewProjectListItem[] pagination: { page: number pageSize: number total: number totalPages: number } } function getDepartmentDashboardProjectWhere(ctx: Awaited>) { return { submitterEmployee: { is: buildDepartmentDashboardEmployeeWhere(ctx.scopeDepartmentPath), }, } } function getFirstPassReviewWhere(projectWhere: any) { return { version: 1, project: { is: projectWhere }, } } async function getFirstPassReviewSummaryForProjectWhere( projectWhere: any, ): Promise { const [rows, totalApproved] = await Promise.all([ prisma.workReviewVersion.groupBy({ by: ['status'], where: getFirstPassReviewWhere(projectWhere), _count: { _all: true }, }), prisma.workReviewProject.count({ where: { AND: [projectWhere, { status: 'approved' }], }, }), ]) const summary = buildFirstPassReviewSummary(rows.map((row) => ({ status: row.status, count: row._count._all, })), totalApproved) const sync = getWorkReviewDashboardSyncState() return { ...summary, syncStatus: sync.status, lastSyncedAt: sync.lastSyncedAt, syncRowsChecked: sync.rowsChecked, } } /** * 部门看板“首轮通过”的真实统计口径。 * * 分子仅包含 V1 状态为 approved 的作品,分母是当前账号明确部门路径范围内 * 当前状态为 approved 的全部作品。全部 V1 上传数只用于明细总量展示。 */ export async function getFirstPassReviewSummary( session: Session | null, ): Promise { const ctx = await getContext(session) return getFirstPassReviewSummaryForProjectWhere(getDepartmentDashboardProjectWhere(ctx)) } export async function getFirstPassReviewProjects( session: Session | null, params?: { page?: number; pageSize?: number; query?: string }, ): Promise { const ctx = await getContext(session) const page = Math.max(1, Number(params?.page || 1) || 1) const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20)) const query = clean(params?.query) const scopeWhere = getDepartmentDashboardProjectWhere(ctx) const scopeFilters: any[] = [ scopeWhere, { versions: { some: { version: 1 } } }, ] const baseWhere = { AND: scopeFilters } let where: any = baseWhere if (query) { where = { AND: [ baseWhere, { OR: [ { title: { contains: query, mode: 'insensitive' } }, { submitterName: { contains: query, mode: 'insensitive' } }, { managerName: { contains: query, mode: 'insensitive' } }, ], }, ], } } const [summary, total, projects] = await Promise.all([ // Searching and detail-only V1 filters must not change the headline rate. // The denominator is every approved project in the complete department scope. getFirstPassReviewSummaryForProjectWhere(scopeWhere), prisma.workReviewProject.count({ where }), prisma.workReviewProject.findMany({ where, select: { id: true, title: true, description: true, status: true, latestVersion: true, submitterName: true, managerName: true, finalReviewerName: true, workDate: true, createdAt: true, updatedAt: true, versions: { where: { version: 1 }, select: { status: true, submittedAt: true, completedAt: true, reviews: { select: { reviewerRole: true, reviewerName: true, decision: true, reviewedAt: true, }, orderBy: [{ reviewedAt: 'asc' }, { reviewerRole: 'asc' }], }, }, take: 1, }, }, orderBy: [{ updatedAt: 'desc' }, { workDate: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), ]) return { summary, items: projects.map((project) => { const firstVersion = project.versions[0] return { id: project.id, title: project.title, description: project.description, currentStatus: project.status, latestVersion: project.latestVersion, submitterName: project.submitterName, managerName: project.managerName, finalReviewerName: project.finalReviewerName, workDate: project.workDate.toISOString(), createdAt: project.createdAt.toISOString(), updatedAt: project.updatedAt.toISOString(), firstVersionStatus: normalizeFirstPassReviewStatus(firstVersion.status), firstVersionSubmittedAt: firstVersion.submittedAt.toISOString(), firstVersionCompletedAt: firstVersion.completedAt?.toISOString() || null, firstVersionReviews: firstVersion.reviews.map((review) => ({ reviewerRole: review.reviewerRole, reviewerName: review.reviewerName, decision: review.decision, reviewedAt: review.reviewedAt.toISOString(), })), } }), pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)), }, } } /** * 部门看板“进行中项目”的真实数据口径。 * * 当前仓库唯一的项目实体是作品过稿项目,因此这里仅统计仍在评审或待修改的 * WorkReviewProject,并严格限制在登录人的明确部门路径范围内,避免跨部门展示项目。 */ export async function getOngoingWorkReviewProjects( session: Session | null, params?: { page?: number; pageSize?: number; query?: string }, ): Promise { const ctx = await getContext(session) const page = Math.max(1, Number(params?.page || 1) || 1) const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20)) const query = clean(params?.query) const filters: any[] = [ getDepartmentDashboardProjectWhere(ctx), { status: { in: [VERSION_STATUS.PENDING_REVIEW, VERSION_STATUS.REVISION_REQUIRED] } }, ] if (query) { filters.push({ OR: [ { title: { contains: query, mode: 'insensitive' } }, { submitterName: { contains: query, mode: 'insensitive' } }, { managerName: { contains: query, mode: 'insensitive' } }, ], }) } const where = { AND: filters } const [total, projects] = await prisma.$transaction([ prisma.workReviewProject.count({ where }), prisma.workReviewProject.findMany({ where, select: { id: true, title: true, description: true, status: true, workDate: true, latestVersion: true, submitterName: true, managerName: true, finalReviewerName: true, updatedAt: true, }, orderBy: [{ updatedAt: 'desc' }, { workDate: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), ]) return { items: projects.map((project) => ({ ...project, status: project.status as OngoingWorkReviewProjectListItem['status'], workDate: project.workDate.toISOString(), updatedAt: project.updatedAt.toISOString(), })), pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)), }, } } function getStoredWorkReviewAssetUrls(value: unknown) { const payload = serializeWorkReviewImagePayload(value) const groups = [...payload.imageGroups, ...payload.referenceImageGroups] return Array.from(new Set(groups.flatMap((group) => [ group.previewUrl, ...group.slices.map((slice) => slice.url), ]).filter(Boolean))) } export async function deleteWorkReviewProject(session: Session | null, projectId: string) { const ctx = await getContext(session) if (!canDeleteWorkReviewProject(ctx.user)) { throw new WorkReviewApiError('只有无邪可以删除作品过稿记录', 403) } const id = clean(projectId) if (!id) throw new WorkReviewApiError('作品参数无效') const project = await prisma.workReviewProject.findUnique({ where: { id }, select: { id: true, title: true, versions: { select: { imageUrls: true } }, }, }) if (!project) throw new WorkReviewApiError('作品不存在', 404) const result = await prisma.workReviewProject.deleteMany({ where: { id } }) if (result.count !== 1) { throw new WorkReviewApiError('作品已被删除,请刷新看板', 409) } const assetUrls = Array.from(new Set(project.versions.flatMap((version) => ( getStoredWorkReviewAssetUrls(version.imageUrls) )))) const cleanupResults = await Promise.allSettled(assetUrls.map((url) => deleteFromS3(url))) const cleanupFailures = cleanupResults.filter((item) => item.status === 'rejected') if (cleanupFailures.length > 0) { console.error(`[work-review] 作品 ${id} 已删除,但有 ${cleanupFailures.length} 个图片对象清理失败`) } return { id: project.id, title: project.title } } export async function getWorkReviewReviewerOptions(session: Session | null) { const ctx = await getContext(session) if (!ctx.employee) { throw new WorkReviewApiError('当前账号尚未匹配考核人员档案,无法选择评审人', 403) } const finalReviewer = await resolveFinalReviewer() const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { id: { notIn: [ctx.employee.id, finalReviewer.employee.id] } }, ], }, select: { id: true, dingtalkUserId: true, dingEmail: true, jobNumber: true, flowerName: true, realName: true, displayName: true, departmentName: true, positionTitle: true, sourcePayload: true, }, orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }], take: 1000, }) const excludedUserIds = new Set([ ctx.user.id.toLowerCase(), ...(finalReviewer.userId ? [finalReviewer.userId.toLowerCase()] : []), ]) const excludedIdentityKeys = new Set([ ...getWorkReviewEmployeeIdentityKeys(ctx.employee), ...getWorkReviewEmployeeIdentityKeys(finalReviewer.employee), ]) const excludedAliasKeys = new Set([ ...getWorkReviewEmployeeAliasKeys(ctx.employee), ...getWorkReviewEmployeeAliasKeys(finalReviewer.employee), ]) const seenIdentityKeys = new Set() return employees.filter((employee) => { if (getWorkReviewEmployeeMaterialUserIds(employee).some((id) => excludedUserIds.has(id))) { return false } const identityKeys = getWorkReviewEmployeeIdentityKeys(employee) const aliasKeys = getWorkReviewEmployeeAliasKeys(employee) if (aliasKeys.some((key) => excludedAliasKeys.has(key))) { return false } if (identityKeys.some((key) => excludedIdentityKeys.has(key) || seenIdentityKeys.has(key))) { return false } identityKeys.forEach((key) => seenIdentityKeys.add(key)) return true }).map((employee) => ({ id: employee.id, name: getEmployeeName(employee), jobNumber: employee.jobNumber, departmentName: employee.departmentName, positionTitle: employee.positionTitle, })) } async function readWebpDimensions(filePath: string) { const handle = await open(filePath, 'r') try { const header = Buffer.alloc(64) const { bytesRead } = await handle.read(header, 0, header.length, 0) return readPerformanceSurveyWebpDimensions(header.subarray(0, bytesRead)) } finally { await handle.close() } } async function resolveProcessedUpload( declaration: PerformanceSurveyProcessedImageDeclaration, userId: string, ): Promise { const uploadSession = await getOwnedUploadSession(declaration.uploadId, userId) if (!uploadSession) throw new WorkReviewApiError('图片上传已过期,请重新选择图片') if (uploadSession.fileSize > PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxArtifactBytes) { throw new WorkReviewApiError('单张预处理图片不能超过 8MB') } const filePath = await getOwnedUploadedFile(declaration.uploadId, userId) if (!filePath) throw new WorkReviewApiError(`图片「${declaration.sourceName}」上传不完整,请重试`) return { declaration, filePath, fileSize: uploadSession.fileSize } } type ValidatedWorkReviewProcessedImageGroups = ReturnType< typeof validatePerformanceSurveyProcessedImages > type WorkReviewPersistenceOutcome = | 'not_attempted' | 'attempted' | 'not_committed' | 'committed' | 'unknown' function validateStoredImages( processedImages: unknown, options: { optional?: boolean } = {}, ): ValidatedWorkReviewProcessedImageGroups { if (options.optional && (!Array.isArray(processedImages) || processedImages.length === 0)) { return [] } let groups: ReturnType try { groups = validatePerformanceSurveyProcessedImages(processedImages, { maxSliceWidth: 3_200 }) } catch (error) { if (error instanceof PerformanceSurveyProcessedImageError) { throw new WorkReviewApiError(error.message) } throw error } return groups } function getProcessedImageDeclarations(groups: ValidatedWorkReviewProcessedImageGroups) { return groups.flatMap((group) => [ ...group.slices, ...(group.preview ? [group.preview] : []), ]) } function getLockedUploadIds( workGroups: ValidatedWorkReviewProcessedImageGroups, referenceGroups: ValidatedWorkReviewProcessedImageGroups, ) { const uploadIds = [ ...getProcessedImageDeclarations(workGroups), ...getProcessedImageDeclarations(referenceGroups), ].map((declaration) => declaration.uploadId) if (new Set(uploadIds).size !== uploadIds.length) { throw new WorkReviewApiError('作品图和参考图不能复用同一图片上传凭证') } return uploadIds.sort() } async function resolveStoredImageGroups( groups: ValidatedWorkReviewProcessedImageGroups, userId: string, ) { const resolvedByUploadId = new Map() let totalBytes = 0 for (const declaration of getProcessedImageDeclarations(groups)) { const resolved = await resolveProcessedUpload(declaration, userId) totalBytes += resolved.fileSize if (totalBytes > PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxBatchBytes) { throw new WorkReviewApiError('本次作品的预处理图片总大小不能超过 256MB') } const dimensions = await readWebpDimensions(resolved.filePath) if (dimensions.width !== declaration.width || dimensions.height !== declaration.height) { throw new WorkReviewApiError(`图片「${declaration.sourceName}」尺寸校验失败,请重新上传`) } resolvedByUploadId.set(declaration.uploadId, resolved) } return resolvedByUploadId } async function uploadStoredImageGroups(input: { groups: ValidatedWorkReviewProcessedImageGroups resolvedByUploadId: Map projectId: string versionId: string assetKind: 'work' | 'reference' uploadedUrls: Set }) { const imageGroups: PerformanceSurveyStoredImageGroup[] = [] for (const group of input.groups) { const slices: PerformanceSurveyStoredImageGroup['slices'] = [] for (const declaration of group.slices) { const resolved = input.resolvedByUploadId.get(declaration.uploadId)! const fileName = [ 'work-review', input.projectId, input.versionId, input.assetKind, `${group.sourceIndex + 1}-${declaration.sliceIndex + 1}.webp`, ].join('/') const url = await uploadFileToS3(resolved.filePath, fileName, declaration.mimeType) input.uploadedUrls.add(url) slices.push({ url, width: declaration.width, height: declaration.height }) } const firstSlice = slices[0] let previewUrl = firstSlice.url let previewWidth = firstSlice.width let previewHeight = firstSlice.height if (group.preview) { const resolved = input.resolvedByUploadId.get(group.preview.uploadId)! const fileName = [ 'work-review', input.projectId, input.versionId, input.assetKind, `${group.sourceIndex + 1}-preview.webp`, ].join('/') previewUrl = await uploadFileToS3(resolved.filePath, fileName, group.preview.mimeType) input.uploadedUrls.add(previewUrl) previewWidth = group.preview.width previewHeight = group.preview.height } imageGroups.push({ version: 1, imageId: randomUUID(), sourceName: group.sourceName, sourceWidth: Math.max(...slices.map((slice) => slice.width)), sourceHeight: slices.reduce((sum, slice) => sum + slice.height, 0), width: Math.max(...slices.map((slice) => slice.width)), height: slices.reduce((sum, slice) => sum + slice.height, 0), previewUrl, previewWidth, previewHeight, slices, }) } return imageGroups } async function cleanupConsumedWorkReviewUploadSessions(uploadIds: string[]) { const results = await Promise.allSettled( uploadIds.map((uploadId) => cleanupConsumedUploadSession(uploadId)), ) results.forEach((result, index) => { if (result.status === 'rejected') { console.warn( `[work-review] 作品已保存,但上传会话 ${uploadIds[index]} 清理失败:`, result.reason, ) return } if (!result.value.success) { console.warn( `[work-review] 作品已保存,但上传会话 ${uploadIds[index]} 未能安全清理:`, result.value, ) } }) } async function withStoredImageSets(input: { userId: string projectId: string versionId: string processedImages: unknown processedReferenceImages: unknown task: ( imageGroups: PerformanceSurveyStoredImageGroup[], referenceImageGroups: PerformanceSurveyStoredImageGroup[], ) => Promise confirmCommitted: ( imageGroups: PerformanceSurveyStoredImageGroup[], referenceImageGroups: PerformanceSurveyStoredImageGroup[], ) => Promise }) { const workGroups = validateStoredImages(input.processedImages) const referenceGroups = validateStoredImages(input.processedReferenceImages, { optional: true }) const uploadIds = getLockedUploadIds(workGroups, referenceGroups) const uploadedUrls = new Set() let releaseUploadSessionLocks: MaterialUploadStorageLockRelease | null = null const persistenceOutcome: { state: WorkReviewPersistenceOutcome } = { state: 'not_attempted' } try { releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(uploadIds) const resolvedWorkImages = await resolveStoredImageGroups(workGroups, input.userId) const resolvedReferenceImages = await resolveStoredImageGroups(referenceGroups, input.userId) const imageGroups = await uploadStoredImageGroups({ groups: workGroups, resolvedByUploadId: resolvedWorkImages, projectId: input.projectId, versionId: input.versionId, assetKind: 'work', uploadedUrls, }) const referenceImageGroups = await uploadStoredImageGroups({ groups: referenceGroups, resolvedByUploadId: resolvedReferenceImages, projectId: input.projectId, versionId: input.versionId, assetKind: 'reference', uploadedUrls, }) let result: T persistenceOutcome.state = 'attempted' releaseUploadSessionLocks.assertHeld() try { result = await input.task(imageGroups, referenceImageGroups) persistenceOutcome.state = 'committed' } catch (persistenceError) { let confirmed: T | null try { confirmed = await input.confirmCommitted(imageGroups, referenceImageGroups) } catch (inspectionError) { persistenceOutcome.state = 'unknown' console.error('[work-review] 作品保存异常后无法确认数据库最终状态:', inspectionError) throw new WorkReviewApiError( '作品保存状态暂时无法确认,服务器已保留图片和上传会话,请勿重复提交', 503, ) } if (!confirmed) { persistenceOutcome.state = 'not_committed' throw persistenceError } persistenceOutcome.state = 'committed' result = confirmed } try { releaseUploadSessionLocks.assertHeld() await cleanupConsumedWorkReviewUploadSessions(uploadIds) } catch (cleanupError) { console.warn('[work-review] 作品已保存,但上传会话清理阶段异常:', cleanupError) } return result } catch (error) { if ( persistenceOutcome.state === 'not_attempted' || persistenceOutcome.state === 'not_committed' ) { if (uploadedUrls.size > 0) { try { releaseUploadSessionLocks?.assertHeld() } catch (lockError) { console.error('[work-review] 无法确认上传会话锁仍有效,保留本次 S3 图片:', lockError) throw new WorkReviewApiError( '作品保存状态暂时无法确认,服务器已保留图片和上传会话,请勿重复提交', 503, ) } await Promise.allSettled(Array.from(uploadedUrls, (url) => deleteFromS3(url))) } } throw error } finally { try { await releaseUploadSessionLocks?.() } catch (releaseError) { console.error('[work-review] 释放图片上传会话锁失败:', releaseError) } } } function hasCompleteCommittedWorkReviewVersion( project: any, expected: { projectId: string versionId: string submitterUserId: string versionNumber: number imageGroups: PerformanceSurveyStoredImageGroup[] referenceImageGroups: PerformanceSurveyStoredImageGroup[] }, ) { if ( !project || project.id !== expected.projectId || project.submitterUserId !== expected.submitterUserId || project.status !== VERSION_STATUS.PENDING_REVIEW || project.latestVersion !== expected.versionNumber ) { return false } const version = Array.isArray(project.versions) ? project.versions.find((item: any) => item?.id === expected.versionId) : null if ( !version || version.projectId !== expected.projectId || version.version !== expected.versionNumber || version.status !== VERSION_STATUS.PENDING_REVIEW || !version.submittedAt ) { return false } const actualImages = serializeWorkReviewImagePayload(version.imageUrls) const expectedImages = serializeWorkReviewImagePayload( buildWorkReviewImagePayload(expected.imageGroups, expected.referenceImageGroups), ) return ( JSON.stringify(actualImages.imageGroups) === JSON.stringify(expectedImages.imageGroups) && JSON.stringify(actualImages.referenceImageGroups) === JSON.stringify(expectedImages.referenceImageGroups) ) } function assertCompleteCommittedWorkReviewVersion( project: any, expected: Parameters[1], ) { if (!hasCompleteCommittedWorkReviewVersion(project, expected)) { throw new Error('作品落库记录与本次提交的项目、版本或完整图片组不一致') } } async function queueSubmittedNotifications(project: any, version: any) { const actionUrl = getWorkReviewActionUrl(project.id) const isInitialVersion = Number(version.version) === 1 const submittedDate = isInitialVersion ? new Date(project.workDate).toISOString().slice(0, 10) : new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date(version.submittedAt || Date.now())) const content = [ '### ◆ 作品待评审', '', `**${escapeDingtalkMarkdown(project.title)}**`, '', `**提交人:** ${escapeDingtalkMarkdown(project.submitterName)} `, `**${isInitialVersion ? '作品日期' : '修改提交日期'}:** ${submittedDate} `, `**版本:** V${version.version} `, '', '> 请逐张查看作品图片并填写评审修改建议。', ].join('\n') const notifications = [ queueWorkReviewNotification({ projectId: project.id, versionId: version.id, recipientEmployeeId: project.managerEmployeeId, recipientUserId: project.managerUserId, dingtalkUserId: project.managerDingtalkUserId, recipientName: project.managerName, eventType: 'work_review.version.submitted.manager', businessKey: `work-review-version:${version.id}:manager`, title: '作品过稿看板', content, actionUrl, }), ] if (getRequiredReviewRoles(project).includes(REVIEW_ROLES.FINAL_REVIEWER)) { notifications.push(queueWorkReviewNotification({ projectId: project.id, versionId: version.id, recipientEmployeeId: project.finalReviewerEmployeeId, recipientUserId: project.finalReviewerUserId, dingtalkUserId: project.finalReviewerDingtalkUserId, recipientName: project.finalReviewerName, eventType: 'work_review.version.submitted.final_reviewer', businessKey: `work-review-version:${version.id}:final-reviewer`, title: '作品过稿看板', content, actionUrl, })) } return Promise.all(notifications) } async function queueCompletedNotification(project: any, version: any) { const approved = version.status === VERSION_STATUS.APPROVED const singleReviewerTest = getWorkReviewReviewMode(project) === 'single_reviewer_test' return queueWorkReviewNotification({ projectId: project.id, versionId: version.id, recipientEmployeeId: project.submitterEmployeeId, recipientUserId: project.submitterUserId, dingtalkUserId: project.submitterEmployee?.dingtalkUserId || null, recipientName: project.submitterName, eventType: 'work_review.version.completed', businessKey: `work-review-version:${version.id}:completed`, title: '作品过稿看板', content: [ `### ${approved ? '✓ 作品已通过' : '◆ 评审已完成'}`, '', `**${escapeDingtalkMarkdown(project.title)}**`, '', `**版本:** V${version.version} `, `**评审结果:** ${approved ? (singleReviewerTest ? '指定评审人已通过' : '指定评审人与元泽均已通过') : '请按评审建议修改后再次提交'} `, '', approved ? '> 本轮作品已完成过稿。' : '> 点击下方按钮查看每张图的修改建议。', ].join('\n'), actionUrl: getWorkReviewActionUrl(project.id), }) } async function queueNotificationsSafely(task: () => Promise) { try { await task() } catch (error) { console.error('[work-review] 业务已保存,但通知排队失败,将由运维检查补发:', error) } } function ensureCanSubmit(ctx: Awaited>) { if (!ctx.employee) throw new WorkReviewApiError('当前账号尚未匹配考核人员档案,无法提交作品', 403) } export async function createWorkReviewProject(session: Session | null, body: any) { const ctx = await getContext(session) ensureCanSubmit(ctx) const title = clean(body?.title) if (!title) throw new WorkReviewApiError('请填写作品名称') if (title.length > 120) throw new WorkReviewApiError('作品名称不能超过 120 个字') const description = clean(body?.description) const submissionNote = clean(body?.submissionNote) const workDate = parseWorkDate(body?.workDate) const finalReviewer = await resolveFinalReviewer() const manager = await resolveSelectedReviewer( body?.reviewerEmployeeId, ctx.employee, ctx.user.id, finalReviewer, ) const projectId = randomUUID() const versionId = randomUUID() const created = await withStoredImageSets({ userId: ctx.user.id, projectId, versionId, processedImages: body?.processedImages, processedReferenceImages: body?.processedReferenceImages, task: async (imageGroups, referenceImageGroups) => prisma.workReviewProject.create({ data: { id: projectId, title, description, workDate, submitterUserId: ctx.user.id, submitterEmployeeId: ctx.employee.id, submitterName: getEmployeeName(ctx.employee), submitterSnapshot: getEmployeeSnapshot(ctx.employee), managerEmployeeId: manager.employee.id, managerUserId: manager.userId, managerDingtalkUserId: manager.dingtalkUserId, managerName: manager.name, finalReviewerEmployeeId: finalReviewer.employee.id, finalReviewerUserId: finalReviewer.userId, finalReviewerDingtalkUserId: finalReviewer.dingtalkUserId, finalReviewerName: finalReviewer.name, versions: { create: { id: versionId, version: 1, submissionNote, imageUrls: buildWorkReviewImagePayload(imageGroups, referenceImageGroups) as any, }, }, }, include: { submitterEmployee: true, versions: { include: { reviews: true } }, }, }), confirmCommitted: async (imageGroups, referenceImageGroups) => { const persisted = await prisma.workReviewProject.findUnique({ where: { id: projectId }, include: { submitterEmployee: true, versions: { include: { reviews: true }, orderBy: { version: 'desc' }, }, }, }) if (!persisted) return null assertCompleteCommittedWorkReviewVersion(persisted, { projectId, versionId, submitterUserId: ctx.user.id, versionNumber: 1, imageGroups, referenceImageGroups, }) return persisted }, }) const createdVersion = created.versions.find((item: any) => item.id === versionId) if (!createdVersion) throw new WorkReviewApiError('作品版本创建失败', 500) await queueNotificationsSafely(() => queueSubmittedNotifications(created, createdVersion)) return serializeProject(ctx, created) } export async function createWorkReviewVersion(session: Session | null, projectId: string, body: any) { const ctx = await getContext(session) ensureCanSubmit(ctx) const expectedLatestVersion = Number(body?.expectedLatestVersion) if (!Number.isInteger(expectedLatestVersion) || expectedLatestVersion < 1) { throw new WorkReviewApiError('修改版本信息无效,请刷新后重试') } const existing = await prisma.workReviewProject.findUnique({ where: { id: projectId }, include: { versions: { orderBy: { version: 'desc' }, take: 1 } }, }) if (!existing) throw new WorkReviewApiError('作品不存在', 404) if (existing.submitterUserId !== ctx.user.id) throw new WorkReviewApiError('只能重新提交自己的作品', 403) if (existing.latestVersion !== expectedLatestVersion) { throw new WorkReviewApiError('作品版本已更新,请刷新后重新提交', 409) } if (existing.status !== VERSION_STATUS.REVISION_REQUIRED) { throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409) } const versionId = randomUUID() const submissionNote = clean(body?.submissionNote) const expectedVersionNumber = expectedLatestVersion + 1 const updated = await withStoredImageSets({ userId: ctx.user.id, projectId, versionId, processedImages: body?.processedImages, processedReferenceImages: body?.processedReferenceImages, task: async (imageGroups, referenceImageGroups) => prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw>` SELECT "status", "latest_version" FROM "work_review_projects" WHERE "id" = ${projectId} FOR UPDATE ` if (!locked[0]) throw new WorkReviewApiError('作品不存在', 404) if (locked[0].status !== VERSION_STATUS.REVISION_REQUIRED) { throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409) } if (locked[0].latest_version !== expectedLatestVersion) { throw new WorkReviewApiError('作品版本已更新,请刷新后重新提交', 409) } const version = locked[0].latest_version + 1 await tx.workReviewVersion.create({ data: { id: versionId, projectId, version, submissionNote, imageUrls: buildWorkReviewImagePayload(imageGroups, referenceImageGroups) as any, }, }) return tx.workReviewProject.update({ where: { id: projectId }, data: { status: VERSION_STATUS.PENDING_REVIEW, latestVersion: version, }, include: { submitterEmployee: true, versions: { include: { reviews: true }, orderBy: { version: 'desc' }, }, }, }) }), confirmCommitted: async (imageGroups, referenceImageGroups) => { const persisted = await prisma.workReviewProject.findUnique({ where: { id: projectId }, include: { submitterEmployee: true, versions: { include: { reviews: true }, orderBy: { version: 'desc' }, }, }, }) if (!persisted) return null const persistedVersion = Array.isArray(persisted.versions) ? persisted.versions.find((item: any) => item?.id === versionId) : null if (!persistedVersion) return null assertCompleteCommittedWorkReviewVersion(persisted, { projectId, versionId, submitterUserId: ctx.user.id, versionNumber: expectedVersionNumber, imageGroups, referenceImageGroups, }) return persisted }, }) const version = updated.versions.find((item) => item.id === versionId) if (!version) throw new WorkReviewApiError('新版本创建失败', 500) await queueNotificationsSafely(() => queueSubmittedNotifications(updated, version)) return serializeProject(ctx, updated) } function readImageComments(value: unknown, imageGroups: PerformanceSurveyStoredImageGroup[]) { if (!Array.isArray(value)) throw new WorkReviewApiError('请填写每张图的评审建议') const byIndex = new Map() for (const item of value) { const imageIndex = Number(item?.imageIndex) if (!Number.isInteger(imageIndex) || imageIndex < 0 || imageIndex >= imageGroups.length || byIndex.has(imageIndex)) { throw new WorkReviewApiError('评审图片序号无效') } byIndex.set(imageIndex, item) } let annotationCount = 0 return imageGroups.map((group, imageIndex): ImageCommentInput & { annotations: NormalizedWorkReviewAnnotation[] } => { const item = byIndex.get(imageIndex) if (!item) throw new WorkReviewApiError(`请填写图片「${group.sourceName}」的评审建议`) const decision = clean(item.decision) if (decision !== VERSION_STATUS.APPROVED && decision !== VERSION_STATUS.REVISION_REQUIRED) { throw new WorkReviewApiError(`请选择图片「${group.sourceName}」的评审结论`) } const comment = clean(item.comment) if (!comment) throw new WorkReviewApiError(`请填写图片「${group.sourceName}」的评审建议`) if (comment.length > 2000) throw new WorkReviewApiError('单张图片的评审建议不能超过 2000 个字') const annotationResult = validateWorkReviewAnnotations(item.annotations) if (!annotationResult.ok) throw new WorkReviewApiError(annotationResult.error) annotationCount += annotationResult.annotations.length if (annotationCount > 200) throw new WorkReviewApiError('单次评审最多添加 200 个批注') return { imageId: group.imageId || `image-${imageIndex + 1}`, imageIndex, sourceName: group.sourceName, decision, comment, annotations: annotationResult.annotations, } }) } export async function submitWorkReview(session: Session | null, versionId: string, body: any) { const ctx = await getContext(session) if (!ctx.employee) throw new WorkReviewApiError('当前账号尚未匹配考核人员档案', 403) const role = normalizeReviewRole(body?.reviewerRole) const version = await prisma.workReviewVersion.findUnique({ where: { id: versionId }, include: { project: { include: { submitterEmployee: true }, }, reviews: true, }, }) if (!version) throw new WorkReviewApiError('作品版本不存在', 404) if (version.status !== VERSION_STATUS.PENDING_REVIEW) { throw new WorkReviewApiError('该版本评审已经结束', 409) } const requiredReviewRoles = getRequiredReviewRoles(version.project) if (!requiredReviewRoles.includes(role)) { throw new WorkReviewApiError('当前测试流程已跳过元泽评审', 409) } if (!canReviewRole(ctx, version.project, role)) { throw new WorkReviewApiError(role === REVIEW_ROLES.MANAGER ? '无权填写指定评审人区域' : '无权填写元泽评审', 403) } const imageGroups = serializeWorkReviewImagePayload(version.imageUrls).imageGroups const imageComments = readImageComments(body?.imageComments, imageGroups) const generalComment = clean(body?.generalComment) if (generalComment && generalComment.length > 4000) { throw new WorkReviewApiError('总体意见不能超过 4000 个字') } const decision = deriveWorkReviewDecision(imageComments) const result = await prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw>` SELECT "status" FROM "work_review_versions" WHERE "id" = ${versionId} FOR UPDATE ` if (!locked[0]) throw new WorkReviewApiError('作品版本不存在', 404) if (locked[0].status !== VERSION_STATUS.PENDING_REVIEW) { throw new WorkReviewApiError('该版本评审已经结束', 409) } const duplicate = await tx.workReviewReview.findUnique({ where: { versionId_reviewerRole: { versionId, reviewerRole: role } }, }) if (duplicate) throw new WorkReviewApiError('该评审区域已经提交,不能重复提交', 409) await tx.workReviewReview.create({ data: { id: randomUUID(), versionId, reviewerRole: role, reviewerEmployeeId: ctx.employee.id, reviewerUserId: ctx.user.id, reviewerName: getEmployeeName(ctx.employee), reviewerSnapshot: getEmployeeSnapshot(ctx.employee), decision, imageComments: imageComments as any, generalComment, }, }) const reviews = await tx.workReviewReview.findMany({ where: { versionId } }) const finalStatus = deriveCompletedWorkReviewStatus(reviews as Array<{ reviewerRole: string decision: 'approved' | 'revision_required' }>, requiredReviewRoles) if (!finalStatus) return { completed: false, status: VERSION_STATUS.PENDING_REVIEW } const completedAt = new Date() await tx.workReviewVersion.update({ where: { id: versionId }, data: { status: finalStatus, completedAt }, }) await tx.workReviewProject.update({ where: { id: version.projectId }, data: { status: finalStatus }, }) return { completed: true, status: finalStatus, completedAt } }) if (result.completed) { await queueNotificationsSafely(() => queueCompletedNotification(version.project, { ...version, status: result.status, completedAt: result.completedAt, })) } const refreshed = await prisma.workReviewProject.findUnique({ where: { id: version.projectId }, include: { submitterEmployee: true, versions: { include: { reviews: true }, orderBy: { version: 'desc' }, }, }, }) if (!refreshed) throw new WorkReviewApiError('作品不存在', 404) return serializeProject(ctx, refreshed) }