import { prisma } from '@/lib/prisma' /** * 作品过稿是首轮通过指标的唯一权威数据源。这个任务不复制作品数据, * 只用 updatedAt 增量检查项目、版本和评审记录,给看板提供可观测的 * 后台同步心跳;看板查询仍直接读取作品过稿后台,避免出现两套事实。 */ export type WorkReviewDashboardSyncStatus = 'idle' | 'syncing' | 'ready' | 'error' export type WorkReviewDashboardSyncState = { status: WorkReviewDashboardSyncStatus lastSyncedAt: string | null sourceCursor: string | null rowsChecked: number lastError: string | null } type MutableState = { status: WorkReviewDashboardSyncStatus lastSyncedAt: Date | null sourceCursor: Date | null rowsChecked: number lastError: string | null running: boolean } type SyncGlobal = typeof globalThis & { __workReviewDashboardSync?: MutableState __workReviewDashboardSyncStarted?: boolean } const INITIAL_SYNC_DELAY_MS = 5_000 const DEFAULT_SYNC_INTERVAL_MS = 60 * 60 * 1_000 // A small overlap prevents a write that races the query from being skipped. const CURSOR_OVERLAP_MS = 60_000 function getState(): MutableState { const globalState = globalThis as SyncGlobal if (!globalState.__workReviewDashboardSync) { globalState.__workReviewDashboardSync = { status: 'idle', lastSyncedAt: null, sourceCursor: null, rowsChecked: 0, lastError: null, running: false, } } return globalState.__workReviewDashboardSync } export function getWorkReviewDashboardSyncState(): WorkReviewDashboardSyncState { const state = getState() return { status: state.status, lastSyncedAt: state.lastSyncedAt?.toISOString() || null, sourceCursor: state.sourceCursor?.toISOString() || null, rowsChecked: state.rowsChecked, lastError: state.lastError, } } function getIncrementalWhere(cursor: Date | null) { if (!cursor) return { versions: { some: { version: 1 } } } const changedAfter = new Date(cursor.getTime() - CURSOR_OVERLAP_MS) return { versions: { some: { version: 1 } }, OR: [ { updatedAt: { gt: changedAfter } }, { versions: { some: { version: 1, updatedAt: { gt: changedAfter } } } }, { versions: { some: { version: 1, reviews: { some: { updatedAt: { gt: changedAfter } } }, }, }, }, ], } } function maxDate(values: Array) { return values.reduce((current, value) => { if (!value) return current return !current || value.getTime() > current.getTime() ? value : current }, null) } /** Run one idempotent, read-only incremental source check. */ export async function syncWorkReviewDashboardIncremental() { const state = getState() if (state.running) return { skipped: true, rowsChecked: 0, lastSyncedAt: state.lastSyncedAt } state.running = true state.status = 'syncing' state.lastError = null try { const rows = await prisma.workReviewProject.findMany({ where: getIncrementalWhere(state.sourceCursor), select: { updatedAt: true, versions: { where: { version: 1 }, select: { updatedAt: true, reviews: { select: { updatedAt: true } }, }, }, }, orderBy: [{ updatedAt: 'asc' }, { id: 'asc' }], }) const sourceCursor = rows.reduce((cursor, project) => { const projectDates = project.versions.flatMap((version) => [ version.updatedAt, ...version.reviews.map((review) => review.updatedAt), ]) return maxDate([cursor, project.updatedAt, ...projectDates]) }, state.sourceCursor) state.sourceCursor = sourceCursor || state.sourceCursor state.rowsChecked = rows.length state.lastSyncedAt = new Date() state.status = 'ready' return { skipped: false, rowsChecked: rows.length, lastSyncedAt: state.lastSyncedAt } } catch (error) { state.status = 'error' state.lastError = (error instanceof Error ? error.message : String(error)).slice(0, 500) throw error } finally { state.running = false } } export function ensureHourlyWorkReviewDashboardSyncStarted() { const globalState = globalThis as SyncGlobal if (globalState.__workReviewDashboardSyncStarted) return globalState.__workReviewDashboardSyncStarted = true const configuredHours = Number(process.env.WORK_REVIEW_SYNC_INTERVAL_HOURS || 1) const intervalMs = Number.isFinite(configuredHours) && configuredHours > 0 ? Math.max(5 * 60 * 1_000, configuredHours * 60 * 60 * 1_000) : DEFAULT_SYNC_INTERVAL_MS const runOnce = () => { void syncWorkReviewDashboardIncremental() .then((result) => { if (result.skipped) { console.info('[work-review-dashboard-sync] 上一轮仍在检查,本轮跳过') return } console.info(`[work-review-dashboard-sync] 增量检查完成:${result.rowsChecked} 个变更项目`) }) .catch((error) => console.warn('[work-review-dashboard-sync] 增量检查失败:', error)) } const initialTimer = setTimeout(runOnce, INITIAL_SYNC_DELAY_MS) initialTimer.unref?.() const interval = setInterval(runOnce, intervalMs) interval.unref?.() console.info( `[work-review-dashboard-sync] 已调度每小时增量检查,周期 ${Math.round(intervalMs / 60_000)} 分钟`, ) }