feat: hourly work review reminders

This commit is contained in:
江林铖 2026-08-13 12:22:52 +08:00
parent 930b22cd1e
commit fa14bbc207
5 changed files with 322 additions and 29 deletions

View File

@ -62,5 +62,13 @@ export async function register() {
console.error('[Instrumentation] 视频转码启动恢复失败,已保留周期重试:', error)
}
}
try {
const { ensureWorkReviewNotificationWorkerStarted } = await import('./lib/work-review-notifications')
ensureWorkReviewNotificationWorkerStarted()
console.log('[Instrumentation] 作品过稿通知与每小时提醒任务已启动')
} catch (error) {
console.error('[Instrumentation] 启动作作品过稿通知任务失败:', error)
}
}
}

View File

@ -0,0 +1,80 @@
import {
getPendingWorkReviewRoles,
getRequiredWorkReviewRoles,
getWorkReviewReminderSequence,
WORK_REVIEW_REVIEW_ROLES,
} from '@/lib/work-review-review-mode'
describe('作品过稿评审模式', () => {
const originalEnv = { ...process.env }
afterEach(() => {
process.env = { ...originalEnv }
jest.restoreAllMocks()
})
test('正常作品需要指定评审人和元泽共同评审', () => {
delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID
delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID
delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT
expect(getRequiredWorkReviewRoles({ id: 'project-1', managerEmployeeId: 'manager-1' })).toEqual([
WORK_REVIEW_REVIEW_ROLES.MANAGER,
WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER,
])
})
test('有效测试配置仅提醒指定评审人', () => {
process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID = 'project-1'
process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID = 'manager-1'
process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT = '2099-01-01T00:00:00.000Z'
expect(getRequiredWorkReviewRoles({ id: 'project-1', managerEmployeeId: 'manager-1' })).toEqual([
WORK_REVIEW_REVIEW_ROLES.MANAGER,
])
})
test('过期测试配置恢复双人评审', () => {
process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID = 'project-1'
process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID = 'manager-1'
process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT = '2020-01-01T00:00:00.000Z'
expect(getRequiredWorkReviewRoles({ id: 'project-1', managerEmployeeId: 'manager-1' })).toHaveLength(2)
})
test('每小时提醒只保留尚未操作的评审人', () => {
delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID
delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID
delete process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT
expect(getPendingWorkReviewRoles(
{ id: 'project-1', managerEmployeeId: 'manager-1' },
[WORK_REVIEW_REVIEW_ROLES.MANAGER],
)).toEqual([WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER])
expect(getPendingWorkReviewRoles(
{ id: 'project-1', managerEmployeeId: 'manager-1' },
[WORK_REVIEW_REVIEW_ROLES.MANAGER, WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER],
)).toEqual([])
})
test('提交满一小时后才进入对应的小时提醒序号', () => {
const submittedAt = new Date('2026-08-13T00:00:00.000Z')
expect(getWorkReviewReminderSequence(
submittedAt,
new Date('2026-08-13T00:59:59.999Z'),
)).toBeNull()
expect(getWorkReviewReminderSequence(
submittedAt,
new Date('2026-08-13T01:00:00.000Z'),
)).toBe(1)
expect(getWorkReviewReminderSequence(
submittedAt,
new Date('2026-08-13T01:59:59.999Z'),
)).toBe(1)
expect(getWorkReviewReminderSequence(
submittedAt,
new Date('2026-08-13T02:00:00.000Z'),
)).toBe(2)
})
})

View File

