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:
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { TextbooksModule } from "./textbooks/textbooks.module.js";
|
||||
import { ChaptersModule } from "./chapters/chapters.module.js";
|
||||
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
|
||||
import { QuestionsModule } from "./questions/questions.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: [
|
||||
@@ -13,5 +16,9 @@ import { HealthModule } from "./shared/health/health.module.js";
|
||||
QuestionsModule,
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
LifecycleService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
type UpdateChapterInput,
|
||||
} from "./chapters.service.js";
|
||||
import type { Chapter } from "./chapters.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
|
||||
@Controller("chapters")
|
||||
export class ChaptersController {
|
||||
constructor(private readonly service: ChaptersService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_CHAPTER_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateChapterInput,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
@@ -27,6 +32,7 @@ export class ChaptersController {
|
||||
}
|
||||
|
||||
@Get("textbook/:textbookId")
|
||||
@RequirePermission(Permissions.CONTENT_CHAPTER_READ)
|
||||
async listByTextbook(
|
||||
@Param("textbookId") textbookId: string,
|
||||
): Promise<{ success: true; data: Chapter[] }> {
|
||||
@@ -35,6 +41,7 @@ export class ChaptersController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.CONTENT_CHAPTER_READ)
|
||||
async getById(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: Chapter }> {
|
||||
@@ -43,6 +50,7 @@ export class ChaptersController {
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.CONTENT_CHAPTER_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateChapterInput,
|
||||
@@ -52,6 +60,7 @@ export class ChaptersController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.CONTENT_CHAPTER_DELETE)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
|
||||
@@ -14,12 +14,17 @@ import {
|
||||
type PrerequisiteNode,
|
||||
} from "./knowledge-points.service.js";
|
||||
import type { KnowledgePoint } from "./knowledge-points.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
|
||||
@Controller("knowledge-points")
|
||||
export class KnowledgePointsController {
|
||||
constructor(private readonly service: KnowledgePointsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateKnowledgePointInput,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
@@ -28,6 +33,7 @@ export class KnowledgePointsController {
|
||||
}
|
||||
|
||||
@Get("chapter/:chapterId")
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
|
||||
async listByChapter(
|
||||
@Param("chapterId") chapterId: string,
|
||||
): Promise<{ success: true; data: KnowledgePoint[] }> {
|
||||
@@ -36,6 +42,7 @@ export class KnowledgePointsController {
|
||||
}
|
||||
|
||||
@Get(":id/prerequisites")
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
|
||||
async getPrerequisites(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: PrerequisiteNode[] }> {
|
||||
@@ -44,6 +51,7 @@ export class KnowledgePointsController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
|
||||
async getById(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: KnowledgePoint }> {
|
||||
@@ -52,6 +60,7 @@ export class KnowledgePointsController {
|
||||
}
|
||||
|
||||
@Post(":id/prerequisites/:prerequisiteId")
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
|
||||
async addPrerequisite(
|
||||
@Param("id") id: string,
|
||||
@Param("prerequisiteId") prerequisiteId: string,
|
||||
@@ -61,6 +70,7 @@ export class KnowledgePointsController {
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateKnowledgePointInput,
|
||||
@@ -70,6 +80,7 @@ export class KnowledgePointsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_DELETE)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { closeDb } from "./config/database.js";
|
||||
import { closeNeo4j } from "./config/neo4j.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();
|
||||
@@ -20,8 +21,7 @@ async function bootstrap(): Promise<void> {
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// 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());
|
||||
});
|
||||
|
||||
30
services/content/src/middleware/auth.middleware.ts
Normal file
30
services/content/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
userRoles?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
|
||||
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");
|
||||
}
|
||||
|
||||
req.userId = userId;
|
||||
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
|
||||
next();
|
||||
}
|
||||
}
|
||||
105
services/content/src/middleware/permission.guard.ts
Normal file
105
services/content/src/middleware/permission.guard.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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 const Permissions = {
|
||||
CONTENT_TEXTBOOK_CREATE: "CONTENT_TEXTBOOK_CREATE" as const,
|
||||
CONTENT_TEXTBOOK_READ: "CONTENT_TEXTBOOK_READ" as const,
|
||||
CONTENT_TEXTBOOK_UPDATE: "CONTENT_TEXTBOOK_UPDATE" as const,
|
||||
CONTENT_TEXTBOOK_DELETE: "CONTENT_TEXTBOOK_DELETE" as const,
|
||||
CONTENT_CHAPTER_CREATE: "CONTENT_CHAPTER_CREATE" as const,
|
||||
CONTENT_CHAPTER_READ: "CONTENT_CHAPTER_READ" as const,
|
||||
CONTENT_CHAPTER_UPDATE: "CONTENT_CHAPTER_UPDATE" as const,
|
||||
CONTENT_CHAPTER_DELETE: "CONTENT_CHAPTER_DELETE" as const,
|
||||
CONTENT_QUESTION_CREATE: "CONTENT_QUESTION_CREATE" as const,
|
||||
CONTENT_QUESTION_READ: "CONTENT_QUESTION_READ" as const,
|
||||
CONTENT_QUESTION_UPDATE: "CONTENT_QUESTION_UPDATE" as const,
|
||||
CONTENT_QUESTION_DELETE: "CONTENT_QUESTION_DELETE" as const,
|
||||
CONTENT_KNOWLEDGE_POINT_CREATE: "CONTENT_KNOWLEDGE_POINT_CREATE" as const,
|
||||
CONTENT_KNOWLEDGE_POINT_READ: "CONTENT_KNOWLEDGE_POINT_READ" as const,
|
||||
CONTENT_KNOWLEDGE_POINT_UPDATE: "CONTENT_KNOWLEDGE_POINT_UPDATE" as const,
|
||||
CONTENT_KNOWLEDGE_POINT_DELETE: "CONTENT_KNOWLEDGE_POINT_DELETE" as const,
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions];
|
||||
|
||||
export const PERMISSIONS_KEY = "permissions";
|
||||
export const RequirePermission = (...permissions: Permission[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
Permissions.CONTENT_TEXTBOOK_CREATE,
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
Permissions.CONTENT_TEXTBOOK_UPDATE,
|
||||
Permissions.CONTENT_TEXTBOOK_DELETE,
|
||||
Permissions.CONTENT_CHAPTER_CREATE,
|
||||
Permissions.CONTENT_CHAPTER_READ,
|
||||
Permissions.CONTENT_CHAPTER_UPDATE,
|
||||
Permissions.CONTENT_CHAPTER_DELETE,
|
||||
Permissions.CONTENT_QUESTION_CREATE,
|
||||
Permissions.CONTENT_QUESTION_READ,
|
||||
Permissions.CONTENT_QUESTION_UPDATE,
|
||||
Permissions.CONTENT_QUESTION_DELETE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_DELETE,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
Permissions.CONTENT_CHAPTER_CREATE,
|
||||
Permissions.CONTENT_CHAPTER_READ,
|
||||
Permissions.CONTENT_CHAPTER_UPDATE,
|
||||
Permissions.CONTENT_QUESTION_CREATE,
|
||||
Permissions.CONTENT_QUESTION_READ,
|
||||
Permissions.CONTENT_QUESTION_UPDATE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
|
||||
],
|
||||
student: [
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
Permissions.CONTENT_CHAPTER_READ,
|
||||
Permissions.CONTENT_QUESTION_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
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 && requiredPermissions.some((p) => perms.includes(p))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PermissionDeniedError(requiredPermissions.join(", "));
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
type UpdateQuestionInput,
|
||||
} from "./questions.service.js";
|
||||
import type { Question } from "./questions.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
|
||||
@Controller("questions")
|
||||
export class QuestionsController {
|
||||
constructor(private readonly service: QuestionsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateQuestionInput,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
@@ -27,6 +32,7 @@ export class QuestionsController {
|
||||
}
|
||||
|
||||
@Get("knowledge-point/:knowledgePointId")
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
|
||||
async listByKnowledgePoint(
|
||||
@Param("knowledgePointId") knowledgePointId: string,
|
||||
): Promise<{ success: true; data: Question[] }> {
|
||||
@@ -35,6 +41,7 @@ export class QuestionsController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
|
||||
async getById(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: Question }> {
|
||||
@@ -43,6 +50,7 @@ export class QuestionsController {
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateQuestionInput,
|
||||
@@ -52,6 +60,7 @@ export class QuestionsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_DELETE)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HttpException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import { ApplicationError } from "./application-error.js";
|
||||
|
||||
@@ -14,13 +15,12 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例,
|
||||
// 但 content 服务未引入 @types/express,此处按 core-edu 模式不显式标注类型。
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const traceIdHeader = request.headers["x-request-id"];
|
||||
const traceId =
|
||||
(request.headers["x-request-id"] as string | undefined) ?? "unknown";
|
||||
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
|
||||
|
||||
let statusCode = 500;
|
||||
let body: Record<string, unknown>;
|
||||
@@ -30,7 +30,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
statusCode = exception.statusCode;
|
||||
body = exception.toJSON();
|
||||
} else if (exception instanceof ZodError) {
|
||||
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
|
||||
statusCode = 400;
|
||||
body = {
|
||||
success: false,
|
||||
@@ -79,7 +78,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
return 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;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationShutdown,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { closeDb } from "../../config/database.js";
|
||||
import { closeNeo4j } from "../../config/neo4j.js";
|
||||
|
||||
const SERVICE_NAME = "content";
|
||||
|
||||
@Injectable()
|
||||
export class LifecycleService {
|
||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(LifecycleService.name);
|
||||
|
||||
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"})`,
|
||||
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
type UpdateTextbookInput,
|
||||
} from "./textbooks.service.js";
|
||||
import type { Textbook } from "./textbooks.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
|
||||
@Controller("textbooks")
|
||||
export class TextbooksController {
|
||||
constructor(private readonly service: TextbooksService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateTextbookInput,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
@@ -27,12 +32,14 @@ export class TextbooksController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
|
||||
async list(): Promise<{ success: true; data: Textbook[] }> {
|
||||
const data = await this.service.list();
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
|
||||
async getById(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: Textbook }> {
|
||||
@@ -41,6 +48,7 @@ export class TextbooksController {
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateTextbookInput,
|
||||
@@ -50,6 +58,7 @@ export class TextbooksController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_DELETE)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
|
||||
Reference in New Issue
Block a user