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 { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { JobsModule } from './modules/jobs/jobs.module';
|
import { JobsModule } from './modules/jobs/jobs.module';
|
||||||
|
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -43,6 +44,7 @@ import { JobsModule } from './modules/jobs/jobs.module';
|
|||||||
V22QueryModule,
|
V22QueryModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
|
XiaobaoModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||||
import { BugController } from './bug.controller';
|
import { BugController } from './bug.controller';
|
||||||
import { BugService } from './bug.service';
|
import { BUG_PRISMA, BugService } from './bug.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkActivityModule],
|
imports: [WorkActivityModule],
|
||||||
controllers: [BugController],
|
controllers: [BugController],
|
||||||
providers: [BugService],
|
providers: [
|
||||||
|
{ provide: BUG_PRISMA, useExisting: PrismaService },
|
||||||
|
BugService,
|
||||||
|
],
|
||||||
exports: [BugService],
|
exports: [BugService],
|
||||||
})
|
})
|
||||||
export class BugModule {}
|
export class BugModule {}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateBugDto } from './dto/create-bug.dto';
|
import { CreateBugDto } from './dto/create-bug.dto';
|
||||||
import { UpdateBugDto } from './dto/update-bug.dto';
|
import { UpdateBugDto } from './dto/update-bug.dto';
|
||||||
|
|
||||||
|
export const BUG_PRISMA = 'BUG_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BugService {
|
export class BugService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(BUG_PRISMA) private readonly prisma: any,
|
||||||
private readonly workActivity: WorkActivityService,
|
private readonly workActivity: WorkActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export class BugService {
|
|||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toBugData(dto),
|
data: this.toBugData(dto),
|
||||||
});
|
});
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +86,9 @@ export class BugService {
|
|||||||
|
|
||||||
async remove(versionId: string, id: string) {
|
async remove(versionId: string, id: string) {
|
||||||
await this.ensureBugInVersion(versionId, id);
|
await this.ensureBugInVersion(versionId, id);
|
||||||
return this.prisma.bug.delete({ where: { id_versionId: { id, versionId } } });
|
const item = await this.prisma.bug.delete({ where: { id_versionId: { id, versionId } } });
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toBugData(dto: Partial<CreateBugDto>) {
|
private toBugData(dto: Partial<CreateBugDto>) {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||||
import { DevTaskController } from './dev-task.controller';
|
import { DevTaskController } from './dev-task.controller';
|
||||||
import { DevTaskService } from './dev-task.service';
|
import { DEV_TASK_PRISMA, DevTaskService } from './dev-task.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkActivityModule],
|
imports: [WorkActivityModule],
|
||||||
controllers: [DevTaskController],
|
controllers: [DevTaskController],
|
||||||
providers: [DevTaskService],
|
providers: [
|
||||||
|
{ provide: DEV_TASK_PRISMA, useExisting: PrismaService },
|
||||||
|
DevTaskService,
|
||||||
|
],
|
||||||
exports: [DevTaskService],
|
exports: [DevTaskService],
|
||||||
})
|
})
|
||||||
export class DevTaskModule {}
|
export class DevTaskModule {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
||||||
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
||||||
|
|
||||||
|
export const DEV_TASK_PRISMA = 'DEV_TASK_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DevTaskService {
|
export class DevTaskService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(DEV_TASK_PRISMA) private readonly prisma: any,
|
||||||
private readonly workActivity: WorkActivityService,
|
private readonly workActivity: WorkActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ export class DevTaskService {
|
|||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toTaskData(dto),
|
data: this.toTaskData(dto),
|
||||||
});
|
});
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +100,9 @@ export class DevTaskService {
|
|||||||
|
|
||||||
async remove(versionId: string, id: string) {
|
async remove(versionId: string, id: string) {
|
||||||
await this.ensureTaskInVersion(versionId, id);
|
await this.ensureTaskInVersion(versionId, id);
|
||||||
return this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } });
|
const item = await this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } });
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toTaskData(dto: Partial<CreateDevTaskDto>) {
|
private toTaskData(dto: Partial<CreateDevTaskDto>) {
|
||||||
@@ -200,6 +203,6 @@ function parsePriority(value: string | number | null | undefined): number | unde
|
|||||||
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
|
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
function toJsonInput(value: unknown): any {
|
||||||
return value as Prisma.InputJsonValue;
|
return value as any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||||
import { TestCaseController } from './test-case.controller';
|
import { TestCaseController } from './test-case.controller';
|
||||||
import { TestCaseService } from './test-case.service';
|
import { TEST_CASE_PRISMA, TestCaseService } from './test-case.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkActivityModule],
|
imports: [WorkActivityModule],
|
||||||
controllers: [TestCaseController],
|
controllers: [TestCaseController],
|
||||||
providers: [TestCaseService],
|
providers: [
|
||||||
|
{ provide: TEST_CASE_PRISMA, useExisting: PrismaService },
|
||||||
|
TestCaseService,
|
||||||
|
],
|
||||||
exports: [TestCaseService],
|
exports: [TestCaseService],
|
||||||
})
|
})
|
||||||
export class TestCaseModule {}
|
export class TestCaseModule {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
||||||
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
||||||
|
|
||||||
|
export const TEST_CASE_PRISMA = 'TEST_CASE_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TestCaseService {
|
export class TestCaseService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(TEST_CASE_PRISMA) private readonly prisma: any,
|
||||||
private readonly workActivity: WorkActivityService,
|
private readonly workActivity: WorkActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export class TestCaseService {
|
|||||||
const items = await this.prisma.testCase.findMany({
|
const items = await this.prisma.testCase.findMany({
|
||||||
where: { versionId, code: { in: rows.map((row) => row.code) } },
|
where: { versionId, code: { in: rows.map((row) => row.code) } },
|
||||||
});
|
});
|
||||||
const activities = await Promise.all(items.map((item) => (
|
const activities = await Promise.all(items.map((item: any) => (
|
||||||
this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`)
|
this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`)
|
||||||
)));
|
)));
|
||||||
return { items, activities };
|
return { items, activities };
|
||||||
@@ -64,6 +64,7 @@ export class TestCaseService {
|
|||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toTestCaseData(dto),
|
data: this.toTestCaseData(dto),
|
||||||
});
|
});
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +85,9 @@ export class TestCaseService {
|
|||||||
|
|
||||||
async remove(versionId: string, id: string) {
|
async remove(versionId: string, id: string) {
|
||||||
await this.ensureTestCaseInVersion(versionId, id);
|
await this.ensureTestCaseInVersion(versionId, id);
|
||||||
return this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } });
|
const item = await this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } });
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toTestCaseData(dto: Partial<CreateTestCaseDto>) {
|
private toTestCaseData(dto: Partial<CreateTestCaseDto>) {
|
||||||
@@ -189,6 +192,6 @@ function parsePriority(value: string | number | null | undefined): number | unde
|
|||||||
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
|
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
function toJsonInput(value: unknown): any {
|
||||||
return value as Prisma.InputJsonValue;
|
return value as any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||||
import { VersionPlanController } from './version-plan.controller';
|
import { VersionPlanController } from './version-plan.controller';
|
||||||
import { VersionPlanService } from './version-plan.service';
|
import { VERSION_PLAN_PRISMA, VersionPlanService } from './version-plan.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkActivityModule],
|
imports: [WorkActivityModule],
|
||||||
controllers: [VersionPlanController],
|
controllers: [VersionPlanController],
|
||||||
providers: [VersionPlanService],
|
providers: [
|
||||||
|
{ provide: VERSION_PLAN_PRISMA, useExisting: PrismaService },
|
||||||
|
VersionPlanService,
|
||||||
|
],
|
||||||
exports: [VersionPlanService],
|
exports: [VersionPlanService],
|
||||||
})
|
})
|
||||||
export class VersionPlanModule {}
|
export class VersionPlanModule {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
||||||
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
||||||
|
|
||||||
|
export const VERSION_PLAN_PRISMA = 'VERSION_PLAN_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VersionPlanService {
|
export class VersionPlanService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(VERSION_PLAN_PRISMA) private readonly prisma: any,
|
||||||
private readonly workActivity: WorkActivityService,
|
private readonly workActivity: WorkActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ export class VersionPlanService {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
const activity = current.status !== item.status ? await this.recordStatusActivity(item, current.status, item.status) : undefined;
|
const activity = current.status !== item.status ? await this.recordStatusActivity(item, current.status, item.status) : undefined;
|
||||||
|
if (!activity) await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: activity ? [activity] : [] };
|
return { item, activities: activity ? [activity] : [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,9 @@ export class VersionPlanService {
|
|||||||
|
|
||||||
async remove(versionId: string, id: string) {
|
async remove(versionId: string, id: string) {
|
||||||
await this.ensurePlanInVersion(versionId, id);
|
await this.ensurePlanInVersion(versionId, id);
|
||||||
return this.prisma.versionPlan.delete({ where: { id } });
|
const item = await this.prisma.versionPlan.delete({ where: { id } });
|
||||||
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPlanData(dto: Partial<CreateVersionPlanDto>) {
|
private toPlanData(dto: Partial<CreateVersionPlanDto>) {
|
||||||
@@ -147,6 +150,6 @@ function parseOptionalDate(value: string | null | undefined): Date | null {
|
|||||||
return Number.isFinite(date.getTime()) ? date : null;
|
return Number.isFinite(date.getTime()) ? date : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toJsonInput(value: unknown): Prisma.InputJsonValue {
|
function toJsonInput(value: unknown): any {
|
||||||
return value as Prisma.InputJsonValue;
|
return value as any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { XiaobaoModule } from '../xiaobao/xiaobao.module';
|
||||||
import { WorkActivityController } from './work-activity.controller';
|
import { WorkActivityController } from './work-activity.controller';
|
||||||
import { WorkActivityService } from './work-activity.service';
|
import { WORK_ACTIVITY_PRISMA, WorkActivityService } from './work-activity.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [XiaobaoModule],
|
||||||
controllers: [WorkActivityController],
|
controllers: [WorkActivityController],
|
||||||
providers: [WorkActivityService],
|
providers: [
|
||||||
|
{ provide: WORK_ACTIVITY_PRISMA, useExisting: PrismaService },
|
||||||
|
WorkActivityService,
|
||||||
|
],
|
||||||
exports: [WorkActivityService],
|
exports: [WorkActivityService],
|
||||||
})
|
})
|
||||||
export class WorkActivityModule {}
|
export class WorkActivityModule {}
|
||||||
|
|||||||
@@ -60,4 +60,24 @@ describe('WorkActivityService relational evidence', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enqueues a Xiaobao summary refresh when the background risk service is available', async () => {
|
||||||
|
const prisma = {
|
||||||
|
workActivity: {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||||
|
},
|
||||||
|
xiaobaoRiskSummary: {
|
||||||
|
upsert: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const xiaobaoRisk = {
|
||||||
|
markDirtyAndEnqueue: jest.fn().mockResolvedValue({ id: 'job-1' }),
|
||||||
|
};
|
||||||
|
const service = new WorkActivityService(prisma as any, xiaobaoRisk as any);
|
||||||
|
|
||||||
|
await service.markXiaobaoSummaryDirty('version-1');
|
||||||
|
|
||||||
|
expect(xiaobaoRisk.markDirtyAndEnqueue).toHaveBeenCalledWith('version-1');
|
||||||
|
expect(prisma.xiaobaoRiskSummary.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { XiaobaoRiskService } from '../xiaobao/xiaobao-risk.service';
|
||||||
|
|
||||||
export interface WorkActivityRecordInput {
|
export interface WorkActivityRecordInput {
|
||||||
versionId?: string | null;
|
versionId?: string | null;
|
||||||
@@ -18,9 +18,14 @@ export interface WorkActivityRecordInput {
|
|||||||
occurredAt?: string | Date | null;
|
occurredAt?: string | Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const WORK_ACTIVITY_PRISMA = 'WORK_ACTIVITY_PRISMA';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkActivityService {
|
export class WorkActivityService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
@Inject(WORK_ACTIVITY_PRISMA) private readonly prisma: any,
|
||||||
|
@Optional() private readonly xiaobaoRisk?: XiaobaoRiskService,
|
||||||
|
) {}
|
||||||
|
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } });
|
return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } });
|
||||||
@@ -64,6 +69,11 @@ export class WorkActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async markXiaobaoSummaryDirty(versionId: string) {
|
async markXiaobaoSummaryDirty(versionId: string) {
|
||||||
|
if (this.xiaobaoRisk) {
|
||||||
|
await this.xiaobaoRisk.markDirtyAndEnqueue(versionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const riskSignature = `dirty:${versionId}`;
|
const riskSignature = `dirty:${versionId}`;
|
||||||
await this.prisma.xiaobaoRiskSummary.upsert({
|
await this.prisma.xiaobaoRiskSummary.upsert({
|
||||||
where: { versionId },
|
where: { versionId },
|
||||||
|
|||||||
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`.
|
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.
|
Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet.
|
||||||
## V2.2 Partitioned Domain Data Layer (2026-07-03)
|
## V2.2 Partitioned Domain Data Layer (2026-07-03)
|
||||||
|
|||||||
@@ -592,3 +592,16 @@
|
|||||||
- `BackgroundJobWorker` 只负责 handler 注册和单次执行,业务副作用仍放在各领域 service 内,避免队列层知道小宝、通知或审计细节。
|
- `BackgroundJobWorker` 只负责 handler 注册和单次执行,业务副作用仍放在各领域 service 内,避免队列层知道小宝、通知或审计细节。
|
||||||
|
|
||||||
**理由**:PostgreSQL 队列足够支撑 V2.6 的低频后台刷新,同时能和领域写入共享事务边界、唯一约束和迁移流程。等 V2.7 通知或更高吞吐任务落地后,如确实需要 Redis/专用队列,再通过同一 `JobsService` 接口替换底层实现,而不是现在提前引入第二套事实源。
|
**理由**: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.1 已新增 deterministic large-data fixture、HTTP performance harness 和性能预算文档。
|
||||||
- V2.6.2 已新增 hot query explain/index audit 脚本、热查询索引迁移和 `docs/performance-hot-queries.md`。
|
- 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.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。
|
||||||
|
- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。
|
||||||
|
|
||||||
### 已完成(按时间倒序)
|
### 已完成(按时间倒序)
|
||||||
|
|
||||||
**2026-07-08**
|
**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.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.2 added `perf:explain`, hot query explain targets, index audit documentation, and V2.6 hot-path indexes for workspace, Xiaobao warning/dirty queues, project/version lists, and evidence scans.
|
||||||
- V2.6.1 added deterministic small/medium/large fixture generation, `perf:check`, and `docs/performance.md` for hot API p50/p95 budgets.
|
- V2.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