feat(p1): complete P1 foundation stage
- monorepo: pnpm workspace + go.work + pyproject.toml + commitlint/husky - infra: docker-compose (minimal + full profiles) + init-sql + prometheus - arch-scan: multi-language scanner skeleton (TS/Go/Python/Proto) - shared-proto: buf v2 + classes.proto (ClassService CRUD contract) - api-gateway: Go/Gin + JWT HS256 auth + reverse proxy + request ID - classes: NestJS golden template (error system + observability + middleware + CRUD + tests) - teacher-portal: Next.js + paper-feel UI design system - CI/CD: 4 workflows (go/ts/py/proto) - docs: migration guide + project_rules + coding-standards + git-workflow + ui-design-system + 004 + 9 module READMEs + known-issues + spec/plan migration + roadmap
This commit is contained in:
7
services/classes/src/app.module.ts
Normal file
7
services/classes/src/app.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClassesModule } from './classes/classes.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [ClassesModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
83
services/classes/src/classes/classes.controller.ts
Normal file
83
services/classes/src/classes/classes.controller.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
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';
|
||||
|
||||
interface ClassResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
gradeId: string;
|
||||
headTeacherId: string | null;
|
||||
description: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@Controller('classes')
|
||||
export class ClassesController {
|
||||
constructor(private readonly service: ClassesService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() body: unknown): Promise<SuccessResponse<ClassResponse>> {
|
||||
const dto = createClassSchema.parse(body);
|
||||
const result = await this.service.create(dto);
|
||||
return { success: true as const, data: this.toResponse(result) };
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@Req() req: AuthenticatedRequest): Promise<SuccessResponse<ClassResponse[]>> {
|
||||
const gradeId = req.query.gradeId as string | undefined;
|
||||
const result = await this.service.list(gradeId);
|
||||
return { success: true as const, data: result.map((c) => this.toResponse(c)) };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
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')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<SuccessResponse<ClassResponse>> {
|
||||
const dto = updateClassSchema.parse(body);
|
||||
const result = await this.service.update(id, dto);
|
||||
return { success: true as const, data: this.toResponse(result) };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string): Promise<{ success: true }> {
|
||||
await this.service.delete(id);
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
// 修复 #2: 消除 any,使用 Class 类型;drizzle timestamp 返回 Date,统一转毫秒数(epoch ms)
|
||||
private toResponse(c: Class): ClassResponse {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
gradeId: c.gradeId,
|
||||
headTeacherId: c.headTeacherId ?? null,
|
||||
description: c.description ?? null,
|
||||
createdAt: c.createdAt.getTime(),
|
||||
updatedAt: c.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
services/classes/src/classes/classes.dto.ts
Normal file
17
services/classes/src/classes/classes.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createClassSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
gradeId: z.string().uuid(),
|
||||
headTeacherId: z.string().uuid().optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const updateClassSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
headTeacherId: z.string().uuid().optional().nullable(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateClassDto = z.infer<typeof createClassSchema>;
|
||||
export type UpdateClassDto = z.infer<typeof updateClassSchema>;
|
||||
16
services/classes/src/classes/classes.module.ts
Normal file
16
services/classes/src/classes/classes.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClassesController } from './classes.controller.js';
|
||||
import { ClassesService } from './classes.service.js';
|
||||
import { ClassesRepository } from './classes.repository.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ClassesController],
|
||||
providers: [
|
||||
ClassesService,
|
||||
{
|
||||
provide: ClassesRepository,
|
||||
useFactory: () => new ClassesRepository(),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ClassesModule {}
|
||||
41
services/classes/src/classes/classes.repository.ts
Normal file
41
services/classes/src/classes/classes.repository.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { classes, type Class, type NewClass } from './classes.schema.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));
|
||||
if (!result) {
|
||||
throw new Error('Failed to read created class');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Class | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db.select().from(classes).where(eq(classes.id, id));
|
||||
return result;
|
||||
}
|
||||
|
||||
async list(gradeId?: string): Promise<Class[]> {
|
||||
const db = getDb();
|
||||
if (gradeId) {
|
||||
return db.select().from(classes).where(eq(classes.gradeId, gradeId));
|
||||
}
|
||||
return db.select().from(classes);
|
||||
}
|
||||
|
||||
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));
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db.delete(classes).where(eq(classes.id, id));
|
||||
}
|
||||
}
|
||||
14
services/classes/src/classes/classes.schema.ts
Normal file
14
services/classes/src/classes/classes.schema.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { mysqlTable, varchar, text, timestamp, char } from 'drizzle-orm/mysql-core';
|
||||
|
||||
export const classes = mysqlTable('classes', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
gradeId: char('grade_id', { length: 36 }).notNull(),
|
||||
headTeacherId: char('head_teacher_id', { length: 36 }),
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export type Class = typeof classes.$inferSelect;
|
||||
export type NewClass = typeof classes.$inferInsert;
|
||||
48
services/classes/src/classes/classes.service.ts
Normal file
48
services/classes/src/classes/classes.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ClassesRepository } from './classes.repository.js';
|
||||
import { ValidationError, NotFoundError } from '../shared/errors/application-error.js';
|
||||
import type { CreateClassDto, UpdateClassDto } from './classes.dto.js';
|
||||
import type { Class, NewClass } from './classes.schema.js';
|
||||
|
||||
export class ClassesService {
|
||||
constructor(private readonly repository: ClassesRepository) {}
|
||||
|
||||
async create(dto: CreateClassDto): Promise<Class> {
|
||||
const newClass: NewClass = {
|
||||
id: uuidv4(),
|
||||
...dto,
|
||||
};
|
||||
return this.repository.create(newClass);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Class> {
|
||||
const result = await this.repository.findById(id);
|
||||
if (!result) {
|
||||
throw new NotFoundError('Class', id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async list(gradeId?: string): Promise<Class[]> {
|
||||
return this.repository.list(gradeId);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateClassDto): Promise<Class> {
|
||||
if (Object.keys(dto).length === 0) {
|
||||
throw new ValidationError('No fields to update');
|
||||
}
|
||||
const result = await this.repository.update(id, dto);
|
||||
if (!result) {
|
||||
throw new NotFoundError('Class', id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundError('Class', id);
|
||||
}
|
||||
await this.repository.delete(id);
|
||||
}
|
||||
}
|
||||
24
services/classes/src/config/database.ts
Normal file
24
services/classes/src/config/database.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { env } from './env.js';
|
||||
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
export function getDb() {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
uri: env.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
}
|
||||
return drizzle(pool);
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
25
services/classes/src/config/env.ts
Normal file
25
services/classes/src/config/env.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('3001'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
JWT_SECRET: z.string(),
|
||||
JWT_ISSUER: z.string().default('next-edu-cloud'),
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment configuration');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const env = loadEnv();
|
||||
31
services/classes/src/main.ts
Normal file
31
services/classes/src/main.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
||||
import { env } from './config/env.js';
|
||||
import { logger } from './shared/observability/logger.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['log', 'error', 'warn'],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, 'Classes service started');
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await app.close();
|
||||
await shutdownTracer();
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
logger.error({ err }, 'Failed to start classes service');
|
||||
process.exit(1);
|
||||
});
|
||||
24
services/classes/src/middleware/auth.middleware.ts
Normal file
24
services/classes/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||
import { 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 {
|
||||
// 从 Gateway 注入的头部读取用户信息
|
||||
const userId = req.headers['x-user-id'] as string | undefined;
|
||||
const rolesHeader = req.headers['x-user-roles'] as string | undefined;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Missing x-user-id header');
|
||||
}
|
||||
|
||||
req.userId = userId;
|
||||
req.userRoles = rolesHeader ? rolesHeader.split(',') : [];
|
||||
next();
|
||||
}
|
||||
}
|
||||
50
services/classes/src/middleware/permission.guard.ts
Normal file
50
services/classes/src/middleware/permission.guard.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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';
|
||||
|
||||
export type Permission =
|
||||
| 'CLASS_CREATE'
|
||||
| 'CLASS_READ'
|
||||
| 'CLASS_UPDATE'
|
||||
| 'CLASS_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,
|
||||
};
|
||||
|
||||
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],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private readonly requiredPermission: Permission) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PermissionDeniedError(this.requiredPermission);
|
||||
}
|
||||
}
|
||||
|
||||
// 工厂函数,用于装饰器(修复:import Reflector 已移至文件顶部)
|
||||
export function createPermissionGuardFactory(_reflector: Reflector) {
|
||||
return {
|
||||
create: (permission: Permission) => new PermissionGuard(permission),
|
||||
};
|
||||
}
|
||||
96
services/classes/src/shared/errors/application-error.ts
Normal file
96
services/classes/src/shared/errors/application-error.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type ErrorType =
|
||||
| 'validation'
|
||||
| 'not_found'
|
||||
| 'permission_denied'
|
||||
| 'conflict'
|
||||
| 'business'
|
||||
| 'database'
|
||||
| 'internal';
|
||||
|
||||
export interface ErrorDetails {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export abstract class ApplicationError extends Error {
|
||||
abstract readonly type: ErrorType;
|
||||
abstract readonly statusCode: number;
|
||||
readonly code: string;
|
||||
readonly details?: ErrorDetails;
|
||||
// FIX #1: traceId 改为可写,以便 GlobalErrorFilter 注入请求级 traceId
|
||||
traceId?: string;
|
||||
|
||||
constructor(message: string, code: string, details?: ErrorDetails) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
details: this.details,
|
||||
traceId: this.traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends ApplicationError {
|
||||
readonly type = 'validation' as const;
|
||||
readonly statusCode = 400;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CLASSES_VALIDATION_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApplicationError {
|
||||
readonly type = 'not_found' as const;
|
||||
readonly statusCode = 404;
|
||||
constructor(resource: string, id: string) {
|
||||
super(`${resource} not found: ${id}`, 'CLASSES_NOT_FOUND', { resource, id });
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionDeniedError extends ApplicationError {
|
||||
readonly type = 'permission_denied' as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`, 'CLASSES_PERMISSION_DENIED', { permission });
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends ApplicationError {
|
||||
readonly type = 'conflict' as const;
|
||||
readonly statusCode = 409;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CLASSES_CONFLICT', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class BusinessError extends ApplicationError {
|
||||
readonly type = 'business' as const;
|
||||
readonly statusCode = 422;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CLASSES_BUSINESS_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends ApplicationError {
|
||||
readonly type = 'database' as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CLASSES_DATABASE_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends ApplicationError {
|
||||
readonly type = 'internal' as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CLASSES_INTERNAL_ERROR', details);
|
||||
}
|
||||
}
|
||||
77
services/classes/src/shared/errors/global-error.filter.ts
Normal file
77
services/classes/src/shared/errors/global-error.filter.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import { ApplicationError } from './application-error.js';
|
||||
|
||||
@Catch()
|
||||
export class GlobalErrorFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalErrorFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
|
||||
|
||||
let statusCode = 500;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (exception instanceof ApplicationError) {
|
||||
exception.traceId = traceId;
|
||||
statusCode = exception.statusCode;
|
||||
body = exception.toJSON();
|
||||
} else if (exception instanceof ZodError) {
|
||||
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
|
||||
statusCode = 400;
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'CLASSES_VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
details: exception.flatten(),
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else if (exception instanceof HttpException) {
|
||||
statusCode = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
const message = this.extractHttpMessage(res, exception);
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'HTTP_ERROR',
|
||||
message,
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Unhandled exception: ${exception}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unexpected error occurred',
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
response.status(statusCode).json(body);
|
||||
}
|
||||
|
||||
private extractHttpMessage(res: string | object, exception: HttpException): string {
|
||||
if (typeof res === 'string') {
|
||||
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;
|
||||
}
|
||||
return exception.message;
|
||||
}
|
||||
}
|
||||
20
services/classes/src/shared/observability/logger.ts
Normal file
20
services/classes/src/shared/observability/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
export const logger = pino({
|
||||
level: env.LOG_LEVEL,
|
||||
// 修复:pino 默认字段选项为 `base`,而非 `defaultFields`
|
||||
base: {
|
||||
service: 'classes',
|
||||
version: '0.1.0',
|
||||
},
|
||||
transport:
|
||||
env.NODE_ENV === 'development'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true },
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export type Logger = typeof logger;
|
||||
24
services/classes/src/shared/observability/metrics.ts
Normal file
24
services/classes/src/shared/observability/metrics.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import promClient from 'prom-client';
|
||||
|
||||
const registry = new promClient.Registry();
|
||||
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register)
|
||||
registry.setDefaultLabels({ service: 'classes' });
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: 'classes_requests_total',
|
||||
help: 'Total number of class requests',
|
||||
labelNames: ['method', 'endpoint', 'status'],
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Histogram({
|
||||
name: 'classes_request_duration_seconds',
|
||||
help: 'Class request duration in seconds',
|
||||
labelNames: ['method', 'endpoint'],
|
||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||
}),
|
||||
);
|
||||
|
||||
export { registry as metricsRegistry };
|
||||
25
services/classes/src/shared/observability/tracer.ts
Normal file
25
services/classes/src/shared/observability/tracer.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
let sdk: NodeSDK | null = null;
|
||||
|
||||
export function initTracer(): void {
|
||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
||||
|
||||
sdk = new NodeSDK({
|
||||
serviceName: 'classes',
|
||||
traceExporter: new OTLPTraceExporter({
|
||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||
}),
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
console.log('Tracer initialized');
|
||||
}
|
||||
|
||||
export async function shutdownTracer(): Promise<void> {
|
||||
if (sdk) {
|
||||
await sdk.shutdown();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user