fix: code compliance audit and fix across all services
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:
@@ -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 {}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 / SIGINT),NestJS 会依次调用 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 ORM(MySQL),无 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user