gongxiangp2/lib/work-review-dashboard-sync.ts
2026-08-31 14:38:23 +08:00

166 lines
5.3 KiB
TypeScript

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<Date | null | undefined>) {
return values.reduce<Date | null>((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<Date | null>((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)} 分钟`,
)
}