merge: 集成V2.6 性能增强与小宝后台化
# Conflicts: # apps/server/prisma/schema.prisma # apps/server/src/app.module.ts # apps/web/components/layout/Sidebar.tsx # docs/architecture.md # docs/decisions.md # docs/roadmap.md
This commit is contained in:
@@ -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);
|
||||||
@@ -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');
|
||||||
@@ -443,6 +443,25 @@ model AuditEvent {
|
|||||||
@@map("audit_events")
|
@@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 {
|
model XiaobaoRiskSummary {
|
||||||
versionId String @id @map("version_id")
|
versionId String @id @map("version_id")
|
||||||
riskLevel String @map("risk_level")
|
riskLevel String @map("risk_level")
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import { MigrationModule } from './modules/migration/migration.module';
|
|||||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { ConsistencyModule } from './modules/consistency/consistency.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -47,6 +50,9 @@ import { ConsistencyModule } from './modules/consistency/consistency.module';
|
|||||||
V22QueryModule,
|
V22QueryModule,
|
||||||
ConsistencyModule,
|
ConsistencyModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
JobsModule,
|
||||||
|
XiaobaoModule,
|
||||||
|
OpsModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||||
import { finalize, Observable } from 'rxjs';
|
import { finalize, Observable } from 'rxjs';
|
||||||
|
import { recordSlowRequest } from '../../modules/ops/ops-runtime.store';
|
||||||
|
|
||||||
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export class ApiTimingInterceptor implements NestInterceptor {
|
|||||||
const method = request.method ?? 'UNKNOWN';
|
const method = request.method ?? 'UNKNOWN';
|
||||||
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
||||||
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
||||||
|
recordSlowRequest({ method, url, durationMs, thresholdMs: this.thresholdMs });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||||
import { BugController } from './bug.controller';
|
import { BugController } from './bug.controller';
|
||||||
import { BugService } from './bug.service';
|
import { BUG_PRISMA, BugService } from './bug.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkActivityModule],
|
imports: [WorkActivityModule],
|
||||||
controllers: [BugController],
|
controllers: [BugController],
|
||||||
providers: [BugService],
|
providers: [
|
||||||
|
{ provide: BUG_PRISMA, useExisting: PrismaService },
|
||||||
|
BugService,
|
||||||
|
],
|
||||||
exports: [BugService],
|
exports: [BugService],
|
||||||
})
|
})
|
||||||
export class BugModule {}
|
export class BugModule {}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateBugDto } from './dto/create-bug.dto';
|
import { CreateBugDto } from './dto/create-bug.dto';
|
||||||
import { UpdateBugDto } from './dto/update-bug.dto';
|
import { UpdateBugDto } from './dto/update-bug.dto';
|
||||||
|
|
||||||
|
export const BUG_PRISMA = 'BUG_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BugService {
|
export class BugService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(BUG_PRISMA) private readonly prisma: any,
|
||||||
private readonly workActivity: WorkActivityService,
|
private readonly workActivity: WorkActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export class BugService {
|
|||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toBugData(dto),
|
data: this.toBugData(dto),
|
||||||
});
|
});
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +86,9 @@ export class BugService {
|
|||||||
|
|
||||||
async remove(versionId: string, id: string) {
|
async remove(versionId: string, id: string) {
|
||||||
await this.ensureBugInVersion(versionId, id);
|
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<CreateBugDto>) {
|
private toBugData(dto: Partial<CreateBugDto>) {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||||
import { DevTaskController } from './dev-task.controller';
|
import { DevTaskController } from './dev-task.controller';
|
||||||
import { DevTaskService } from './dev-task.service';
|
import { DEV_TASK_PRISMA, DevTaskService } from './dev-task.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkActivityModule],
|
imports: [WorkActivityModule],
|
||||||
controllers: [DevTaskController],
|
controllers: [DevTaskController],
|
||||||
providers: [DevTaskService],
|
providers: [
|
||||||
|
{ provide: DEV_TASK_PRISMA, useExisting: PrismaService },
|
||||||
|
DevTaskService,
|
||||||
|
],
|
||||||
exports: [DevTaskService],
|
exports: [DevTaskService],
|
||||||
})
|
})
|
||||||
export class DevTaskModule {}
|
export class DevTaskModule {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
||||||
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
||||||
|
|
||||||
|
export const DEV_TASK_PRISMA = 'DEV_TASK_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DevTaskService {
|
export class DevTaskService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(DEV_TASK_PRISMA) private readonly prisma: any,
|
||||||
private readonly workActivity: WorkActivityService,
|
private readonly workActivity: WorkActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ export class DevTaskService {
|
|||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toTaskData(dto),
|
data: this.toTaskData(dto),
|
||||||
});
|
});
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +100,9 @@ export class DevTaskService {
|
|||||||
|
|
||||||
async remove(versionId: string, id: string) {
|
async remove(versionId: string, id: string) {
|
||||||
await this.ensureTaskInVersion(versionId, id);
|
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<CreateDevTaskDto>) {
|
private toTaskData(dto: Partial<CreateDevTaskDto>) {
|
||||||
@@ -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;
|
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
function toJsonInput(value: unknown): any {
|
||||||
return value as Prisma.InputJsonValue;
|
return value as any;
|
||||||
}
|
}
|
||||||
|
|||||||
48
apps/server/src/modules/jobs/background-job.worker.spec.ts
Normal file
48
apps/server/src/modules/jobs/background-job.worker.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/server/src/modules/jobs/background-job.worker.ts
Normal file
58
apps/server/src/modules/jobs/background-job.worker.ts
Normal file
@@ -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> | void;
|
||||||
|
|
||||||
|
export interface RunJobOnceOptions {
|
||||||
|
workerId: string;
|
||||||
|
now?: Date;
|
||||||
|
leaseMs?: number;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BackgroundJobWorker {
|
||||||
|
private readonly handlers = new Map<string, BackgroundJobHandler>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly locks: JobLockService,
|
||||||
|
private readonly jobs: JobsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
registerHandler(type: string, handler: BackgroundJobHandler) {
|
||||||
|
this.handlers.set(type, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(options: RunJobOnceOptions): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/server/src/modules/jobs/job-lock.service.spec.ts
Normal file
51
apps/server/src/modules/jobs/job-lock.service.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/server/src/modules/jobs/job-lock.service.ts
Normal file
57
apps/server/src/modules/jobs/job-lock.service.ts
Normal file
@@ -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<BackgroundJobRecord | null> {
|
||||||
|
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(', ')})`;
|
||||||
|
}
|
||||||
17
apps/server/src/modules/jobs/jobs.module.ts
Normal file
17
apps/server/src/modules/jobs/jobs.module.ts
Normal file
@@ -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 {}
|
||||||
115
apps/server/src/modules/jobs/jobs.service.spec.ts
Normal file
115
apps/server/src/modules/jobs/jobs.service.spec.ts
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/server/src/modules/jobs/jobs.service.ts
Normal file
104
apps/server/src/modules/jobs/jobs.service.ts
Normal file
@@ -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<BackgroundJobRecord> {
|
||||||
|
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<BackgroundJobRecord> {
|
||||||
|
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<BackgroundJobRecord> {
|
||||||
|
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<BackgroundJobRecord | null> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
38
apps/server/src/modules/jobs/jobs.types.ts
Normal file
38
apps/server/src/modules/jobs/jobs.types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
11
apps/server/src/modules/ops/ops-permission.adapter.ts
Normal file
11
apps/server/src/modules/ops/ops-permission.adapter.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/server/src/modules/ops/ops-runtime.store.spec.ts
Normal file
53
apps/server/src/modules/ops/ops-runtime.store.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
116
apps/server/src/modules/ops/ops-runtime.store.ts
Normal file
116
apps/server/src/modules/ops/ops-runtime.store.ts
Normal file
@@ -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<T>(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;
|
||||||
|
}
|
||||||
19
apps/server/src/modules/ops/ops.controller.spec.ts
Normal file
19
apps/server/src/modules/ops/ops.controller.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/server/src/modules/ops/ops.controller.ts
Normal file
17
apps/server/src/modules/ops/ops.controller.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/server/src/modules/ops/ops.module.ts
Normal file
16
apps/server/src/modules/ops/ops.module.ts
Normal file
@@ -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 {}
|
||||||
89
apps/server/src/modules/ops/ops.service.spec.ts
Normal file
89
apps/server/src/modules/ops/ops.service.spec.ts
Normal file
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
148
apps/server/src/modules/ops/ops.service.ts
Normal file
148
apps/server/src/modules/ops/ops.service.ts
Normal file
@@ -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<string, ReturnType<typeof emptyTypeCounts>>();
|
||||||
|
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<JobStatus, number> & { 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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||||
import { TestCaseController } from './test-case.controller';
|
import { TestCaseController } from './test-case.controller';
|
||||||
import { TestCaseService } from './test-case.service';
|
import { TEST_CASE_PRISMA, TestCaseService } from './test-case.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkActivityModule],
|
imports: [WorkActivityModule],
|
||||||
controllers: [TestCaseController],
|
controllers: [TestCaseController],
|
||||||
providers: [TestCaseService],
|
providers: [
|
||||||
|
{ provide: TEST_CASE_PRISMA, useExisting: PrismaService },
|
||||||
|
TestCaseService,
|
||||||
|
],
|
||||||
exports: [TestCaseService],
|
exports: [TestCaseService],
|
||||||
})
|
})
|
||||||
export class TestCaseModule {}
|
export class TestCaseModule {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
||||||
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
||||||
|
|
||||||
|
export const TEST_CASE_PRISMA = 'TEST_CASE_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TestCaseService {
|
export class TestCaseService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(TEST_CASE_PRISMA) private readonly prisma: any,
|
||||||
private readonly workActivity: WorkActivityService,
|
private readonly workActivity: WorkActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export class TestCaseService {
|
|||||||
const items = await this.prisma.testCase.findMany({
|
const items = await this.prisma.testCase.findMany({
|
||||||
where: { versionId, code: { in: rows.map((row) => row.code) } },
|
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}`)
|
this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`)
|
||||||
)));
|
)));
|
||||||
return { items, activities };
|
return { items, activities };
|
||||||
@@ -64,6 +64,7 @@ export class TestCaseService {
|
|||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toTestCaseData(dto),
|
data: this.toTestCaseData(dto),
|
||||||
});
|
});
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +85,9 @@ export class TestCaseService {
|
|||||||
|
|
||||||
async remove(versionId: string, id: string) {
|
async remove(versionId: string, id: string) {
|
||||||
await this.ensureTestCaseInVersion(versionId, id);
|
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<CreateTestCaseDto>) {
|
private toTestCaseData(dto: Partial<CreateTestCaseDto>) {
|
||||||
@@ -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;
|
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
function toJsonInput(value: unknown): any {
|
||||||
return value as Prisma.InputJsonValue;
|
return value as any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||||
import { VersionPlanController } from './version-plan.controller';
|
import { VersionPlanController } from './version-plan.controller';
|
||||||
import { VersionPlanService } from './version-plan.service';
|
import { VERSION_PLAN_PRISMA, VersionPlanService } from './version-plan.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkActivityModule],
|
imports: [WorkActivityModule],
|
||||||
controllers: [VersionPlanController],
|
controllers: [VersionPlanController],
|
||||||
providers: [VersionPlanService],
|
providers: [
|
||||||
|
{ provide: VERSION_PLAN_PRISMA, useExisting: PrismaService },
|
||||||
|
VersionPlanService,
|
||||||
|
],
|
||||||
exports: [VersionPlanService],
|
exports: [VersionPlanService],
|
||||||
})
|
})
|
||||||
export class VersionPlanModule {}
|
export class VersionPlanModule {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
||||||
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
||||||
|
|
||||||
|
export const VERSION_PLAN_PRISMA = 'VERSION_PLAN_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VersionPlanService {
|
export class VersionPlanService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(VERSION_PLAN_PRISMA) private readonly prisma: any,
|
||||||
private readonly workActivity: WorkActivityService,
|
private readonly workActivity: WorkActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ export class VersionPlanService {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
const activity = current.status !== item.status ? await this.recordStatusActivity(item, current.status, item.status) : undefined;
|
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] : [] };
|
return { item, activities: activity ? [activity] : [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,9 @@ export class VersionPlanService {
|
|||||||
|
|
||||||
async remove(versionId: string, id: string) {
|
async remove(versionId: string, id: string) {
|
||||||
await this.ensurePlanInVersion(versionId, id);
|
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<CreateVersionPlanDto>) {
|
private toPlanData(dto: Partial<CreateVersionPlanDto>) {
|
||||||
@@ -147,6 +150,6 @@ function parseOptionalDate(value: string | null | undefined): Date | null {
|
|||||||
return Number.isFinite(date.getTime()) ? date : null;
|
return Number.isFinite(date.getTime()) ? date : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
function toJsonInput(value: unknown): any {
|
||||||
return value as Prisma.InputJsonValue;
|
return value as any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { XiaobaoModule } from '../xiaobao/xiaobao.module';
|
||||||
import { WorkActivityController } from './work-activity.controller';
|
import { WorkActivityController } from './work-activity.controller';
|
||||||
import { WorkActivityService } from './work-activity.service';
|
import { WORK_ACTIVITY_PRISMA, WorkActivityService } from './work-activity.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [XiaobaoModule],
|
||||||
controllers: [WorkActivityController],
|
controllers: [WorkActivityController],
|
||||||
providers: [WorkActivityService],
|
providers: [
|
||||||
|
{ provide: WORK_ACTIVITY_PRISMA, useExisting: PrismaService },
|
||||||
|
WorkActivityService,
|
||||||
|
],
|
||||||
exports: [WorkActivityService],
|
exports: [WorkActivityService],
|
||||||
})
|
})
|
||||||
export class WorkActivityModule {}
|
export class WorkActivityModule {}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { XiaobaoRiskService } from '../xiaobao/xiaobao-risk.service';
|
||||||
|
|
||||||
export interface WorkActivityRecordInput {
|
export interface WorkActivityRecordInput {
|
||||||
versionId?: string | null;
|
versionId?: string | null;
|
||||||
@@ -18,9 +18,14 @@ export interface WorkActivityRecordInput {
|
|||||||
occurredAt?: string | Date | null;
|
occurredAt?: string | Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const WORK_ACTIVITY_PRISMA = 'WORK_ACTIVITY_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkActivityService {
|
export class WorkActivityService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
@Inject(WORK_ACTIVITY_PRISMA) private readonly prisma: any,
|
||||||
|
@Optional() private readonly xiaobaoRisk?: XiaobaoRiskService,
|
||||||
|
) {}
|
||||||
|
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } });
|
return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } });
|
||||||
@@ -64,6 +69,11 @@ export class WorkActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async markXiaobaoSummaryDirty(versionId: string) {
|
async markXiaobaoSummaryDirty(versionId: string) {
|
||||||
|
if (this.xiaobaoRisk) {
|
||||||
|
await this.xiaobaoRisk.markDirtyAndEnqueue(versionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const riskSignature = `dirty:${versionId}`;
|
const riskSignature = `dirty:${versionId}`;
|
||||||
await this.prisma.xiaobaoRiskSummary.upsert({
|
await this.prisma.xiaobaoRiskSummary.upsert({
|
||||||
where: { versionId },
|
where: { versionId },
|
||||||
|
|||||||
20
apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts
Normal file
20
apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts
Normal file
@@ -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 {}
|
||||||
259
apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts
Normal file
259
apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts
Normal file
@@ -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> = {}): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
378
apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts
Normal file
378
apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts
Normal file
@@ -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<XiaobaoAiEvaluateResult> {
|
||||||
|
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<XiaobaoAiEvaluateResult> {
|
||||||
|
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<XiaobaoAiGenerationResult> {
|
||||||
|
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<any | null> {
|
||||||
|
return this.prisma.xiaobaoRiskInsight.findFirst({
|
||||||
|
where: {
|
||||||
|
versionId: summary.versionId,
|
||||||
|
riskSignature: summary.riskSignature,
|
||||||
|
status: 'generated',
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private findLatestInsight(versionId: string): Promise<any | null> {
|
||||||
|
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<string, unknown> {
|
||||||
|
return isRecord(value) ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
117
apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts
Normal file
117
apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts
Normal file
@@ -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<XiaobaoAiRiskInterpretResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<XiaobaoRiskLevel, number> = {
|
||||||
|
on_track: 0,
|
||||||
|
attention: 1,
|
||||||
|
at_risk: 2,
|
||||||
|
likely_delayed: 3,
|
||||||
|
blocked: 4,
|
||||||
|
};
|
||||||
38
apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts
Normal file
38
apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts
Normal file
30
apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts
Normal file
@@ -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() };
|
||||||
|
}
|
||||||
27
apps/server/src/modules/xiaobao/xiaobao-risk.controller.ts
Normal file
27
apps/server/src/modules/xiaobao/xiaobao-risk.controller.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
118
apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts
Normal file
118
apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
452
apps/server/src/modules/xiaobao/xiaobao-risk.service.ts
Normal file
452
apps/server/src/modules/xiaobao/xiaobao-risk.service.ts
Normal file
@@ -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<string, number> = {
|
||||||
|
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<number> {
|
||||||
|
return this.prisma.xiaobaoRiskSummary.count({ where: { dirty: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSummary(versionId: string, options: RefreshXiaobaoRiskOptions = {}): Promise<XiaobaoRiskSummaryPayload> {
|
||||||
|
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>): 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)));
|
||||||
|
}
|
||||||
68
apps/server/src/modules/xiaobao/xiaobao-risk.types.ts
Normal file
68
apps/server/src/modules/xiaobao/xiaobao-risk.types.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
21
apps/server/src/modules/xiaobao/xiaobao-risk.worker.spec.ts
Normal file
21
apps/server/src/modules/xiaobao/xiaobao-risk.worker.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
26
apps/server/src/modules/xiaobao/xiaobao-risk.worker.ts
Normal file
26
apps/server/src/modules/xiaobao/xiaobao-risk.worker.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
20
apps/server/src/modules/xiaobao/xiaobao.module.ts
Normal file
20
apps/server/src/modules/xiaobao/xiaobao.module.ts
Normal file
@@ -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 {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { recordSlowPrismaQuery } from '../modules/ops/ops-runtime.store';
|
||||||
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
|
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -16,6 +17,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
(this as any).$on('query', (event: { duration: number; query: string }) => {
|
(this as any).$on('query', (event: { duration: number; query: string }) => {
|
||||||
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
|
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
|
||||||
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
|
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
|
||||||
|
recordSlowPrismaQuery({
|
||||||
|
query: event.query,
|
||||||
|
durationMs: event.duration,
|
||||||
|
thresholdMs: this.slowQueryThresholdMs,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
391
apps/web/app/admin/ops/page.tsx
Normal file
391
apps/web/app/admin/ops/page.tsx
Normal file
@@ -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<JobStatus, number> & { total: number };
|
||||||
|
byType: Array<Record<JobStatus, number> & {
|
||||||
|
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<JobStatus, string> = {
|
||||||
|
queued: '排队',
|
||||||
|
running: '运行',
|
||||||
|
succeeded: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OpsPage() {
|
||||||
|
return (
|
||||||
|
<RouteGuard permission="ops:view">
|
||||||
|
<OpsPageContent />
|
||||||
|
</RouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpsPageContent() {
|
||||||
|
const [snapshot, setSnapshot] = useState<OpsRuntimeSnapshot | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchSnapshot = async (initial = false) => {
|
||||||
|
if (initial) setLoading(true);
|
||||||
|
else setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const next = await api.get<OpsRuntimeSnapshot>('/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 (
|
||||||
|
<div className="flex h-full flex-col bg-[var(--bg)]">
|
||||||
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Activity className="h-4 w-4 text-blue-600" strokeWidth={2} />
|
||||||
|
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">运维看板</h1>
|
||||||
|
{snapshot && (
|
||||||
|
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
||||||
|
{formatClock(snapshot.collectedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchSnapshot()}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{refreshing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
加载中
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700">{error}</div>
|
||||||
|
) : snapshot ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<OverviewStrip snapshot={snapshot} />
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SlowRequestsPanel snapshot={snapshot} />
|
||||||
|
<SlowQueriesPanel snapshot={snapshot} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<JobQueuePanel snapshot={snapshot} />
|
||||||
|
<FailuresPanel snapshot={snapshot} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{cards.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<section key={card.label} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 shadow-[var(--shadow-sm)]">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-medium text-[var(--ink-muted)]">{card.label}</p>
|
||||||
|
<p className="mt-1 text-[24px] font-semibold leading-none tabular-nums text-[var(--ink)]">{card.value}</p>
|
||||||
|
<p className="mt-1 truncate text-[11px] text-[var(--ink-soft)]">{card.sub}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${toneClass(card.tone)}`}>
|
||||||
|
<Icon className="h-4 w-4" strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlowRequestsPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||||
|
<PanelHeader title="慢请求" right={`${snapshot.slowRequests.length}`} />
|
||||||
|
<div className="divide-y divide-[var(--line)]">
|
||||||
|
{snapshot.slowRequests.length === 0 ? (
|
||||||
|
<EmptyRow label="暂无慢请求" />
|
||||||
|
) : snapshot.slowRequests.map((item) => (
|
||||||
|
<div key={item.id} className="grid grid-cols-[76px_minmax(0,1fr)_92px_96px] items-center gap-3 px-4 py-2.5 text-[12px]">
|
||||||
|
<span className="font-mono font-medium text-blue-700">{item.method}</span>
|
||||||
|
<span className="truncate font-mono text-[var(--ink)]">{item.path}</span>
|
||||||
|
<span className="text-right font-mono tabular-nums text-red-600">{item.durationMs}ms</span>
|
||||||
|
<span className="text-right text-[var(--ink-muted)]">{formatClock(item.occurredAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlowQueriesPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||||
|
<PanelHeader title="慢查询" right={`${snapshot.slowQueries.length}`} />
|
||||||
|
<div className="divide-y divide-[var(--line)]">
|
||||||
|
{snapshot.slowQueries.length === 0 ? (
|
||||||
|
<EmptyRow label="暂无慢查询" />
|
||||||
|
) : snapshot.slowQueries.map((item) => (
|
||||||
|
<div key={item.id} className="grid grid-cols-[minmax(0,1fr)_92px_96px] items-start gap-3 px-4 py-2.5 text-[12px]">
|
||||||
|
<code className="min-w-0 break-words font-mono text-[11px] leading-5 text-[var(--ink)]">{item.queryPreview}</code>
|
||||||
|
<span className="text-right font-mono tabular-nums text-amber-700">{item.durationMs}ms</span>
|
||||||
|
<span className="text-right text-[var(--ink-muted)]">{formatClock(item.occurredAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobQueuePanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||||
|
const rows = snapshot.jobQueue.byType;
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||||
|
<PanelHeader title="后台任务" right={`${snapshot.jobQueue.totals.total}`} />
|
||||||
|
<div className="border-b border-[var(--line)] px-4 py-3">
|
||||||
|
<StatusBars totals={snapshot.jobQueue.totals} />
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[var(--line)]">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyRow label="暂无任务" />
|
||||||
|
) : rows.map((row) => (
|
||||||
|
<div key={row.type} className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 truncate font-mono text-[12px] font-medium text-[var(--ink)]">{row.type}</span>
|
||||||
|
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-muted)]">{row.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-4 gap-1.5">
|
||||||
|
{(['queued', 'running', 'succeeded', 'failed'] as JobStatus[]).map((status) => (
|
||||||
|
<StatusPill key={status} status={status} count={row[status]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(row.oldestQueuedAt || row.nextLeaseExpiresAt) && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-[var(--ink-muted)]">
|
||||||
|
{row.oldestQueuedAt && <span>最早排队 {formatClock(row.oldestQueuedAt)}</span>}
|
||||||
|
{row.nextLeaseExpiresAt && <span>Lease {formatClock(row.nextLeaseExpiresAt)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FailuresPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||||
|
const failures = snapshot.jobQueue.recentFailures;
|
||||||
|
const dbOk = snapshot.database.ok;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||||
|
<PanelHeader title="运行状态" right={dbOk ? 'OK' : 'DB'} />
|
||||||
|
<div className="border-b border-[var(--line)] px-4 py-3">
|
||||||
|
<div className={`flex items-center gap-2 rounded-md border px-3 py-2 text-[12px] ${dbOk ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
|
||||||
|
{dbOk ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
|
||||||
|
<span className="min-w-0 truncate">{dbOk ? '数据库可读' : snapshot.database.error || '数据库不可读'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[var(--line)]">
|
||||||
|
{failures.length === 0 ? (
|
||||||
|
<EmptyRow label="暂无失败任务" />
|
||||||
|
) : failures.map((item) => (
|
||||||
|
<div key={item.id} className="px-4 py-3 text-[12px]">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 truncate font-mono font-medium text-[var(--ink)]">{item.type}</span>
|
||||||
|
<span className="shrink-0 font-mono tabular-nums text-red-600">{item.attempts}/{item.maxAttempts}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 line-clamp-2 text-[11px] leading-5 text-[var(--ink-soft)]">{item.lastError || '-'}</p>
|
||||||
|
{item.updatedAt && <p className="mt-1 text-[11px] text-[var(--ink-muted)]">{formatClock(item.updatedAt)}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBars({ totals }: { totals: OpsRuntimeSnapshot['jobQueue']['totals'] }) {
|
||||||
|
const statuses: JobStatus[] = ['queued', 'running', 'succeeded', 'failed'];
|
||||||
|
const total = Math.max(1, totals.total);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex h-2 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
|
||||||
|
{statuses.map((status) => (
|
||||||
|
<span
|
||||||
|
key={status}
|
||||||
|
className={statusBarClass(status)}
|
||||||
|
style={{ width: `${(totals[status] / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{statuses.map((status) => <StatusPill key={status} status={status} count={totals[status]} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status, count }: { status: JobStatus; count: number }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between gap-1 rounded-md px-2 py-1 text-[11px] ${statusPillClass(status)}`}>
|
||||||
|
<span>{STATUS_LABEL[status]}</span>
|
||||||
|
<span className="font-mono tabular-nums">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelHeader({ title, right }: { title: string; right: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 items-center justify-between border-b border-[var(--line)] px-4">
|
||||||
|
<h2 className="text-[13px] font-semibold text-[var(--ink)]">{title}</h2>
|
||||||
|
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{right}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyRow({ label }: { label: string }) {
|
||||||
|
return <div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">{label}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
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 { useHasPermission } from '@/components/auth/Guard';
|
||||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||||
@@ -47,6 +47,7 @@ const NAV_GROUPS = [
|
|||||||
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
||||||
{ label: '审计', path: '/admin/audit', icon: ScrollText, permission: 'audit:view' },
|
{ label: '审计', path: '/admin/audit', icon: ScrollText, permission: 'audit:view' },
|
||||||
{ label: '一致性', path: '/admin/consistency', icon: Database, permission: 'consistency: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: '*' },
|
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ action: 'manage', label: '管理', permission: 'xiaobao.warning:manage' },
|
{ 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: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
|
||||||
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
||||||
{ module: 'audit', moduleLabel: '审计', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'audit:view' }] },
|
{ module: 'audit', moduleLabel: '审计', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'audit:view' }] },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import type { RoleItem } from './members';
|
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';
|
import { mergePresetRolePermissions } from './role-permission-migration';
|
||||||
|
|
||||||
test('default group leader role has business permissions without admin modules', () => {
|
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);
|
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', () => {
|
test('mergePresetRolePermissions adds the built-in group leader role to old role data', () => {
|
||||||
const roles: RoleItem[] = [
|
const roles: RoleItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -157,6 +157,27 @@ V2.5 控制面新增三类横切能力:
|
|||||||
- V2.6 在关系表主源稳定后做大数据性能增强和小宝预警后台化;性能基础不后置,增强项包括压测、慢查询治理、缓存/摘要、后台任务、幂等和失败重试。
|
- V2.6 在关系表主源稳定后做大数据性能增强和小宝预警后台化;性能基础不后置,增强项包括压测、慢查询治理、缓存/摘要、后台任务、幂等和失败重试。
|
||||||
- V2.7 面向企业级协作与管理治理,V2.8 面向生产硬化与运维闭环;生产部署基线已经存在,V2.8 重点是备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册。
|
- 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)
|
## 生产部署层(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`.
|
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.
|
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)
|
## V2.2 Partitioned Domain Data Layer (2026-07-03)
|
||||||
|
|||||||
@@ -623,3 +623,60 @@
|
|||||||
- Xiaobao risk snapshots/insights 的 AppData key 进入 `read_only_archive`,关系表写入和后台化归 V2.6;`xiaobao-warning-views` 读状态 API 归 V2.7。
|
- 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 合同。
|
**理由**:冻结写入能立即切断新的双主源风险,同时保留旧 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 合同互相绑死。
|
||||||
|
|||||||
79
docs/performance-hot-queries.md
Normal file
79
docs/performance-hot-queries.md
Normal file
@@ -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.
|
||||||
104
docs/performance.md
Normal file
104
docs/performance.md
Normal file
@@ -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.
|
||||||
@@ -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.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步副本”推进到“领域 CRUD 主写关系表 + AppData 兼容/迁移兜底”。V2.2 快读 API 和 V2.3 AppData 写后同步继续保留,但它们现在是兼容基础设施,不再是已迁移领域的数据新鲜度主链路。
|
||||||
|
|
||||||
V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性核对。AppData 不能直接删除,必须按“禁写 → 双读核对 → 移除 fallback → 只读归档/导出 → 后续删表”的顺序推进。
|
V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性核对。AppData 不能直接删除,必须按“禁写 → 双读核对 → 移除 fallback → 只读归档/导出 → 后续删表”的顺序推进。
|
||||||
|
|
||||||
### V2.5 完成范围
|
V2.6 的目标是在关系表主源稳定后完成大数据性能增强、小宝风险后台化、AI 解读队列和运行时 Ops 看板,让高增长热路径、后台任务和风险摘要不再依赖页面打开。
|
||||||
|
|
||||||
|
### V2.5-V2.6 完成范围
|
||||||
|
|
||||||
1. **RBAC 收口**:领域 mutation API 已接入服务端权限校验、资源作用域和当前用户上下文。
|
1. **RBAC 收口**:领域 mutation API 已接入服务端权限校验、资源作用域和当前用户上下文。
|
||||||
2. **审计事件**:领域 mutation 通过 `audit_events` 写 append-only audit event,支持后台查询和敏感字段脱敏。
|
2. **审计事件**:领域 mutation 通过 `audit_events` 写 append-only audit event,支持后台查询和敏感字段脱敏。
|
||||||
@@ -14,6 +16,10 @@ V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性
|
|||||||
4. **导出归档**:已提供 AppData archive export/verify 脚本,包含 checksum、key list 和应用版本元数据。
|
4. **导出归档**:已提供 AppData archive export/verify 脚本,包含 checksum、key list 和应用版本元数据。
|
||||||
5. **一致性校验**:已提供 counts、partition key、orphan refs、audit coverage 的本地脚本和后台页面。
|
5. **一致性校验**:已提供 counts、partition key、orphan refs、audit coverage 的本地脚本和后台页面。
|
||||||
6. **管理端可视化**:已补 `/admin/audit` 与 `/admin/consistency`,并由 `audit:view` / `consistency:view` 控制。
|
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 分阶段交付链路
|
## V2 分阶段交付链路
|
||||||
|
|
||||||
@@ -42,10 +48,22 @@ V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性
|
|||||||
- 需求池已切到服务端分页、搜索、筛选、排序,不再要求加载全量 AppData 文档。
|
- 需求池已切到服务端分页、搜索、筛选、排序,不再要求加载全量 AppData 文档。
|
||||||
- `packages/shared` 状态契约已统一为当前业务状态机。
|
- `packages/shared` 状态契约已统一为当前业务状态机。
|
||||||
- V2.6/V2.7 协调边界:Xiaobao risk snapshots/insights 关系表写入和后台化归 V2.6;warning read-state API、部门/角色/密码规则/加班原因配置表归 V2.7。
|
- 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**
|
**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.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.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()`.
|
- V2.5.2 protected V2.4 domain mutation APIs with server-side permission metadata and audit writes through `@ProtectedMutation()`.
|
||||||
|
|||||||
@@ -339,9 +339,9 @@ Implementation convention:
|
|||||||
- `xiaobao.warning:manage`:查看所有未结束版本的预警。
|
- `xiaobao.warning:manage`:查看所有未结束版本的预警。
|
||||||
- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。
|
- `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 解读输入。
|
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"type-check": "turbo type-check",
|
"type-check": "turbo type-check",
|
||||||
"test": "turbo test",
|
"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:verify": "node scripts/verify-production-deploy.mjs",
|
||||||
"deploy:check-runtime": "node scripts/check-runtime-version.mjs",
|
"deploy:check-runtime": "node scripts/check-runtime-version.mjs",
|
||||||
"appdata:archive:export": "node scripts/export-appdata-archive.mjs",
|
"appdata:archive:export": "node scripts/export-appdata-archive.mjs",
|
||||||
|
|||||||
572
scripts/explain-hot-queries.mjs
Normal file
572
scripts/explain-hot-queries.mjs
Normal file
@@ -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;
|
||||||
|
});
|
||||||
251
scripts/perf-check.mjs
Normal file
251
scripts/perf-check.mjs
Normal file
@@ -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;
|
||||||
|
});
|
||||||
|
|
||||||
661
scripts/seed-large-dataset.mjs
Normal file
661
scripts/seed-large-dataset.mjs
Normal file
@@ -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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user