166 lines
5.3 KiB
TypeScript
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)} 分钟`,
|
|
)
|
|
}
|