feat(xiaobao): 后台刷新风险摘要
This commit is contained in:
@@ -21,6 +21,7 @@ import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { JobsModule } from './modules/jobs/jobs.module';
|
||||
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -43,6 +44,7 @@ import { JobsModule } from './modules/jobs/jobs.module';
|
||||
V22QueryModule,
|
||||
HealthModule,
|
||||
JobsModule,
|
||||
XiaobaoModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
87
apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts
Normal file
87
apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { JobsService } from '../jobs/jobs.service';
|
||||
import { XiaobaoRiskService } from './xiaobao-risk.service';
|
||||
|
||||
describe('XiaobaoRiskService', () => {
|
||||
function makeService() {
|
||||
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) };
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
448
apps/server/src/modules/xiaobao/xiaobao-risk.service.ts
Normal file
448
apps/server/src/modules/xiaobao/xiaobao-risk.service.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { JobsService } from '../jobs/jobs.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,
|
||||
) {}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
19
apps/server/src/modules/xiaobao/xiaobao.module.ts
Normal file
19
apps/server/src/modules/xiaobao/xiaobao.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { JobsModule } from '../jobs/jobs.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],
|
||||
controllers: [XiaobaoRiskController],
|
||||
providers: [
|
||||
{ provide: XIAOBAO_PRISMA, useExisting: PrismaService },
|
||||
XiaobaoRiskService,
|
||||
XiaobaoRiskWorker,
|
||||
],
|
||||
exports: [XiaobaoRiskService, XiaobaoRiskWorker],
|
||||
})
|
||||
export class XiaobaoModule {}
|
||||
@@ -248,7 +248,14 @@ The rule surface stays in pure frontend engines:
|
||||
|
||||
Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-managers with `xiaobao.warning:view` can only see unfinished versions where the current user is in `version.members`.
|
||||
|
||||
AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent.
|
||||
AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened.
|
||||
|
||||
V2.6 moves the current risk summary refresh to the server:
|
||||
|
||||
- `XiaobaoRiskService.refreshSummary(versionId)` recomputes deterministic rule output from Version, DevTask, TestCase, Bug, WorkActivity, and TaskWorklog relation rows, then upserts `xiaobao_risk_summaries` with `dirty=false`.
|
||||
- `XiaobaoRiskWorker` registers the `xiaobao.summary.refresh` background job handler, so dirty summaries can be refreshed without opening `/xiaobao-warning`.
|
||||
- Domain writes that produce work activity already mark the affected version dirty; V2.6 also enqueues a deduped refresh job. Plain update/delete paths for version plans, dev tasks, test cases, and bugs explicitly mark the version dirty as well.
|
||||
- Frontend `/xiaobao-warning` still consumes V2.2 summary reads first and only falls back to AppData calculation when summaries are empty or unavailable.
|
||||
|
||||
Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet.
|
||||
## V2.2 Partitioned Domain Data Layer (2026-07-03)
|
||||
|
||||
@@ -592,3 +592,16 @@
|
||||
- `BackgroundJobWorker` 只负责 handler 注册和单次执行,业务副作用仍放在各领域 service 内,避免队列层知道小宝、通知或审计细节。
|
||||
|
||||
**理由**:PostgreSQL 队列足够支撑 V2.6 的低频后台刷新,同时能和领域写入共享事务边界、唯一约束和迁移流程。等 V2.7 通知或更高吞吐任务落地后,如确实需要 Redis/专用队列,再通过同一 `JobsService` 接口替换底层实现,而不是现在提前引入第二套事实源。
|
||||
|
||||
## 46. V2.6 小宝风险摘要改为服务端后台刷新
|
||||
|
||||
**问题**:小宝预警最初由页面加载完整前端 store 后计算并保存快照/缓存。这样会导致没人打开页面时 `xiaobao_risk_summaries` 不刷新,侧边栏和 V2.2 快读只能看到旧风险。
|
||||
|
||||
**决策**:
|
||||
- 新增 `XiaobaoModule`,包含 `XiaobaoRiskService`、`XiaobaoRiskWorker` 和最小 controller。
|
||||
- 服务端先移植确定性规则的核心口径:剩余开发/测试/Bug 工作量、关键缺陷、阻塞项、失败用例、预测延期、置信度和 risk signature。
|
||||
- `XiaobaoRiskService.markDirtyAndEnqueue(versionId)` 负责 upsert dirty summary 并排入 `xiaobao.summary.refresh`,dedupe key 使用 `versionId`。
|
||||
- `XiaobaoRiskWorker` 通过 V2.6 `BackgroundJobWorker` 注册 handler,执行时只刷新 `xiaobao_risk_summaries`,不修改 Version、Requirement、DevTask、TestCase、Bug 或 Member。
|
||||
- 领域写入侧继续通过 `WorkActivityService.markXiaobaoSummaryDirty()` 收口;普通 update/delete 没有 activity 证据时显式标脏,避免风险摘要漏刷新。
|
||||
|
||||
**理由**:把 deterministic summary 放到服务端后,读路径不再依赖页面打开,且所有前端仍可沿用 V2.2 summary API。AI 解读仍是后续独立队列,只消费 summary/signature 并写 insight cache;本决策不让 AI 或后台 worker 直接改业务实体。
|
||||
|
||||
@@ -17,10 +17,12 @@ V2.4 已完成高增长和核心业务领域从“AppData 主写 + 关系表同
|
||||
- V2.6.1 已新增 deterministic large-data fixture、HTTP performance harness 和性能预算文档。
|
||||
- V2.6.2 已新增 hot query explain/index audit 脚本、热查询索引迁移和 `docs/performance-hot-queries.md`。
|
||||
- V2.6.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。
|
||||
- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。
|
||||
|
||||
### 已完成(按时间倒序)
|
||||
|
||||
**2026-07-08**
|
||||
- V2.6.4 moved deterministic Xiaobao risk summary refresh into the server, registered the `xiaobao.summary.refresh` background job handler, and enqueue refresh jobs from dirty domain writes.
|
||||
- V2.6.3 added DB-backed background jobs with active dedupe keys, lease-based claiming, expired lock recovery, retry/terminal-failure handling, and a small handler worker.
|
||||
- V2.6.2 added `perf:explain`, hot query explain targets, index audit documentation, and V2.6 hot-path indexes for workspace, Xiaobao warning/dirty queues, project/version lists, and evidence scans.
|
||||
- V2.6.1 added deterministic small/medium/large fixture generation, `perf:check`, and `docs/performance.md` for hot API p50/p95 budgets.
|
||||
|
||||
Reference in New Issue
Block a user