@ -6,6 +6,16 @@ import {
sendDingtalkWorkNotice,
} from '@/lib/dingtalk-client'
import { submitYouyidianNotification } from '@/lib/youyidian-notification-center'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { buildDingtalkEntryPath } from '@/lib/safe-redirect'
import {
getPendingWorkReviewRoles,
getRequiredWorkReviewRoles,
getWorkReviewReminderSequence,
WORK_REVIEW_REMINDER_INTERVAL_MS,
WORK_REVIEW_REVIEW_ROLES,
type WorkReviewReviewRole,
} from '@/lib/work-review-review-mode'
const STATUS = {
PENDING: 'pending',
@ -20,6 +30,7 @@ const MAX_ATTEMPTS = 5
const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
const RECOVERY_INTERVAL_MS = 60_000
const STALE_PROCESSING_MS = 5 * 60_000
const REMINDER_EVENT_TYPE_PREFIX = 'work_review.version.reminder.'
type QueueWorkReviewNotificationInput = {
projectId: string
@ -37,6 +48,7 @@ type QueueWorkReviewNotificationInput = {
let workerStarted = false
let recoveryTimer: NodeJS.Timeout | null = null
let reminderTimer: NodeJS.Timeout | null = null
const deliveryTimers = new Map<string, NodeJS.Timeout>()
function clean(value: unknown) {
@ -102,6 +114,25 @@ async function processWorkReviewNotification(notificationId: string) {
const notification = await prisma.workReviewNotification.findUnique({ where: { id: notificationId } })
if (!notification) return null
if (notification.eventType.startsWith(REMINDER_EVENT_TYPE_PREFIX)) {
const reviewerRole = notification.eventType.slice(REMINDER_EVENT_TYPE_PREFIX.length)
const stillPending = reviewerRole === WORK_REVIEW_REVIEW_ROLES.MANAGER
|| reviewerRole === WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER
? await isWorkReviewRoleStillPending(notification.versionId, reviewerRole)
: false
if (!stillPending) {
await prisma.workReviewNotification.update({
where: { id: notificationId },
data: {
status: STATUS.SKIPPED,
lastError: '评审人已完成操作,无需发送提醒',
nextRetryAt: null,
completedAt: new Date(),
},
})
return { success: false, status: STATUS.SKIPPED }
}
}
const dingtalkUserId = await resolveDingtalkUserId(notification)
if (!dingtalkUserId) {
await prisma.workReviewNotification.update({
@ -197,6 +228,133 @@ async function processWorkReviewNotification(notificationId: string) {
}
}
async function isWorkReviewRoleStillPending(versionId: string, role: WorkReviewReviewRole) {
const version = await prisma.workReviewVersion.findUnique({
where: { id: versionId },
select: {
status: true,
project: { select: { id: true, managerEmployeeId: true } },
reviews: { where: { reviewerRole: role }, select: { id: true }, take: 1 },
},
})
return Boolean(
version
&& version.status === 'pending_review'
&& getRequiredWorkReviewRoles(version.project).includes(role)
&& version.reviews.length === 0,
)
}
function formatChinaDateTime(value: Date) {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(value)
}
function escapeDingtalkMarkdown(value: unknown) {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_\[\]#>])/g, '\\$1')
.trim()
}
function getReminderRecipient(project: {
managerEmployeeId: string
managerUserId: string | null
managerDingtalkUserId: string | null
managerName: string
finalReviewerEmployeeId: string
finalReviewerUserId: string | null
finalReviewerDingtalkUserId: string | null
finalReviewerName: string
}, role: WorkReviewReviewRole) {
return role === WORK_REVIEW_REVIEW_ROLES.MANAGER
? {
recipientEmployeeId: project.managerEmployeeId,
recipientUserId: project.managerUserId,
dingtalkUserId: project.managerDingtalkUserId,
recipientName: project.managerName,
}
: {
recipientEmployeeId: project.finalReviewerEmployeeId,
recipientUserId: project.finalReviewerUserId,
dingtalkUserId: project.finalReviewerDingtalkUserId,
recipientName: project.finalReviewerName,
}
}
export async function queueDueWorkReviewReminders(now = new Date()) {
const dueBefore = new Date(now.getTime() - WORK_REVIEW_REMINDER_INTERVAL_MS)
const versions = await prisma.workReviewVersion.findMany({
where: {
status: 'pending_review',
submittedAt: { lte: dueBefore },
},
select: {
id: true,
version: true,
submittedAt: true,
project: {
select: {
id: true,
title: true,
submitterName: true,
managerEmployeeId: true,
managerUserId: true,
managerDingtalkUserId: true,
managerName: true,
finalReviewerEmployeeId: true,
finalReviewerUserId: true,
finalReviewerDingtalkUserId: true,
finalReviewerName: true,
},
},
reviews: { select: { reviewerRole: true } },
},
orderBy: { submittedAt: 'asc' },
})
const queued: string[] = []
for (const version of versions) {
const reminderSequence = getWorkReviewReminderSequence(version.submittedAt, now)
if (reminderSequence == null) continue
const pendingRoles = getPendingWorkReviewRoles(
version.project,
version.reviews.map((review: { reviewerRole: string }) => review.reviewerRole),
)
for (const role of pendingRoles) {
const recipient = getReminderRecipient(version.project, role)
const id = await queueWorkReviewNotification({
projectId: version.project.id,
versionId: version.id,
...recipient,
eventType: `${REMINDER_EVENT_TYPE_PREFIX}${role}`,
businessKey: `work-review-version:${version.id}:${role}:reminder:${reminderSequence}`,
title: '作品过稿待评审提醒',
content: [
'### ◆ 作品仍待您评审',
'',
`**${escapeDingtalkMarkdown(version.project.title)}**`,
'',
`**提交人:** ${escapeDingtalkMarkdown(version.project.submitterName)} `,
`**版本:** V${version.version} `,
`**提交时间:** ${formatChinaDateTime(version.submittedAt)} `,
'',
'> 该作品尚未收到您的评审,请尽快处理。未操作前系统将每小时提醒一次。',
].join('\n'),
actionUrl: `${getPublicAppOrigin()}${buildDingtalkEntryPath(`/work-review?project=${encodeURIComponent(version.project.id)}`)}`,
})
queued.push(id)
}
}
return queued
}
async function recoverDueNotifications() {
const staleBefore = new Date(Date.now() - STALE_PROCESSING_MS)
await prisma.workReviewNotification.updateMany({
@ -237,6 +395,15 @@ export function ensureWorkReviewNotificationWorkerStarted() {
})
}, RECOVERY_INTERVAL_MS)
recoveryTimer.unref?.()
reminderTimer = setInterval(() => {
void queueDueWorkReviewReminders().catch((error) => {
console.error('[work-review] 每小时待评审提醒扫描失败:', error)
})
}, RECOVERY_INTERVAL_MS)
reminderTimer.unref?.()
void queueDueWorkReviewReminders().catch((error) => {
console.error('[work-review] 首次待评审提醒扫描失败:', error)
})
}
export async function queueWorkReviewNotification(input: QueueWorkReviewNotificationInput) {

View File

@ -0,0 +1,58 @@
export const WORK_REVIEW_REVIEW_ROLES = {
MANAGER: 'manager',
FINAL_REVIEWER: 'final_reviewer',
} as const
export type WorkReviewReviewRole = typeof WORK_REVIEW_REVIEW_ROLES[keyof typeof WORK_REVIEW_REVIEW_ROLES]
export type WorkReviewReviewMode = 'dual' | 'single_reviewer_test'
export const WORK_REVIEW_REMINDER_INTERVAL_MS = 60 * 60_000
function clean(value: unknown) {
const text = String(value ?? '').trim()
return text || null
}
function getSingleReviewerTestConfig() {
const projectId = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID)
const managerEmployeeId = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID)
const expiresAt = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT)
if (!projectId || !managerEmployeeId || !expiresAt) return null
const expiresAtMs = Date.parse(expiresAt)
if (!Number.isFinite(expiresAtMs) || Date.now() >= expiresAtMs) return null
return { projectId, managerEmployeeId }
}
export function getWorkReviewReviewMode(project: {
id: string
managerEmployeeId: string
}): WorkReviewReviewMode {
const config = getSingleReviewerTestConfig()
return config
&& project.id === config.projectId
&& project.managerEmployeeId === config.managerEmployeeId
? 'single_reviewer_test'
: 'dual'
}
export function getRequiredWorkReviewRoles(project: {
id: string
managerEmployeeId: string
}): WorkReviewReviewRole[] {
return getWorkReviewReviewMode(project) === 'single_reviewer_test'
? [WORK_REVIEW_REVIEW_ROLES.MANAGER]
: [WORK_REVIEW_REVIEW_ROLES.MANAGER, WORK_REVIEW_REVIEW_ROLES.FINAL_REVIEWER]
}
export function getPendingWorkReviewRoles(
project: { id: string; managerEmployeeId: string },
completedRoles: Iterable<string>,
) {
const completed = new Set(completedRoles)
return getRequiredWorkReviewRoles(project).filter((role) => !completed.has(role))
}
export function getWorkReviewReminderSequence(submittedAt: Date, now: Date) {
const elapsedMs = now.getTime() - submittedAt.getTime()
if (elapsedMs < WORK_REVIEW_REMINDER_INTERVAL_MS) return null
return Math.floor(elapsedMs / WORK_REVIEW_REMINDER_INTERVAL_MS)
}

View File

@ -14,6 +14,12 @@ import {
ensureWorkReviewNotificationWorkerStarted,
queueWorkReviewNotification,
} from '@/lib/work-review-notifications'
import {
getRequiredWorkReviewRoles,
getWorkReviewReviewMode,
WORK_REVIEW_REVIEW_ROLES,
type WorkReviewReviewRole,
} from '@/lib/work-review-review-mode'
import {
serializePerformanceSurveyImagePayload,
type PerformanceSurveyStoredImageGroup,
@ -50,10 +56,7 @@ import {
type NormalizedWorkReviewAnnotation,
} from '@/lib/work-review-policy'
const REVIEW_ROLES = {
MANAGER: 'manager',
FINAL_REVIEWER: 'final_reviewer',
} as const
const REVIEW_ROLES = WORK_REVIEW_REVIEW_ROLES
const VERSION_STATUS = {
PENDING_REVIEW: 'pending_review',
@ -61,7 +64,7 @@ const VERSION_STATUS = {
APPROVED: 'approved',
} as const
type ReviewRole = typeof REVIEW_ROLES[keyof typeof REVIEW_ROLES]
type ReviewRole = WorkReviewReviewRole
type ResolvedReviewer = {
employee: any
@ -140,31 +143,8 @@ function clean(value: unknown) {
return text || null
}
type WorkReviewReviewMode = 'dual' | 'single_reviewer_test'
function getSingleReviewerTestConfig() {
const projectId = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID)
const managerEmployeeId = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID)
const expiresAt = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT)
if (!projectId || !managerEmployeeId || !expiresAt) return null
const expiresAtMs = Date.parse(expiresAt)
if (!Number.isFinite(expiresAtMs) || Date.now() >= expiresAtMs) return null
return { projectId, managerEmployeeId, expiresAt }
}
function getWorkReviewReviewMode(project: { id: string; managerEmployeeId: string }): WorkReviewReviewMode {
const config = getSingleReviewerTestConfig()
return config
&& project.id === config.projectId
&& project.managerEmployeeId === config.managerEmployeeId
? 'single_reviewer_test'
: 'dual'
}
function getRequiredReviewRoles(project: { id: string; managerEmployeeId: string }): ReviewRole[] {
return getWorkReviewReviewMode(project) === 'single_reviewer_test'
? [REVIEW_ROLES.MANAGER]
: [REVIEW_ROLES.MANAGER, REVIEW_ROLES.FINAL_REVIEWER]
return getRequiredWorkReviewRoles(project)
}
function escapeDingtalkMarkdown(value: unknown) {