feat(v2.7): 增加管理驾驶舱与治理字典服务

This commit is contained in:
2026-07-08 16:19:29 +08:00
parent 0f57e75689
commit bcbe84bb6e
10 changed files with 637 additions and 1 deletions

View File

@@ -13,9 +13,11 @@ import { HealthModule } from './modules/health/health.module';
import { NotificationModule } from './modules/notification/notification.module'; import { NotificationModule } from './modules/notification/notification.module';
import { CommentModule } from './modules/comment/comment.module'; import { CommentModule } from './modules/comment/comment.module';
import { ProjectMemberModule } from './modules/project-member/project-member.module'; import { ProjectMemberModule } from './modules/project-member/project-member.module';
import { ManagementModule } from './modules/management/management.module';
import { GovernanceModule } from './modules/governance/governance.module';
@Module({ @Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule, ProjectMemberModule], imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule, ProjectMemberModule, ManagementModule, GovernanceModule],
controllers: [], controllers: [],
providers: [ providers: [
{ {

View File

@@ -0,0 +1,41 @@
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { GOVERNANCE_DICTIONARY_KINDS, type GovernanceDictionaryKind } from '../governance.service';
export class GovernanceDictionaryDto {
@IsString()
actorId!: string;
@IsIn(GOVERNANCE_DICTIONARY_KINDS)
kind!: GovernanceDictionaryKind;
@IsString()
name!: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
group?: string;
@IsOptional()
@IsString()
scope?: string;
}
export class RemoveGovernanceDictionaryDto {
@IsString()
actorId!: string;
}
export class ImportGovernanceDictionariesDto {
@IsString()
actorId!: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => GovernanceDictionaryDto)
items!: GovernanceDictionaryDto[];
}

View File

@@ -0,0 +1,46 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { GovernanceDictionaryDto, ImportGovernanceDictionariesDto, RemoveGovernanceDictionaryDto } from './dto/governance-dictionary.dto';
import { GovernanceDictionaryKind, GovernanceService } from './governance.service';
@Controller('governance')
export class GovernanceController {
constructor(private readonly governanceService: GovernanceService) {}
@Get('dictionaries')
list(@Query('kind') kind: GovernanceDictionaryKind) {
return this.governanceService.list(kind);
}
@Post('dictionaries')
create(@Body() dto: GovernanceDictionaryDto) {
return this.governanceService.create(dto);
}
@Patch('dictionaries/:kind/:id')
update(
@Param('kind') kind: GovernanceDictionaryKind,
@Param('id') id: string,
@Body() dto: GovernanceDictionaryDto,
) {
return this.governanceService.update({ ...dto, kind, id });
}
@Delete('dictionaries/:kind/:id')
remove(
@Param('kind') kind: GovernanceDictionaryKind,
@Param('id') id: string,
@Body() dto: RemoveGovernanceDictionaryDto,
) {
return this.governanceService.remove({ actorId: dto.actorId, kind, id });
}
@Get('export')
exportAll() {
return this.governanceService.exportAll();
}
@Post('import')
importAll(@Body() dto: ImportGovernanceDictionariesDto) {
return this.governanceService.importAll(dto.actorId, dto.items);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { GovernanceController } from './governance.controller';
import { GovernanceService } from './governance.service';
@Module({
imports: [CommonDomainModule],
controllers: [GovernanceController],
providers: [GovernanceService],
exports: [GovernanceService],
})
export class GovernanceModule {}

View File

@@ -0,0 +1,69 @@
import { BadRequestException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { GovernanceService } from './governance.service';
describe('GovernanceService', () => {
const makeService = () => {
const prisma = {
taskCategory: {
create: jest.fn(),
delete: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
devTask: { count: jest.fn() },
testCase: { count: jest.fn() },
governanceDictionary: {
create: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
upsert: jest.fn(),
},
};
const audit = { record: jest.fn() } as unknown as AuditService;
return { prisma, audit, service: new GovernanceService(prisma as any, audit) };
};
it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => {
const { prisma, service } = makeService();
prisma.devTask.count.mockResolvedValue(1);
prisma.testCase.count.mockResolvedValue(0);
await expect(service.remove({
actorId: 'm-admin',
kind: 'task_category',
id: 'cat-1',
})).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
});
it('soft deletes requirement dictionaries and writes audit', async () => {
const { prisma, audit, service } = makeService();
prisma.governanceDictionary.update.mockResolvedValue({ id: 'dict-1', kind: 'requirement_type', deletedAt: new Date('2026-07-08T00:00:00.000Z') });
await service.remove({ actorId: 'm-admin', kind: 'requirement_type', id: 'dict-1' });
expect(prisma.governanceDictionary.update).toHaveBeenCalledWith({
where: { id: 'dict-1' },
data: { deletedAt: expect.any(Date) },
});
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
actorId: 'm-admin',
action: 'governance.dictionary_deleted',
resourceType: 'governance_dictionary',
resourceId: 'dict-1',
}));
});
it('exports task categories and governance dictionaries together', async () => {
const { prisma, service } = makeService();
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]);
prisma.governanceDictionary.findMany.mockResolvedValue([{ id: 'type-1', kind: 'requirement_type', name: '新功能' }]);
await expect(service.exportAll()).resolves.toEqual({
taskCategories: [{ id: 'cat-1', name: '前端' }],
dictionaries: [{ id: 'type-1', kind: 'requirement_type', name: '新功能' }],
});
});
});

View File

@@ -0,0 +1,211 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { PrismaService } from '../../prisma/prisma.service';
export const GOVERNANCE_DICTIONARY_KINDS = ['task_category', 'requirement_type', 'requirement_platform', 'requirement_source'] as const;
export type GovernanceDictionaryKind = (typeof GOVERNANCE_DICTIONARY_KINDS)[number];
export interface GovernanceDictionaryInput {
actorId: string;
kind: GovernanceDictionaryKind;
id?: string;
name: string;
code?: string | null;
group?: string | null;
scope?: string;
value?: unknown;
}
export interface GovernanceRemoveInput {
actorId: string;
kind: GovernanceDictionaryKind;
id: string;
}
@Injectable()
export class GovernanceService {
constructor(
private readonly prisma: PrismaService,
private readonly auditService: AuditService,
) {}
list(kind: GovernanceDictionaryKind) {
assertDictionaryKind(kind);
if (kind === 'task_category') {
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
}
return this.prisma.governanceDictionary.findMany({
where: { kind, deletedAt: null },
orderBy: { name: 'asc' },
});
}
async create(input: GovernanceDictionaryInput) {
assertDictionaryKind(input.kind);
const actorId = requireText(input.actorId, 'actorId');
const created = input.kind === 'task_category'
? await this.prisma.taskCategory.create({
data: {
name: requireText(input.name, 'name'),
code: input.code ?? null,
group: input.group ?? 'other',
},
})
: await this.prisma.governanceDictionary.create({
data: {
scope: input.scope ?? 'global',
kind: input.kind,
name: requireText(input.name, 'name'),
code: input.code ?? null,
group: input.group ?? null,
value: input.value ?? {},
},
});
await this.auditService.record({
actorId,
action: 'governance.dictionary_created',
resourceType: this.resourceType(input.kind),
resourceId: created.id,
after: created,
});
return created;
}
async update(input: GovernanceDictionaryInput & { id: string }) {
assertDictionaryKind(input.kind);
const actorId = requireText(input.actorId, 'actorId');
const id = requireText(input.id, 'id');
const updated = input.kind === 'task_category'
? await this.prisma.taskCategory.update({
where: { id },
data: {
name: requireText(input.name, 'name'),
code: input.code ?? null,
group: input.group ?? 'other',
},
})
: await this.prisma.governanceDictionary.update({
where: { id },
data: {
name: requireText(input.name, 'name'),
code: input.code ?? null,
group: input.group ?? null,
value: input.value ?? {},
},
});
await this.auditService.record({
actorId,
action: 'governance.dictionary_updated',
resourceType: this.resourceType(input.kind),
resourceId: id,
after: updated,
});
return updated;
}
async remove(input: GovernanceRemoveInput) {
assertDictionaryKind(input.kind);
const actorId = requireText(input.actorId, 'actorId');
const id = requireText(input.id, 'id');
if (input.kind === 'task_category') {
const [devTaskCount, testCaseCount] = await Promise.all([
this.prisma.devTask.count({ where: { categoryId: id } }),
this.prisma.testCase.count({ where: { categoryId: id } }),
]);
if (devTaskCount + testCaseCount > 0) {
throw new BadRequestException('Dictionary item is in use and cannot be hard deleted');
}
const removed = await this.prisma.taskCategory.delete({ where: { id } });
await this.auditService.record({
actorId,
action: 'governance.dictionary_deleted',
resourceType: 'task_category',
resourceId: id,
before: removed,
});
return removed;
}
const removed = await this.prisma.governanceDictionary.update({
where: { id },
data: { deletedAt: new Date() },
});
await this.auditService.record({
actorId,
action: 'governance.dictionary_deleted',
resourceType: 'governance_dictionary',
resourceId: id,
after: removed,
});
return removed;
}
async exportAll() {
const [taskCategories, dictionaries] = await Promise.all([
this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }),
this.prisma.governanceDictionary.findMany({ where: { deletedAt: null }, orderBy: [{ kind: 'asc' }, { name: 'asc' }] }),
]);
return { taskCategories, dictionaries };
}
async importAll(actorId: string, items: GovernanceDictionaryInput[]) {
const results = [];
for (const item of items) {
assertDictionaryKind(item.kind);
if (item.kind === 'task_category') {
results.push(await this.create({ ...item, actorId }));
} else {
const saved = await this.prisma.governanceDictionary.upsert({
where: {
scope_kind_name: {
scope: item.scope ?? 'global',
kind: item.kind,
name: requireText(item.name, 'name'),
},
},
update: {
code: item.code ?? null,
group: item.group ?? null,
value: item.value ?? {},
deletedAt: null,
},
create: {
scope: item.scope ?? 'global',
kind: item.kind,
name: requireText(item.name, 'name'),
code: item.code ?? null,
group: item.group ?? null,
value: item.value ?? {},
},
});
results.push(saved);
}
}
await this.auditService.record({
actorId: requireText(actorId, 'actorId'),
action: 'governance.dictionary_imported',
resourceType: 'governance_dictionary',
resourceId: 'bulk',
after: { count: results.length },
});
return { count: results.length, items: results };
}
private resourceType(kind: GovernanceDictionaryKind) {
return kind === 'task_category' ? 'task_category' : 'governance_dictionary';
}
}
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
if (!GOVERNANCE_DICTIONARY_KINDS.includes(kind as GovernanceDictionaryKind)) {
throw new BadRequestException(`Unsupported governance dictionary kind: ${kind}`);
}
}
function requireText(value: string | undefined | null, field: string): string {
const normalized = value?.trim();
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ManagementService } from './management.service';
@Controller('management')
export class ManagementController {
constructor(private readonly managementService: ManagementService) {}
@Get('overview')
getOverview(
@Query('actorId') actorId: string,
@Query('permissions') permissions?: string,
) {
return this.managementService.getOverview({
actorId,
permissions: permissions ? permissions.split(',').map((item) => item.trim()).filter(Boolean) : [],
});
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ManagementController } from './management.controller';
import { ManagementService } from './management.service';
@Module({
controllers: [ManagementController],
providers: [ManagementService],
})
export class ManagementModule {}

View File

@@ -0,0 +1,75 @@
import { ManagementService } from './management.service';
describe('ManagementService', () => {
const makeService = () => {
const prisma = {
appData: { findMany: jest.fn() },
projectMember: { findMany: jest.fn() },
version: { findMany: jest.fn() },
versionPlan: { findMany: jest.fn() },
devTask: { findMany: jest.fn() },
testCase: { findMany: jest.fn() },
bug: { findMany: jest.fn() },
xiaobaoRiskSummary: { findMany: jest.fn() },
};
return { prisma, service: new ManagementService(prisma as any) };
};
it('returns an empty dashboard when actor has no managed projects', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findMany.mockResolvedValue([]);
await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).resolves.toEqual({
activeVersionCount: 0,
overdueItemCount: 0,
blockedItemCount: 0,
riskCounts: {},
memberLoads: [],
activeVersions: [],
overdueItems: [],
blockedItems: [],
highRiskVersions: [],
});
expect(prisma.appData.findMany).not.toHaveBeenCalled();
});
it('aggregates active versions, overdue work, blockers, risks, and member load from relation tables only', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }]);
prisma.version.findMany.mockResolvedValue([
{ id: 'ver-1', projectId: 'project-1', name: 'V1', releaseDate: new Date('2026-07-20T00:00:00.000Z') },
]);
prisma.versionPlan.findMany.mockResolvedValue([
{ id: 'plan-1', versionId: 'ver-1', title: '产品方案', ownerId: 'm-pm', status: 'in_progress', expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
]);
prisma.devTask.findMany.mockResolvedValue([
{ id: 'task-1', versionId: 'ver-1', title: '接口开发', assigneeId: 'm-dev', status: 'in_progress', isBlocked: true, expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
]);
prisma.testCase.findMany.mockResolvedValue([
{ id: 'tc-1', versionId: 'ver-1', title: '权限测试', assigneeId: 'm-qa', status: 'running', plannedEndAt: new Date('2026-07-01T00:00:00.000Z') },
]);
prisma.bug.findMany.mockResolvedValue([
{ id: 'bug-1', versionId: 'ver-1', title: '线上缺陷', assigneeId: 'm-dev', status: 'open', plannedFixAt: new Date('2026-07-01T00:00:00.000Z') },
]);
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
{ versionId: 'ver-1', riskLevel: 'likely_delayed', riskScore: 82 },
]);
const result = await service.getOverview({
actorId: 'm-manager',
permissions: [],
now: new Date('2026-07-08T00:00:00.000Z'),
});
expect(result.activeVersionCount).toBe(1);
expect(result.overdueItemCount).toBe(4);
expect(result.blockedItemCount).toBe(1);
expect(result.riskCounts).toEqual({ likely_delayed: 1 });
expect(result.memberLoads).toEqual([
{ memberId: 'm-dev', openItemCount: 2 },
{ memberId: 'm-pm', openItemCount: 1 },
{ memberId: 'm-qa', openItemCount: 1 },
]);
expect(prisma.appData.findMany).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,153 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface ManagementOverviewQuery {
actorId?: string;
permissions?: string[];
now?: Date;
}
type WorkItem = {
id: string;
versionId: string;
title: string;
ownerId?: string | null;
assigneeId?: string | null;
status?: string;
isBlocked?: boolean;
expectedEndAt?: Date | string | null;
plannedEndAt?: Date | string | null;
plannedFixAt?: Date | string | null;
};
@Injectable()
export class ManagementService {
constructor(private readonly prisma: PrismaService) {}
async getOverview(query: ManagementOverviewQuery) {
const actorId = query.actorId?.trim();
if (!actorId) throw new BadRequestException('actorId is required');
const now = query.now ?? new Date();
const allProjects = query.permissions?.includes('*') === true;
const projectIds = allProjects ? undefined : await this.getManagedProjectIds(actorId);
if (!allProjects && projectIds?.length === 0) return emptyOverview();
const activeVersions = await this.prisma.version.findMany({
where: {
...(projectIds ? { projectId: { in: projectIds } } : {}),
OR: [{ releaseDate: null }, { releaseDate: { gte: now } }],
},
orderBy: { releaseDate: 'asc' },
});
const versionIds = activeVersions.map((version: { id: string }) => version.id);
if (versionIds.length === 0) {
return { ...emptyOverview(), activeVersions };
}
const [versionPlans, devTasks, testCases, bugs, highRiskVersions] = await Promise.all([
this.prisma.versionPlan.findMany({
where: { versionId: { in: versionIds }, status: { not: 'completed' } },
}),
this.prisma.devTask.findMany({
where: { versionId: { in: versionIds }, status: { not: 'submitted' } },
}),
this.prisma.testCase.findMany({
where: { versionId: { in: versionIds }, status: { notIn: ['passed', 'failed', 'blocked'] } },
}),
this.prisma.bug.findMany({
where: { versionId: { in: versionIds }, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
}),
this.prisma.xiaobaoRiskSummary.findMany({
where: { versionId: { in: versionIds }, riskLevel: { not: 'on_track' } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
}),
]);
const overdueItems = [
...collectOverdue(versionPlans, 'version_plan', now, 'expectedEndAt'),
...collectOverdue(devTasks, 'dev_task', now, 'expectedEndAt'),
...collectOverdue(testCases, 'test_case', now, 'plannedEndAt'),
...collectOverdue(bugs, 'bug', now, 'plannedFixAt'),
];
const blockedItems = [
...devTasks.filter((item: WorkItem) => item.isBlocked).map((item: WorkItem) => toDashboardItem(item, 'dev_task')),
...testCases.filter((item: WorkItem) => item.status === 'blocked').map((item: WorkItem) => toDashboardItem(item, 'test_case')),
];
return {
activeVersionCount: activeVersions.length,
overdueItemCount: overdueItems.length,
blockedItemCount: blockedItems.length,
riskCounts: countByRiskLevel(highRiskVersions),
memberLoads: buildMemberLoads(versionPlans, devTasks, testCases, bugs),
activeVersions,
overdueItems,
blockedItems,
highRiskVersions,
};
}
private async getManagedProjectIds(actorId: string): Promise<string[]> {
const rows = await this.prisma.projectMember.findMany({
where: { userId: actorId, role: { in: ['owner', 'admin'] } },
select: { projectId: true },
});
return Array.from(new Set(rows.map((row: { projectId: string }) => row.projectId)));
}
}
function emptyOverview() {
return {
activeVersionCount: 0,
overdueItemCount: 0,
blockedItemCount: 0,
riskCounts: {},
memberLoads: [],
activeVersions: [],
overdueItems: [],
blockedItems: [],
highRiskVersions: [],
};
}
function collectOverdue(items: WorkItem[], type: string, now: Date, dateField: keyof WorkItem) {
return items
.filter((item) => isBefore(item[dateField], now))
.map((item) => ({ ...toDashboardItem(item, type), dueAt: item[dateField] }));
}
function toDashboardItem(item: WorkItem, type: string) {
return {
type,
id: item.id,
versionId: item.versionId,
title: item.title,
ownerId: item.ownerId ?? item.assigneeId ?? null,
status: item.status,
};
}
function isBefore(value: unknown, now: Date): boolean {
if (!value) return false;
const time = value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
return Number.isFinite(time) && time < now.getTime();
}
function countByRiskLevel(rows: Array<{ riskLevel: string }>): Record<string, number> {
return rows.reduce<Record<string, number>>((acc, row) => {
acc[row.riskLevel] = (acc[row.riskLevel] ?? 0) + 1;
return acc;
}, {});
}
function buildMemberLoads(...groups: WorkItem[][]) {
const counts = new Map<string, number>();
for (const item of groups.flat()) {
const memberId = item.ownerId ?? item.assigneeId;
if (!memberId) continue;
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([memberId, openItemCount]) => ({ memberId, openItemCount }))
.sort((a, b) => b.openItemCount - a.openItemCount || a.memberId.localeCompare(b.memberId));
}