From ba0b198eb8ba767dcdf586ae8a4f75c1a572d97d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?=
Date: Mon, 31 Aug 2026 14:38:23 +0800
Subject: [PATCH] feat: hourly sync work review dashboard data
---
.../first-pass/projects/route.test.ts | 7 +
.../core-metrics/first-pass/projects/route.ts | 7 +-
.../core-metrics/first-pass/route.ts | 7 +-
.../department-dashboard/CoreMetricDetail.tsx | 14 +-
.../CoreMetricsDashboard.test.tsx | 4 +-
.../CoreMetricsDashboard.tsx | 15 +-
instrumentation.ts | 10 ++
lib/brand-center-dashboard-access.ts | 33 ++++
lib/work-review-dashboard-sync.ts | 165 ++++++++++++++++++
lib/work-review-metrics.ts | 4 +
lib/work-review-service.ts | 10 +-
11 files changed, 263 insertions(+), 13 deletions(-)
create mode 100644 lib/brand-center-dashboard-access.ts
create mode 100644 lib/work-review-dashboard-sync.ts
diff --git a/app/api/department-dashboard/core-metrics/first-pass/projects/route.test.ts b/app/api/department-dashboard/core-metrics/first-pass/projects/route.test.ts
index 47d7201..c03593a 100644
--- a/app/api/department-dashboard/core-metrics/first-pass/projects/route.test.ts
+++ b/app/api/department-dashboard/core-metrics/first-pass/projects/route.test.ts
@@ -11,6 +11,13 @@ jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
+jest.mock('@/lib/brand-center-dashboard-access', () => ({
+ BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
+ status = 403
+ },
+ assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
+}))
+
jest.mock('@/lib/work-review-service', () => {
const actual = jest.requireActual('@/lib/work-review-service')
return {
diff --git a/app/api/department-dashboard/core-metrics/first-pass/projects/route.ts b/app/api/department-dashboard/core-metrics/first-pass/projects/route.ts
index f481f87..5171ed1 100644
--- a/app/api/department-dashboard/core-metrics/first-pass/projects/route.ts
+++ b/app/api/department-dashboard/core-metrics/first-pass/projects/route.ts
@@ -1,6 +1,10 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
+import {
+ assertBrandCenterDashboardAccess,
+ BrandCenterDashboardAccessError,
+} from '@/lib/brand-center-dashboard-access'
import { PerformanceSurveyApiError } from '@/lib/performance-survey-service'
import {
getFirstPassReviewProjects,
@@ -27,6 +31,7 @@ function parsePositiveInteger(value: string | null, fallback: number) {
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions)
+ await assertBrandCenterDashboardAccess(session)
const url = new URL(request.url)
const page = parsePositiveInteger(url.searchParams.get('page'), 1)
const pageSize = Math.min(parsePositiveInteger(url.searchParams.get('pageSize'), 20), 50)
@@ -36,7 +41,7 @@ export async function GET(request: Request) {
const data = await getFirstPassReviewProjects(session, { page, pageSize, query })
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
- if (error instanceof WorkReviewApiError || error instanceof PerformanceSurveyApiError) {
+ if (error instanceof BrandCenterDashboardAccessError || error instanceof WorkReviewApiError || error instanceof PerformanceSurveyApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
diff --git a/app/api/department-dashboard/core-metrics/first-pass/route.ts b/app/api/department-dashboard/core-metrics/first-pass/route.ts
index a4b2eff..8927f98 100644
--- a/app/api/department-dashboard/core-metrics/first-pass/route.ts
+++ b/app/api/department-dashboard/core-metrics/first-pass/route.ts
@@ -1,6 +1,10 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
+import {
+ assertBrandCenterDashboardAccess,
+ BrandCenterDashboardAccessError,
+} from '@/lib/brand-center-dashboard-access'
import { PerformanceSurveyApiError } from '@/lib/performance-survey-service'
import {
WorkReviewApiError,
@@ -18,10 +22,11 @@ const PRIVATE_HEADERS = {
export async function GET() {
try {
const session = await getServerSession(authOptions)
+ await assertBrandCenterDashboardAccess(session)
const data = await getFirstPassReviewSummary(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
- if (error instanceof WorkReviewApiError || error instanceof PerformanceSurveyApiError) {
+ if (error instanceof BrandCenterDashboardAccessError || error instanceof WorkReviewApiError || error instanceof PerformanceSurveyApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
diff --git a/components/department-dashboard/CoreMetricDetail.tsx b/components/department-dashboard/CoreMetricDetail.tsx
index 8f1f80c..12c9166 100644
--- a/components/department-dashboard/CoreMetricDetail.tsx
+++ b/components/department-dashboard/CoreMetricDetail.tsx
@@ -325,11 +325,15 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
? monthlyLaunches.summary.lastSyncedAt
? `每小时增量同步 · ${formatDate(monthlyLaunches.summary.lastSyncedAt, true)}`
: monthlyLaunches.summary.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
- : hasOnTimeDeliverySource
- ? onTimeDelivery.summary.lastSyncedAt
- ? `每小时增量同步 · ${formatDate(onTimeDelivery.summary.lastSyncedAt, true)}`
- : onTimeDelivery.summary.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
- : hasFirstPassSource ? '打开页面时读取' : '每日同步待接入'}
+ : hasOnTimeDeliverySource
+ ? onTimeDelivery.summary.lastSyncedAt
+ ? `每小时增量同步 · ${formatDate(onTimeDelivery.summary.lastSyncedAt, true)}`
+ : onTimeDelivery.summary.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
+ : hasFirstPassSource
+ ? firstPassPage.summary.lastSyncedAt
+ ? `每小时增量同步 · ${formatDate(firstPassPage.summary.lastSyncedAt, true)}`
+ : firstPassPage.summary.syncStatus === 'error' ? '增量同步失败' : '等待首轮同步'
+ : '每日同步待接入'}
diff --git a/components/department-dashboard/CoreMetricsDashboard.test.tsx b/components/department-dashboard/CoreMetricsDashboard.test.tsx
index 51c5059..108c4b0 100644
--- a/components/department-dashboard/CoreMetricsDashboard.test.tsx
+++ b/components/department-dashboard/CoreMetricsDashboard.test.tsx
@@ -56,6 +56,8 @@ describe('CoreMetricsDashboard', () => {
rate: 73.3,
statusBreakdown: { approved: 11, revisionRequired: 7, pendingReview: 10, unknown: 2 },
readAt: '2026-08-14T02:00:00.000Z',
+ syncStatus: 'ready',
+ lastSyncedAt: '2026-08-14T02:00:00.000Z',
},
},
})) as jest.Mock
@@ -72,7 +74,7 @@ describe('CoreMetricsDashboard', () => {
expect(screen.queryByRole('complementary', { name: '数据接入说明' })).not.toBeInTheDocument()
expect(await screen.findByText('73.3%')).toBeInTheDocument()
expect(screen.getByText('V1通过 11 / 已通过 15')).toBeInTheDocument()
- expect(screen.getByText('实时读取')).toBeInTheDocument()
+ expect(screen.getByText('作品过稿后台')).toBeInTheDocument()
expect(screen.getAllByText('每小时同步').length).toBeGreaterThan(0)
expect(screen.getAllByText('示例值')).toHaveLength(2)
diff --git a/components/department-dashboard/CoreMetricsDashboard.tsx b/components/department-dashboard/CoreMetricsDashboard.tsx
index 20fda13..f262983 100644
--- a/components/department-dashboard/CoreMetricsDashboard.tsx
+++ b/components/department-dashboard/CoreMetricsDashboard.tsx
@@ -188,7 +188,9 @@ export default function CoreMetricsDashboard() {
: '等待首轮同步'
: onTimeDelivery.status === 'error' ? '读取失败' : '正在读取'
: isFirstPass
- ? firstPass.status === 'success' ? '实时读取' : firstPass.status === 'error' ? '读取失败' : '正在读取'
+ ? firstPass.status === 'success'
+ ? firstPass.data.syncStatus === 'ready' ? '每小时同步' : firstPass.data.syncStatus === 'error' ? '同步异常' : '正在同步'
+ : firstPass.status === 'error' ? '读取失败' : '正在读取'
: '示例值'
const value = isOngoingWorkflow
? ongoingWorkflows.status === 'success' ? String(ongoingWorkflows.data.total) : '—'
@@ -230,6 +232,11 @@ export default function CoreMetricsDashboard() {
V1通过 {firstPass.data.firstPassApproved} / 已通过 {firstPass.data.totalApproved}
)}
+ {isFirstPass && firstPass.status === 'success' && firstPass.data.lastSyncedAt && (
+
+ 最近同步 {formatSyncTime(firstPass.data.lastSyncedAt)}
+
+ )}
{isFirstPass && firstPass.status === 'loading' && (
正在连接作品过稿
)}
@@ -266,12 +273,12 @@ export default function CoreMetricsDashboard() {
{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery
? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
- : isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '打开页面时读取') : '每日同步'}
+ : isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '每小时增量同步') : '每日同步'}
·
{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery
? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '数据未加载' : '钉钉流程快照'
- : isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'}
+ : isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '作品过稿后台') : '公式待接入'}
{isFirstPass ? '查看所有作品明细' : '查看明细'}
@@ -283,7 +290,7 @@ export default function CoreMetricsDashboard() {
})}
- “进行中项目”和“本月上新”每小时读取钉钉审批流程三表快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
+ “进行中项目”和“本月上新”每小时读取钉钉审批流程三表快照,“首轮通过”读取作品过稿后台 V1 审核结果并每小时增量同步;其余示例值将在对应数据源与公式确认后替换。
diff --git a/instrumentation.ts b/instrumentation.ts
index 90b37be..bf4a817 100644
--- a/instrumentation.ts
+++ b/instrumentation.ts
@@ -129,5 +129,15 @@ export async function register() {
} catch (error) {
console.error('[Instrumentation] 调度钉钉流程每小时同步任务失败:', error)
}
+
+ // 作品过稿后台是首轮通过指标的唯一权威源;应用启动后增量检查项目、
+ // V1 版本和评审记录,避免看板退化为只在有人打开时才知道数据是否更新。
+ try {
+ const { ensureHourlyWorkReviewDashboardSyncStarted } = await import('./lib/work-review-dashboard-sync')
+ ensureHourlyWorkReviewDashboardSyncStarted()
+ console.log('[Instrumentation] 作品过稿后台每小时增量检查任务已调度')
+ } catch (error) {
+ console.error('[Instrumentation] 调度作品过稿每小时增量检查任务失败:', error)
+ }
}
}
diff --git a/lib/brand-center-dashboard-access.ts b/lib/brand-center-dashboard-access.ts
new file mode 100644
index 0000000..337897a
--- /dev/null
+++ b/lib/brand-center-dashboard-access.ts
@@ -0,0 +1,33 @@
+import type { Session } from 'next-auth'
+import { prisma } from '@/lib/prisma'
+import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
+
+export class BrandCenterDashboardAccessError extends Error {
+ status: number
+
+ constructor(message: string, status: number) {
+ super(message)
+ this.name = 'BrandCenterDashboardAccessError'
+ this.status = status
+ }
+}
+
+/** Shared API gate for dashboard metrics that expose cross-project aggregates. */
+export async function assertBrandCenterDashboardAccess(session: Session | null) {
+ const userId = session?.user?.id
+ if (!userId) throw new BrandCenterDashboardAccessError('请先登录', 401)
+
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { role: true, isSuperAdmin: true, isActive: true, status: true },
+ })
+ if (!user || !user.isActive || user.status === 'archived' || user.status === 'suspended') {
+ throw new BrandCenterDashboardAccessError('账号不可用,请重新登录', 401)
+ }
+
+ const allowed = await resolveBrandCenterAccessForUser(userId, {
+ role: user.role,
+ isSuperAdmin: user.isSuperAdmin,
+ })
+ if (!allowed) throw new BrandCenterDashboardAccessError('无权查看品牌中心流程指标', 403)
+}
diff --git a/lib/work-review-dashboard-sync.ts b/lib/work-review-dashboard-sync.ts
new file mode 100644
index 0000000..86be4a5
--- /dev/null
+++ b/lib/work-review-dashboard-sync.ts
@@ -0,0 +1,165 @@
+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)} 分钟`,
+ )
+}
diff --git a/lib/work-review-metrics.ts b/lib/work-review-metrics.ts
index bd9630d..5981dd8 100644
--- a/lib/work-review-metrics.ts
+++ b/lib/work-review-metrics.ts
@@ -17,6 +17,10 @@ export type FirstPassReviewSummary = {
unknown: number
}
readAt: string
+ /** 作品过稿后台的增量检查状态;统计本身仍直接读取该后台。 */
+ syncStatus?: 'idle' | 'syncing' | 'ready' | 'error'
+ lastSyncedAt?: string | null
+ syncRowsChecked?: number
}
export function normalizeFirstPassReviewStatus(status: string): FirstPassReviewStatus {
diff --git a/lib/work-review-service.ts b/lib/work-review-service.ts
index c816538..4191880 100644
--- a/lib/work-review-service.ts
+++ b/lib/work-review-service.ts
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { open } from 'fs/promises'
import type { Session } from 'next-auth'
import { prisma } from '@/lib/prisma'
+import { getWorkReviewDashboardSyncState } from '@/lib/work-review-dashboard-sync'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { buildDingtalkEntryPath } from '@/lib/safe-redirect'
import {
@@ -640,10 +641,17 @@ async function getFirstPassReviewSummaryForProjectWhere(
}),
])
- return buildFirstPassReviewSummary(rows.map((row) => ({
+ const summary = buildFirstPassReviewSummary(rows.map((row) => ({
status: row.status,
count: row._count._all,
})), totalApproved)
+ const sync = getWorkReviewDashboardSyncState()
+ return {
+ ...summary,
+ syncStatus: sync.status,
+ lastSyncedAt: sync.lastSyncedAt,
+ syncRowsChecked: sync.rowsChecked,
+ }
}
/**