import { randomUUID } from 'crypto' import { prisma } from '@/lib/prisma' import { getPublicAppOrigin } from '@/lib/public-app-url' import { buildDingtalkEntryPath } from '@/lib/safe-redirect' import { getDingtalkPerformanceSurveyNotificationChannelForUser, getDingtalkRobotOtoReadResult, getDingtalkWorkNoticeSendResult, sendDingtalkRobotOtoMessage, sendDingtalkWorkNotice, } from '@/lib/dingtalk-client' import { getYouyidianNotificationStatus, submitYouyidianNotification, } from '@/lib/youyidian-notification-center' 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 REMINDER_INTERVAL_MS = 10 * 60_000 const STALE_PROCESSING_MS = 5 * 60_000 const READ_STATUS_REFRESH_MS = 5 * 60_000 const NOTIFICATION_PROVIDER = { WORK_NOTICE: 'work_notice', ROBOT_OTO: 'robot_oto', NOTIFICATION_CENTER: 'notification_center', } as const type NotificationProvider = typeof NOTIFICATION_PROVIDER[keyof typeof NOTIFICATION_PROVIDER] let workerStarted = false let recoveryTimer: NodeJS.Timeout | null = null let reminderTimer: NodeJS.Timeout | null = null const deliveryTimers = new Map() let readStatusRefreshInFlight: Promise | null = null let missingAssignmentRecoveryInFlight: Promise | null = null type QueueNotificationInput = { surveyId?: string | null targetId?: string | null assignmentId?: string | null recipientEmployeeId?: string | null recipientUserId?: string | null dingtalkUserId?: string | null recipientName: string eventType: string businessKey: string title: string content: string actionUrl?: string | null } function truncateBody(value: string | null | undefined, limit = 2000) { const text = String(value || '') return text.length > limit ? text.slice(0, limit) : text } function getSurveyUrl(path: string) { return `${getPublicAppOrigin()}${buildDingtalkEntryPath(path)}` } function clean(value: unknown) { const normalized = String(value ?? '').trim() return normalized.length > 0 ? normalized : null } function escapeDingtalkMarkdown(value: unknown) { return String(value ?? '') .replace(/\\/g, '\\\\') .replace(/([`*_\[\]#>])/g, '\\$1') .trim() } function buildNotificationCard(input: { icon: string statusLabel: string surveyTitle: string fields: Array<{ label: string; value: string | null | undefined }> note?: string | null }) { const fieldLines = input.fields .filter((field) => clean(field.value)) .map((field) => `**${escapeDingtalkMarkdown(field.label)}:** ${escapeDingtalkMarkdown(field.value)} `) return [ `### ${input.icon} ${escapeDingtalkMarkdown(input.statusLabel)}`, '', `**${escapeDingtalkMarkdown(input.surveyTitle)}**`, '', ...fieldLines, input.note ? `\n> ${escapeDingtalkMarkdown(input.note)}` : null, ].filter((line): line is string => line !== null).join('\n').trim() } function getNotificationActionTitle(eventType: string) { if (eventType === 'survey.assignment.created') return '立即填写' if (eventType === 'survey.assignment.due_reminder') return '继续填写' if (eventType === 'survey.target.confirmation.required') return '确认并提交' if (eventType === 'survey.result.available') return '查看结果' if (eventType.startsWith('survey.creator.')) return '查看结果' return '查看详情' } async function resolveNotificationDingtalkUserId(notification: { dingtalkUserId?: string | null recipientUserId?: string | null recipientEmployeeId?: string | null }) { const current = clean(notification.dingtalkUserId) if (current) return current const recipientUserId = clean(notification.recipientUserId) if (recipientUserId) { const user = await prisma.user.findUnique({ where: { id: recipientUserId }, select: { dingtalkUserId: true }, }) const userDingtalkUserId = clean(user?.dingtalkUserId) if (userDingtalkUserId) return userDingtalkUserId } const recipientEmployeeId = clean(notification.recipientEmployeeId) if (!recipientEmployeeId) return null const employee = await prisma.performanceSurveyEmployeeProfile.findUnique({ where: { id: recipientEmployeeId }, select: { dingtalkUserId: true, dingEmail: true }, }) const employeeDingtalkUserId = clean(employee?.dingtalkUserId) if (employeeDingtalkUserId) return employeeDingtalkUserId const email = clean(employee?.dingEmail) if (!email) return null const user = await prisma.user.findFirst({ where: { email }, select: { dingtalkUserId: true }, }) return clean(user?.dingtalkUserId) } export function ensurePerformanceSurveyNotificationWorkerStarted() { if (workerStarted) return workerStarted = true const bootstrap = setTimeout(() => { void recoverDuePerformanceSurveyNotifications() void recoverMissingSurveyAssignmentNotifications() void queueDuePerformanceSurveyReminders() void refreshPerformanceSurveyNotificationReadStatuses() }, 1000) bootstrap.unref?.() recoveryTimer = setInterval(() => { void recoverDuePerformanceSurveyNotifications() void recoverMissingSurveyAssignmentNotifications() void refreshPerformanceSurveyNotificationReadStatuses() }, RECOVERY_INTERVAL_MS) recoveryTimer.unref?.() reminderTimer = setInterval(() => { void queueDuePerformanceSurveyReminders() }, REMINDER_INTERVAL_MS) reminderTimer.unref?.() } function scheduleNotificationProcessing(notificationId: string, delayMs: number) { const existing = deliveryTimers.get(notificationId) if (existing) { clearTimeout(existing) } const timer = setTimeout(() => { deliveryTimers.delete(notificationId) void processPerformanceSurveyNotification(notificationId) }, Math.max(0, delayMs)) timer.unref?.() deliveryTimers.set(notificationId, timer) } export async function queuePerformanceSurveyNotification(input: QueueNotificationInput) { ensurePerformanceSurveyNotificationWorkerStarted() const existing = await prisma.performanceSurveyNotification.findUnique({ where: { eventType_businessKey: { eventType: input.eventType, businessKey: input.businessKey, }, }, }) if (existing) { return existing.id } const created = await prisma.performanceSurveyNotification.create({ data: { id: randomUUID(), surveyId: input.surveyId || null, targetId: input.targetId || null, assignmentId: input.assignmentId || null, 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 }, }) scheduleNotificationProcessing(created.id, 0) return created.id } export async function queueSurveyAssignmentNotifications(surveyId: string) { const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { assignments: { include: { target: true, evaluatorEmployee: true }, }, }, }) if (!survey || survey.status === 'cancelled') return [] const selfCompletedTargetIds = new Set( survey.assignments .filter((assignment) => { const snapshot = assignment.evaluatorSnapshot as Record | null return ['self', 'self_visual_final'].includes(String(snapshot?.performanceSurveyWorkflowRole || '')) && assignment.status === 'submitted' }) .map((assignment) => assignment.targetId), ) const ids: string[] = [] for (const assignment of survey.assignments) { if (assignment.status !== 'pending') continue const workflowStageKey = clean(assignment.workflowStageKey) const isConfiguredWorkflow = Boolean(workflowStageKey && workflowStageKey !== 'legacy') if (isConfiguredWorkflow && !assignment.releasedAt) continue const snapshot = assignment.evaluatorSnapshot as Record | null const workflowRole = snapshot?.performanceSurveyWorkflowRole if ( !isConfiguredWorkflow && survey.templateCode === 'visual_brand_tone' && !['self', 'self_visual_final'].includes(String(workflowRole || '')) && !selfCompletedTargetIds.has(assignment.targetId) ) continue const isVisualSelfAssignment = isConfiguredWorkflow ? assignment.reviewerSource === 'self' : survey.templateCode === 'visual_brand_tone' && ['self', 'self_visual_final'].includes(String(workflowRole || '')) const targetName = assignment.target.targetName ids.push( await queuePerformanceSurveyNotification({ surveyId: survey.id, targetId: assignment.targetId, assignmentId: assignment.id, recipientEmployeeId: assignment.evaluatorEmployeeId, recipientUserId: assignment.evaluatorUserId, dingtalkUserId: assignment.evaluatorDingtalkUserId, recipientName: assignment.evaluatorName, eventType: 'survey.assignment.created', businessKey: `assignment:${assignment.id}`, title: '考核调研', content: buildNotificationCard({ icon: '◆', statusLabel: '待填写', surveyTitle: survey.title, fields: [ { label: '评价对象', value: targetName }, { label: '截止时间', value: formatDateTime(survey.dueAt) }, ], note: isVisualSelfAssignment ? isConfiguredWorkflow ? '请先完成当前阶段;提交后系统会自动开放后续评审' : '请先完成自评;提交后系统才会通知主管和终审人评分' : '请在截止时间前完成评分', }), actionUrl: getSurveyUrl(`/performance-survey/survey/${survey.id}?targetId=${assignment.targetId}`), }), ) } return ids } export async function recoverMissingSurveyAssignmentNotifications(limit = 20) { ensurePerformanceSurveyNotificationWorkerStarted() if (missingAssignmentRecoveryInFlight) return missingAssignmentRecoveryInFlight missingAssignmentRecoveryInFlight = (async () => { const rows = await prisma.performanceSurveyAssignment.findMany({ where: { status: 'pending', survey: { status: 'open' }, notifications: { none: { eventType: 'survey.assignment.created' }, }, OR: [ { AND: [ { workflowStageKey: { not: 'legacy' } }, { releasedAt: { not: null } }, ], }, { AND: [ { workflowStageKey: 'legacy' }, { OR: [ { survey: { templateCode: { not: 'visual_brand_tone' } } }, { evaluatorSnapshot: { path: ['performanceSurveyWorkflowRole'], equals: 'self' } }, { evaluatorSnapshot: { path: ['performanceSurveyWorkflowRole'], equals: 'self_visual_final' } }, { target: { assignments: { some: { status: 'submitted', OR: [ { evaluatorSnapshot: { path: ['performanceSurveyWorkflowRole'], equals: 'self' } }, { evaluatorSnapshot: { path: ['performanceSurveyWorkflowRole'], equals: 'self_visual_final' } }, ], }, }, }, }, ], }, ], }, ], } as any, select: { surveyId: true }, distinct: ['surveyId'], take: limit, }) let queuedCount = 0 for (const surveyId of Array.from(new Set(rows.map((row) => row.surveyId)))) { queuedCount += (await queueSurveyAssignmentNotifications(surveyId)).length } return queuedCount })() try { return await missingAssignmentRecoveryInFlight } finally { missingAssignmentRecoveryInFlight = null } } export async function queueSurveyTargetConfirmationNotifications(surveyId: string) { const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { targets: { include: { employeeProfile: true }, }, }, }) if (!survey || survey.status === 'cancelled') return [] const creator = await prisma.user.findUnique({ where: { id: survey.creatorUserId }, select: { email: true, dingtalkUserId: true }, }) const creatorDingtalkUserId = clean(creator?.dingtalkUserId) const creatorEmail = clean(creator?.email)?.toLowerCase() || null const visualTemplate = survey.templateSnapshot as { surveyType?: string; requiredWorkItemCount?: number } | null const isVisualSurvey = visualTemplate?.surveyType === 'visual_case' || (!survey.positionGroupId && !survey.templateVersionId && survey.templateCode === 'visual_brand_tone') const requiredWorkItemCount = Math.max(1, Number(visualTemplate?.requiredWorkItemCount || 3)) const ids: string[] = [] for (const target of survey.targets) { if (target.targetType !== 'employee' || !target.employeeProfile) continue const targetDingtalkUserId = clean(target.employeeProfile.dingtalkUserId) const targetEmail = clean(target.employeeProfile.dingEmail)?.toLowerCase() || null const isCreator = Boolean( (survey.creatorEmployeeId && survey.creatorEmployeeId === target.employeeProfileId) || (creatorDingtalkUserId && creatorDingtalkUserId === targetDingtalkUserId) || (creatorEmail && creatorEmail === targetEmail), ) if (isCreator) continue ids.push( await queuePerformanceSurveyNotification({ surveyId: survey.id, targetId: target.id, recipientEmployeeId: target.employeeProfileId, dingtalkUserId: targetDingtalkUserId, recipientName: target.targetName, eventType: 'survey.target.confirmation.required', businessKey: `target-confirmation:${survey.id}:${target.id}`, title: '考核调研', content: buildNotificationCard({ icon: isVisualSurvey ? '🖼️' : '📋', statusLabel: isVisualSurvey ? '待提交' : '待确认', surveyTitle: survey.title, fields: [ { label: '发起人', value: survey.creatorName }, { label: '被考核人', value: target.targetName }, { label: '考核月份', value: formatAssessmentMonth(survey.assessmentMonth, survey.createdAt) }, isVisualSurvey ? { label: '作品要求', value: `${requiredWorkItemCount} 个案例作品` } : { label: '确认事项', value: '被考核人信息' }, ], note: '如信息有误,请联系发起人', }), actionUrl: getSurveyUrl( isVisualSurvey ? `/performance-survey/survey/${survey.id}?targetId=${target.id}` : '/performance-survey?tab=personal', ), }), ) } return ids } export async function queueSurveyResultNotifications(surveyId: string) { const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { targets: { include: { employeeProfile: true } }, }, }) if (!survey || survey.status === 'cancelled') return [] const ids: string[] = [] for (const target of survey.targets) { if (target.targetType !== 'employee' || !target.employeeProfile) continue ids.push( await queuePerformanceSurveyNotification({ surveyId: survey.id, targetId: target.id, recipientEmployeeId: target.employeeProfileId, dingtalkUserId: target.employeeProfile.dingtalkUserId, recipientName: target.targetName, eventType: 'survey.result.available', businessKey: `result:${survey.id}:${target.id}`, title: '考核调研', content: buildNotificationCard({ icon: '📊', statusLabel: '已生成', surveyTitle: survey.title, fields: [{ label: '评价对象', value: target.targetName }], note: '点击下方按钮查看完整结果', }), actionUrl: getSurveyUrl(`/performance-survey/results/${survey.id}?targetId=${target.id}`), }), ) } return ids } export async function queueSurveyCreatorNotification(surveyId: string, eventType = 'survey.creator.completed') { const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { assignments: true, }, }) if (!survey || survey.status === 'cancelled') return null const creator = await prisma.user.findUnique({ where: { id: survey.creatorUserId }, select: { id: true, username: true, realName: true, dingtalkUserId: true }, }) const total = survey.assignments.length const submitted = survey.assignments.filter((assignment) => assignment.status === 'submitted').length return queuePerformanceSurveyNotification({ surveyId: survey.id, recipientUserId: creator?.id || survey.creatorUserId, dingtalkUserId: creator?.dingtalkUserId || null, recipientName: creator?.realName || creator?.username || survey.creatorName, eventType, businessKey: `${eventType}:${survey.id}`, title: '考核调研', content: buildNotificationCard({ icon: eventType === 'survey.creator.closed' ? '⏹️' : '✅', statusLabel: eventType === 'survey.creator.closed' ? '已关闭' : '已完成', surveyTitle: survey.title, fields: [{ label: '提交进度', value: `${submitted}/${total}` }], note: '点击下方按钮查看调研结果', }), actionUrl: getSurveyUrl(`/performance-survey/results/${survey.id}`), }) } export async function queueDuePerformanceSurveyReminders() { ensurePerformanceSurveyNotificationWorkerStarted() const now = new Date() const before = new Date(Date.now() + 24 * 60 * 60 * 1000) const surveys = await prisma.performanceSurvey.findMany({ where: { status: 'open', dueAt: { gt: now, lte: before, }, }, include: { assignments: { where: { status: 'pending', OR: [{ workflowStageKey: 'legacy' }, { releasedAt: { not: null } }], }, include: { target: true }, }, }, }) const ids: string[] = [] for (const survey of surveys) { const dueKey = survey.dueAt ? survey.dueAt.toISOString().slice(0, 10) : 'no-date' for (const assignment of survey.assignments) { ids.push( await queuePerformanceSurveyNotification({ surveyId: survey.id, targetId: assignment.targetId, assignmentId: assignment.id, recipientEmployeeId: assignment.evaluatorEmployeeId, recipientUserId: assignment.evaluatorUserId, dingtalkUserId: assignment.evaluatorDingtalkUserId, recipientName: assignment.evaluatorName, eventType: 'survey.assignment.due_reminder', businessKey: `due:${dueKey}:${assignment.id}`, title: '考核调研', content: buildNotificationCard({ icon: '⏰', statusLabel: '即将截止', surveyTitle: survey.title, fields: [ { label: '评价对象', value: assignment.target.targetName }, { label: '截止时间', value: formatDateTime(survey.dueAt) }, ], note: '请尽快完成评分,避免错过截止时间', }), actionUrl: getSurveyUrl(`/performance-survey/survey/${survey.id}?targetId=${assignment.targetId}`), }), ) } } return ids } export async function recoverDuePerformanceSurveyNotifications(limit = 20) { ensurePerformanceSurveyNotificationWorkerStarted() await prisma.performanceSurveyNotification.updateMany({ where: { status: STATUS.PROCESSING, updatedAt: { lte: new Date(Date.now() - STALE_PROCESSING_MS) }, }, data: { status: STATUS.RETRYING, nextRetryAt: new Date(), lastError: '上次投递处理中断,已重新入队', }, }) const rows = await prisma.performanceSurveyNotification.findMany({ where: { status: { in: [STATUS.PENDING, STATUS.RETRYING] }, OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }], }, select: { id: true }, orderBy: { createdAt: 'asc' }, take: limit, }) rows.forEach((row) => scheduleNotificationProcessing(row.id, 0)) return rows.length } export async function retryPerformanceSurveyNotification(notificationId: string) { ensurePerformanceSurveyNotificationWorkerStarted() await prisma.performanceSurveyNotification.update({ where: { id: notificationId }, data: { status: STATUS.PENDING, nextRetryAt: new Date(), completedAt: null, attemptCount: 0, maxAttempts: MAX_ATTEMPTS, lastStatusCode: null, lastResponseBody: null, lastError: null, providerMessageId: null, readStatus: 'unknown', readAt: null, readStatusCheckedAt: null, sentAt: null, }, }) scheduleNotificationProcessing(notificationId, 0) } export async function processPerformanceSurveyNotification(notificationId: string) { ensurePerformanceSurveyNotificationWorkerStarted() const notification = await prisma.performanceSurveyNotification.findUnique({ where: { id: notificationId }, include: { survey: { select: { status: true } } }, }) if (!notification) return null if (![STATUS.PENDING, STATUS.RETRYING, STATUS.PROCESSING].includes(notification.status as any)) { return null } if (notification.survey?.status === 'cancelled') { await prisma.performanceSurveyNotification.update({ where: { id: notificationId }, data: { status: STATUS.SKIPPED, nextRetryAt: null, completedAt: new Date(), lastError: '调研已撤销,通知不再发送', }, }) return { success: false, status: STATUS.SKIPPED } } await prisma.performanceSurveyNotification.update({ where: { id: notificationId }, data: { status: STATUS.PROCESSING, updatedAt: new Date(), }, }) const resolvedDingtalkUserId = await resolveNotificationDingtalkUserId(notification) if (!resolvedDingtalkUserId) { await prisma.performanceSurveyNotification.update({ where: { id: notificationId }, data: { status: STATUS.SKIPPED, lastError: '收件人缺少钉钉 userId', completedAt: new Date(), }, }) return { success: false, status: STATUS.SKIPPED } } if (resolvedDingtalkUserId !== notification.dingtalkUserId) { await prisma.performanceSurveyNotification.update({ where: { id: notificationId }, data: { dingtalkUserId: resolvedDingtalkUserId, updatedAt: new Date(), }, }) } try { const providerType: NotificationProvider = getDingtalkPerformanceSurveyNotificationChannelForUser( resolvedDingtalkUserId, ) const input = { userIds: [resolvedDingtalkUserId], title: notification.title, text: notification.content, actionUrl: notification.actionUrl, actionTitle: getNotificationActionTitle(notification.eventType), } const result = providerType === NOTIFICATION_PROVIDER.NOTIFICATION_CENTER ? await submitYouyidianNotification({ idempotencyKey: `${notification.eventType}:${notification.businessKey}`, recipientUserId: resolvedDingtalkUserId, title: notification.title, content: notification.content, actionUrl: notification.actionUrl || '', actionTitle: getNotificationActionTitle(notification.eventType), }) : providerType === NOTIFICATION_PROVIDER.ROBOT_OTO ? await sendDingtalkRobotOtoMessage(input) : await sendDingtalkWorkNotice(input) if ('skipped' in result && result.skipped) { await prisma.performanceSurveyNotification.update({ where: { id: notificationId }, data: { status: STATUS.SKIPPED, lastError: result.reason || '钉钉通知未发送', completedAt: new Date(), }, }) return { success: false, status: STATUS.SKIPPED } } await prisma.performanceSurveyNotification.update({ where: { id: notificationId }, data: { status: STATUS.SUCCEEDED, attemptCount: { increment: 1 }, providerType, providerMessageId: providerType === NOTIFICATION_PROVIDER.NOTIFICATION_CENTER ? ('id' in result ? result.id || null : null) : providerType === NOTIFICATION_PROVIDER.ROBOT_OTO ? ('processQueryKey' in result ? result.processQueryKey || null : null) : ('taskId' in result ? result.taskId || result.requestId || null : null), readStatus: 'unknown', readAt: null, readStatusCheckedAt: null, lastStatusCode: 200, lastResponseBody: truncateBody(JSON.stringify(result.raw || result)), 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)] const nextRetryAt = new Date(Date.now() + retryDelayMs) await prisma.performanceSurveyNotification.update({ where: { id: notificationId }, data: { status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING, attemptCount: { increment: 1 }, lastError: error instanceof Error ? error.message : '钉钉通知发送失败', nextRetryAt: exhausted ? null : nextRetryAt, completedAt: exhausted ? new Date() : null, }, }) if (!exhausted) { scheduleNotificationProcessing(notificationId, retryDelayMs) } return { success: false, status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING } } } export function refreshPerformanceSurveyNotificationReadStatuses(limit = 20) { if (readStatusRefreshInFlight) return readStatusRefreshInFlight readStatusRefreshInFlight = refreshPerformanceSurveyNotificationReadStatusesInternal(limit) .finally(() => { readStatusRefreshInFlight = null }) return readStatusRefreshInFlight } async function refreshPerformanceSurveyNotificationReadStatusesInternal(limit = 20) { const staleBefore = new Date(Date.now() - READ_STATUS_REFRESH_MS) const notifications = await prisma.performanceSurveyNotification.findMany({ where: { status: STATUS.SUCCEEDED, providerMessageId: { not: null }, readStatus: { in: ['unknown', 'unread'] }, OR: [ { readStatusCheckedAt: null }, { readStatusCheckedAt: { lte: staleBefore } }, ], }, select: { id: true, providerType: true, providerMessageId: true, dingtalkUserId: true, }, orderBy: { sentAt: 'asc' }, take: Math.max(1, Math.min(limit, 100)), }) let refreshed = 0 for (const notification of notifications) { const providerMessageId = clean(notification.providerMessageId) const recipientDingtalkUserId = clean(notification.dingtalkUserId) if (!providerMessageId || !recipientDingtalkUserId) continue try { const providerType = notification.providerType === NOTIFICATION_PROVIDER.NOTIFICATION_CENTER ? NOTIFICATION_PROVIDER.NOTIFICATION_CENTER : notification.providerType === NOTIFICATION_PROVIDER.ROBOT_OTO ? NOTIFICATION_PROVIDER.ROBOT_OTO : NOTIFICATION_PROVIDER.WORK_NOTICE let read = false let unread = false let deliveryFailed = false if (providerType === NOTIFICATION_PROVIDER.NOTIFICATION_CENTER) { const result = await getYouyidianNotificationStatus(providerMessageId) read = result.status === 'read' unread = result.status === 'unread' deliveryFailed = result.status === 'failed' } else if (providerType === NOTIFICATION_PROVIDER.ROBOT_OTO) { const result = await getDingtalkRobotOtoReadResult(providerMessageId) read = result.readUserIds.includes(recipientDingtalkUserId) unread = result.unreadUserIds.includes(recipientDingtalkUserId) deliveryFailed = result.deliveryFailed } else { const result = await getDingtalkWorkNoticeSendResult(providerMessageId) read = result.readUserIds.includes(recipientDingtalkUserId) unread = result.unreadUserIds.includes(recipientDingtalkUserId) deliveryFailed = [ ...result.failedUserIds, ...result.invalidUserIds, ...result.forbiddenUserIds, ].includes(recipientDingtalkUserId) } const checkedAt = new Date() await prisma.performanceSurveyNotification.update({ where: { id: notification.id }, data: { readStatus: read ? 'read' : unread ? 'unread' : deliveryFailed ? 'delivery_failed' : 'unknown', readAt: read ? checkedAt : null, readStatusCheckedAt: checkedAt, }, }) refreshed += 1 } catch { await prisma.performanceSurveyNotification.update({ where: { id: notification.id }, data: { readStatusCheckedAt: new Date() }, }) } } return refreshed } function formatDateTime(value: Date | string | null | undefined) { if (!value) return '' const date = new Date(value) if (Number.isNaN(date.getTime())) return '' const hour = String(date.getHours()).padStart(2, '0') const minute = String(date.getMinutes()).padStart(2, '0') return `${date.getMonth() + 1}月${date.getDate()}日 ${hour}:${minute}` } function formatAssessmentMonth(value: string | null | undefined, fallback: Date | string) { const normalized = String(value || '').trim() const match = normalized.match(/^(\d{4})-(\d{2})$/) if (match) return `${match[1]}年${Number(match[2])}月` const date = new Date(fallback) if (Number.isNaN(date.getTime())) return '-' return `${date.getFullYear()}年${date.getMonth() + 1}月` }