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:
@@ -32,6 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { NotificationsModule } from "./notifications/notifications.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: [NotificationsModule, HealthModule],
|
||||
providers: [LifecycleService],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
LifecycleService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import { Client } from "@elastic/elasticsearch";
|
||||
import { env } from "./env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
|
||||
/**
|
||||
* Elasticsearch 客户端。
|
||||
*
|
||||
* 降级模式:当 ES_URL 未设置或连接失败时,esClient 为 null,
|
||||
* 所有 ES 操作通过 safeIndex / safeSearch 自动跳过,服务仍可启动。
|
||||
*/
|
||||
export const esClient: Client | null = env.ES_URL
|
||||
? new Client({ node: env.ES_URL })
|
||||
: null;
|
||||
|
||||
/**
|
||||
* 探活 ES 连接。失败仅记录日志,不抛错(降级模式)。
|
||||
*/
|
||||
export async function checkEsConnection(): Promise<void> {
|
||||
if (!esClient) {
|
||||
console.log("Elasticsearch disabled (ES_URL not set)");
|
||||
logger.info("Elasticsearch disabled (ES_URL not set)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await esClient.ping();
|
||||
console.log("Elasticsearch connected");
|
||||
logger.info("Elasticsearch connected");
|
||||
} catch (err) {
|
||||
console.error("Elasticsearch connection failed:", err);
|
||||
logger.error({ err }, "Elasticsearch connection failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 ES 客户端连接。
|
||||
*/
|
||||
export async function closeEs(): Promise<void> {
|
||||
if (esClient) {
|
||||
await esClient.close();
|
||||
@@ -51,23 +40,20 @@ export interface SearchResult {
|
||||
hits: SearchHit[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全索引文档。client 为 null 或失败时跳过(降级模式),返回是否成功。
|
||||
*/
|
||||
export async function safeIndex(params: IndexParams): Promise<boolean> {
|
||||
if (!esClient) return false;
|
||||
try {
|
||||
await esClient.index(params);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Elasticsearch index failed:", err);
|
||||
logger.error(
|
||||
{ err, index: params.index, id: params.id },
|
||||
"Elasticsearch index failed",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全搜索。client 为 null 或失败时返回空数组(降级模式)。
|
||||
*/
|
||||
export async function safeSearch(
|
||||
index: string,
|
||||
query: Record<string, unknown>,
|
||||
@@ -75,13 +61,25 @@ export async function safeSearch(
|
||||
if (!esClient) return { hits: [] };
|
||||
try {
|
||||
const result = await esClient.search({ index, query });
|
||||
const hits = (result.hits.hits as unknown[]).map((raw) => {
|
||||
const hit = raw as { _id: string; _source: Record<string, unknown> };
|
||||
return { _id: hit._id, _source: hit._source };
|
||||
});
|
||||
const hits: SearchHit[] = [];
|
||||
for (const raw of result.hits.hits) {
|
||||
const source = raw._source;
|
||||
if (
|
||||
raw._id &&
|
||||
source &&
|
||||
typeof source === "object" &&
|
||||
!Array.isArray(source)
|
||||
) {
|
||||
// source 已收窄为 object,但 object 无索引签名,需断言为 Record<string, unknown>
|
||||
hits.push({
|
||||
_id: raw._id,
|
||||
_source: source as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { hits };
|
||||
} catch (err) {
|
||||
console.error("Elasticsearch search failed:", err);
|
||||
logger.error({ err, index }, "Elasticsearch search failed");
|
||||
return { hits: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { logger } from "./shared/observability/logger.js";
|
||||
import { checkEsConnection, closeEs } from "./config/elasticsearch.js";
|
||||
import { closeDb } from "./config/database.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
@@ -19,13 +20,11 @@ async function bootstrap(): Promise<void> {
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
|
||||
// 无全局路由前缀:Gateway 会去掉 /api/v1,再转发至本服务。
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
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/msg/src/middleware/auth.middleware.ts
Normal file
30
services/msg/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();
|
||||
}
|
||||
}
|
||||
66
services/msg/src/middleware/permission.guard.ts
Normal file
66
services/msg/src/middleware/permission.guard.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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 = {
|
||||
MSG_NOTIFICATION_SEND: "MSG_NOTIFICATION_SEND" as const,
|
||||
MSG_NOTIFICATION_READ: "MSG_NOTIFICATION_READ" as const,
|
||||
MSG_NOTIFICATION_MANAGE: "MSG_NOTIFICATION_MANAGE" 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.MSG_NOTIFICATION_SEND,
|
||||
Permissions.MSG_NOTIFICATION_READ,
|
||||
Permissions.MSG_NOTIFICATION_MANAGE,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.MSG_NOTIFICATION_SEND,
|
||||
Permissions.MSG_NOTIFICATION_READ,
|
||||
],
|
||||
student: [Permissions.MSG_NOTIFICATION_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(", "));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
@@ -11,42 +9,42 @@ import {
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service.js";
|
||||
import type { SendNotificationDto } from "./notifications.service.js";
|
||||
|
||||
interface AuthedRequest {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头读取用户身份。
|
||||
* Gateway 在通过鉴权后会注入 x-user-id;如未注入(开发模式或匿名请求)返回 null。
|
||||
*/
|
||||
function getUserIdFromRequest(req: AuthedRequest): string | null {
|
||||
const raw = req.headers["x-user-id"];
|
||||
if (typeof raw === "string" && raw.length > 0) return raw;
|
||||
return null;
|
||||
}
|
||||
import {
|
||||
sendNotificationSchema,
|
||||
sendNotificationBatchSchema,
|
||||
} from "./notifications.dto.js";
|
||||
import type { SendNotificationDto } from "./notifications.dto.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import { PermissionDeniedError } from "../shared/errors/application-error.js";
|
||||
|
||||
@Controller("notifications")
|
||||
export class NotificationsController {
|
||||
constructor(private readonly service: NotificationsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
|
||||
async send(@Body() body: unknown): Promise<{ success: true; data: unknown }> {
|
||||
const result = await this.service.send(body as SendNotificationDto);
|
||||
const dto: SendNotificationDto = sendNotificationSchema.parse(body);
|
||||
const result = await this.service.send(dto);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Post("batch")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
|
||||
async createBatch(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dtos = (body as SendNotificationDto[]) ?? [];
|
||||
const dtos: SendNotificationDto[] = sendNotificationBatchSchema.parse(body);
|
||||
const result = await this.service.createBatch(dtos);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get("user/:userId")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async listByUser(
|
||||
@Param("userId") userId: string,
|
||||
@Query("unread") unread: string,
|
||||
@@ -56,6 +54,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Get("user/:userId/page")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async listByUserPaginated(
|
||||
@Param("userId") userId: string,
|
||||
@Query("page") page: string,
|
||||
@@ -72,29 +71,22 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Put(":id/read")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async markAsRead(@Param("id") id: string): Promise<{ success: true }> {
|
||||
await this.service.markAsRead(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async search(
|
||||
@Req() req: AuthedRequest,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("q") q: string,
|
||||
@Query("userId") userIdParam: string,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const userId = getUserIdFromRequest(req) ?? userIdParam;
|
||||
const userId = req.userId ?? userIdParam;
|
||||
if (!userId) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: "MSG_PERMISSION_DENIED",
|
||||
message: "Missing user identity",
|
||||
},
|
||||
},
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
throw new PermissionDeniedError("MSG_NOTIFICATION_READ");
|
||||
}
|
||||
const result = await this.service.search(userId, q);
|
||||
return { success: true, data: result };
|
||||
|
||||
14
services/msg/src/notifications/notifications.dto.ts
Normal file
14
services/msg/src/notifications/notifications.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const sendNotificationSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
type: z.string().min(1).max(50),
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1),
|
||||
channel: z.string().max(20).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const sendNotificationBatchSchema = z.array(sendNotificationSchema);
|
||||
|
||||
export type SendNotificationDto = z.infer<typeof sendNotificationSchema>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { db } from "../config/database.js";
|
||||
import {
|
||||
notifications,
|
||||
@@ -10,19 +10,11 @@ import type {
|
||||
Notification,
|
||||
NotificationPreference,
|
||||
} from "./notifications.schema.js";
|
||||
import type { SendNotificationDto } from "./notifications.dto.js";
|
||||
import { safeIndex, safeSearch } from "../config/elasticsearch.js";
|
||||
import { env } from "../config/env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
|
||||
export interface SendNotificationDto {
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
channel?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SendResult {
|
||||
id: string;
|
||||
skipped: boolean;
|
||||
@@ -39,7 +31,7 @@ export interface PaginatedResult {
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
async send(dto: SendNotificationDto): Promise<SendResult> {
|
||||
const id = uuidv4();
|
||||
const id = randomUUID();
|
||||
const channel = dto.channel ?? "in_app";
|
||||
|
||||
const [pref] = await db
|
||||
|
||||
@@ -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 实例,
|
||||
// 但 msg 服务未引入 @types/express,此处按 core-edu / content 模式不显式标注类型。
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user