feat(p1): complete P1 foundation stage
Some checks failed
CI Go / test (push) Has been cancelled
CI Proto / lint (push) Has been cancelled
CI Python / test (push) Has been cancelled
CI TypeScript / test (push) Has been cancelled

- 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:
SpecialX
2026-07-07 23:39:37 +08:00
commit 2ba4250165
100 changed files with 15242 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ClassesModule } from './classes/classes.module.js';
@Module({
imports: [ClassesModule],
})
export class AppModule {}

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

View 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>;

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

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

View 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;

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

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

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

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

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

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

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

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

View 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;

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

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