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

@@ -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"

View File

@@ -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 {}

View File

@@ -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 } }> {

View File

@@ -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 } }> {

View File

@@ -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());
});

View 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();
}
}

View 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(", "));
}
}

View File

@@ -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 } }> {

View File

@@ -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;
}

View File

@@ -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"})`,

View File

@@ -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 } }> {