fix: code compliance audit and fix across all services
Some checks failed
CI / quality-ts (push) Failing after 48s
CI / quality-go (push) Failing after 4s
CI / quality-proto (push) Failing after 2s
CI / deploy (push) Has been skipped

NestJS (6 services): implement @RequirePermission decorator with
SetMetadata+Reflector, register APP_GUARD globally, fix as assertions
to type guards, add explicit return types, fix import type for express,
fix /metrics implicit any, replace native Error with ApplicationError,
remove typeorm remnants, register LifecycleService.

teacher-bff: add logger, ApplicationError, GlobalErrorFilter, forward
real userId to downstream, log downstream failures, migrate health
controller to shared/health.

Go (2 services): interface to any, doc comments, CORS dev whitelist,
JWT secret fail-fast, push-gateway internal API auth, metrics and
readyz endpoints, remove dead code.

Python (2 services): lifespan return type, dev_mode to bool, data-ana
APIRouter, ai POST body model, ClickHouse async wrapping.
This commit is contained in:
SpecialX
2026-07-09 17:28:27 +08:00
parent b53a486c6e
commit 0a71b02e04
93 changed files with 5775 additions and 608 deletions

View File

@@ -1,8 +1,15 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ClassesModule } from "./classes/classes.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
imports: [ClassesModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
})
export class AppModule {}

View File

