diff --git a/instrumentation.ts b/instrumentation.ts index 623521a..e7908eb 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -62,5 +62,13 @@ export async function register() { console.error('[Instrumentation] 视频转码启动恢复失败,已保留周期重试:', error) } } + + try { + const { ensureWorkReviewNotificationWorkerStarted } = await import('./lib/work-review-notifications') + ensureWorkReviewNotificationWorkerStarted() + console.log('[Instrumentation] 作品过稿通知与每小时提醒任务已启动') + } catch (error) { + console.error('[Instrumentation] 启动作作品过稿通知任务失败:', error) + } } } diff --git a/lib/__tests__/work-review-review-mode.test.ts b/lib/__tests__/work-review-review-mode.test.ts new file mode 100644 index 0000000..0b36286 --- /dev/null +++ b/lib/__tests__/work-review-review-mode.test.ts @@ -0,0 +1,80 @@ +import { + getPendingWorkReviewRoles, + getRequiredWorkReviewRoles, + getWorkReviewReminderSequence, + WORK_REVIEW_REVIEW_ROLES, +} from '@/lib/work-review-review-mode' + +describe('作品过稿评审模式', () => { + const originalEnv = { ...process.env } + + afterEach(() => { + process.env = { ...originalEnv } + jest.restoreAllMocks() + }) + + test('正常作品需要指定评审人和元泽共同评审', () => { + delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID + delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID + delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT + + expect(getRequiredWorkReviewRoles({ id: 'project-1', managerEmployeeId: 'manager-1' })).toEqual([ + WORK_REVIEW_REVIEW_ROLES.MANAGER, + WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER, + ]) + }) + + test('有效测试配置仅提醒指定评审人', () => { + process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID = 'project-1' + process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID = 'manager-1' + process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT = '2099-01-01T00:00:00.000Z' + + expect(getRequiredWorkReviewRoles({ id: 'project-1', managerEmployeeId: 'manager-1' })).toEqual([ + WORK_REVIEW_REVIEW_ROLES.MANAGER, + ]) + }) + + test('过期测试配置恢复双人评审', () => { + process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID = 'project-1' + process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID = 'manager-1' + process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT = '2020-01-01T00:00:00.000Z' + + expect(getRequiredWorkReviewRoles({ id: 'project-1', managerEmployeeId: 'manager-1' })).toHaveLength(2) + }) + + test('每小时提醒只保留尚未操作的评审人', () => { + delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID + delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID + delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT + + expect(getPendingWorkReviewRoles( + { id: 'project-1', managerEmployeeId: 'manager-1' }, + [WORK_REVIEW_REVIEW_ROLES.MANAGER], + )).toEqual([WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER]) + expect(getPendingWorkReviewRoles( + { id: 'project-1', managerEmployeeId: 'manager-1' }, + [WORK_REVIEW_REVIEW_ROLES.MANAGER, WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER], + )).toEqual([]) + }) + + test('提交满一小时后才进入对应的小时提醒序号', () => { + const submittedAt = new Date('2026-08-13T00:00:00.000Z') + + expect(getWorkReviewReminderSequence( + submittedAt, + new Date('2026-08-13T00:59:59.999Z'), + )).toBeNull() + expect(getWorkReviewReminderSequence( + submittedAt, + new Date('2026-08-13T01:00:00.000Z'), + )).toBe(1) + expect(getWorkReviewReminderSequence( + submittedAt, + new Date('2026-08-13T01:59:59.999Z'), + )).toBe(1) + expect(getWorkReviewReminderSequence( + submittedAt, + new Date('2026-08-13T02:00:00.000Z'), + )).toBe(2) + }) +}) diff --git a/lib/work-review-notifications.ts b/lib/work-review-notifications.ts index 2c330a4..5f5cd87 100644 --- a/lib/work-review-notifications.ts +++ b/lib/work-review-notifications.ts @@ -6,6 +6,16 @@ import { sendDingtalkWorkNotice, } from '@/lib/dingtalk-client' import { submitYouyidianNotification } from '@/lib/youyidian-notification-center' +import { getPublicAppOrigin } from '@/lib/public-app-url' +import { buildDingtalkEntryPath } from '@/lib/safe-redirect' +import { + getPendingWorkReviewRoles, + getRequiredWorkReviewRoles, + getWorkReviewReminderSequence, + WORK_REVIEW_REMINDER_INTERVAL_MS, + WORK_REVIEW_REVIEW_ROLES, + type WorkReviewReviewRole, +} from '@/lib/work-review-review-mode' const STATUS = { PENDING: 'pending', @@ -20,6 +30,7 @@ const MAX_ATTEMPTS = 5 const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000] const RECOVERY_INTERVAL_MS = 60_000 const STALE_PROCESSING_MS = 5 * 60_000 +const REMINDER_EVENT_TYPE_PREFIX = 'work_review.version.reminder.' type QueueWorkReviewNotificationInput = { projectId: string @@ -37,6 +48,7 @@ type QueueWorkReviewNotificationInput = { let workerStarted = false let recoveryTimer: NodeJS.Timeout | null = null +let reminderTimer: NodeJS.Timeout | null = null const deliveryTimers = new Map() function clean(value: unknown) { @@ -102,6 +114,25 @@ async function processWorkReviewNotification(notificationId: string) { const notification = await prisma.workReviewNotification.findUnique({ where: { id: notificationId } }) if (!notification) return null + if (notification.eventType.startsWith(REMINDER_EVENT_TYPE_PREFIX)) { + const reviewerRole = notification.eventType.slice(REMINDER_EVENT_TYPE_PREFIX.length) + const stillPending = reviewerRole === WORK_REVIEW_REVIEW_ROLES.MANAGER + || reviewerRole === WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER + ? await isWorkReviewRoleStillPending(notification.versionId, reviewerRole) + : false + if (!stillPending) { + await prisma.workReviewNotification.update({ + where: { id: notificationId }, + data: { + status: STATUS.SKIPPED, + lastError: '评审人已完成操作,无需发送提醒', + nextRetryAt: null, + completedAt: new Date(), + }, + }) + return { success: false, status: STATUS.SKIPPED } + } + } const dingtalkUserId = await resolveDingtalkUserId(notification) if (!dingtalkUserId) { await prisma.workReviewNotification.update({ @@ -197,6 +228,133 @@ async function processWorkReviewNotification(notificationId: string) { } } +async function isWorkReviewRoleStillPending(versionId: string, role: WorkReviewReviewRole) { + const version = await prisma.workReviewVersion.findUnique({ + where: { id: versionId }, + select: { + status: true, + project: { select: { id: true, managerEmployeeId: true } }, + reviews: { where: { reviewerRole: role }, select: { id: true }, take: 1 }, + }, + }) + return Boolean( + version + && version.status === 'pending_review' + && getRequiredWorkReviewRoles(version.project).includes(role) + && version.reviews.length === 0, + ) +} + +function formatChinaDateTime(value: Date) { + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(value) +} + +function escapeDingtalkMarkdown(value: unknown) { + return String(value ?? '') + .replace(/\\/g, '\\\\') + .replace(/([`*_\[\]#>])/g, '\\$1') + .trim() +} + +function getReminderRecipient(project: { + managerEmployeeId: string + managerUserId: string | null + managerDingtalkUserId: string | null + managerName: string + finalReviewerEmployeeId: string + finalReviewerUserId: string | null + finalReviewerDingtalkUserId: string | null + finalReviewerName: string +}, role: WorkReviewReviewRole) { + return role === WORK_REVIEW_REVIEW_ROLES.MANAGER + ? { + recipientEmployeeId: project.managerEmployeeId, + recipientUserId: project.managerUserId, + dingtalkUserId: project.managerDingtalkUserId, + recipientName: project.managerName, + } + : { + recipientEmployeeId: project.finalReviewerEmployeeId, + recipientUserId: project.finalReviewerUserId, + dingtalkUserId: project.finalReviewerDingtalkUserId, + recipientName: project.finalReviewerName, + } +} + +export async function queueDueWorkReviewReminders(now = new Date()) { + const dueBefore = new Date(now.getTime() - WORK_REVIEW_REMINDER_INTERVAL_MS) + const versions = await prisma.workReviewVersion.findMany({ + where: { + status: 'pending_review', + submittedAt: { lte: dueBefore }, + }, + select: { + id: true, + version: true, + submittedAt: true, + project: { + select: { + id: true, + title: true, + submitterName: true, + managerEmployeeId: true, + managerUserId: true, + managerDingtalkUserId: true, + managerName: true, + finalReviewerEmployeeId: true, + finalReviewerUserId: true, + finalReviewerDingtalkUserId: true, + finalReviewerName: true, + }, + }, + reviews: { select: { reviewerRole: true } }, + }, + orderBy: { submittedAt: 'asc' }, + }) + + const queued: string[] = [] + for (const version of versions) { + const reminderSequence = getWorkReviewReminderSequence(version.submittedAt, now) + if (reminderSequence == null) continue + const pendingRoles = getPendingWorkReviewRoles( + version.project, + version.reviews.map((review: { reviewerRole: string }) => review.reviewerRole), + ) + for (const role of pendingRoles) { + const recipient = getReminderRecipient(version.project, role) + const id = await queueWorkReviewNotification({ + projectId: version.project.id, + versionId: version.id, + ...recipient, + eventType: `${REMINDER_EVENT_TYPE_PREFIX}${role}`, + businessKey: `work-review-version:${version.id}:${role}:reminder:${reminderSequence}`, + title: '作品过稿待评审提醒', + content: [ + '### ◆ 作品仍待您评审', + '', + `**${escapeDingtalkMarkdown(version.project.title)}**`, + '', + `**提交人:** ${escapeDingtalkMarkdown(version.project.submitterName)} `, + `**版本:** V${version.version} `, + `**提交时间:** ${formatChinaDateTime(version.submittedAt)} `, + '', + '> 该作品尚未收到您的评审,请尽快处理。未操作前系统将每小时提醒一次。', + ].join('\n'), + actionUrl: `${getPublicAppOrigin()}${buildDingtalkEntryPath(`/work-review?project=${encodeURIComponent(version.project.id)}`)}`, + }) + queued.push(id) + } + } + return queued +} + async function recoverDueNotifications() { const staleBefore = new Date(Date.now() - STALE_PROCESSING_MS) await prisma.workReviewNotification.updateMany({ @@ -237,6 +395,15 @@ export function ensureWorkReviewNotificationWorkerStarted() { }) }, RECOVERY_INTERVAL_MS) recoveryTimer.unref?.() + reminderTimer = setInterval(() => { + void queueDueWorkReviewReminders().catch((error) => { + console.error('[work-review] 每小时待评审提醒扫描失败:', error) + }) + }, RECOVERY_INTERVAL_MS) + reminderTimer.unref?.() + void queueDueWorkReviewReminders().catch((error) => { + console.error('[work-review] 首次待评审提醒扫描失败:', error) + }) } export async function queueWorkReviewNotification(input: QueueWorkReviewNotificationInput) { diff --git a/lib/work-review-review-mode.ts b/lib/work-review-review-mode.ts new file mode 100644 index 0000000..867c448 --- /dev/null +++ b/lib/work-review-review-mode.ts @@ -0,0 +1,58 @@ +export const WORK_REVIEW_REVIEW_ROLES = { + MANAGER: 'manager', + FINAL_REVIEWER: 'final_reviewer', +} as const + +export type WorkReviewReviewRole = typeof WORK_REVIEW_REVIEW_ROLES[keyof typeof WORK_REVIEW_REVIEW_ROLES] +export type WorkReviewReviewMode = 'dual' | 'single_reviewer_test' +export const WORK_REVIEW_REMINDER_INTERVAL_MS = 60 * 60_000 + +function clean(value: unknown) { + const text = String(value ?? '').trim() + return text || null +} + +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 } +} + +export 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' +} + +export function getRequiredWorkReviewRoles(project: { + id: string + managerEmployeeId: string +}): WorkReviewReviewRole[] { + return getWorkReviewReviewMode(project) === 'single_reviewer_test' + ? [WORK_REVIEW_REVIEW_ROLES.MANAGER] + : [WORK_REVIEW_REVIEW_ROLES.MANAGER, WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER] +} + +export function getPendingWorkReviewRoles( + project: { id: string; managerEmployeeId: string }, + completedRoles: Iterable, +) { + const completed = new Set(completedRoles) + return getRequiredWorkReviewRoles(project).filter((role) => !completed.has(role)) +} + +export function getWorkReviewReminderSequence(submittedAt: Date, now: Date) { + const elapsedMs = now.getTime() - submittedAt.getTime() + if (elapsedMs < WORK_REVIEW_REMINDER_INTERVAL_MS) return null + return Math.floor(elapsedMs / WORK_REVIEW_REMINDER_INTERVAL_MS) +} diff --git a/lib/work-review-service.ts b/lib/work-review-service.ts index 62cbbbf..05681dc 100644 --- a/lib/work-review-service.ts +++ b/lib/work-review-service.ts @@ -14,6 +14,12 @@ 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, @@ -50,10 +56,7 @@ import { type NormalizedWorkReviewAnnotation, } from '@/lib/work-review-policy' -const REVIEW_ROLES = { - MANAGER: 'manager', - FINAL_REVIEWER: 'final_reviewer', -} as const +const REVIEW_ROLES = WORK_REVIEW_REVIEW_ROLES const VERSION_STATUS = { PENDING_REVIEW: 'pending_review', @@ -61,7 +64,7 @@ const VERSION_STATUS = { APPROVED: 'approved', } as const -type ReviewRole = typeof REVIEW_ROLES[keyof typeof REVIEW_ROLES] +type ReviewRole = WorkReviewReviewRole type ResolvedReviewer = { employee: any @@ -140,31 +143,8 @@ function clean(value: unknown) { 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] + return getRequiredWorkReviewRoles(project) } function escapeDingtalkMarkdown(value: unknown) {