gongxiangp2/lib/performance-survey-notifications.ts
2026-07-23 09:25:50 +08:00

880 lines
30 KiB
TypeScript
Raw Permalink 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 { 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<string, NodeJS.Timeout>()
let readStatusRefreshInFlight: Promise<number> | null = null
let missingAssignmentRecoveryInFlight: Promise<number> | 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<string, unknown> | 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<string, unknown> | 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}月`
}