@@ -7,11 +7,15 @@ import {
Post,
Put,
Req,
} from '@nestjs/common';
import { ClassesService } from './classes.service.js';
import { createClassSchema, updateClassSchema } from './classes.dto.js';
import type { AuthenticatedRequest } from '../middleware/auth.middleware.js';
import type { Class } from './classes.schema.js';
} from "@nestjs/common";
import { ClassesService } from "./classes.service.js";
import { createClassSchema, updateClassSchema } from "./classes.dto.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import type { Class } from "./classes.schema.js";
interface ClassResponse {
id: string;
@@ -28,11 +32,12 @@ interface SuccessResponse<T> {
data: T;
}
@Controller('classes')
@Controller("classes")
export class ClassesController {
constructor(private readonly service: ClassesService) {}
@Post()
@RequirePermission(Permissions.CLASSES_CREATE)
async create(@Body() body: unknown): Promise<SuccessResponse<ClassResponse>> {
const dto = createClassSchema.parse(body);
const result = await this.service.create(dto);
@@ -40,21 +45,32 @@ export class ClassesController {
}
@Get()
async list(@Req() req: AuthenticatedRequest): Promise<SuccessResponse<ClassResponse[]>> {
const gradeId = req.query.gradeId as string | undefined;
@RequirePermission(Permissions.CLASSES_READ)
async list(
@Req() req: AuthenticatedRequest,
): Promise<SuccessResponse<ClassResponse[]>> {
const gradeIdRaw = req.query.gradeId;
const gradeId = typeof gradeIdRaw === "string" ? gradeIdRaw : undefined;
const result = await this.service.list(gradeId);
return { success: true as const, data: result.map((c) => this.toResponse(c)) };
return {
success: true as const,
data: result.map((c) => this.toResponse(c)),
};
}
@Get(':id')
async getById(@Param('id') id: string): Promise<SuccessResponse<ClassResponse>> {
@Get(":id")
@RequirePermission(Permissions.CLASSES_READ)
async getById(
@Param("id") id: string,
): Promise<SuccessResponse<ClassResponse>> {
const result = await this.service.getById(id);
return { success: true as const, data: this.toResponse(result) };
}
@Put(':id')
@Put(":id")
@RequirePermission(Permissions.CLASSES_UPDATE)
async update(
@Param('id') id: string,
@Param("id") id: string,
@Body() body: unknown,
): Promise<SuccessResponse<ClassResponse>> {
const dto = updateClassSchema.parse(body);
@@ -62,8 +78,9 @@ export class ClassesController {
return { success: true as const, data: this.toResponse(result) };
}
@Delete(':id')
async delete(@Param('id') id: string): Promise<{ success: true }> {
@Delete(":id")
@RequirePermission(Permissions.CLASSES_DELETE)
async delete(@Param("id") id: string): Promise<{ success: true }> {
await this.service.delete(id);
return { success: true as const };
}

View File

@@ -1,14 +1,18 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../config/database.js';
import { classes, type Class, type NewClass } from './classes.schema.js';
import { eq } from "drizzle-orm";
import { getDb } from "../config/database.js";
import { classes, type Class, type NewClass } from "./classes.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js";
export class ClassesRepository {
async create(data: NewClass): Promise<Class> {
const db = getDb();
await db.insert(classes).values(data);
const [result] = await db.select().from(classes).where(eq(classes.id, data.id));
const [result] = await db
.select()
.from(classes)
.where(eq(classes.id, data.id));
if (!result) {
throw new Error('Failed to read created class');
throw new DatabaseError("Failed to read created class");
}
return result;
}
@@ -27,7 +31,10 @@ export class ClassesRepository {
return db.select().from(classes);
}
async update(id: string, data: Partial<NewClass>): Promise<Class | undefined> {
async update(
id: string,
data: Partial<NewClass>,
): Promise<Class | undefined> {
const db = getDb();
await db.update(classes).set(data).where(eq(classes.id, id));
const [result] = await db.select().from(classes).where(eq(classes.id, id));

View File

@@ -1,10 +1,11 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from './env.js';
import { drizzle } from "drizzle-orm/mysql2";
import type { MySql2Database } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null;
export function getDb() {
export function getDb(): MySql2Database {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,

View File

@@ -6,6 +6,7 @@ import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -19,7 +20,7 @@ async function bootstrap(): Promise<void> {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});

View File

@@ -1,5 +1,9 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
@@ -10,15 +14,18 @@ export interface AuthenticatedRequest extends Request {
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
// 从 Gateway 注入的头部读取用户信息
const userId = req.headers['x-user-id'] as string | undefined;
const rolesHeader = req.headers['x-user-roles'] as string | undefined;
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException('Missing x-user-id header');
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(',') : [];
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -1,50 +1,71 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import { PermissionDeniedError } from '../shared/errors/application-error.js';
import type { AuthenticatedRequest } from './auth.middleware.js';
import {
Injectable,
CanActivate,
ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export type Permission =
| 'CLASS_CREATE'
| 'CLASS_READ'
| 'CLASS_UPDATE'
| 'CLASS_DELETE';
"CLASSES_CREATE" | "CLASSES_READ" | "CLASSES_UPDATE" | "CLASSES_DELETE";
export const Permissions = {
CLASS_CREATE: 'CLASS_CREATE' as const,
CLASS_READ: 'CLASS_READ' as const,
CLASS_UPDATE: 'CLASS_UPDATE' as const,
CLASS_DELETE: 'CLASS_DELETE' as const,
CLASSES_CREATE: "CLASSES_CREATE" as const,
CLASSES_READ: "CLASSES_READ" as const,
CLASSES_UPDATE: "CLASSES_UPDATE" as const,
CLASSES_DELETE: "CLASSES_DELETE" as const,
};
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE],
teacher: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE],
student: [Permissions.CLASS_READ],
parent: [Permissions.CLASS_READ],
admin: [
Permissions.CLASSES_CREATE,
Permissions.CLASSES_READ,
Permissions.CLASSES_UPDATE,
Permissions.CLASSES_DELETE,
],
teacher: [
Permissions.CLASSES_CREATE,
Permissions.CLASSES_READ,
Permissions.CLASSES_UPDATE,
],
student: [Permissions.CLASSES_READ],
parent: [Permissions.CLASSES_READ],
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {}
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && perms.includes(this.requiredPermission)) {
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
throw new PermissionDeniedError(this.requiredPermission);
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}
// 工厂函数用于装饰器修复import Reflector 已移至文件顶部)
export function createPermissionGuardFactory(_reflector: Reflector) {
return {
create: (permission: Permission) => new PermissionGuard(permission),
};
}

View File

@@ -1,7 +1,13 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { ZodError } from 'zod';
import { ApplicationError } from './application-error.js';
import {
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
@@ -12,7 +18,9 @@ export class GlobalErrorFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'CLASSES_VALIDATION_ERROR',
message: 'Validation failed',
code: "CLASSES_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'HTTP_ERROR',
code: "HTTP_ERROR",
message,
traceId,
},
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body);
}
private extractHttpMessage(res: string | object, exception: HttpException): string {
if (typeof res === 'string') {
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === 'object' && 'message' in res) {
if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}

View File

@@ -1,9 +1,12 @@
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { Redis } from 'ioredis';
import type { Producer } from 'kafkajs';
import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
const SERVICE_NAME = 'classes';
const SERVICE_NAME = "classes";
/**
* 优雅停机服务。
@@ -12,52 +15,31 @@ const SERVICE_NAME = 'classes';
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
*
* 关闭顺序Kafka producer → Redis → DataSource
* 先停外部消息生产避免新事件,再关缓存,最后关 DB
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
*
* 依赖注入 token 约定(需与各服务 provider 注册一致):
* - DataSource由 `TypeOrmModule.forRoot()` 提供。
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
* Classes 服务仅使用 Drizzle ORMMySQL无 Kafka / Redis 依赖
* 关闭时仅需关闭数据库连接池
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name);
constructor(
private readonly dataSource: DataSource,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
) {}
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
);
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
await this.safeDisconnect('redis', () => this.redis.quit());
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
try {
await closeDb();
this.logger.log("database connection closed");
} catch (error) {
this.logger.error(
`database close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
}
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
this.logger.log(`${name} closed`);
} catch (error) {
this.logger.error(
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}