diff --git a/apps/server/prisma/migrations/20260708030000_v26_hot_query_indexes/migration.sql b/apps/server/prisma/migrations/20260708030000_v26_hot_query_indexes/migration.sql new file mode 100644 index 0000000..50fe1e5 --- /dev/null +++ b/apps/server/prisma/migrations/20260708030000_v26_hot_query_indexes/migration.sql @@ -0,0 +1,44 @@ +-- V2.6 hot query indexes for performance harness, query budget audit, +-- and background Xiaobao summary refresh scans. + +CREATE INDEX IF NOT EXISTS "projects_product_created_at_idx" + ON "projects"("product_id", "created_at" DESC); + +CREATE INDEX IF NOT EXISTS "versions_product_created_at_idx" + ON "versions"("product_id", "created_at" DESC); + +CREATE INDEX IF NOT EXISTS "versions_product_project_created_at_idx" + ON "versions"("product_id", "project_id", "created_at" DESC); + +CREATE INDEX IF NOT EXISTS "version_plans_owner_open_due_idx" + ON "version_plans"("owner_id", "expected_end_at" ASC, "updated_at" DESC) + WHERE "status" <> 'completed'; + +CREATE INDEX IF NOT EXISTS "dev_tasks_assignee_open_priority_idx" + ON "dev_tasks"("assignee_id", "priority" ASC, "updated_at" DESC) + WHERE "status" <> 'submitted'; + +CREATE INDEX IF NOT EXISTS "test_cases_assignee_open_priority_idx" + ON "test_cases"("assignee_id", "priority" ASC, "updated_at" DESC) + WHERE "status" NOT IN ('passed', 'failed', 'blocked'); + +CREATE INDEX IF NOT EXISTS "bugs_version_status_priority_updated_at_idx" + ON "bugs"("version_id", "status" ASC, "priority" ASC, "updated_at" DESC); + +CREATE INDEX IF NOT EXISTS "bugs_assignee_open_priority_idx" + ON "bugs"("assignee_id", "priority" ASC, "updated_at" DESC) + WHERE "status" IN ('open', 'fixing', 'fixed', 'verifying'); + +CREATE INDEX IF NOT EXISTS "test_cases_version_round_status_updated_at_desc_idx" + ON "test_cases"("version_id", "round_no" DESC, "status" ASC, "updated_at" DESC); + +CREATE INDEX IF NOT EXISTS "xiaobao_risk_summaries_warning_score_idx" + ON "xiaobao_risk_summaries"("risk_score" DESC, "updated_at" DESC) + WHERE "risk_level" <> 'on_track'; + +CREATE INDEX IF NOT EXISTS "xiaobao_risk_summaries_dirty_updated_at_idx" + ON "xiaobao_risk_summaries"("updated_at" ASC) + WHERE "dirty" = true; + +CREATE INDEX IF NOT EXISTS "work_activities_version_occurred_at_idx" + ON "work_activities"("version_id", "occurred_at" DESC); diff --git a/apps/server/prisma/migrations/20260708040000_v26_background_jobs/migration.sql b/apps/server/prisma/migrations/20260708040000_v26_background_jobs/migration.sql new file mode 100644 index 0000000..acae193 --- /dev/null +++ b/apps/server/prisma/migrations/20260708040000_v26_background_jobs/migration.sql @@ -0,0 +1,32 @@ +CREATE TABLE "background_jobs" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'queued', + "dedupe_key" TEXT, + "payload" JSONB NOT NULL DEFAULT '{}', + "attempts" INTEGER NOT NULL DEFAULT 0, + "max_attempts" INTEGER NOT NULL DEFAULT 3, + "available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "locked_by" TEXT, + "locked_until" TIMESTAMP(3), + "last_error" TEXT, + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "background_jobs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "background_jobs_status_available_at_idx" + ON "background_jobs"("status", "available_at" ASC, "created_at" ASC); + +CREATE INDEX "background_jobs_type_status_available_at_idx" + ON "background_jobs"("type", "status", "available_at" ASC); + +CREATE INDEX "background_jobs_lease_idx" + ON "background_jobs"("status", "locked_until" ASC) + WHERE "locked_until" IS NOT NULL; + +CREATE UNIQUE INDEX "background_jobs_active_dedupe_key_idx" + ON "background_jobs"("type", "dedupe_key") + WHERE "dedupe_key" IS NOT NULL AND "status" IN ('queued', 'running'); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index d6c8033..513f90d 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -443,6 +443,25 @@ model AuditEvent { @@map("audit_events") } +model BackgroundJob { + id String @id @default(cuid()) + type String + status String @default("queued") + dedupeKey String? @map("dedupe_key") + payload Json @default("{}") + attempts Int @default(0) + maxAttempts Int @default(3) @map("max_attempts") + availableAt DateTime @default(now()) @map("available_at") + lockedBy String? @map("locked_by") + lockedUntil DateTime? @map("locked_until") + lastError String? @map("last_error") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("background_jobs") +} + model XiaobaoRiskSummary { versionId String @id @map("version_id") riskLevel String @map("risk_level") diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index ac73119..121dfe8 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -23,6 +23,9 @@ import { MigrationModule } from './modules/migration/migration.module'; import { V22QueryModule } from './modules/v22-query/v22-query.module'; import { HealthModule } from './modules/health/health.module'; import { ConsistencyModule } from './modules/consistency/consistency.module'; +import { JobsModule } from './modules/jobs/jobs.module'; +import { XiaobaoModule } from './modules/xiaobao/xiaobao.module'; +import { OpsModule } from './modules/ops/ops.module'; @Module({ imports: [ @@ -47,6 +50,9 @@ import { ConsistencyModule } from './modules/consistency/consistency.module'; V22QueryModule, ConsistencyModule, HealthModule, + JobsModule, + XiaobaoModule, + OpsModule, AiModule, ], controllers: [], diff --git a/apps/server/src/common/interceptors/api-timing.interceptor.ts b/apps/server/src/common/interceptors/api-timing.interceptor.ts index d925438..75500b4 100644 --- a/apps/server/src/common/interceptors/api-timing.interceptor.ts +++ b/apps/server/src/common/interceptors/api-timing.interceptor.ts @@ -1,5 +1,6 @@ import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; import { finalize, Observable } from 'rxjs'; +import { recordSlowRequest } from '../../modules/ops/ops-runtime.store'; const DEFAULT_API_SLOW_REQUEST_MS = 1000; @@ -25,6 +26,7 @@ export class ApiTimingInterceptor implements NestInterceptor { const method = request.method ?? 'UNKNOWN'; const url = request.originalUrl ?? request.url ?? 'unknown-url'; this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`); + recordSlowRequest({ method, url, durationMs, thresholdMs: this.thresholdMs }); }), ); } diff --git a/apps/server/src/modules/bug/bug.module.ts b/apps/server/src/modules/bug/bug.module.ts index e9843c6..59c7be0 100644 --- a/apps/server/src/modules/bug/bug.module.ts +++ b/apps/server/src/modules/bug/bug.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; import { WorkActivityModule } from '../work-activity/work-activity.module'; import { BugController } from './bug.controller'; -import { BugService } from './bug.service'; +import { BUG_PRISMA, BugService } from './bug.service'; @Module({ imports: [WorkActivityModule], controllers: [BugController], - providers: [BugService], + providers: [ + { provide: BUG_PRISMA, useExisting: PrismaService }, + BugService, + ], exports: [BugService], }) export class BugModule {} diff --git a/apps/server/src/modules/bug/bug.service.ts b/apps/server/src/modules/bug/bug.service.ts index bde049c..4663665 100644 --- a/apps/server/src/modules/bug/bug.service.ts +++ b/apps/server/src/modules/bug/bug.service.ts @@ -1,13 +1,14 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { WorkActivityService } from '../work-activity/work-activity.service'; import { CreateBugDto } from './dto/create-bug.dto'; import { UpdateBugDto } from './dto/update-bug.dto'; +export const BUG_PRISMA = 'BUG_PRISMA'; + @Injectable() export class BugService { constructor( - private readonly prisma: PrismaService, + @Inject(BUG_PRISMA) private readonly prisma: any, private readonly workActivity: WorkActivityService, ) {} @@ -44,6 +45,7 @@ export class BugService { where: { id_versionId: { id, versionId } }, data: this.toBugData(dto), }); + await this.workActivity.markXiaobaoSummaryDirty(versionId); return { item, activities: [] }; } @@ -84,7 +86,9 @@ export class BugService { async remove(versionId: string, id: string) { await this.ensureBugInVersion(versionId, id); - return this.prisma.bug.delete({ where: { id_versionId: { id, versionId } } }); + const item = await this.prisma.bug.delete({ where: { id_versionId: { id, versionId } } }); + await this.workActivity.markXiaobaoSummaryDirty(versionId); + return item; } private toBugData(dto: Partial) { diff --git a/apps/server/src/modules/dev-task/dev-task.module.ts b/apps/server/src/modules/dev-task/dev-task.module.ts index 770f99c..b61ebea 100644 --- a/apps/server/src/modules/dev-task/dev-task.module.ts +++ b/apps/server/src/modules/dev-task/dev-task.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; import { WorkActivityModule } from '../work-activity/work-activity.module'; import { DevTaskController } from './dev-task.controller'; -import { DevTaskService } from './dev-task.service'; +import { DEV_TASK_PRISMA, DevTaskService } from './dev-task.service'; @Module({ imports: [WorkActivityModule], controllers: [DevTaskController], - providers: [DevTaskService], + providers: [ + { provide: DEV_TASK_PRISMA, useExisting: PrismaService }, + DevTaskService, + ], exports: [DevTaskService], }) export class DevTaskModule {} diff --git a/apps/server/src/modules/dev-task/dev-task.service.ts b/apps/server/src/modules/dev-task/dev-task.service.ts index 384ee95..738d6b1 100644 --- a/apps/server/src/modules/dev-task/dev-task.service.ts +++ b/apps/server/src/modules/dev-task/dev-task.service.ts @@ -1,14 +1,14 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import type { Prisma } from '@prisma/client'; -import { PrismaService } from '../../prisma/prisma.service'; +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { WorkActivityService } from '../work-activity/work-activity.service'; import { CreateDevTaskDto } from './dto/create-dev-task.dto'; import { UpdateDevTaskDto } from './dto/update-dev-task.dto'; +export const DEV_TASK_PRISMA = 'DEV_TASK_PRISMA'; + @Injectable() export class DevTaskService { constructor( - private readonly prisma: PrismaService, + @Inject(DEV_TASK_PRISMA) private readonly prisma: any, private readonly workActivity: WorkActivityService, ) {} @@ -46,6 +46,7 @@ export class DevTaskService { where: { id_versionId: { id, versionId } }, data: this.toTaskData(dto), }); + await this.workActivity.markXiaobaoSummaryDirty(versionId); return { item, activities: [] }; } @@ -99,7 +100,9 @@ export class DevTaskService { async remove(versionId: string, id: string) { await this.ensureTaskInVersion(versionId, id); - return this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } }); + const item = await this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } }); + await this.workActivity.markXiaobaoSummaryDirty(versionId); + return item; } private toTaskData(dto: Partial) { @@ -200,6 +203,6 @@ function parsePriority(value: string | number | null | undefined): number | unde return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined; } -function toJsonInput(value: unknown): Prisma.InputJsonValue { - return value as Prisma.InputJsonValue; +function toJsonInput(value: unknown): any { + return value as any; } diff --git a/apps/server/src/modules/jobs/background-job.worker.spec.ts b/apps/server/src/modules/jobs/background-job.worker.spec.ts new file mode 100644 index 0000000..bbd4818 --- /dev/null +++ b/apps/server/src/modules/jobs/background-job.worker.spec.ts @@ -0,0 +1,48 @@ +import { BackgroundJobWorker } from './background-job.worker'; + +describe('BackgroundJobWorker', () => { + it('runs a registered handler and marks the job succeeded', async () => { + const lock = { + claimNext: jest.fn().mockResolvedValue({ + id: 'job-1', + type: 'demo.job', + payload: { ok: true }, + }), + }; + const jobs = { + markSucceeded: jest.fn(), + markFailed: jest.fn(), + }; + const worker = new BackgroundJobWorker(lock as any, jobs as any); + const handler = jest.fn().mockResolvedValue(undefined); + worker.registerHandler('demo.job', handler); + + await expect(worker.runOnce({ workerId: 'worker-a' })).resolves.toBe(true); + + expect(handler).toHaveBeenCalledWith({ ok: true }, expect.objectContaining({ id: 'job-1' })); + expect(jobs.markSucceeded).toHaveBeenCalledWith('job-1'); + expect(jobs.markFailed).not.toHaveBeenCalled(); + }); + + it('marks a job failed when the handler throws', async () => { + const lock = { + claimNext: jest.fn().mockResolvedValue({ + id: 'job-1', + type: 'demo.job', + payload: {}, + }), + }; + const jobs = { + markSucceeded: jest.fn(), + markFailed: jest.fn(), + }; + const worker = new BackgroundJobWorker(lock as any, jobs as any); + const error = new Error('bad handler'); + worker.registerHandler('demo.job', jest.fn().mockRejectedValue(error)); + + await expect(worker.runOnce({ workerId: 'worker-a' })).resolves.toBe(false); + + expect(jobs.markFailed).toHaveBeenCalledWith('job-1', error, expect.any(Object)); + expect(jobs.markSucceeded).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/jobs/background-job.worker.ts b/apps/server/src/modules/jobs/background-job.worker.ts new file mode 100644 index 0000000..781ead6 --- /dev/null +++ b/apps/server/src/modules/jobs/background-job.worker.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { JobLockService } from './job-lock.service'; +import { JobsService } from './jobs.service'; +import { BackgroundJobRecord } from './jobs.types'; + +export type BackgroundJobHandler = (payload: unknown, job: BackgroundJobRecord) => Promise | void; + +export interface RunJobOnceOptions { + workerId: string; + now?: Date; + leaseMs?: number; + retryDelayMs?: number; +} + +@Injectable() +export class BackgroundJobWorker { + private readonly handlers = new Map(); + + constructor( + private readonly locks: JobLockService, + private readonly jobs: JobsService, + ) {} + + registerHandler(type: string, handler: BackgroundJobHandler) { + this.handlers.set(type, handler); + } + + async runOnce(options: RunJobOnceOptions): Promise { + const job = await this.locks.claimNext({ + workerId: options.workerId, + types: Array.from(this.handlers.keys()), + now: options.now, + leaseMs: options.leaseMs, + }); + if (!job) return false; + + const handler = this.handlers.get(job.type); + if (!handler) { + await this.jobs.markFailed(job.id, `No handler registered for ${job.type}`, { + now: options.now, + retryDelayMs: options.retryDelayMs, + }); + return false; + } + + try { + await handler(job.payload, job); + await this.jobs.markSucceeded(job.id); + return true; + } catch (error) { + await this.jobs.markFailed(job.id, error, { + now: options.now, + retryDelayMs: options.retryDelayMs, + }); + return false; + } + } +} diff --git a/apps/server/src/modules/jobs/job-lock.service.spec.ts b/apps/server/src/modules/jobs/job-lock.service.spec.ts new file mode 100644 index 0000000..fe5baa9 --- /dev/null +++ b/apps/server/src/modules/jobs/job-lock.service.spec.ts @@ -0,0 +1,51 @@ +import { JobLockService } from './job-lock.service'; + +describe('JobLockService', () => { + it('claims queued or expired jobs with a lease and increments attempts', async () => { + const tx = { + $queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 'job-1' }]), + backgroundJob: { + update: jest.fn().mockResolvedValue({ id: 'job-1', status: 'running' }), + }, + }; + const prisma = { + $transaction: jest.fn((callback) => callback(tx)), + }; + const service = new JobLockService(prisma as any); + const now = new Date('2026-07-08T10:00:00.000Z'); + + const result = await service.claimNext({ + workerId: 'worker-a', + types: ['xiaobao.summary.refresh'], + now, + leaseMs: 60_000, + }); + + expect(result).toEqual({ id: 'job-1', status: 'running' }); + expect(tx.$queryRawUnsafe.mock.calls[0][0]).toContain('FOR UPDATE SKIP LOCKED'); + expect(tx.backgroundJob.update).toHaveBeenCalledWith({ + where: { id: 'job-1' }, + data: { + status: 'running', + lockedBy: 'worker-a', + lockedUntil: new Date('2026-07-08T10:01:00.000Z'), + attempts: { increment: 1 }, + lastError: null, + }, + }); + }); + + it('returns null when no claimable jobs exist', async () => { + const tx = { + $queryRawUnsafe: jest.fn().mockResolvedValue([]), + backgroundJob: { update: jest.fn() }, + }; + const prisma = { + $transaction: jest.fn((callback) => callback(tx)), + }; + const service = new JobLockService(prisma as any); + + await expect(service.claimNext({ workerId: 'worker-a' })).resolves.toBeNull(); + expect(tx.backgroundJob.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/jobs/job-lock.service.ts b/apps/server/src/modules/jobs/job-lock.service.ts new file mode 100644 index 0000000..701a143 --- /dev/null +++ b/apps/server/src/modules/jobs/job-lock.service.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { BackgroundJobRecord, ClaimJobInput, JOBS_PRISMA } from './jobs.types'; + +const DEFAULT_LEASE_MS = 60_000; + +@Injectable() +export class JobLockService { + constructor(@Inject(JOBS_PRISMA) private readonly prisma: any) {} + + async claimNext(input: ClaimJobInput): Promise { + const now = input.now ?? new Date(); + const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS; + const types = input.types?.map((type) => type.trim()).filter(Boolean) ?? []; + + return (this.prisma as any).$transaction(async (tx: any) => { + const params: unknown[] = [now]; + const typeCondition = buildTypeCondition(types, params); + const rows = await tx.$queryRawUnsafe( + ` +SELECT id +FROM background_jobs +WHERE ( + (status = 'queued' AND available_at <= $1) + OR (status = 'running' AND locked_until IS NOT NULL AND locked_until < $1) + ) + ${typeCondition} +ORDER BY available_at ASC, created_at ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED +`, + ...params, + ); + const id = rows[0]?.id; + if (!id) return null; + + return tx.backgroundJob.update({ + where: { id }, + data: { + status: 'running', + lockedBy: input.workerId, + lockedUntil: new Date(now.getTime() + leaseMs), + attempts: { increment: 1 }, + lastError: null, + }, + }); + }); + } +} + +function buildTypeCondition(types: string[], params: unknown[]): string { + if (types.length === 0) return ''; + const placeholders = types.map((type) => { + params.push(type); + return `$${params.length}`; + }); + return `AND type IN (${placeholders.join(', ')})`; +} diff --git a/apps/server/src/modules/jobs/jobs.module.ts b/apps/server/src/modules/jobs/jobs.module.ts new file mode 100644 index 0000000..981974e --- /dev/null +++ b/apps/server/src/modules/jobs/jobs.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { BackgroundJobWorker } from './background-job.worker'; +import { JobLockService } from './job-lock.service'; +import { JobsService } from './jobs.service'; +import { JOBS_PRISMA } from './jobs.types'; + +@Module({ + providers: [ + { provide: JOBS_PRISMA, useExisting: PrismaService }, + JobsService, + JobLockService, + BackgroundJobWorker, + ], + exports: [JobsService, JobLockService, BackgroundJobWorker], +}) +export class JobsModule {} diff --git a/apps/server/src/modules/jobs/jobs.service.spec.ts b/apps/server/src/modules/jobs/jobs.service.spec.ts new file mode 100644 index 0000000..401dd2d --- /dev/null +++ b/apps/server/src/modules/jobs/jobs.service.spec.ts @@ -0,0 +1,115 @@ +import { JobsService } from './jobs.service'; + +describe('JobsService', () => { + function makeService() { + const prisma = { + backgroundJob: { + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + return { prisma, service: new JobsService(prisma as any) }; + } + + it('dedupes active jobs by dedupeKey before creating a new row', async () => { + const { prisma, service } = makeService(); + prisma.backgroundJob.findFirst.mockResolvedValue({ id: 'job-1', status: 'queued' }); + + const result = await service.enqueue({ + type: 'xiaobao.summary.refresh', + dedupeKey: 'version-1', + payload: { versionId: 'version-1' }, + }); + + expect(result).toEqual({ id: 'job-1', status: 'queued' }); + expect(prisma.backgroundJob.findFirst).toHaveBeenCalledWith({ + where: { + type: 'xiaobao.summary.refresh', + dedupeKey: 'version-1', + status: { in: ['queued', 'running'] }, + }, + orderBy: { createdAt: 'asc' }, + }); + expect(prisma.backgroundJob.create).not.toHaveBeenCalled(); + }); + + it('creates a queued job when no active dedupe match exists', async () => { + const { prisma, service } = makeService(); + prisma.backgroundJob.findFirst.mockResolvedValue(null); + prisma.backgroundJob.create.mockResolvedValue({ id: 'job-2', status: 'queued' }); + + await service.enqueue({ + type: 'xiaobao.summary.refresh', + dedupeKey: 'version-2', + payload: { versionId: 'version-2' }, + maxAttempts: 5, + }); + + expect(prisma.backgroundJob.create).toHaveBeenCalledWith({ + data: { + type: 'xiaobao.summary.refresh', + dedupeKey: 'version-2', + payload: { versionId: 'version-2' }, + maxAttempts: 5, + availableAt: undefined, + }, + }); + }); + + it('recovers from concurrent dedupe unique conflicts by returning the active job', async () => { + const { prisma, service } = makeService(); + prisma.backgroundJob.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'job-existing', status: 'queued' }); + prisma.backgroundJob.create.mockRejectedValue(Object.assign(new Error('duplicate'), { code: 'P2002' })); + + const result = await service.enqueue({ + type: 'xiaobao.summary.refresh', + dedupeKey: 'version-2', + payload: { versionId: 'version-2' }, + }); + + expect(result).toEqual({ id: 'job-existing', status: 'queued' }); + expect(prisma.backgroundJob.findFirst).toHaveBeenCalledTimes(2); + }); + + it('requeues failed jobs while attempts remain', async () => { + const { prisma, service } = makeService(); + const now = new Date('2026-07-08T10:00:00.000Z'); + prisma.backgroundJob.findUnique.mockResolvedValue({ id: 'job-1', attempts: 1, maxAttempts: 3 }); + + await service.markFailed('job-1', new Error('boom'), { now, retryDelayMs: 30_000 }); + + expect(prisma.backgroundJob.update).toHaveBeenCalledWith({ + where: { id: 'job-1' }, + data: { + status: 'queued', + availableAt: new Date('2026-07-08T10:00:30.000Z'), + lockedBy: null, + lockedUntil: null, + lastError: 'boom', + }, + }); + }); + + it('marks failed jobs terminal when max attempts is reached', async () => { + const { prisma, service } = makeService(); + const now = new Date('2026-07-08T10:00:00.000Z'); + prisma.backgroundJob.findUnique.mockResolvedValue({ id: 'job-1', attempts: 3, maxAttempts: 3 }); + + await service.markFailed('job-1', 'still broken', { now, retryDelayMs: 30_000 }); + + expect(prisma.backgroundJob.update).toHaveBeenCalledWith({ + where: { id: 'job-1' }, + data: { + status: 'failed', + availableAt: now, + lockedBy: null, + lockedUntil: null, + lastError: 'still broken', + }, + }); + }); +}); diff --git a/apps/server/src/modules/jobs/jobs.service.ts b/apps/server/src/modules/jobs/jobs.service.ts new file mode 100644 index 0000000..7c77522 --- /dev/null +++ b/apps/server/src/modules/jobs/jobs.service.ts @@ -0,0 +1,104 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ACTIVE_JOB_STATUSES, BackgroundJobRecord, EnqueueJobInput, JOBS_PRISMA, MarkFailedOptions } from './jobs.types'; + +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 60_000; +const MAX_ERROR_LENGTH = 2000; + +@Injectable() +export class JobsService { + constructor(@Inject(JOBS_PRISMA) private readonly prisma: any) {} + + async enqueue(input: EnqueueJobInput): Promise { + const type = input.type.trim(); + if (!type) throw new Error('Job type is required'); + const dedupeKey = input.dedupeKey?.trim() || null; + + if (dedupeKey) { + const existing = await this.findActiveDedupe(type, dedupeKey); + if (existing) return existing; + } + + try { + return await this.delegate.create({ + data: { + type, + dedupeKey, + payload: input.payload ?? {}, + maxAttempts: input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + availableAt: input.availableAt, + }, + }); + } catch (error) { + if (dedupeKey && isUniqueConflict(error)) { + const existing = await this.findActiveDedupe(type, dedupeKey); + if (existing) return existing; + } + throw error; + } + } + + markSucceeded(id: string, now = new Date()): Promise { + return this.delegate.update({ + where: { id }, + data: { + status: 'succeeded', + lockedBy: null, + lockedUntil: null, + completedAt: now, + lastError: null, + }, + }); + } + + async markFailed(id: string, error: unknown, options: MarkFailedOptions = {}): Promise { + const job = await this.delegate.findUnique({ where: { id } }); + if (!job) throw new NotFoundException('Background job not found'); + + const now = options.now ?? new Date(); + const attempts = Number(job.attempts ?? 0); + const maxAttempts = Number(job.maxAttempts ?? DEFAULT_MAX_ATTEMPTS); + const hasRetriesLeft = attempts < maxAttempts; + const availableAt = hasRetriesLeft + ? new Date(now.getTime() + (options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS)) + : now; + + return this.delegate.update({ + where: { id }, + data: { + status: hasRetriesLeft ? 'queued' : 'failed', + availableAt, + lockedBy: null, + lockedUntil: null, + lastError: normalizeError(error), + }, + }); + } + + private get delegate(): any { + return (this.prisma as any).backgroundJob; + } + + private findActiveDedupe(type: string, dedupeKey: string): Promise { + return this.delegate.findFirst({ + where: { + type, + dedupeKey, + status: { in: [...ACTIVE_JOB_STATUSES] }, + }, + orderBy: { createdAt: 'asc' }, + }); + } +} + +function normalizeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.slice(0, MAX_ERROR_LENGTH); +} + +function isUniqueConflict(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && (error as { code?: unknown }).code === 'P2002'; +} diff --git a/apps/server/src/modules/jobs/jobs.types.ts b/apps/server/src/modules/jobs/jobs.types.ts new file mode 100644 index 0000000..203c70c --- /dev/null +++ b/apps/server/src/modules/jobs/jobs.types.ts @@ -0,0 +1,38 @@ +export const ACTIVE_JOB_STATUSES = ['queued', 'running'] as const; +export const JOBS_PRISMA = 'JOBS_PRISMA'; + +export type BackgroundJobStatus = 'queued' | 'running' | 'succeeded' | 'failed'; + +export interface BackgroundJobRecord { + id: string; + type: string; + status: BackgroundJobStatus; + dedupeKey?: string | null; + payload: unknown; + attempts: number; + maxAttempts: number; + availableAt?: Date; + lockedBy?: string | null; + lockedUntil?: Date | null; + lastError?: string | null; +} + +export interface EnqueueJobInput { + type: string; + dedupeKey?: string | null; + payload?: unknown; + maxAttempts?: number; + availableAt?: Date; +} + +export interface ClaimJobInput { + workerId: string; + types?: string[]; + now?: Date; + leaseMs?: number; +} + +export interface MarkFailedOptions { + now?: Date; + retryDelayMs?: number; +} diff --git a/apps/server/src/modules/ops/ops-permission.adapter.ts b/apps/server/src/modules/ops/ops-permission.adapter.ts new file mode 100644 index 0000000..62320f0 --- /dev/null +++ b/apps/server/src/modules/ops/ops-permission.adapter.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { OPS_VIEW_PERMISSION } from './ops.service'; + +@Injectable() +export class OpsPermissionAdapter { + assertCanViewOps(_request: unknown) { + // V2.5 backend RBAC is not landed yet. Keep this adapter as the single + // replacement point for a real guard instead of coupling Ops to a temporary shape. + return { requiredPermission: OPS_VIEW_PERMISSION, enforced: false }; + } +} diff --git a/apps/server/src/modules/ops/ops-runtime.store.spec.ts b/apps/server/src/modules/ops/ops-runtime.store.spec.ts new file mode 100644 index 0000000..b960af0 --- /dev/null +++ b/apps/server/src/modules/ops/ops-runtime.store.spec.ts @@ -0,0 +1,53 @@ +import { + clearOpsRuntimeEventsForTests, + getOpsRuntimeEvents, + recordSlowPrismaQuery, + recordSlowRequest, +} from './ops-runtime.store'; + +describe('ops runtime store', () => { + afterEach(() => clearOpsRuntimeEventsForTests()); + + it('records recent slow requests without leaking query-string secrets', () => { + recordSlowRequest({ + method: 'GET', + url: '/api/v1/config/ai?apiKey=sk-secret&visible=1', + durationMs: 1300, + thresholdMs: 1000, + occurredAt: new Date('2026-07-08T08:00:00.000Z'), + }); + + const events = getOpsRuntimeEvents(); + + expect(events.slowRequests).toEqual([ + expect.objectContaining({ + method: 'GET', + path: '/api/v1/config/ai', + durationMs: 1300, + thresholdMs: 1000, + occurredAt: '2026-07-08T08:00:00.000Z', + }), + ]); + expect(JSON.stringify(events)).not.toContain('sk-secret'); + }); + + it('records recent slow Prisma queries with redacted and bounded previews', () => { + recordSlowPrismaQuery({ + query: `SELECT * FROM ai_logs WHERE metadata::text LIKE '%sk-secret-token%' ${'x'.repeat(400)}`, + durationMs: 450, + thresholdMs: 300, + occurredAt: new Date('2026-07-08T08:01:00.000Z'), + }); + + const [event] = getOpsRuntimeEvents().slowQueries; + + expect(event).toEqual(expect.objectContaining({ + durationMs: 450, + thresholdMs: 300, + occurredAt: '2026-07-08T08:01:00.000Z', + })); + expect(event.queryPreview).toContain('[redacted]'); + expect(event.queryPreview.length).toBeLessThanOrEqual(240); + expect(event.queryPreview).not.toContain('sk-secret-token'); + }); +}); diff --git a/apps/server/src/modules/ops/ops-runtime.store.ts b/apps/server/src/modules/ops/ops-runtime.store.ts new file mode 100644 index 0000000..2617c45 --- /dev/null +++ b/apps/server/src/modules/ops/ops-runtime.store.ts @@ -0,0 +1,116 @@ +const MAX_EVENTS = 50; +const MAX_PREVIEW_LENGTH = 240; + +export interface SlowRequestEvent { + id: string; + method: string; + path: string; + durationMs: number; + thresholdMs: number; + occurredAt: string; +} + +export interface SlowPrismaQueryEvent { + id: string; + queryPreview: string; + durationMs: number; + thresholdMs: number; + occurredAt: string; +} + +export interface OpsRuntimeEvents { + slowRequests: SlowRequestEvent[]; + slowQueries: SlowPrismaQueryEvent[]; +} + +interface RecordSlowRequestInput { + method: string; + url: string; + durationMs: number; + thresholdMs: number; + occurredAt?: Date; +} + +interface RecordSlowPrismaQueryInput { + query: string; + durationMs: number; + thresholdMs: number; + occurredAt?: Date; +} + +const slowRequests: SlowRequestEvent[] = []; +const slowQueries: SlowPrismaQueryEvent[] = []; +let nextId = 1; + +export function recordSlowRequest(input: RecordSlowRequestInput) { + slowRequests.unshift({ + id: makeId('req'), + method: normalizeMethod(input.method), + path: sanitizeRequestPath(input.url), + durationMs: Math.round(input.durationMs), + thresholdMs: Math.round(input.thresholdMs), + occurredAt: (input.occurredAt ?? new Date()).toISOString(), + }); + trim(slowRequests); +} + +export function recordSlowPrismaQuery(input: RecordSlowPrismaQueryInput) { + slowQueries.unshift({ + id: makeId('qry'), + queryPreview: truncate(redactText(input.query.replace(/\s+/g, ' ').trim()), MAX_PREVIEW_LENGTH), + durationMs: Math.round(input.durationMs), + thresholdMs: Math.round(input.thresholdMs), + occurredAt: (input.occurredAt ?? new Date()).toISOString(), + }); + trim(slowQueries); +} + +export function getOpsRuntimeEvents(): OpsRuntimeEvents { + return { + slowRequests: slowRequests.map((event) => ({ ...event })), + slowQueries: slowQueries.map((event) => ({ ...event })), + }; +} + +export function clearOpsRuntimeEventsForTests() { + slowRequests.splice(0, slowRequests.length); + slowQueries.splice(0, slowQueries.length); + nextId = 1; +} + +export function redactText(value: string, maxLength = 500): string { + const redacted = value + .replace(/(^|[^A-Za-z0-9])sk-[A-Za-z0-9_-]+/g, '$1[redacted]') + .replace(/(api[_-]?key|token|secret|password|authorization)(\s*[=:]\s*)(["']?)[^&\s"']+/gi, '$1$2$3[redacted]'); + return truncate(redacted, maxLength); +} + +function sanitizeRequestPath(url: string): string { + const raw = String(url || 'unknown-url'); + try { + const parsed = new URL(raw, 'http://local.invalid'); + return redactText(parsed.pathname || '/'); + } catch { + return redactText(raw.split('?')[0] || 'unknown-url'); + } +} + +function normalizeMethod(method: string): string { + const value = String(method || 'UNKNOWN').toUpperCase(); + return /^[A-Z]+$/.test(value) ? value : 'UNKNOWN'; +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 3))}...`; +} + +function trim(items: T[]) { + if (items.length > MAX_EVENTS) items.splice(MAX_EVENTS); +} + +function makeId(prefix: string): string { + const id = `${prefix}-${Date.now().toString(36)}-${nextId.toString(36)}`; + nextId += 1; + return id; +} diff --git a/apps/server/src/modules/ops/ops.controller.spec.ts b/apps/server/src/modules/ops/ops.controller.spec.ts new file mode 100644 index 0000000..87e6c8e --- /dev/null +++ b/apps/server/src/modules/ops/ops.controller.spec.ts @@ -0,0 +1,19 @@ +import { OpsController } from './ops.controller'; + +describe('OpsController', () => { + it('checks the ops:view adapter before returning runtime snapshot', async () => { + const service = { + getRuntimeSnapshot: jest.fn().mockResolvedValue({ ok: true }), + }; + const permissions = { + assertCanViewOps: jest.fn(), + }; + const controller = new OpsController(service as any, permissions as any); + const request = { headers: {} }; + + await expect(controller.getRuntime(request as any)).resolves.toEqual({ ok: true }); + + expect(permissions.assertCanViewOps).toHaveBeenCalledWith(request); + expect(service.getRuntimeSnapshot).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/ops/ops.controller.ts b/apps/server/src/modules/ops/ops.controller.ts new file mode 100644 index 0000000..d3e8f93 --- /dev/null +++ b/apps/server/src/modules/ops/ops.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Req } from '@nestjs/common'; +import { OpsPermissionAdapter } from './ops-permission.adapter'; +import { OpsService } from './ops.service'; + +@Controller('ops') +export class OpsController { + constructor( + private readonly ops: OpsService, + private readonly permissions: OpsPermissionAdapter, + ) {} + + @Get('runtime') + getRuntime(@Req() request: unknown) { + this.permissions.assertCanViewOps(request); + return this.ops.getRuntimeSnapshot(); + } +} diff --git a/apps/server/src/modules/ops/ops.module.ts b/apps/server/src/modules/ops/ops.module.ts new file mode 100644 index 0000000..6689fea --- /dev/null +++ b/apps/server/src/modules/ops/ops.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { OpsController } from './ops.controller'; +import { OpsPermissionAdapter } from './ops-permission.adapter'; +import { OPS_PRISMA, OpsService } from './ops.service'; + +@Module({ + controllers: [OpsController], + providers: [ + { provide: OPS_PRISMA, useExisting: PrismaService }, + OpsService, + OpsPermissionAdapter, + ], + exports: [OpsService, OpsPermissionAdapter], +}) +export class OpsModule {} diff --git a/apps/server/src/modules/ops/ops.service.spec.ts b/apps/server/src/modules/ops/ops.service.spec.ts new file mode 100644 index 0000000..039c94d --- /dev/null +++ b/apps/server/src/modules/ops/ops.service.spec.ts @@ -0,0 +1,89 @@ +import { recordSlowPrismaQuery, recordSlowRequest, clearOpsRuntimeEventsForTests } from './ops-runtime.store'; +import { OpsService } from './ops.service'; + +describe('OpsService', () => { + afterEach(() => clearOpsRuntimeEventsForTests()); + + it('returns runtime performance counters without exposing secrets', async () => { + const prisma = { + backgroundJob: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'job-queued', + type: 'xiaobao.summary.refresh', + status: 'queued', + attempts: 0, + maxAttempts: 5, + availableAt: new Date('2026-07-08T08:00:00.000Z'), + lockedUntil: null, + lastError: null, + updatedAt: new Date('2026-07-08T08:00:00.000Z'), + }, + { + id: 'job-running', + type: 'xiaobao.ai.interpret', + status: 'running', + attempts: 1, + maxAttempts: 3, + availableAt: new Date('2026-07-08T08:01:00.000Z'), + lockedUntil: new Date('2026-07-08T08:05:00.000Z'), + lastError: null, + updatedAt: new Date('2026-07-08T08:02:00.000Z'), + }, + { + id: 'job-failed', + type: 'xiaobao.ai.interpret', + status: 'failed', + attempts: 3, + maxAttempts: 3, + availableAt: new Date('2026-07-08T08:03:00.000Z'), + lockedUntil: null, + lastError: 'provider failed with sk-secret-token', + updatedAt: new Date('2026-07-08T08:04:00.000Z'), + }, + ]), + }, + xiaobaoRiskSummary: { + count: jest.fn().mockResolvedValue(7), + }, + }; + recordSlowRequest({ + method: 'POST', + url: '/api/v1/ai/risk-interpret?token=sk-secret-token', + durationMs: 1500, + thresholdMs: 1000, + occurredAt: new Date('2026-07-08T08:05:00.000Z'), + }); + recordSlowPrismaQuery({ + query: 'SELECT * FROM background_jobs WHERE last_error = "sk-secret-token"', + durationMs: 420, + thresholdMs: 300, + occurredAt: new Date('2026-07-08T08:06:00.000Z'), + }); + const service = new OpsService(prisma as any); + + const snapshot = await service.getRuntimeSnapshot(new Date('2026-07-08T08:07:00.000Z')); + + expect(snapshot.dirtySummaryCount).toBe(7); + expect(snapshot.jobQueue.totals).toEqual({ queued: 1, running: 1, succeeded: 0, failed: 1, total: 3 }); + expect(snapshot.jobQueue.byType).toEqual([ + expect.objectContaining({ type: 'xiaobao.ai.interpret', running: 1, failed: 1, total: 2 }), + expect.objectContaining({ type: 'xiaobao.summary.refresh', queued: 1, total: 1 }), + ]); + expect(snapshot.jobQueue.recentFailures).toEqual([ + expect.objectContaining({ + id: 'job-failed', + type: 'xiaobao.ai.interpret', + lastError: expect.stringContaining('[redacted]'), + }), + ]); + expect(snapshot.slowRequests[0].path).toBe('/api/v1/ai/risk-interpret'); + expect(snapshot.slowQueries[0].queryPreview).toContain('[redacted]'); + expect(JSON.stringify(snapshot)).not.toContain('sk-secret-token'); + expect(snapshot.access).toEqual({ + requiredPermission: 'ops:view', + backendEnforced: false, + adapter: 'OpsPermissionAdapter', + }); + }); +}); diff --git a/apps/server/src/modules/ops/ops.service.ts b/apps/server/src/modules/ops/ops.service.ts new file mode 100644 index 0000000..8b7b9da --- /dev/null +++ b/apps/server/src/modules/ops/ops.service.ts @@ -0,0 +1,148 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { resolveApiSlowRequestThreshold } from '../../common/interceptors/api-timing.interceptor'; +import { resolvePrismaSlowQueryThreshold } from '../../prisma/prisma-monitoring'; +import { getOpsRuntimeEvents, redactText } from './ops-runtime.store'; + +export const OPS_PRISMA = 'OPS_PRISMA'; +export const OPS_VIEW_PERMISSION = 'ops:view'; + +type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'; + +interface BackgroundJobRow { + id: string; + type: string; + status: string; + attempts?: number | null; + maxAttempts?: number | null; + availableAt?: Date | string | null; + lockedUntil?: Date | string | null; + lastError?: string | null; + updatedAt?: Date | string | null; +} + +@Injectable() +export class OpsService { + constructor(@Inject(OPS_PRISMA) private readonly prisma: any) {} + + async getRuntimeSnapshot(now = new Date()) { + const events = getOpsRuntimeEvents(); + const database = { ok: true, error: undefined as string | undefined }; + let jobRows: BackgroundJobRow[] = []; + let dirtySummaryCount = 0; + + try { + [jobRows, dirtySummaryCount] = await Promise.all([ + this.prisma.backgroundJob.findMany({ + select: { + id: true, + type: true, + status: true, + attempts: true, + maxAttempts: true, + availableAt: true, + lockedUntil: true, + lastError: true, + updatedAt: true, + }, + orderBy: { updatedAt: 'desc' }, + take: 200, + }), + this.prisma.xiaobaoRiskSummary.count({ where: { dirty: true } }), + ]); + } catch (error) { + database.ok = false; + database.error = redactText(error instanceof Error ? error.message : String(error), 300); + } + + return { + collectedAt: now.toISOString(), + thresholds: { + apiSlowRequestMs: resolveApiSlowRequestThreshold(), + prismaSlowQueryMs: resolvePrismaSlowQueryThreshold(), + }, + database, + slowRequests: events.slowRequests, + slowQueries: events.slowQueries, + jobQueue: buildJobQueue(jobRows), + dirtySummaryCount, + access: { + requiredPermission: OPS_VIEW_PERMISSION, + backendEnforced: false, + adapter: 'OpsPermissionAdapter', + }, + }; + } +} + +function buildJobQueue(rows: BackgroundJobRow[]) { + const totals = emptyStatusCounts(); + const byType = new Map>(); + const recentFailures = rows + .filter((row) => normalizeStatus(row.status) === 'failed') + .slice(0, 10) + .map((row) => ({ + id: row.id, + type: row.type, + attempts: row.attempts ?? 0, + maxAttempts: row.maxAttempts ?? 0, + lastError: redactText(row.lastError ?? '', 300), + updatedAt: toIso(row.updatedAt), + })); + + for (const row of rows) { + const status = normalizeStatus(row.status); + totals[status] += 1; + totals.total += 1; + + const current = byType.get(row.type) ?? emptyTypeCounts(row.type); + current[status] += 1; + current.total += 1; + if (status === 'queued') { + current.oldestQueuedAt = minIso(current.oldestQueuedAt, toIso(row.availableAt)); + } + if (status === 'running') { + current.nextLeaseExpiresAt = minIso(current.nextLeaseExpiresAt, toIso(row.lockedUntil)); + } + byType.set(row.type, current); + } + + return { + totals, + byType: Array.from(byType.values()).sort((a, b) => b.total - a.total || a.type.localeCompare(b.type)), + recentFailures, + }; +} + +function emptyStatusCounts(): Record & { total: number } { + return { queued: 0, running: 0, succeeded: 0, failed: 0, total: 0 }; +} + +function emptyTypeCounts(type: string) { + return { + type, + queued: 0, + running: 0, + succeeded: 0, + failed: 0, + total: 0, + oldestQueuedAt: undefined as string | undefined, + nextLeaseExpiresAt: undefined as string | undefined, + }; +} + +function normalizeStatus(status: string): JobStatus { + return status === 'running' || status === 'succeeded' || status === 'failed' ? status : 'queued'; +} + +function toIso(value: Date | string | null | undefined): string | undefined { + if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : undefined; + if (typeof value !== 'string') return undefined; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; +} + +function minIso(current: string | undefined, next: string | undefined): string | undefined { + if (!next) return current; + if (!current) return next; + return next < current ? next : current; +} diff --git a/apps/server/src/modules/test-case/test-case.module.ts b/apps/server/src/modules/test-case/test-case.module.ts index ee97dad..eb3dee1 100644 --- a/apps/server/src/modules/test-case/test-case.module.ts +++ b/apps/server/src/modules/test-case/test-case.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; import { WorkActivityModule } from '../work-activity/work-activity.module'; import { TestCaseController } from './test-case.controller'; -import { TestCaseService } from './test-case.service'; +import { TEST_CASE_PRISMA, TestCaseService } from './test-case.service'; @Module({ imports: [WorkActivityModule], controllers: [TestCaseController], - providers: [TestCaseService], + providers: [ + { provide: TEST_CASE_PRISMA, useExisting: PrismaService }, + TestCaseService, + ], exports: [TestCaseService], }) export class TestCaseModule {} diff --git a/apps/server/src/modules/test-case/test-case.service.ts b/apps/server/src/modules/test-case/test-case.service.ts index 7e658c5..bb7d63e 100644 --- a/apps/server/src/modules/test-case/test-case.service.ts +++ b/apps/server/src/modules/test-case/test-case.service.ts @@ -1,14 +1,14 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import type { Prisma } from '@prisma/client'; -import { PrismaService } from '../../prisma/prisma.service'; +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { WorkActivityService } from '../work-activity/work-activity.service'; import { CreateTestCaseDto } from './dto/create-test-case.dto'; import { UpdateTestCaseDto } from './dto/update-test-case.dto'; +export const TEST_CASE_PRISMA = 'TEST_CASE_PRISMA'; + @Injectable() export class TestCaseService { constructor( - private readonly prisma: PrismaService, + @Inject(TEST_CASE_PRISMA) private readonly prisma: any, private readonly workActivity: WorkActivityService, ) {} @@ -45,7 +45,7 @@ export class TestCaseService { const items = await this.prisma.testCase.findMany({ where: { versionId, code: { in: rows.map((row) => row.code) } }, }); - const activities = await Promise.all(items.map((item) => ( + const activities = await Promise.all(items.map((item: any) => ( this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`) ))); return { items, activities }; @@ -64,6 +64,7 @@ export class TestCaseService { where: { id_versionId: { id, versionId } }, data: this.toTestCaseData(dto), }); + await this.workActivity.markXiaobaoSummaryDirty(versionId); return { item, activities: [] }; } @@ -84,7 +85,9 @@ export class TestCaseService { async remove(versionId: string, id: string) { await this.ensureTestCaseInVersion(versionId, id); - return this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } }); + const item = await this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } }); + await this.workActivity.markXiaobaoSummaryDirty(versionId); + return item; } private toTestCaseData(dto: Partial) { @@ -189,6 +192,6 @@ function parsePriority(value: string | number | null | undefined): number | unde return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined; } -function toJsonInput(value: unknown): Prisma.InputJsonValue { - return value as Prisma.InputJsonValue; +function toJsonInput(value: unknown): any { + return value as any; } diff --git a/apps/server/src/modules/version-plan/version-plan.module.ts b/apps/server/src/modules/version-plan/version-plan.module.ts index d63834d..174c3cc 100644 --- a/apps/server/src/modules/version-plan/version-plan.module.ts +++ b/apps/server/src/modules/version-plan/version-plan.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; import { WorkActivityModule } from '../work-activity/work-activity.module'; import { VersionPlanController } from './version-plan.controller'; -import { VersionPlanService } from './version-plan.service'; +import { VERSION_PLAN_PRISMA, VersionPlanService } from './version-plan.service'; @Module({ imports: [WorkActivityModule], controllers: [VersionPlanController], - providers: [VersionPlanService], + providers: [ + { provide: VERSION_PLAN_PRISMA, useExisting: PrismaService }, + VersionPlanService, + ], exports: [VersionPlanService], }) export class VersionPlanModule {} diff --git a/apps/server/src/modules/version-plan/version-plan.service.ts b/apps/server/src/modules/version-plan/version-plan.service.ts index 43a5ad4..647a431 100644 --- a/apps/server/src/modules/version-plan/version-plan.service.ts +++ b/apps/server/src/modules/version-plan/version-plan.service.ts @@ -1,14 +1,14 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import type { Prisma } from '@prisma/client'; -import { PrismaService } from '../../prisma/prisma.service'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { WorkActivityService } from '../work-activity/work-activity.service'; import { CreateVersionPlanDto } from './dto/create-version-plan.dto'; import { UpdateVersionPlanDto } from './dto/update-version-plan.dto'; +export const VERSION_PLAN_PRISMA = 'VERSION_PLAN_PRISMA'; + @Injectable() export class VersionPlanService { constructor( - private readonly prisma: PrismaService, + @Inject(VERSION_PLAN_PRISMA) private readonly prisma: any, private readonly workActivity: WorkActivityService, ) {} @@ -50,6 +50,7 @@ export class VersionPlanService { data, }); const activity = current.status !== item.status ? await this.recordStatusActivity(item, current.status, item.status) : undefined; + if (!activity) await this.workActivity.markXiaobaoSummaryDirty(versionId); return { item, activities: activity ? [activity] : [] }; } @@ -63,7 +64,9 @@ export class VersionPlanService { async remove(versionId: string, id: string) { await this.ensurePlanInVersion(versionId, id); - return this.prisma.versionPlan.delete({ where: { id } }); + const item = await this.prisma.versionPlan.delete({ where: { id } }); + await this.workActivity.markXiaobaoSummaryDirty(versionId); + return item; } private toPlanData(dto: Partial) { @@ -147,6 +150,6 @@ function parseOptionalDate(value: string | null | undefined): Date | null { return Number.isFinite(date.getTime()) ? date : null; } -function toJsonInput(value: unknown): Prisma.InputJsonValue { - return value as Prisma.InputJsonValue; +function toJsonInput(value: unknown): any { + return value as any; } diff --git a/apps/server/src/modules/work-activity/work-activity.module.ts b/apps/server/src/modules/work-activity/work-activity.module.ts index ab70dee..6229a02 100644 --- a/apps/server/src/modules/work-activity/work-activity.module.ts +++ b/apps/server/src/modules/work-activity/work-activity.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { XiaobaoModule } from '../xiaobao/xiaobao.module'; import { WorkActivityController } from './work-activity.controller'; -import { WorkActivityService } from './work-activity.service'; +import { WORK_ACTIVITY_PRISMA, WorkActivityService } from './work-activity.service'; @Module({ + imports: [XiaobaoModule], controllers: [WorkActivityController], - providers: [WorkActivityService], + providers: [ + { provide: WORK_ACTIVITY_PRISMA, useExisting: PrismaService }, + WorkActivityService, + ], exports: [WorkActivityService], }) export class WorkActivityModule {} diff --git a/apps/server/src/modules/work-activity/work-activity.service.spec.ts b/apps/server/src/modules/work-activity/work-activity.service.spec.ts index 2540c1c..0f0008e 100644 --- a/apps/server/src/modules/work-activity/work-activity.service.spec.ts +++ b/apps/server/src/modules/work-activity/work-activity.service.spec.ts @@ -60,4 +60,24 @@ describe('WorkActivityService relational evidence', () => { }), ); }); + + it('enqueues a Xiaobao summary refresh when the background risk service is available', async () => { + const prisma = { + workActivity: { + create: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }, + xiaobaoRiskSummary: { + upsert: jest.fn(), + }, + }; + const xiaobaoRisk = { + markDirtyAndEnqueue: jest.fn().mockResolvedValue({ id: 'job-1' }), + }; + const service = new WorkActivityService(prisma as any, xiaobaoRisk as any); + + await service.markXiaobaoSummaryDirty('version-1'); + + expect(xiaobaoRisk.markDirtyAndEnqueue).toHaveBeenCalledWith('version-1'); + expect(prisma.xiaobaoRiskSummary.upsert).not.toHaveBeenCalled(); + }); }); diff --git a/apps/server/src/modules/work-activity/work-activity.service.ts b/apps/server/src/modules/work-activity/work-activity.service.ts index 42305ec..aec32f1 100644 --- a/apps/server/src/modules/work-activity/work-activity.service.ts +++ b/apps/server/src/modules/work-activity/work-activity.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; +import { Inject, Injectable, Optional } from '@nestjs/common'; +import { XiaobaoRiskService } from '../xiaobao/xiaobao-risk.service'; export interface WorkActivityRecordInput { versionId?: string | null; @@ -18,9 +18,14 @@ export interface WorkActivityRecordInput { occurredAt?: string | Date | null; } +export const WORK_ACTIVITY_PRISMA = 'WORK_ACTIVITY_PRISMA'; + @Injectable() export class WorkActivityService { - constructor(private readonly prisma: PrismaService) {} + constructor( + @Inject(WORK_ACTIVITY_PRISMA) private readonly prisma: any, + @Optional() private readonly xiaobaoRisk?: XiaobaoRiskService, + ) {} findAll() { return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } }); @@ -64,6 +69,11 @@ export class WorkActivityService { } async markXiaobaoSummaryDirty(versionId: string) { + if (this.xiaobaoRisk) { + await this.xiaobaoRisk.markDirtyAndEnqueue(versionId); + return; + } + const riskSignature = `dirty:${versionId}`; await this.prisma.xiaobaoRiskSummary.upsert({ where: { versionId }, diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts new file mode 100644 index 0000000..693bac1 --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { AiService } from '../ai/ai.service'; +import { AiModule } from '../ai/ai.module'; +import { JobsModule } from '../jobs/jobs.module'; +import { XiaobaoAiService } from './xiaobao-ai.service'; +import { XIAOBAO_AI_INTERPRETER, XIAOBAO_AI_PRISMA } from './xiaobao-ai.types'; +import { XiaobaoAiWorker } from './xiaobao-ai.worker'; + +@Module({ + imports: [AiModule, JobsModule], + providers: [ + { provide: XIAOBAO_AI_PRISMA, useExisting: PrismaService }, + { provide: XIAOBAO_AI_INTERPRETER, useExisting: AiService }, + XiaobaoAiService, + XiaobaoAiWorker, + ], + exports: [XiaobaoAiService, XiaobaoAiWorker], +}) +export class XiaobaoAiModule {} diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts new file mode 100644 index 0000000..329af0d --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts @@ -0,0 +1,259 @@ +import { JobsService } from '../jobs/jobs.service'; +import type { XiaobaoRiskSummaryPayload } from '../xiaobao/xiaobao-risk.types'; +import { XiaobaoAiService } from './xiaobao-ai.service'; +import { XIAOBAO_AI_INTERPRET_JOB } from './xiaobao-ai.types'; + +describe('XiaobaoAiService', () => { + function makeService() { + const prisma = { + xiaobaoRiskSummary: { + findUnique: jest.fn(), + }, + xiaobaoRiskInsight: { + findFirst: jest.fn(), + create: jest.fn(), + }, + version: { update: jest.fn() }, + devTask: { update: jest.fn() }, + testCase: { update: jest.fn() }, + bug: { update: jest.fn() }, + requirement: { update: jest.fn() }, + }; + const jobs = { + enqueue: jest.fn().mockResolvedValue({ id: 'job-1', status: 'queued' }), + }; + const ai = { + interpretRisk: jest.fn(), + }; + return { + prisma, + jobs, + ai, + service: new XiaobaoAiService(prisma as any, jobs as unknown as JobsService, ai as any), + }; + } + + it('enqueues an AI interpretation job for high-risk summaries without reusable cache', async () => { + const { prisma, jobs, service } = makeService(); + const summary = buildSummary({ riskLevel: 'at_risk', riskScore: 68, riskSignature: 'version-1|at_risk|68' }); + prisma.xiaobaoRiskInsight.findFirst.mockResolvedValue(null); + + const result = await service.evaluateSummary(summary, { now: new Date('2026-07-08T08:00:00.000Z') }); + + expect(result).toEqual({ enqueued: true, reason: 'queued', jobId: 'job-1' }); + expect(jobs.enqueue).toHaveBeenCalledWith({ + type: XIAOBAO_AI_INTERPRET_JOB, + dedupeKey: 'version-1:version-1|at_risk|68', + payload: { versionId: 'version-1', riskSignature: 'version-1|at_risk|68' }, + maxAttempts: 3, + }); + }); + + it('skips enqueue when the same risk signature already has a generated insight', async () => { + const { prisma, jobs, service } = makeService(); + const summary = buildSummary({ riskSignature: 'version-1|blocked|90' }); + prisma.xiaobaoRiskInsight.findFirst.mockResolvedValueOnce({ + versionId: 'version-1', + riskSignature: 'version-1|blocked|90', + createdAt: new Date('2026-07-08T07:00:00.000Z'), + }); + + const result = await service.evaluateSummary(summary, { now: new Date('2026-07-08T08:00:00.000Z') }); + + expect(result).toEqual({ enqueued: false, reason: 'cache_hit' }); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); + + it('respects the six-hour cooldown when risk level has not escalated', async () => { + const { prisma, jobs, service } = makeService(); + const summary = buildSummary({ riskLevel: 'at_risk', riskScore: 70, riskSignature: 'version-1|at_risk|70' }); + prisma.xiaobaoRiskInsight.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + versionId: 'version-1', + riskSignature: 'version-1|at_risk|64', + createdAt: new Date('2026-07-08T06:00:00.000Z'), + }); + + const result = await service.evaluateSummary(summary, { now: new Date('2026-07-08T08:00:00.000Z') }); + + expect(result).toEqual({ enqueued: false, reason: 'cooldown' }); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); + + it('bypasses cooldown when the risk level escalates', async () => { + const { prisma, jobs, service } = makeService(); + const summary = buildSummary({ riskLevel: 'blocked', riskScore: 92, riskSignature: 'version-1|blocked|92' }); + prisma.xiaobaoRiskInsight.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + versionId: 'version-1', + riskSignature: 'version-1|at_risk|70', + createdAt: new Date('2026-07-08T06:00:00.000Z'), + }); + + const result = await service.evaluateSummary(summary, { now: new Date('2026-07-08T08:00:00.000Z') }); + + expect(result).toEqual({ enqueued: true, reason: 'risk_escalated', jobId: 'job-1' }); + expect(jobs.enqueue).toHaveBeenCalled(); + }); + + it('does not enqueue on-track summaries', async () => { + const { jobs, service } = makeService(); + + const result = await service.evaluateSummary(buildSummary({ riskLevel: 'on_track', riskScore: 10 }), { + now: new Date('2026-07-08T08:00:00.000Z'), + }); + + expect(result).toEqual({ enqueued: false, reason: 'policy_skip' }); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); + + it('generates and stores an insight cache item without mutating business entities', async () => { + const { prisma, ai, service } = makeService(); + const now = new Date('2026-07-08T08:00:00.000Z'); + const summary = buildSummary({ riskLevel: 'blocked', riskScore: 90, riskSignature: 'version-1|blocked|90' }); + prisma.xiaobaoRiskSummary.findUnique.mockResolvedValue({ + versionId: 'version-1', + riskSignature: summary.riskSignature, + summary, + }); + prisma.xiaobaoRiskInsight.findFirst.mockResolvedValue(null); + ai.interpretRisk.mockResolvedValue({ + ok: true, + result: { + summary: '存在发布阻塞', + why: ['关键缺陷未关闭'], + forecast: '预计延期', + suggestedActions: ['优先修复阻塞项'], + ownerHints: ['测试负责人跟进复测'], + generatedAt: 'model-time-should-not-be-cache-time', + }, + meta: { model: 'test-model', inputTokens: 10, outputTokens: 20, durationMs: 100 }, + }); + + await service.runInterpretation( + { versionId: 'version-1', riskSignature: 'version-1|blocked|90' }, + { now }, + ); + + expect(ai.interpretRisk).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + riskLevel: 'blocked', + reasons: [{ key: 'critical_bug', label: '关键缺陷', severity: 'critical', detail: '仍有关键缺陷' }], + })); + expect(prisma.xiaobaoRiskInsight.create).toHaveBeenCalledWith({ + data: { + versionId: 'version-1', + riskSignature: 'version-1|blocked|90', + status: 'generated', + insight: { + versionId: 'version-1', + riskSignature: 'version-1|blocked|90', + generatedAt: now.toISOString(), + insight: expect.objectContaining({ summary: '存在发布阻塞' }), + providerInfo: { model: 'test-model' }, + }, + createdAt: now, + }, + }); + expect(prisma.version.update).not.toHaveBeenCalled(); + expect(prisma.devTask.update).not.toHaveBeenCalled(); + expect(prisma.testCase.update).not.toHaveBeenCalled(); + expect(prisma.bug.update).not.toHaveBeenCalled(); + expect(prisma.requirement.update).not.toHaveBeenCalled(); + }); + + it('skips stale interpretation jobs when the summary signature has moved on', async () => { + const { prisma, ai, service } = makeService(); + prisma.xiaobaoRiskSummary.findUnique.mockResolvedValue({ + versionId: 'version-1', + riskSignature: 'version-1|blocked|95', + summary: buildSummary({ riskSignature: 'version-1|blocked|95' }), + }); + + await service.runInterpretation({ versionId: 'version-1', riskSignature: 'version-1|blocked|90' }); + + expect(ai.interpretRisk).not.toHaveBeenCalled(); + expect(prisma.xiaobaoRiskInsight.create).not.toHaveBeenCalled(); + }); + + it('throws when AI interpretation fails so the background job can retry', async () => { + const { prisma, ai, service } = makeService(); + const summary = buildSummary({ riskSignature: 'version-1|blocked|90' }); + prisma.xiaobaoRiskSummary.findUnique.mockResolvedValue({ + versionId: 'version-1', + riskSignature: summary.riskSignature, + summary, + }); + prisma.xiaobaoRiskInsight.findFirst.mockResolvedValue(null); + ai.interpretRisk.mockResolvedValue({ ok: false, code: 'API_ERROR', error: 'provider down' }); + + await expect(service.runInterpretation({ versionId: 'version-1', riskSignature: summary.riskSignature })) + .rejects.toThrow('provider down'); + expect(prisma.xiaobaoRiskInsight.create).not.toHaveBeenCalled(); + }); +}); + +function buildSummary(patch: Partial = {}): XiaobaoRiskSummaryPayload { + const riskLevel = patch.riskLevel ?? 'blocked'; + const riskScore = (patch.riskScore as number | undefined) ?? 88; + return { + versionId: 'version-1', + versionName: 'V1.0', + productName: 'FTB', + projectName: '智能项目管理', + expectedReleaseDate: '2026-07-09', + forecastReleaseDate: '2026-07-12T08:00:00.000Z', + delayDays: 3, + remainingWorkHours: 24, + riskLevel, + riskScore, + confidence: 80, + riskSignature: patch.riskSignature ?? `version-1|${riskLevel}|${riskScore}`, + signals: { + unfinishedCount: 4, + openBugCount: 2, + criticalBugCount: 1, + failedTestCount: 1, + blockedCount: 1, + silentRiskCount: 0, + daysToExpectedRelease: 1, + }, + reasons: [ + { + key: 'critical_bug', + title: '关键缺陷', + severity: 'danger', + detail: '仍有关键缺陷', + }, + ], + dailyEvidence: { + todayDeliveries: [{ summary: '提交修复分支' }], + todayProgress: [], + todayCreations: [], + todayRisks: [{ summary: '关键缺陷仍未关闭' }], + progressNotes: [], + needsProgressItems: [], + recentActivityCount: 1, + totalActivityCount: 4, + todayActualHours: 2, + lastActivityAt: '2026-07-08T07:00:00.000Z', + silentRisks: [], + }, + currentSnapshot: { + versionId: 'version-1', + date: '2026-07-08', + riskScore, + riskLevel, + openBugCount: 2, + criticalBugCount: 1, + failedTestCount: 1, + blockedCount: 1, + silentRiskCount: 0, + confidence: 80, + createdAt: '2026-07-08T08:00:00.000Z', + }, + ...patch, + }; +} diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts new file mode 100644 index 0000000..6a6fe70 --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts @@ -0,0 +1,378 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { JobsService } from '../jobs/jobs.service'; +import type { XiaobaoRiskLevel, XiaobaoRiskSummaryPayload } from '../xiaobao/xiaobao-risk.types'; +import { + XIAOBAO_AI_COOLDOWN_MS, + XIAOBAO_AI_INTERPRETER, + XIAOBAO_AI_INTERPRET_JOB, + XIAOBAO_AI_PRISMA, + XIAOBAO_RISK_LEVEL_RANK, + XiaobaoAiEvaluateOptions, + XiaobaoAiEvaluateResult, + XiaobaoAiGenerationResult, + XiaobaoAiInterpreter, + XiaobaoAiInterpretPayload, + XiaobaoAiRiskInterpretRequest, +} from './xiaobao-ai.types'; + +@Injectable() +export class XiaobaoAiService { + constructor( + @Inject(XIAOBAO_AI_PRISMA) private readonly prisma: any, + private readonly jobs: JobsService, + @Inject(XIAOBAO_AI_INTERPRETER) private readonly ai: XiaobaoAiInterpreter, + ) {} + + async evaluateSummary( + summary: XiaobaoRiskSummaryPayload, + options: XiaobaoAiEvaluateOptions = {}, + ): Promise { + const now = options.now ?? new Date(); + const gate = await this.evaluateGenerationGate(summary, now); + if (!gate.allow) return { enqueued: false, reason: gate.reason }; + + const job = await this.jobs.enqueue({ + type: XIAOBAO_AI_INTERPRET_JOB, + dedupeKey: buildDedupeKey(summary.versionId, summary.riskSignature), + payload: { + versionId: summary.versionId, + riskSignature: summary.riskSignature, + }, + maxAttempts: 3, + }); + + return { + enqueued: true, + reason: gate.escalated ? 'risk_escalated' : 'queued', + jobId: job?.id, + }; + } + + async evaluateCurrentSummary( + versionId: string, + options: XiaobaoAiEvaluateOptions = {}, + ): Promise { + const row = await this.prisma.xiaobaoRiskSummary.findUnique({ where: { versionId } }); + if (!row) throw new NotFoundException('Xiaobao risk summary not found'); + return this.evaluateSummary(coerceSummary(row), options); + } + + async runInterpretation( + payload: XiaobaoAiInterpretPayload, + options: XiaobaoAiEvaluateOptions = {}, + ): Promise { + const now = options.now ?? new Date(); + const row = await this.prisma.xiaobaoRiskSummary.findUnique({ where: { versionId: payload.versionId } }); + if (!row) throw new NotFoundException('Xiaobao risk summary not found'); + + const summary = coerceSummary(row); + if (summary.riskSignature !== payload.riskSignature) { + return { generated: false, reason: 'stale' }; + } + + const gate = await this.evaluateGenerationGate(summary, now); + if (!gate.allow) return { generated: false, reason: gate.reason }; + + const response = await this.ai.interpretRisk(buildRiskInterpretRequest(summary)); + if (!response.ok) { + throw new Error(response.error || 'AI risk interpretation failed'); + } + + await this.prisma.xiaobaoRiskInsight.create({ + data: { + versionId: summary.versionId, + riskSignature: summary.riskSignature, + status: 'generated', + insight: { + versionId: summary.versionId, + riskSignature: summary.riskSignature, + generatedAt: now.toISOString(), + insight: response.result, + providerInfo: { model: response.meta.model }, + }, + createdAt: now, + }, + }); + + return { generated: true, reason: 'generated' }; + } + + private async evaluateGenerationGate( + summary: XiaobaoRiskSummaryPayload, + now: Date, + ): Promise< + | { allow: true; escalated: boolean } + | { allow: false; reason: 'policy_skip' | 'cache_hit' | 'cooldown' } + > { + if (!shouldRequestRiskInsight(summary)) { + return { allow: false, reason: 'policy_skip' }; + } + + const reusable = await this.findReusableInsight(summary); + if (reusable) return { allow: false, reason: 'cache_hit' }; + + const latest = await this.findLatestInsight(summary.versionId); + const escalated = latest ? isRiskLevelEscalation(summary.riskLevel, latest.riskSignature) : false; + if (latest && !escalated && isWithinCooldown(latest, now)) { + return { allow: false, reason: 'cooldown' }; + } + + return { allow: true, escalated }; + } + + private findReusableInsight(summary: XiaobaoRiskSummaryPayload): Promise { + return this.prisma.xiaobaoRiskInsight.findFirst({ + where: { + versionId: summary.versionId, + riskSignature: summary.riskSignature, + status: 'generated', + }, + orderBy: { createdAt: 'desc' }, + }); + } + + private findLatestInsight(versionId: string): Promise { + return this.prisma.xiaobaoRiskInsight.findFirst({ + where: { + versionId, + status: 'generated', + }, + orderBy: { createdAt: 'desc' }, + }); + } +} + +function buildRiskInterpretRequest(summary: XiaobaoRiskSummaryPayload): XiaobaoAiRiskInterpretRequest { + return { + versionId: summary.versionId, + versionName: summary.versionName, + productName: summary.productName, + projectName: summary.projectName, + riskScore: summary.riskScore, + riskLevel: summary.riskLevel, + expectedReleaseDate: summary.expectedReleaseDate, + forecastReleaseDate: summary.forecastReleaseDate, + delayDays: summary.delayDays, + confidence: summary.confidence, + signals: summary.signals, + trendSummary: buildTrendSummary(summary), + reasons: summary.reasons.map((reason) => ({ + key: reason.key, + label: reason.title, + severity: mapReasonSeverity(reason), + detail: reason.detail, + })), + silentRisks: summarizeSilentRisks(summary.dailyEvidence.silentRisks), + dailyEvidence: { + todayDeliveries: summarizeEvidence(summary.dailyEvidence.todayDeliveries), + todayProgress: summarizeEvidence(summary.dailyEvidence.todayProgress), + todayCreations: summarizeEvidence(summary.dailyEvidence.todayCreations), + todayRisks: summarizeEvidence(summary.dailyEvidence.todayRisks), + progressNotes: summarizeEvidence(summary.dailyEvidence.progressNotes), + needsProgressItems: summarizeEvidence(summary.dailyEvidence.needsProgressItems), + recentActivityCount: summary.dailyEvidence.recentActivityCount, + totalActivityCount: summary.dailyEvidence.totalActivityCount, + todayActualHours: summary.dailyEvidence.todayActualHours, + lastActivityAt: summary.dailyEvidence.lastActivityAt, + }, + }; +} + +function shouldRequestRiskInsight(summary: XiaobaoRiskSummaryPayload): boolean { + if (summary.riskLevel === 'on_track') return false; + if (summary.riskLevel === 'at_risk' || summary.riskLevel === 'likely_delayed' || summary.riskLevel === 'blocked') { + return true; + } + + return ( + summary.riskLevel === 'attention' + && summary.signals.daysToExpectedRelease !== undefined + && summary.signals.daysToExpectedRelease <= 1 + && summary.signals.unfinishedCount > 0 + ); +} + +function isWithinCooldown(latest: any, now: Date): boolean { + const createdAt = toDate(latest?.createdAt) ?? toDate(latest?.updatedAt); + if (!createdAt) return false; + return now.getTime() - createdAt.getTime() < XIAOBAO_AI_COOLDOWN_MS; +} + +function isRiskLevelEscalation(currentLevel: XiaobaoRiskLevel, latestSignature: string): boolean { + const previousLevel = parseRiskLevel(latestSignature); + if (!previousLevel) return false; + return XIAOBAO_RISK_LEVEL_RANK[currentLevel] > XIAOBAO_RISK_LEVEL_RANK[previousLevel]; +} + +function parseRiskLevel(signature: string): XiaobaoRiskLevel | undefined { + try { + const parsed = JSON.parse(signature) as { riskLevel?: XiaobaoRiskLevel }; + if (parsed.riskLevel && parsed.riskLevel in XIAOBAO_RISK_LEVEL_RANK) return parsed.riskLevel; + } catch { + // Fall through to pipe-string compatibility. + } + + const parts = signature.split('|'); + return parts.find((part): part is XiaobaoRiskLevel => part in XIAOBAO_RISK_LEVEL_RANK); +} + +function buildDedupeKey(versionId: string, riskSignature: string): string { + return `${versionId}:${riskSignature}`; +} + +function buildTrendSummary(summary: XiaobaoRiskSummaryPayload): string { + if (summary.delayDays > 0) { + return `当前风险等级 ${summary.riskLevel},风险分 ${summary.riskScore},预计延期 ${summary.delayDays} 天。`; + } + return `当前风险等级 ${summary.riskLevel},风险分 ${summary.riskScore}。`; +} + +function mapReasonSeverity(reason: XiaobaoRiskSummaryPayload['reasons'][number]): XiaobaoAiRiskInterpretRequest['reasons'][number]['severity'] { + if (reason.severity === 'info') return 'low'; + if (reason.severity === 'warning') return 'medium'; + if (reason.key === 'critical_bug' || reason.key === 'blocked_work') return 'critical'; + return 'high'; +} + +function summarizeEvidence(items: unknown[]): string[] { + return items.map(summarizeEvidenceItem).filter((item) => item.length > 0); +} + +function summarizeEvidenceItem(item: unknown): string { + if (typeof item === 'string') return item.trim(); + if (!isRecord(item)) return ''; + const summary = readString(item.summary); + if (summary) return summary; + const title = readString(item.title); + const detail = readString(item.detail); + return [title, detail].filter(Boolean).join(':').trim(); +} + +function summarizeSilentRisks(items: unknown[]): XiaobaoAiRiskInterpretRequest['silentRisks'] { + return items + .map((item) => { + if (!isRecord(item)) return null; + const key = readString(item.key); + const detail = readString(item.detail) || readString(item.summary); + if (!key || !detail) return null; + const days = readNumber(item.days); + return days === undefined ? { key, detail } : { key, detail, days }; + }) + .filter((item): item is XiaobaoAiRiskInterpretRequest['silentRisks'][number] => Boolean(item)); +} + +function coerceSummary(row: any): XiaobaoRiskSummaryPayload { + const source = isRecord(row?.summary) ? row.summary : {}; + const versionId = readString(source.versionId) ?? String(row.versionId); + const riskLevel = readRiskLevel(source.riskLevel) ?? readRiskLevel(row.riskLevel) ?? 'attention'; + const riskScore = readNumber(source.riskScore) ?? readNumber(row.riskScore) ?? 0; + const confidence = readNumber(source.confidence) ?? readNumber(row.confidence) ?? 0; + const riskSignature = readString(source.riskSignature) ?? String(row.riskSignature ?? `${versionId}|${riskLevel}|${riskScore}`); + const dailyEvidence = readRecord(source.dailyEvidence); + const signals = readRecord(source.signals); + const currentSnapshot = readRecord(source.currentSnapshot); + + return { + versionId, + versionName: readString(source.versionName) ?? versionId, + productId: readString(source.productId) ?? undefined, + productName: readString(source.productName) ?? undefined, + projectId: readString(source.projectId), + projectName: readString(source.projectName) ?? undefined, + expectedReleaseDate: readString(source.expectedReleaseDate) ?? null, + riskScore, + riskLevel, + confidence, + forecastReleaseDate: readString(source.forecastReleaseDate) ?? undefined, + delayDays: readNumber(source.delayDays) ?? 0, + remainingWorkHours: readNumber(source.remainingWorkHours) ?? 0, + riskSignature, + signals: { + unfinishedCount: readNumber(signals.unfinishedCount) ?? 0, + openBugCount: readNumber(signals.openBugCount) ?? 0, + criticalBugCount: readNumber(signals.criticalBugCount) ?? 0, + failedTestCount: readNumber(signals.failedTestCount) ?? 0, + blockedCount: readNumber(signals.blockedCount) ?? 0, + silentRiskCount: readNumber(signals.silentRiskCount) ?? 0, + daysToExpectedRelease: readNumber(signals.daysToExpectedRelease), + }, + reasons: readReasons(source.reasons), + dailyEvidence: { + todayDeliveries: readArray(dailyEvidence.todayDeliveries), + todayProgress: readArray(dailyEvidence.todayProgress), + todayCreations: readArray(dailyEvidence.todayCreations), + todayRisks: readArray(dailyEvidence.todayRisks), + progressNotes: readArray(dailyEvidence.progressNotes), + needsProgressItems: readArray(dailyEvidence.needsProgressItems), + recentActivityCount: readNumber(dailyEvidence.recentActivityCount) ?? 0, + totalActivityCount: readNumber(dailyEvidence.totalActivityCount) ?? 0, + todayActualHours: readNumber(dailyEvidence.todayActualHours) ?? 0, + lastActivityAt: readString(dailyEvidence.lastActivityAt) ?? undefined, + silentRisks: readArray(dailyEvidence.silentRisks), + }, + currentSnapshot: { + versionId, + date: readString(currentSnapshot.date) ?? new Date().toISOString().slice(0, 10), + riskScore, + riskLevel, + forecastReleaseDate: readString(currentSnapshot.forecastReleaseDate) ?? undefined, + openBugCount: readNumber(currentSnapshot.openBugCount) ?? 0, + criticalBugCount: readNumber(currentSnapshot.criticalBugCount) ?? 0, + failedTestCount: readNumber(currentSnapshot.failedTestCount) ?? 0, + blockedCount: readNumber(currentSnapshot.blockedCount) ?? 0, + silentRiskCount: readNumber(currentSnapshot.silentRiskCount) ?? 0, + confidence, + createdAt: readString(currentSnapshot.createdAt) ?? new Date().toISOString(), + }, + }; +} + +function readReasons(value: unknown): XiaobaoRiskSummaryPayload['reasons'] { + return readArray(value) + .map((item) => { + if (!isRecord(item)) return null; + const key = readString(item.key); + const title = readString(item.title) ?? readString(item.label); + const detail = readString(item.detail); + const severity = readSummarySeverity(item.severity); + if (!key || !title || !detail || !severity) return null; + const count = readNumber(item.count); + return count === undefined ? { key, title, detail, severity } : { key, title, detail, severity, count }; + }) + .filter((item): item is XiaobaoRiskSummaryPayload['reasons'][number] => Boolean(item)); +} + +function readSummarySeverity(value: unknown): XiaobaoRiskSummaryPayload['reasons'][number]['severity'] | undefined { + return value === 'info' || value === 'warning' || value === 'danger' ? value : undefined; +} + +function readRiskLevel(value: unknown): XiaobaoRiskLevel | undefined { + return typeof value === 'string' && value in XIAOBAO_RISK_LEVEL_RANK ? value as XiaobaoRiskLevel : undefined; +} + +function toDate(value: unknown): Date | undefined { + if (value instanceof Date && Number.isFinite(value.getTime())) return value; + if (typeof value !== 'string') return undefined; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : undefined; +} + +function readArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts new file mode 100644 index 0000000..14ecb5a --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts @@ -0,0 +1,117 @@ +import type { XiaobaoRiskLevel } from '../xiaobao/xiaobao-risk.types'; + +export const XIAOBAO_AI_PRISMA = 'XIAOBAO_AI_PRISMA'; +export const XIAOBAO_AI_INTERPRETER = 'XIAOBAO_AI_INTERPRETER'; +export const XIAOBAO_AI_INTERPRET_JOB = 'xiaobao.ai.interpret'; +export const XIAOBAO_AI_COOLDOWN_MS = 6 * 60 * 60 * 1000; + +export type XiaobaoAiQueueReason = 'queued' | 'risk_escalated'; +export type XiaobaoAiSkipReason = 'policy_skip' | 'cache_hit' | 'cooldown' | 'stale'; + +export interface XiaobaoAiEvaluateOptions { + now?: Date; +} + +export interface XiaobaoAiInterpretPayload { + versionId: string; + riskSignature: string; +} + +export interface XiaobaoAiRiskInterpretRequest { + versionId: string; + versionName: string; + productName?: string; + projectName?: string; + riskScore: number; + riskLevel: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; + expectedReleaseDate?: string | null; + forecastReleaseDate?: string; + delayDays: number; + confidence: number; + signals: { + unfinishedCount: number; + openBugCount: number; + criticalBugCount: number; + failedTestCount: number; + blockedCount: number; + silentRiskCount: number; + daysToExpectedRelease?: number; + }; + trendSummary: string; + reasons: Array<{ + key: string; + label: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + detail: string; + }>; + silentRisks: Array<{ key: string; days?: number; detail: string }>; + dailyEvidence: { + todayDeliveries: string[]; + todayProgress: string[]; + todayCreations: string[]; + todayRisks: string[]; + progressNotes: string[]; + needsProgressItems: string[]; + recentActivityCount: number; + totalActivityCount: number; + todayActualHours: number; + lastActivityAt?: string; + }; +} + +export interface XiaobaoAiRiskInsight { + summary: string; + why: string[]; + forecast: string; + recommendedReleaseWindow?: string; + suggestedActions: string[]; + ownerHints: string[]; + generatedAt: string; +} + +export type XiaobaoAiRiskInterpretResponse = + | { + ok: true; + result: XiaobaoAiRiskInsight; + meta: { + model: string; + inputTokens: number; + outputTokens: number; + durationMs: number; + }; + } + | { + ok: false; + error: string; + code: 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN'; + }; + +export interface XiaobaoAiInterpreter { + interpretRisk(req: XiaobaoAiRiskInterpretRequest): Promise; +} + +export interface XiaobaoAiEvaluateQueuedResult { + enqueued: true; + reason: XiaobaoAiQueueReason; + jobId?: string; +} + +export interface XiaobaoAiEvaluateSkippedResult { + enqueued: false; + reason: XiaobaoAiSkipReason; +} + +export type XiaobaoAiEvaluateResult = XiaobaoAiEvaluateQueuedResult | XiaobaoAiEvaluateSkippedResult; + +export interface XiaobaoAiGenerationResult { + generated: boolean; + reason: 'generated' | XiaobaoAiSkipReason; +} + +export const XIAOBAO_RISK_LEVEL_RANK: Record = { + on_track: 0, + attention: 1, + at_risk: 2, + likely_delayed: 3, + blocked: 4, +}; diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts new file mode 100644 index 0000000..e18e013 --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts @@ -0,0 +1,38 @@ +import { BackgroundJobWorker } from '../jobs/background-job.worker'; +import { XiaobaoAiWorker } from './xiaobao-ai.worker'; + +describe('XiaobaoAiWorker', () => { + it('registers the AI interpretation handler on module init', async () => { + const worker = { + registerHandler: jest.fn(), + }; + const service = { + runInterpretation: jest.fn().mockResolvedValue(undefined), + }; + const aiWorker = new XiaobaoAiWorker(worker as unknown as BackgroundJobWorker, service as any); + + aiWorker.onModuleInit(); + + expect(worker.registerHandler).toHaveBeenCalledWith('xiaobao.ai.interpret', expect.any(Function)); + const handler = worker.registerHandler.mock.calls[0][1]; + await handler({ versionId: 'version-1', riskSignature: 'sig-1' }, { id: 'job-1' }); + expect(service.runInterpretation).toHaveBeenCalledWith({ versionId: 'version-1', riskSignature: 'sig-1' }); + }); + + it('rejects malformed interpretation payloads', async () => { + const worker = { + registerHandler: jest.fn(), + }; + const service = { + runInterpretation: jest.fn(), + }; + const aiWorker = new XiaobaoAiWorker(worker as unknown as BackgroundJobWorker, service as any); + + aiWorker.onModuleInit(); + const handler = worker.registerHandler.mock.calls[0][1]; + + await expect(handler({ versionId: 'version-1' }, { id: 'job-1' })) + .rejects.toThrow('xiaobao.ai.interpret requires payload.versionId and payload.riskSignature'); + expect(service.runInterpretation).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts new file mode 100644 index 0000000..8f8a917 --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts @@ -0,0 +1,30 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { BackgroundJobWorker } from '../jobs/background-job.worker'; +import { XIAOBAO_AI_INTERPRET_JOB, XiaobaoAiInterpretPayload } from './xiaobao-ai.types'; +import { XiaobaoAiService } from './xiaobao-ai.service'; + +@Injectable() +export class XiaobaoAiWorker implements OnModuleInit { + constructor( + private readonly worker: BackgroundJobWorker, + private readonly xiaobaoAi: XiaobaoAiService, + ) {} + + onModuleInit() { + this.worker.registerHandler(XIAOBAO_AI_INTERPRET_JOB, async (payload) => { + await this.xiaobaoAi.runInterpretation(readPayload(payload)); + }); + } +} + +function readPayload(payload: unknown): XiaobaoAiInterpretPayload { + if (!payload || typeof payload !== 'object') { + throw new Error('xiaobao.ai.interpret requires payload.versionId and payload.riskSignature'); + } + const versionId = (payload as { versionId?: unknown }).versionId; + const riskSignature = (payload as { riskSignature?: unknown }).riskSignature; + if (typeof versionId !== 'string' || !versionId.trim() || typeof riskSignature !== 'string' || !riskSignature.trim()) { + throw new Error('xiaobao.ai.interpret requires payload.versionId and payload.riskSignature'); + } + return { versionId: versionId.trim(), riskSignature: riskSignature.trim() }; +} diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.controller.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.controller.ts new file mode 100644 index 0000000..09fb8ad --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { XiaobaoRiskService } from './xiaobao-risk.service'; + +@Controller('xiaobao-risk') +export class XiaobaoRiskController { + constructor(private readonly risks: XiaobaoRiskService) {} + + @Get('dirty-count') + countDirtySummaries() { + return this.risks.countDirtySummaries(); + } + + @Post(':versionId/enqueue') + enqueueRefresh(@Param('versionId') versionId: string) { + return this.risks.markDirtyAndEnqueue(versionId); + } + + @Post(':versionId/refresh') + refreshSummary( + @Param('versionId') versionId: string, + @Body('now') now?: string, + ) { + return this.risks.refreshSummary(versionId, { + now: now ? new Date(now) : undefined, + }); + } +} diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts new file mode 100644 index 0000000..3edd679 --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts @@ -0,0 +1,118 @@ +import { JobsService } from '../jobs/jobs.service'; +import { XiaobaoRiskService } from './xiaobao-risk.service'; + +describe('XiaobaoRiskService', () => { + function makeService(xiaobaoAi?: { evaluateSummary: jest.Mock }) { + const prisma = { + version: { findUnique: jest.fn() }, + devTask: { findMany: jest.fn() }, + testCase: { findMany: jest.fn() }, + bug: { findMany: jest.fn() }, + workActivity: { findMany: jest.fn() }, + taskWorklog: { findMany: jest.fn() }, + xiaobaoRiskSummary: { + upsert: jest.fn(), + count: jest.fn(), + }, + }; + const jobs = { + enqueue: jest.fn(), + }; + return { prisma, jobs, service: new XiaobaoRiskService(prisma as any, jobs as unknown as JobsService, xiaobaoAi as any) }; + } + + it('refreshes a version risk summary from relational rows without opening the page', async () => { + const { prisma, service } = makeService(); + const now = new Date('2026-07-08T09:00:00.000Z'); + prisma.version.findUnique.mockResolvedValue({ + id: 'version-1', + name: 'V1.0', + productId: 'product-1', + projectId: 'project-1', + expectedReleaseDate: new Date('2026-07-08T18:00:00.000Z'), + members: [{ id: 'u1', name: 'Dev' }], + product: { id: 'product-1', name: 'FTB' }, + project: { id: 'project-1', name: 'PM' }, + }); + prisma.devTask.findMany.mockResolvedValue([ + { id: 'dev-1', title: 'Build API', status: 'in_progress', estimateHours: 16, aiEstimateHours: null, isBlocked: false, updatedAt: now }, + ]); + prisma.testCase.findMany.mockResolvedValue([ + { id: 'case-1', title: 'Regression', status: 'failed', estimateHours: 4, aiEstimateHours: null, updatedAt: now }, + ]); + prisma.bug.findMany.mockResolvedValue([ + { id: 'bug-1', title: 'Crash', status: 'open', severity: 'critical', priority: 1, estimateHours: null, aiEstimateHours: null, updatedAt: now }, + ]); + prisma.workActivity.findMany.mockResolvedValue([{ id: 'activity-1', occurredAt: now }]); + prisma.taskWorklog.findMany.mockResolvedValue([]); + prisma.xiaobaoRiskSummary.upsert.mockImplementation(({ create }) => create); + + const result = await service.refreshSummary('version-1', { now }); + + expect(result.riskLevel).toBe('blocked'); + expect(result.riskScore).toBeGreaterThanOrEqual(75); + expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith({ + where: { versionId: 'version-1' }, + update: expect.objectContaining({ + dirty: false, + riskLevel: 'blocked', + riskSignature: expect.stringContaining('version-1'), + }), + create: expect.objectContaining({ + versionId: 'version-1', + dirty: false, + riskLevel: 'blocked', + }), + }); + }); + + it('marks a summary dirty and enqueues a deduped refresh job', async () => { + const { prisma, jobs, service } = makeService(); + prisma.xiaobaoRiskSummary.upsert.mockResolvedValue({}); + jobs.enqueue.mockResolvedValue({ id: 'job-1' }); + + await service.markDirtyAndEnqueue('version-1'); + + expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { versionId: 'version-1' }, + update: { dirty: true }, + })); + expect(jobs.enqueue).toHaveBeenCalledWith({ + type: 'xiaobao.summary.refresh', + dedupeKey: 'version-1', + payload: { versionId: 'version-1' }, + maxAttempts: 5, + }); + }); + + it('evaluates AI interpretation enqueue policy after refreshing the summary', async () => { + const xiaobaoAi = { + evaluateSummary: jest.fn().mockResolvedValue({ enqueued: true, reason: 'queued' }), + }; + const { prisma, service } = makeService(xiaobaoAi); + const now = new Date('2026-07-08T09:00:00.000Z'); + prisma.version.findUnique.mockResolvedValue({ + id: 'version-1', + name: 'V1.0', + productId: 'product-1', + projectId: 'project-1', + expectedReleaseDate: new Date('2026-07-08T18:00:00.000Z'), + product: { id: 'product-1', name: 'FTB' }, + project: { id: 'project-1', name: 'PM' }, + }); + prisma.devTask.findMany.mockResolvedValue([ + { id: 'dev-1', status: 'in_progress', estimateHours: 16, aiEstimateHours: null, isBlocked: false }, + ]); + prisma.testCase.findMany.mockResolvedValue([]); + prisma.bug.findMany.mockResolvedValue([ + { id: 'bug-1', status: 'open', severity: 'critical', priority: 1, estimateHours: null, aiEstimateHours: null }, + ]); + prisma.workActivity.findMany.mockResolvedValue([]); + prisma.taskWorklog.findMany.mockResolvedValue([]); + prisma.xiaobaoRiskSummary.upsert.mockImplementation(({ create }) => create); + + const payload = await service.refreshSummary('version-1', { now }); + + expect(xiaobaoAi.evaluateSummary).toHaveBeenCalledWith(payload, { now }); + }); +}); diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts new file mode 100644 index 0000000..88452a9 --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts @@ -0,0 +1,452 @@ +import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common'; +import { JobsService } from '../jobs/jobs.service'; +import { XiaobaoAiService } from '../xiaobao-ai/xiaobao-ai.service'; +import { + RefreshXiaobaoRiskOptions, + XIAOBAO_PRISMA, + XIAOBAO_SUMMARY_REFRESH_JOB, + XiaobaoRiskLevel, + XiaobaoRiskSummaryPayload, +} from './xiaobao-risk.types'; + +const WORK_HOURS_PER_DAY = 8; +const DEV_PROGRESS: Record = { + todo: 0, + in_progress: 40, + testing: 80, + submitted: 100, +}; +const OPEN_BUG_STATUSES = new Set(['open', 'fixing', 'fixed', 'verifying']); + +@Injectable() +export class XiaobaoRiskService { + constructor( + @Inject(XIAOBAO_PRISMA) private readonly prisma: any, + private readonly jobs: JobsService, + @Optional() private readonly xiaobaoAi?: XiaobaoAiService, + ) {} + + async markDirtyAndEnqueue(versionId: string) { + const riskSignature = `dirty:${versionId}`; + await this.prisma.xiaobaoRiskSummary.upsert({ + where: { versionId }, + update: { dirty: true }, + create: { + versionId, + riskLevel: 'attention', + riskScore: 1, + confidence: 0, + riskSignature, + summary: { + versionId, + riskLevel: 'attention', + riskScore: 1, + confidence: 0, + riskSignature, + dirty: true, + }, + dirty: true, + }, + }); + + return this.jobs.enqueue({ + type: XIAOBAO_SUMMARY_REFRESH_JOB, + dedupeKey: versionId, + payload: { versionId }, + maxAttempts: 5, + }); + } + + countDirtySummaries(): Promise { + return this.prisma.xiaobaoRiskSummary.count({ where: { dirty: true } }); + } + + async refreshSummary(versionId: string, options: RefreshXiaobaoRiskOptions = {}): Promise { + const now = options.now ?? new Date(); + const version = await this.prisma.version.findUnique({ + where: { id: versionId }, + include: { + product: { select: { id: true, name: true } }, + project: { select: { id: true, name: true } }, + }, + }); + if (!version) throw new NotFoundException('Version not found'); + + const [devTasks, testCases, bugs, activities, worklogs] = await Promise.all([ + this.prisma.devTask.findMany({ where: { versionId } }), + this.prisma.testCase.findMany({ where: { versionId } }), + this.prisma.bug.findMany({ where: { versionId } }), + this.prisma.workActivity.findMany({ + where: { versionId }, + orderBy: { occurredAt: 'desc' }, + take: 200, + }), + this.prisma.taskWorklog.findMany({ + where: { versionId }, + orderBy: { workDate: 'desc' }, + take: 200, + }), + ]); + + const payload = calculateRiskPayload({ + version, + devTasks, + testCases, + bugs, + activities, + worklogs, + now, + }); + const forecastReleaseDate = payload.forecastReleaseDate ? new Date(payload.forecastReleaseDate) : null; + const data = { + riskLevel: payload.riskLevel, + riskScore: payload.riskScore, + confidence: payload.confidence, + forecastReleaseDate, + riskSignature: payload.riskSignature, + summary: payload, + dirty: false, + recomputedAt: now, + }; + + await this.prisma.xiaobaoRiskSummary.upsert({ + where: { versionId }, + update: data, + create: { + versionId, + ...data, + }, + }); + + await this.xiaobaoAi?.evaluateSummary(payload, { now }); + + return payload; + } +} + +function calculateRiskPayload(input: { + version: any; + devTasks: any[]; + testCases: any[]; + bugs: any[]; + activities: any[]; + worklogs: any[]; + now: Date; +}): XiaobaoRiskSummaryPayload { + const { version, devTasks, testCases, bugs, activities, worklogs, now } = input; + const remainingDevHours = devTasks.reduce((sum, task) => { + const estimate = getEstimateHours(task, 8); + const progress = DEV_PROGRESS[String(task.status)] ?? 0; + return sum + estimate * Math.max(0, 100 - progress) / 100; + }, 0); + const remainingTestHours = testCases.reduce((sum, testCase) => { + if (testCase.status === 'passed') return sum; + return sum + getEstimateHours(testCase, 4); + }, 0); + const openBugs = bugs.filter((bug) => OPEN_BUG_STATUSES.has(String(bug.status))); + const remainingBugHours = openBugs.reduce((sum, bug) => sum + getBugEstimateHours(bug), 0); + const remainingWorkHours = roundHours(remainingDevHours + remainingTestHours + remainingBugHours); + + const forecastReleaseDate = remainingWorkHours > 0 ? addWorkHours(now, remainingWorkHours).toISOString() : undefined; + const expectedReleaseDate = toIsoOrNull(version.expectedReleaseDate); + const expectedRelease = expectedReleaseDate ? new Date(expectedReleaseDate) : undefined; + const forecast = forecastReleaseDate ? new Date(forecastReleaseDate) : undefined; + const delayDays = expectedRelease && forecast && forecast.getTime() > expectedRelease.getTime() + ? roundDays((forecast.getTime() - expectedRelease.getTime()) / 86_400_000) + : 0; + const daysToExpectedRelease = expectedRelease + ? roundDays((expectedRelease.getTime() - now.getTime()) / 86_400_000) + : undefined; + + const criticalBugCount = openBugs.filter((bug) => bug.severity === 'critical' || Number(bug.priority) <= 1).length; + const failedTestCount = testCases.filter((testCase) => testCase.status === 'failed').length; + const blockedCount = devTasks.filter((task) => Boolean(task.isBlocked)).length + + testCases.filter((testCase) => testCase.status === 'blocked').length; + const unfinishedCount = devTasks.filter((task) => task.status !== 'submitted').length + + testCases.filter((testCase) => testCase.status !== 'passed').length + + openBugs.length; + const recentActivityCount = countRecentActivity(activities, worklogs, now); + const lastActivityAt = latestIso([ + ...activities.map((item) => toIsoOrUndefined(item.occurredAt)), + ...worklogs.map((item) => toIsoOrUndefined(item.createdAt)), + ]); + + const reasons = buildReasons({ + remainingWorkHours, + delayDays, + criticalBugCount, + failedTestCount, + blockedCount, + }); + const riskScore = calcRiskScore({ + delayDays, + remainingWorkHours, + criticalBugCount, + failedTestCount, + blockedCount, + silentRiskCount: 0, + daysToExpectedRelease, + }); + const riskLevel = getRiskLevel(riskScore, delayDays, criticalBugCount > 0 || blockedCount > 0); + const confidence = calcConfidence({ + version, + devTasks, + testCases, + bugs, + recentActivityCount, + }); + const signals = { + unfinishedCount, + openBugCount: openBugs.length, + criticalBugCount, + failedTestCount, + blockedCount, + silentRiskCount: 0, + daysToExpectedRelease, + }; + const riskSignature = [ + version.id, + riskLevel, + riskScore, + unfinishedCount, + openBugs.length, + criticalBugCount, + failedTestCount, + blockedCount, + ].join('|'); + + return { + versionId: version.id, + versionName: version.name, + productId: version.productId, + productName: version.product?.name, + projectId: version.projectId, + projectName: version.project?.name, + expectedReleaseDate, + riskScore, + riskLevel, + confidence, + forecastReleaseDate, + delayDays, + remainingWorkHours, + riskSignature, + signals, + reasons, + dailyEvidence: { + todayDeliveries: [], + todayProgress: [], + todayCreations: [], + todayRisks: [], + progressNotes: [], + needsProgressItems: [], + recentActivityCount, + totalActivityCount: activities.length, + todayActualHours: calcTodayWorklogHours(worklogs, now), + lastActivityAt, + silentRisks: [], + }, + currentSnapshot: { + versionId: version.id, + date: now.toISOString().slice(0, 10), + riskScore, + riskLevel, + forecastReleaseDate, + openBugCount: openBugs.length, + criticalBugCount, + failedTestCount, + blockedCount, + silentRiskCount: 0, + confidence, + createdAt: now.toISOString(), + }, + }; +} + +function getEstimateHours(item: any, fallback: number): number { + if (typeof item.estimateHours === 'number' && item.estimateHours > 0) return roundHours(item.estimateHours); + if (typeof item.aiEstimateHours === 'number' && item.aiEstimateHours > 0) return roundHours(item.aiEstimateHours); + return fallback; +} + +function getBugEstimateHours(bug: any): number { + const estimate = getEstimateHours(bug, 0); + if (estimate > 0) return estimate; + if (bug.severity === 'critical') return 16; + if (bug.severity === 'major') return 8; + return 4; +} + +function buildReasons(input: { + remainingWorkHours: number; + delayDays: number; + criticalBugCount: number; + failedTestCount: number; + blockedCount: number; +}): XiaobaoRiskSummaryPayload['reasons'] { + const reasons: XiaobaoRiskSummaryPayload['reasons'] = []; + if (input.remainingWorkHours > 0) { + reasons.push({ + key: 'remaining_work', + title: '剩余工作量', + detail: `预计还剩 ${input.remainingWorkHours}h 工作量。`, + severity: input.delayDays > 0 ? 'danger' : 'warning', + }); + } + if (input.delayDays > 0) { + reasons.push({ + key: 'forecast_delay', + title: '预测延期', + detail: `预测发布时间晚于计划约 ${input.delayDays} 天。`, + severity: 'danger', + }); + } + if (input.criticalBugCount > 0) { + reasons.push({ + key: 'critical_bug', + title: '关键缺陷', + detail: `仍有 ${input.criticalBugCount} 个 P1 或致命 Bug 未关闭。`, + severity: 'danger', + count: input.criticalBugCount, + }); + } + if (input.blockedCount > 0) { + reasons.push({ + key: 'blocked_work', + title: '阻塞工作', + detail: `仍有 ${input.blockedCount} 个开发或测试项处于阻塞。`, + severity: 'danger', + count: input.blockedCount, + }); + } + if (input.failedTestCount > 0) { + reasons.push({ + key: 'failed_test', + title: '失败用例', + detail: `仍有 ${input.failedTestCount} 个测试用例未通过。`, + severity: 'warning', + count: input.failedTestCount, + }); + } + return reasons; +} + +function calcRiskScore(input: { + delayDays: number; + remainingWorkHours: number; + criticalBugCount: number; + failedTestCount: number; + blockedCount: number; + silentRiskCount: number; + daysToExpectedRelease?: number; +}): number { + let score = 0; + if (input.delayDays > 0) score += 70 + Math.min(15, input.delayDays * 3); + if (input.daysToExpectedRelease !== undefined && input.daysToExpectedRelease <= 2 && input.remainingWorkHours > 0) { + score += Math.min(20, input.remainingWorkHours / WORK_HOURS_PER_DAY * 4); + } + score += input.criticalBugCount * 25; + score += input.blockedCount * 22; + score += input.failedTestCount * 12; + score += input.silentRiskCount * 8; + if (input.remainingWorkHours > 0 && input.delayDays === 0) { + score += Math.min(35, input.remainingWorkHours / WORK_HOURS_PER_DAY * 5); + } + return clampScore(score); +} + +function getRiskLevel(score: number, delayDays: number, hasBlockingRisk: boolean): XiaobaoRiskLevel { + if (hasBlockingRisk) return 'blocked'; + if (delayDays > 0 || score >= 75) return 'likely_delayed'; + if (score >= 55) return 'at_risk'; + if (score >= 30) return 'attention'; + return 'on_track'; +} + +function calcConfidence(input: { + version: any; + devTasks: any[]; + testCases: any[]; + bugs: any[]; + recentActivityCount: number; +}): number { + let confidence = 100; + if (!input.version.expectedReleaseDate) confidence -= 20; + const workItems = [...input.devTasks, ...input.testCases, ...input.bugs]; + const missingEstimateCount = workItems.filter((item) => !hasEstimate(item)).length; + if (missingEstimateCount > 0) confidence -= Math.min(25, missingEstimateCount * 5); + if (input.testCases.length === 0) confidence -= 15; + if (!Array.isArray(input.version.members) || input.version.members.length === 0) confidence -= 10; + if (input.recentActivityCount === 0) confidence -= 10; + confidence -= 10; + return clampScore(confidence); +} + +function hasEstimate(item: any): boolean { + return (typeof item.estimateHours === 'number' && item.estimateHours > 0) + || (typeof item.aiEstimateHours === 'number' && item.aiEstimateHours > 0); +} + +function addWorkHours(start: Date, hours: number): Date { + const result = new Date(start); + result.setTime(result.getTime() + Math.ceil(hours / WORK_HOURS_PER_DAY) * 24 * 60 * 60 * 1000); + return result; +} + +function countRecentActivity(activities: any[], worklogs: any[], now: Date): number { + const recentSince = now.getTime() - 3 * 86_400_000; + return [ + ...activities.map((item) => item.occurredAt), + ...worklogs.map((item) => item.createdAt), + ].filter((value) => { + const date = parseDate(value); + return Boolean(date && date.getTime() >= recentSince); + }).length; +} + +function calcTodayWorklogHours(worklogs: any[], now: Date): number { + const today = now.toISOString().slice(0, 10); + return roundHours(worklogs + .filter((worklog) => toIsoOrUndefined(worklog.workDate)?.slice(0, 10) === today) + .reduce((sum, worklog) => sum + (Number(worklog.hours) || 0), 0)); +} + +function latestIso(values: Array): string | undefined { + let latest: string | undefined; + let latestTime = Number.NEGATIVE_INFINITY; + for (const value of values) { + const date = parseDate(value); + if (!date) continue; + if (date.getTime() > latestTime) { + latest = date.toISOString(); + latestTime = date.getTime(); + } + } + return latest; +} + +function toIsoOrNull(value: unknown): string | null { + return toIsoOrUndefined(value) ?? null; +} + +function toIsoOrUndefined(value: unknown): string | undefined { + const date = parseDate(value); + return date?.toISOString(); +} + +function parseDate(value: unknown): Date | undefined { + if (!value) return undefined; + const date = value instanceof Date ? value : new Date(String(value)); + return Number.isFinite(date.getTime()) ? date : undefined; +} + +function roundHours(hours: number): number { + return Math.round(hours * 2) / 2; +} + +function roundDays(days: number): number { + return Math.round(days * 10) / 10; +} + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, Math.round(score))); +} diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.types.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.types.ts new file mode 100644 index 0000000..b4d186b --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.types.ts @@ -0,0 +1,68 @@ +export const XIAOBAO_PRISMA = 'XIAOBAO_PRISMA'; +export const XIAOBAO_SUMMARY_REFRESH_JOB = 'xiaobao.summary.refresh'; + +export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; + +export interface RefreshXiaobaoRiskOptions { + now?: Date; +} + +export interface XiaobaoRiskSummaryPayload { + versionId: string; + versionName: string; + productId?: string; + productName?: string; + projectId?: string | null; + projectName?: string; + expectedReleaseDate: string | null; + riskScore: number; + riskLevel: XiaobaoRiskLevel; + confidence: number; + forecastReleaseDate?: string; + delayDays: number; + remainingWorkHours: number; + riskSignature: string; + signals: { + unfinishedCount: number; + openBugCount: number; + criticalBugCount: number; + failedTestCount: number; + blockedCount: number; + silentRiskCount: number; + daysToExpectedRelease?: number; + }; + reasons: Array<{ + key: string; + title: string; + detail: string; + severity: 'info' | 'warning' | 'danger'; + count?: number; + }>; + dailyEvidence: { + todayDeliveries: unknown[]; + todayProgress: unknown[]; + todayCreations: unknown[]; + todayRisks: unknown[]; + progressNotes: unknown[]; + needsProgressItems: unknown[]; + recentActivityCount: number; + totalActivityCount: number; + todayActualHours: number; + lastActivityAt?: string; + silentRisks: unknown[]; + }; + currentSnapshot: { + versionId: string; + date: string; + riskScore: number; + riskLevel: XiaobaoRiskLevel; + forecastReleaseDate?: string; + openBugCount: number; + criticalBugCount: number; + failedTestCount: number; + blockedCount: number; + silentRiskCount: number; + confidence: number; + createdAt: string; + }; +} diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.worker.spec.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.worker.spec.ts new file mode 100644 index 0000000..02b9e03 --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.worker.spec.ts @@ -0,0 +1,21 @@ +import { BackgroundJobWorker } from '../jobs/background-job.worker'; +import { XiaobaoRiskWorker } from './xiaobao-risk.worker'; + +describe('XiaobaoRiskWorker', () => { + it('registers the summary refresh handler on module init', async () => { + const worker = { + registerHandler: jest.fn(), + }; + const risk = { + refreshSummary: jest.fn().mockResolvedValue({ versionId: 'version-1' }), + }; + const service = new XiaobaoRiskWorker(worker as unknown as BackgroundJobWorker, risk as any); + + service.onModuleInit(); + + expect(worker.registerHandler).toHaveBeenCalledWith('xiaobao.summary.refresh', expect.any(Function)); + const handler = worker.registerHandler.mock.calls[0][1]; + await handler({ versionId: 'version-1' }, { id: 'job-1' }); + expect(risk.refreshSummary).toHaveBeenCalledWith('version-1'); + }); +}); diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.worker.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.worker.ts new file mode 100644 index 0000000..3ca9c8f --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.worker.ts @@ -0,0 +1,26 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { BackgroundJobWorker } from '../jobs/background-job.worker'; +import { XIAOBAO_SUMMARY_REFRESH_JOB } from './xiaobao-risk.types'; +import { XiaobaoRiskService } from './xiaobao-risk.service'; + +@Injectable() +export class XiaobaoRiskWorker implements OnModuleInit { + constructor( + private readonly worker: BackgroundJobWorker, + private readonly risks: XiaobaoRiskService, + ) {} + + onModuleInit() { + this.worker.registerHandler(XIAOBAO_SUMMARY_REFRESH_JOB, async (payload) => { + const versionId = readVersionId(payload); + if (!versionId) throw new Error('xiaobao.summary.refresh requires payload.versionId'); + await this.risks.refreshSummary(versionId); + }); + } +} + +function readVersionId(payload: unknown): string | undefined { + if (!payload || typeof payload !== 'object') return undefined; + const value = (payload as { versionId?: unknown }).versionId; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} diff --git a/apps/server/src/modules/xiaobao/xiaobao.module.ts b/apps/server/src/modules/xiaobao/xiaobao.module.ts new file mode 100644 index 0000000..15e4327 --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { JobsModule } from '../jobs/jobs.module'; +import { XiaobaoAiModule } from '../xiaobao-ai/xiaobao-ai.module'; +import { XiaobaoRiskController } from './xiaobao-risk.controller'; +import { XiaobaoRiskService } from './xiaobao-risk.service'; +import { XiaobaoRiskWorker } from './xiaobao-risk.worker'; +import { XIAOBAO_PRISMA } from './xiaobao-risk.types'; + +@Module({ + imports: [JobsModule, XiaobaoAiModule], + controllers: [XiaobaoRiskController], + providers: [ + { provide: XIAOBAO_PRISMA, useExisting: PrismaService }, + XiaobaoRiskService, + XiaobaoRiskWorker, + ], + exports: [XiaobaoRiskService, XiaobaoRiskWorker], +}) +export class XiaobaoModule {} diff --git a/apps/server/src/prisma/prisma.service.ts b/apps/server/src/prisma/prisma.service.ts index 6c2a82f..195784b 100644 --- a/apps/server/src/prisma/prisma.service.ts +++ b/apps/server/src/prisma/prisma.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +import { recordSlowPrismaQuery } from '../modules/ops/ops-runtime.store'; import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring'; @Injectable() @@ -16,6 +17,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul (this as any).$on('query', (event: { duration: number; query: string }) => { if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return; this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`); + recordSlowPrismaQuery({ + query: event.query, + durationMs: event.duration, + thresholdMs: this.slowQueryThresholdMs, + }); }); } diff --git a/apps/web/app/admin/ops/page.tsx b/apps/web/app/admin/ops/page.tsx new file mode 100644 index 0000000..a89aa22 --- /dev/null +++ b/apps/web/app/admin/ops/page.tsx @@ -0,0 +1,391 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Activity, + AlertTriangle, + CheckCircle2, + Clock3, + Database, + Loader2, + RefreshCw, + ServerCog, +} from 'lucide-react'; +import { RouteGuard } from '@/components/auth/Guard'; +import { api } from '@/lib/api'; + +type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'; + +interface OpsRuntimeSnapshot { + collectedAt: string; + thresholds: { + apiSlowRequestMs: number; + prismaSlowQueryMs: number; + }; + database: { + ok: boolean; + error?: string; + }; + slowRequests: Array<{ + id: string; + method: string; + path: string; + durationMs: number; + thresholdMs: number; + occurredAt: string; + }>; + slowQueries: Array<{ + id: string; + queryPreview: string; + durationMs: number; + thresholdMs: number; + occurredAt: string; + }>; + jobQueue: { + totals: Record & { total: number }; + byType: Array & { + type: string; + total: number; + oldestQueuedAt?: string; + nextLeaseExpiresAt?: string; + }>; + recentFailures: Array<{ + id: string; + type: string; + attempts: number; + maxAttempts: number; + lastError: string; + updatedAt?: string; + }>; + }; + dirtySummaryCount: number; + access: { + requiredPermission: string; + backendEnforced: boolean; + adapter: string; + }; +} + +const STATUS_LABEL: Record = { + queued: '排队', + running: '运行', + succeeded: '成功', + failed: '失败', +}; + +export default function OpsPage() { + return ( + + + + ); +} + +function OpsPageContent() { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const fetchSnapshot = async (initial = false) => { + if (initial) setLoading(true); + else setRefreshing(true); + try { + const next = await api.get('/ops/runtime'); + setSnapshot(next); + setError(null); + } catch (e: any) { + setError(e?.message || '读取失败'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + void fetchSnapshot(true); + const timer = window.setInterval(() => { void fetchSnapshot(); }, 30_000); + return () => window.clearInterval(timer); + }, []); + + return ( +
+
+
+ +

运维看板

+ {snapshot && ( + + {formatClock(snapshot.collectedAt)} + + )} +
+ +
+ +
+ {loading ? ( +
+ + 加载中 +
+ ) : error ? ( +
{error}
+ ) : snapshot ? ( +
+ +
+
+ + +
+
+ + +
+
+
+ ) : null} +
+
+ ); +} + +function OverviewStrip({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + const cards = [ + { + label: '慢请求', + value: snapshot.slowRequests.length, + sub: `阈值 ${snapshot.thresholds.apiSlowRequestMs}ms`, + icon: Clock3, + tone: 'blue', + }, + { + label: '慢查询', + value: snapshot.slowQueries.length, + sub: `阈值 ${snapshot.thresholds.prismaSlowQueryMs}ms`, + icon: Database, + tone: 'amber', + }, + { + label: '后台任务', + value: snapshot.jobQueue.totals.total, + sub: `${snapshot.jobQueue.totals.queued} 排队 / ${snapshot.jobQueue.totals.running} 运行`, + icon: ServerCog, + tone: 'zinc', + }, + { + label: '脏 Summary', + value: snapshot.dirtySummaryCount, + sub: 'xiaobao_risk_summaries', + icon: AlertTriangle, + tone: snapshot.dirtySummaryCount > 0 ? 'red' : 'emerald', + }, + ]; + + return ( +
+ {cards.map((card) => { + const Icon = card.icon; + return ( +
+
+
+

{card.label}

+

{card.value}

+

{card.sub}

+
+ + + +
+
+ ); + })} +
+ ); +} + +function SlowRequestsPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + return ( +
+ +
+ {snapshot.slowRequests.length === 0 ? ( + + ) : snapshot.slowRequests.map((item) => ( +
+ {item.method} + {item.path} + {item.durationMs}ms + {formatClock(item.occurredAt)} +
+ ))} +
+
+ ); +} + +function SlowQueriesPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + return ( +
+ +
+ {snapshot.slowQueries.length === 0 ? ( + + ) : snapshot.slowQueries.map((item) => ( +
+ {item.queryPreview} + {item.durationMs}ms + {formatClock(item.occurredAt)} +
+ ))} +
+
+ ); +} + +function JobQueuePanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + const rows = snapshot.jobQueue.byType; + return ( +
+ +
+ +
+
+ {rows.length === 0 ? ( + + ) : rows.map((row) => ( +
+
+ {row.type} + {row.total} +
+
+ {(['queued', 'running', 'succeeded', 'failed'] as JobStatus[]).map((status) => ( + + ))} +
+ {(row.oldestQueuedAt || row.nextLeaseExpiresAt) && ( +
+ {row.oldestQueuedAt && 最早排队 {formatClock(row.oldestQueuedAt)}} + {row.nextLeaseExpiresAt && Lease {formatClock(row.nextLeaseExpiresAt)}} +
+ )} +
+ ))} +
+
+ ); +} + +function FailuresPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + const failures = snapshot.jobQueue.recentFailures; + const dbOk = snapshot.database.ok; + + return ( +
+ +
+
+ {dbOk ? : } + {dbOk ? '数据库可读' : snapshot.database.error || '数据库不可读'} +
+
+
+ {failures.length === 0 ? ( + + ) : failures.map((item) => ( +
+
+ {item.type} + {item.attempts}/{item.maxAttempts} +
+

{item.lastError || '-'}

+ {item.updatedAt &&

{formatClock(item.updatedAt)}

} +
+ ))} +
+
+ ); +} + +function StatusBars({ totals }: { totals: OpsRuntimeSnapshot['jobQueue']['totals'] }) { + const statuses: JobStatus[] = ['queued', 'running', 'succeeded', 'failed']; + const total = Math.max(1, totals.total); + return ( +
+
+ {statuses.map((status) => ( + + ))} +
+
+ {statuses.map((status) => )} +
+
+ ); +} + +function StatusPill({ status, count }: { status: JobStatus; count: number }) { + return ( +
+ {STATUS_LABEL[status]} + {count} +
+ ); +} + +function PanelHeader({ title, right }: { title: string; right: string }) { + return ( +
+

{title}

+ {right} +
+ ); +} + +function EmptyRow({ label }: { label: string }) { + return
{label}
; +} + +function formatClock(value?: string) { + if (!value) return '-'; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return '-'; + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); +} + +function toneClass(tone: string) { + if (tone === 'blue') return 'bg-blue-50 text-blue-700'; + if (tone === 'amber') return 'bg-amber-50 text-amber-700'; + if (tone === 'red') return 'bg-red-50 text-red-700'; + if (tone === 'emerald') return 'bg-emerald-50 text-emerald-700'; + return 'bg-zinc-100 text-zinc-700'; +} + +function statusBarClass(status: JobStatus) { + if (status === 'queued') return 'bg-blue-500'; + if (status === 'running') return 'bg-amber-500'; + if (status === 'succeeded') return 'bg-emerald-500'; + return 'bg-red-500'; +} + +function statusPillClass(status: JobStatus) { + if (status === 'queued') return 'bg-blue-50 text-blue-700'; + if (status === 'running') return 'bg-amber-50 text-amber-700'; + if (status === 'succeeded') return 'bg-emerald-50 text-emerald-700'; + return 'bg-red-50 text-red-700'; +} diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index ce39c79..24debd9 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; -import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database } from 'lucide-react'; +import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database, Activity } from 'lucide-react'; import { useHasPermission } from '@/components/auth/Guard'; import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems'; @@ -47,6 +47,7 @@ const NAV_GROUPS = [ { label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' }, { label: '审计', path: '/admin/audit', icon: ScrollText, permission: 'audit:view' }, { label: '一致性', path: '/admin/consistency', icon: Database, permission: 'consistency:view' }, + { label: '运维', path: '/admin/ops', icon: Activity, permission: 'ops:view' }, { label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' }, ], }, diff --git a/apps/web/lib/permissions.ts b/apps/web/lib/permissions.ts index ddebddf..ae62ae8 100644 --- a/apps/web/lib/permissions.ts +++ b/apps/web/lib/permissions.ts @@ -52,6 +52,14 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [ { action: 'manage', label: '管理', permission: 'xiaobao.warning:manage' }, ], }, + { + module: 'ops', + moduleLabel: '运维看板', + category: 'main', + actions: [ + { action: 'view', label: '查看', permission: 'ops:view' }, + ], + }, { module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') }, { module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') }, { module: 'audit', moduleLabel: '审计', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'audit:view' }] }, diff --git a/apps/web/lib/role-permission-migration.test.ts b/apps/web/lib/role-permission-migration.test.ts index 66e36dd..70f072c 100644 --- a/apps/web/lib/role-permission-migration.test.ts +++ b/apps/web/lib/role-permission-migration.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { RoleItem } from './members'; -import { DEFAULT_ROLE_PERMISSIONS } from './permissions'; +import { ALL_PERMISSIONS, DEFAULT_ROLE_PERMISSIONS } from './permissions'; import { mergePresetRolePermissions } from './role-permission-migration'; test('default group leader role has business permissions without admin modules', () => { @@ -14,6 +14,13 @@ test('default group leader role has business permissions without admin modules', assert.equal(permissions.some((p) => p.startsWith('role:')), false); }); +test('ops view permission is grantable but not part of non-admin preset roles', () => { + assert.ok(ALL_PERMISSIONS.includes('ops:view')); + assert.ok(DEFAULT_ROLE_PERMISSIONS['role-admin'].includes('*')); + assert.equal(DEFAULT_ROLE_PERMISSIONS['role-pm'].includes('ops:view'), false); + assert.equal(DEFAULT_ROLE_PERMISSIONS['role-lead'].includes('ops:view'), false); +}); + test('mergePresetRolePermissions adds the built-in group leader role to old role data', () => { const roles: RoleItem[] = [ { diff --git a/docs/architecture.md b/docs/architecture.md index bdfc8b2..9a5b02b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,6 +157,27 @@ V2.5 控制面新增三类横切能力: - V2.6 在关系表主源稳定后做大数据性能增强和小宝预警后台化;性能基础不后置,增强项包括压测、慢查询治理、缓存/摘要、后台任务、幂等和失败重试。 - V2.7 面向企业级协作与管理治理,V2.8 面向生产硬化与运维闭环;生产部署基线已经存在,V2.8 重点是备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册。 +## Background Job Runtime Layer (V2.6) + +V2.6 introduces a database-backed background job runtime for server-side refresh work that must not depend on a user opening a page. + +- Storage: `background_jobs` stores `type`, `payload`, `dedupe_key`, `status`, `attempts`, `max_attempts`, `available_at`, `locked_by`, `locked_until`, and `last_error`. +- Dedupe: active jobs (`queued` / `running`) are unique by `(type, dedupe_key)` when `dedupe_key` is present. Services still check first and recover from unique conflicts to stay idempotent under concurrent enqueue. +- Lease: `JobLockService.claimNext()` uses `FOR UPDATE SKIP LOCKED` and treats expired `running` rows as claimable, so a crashed worker can be recovered by a later worker. +- Retry: failed handlers are requeued while `attempts < max_attempts`; terminal failures keep `last_error` and move to `failed`. +- Worker boundary: `BackgroundJobWorker` is a small handler registry and single-job runner. Domain modules register typed handlers and only write through their own services. + +This runtime is intentionally DB-backed first. Redis is already available in deployment, but V2.6 jobs need transactional dedupe with domain writes more than high-throughput queue semantics. + +## Runtime Ops Dashboard (V2.6) + +V2.6 adds an Ops runtime surface for local operators and future production admins: + +- Backend: `OpsModule` exposes `GET /api/v1/ops/runtime`, reading recent in-memory slow API request events, recent in-memory slow Prisma query events, `background_jobs` queue rows, and dirty `xiaobao_risk_summaries` count. +- Capture: `ApiTimingInterceptor` and `PrismaService` still log slow events, and also append redacted bounded previews into the Ops runtime buffer. SQL query parameters are not exposed; request query strings and key-like values are stripped or redacted. +- Jobs: the dashboard summarizes queued/running/succeeded/failed jobs by type and shows recent terminal failures with redacted `lastError`. +- Permissions: the frontend route is guarded by `ops:view`. Backend RBAC is represented by `OpsPermissionAdapter` until the V2.5 RBAC contract lands, so the replacement point is explicit instead of hard-coupled to temporary role data. + ## 生产部署层(2026-07-01) 当前仓库已补齐云服务器生产部署基线: @@ -260,7 +281,16 @@ The rule surface stays in pure frontend engines: Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-managers with `xiaobao.warning:view` can only see unfinished versions where the current user is in `version.members`. -AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent. +AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. + +V2.6 moves the current risk summary refresh to the server: + +- `XiaobaoRiskService.refreshSummary(versionId)` recomputes deterministic rule output from Version, DevTask, TestCase, Bug, WorkActivity, and TaskWorklog relation rows, then upserts `xiaobao_risk_summaries` with `dirty=false`. +- `XiaobaoRiskWorker` registers the `xiaobao.summary.refresh` background job handler, so dirty summaries can be refreshed without opening `/xiaobao-warning`. +- Domain writes that produce work activity already mark the affected version dirty; V2.6 also enqueues a deduped refresh job. Plain update/delete paths for version plans, dev tasks, test cases, and bugs explicitly mark the version dirty as well. +- `XiaobaoAiService` evaluates the refreshed summary and enqueues `xiaobao.ai.interpret` when policy allows. The worker reloads the latest summary, skips stale signatures, calls the existing `AiService.interpretRisk()` prompt path, and writes only `xiaobao_risk_insights`. +- AI interpretation cache uses the summary `riskSignature`, exact cache reuse, a six-hour cooldown, and risk-level escalation bypass. Until the server has full daily trend snapshots, `attention` summaries trigger server-side AI only when release is within one day and unfinished work remains. +- Frontend `/xiaobao-warning` still consumes V2.2 summary reads first and only falls back to AppData calculation when summaries are empty or unavailable. Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet. ## V2.2 Partitioned Domain Data Layer (2026-07-03) diff --git a/docs/decisions.md b/docs/decisions.md index ad73616..286e2d5 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -623,3 +623,60 @@ - Xiaobao risk snapshots/insights 的 AppData key 进入 `read_only_archive`,关系表写入和后台化归 V2.6;`xiaobao-warning-views` 读状态 API 归 V2.7。 **理由**:冻结写入能立即切断新的双主源风险,同时保留旧 JSON 的审计和回滚价值。把权限和审计合并到领域 mutation 装饰器,可以确保后续新增写接口默认带服务端 guard 和 audit event。审计覆盖对历史数据只告警,避免为了“补齐历史审计”伪造事件。auth header adapter 给 V2.5 一个可测试的服务端权限边界,但不把它包装成最终安全方案,后续 JWT/企业 RBAC 可以替换 adapter 而不改领域 controller 合同。 + +## 48. V2.6 后台任务先采用 PostgreSQL Lease 队列 + +**问题**:小宝风险摘要、AI 解读和后续通知都需要在用户不打开页面时后台刷新。直接把这些逻辑放在页面 effect 中会导致无人访问时数据不更新;直接引入 Redis queue 又会增加一套可靠性、幂等和迁移运维面。 + +**决策**: +- 新增 `background_jobs` 表和 `JobsModule`,作为 V2.6 后台任务运行时。 +- Job 行包含 `type`、`payload`、`dedupe_key`、`status`、`attempts`、`max_attempts`、`available_at`、`locked_by`、`locked_until`、`last_error`。 +- 同一 `type + dedupe_key` 在 `queued/running` 状态下唯一;服务层先查 active job,遇到并发唯一冲突再回读,保证 enqueue 幂等。 +- Worker claim 使用数据库事务、`FOR UPDATE SKIP LOCKED` 和 lease 时间;`running` 且 `locked_until` 过期的 job 可以被新 worker 回收。 +- Handler 失败时按 `attempts < max_attempts` 重回 `queued` 并设置下一次 `available_at`;达到上限后进入 `failed`,只记录错误,不修改业务实体。 +- `BackgroundJobWorker` 只负责 handler 注册和单次执行,业务副作用仍放在各领域 service 内,避免队列层知道小宝、通知或审计细节。 + +**理由**:PostgreSQL 队列足够支撑 V2.6 的低频后台刷新,同时能和领域写入共享事务边界、唯一约束和迁移流程。等 V2.7 通知或更高吞吐任务落地后,如确实需要 Redis/专用队列,再通过同一 `JobsService` 接口替换底层实现,而不是现在提前引入第二套事实源。 + +## 49. V2.6 小宝风险摘要改为服务端后台刷新 + +**问题**:小宝预警最初由页面加载完整前端 store 后计算并保存快照/缓存。这样会导致没人打开页面时 `xiaobao_risk_summaries` 不刷新,侧边栏和 V2.2 快读只能看到旧风险。 + +**决策**: +- 新增 `XiaobaoModule`,包含 `XiaobaoRiskService`、`XiaobaoRiskWorker` 和最小 controller。 +- 服务端先移植确定性规则的核心口径:剩余开发/测试/Bug 工作量、关键缺陷、阻塞项、失败用例、预测延期、置信度和 risk signature。 +- `XiaobaoRiskService.markDirtyAndEnqueue(versionId)` 负责 upsert dirty summary 并排入 `xiaobao.summary.refresh`,dedupe key 使用 `versionId`。 +- `XiaobaoRiskWorker` 通过 V2.6 `BackgroundJobWorker` 注册 handler,执行时只刷新 `xiaobao_risk_summaries`,不修改 Version、Requirement、DevTask、TestCase、Bug 或 Member。 +- 领域写入侧继续通过 `WorkActivityService.markXiaobaoSummaryDirty()` 收口;普通 update/delete 没有 activity 证据时显式标脏,避免风险摘要漏刷新。 + +**理由**:把 deterministic summary 放到服务端后,读路径不再依赖页面打开,且所有前端仍可沿用 V2.2 summary API。AI 解读仍是后续独立队列,只消费 summary/signature 并写 insight cache;本决策不让 AI 或后台 worker 直接改业务实体。 + +## 50. V2.6 小宝 AI 解读改为服务端队列,只写 insight cache + +**问题**:小宝 AI 解读原先由 `/xiaobao-warning` 页面触发。即使 V2.6 已经把 deterministic summary 刷新移到服务端,如果 AI 解读仍依赖页面打开,高风险版本在无人访问时仍不会产生新的解释缓存,也不利于后续 V2.7 通知使用同一解读结果。 + +**决策**: +- 新增 `XiaobaoAiModule`,通过 `XiaobaoAiService` 和 `XiaobaoAiWorker` 注册 `xiaobao.ai.interpret` job。 +- `XiaobaoRiskService.refreshSummary()` upsert summary 后调用 `XiaobaoAiService.evaluateSummary()`,按 policy 判断是否排入 AI 解读 job。 +- AI 触发策略复用页面规则的核心边界:`at_risk`、`likely_delayed`、`blocked` 可触发;精确 `riskSignature` 命中时复用缓存;同版本最近 6 小时内已有解读时 cooldown;风险等级升级可绕过 cooldown。 +- 服务端当前没有完整前端趋势快照上下文,因此 `attention` 只在“距离预期发版日小于等于 1 天且仍有未完成工作”时触发。趋势、置信度下降和明细信号变化的完整 attention 策略等待服务端趋势快照补齐后再扩展。 +- Worker 执行时重新读取 `xiaobao_risk_summaries`,若 job payload 的 `riskSignature` 已过期则跳过,避免为旧风险写新解释。 +- AI 调用只走现有 `AiService.interpretRisk()` 和 risk prompt/provider 抽象,不新增 SDK 调用、不绕过 AI 配置。 +- AI 成功后只写 `xiaobao_risk_insights`,缓存保存时间使用服务端 `now`,不信任模型返回的 `generatedAt` 作为缓存新鲜度;失败抛错交给 background job retry。 +- Worker 不修改 Version、Requirement、DevTask、TestCase、Bug、Member 等业务实体,也不写通知。V2.7 通知如需消费结果,应通过 insight cache 或 adapter 读取。 + +**理由**:AI 解读是对确定性规则结果的解释层,不是业务事实源。把它做成 summary 后置队列,能让无人打开页面时也生成解释,同时通过 signature/cooldown/escalation 控制成本和重复调用。只写 cache 能保持 AI 与业务实体解耦,后续通知和审计可以复用缓存,而不是让 AI worker 直接参与业务状态流转。 + +## 51. V2.6 运维看板先做轻量运行时快照,RBAC 通过 adapter 衔接 + +**问题**:V2.6 增加了性能 harness、后台 job runtime、小宝 summary refresh 和 AI 解读队列。如果没有一个运行时入口,慢请求、慢查询、job 堆积和 dirty summary 数只能从日志或数据库手工排查。与此同时,V2.5 后端 RBAC/audit 合同尚未落地,不能为了看板临时硬编码一套后端权限结构。 + +**决策**: +- 新增 `OpsModule`,提供 `GET /api/v1/ops/runtime`,返回慢请求、慢 Prisma 查询、后台任务队列、失败任务和 dirty summary 数。 +- 慢请求继续由 `ApiTimingInterceptor` 识别;慢查询继续由 `PrismaService` query event 识别。二者额外写入进程内 ring buffer,作为轻量 dashboard 数据源。 +- 看板只保留最近事件,不做长期审计。长期审计和多实例聚合等待 V2.5 audit 或后续 observability 方案。 +- 请求 URL 去掉 query string;SQL 只展示截断后的 query preview;`sk-*`、token、secret、password、authorization 等 key-like 文本统一 redacted;不展示 AI provider apiKey、请求参数或环境变量。 +- Job 队列从 `background_jobs` 最近 200 行聚合,按 type 展示 queued/running/succeeded/failed,并展示最近 failed job 的脱敏 `lastError`。 +- 前端 `/admin/ops` 使用 `RouteGuard permission="ops:view"`;权限字典新增 `ops:view`,但不默认授给非管理员 preset。后端通过 `OpsPermissionAdapter` 保留 `ops:view` 校验入口,待 V2.5 RBAC guard 落地后替换。 + +**理由**:当前目标是让 V2.6 的性能和后台化能力可观察,而不是建设完整监控平台。进程内 ring buffer 成本低、对生产数据无额外写放大;结合脱敏规则可避免把 secrets 带进管理端。权限 adapter 明确了未来替换点,避免 Ops 看板和未定型 RBAC/audit 合同互相绑死。 diff --git a/docs/performance-hot-queries.md b/docs/performance-hot-queries.md new file mode 100644 index 0000000..589e2c1 --- /dev/null +++ b/docs/performance-hot-queries.md @@ -0,0 +1,79 @@ +# V2.6 Hot Query Budget And Index Audit + +This document records the V2.6 query budget for the large-data harness and the index contracts that keep hot APIs on partition keys. + +## Commands + +Offline contract audit: + +```bash +node scripts/explain-hot-queries.mjs --dry-run +``` + +Database explain audit: + +```bash +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain +``` + +Strict plan mode is available for seeded medium/large databases: + +```bash +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain -- --strict-plan +``` + +`--strict-plan` fails on sequential scans. It is useful after the medium fixture is seeded and analyzed, but not required for empty or tiny local databases where PostgreSQL may choose a sequential scan correctly. + +## Budgets + +| Area | Query Shape | Budget | +| --- | --- | --- | +| `health/version` | no database query | HTTP p95 <= 500ms | +| Requirement pool | `requirements.product_id` + optional filters/search, cursor, `created_at` sort | HTTP p95 <= 1000ms | +| Version detail | root version plus child rows by `version_id` | HTTP p95 <= 1500ms | +| Workspace | owner/assignee unfinished rows | HTTP p95 <= 1200ms | +| Xiaobao warnings | non-`on_track` summaries by score | HTTP p95 <= 1000ms | +| Xiaobao dirty queue | `dirty=true` summaries ordered by `updated_at` | background batch <= 100 rows | +| Audit search adapter | V2.5 audit table pending; `ai_logs` is the current AI audit surface | explain-only contract | + +## Required Index Contracts + +V2.6 keeps the existing partition prefixes: + +- Requirement pool queries must include `productId`; `requirements` is hash-partitioned by `product_id`. +- Version detail child queries must include `versionId`; `dev_tasks`, `test_cases`, and `bugs` are hash-partitioned by `version_id`. +- Append evidence tables stay range-partitioned by `created_at`; background workers must still filter by `version_id`, `user_id`, or date before scanning. + +Added in migration `20260708030000_v26_hot_query_indexes`: + +| Index | Purpose | +| --- | --- | +| `projects_product_created_at_idx` | product-scoped project list | +| `versions_product_created_at_idx` | product-scoped version list | +| `versions_product_project_created_at_idx` | project-scoped version list | +| `version_plans_owner_open_due_idx` | workspace plan queue | +| `dev_tasks_assignee_open_priority_idx` | workspace dev task queue | +| `test_cases_assignee_open_priority_idx` | workspace test case queue | +| `bugs_version_status_priority_updated_at_idx` | version detail bug ordering | +| `bugs_assignee_open_priority_idx` | workspace bug queue | +| `test_cases_version_round_status_updated_at_desc_idx` | version detail test case ordering | +| `xiaobao_risk_summaries_warning_score_idx` | manager Xiaobao warning list | +| `xiaobao_risk_summaries_dirty_updated_at_idx` | background Xiaobao dirty summary queue | +| `work_activities_version_occurred_at_idx` | Xiaobao evidence recompute | + +Existing V2.2 indexes remain part of the contract, including requirement pool indexes, version child indexes, workspace partial indexes, task worklog date indexes, Xiaobao snapshot/insight indexes, and `ai_logs` operation/status indexes. + +## Audit Adapter Note + +The V2.5 RBAC/audit contract is not present in this branch. V2.6 therefore documents `audit.searchAdapter` as an adapter target instead of inventing a temporary audit table. When audit lands, the expected query shape should be: + +```sql +SELECT * +FROM audit_events +WHERE product_id = $1 + AND created_at >= $2 +ORDER BY created_at DESC +LIMIT 100; +``` + +Expected future index: `(product_id, created_at DESC)` plus actor/resource indexes required by the audit module. Until then, `ai_logs_operation_created_at_idx` and `ai_logs_status_created_at_idx` cover AI operation audit searches only. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..d7e31f9 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,104 @@ +# V2.6 Performance Harness + +V2.6 adds a deterministic large-data fixture and a small HTTP performance harness for the current hot paths. The goal is to make performance regressions visible before adding more background jobs and Xiaobao automation. + +## Fixture Sizes + +The fixture script creates only `perf-*` rows and can be rerun safely. It covers: + +- products +- projects +- versions +- requirements +- version plans +- dev tasks +- test cases +- bugs +- work activities +- Xiaobao risk summaries + +Preset sizes: + +| Size | Purpose | +| --- | --- | +| `small` | Local smoke fixture. Fast dry-run and minimal database seed. | +| `medium` | Default performance gate for V2.6 hot APIs. | +| `large` | Stress fixture for query/index audit work. | + +Stable anchors used by the harness: + +```text +productId=perf-product-001 +versionId=perf-version-001-001-001 +userId=perf-user-dev-01 +``` + +## Commands + +Dry-run without database access: + +```bash +node scripts/seed-large-dataset.mjs --size small --dry-run +node scripts/perf-check.mjs --base-url http://localhost:3001/api/v1 --dry-run +``` + +Seed a database: + +```bash +pnpm perf:seed -- --size small +``` + +Run the hot-path harness against a running NestJS API: + +```bash +pnpm perf:check -- --base-url http://localhost:3001/api/v1 +``` + +Audit SQL plans and hot-path indexes: + +```bash +pnpm perf:explain -- --dry-run +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain +``` + +## Hot Probes + +`perf-check` measures p50 and p95 latency, records HTTP status code counts, and exits non-zero when a request fails or p95 exceeds the current query budget. + +| Probe | Endpoint | p95 Budget | +| --- | --- | ---: | +| Runtime version | `/health/version` | 500ms | +| Requirement pool | `/v2.2/requirements?productId=perf-product-001&q=REQ&limit=50` | 1000ms | +| Version detail | `/v2.2/versions/perf-version-001-001-001/detail-data` | 1500ms | +| Workspace | `/v2.2/workspace?userId=perf-user-dev-01` | 1200ms | +| Xiaobao warning | `/v2.2/xiaobao-warning?manager=true` | 1000ms | + +The medium fixture is the V2.6 acceptance target. Large fixture runs are for index audit and explain-plan work, not for every local commit. + +See `docs/performance-hot-queries.md` for query budgets, partition-key contracts, and required indexes. + +## Runtime Ops Dashboard + +`/admin/ops` provides the V2.6 runtime view for checking whether the hot paths and background workers stay healthy after fixture/perf runs. + +It reads `GET /api/v1/ops/runtime` and shows: + +- recent slow API requests captured by `ApiTimingInterceptor` +- recent slow Prisma query previews captured by `PrismaService` +- `background_jobs` totals and per-type queued/running/succeeded/failed counts +- recent failed jobs with redacted `lastError` +- dirty `xiaobao_risk_summaries` count + +Access is guarded in the frontend by `ops:view`. Backend RBAC is currently an explicit `OpsPermissionAdapter` placeholder until the V2.5 RBAC contract lands. + +Secret handling: + +- request query strings are stripped before display +- SQL is shown only as a bounded query preview, without Prisma parameters +- key-like text such as `sk-*`, token, secret, password, and authorization is redacted + +## Notes + +- `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable. +- Non-dry-run seeding deletes and recreates only `perf-*` rows. +- The harness intentionally depends on public API endpoints instead of calling Prisma directly; it measures the same path the frontend uses. diff --git a/docs/roadmap.md b/docs/roadmap.md index 901875e..c2fdd84 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,12 +1,14 @@ # 开发路线图 -## 当前阶段:V2.5 已完成 — 下一阶段 V2.6 大数据性能增强 + 小宝预警后台化 +## 当前阶段:V2.6 已完成 — 下一阶段 V2.7 企业协作 + V2.8 运维闭环集成 V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步副本”推进到“领域 CRUD 主写关系表 + AppData 兼容/迁移兜底”。V2.2 快读 API 和 V2.3 AppData 写后同步继续保留,但它们现在是兼容基础设施,不再是已迁移领域的数据新鲜度主链路。 V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性核对。AppData 不能直接删除,必须按“禁写 → 双读核对 → 移除 fallback → 只读归档/导出 → 后续删表”的顺序推进。 -### V2.5 完成范围 +V2.6 的目标是在关系表主源稳定后完成大数据性能增强、小宝风险后台化、AI 解读队列和运行时 Ops 看板,让高增长热路径、后台任务和风险摘要不再依赖页面打开。 + +### V2.5-V2.6 完成范围 1. **RBAC 收口**:领域 mutation API 已接入服务端权限校验、资源作用域和当前用户上下文。 2. **审计事件**:领域 mutation 通过 `audit_events` 写 append-only audit event,支持后台查询和敏感字段脱敏。 @@ -14,6 +16,10 @@ V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性 4. **导出归档**:已提供 AppData archive export/verify 脚本,包含 checksum、key list 和应用版本元数据。 5. **一致性校验**:已提供 counts、partition key、orphan refs、audit coverage 的本地脚本和后台页面。 6. **管理端可视化**:已补 `/admin/audit` 与 `/admin/consistency`,并由 `audit:view` / `consistency:view` 控制。 +7. **性能压测与热查询治理**:已补 deterministic fixture、`perf:check`、`perf:explain`、热查询索引审计和性能预算文档。 +8. **后台任务运行时**:已补 PostgreSQL-backed `background_jobs`、dedupe、lease、retry、失败记录和单步 worker。 +9. **小宝后台化**:已补服务端 summary refresh、dirty/enqueue 桥接和 `xiaobao.ai.interpret` AI 解读队列。 +10. **Ops 看板**:已补 `/admin/ops` 与 `GET /api/v1/ops/runtime`,展示慢请求、慢查询、job 队列和 dirty summary 数。 ## V2 分阶段交付链路 @@ -42,10 +48,22 @@ V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性 - 需求池已切到服务端分页、搜索、筛选、排序,不再要求加载全量 AppData 文档。 - `packages/shared` 状态契约已统一为当前业务状态机。 - V2.6/V2.7 协调边界:Xiaobao risk snapshots/insights 关系表写入和后台化归 V2.6;warning read-state API、部门/角色/密码规则/加班原因配置表归 V2.7。 +- V2.6.1 已新增 deterministic large-data fixture、HTTP performance harness 和性能预算文档。 +- V2.6.2 已新增 hot query explain/index audit 脚本、热查询索引迁移和 `docs/performance-hot-queries.md`。 +- V2.6.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。 +- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。 +- V2.6.5 已新增服务端小宝 AI 解读队列,summary 刷新后按 signature/cooldown/escalation policy 入队,只写 `xiaobao_risk_insights` 缓存。 +- V2.6.6 已新增 `/admin/ops` 运行时看板和 `GET /api/v1/ops/runtime`,展示慢请求、慢查询、job 队列和 dirty summary 数。 ### 已完成(按时间倒序) **2026-07-08** +- V2.6.6 added the Ops runtime dashboard with `ops:view`, redacted slow request/query buffers, background job queue summary, failed job list, and dirty Xiaobao summary count. +- V2.6.5 moved Xiaobao AI interpretation behind the background job runtime, reusing `AiService.interpretRisk()` and writing only insight cache rows. +- V2.6.4 moved deterministic Xiaobao risk summary refresh into the server, registered the `xiaobao.summary.refresh` background job handler, and enqueue refresh jobs from dirty domain writes. +- V2.6.3 added DB-backed background jobs with active dedupe keys, lease-based claiming, expired lock recovery, retry/terminal-failure handling, and a small handler worker. +- V2.6.2 added `perf:explain`, hot query explain targets, index audit documentation, and V2.6 hot-path indexes for workspace, Xiaobao warning/dirty queues, project/version lists, and evidence scans. +- V2.6.1 added deterministic small/medium/large fixture generation, `perf:check`, and `docs/performance.md` for hot API p50/p95 budgets. - V2.5.0 added server auth context, current-user decorator, permission decorator/guard/service, wildcard super admin support, project/version-member scope checks, and guard/service tests. - V2.5.1 added append-only `audit_events`, audit service/controller/query DTO, sensitive-field redaction, `audit:view`, and audit service/controller tests. - V2.5.2 protected V2.4 domain mutation APIs with server-side permission metadata and audit writes through `@ProtectedMutation()`. diff --git a/docs/workflow.md b/docs/workflow.md index 55c461b..b1fa0c8 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -339,9 +339,9 @@ Implementation convention: - `xiaobao.warning:manage`:查看所有未结束版本的预警。 - `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。 -页面打开时会聚合版本下的计划、开发任务、测试用例、Bug、日报和工作活动,计算当前风险并保存当天快照。页面使用 `buildXiaobaoWorkItems` 做版本级聚合,不使用个人工作台的 `aggregateWorkItems(userName, ...)` 过滤。快照按同版本同日节流保存:重大变化立即保存,普通变化 10 分钟内不重复写入。 +V2.6 后小宝当前 summary 由服务端后台刷新:领域写入标记 `xiaobao_risk_summaries.dirty=true` 并排入 `xiaobao.summary.refresh`,worker 从版本下的计划、开发任务、测试用例、Bug、日报和工作活动重新计算风险。页面仍可用前端 `buildXiaobaoWorkItems` 做兼容聚合和快照保存,但默认优先读取 V2.2 summary。 -AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、测试失败、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用客户端时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。 +AI 解读不由人工按钮触发。服务端 summary 刷新后按 policy 排入 `xiaobao.ai.interpret`:`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 当前服务端只在临近发版且仍有未完成工作时触发,页面完整趋势策略仍保留作为兼容。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用服务端写入时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。 静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。 diff --git a/package.json b/package.json index 16f92e5..ac341f1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "lint": "turbo lint", "type-check": "turbo type-check", "test": "turbo test", + "perf:seed": "node scripts/seed-large-dataset.mjs", + "perf:check": "node scripts/perf-check.mjs", + "perf:explain": "node scripts/explain-hot-queries.mjs", "deploy:verify": "node scripts/verify-production-deploy.mjs", "deploy:check-runtime": "node scripts/check-runtime-version.mjs", "appdata:archive:export": "node scripts/export-appdata-archive.mjs", diff --git a/scripts/explain-hot-queries.mjs b/scripts/explain-hot-queries.mjs new file mode 100644 index 0000000..327bdf3 --- /dev/null +++ b/scripts/explain-hot-queries.mjs @@ -0,0 +1,572 @@ +#!/usr/bin/env node +import { createRequire } from 'node:module'; + +const DEFAULTS = { + databaseUrl: process.env.DATABASE_URL, + productId: 'perf-product-001', + projectId: 'perf-project-001-001', + versionId: 'perf-version-001-001-001', + userId: 'perf-user-dev-01', + search: 'REQ', + limit: 50, + dryRun: false, + json: false, + strictPlan: false, +}; + +const REQUIRED_INDEXES = [ + 'requirements_product_project_status_created_at_idx', + 'requirements_version_status_created_at_idx', + 'version_plans_version_type_status_idx', + 'version_plans_owner_status_end_idx', + 'dev_tasks_version_status_updated_at_idx', + 'dev_tasks_assignee_unfinished_idx', + 'test_cases_version_round_status_updated_at_idx', + 'test_cases_version_round_status_updated_at_desc_idx', + 'test_cases_assignee_unfinished_idx', + 'bugs_version_status_severity_updated_at_idx', + 'bugs_assignee_open_idx', + 'xiaobao_risk_summaries_warning_score_idx', + 'xiaobao_risk_summaries_dirty_updated_at_idx', + 'projects_product_created_at_idx', + 'versions_product_created_at_idx', + 'versions_product_project_created_at_idx', + 'work_activities_version_occurred_at_idx', + 'task_worklogs_version_date_idx', + 'ai_logs_operation_created_at_idx', + 'ai_logs_status_created_at_idx', +]; + +function parseArgs(argv) { + const args = { ...DEFAULTS }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--dry-run') { + args.dryRun = true; + continue; + } + if (arg === '--json') { + args.json = true; + continue; + } + if (arg === '--strict-plan') { + args.strictPlan = true; + continue; + } + if (arg === '--database-url') { + args.databaseUrl = argv[++index] ?? args.databaseUrl; + continue; + } + if (arg.startsWith('--database-url=')) { + args.databaseUrl = arg.slice('--database-url='.length); + continue; + } + if (arg === '--product-id') { + args.productId = argv[++index] ?? args.productId; + continue; + } + if (arg === '--project-id') { + args.projectId = argv[++index] ?? args.projectId; + continue; + } + if (arg === '--version-id') { + args.versionId = argv[++index] ?? args.versionId; + continue; + } + if (arg === '--user-id') { + args.userId = argv[++index] ?? args.userId; + continue; + } + if (arg === '--search') { + args.search = argv[++index] ?? args.search; + continue; + } + if (arg === '--limit') { + args.limit = parsePositiveInt(argv[++index], args.limit); + continue; + } + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + return args; +} + +function printHelp() { + console.log(`Usage: node scripts/explain-hot-queries.mjs [--dry-run] [--database-url URL] + +Audits V2.6 hot query budgets and indexes. + +Options: + --dry-run Print hot query contracts without opening a database connection. + --json Print machine-readable JSON. + --strict-plan Fail when a SQL plan contains a sequential scan on a hot table. + --database-url PostgreSQL URL. Defaults to DATABASE_URL. + --product-id Fixture product id. Defaults to ${DEFAULTS.productId} + --project-id Fixture project id. Defaults to ${DEFAULTS.projectId} + --version-id Fixture version id. Defaults to ${DEFAULTS.versionId} + --user-id Fixture assignee/owner id. Defaults to ${DEFAULTS.userId} + --search Requirement search term. Defaults to ${DEFAULTS.search} + --limit Requirement page limit. Defaults to ${DEFAULTS.limit} +`); +} + +function parsePositiveInt(raw, fallback) { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(1, Math.floor(parsed)); +} + +function buildExplainTargets(args) { + const search = `%${args.search}%`; + const limit = args.limit + 1; + + return [ + { + key: 'healthVersion', + label: 'health/version', + budget: 'HTTP p95 <= 500ms; no database query', + partitionKey: 'none', + expectedIndexes: [], + sql: null, + }, + { + key: 'requirementPool', + label: 'V2.2 requirement pool search', + budget: 'HTTP p95 <= 1000ms; SQL should prune by product_id', + partitionKey: 'requirements.product_id', + expectedIndexes: [ + 'requirements_product_project_status_created_at_idx', + 'requirements_product_id_code_key', + ], + sql: ` +SELECT r.* +FROM requirements r +LEFT JOIN users u ON u.id = r.creator_id +WHERE r.product_id = $1 + AND (r.code ILIKE $2 OR r.title ILIKE $2) +ORDER BY r.created_at DESC +LIMIT $3`, + params: [args.productId, search, limit], + }, + { + key: 'versionDetail.version', + label: 'V2.2 version detail root', + budget: 'point lookup by versions.id', + partitionKey: 'versions.id', + expectedIndexes: ['versions_pkey'], + sql: 'SELECT * FROM versions WHERE id = $1', + params: [args.versionId], + }, + { + key: 'versionDetail.requirements', + label: 'V2.2 version detail requirements', + budget: 'child rows constrained by version_id', + partitionKey: 'requirements.version_id', + expectedIndexes: ['requirements_version_status_created_at_idx'], + sql: ` +SELECT r.* +FROM requirements r +LEFT JOIN users u ON u.id = r.creator_id +WHERE r.version_id = $1 +ORDER BY r.created_at DESC`, + params: [args.versionId], + }, + { + key: 'versionDetail.plans', + label: 'V2.2 version detail plans', + budget: 'child rows constrained by version_id', + partitionKey: 'version_plans.version_id', + expectedIndexes: ['version_plans_version_type_status_idx'], + sql: ` +SELECT * +FROM version_plans +WHERE version_id = $1 +ORDER BY type ASC, created_at DESC`, + params: [args.versionId], + }, + { + key: 'versionDetail.devTasks', + label: 'V2.2 version detail dev tasks', + budget: 'partition prune by version_id', + partitionKey: 'dev_tasks.version_id', + expectedIndexes: ['dev_tasks_version_status_updated_at_idx'], + sql: ` +SELECT * +FROM dev_tasks +WHERE version_id = $1 +ORDER BY status ASC, updated_at DESC`, + params: [args.versionId], + }, + { + key: 'versionDetail.testCases', + label: 'V2.2 version detail test cases', + budget: 'partition prune by version_id', + partitionKey: 'test_cases.version_id', + expectedIndexes: ['test_cases_version_round_status_updated_at_desc_idx'], + sql: ` +SELECT * +FROM test_cases +WHERE version_id = $1 +ORDER BY round_no DESC, status ASC, updated_at DESC`, + params: [args.versionId], + }, + { + key: 'versionDetail.bugs', + label: 'V2.2 version detail bugs', + budget: 'partition prune by version_id', + partitionKey: 'bugs.version_id', + expectedIndexes: ['bugs_version_status_priority_updated_at_idx'], + sql: ` +SELECT * +FROM bugs +WHERE version_id = $1 +ORDER BY status ASC, priority ASC, updated_at DESC`, + params: [args.versionId], + }, + { + key: 'workspace.plans', + label: 'V2.2 workspace plans', + budget: 'owner unfinished lookup', + partitionKey: 'version_plans.owner_id', + expectedIndexes: ['version_plans_owner_open_due_idx'], + sql: ` +SELECT * +FROM version_plans +WHERE owner_id = $1 + AND status <> 'completed' +ORDER BY expected_end_at ASC, updated_at DESC`, + params: [args.userId], + }, + { + key: 'workspace.devTasks', + label: 'V2.2 workspace dev tasks', + budget: 'assignee unfinished lookup', + partitionKey: 'dev_tasks.assignee_id', + expectedIndexes: ['dev_tasks_assignee_open_priority_idx'], + sql: ` +SELECT * +FROM dev_tasks +WHERE assignee_id = $1 + AND status <> 'submitted' +ORDER BY priority ASC, updated_at DESC`, + params: [args.userId], + }, + { + key: 'workspace.testCases', + label: 'V2.2 workspace test cases', + budget: 'assignee unfinished lookup', + partitionKey: 'test_cases.assignee_id', + expectedIndexes: ['test_cases_assignee_open_priority_idx'], + sql: ` +SELECT * +FROM test_cases +WHERE assignee_id = $1 + AND status NOT IN ('passed', 'failed', 'blocked') +ORDER BY priority ASC, updated_at DESC`, + params: [args.userId], + }, + { + key: 'workspace.bugs', + label: 'V2.2 workspace bugs', + budget: 'assignee open lookup', + partitionKey: 'bugs.assignee_id', + expectedIndexes: ['bugs_assignee_open_priority_idx'], + sql: ` +SELECT * +FROM bugs +WHERE assignee_id = $1 + AND status IN ('open', 'fixing', 'fixed', 'verifying') +ORDER BY priority ASC, updated_at DESC`, + params: [args.userId], + }, + { + key: 'xiaobao.managerWarnings', + label: 'V2.2 Xiaobao manager warning list', + budget: 'HTTP p95 <= 1000ms; warning rows sorted by score', + partitionKey: 'xiaobao_risk_summaries.version_id', + expectedIndexes: ['xiaobao_risk_summaries_warning_score_idx'], + sql: ` +SELECT * +FROM xiaobao_risk_summaries +WHERE risk_level <> 'on_track' +ORDER BY risk_score DESC, updated_at DESC`, + params: [], + }, + { + key: 'xiaobao.dirtyQueue', + label: 'V2.6 Xiaobao dirty summary queue', + budget: 'background worker batch scan', + partitionKey: 'xiaobao_risk_summaries.version_id', + expectedIndexes: ['xiaobao_risk_summaries_dirty_updated_at_idx'], + sql: ` +SELECT version_id +FROM xiaobao_risk_summaries +WHERE dirty = true +ORDER BY updated_at ASC +LIMIT 100`, + params: [], + }, + { + key: 'domain.projects', + label: 'Project list by product', + budget: 'domain list keeps product_id prefix', + partitionKey: 'projects.product_id', + expectedIndexes: ['projects_product_created_at_idx'], + sql: ` +SELECT * +FROM projects +WHERE product_id = $1 +ORDER BY created_at DESC`, + params: [args.productId], + }, + { + key: 'domain.versionsByProduct', + label: 'Version list by product', + budget: 'domain list keeps product_id prefix', + partitionKey: 'versions.product_id', + expectedIndexes: ['versions_product_created_at_idx'], + sql: ` +SELECT * +FROM versions +WHERE product_id = $1 +ORDER BY created_at DESC`, + params: [args.productId], + }, + { + key: 'domain.versionsByProject', + label: 'Version list by product/project', + budget: 'domain list keeps product_id + project_id prefix', + partitionKey: 'versions.product_id, versions.project_id', + expectedIndexes: ['versions_product_project_created_at_idx'], + sql: ` +SELECT * +FROM versions +WHERE product_id = $1 + AND project_id = $2 +ORDER BY created_at DESC`, + params: [args.productId, args.projectId], + }, + { + key: 'xiaobao.versionActivities', + label: 'Xiaobao evidence activity scan', + budget: 'background summary recompute by version', + partitionKey: 'work_activities.version_id', + expectedIndexes: ['work_activities_version_occurred_at_idx'], + sql: ` +SELECT * +FROM work_activities +WHERE version_id = $1 +ORDER BY occurred_at DESC +LIMIT 200`, + params: [args.versionId], + }, + { + key: 'audit.searchAdapter', + label: 'Audit search adapter contract', + budget: 'V2.5 audit module not landed; AiLog audit uses operation/status created_at indexes', + partitionKey: 'ai_logs.created_at', + expectedIndexes: ['ai_logs_operation_created_at_idx', 'ai_logs_status_created_at_idx'], + sql: ` +SELECT * +FROM ai_logs +WHERE operation = $1 +ORDER BY created_at DESC +LIMIT 100`, + params: ['risk-interpret'], + adapter: 'Replace with audit_events(actor_id, resource_type, created_at) once V2.5 audit contract lands.', + }, + ]; +} + +function printDryRun(targets) { + console.log('Hot query explain dry-run'); + console.log('key\tpartition_key\texpected_indexes\tbudget'); + for (const target of targets) { + console.log([ + target.key, + target.partitionKey, + target.expectedIndexes.join(',') || '-', + target.budget, + ].join('\t')); + } +} + +async function loadPrismaClient(databaseUrl) { + if (databaseUrl) process.env.DATABASE_URL = databaseUrl; + const serverRequire = createRequire(new URL('../apps/server/package.json', import.meta.url)); + const { PrismaClient } = serverRequire('@prisma/client'); + return new PrismaClient(); +} + +async function auditIndexes(prisma, indexNames) { + const uniqueNames = Array.from(new Set(indexNames)); + const valuesSql = uniqueNames.map((_, index) => `($${index + 1})`).join(', '); + const rows = await prisma.$queryRawUnsafe( + ` +WITH wanted(index_name) AS ( + VALUES ${valuesSql} +) +SELECT wanted.index_name, to_regclass('public.' || quote_ident(wanted.index_name)) IS NOT NULL AS present +FROM wanted +ORDER BY wanted.index_name +`, + ...uniqueNames, + ); + return rows.map((row) => ({ + indexName: row.index_name, + present: Boolean(row.present), + })); +} + +async function explainTarget(prisma, target) { + if (!target.sql) { + return { + key: target.key, + label: target.label, + skipped: true, + reason: 'no SQL query', + }; + } + + const rows = await prisma.$queryRawUnsafe( + `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${target.sql}`, + ...(target.params ?? []), + ); + const plan = readExplainPlan(rows); + const summary = summarizePlan(plan); + return { + key: target.key, + label: target.label, + skipped: false, + summary, + seqScans: collectSeqScans(plan), + }; +} + +function readExplainPlan(rows) { + const first = rows?.[0]; + if (!first) return null; + const raw = first['QUERY PLAN'] ?? first['QUERY PLAN'.toLowerCase()] ?? first.query_plan; + if (Array.isArray(raw)) return raw[0]; + if (typeof raw === 'string') return JSON.parse(raw)[0]; + return raw?.[0] ?? raw; +} + +function summarizePlan(plan) { + const root = plan?.Plan ?? plan; + return { + nodeType: root?.['Node Type'] ?? 'unknown', + totalCost: Number(root?.['Total Cost'] ?? 0), + actualTotalTimeMs: Number(root?.['Actual Total Time'] ?? 0), + actualRows: Number(root?.['Actual Rows'] ?? 0), + sharedHitBlocks: Number(root?.['Shared Hit Blocks'] ?? 0), + sharedReadBlocks: Number(root?.['Shared Read Blocks'] ?? 0), + }; +} + +function collectSeqScans(plan) { + const found = []; + visitPlan(plan?.Plan ?? plan, (node) => { + if (node?.['Node Type'] === 'Seq Scan') { + found.push({ + relation: node['Relation Name'] ?? 'unknown', + alias: node.Alias ?? '', + filter: node.Filter ?? '', + }); + } + }); + return found; +} + +function visitPlan(node, callback) { + if (!node) return; + callback(node); + for (const child of node.Plans ?? []) visitPlan(child, callback); +} + +function printResults(indexAudit, explainResults, strictPlan) { + console.log('Index audit'); + for (const item of indexAudit) { + console.log(`${item.present ? 'ok' : 'missing'}\t${item.indexName}`); + } + + console.log('\nExplain plans'); + console.log('key\tnode\tactual_ms\tactual_rows\tshared_read_blocks\tseq_scan'); + for (const result of explainResults) { + if (result.skipped) { + console.log(`${result.key}\tskipped\t-\t-\t-\t${result.reason}`); + continue; + } + const seqScan = result.seqScans.map((scan) => scan.relation).join(',') || '-'; + console.log([ + result.key, + result.summary.nodeType, + result.summary.actualTotalTimeMs.toFixed(3), + result.summary.actualRows, + result.summary.sharedReadBlocks, + seqScan, + ].join('\t')); + } + + if (strictPlan) { + console.log('\nstrict-plan enabled: sequential scans on SQL targets are treated as failures.'); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const targets = buildExplainTargets(args); + const expectedIndexes = [ + ...REQUIRED_INDEXES, + ...targets.flatMap((target) => target.expectedIndexes), + ]; + + if (args.dryRun) { + const payload = { + targets: targets.map(({ sql, params, ...target }) => ({ + ...target, + sql: sql?.trim() ?? null, + params, + })), + requiredIndexes: Array.from(new Set(expectedIndexes)).sort(), + }; + if (args.json) console.log(JSON.stringify(payload, null, 2)); + else printDryRun(targets); + return; + } + + if (!args.databaseUrl) { + throw new Error('DATABASE_URL is required unless --dry-run is used'); + } + + const prisma = await loadPrismaClient(args.databaseUrl); + try { + const indexAudit = await auditIndexes(prisma, expectedIndexes); + const explainResults = []; + for (const target of targets) { + explainResults.push(await explainTarget(prisma, target)); + } + + if (args.json) { + console.log(JSON.stringify({ indexAudit, explainResults }, null, 2)); + } else { + printResults(indexAudit, explainResults, args.strictPlan); + } + + const hasMissingIndexes = indexAudit.some((item) => !item.present); + const hasSeqScans = explainResults.some((result) => !result.skipped && result.seqScans.length > 0); + if (hasMissingIndexes || (args.strictPlan && hasSeqScans)) { + process.exitCode = 1; + } + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/scripts/perf-check.mjs b/scripts/perf-check.mjs new file mode 100644 index 0000000..5982643 --- /dev/null +++ b/scripts/perf-check.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +import { performance } from 'node:perf_hooks'; + +const DEFAULT_THRESHOLDS = { + healthVersion: 500, + requirementPool: 1000, + versionDetail: 1500, + workspace: 1200, + xiaobaoWarning: 1000, +}; + +const DEFAULTS = { + baseUrl: 'http://localhost:3001/api/v1', + productId: 'perf-product-001', + versionId: 'perf-version-001-001-001', + userId: 'perf-user-dev-01', + iterations: 5, + warmup: 1, + dryRun: false, +}; + +function parseArgs(argv) { + const args = { ...DEFAULTS }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--dry-run') { + args.dryRun = true; + continue; + } + if (arg === '--base-url') { + args.baseUrl = argv[++index] ?? args.baseUrl; + continue; + } + if (arg.startsWith('--base-url=')) { + args.baseUrl = arg.slice('--base-url='.length); + continue; + } + if (arg === '--product-id') { + args.productId = argv[++index] ?? args.productId; + continue; + } + if (arg === '--version-id') { + args.versionId = argv[++index] ?? args.versionId; + continue; + } + if (arg === '--user-id') { + args.userId = argv[++index] ?? args.userId; + continue; + } + if (arg === '--iterations') { + args.iterations = parsePositiveInt(argv[++index], args.iterations); + continue; + } + if (arg === '--warmup') { + args.warmup = parsePositiveInt(argv[++index], args.warmup, 0); + continue; + } + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + args.baseUrl = args.baseUrl.replace(/\/+$/, ''); + return args; +} + +function printHelp() { + console.log(`Usage: node scripts/perf-check.mjs [--base-url URL] [--dry-run] + +Runs the V2.6 hot API performance harness. + +Options: + --base-url API root. Defaults to ${DEFAULTS.baseUrl} + --product-id Requirement pool product id. Defaults to ${DEFAULTS.productId} + --version-id Version detail id. Defaults to ${DEFAULTS.versionId} + --user-id Workspace user id. Defaults to ${DEFAULTS.userId} + --iterations Timed iterations per probe. Defaults to ${DEFAULTS.iterations} + --warmup Warmup iterations per probe. Defaults to ${DEFAULTS.warmup} + --dry-run Print probes without making HTTP requests. +`); +} + +function parsePositiveInt(raw, fallback, min = 1) { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.floor(parsed)); +} + +function buildProbes(args) { + const requirementQuery = new URLSearchParams({ + productId: args.productId, + q: 'REQ', + limit: '50', + }); + const workspaceQuery = new URLSearchParams({ userId: args.userId }); + const xiaobaoQuery = new URLSearchParams({ manager: 'true' }); + + return [ + { + key: 'healthVersion', + label: 'health/version', + method: 'GET', + path: '/health/version', + thresholdMs: DEFAULT_THRESHOLDS.healthVersion, + }, + { + key: 'requirementPool', + label: 'v2.2 requirement query', + method: 'GET', + path: `/v2.2/requirements?${requirementQuery.toString()}`, + thresholdMs: DEFAULT_THRESHOLDS.requirementPool, + }, + { + key: 'versionDetail', + label: 'v2.2 version detail', + method: 'GET', + path: `/v2.2/versions/${encodeURIComponent(args.versionId)}/detail-data`, + thresholdMs: DEFAULT_THRESHOLDS.versionDetail, + }, + { + key: 'workspace', + label: 'v2.2 workspace', + method: 'GET', + path: `/v2.2/workspace?${workspaceQuery.toString()}`, + thresholdMs: DEFAULT_THRESHOLDS.workspace, + }, + { + key: 'xiaobaoWarning', + label: 'v2.2 xiaobao warning', + method: 'GET', + path: `/v2.2/xiaobao-warning?${xiaobaoQuery.toString()}`, + thresholdMs: DEFAULT_THRESHOLDS.xiaobaoWarning, + }, + ].map((probe) => ({ + ...probe, + url: `${args.baseUrl}${probe.path}`, + })); +} + +async function measureProbe(probe, args) { + for (let index = 0; index < args.warmup; index += 1) { + await requestOnce(probe); + } + + const samples = []; + const statuses = new Map(); + const errors = []; + for (let index = 0; index < args.iterations; index += 1) { + const result = await requestOnce(probe); + samples.push(result.durationMs); + statuses.set(result.status, (statuses.get(result.status) ?? 0) + 1); + if (result.error) errors.push(result.error); + if (result.status < 200 || result.status >= 300) { + errors.push(`HTTP ${result.status}`); + } + } + + const p50 = percentile(samples, 50); + const p95 = percentile(samples, 95); + if (p95 > probe.thresholdMs) { + errors.push(`p95 ${p95.toFixed(1)}ms > budget ${probe.thresholdMs}ms`); + } + + return { + ...probe, + count: samples.length, + p50, + p95, + statuses: [...statuses.entries()].sort(([a], [b]) => a - b), + ok: errors.length === 0, + errors, + }; +} + +async function requestOnce(probe) { + const startedAt = performance.now(); + try { + const response = await fetch(probe.url, { method: probe.method }); + await response.arrayBuffer(); + return { + status: response.status, + durationMs: performance.now() - startedAt, + }; + } catch (error) { + return { + status: 0, + durationMs: performance.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function percentile(values, pct) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.ceil((pct / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(sorted.length - 1, index))]; +} + +function formatStatuses(statuses) { + return statuses.map(([status, count]) => `${status}:${count}`).join(', '); +} + +function printProbePlan(probes) { + console.log('Performance harness dry-run'); + for (const probe of probes) { + console.log(`${probe.key}\t${probe.method}\t${probe.url}\tbudget_p95_ms=${probe.thresholdMs}`); + } +} + +function printResults(results) { + console.log('probe\tstatus\tp50_ms\tp95_ms\tbudget_p95_ms\tok'); + for (const result of results) { + console.log([ + result.key, + formatStatuses(result.statuses), + result.p50.toFixed(1), + result.p95.toFixed(1), + result.thresholdMs, + result.ok ? 'yes' : 'no', + ].join('\t')); + if (result.errors.length > 0) { + for (const error of result.errors) console.error(`${result.key}: ${error}`); + } + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const probes = buildProbes(args); + if (args.dryRun) { + printProbePlan(probes); + return; + } + + const results = []; + for (const probe of probes) { + results.push(await measureProbe(probe, args)); + } + printResults(results); + if (results.some((result) => !result.ok)) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); + diff --git a/scripts/seed-large-dataset.mjs b/scripts/seed-large-dataset.mjs new file mode 100644 index 0000000..f83a781 --- /dev/null +++ b/scripts/seed-large-dataset.mjs @@ -0,0 +1,661 @@ +#!/usr/bin/env node +import { createRequire } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +const FIXTURE_VERSION = 'v2.6-large-data-fixture-1'; +const ID_PREFIX = 'perf-'; +const BASE_DATE = new Date('2026-07-01T09:00:00.000Z'); +const OUTPUT_PATH = '.tmp/perf-fixture.json'; + +const SIZE_PRESETS = { + small: { + products: 1, + projectsPerProduct: 2, + versionsPerProject: 2, + requirementsPerVersion: 20, + looseRequirementsPerProduct: 20, + plansPerVersion: 3, + devTasksPerRequirement: 2, + testCasesPerRequirement: 2, + bugsPerVersion: 12, + activitiesPerVersion: 16, + users: 8, + }, + medium: { + products: 2, + projectsPerProduct: 8, + versionsPerProject: 4, + requirementsPerVersion: 80, + looseRequirementsPerProduct: 300, + plansPerVersion: 3, + devTasksPerRequirement: 3, + testCasesPerRequirement: 3, + bugsPerVersion: 80, + activitiesPerVersion: 120, + users: 40, + }, + large: { + products: 4, + projectsPerProduct: 20, + versionsPerProject: 6, + requirementsPerVersion: 150, + looseRequirementsPerProduct: 2000, + plansPerVersion: 3, + devTasksPerRequirement: 4, + testCasesPerRequirement: 4, + bugsPerVersion: 180, + activitiesPerVersion: 300, + users: 120, + }, +}; + +const DEV_STATUSES = ['todo', 'in_progress', 'testing', 'submitted']; +const TEST_STATUSES = ['pending', 'running', 'passed', 'failed', 'blocked']; +const BUG_STATUSES = ['open', 'fixing', 'fixed', 'verifying', 'closed']; +const BUG_SEVERITIES = ['normal', 'major', 'critical']; +const PLAN_TYPES = ['research', 'product', 'ui']; +const REQUIREMENT_STATUSES = ['adopted', 'planned', 'developing', 'testing', 'released']; + +function parseArgs(argv) { + const args = { + size: 'small', + dryRun: false, + json: false, + output: OUTPUT_PATH, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--dry-run') { + args.dryRun = true; + continue; + } + if (arg === '--json') { + args.json = true; + continue; + } + if (arg === '--size') { + args.size = argv[++index] ?? args.size; + continue; + } + if (arg.startsWith('--size=')) { + args.size = arg.slice('--size='.length); + continue; + } + if (arg === '--output') { + args.output = argv[++index] ?? args.output; + continue; + } + if (arg.startsWith('--output=')) { + args.output = arg.slice('--output='.length); + continue; + } + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!SIZE_PRESETS[args.size]) { + throw new Error(`Unsupported size "${args.size}". Use one of: ${Object.keys(SIZE_PRESETS).join(', ')}`); + } + return args; +} + +function printHelp() { + console.log(`Usage: node scripts/seed-large-dataset.mjs [--size small|medium|large] [--dry-run] [--json] + +Creates deterministic V2.6 performance fixture data using perf-* ids. + +Options: + --size Fixture size. Defaults to small. + --dry-run Build and summarize rows without writing PostgreSQL. + --json Print the summary as JSON. + --output Metadata path written after a real seed. Defaults to ${OUTPUT_PATH}. +`); +} + +function pad(value, width = 3) { + return String(value).padStart(width, '0'); +} + +function hoursAfter(hours) { + return new Date(BASE_DATE.getTime() + hours * 60 * 60 * 1000); +} + +function daysAfter(days) { + return hoursAfter(days * 24); +} + +function priority(index) { + return index % 5; +} + +function buildFixture(sizeName) { + const preset = SIZE_PRESETS[sizeName]; + const users = buildUsers(preset.users); + const products = []; + const projects = []; + const versions = []; + const requirements = []; + const versionPlans = []; + const devTasks = []; + const testCases = []; + const bugs = []; + const workActivities = []; + const xiaobaoRiskSummaries = []; + + for (let productNo = 1; productNo <= preset.products; productNo += 1) { + const productId = `${ID_PREFIX}product-${pad(productNo)}`; + products.push({ + id: productId, + name: `Performance Product ${pad(productNo)}`, + description: `Deterministic ${sizeName} fixture product ${productNo}`, + createdAt: daysAfter(productNo), + updatedAt: daysAfter(productNo), + }); + + for (let looseNo = 1; looseNo <= preset.looseRequirementsPerProduct; looseNo += 1) { + requirements.push(buildRequirement({ + productId, + projectId: null, + versionId: null, + productNo, + projectNo: 0, + versionNo: 0, + requirementNo: looseNo, + loose: true, + creatorId: users[looseNo % users.length].id, + })); + } + + for (let projectNo = 1; projectNo <= preset.projectsPerProduct; projectNo += 1) { + const projectId = `${ID_PREFIX}project-${pad(productNo)}-${pad(projectNo)}`; + projects.push({ + id: projectId, + productId, + name: `Performance Project ${pad(productNo)}-${pad(projectNo)}`, + description: `Hot-path project ${projectNo}`, + createdAt: daysAfter(projectNo), + updatedAt: daysAfter(projectNo), + }); + + for (let versionNo = 1; versionNo <= preset.versionsPerProject; versionNo += 1) { + const versionId = `${ID_PREFIX}version-${pad(productNo)}-${pad(projectNo)}-${pad(versionNo)}`; + const version = buildVersion({ + productId, + projectId, + versionId, + productNo, + projectNo, + versionNo, + users, + }); + versions.push(version); + + for (let planNo = 1; planNo <= preset.plansPerVersion; planNo += 1) { + versionPlans.push(buildVersionPlan({ + productId, + projectId, + versionId, + planNo, + ownerId: users[(planNo + versionNo) % users.length].id, + })); + } + + const versionRequirementIds = []; + for (let requirementNo = 1; requirementNo <= preset.requirementsPerVersion; requirementNo += 1) { + const req = buildRequirement({ + productId, + projectId, + versionId, + productNo, + projectNo, + versionNo, + requirementNo, + loose: false, + creatorId: users[requirementNo % users.length].id, + }); + requirements.push(req); + versionRequirementIds.push(req.id); + + for (let taskNo = 1; taskNo <= preset.devTasksPerRequirement; taskNo += 1) { + devTasks.push(buildDevTask({ + productId, + projectId, + versionId, + requirementId: req.id, + requirementProductId: productId, + requirementNo, + taskNo, + assigneeId: users[(requirementNo + taskNo) % users.length].id, + })); + } + + for (let caseNo = 1; caseNo <= preset.testCasesPerRequirement; caseNo += 1) { + testCases.push(buildTestCase({ + productId, + projectId, + versionId, + requirementId: req.id, + requirementProductId: productId, + requirementNo, + caseNo, + assigneeId: users[(requirementNo + caseNo + 2) % users.length].id, + })); + } + } + + for (let bugNo = 1; bugNo <= preset.bugsPerVersion; bugNo += 1) { + bugs.push(buildBug({ + productId, + projectId, + versionId, + bugNo, + assigneeId: users[(bugNo + versionNo) % users.length].id, + testCaseId: testCases.find((row) => row.versionId === versionId)?.id, + })); + } + + for (let activityNo = 1; activityNo <= preset.activitiesPerVersion; activityNo += 1) { + const source = devTasks.find((row) => row.versionId === versionId && row.assigneeId === users[activityNo % users.length].id) + ?? devTasks.find((row) => row.versionId === versionId); + workActivities.push(buildWorkActivity({ + productId, + projectId, + versionId, + activityNo, + actor: users[activityNo % users.length], + source, + })); + } + + xiaobaoRiskSummaries.push(buildXiaobaoSummary({ + version, + bugCount: preset.bugsPerVersion, + unfinishedCount: versionRequirementIds.length, + })); + } + } + } + + return { + size: sizeName, + anchors: { + productId: `${ID_PREFIX}product-001`, + projectId: `${ID_PREFIX}project-001-001`, + versionId: `${ID_PREFIX}version-001-001-001`, + userId: `${ID_PREFIX}user-dev-01`, + }, + rows: { + users, + products, + projects, + versions, + requirements, + versionPlans, + devTasks, + testCases, + bugs, + workActivities, + xiaobaoRiskSummaries, + }, + }; +} + +function buildUsers(count) { + return Array.from({ length: count }, (_, index) => { + const no = index + 1; + return { + id: `${ID_PREFIX}user-dev-${pad(no, 2)}`, + email: `perf-user-${pad(no, 2)}@example.test`, + name: `Perf User ${pad(no, 2)}`, + username: `perf_user_${pad(no, 2)}`, + departmentId: no % 3 === 0 ? 'dept-2-3' : no % 2 === 0 ? 'dept-2-2' : 'dept-2-1', + roleId: no % 3 === 0 ? 'role-test' : 'role-dev', + phone: '', + password: 'Perf@2026', + isSystem: false, + createdAt: BASE_DATE, + updatedAt: BASE_DATE, + }; + }); +} + +function buildVersion(input) { + const releaseDate = daysAfter(20 + input.versionNo); + return { + id: input.versionId, + productId: input.productId, + projectId: input.projectId, + name: `V${input.productNo}.${input.projectNo}.${input.versionNo}`, + description: 'Performance hot-path version', + status: input.versionNo % 4 === 0 ? 'paused' : 'developing', + currentStage: input.versionNo % 2 === 0 ? 'testing' : 'development', + startDate: daysAfter(input.versionNo), + expectedReleaseDate: releaseDate, + releaseDate, + members: input.users.slice(0, Math.min(6, input.users.length)).map((user, index) => ({ + id: user.id, + name: user.name, + role: index % 3 === 0 ? 'testing' : index % 2 === 0 ? 'backend' : 'frontend', + })), + progress: [], + priority: priority(input.versionNo), + links: {}, + createdAt: daysAfter(input.versionNo), + updatedAt: daysAfter(input.versionNo + 1), + }; +} + +function buildRequirement(input) { + const scoped = input.loose + ? `LOOSE-${pad(input.productNo)}-${pad(input.requirementNo, 5)}` + : `${pad(input.productNo)}-${pad(input.projectNo)}-${pad(input.versionNo)}-${pad(input.requirementNo, 5)}`; + return { + id: `${ID_PREFIX}req-${scoped}`, + productId: input.productId, + projectId: input.projectId, + versionId: input.versionId, + code: `REQ-${scoped}`, + title: `Requirement ${scoped}`, + description: `Deterministic requirement ${scoped}`, + status: input.loose ? 'adopted' : REQUIREMENT_STATUSES[input.requirementNo % REQUIREMENT_STATUSES.length], + priority: priority(input.requirementNo), + type: input.requirementNo % 2 === 0 ? 'feature' : 'optimization', + sourceType: input.requirementNo % 3 === 0 ? 'customer' : 'internal', + sourceTarget: input.requirementNo % 3 === 0 ? 'perf-customer' : 'perf-team', + platform: input.requirementNo % 2 === 0 ? 'web,ios' : 'web', + creatorId: input.creatorId, + createdAt: hoursAfter(input.requirementNo), + updatedAt: hoursAfter(input.requirementNo + 1), + }; +} + +function buildVersionPlan(input) { + const type = PLAN_TYPES[(input.planNo - 1) % PLAN_TYPES.length]; + return { + id: `${ID_PREFIX}plan-${input.versionId}-${type}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + type, + title: `${type} plan for ${input.versionId}`, + status: input.planNo === 1 ? 'completed' : 'in_progress', + ownerId: input.ownerId, + expectedStartAt: daysAfter(input.planNo), + expectedEndAt: daysAfter(input.planNo + 3), + actualStartAt: daysAfter(input.planNo), + completedAt: input.planNo === 1 ? daysAfter(input.planNo + 2) : null, + resultUrl: input.planNo === 1 ? 'https://example.test/prototype' : null, + requirementCoverage: [], + logs: [], + createdAt: daysAfter(input.planNo), + updatedAt: daysAfter(input.planNo + 1), + }; +} + +function buildDevTask(input) { + const status = DEV_STATUSES[(input.requirementNo + input.taskNo) % DEV_STATUSES.length]; + const blocked = status !== 'submitted' && input.taskNo % 11 === 0; + return { + id: `${ID_PREFIX}dev-${input.versionId}-${pad(input.requirementNo, 5)}-${pad(input.taskNo, 2)}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + requirementId: input.requirementId, + requirementProductId: input.requirementProductId, + categoryId: null, + code: `DEV-${pad(input.requirementNo, 5)}-${pad(input.taskNo, 2)}`, + title: `Dev task ${pad(input.requirementNo, 5)}-${pad(input.taskNo, 2)}`, + description: 'Performance fixture development task', + status, + priority: priority(input.requirementNo + input.taskNo), + assigneeId: input.assigneeId, + creatorId: input.assigneeId, + isBlocked: blocked, + blockReason: blocked ? 'Fixture blocker' : null, + expectedStartAt: daysAfter(input.taskNo), + expectedEndAt: daysAfter(input.taskNo + 2), + startDate: status === 'todo' ? null : daysAfter(input.taskNo), + completedAt: status === 'submitted' ? daysAfter(input.taskNo + 2) : null, + estimateHours: 4 + (input.taskNo % 5), + aiEstimateHours: 3 + (input.taskNo % 4), + references: [{ type: 'requirement', id: input.requirementId }], + aiDraft: false, + aiDraftAt: null, + createdAt: hoursAfter(input.requirementNo + input.taskNo), + updatedAt: hoursAfter(input.requirementNo + input.taskNo + 1), + }; +} + +function buildTestCase(input) { + const status = TEST_STATUSES[(input.requirementNo + input.caseNo) % TEST_STATUSES.length]; + return { + id: `${ID_PREFIX}case-${input.versionId}-${pad(input.requirementNo, 5)}-${pad(input.caseNo, 2)}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + requirementId: input.requirementId, + requirementProductId: input.requirementProductId, + categoryId: null, + code: `TC-${pad(input.requirementNo, 5)}-${pad(input.caseNo, 2)}`, + title: `Test case ${pad(input.requirementNo, 5)}-${pad(input.caseNo, 2)}`, + description: 'Performance fixture test case', + status, + roundNo: input.caseNo % 3 === 0 ? 2 : 1, + priority: priority(input.requirementNo + input.caseNo), + assigneeId: input.assigneeId, + creatorId: input.assigneeId, + plannedTestAt: daysAfter(input.caseNo + 3), + plannedEndAt: daysAfter(input.caseNo + 4), + startedAt: status === 'pending' ? null : daysAfter(input.caseNo + 3), + completedAt: ['passed', 'failed', 'blocked'].includes(status) ? daysAfter(input.caseNo + 4) : null, + estimateHours: 2 + (input.caseNo % 4), + aiEstimateHours: 1 + (input.caseNo % 3), + references: [{ type: 'requirement', id: input.requirementId }], + aiDraft: false, + aiDraftAt: null, + createdAt: hoursAfter(input.requirementNo + input.caseNo), + updatedAt: hoursAfter(input.requirementNo + input.caseNo + 1), + }; +} + +function buildBug(input) { + const status = BUG_STATUSES[input.bugNo % BUG_STATUSES.length]; + const severity = BUG_SEVERITIES[input.bugNo % BUG_SEVERITIES.length]; + return { + id: `${ID_PREFIX}bug-${input.versionId}-${pad(input.bugNo, 5)}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + testCaseId: input.testCaseId ?? null, + testCaseVersionId: input.testCaseId ? input.versionId : null, + code: `BUG-${pad(input.bugNo, 5)}`, + title: `Bug ${pad(input.bugNo, 5)}`, + description: 'Performance fixture bug', + status, + severity, + priority: priority(input.bugNo), + assigneeId: input.assigneeId, + reporterId: input.assigneeId, + plannedFixAt: daysAfter(input.bugNo % 10), + resolvedAt: ['fixed', 'verifying', 'closed'].includes(status) ? daysAfter((input.bugNo % 10) + 1) : null, + closedAt: status === 'closed' ? daysAfter((input.bugNo % 10) + 2) : null, + resolution: status === 'closed' ? 'fixed' : null, + createdAt: hoursAfter(input.bugNo), + updatedAt: hoursAfter(input.bugNo + 1), + }; +} + +function buildWorkActivity(input) { + const source = input.source; + return { + id: `${ID_PREFIX}activity-${input.versionId}-${pad(input.activityNo, 5)}`, + versionId: input.versionId, + productId: input.productId, + projectId: input.projectId, + actorId: input.actor.id, + actorName: input.actor.name, + sourceType: 'dev_task', + sourceId: source?.id ?? `${ID_PREFIX}missing-source`, + sourceVersionId: input.versionId, + action: input.activityNo % 5 === 0 ? 'blocked' : 'progress', + title: `Activity ${pad(input.activityNo, 5)}`, + metadata: { + category: input.activityNo % 5 === 0 ? 'risk' : 'progress', + summary: `Performance activity ${input.activityNo}`, + }, + occurredAt: hoursAfter(input.activityNo), + createdAt: hoursAfter(input.activityNo), + }; +} + +function buildXiaobaoSummary(input) { + const riskScore = Math.min(100, 40 + (input.bugCount % 50)); + const riskLevel = riskScore >= 75 ? 'likely_delayed' : 'at_risk'; + const riskSignature = `${input.version.id}|${riskLevel}|${riskScore}|${input.unfinishedCount}`; + return { + versionId: input.version.id, + riskLevel, + riskScore, + confidence: 72, + forecastReleaseDate: daysAfter(24), + riskSignature, + summary: { + versionId: input.version.id, + versionName: input.version.name, + riskLevel, + riskScore, + confidence: 72, + riskSignature, + dirty: false, + signals: { + unfinishedCount: input.unfinishedCount, + openBugCount: input.bugCount, + criticalBugCount: Math.floor(input.bugCount / 12), + failedTestCount: Math.floor(input.unfinishedCount / 8), + blockedCount: Math.floor(input.unfinishedCount / 15), + silentRiskCount: Math.floor(input.unfinishedCount / 20), + }, + reasons: [ + { + key: 'remaining_work', + title: 'Remaining fixture work', + detail: `Fixture has ${input.unfinishedCount} scoped requirements.`, + severity: 'warning', + }, + ], + currentSnapshot: { + versionId: input.version.id, + date: BASE_DATE.toISOString().slice(0, 10), + riskScore, + riskLevel, + openBugCount: input.bugCount, + criticalBugCount: Math.floor(input.bugCount / 12), + failedTestCount: Math.floor(input.unfinishedCount / 8), + blockedCount: Math.floor(input.unfinishedCount / 15), + silentRiskCount: Math.floor(input.unfinishedCount / 20), + confidence: 72, + createdAt: BASE_DATE.toISOString(), + }, + }, + dirty: false, + recomputedAt: BASE_DATE, + updatedAt: BASE_DATE, + }; +} + +function summarizeFixture(fixture) { + return { + fixtureVersion: FIXTURE_VERSION, + size: fixture.size, + anchors: fixture.anchors, + counts: Object.fromEntries( + Object.entries(fixture.rows).map(([key, rows]) => [key, rows.length]), + ), + }; +} + +async function seedDatabase(fixture, outputPath) { + const serverRequire = createRequire(new URL('../apps/server/package.json', import.meta.url)); + const { PrismaClient } = serverRequire('@prisma/client'); + const prisma = new PrismaClient(); + + try { + await clearPerfRows(prisma); + await prisma.user.createMany({ data: fixture.rows.users, skipDuplicates: true }); + await prisma.product.createMany({ data: fixture.rows.products, skipDuplicates: true }); + await prisma.project.createMany({ data: fixture.rows.projects, skipDuplicates: true }); + await prisma.version.createMany({ data: fixture.rows.versions, skipDuplicates: true }); + await prisma.requirement.createMany({ data: fixture.rows.requirements, skipDuplicates: true }); + await prisma.versionPlan.createMany({ data: fixture.rows.versionPlans, skipDuplicates: true }); + await prisma.devTask.createMany({ data: fixture.rows.devTasks, skipDuplicates: true }); + await prisma.testCase.createMany({ data: fixture.rows.testCases, skipDuplicates: true }); + await prisma.bug.createMany({ data: fixture.rows.bugs, skipDuplicates: true }); + await prisma.workActivity.createMany({ data: fixture.rows.workActivities, skipDuplicates: true }); + await prisma.xiaobaoRiskSummary.createMany({ data: fixture.rows.xiaobaoRiskSummaries, skipDuplicates: true }); + } finally { + await prisma.$disconnect(); + } + + const summary = summarizeFixture(fixture); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + return summary; +} + +async function clearPerfRows(prisma) { + await prisma.$transaction([ + prisma.xiaobaoRiskSummary.deleteMany({ where: { versionId: { startsWith: `${ID_PREFIX}version-` } } }), + prisma.workActivity.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}activity-` } } }), + prisma.bug.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}bug-` } } }), + prisma.testCase.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}case-` } } }), + prisma.devTask.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}dev-` } } }), + prisma.versionPlan.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}plan-` } } }), + prisma.requirement.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}req-` } } }), + prisma.version.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}version-` } } }), + prisma.project.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}project-` } } }), + prisma.product.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}product-` } } }), + prisma.user.deleteMany({ where: { id: { startsWith: `${ID_PREFIX}user-` } } }), + ]); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const fixture = buildFixture(args.size); + const summary = summarizeFixture(fixture); + + if (args.dryRun) { + if (args.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + printSummary(summary, 'dry-run'); + } + return; + } + + const outputPath = resolve(process.cwd(), args.output); + const seeded = await seedDatabase(fixture, outputPath); + if (args.json) { + console.log(JSON.stringify(seeded, null, 2)); + } else { + printSummary(seeded, `seeded; metadata=${args.output}`); + } +} + +function printSummary(summary, mode) { + console.log(`Large dataset fixture ${mode}`); + console.log(`version=${summary.fixtureVersion} size=${summary.size}`); + console.log(`anchors=${JSON.stringify(summary.anchors)}`); + for (const [name, count] of Object.entries(summary.counts)) { + console.log(`${name}=${count}`); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +});