gongxiangp2/lib/work-review-service.ts
2026-08-05 16:49:54 +08:00

1208 lines
42 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 { randomUUID } from 'crypto'
import { open, rm, unlink } from 'fs/promises'
import path from 'path'
import type { Session } from 'next-auth'
import { prisma } from '@/lib/prisma'
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 {
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 {
deleteOwnedUploadSession,
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'
const REVIEW_ROLES = {
MANAGER: 'manager',
FINAL_REVIEWER: 'final_reviewer',
} as const
const VERSION_STATUS = {
PENDING_REVIEW: 'pending_review',
REVISION_REQUIRED: 'revision_required',
APPROVED: 'approved',
} as const
type ReviewRole = typeof REVIEW_ROLES[keyof typeof REVIEW_ROLES]
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<string, unknown> {
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
}
type WorkReviewReviewMode = 'dual' | 'single_reviewer_test'
function getSingleReviewerTestConfig() {
const projectId = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID)
const managerEmployeeId = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID)
const expiresAt = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT)
if (!projectId || !managerEmployeeId || !expiresAt) return null
const expiresAtMs = Date.parse(expiresAt)
if (!Number.isFinite(expiresAtMs) || Date.now() >= expiresAtMs) return null
return { projectId, managerEmployeeId, expiresAt }
}
function getWorkReviewReviewMode(project: { id: string; managerEmployeeId: string }): WorkReviewReviewMode {
const config = getSingleReviewerTestConfig()
return config
&& project.id === config.projectId
&& project.managerEmployeeId === config.managerEmployeeId
? 'single_reviewer_test'
: 'dual'
}
function getRequiredReviewRoles(project: { id: string; managerEmployeeId: string }): ReviewRole[] {
return getWorkReviewReviewMode(project) === 'single_reviewer_test'
? [REVIEW_ROLES.MANAGER]
: [REVIEW_ROLES.MANAGER, REVIEW_ROLES.FINAL_REVIEWER]
}
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<ResolvedReviewer> {
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<string, unknown>).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<ReturnType<typeof getContext>>) {
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<ReturnType<typeof getContext>>,
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<ReturnType<typeof getContext>>,
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<ReturnType<typeof getContext>>, 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)),
},
}
}
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<string>()
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<ResolvedProcessedImage> {
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 }
}
async function cleanupProcessedUploads(
userId: string,
uploads: Array<{ uploadId: string; filePath: string }>,
) {
await Promise.allSettled(uploads.flatMap(({ uploadId, filePath }) => [
unlink(filePath),
rm(path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId), { recursive: true, force: true }),
deleteOwnedUploadSession(uploadId, userId),
]))
}
async function withStoredImages<T>(input: {
userId: string
projectId: string
versionId: string
assetKind: 'work' | 'reference'
processedImages: unknown
task: (imageGroups: PerformanceSurveyStoredImageGroup[]) => Promise<T>
}) {
let groups: ReturnType<typeof validatePerformanceSurveyProcessedImages>
try {
groups = validatePerformanceSurveyProcessedImages(input.processedImages, { maxSliceWidth: 3_200 })
} catch (error) {
if (error instanceof PerformanceSurveyProcessedImageError) {
throw new WorkReviewApiError(error.message)
}
throw error
}
const uploads: Array<{ uploadId: string; filePath: string }> = []
const uploadedUrls: string[] = []
const resolvedByUploadId = new Map<string, ResolvedProcessedImage>()
try {
let totalBytes = 0
for (const group of groups) {
for (const declaration of [...group.slices, ...(group.preview ? [group.preview] : [])]) {
const resolved = await resolveProcessedUpload(declaration, input.userId)
uploads.push({ uploadId: declaration.uploadId, filePath: resolved.filePath })
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)
}
}
const imageGroups: PerformanceSurveyStoredImageGroup[] = []
for (const group of groups) {
const slices: PerformanceSurveyStoredImageGroup['slices'] = []
for (const declaration of group.slices) {
const resolved = 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)
uploadedUrls.push(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 = 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)
uploadedUrls.push(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 await input.task(imageGroups)
} catch (error) {
await Promise.allSettled(uploadedUrls.map((url) => deleteFromS3(url)))
throw error
} finally {
await cleanupProcessedUploads(input.userId, uploads)
}
}
async function withOptionalStoredImages<T>(input: {
userId: string
projectId: string
versionId: string
assetKind: 'reference'
processedImages: unknown
task: (imageGroups: PerformanceSurveyStoredImageGroup[]) => Promise<T>
}) {
if (!Array.isArray(input.processedImages) || input.processedImages.length === 0) {
return input.task([])
}
return withStoredImages(input)
}
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<unknown>) {
try {
await task()
} catch (error) {
console.error('[work-review] 业务已保存,但通知排队失败,将由运维检查补发:', error)
}
}
function ensureCanSubmit(ctx: Awaited<ReturnType<typeof getContext>>) {
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 withStoredImages({
userId: ctx.user.id,
projectId,
versionId,
assetKind: 'work',
processedImages: body?.processedImages,
task: async (imageGroups) => withOptionalStoredImages({
userId: ctx.user.id,
projectId,
versionId,
assetKind: 'reference',
processedImages: body?.processedReferenceImages,
task: async (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 } },
},
}),
}),
})
await queueNotificationsSafely(() => queueSubmittedNotifications(created, created.versions[0]))
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 updated = await withStoredImages({
userId: ctx.user.id,
projectId,
versionId,
assetKind: 'work',
processedImages: body?.processedImages,
task: async (imageGroups) => withOptionalStoredImages({
userId: ctx.user.id,
projectId,
versionId,
assetKind: 'reference',
processedImages: body?.processedReferenceImages,
task: async (referenceImageGroups) => prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw<Array<{ status: string; latest_version: number }>>`
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' },
},
},
})
}),
}),
})
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<number, any>()
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<Array<{ status: string }>>`
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)
}