feat(xiaobao): 后台刷新风险摘要

This commit is contained in:
2026-07-08 17:14:40 +08:00
parent ba999c9322
commit c131a0a133
22 changed files with 825 additions and 40 deletions

View File

@@ -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: [],

View File

@@ -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 {}

View File

@@ -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>) {

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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();
});
});

View File

@@ -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 },

View 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,
});
}
}

View 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,
});
});
});

View 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)));
}

View 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;
};
}

View 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');
});
});

View 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;
}

View 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 {}