feat: hourly work review reminders
This commit is contained in:
parent
930b22cd1e
commit
fa14bbc207
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
lib/__tests__/work-review-review-mode.test.ts
Normal file
80
lib/__tests__/work-review-review-mode.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
58
lib/work-review-review-mode.ts
Normal file
58
lib/work-review-review-mode.ts
Normal 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)
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user