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