880 lines
30 KiB
TypeScript
880 lines
30 KiB
TypeScript
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}月`
|
||
}
|