import { randomUUID } from 'crypto' import { prisma } from '@/lib/prisma' import { getDingtalkPerformanceSurveyNotificationChannelForUser, sendDingtalkRobotOtoMessage, 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', PROCESSING: 'processing', RETRYING: 'retrying', SUCCEEDED: 'succeeded', EXHAUSTED: 'exhausted', SKIPPED: 'skipped', } as const 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 versionId: string recipientEmployeeId?: string | null recipientUserId?: string | null dingtalkUserId?: string | null recipientName: string eventType: string businessKey: string title: string content: string actionUrl?: string | null } let workerStarted = false let recoveryTimer: NodeJS.Timeout | null = null let reminderTimer: NodeJS.Timeout | null = null const deliveryTimers = new Map() function clean(value: unknown) { const text = String(value ?? '').trim() return text || null } async function resolveDingtalkUserId(notification: { dingtalkUserId?: string | null recipientUserId?: string | null recipientEmployeeId?: string | null }) { const current = clean(notification.dingtalkUserId) if (current) return current if (notification.recipientUserId) { const user = await prisma.user.findUnique({ where: { id: notification.recipientUserId }, select: { dingtalkUserId: true }, }) const userDingtalkUserId = clean(user?.dingtalkUserId) if (userDingtalkUserId) return userDingtalkUserId } if (notification.recipientEmployeeId) { const employee = await prisma.performanceSurveyEmployeeProfile.findUnique({ where: { id: notification.recipientEmployeeId }, select: { dingtalkUserId: true, dingEmail: true }, }) const employeeDingtalkUserId = clean(employee?.dingtalkUserId) if (employeeDingtalkUserId) return employeeDingtalkUserId if (employee?.dingEmail) { const user = await prisma.user.findUnique({ where: { email: employee.dingEmail }, select: { dingtalkUserId: true }, }) return clean(user?.dingtalkUserId) } } return null } function scheduleNotification(notificationId: string, delayMs = 0) { const previous = deliveryTimers.get(notificationId) if (previous) clearTimeout(previous) const timer = setTimeout(() => { deliveryTimers.delete(notificationId) void processWorkReviewNotification(notificationId) }, Math.max(0, delayMs)) timer.unref?.() deliveryTimers.set(notificationId, timer) } async function processWorkReviewNotification(notificationId: string) { const claimed = await prisma.workReviewNotification.updateMany({ where: { id: notificationId, status: { in: [STATUS.PENDING, STATUS.RETRYING] }, }, data: { status: STATUS.PROCESSING }, }) if (claimed.count === 0) return null 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({ where: { id: notificationId }, data: { status: STATUS.SKIPPED, lastError: '收件人缺少钉钉 userId', nextRetryAt: null, completedAt: new Date(), }, }) return { success: false, status: STATUS.SKIPPED } } if (dingtalkUserId !== notification.dingtalkUserId) { await prisma.workReviewNotification.update({ where: { id: notificationId }, data: { dingtalkUserId }, }) } try { const providerType = getDingtalkPerformanceSurveyNotificationChannelForUser(dingtalkUserId) const input = { userIds: [dingtalkUserId], title: notification.title, text: notification.content, actionUrl: notification.actionUrl, actionTitle: '查看作品', } const result = providerType === 'notification_center' ? await submitYouyidianNotification({ idempotencyKey: `${notification.eventType}:${notification.businessKey}`, recipientUserId: dingtalkUserId, title: notification.title, content: notification.content, actionUrl: notification.actionUrl || '', actionTitle: '查看作品', }) : providerType === 'robot_oto' ? await sendDingtalkRobotOtoMessage(input) : await sendDingtalkWorkNotice(input) if ('skipped' in result && result.skipped) { await prisma.workReviewNotification.update({ where: { id: notificationId }, data: { status: STATUS.SKIPPED, providerType, lastError: result.reason || '钉钉通知未发送', nextRetryAt: null, completedAt: new Date(), }, }) return { success: false, status: STATUS.SKIPPED } } const providerMessageId = providerType === 'notification_center' ? ('id' in result ? result.id || null : null) : providerType === 'robot_oto' ? ('processQueryKey' in result ? result.processQueryKey || null : null) : ('taskId' in result ? result.taskId || result.requestId || null : null) await prisma.workReviewNotification.update({ where: { id: notificationId }, data: { status: STATUS.SUCCEEDED, attemptCount: { increment: 1 }, providerType, providerMessageId, lastError: null, sentAt: new Date(), completedAt: new Date(), nextRetryAt: null, }, }) return { success: true, status: STATUS.SUCCEEDED } } catch (error) { const nextAttempt = notification.attemptCount + 1 const exhausted = nextAttempt >= notification.maxAttempts const retryDelayMs = RETRY_DELAYS_MS[Math.min(nextAttempt, RETRY_DELAYS_MS.length - 1)] await prisma.workReviewNotification.update({ where: { id: notificationId }, data: { status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING, attemptCount: { increment: 1 }, lastError: error instanceof Error ? error.message : '钉钉通知发送失败', nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelayMs), completedAt: exhausted ? new Date() : null, }, }) if (!exhausted) scheduleNotification(notificationId, retryDelayMs) return { success: false, status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING } } } 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({ where: { status: STATUS.PROCESSING, updatedAt: { lt: staleBefore }, }, data: { status: STATUS.RETRYING, nextRetryAt: new Date(), lastError: '通知处理超时,已自动重试', }, }) const due = await prisma.workReviewNotification.findMany({ where: { status: { in: [STATUS.PENDING, STATUS.RETRYING] }, OR: [ { nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }, ], }, select: { id: true }, orderBy: { createdAt: 'asc' }, take: 20, }) due.forEach(({ id }) => scheduleNotification(id)) } export function ensureWorkReviewNotificationWorkerStarted() { if (workerStarted) return workerStarted = true void recoverDueNotifications().catch((error) => { console.error('[work-review] 恢复待发送通知失败:', error) }) recoveryTimer = setInterval(() => { void recoverDueNotifications().catch((error) => { console.error('[work-review] 恢复待发送通知失败:', error) }) }, 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) { ensureWorkReviewNotificationWorkerStarted() const notification = await prisma.workReviewNotification.upsert({ where: { eventType_businessKey: { eventType: input.eventType, businessKey: input.businessKey, }, }, update: {}, create: { id: randomUUID(), projectId: input.projectId, versionId: input.versionId, recipientEmployeeId: input.recipientEmployeeId || null, recipientUserId: input.recipientUserId || null, dingtalkUserId: input.dingtalkUserId || null, recipientName: input.recipientName, eventType: input.eventType, businessKey: input.businessKey, title: input.title, content: input.content, actionUrl: input.actionUrl || null, status: STATUS.PENDING, maxAttempts: MAX_ATTEMPTS, nextRetryAt: new Date(), }, select: { id: true, status: true }, }) if ([STATUS.PENDING, STATUS.RETRYING].includes(notification.status as any)) { scheduleNotification(notification.id) } return notification.id }