feat(consistency): 增加V2.5一致性校验

This commit is contained in:
2026-07-08 16:55:06 +08:00
parent 869b1c1060
commit ea0b631a83
10 changed files with 391 additions and 4 deletions

View File

@@ -22,6 +22,7 @@ import { DataModule } from './modules/data/data.module';
import { MigrationModule } from './modules/migration/migration.module';
import { V22QueryModule } from './modules/v22-query/v22-query.module';
import { HealthModule } from './modules/health/health.module';
import { ConsistencyModule } from './modules/consistency/consistency.module';
@Module({
imports: [
@@ -44,6 +45,7 @@ import { HealthModule } from './modules/health/health.module';
DataModule,
MigrationModule,
V22QueryModule,
ConsistencyModule,
HealthModule,
AiModule,
],

View File

@@ -1,8 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthenticatedRequest, CurrentUser } from './auth-context.service';
import type { AuthenticatedRequest, CurrentUser as ResolvedCurrentUser } from './auth-context.service';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): CurrentUser | null => {
(_data: unknown, ctx: ExecutionContext): ResolvedCurrentUser | null => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
return request.currentUser ?? null;
},

View File

@@ -0,0 +1,19 @@
import { Reflector } from '@nestjs/core';
import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator';
import { ConsistencyController } from './consistency.controller';
describe('ConsistencyController', () => {
it('requires consistency:view for consistency checks', () => {
const metadata = new Reflector().get(PERMISSION_METADATA_KEY, ConsistencyController.prototype.run);
expect(metadata).toEqual({ permission: 'consistency:view' });
});
it('delegates consistency checks to the service', async () => {
const service = { run: jest.fn().mockResolvedValue({ status: 'pass' }) };
const controller = new ConsistencyController(service as any);
await expect(controller.run()).resolves.toEqual({ status: 'pass' });
expect(service.run).toHaveBeenCalledWith();
});
});

View File

@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { PermissionGuard } from '../../common/auth/permission.guard';
import { RequirePermission } from '../../common/auth/permission.decorator';
import { ConsistencyService } from './consistency.service';
@Controller('consistency')
export class ConsistencyController {
constructor(private readonly consistencyService: ConsistencyService) {}
@Get()
@UseGuards(PermissionGuard)
@RequirePermission('consistency:view')
run() {
return this.consistencyService.run();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ConsistencyController } from './consistency.controller';
import { ConsistencyService } from './consistency.service';
@Module({
controllers: [ConsistencyController],
providers: [ConsistencyService],
})
export class ConsistencyModule {}

View File

@@ -0,0 +1,73 @@
import { ConsistencyService } from './consistency.service';
describe('ConsistencyService', () => {
const makePrisma = () => {
const counts = {
product: 1,
project: 2,
version: 3,
requirement: 4,
versionPlan: 5,
devTask: 6,
testCase: 7,
bug: 8,
user: 9,
taskCategory: 10,
taskWorklog: 11,
overtimeRecord: 12,
workActivity: 13,
auditEvent: 14,
};
const prisma: any = {
$queryRawUnsafe: jest.fn((sql: string) => {
if (sql.includes('dev_tasks') && sql.includes("version_id = ''")) return Promise.resolve([{ count: 1n }]);
if (sql.includes('requirements') && sql.includes('missing_version')) return Promise.resolve([{ count: 2n }]);
if (sql.includes('audit_events') && sql.includes("entity_type = 'bug'")) return Promise.resolve([{ count: 0n }]);
return Promise.resolve([{ count: 0n }]);
}),
};
for (const [model, count] of Object.entries(counts)) {
prisma[model] = { count: jest.fn().mockResolvedValue(count) };
}
return prisma;
};
it('returns counts plus error/warn consistency groups', async () => {
const prisma = makePrisma();
const service = new ConsistencyService(prisma);
const result = await service.run();
expect(result.status).toBe('fail');
expect(result.counts.devTasks).toBe(6);
expect(result.summary.errors).toBeGreaterThan(0);
expect(result.summary.warnings).toBeGreaterThan(0);
expect(result.checks.partitionKeys).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'dev_tasks.version_id.present',
severity: 'error',
count: 1,
}),
]),
);
expect(result.checks.orphanReferences).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'requirements.version_id.exists',
severity: 'error',
count: 2,
}),
]),
);
expect(result.checks.auditCoverage).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'audit.coverage.bug',
severity: 'warn',
count: 0,
}),
]),
);
});
});

