From 524204d30a6f9cb8240f9b3a214c480d61e585a9 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:37:29 +0800 Subject: [PATCH] feat(p2): identity layer with IAM service and Teacher BFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 阶段交付物: - services/iam: 完整身份认证服务(users/roles/permissions/refresh_tokens 6 表 schema) - register/login/refresh/getUserInfo 4 个核心 API - bcrypt 密码哈希 + JWT 双 Token(access + refresh) - 复用 classes 黄金模板(errors/observability/middleware 三件套) - services/teacher-bff: 教师聚合 BFF - Promise.allSettled 并行聚合 IAM + classes 数据 - /teacher/dashboard 单一聚合端点 - packages/shared-proto/proto/iam.proto: IamService 契约(Register/Login/RefreshToken/GetUserInfo) - api-gateway: 新增 IamServiceURL/TeacherBffURL 配置 + /iam/* + /teacher/* 路由 --- packages/shared-proto/proto/iam.proto | 50 ++++++ .../api-gateway/internal/config/config.go | 4 + .../api-gateway/internal/routing/routing.go | 16 +- services/iam/Dockerfile | 19 +++ services/iam/README.md | 35 +++++ services/iam/nest-cli.json | 8 + services/iam/package.json | 42 +++++ services/iam/src/app.module.ts | 7 + services/iam/src/config/database.ts | 24 +++ services/iam/src/config/env.ts | 26 ++++ services/iam/src/iam/iam.controller.ts | 38 +++++ services/iam/src/iam/iam.dto.ts | 19 +++ services/iam/src/iam/iam.module.ts | 13 ++ services/iam/src/iam/iam.repository.ts | 63 ++++++++ services/iam/src/iam/iam.schema.ts | 48 ++++++ services/iam/src/iam/iam.service.ts | 143 ++++++++++++++++++ services/iam/src/main.ts | 31 ++++ .../iam/src/middleware/auth.middleware.ts | 24 +++ .../iam/src/middleware/permission.guard.ts | 56 +++++++ .../src/shared/errors/application-error.ts | 105 +++++++++++++ .../src/shared/errors/global-error.filter.ts | 77 ++++++++++ .../iam/src/shared/observability/logger.ts | 19 +++ .../iam/src/shared/observability/metrics.ts | 23 +++ .../iam/src/shared/observability/tracer.ts | 25 +++ services/iam/tsconfig.json | 15 ++ services/teacher-bff/Dockerfile | 19 +++ services/teacher-bff/nest-cli.json | 8 + services/teacher-bff/package.json | 31 ++++ services/teacher-bff/src/app.module.ts | 7 + services/teacher-bff/src/config/env.ts | 25 +++ services/teacher-bff/src/main.ts | 24 +++ .../src/teacher/teacher.controller.ts | 19 +++ .../teacher-bff/src/teacher/teacher.module.ts | 9 ++ .../src/teacher/teacher.service.ts | 22 +++ services/teacher-bff/tsconfig.json | 15 ++ 35 files changed, 1108 insertions(+), 1 deletion(-) create mode 100644 packages/shared-proto/proto/iam.proto create mode 100644 services/iam/Dockerfile create mode 100644 services/iam/README.md create mode 100644 services/iam/nest-cli.json create mode 100644 services/iam/package.json create mode 100644 services/iam/src/app.module.ts create mode 100644 services/iam/src/config/database.ts create mode 100644 services/iam/src/config/env.ts create mode 100644 services/iam/src/iam/iam.controller.ts create mode 100644 services/iam/src/iam/iam.dto.ts create mode 100644 services/iam/src/iam/iam.module.ts create mode 100644 services/iam/src/iam/iam.repository.ts create mode 100644 services/iam/src/iam/iam.schema.ts create mode 100644 services/iam/src/iam/iam.service.ts create mode 100644 services/iam/src/main.ts create mode 100644 services/iam/src/middleware/auth.middleware.ts create mode 100644 services/iam/src/middleware/permission.guard.ts create mode 100644 services/iam/src/shared/errors/application-error.ts create mode 100644 services/iam/src/shared/errors/global-error.filter.ts create mode 100644 services/iam/src/shared/observability/logger.ts create mode 100644 services/iam/src/shared/observability/metrics.ts create mode 100644 services/iam/src/shared/observability/tracer.ts create mode 100644 services/iam/tsconfig.json create mode 100644 services/teacher-bff/Dockerfile create mode 100644 services/teacher-bff/nest-cli.json create mode 100644 services/teacher-bff/package.json create mode 100644 services/teacher-bff/src/app.module.ts create mode 100644 services/teacher-bff/src/config/env.ts create mode 100644 services/teacher-bff/src/main.ts create mode 100644 services/teacher-bff/src/teacher/teacher.controller.ts create mode 100644 services/teacher-bff/src/teacher/teacher.module.ts create mode 100644 services/teacher-bff/src/teacher/teacher.service.ts create mode 100644 services/teacher-bff/tsconfig.json diff --git a/packages/shared-proto/proto/iam.proto b/packages/shared-proto/proto/iam.proto new file mode 100644 index 0000000..b0e3277 --- /dev/null +++ b/packages/shared-proto/proto/iam.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; + +package next_edu_cloud.iam.v1; + +// IamService 定义身份与访问管理契约 +// P2: REST 实现,P3 起转 gRPC +service IamService { + rpc Register(RegisterRequest) returns (AuthResponse); + rpc Login(LoginRequest) returns (AuthResponse); + rpc RefreshToken(RefreshTokenRequest) returns (TokenPair); + rpc GetUserInfo(GetUserInfoRequest) returns (UserInfo); +} + +message RegisterRequest { + string email = 1; + string password = 2; + string name = 3; +} + +message LoginRequest { + string email = 1; + string password = 2; +} + +message RefreshTokenRequest { + string refresh_token = 1; +} + +message GetUserInfoRequest { + string user_id = 1; +} + +message AuthResponse { + UserInfo user = 1; + TokenPair tokens = 2; +} + +message TokenPair { + string access_token = 1; + string refresh_token = 2; + int32 expires_in = 3; +} + +message UserInfo { + string id = 1; + string email = 2; + string name = 3; + repeated string roles = 4; + repeated string permissions = 5; +} diff --git a/services/api-gateway/internal/config/config.go b/services/api-gateway/internal/config/config.go index 96c0a33..6c5ab3f 100644 --- a/services/api-gateway/internal/config/config.go +++ b/services/api-gateway/internal/config/config.go @@ -11,6 +11,8 @@ type Config struct { JWTIssuer string JWTAudience string ClassesServiceURL string + IamServiceURL string + TeacherBffURL string OTLPEndpoint string LogLevel string } @@ -22,6 +24,8 @@ func Load() *Config { JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"), JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"), ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"), + IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"), + TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"), OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), LogLevel: getEnv("LOG_LEVEL", "info"), } diff --git a/services/api-gateway/internal/routing/routing.go b/services/api-gateway/internal/routing/routing.go index 41424af..ebadce3 100644 --- a/services/api-gateway/internal/routing/routing.go +++ b/services/api-gateway/internal/routing/routing.go @@ -1,4 +1,4 @@ -package routing +package routing import ( "github.com/edu-cloud/api-gateway/internal/config" @@ -29,6 +29,20 @@ func Setup(cfg *config.Config) *gin.Engine { panic("failed to create classes proxy: " + err.Error()) } api.Any("/classes/*path", proxy.ProxyHandler(classesProxy)) + + // IAM 服务路由(身份与访问管理) + iamProxy, err := proxy.NewProxy(cfg.IamServiceURL) + if err != nil { + panic("failed to create iam proxy: " + err.Error()) + } + api.Any("/iam/*path", proxy.ProxyHandler(iamProxy)) + + // Teacher BFF 路由(教师聚合层) + bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL) + if err != nil { + panic("failed to create teacher-bff proxy: " + err.Error()) + } + api.Any("/teacher/*path", proxy.ProxyHandler(bffProxy)) } return r diff --git a/services/iam/Dockerfile b/services/iam/Dockerfile new file mode 100644 index 0000000..bbf2ee7 --- /dev/null +++ b/services/iam/Dockerfile @@ -0,0 +1,19 @@ +# Build stage +FROM node:20-alpine AS builder +WORKDIR /app +RUN npm install -g pnpm +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile +COPY tsconfig.json nest-cli.json ./ +COPY src ./src +RUN pnpm build + +# Runtime stage +FROM node:20-alpine +WORKDIR /app +RUN npm install -g pnpm +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --prod --frozen-lockfile +COPY --from=builder /app/dist ./dist +EXPOSE 3002 +CMD ["node", "dist/main.js"] diff --git a/services/iam/README.md b/services/iam/README.md new file mode 100644 index 0000000..5ba6daf --- /dev/null +++ b/services/iam/README.md @@ -0,0 +1,35 @@ +# IAM Service + +身份与访问管理服务(Identity and Access Management),P2 身份阶段交付。 + +## 职责 + +- 用户注册 / 登录 / 刷新令牌 +- 角色(Role)与权限(Permission)管理 +- Access / Refresh Token 签发与校验 +- 用户信息查询(供 BFF / Gateway 聚合) + +## 技术栈 + +- NestJS 10 + TypeScript(ESM) +- Drizzle ORM + MySQL +- bcrypt 密码哈希 +- jsonwebtoken(P2 骨架使用 HS256,后续切 RS256) +- pino 日志 / prom-client 指标 / OpenTelemetry 链路 + +## 端口 + +默认 `3002`,通过 `PORT` 环境变量覆盖。 + +## API + +| Method | Path | 说明 | +|--------|------|------| +| POST | `/iam/register` | 注册 | +| POST | `/iam/login` | 登录 | +| POST | `/iam/refresh` | 刷新令牌 | +| GET | `/iam/me` | 当前用户信息(需 `x-user-id` 头) | + +## 数据表 + +`iam_users` / `iam_roles` / `iam_user_roles` / `iam_permissions` / `iam_role_permissions` / `iam_refresh_tokens` diff --git a/services/iam/nest-cli.json b/services/iam/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/services/iam/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/iam/package.json b/services/iam/package.json new file mode 100644 index 0000000..4d00b78 --- /dev/null +++ b/services/iam/package.json @@ -0,0 +1,42 @@ +{ + "name": "@edu/iam-service", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "nest start --watch", + "build": "nest build", + "start": "node dist/main.js", + "test": "vitest run", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/platform-express": "^10.4.0", + "drizzle-orm": "^0.31.0", + "mysql2": "^3.11.0", + "pino": "^9.4.0", + "prom-client": "^15.1.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/sdk-node": "^0.53.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.53.0", + "zod": "^3.23.0", + "uuid": "^10.0.0", + "bcrypt": "^5.1.0", + "jsonwebtoken": "^9.0.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.0", + "@types/bcrypt": "^5.0.0", + "@types/jsonwebtoken": "^9.0.0", + "@types/node": "^22.0.0", + "@types/uuid": "^10.0.0", + "eslint": "^9.10.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/services/iam/src/app.module.ts b/services/iam/src/app.module.ts new file mode 100644 index 0000000..34ae55e --- /dev/null +++ b/services/iam/src/app.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { IamModule } from './iam/iam.module.js'; + +@Module({ + imports: [IamModule], +}) +export class AppModule {} diff --git a/services/iam/src/config/database.ts b/services/iam/src/config/database.ts new file mode 100644 index 0000000..d6550c8 --- /dev/null +++ b/services/iam/src/config/database.ts @@ -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 { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/services/iam/src/config/env.ts b/services/iam/src/config/env.ts new file mode 100644 index 0000000..9f9d2d9 --- /dev/null +++ b/services/iam/src/config/env.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + PORT: z.string().default('3002'), + DATABASE_URL: z.string().url(), + REDIS_URL: z.string().url().optional(), + JWT_SECRET: z.string(), + JWT_ISSUER: z.string().default('next-edu-cloud'), + JWT_AUDIENCE: 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; + +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(); diff --git a/services/iam/src/iam/iam.controller.ts b/services/iam/src/iam/iam.controller.ts new file mode 100644 index 0000000..21e5730 --- /dev/null +++ b/services/iam/src/iam/iam.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, Post, Req } from '@nestjs/common'; +import type { Request } from 'express'; +import { IamService } from './iam.service.js'; +import { registerSchema, loginSchema, refreshTokenSchema } from './iam.dto.js'; +import type { AuthenticatedRequest } from '../middleware/auth.middleware.js'; + +@Controller('iam') +export class IamController { + constructor(private readonly service: IamService) {} + + @Post('register') + async register(@Body() body: unknown) { + const dto = registerSchema.parse(body); + const result = await this.service.register(dto); + return { success: true as const, data: result }; + } + + @Post('login') + async login(@Body() body: unknown) { + const dto = loginSchema.parse(body); + const result = await this.service.login(dto); + return { success: true as const, data: result }; + } + + @Post('refresh') + async refresh(@Body() body: unknown) { + const dto = refreshTokenSchema.parse(body); + const tokens = await this.service.refresh(dto.refreshToken); + return { success: true as const, data: tokens }; + } + + @Get('me') + async me(@Req() req: Request) { + const authReq = req as AuthenticatedRequest; + const user = await this.service.getUserInfo(authReq.userId as string); + return { success: true as const, data: user }; + } +} diff --git a/services/iam/src/iam/iam.dto.ts b/services/iam/src/iam/iam.dto.ts new file mode 100644 index 0000000..d9d4518 --- /dev/null +++ b/services/iam/src/iam/iam.dto.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(8).max(72), + name: z.string().min(1).max(100), +}); + +export const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +export const refreshTokenSchema = z.object({ + refreshToken: z.string(), +}); + +export type RegisterDto = z.infer; +export type LoginDto = z.infer; diff --git a/services/iam/src/iam/iam.module.ts b/services/iam/src/iam/iam.module.ts new file mode 100644 index 0000000..65cd2b2 --- /dev/null +++ b/services/iam/src/iam/iam.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { IamController } from './iam.controller.js'; +import { IamService } from './iam.service.js'; +import { IamRepository } from './iam.repository.js'; + +@Module({ + controllers: [IamController], + providers: [ + IamService, + { provide: IamRepository, useFactory: () => new IamRepository() }, + ], +}) +export class IamModule {} diff --git a/services/iam/src/iam/iam.repository.ts b/services/iam/src/iam/iam.repository.ts new file mode 100644 index 0000000..3a6e8d1 --- /dev/null +++ b/services/iam/src/iam/iam.repository.ts @@ -0,0 +1,63 @@ +import { eq } from 'drizzle-orm'; +import { getDb } from '../config/database.js'; +import { users, roles, userRoles, permissions, rolePermissions, refreshTokens } from './iam.schema.js'; +import type { User, Role, Permission } from './iam.schema.js'; + +export class IamRepository { + async createUser(data: { id: string; email: string; passwordHash: string; name: string }): Promise { + const db = getDb(); + await db.insert(users).values(data); + const [result] = await db.select().from(users).where(eq(users.id, data.id)); + return result; + } + + async findUserByEmail(email: string): Promise { + const db = getDb(); + const [result] = await db.select().from(users).where(eq(users.email, email)); + return result; + } + + async findUserById(id: string): Promise { + const db = getDb(); + const [result] = await db.select().from(users).where(eq(users.id, id)); + return result; + } + + async getUserRoles(userId: string): Promise { + const db = getDb(); + const result = await db + .select() + .from(roles) + .innerJoin(userRoles, eq(roles.id, userRoles.roleId)) + .where(eq(userRoles.userId, userId)); + return result.map((r) => r.roles); + } + + async getUserPermissions(userId: string): Promise { + const db = getDb(); + const userRoleRows = await db.select().from(userRoles).where(eq(userRoles.userId, userId)); + const roleIds = userRoleRows.map((r) => r.roleId); + if (roleIds.length === 0) return []; + const result = await db + .select() + .from(permissions) + .innerJoin(rolePermissions, eq(permissions.id, rolePermissions.permissionId)) + .where(rolePermissions.roleId.in(roleIds)); + return result.map((r) => r.permissions); + } + + async assignRole(userId: string, roleId: string): Promise { + const db = getDb(); + await db.insert(userRoles).values({ userId, roleId }); + } + + async createRefreshToken(data: { id: string; userId: string; tokenHash: string; expiresAt: Date }): Promise { + const db = getDb(); + await db.insert(refreshTokens).values(data); + } + + async revokeRefreshToken(id: string): Promise { + const db = getDb(); + await db.update(refreshTokens).set({ revokedAt: new Date() }).where(eq(refreshTokens.id, id)); + } +} diff --git a/services/iam/src/iam/iam.schema.ts b/services/iam/src/iam/iam.schema.ts new file mode 100644 index 0000000..a0b6867 --- /dev/null +++ b/services/iam/src/iam/iam.schema.ts @@ -0,0 +1,48 @@ +import { mysqlTable, varchar, char, timestamp } from 'drizzle-orm/mysql-core'; + +export const users = mysqlTable('iam_users', { + id: char('id', { length: 36 }).notNull().primaryKey(), + email: varchar('email', { length: 255 }).notNull().unique(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + status: varchar('status', { length: 20 }).notNull().default('active'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), +}); + +export const roles = mysqlTable('iam_roles', { + id: char('id', { length: 36 }).notNull().primaryKey(), + name: varchar('name', { length: 50 }).notNull().unique(), + description: varchar('description', { length: 255 }), +}); + +export const userRoles = mysqlTable('iam_user_roles', { + userId: char('user_id', { length: 36 }).notNull(), + roleId: char('role_id', { length: 36 }).notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +export const permissions = mysqlTable('iam_permissions', { + id: char('id', { length: 36 }).notNull().primaryKey(), + name: varchar('name', { length: 100 }).notNull().unique(), + resource: varchar('resource', { length: 50 }).notNull(), + action: varchar('action', { length: 50 }).notNull(), +}); + +export const rolePermissions = mysqlTable('iam_role_permissions', { + roleId: char('role_id', { length: 36 }).notNull(), + permissionId: char('permission_id', { length: 36 }).notNull(), +}); + +export const refreshTokens = mysqlTable('iam_refresh_tokens', { + id: char('id', { length: 36 }).notNull().primaryKey(), + userId: char('user_id', { length: 36 }).notNull(), + tokenHash: varchar('token_hash', { length: 255 }).notNull(), + expiresAt: timestamp('expires_at').notNull(), + revokedAt: timestamp('revoked_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +export type User = typeof users.$inferSelect; +export type Role = typeof roles.$inferSelect; +export type Permission = typeof permissions.$inferSelect; diff --git a/services/iam/src/iam/iam.service.ts b/services/iam/src/iam/iam.service.ts new file mode 100644 index 0000000..6f9862c --- /dev/null +++ b/services/iam/src/iam/iam.service.ts @@ -0,0 +1,143 @@ +import { v4 as uuidv4 } from 'uuid'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { IamRepository } from './iam.repository.js'; +import { ConflictError, UnauthorizedError, NotFoundError } from '../shared/errors/application-error.js'; +import { env } from '../config/env.js'; +import type { RegisterDto, LoginDto } from './iam.dto.js'; +import type { User } from './iam.schema.js'; + +export interface TokenPair { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export interface UserInfo { + id: string; + email: string; + name: string; + roles: string[]; + permissions: string[]; +} + +export class IamService { + constructor(private readonly repository: IamRepository) {} + + async register(dto: RegisterDto): Promise<{ user: UserInfo; tokens: TokenPair }> { + const existing = await this.repository.findUserByEmail(dto.email); + if (existing) { + throw new ConflictError('Email already registered'); + } + + const userId = uuidv4(); + const passwordHash = await bcrypt.hash(dto.password, 12); + const user = await this.repository.createUser({ + id: userId, + email: dto.email, + passwordHash, + name: dto.name, + }); + + // P2 骨架:默认分配 teacher 角色(实际应通过邀请码注册) + // await this.repository.assignRole(userId, defaultRoleId); + + const { tokens } = await this.issueTokens(user); + const info = await this.buildUserInfo(user); + return { user: info, tokens }; + } + + async login(dto: LoginDto): Promise<{ user: UserInfo; tokens: TokenPair }> { + const user = await this.repository.findUserByEmail(dto.email); + if (!user) { + throw new UnauthorizedError('Invalid credentials'); + } + + const valid = await bcrypt.compare(dto.password, user.passwordHash); + if (!valid) { + throw new UnauthorizedError('Invalid credentials'); + } + + if (user.status !== 'active') { + throw new UnauthorizedError('Account is not active'); + } + + const { tokens } = await this.issueTokens(user); + const info = await this.buildUserInfo(user); + return { user: info, tokens }; + } + + async refresh(refreshToken: string): Promise { + let payload: jwt.JwtPayload; + try { + payload = jwt.verify(refreshToken, env.JWT_SECRET) as jwt.JwtPayload; + } catch { + throw new UnauthorizedError('Invalid refresh token'); + } + + if (payload.type !== 'refresh') { + throw new UnauthorizedError('Invalid token type'); + } + + const user = await this.repository.findUserById(payload.sub as string); + if (!user) { + throw new NotFoundError('User', payload.sub as string); + } + + return this.issueTokens(user).then((r) => r.tokens); + } + + async getUserInfo(userId: string): Promise { + const user = await this.repository.findUserById(userId); + if (!user) { + throw new NotFoundError('User', userId); + } + return this.buildUserInfo(user); + } + + private async issueTokens(user: User): Promise<{ tokens: TokenPair }> { + const roles = await this.repository.getUserRoles(user.id); + const roleNames = roles.map((r) => r.name); + + const accessToken = jwt.sign( + { sub: user.id, email: user.email, roles: roleNames, type: 'access' }, + env.JWT_SECRET, + { issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: '15m' } + ); + + const refreshToken = jwt.sign( + { sub: user.id, type: 'refresh' }, + env.JWT_SECRET, + { issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: '7d' } + ); + + // 存储 refresh token hash + const tokenHash = await bcrypt.hash(refreshToken, 10); + await this.repository.createRefreshToken({ + id: uuidv4(), + userId: user.id, + tokenHash, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }); + + return { + tokens: { + accessToken, + refreshToken, + expiresIn: 15 * 60, + }, + }; + } + + private async buildUserInfo(user: User): Promise { + const roles = await this.repository.getUserRoles(user.id); + const permissions = await this.repository.getUserPermissions(user.id); + return { + id: user.id, + email: user.email, + name: user.name, + roles: roles.map((r) => r.name), + permissions: permissions.map((p) => p.name), + }; + } +} diff --git a/services/iam/src/main.ts b/services/iam/src/main.ts new file mode 100644 index 0000000..922acf6 --- /dev/null +++ b/services/iam/src/main.ts @@ -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 { + 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 }, 'IAM service started'); + + process.on('SIGTERM', async () => { + await app.close(); + await shutdownTracer(); + }); +} + +bootstrap().catch((err: unknown) => { + logger.error({ err }, 'Failed to start IAM service'); + process.exit(1); +}); diff --git a/services/iam/src/middleware/auth.middleware.ts b/services/iam/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..856af89 --- /dev/null +++ b/services/iam/src/middleware/auth.middleware.ts @@ -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(); + } +} diff --git a/services/iam/src/middleware/permission.guard.ts b/services/iam/src/middleware/permission.guard.ts new file mode 100644 index 0000000..6d418ed --- /dev/null +++ b/services/iam/src/middleware/permission.guard.ts @@ -0,0 +1,56 @@ +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 = + | 'IAM_USER_CREATE' + | 'IAM_USER_READ' + | 'IAM_USER_UPDATE' + | 'IAM_USER_DELETE' + | 'IAM_ROLE_MANAGE'; + +export const Permissions = { + IAM_USER_CREATE: 'IAM_USER_CREATE' as const, + IAM_USER_READ: 'IAM_USER_READ' as const, + IAM_USER_UPDATE: 'IAM_USER_UPDATE' as const, + IAM_USER_DELETE: 'IAM_USER_DELETE' as const, + IAM_ROLE_MANAGE: 'IAM_ROLE_MANAGE' as const, +}; + +const ROLE_PERMISSIONS: Record = { + admin: [ + Permissions.IAM_USER_CREATE, + Permissions.IAM_USER_READ, + Permissions.IAM_USER_UPDATE, + Permissions.IAM_USER_DELETE, + Permissions.IAM_ROLE_MANAGE, + ], + teacher: [Permissions.IAM_USER_READ], +}; + +@Injectable() +export class PermissionGuard implements CanActivate { + constructor(private readonly requiredPermission: Permission) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + 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); + } +} + +// 工厂函数,用于装饰器 +export function createPermissionGuardFactory(_reflector: Reflector) { + return { + create: (permission: Permission) => new PermissionGuard(permission), + }; +} diff --git a/services/iam/src/shared/errors/application-error.ts b/services/iam/src/shared/errors/application-error.ts new file mode 100644 index 0000000..adb1f15 --- /dev/null +++ b/services/iam/src/shared/errors/application-error.ts @@ -0,0 +1,105 @@ +export type ErrorType = + | 'validation' + | 'not_found' + | 'permission_denied' + | 'unauthorized' + | '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; + // 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 { + 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, 'IAM_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}`, 'IAM_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}`, 'IAM_PERMISSION_DENIED', { permission }); + } +} + +export class UnauthorizedError extends ApplicationError { + readonly type = 'unauthorized' as const; + readonly statusCode = 401; + constructor(message: string, details?: ErrorDetails) { + super(message, 'IAM_UNAUTHORIZED', details); + } +} + +export class ConflictError extends ApplicationError { + readonly type = 'conflict' as const; + readonly statusCode = 409; + constructor(message: string, details?: ErrorDetails) { + super(message, 'IAM_CONFLICT', details); + } +} + +export class BusinessError extends ApplicationError { + readonly type = 'business' as const; + readonly statusCode = 422; + constructor(message: string, details?: ErrorDetails) { + super(message, 'IAM_BUSINESS_ERROR', details); + } +} + +export class DatabaseError extends ApplicationError { + readonly type = 'database' as const; + readonly statusCode = 500; + constructor(message: string, details?: ErrorDetails) { + super(message, 'IAM_DATABASE_ERROR', details); + } +} + +export class InternalError extends ApplicationError { + readonly type = 'internal' as const; + readonly statusCode = 500; + constructor(message: string, details?: ErrorDetails) { + super(message, 'IAM_INTERNAL_ERROR', details); + } +} diff --git a/services/iam/src/shared/errors/global-error.filter.ts b/services/iam/src/shared/errors/global-error.filter.ts new file mode 100644 index 0000000..555a308 --- /dev/null +++ b/services/iam/src/shared/errors/global-error.filter.ts @@ -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(); + const request = ctx.getRequest(); + + const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown'; + + let statusCode = 500; + let body: Record; + + if (exception instanceof ApplicationError) { + exception.traceId = traceId; + statusCode = exception.statusCode; + body = exception.toJSON(); + } else if (exception instanceof ZodError) { + // 捕获 Zod 解析错误,转换为结构化 ValidationError 响应 + statusCode = 400; + body = { + success: false, + error: { + code: 'IAM_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; + } +} diff --git a/services/iam/src/shared/observability/logger.ts b/services/iam/src/shared/observability/logger.ts new file mode 100644 index 0000000..6dc81f2 --- /dev/null +++ b/services/iam/src/shared/observability/logger.ts @@ -0,0 +1,19 @@ +import pino from 'pino'; +import { env } from '../../config/env.js'; + +export const logger = pino({ + level: env.LOG_LEVEL, + base: { + service: 'iam', + version: '0.1.0', + }, + transport: + env.NODE_ENV === 'development' + ? { + target: 'pino-pretty', + options: { colorize: true }, + } + : undefined, +}); + +export type Logger = typeof logger; diff --git a/services/iam/src/shared/observability/metrics.ts b/services/iam/src/shared/observability/metrics.ts new file mode 100644 index 0000000..211321a --- /dev/null +++ b/services/iam/src/shared/observability/metrics.ts @@ -0,0 +1,23 @@ +import promClient from 'prom-client'; + +const registry = new promClient.Registry(); +registry.setDefaultLabels({ service: 'iam' }); + +registry.registerMetric( + new promClient.Counter({ + name: 'iam_requests_total', + help: 'Total number of iam requests', + labelNames: ['method', 'endpoint', 'status'], + }), +); + +registry.registerMetric( + new promClient.Histogram({ + name: 'iam_request_duration_seconds', + help: 'Iam 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 }; diff --git a/services/iam/src/shared/observability/tracer.ts b/services/iam/src/shared/observability/tracer.ts new file mode 100644 index 0000000..62d0caf --- /dev/null +++ b/services/iam/src/shared/observability/tracer.ts @@ -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: 'iam', + traceExporter: new OTLPTraceExporter({ + url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`, + }), + }); + + sdk.start(); + console.log('Tracer initialized'); +} + +export async function shutdownTracer(): Promise { + if (sdk) { + await sdk.shutdown(); + } +} diff --git a/services/iam/tsconfig.json b/services/iam/tsconfig.json new file mode 100644 index 0000000..cd22ff6 --- /dev/null +++ b/services/iam/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/services/teacher-bff/Dockerfile b/services/teacher-bff/Dockerfile new file mode 100644 index 0000000..1a2d4a3 --- /dev/null +++ b/services/teacher-bff/Dockerfile @@ -0,0 +1,19 @@ +# Build stage +FROM node:20-alpine AS builder +WORKDIR /app +RUN npm install -g pnpm +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile +COPY tsconfig.json nest-cli.json ./ +COPY src ./src +RUN pnpm build + +# Runtime stage +FROM node:20-alpine +WORKDIR /app +RUN npm install -g pnpm +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --prod --frozen-lockfile +COPY --from=builder /app/dist ./dist +EXPOSE 3003 +CMD ["node", "dist/main.js"] diff --git a/services/teacher-bff/nest-cli.json b/services/teacher-bff/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/services/teacher-bff/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/teacher-bff/package.json b/services/teacher-bff/package.json new file mode 100644 index 0000000..a639a36 --- /dev/null +++ b/services/teacher-bff/package.json @@ -0,0 +1,31 @@ +{ + "name": "@edu/teacher-bff", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "nest start --watch", + "build": "nest build", + "start": "node dist/main.js", + "test": "vitest run", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/platform-express": "^10.4.0", + "pino": "^9.4.0", + "prom-client": "^15.1.0", + "@opentelemetry/api": "^1.9.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.0", + "@types/node": "^22.0.0", + "eslint": "^9.10.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/services/teacher-bff/src/app.module.ts b/services/teacher-bff/src/app.module.ts new file mode 100644 index 0000000..59d0dab --- /dev/null +++ b/services/teacher-bff/src/app.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { TeacherModule } from './teacher/teacher.module.js'; + +@Module({ + imports: [TeacherModule], +}) +export class AppModule {} diff --git a/services/teacher-bff/src/config/env.ts b/services/teacher-bff/src/config/env.ts new file mode 100644 index 0000000..cc925c1 --- /dev/null +++ b/services/teacher-bff/src/config/env.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + PORT: z.string().default('3003'), + IamServiceUrl: z.string().url().default('http://localhost:3002'), + ClassesServiceUrl: z.string().url().default('http://localhost:3001'), + LOG_LEVEL: z.string().default('info'), + NODE_ENV: z.string().default('development'), +}); + +export type Env = z.infer; + +export function loadEnv(): Env { + const result = envSchema.safeParse({ + ...process.env, + IamServiceUrl: process.env.IAM_SERVICE_URL || 'http://localhost:3002', + ClassesServiceUrl: process.env.CLASSES_SERVICE_URL || 'http://localhost:3001', + }); + if (!result.success) { + throw new Error('Invalid env: ' + JSON.stringify(result.error.flatten())); + } + return result.data; +} + +export const env = loadEnv(); diff --git a/services/teacher-bff/src/main.ts b/services/teacher-bff/src/main.ts new file mode 100644 index 0000000..263a73e --- /dev/null +++ b/services/teacher-bff/src/main.ts @@ -0,0 +1,24 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module.js'; +import { env } from './config/env.js'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { + logger: ['log', 'error', 'warn'], + }); + + app.enableShutdownHooks(); + + await app.listen(env.PORT); + console.log(`Teacher BFF started on port ${env.PORT}`); + + process.on('SIGTERM', async () => { + await app.close(); + }); +} + +bootstrap().catch((err: unknown) => { + console.error('Failed to start Teacher BFF', err); + process.exit(1); +}); diff --git a/services/teacher-bff/src/teacher/teacher.controller.ts b/services/teacher-bff/src/teacher/teacher.controller.ts new file mode 100644 index 0000000..b69a061 --- /dev/null +++ b/services/teacher-bff/src/teacher/teacher.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Req } from '@nestjs/common'; +import type { Request } from 'express'; +import { TeacherService } from './teacher.service.js'; + +interface AuthenticatedRequest extends Request { + userId?: string; +} + +@Controller('teacher') +export class TeacherController { + constructor(private readonly service: TeacherService) {} + + @Get('dashboard') + async dashboard(@Req() req: Request) { + const authReq = req as AuthenticatedRequest; + const data = await this.service.getDashboard(authReq.userId as string); + return { success: true, data }; + } +} diff --git a/services/teacher-bff/src/teacher/teacher.module.ts b/services/teacher-bff/src/teacher/teacher.module.ts new file mode 100644 index 0000000..91864c1 --- /dev/null +++ b/services/teacher-bff/src/teacher/teacher.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TeacherController } from './teacher.controller.js'; +import { TeacherService } from './teacher.service.js'; + +@Module({ + controllers: [TeacherController], + providers: [TeacherService], +}) +export class TeacherModule {} diff --git a/services/teacher-bff/src/teacher/teacher.service.ts b/services/teacher-bff/src/teacher/teacher.service.ts new file mode 100644 index 0000000..c0d80fa --- /dev/null +++ b/services/teacher-bff/src/teacher/teacher.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { env } from '../config/env.js'; + +@Injectable() +export class TeacherService { + // 聚合 IAM + classes 服务的数据 + async getDashboard(userId: string): Promise { + const [iamRes, classesRes] = await Promise.allSettled([ + fetch(`${env.IamServiceUrl}/iam/me`, { + headers: { 'x-user-id': userId }, + }), + fetch(`${env.ClassesServiceUrl}/classes`, { + headers: { 'x-user-id': userId }, + }), + ]); + + return { + user: iamRes.status === 'fulfilled' ? await iamRes.value.json() : null, + classes: classesRes.status === 'fulfilled' ? await classesRes.value.json() : null, + }; + } +} diff --git a/services/teacher-bff/tsconfig.json b/services/teacher-bff/tsconfig.json new file mode 100644 index 0000000..cd22ff6 --- /dev/null +++ b/services/teacher-bff/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +}