feat(consistency): 增加V2.5一致性校验
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
186
apps/server/src/modules/consistency/consistency.service.ts
Normal file
186
apps/server/src/modules/consistency/consistency.service.ts
Normal 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).`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"appdata:archive:export": "node scripts/export-appdata-archive.mjs",
|
||||
"appdata:archive:verify": "node scripts/verify-appdata-archive.mjs",
|
||||
"appdata:archive:test": "node --test scripts/appdata-archive.test.mjs",
|
||||
"consistency:v25": "node scripts/check-v25-consistency.mjs",
|
||||
"deploy:local:build": "docker compose --env-file .env.local-server -f docker-compose.local.yml build",
|
||||
"deploy:local:up": "docker compose --env-file .env.local-server -f docker-compose.local.yml up -d",
|
||||
"deploy:local:down": "docker compose --env-file .env.local-server -f docker-compose.local.yml down",
|
||||
|
||||
80
scripts/check-v25-consistency.mjs
Normal file
80
scripts/check-v25-consistency.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const DEFAULT_URL = 'http://localhost:3001/api/v1/consistency';
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const url = args.url || process.env.CONSISTENCY_URL || DEFAULT_URL;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'x-ftb-user-id': process.env.CONSISTENCY_USER_ID || 'm-8',
|
||||
'x-ftb-user-role-id': process.env.CONSISTENCY_ROLE_ID || 'role-admin',
|
||||
'x-ftb-user-name': encodeURIComponent(process.env.CONSISTENCY_USER_NAME || '超级管理员'),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`V2.5 consistency check failed to call ${url}: HTTP ${response.status}`);
|
||||
console.error(await response.text().catch(() => ''));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printHuman(result);
|
||||
}
|
||||
|
||||
process.exit(result.status === 'pass' ? 0 : 1);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = { help: false, json: false, url: '' };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--') continue;
|
||||
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
||||
else if (arg === '--json') parsed.json = true;
|
||||
else if (arg === '--url') parsed.url = argv[++index];
|
||||
else if (arg.startsWith('--url=')) parsed.url = arg.slice('--url='.length);
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printHuman(result) {
|
||||
console.log(result.summary?.human ?? `V2.5 consistency ${result.status}`);
|
||||
console.log(`Generated at: ${result.generatedAt}`);
|
||||
console.log(`Counts: ${Object.entries(result.counts ?? {}).map(([key, value]) => `${key}=${value}`).join(', ')}`);
|
||||
|
||||
for (const [group, checks] of Object.entries(result.checks ?? {})) {
|
||||
const failed = checks.filter((item) => item.severity !== 'ok');
|
||||
if (failed.length === 0) {
|
||||
console.log(`${group}: ok`);
|
||||
continue;
|
||||
}
|
||||
console.log(`${group}:`);
|
||||
for (const item of failed) {
|
||||
console.log(`- [${item.severity}] ${item.id}: ${item.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: pnpm consistency:v25 -- --url http://localhost:3001/api/v1/consistency
|
||||
|
||||
Calls the V2.5 consistency endpoint and prints both human-readable and JSON-ready
|
||||
results for counts, partition keys, orphan references, and audit coverage.
|
||||
|
||||
Options:
|
||||
--url <url> Consistency endpoint URL. Defaults to ${DEFAULT_URL}.
|
||||
--json Print raw JSON response.
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
Reference in New Issue
Block a user