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")
|
||||
}
|
||||
|
||||
model BackgroundJob {
|
||||
id String @id @default(cuid())
|
||||
type String
|
||||
status String @default("queued")
|
||||
dedupeKey String? @map("dedupe_key")
|
||||
payload Json @default("{}")
|
||||
attempts Int @default(0)
|
||||
maxAttempts Int @default(3) @map("max_attempts")
|
||||
availableAt DateTime @default(now()) @map("available_at")
|
||||
lockedBy String? @map("locked_by")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastError String? @map("last_error")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("background_jobs")
|
||||
}
|
||||
|
||||
model XiaobaoRiskSummary {
|
||||
versionId String @id @map("version_id")
|
||||
riskLevel String @map("risk_level")
|
||||
|
||||
@@ -23,6 +23,9 @@ import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { ConsistencyModule } from './modules/consistency/consistency.module';
|
||||
import { JobsModule } from './modules/jobs/jobs.module';
|
||||
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
||||
import { OpsModule } from './modules/ops/ops.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -47,6 +50,9 @@ import { ConsistencyModule } from './modules/consistency/consistency.module';
|
||||
V22QueryModule,
|
||||
ConsistencyModule,
|
||||
HealthModule,
|
||||
JobsModule,
|
||||
XiaobaoModule,
|
||||
OpsModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { finalize, Observable } from 'rxjs';
|
||||
import { recordSlowRequest } from '../../modules/ops/ops-runtime.store';
|
||||
|
||||
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
||||
|
||||
@@ -25,6 +26,7 @@ export class ApiTimingInterceptor implements NestInterceptor {
|
||||
const method = request.method ?? 'UNKNOWN';
|
||||
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
||||
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
||||
recordSlowRequest({ method, url, durationMs, thresholdMs: this.thresholdMs });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||
import { BugController } from './bug.controller';
|
||||
import { BugService } from './bug.service';
|
||||
import { BUG_PRISMA, BugService } from './bug.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkActivityModule],
|
||||
controllers: [BugController],
|
||||
providers: [BugService],
|
||||
providers: [
|
||||
{ provide: BUG_PRISMA, useExisting: PrismaService },
|
||||
BugService,
|
||||
],
|
||||
exports: [BugService],
|
||||
})
|
||||
export class BugModule {}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateBugDto } from './dto/create-bug.dto';
|
||||
import { UpdateBugDto } from './dto/update-bug.dto';
|
||||
|
||||
export const BUG_PRISMA = 'BUG_PRISMA';
|
||||
|
||||
@Injectable()
|
||||
export class BugService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(BUG_PRISMA) private readonly prisma: any,
|
||||
private readonly workActivity: WorkActivityService,
|
||||
) {}
|
||||
|
||||
@@ -44,6 +45,7 @@ export class BugService {
|
||||
where: { id_versionId: { id, versionId } },
|
||||
data: this.toBugData(dto),
|
||||
});
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return { item, activities: [] };
|
||||
}
|
||||
|
||||
@@ -84,7 +86,9 @@ export class BugService {
|
||||
|
||||
async remove(versionId: string, id: string) {
|
||||
await this.ensureBugInVersion(versionId, id);
|
||||
return this.prisma.bug.delete({ where: { id_versionId: { id, versionId } } });
|
||||
const item = await this.prisma.bug.delete({ where: { id_versionId: { id, versionId } } });
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return item;
|
||||
}
|
||||
|
||||
private toBugData(dto: Partial<CreateBugDto>) {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||
import { DevTaskController } from './dev-task.controller';
|
||||
import { DevTaskService } from './dev-task.service';
|
||||
import { DEV_TASK_PRISMA, DevTaskService } from './dev-task.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkActivityModule],
|
||||
controllers: [DevTaskController],
|
||||
providers: [DevTaskService],
|
||||
providers: [
|
||||
{ provide: DEV_TASK_PRISMA, useExisting: PrismaService },
|
||||
DevTaskService,
|
||||
],
|
||||
exports: [DevTaskService],
|
||||
})
|
||||
export class DevTaskModule {}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
||||
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
||||
|
||||
export const DEV_TASK_PRISMA = 'DEV_TASK_PRISMA';
|
||||
|
||||
@Injectable()
|
||||
export class DevTaskService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(DEV_TASK_PRISMA) private readonly prisma: any,
|
||||
private readonly workActivity: WorkActivityService,
|
||||
) {}
|
||||
|
||||
@@ -46,6 +46,7 @@ export class DevTaskService {
|
||||
where: { id_versionId: { id, versionId } },
|
||||
data: this.toTaskData(dto),
|
||||
});
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return { item, activities: [] };
|
||||
}
|
||||
|
||||
@@ -99,7 +100,9 @@ export class DevTaskService {
|
||||
|
||||
async remove(versionId: string, id: string) {
|
||||
await this.ensureTaskInVersion(versionId, id);
|
||||
return this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } });
|
||||
const item = await this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } });
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return item;
|
||||
}
|
||||
|
||||
private toTaskData(dto: Partial<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;
|
||||
}
|
||||
|
||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
||||
return value as Prisma.InputJsonValue;
|
||||
function toJsonInput(value: unknown): any {
|
||||
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 { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||
import { TestCaseController } from './test-case.controller';
|
||||
import { TestCaseService } from './test-case.service';
|
||||
import { TEST_CASE_PRISMA, TestCaseService } from './test-case.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkActivityModule],
|
||||
controllers: [TestCaseController],
|
||||
providers: [TestCaseService],
|
||||
providers: [
|
||||
{ provide: TEST_CASE_PRISMA, useExisting: PrismaService },
|
||||
TestCaseService,
|
||||
],
|
||||
exports: [TestCaseService],
|
||||
})
|
||||
export class TestCaseModule {}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
||||
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
||||
|
||||
export const TEST_CASE_PRISMA = 'TEST_CASE_PRISMA';
|
||||
|
||||
@Injectable()
|
||||
export class TestCaseService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(TEST_CASE_PRISMA) private readonly prisma: any,
|
||||
private readonly workActivity: WorkActivityService,
|
||||
) {}
|
||||
|
||||
@@ -45,7 +45,7 @@ export class TestCaseService {
|
||||
const items = await this.prisma.testCase.findMany({
|
||||
where: { versionId, code: { in: rows.map((row) => row.code) } },
|
||||
});
|
||||
const activities = await Promise.all(items.map((item) => (
|
||||
const activities = await Promise.all(items.map((item: any) => (
|
||||
this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`)
|
||||
)));
|
||||
return { items, activities };
|
||||
@@ -64,6 +64,7 @@ export class TestCaseService {
|
||||
where: { id_versionId: { id, versionId } },
|
||||
data: this.toTestCaseData(dto),
|
||||
});
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return { item, activities: [] };
|
||||
}
|
||||
|
||||
@@ -84,7 +85,9 @@ export class TestCaseService {
|
||||
|
||||
async remove(versionId: string, id: string) {
|
||||
await this.ensureTestCaseInVersion(versionId, id);
|
||||
return this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } });
|
||||
const item = await this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } });
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return item;
|
||||
}
|
||||
|
||||
private toTestCaseData(dto: Partial<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;
|
||||
}
|
||||
|
||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
||||
return value as Prisma.InputJsonValue;
|
||||
function toJsonInput(value: unknown): any {
|
||||
return value as any;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||
import { VersionPlanController } from './version-plan.controller';
|
||||
import { VersionPlanService } from './version-plan.service';
|
||||
import { VERSION_PLAN_PRISMA, VersionPlanService } from './version-plan.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkActivityModule],
|
||||
controllers: [VersionPlanController],
|
||||
providers: [VersionPlanService],
|
||||
providers: [
|
||||
{ provide: VERSION_PLAN_PRISMA, useExisting: PrismaService },
|
||||
VersionPlanService,
|
||||
],
|
||||
exports: [VersionPlanService],
|
||||
})
|
||||
export class VersionPlanModule {}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
||||
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
||||
|
||||
export const VERSION_PLAN_PRISMA = 'VERSION_PLAN_PRISMA';
|
||||
|
||||
@Injectable()
|
||||
export class VersionPlanService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(VERSION_PLAN_PRISMA) private readonly prisma: any,
|
||||
private readonly workActivity: WorkActivityService,
|
||||
) {}
|
||||
|
||||
@@ -50,6 +50,7 @@ export class VersionPlanService {
|
||||
data,
|
||||
});
|
||||
const activity = current.status !== item.status ? await this.recordStatusActivity(item, current.status, item.status) : undefined;
|
||||
if (!activity) await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return { item, activities: activity ? [activity] : [] };
|
||||
}
|
||||
|
||||
@@ -63,7 +64,9 @@ export class VersionPlanService {
|
||||
|
||||
async remove(versionId: string, id: string) {
|
||||
await this.ensurePlanInVersion(versionId, id);
|
||||
return this.prisma.versionPlan.delete({ where: { id } });
|
||||
const item = await this.prisma.versionPlan.delete({ where: { id } });
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return item;
|
||||
}
|
||||
|
||||
private toPlanData(dto: Partial<CreateVersionPlanDto>) {
|
||||
@@ -147,6 +150,6 @@ function parseOptionalDate(value: string | null | undefined): Date | null {
|
||||
return Number.isFinite(date.getTime()) ? date : null;
|
||||
}
|
||||
|
||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
||||
return value as Prisma.InputJsonValue;
|
||||
function toJsonInput(value: unknown): any {
|
||||
return value as any;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { XiaobaoModule } from '../xiaobao/xiaobao.module';
|
||||
import { WorkActivityController } from './work-activity.controller';
|
||||
import { WorkActivityService } from './work-activity.service';
|
||||
import { WORK_ACTIVITY_PRISMA, WorkActivityService } from './work-activity.service';
|
||||
|
||||
@Module({
|
||||
imports: [XiaobaoModule],
|
||||
controllers: [WorkActivityController],
|
||||
providers: [WorkActivityService],
|
||||
providers: [
|
||||
{ provide: WORK_ACTIVITY_PRISMA, useExisting: PrismaService },
|
||||
WorkActivityService,
|
||||
],
|
||||
exports: [WorkActivityService],
|
||||
})
|
||||
export class WorkActivityModule {}
|
||||
|
||||
@@ -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 { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { XiaobaoRiskService } from '../xiaobao/xiaobao-risk.service';
|
||||
|
||||
export interface WorkActivityRecordInput {
|
||||
versionId?: string | null;
|
||||
@@ -18,9 +18,14 @@ export interface WorkActivityRecordInput {
|
||||
occurredAt?: string | Date | null;
|
||||
}
|
||||
|
||||
export const WORK_ACTIVITY_PRISMA = 'WORK_ACTIVITY_PRISMA';
|
||||
|
||||
@Injectable()
|
||||
export class WorkActivityService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
@Inject(WORK_ACTIVITY_PRISMA) private readonly prisma: any,
|
||||
@Optional() private readonly xiaobaoRisk?: XiaobaoRiskService,
|
||||
) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } });
|
||||
@@ -64,6 +69,11 @@ export class WorkActivityService {
|
||||
}
|
||||
|
||||
async markXiaobaoSummaryDirty(versionId: string) {
|
||||
if (this.xiaobaoRisk) {
|
||||
await this.xiaobaoRisk.markDirtyAndEnqueue(versionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const riskSignature = `dirty:${versionId}`;
|
||||
await this.prisma.xiaobaoRiskSummary.upsert({
|
||||
where: { versionId },
|
||||
|
||||
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 { PrismaClient } from '@prisma/client';
|
||||
import { recordSlowPrismaQuery } from '../modules/ops/ops-runtime.store';
|
||||
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,6 +17,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
(this as any).$on('query', (event: { duration: number; query: string }) => {
|
||||
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
|
||||
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
|
||||
recordSlowPrismaQuery({
|
||||
query: event.query,
|
||||
durationMs: event.duration,
|
||||
thresholdMs: this.slowQueryThresholdMs,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user