diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 6620393..ac73119 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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, ], diff --git a/apps/server/src/common/auth/current-user.decorator.ts b/apps/server/src/common/auth/current-user.decorator.ts index 041726d..d54e17b 100644 --- a/apps/server/src/common/auth/current-user.decorator.ts +++ b/apps/server/src/common/auth/current-user.decorator.ts @@ -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(); return request.currentUser ?? null; }, diff --git a/apps/server/src/modules/consistency/consistency.controller.spec.ts b/apps/server/src/modules/consistency/consistency.controller.spec.ts new file mode 100644 index 0000000..cf47c82 --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.controller.spec.ts @@ -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(); + }); +}); diff --git a/apps/server/src/modules/consistency/consistency.controller.ts b/apps/server/src/modules/consistency/consistency.controller.ts new file mode 100644 index 0000000..e1744de --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.controller.ts @@ -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(); + } +} diff --git a/apps/server/src/modules/consistency/consistency.module.ts b/apps/server/src/modules/consistency/consistency.module.ts new file mode 100644 index 0000000..100e571 --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.module.ts @@ -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 {} diff --git a/apps/server/src/modules/consistency/consistency.service.spec.ts b/apps/server/src/modules/consistency/consistency.service.spec.ts new file mode 100644 index 0000000..60aa074 --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.service.spec.ts @@ -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, + }), + ]), + ); + }); +}); diff --git a/apps/server/src/modules/consistency/consistency.service.ts b/apps/server/src/modules/consistency/consistency.service.ts new file mode 100644 index 0000000..dca6a15 --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.service.ts @@ -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; + 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 { + 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 { + 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 { + const rows = await this.prisma.$queryRawUnsafe>(sql); + return Number(rows[0]?.count ?? 0); + } +} + +interface ConsistencyCheck { + id: string; + label: string; + severityWhenNonZero: Exclude; + sql: string; +} + +function check( + id: string, + label: string, + severityWhenNonZero: Exclude, + sql: string, +): ConsistencyCheck { + return { id, label, severityWhenNonZero, sql }; +} + +function buildHumanSummary( + status: ConsistencyStatus, + errors: number, + warnings: number, + counts: Record, +) { + return `V2.5 consistency ${status}: ${errors} error(s), ${warnings} warning(s), ${counts.auditEvents ?? 0} audit event(s).`; +} diff --git a/apps/server/src/modules/data/data.service.ts b/apps/server/src/modules/data/data.service.ts index 48830db..1bb8ceb 100644 --- a/apps/server/src/modules/data/data.service.ts +++ b/apps/server/src/modules/data/data.service.ts @@ -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) { diff --git a/package.json b/package.json index d54addf..16f92e5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-v25-consistency.mjs b/scripts/check-v25-consistency.mjs new file mode 100644 index 0000000..348b2a1 --- /dev/null +++ b/scripts/check-v25-consistency.mjs @@ -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 Consistency endpoint URL. Defaults to ${DEFAULT_URL}. + --json Print raw JSON response. + -h, --help Show this help. +`); +}