View File

@@ -0,0 +1,186 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export type ConsistencySeverity = 'ok' | 'warn' | 'error';
export type ConsistencyStatus = 'pass' | 'fail';
export interface ConsistencyCheckResult {
id: string;
label: string;
severity: ConsistencySeverity;
count: number;
message: string;
}
export interface ConsistencyResult {
generatedAt: string;
status: ConsistencyStatus;
counts: Record<string, number>;
checks: {
partitionKeys: ConsistencyCheckResult[];
orphanReferences: ConsistencyCheckResult[];
auditCoverage: ConsistencyCheckResult[];
};
summary: {
errors: number;
warnings: number;
human: string;
};
}
const COUNT_MODELS: Array<[string, string]> = [
['products', 'product'],
['projects', 'project'],
['versions', 'version'],
['requirements', 'requirement'],
['versionPlans', 'versionPlan'],
['devTasks', 'devTask'],
['testCases', 'testCase'],
['bugs', 'bug'],
['members', 'user'],
['taskCategories', 'taskCategory'],
['taskWorklogs', 'taskWorklog'],
['overtimeRecords', 'overtimeRecord'],
['workActivities', 'workActivity'],
['auditEvents', 'auditEvent'],
];
const PARTITION_KEY_CHECKS = [
check('requirements.product_id.present', 'requirements must keep product_id partition key', 'error', "SELECT COUNT(*) AS count FROM requirements WHERE product_id IS NULL OR product_id = ''"),
check('dev_tasks.version_id.present', 'dev_tasks must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM dev_tasks WHERE version_id IS NULL OR version_id = ''"),
check('test_cases.version_id.present', 'test_cases must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM test_cases WHERE version_id IS NULL OR version_id = ''"),
check('bugs.version_id.present', 'bugs must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM bugs WHERE version_id IS NULL OR version_id = ''"),
check('work_activities.created_at.present', 'work_activities must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM work_activities WHERE created_at IS NULL'),
check('task_worklogs.created_at.present', 'task_worklogs must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM task_worklogs WHERE created_at IS NULL'),
check('overtime_records.created_at.present', 'overtime_records must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM overtime_records WHERE created_at IS NULL'),
check('audit_events.created_at.present', 'audit_events must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM audit_events WHERE created_at IS NULL'),
];
const ORPHAN_REFERENCE_CHECKS = [
check('projects.product_id.exists', 'projects.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM projects p LEFT JOIN products pr ON pr.id = p.product_id WHERE pr.id IS NULL'),
check('versions.product_id.exists', 'versions.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN products p ON p.id = v.product_id WHERE p.id IS NULL'),
check('versions.project_id.exists', 'versions.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN projects p ON p.id = v.project_id WHERE v.project_id IS NOT NULL AND p.id IS NULL'),
check('requirements.product_id.exists', 'requirements.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN products p ON p.id = r.product_id WHERE p.id IS NULL'),
check('requirements.project_id.exists', 'requirements.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN projects p ON p.id = r.project_id WHERE r.project_id IS NOT NULL AND p.id IS NULL'),
check('requirements.version_id.exists', 'requirements.version_id must reference versions.id when present', 'error', 'SELECT COUNT(*) AS count /* missing_version */ FROM requirements r LEFT JOIN versions v ON v.id = r.version_id WHERE r.version_id IS NOT NULL AND v.id IS NULL'),
check('version_plans.version_id.exists', 'version_plans.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM version_plans vp LEFT JOIN versions v ON v.id = vp.version_id WHERE v.id IS NULL'),
check('dev_tasks.version_id.exists', 'dev_tasks.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN versions v ON v.id = dt.version_id WHERE v.id IS NULL'),
check('dev_tasks.requirement.exists', 'dev_tasks requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN requirements r ON r.id = dt.requirement_id AND r.product_id = dt.requirement_product_id WHERE dt.requirement_id IS NOT NULL AND r.id IS NULL'),
check('test_cases.version_id.exists', 'test_cases.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN versions v ON v.id = tc.version_id WHERE v.id IS NULL'),
check('test_cases.requirement.exists', 'test_cases requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN requirements r ON r.id = tc.requirement_id AND r.product_id = tc.requirement_product_id WHERE tc.requirement_id IS NOT NULL AND r.id IS NULL'),
check('bugs.version_id.exists', 'bugs.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN versions v ON v.id = b.version_id WHERE v.id IS NULL'),
check('bugs.test_case.exists', 'bugs test_case composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN test_cases tc ON tc.id = b.test_case_id AND tc.version_id = b.test_case_version_id WHERE b.test_case_id IS NOT NULL AND tc.id IS NULL'),
];
const AUDIT_ENTITY_TYPES = [
'product',
'project',
'version',
'requirement',
'version_plan',
'dev_task',
'test_case',
'bug',
'member',
'task_category',
'task_worklog',
'overtime',
'work_activity',
];
@Injectable()
export class ConsistencyService {
constructor(private readonly prisma: PrismaService) {}
async run(): Promise<ConsistencyResult> {
const [counts, partitionKeys, orphanReferences, auditCoverage] = await Promise.all([
this.collectCounts(),
this.runChecks(PARTITION_KEY_CHECKS),
this.runChecks(ORPHAN_REFERENCE_CHECKS),
this.runAuditCoverageChecks(),
]);
const allChecks = [...partitionKeys, ...orphanReferences, ...auditCoverage];
const errors = allChecks.filter((item) => item.severity === 'error').length;
const warnings = allChecks.filter((item) => item.severity === 'warn').length;
const status: ConsistencyStatus = errors > 0 ? 'fail' : 'pass';
return {
generatedAt: new Date().toISOString(),
status,
counts,
checks: { partitionKeys, orphanReferences, auditCoverage },
summary: {
errors,
warnings,
human: buildHumanSummary(status, errors, warnings, counts),
},
};
}
private async collectCounts() {
const entries = await Promise.all(
COUNT_MODELS.map(async ([label, model]) => [label, await (this.prisma as any)[model].count()] as const),
);
return Object.fromEntries(entries);
}
private async runChecks(checks: ConsistencyCheck[]) {
return Promise.all(checks.map((item) => this.runSqlCheck(item)));
}
private async runAuditCoverageChecks() {
return Promise.all(AUDIT_ENTITY_TYPES.map(async (entityType) => {
const count = await this.rawCount(`SELECT COUNT(*) AS count FROM audit_events WHERE entity_type = '${entityType}'`);
const label = `audit_events should contain mutation events for ${entityType}`;
return {
id: `audit.coverage.${entityType}`,
label,
severity: count === 0 ? 'warn' : 'ok',
count,
message: count === 0 ? `${label}: no events yet` : `${label}: ${count}`,
} satisfies ConsistencyCheckResult;
}));
}
private async runSqlCheck(item: ConsistencyCheck): Promise<ConsistencyCheckResult> {
const count = await this.rawCount(item.sql);
const severity = count > 0 ? item.severityWhenNonZero : 'ok';
return {
id: item.id,
label: item.label,
severity,
count,
message: count > 0 ? `${item.label}: ${count}` : `${item.label}: ok`,
};
}
private async rawCount(sql: string): Promise<number> {
const rows = await this.prisma.$queryRawUnsafe<Array<{ count: bigint | number | string }>>(sql);
return Number(rows[0]?.count ?? 0);
}
}
interface ConsistencyCheck {
id: string;
label: string;
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>;
sql: string;
}
function check(
id: string,
label: string,
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>,
sql: string,
): ConsistencyCheck {
return { id, label, severityWhenNonZero, sql };
}
function buildHumanSummary(
status: ConsistencyStatus,
errors: number,
warnings: number,
counts: Record<string, number>,
) {
return `V2.5 consistency ${status}: ${errors} error(s), ${warnings} warning(s), ${counts.auditEvents ?? 0} audit event(s).`;
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, Logger, Optional } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
@@ -18,7 +18,8 @@ export class DataService {
constructor(
private prisma: PrismaService,
private readonly appDataSync?: AppDataV23SyncService,
private readonly appDataRetirement = new AppDataRetirementService(),
@Optional()
private readonly appDataRetirement: AppDataRetirementService = new AppDataRetirementService(),
) {}
async get(key: string) {