gongxiangp2/lib/work-review-service.ts

1723 lines
58 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 } 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<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
}
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<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)),
},
}
}
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<ReturnType<typeof getContext>>) {
return {
submitterEmployee: {
is: buildDepartmentDashboardEmployeeWhere(ctx.scopeDepartmentPath),
},
}
}
function getFirstPassReviewWhere(projectWhere: any) {
return {
version: 1,
project: { is: projectWhere },
}
}
async function getFirstPassReviewSummaryForProjectWhere(
projectWhere: any,
): Promise<FirstPassReviewSummary> {
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<FirstPassReviewSummary> {
// 首轮通过是品牌中心全量作品指标,不按登录人的部门路径缩小分母。
// getContext 仍负责会话校验和作品过稿通知 worker 的启动,但其部门范围
// 只适用于“进行中项目”等部门视图,不适用于这个全量作品指标。
await getContext(session)
return getFirstPassReviewSummaryForProjectWhere({})
}
export async function getFirstPassReviewProjects(
session: Session | null,
params?: { page?: number; pageSize?: number; query?: string },
): Promise<FirstPassReviewProjectPage> {
// 明细与卡片使用同一套品牌中心全量作品口径;搜索和分页只缩小明细列表。
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 = {}
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<OngoingWorkReviewProjectPage> {
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<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 }
}
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<typeof validatePerformanceSurveyProcessedImages>
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<string, ResolvedProcessedImage>()
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<string, ResolvedProcessedImage>
projectId: string
versionId: string
assetKind: 'work' | 'reference'
uploadedUrls: Set<string>
}) {
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<T>(input: {
userId: string
projectId: string
versionId: string
processedImages: unknown
processedReferenceImages: unknown
task: (
imageGroups: PerformanceSurveyStoredImageGroup[],
referenceImageGroups: PerformanceSurveyStoredImageGroup[],
) => Promise<T>
confirmCommitted: (
imageGroups: PerformanceSurveyStoredImageGroup[],
referenceImageGroups: PerformanceSurveyStoredImageGroup[],
) => Promise<T | null>
}) {
const workGroups = validateStoredImages(input.processedImages)
const referenceGroups = validateStoredImages(input.processedReferenceImages, { optional: true })
const uploadIds = getLockedUploadIds(workGroups, referenceGroups)
const uploadedUrls = new Set<string>()
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<typeof hasCompleteCommittedWorkReviewVersion>[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<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 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<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' },
},
},
})
}),
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<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)
}