diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index c255c88..c81d874 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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: [], diff --git a/apps/server/src/modules/bug/bug.module.ts b/apps/server/src/modules/bug/bug.module.ts index e9843c6..59c7be0 100644 --- a/apps/server/src/modules/bug/bug.module.ts +++ b/apps/server/src/modules/bug/bug.module.ts @@ -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 {} diff --git a/apps/server/src/modules/bug/bug.service.ts b/apps/server/src/modules/bug/bug.service.ts index bde049c..4663665 100644 --- a/apps/server/src/modules/bug/bug.service.ts +++ b/apps/server/src/modules/bug/bug.service.ts @@ -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) { diff --git a/apps/server/src/modules/dev-task/dev-task.module.ts b/apps/server/src/modules/dev-task/dev-task.module.ts index 770f99c..b61ebea 100644 --- a/apps/server/src/modules/dev-task/dev-task.module.ts +++ b/apps/server/src/modules/dev-task/dev-task.module.ts @@ -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 {} diff --git a/apps/server/src/modules/dev-task/dev-task.service.ts b/apps/server/src/modules/dev-task/dev-task.service.ts index 384ee95..738d6b1 100644 --- a/apps/server/src/modules/dev-task/dev-task.service.ts +++ b/apps/server/src/modules/dev-task/dev-task.service.ts @@ -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) { @@ -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; } diff --git a/apps/server/src/modules/test-case/test-case.module.ts b/apps/server/src/modules/test-case/test-case.module.ts index ee97dad..eb3dee1 100644 --- a/apps/server/src/modules/test-case/test-case.module.ts +++ b/apps/server/src/modules/test-case/test-case.module.ts @@ -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 {} diff --git a/apps/server/src/modules/test-case/test-case.service.ts b/apps/server/src/modules/test-case/test-case.service.ts index 7e658c5..bb7d63e 100644 --- a/apps/server/src/modules/test-case/test-case.service.ts +++ b/apps/server/src/modules/test-case/test-case.service.ts @@ -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) { @@ -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; } diff --git a/apps/server/src/modules/version-plan/version-plan.module.ts b/apps/server/src/modules/version-plan/version-plan.module.ts index d63834d..174c3cc 100644 --- a/apps/server/src/modules/version-plan/version-plan.module.ts +++ b/apps/server/src/modules/version-plan/version-plan.module.ts @@ -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 {} diff --git a/apps/server/src/modules/version-plan/version-plan.service.ts b/apps/server/src/modules/version-plan/version-plan.service.ts index 43a5ad4..647a431 100644 --- a/apps/server/src/modules/version-plan/version-plan.service.ts +++ b/apps/server/src/modules/version-plan/version-plan.service.ts @@ -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) { @@ -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; } diff --git a/apps/server/src/modules/work-activity/work-activity.module.ts b/apps/server/src/modules/work-activity/work-activity.module.ts index ab70dee..6229a02 100644 --- a/apps/server/src/modules/work-activity/work-activity.module.ts +++ b/apps/server/src/modules/work-activity/work-activity.module.ts @@ -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 {} diff --git a/apps/server/src/modules/work-activity/work-activity.service.spec.ts b/apps/server/src/modules/work-activity/work-activity.service.spec.ts index 2540c1c..0f0008e 100644 --- a/apps/server/src/modules/work-activity/work-activity.service.spec.ts +++ b/apps/server/src/modules/work-activity/work-activity.service.spec.ts @@ -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(); + }); }); diff --git a/apps/server/src/modules/work-activity/work-activity.service.ts b/apps/server/src/modules/work-activity/work-activity.service.ts index 42305ec..aec32f1 100644 --- a/apps/server/src/modules/work-activity/work-activity.service.ts +++ b/apps/server/src/modules/work-activity/work-activity.service.ts @@ -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 }, diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.controller.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.controller.ts new file mode 100644 index 0000000..09fb8ad --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.controller.ts @@ -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, + }); + } +} diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts new file mode 100644 index 0000000..a341e7c --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts @@ -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, + }); + }); +}); diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts new file mode 100644 index 0000000..b71655e --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts @@ -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 = { + 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 { + return this.prisma.xiaobaoRiskSummary.count({ where: { dirty: true } }); + } + + async refreshSummary(versionId: string, options: RefreshXiaobaoRiskOptions = {}): Promise { + 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 { + 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))); +} diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.types.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.types.ts new file mode 100644 index 0000000..b4d186b --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.types.ts @@ -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; + }; +} diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.worker.spec.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.worker.spec.ts new file mode 100644 index 0000000..02b9e03 --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.worker.spec.ts @@ -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'); + }); +}); diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.worker.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.worker.ts new file mode 100644 index 0000000..3ca9c8f --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.worker.ts @@ -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; +} diff --git a/apps/server/src/modules/xiaobao/xiaobao.module.ts b/apps/server/src/modules/xiaobao/xiaobao.module.ts new file mode 100644 index 0000000..c4db5ce --- /dev/null +++ b/apps/server/src/modules/xiaobao/xiaobao.module.ts @@ -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 {} diff --git a/docs/architecture.md b/docs/architecture.md index 7250f48..572efa1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) diff --git a/docs/decisions.md b/docs/decisions.md index 5b5ed79..84d5a34 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 直接改业务实体。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 6abb9f8..7346be8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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.