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

View File

@@ -1,8 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 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( export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): CurrentUser | null => { (_data: unknown, ctx: ExecutionContext): ResolvedCurrentUser | null => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>(); const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
return request.currentUser ?? null; 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 { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service'; import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
@@ -18,7 +18,8 @@ export class DataService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private readonly appDataSync?: AppDataV23SyncService, private readonly appDataSync?: AppDataV23SyncService,
private readonly appDataRetirement = new AppDataRetirementService(), @Optional()
private readonly appDataRetirement: AppDataRetirementService = new AppDataRetirementService(),
) {} ) {}
async get(key: string) { async get(key: string) {

View File

@@ -12,6 +12,7 @@
"appdata:archive:export": "node scripts/export-appdata-archive.mjs", "appdata:archive:export": "node scripts/export-appdata-archive.mjs",
"appdata:archive:verify": "node scripts/verify-appdata-archive.mjs", "appdata:archive:verify": "node scripts/verify-appdata-archive.mjs",
"appdata:archive:test": "node --test scripts/appdata-archive.test.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: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: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", "deploy:local:down": "docker compose --env-file .env.local-server -f docker-compose.local.yml down",

View 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.
`);
}