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 { 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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { 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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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