8 Commits

Author SHA1 Message Date
SpecialX
0a71b02e04 fix: code compliance audit and fix across all services
Some checks failed
CI / quality-ts (push) Failing after 48s
CI / quality-go (push) Failing after 4s
CI / quality-proto (push) Failing after 2s
CI / deploy (push) Has been skipped
NestJS (6 services): implement @RequirePermission decorator with
SetMetadata+Reflector, register APP_GUARD globally, fix as assertions
to type guards, add explicit return types, fix import type for express,
fix /metrics implicit any, replace native Error with ApplicationError,
remove typeorm remnants, register LifecycleService.

teacher-bff: add logger, ApplicationError, GlobalErrorFilter, forward
real userId to downstream, log downstream failures, migrate health
controller to shared/health.

Go (2 services): interface to any, doc comments, CORS dev whitelist,
JWT secret fail-fast, push-gateway internal API auth, metrics and
readyz endpoints, remove dead code.

Python (2 services): lifespan return type, dev_mode to bool, data-ana
APIRouter, ai POST body model, ClickHouse async wrapping.
2026-07-09 17:28:27 +08:00
SpecialX
b53a486c6e docs(teacher-portal): ai07 阶段1+2 模块理解确认书与架构设计文档
4端合并交付:理解确认书+架构设计+MF Shell/Remote架构+4端差异化对照表

AI Agent: ai07 (4 portals)

Branch: docs/teacher-portal-stage1-stage2-design-ai07
2026-07-09 17:24:58 +08:00
SpecialX
a0f6c228af docs(push-gateway): ai01 阶段1+2 模块理解确认书与架构设计文档
阶段1交付:services/push-gateway/docs/01-understanding.md
- 8节:架构位置/限界上下文/契约/技术栈/阶段归属P5骨架/审计
- 审计16项差距(6高/5中/5低)

阶段2交付:services/push-gateway/docs/02-architecture-design.md
- 13节:分层图/Connection+Hub模型/Redis key/WS端点
- 双通道协议/生命周期/心跳RFC6455/多实例PubSub
- 容量目标10w+连接

AI Agent: ai01 (api-gateway/push-gateway)
Branch: main
Coordinator: coord
2026-07-09 17:22:23 +08:00
SpecialX
834e2c61fd docs(api-gateway): ai01 阶段1+2 模块理解确认书与架构设计文档
阶段1交付:services/api-gateway/docs/01-understanding.md
- 8节内容:架构位置/限界上下文/契约/技术栈/阶段归属/黄金模板对齐审计
- 审计13项差距(3高:缺/metrics、/readyz stub、auth.go死代码;4中:log/slog缺失、go.mod版本不匹配、HS256待升RS256、DevMode风险;6低)

阶段2交付:services/api-gateway/docs/02-architecture-design.md
- 9节内容:内部分层图/路由表矩阵9下游/限流策略表/熔断阈值表/JWT RS256流程含JWKS缓存/CORS白名单/请求ID注入/metrics 7项指标/P0-P3实施优先级

同步更新 docs/troubleshooting/known-issues.md 工作经验日志(追加ai01条目)

AI Agent: ai01 (api-gateway/push-gateway)
Branch: main
Coordinator: coord
2026-07-09 17:21:03 +08:00
SpecialX
f1e466a772 fix(infra): resolve NestJS dist build and Prometheus target issues
Some checks failed
CI / quality-go (push) Failing after 5s
CI / quality-proto (push) Failing after 3s
CI / deploy (push) Has been skipped
CI / quality-ts (push) Failing after 50s
NestJS: disable incremental in 6 services tsconfig.json to fix dist
not emitted when nest-cli deleteOutDir conflicts with tsc tsbuildinfo.
classes/iam: import HealthModule in AppModule to fix /healthz 404.
classes: rewrite HealthController to Drizzle getDb from TypeORM DI.
teacher-bff: add /metrics endpoint for Prometheus scraping.
infra: add node/mysql/redis exporters to observability profile.
mysql-exporter v0.15.1 uses command-line flags not DATA_SOURCE_NAME.
prometheus: enable web.enable-lifecycle for hot reload.
2026-07-09 15:12:15 +08:00
SpecialX
b72c8d81d4 fix(infra): use conditional docker template to avoid Health key error
Some checks failed
CI / quality-ts (push) Failing after 1m0s
CI / quality-go (push) Failing after 4s
CI / quality-proto (push) Failing after 2s
CI / deploy (push) Has been skipped
Use {{if .State.Health}}...{{end}} instead of direct {{.State.Health.Status}}.
2026-07-09 13:58:55 +08:00
SpecialX
959d58a95d docs: add full-stack runbook and one-click scripts
Some checks failed
CI / quality-ts (push) Failing after 51s
CI / quality-go (push) Failing after 3s
CI / quality-proto (push) Failing after 2s
CI / deploy (push) Has been skipped
start-all/stop-all/health-check/test-cdc PowerShell scripts.

Compatible with PowerShell 5.1 (ASCII only, no emoji).
2026-07-09 13:47:46 +08:00
SpecialX
d8dab70406 feat(infra): add OTel auto-instrumentations across all services
Some checks failed
CI / quality-proto (push) Failing after 2s
CI / deploy (push) Has been skipped
CI / quality-ts (push) Failing after 1m11s
CI / quality-go (push) Failing after 5s
NestJS 6 services use getNodeAutoInstrumentations().

Python 2 services use FastAPIInstrumentor. Go 2 services use otelgin.
2026-07-09 13:25:46 +08:00
126 changed files with 10824 additions and 862 deletions

View File

@@ -0,0 +1,945 @@
# teacher-portal / 4 端微前端架构设计
> 版本1.0
> 日期2026-07-09
> AI Agentai07前端 4 端teacher-portal / student-portal / parent-portal / admin-portal
> 阶段:阶段 1理解确认书+ 阶段 2模块架构设计文档
> 关联文档:
>
> - [004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md) §1.1a/1.1b/§5.4
> - [AI 分配方案](../../docs/architecture/ai-allocation.md) §5 ai07
> - [项目规则](../../.trae/rules/project_rules.md) §3.8/§3.9/§3.10
> - [编码规范](../../docs/standards/coding-standards.md) §2.8-2.10/§7
> - [迁移指南](../../MIGRATION_GUIDE.md) §7.1-7.7
> - [known-issues](../../docs/troubleshooting/known-issues.md) §2.12
> 本文档合并 4 端的设计,因 ai07 单一负责全部 4 端Module Federation shell + remote 架构需统一设计4 端共享组件库和权限体系。当前仓库仅 `teacher-portal` 已实现P1 测试页 + P2 骨架student/parent/admin-portal 待建。
---
## 目录
1. [阶段 1模块理解确认书4 端)](#阶段-1模块理解确认书4-端)
2. [teacher-portal 现状审计(对齐黄金模板)](#teacher-portal-现状审计对齐黄金模板)
3. [阶段 2模块架构设计文档](#阶段-2模块架构设计文档)
4. [4 端差异化对比表](#4-端差异化对比表)
5. [与其他模块的交互点(契约清单)](#与其他模块的交互点契约清单)
6. [风险与假设](#风险与假设)
7. [coord 交叉审查所需信息](#coord-交叉审查所需信息)
---
# 阶段 1模块理解确认书4 端)
## 1.1 我在架构中的位置
| 维度 | teacher-portal | student-portal | parent-portal | admin-portal |
| ------------ | --------------------------------------------------------- | ---------------- | ---------------- | --------------------------- |
| 层级 | L2 微前端层 | L2 微前端层 | L2 微前端层 | L2 微前端层 |
| MF 角色 | **Shell 宿主**(主应用) | Remote子应用 | Remote子应用 | Remote子应用 |
| 上游 | 浏览器(教师 / 教导主任 / 教研组长) | 浏览器(学生) | 浏览器(家长) | 浏览器(系统/校管理员) |
| 下游(同步) | api-gatewayREST经 Next.js rewrites 代理) | api-gateway | api-gateway | api-gateway |
| 下游(推送) | push-gatewayWebSocket/SSEP5 | push-gateway | push-gateway | — |
| BFF 对接 | teacher-bffGraphQL Yoga + DataLoader | student-bff | parent-bff | teacher-bff 复用 + iam 直连 |
| 通信方式 | HTTP/REST前端→Gateway+ WebSocket前端→push-gateway | 同左 | 同左 | HTTP/REST |
**说明**
- 4 端均通过 `next.config.js``rewrites``/api/v1/*` 代理到 `api-gateway`,前端不直连任何业务服务或 BFF 后端实例
- MF 架构下4 端共享同一 Shellteacher-portal 作为 Shell 宿主),其余 3 端作为 Remote 子应用挂载Shell 提供 AppShell + 共享组件库 + 权限 Hook + API 请求层
- 场景域 BFF 复用策略004 §5.4):教导主任/教研组长复用 teacher-portal + 额外管理视口,不单独建 portal
## 1.2 我的限界上下文
| 项 | teacher-portal | student-portal | parent-portal | admin-portal |
| -------- | --------------------------------------------------------------------------------------------------- | -------------------------------- | ---------------------------------------- | ------------------------------------------------- |
| 业务领域 | 教学场景域 | 学习场景域 | 家长场景域 | 管理场景域 |
| 主要聚合 | 班级、考试、作业、成绩、备课、AI 出题 | 作答、作业提交、学情诊断、错题本 | 多子女切换、通知偏好、学情查看、成绩通知 | 用户/角色/权限/视口 CRUD、组织/班级管理、平台监控 |
| 不负责 | 学生作答界面、家长多子女切换 | 教师批改界面、AI 出题 | 教师沟通、学生作答 | 教学业务编排(归 teacher-portal |
| 数据范围 | DataScope L1-L5教师 L1 班级 / 教导主任 L2 年级 / 校管理员 L3 学校 / 区教研员 L4 / 系统管理员 L5 | DataScope L0仅本人 | DataScope L0仅子女 | DataScope L3-L5校管理员 L3 / 系统管理员 L5 |
## 1.3 我与外部的契约
### 1.3.1 消费的后端 API经 api-gateway 代理)
| 端 | 路径前缀 | 下游 BFF/服务 | 关键端点 |
| -------------- | ------------------------------------------------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| teacher-portal | `/api/v1/iam/*` | iam | `POST /iam/login``POST /iam/register``POST /iam/refresh``GET /iam/me``GET /iam/rbac/...``GET /iam/effective-permissions` |
| teacher-portal | `/api/v1/teacher/*` | teacher-bff | `GET /teacher/viewports``GET /teacher/dashboard``GET /teacher/classes/:id/exams``GET /teacher/classes/:id/homework``GET /teacher/exams/:id/grades` |
| teacher-portal | `/api/v1/classes/*` | core-educlasses 模块) | CRUD黄金模板 |
| teacher-portal | `/api/v1/exams/*` `/api/v1/homework/*` `/api/v1/grades/*` | core-edu | P3 教学核心 |
| teacher-portal | `/api/v1/textbooks/*` `/api/v1/knowledge-points/*` `/api/v1/questions/*` | content | P4 内容 |
| teacher-portal | `/api/v1/ai/*` | aiSSE 流式) | P5 AI 辅助出题 |
| student-portal | `/api/v1/student/*` | student-bff | `GET /student/viewports``GET /student/dashboard``GET /student/homework``POST /student/homework/:id/submit``GET /student/diagnostic` |
| parent-portal | `/api/v1/parent/*` | parent-bff | `GET /parent/viewports``GET /parent/children``POST /parent/switch-child``GET /parent/notifications``PUT /parent/notification-preferences` |
| admin-portal | `/api/v1/iam/*`(管理用) | iam | 用户/角色/权限/视口 CRUD |
| admin-portal | `/api/v1/admin/*` | teacher-bff 复用 + iam 直连 | 平台监控、统计数据聚合 |
| 全部 | `/api/v1/notifications/*` | msg | 通知中心P5 |
### 1.3.2 统一响应契约
所有后端响应遵循 `ActionState` 结构(迁移指南 §7.5
```typescript
type ActionState<T> =
| { success: true; data: T }
| {
success: false;
error: { code: string; message: string; details?: unknown };
};
```
错误码前缀按服务名大写(如 `IAM_``CORE_EDU_``CONTENT_``MSG_``AI_``BFF_``GW_`)。前端 API 请求层根据 `error.code` 前缀路由到对应的 i18n key。
### 1.3.3 推送契约P5
| 端 | 协议 | 场景 |
| -------------- | ------------------------- | -------------------------------------------- |
| teacher-portal | WebSocketpush-gateway | 学生提交作业通知、考试成绩录入提醒、全校广播 |
| student-portal | WebSocket | 考试发布通知、成绩发布、作业截止提醒 |
| parent-portal | WebSocket | 子女成绩发布、教师沟通、学校通知 |
| admin-portal | — | 不消费推送(管理端用轮询) |
## 1.4 我的技术栈
| 维度 | 选型 | 说明 |
| --------------------------- | -------------------------------------------------------- | -------------------------------------------------------- |
| 框架 | Next.js 14+App Router | 4 端统一server components 默认client components 按需 |
| 语言 | TypeScript 5.5+strict | 沿用 tsconfig.base.json |
| 微前端 | Module Federation 2.0@module-federation/nextjs-mf | teacher-portal = Shell其余 = Remote |
| 样式 | Tailwind CSS 3.4+ | 配合设计令牌三层模型 |
| UI 组件库 | shadcn/ui迁移指南 §7.2 | 平移至 `packages/ui-components/`MF 共享 |
| 状态管理 L1 URL | nuqs | 可分享、可刷新状态 |
| 状态管理 L2 Server | TanStack Query v5 | 服务端数据缓存、重试、乐观更新 |
| 状态管理 L3 Client Business | Zustand slice | 客户端业务状态 |
| 状态管理 L4 Global UI | Zustand ui-store + ModalRoot | 全局 UI 状态 |
| 状态管理 L5 Form | react-hook-form + zodResolver | 表单状态 |
| 富文本 | Tiptap备课、出题、反馈 | SSR 安全 |
| 图表 | recharts | 学情、Dashboard |
| i18n | next-intl | BFF/服务返回 i18n key + 参数,前端翻译 |
| A11y | eslint-plugin-jsx-a11yerror 级) | WCAG 2.2 AA |
| 字体 | Intersans/ Frauncesserif/ JetBrains Monomono | next/font/google 加载CSS 变量暴露 |
## 1.5 我的阶段归属
| 端 | 阶段 | 当前状态 | 依赖上游阶段 |
| -------------- | ---- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| teacher-portal | P2 | ✅ 已实现 P1 测试页 + P2 骨架(登录/AppShell/Dashboard/classes CRUD 待审计对齐黄金模板 + 引入 MF + 共享组件库 | P1api-gateway + classes + iam |
| student-portal | P3 | 📐 需设计(待 core-edu + student-bff 就绪) | P3core-edu + student-bff |
| parent-portal | P4 | 📐 需设计(待 parent-bff + data-ana 就绪) | P4parent-bff + data-ana |
| admin-portal | P6 | 📐 需设计(待全部业务服务稳定) | P6硬化阶段 |
## 1.6 黄金模板对齐清单(对照 classes 服务)
> 前端无 `@RequirePermission` 装饰器(后端概念),对齐项改造为前端等价物。
| 对齐项 | classes后端黄金模板 | teacher-portal 前端等价 | 当前状态 |
| --------------------- | ------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------- |
| 权限校验 | `@RequirePermission(Permissions.XXX)` | `usePermission().hasPermission("XXX")` Hook + `<RequirePermission>` 组件 | ❌ 缺失,直接硬编码 `user.roles.join(", ")` |
| 错误码前缀统一 | `CLASSES_*``IAM_*` | API 请求层根据 `error.code` 前缀路由 i18n | ❌ 缺失统一请求层 |
| logger | pino | 前端 console + SentryP6 | ⚠️ 仅 console.error |
| metrics | prom-client `/metrics` | 前端 Web Vitals → Gateway 上报 | ❌ 缺失 |
| tracer | OTel SDK | 前端 OTel browser SDKP6 | ❌ 缺失 |
| /healthz + /readyz | `GET /healthz` `GET /readyz` | Next.js `/api/health` route + Dockerfile HEALTHCHECK | ⚠️ Dockerfile 有 HEALTHCHECK无 /api/health |
| 优雅关闭 | SIGTERM handler | Next.js 无长连接,无需 | ✅ N/A |
| 测试覆盖率 ≥ 80% | Vitest | Vitest + @testing-library/react + Playwright E2E | ❌ 0% |
| Dockerfile 多阶段构建 | builder + runtime | 已有多阶段 | ✅ 已对齐 |
| Zod 输入验证 | class-validator + Zod schema | react-hook-form + zodResolver | ❌ 缺失 |
| GlobalErrorFilter | NestJS 全局异常过滤器 | React ErrorBoundary + API 请求层统一错误处理 | ❌ 缺失 |
| 设计令牌三层 | — | primitive.css / semantic-light/dark.css / tailwind-theme.css | ❌ 硬编码在 globals.css + tailwind.config.js |
| A11y 工具集 | — | useA11yId / mergeA11yProps / describeInput / focus-trap | ❌ 缺失 |
---
# teacher-portal 现状审计(对齐黄金模板)
## 2.1 审计表
| 维度 | 状态 | 说明 |
| ------------------------------------ | ------ | ---------------------------------------------------------------------- |
| 权限装饰器(前端等价 usePermission | ❌ | AppShell.tsx 直接 `user.roles.join(", ")`,违反 project_rules §3.8 |
| 错误码前缀 | ❌ | 无统一 API 请求层,错误处理散落在每个 page.tsx |
| logger | ⚠️ | 仅 `console.error`,无结构化、无 trace_id |
| metrics | ❌ | 无 Web Vitals 采集 |
| tracer | ❌ | 无 OTel browser SDK |
| /healthz | ⚠️ | Dockerfile 有 `HEALTHCHECK wget /`,但无 `/api/health` route |
| /readyz | ❌ | 无 |
| 优雅关闭 | ✅ N/A | Next.js 无长连接 |
| 测试覆盖率 | ❌ | 0%,无测试文件 |
| Dockerfile 多阶段 | ✅ | builder + runtime非 root 用户HEALTHCHECK |
| Zod 输入验证 | ❌ | 表单直接 useState无 zodResolver |
| GlobalErrorFilterErrorBoundary | ❌ | 无 React ErrorBoundary |
| 设计令牌三层 | ❌ | 硬编码在 globals.css`:root` 变量)+ tailwind.config.jshex 字面量) |
| A11y 工具集 | ❌ | 无 useA11yId、focus-trap 等 |
| Module Federation 配置 | ❌ | next.config.js 仅有 rewrites无 MF |
| 5 层状态管理 | ❌ | 仅 useState + localStorage无 nuqs/TanStack Query/Zustand |
| 共享组件库 | ❌ | 仅 AppShell无 ErrorBoundary/Loading/Empty/RequirePermission |
| i18n | ❌ | 中文硬编码在 JSX |
| API 请求层 | ❌ | 每页重复 fetch + authHeaders + try/catch |
| ESLint flat config 自定义规则 | ❌ | 未配置 no-hardcoded-fonts / design-tokens 规则 |
## 2.2 现有文件清单
```
apps/teacher-portal/
├─ src/
│ ├─ app/
│ │ ├─ (app)/ # 受保护路由组(套 AppShell
│ │ │ ├─ classes/page.tsx # 班级 CRUDP1 测试页)
│ │ │ ├─ dashboard/page.tsx # 教师仪表盘
│ │ │ ├─ exams/page.tsx # 考试列表
│ │ │ ├─ grades/page.tsx # 成绩查询
│ │ │ ├─ homework/page.tsx # 作业列表
│ │ │ └─ layout.tsx # 套 AppShell
│ │ ├─ login/page.tsx # 登录页(不套壳)
│ │ ├─ globals.css # 全局样式 + 设计令牌(硬编码)
│ │ ├─ layout.tsx # 根布局(字体加载)
│ │ └─ page.tsx # 根路径重定向
│ ├─ components/
│ │ └─ AppShell.tsx # 左侧栏 + 主内容区
│ └─ lib/
│ └─ auth.ts # token + userInfo localStorage 管理
├─ Dockerfile # 多阶段构建 ✅
├─ next.config.js # 仅 rewrites无 MF ❌
├─ package.json # 仅 next/react/react-dom无 MF/Query/Zustand ❌
├─ tailwind.config.js # 硬编码 hex ❌
├─ postcss.config.js
└─ tsconfig.json
```
## 2.3 主要违规点(必须在 P2 收尾或 P3 起步时修复)
1. **权限硬编码**[AppShell.tsx:143](src/components/AppShell.tsx) `user.roles.join(", ")` 违反 project_rules §3.8,必须改为 `usePermission().hasPermission()`
2. **设计令牌硬编码**[globals.css:6-13](src/app/globals.css) 与 [tailwind.config.js:7-19](tailwind.config.js) 出现 `hsl(...)` 字面量与 `'Fraunces'`/`'Inter'` 字面量,违反 project_rules §3.10
3. **无统一 API 请求层**4 个 page.tsx 重复 `authHeaders()` + `fetch` + `try/catch` + `setError`,必须抽取到 `lib/api.ts`
4. **无权限 Hook**:缺少 `usePermission().hasPermission()`,无法做 L3 组件级视口控制
5. **无 ErrorBoundary**React 渲染异常会白屏
6. **无 5 层状态管理**:登录态用 localStorageL3但无 TanStack QueryL2导致每页重复 fetch
7. **字体名硬编码**[layout.tsx:3-7](src/app/layout.tsx) 直接 import `Inter/Fraunces/JetBrains_Mono`,应改为 `var(--font-family-sans/serif/mono)`
---
# 阶段 2模块架构设计文档
## 3.1 模块内部分层图4 端统一 MF 架构)
```mermaid
graph TB
subgraph Browser["浏览器"]
URL[URL 路由]
end
subgraph Shell["teacher-portalShell 宿主)"]
AppShell[AppShell<br/>左栏导航 + 主内容区]
RootLayout[RootLayout<br/>字体/令牌/i18n Provider]
Router[Next.js App Router]
SharedDeps["共享依赖暴露<br/>react/react-dom/@tanstack/react-query/zustand/nuqs"]
end
subgraph RemoteTeacher["teacher-portal Remote 模块"]
TeacherPages[教学场景页面<br/>dashboard/classes/exams/homework/grades/ai-assist]
end
subgraph RemoteStudent["student-portalRemote"]
StudentPages[学习场景页面<br/>dashboard/homework/submit/diagnostic/exam-taking]
end
subgraph RemoteParent["parent-portalRemote"]
ParentPages[家长场景页面<br/>dashboard/children-switch/grades/notifications]
end
subgraph RemoteAdmin["admin-portalRemote"]
AdminPages[管理场景页面<br/>users/roles/permissions/viewports/monitoring]
end
subgraph Shared["共享层packages/"]
UITokens[ui-tokens<br/>三层设计令牌]
UIComponents[ui-components<br/>shadcn + A11y + ErrorBoundary]
Contracts[contracts<br/>Permissions 常量 + 类型]
Hooks[hooks<br/>usePermission/useAuth/useA11y]
LibTS[shared-ts<br/>通用工具]
end
subgraph Gateway["api-gateway"]
GW[Gin 路由/鉴权/限流]
end
Browser --> URL
URL --> RootLayout
RootLayout --> AppShell
AppShell --> Router
Router -->|动态加载| RemoteTeacher
Router -->|动态加载| RemoteStudent
Router -->|动态加载| RemoteParent
Router -->|动态加载| RemoteAdmin
RemoteTeacher --> SharedDeps
RemoteStudent --> SharedDeps
RemoteParent --> SharedDeps
RemoteAdmin --> SharedDeps
Shell --> UITokens
Shell --> UIComponents
Shell --> Contracts
Shell --> Hooks
RemoteTeacher --> UITokens
RemoteStudent --> UITokens
RemoteParent --> UITokens
RemoteAdmin --> UITokens
AppShell -->|fetch /api/v1/iam/effective-permissions| Hooks
Hooks -->|透传 token| GW
RemoteTeacher -->|fetch /api/v1/teacher/*| GW
RemoteStudent -->|fetch /api/v1/student/*| GW
RemoteParent -->|fetch /api/v1/parent/*| GW
RemoteAdmin -->|fetch /api/v1/iam/* + /api/v1/admin/*| GW
```
### 3.1.1 MF 拓扑选型
| 方案 | 选否 | 理由 |
| ------------------------------------ | ---- | --------------------------------------------------------------------------------------- |
| 4 端独立部署 + 独立域名 + 各自 Shell | ❌ | 4 套 Shell 重复,登录态/权限/组件库要重复实现 |
| 单 Shell + 4 Remote**采用** | ✅ | teacher-portal 作为 Shell 宿主,提供 AppShell + 共享依赖;其余 3 端作为 Remote 动态加载 |
| 单一 Next.js 应用 + 4 路由组 | ❌ | 违反"微前端独立部署"目标ADR-012 |
**Shell 职责**
- RootLayout字体、设计令牌、i18n Provider、TanStack QueryClientProvider、Zustand StoreProvider
- AppShell左侧导航 + 主内容区 + 用户信息 + 登出)
- 共享依赖暴露react、react-dom、@tanstack/react-query、zustand、nuqs、ui-components、ui-tokens、contracts、hooks
- 路由表4 端路由前缀:`/teacher/*``/student/*``/parent/*``/admin/*`
- 登录页(统一登录入口,按角色重定向到对应 portal
**Remote 职责**
- 各场景域页面page.tsx
- 各场景域专属组件
- 各场景域专属 Zustand slice
- 通过 MF 共享 Shell 暴露的依赖,避免重复加载
### 3.1.2 MF 配置next.config.js
```javascript
// teacher-portal/next.config.jsShell
const NextFederationPlugin = require("@module-federation/nextjs-mf");
const remotes = (isServer) => ({
student: `student_app@http://localhost:3001/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
parent: `parent_app@http://localhost:3002/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
admin: `admin_app@http://localhost:3003/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
});
module.exports = {
reactStrictMode: true,
webpack(config, { isServer }) {
config.plugins.push(
new NextFederationPlugin({
name: "teacher_app",
filename: "static/chunks/remoteEntry.js",
remotes: remotes(isServer),
exposes: {
"./AppShell": "./src/components/AppShell",
"./shared-deps": "./src/shared/deps",
},
shared: {
react: { singleton: true, requiredVersion: "^18.3.0" },
"react-dom": { singleton: true, requiredVersion: "^18.3.0" },
"@tanstack/react-query": { singleton: true },
zustand: { singleton: true },
nuqs: { singleton: true },
},
extraOptions: { exposePages: false },
}),
);
return config;
},
async rewrites() {
return [
{
source: "/api/v1/:path*",
destination: `${process.env.API_GATEWAY_URL || "http://localhost:8080"}/api/v1/:path*`,
},
];
},
};
```
> Remote 端配置对称:`name: 'student_app'``exposes: { './pages': './src/pages' }``remotes: { teacher: 'teacher_app@...' }`。
## 3.2 领域模型(前端视角)
前端不持有业务聚合根,仅持有"视图模型"ViewModel和"会话状态"。
### 3.2.1 会话状态Session
```typescript
interface Session {
user: UserInfo; // { id, email, name, roles, permissions, dataScope }
tokens: { accessToken: string; refreshToken: string };
viewports: ViewportItem[]; // L1 导航视口
expiresAt: number; // access token 过期时间戳
}
```
存储Zustand sessionSliceL3+ localStorage 持久化(刷新恢复)+ TanStack Query 缓存 `['session']`L2
### 3.2.2 视口模型Viewport
```typescript
interface ViewportItem {
key: string; // 'dashboard' | 'classes' | ...
label: string; // i18n key 或显式文案
route: string; // '/teacher/dashboard'
icon: string | null; // 图标 key按需
sortOrder: number; // 排序
requiredPermission: string | null; // 'CLASSES_READ' 等
scope: "teacher" | "student" | "parent" | "admin"; // 标记归属哪个 portal
}
```
来源:`GET /api/v1/{scope}/viewports`BFF 聚合 iam 视口配置。AppShell 按 `scope` 过滤渲染对应 portal 的导航。
### 3.2.3 权限模型Permission
```typescript
interface PermissionState {
permissions: string[]; // ['CLASSES_READ', 'EXAMS_CREATE', ...]
dataScope: DataScope; // L0-L5
hasPermission: (perm: string) => boolean;
hasAnyPermission: (perms: string[]) => boolean;
hasAllPermissions: (perms: string[]) => boolean;
}
```
来源:`GET /api/v1/iam/effective-permissions``{ permissions, viewports, dataScope }`。Redis 缓存 5miniam 侧),前端 TanStack Query 缓存 5min角色变更主动 invalidate。
## 3.3 数据模型(前端)
前端无数据库,仅有缓存层:
| 数据类型 | 存储 | TTL | 失效策略 |
| ----------------------- | ---------------------- | --------------------------- | -------------------------------------- |
| Sessiontoken + user | localStorage + Zustand | access 15min / refresh 7day | 401 自动 refreshrefresh 失败跳登录 |
| 权限列表 | TanStack Query cache | 5min | 角色变更事件 invalidate |
| 视口列表 | TanStack Query cache | 5min | 同上 |
| 班级/年级列表 | TanStack Query cache | 5min | staleTime 5minmutation 后 invalidate |
| 教学资源详情 | TanStack Query cache | 30s | staleTime 30s |
| 学情宽表 | TanStack Query cache | 30s | staleTime 30s实时性由 BFF 决定) |
| URL 状态(分页/筛选) | nuqs | — | 永久(可分享) |
| 表单临时态 | react-hook-form | — | 卸载即销毁 |
## 3.4 API 设计(前端 → 后端)
前端不设计后端 API仅声明消费的端点。详见 §1.3.1。
### 3.4.1 统一 API 请求层lib/api.ts
```typescript
// packages/shared-ts/src/api-client.ts共享
interface ApiClientOptions {
baseUrl?: string; // 默认 ''(走 Next.js rewrites
getToken?: () => string | null;
onUnauthorized?: () => void; // 401 → refresh → 重试 / 跳登录
onError?: (error: ApiError) => void; // 全局 toast
}
class ApiClient {
async get<T>(path: string, query?: Record<string, string>): Promise<T>;
async post<T>(path: string, body: unknown): Promise<T>;
async put<T>(path: string, body: unknown): Promise<T>;
async delete<T>(path: string): Promise<T>;
async sse<T>(path: string, body: unknown): AsyncIterable<T>; // AI 流式
}
// 错误结构
interface ApiError {
code: string; // 'IAM_INVALID_CREDENTIALS'
message: string; // 已 i18n 翻译或后端原文
details?: unknown;
httpStatus: number;
}
```
**职责**
- 自动注入 `Authorization: Bearer ${token}`
- 401 自动 refresh token 一次,失败调 `onUnauthorized`
- 解析 `ActionState`success=false 抛 `ApiError`
-`error.code` 前缀路由 i18n key
- 全局错误 toast除 401
- 请求/响应 trace_id 透传(从响应头 `X-Request-Id` 提取)
### 3.4.2 TanStack Query 约定
```typescript
// Query Key 命名:[scope, resource, ...args]
queryKey: ["teacher", "classes", { gradeId }];
queryKey: ["teacher", "exams", classId];
queryKey: ["session", "effective-permissions"];
queryKey: ["session", "viewports", "teacher"];
// Mutation 约定
const mutation = useMutation({
mutationFn: (input) => api.post("/api/v1/classes", input),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["teacher", "classes"] }),
onError: (e: ApiError) => toast.error(e.message),
});
```
## 3.5 事件设计
前端不发布 Kafka 事件,仅消费 WebSocket 推送P5和 Server-Sent EventsAI 流式)。
### 3.5.1 WebSocket 推送P5
| 事件 | 触发 | 前端动作 |
| ----------------------- | ------------ | --------------------------------------- |
| `NotificationRequested` | msg 服务投递 | toast 提示 + 通知中心未读数 +1 |
| `ExamPublished` | 教师发布考试 | 学生端 toast + dashboard invalidate |
| `GradeRecorded` | 教师录入成绩 | 学生/家长端 toast + 成绩列表 invalidate |
| `HomeworkSubmitted` | 学生提交作业 | 教师端 toast + 作业批改列表 invalidate |
### 3.5.2 SSE 流式P5 AI 辅助出题)
```
GET /api/v1/ai/generate-questions (SSE)
data: {"delta": "题目"}\n\n
data: {"delta": "A. option1"}\n\n
data: {"done": true}\n\n
```
前端用 `AsyncIterable<T>` 消费Tiptap 逐字插入。
## 3.6 横切关注点对齐清单
### 3.6.1 权限(前端等价)
| 端 | 路由 | requiredPermission |
| -------------- | ------------------------------ | ------------------------ |
| teacher-portal | `/teacher/dashboard` | `TEACHER_DASHBOARD_VIEW` |
| teacher-portal | `/teacher/classes` | `CLASSES_READ` |
| teacher-portal | `/teacher/classes/new` | `CLASSES_CREATE` |
| teacher-portal | `/teacher/exams` | `EXAMS_READ` |
| teacher-portal | `/teacher/exams/new` | `EXAMS_CREATE` |
| teacher-portal | `/teacher/homework` | `HOMEWORK_READ` |
| teacher-portal | `/teacher/homework/:id/grade` | `HOMEWORK_GRADE` |
| teacher-portal | `/teacher/grades` | `GRADES_READ` |
| teacher-portal | `/teacher/ai-assist` | `AI_GENERATE` |
| student-portal | `/student/dashboard` | `STUDENT_DASHBOARD_VIEW` |
| student-portal | `/student/homework` | `HOMEWORK_READ_OWN` |
| student-portal | `/student/homework/:id/submit` | `HOMEWORK_SUBMIT` |
| student-portal | `/student/diagnostic` | `DIAGNOSTIC_READ_OWN` |
| parent-portal | `/parent/dashboard` | `PARENT_DASHBOARD_VIEW` |
| parent-portal | `/parent/children` | `PARENT_CHILDREN_VIEW` |
| parent-portal | `/parent/grades` | `GRADES_READ_CHILD` |
| admin-portal | `/admin/users` | `IAM_USER_READ` |
| admin-portal | `/admin/users/new` | `IAM_USER_CREATE` |
| admin-portal | `/admin/roles` | `IAM_ROLE_READ` |
| admin-portal | `/admin/permissions` | `IAM_PERMISSION_READ` |
| admin-portal | `/admin/viewports` | `IAM_VIEWPORT_READ` |
| admin-portal | `/admin/monitoring` | `ADMIN_MONITORING_VIEW` |
> 完整权限点常量集中在 `packages/contracts/src/permissions.ts`待建立coord 负责 shared-tsai07 负责调用。L3 组件级视口用 `<RequirePermission perm="EXAMS_CREATE"><Button>新建考试</Button></RequirePermission>`。
### 3.6.2 错误码清单(前端 i18n 路由)
| 前缀 | 来源服务 | i18n key 模式 |
| ------------ | -------------------------- | ------------------------ |
| `IAM_*` | iam | `iam.error.{{code}}` |
| `CORE_EDU_*` | core-edu | `coreEdu.error.{{code}}` |
| `CLASSES_*` | core-edu/classes | `classes.error.{{code}}` |
| `CONTENT_*` | content | `content.error.{{code}}` |
| `MSG_*` | msg | `msg.error.{{code}}` |
| `AI_*` | ai | `ai.error.{{code}}` |
| `BFF_*` | teacher/student/parent-bff | `bff.error.{{code}}` |
| `GW_*` | api-gateway | `gateway.error.{{code}}` |
| `NETWORK_*` | 前端网络层 | `network.error.{{code}}` |
### 3.6.3 Logger
```typescript
// packages/shared-ts/src/logger.ts
interface Logger {
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void;
}
// 实现:开发环境 console + 结构化;生产环境 → SentryP6
// 必含字段trace_id从响应头提取、user_id、scope、path
```
### 3.6.4 MetricsWeb Vitals
| 指标 | 类型 | 上报 |
| ----------------------------- | ---- | --------------------------------------------------- |
| `teacher_portal_lcp_seconds` | LCP | `next/web-vitals``POST /api/v1/admin/web-vitals` |
| `teacher_portal_cls` | CLS | 同上 |
| `teacher_portal_fid_seconds` | FID | 同上 |
| `teacher_portal_ttfb_seconds` | TTFB | 同上 |
P6 接入P2-P5 暂缓。
### 3.6.5 TracerOTel browser SDKP6
```typescript
// packages/shared-ts/src/tracer.ts
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
// BatchSpanProcessor → OTLP exporter → collector → Tempo
// 自动埋点fetch、XMLHttpRequest、document load、user interaction
```
### 3.6.6 健康检查
| 端点 | 用途 | 实现 |
| ----------------- | ---------------------- | -------------------------------------------------------------- |
| `GET /api/health` | Dockerfile HEALTHCHECK | Next.js Route Handler返回 `{ status: 'ok', ts: Date.now() }` |
| `GET /api/ready` | K8s readinessProbe | 检查 `process.env.API_GATEWAY_URL` 可达 + 内存 < 阈值 |
### 3.6.7 优雅关闭
Next.js 无长连接(除 SSE/WS无需特殊处理。SSE/WS 在 P5 由 push-gateway 管理,前端断线自动重连。
## 3.7 共享组件库packages/ui-components/,待建立)
| 组件 | 用途 | 来源 |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------ |
| `AppShell` | 左侧栏 + 主内容区布局 | teacher-portal 现有 → 抽取共享 |
| `RequirePermission` | L3 组件级视口控制(无权限不渲染 children | 新建 |
| `ErrorBoundary` | React 渲染异常兜底fallback UI | 新建 |
| `Loading` | 骨架屏Skeleton | 新建 |
| `Empty` | 空态(插画 + 文案 + CTA | 新建 |
| `Modal` / `Dialog` | 全局 ModalModalRoot + Zustand ui-store | shadcn/ui |
| `Toast` | 全局 toast错误/成功/警告) | shadcn/ui sonner |
| `Button` / `Input` / `Select` / `Textarea` | 基础表单 | shadcn/ui |
| `DataTable` | 表格(排序/分页/筛选) | shadcn/ui + TanStack Table |
| `Chart` | 图表封装recharts | 新建 |
| `A11y` 工具集 | useA11yId / mergeA11yProps / describeInput / focus-trap / skip-link / visually-hidden / aria-status | 迁移指南 §7.7 |
| `Form` | react-hook-form + zodResolver 封装 | 新建 |
## 3.8 共享 Hookspackages/hooks/,待建立)
| Hook | 职责 |
| --------------------- | --------------------------------------------------- |
| `useAuth()` | 会话状态user/token/refresh/login/logout |
| `usePermission()` | 权限查询hasPermission/hasAny/hasAll + dataScope |
| `useViewports(scope)` | 视口列表(按 scope 过滤) |
| `useApi()` | ApiClient 实例(注入 token + 401 处理) |
| `useA11yId()` | 唯一 ARIA ID 生成 |
| `useAriaLive()` | aria-live 区域管理 |
| `useToast()` | 全局 toastZustand ui-store |
## 3.9 设计令牌三层packages/ui-tokens/,待建立)
```
packages/ui-tokens/
├─ primitive.css # Layer 1 原始色板/字号/间距/阴影
├─ semantic-light.css # Layer 2 语义令牌(亮色)
├─ semantic-dark.css # Layer 2 语义令牌(暗色)
├─ tailwind-theme.css # Layer 3 @theme inline 暴露 bg-*/text-*/font-*
└─ package.json
```
**强制规则**project_rules §3.10
- 禁止 `#hex` 字面量ESLint `no-restricted-syntax`
- 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量ESLint `design-tokens/no-hardcoded-fonts`
- 禁止 `font-size: Npx`(用 `var(--font-size-1~9)`
- 禁止 Tailwind 任意值 `w-[Npx]`(用 `--space-*` 或默认阶梯)
**令牌命名**(迁移指南 §7.1
| Layer 1 Primitive | Layer 2 Semantic | Layer 3 Tailwind |
| ------------------ | ------------------ | ---------------- |
| `--color-blue-500` | `--color-accent` | `bg-accent` |
| `--font-size-3` | `--font-size-body` | `text-body` |
| `--space-4` | `--space-md` | `p-md` |
---
# 4 端差异化对比表
## 4.1 整体差异
| 维度 | teacher-portal | student-portal | parent-portal | admin-portal |
| -------------- | -------------------------------------------------------- | -------------- | ------------- | --------------------------- |
| MF 角色 | Shell + Remote | Remote | Remote | Remote |
| 路由前缀 | `/teacher/*` | `/student/*` | `/parent/*` | `/admin/*` |
| 端口dev | 3000 | 3001 | 3002 | 3003 |
| 对接 BFF | teacher-bff | student-bff | parent-bff | teacher-bff 复用 + iam 直连 |
| 默认角色 | teacher / head_teacher / grade_director / subject_leader | student | parent | school_admin / system_admin |
| DataScope 默认 | L1-L5按角色 | L0 | L0 | L3-L5 |
| 推送消费 | ✅ WebSocket | ✅ WebSocket | ✅ WebSocket | ❌ 轮询 |
| AI 辅助 | ✅ 出题/备课/分析 | ❌ | ❌ | ❌ |
| 富文本编辑 | ✅ Tiptap备课/出题/反馈) | ❌ | ❌ | ❌ |
| 多子女切换 | ❌ | ❌ | ✅ | ❌ |
| 用户管理 | ❌ | ❌ | ❌ | ✅ |
| 角色权限配置 | ❌ | ❌ | ❌ | ✅ |
| 平台监控 | ❌ | ❌ | ❌ | ✅ |
## 4.2 L1 导航菜单差异
| 端 | 菜单项(视口) |
| -------------- | -------------------------------------------------------------------------------------------- |
| teacher-portal | Dashboard、班级管理、考试管理、作业管理、成绩查询、备课P5、AI 辅助P5、知识图谱P4 |
| student-portal | Dashboard、我的作业、我的考试、学情诊断P4、错题本P4、通知中心P5 |
| parent-portal | Dashboard、子女切换、成绩查看、作业查看、通知中心P5、通知偏好设置 |
| admin-portal | Dashboard、用户管理、角色管理、权限管理、视口配置、组织管理、平台监控 |
## 4.3 L2 路由表差异
### teacher-portal
| 路由 | 页面 | 权限 |
| ----------------------------- | -------------- | ------------------------ |
| `/teacher/dashboard` | 教师仪表盘 | `TEACHER_DASHBOARD_VIEW` |
| `/teacher/classes` | 班级列表 | `CLASSES_READ` |
| `/teacher/classes/:id` | 班级详情 | `CLASSES_READ` |
| `/teacher/classes/new` | 新建班级 | `CLASSES_CREATE` |
| `/teacher/exams` | 考试列表 | `EXAMS_READ` |
| `/teacher/exams/:id` | 考试详情 | `EXAMS_READ` |
| `/teacher/exams/new` | 新建考试 | `EXAMS_CREATE` |
| `/teacher/homework` | 作业列表 | `HOMEWORK_READ` |
| `/teacher/homework/:id/grade` | 批改作业 | `HOMEWORK_GRADE` |
| `/teacher/grades` | 成绩查询 | `GRADES_READ` |
| `/teacher/lesson-prep` | 备课P5 | `LESSON_PREP_VIEW` |
| `/teacher/ai-assist` | AI 辅助P5 | `AI_GENERATE` |
| `/teacher/knowledge-graph` | 知识图谱P4 | `CONTENT_READ` |
### student-portal
| 路由 | 页面 | 权限 |
| ------------------------------ | -------------- | ------------------------ |
| `/student/dashboard` | 学生仪表盘 | `STUDENT_DASHBOARD_VIEW` |
| `/student/homework` | 我的作业 | `HOMEWORK_READ_OWN` |
| `/student/homework/:id/submit` | 提交作业 | `HOMEWORK_SUBMIT` |
| `/student/exams` | 我的考试 | `EXAMS_READ_OWN` |
| `/student/exams/:id/take` | 作答考试 | `EXAMS_TAKE` |
| `/student/diagnostic` | 学情诊断P4 | `DIAGNOSTIC_READ_OWN` |
| `/student/weakness` | 错题本P4 | `WEAKNESS_READ_OWN` |
| `/student/notifications` | 通知中心P5 | `NOTIFICATION_READ_OWN` |
### parent-portal
| 路由 | 页面 | 权限 |
| ----------------------- | -------------- | --------------------------- |
| `/parent/dashboard` | 家长仪表盘 | `PARENT_DASHBOARD_VIEW` |
| `/parent/children` | 子女列表 | `PARENT_CHILDREN_VIEW` |
| `/parent/grades` | 子女成绩 | `GRADES_READ_CHILD` |
| `/parent/homework` | 子女作业 | `HOMEWORK_READ_CHILD` |
| `/parent/notifications` | 通知中心P5 | `NOTIFICATION_READ_OWN` |
| `/parent/preferences` | 通知偏好 | `PARENT_PREFERENCES_UPDATE` |
### admin-portal
| 路由 | 页面 | 权限 |
| --------------------- | ---------- | ----------------------- |
| `/admin/dashboard` | 管理仪表盘 | `ADMIN_DASHBOARD_VIEW` |
| `/admin/users` | 用户管理 | `IAM_USER_READ` |
| `/admin/users/new` | 新建用户 | `IAM_USER_CREATE` |
| `/admin/users/:id` | 用户编辑 | `IAM_USER_UPDATE` |
| `/admin/roles` | 角色管理 | `IAM_ROLE_READ` |
| `/admin/permissions` | 权限管理 | `IAM_PERMISSION_READ` |
| `/admin/viewports` | 视口配置 | `IAM_VIEWPORT_READ` |
| `/admin/organization` | 组织管理 | `ORG_MANAGE` |
| `/admin/monitoring` | 平台监控 | `ADMIN_MONITORING_VIEW` |
## 4.4 L3 组件级差异
| 组件 | teacher | student | parent | admin |
| ---------------------------------- | -------------------- | ------------------ | ------------------ | -------------------- |
| `AppShell`(左栏+主区) | ✅ | ✅(复用 Shell | ✅(复用 Shell | ✅(复用 Shell |
| `RequirePermission` | ✅ | ✅ | ✅ | ✅ |
| `ErrorBoundary` | ✅ | ✅ | ✅ | ✅ |
| `Loading` / `Empty` | ✅ | ✅ | ✅ | ✅ |
| `DataTable` | ✅(班级/考试列表) | ✅(作业列表) | ✅(成绩列表) | ✅(用户列表) |
| `Form` | ✅(创建班级/考试) | ✅(提交作业) | ✅(通知偏好) | ✅(用户/角色 CRUD |
| `Chart` | ✅(班级成绩分布) | ✅(个人学情趋势) | ✅(子女成绩趋势) | ✅(平台监控) |
| `RichTextEditor`Tiptap | ✅(备课/出题/反馈) | ❌ | ❌ | ❌ |
| `ChildSwitcher` | ❌ | ❌ | ✅ | ❌ |
| `ExamTaking`(倒计时+自动保存) | ❌ | ✅ | ❌ | ❌ |
| `SSEViewer`AI 流式) | ✅ | ❌ | ❌ | ❌ |
| `UserManagementTable` | ❌ | ❌ | ❌ | ✅ |
| `RolePermissionMatrix` | ❌ | ❌ | ❌ | ✅ |
| `ViewportConfigEditor` | ❌ | ❌ | ❌ | ✅ |
| `PlatformMonitor`Grafana embed | ❌ | ❌ | ❌ | ✅ |
## 4.5 L4 数据层差异
| 端 | 主要数据来源 | 缓存策略 |
| -------------- | -------------------------------------------------------- | --------------------------------- |
| teacher-portal | teacher-bff聚合 iam + core-edu + content + data-ana | 5-30s 短缓存 |
| student-portal | student-bff聚合 iam + core-edu + data-ana | 5-30s 短缓存,作业列表 30s |
| parent-portal | parent-bff聚合 iam + core-edu + data-ana含子女关联 | 5-30s 短缓存,子女切换 invalidate |
| admin-portal | iam 直连 + teacher-bff 复用 | 5min 长缓存(管理数据低频变) |
---
# 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 | 阶段 |
| ------ | ------------ | ------------------ | --------------------------------------------------- | ---------------------------- | ---- |
| 调用 | api-gateway | HTTP/REST | `/api/v1/*` 代理 | 全部业务请求 | P1+ |
| 调用 | push-gateway | WebSocket | `ws://push-gateway/ws` | 实时推送 | P5 |
| 调用 | ai | SSE | `GET /api/v1/ai/generate-questions` | AI 流式出题 | P5 |
| 被调用 | — | — | — | 前端不暴露接口给其他服务 | — |
| 消费 | teacher-bff | HTTP经 Gateway | `GET /teacher/viewports` 等 | 教师场景聚合 | P2+ |
| 消费 | student-bff | HTTP经 Gateway | `GET /student/viewports` 等 | 学生场景聚合 | P3+ |
| 消费 | parent-bff | HTTP经 Gateway | `GET /parent/viewports` 等 | 家长场景聚合 | P4+ |
| 消费 | iam | HTTP经 Gateway | `/iam/*` | 登录/权限/视口/用户管理 | P2+ |
| 消费 | core-edu | HTTP经 Gateway | `/classes/*` `/exams/*` `/homework/*` `/grades/*` | 教学核心 | P2+ |
| 消费 | content | HTTP经 Gateway | `/textbooks/*` `/knowledge-points/*` `/questions/*` | 内容资源 | P4+ |
| 消费 | data-ana | HTTP经 Gateway | `/analytics/*` | 学情分析 | P4+ |
| 消费 | msg | HTTP经 Gateway | `/notifications/*` | 通知中心 | P5+ |
| 依赖 | coord 维护 | — | `packages/shared-proto` | TS 类型(仅 contracts 部分) | P1+ |
| 依赖 | coord 维护 | — | `packages/shared-ts`(待建) | ApiClient/Logger/通用工具 | P2+ |
| 依赖 | ai07 维护 | — | `packages/ui-tokens`(待建) | 三层设计令牌 | P2+ |
| 依赖 | ai07 维护 | — | `packages/ui-components`(待建) | shadcn + 共享组件 | P2+ |
| 依赖 | ai07 维护 | — | `packages/hooks`(待建) | usePermission/useAuth 等 | P2+ |
| 依赖 | coord 维护 | — | `packages/contracts`(待建) | Permissions 常量 + 类型 | P2+ |
> **proto 不直接消费**:前端不调用 gRPCBFF 把 gRPC 聚合为 REST/GraphQL 暴露给前端。前端仅消费 `packages/contracts/src/permissions.ts` 中的权限点常量TS 文件,非 proto 生成)。
---
# 风险与假设
## 8.1 假设
1. **假设 coord 建立 `packages/shared-ts`、`packages/contracts`**:包含 ApiClient、Logger、Permissions 常量、通用类型。若 coord 未建立ai07 自行在 `apps/teacher-portal/src/shared/` 内实现,后续提取到 packages。
2. **假设 ai02 iam 提供 `GET /iam/effective-permissions`**:返回 `{ permissions, viewports, dataScope }`。当前已实现known-issues §2.3 iam
3. **假设 ai03 teacher-bff 提供 `GET /teacher/viewports`**:返回 L1 导航视口。当前已实现。
4. **假设 ai03 core-edu classes 模块维持 `ActionState` 响应结构**:前端 API 请求层依赖此契约。
5. **假设 Next.js 14+ Module Federation 2.0 稳定**`@module-federation/nextjs-mf` 在 Next.js App Router 下可用。若不稳定,降级为 4 端独立部署 + 各自 Shell重复实现 AppShell
## 8.2 技术风险
| 风险 | 影响 | 缓解 |
| ------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| MF SSR 对齐复杂 | Remote 在 SSR 时需 Shell 提供上下文 | 优先 CSRSSR 仅用于首屏 dashboardMF 2.0 支持 SSR |
| 共享依赖版本漂移 | Remote 与 Shell 的 react/react-dom 版本不一致导致运行时错误 | MF `shared.singleton: true` + CI 检查版本对齐 |
| Token 刷新竞态 | 多请求同时 401 触发多次 refresh | ApiClient 全局单例 + refresh promise 复用 |
| 权限缓存陈旧 | 角色变更后前端 5min 内仍用旧权限 | iam 角色变更发 Kafka 事件 → msg 推送 WebSocket → 前端 invalidate |
| 设计令牌迁移破坏现有样式 | teacher-portal 现有硬编码令牌迁移到三层模型后样式漂移 | 灰度迁移:先建 ui-tokens 包teacher-portal 引入但不删除旧 globals.css验证后切换 |
| 4 端独立部署运维成本 | 4 个 Next.js 实例 = 4 倍内存 | Shell + 3 Remote 共享 node_modulesMF 运行时共享),实际内存增量 < 2x |
| TanStack Query 缓存膨胀 | 长时间使用后缓存项过多 | `gcTime` 5min + `staleTime` 按数据类型分级 |
## 8.3 未决设计决策(需 coord 仲裁)
1. **packages 归属**`ui-tokens` / `ui-components` / `hooks` 是 ai07 维护还是 coord 维护建议ai07 维护前端专属coord 仅维护 `shared-ts` / `contracts`(跨语言/跨服务)。
2. **GraphQL vs REST**004 §11.3 提到 BFF GraphQL Yoga + DataLoader但当前 teacher-bff 实现为 REST。前端 API 请求层是否需要 GraphQL clienturql/apollo建议P2-P3 用 RESTP4 起若 BFF 切 GraphQL 再引入 urql。
3. **i18n key 命名**`iam.error.IAM_INVALID_CREDENTIALS` 还是 `error.iam.invalid_credentials`?建议:`error.{{service}}.{{code_snake_case}}`,与错误码前缀对齐。
4. **MF 暴露粒度**Shell 暴露整个 AppShell 还是暴露更细粒度的组件Sidebar、Header、Content建议暴露 AppShell 整体 + 各 Remote 自行决定内部布局。
---
# coord 交叉审查所需信息
## 9.1 端口矩阵4 端)
| 端 | dev 端口 | 生产端口 | 备注 |
| -------------- | -------- | -------- | ---------- |
| teacher-portal | 3000 | 3000 | Shell 宿主 |
| student-portal | 3001 | 3001 | Remote |
| parent-portal | 3002 | 3002 | Remote |
| admin-portal | 3003 | 3003 | Remote |
> 与 [full-stack-runbook](../standards/full-stack-runbook.md) 端口矩阵对齐3000-3003 前端3001-3003 已被 Grafana3030/其他服务避让。
## 9.2 依赖的共享包(需 coord 建立)
| 包 | 路径 | 维护方 | 内容 |
| --------------- | ------------------------- | ------------ | ------------------------------------------------- |
| `shared-ts` | `packages/shared-ts/` | coord | ApiClient、Logger、通用工具 |
| `contracts` | `packages/contracts/` | coord | Permissions 常量、ActionState 类型、UserInfo 类型 |
| `ui-tokens` | `packages/ui-tokens/` | ai07建议 | 三层设计令牌 |
| `ui-components` | `packages/ui-components/` | ai07建议 | shadcn + ErrorBoundary + RequirePermission |
| `hooks` | `packages/hooks/` | ai07建议 | usePermission、useAuth、useViewports |
## 9.3 依赖的后端契约(需对应 AI 确认)
| 契约 | 提供方 | 当前状态 |
| ------------------------------------------------------------------ | ---------------------------- | ------------------- |
| `POST /iam/login``GET /iam/effective-permissions``GET /iam/me` | ai02 iam | ✅ 已实现 |
| `GET /teacher/viewports``GET /teacher/dashboard` | ai03 teacher-bff | ✅ 已实现 |
| `/classes/*` CRUD | ai03 core-edu | ✅ 已实现 |
| `/exams/*` `/homework/*` `/grades/*` | ai03 core-edu | ✅ 已实现P3 |
| `/textbooks/*` `/knowledge-points/*` `/questions/*` | ai05 content | ✅ 已实现P4 |
| `/analytics/*` | ai06 data-ana | ✅ 已实现P4 CDC |
| `GET /student/viewports` 等 | ai04 student-bff | 📐 待 ai04 设计 |
| `GET /parent/viewports` 等 | ai04 parent-bff | 📐 待 ai04 设计 |
| `/notifications/*` + WebSocket 推送 | ai05 msg + ai01 push-gateway | 📐 待 P5 |
| `GET /ai/generate-questions`SSE | ai06 ai | 📐 待 P5 |
## 9.4 错误码前缀(前端 i18n 路由依赖)
前端不产生错误码,仅消费。需各服务确认错误码前缀不重叠:
| 前缀 | 服务 | 状态 |
| ----------------------------------------------- | ---------------- | -------------------- |
| `IAM_` | iam | ✅ ai02 已用 |
| `CLASSES_` | core-edu/classes | ✅ 已用 |
| `EXAMS_` / `HOMEWORK_` / `GRADES_` | core-edu | ⚠️ 待 ai03 确认 |
| `CONTENT_` | content | ⚠️ 待 ai05 确认 |
| `MSG_` | msg | ⚠️ 待 ai05 确认 |
| `AI_` | ai | ⚠️ 待 ai06 确认 |
| `BFF_TEACHER_` / `BFF_STUDENT_` / `BFF_PARENT_` | 3 BFF | ⚠️ 待 ai03/ai04 确认 |
| `GW_` | api-gateway | ✅ ai01 已用 |
| `NETWORK_` | 前端 | ai07 自有 |
## 9.5 不产生 Kafka 事件
前端不发布/消费 Kafka 事件。WebSocket 推送由 push-gateway 消费 Kafka 转发。
---
# 实施路线ai07 自用)
## P2 收尾teacher-portal 审计对齐)
1.`packages/ui-tokens/`(三层设计令牌)+ `packages/ui-components/`ErrorBoundary/RequirePermission/Loading/Empty+ `packages/hooks/`usePermission/useAuth
2. teacher-portal 引入 TanStack Query + Zustand + nuqs + react-hook-form
3. 抽取 `lib/api.ts` 统一 API 请求层
4. AppShell 改用 `usePermission()`,删除 `user.roles.join(", ")` 硬编码
5. globals.css / tailwind.config.js 迁移到 ui-tokens 三层令牌
6. 引入 next-intl + i18n key 路由
7. 引入 ESLint flat config 自定义规则no-hardcoded-fonts / design-tokens
8. 补 ErrorBoundary + /api/health route
9. 配置 next.config.js Module FederationShell 角色)
10. 补 Vitest 单测 + Playwright E2E覆盖率 ≥ 80%
## P3student-portal
1.`apps/student-portal/`Remote 角色)
2. 配置 MFexposes pagesremotes teacher
3. 实现 Dashboard + 我的作业 + 提交作业 + 我的考试 + 作答考试
4. 复用 Shell 的 AppShell + 共享组件
5. SSE 接入(考试作答自动保存)
## P4parent-portal
1.`apps/parent-portal/`Remote 角色)
2. 实现 Dashboard + 子女切换 + 成绩查看 + 通知偏好
3. 多子女状态管理Zustand slice
## P5推送 + AI 接入)
1. teacher-portal 接入 WebSocketpush-gateway
2. teacher-portal AI 辅助出题SSE + Tiptap
3. student/parent-portal 接入通知推送
## P6admin-portal + 硬化)
1.`apps/admin-portal/`Remote 角色)
2. 实现用户/角色/权限/视口/组织/监控管理
3. Web Vitals + OTel browser SDK 接入
4. A11y WCAG 2.2 AA 审计
5. 性能优化MF shared 单例验证、bundle 分析)
---
**AI Agent**: ai07 (teacher-portal / student-portal / parent-portal / admin-portal)
**Branch**: docs/teacher-portal-stage1-stage2-design-ai07
**Coordinator**: coord-ai

View File

@@ -156,7 +156,7 @@ graph TB
subgraph D4["D4 内容资源领域"] subgraph D4["D4 内容资源领域"]
CONTENT[content 服务] CONTENT[content 服务]
CONTENT_M[textbooks / knowledge-points<br/>questions / grading / search(ES)] CONTENT_M[textbooks / knowledge-points<br/>questions / grading / search]
end end
subgraph D5["D5 沟通通知领域"] subgraph D5["D5 沟通通知领域"]
@@ -167,19 +167,19 @@ graph TB
subgraph D6["D6 智能洞察领域"] subgraph D6["D6 智能洞察领域"]
DATA[data-ana 服务] DATA[data-ana 服务]
AI[ai 服务] AI[ai 服务]
DATA_M[analytics / dashboard / diagnostic(ClickHouse)] DATA_M[analytics / dashboard / diagnostic]
AI_M[ai 备课/出题/分析 / search] AI_M[AI 备课 / 出题 / 分析 / 搜索]
end end
D1 --> D2 IAM --> ORG
D1 --> D3 IAM --> TEACH
D1 --> D4 IAM --> CONTENT
D1 --> D5 IAM --> MSG
D2 --> D3 ORG --> TEACH
D3 --> D4 TEACH --> CONTENT
D3 --> D5 TEACH --> MSG
D4 --> D6 CONTENT --> DATA
D3 --> D6 TEACH --> DATA
``` ```
**双图并存说明** **双图并存说明**
@@ -517,24 +517,24 @@ graph LR
Cmd[Command 命令] --> App[Application Service] Cmd[Command 命令] --> App[Application Service]
App --> Domain[Domain 领域模型] App --> Domain[Domain 领域模型]
Domain --> Repo[Repository 写模型] Domain --> Repo[Repository 写模型]
Repo -->[(MySQL 主库)] Repo --> mysql_w[(MySQL 主库)]
App --> Outbox[(Outbox 表<br/>同事务)] App --> Outbox[(Outbox 表<br/>同事务)]
end end
subgraph Sync["同步链路"] subgraph Sync["同步链路"]
Outbox --> Relay[Relay Worker] Outbox --> Relay[Relay Worker]
Relay --> Kafka[(Kafka)] Relay --> kafka_sync[(Kafka)]
Kafka --> Proj[Projection] kafka_sync --> Proj[Projection]
Proj -->[(ClickHouse 宽表)] Proj --> ch_sync[(ClickHouse 宽表)]
Proj -->[(Redis 缓存)] Proj --> redis_sync[(Redis 缓存)]
Proj -->[(ES 索引)] Proj --> es_sync[(ES 索引)]
end end
subgraph Read["读路径"] subgraph Read["读路径"]
Query[Query 查询] --> ReadModel[Read Model] Query[Query 查询] --> ReadModel[Read Model]
ReadModel -->[(ClickHouse 宽表)] ReadModel --> ch_read[(ClickHouse 宽表)]
ReadModel -->[(Redis 缓存)] ReadModel --> redis_read[(Redis 缓存)]
ReadModel -->[(ES 索引)] ReadModel --> es_read[(ES 索引)]
end end
``` ```
@@ -581,7 +581,7 @@ graph LR
Outbox[(Outbox 表)] Outbox[(Outbox 表)]
end end
subgraph MySQL[("MySQL 主库")] subgraph MySQL["MySQL 主库"]
BizTable[(业务表)] BizTable[(业务表)]
OutboxTable[(outbox 表)] OutboxTable[(outbox 表)]
end end
@@ -593,7 +593,7 @@ graph LR
end end
subgraph Bus["事件总线"] subgraph Bus["事件总线"]
Kafka[(Kafka topic)] kafka_bus[(Kafka topic)]
end end
subgraph Consumers["消费者"] subgraph Consumers["消费者"]
@@ -607,10 +607,10 @@ graph LR
Repo --> OutboxTable Repo --> OutboxTable
OutboxTable --> Poll OutboxTable --> Poll
Poll --> Publish Poll --> Publish
Publish --> Kafka Publish --> kafka_bus
Kafka --> Proj kafka_bus --> Proj
Kafka --> OtherSvc kafka_bus --> OtherSvc
Proj -->[(ClickHouse/Redis/ES)] Proj --> read_stores[(ClickHouse / Redis / ES)]
``` ```
### 7.2 事件 Topic 分类 ### 7.2 事件 Topic 分类

View File

@@ -0,0 +1,401 @@
# AI 分配方案与架构设计外包流程
> 版本1.0
> 日期2026-07-09
> 适用范围Edu 微服务项目模块架构设计外包阶段
> 关联文档:[多 AI 协作指南](../standards/multi-ai-collaboration.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[待开发功能路线图](./roadmap/pending-features.md)
---
## 1. 外包总流程:三阶段
```
阶段 1全局理解 阶段 2模块架构设计 阶段 3按图实施
(每个 AI 独立) (每个 AI 独立) (并行开发)
│ │ │
阅读全局架构文档 产出模块内部架构图 按自己画的图写代码
理解边界与契约 定义内部模块/数据流 coord 定期巡检一致性
理解与其他模块的接口 标注与其他模块的交互点 遇到偏差更新架构图
│ │ │
▼ ▼ ▼
交付:理解确认书 交付:模块架构设计文档 交付:代码 + 更新图
```
**阶段 1 目标**:每个 AI 读懂自己负责的模块在全局架构中的位置、边界、契约。
**阶段 2 目标**:每个 AI 产出自己模块的内部架构设计,经过 coord 交叉审查后放行。
**阶段 3 目标**按设计文档写代码coord 定期巡检一致性。
---
## 2. 完整服务清单
| 类别 | 服务名 | 语言/框架 | 限界上下文 | 阶段 | 状态 |
| ---- | -------------- | ---------------- | ---------------------- | ------ | ----------------- |
| 网关 | api-gateway | Go (Gin) | API 网关 | P1 | ✅ 已实现 |
| 网关 | push-gateway | Go (Gin) | 推送网关 | P5 | 📐 需设计 |
| BFF | teacher-bff | TS (NestJS) | 教学场景域聚合 | P2 | ✅ 已实现 |
| BFF | student-bff | TS (NestJS) | 学习场景域聚合 | P3 | 📐 需设计 |
| BFF | parent-bff | TS (NestJS) | 家长场景域聚合 | P4 | 📐 需设计 |
| 业务 | iam | TS (NestJS) | 身份认证 | P2 | ✅ 已实现 |
| 业务 | core-edu | TS (NestJS) | 教学核心(含 classes | P3 | 📐 待合并 classes |
| 业务 | content | TS (NestJS) | 内容资源 | P4 | 📐 需设计 |
| 业务 | msg | TS (NestJS) | 消息通知 | P5 | 📐 需设计 |
| 业务 | data-ana | Python (FastAPI) | 数据分析 | P4 | 📐 需设计 |
| 业务 | ai | Python (FastAPI) | AI 网关 | P5 | 📐 需设计 |
| 前端 | teacher-portal | TS (Next.js) | 教学场景域前端 | P2 | ✅ 已实现 |
| 前端 | student-portal | TS (Next.js) | 学习场景域前端 | P3 | 📐 需设计 |
| 前端 | parent-portal | TS (Next.js) | 家长场景域前端 | P4 | 📐 需设计 |
| 前端 | admin-portal | TS (Next.js) | 管理场景域前端 | P6 | 📐 需设计 |
| 共享 | shared-proto | protobuf | 契约 | 跨阶段 | ✅ 部分 |
| 共享 | shared-ts | TS | TS 共享工具 | 跨阶段 | — |
| 共享 | shared-go | Go | Go 共享工具 | 跨阶段 | — |
| 共享 | shared-py | Python | Python 共享工具 | 跨阶段 | — |
| 基础 | infra | — | K8s/Grafana/WAF | 跨阶段 | ✅ 部分 |
> 状态标记:✅ 已实现需审计 | 📐 需架构设计(本次外包核心产出)
---
## 3. AI 分配方案7 AI + 1 coord
### 3.1 分配原则
- **同语言内聚**:一个 AI 负责多个同语言服务,学习成本只付一次
- **领域亲缘性**同类业务放一起BFF 归 BFF、Python 归 Python
- **工作负载均衡**Neo4j+ES 的内容服务、ClickHouse 的分析服务复杂度高,不绑太多其他服务
- **前端统一**Module Federation 微前端由一人设计,保证 shell + remote 架构一致
- **黄金模板对齐**:已实现的 services 负责 AI 需审计并对齐 classes 标准
### 3.2 分配矩阵
| AI 标识 | 语言 | 服务 | 数量 | 阶段归属 |
| --------- | ------ | ----------------------------------------------------------- | ---- | -------- |
| **ai01** | Go | api-gateway、push-gateway | 2 | P1 + P5 |
| **ai02** | TS | iam | 1 | P2 |
| **ai03** | TS | teacher-bff、core-edu | 2 | P2 + P3 |
| **ai04** | TS | student-bff、parent-bff | 2 | P3 + P4 |
| **ai05** | TS | content、msg | 2 | P4 + P5 |
| **ai06** | Python | data-ana、ai | 2 | P4 + P5 |
| **ai07** | TS | teacher-portal、student-portal、parent-portal、admin-portal | 4 | P2-P6 |
| **coord** | — | shared-proto、shared-*、infra/、docs/、CI/CD | — | 跨阶段 |
### 3.3 为什么这样拆
| 决策 | 理由 |
| ----------------------------- | ------------------------------------------------------------------------------- |
| ai02 独立负责 iam | RBAC 三层角色 + DataScope 6 级 + 视口 4 层是整个系统的权限中枢,复杂度最高 |
| ai03 teacher-bff + core-edu | 教学域全栈BFF 聚合 + 核心业务。考试/作业/成绩状态机在一个人手里,不跨 AI 协调 |
| ai04 student-bff + parent-bff | 两个 BFF 都是纯聚合层技术同质GraphQL + DataLoader设计模式完全复用 |
| ai05 content + msg | 都依赖 EScontent 建索引、msg 查索引,一人设计避免 ES 索引冲突 |
| ai07 前端 4 端 | Module Federation shell + remote 架构需一人统一设计4 端共享组件库和权限体系 |
| coord 不写业务代码 | 专注契约管理 + 交叉审查,保证 7 份设计文档的接口一致性 |
---
## 4. 各 AI 阶段 1 必读文档清单
以下为每个 AI 在阶段 1 必须按顺序阅读的文档(标注 ★ 为强制必读):
| 顺序 | 文档 | ai01 | ai02 | ai03 | ai04 | ai05 | ai06 | ai07 | coord |
| ---- | ------------------------------------------------------------------- | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :---: |
| 1 | [README.md](../../README.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 2 | [MIGRATION_GUIDE.md](../../MIGRATION_GUIDE.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 3 | [004 架构影响地图](./004_architecture_impact_map.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 4 | [pending-features.md](./roadmap/pending-features.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 5 | [project_rules.md](../../.trae/rules/project_rules.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 6 | [coding-standards.md](../standards/coding-standards.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 7 | [multi-ai-collaboration.md](../standards/multi-ai-collaboration.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 8 | 黄金模板 `services/classes/src/` 全部源码 | ★ | ★ | ★ | ★ | — | — | — | ★ |
| 9 | `packages/shared-proto/proto/` 全部 .proto | ★ | ★ | ★ | ★ | ★ | ★ | — | ★ |
**语言特定补充阅读**
| AI | 语言 | 补充文档 |
| ------- | -------- | --------------------------------------------------------------- |
| ai01 | Go | `services/api-gateway/` 全部源码 |
| ai02-05 | TS | `services/iam/``services/teacher-bff/` 源码(参考已实现模板) |
| ai06 | Python | `services/data-ana/``services/ai/` 骨架源码 |
| ai07 | TS/React | `apps/teacher-portal/` 全部源码、Module Federation 配置 |
---
## 5. 各 AI 阶段 2 设计重点
### ai01 — Go 网关层
| 服务 | 设计重点 |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| api-gateway | 路由表矩阵(路径 → 下游服务 + 端口,需覆盖全部 6 个业务服务 + 3 个 BFF限流策略表每路由 QPS熔断阈值配置错误率/延迟阈值JWT RS256 公钥校验流程CORS 白名单;请求 ID 注入 |
| push-gateway | WebSocket 连接生命周期(认证 → 心跳 → 断线重连);与 msg 的 gRPC 推送通道协议;用户 session 映射(在线用户 → WebSocket 连接水平扩展方案Redis Pub/Sub 跨实例广播) |
### ai02 — 身份认证
| 服务 | 设计重点 |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| iam | RBAC 权限点枚举(全部模块的 CRUD 权限常量);三层角色模型(系统/组织/临时权限合并规则DataScope 6 级 SQL WHERE 注入规则(每级对应的过滤条件);视口 4 层配置表设计(导航/路由/组件/数据JWT RS256 私钥签发 + 公钥暴露端点refresh_token 轮换策略;权限解析 APIgetEffectivePermissions → permissions + viewports + dataScope |
### ai03 — 教学场景域
| 服务 | 设计重点 |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| teacher-bff | GraphQL schemaQuery/Mutation按场景域组织DataLoader 批量去重策略;并行 gRPC 调用编排;聚合结果缓存 TTL 策略5-30s 短缓存);教师角色差异化(教师 vs 教导主任 vs 教研组长 → 视口推导) |
| core-edu | classes 模块黄金模板对齐;考试生命周期状态机(草稿 → 已发布 → 作答中 → 批改中 → 已出分 → 已归档Outbox 事件定义ExamPublished、HomeworkSubmitted、GradeRecorded成绩计算公式与配置化作业提交高并发优化Redis 分布式锁 + 排队);排课/考勤数据模型 |
### ai04 — 学习 + 家长场景域 BFF
| 服务 | 设计重点 |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| student-bff | 学生端 GraphQL schema与 teacher-bff 对比差异DataLoader 复用 teacher-bff 模式权限区分学生只能看自己的数据DataScope=SELF考试/作业/成绩的学生视角 API |
| parent-bff | 家长端 GraphQL schema与 iam 的学生-家长关联查询;多子女账户切换设计;家长通知偏好配置 |
### ai05 — 内容 + 通知
| 服务 | 设计重点 |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| content | Neo4j 图模型(知识点 → 知识点前置依赖 → 教材关联ES 索引 mapping 设计(题库全文检索 + 标签过滤);题库 CRUD 完整 API含批量导入与 ai 服务的 gRPC 接口(查询知识点/题库用于 AI 出题);教材/章节结构树 |
| msg | 通知渠道抽象(站内信/邮件/短信策略模式ES 降级查询策略DB 不可用时走 ESKafka 消费幂等设计event_id 去重);与 push-gateway 的推送通道协议;通知模板管理;已读/未读状态管理 |
### ai06 — Python 数据 + AI
| 服务 | 设计重点 |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data-ana | ClickHouse 宽表设计(考试、作业、成绩、掌握度、出勤 5 张宽表CDC 消费者架构Debezium → Kafka → ClickHouse学情分析 API班级统计/个人趋势/预警阈值掌握度计算算法加权滑动平均Dashboard 数据聚合 |
| ai | LLM Provider 适配器模式OpenAI/百川/本地模型SSE 流式响应(题目逐字生成);出题 Prompt 模板管理;备课工作流(分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库);用量计费/频率限制 |
### ai07 — 前端 4 端
| 服务 | 设计重点 |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| teacher-portal | 现有代码审计对齐黄金标准Module Federation shell 暴露的共享组件 |
| 全部 4 端 | Module Federation shell + remote 架构设计路由骨架4 端路由表对照);共享组件库(错误边界 ErrorBoundary、Loading 骨架屏、Empty 空态、权限控制组件);`usePermission().hasPermission()` 统一权限 HookAPI 请求层统一错误处理toast 提示4 端差异化对比表(导航菜单/路由/组件/数据 4 层差异) |
### coord — 协调 AI
| 职责 | 具体内容 |
| ---------------- | ------------------------------------------------------------------------------------------------------- |
| proto 契约维护 | 统一管理 `packages/shared-proto/`,跨 AI 的 proto 变更唯一入口 |
| 设计文档交叉审查 | 审查 7 份模块架构设计文档的跨模块接口一致性接口签名匹配、topic 不重复、端口不冲突、错误码前缀不重叠) |
| 黄金模板维护 | 维护 classes 黄金模板标准,审查其他服务对齐情况 |
| 架构文档同步 | 各 AI 产出设计文档后,同步更新 `004_architecture_impact_map.md` |
| 共享包管理 | shared-ts / shared-go / shared-py 建立与维护 |
| CI/CD | `.github/workflows/ci.yml` 覆盖全部 15 服务 |
| 基础设施 | `infra/` K8s/Grafana/WAF/灾备(可由 SRE AI 协助) |
---
## 6. 阶段 1 交付物模板
每个 AI 阅读完 §4 的文档清单后,必须产出以下确认书:
```markdown
## 模块理解确认书 — [模块名]
### 1. 我在架构中的位置
- 层级Gateway / BFF / Service / Data / Frontend
- 上游:谁调用我?
- 下游:我调用谁?
- 通信方式HTTP / gRPC / Kafka / WebSocket / 直接 DB
### 2. 我的限界上下文
- 我负责哪些聚合/实体?
- 我的数据属于哪个业务领域D1-D6 中的哪个)?
- 我不负责什么(明确边界外的东西)?
### 3. 我与外部的契约
- 我消费哪些 proto message从 shared-proto
- 我暴露哪些 API 端点或 gRPC 方法或 Kafka 事件?
- 错误码前缀是什么?
### 4. 我的技术栈
- 语言 / 框架 / ORM / 存储
### 5. 我的阶段归属
- 属于 P1-P6 哪个阶段?
- 当前阶段目标是什么?
- 依赖哪些上游阶段的产出?
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [ ] 权限装饰器 @RequirePermission(全部 Controller 方法)
- [ ] 错误码前缀统一
- [ ] logger / metrics / tracer 三支柱
- [ ] /healthz + /readyz 健康检查
- [ ] 优雅关闭SIGTERM
- [ ] 测试覆盖率 ≥ 80%
- [ ] Dockerfile 多阶段构建
- [ ] Zod 输入验证
- [ ] GlobalErrorFilter 统一兜底
```
---
## 7. 阶段 2 交付物模板
每个 AI 在阶段 1 确认书通过 coord 审核后,产出以下架构设计文档:
```markdown
## 模块架构设计文档 — [模块名]
### 1. 模块内部分层图
[画图Controller → Guard → Service → Repository → DB 调用链]
[标注中间件、Guard、Filter 的拦截点]
### 2. 领域模型
- 聚合根:有哪些?
- 实体/值对象:有哪些?
- 聚合间如何通信?(同服务内直接调用 / 跨服务走事件)
### 3. 数据模型
- 有哪些表?(列出 schema标注字段类型、约束
- 每张表的索引策略(主键、唯一索引、查询索引)
- 读写分离策略(哪些走主库、哪些走读模型)
### 4. API 设计
| method | path | 权限 | 请求/响应结构 | 说明 |
| ------ | ---- | ---------- | ------------- | ---- |
| POST | /xxx | XXX_CREATE | { ... } | ... |
### 5. 事件设计(如适用)
- 我发布哪些领域事件?触发时机是什么?
- 我消费哪些外部事件?消费后做什么?
- 事件 Topic 名称(遵循 `edu.<domain>.<aggregate>.<action>` 格式)
### 6. 横切关注点对齐清单
- [ ] 权限装饰器(列出所有端点及对应权限常量)
- [ ] 错误码清单(带前缀,每个错误码 → 触发条件 → HTTP 状态码)
- [ ] Logger 初始化位置与配置pino/zap/structlog
- [ ] Metrics 指标清单(指标名 / 类型 / 标签 / 描述)
- [ ] Tracer 初始化位置OTLP endpoint
- [ ] /healthz 检查逻辑
- [ ] /readyz 检查逻辑DB SELECT 1 / Redis PING / Kafka 连接)
- [ ] 优雅关闭顺序HTTP server → DB → Redis → Kafka
### 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | -------- | ----- | --------- | ---- |
| 调用 | xxx | gRPC | XxxMethod | ... |
| 被调用 | xxx | gRPC | YyyMethod | ... |
| 发布 | — | Kafka | topic 名 | ... |
| 消费 | — | Kafka | topic 名 | ... |
### 8. 风险与假设
- 我假设 [某服务] 提供了 [某接口],如果没提供我的 fallback 是什么?
- 我的模块有哪些技术风险?(性能瓶颈、数据一致性、外部依赖)
- 有哪些未决的设计决策需要协调 AI 仲裁?
```
---
## 8. 交叉审查规则
coord 收到全部 7 份设计文档后,执行以下审查:
### 8.1 接口一致性检查
```markdown
| 服务 A 说 | 服务 B 说 | 是否匹配 |
| --------------------------------------------------------- | ---------------------------------------------- | --------- |
| ai02 iam: 暴露 getUserInfo(userId) | ai03 teacher-bff: 调用 iam.getUserInfo(userId) | ✅ |
| ai03 core-edu: 调用 content.getKnowledgePoints(subjectId) | ai05 content: ??? | ⚠️ 待确认 |
```
### 8.2 全局冲突检查
| 检查项 | 检查方式 |
| -------------------- | ---------------------------------------------------------------------- |
| 端口不冲突 | 对照 [full-stack-runbook](../standards/full-stack-runbook.md) 端口矩阵 |
| Topic 不重复 | 汇总全部 AI 的 §5 事件设计,去重检查 |
| 错误码前缀不重叠 | 汇总全部 AI 的 §6 错误码清单,前缀唯一性检查 |
| Proto message 不遗漏 | 检查全部"跨模块交互点"是否在 proto 中有对应定义 |
### 8.3 黄金模板对齐检查
| 检查项 | 全部 TS NestJS 服务 |
| ----------------------- | ---------------------- |
| @RequirePermission 覆盖 | 每个 Controller 方法 |
| 错误码前缀 | 用服务名大写前缀 |
| /healthz + /readyz | 存在且逻辑正确 |
| Zod 输入验证 | Controller 层解析 body |
| GlobalErrorFilter | 注册到 AppModule |
| Dockerfile 多阶段 | builder + runtime |
---
## 9. 协作规则
### 9.1 单仓库并行开发
当前阶段采用**单仓库直接 push main**模式,不经过 PR
- 每个 AI 只能修改自己负责的目录(见 §3.2
- `packages/shared-proto/` 仅 coord 修改,其他 AI 只读
- `docs/``.trae/``infra/``.github/` 仅 coord 修改
### 9.2 唯一冲突文件处理
`pnpm-lock.yaml` 是唯一可能多 AI 同时修改的文件,冲突时:
```bash
git pull origin main --rebase
# 冲突时:
git checkout --theirs pnpm-lock.yaml # 取远程版本
pnpm install # 重新生成
git add pnpm-lock.yaml
git rebase --continue
```
### 9.3 提交规范
```bash
# 每个 AI 在自己的服务目录内工作
git add services/<service>/...
git commit -m "docs(<service>): 模块架构设计文档"
# 或
git commit -m "docs(<service>): 阶段1理解确认书"
```
### 9.4 proto 变更流程
任何 AI 需要新增/修改 proto
1. 在共享协调渠道声明需求(格式:`# proto-change: <描述>`
2. coord 统一修改 `packages/shared-proto/`
3. coord 通知受影响 AI 更新设计文档
---
## 10. 审计模板(阶段 1 自检用)
每个 AI 审计自己负责的已实现服务时,填写下表:
```markdown
## 服务审计表 — [AI标识]
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ---- | ---------- | ---------- | -------- | -------- | -------- | -------- | -------- | -------- | ---------- | ---------- |
| xxx | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | XX% | ✅/❌/⚠️ |
```
---
## 11. 相关文档
- [多 AI 协作指南](../standards/multi-ai-collaboration.md) — 日常开发协作流程
- [004 架构影响地图](./004_architecture_impact_map.md) — 全局架构与依赖
- [项目规则](../../.trae/rules/project_rules.md) — 强制约束
- [编码规范](../standards/coding-standards.md) — 多语言编码标准
- [待开发功能路线图](./roadmap/pending-features.md) — 六阶段目标

View File

@@ -0,0 +1,216 @@
# ai03 阶段 1 交付物:模块理解确认书
> AI 标识ai03
> 负责模块teacher-bffP2、core-eduP3
> 阶段:架构设计外包 · 阶段 1全局理解
> 日期2026-07-09
> 关联文档:[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)
---
## 模块理解确认书 — teacher-bff
### 1. 我在架构中的位置
- **层级**BFF 聚合层L4
- **上游**api-gatewayGo/Gin通过 HTTP 转发请求,注入 `x-user-id` / `x-user-roles`
- **下游**iam3002、classes3001、core-edu3004P3 后扩展 content、data-ana、ai
- **通信方式**
- 当前REST `fetch`(同步)
- 目标态004 §4.1 / pending-features P2**gRPC** 调下游业务服务 + **GraphQL Yoga** 对前端暴露 + DataLoader 防 N+1
- **端口**3003见 [teacher-bff env.ts](../../services/teacher-bff/src/config/env.ts)
### 2. 我的限界上下文
- **聚合职责**:教学场景域(教师 / 教导主任 / 教研组长 共用)的数据聚合、裁剪、协议转换
- **业务领域**:跨 D2 教学组织 + D3 教学核心 + D1 身份认证(只读拉取视口/权限)
- **我不负责**
- 不持有业务状态(无 DB 写入,无 Outbox
- 不做权限决策(依赖 Gateway JWT 校验 + 下游服务 `@RequirePermission`
- 不直接访问任何业务服务数据库
- **复用策略**004 §5.4):教导主任 / 教研组长复用 Teacher BFF通过视口差异化L1 导航扩展管理菜单L4 DataScope 扩大到年级)
### 3. 我与外部的契约
- **消费的 proto message**
- `iam.v1.IamService`GetUserInfo / getEffectivePermissions视口 + DataScope
- `classes.v1.ClassService`ListClasses / GetClass
- `core_edu.v1.ExamService / HomeworkService / GradeService`:全部 RPC
- **暴露的 API**(当前 REST目标 GraphQL
- `GET /teacher/dashboard` — 聚合 IAM 用户信息 + classes 列表
- `GET /teacher/viewports` — 拉取 IAM 视口配置L1 导航)
- `GET /teacher/classes/:classId/exams` — 聚合 core-edu 考试列表
- `GET /teacher/classes/:classId/homework` — 聚合 core-edu 作业列表
- `GET /teacher/exams/:examId/grades` — 聚合 core-edu 成绩列表
- **错误码前缀**BFF 自身错误用 `BAD_GATEWAY`(下游不可达);业务错误透传下游 `CORE_EDU_*` / `IAM_*` / `CLASSES_*`
- **缓存**:聚合结果 Redis 短缓存 5-30s004 §6.3,当前未实现)
### 4. 我的技术栈
- 语言TypeScript 5.5+ESM 模式,相对 import 带 `.js` 后缀)
- 框架NestJS 10
- 下游通信:当前 `fetch`REST→ 目标 `@grpc/grpc-js` + `@bufbuild/protobuf`
- 对前端:目标 GraphQL Yoga + DataLoader
- 缓存Redis待引入
- 可观测pino logger + prom-client metrics + OpenTelemetry tracer已具备 [tracer.ts](../../services/teacher-bff/src/shared/observability/tracer.ts)
### 5. 我的阶段归属
- **P2 身份**:教师登录 → 获取 JWT → 访问 teacher-portal → 侧边栏按 viewports.L1 渲染 → 空白 Dashboard
- **P3 扩展**:考试/作业/成绩的 GraphQL 查询与 mutation
- **依赖上游**P1 黄金模板 classes、P2 iamgetEffectivePermissions + 视口)
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [ ] 权限装饰器 `@RequirePermission`**BFF 不做权限决策**当前无目标态BFF 不加 Guard仅校验 `x-user-id` 存在)
- [x] 错误处理:[GlobalErrorFilter](../../services/teacher-bff/src/shared/errors/global-error.filter.ts) + ApplicationError 层次
- [x] logger / metrics / tracer 三支柱(已具备)
- [x] `/healthz` 健康检查HealthModule 已注册)
- [ ] `/readyz`(当前 HealthController 仅 liveness无下游就绪探针
- [x] 优雅关闭 SIGTERMmain.ts 已处理)
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无测试文件)
- [ ] Dockerfile 多阶段构建(需核对)
- [ ] Zod 输入验证(当前 Controller 直接透传 unknown**未做 Zod 校验**
- [x] GlobalErrorFilter 统一兜底
---
## 模块理解确认书 — core-edu
### 1. 我在架构中的位置
- **层级**业务微服务层L5
- **上游**teacher-bff聚合层、api-gateway直接路由 `/api/v1/exams` 等)
- **下游**MySQL独占库、Kafka事件发布、Redis待引入高并发提交锁
- **通信方式**
- 入口:当前 REST`/exams``/homework``/grades`
- 目标态proto 注释P3 起转 gRPC`core_edu.v1.ExamService/HomeworkService/GradeService` 已定义)
- 出口Kafka 事件Outbox 模式发布)
- **端口**3004见 [core-edu env.ts](../../services/core-edu/src/config/env.ts)
### 2. 我的限界上下文
- **聚合职责**:跨 **D2 教学组织**classes 模块,待合并)+ **D3 教学核心**exams / homework / grades
- **聚合根**Exam、Homework、Grade、Class待合并
- **我不负责**
- 不负责题库内容(→ content 服务)
- 不负责学情分析(→ data-ana 服务,消费 core-edu 事件)
- 不负责通知投递(→ msg 服务,消费 core-edu 事件)
- **数据自治**:独占 `core_edu` 数据库,表前缀 `core_edu_*`
### 3. 我与外部的契约
- **暴露的 gRPC 契约**[core_edu.proto](../../packages/shared-proto/proto/core_edu.proto),已定义待实现):
- `ExamService`CreateExam / GetExam / ListExamsByClass / UpdateExam / DeleteExam
- `HomeworkService`AssignHomework / GetHomework / ListHomeworkByClass / SubmitHomework
- `GradeService`RecordGrade / GetGrade / ListGradesByStudent/Exam/Homework
- **发布的领域事件**[events.proto](../../packages/shared-proto/proto/events.proto) + [outbox.publisher.ts TOPIC_MAP](../../services/core-edu/src/shared/outbox/outbox.publisher.ts)
| 事件 | Topic | 触发时机 | 消费者 |
| ------------------ | ------------------- | --------------------- | ------------- |
| exam.created | edu.exam.events | CreateExam 事务内 | msg、data-ana |
| exam.updated | edu.exam.events | UpdateExam | msg |
| exam.deleted | edu.exam.events | DeleteExam | data-ana |
| homework.assigned | edu.homework.events | AssignHomework | msg、data-ana |
| homework.submitted | edu.homework.events | SubmitHomework | data-ana、msg |
| homework.graded | edu.homework.events | **未实现**P3 待补) | msg、data-ana |
| grade.recorded | edu.grade.events | RecordGrade | data-ana、msg |
| grade.updated | edu.grade.events | **未实现**P3 待补) | data-ana |
| class.transferred | edu.class.events | classes 合并后 | data-ana |
- **消费的事件**`edu.identity.user.created`IAM初始化教师默认班级关联**当前未消费**P3 待补)
- **错误码前缀**`CORE_EDU_*`(见 [application-error.ts CoreEduErrorCode](../../services/core-edu/src/shared/errors/application-error.ts)
### 4. 我的技术栈
- 语言TypeScript 5.5+ESM 模式)
- 框架NestJS 10
- ORMDrizzle ORMmysql2 driver直接 `db` 导出,**与 classes 的 `getDb()` 不一致**
- 存储MySQL 8独占库、Redis待引入、Kafkakafkajsidempotent + transactionalId
- 可观测pino + prom-client + OTel已具备
### 5. 我的阶段归属
- **P3 核心教学**:考试全生命周期 + Outbox + Kafka 事件落地
- **退出标准**:教师创建考试 → 发布 → 学生作答 → 教师批改 → 事件到 Kafka → 成绩统计更新 → 全链路可观测
- **依赖上游**P1 classes 黄金模板、P2 iam用户身份 + 权限)
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [x] 权限装饰器 `@RequirePermission`exams.controller 全覆盖;需核对 homework/grades controller
- [x] 错误码前缀 `CORE_EDU_*`(已用 [CoreEduErrorCode 枚举](../../services/core-edu/src/shared/errors/application-error.ts)
- [x] logger / metrics / tracer 三支柱
- [x] `/healthz` 健康检查
- [ ] `/readyz`(需补 DB SELECT 1 / Kafka 连接探针)
- [x] 优雅关闭 SIGTERMmain.ts 已处理 outboxPublisher.stop + disconnectKafka
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无测试文件)
- [ ] Dockerfile 多阶段构建(需核对)
- [ ] Zod 输入验证(**当前 Controller 直接接收 body未 Zod 校验**classes 用 zod schema
- [x] GlobalErrorFilter 统一兜底
- [x] Outbox 模式(事务内写业务表 + outbox 表,独立 publisher 投递)
---
## §10 服务审计表 — ai03
> 对照 [黄金模板 classes 服务](../../services/classes/src/),审计已实现的两服务。状态:✅ 达标 / ⚠️ 部分 / ❌ 缺失
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ----------- | --------------------------------------- | --------------------------------- | ------- | -------------- | ------- | -------- | ------- | -------- | ---------- | ---------- |
| teacher-bff | ⚠️ 无BFF 不做权限决策,依赖 Gateway | ⚠️ 用 `BAD_GATEWAY`(无自有前缀) | ✅ pino | ✅ prom-client | ✅ OTel | ✅ | ❌ | ✅ | 0% ❌ | 待核对 |
| core-edu | ✅ `@RequirePermission(EXAM_*)` 全覆盖 | ✅ `CORE_EDU_*` | ✅ pino | ✅ prom-client | ✅ OTel | ✅ | ❌ | ✅ | 0% ❌ | 待核对 |
### 审计发现的关键差距P3 阶段 2 设计需解决)
**teacher-bff**
1. ❌ 通信方式:当前 REST `fetch`,目标 gRPC + GraphQLP2 退出标准要求 GraphQL Yoga + DataLoader
2. ❌ 无 Redis 聚合缓存004 §6.3 要求 5-30s 短缓存)
3. ❌ 无 DataLoader防 N+1pending-features P2 明确要求)
4. ❌ 无 `/readyz` 下游就绪探针
5. ⚠️ env 配置用 `IamServiceUrl`/`ClassesServiceUrl`REST URL转 gRPC 后需改为 gRPC target
6. ⚠️ 无 Zod 输入验证
7. ⚠️ 无测试
**core-edu**
1. ❌ 考试生命周期状态机缺失(当前仅 `draft` 初值,无 `published → in_progress → grading → graded → archived` 转换与校验)
2. ❌ 作业状态机不完整(仅 `assigned → submitted`,缺 `graded`pending-features 要求 `HomeworkGraded` 事件)
3. ❌ 成绩录入无业务校验(不校验 exam/homework 是否存在、score 是否在 totalScore 范围内、是否重复录入)
4. ❌ 作业提交高并发优化缺失004 §9.2 要求 Redis 分布式锁 + 排队)
5. ❌ 无 `grade.updated` / `homework.graded` 事件触发点proto 已定义service 未实现)
6. ❌ 未消费 IAM `user.created` 事件(初始化教师默认关联)
7. ⚠️ Drizzle `db` 直接导出 vs classes 的 `getDb()` 函数式 — **不一致**,建议统一为 `getDb()`
8. ⚠️ [kafka.ts](../../services/core-edu/src/config/kafka.ts) 用 `console.log`/`console.warn`,应改用结构化 logger
9. ⚠️ classes 模块在 core-edu 仅有 `classes.module.ts` 占位,**P3 待合并**classes 服务代码迁入 + 删除独立 services/classes
10. ⚠️ 入口仍为 RESTproto gRPC 契约已定义但未接入 `@grpc/grpc-js` + buf generate 代码
11. ❌ 无 Zod 输入验证Controller 直接接收 `body: CreateExamInput`,未走 zod schema
12. ❌ 无测试
### 跨模块契约对齐待确认项(提请 coord 交叉审查)
| 待确认项 | 我方期望 | 对方模块 | 状态 |
| ---------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------- |
| iam `getEffectivePermissions(userId)` 返回结构 | `{permissions, viewports, dataScope}` | iamai02 | ⚠️ 当前 teacher-bff 调 `/iam/viewports``/iam/me`,未定义此聚合 API 的 proto |
| iam `user.created` 事件 topic | `edu.identity.user.created` | iamai02 | ⚠️ 004 §7.2 定义,但 core-edu 未消费,需确认 iam 是否发布 |
| core-edu 端口 3004 | 不冲突 | 全局端口矩阵 | 待 coord 核对 |
| Kafka topic 命名 | `edu.exam.events` / `edu.homework.events` / `edu.grade.events` / `edu.class.events` | 004 §7.2 用 `edu.teaching.*` 前缀 | ⚠️ **不一致**004 文档用 `edu.teaching.exam.published`,代码用 `edu.exam.events`,需 coord 仲裁统一 |
| data-ana 消费 core-edu 事件 | 消费 `exam.created` / `homework.submitted` / `grade.recorded` | data-anaai06 | 待 ai06 确认消费契约 |
| msg 消费 core-edu 事件 | 消费 `exam.created` / `homework.assigned` / `grade.recorded` 触发通知 | msgai05 | 待 ai05 确认消费契约 |
---
## 下一步(阶段 2 入口)
待 coord 审核本确认书通过后ai03 进入阶段 2按 [ai-allocation.md §5 ai03 设计重点](./ai-allocation.md#ai03--教学场景域) 产出两份模块架构设计文档:
1. **teacher-bff 模块架构设计**GraphQL schemaQuery/Mutation 按场景域组织、DataLoader 批量去重、并行 gRPC 编排、聚合缓存 TTL、教师角色差异化视口推导
2. **core-edu 模块架构设计**classes 黄金模板对齐、考试生命周期状态机、Outbox 事件定义、成绩计算配置化、作业提交高并发Redis 锁 + 排队)、排课/考勤数据模型
阶段 2 设计需先解决上述 12 项差距与 6 项跨模块契约对齐。
---
**AI Agent**: ai03 (teacher-bff + core-edu)
**Coordinator**: coord-ai
**Branch**: 单仓库并行模式(直接 push main

View File

@@ -0,0 +1,269 @@
# ai05 阶段 1 交付物:模块理解确认书
> AI 标识ai05
> 负责模块contentP4、msgP5
> 阶段:架构设计外包 · 阶段 1全局理解
> 日期2026-07-09
> 关联文档:[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)、[known-issues.md](../troubleshooting/known-issues.md)
---
## 模块理解确认书 — content
### 1. 我在架构中的位置
- **层级**业务微服务层L5DDD 限界上下文
- **业务领域**D4 内容资源领域004 §1.1b
- **上游**(谁调用我):
- teacher-bff3003教学场景聚合教师查教材/知识点/题库
- student-bff学习场景P3 起):学生查学习路径、知识点前置
- aiPythonP5gRPC 查询知识点/题库用于 AI 出题004 §4.1 `AI -.gRPC.-> Content`§9.3 AI 辅助出题流程)
- **下游**(我调用谁):
- MySQL写模型主库独占
- Neo4j知识图谱前置依赖图
- Elasticsearch题库全文检索P4 后续补充,当前未实现)
- **通信方式**
- 当前HTTP RESTController无 gRPC controller
- 目标态004 §4.1 / pending-features P4gRPC 暴露 `TextbookService` + `KnowledgeGraphService`
- Kafka消费 core-edu 教学内容变更通知004 §4.1 `CoreEdu -.事件.-> Content`);发布 `edu.content.question.published`004 §7.2
- **端口**3005见 [content env.ts](../../services/content/src/config/env.ts)
### 2. 我的限界上下文
- **聚合职责**:管理 Textbook教材、Chapter章节、KnowledgePoint知识点、Question题库四个聚合
- **我的数据属于**D4 内容资源领域
- **我不负责**
- 不负责学情分析D6由 data-ana 承载)
- 不负责考试/作业/成绩D3由 core-edu 承载)
- 不负责通知分发D5由 msg 承载)
- 不直接访问 core-edu / iam / msg 的数据库
- **现有骨架领域模块**4 个,见 [content/src](../../services/content/src)
- `textbooks/`:教材 CRUD5 端点)
- `chapters/`:章节 CRUD5 端点,按 textbook 查询)
- `knowledge-points/`:知识点 CRUD + Neo4j 知识图谱7 端点,含前置链路查询/添加)
- `questions/`:题库 CRUD5 端点4 种题型校验)
### 3. 我与外部的契约
- **消费的 proto message**(从 shared-proto
- 无直接消费其他服务 proto通过 Kafka 事件接收 core-edu 教学内容变更(事件契约见 `events.proto`
- **暴露的契约**(见 [content.proto](../../packages/shared-proto/proto/content.proto),包名 `next_edu_cloud.content.v1`
- `TextbookService`CreateTextbook / GetTextbook / ListTextbooks
- `KnowledgeGraphService`GetPrerequisites / GetLearningPath
- **当前实现均为 RESTgRPC controller 未实现**proto 已定义待迁移)
- **事件契约**
- 发布:`edu.content.question.published`(题库新增/发布,消费者 AI、ES见 004 §7.2
- 消费core-edu 教学内容变更事件004 §4.1,具体 topic 待 core-edu ai03 设计确认)
- **错误码前缀**`CONTENT_*`CONTENT_VALIDATION_ERROR / NOT_FOUND / PERMISSION_DENIED / CONFLICT / BUSINESS_ERROR / DATABASE_ERROR / INTERNAL_ERROR见 [application-error.ts](../../services/content/src/shared/errors/application-error.ts)
- **权限点**16 个,见 [permission.guard.ts](../../services/content/src/middleware/permission.guard.ts)
- `CONTENT_TEXTBOOK_{CREATE,READ,UPDATE,DELETE}`
- `CONTENT_CHAPTER_{CREATE,READ,UPDATE,DELETE}`
- `CONTENT_QUESTION_{CREATE,READ,UPDATE,DELETE}`
- `CONTENT_KNOWLEDGE_POINT_{CREATE,READ,UPDATE,DELETE}`
### 4. 我的技术栈
- 语言TypeScript 5.6ESM 模式,相对 import 带 `.js` 后缀)
- 框架NestJS 10
- ORMDrizzle ORM 0.31 + mysql2 3.11
- 知识图谱neo4j-driver 5.23PREREQUISITE_OF 关系Cypher 查询深度 1..5
- 全文检索Elasticsearch**待引入**env.ts 预留 ES_URL 但 package.json 未装 @elastic/elasticsearch
- 可观测pino logger + prom-client metrics + OpenTelemetry tracer三支柱已具备
- 消息总线Kafka**待引入**pending-features P4 未明确要求 content 发事件,但 004 §7.2 列了 `edu.content.question.published`
### 5. 我的阶段归属
- **P4 内容分析阶段**pending-features §P4
- 教材/章节/知识点 CRUD仅 CRUD不实现检索+ 知识图谱查询Neo4j+ 题库 CRUD不实现检索
- MySQL schematextbooks / chapters / knowledge_points / questions
- Neo4j 数据:知识点前置依赖图(从 MySQL 同步)
- CDC 链路Debezium 监听 MySQL binlog → Kafka → data-ana 消费写 ClickHouse
- **退出标准**教师查看知识图谱前置依赖Neo4j 秒级返回)→ 学生查看学情诊断ClickHouse 宽表 5s 内返回)→ CDC 链路延迟 < 5s
- **依赖上游**P1 黄金模板 classes横切关注点对齐、P3 core-edu教学内容变更事件待 ai03 设计确认 topic
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [x] 权限装饰器 `@RequirePermission`16 个 CONTENT_* 权限点,全部 Controller 方法已覆盖)
- [x] 错误码前缀统一(`CONTENT_*`
- [x] logger / metrics / tracer 三支柱(已具备)
- [x] `/healthz` 健康检查HealthModule 已注册)
- [⚠️] `/readyz`**仅检查 DB `SELECT 1`,未检查 Neo4j 连通性**Neo4j 故障时仍返回 ok
- [x] 优雅关闭 SIGTERMmain.ts 已处理closeNeo4j → closeDb → shutdownTracer
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无测试文件)
- [x] Dockerfile 多阶段构建(已具备)
- [⚠️] Zod 输入验证questions 用 service 层手动 if 校验抛 ValidationError**非 Controller 层 schema.parse**
- [x] GlobalErrorFilter 统一兜底
### 7. 现有骨架差距与待决策(提请 coord 仲裁)
| # | 差距 | 影响 | 提请决策 |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| C1 | **ES 完全未实现**env.ts 预留 ES_URL 但无 @elastic/elasticsearch 依赖、无 config/elasticsearch.ts、无 service 调用 | P4 退出标准要求题库检索pending-features 注明"P4 仅 CRUDP5 引入 ES" | 确认 content 的 ES 检索归属 P4 还是 P5004 §2.2 列 ES 使用者为 Content、AI |
| C2 | **无 Outbox / Kafka**:骨架无 shared/outbox/,无 Kafka producer/consumer | 004 §7.2 列 `edu.content.question.published` 事件需发布 | 确认 content 是否需 Outboxiam 无 Outboxcore-edu 有) |
| C3 | **README 与实现脱节**README 声称 `TextbooksService.createKnowledgeGraph``getPrerequisites`,源码中 createKnowledgeGraph 不存在getPrerequisites 在 KnowledgePointsService | 文档误导 | 阶段 2 设计需同步修正 README |
| C4 | **gRPC 未实现**proto 定义了 TextbookService/KnowledgeGraphService但无 gRPC controller | pending-features P4 未强制 gRPC004 §4.1 目标态 gRPC | 确认 P4 是否启用 gRPCai03 提请统一决策) |
| C5 | **schema 定义分散**textbooks.schema.ts 定义 3 张表textbooks/chapters/knowledgePointschapters/knowledge-points schema 仅 re-exportquestions.schema.ts 独立 | 维护成本 | 阶段 2 设计统一 schema 归属 |
| C6 | **DB 连接模式与 iam 不一致**content 用模块级 `const db`iam 用 `getDb()` 函数式懒加载 | 测试 mock 困难 | coord 已在 known-issues 记录"db 常量导出对齐黄金模板",需确认统一方向 |
| C7 | **表时间戳不统一**textbooks/questions 有 created_at+updated_atchapters/knowledge_points 无时间戳 | 审计追踪缺失 | 阶段 2 设计补齐 |
| C8 | **无外键约束**questions.knowledge_point_id → knowledge_points.id 等无 Drizzle 外键 | 引用完整性靠应用层 | 阶段 2 设计评估是否加外键 |
| C9 | **addPrerequisite 降级策略不一致**safeCreateNode 非阻塞Neo4j 失败仅 warnaddPrerequisite 在 Neo4j 不可用时抛 InternalError | 行为不一致 | 阶段 2 设计统一降级策略 |
| C10 | **proto 包名**:实际 `next_edu_cloud.content.v1`project_rules §5 规定 `edu.content.v1` | 命名规范不一致 | **coord 仲裁**ai03 已提请) |
---
## 模块理解确认书 — msg
### 1. 我在架构中的位置
- **层级**业务微服务层L5DDD 限界上下文
- **业务领域**D5 沟通通知领域004 §1.1b
- **上游**(谁调用我):
- teacher-bff / student-bff / parent-bff聚合层调 msg 查询用户通知列表、发送通知
- api-gatewayREST 转发通知请求
- Kafka消费 core-edu / iam 事件触发通知004 §4.1 `CoreEdu -.事件.-> Msg``IAM -.事件.-> Msg`
- **下游**(我调用谁):
- MySQL写模型主库独占
- Elasticsearch全文检索已实现 safeSearch
- push-gatewaygRPC 推送通道004 §4.1 `PushGW → Msg`,当前用 fetch POST /internal/push 降级)
- **通信方式**
- 当前HTTP RESTController无 gRPC controller
- 目标态004 §4.1 / pending-features P5gRPC 暴露 `NotificationService`
- Kafka消费 core-eduExamPublished/HomeworkGraded/GradeRecorded、iamUserRegistered事件004 §7.3
- **端口**3007见 [msg env.ts](../../services/msg/src/config/env.ts)
### 2. 我的限界上下文
- **聚合职责**:管理 Notification通知聚合 + NotificationPreference用户通知偏好
- **我的数据属于**D5 沟通通知领域
- **我不负责**
- 不负责 WebSocket 长连接管理(由 push-gateway 承载)
- 不负责业务数据变更(仅消费事件触发通知)
- 不直接访问 core-edu / iam 的数据库
- **现有骨架领域模块**1 个,见 [msg/src](../../services/msg/src)
- `notifications/`:通知 CRUD + ES 全文检索 + Push Gateway 推送 + 用户偏好6 端点)
### 3. 我与外部的契约
- **消费的 proto message**(从 shared-proto
- 消费 `events.proto` 的事件 messageClassEvent/ExamEvent/HomeworkEvent/GradeEvent
- **events.proto 当前无 NotificationEvent**,若 msg 发事件需补充
- **暴露的契约**(见 [msg.proto](../../packages/shared-proto/proto/msg.proto),包名 `next_edu_cloud.msg.v1`
- `NotificationService`SendNotification / ListNotifications / MarkAsRead / SearchNotifications
- **当前实现均为 RESTgRPC controller 未实现**
- **事件契约**
- 消费004 §7.2 / §7.3
- `edu.identity.user.created` / `edu.identity.user.updated`IAM 发msg 发欢迎通知)
- `edu.teaching.exam.published`core-edu 发msg 推送考试通知给学生)
- `edu.teaching.assignment.submitted`core-edu 发msg 通知教师)
- `edu.teaching.grade.recorded`core-edu 发msg 通知学生)
- `edu.insight.mastery.updated`data-ana 发msg 触发预警)
- 发布无明确pending-features 未要求 msg 发事件)
- **错误码前缀**`MSG_*`MSG_VALIDATION_ERROR / NOT_FOUND / PERMISSION_DENIED / CONFLICT / BUSINESS_ERROR / DATABASE_ERROR / INTERNAL_ERROR见 [application-error.ts](../../services/msg/src/shared/errors/application-error.ts)
- **权限点**3 个,见 [permission.guard.ts](../../services/msg/src/middleware/permission.guard.ts)
- `MSG_NOTIFICATION_SEND``MSG_NOTIFICATION_READ``MSG_NOTIFICATION_MANAGE`
### 4. 我的技术栈
- 语言TypeScript 5.6ESM 模式)
- 框架NestJS 10
- ORMDrizzle ORM 0.31 + mysql2 3.11
- 全文检索:@elastic/elasticsearch 8.15(已实现 safeIndex/safeSearch**无 mapping 定义**,依赖动态 mapping
- 消息总线kafkajs 2.2**已装依赖但无 consumer/producer 代码**env.ts 有 KAFKA_BROKERS 默认值)
- 推送fetch POST 到 push-gateway `/internal/push`降级模式PUSH_GATEWAY_URL 未配置或失败时跳过)
- 幂等去重Redis**env.ts 预留 REDIS_URL 但无 redis 客户端依赖**pending-features P5 要求 event_id 去重)
- 可观测pino + prom-client + OpenTelemetry三支柱已具备
### 5. 我的阶段归属
- **P5 沟通与 AI 阶段**pending-features §P5
- 会话/消息 CRUD + 调 Push Gateway 推送 + 通知偏好
- 多渠道(站内/SMS/邮件/微信),沿用旧项目 dispatcher 模式
- Elasticsearch 题库全文检索(从 MySQL 同步)
- **退出标准**:教师发广播通知 → 全在线学生实时收到Push Gateway→ AI 辅助出题流式返回 → 题库全文检索 < 200ms
- **依赖上游**P1 黄金模板 classes、P3 core-edu事件来源、P5 push-gateway推送通道ai01 设计、P5 ai无直接依赖
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [x] 权限装饰器 `@RequirePermission`3 个 MSG_* 权限点,全部 Controller 方法已覆盖)
- [x] 错误码前缀统一(`MSG_*`
- [x] logger / metrics / tracer 三支柱(已具备)
- [x] `/healthz` 健康检查HealthModule 已注册)
- [⚠️] `/readyz`**仅检查 DB `SELECT 1`,未检查 ES 连通性**ES 故障时仍返回 ok
- [x] 优雅关闭 SIGTERMmain.ts 已处理app.close → closeEs → closeDb → shutdownTracer
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无测试文件)
- [x] Dockerfile 多阶段构建(已具备)
- [⚠️] Zod 输入验证Controller 用 `schema.parse(body)`**但 ZodError 未在 GlobalErrorFilter 特殊处理,走默认 500 而非 400**
- [x] GlobalErrorFilter 统一兜底
### 7. 现有骨架差距与待决策(提请 coord 仲裁)
| # | 差距 | 影响 | 提请决策 |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| M1 | **Kafka 消费未实现**:装了 kafkajs 但无 consumer 代码,无 shared/kafka/ 目录 | 004 §7.3 列 msg 消费 5 类事件ExamPublished/HomeworkGraded/GradeRecorded/UserRegistered/MasteryUpdated | 阶段 2 设计需补 Kafka consumer + 幂等去重 |
| M2 | **无 Outbox**:骨架无 shared/outbox/ | msg 作为通知中心,发送通知后可能需发事件(如 NotificationSent | 确认 msg 是否需 Outboxiam 无core-edu 有) |
| M3 | **ES 无 mapping 定义**:依赖动态 mapping索引名 "notifications" 硬编码在 service | 检索质量不稳定,索引管理缺失 | 阶段 2 设计补 mapping + ensureIndex |
| M4 | **无独立 repository**notifications.service.ts 直接用 db无 repository 抽象 | 与黄金模板分层不一致iam 有 repository | 阶段 2 设计补 repository 层 |
| M5 | **Redis 未引入**env.ts 预留 REDIS_URL 但无 redis 客户端依赖 | pending-features P5 要求 event_id 去重Redis SETNX 或 DB 唯一索引) | 阶段 2 设计决策Redis SETNX vs DB 唯一索引 |
| M6 | **createBatch 无事务/无批量优化**for 循环串行调 send无批量 INSERT | 性能瓶颈(广播场景) | 阶段 2 设计改为批量 INSERT |
| M7 | **NotificationsModule 缺 exports**notifications.module.ts 无 `exports: [NotificationsService]` | 未来 BFF 注入受阻 | 阶段 2 设计补 exports |
| M8 | **ZodError 未特殊处理**GlobalErrorFilter 未识别 ZodError走默认 500 | 输入校验错误返回码错误500 而非 400 | 阶段 2 设计 GlobalErrorFilter 补 ZodError 分支iam 已有可参考) |
| M9 | **gRPC 未实现**proto 定义了 NotificationService但无 gRPC controller | pending-features P5 未强制 gRPC | 确认 P5 是否启用 gRPCai03 提请统一决策) |
| M10 | **README 与实现脱节**README API 表标 `POST /notifications/:id/read`,实际是 PUT漏 batch/user/:userId/user/:userId/page 端点;声称"消费 Kafka 事件"但无代码 | 文档误导 | 阶段 2 设计同步修正 README |
| M11 | **main.ts 与 LifecycleService 重复关闭资源**:两者都调 closeDb/closeEs | 重复关闭可能报错(虽有 try-catch | 阶段 2 设计统一关闭逻辑到 LifecycleService |
| M12 | **proto 包名**:实际 `next_edu_cloud.msg.v1`project_rules §5 规定 `edu.msg.v1` | 命名规范不一致 | **coord 仲裁**ai03 已提请) |
---
## 三、服务审计表 — ai05
> 审计标准对照 [project_rules §3](../../.trae/rules/project_rules.md) 与 [known-issues §2.2 classes 黄金模板](../troubleshooting/known-issues.md)
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ------- | ---------------- | -------------- | ------- | -------------- | ------------------------------- | -------- | ------------------- | --------------------- | ---------- | ---------- |
| content | ✅ 16 端点全覆盖 | ✅ `CONTENT_*` | ✅ pino | ✅ prom-client | ✅ OTel + auto-instrumentations | ✅ | ⚠️ 仅 DB 不查 Neo4j | ✅ closeNeo4j→closeDb | 0% | ✅ |
| msg | ✅ 6 端点全覆盖 | ✅ `MSG_*` | ✅ pino | ✅ prom-client | ✅ OTel + auto-instrumentations | ✅ | ⚠️ 仅 DB 不查 ES | ✅ closeEs→closeDb | 0% | ✅ |
---
## 四、跨模块契约对齐提请coord 交叉审查)
### 4.1 接口一致性检查
| 本服务声明 | 对方服务声明 | 是否匹配 | 备注 |
| --------------------------------------------------------- | ------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
| ai05 content: 暴露 KnowledgeGraphService.GetPrerequisites | ai06 ai: 调 content 查知识点004 §9.3 | ⚠️ 待确认 | ai06 设计文档需确认调用签名 |
| ai05 content: 暴露 TextbookService.ListTextbooks | ai03 teacher-bff: 聚合 content 查教材004 §4.1 | ⚠️ 待确认 | ai03 设计文档需确认调用 |
| ai05 content: 发布 `edu.content.question.published` | ai06 ai: 消费题库事件004 §7.2 消费者 AI | ⚠️ 待确认 | ai06 设计需确认是否消费 |
| ai05 msg: 消费 `edu.teaching.exam.published` | ai03 core-edu: 发布考试事件 | ⚠️ 待确认 | ai03 提请 topic 命名统一004 `edu.teaching.exam.published` vs core-edu 代码 `edu.exam.events` |
| ai05 msg: 消费 `edu.identity.user.created` | ai02 iam: 发布用户创建事件 | ⚠️ 待确认 | ai02 设计需确认 topic |
| ai05 msg: 调 push-gateway `/internal/push` | ai01 push-gateway: 暴露推送端点 | ✅ 已实现 | msg 当前用 fetch POSTpush-gateway 已有 /internal/push 端点 |
### 4.2 全局冲突检查
| 检查项 | 检查结果 | 备注 |
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| 端口不冲突 | ✅ content 3005、msg 3007 | 与 iam(3002)/classes(3001)/teacher-bff(3003)/core-edu(3004)/data-ana(3006)/ai(3008) 不冲突 |
| Topic 不重复 | ⚠️ 待汇总 | content 发布 `edu.content.question.published`msg 仅消费不发布 |
| 错误码前缀不重叠 | ✅ `CONTENT_*` / `MSG_*` 唯一 | 与 iam `IAM_*` / core-edu `CORE_EDU_*` 不重叠 |
| Proto message 不遗漏 | ⚠️ 待确认 | events.proto 无 NotificationEvent若 msg 发事件需补充content.proto 缺 Update/Delete/分页(对比 classes.proto 有 page_token |
| proto 包名规范 | ⚠️ 不一致 | 实际 `next_edu_cloud.<domain>.v1`,规则要求 `edu.<domain>.v1`**提请 coord 仲裁**ai03 已提请) |
### 4.3 待 coord 仲裁的决策项
1. **proto 包名统一**`next_edu_cloud.*` vs `edu.*`(影响全部 proto需 coord 决策)
2. **gRPC 启用时机**P4/P5 是否启用 gRPC controller还是继续 REST影响 content/msg/iam/core-edu 全部服务)
3. **content ES 检索归属**P4pending-features 注明"P4 仅 CRUDP5 引入 ES"vs 004 §2.2(列 ES 使用者 Content、AI—— 需明确 content 何时引入 ES
4. **content Outbox**是否需要004 §7.2 列 content 发事件,但 pending-features P4 未要求)
5. **msg Outbox**是否需要msg 仅消费事件触发通知,是否需发 NotificationSent 事件)
6. **msg Redis 引入**env.ts 预留 REDIS_URL 但无依赖,幂等去重用 Redis SETNX 还是 DB 唯一索引
7. **events.proto 补充**:若 msg 发事件需追加 NotificationEvent message
---
## 五、下一步
1. **等待 coord 审核本确认书**§4 跨模块契约对齐 + §4.3 仲裁项)
2. coord 放行后进入**阶段 2模块架构设计文档**,按 ai-allocation.md §7 模板产出:
- content 模块架构设计文档(含 Neo4j 图模型、ES 索引 mapping、题库 CRUD API、与 ai 的 gRPC 接口、教材/章节结构树)
- msg 模块架构设计文档含通知渠道抽象策略模式、ES 降级查询、Kafka 消费幂等、push-gateway 推送协议、通知模板、已读/未读状态管理)
3. 阶段 2 设计完成后同步更新 README修正 §C3/M10 文档脱节问题)

View File

@@ -0,0 +1,333 @@
# ai06 阶段 1 交付物:模块理解确认书
> AI 标识ai06
> 负责模块data-anaP4、aiP5
> 语言Python 3.12+ / FastAPI 0.115+
> 阶段:架构设计外包 · 阶段 1全局理解
> 日期2026-07-09
> 关联文档:[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)、[known-issues.md](../troubleshooting/known-issues.md)
---
## 阶段 1 必读清单完成确认
按 ai-allocation.md §4ai06 必读 7 份全局文档 + 全部 `.proto` + `services/data-ana/``services/ai/` 骨架源码:
| # | 文档 | 状态 |
| --- | ------------------------------------------------------------------- | ------------------------------ |
| 1 | [README.md](../../README.md) | ✅ 已读 |
| 2 | [MIGRATION_GUIDE.md](../../MIGRATION_GUIDE.md) | ✅ 已读 |
| 3 | [004 架构影响地图](./004_architecture_impact_map.md) | ✅ 已读 |
| 4 | [pending-features.md](./roadmap/pending-features.md) | ✅ 已读 |
| 5 | [project_rules.md](../../.trae/rules/project_rules.md) | ✅ 已读workspace 规则) |
| 6 | [coding-standards.md](../standards/coding-standards.md) | ✅ 已读(重点 §4 Python 规范) |
| 7 | [multi-ai-collaboration.md](../standards/multi-ai-collaboration.md) | ✅ 已读 |
| 8 | `packages/shared-proto/proto/*.proto`8 份) | ✅ 已读 |
| 9 | `services/data-ana/src/``services/ai/src/` 全部源码 | ✅ 已读 |
> ai06 不需要读 classes 黄金模板ai-allocation.md §4 矩阵 ai06 列对黄金模板行为 "—"),但审计表横切关注点对齐仍参考 classes 标准。
---
## 模块理解确认书 — data-ana
### 1. 我在架构中的位置
- **层级**业务微服务层L5**D6 智能洞察领域**004 §1.1b
- **上游**
- api-gateway 直接 HTTP 代理 `/api/v1/analytics/*` → data-ana:3006见 [api-gateway main.go:142-148](../../services/api-gateway/main.go)
- teacher-bff / student-bff 聚合查询004 §4.1BFF → 业务服务 gRPC当前为 REST
- **下游**
- ClickHouse独占读模型宽表 `student_dashboard_view` / `student_errors`
- Kafka消费 Debezium CDC 事件 + 待发布 `edu.insight.mastery.updated` 领域事件)
- **通信方式**
- 入口:当前 REST`/analytics/class/{id}/performance``/analytics/student/{id}/weakness``/analytics/student/{id}/errorbook`目标态004 §4.1 + analytics.proto**gRPC** 暴露 `AnalyticsService`
- 出口Kafka 消费CDC + 领域事件订阅Kafka 发布(`edu.insight.mastery.updated`**未实现**
- **端口**3006见 [data-ana config.py:7](../../services/data-ana/src/data_ana/config.py) + [api-gateway config.go:55](../../services/api-gateway/internal/config/config.go)
### 2. 我的限界上下文
- **聚合职责**:学情分析读模型构建 + 查询服务,承载 **D6 智能洞察领域**的"分析/诊断"子域
- **聚合根 / 实体**
- `StudentDashboard`学生学情宽表视图ClickHouse 物化)
- `StudentErrorBook`学生错题本ClickHouse 物化)
- `ClassPerformance`班级成绩聚合ClickHouse 即时聚合)
- `MasterySnapshot`(知识点掌握度快照,**待实现**
- **业务领域**D6 智能洞察(与 ai 服务共享领域ai 偏"生成"data-ana 偏"分析"
- **我不负责**
- 不负责写模型(成绩由 core-edu 写 MySQLdata-ana 只消费 CDC
- 不负责题库内容(→ content 服务)
- 不负责通知投递(→ msg 服务消费 data-ana 发布的 `mastery.updated` 事件触发预警)
- 不负责 AI 推理(→ ai 服务ai 通过 gRPC 反向查询 data-ana 学情数据)
- **数据自治**:独占 `edu_analytics` ClickHouse 数据库(不与 MySQL 写模型混用)
### 3. 我与外部的契约
#### 消费的 proto message
| 来源 | message | 用途 |
| --------------- | ------------------------------- | ---------------------------------------------- |
| events.proto | `GradeEvent` | 消费 core-edu 成绩写入事件 → 更新学情宽表 |
| events.proto | `ExamEvent` | 消费考试事件 → 缓存 exam_id→class_id 映射 |
| events.proto | `HomeworkEvent` | 消费作业提交/批改事件 → 更新学情(**待实现** |
| events.proto | `ClassEvent` | 消费班级变更事件 → 同步班级维度(**待实现** |
| analytics.proto | `GetClassPerformanceRequest` 等 | gRPC 暴露契约(**待实现 gRPC server** |
> **CDC 直连 vs 领域事件双通道**:当前实现走 Debezium CDC直接监听 MySQL binlog不依赖 core-edu 的 Outbox。这是 ADR-008 的设计决策CDC 解耦 Outbox Relay减少业务侵入。Outbox 领域事件events.proto作为业务语义更清晰的补充通道待 P4 后期评估是否双消费。
#### 暴露的 API / 事件
**HTTP 端点**(当前实现,见 [main.py](../../services/data-ana/src/data_ana/main.py)
| method | path | 说明 |
| ------ | ------------------------------------------- | -------------------------------------- |
| GET | `/healthz` | liveness |
| GET | `/readyz` | readiness含 ClickHouse + CDC 状态) |
| GET | `/metrics` | Prometheus 指标 |
| GET | `/analytics/class/{class_id}/performance` | 班级成绩分析(平均分/及格率/参考人数) |
| GET | `/analytics/student/{student_id}/weakness` | 学生薄弱知识点mastery < 0.6 |
| GET | `/analytics/student/{student_id}/errorbook` | 学生错题本 |
**gRPC 契约**analytics.proto**待实现**
- `GetClassPerformance(GetClassPerformanceRequest) → ClassPerformance`
- `GetStudentWeakness(GetStudentWeaknessRequest) → StudentWeakness`
- `GetLearningTrend(GetLearningTrendRequest) → LearningTrend`**当前 HTTP 未暴露**
**发布的领域事件**
| 事件 | Topic004 §7.2 | 触发时机 | 消费者004 §7.3 |
| ---------------- | ----------------------------- | -------------- | --------------------------------------- |
| `MasteryUpdated` | `edu.insight.mastery.updated` | 掌握度计算完成 | core-edu推荐个性化练习、msg预警 |
> **当前未实现发布**data-ana 当前只消费不发布。掌握度计算完成后应通过 Kafka 发布 `MasteryUpdated`,下游 core-edu / msg 消费。阶段 2 设计需补全此发布链路Python 无 Outbox 模式,需评估直接 producer 还是引入 Outbox 表)。
- **错误码前缀**`DATA_ANA_*`(待定义清单,见阶段 2 §6
- **缓存**:当前无 Redis 缓存004 §6.3 学情宽表走 ClickHouse 实时CDC 同步延迟 < 5s
### 4. 我的技术栈
- 语言Python 3.12+
- 框架FastAPI 0.115+ / uvicorn
- ORM / 客户端:`clickhouse-connect`HTTP 协议,非原生协议)
- 消息:`aiokafka`CDC 消费者AIOKafkaConsumer
- 配置:`pydantic-settings` BaseSettingsenv_prefix="",全大写环境变量)
- 可观测:
- 日志:`structlog` 24.x`make_filtering_bound_logger`**注意**:旧版 `make_filtering_logger` 已废弃)
- 指标:`prometheus-client` + `make_asgi_app()` 挂载 `/metrics`
- 链路:`opentelemetry-sdk` + `OTLPSpanExporter` + `FastAPIInstrumentor.instrument_app(app)`
- 序列化JSONDebezium 事件 `schemas.enable=false`,直接 `json.loads`
- 测试pytest + pytest-asyncio**当前 0% 覆盖率**
### 5. 我的阶段归属
- **P4 内容分析阶段M11-M13**:建 DataAna 服务 + CDC 链路落地
- **退出标准**pending-features P4学生查看学情诊断 ClickHouse 宽表 5s 内返回 + CDC 链路延迟 < 5s
- **依赖上游**
- P1 地基api-gateway 路由 + arch.db 扫描器 Python 支持
- P3 核心教学core-edu 写成绩到 MySQLDebezium 监听 binlog
- P4 同期content 服务(提供知识点 ID 供掌握度计算)
- **下游依赖我**
- P5 ai 服务通过 gRPC 查询学情数据004 §4.1AI → DataAna gRPC
- P5 msg 服务消费 `mastery.updated` 触发预警
### 6. 我需要对齐的黄金模板项(对照 classes 服务 + Python 规范)
> Python 服务无 NestJS 装饰器体系,权限校验等通过等价方式实现。
- [ ] 权限装饰器等价物:**当前 HTTP 端点全部裸露,无权限校验**。Gateway 层做 JWT 校验,但 data-ana 本身未校验 `x-user-id` / DataScope。**阶段 2 需设计 FastAPI Depends 权限依赖 + DataScope 过滤注入**
- [ ] 错误码前缀统一:**当前无错误码体系**,降级时返回 `degraded: true` 标记但无业务错误码。**阶段 2 需定义 `DATA_ANA_*` 错误码清单**
- [x] logger / metrics / tracer 三支柱(已具备,见 main.py + clickhouse_client.py
- [x] `/healthz` + `/readyz` 健康检查已具备readyz 含 ClickHouse ping + CDC 状态)
- [ ] 优雅关闭 SIGTERM当前 lifespan 仅关闭 CDC task + ClickHouse client**未注册 SIGTERM 信号处理器**显式 drain
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无 tests/ 目录
- [ ] Dockerfile 多阶段构建:**当前单阶段**`FROM python:3.12-slim``uv sync``COPY src`),非多阶段
- [ ] Pydantic 输入验证:**当前端点直接接收 path param无 Pydantic 请求模型校验**(应补 `ClassPerformanceResponse` 等 response_model
- [x] 配置通过 pydantic-settings 管理(已具备)
- [x] 异步优先async defaiokafka async consumer
- [ ] 类型注解强制:**部分函数缺返回值标注**(如 `init_logger` 返回 `BoundLogger``_logger` 全局变量标注 `None`,需统一)
- [ ] ruff check 零错误:需阶段 2 验证
---
## 模块理解确认书 — ai
### 1. 我在架构中的位置
- **层级**业务微服务层L5**D6 智能洞察领域**004 §1.1b,与 data-ana 共享领域)
- **上游**
- api-gateway 直接 HTTP 代理 `/api/v1/ai/*` → ai:3008见 [api-gateway main.go:138-139](../../services/api-gateway/main.go)
- teacher-bff 聚合 AI 出题/优化能力004 §4.1BFF → ai当前 REST
- **下游**
- content 服务gRPC 查询知识点 / 题库004 §4.1AI → Content gRPC
- data-ana 服务gRPC 查询学情数据004 §4.1AI → DataAna gRPC
- LLM Provider外部 HTTPOpenAI 兼容 REST API
- **通信方式**
- 入口:当前 REST`/ai/chat``/ai/chat/stream``/ai/generate/question``/ai/optimize/expression`目标态ai.proto**gRPC** 暴露 `AiService`(含 `StreamChat` 流式 RPC
- 出口gRPC 调 content / data-ana**当前未实现,仅 LLM HTTP 调用**SSE 流式对前端
- **端口**3008见 [ai config.py:8](../../services/ai/src/ai/config.py) + [api-gateway config.go:57](../../services/api-gateway/internal/config/config.go)
### 2. 我的限界上下文
- **聚合职责**LLM 调用网关 + 教学场景 AI 编排(备课 / 出题 / 表达优化),承载 **D6 智能洞察领域**的"生成"子域
- **聚合根 / 实体**
- `ChatConversation`(聊天会话,**待实现**,当前无状态)
- `GeneratedQuestion`(生成的题目,待审核入库)
- `PromptTemplate`Prompt 模板,**待实现**
- `UsageRecord`(用量计费记录,**待实现**
- **业务领域**D6 智能洞察(与 data-ana 共享data-ana 偏"分析"ai 偏"生成"
- **我不负责**
- 不负责题库存储(→ content 服务ai 生成后调 content.CreateQuestions 入库)
- 不负责学情计算(→ data-ana 服务ai 查询学情用于个性化出题)
- 不负责通知投递(→ msg 服务)
- 不持有业务状态(无 DB 写入,无 Outbox用量计费记录可走 Kafka 事件给 data-ana 落 ClickHouse
- **数据自治****无独占数据库**(设计上无状态;用量计费通过 Kafka 事件外发)
### 3. 我与外部的契约
#### 消费的 proto message
| 来源 | message / service | 用途 |
| --------------- | ---------------------------------------- | --------------------------------------- |
| content.proto | `KnowledgeGraphService.GetPrerequisites` | 查询知识点前置依赖用于出题上下文 |
| content.proto | `KnowledgeGraphService.GetLearningPath` | 查询学生学习路径用于个性化出题 |
| analytics.proto | `AnalyticsService.GetStudentWeakness` | 查询学生薄弱知识点用于靶向出题 |
| analytics.proto | `AnalyticsService.GetLearningTrend` | 查询学习趋势用于难度调节 |
| ai.proto | `ChatRequest` 等 | gRPC 暴露契约(**待实现 gRPC server** |
#### 暴露的 API / 事件
**HTTP 端点**(当前实现,见 [main.py](../../services/ai/src/ai/main.py)
| method | path | 说明 |
| ------ | ------------------------- | ---------------------------- |
| GET | `/healthz` | liveness |
| GET | `/readyz` | readiness含 LLM 是否配置) |
| GET | `/metrics` | Prometheus 指标 |
| POST | `/ai/chat` | LLM 聊天(非流式) |
| POST | `/ai/chat/stream` | LLM 流式聊天SSE |
| POST | `/ai/generate/question` | 生成题目 |
| POST | `/ai/optimize/expression` | 优化表达 |
**gRPC 契约**ai.proto**待实现**
- `Chat(ChatRequest) → ChatResponse`
- `StreamChat(ChatRequest) → stream ChatChunk`(流式 RPC
- `GenerateQuestion(GenerateQuestionRequest) → GeneratedQuestion`
- `OptimizeExpression(OptimizeExpressionRequest) → OptimizedExpression`
**发布的领域事件****当前无发布**。设计上可发布 `AIUsageRecorded` 事件(用量计费),由 data-ana 消费落 ClickHouse。004 §7.2 未列出此 topic**阶段 2 需与 coord 确认是否新增 `edu.insight.ai.usage` topic**。
- **错误码前缀**`AI_*`(待定义清单,见阶段 2 §6
- **降级策略**LLM API key 为空或调用失败时返回 `degraded: true` 骨架响应(见 [llm_client.py](../../services/ai/src/ai/llm_client.py)
### 4. 我的技术栈
- 语言Python 3.12+
- 框架FastAPI 0.115+ / uvicorn
- LLM 客户端:`httpx` 异步直接调 OpenAI 兼容 REST API**不依赖 openai SDK**,见 [llm_client.py:1-7](../../services/ai/src/ai/llm_client.py)
- 配置:`pydantic-settings` BaseSettingsenv_prefix=""
- 可观测:
- 日志:`structlog`
- 指标:`prometheus-client` + `make_asgi_app()`
- 链路:`opentelemetry-sdk` + `OTLPSpanExporter` + `FastAPIInstrumentor`dev_mode=true 时跳过 exporter 初始化避免本地无 collector 报错)
- 流式响应FastAPI `StreamingResponse` + `AsyncGenerator`SSE 格式 `data: <chunk>\n\n`
- 测试pytest**当前 0% 覆盖率**
### 5. 我的阶段归属
- **P5 沟通与 AI 阶段M14-M16**:建 AI 网关 + LLM Provider 适配 + 流式 SSE
- **退出标准**pending-features P5AI 辅助出题流式返回 + 题库全文检索 < 200msES 部分由 ai05 负责)
- **依赖上游**
- P1 地基api-gateway 路由
- P4 内容分析content 服务gRPC 查询知识点 / 题库)+ data-ana 服务gRPC 查询学情)
- 外部LLM Provider API keyOpenAI / Anthropic / 百川 / 本地模型)
- **下游依赖我**
- P5 teacher-bff 聚合 AI 出题 mutation004 §9.3:教师用 AI 出题并发布到班级)
- P5 teacher-portal SSE 流式 AI 对话(前端)
### 6. 我需要对齐的黄金模板项(对照 classes 服务 + Python 规范)
- [ ] 权限装饰器等价物:**当前 HTTP 端点全部裸露,无权限校验**。阶段 2 需设计 FastAPI Depends 权限依赖AI 出题需 `AI_QUESTION_GENERATE` 权限,表达优化需 `AI_EXPRESSION_OPTIMIZE`
- [ ] 错误码前缀统一:**当前无错误码体系**,降级时返回 `degraded: true` 但无业务错误码。**阶段 2 需定义 `AI_*` 错误码清单**
- [x] logger / metrics / tracer 三支柱(已具备)
- [x] `/healthz` + `/readyz` 健康检查已具备readyz 含 LLM 配置状态)
- [ ] 优雅关闭 SIGTERM当前 lifespan 无显式 drainLLM 流式请求需等待完成)
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无 tests/ 目录
- [ ] Dockerfile 多阶段构建:**当前单阶段**`FROM python:3.12-slim`
- [ ] Pydantic 输入验证:**当前仅 `ChatRequest` 是 BaseModel`generate/question``optimize/expression` 直接接收 `prompt: str` / `text: str` query param无请求体模型**。阶段 2 需补完整 Pydantic 请求/响应模型
- [x] 配置通过 pydantic-settings 管理(已具备)
- [x] 异步优先httpx async + AsyncGenerator stream
- [x] 类型注解强制已基本符合main.py 函数返回值已标注)
- [ ] LLM Provider 适配器模式:**当前仅 OpenAI 兼容 REST**,未抽象 Provider 接口。阶段 2 需设计 `LLMProvider` 抽象 + OpenAI/Anthropic/百川/本地 多适配器
- [ ] Prompt 模板管理:**当前 Prompt 硬编码在 main.py**,阶段 2 需设计模板管理DB 或文件)
- [ ] 用量计费 / 频率限制:**当前无用量记录和限流**,阶段 2 需设计Kafka 事件 + Redis 限流)
- [ ] 备课工作流:**当前未实现**pending-features P5 要求"分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库"完整工作流
- [ ] ruff check 零错误:需阶段 2 验证
---
## ai06 服务审计表
> 对照 ai-allocation.md §10 审计模板。data-ana 与 ai 均为 Python/FastAPI黄金模板对齐按 Python 规范coding-standards §4评估。
> 符号说明:✅ 已实现 | ❌ 缺失 | ⚠️ 部分实现
| 服务 | 权限校验 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| -------- | -------- | ---------- | ------------ | ------------- | ------------------- | -------- | -------------------- | ------------------- | ---------- | ---------- |
| data-ana | ❌ | ❌ | ✅ structlog | ✅ prometheus | ✅ OTel | ✅ | ✅(含 CH+CDC 状态) | ⚠️ 仅 lifespan 关闭 | 0% | ❌ 单阶段 |
| ai | ❌ | ❌ | ✅ structlog | ✅ prometheus | ✅ OTeldev 跳过) | ✅ | ✅(含 LLM 状态) | ⚠️ 仅 lifespan 关闭 | 0% | ❌ 单阶段 |
### 审计发现的关键差距清单
#### data-ana 关键差距(按优先级)
1. **P0 权限校验缺失**:所有 `/analytics/*` 端点裸露,无 DataScope 过滤。学生 A 可查询学生 B 的错题本(越权风险)。阶段 2 必须设计 `Depends(require_permission)` + `Depends(inject_data_scope)` 依赖注入
2. **P0 gRPC server 未实现**analytics.proto 定义了 `AnalyticsService` 但 data-ana 当前仅 HTTP。004 §4.1 明确 BFF → 业务服务走 gRPC阶段 2 需引入 `grpc.aio` + `betterproto` 实现 gRPC server
3. **P1 事件发布缺失**`edu.insight.mastery.updated` 事件未发布,下游 core-edu/msg 无法消费。需设计掌握度计算 + Kafka producer 发布链路
4. **P1 ClickHouse schema 不规范**:当前 `student_dashboard_view` 实为 MergeTree 表(注释提到应为 ReplacingMergeTree(last_updated) 实现幂等去重),无 DDL 文件管理。阶段 2 需产出完整 ClickHouse DDL5 张宽表:考试/作业/成绩/掌握度/出勤)
5. **P2 测试覆盖率 0%**:无 tests/ 目录pytest 未配置
6. **P2 Dockerfile 单阶段**未做多阶段构建builder + runtime镜像体积大
7. **P2 掌握度计算算法缺失**:当前 `_handle_grades_event``score / 100.0` 简化,未实现 pending-features 要求的"加权滑动平均"算法
8. **P2 无 Pydantic 响应模型**:端点返回 `dict` 而非 `BaseModel`,无 `response_model` 校验
#### ai 关键差距(按优先级)
1. **P0 权限校验缺失**:所有 `/ai/*` 端点裸露AI 出题等敏感操作无权限校验
2. **P0 gRPC server 未实现**ai.proto 定义了 `AiService`(含 `StreamChat` 流式 RPC但 ai 当前仅 HTTP。阶段 2 需引入 `grpc.aio` 实现 gRPC server + 流式 RPC
3. **P0 gRPC client 未实现**004 §4.1 明确 AI → Content / AI → DataAna 走 gRPC当前未实现。阶段 2 需设计 gRPC client 调用 content / data-ana
4. **P1 LLM Provider 适配器缺失**:当前 `llm_client.py` 仅 OpenAI 兼容 REST未抽象 Provider 接口。pending-features P5 要求"LLM Provider 适配OpenAI/Anthropic/百川/本地模型)"
5. **P1 Prompt 模板管理缺失**system prompt 硬编码在 main.py无模板管理。阶段 2 需设计模板存储(文件 or DB+ 模板渲染
6. **P1 备课工作流缺失**pending-features P5 要求"分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库"完整工作流,当前仅"生成题目"单步
7. **P1 用量计费 / 频率限制缺失**无用量记录token 消耗)、无频率限制(用户可无限调用 LLM。阶段 2 需设计 Redis 限流 + Kafka 事件外发用量
8. **P2 测试覆盖率 0%**:无 tests/ 目录
9. **P2 Dockerfile 单阶段**:未做多阶段构建
10. **P2 Pydantic 输入验证不完整**`generate/question``optimize/expression` 直接接收 query param无请求体模型
---
## 阶段 1 待 coord 交叉审查的跨模块契约对齐项
以下项需 coord 在交叉审查时仲裁(见 ai-allocation.md §8
| # | 议题 | 涉及方 | 当前状态 | ai06 建议 |
| --- | -------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **data-ana 是否发布 `edu.insight.mastery.updated` 事件** | data-ana → core-edu / msg | 004 §7.2 列出此 topic但当前 data-ana 未实现发布 | 阶段 2 设计发布链路Python 无 Outbox建议直接 Kafka producer掌握度计算是派生数据非事务写 |
| 2 | **ai 是否发布 `edu.insight.ai.usage` 用量事件** | ai → data-ana | 004 §7.2 未列出此 topic | 建议 coord 新增此 topic 用于用量计费落 ClickHouse若 coord 不同意则 ai 自建用量记录表(破坏无状态原则) |
| 3 | **data-ana / ai 是否需要实现 gRPC server** | data-ana / ai ← teacher-bff / student-bff / ai | 004 §4.1 明确 BFF → 业务服务走 gRPCanalytics.proto / ai.proto 已定义 service但当前两服务仅 HTTP | 阶段 2 引入 `grpc.aio` + `betterproto` 实现 gRPC serverHTTP 端点保留作为 Gateway 直连降级通道 |
| 4 | **CDC 直连 vs Outbox 领域事件双通道** | data-ana ← core-edu | 当前 data-ana 走 Debezium CDC监听 binlog不消费 core-edu Outbox 事件events.proto | 维持 CDC 为主通道ADR-008 决策events.proto 作为业务语义补充,待 P4 后期评估是否双消费 |
| 5 | **data-ana DataScope 过滤实现位置** | data-ana + iam | 004 §5.3 DataScope 6 级,业务服务在 Repository 层注入 WHEREdata-ana 是 ClickHouse 查询无 Repository 层 | 阶段 2 在 ClickHouse 查询 SQL 拼接时注入 DataScope WHERE学生只能看自己教师看本班校管理员看本校需 iam 提供 `getEffectiveDataScope(userId)` API |
| 6 | **ai 备课工作流是否引入 Temporal** | ai + coord | 004 §2.3 列出 Temporal 用于"工作流编排考试生命周期、AI 编排)"P3 已引入 Temporal 试点 | 阶段 2 评估简单工作流4 步)可用 FastAPI BackgroundTasks复杂工作流含教师审核等待建议 Temporal待 coord 仲裁 |
| 7 | **LLM Provider 切换的配置化** | ai + coord | 当前 ai config.py 仅 OpenAI + Anthropic 字段 | 阶段 2 设计 `LLMProvider` 抽象 + 配置化路由(按 model 名路由到不同 provider本地模型走 Ollama REST API |
| 8 | **data-ana ClickHouse DDL 管理位置** | data-ana + coord | 当前无 DDL 文件,宽表手动创建 | 建议 coord 在 `infra/clickhouse/` 下建立 DDL 目录(类似 MySQL init.sqldata-ana 提供 DDL 内容 |
---
## 阶段 1 总结
ai06 已完成阶段 1 全局理解,产出本确认书。核心结论:
1. **data-ana**P4CDC 链路已跑通Debezium → Kafka → ClickHouse3 个分析端点已实现降级模式。**关键差距**权限校验、gRPC server、事件发布mastery.updated、ClickHouse DDL 规范化、掌握度算法、测试覆盖。
2. **ai**P5LLM 客户端已实现降级模式httpx 异步 + SSE 流式4 个端点已实现。**关键差距**权限校验、gRPC server + client、LLM Provider 适配器、Prompt 模板管理、备课工作流、用量计费、测试覆盖。
3. **跨模块契约**8 项待 coord 仲裁,最关键的是 gRPC server 实现决策(影响阶段 2 设计核心)和事件发布 topic 新增决策。
下一步进入阶段 2按 ai-allocation.md §7 模板产出 data-ana 与 ai 的模块架构设计文档。

View File

@@ -0,0 +1,858 @@
# ai06 阶段 2 交付物:模块架构设计文档
> AI 标识ai06
> 负责模块data-anaP4、aiP5
> 阶段:架构设计外包 · 阶段 2模块架构设计
> 日期2026-07-09
> 关联文档:[ai06 阶段 1 确认书](./ai06-phase1-understanding.md)、[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)
> 审查请求:本设计文档待 coord 按 ai-allocation.md §8 交叉审查(接口一致性 / 端口冲突 / Topic 重复 / 错误码重叠 / 黄金模板对齐)
---
## 设计原则与全局约束
本设计遵循以下强制约束(来自 project_rules.md + coding-standards.md + 004
1. **契约先行**proto 已定义analytics.proto / ai.proto实现前不修改 proto如需修改走 coord 流程
2. **CQRS 读写分离**data-ana 是纯读模型服务(无 MySQL 写ClickHouse 宽表由 CDC 投影构建
3. **事件驱动**data-ana 消费 CDC + 领域事件ai 不参与事件流(无状态)
4. **gRPC 优先**004 §4.1 明确 BFF → 业务服务走 gRPC两服务需实现 gRPC serverHTTP 保留作 Gateway 直连降级)
5. **DataScope 过滤**004 §5.3 DataScope 6 级在查询层注入 WHERE
6. **三支柱可观测**structlog + prometheus-client + OpenTelemetry已具备需补业务指标
7. **降级模式**外部依赖ClickHouse / LLM / 下游 gRPC不可用时返回骨架数据 + `degraded: true`
8. **Python 规范**pydantic-settings 配置 / Pydantic 模型校验 / async 优先 / 类型注解强制 / ruff 零错误
---
# 模块架构设计文档 — data-ana
## 1. 模块内部分层图
```mermaid
flowchart TB
subgraph Entry["入口层"]
HTTP[FastAPI HTTP Router<br/>/analytics/* + /healthz + /readyz]
GRPC[grpc.aio Server<br/>AnalyticsService]
end
subgraph Middleware["中间件层"]
AUTH[AuthDepends<br/>校验 x-user-id / x-user-roles]
SCOPE[DataScopeDepends<br/>注入 data_scope 元数据]
TRACE[OTel FastAPIInstrumentor<br/>+ grpc.aio server interceptor]
end
subgraph Service["应用服务层 Application Service"]
S1[AnalyticsService<br/>班级/学生/趋势查询编排]
S2[MasteryService<br/>掌握度计算 + 事件发布]
S3[ErrorBookService<br/>错题本查询]
end
subgraph Repo["数据访问层 Repository"]
R1[ClickHouseRepository<br/>宽表查询 + DataScope WHERE 注入]
R2[KafkaProducer<br/>mastery.updated 事件发布]
R3[IamClient<br/>gRPC 调 iam.getEffectiveDataScope]
end
subgraph Consumer["CDC 消费者(后台任务)"]
C1[CdcConsumer<br/>aiokafka AIOKafkaConsumer]
C2[ExamCache<br/>exam_id→class_id 内存映射]
C3[EventHandler<br/>grades/exams/homework/classes 路由]
end
subgraph Storage["存储 / 总线"]
CH[(ClickHouse<br/>edu_analytics 库)]
KAFKA[(Kafka<br/>edu-cdc.* + edu.insight.mastery.updated)]
IAM[iam:3002 gRPC]
end
HTTP --> AUTH --> SCOPE --> S1
HTTP --> S3
GRPC --> S1
S1 --> R1
S3 --> R1
S2 --> R1
S2 --> R2
SCOPE --> R3
R1 --> CH
R2 --> KAFKA
R3 --> IAM
C1 --> C3
C3 --> C2
C3 --> R1
C1 --> KAFKA
```
**分层规则**
- **入口层**HTTP保留作 Gateway 直连降级)+ gRPC主入口BFF 调用)。两入口共享同一 Application Service
- **中间件层**FastAPI Depends 链(`AuthDepends``DataScopeDepends`gRPC 用 server interceptor 注入身份元数据
- **应用服务层**:编排查询 / 计算掌握度 / 发布事件,不直接访问存储
- **数据访问层**ClickHouse 查询封装 + Kafka producer + gRPC client调 iam
- **CDC 消费者**独立后台任务lifespan 启动),与 HTTP/gRPC 入口解耦
## 2. 领域模型
data-ana 是**纯读模型服务**,不持有写聚合根。领域模型为**视图聚合**ClickHouse 物化):
### 聚合根(视图型)
| 聚合根 | 含义 | 物化载体 | 不变式 |
| ------------------ | ---------------- | ----------------------------------------- | ------------------------------------------------------------- |
| `StudentDashboard` | 学生学情宽表 | ClickHouse `student_dashboard_view` | 同一 (student_id, exam_id, knowledge_point_id) 仅保留最新版本 |
| `ClassPerformance` | 班级成绩聚合 | ClickHouse 即时聚合(不物化) | 聚合维度为 class_id + 时间窗 |
| `StudentErrorBook` | 学生错题本 | ClickHouse `student_errors` | 同一 (student_id, question_id) 累计 error_count |
| `MasterySnapshot` | 知识点掌握度快照 | ClickHouse `mastery_snapshot`**新增** | 同一 (student_id, knowledge_point_id) 保留历史版本 |
### 值对象
- `WeakPoint`knowledge_point_id + title + mastery_level
- `TrendPoint`date + score
- `DataScope`level (SELF/CLASS/GRADE/SCHOOL/DISTRICT/ALL) + scope_ids具体可见的 class_id / grade_id 列表)
### 聚合间通信
- 同服务内直接函数调用Application Service → Repository
- 跨服务:仅通过 Kafka 事件(发布 `mastery.updated`+ gRPC调 iam 查 DataScope
## 3. 数据模型ClickHouse DDL
> DDL 文件由 coord 统一管理在 `infra/clickhouse/ddl/`(待 coord 建立data-ana 提供内容。
### 3.1 宽表 `student_dashboard_view`
```sql
-- 学生学情宽表:每次成绩写入产生一行,按 ORDER BY 去重保留最新版本
CREATE TABLE IF NOT EXISTS student_dashboard_view
(
student_id String,
class_id String,
exam_id String,
subject_id String,
score Float64,
rank_in_class UInt32,
knowledge_point_id String,
mastery_level Float32, -- 0.0-1.0
error_count UInt32,
last_updated DateTime64(3, 'UTC')
)
ENGINE = ReplacingMergeTree(last_updated)
PARTITION BY toYYYYMM(last_updated)
ORDER BY (student_id, exam_id, knowledge_point_id)
SETTINGS index_granularity = 8192;
```
**索引策略**
- ORDER BY `(student_id, exam_id, knowledge_point_id)`:主键索引,支持按学生查学情、按考试查成绩、按知识点查掌握度
- PARTITION BY `toYYYYMM(last_updated)`:按月分区,支持历史数据归档
- ReplacingMergeTree(last_updated):同 ORDER BY 自动去重,保留 last_updated 最大版本(幂等消费保证)
### 3.2 错题本 `student_errors`
```sql
CREATE TABLE IF NOT EXISTS student_errors
(
student_id String,
question_id String,
knowledge_point_id String,
error_count UInt32,
last_error_time DateTime64(3, 'UTC'),
content String
)
ENGINE = ReplacingMergeTree(last_error_time)
PARTITION BY toYYYYMM(last_error_time)
ORDER BY (student_id, question_id);
```
### 3.3 掌握度快照 `mastery_snapshot`**新增**
```sql
-- 知识点掌握度历史快照:每次掌握度计算产生新版本,支持趋势查询
CREATE TABLE IF NOT EXISTS mastery_snapshot
(
student_id String,
knowledge_point_id String,
subject_id String,
mastery_level Float32,
calculated_at DateTime64(3, 'UTC'),
calculation_method LowCardinality(String) -- 'weighted_moving_avg' / 'simple_avg'
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(calculated_at)
ORDER BY (student_id, knowledge_point_id, calculated_at);
```
### 3.4 用量计费 `ai_usage_log`**新增,供 ai 服务写入**
```sql
-- AI 用量记录ai 服务通过 Kafka 事件投递data-ana 消费落库
CREATE TABLE IF NOT EXISTS ai_usage_log
(
request_id String,
user_id String,
provider LowCardinality(String), -- 'openai' / 'anthropic' / 'baichuan' / 'local'
model LowCardinality(String),
prompt_tokens UInt32,
completion_tokens UInt32,
total_tokens UInt32,
latency_ms UInt32,
success Boolean,
occurred_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (user_id, occurred_at);
```
### 3.5 读写分离策略
| 操作 | 路径 | 说明 |
| -------------- | ----------------------------------- | ------------------------------------------ |
| 学情查询 | ClickHouse 宽表 | 实时聚合,亚秒级响应 |
| 错题本查询 | ClickHouse student_errors | 实时查询 |
| 掌握度趋势 | ClickHouse mastery_snapshot | 历史快照 |
| 掌握度计算 | CDC 触发 → 内存计算 → 写 ClickHouse | 派生数据,非事务写 |
| DataScope 解析 | gRPC 调 iam | 实时查询,结果 Redis 缓存 5min004 §6.3 |
## 4. API 设计
### 4.1 HTTP 端点(保留作 Gateway 直连降级)
| method | path | 权限 | 请求 | 响应 | 说明 |
| ------ | ------------------------------------------- | ----------------------------- | ------------------------------------------------ | ----------------------------------------------------- | -------------------------------- |
| GET | `/healthz` | — | — | `{status, service}` | liveness |
| GET | `/readyz` | — | — | `{status, ready, degraded, clickhouse, cdc_consumer}` | readiness |
| GET | `/metrics` | — | — | Prometheus 格式 | 指标 |
| GET | `/analytics/class/{class_id}/performance` | `ANALYTICS_CLASS_READ` | query: `subject_id?`, `start_date?`, `end_date?` | `ClassPerformanceResponse` | 班级成绩分析 |
| GET | `/analytics/student/{student_id}/weakness` | `ANALYTICS_STUDENT_READ` | query: `subject_id?` | `StudentWeaknessResponse` | 学生薄弱知识点DataScope 过滤) |
| GET | `/analytics/student/{student_id}/errorbook` | `ANALYTICS_STUDENT_READ` | query: `page?`, `size?` | `StudentErrorBookResponse` | 学生错题本DataScope 过滤) |
| GET | `/analytics/student/{student_id}/trend` | `ANALYTICS_STUDENT_READ` | query: `start_date`, `end_date`, `subject_id?` | `LearningTrendResponse` | 学习趋势(**新增** |
| GET | `/analytics/dashboard/teacher/{user_id}` | `ANALYTICS_TEACHER_DASHBOARD` | query: `class_id?` | `TeacherDashboardResponse` | 教师仪表盘聚合(**新增** |
### 4.2 gRPC 契约analytics.proto待实现 server
| RPC | 请求 | 响应 | 权限 |
| --------------------- | ------------------------------------------------------------------------ | ------------------ | ------------------------ |
| `GetClassPerformance` | `GetClassPerformanceRequest{class_id, subject_id, start_date, end_date}` | `ClassPerformance` | `ANALYTICS_CLASS_READ` |
| `GetStudentWeakness` | `GetStudentWeaknessRequest{student_id, subject_id}` | `StudentWeakness` | `ANALYTICS_STUDENT_READ` |
| `GetLearningTrend` | `GetLearningTrendRequest{student_id, start_date, end_date}` | `LearningTrend` | `ANALYTICS_STUDENT_READ` |
**权限校验**gRPC server interceptor 从 metadata 提取 `x-user-id` / `x-user-roles` / `x-data-scope`,调用 `AuthDepends` 等价逻辑。
### 4.3 Pydantic 请求/响应模型
```python
# 示例:班级成绩分析响应
class ClassPerformanceResponse(BaseModel):
success: bool
data: ClassPerformanceData
degraded: bool = False
class ClassPerformanceData(BaseModel):
class_id: str
average_score: float
pass_rate: float
total_students: int
scores: list[StudentScore] = [] # 详细成绩列表(受 DataScope 过滤)
class StudentScore(BaseModel):
student_id: str
score: float
grade: str
```
## 5. 事件设计
### 5.1 消费的事件
| Topic | 来源 | 消息格式 | 消费动作 |
| ------------------------------------------------------ | ------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| `edu-cdc.next_edu_cloud.core_edu_grades` | Debezium CDCcore-edu MySQL | Debezium JSONbefore/after/source/op/ts_ms | 解析 → 查 ExamCache 填 class_id → 计算掌握度 → upsert student_dashboard_view |
| `edu-cdc.next_edu_cloud.core_edu_exams` | Debezium CDC | Debezium JSON | upsert ExamCacheexam_id → class_id, subject_id |
| `edu-cdc.next_edu_cloud.core_edu_homework_submissions` | Debezium CDC**新增订阅** | Debezium JSON | 记录作业提交行为 → 更新 student_dashboard_view |
| `edu-cdc.next_edu_cloud.classes` | Debezium CDC | Debezium JSON | 同步班级维度head_teacher_id用于教师 DataScope |
| `edu-cdc.next_edu_cloud.iam_users` | Debezium CDC**新增订阅** | Debezium JSON | 同步用户 dataScope 用于查询过滤(避免每次查 iam |
| `edu.insight.ai.usage` | ai 服务 Kafka producer**待 coord 新增 topic** | JSONUsageRecord | 落 ai_usage_log 表 |
**幂等性**
- CDC 事件:依赖 ClickHouse `ReplacingMergeTree(last_updated)` 引擎按 ORDER BY 去重
- 领域事件(若消费):基于 `event_id` 去重Redis SETNXTTL 7 天)
### 5.2 发布的事件
| 事件 | Topic | 触发时机 | 消费者 | Payload |
| ---------------- | ----------------------------- | ----------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
| `MasteryUpdated` | `edu.insight.mastery.updated` | 掌握度计算完成CDC grades 事件触发后异步计算) | core-edu推荐个性化练习、msg掌握度预警 | `{event_id, student_id, knowledge_point_id, mastery_level, calculated_at}` |
**发布实现**Python 无 Outbox 模式):
- 掌握度计算是**派生数据**(非业务事务写),不需要 Outbox 保证事务一致
- 直接用 `aiokafka.AIOKafkaProducer` 发布,`idempotent=true` + 事务性 producer
- 失败重试 3 次,仍失败记录日志 + 落 `mastery_publish_failed` 本地表(待 P6 评估是否引入 Outbox
## 6. 横切关注点对齐清单
### 6.1 权限装饰器等价物FastAPI Depends
```python
# 权限点常量(与 iam 权限点对齐)
class Permissions:
ANALYTICS_CLASS_READ = "analytics:class:read"
ANALYTICS_STUDENT_READ = "analytics:student:read"
ANALYTICS_TEACHER_DASHBOARD = "analytics:teacher:dashboard"
async def require_permission(permission: str) -> UserContext:
"""FastAPI Depends 权限校验.
从 x-user-id / x-user-roles 头提取身份,校验角色是否含 permission。
"""
...
async def inject_data_scope(ctx: UserContext = Depends(require_permission(...)))-> DataScope:
"""注入 DataScope从 iam.getEffectiveDataScope 查询Redis 缓存 5min."""
...
```
### 6.2 错误码清单(前缀 `DATA_ANA_*`
| 错误码 | 触发条件 | HTTP | gRPC status |
| --------------------------------- | --------------------------------------- | ------------------- | ------------------ |
| `DATA_ANA_UNAUTHORIZED` | 缺失 x-user-id 头或 token 无效 | 401 | UNAUTHENTICATED |
| `DATA_ANA_FORBIDDEN` | 角色无对应权限 | 403 | PERMISSION_DENIED |
| `DATA_ANA_DATASCOPE_VIOLATION` | 查询目标超出 DataScope 范围 | 403 | PERMISSION_DENIED |
| `DATA_ANA_CLICKHOUSE_UNAVAILABLE` | ClickHouse 不可达(降级模式仍返回骨架) | 200 + degraded:true | OK + degraded flag |
| `DATA_ANA_INVALID_DATE_RANGE` | start_date > end_date | 400 | INVALID_ARGUMENT |
| `DATA_ANA_STUDENT_NOT_FOUND` | student_id 不存在 | 404 | NOT_FOUND |
| `DATA_ANA_CLASS_NOT_FOUND` | class_id 不存在 | 404 | NOT_FOUND |
| `DATA_ANA_INTERNAL_ERROR` | 未捕获异常 | 500 | INTERNAL |
### 6.3 Logger 初始化
- 位置:`main.py` `init_logger()`(已具备)
- 配置:`structlog.make_filtering_bound_logger(level)` + `TimeStamper(fmt="iso")` + `ConsoleRenderer`
- **改进**:生产环境改用 `structlog.processors.JSONRenderer()`(当前 ConsoleRenderer 适合开发)
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| --------------------------------------------- | --------- | -------------------- | -------------------------- |
| `data_ana_http_requests_total` | Counter | method, path, status | HTTP 请求总数 |
| `data_ana_http_request_duration_seconds` | Histogram | method, path | HTTP 请求延迟 |
| `data_ana_clickhouse_query_duration_seconds` | Histogram | query_type | ClickHouse 查询延迟 |
| `data_ana_clickhouse_query_total` | Counter | query_type, status | ClickHouse 查询总数 |
| `data_ana_cdc_events_consumed_total` | Counter | table, op | CDC 事件消费总数 |
| `data_ana_cdc_event_process_duration_seconds` | Histogram | table | CDC 事件处理延迟 |
| `data_ana_cdc_consumer_lag` | Gauge | topic, partition | CDC 消费者 lag |
| `data_ana_mastery_calculated_total` | Counter | — | 掌握度计算次数 |
| `data_ana_mastery_published_total` | Counter | status | mastery.updated 事件发布数 |
| `data_ana_datascope_cache_hits_total` | Counter | — | DataScope 缓存命中 |
### 6.5 Tracer 初始化
- 位置:`main.py` `init_tracer()`(已具备)
- endpoint`settings.otel_endpoint` + `/v1/traces`
- **改进**gRPC server 注册 `grpc.aio.ServerInterceptor` 透传 W3C trace context
### 6.6 /healthz 检查逻辑
- liveness仅进程存活已具备
### 6.7 /readyz 检查逻辑
```python
async def readyz() -> dict:
return {
"status": "ok" if all_ready else "degraded",
"ready": all_ready,
"degraded": not all_ready,
"clickhouse": "ok" | "unreachable" | "not_configured",
"cdc_consumer": "running" | "disabled" | "failed",
"kafka_brokers": settings.kafka_brokers or None,
"iam_grpc": "ok" | "unreachable", # 新增iam gRPC 连通性
"timestamp": datetime.now(UTC).isoformat(),
}
```
### 6.8 优雅关闭顺序
1. HTTP server stop accepting new requestsuvicorn graceful shutdown
2. gRPC server graceful stop等待在途 RPC 完成30s 超时)
3. CDC consumer stop等待在途消息处理完成commit offset
4. Kafka producer flush + close确保 mastery.updated 事件已投递)
5. ClickHouse client close
6. iam gRPC channel close
**信号处理**:注册 `signal.SIGTERM` handler触发上述顺序。
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ------------------------- | ----- | ------------------------------------------------------------------- | ----------------------------- |
| 被调用 | api-gateway | HTTP | `/analytics/*` | Gateway 代理 |
| 被调用 | teacher-bff / student-bff | gRPC | `AnalyticsService.*` | BFF 聚合查询 |
| 被调用 | ai | gRPC | `AnalyticsService.GetStudentWeakness / GetLearningTrend` | AI 个性化出题上下文 |
| 调用 | iam | gRPC | `IamService.GetEffectiveDataScope`**待 proto 新增** | DataScope 解析 |
| 消费 | core-eduCDC | Kafka | `edu-cdc.next_edu_cloud.core_edu_grades/exams/homework_submissions` | 学情数据投递 |
| 消费 | core-eduCDC | Kafka | `edu-cdc.next_edu_cloud.classes` | 班级维度同步 |
| 消费 | iamCDC | Kafka | `edu-cdc.next_edu_cloud.iam_users` | 用户 dataScope 同步 |
| 消费 | ai | Kafka | `edu.insight.ai.usage`**待 coord 新增** | AI 用量落库 |
| 发布 | — | Kafka | `edu.insight.mastery.updated` | 掌握度更新通知 core-edu / msg |
## 8. 风险与假设
### 8.1 假设
- **假设 1**iam 提供 `GetEffectiveDataScope(userId) → DataScope` gRPC API。若 iam 未提供fallback 为:从 `x-user-roles` 头推导admin=ALL, teacher=CLASS_TAUGHT, student=SELF但无法支持细粒度年级/学校范围
- **假设 2**core-edu 的 `core_edu_homework_submissions` 表存在 binlog。若不存在作业相关学情无法通过 CDC 获取,需 core-edu 补表或走 Outbox 事件
- **假设 3**ClickHouse `ReplacingMergeTree` 在查询时需 `FINAL` 关键字确保去重生效。当前查询未加 `FINAL`,可能读到重复版本。**修复**:所有查询加 `FINAL` 或使用 `argMax` 聚合
- **假设 4**coord 同意新增 `edu.insight.ai.usage` topic。若不同意ai 服务的用量计费需自建记录(破坏 ai 无状态原则)
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| --------------------------- | ------------------------------------ | ------------------------------------------------ |
| ClickHouse 查询延迟超 5s | 违反 P4 退出标准 | 宽表索引优化 + 物化视图预聚合 + 查询超时 3s 降级 |
| CDC 消费者 lag 过大 | 学情数据延迟 > 5s | 监控 lag + 告警 + 水平扩展消费者(分区数提升) |
| ExamCache 内存泄漏 | 长期运行 OOM | LRU 淘汰策略max 10000 条)+ 定期清理过期 exam |
| mastery.updated 事件丢失 | 下游 core-edu/msg 收不到通知 | Kafka producer `acks=all` + 本地失败表重试 |
| iam gRPC 不可达 | DataScope 无法解析 → 查询降级为 SELF | Redis 缓存 5min + fallback SELF 范围(最保守) |
| ClickHouse `FINAL` 查询性能 | 查询变慢 | 使用 `argMax` 替代 `FINAL`,或在写入时去重 |
### 8.3 未决设计决策(需 coord 仲裁)
1. **mastery.updated 发布是否需要 Outbox**Python 无 Outbox 模式,建议直接 producer但 004 §12.2 明确"事件发布Outbox 模式 / 禁止直接 Kafka producer"。**冲突**data-ana 是 Python 服务无 MySQL 写事务Outbox 不适用。建议 coord 裁定:派生数据事件(非业务事务)允许直接 producer
2. **iam GetEffectiveDataScope proto 新增**:当前 iam.proto 仅有 `GetUserInfo`,无 DataScope 解析 API。需 coord 在 shared-proto 新增 `GetEffectiveDataScope` RPC
3. **edu.insight.ai.usage topic 新增**004 §7.2 未列出,需 coord 确认是否新增
---
# 模块架构设计文档 — ai
## 1. 模块内部分层图
```mermaid
flowchart TB
subgraph Entry["入口层"]
HTTP[FastAPI HTTP Router<br/>/ai/* + /healthz + /readyz]
GRPC[grpc.aio Server<br/>AiService 含 StreamChat]
end
subgraph Middleware["中间件层"]
AUTH[AuthDepends<br/>校验 x-user-id / x-user-roles]
RATE[RateLimitDepends<br/>Redis 令牌桶限流]
TRACE[OTel + grpc interceptor]
end
subgraph Service["应用服务层"]
S1[ChatService<br/>聊天编排 + Prompt 模板渲染]
S2[QuestionGenerationService<br/>出题工作流编排]
S3[ExpressionOptimizationService<br/>表达优化]
S4[LessonPreparationWorkflow<br/>备课工作流 4 步编排]
end
subgraph Provider["LLM Provider 适配层"]
P0[LLMProvider 抽象接口<br/>chat / stream_chat]
P1[OpenAIProvider<br/>httpx 异步]
P2[AnthropicProvider<br/>httpx 异步]
P3[BaichuanProvider<br/>httpx 异步]
P4[LocalOllamaProvider<br/>httpx 异步]
end
subgraph Template["Prompt 模板管理"]
T1[PromptTemplateRegistry<br/>模板注册 + 渲染]
T2[模板存储<br/>YAML 文件 / DB]
end
subgraph Client["下游 gRPC client"]
C1[ContentClient<br/>查询知识点 / 题库]
C2[DataAnaClient<br/>查询学情 / 薄弱点]
end
subgraph Usage["用量计费"]
U1[UsageRecorder<br/>token 消耗统计]
U2[KafkaProducer<br/>发布 edu.insight.ai.usage]
end
subgraph External["外部 / 存储"]
LLM[LLM Provider API<br/>OpenAI/Anthropic/百川/Ollama]
CONTENT[content:3005 gRPC]
DATAANA[data-ana:3006 gRPC]
KAFKA[(Kafka)]
REDIS[(Redis<br/>限流 + 缓存)]
end
HTTP --> AUTH --> RATE --> S1
HTTP --> S2
HTTP --> S3
GRPC --> S1
GRPC --> S2
S2 --> S4
S4 --> C1
S4 --> C2
S1 --> T1
S2 --> T1
S1 --> P0
S2 --> P0
P0 --> P1
P0 --> P2
P0 --> P3
P0 --> P4
P1 --> LLM
P2 --> LLM
P3 --> LLM
P4 --> LLM
S1 --> U1
S2 --> U1
U1 --> U2
U2 --> KAFKA
RATE --> REDIS
C1 --> CONTENT
C2 --> DATAANA
```
**分层规则**
- **入口层**HTTP保留作 Gateway 直连)+ gRPC主入口`StreamChat` 流式 RPC
- **中间件层**Auth + RateLimitRedis 令牌桶,按 user_id 限流)
- **应用服务层**4 个 Service每个对应一类 AI 能力
- **Provider 适配层**:抽象 `LLMProvider` 接口,多适配器实现(策略模式)
- **Prompt 模板**:模板注册 + 渲染模板存储可配置YAML 文件 or DB
- **下游 client**gRPC 调 content / data-ana
- **用量计费**token 消耗统计 + Kafka 事件外发
## 2. 领域模型
ai 是**无状态服务**,不持有持久化聚合根。领域模型为**请求/响应模型 + 工作流编排**
### 聚合根(请求型,无持久化)
| 聚合根 | 含义 | 生命周期 |
| ---------------------------- | ------------ | ------------------------------------------------------------ |
| `ChatConversation` | 单次聊天请求 | 单次请求 |
| `QuestionGenerationTask` | 出题任务 | 单次请求(备课工作流中多步) |
| `ExpressionOptimizationTask` | 表达优化任务 | 单次请求 |
| `LessonPreparationWorkflow` | 备课工作流 | 跨多步(分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库) |
### 值对象
- `ChatMessage`role + content
- `Usage`prompt_tokens + completion_tokens + total_tokens
- `GeneratedQuestion`question + answer + explanation
- `PromptTemplate`name + system_prompt + user_template + variables
### 工作流编排(备课)
```mermaid
sequenceDiagram
participant T as 教师
participant BFF as teacher-bff
participant AI as ai 服务
participant Content as content
participant DA as data-ana
T->>BFF: 请求备课class_id, subject_id
BFF->>AI: gRPC GenerateLessonPlan
AI->>DA: gRPC GetStudentWeakness(class_id)
DA-->>AI: 薄弱知识点列表
AI->>Content: gRPC GetPrerequisites(knowledge_point_id)
Content-->>AI: 前置依赖知识点
AI->>AI: LLM 生成题目(基于学情 + 知识点)
AI-->>BFF: 题目列表 + 推荐理由
BFF-->>T: 题目供审核
T->>BFF: 确认入库
BFF->>Content: gRPC CreateQuestions
Content-->>BFF: 入库成功
```
**工作流实现**
- **简单场景**4 步内FastAPI BackgroundTasks + asyncio.gather 并行查询
- **复杂场景**(含教师审核等待):待 coord 仲裁是否引入 Temporal004 §2.3 列出 Temporal 用于 AI 编排)
## 3. 数据模型
ai 服务**无独占数据库**,无 MySQL schema。所有数据通过 Kafka 事件外发(用量计费)或 gRPC 查询下游。
### 用量计费Kafka 事件 → data-ana 落 ClickHouse
```json
{
"event_id": "uuid",
"user_id": "user-xxx",
"request_id": "req-xxx",
"provider": "openai",
"model": "gpt-4o-mini",
"prompt_tokens": 150,
"completion_tokens": 80,
"total_tokens": 230,
"latency_ms": 1200,
"success": true,
"occurred_at": "2026-07-09T12:00:00Z"
}
```
**Topic**`edu.insight.ai.usage`**待 coord 新增**
### 缓存策略
| 数据 | 存储 | TTL | 失效策略 |
| -------------------------------------- | ----------------------------- | -------- | ---------------------- |
| Prompt 模板 | Redis模板变更事件驱动失效 | 1 小时 | 文件/DB 变更时主动失效 |
| LLM 响应(相同 prompt | Redishash 缓存) | 30 分钟 | 短 TTL避免陈旧 |
| DataScopeai 不需要,仅 data-ana 用) | — | — | — |
| 限流计数 | Redis 令牌桶 | 滑动窗口 | 自动过期 |
## 4. API 设计
### 4.1 HTTP 端点(保留作 Gateway 直连降级)
| method | path | 权限 | 请求体 | 响应 | 说明 |
| ------ | ------------------------- | ------------------------ | --------------------------- | ------------------------------------ | ---------------------- |
| GET | `/healthz` | — | — | `{status, service}` | liveness |
| GET | `/readyz` | — | — | `{status, llm_configured, degraded}` | readiness |
| GET | `/metrics` | — | — | Prometheus | 指标 |
| POST | `/ai/chat` | `AI_CHAT` | `ChatRequest` | `ChatResponse` | LLM 聊天 |
| POST | `/ai/chat/stream` | `AI_CHAT` | `ChatRequest` | SSE stream | 流式聊天 |
| POST | `/ai/generate/question` | `AI_QUESTION_GENERATE` | `GenerateQuestionRequest` | `GeneratedQuestionResponse` | 生成题目 |
| POST | `/ai/optimize/expression` | `AI_EXPRESSION_OPTIMIZE` | `OptimizeExpressionRequest` | `OptimizedExpressionResponse` | 优化表达 |
| POST | `/ai/lesson/preparation` | `AI_LESSON_PREPARE` | `LessonPreparationRequest` | `LessonPreparationResponse` | 备课工作流(**新增** |
### 4.2 gRPC 契约ai.proto待实现 server
| RPC | 请求 | 响应 | 权限 | 说明 |
| -------------------- | ------------------------------------------------------ | ------------------------------------- | ------------------------ | ------------------------- |
| `Chat` | `ChatRequest{messages, model, temperature}` | `ChatResponse{content, model, usage}` | `AI_CHAT` | 非流式聊天 |
| `StreamChat` | `ChatRequest` | `stream ChatChunk` | `AI_CHAT` | 流式聊天SSE over gRPC |
| `GenerateQuestion` | `GenerateQuestionRequest{prompt, subject, difficulty}` | `GeneratedQuestion` | `AI_QUESTION_GENERATE` | 生成题目 |
| `OptimizeExpression` | `OptimizeExpressionRequest{text, context}` | `OptimizedExpression` | `AI_EXPRESSION_OPTIMIZE` | 优化表达 |
### 4.3 Pydantic 请求/响应模型
```python
class ChatRequest(BaseModel):
messages: list[ChatMessage]
model: str = "gpt-4o-mini"
temperature: float = Field(0.7, ge=0.0, le=2.0)
stream: bool = False
class ChatMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
class ChatResponse(BaseModel):
success: bool
data: ChatData
degraded: bool = False
class ChatData(BaseModel):
content: str
model: str
usage: Usage
class Usage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class GenerateQuestionRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=2000)
subject: str
difficulty: Literal["easy", "medium", "hard"]
knowledge_point_ids: list[str] = [] # 可选:靶向知识点
class GeneratedQuestionResponse(BaseModel):
success: bool
data: GeneratedQuestionData
degraded: bool = False
```
## 5. 事件设计
### 5.1 消费的事件
ai 服务**不消费任何事件**(无状态,纯请求-响应)。
### 5.2 发布的事件
| 事件 | Topic | 触发时机 | 消费者 | Payload |
| ----------------- | ------------------------------------------- | ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `AIUsageRecorded` | `edu.insight.ai.usage`**待 coord 新增** | 每次 LLM 调用完成 | data-ana落 ClickHouse ai_usage_log | `{event_id, user_id, request_id, provider, model, prompt_tokens, completion_tokens, total_tokens, latency_ms, success, occurred_at}` |
**发布实现**
- 每次 LLM 调用后异步发布(不阻塞响应)
- `aiokafka.AIOKafkaProducer` + `acks=all`
- 失败重试 3 次,仍失败记录日志(不影响主流程)
## 6. 横切关注点对齐清单
### 6.1 权限装饰器等价物
```python
class Permissions:
AI_CHAT = "ai:chat"
AI_QUESTION_GENERATE = "ai:question:generate"
AI_EXPRESSION_OPTIMIZE = "ai:expression:optimize"
AI_LESSON_PREPARE = "ai:lesson:prepare"
async def require_permission(permission: str) -> UserContext:
"""从 x-user-id / x-user-roles 校验权限."""
...
```
### 6.2 错误码清单(前缀 `AI_*`
| 错误码 | 触发条件 | HTTP | gRPC status |
| --------------------------- | ----------------------------------------- | ------------------- | ------------------ |
| `AI_UNAUTHORIZED` | 缺失 x-user-id 或 token 无效 | 401 | UNAUTHENTICATED |
| `AI_FORBIDDEN` | 角色无对应权限 | 403 | PERMISSION_DENIED |
| `AI_RATE_LIMITED` | 触发限流 | 429 | RESOURCE_EXHAUSTED |
| `AI_LLM_UNAVAILABLE` | LLM Provider 不可达(降级模式仍返回骨架) | 200 + degraded:true | OK + degraded flag |
| `AI_LLM_TIMEOUT` | LLM 调用超时30s | 504 | DEADLINE_EXCEEDED |
| `AI_INVALID_MODEL` | model 名不支持 | 400 | INVALID_ARGUMENT |
| `AI_INVALID_DIFFICULTY` | difficulty 不在 easy/medium/hard | 400 | INVALID_ARGUMENT |
| `AI_DOWNSTREAM_UNAVAILABLE` | content / data-ana gRPC 不可达 | 502 | UNAVAILABLE |
| `AI_PROMPT_RENDER_FAILED` | Prompt 模板渲染失败(变量缺失) | 500 | INTERNAL |
| `AI_INTERNAL_ERROR` | 未捕获异常 | 500 | INTERNAL |
### 6.3 Logger 初始化
- 位置:`main.py`(已具备)
- **改进**:生产环境改用 `JSONRenderer`
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| ---------------------------------- | --------- | ----------------------------------------- | ------------------ |
| `ai_http_requests_total` | Counter | method, path, status | HTTP 请求总数 |
| `ai_http_request_duration_seconds` | Histogram | method, path | HTTP 请求延迟 |
| `ai_llm_calls_total` | Counter | provider, model, status | LLM 调用总数 |
| `ai_llm_call_duration_seconds` | Histogram | provider, model | LLM 调用延迟 |
| `ai_llm_tokens_total` | Counter | provider, model, type (prompt/completion) | token 消耗总数 |
| `ai_llm_stream_chunks_total` | Counter | provider, model | 流式 chunk 总数 |
| `ai_grpc_calls_total` | Counter | downstream, method, status | 下游 gRPC 调用总数 |
| `ai_rate_limit_hits_total` | Counter | user_id | 限流命中次数 |
| `ai_usage_events_published_total` | Counter | status | 用量事件发布数 |
| `ai_prompt_template_renders_total` | Counter | template_name, status | 模板渲染次数 |
### 6.5 Tracer 初始化
- 位置:`main.py` `init_tracer()`已具备dev_mode 跳过)
- **改进**gRPC server interceptor + 下游 gRPC client interceptor 透传 trace context
### 6.6 /healthz 检查逻辑
- liveness仅进程存活已具备
### 6.7 /readyz 检查逻辑
```python
async def readyz() -> dict:
return {
"status": "ok",
"service": "ai",
"llm_configured": settings.llm_available,
"degraded": not settings.llm_available,
"providers": {
"openai": bool(settings.openai_api_key),
"anthropic": bool(settings.anthropic_api_key),
"baichuan": bool(settings.baichuan_api_key),
"local_ollama": bool(settings.ollama_base_url),
},
"downstream_grpc": {
"content": "ok" | "unreachable", # 新增
"data_ana": "ok" | "unreachable", # 新增
},
"redis": "ok" | "unreachable", # 新增(限流依赖)
}
```
### 6.8 优雅关闭顺序
1. HTTP server stop accepting new requests
2. gRPC server graceful stop**关键**:等待在途 `StreamChat` 流式 RPC 完成60s 超时,避免截断用户响应)
3. LLM 流式请求 drain等待 httpx stream 完成)
4. Kafka producer flush + close确保用量事件已投递
5. 下游 gRPC channels closecontent / data-ana
6. Redis connection close
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ------------ | ----- | ---------------------------------------------------------- | ------------------ |
| 被调用 | api-gateway | HTTP | `/ai/*` | Gateway 代理 |
| 被调用 | teacher-bff | gRPC | `AiService.*` | BFF 聚合 AI 能力 |
| 调用 | content | gRPC | `KnowledgeGraphService.GetPrerequisites / GetLearningPath` | 出题上下文查询 |
| 调用 | content | gRPC | `TextbookService.*`(若需教材上下文) | 出题教材关联 |
| 调用 | data-ana | gRPC | `AnalyticsService.GetStudentWeakness / GetLearningTrend` | 个性化出题学情查询 |
| 调用 | LLM Provider | HTTP | OpenAI 兼容 REST `/chat/completions` | LLM 推理 |
| 发布 | — | Kafka | `edu.insight.ai.usage`**待 coord 新增** | 用量计费外发 |
## 8. 风险与假设
### 8.1 假设
- **假设 1**content 服务实现了 `KnowledgeGraphService` gRPC server。当前 content.proto 已定义但未实现 gRPC serverai05 阶段 2 设计中。ai 调用前需确认 content gRPC 可用
- **假设 2**data-ana 实现了 `AnalyticsService` gRPC server本设计文档已设计。ai 调用前需确认 data-ana gRPC 可用
- **假设 3**coord 同意新增 `edu.insight.ai.usage` topic。若不同意用量计费降级为 ai 本地日志(不落 ClickHouse影响成本分析
- **假设 4**Redis 可用(限流依赖)。若 Redis 不可用,限流降级为"无限制"风险LLM 成本失控),或降级为内存令牌桶(单实例有效,多实例不一致)
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| ---------------------- | ---------------- | --------------------------------------------------------------- |
| LLM 调用延迟高(>30s | 用户体验差 | 超时 30s + 降级骨架响应 + 流式优先(用户感知首字延迟) |
| LLM 成本失控 | 财务风险 | Redis 令牌桶限流(每用户每分钟 10 次)+ 用量计费监控 + 告警阈值 |
| LLM Provider 单点故障 | 服务不可用 | 多 Provider 适配器 + 自动 failoverOpenAI 失败切 Anthropic |
| 流式 RPC 中断 | 用户响应截断 | gRPC server graceful shutdown 60s drain + 客户端重连机制 |
| Prompt 注入攻击 | LLM 输出恶意内容 | 输入 sanitize + system prompt 加安全约束 + 输出过滤 |
| 下游 gRPC 不可达 | 备课工作流失败 | 降级:跳过学情查询,仅基于 prompt 生成题目 + `degraded: true` |
### 8.3 未决设计决策(需 coord 仲裁)
1. **备课工作流是否引入 Temporal**004 §2.3 列出 Temporal 用于 AI 编排,但简单 4 步工作流可用 FastAPI BackgroundTasks。建议M14 用 BackgroundTasksM15 评估是否迁移 Temporal
2. **`edu.insight.ai.usage` topic 新增**:需 coord 在 shared-proto events.proto 新增 `AIUsageEvent` message + 004 §7.2 新增 topic
3. **LLM Provider 配置化路由**:是否在 shared-py 建立通用 `LLMProvider` 抽象(供未来其他 Python 服务复用)。建议 P5 阶段在 ai 服务内部实现P6 评估是否提取到 shared-py
4. **Prompt 模板存储位置**YAML 文件(简单,无 DBvs DB动态更新。建议 P5 用 YAML 文件(`services/ai/src/ai/prompts/*.yaml`P6 评估迁移 DB
---
# 阶段 2 总结
ai06 已完成阶段 2 模块架构设计,产出 data-ana 与 ai 两份设计文档。核心设计决策:
## data-ana 设计要点
1. **分层**HTTP + gRPC 双入口共享 Application ServiceCDC 消费者独立后台任务
2. **数据模型**4 张 ClickHouse 宽表student_dashboard_view / student_errors / mastery_snapshot / ai_usage_logReplacingMergeTree 引擎保证幂等
3. **权限**FastAPI Depends 链require_permission + inject_data_scopeDataScope WHERE 注入 ClickHouse 查询
4. **事件**:消费 6 个 CDC topic + 发布 `edu.insight.mastery.updated`(直接 producer非 Outbox因派生数据非事务写
5. **gRPC**:实现 `AnalyticsService` serveranalytics.proto 已定义)
6. **降级**ClickHouse 不可达返回骨架 + degraded:trueiam gRPC 不可达降级为 SELF DataScope
## ai 设计要点
1. **分层**HTTP + gRPC 双入口LLM Provider 适配层策略模式4 适配器)
2. **无状态**:无 DB用量计费通过 Kafka 事件外发
3. **权限**FastAPI Depends + Redis 令牌桶限流(每用户每分钟 10 次)
4. **工作流**:备课 4 步编排(学情查询 → 知识点推荐 → 题目生成 → 教师审核入库),简单场景用 BackgroundTasks
5. **gRPC**:实现 `AiService` server`StreamChat` 流式 RPCgRPC client 调 content / data-ana
6. **降级**LLM 不可达返回骨架 + degraded:true下游 gRPC 不可达降级跳过
## 待 coord 交叉审查项(汇总)
| # | 议题 | 涉及文档 |
| --- | ------------------------------------------------------------------------------------ | ----------------------- |
| 1 | data-ana 发布 `edu.insight.mastery.updated` 用直接 producer非 Outbox是否合规 | 004 §12.2 |
| 2 | 新增 `edu.insight.ai.usage` topic + `AIUsageEvent` proto message | 004 §7.2 + events.proto |
| 3 | iam 新增 `GetEffectiveDataScope` gRPC RPC | iam.proto |
| 4 | data-ana / ai 实现 gRPC server 决策 | 004 §4.1 |
| 5 | ClickHouse DDL 管理位置(建议 `infra/clickhouse/ddl/` | infra/ |
| 6 | ai 备课工作流是否引入 Temporal | 004 §2.3 |
| 7 | 端口冲突检查data-ana 3006 / ai 3008无冲突 | full-stack-runbook |
| 8 | 错误码前缀检查:`DATA_ANA_*` / `AI_*`(与其他服务不重叠) | — |
| 9 | 黄金模板对齐Python 服务无 NestJS 装饰器,权限校验用 FastAPI Depends 等价物是否认可 | — |
下一步:等待 coord 交叉审查通过后,进入阶段 3按图实施

View File

@@ -0,0 +1,281 @@
# 全链路启用与测试手册Full Stack Runbook
> 版本1.0
> 日期2026-07-09
> 适用范围Edu 微服务架构P6 阶段11 基础设施 + 10 应用服务 + 1 前端)
> 关联文档:[local-dev-runbook](./local-dev-runbook.md)、[004 架构影响地图](../architecture/004_architecture_impact_map.md)、[known-issues](../troubleshooting/known-issues.md)
---
## 1. 前置条件
| 工具 | 版本要求 | 验证命令 |
| -------------- | -------- | ------------------------ |
| Node.js | ≥ 20 | `node -v` |
| pnpm | ≥ 9 | `pnpm -v` |
| Go | 1.22+ | `go version` |
| uv | 0.4+ | `uv --version` |
| Docker | 24+ | `docker version` |
| Docker Compose | v2+ | `docker compose version` |
> Windows 用户Go 工具链若不在 PATH临时加入`$env:Path = "C:\Program Files\Go\bin;" + $env:Path`
---
## 2. 服务端口矩阵
### 2.1 应用服务10 个)
| 端口 | 服务 | 语言/框架 | 启动方式 |
| ---- | -------------- | --------- | ---------------------------------------------- |
| 3000 | teacher-portal | Next.js | `pnpm --filter teacher-portal dev` |
| 3001 | classes | NestJS | `pnpm --filter @edu/classes-service dev` |
| 3002 | iam | NestJS | `pnpm --filter @edu/iam-service dev` |
| 3003 | teacher-bff | NestJS | `pnpm --filter @edu/teacher-bff dev` |
| 3004 | core-edu | NestJS | `pnpm --filter @edu/core-edu-service dev` |
| 3005 | content | NestJS | `pnpm --filter @edu/content-service dev` |
| 3006 | data-ana | FastAPI | `uv run uvicorn data_ana.main:app --port 3006` |
| 3007 | msg | NestJS | `pnpm --filter @edu/msg-service dev` |
| 3008 | ai | FastAPI | `uv run uvicorn ai.main:app --port 3008` |
| 8080 | api-gateway | Go (Gin) | `go run .` |
| 8081 | push-gateway | Go (Gin) | `go run .` |
### 2.2 基础设施11 个)
| 端口 | 服务 | 用途 |
| --------- | ------------------ | --------------------- |
| 3306 | MySQL 8 | 写模型主库 |
| 6379 | Redis 7 | 缓存/会话 |
| 8083 | Debezium Connect | CDC source connector |
| 8123/9000 | ClickHouse 24.3 | 读模型宽表 |
| 9092 | Kafka 7.6 | 事件总线 |
| 7474/7687 | Neo4j 5.20 | 知识图谱 |
| 9200 | Elasticsearch 8.13 | 题库检索 |
| 9090 | Prometheus | 指标采集 |
| 3030 | Grafana | 可视化admin/admin |
| 16686 | Jaeger | 分布式追踪 UI |
| 4318 | OTLP Collector | OTel span 接收 |
---
## 3. 一键启动
### 3.1 启动基础设施
```powershell
cd e:\Desktop\Edu\infra
docker compose -f docker-compose.yml --profile p6 --profile observability up -d
```
等待所有容器 healthy约 60 秒):
```powershell
docker ps --filter "name=edu-" --format "table {{.Names}}\t{{.Status}}"
```
### 3.2 一键启动所有应用服务
使用项目根目录的启动脚本:
```powershell
cd e:\Desktop\Edu
.\scripts\start-all.ps1
```
该脚本会:
1. 检查基础设施健康状态
2. 为每个应用服务启动独立后台窗口(带标题)
3. 自动注入 Python 服务的环境变量CLICKHOUSE/KAFKA/OTEL
4. 等待所有服务健康检查通过
### 3.3 健康检查
```powershell
cd e:\Desktop\Edu
.\scripts\health-check.ps1
```
预期输出:所有服务 ✅
---
## 4. 端到端链路测试
### 4.1 IAM 注册 + 登录
```powershell
$h = @{Authorization="Bearer dev-token"}
# 注册
$body = @{username="testteacher";password="Test@1234";email="test@edu.com";role="teacher"} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:8080/iam/auth/register" -Method Post -Body $body -ContentType "application/json" -Headers $h
# 登录
$loginBody = @{username="testteacher";password="Test@1234"} | ConvertTo-Json
$resp = Invoke-RestMethod -Uri "http://localhost:8080/iam/auth/login" -Method Post -Body $loginBody -ContentType "application/json"
$token = $resp.data.accessToken
Write-Host "Token: $token"
```
### 4.2 Classes CRUD
```powershell
# 创建班级
$classBody = @{name="高三一班";gradeId="grade-1";headTeacherId=""} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:8080/classes" -Method Post -Body $classBody -ContentType "application/json" -Headers $h
# 查询班级列表
Invoke-RestMethod -Uri "http://localhost:8080/classes" -Method Get -Headers $h
```
### 4.3 CDC 完整链路MySQL → Debezium → Kafka → data-ana → ClickHouse
```powershell
# 1. 向 MySQL 插入成绩(触发 binlog
docker exec edu-mysql mysql -uedu -pchangeme next_edu_cloud -e "
INSERT INTO core_edu_exams (id, class_id, subject_id, title, exam_date, total_score, created_at, updated_at)
VALUES ('exam-cdc-test-001','cls-test-001','sub-math','CDC测试考试',NOW(),100,NOW(),NOW())
ON DUPLICATE KEY UPDATE updated_at=NOW();
INSERT INTO core_edu_grades (id, exam_id, student_id, score, rank_in_class, created_at, updated_at)
VALUES ('grade-cdc-001','exam-cdc-test-001','student-cdc-001',92.5,1,NOW(),NOW())
ON DUPLICATE KEY UPDATE score=92.5, updated_at=NOW();
"
# 2. 等待 Debezium 捕获 + data-ana 消费
Start-Sleep -Seconds 5
# 3. 验证 ClickHouse 已同步
docker exec edu-clickhouse clickhouse-client --user default --password clickhouse -q "
SELECT student_id, class_id, exam_id, score, last_updated
FROM edu_analytics.student_dashboard_view
WHERE student_id = 'student-cdc-001'
ORDER BY last_updated DESC
"
# 预期返回一行score=92.5class_id='cls-test-001'
```
### 4.4 data-ana 查询 API
```powershell
# 学生学情看板
Invoke-RestMethod -Uri "http://localhost:3006/analytics/student/student-cdc-001/weakness" -Headers $h
# 班级成绩分析
Invoke-RestMethod -Uri "http://localhost:3006/analytics/class/cls-test-001/performance" -Headers $h
# CDC 消费者状态
Invoke-RestMethod -Uri "http://localhost:3006/readyz"
# 预期: cdc_consumer = "running"
```
### 4.5 可观测性验证
| 检查项 | URL | 预期 |
| ------------------ | ----------------------------------- | ------------------------ |
| Prometheus targets | http://localhost:9090/targets | 所有 target UP |
| Grafana 面板 | http://localhost:3030 (admin/admin) | 可登录 |
| Jaeger UI | http://localhost:16686 | 可搜索到各 service trace |
| data-ana /metrics | http://localhost:3006/metrics | Prometheus 格式输出 |
| iam /metrics | http://localhost:3002/metrics | Prometheus 格式输出 |
**Jaeger trace 验证步骤**
1. 打开 http://localhost:16686
2. Service 下拉框应能看到 `iam``classes``data-ana``api-gateway`
3. 选择任一服务 → Find Traces → 应看到 HTTP 请求的自动埋点 span
---
## 5. 一键停止
### 5.1 停止应用服务
```powershell
cd e:\Desktop\Edu
.\scripts\stop-all.ps1
.\scripts\stop-all.ps1 -KillByPort
```
该脚本会关闭所有 `edu-app-*` 标题的终端窗口。
### 5.2 停止基础设施
```powershell
cd e:\Desktop\Edu\infra
docker compose -f docker-compose.yml --profile p6 --profile observability down
```
---
## 6. 故障排查
### 6.1 端口占用
```powershell
# 查看占用端口的进程
netstat -ano | findstr :3001
# 终止进程
taskkill /PID <PID> /F
```
### 6.2 基础设施未启动
```powershell
# 检查容器状态
docker ps --filter "name=edu-"
# 重启单个容器
docker restart edu-mysql
# 查看日志
docker logs edu-debezium --tail 50
```
### 6.3 CDC 链路断开
```powershell
# 1. 检查 Debezium connector 状态
Invoke-RestMethod -Uri "http://localhost:8083/connectors/edu-mysql-source/status"
# 2. 重启 connector
Invoke-RestMethod -Uri "http://localhost:8083/connectors/edu-mysql-source/restart" -Method Post
# 3. 检查 Kafka topic 是否有数据
docker exec edu-kafka kafka-console-consumer --bootstrap-server localhost:9092 --topic edu-cdc.next_edu_cloud.core_edu_grades --from-beginning --max-messages 1
# 4. 检查 data-ana 消费者日志
# 查看 data-ana 终端窗口的 cdc_consumer_started / cdc_event_received 日志
```
### 6.4 OTel trace 未上报
```powershell
# 1. 检查 Jaeger 是否收到 trace
Invoke-RestMethod -Uri "http://localhost:16686/api/services"
# 2. 检查 OTLP endpoint 是否可达
Invoke-RestMethod -Uri "http://localhost:4318/v1/traces" -Method Post -ContentType "application/json" -Body "{}"
# 3. 检查服务环境变量
# 确保 OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 已设置
```
---
## 7. 速查:常用命令
| 场景 | 命令 |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 启动基础设施 | `docker compose -f infra/docker-compose.yml --profile p6 --profile observability up -d` |
| 一键启动应用 | `.\scripts\start-all.ps1` |
| 一键停止应用 | `.\scripts\stop-all.ps1` |
| 健康检查 | `.\scripts\health-check.ps1` |
| CDC 链路验证 | `.\scripts\test-cdc.ps1` |
| 查看容器状态 | `docker ps --filter "name=edu-"` |
| 查看 Debezium 状态 | `Invoke-RestMethod http://localhost:8083/connectors/edu-mysql-source/status` |
| ClickHouse 查询 | `docker exec edu-clickhouse clickhouse-client --user default --password clickhouse -q "SELECT * FROM edu_analytics.student_dashboard_view LIMIT 10"` |
| Kafka topic 列表 | `docker exec edu-kafka kafka-topics --bootstrap-server localhost:9092 --list` |
| Prometheus 查询 | `Invoke-RestMethod "http://localhost:9090/api/v1/query?query=up"` |

View File

@@ -37,36 +37,171 @@
--- ---
## 2. 模块分工矩阵 ## 2. 两种协作模式
### 2.1 模块清单与 AI 分配 本项目支持两种 AI 协作模式,根据开发阶段灵活切换:
每个 AI 负责一个"限界上下文",避免跨模块修改导致冲突。 ### 2.0 模式选择
| 模 | scope | 路径 | 负责阶段 | 建议 AI 数 | | 模 | 适用阶段 | 特点 |
| -------------- | ---------------- | ------------------------- | -------- | ------------ | | ------------------ | ---------------------------------- | ----------------------------------------------------- |
| api-gateway | `api-gateway` | `services/api-gateway/` | P1 | 1 | | **单仓库并行模式** | 架构设计外包、各服务独立功能开发 | 无需 PR直接 push main路径级别物理隔离几乎零冲突 |
| classes | `classes` | `services/classes/` | P1 | 1 | | **PR 模式** | 共享文件修改、跨模块变更、代码审核 | 标准 PR 流程coord 审核后 Squash Merge |
| teacher-portal | `teacher-portal` | `apps/teacher-portal/` | P1-P2 | 1 |
| iam | `iam` | `services/iam/` | P2 | 1 |
| teacher-bff | `teacher-bff` | `services/teacher-bff/` | P2 | 1 |
| core-edu | `core-edu` | `services/core-edu/` | P3 | 1 |
| content | `content` | `services/content/` | P4 | 1 |
| data-ana | `data-ana` | `services/data-ana/` | P4 | 1 |
| msg | `msg` | `services/msg/` | P5 | 1 |
| ai | `ai` | `services/ai/` | P5 | 1 |
| push-gateway | `push-gateway` | `services/push-gateway/` | P5 | 1 |
| shared-proto | `shared-proto` | `packages/shared-proto/` | 跨阶段 | 协调 AI 维护 |
| shared-tokens | `shared-tokens` | `packages/shared-tokens/` | 跨阶段 | 协调 AI 维护 |
| infra | `infra` | `infra/` | 跨阶段 | 1 (SRE AI) |
| docs | `docs` | `docs/` | 跨阶段 | 协调 AI 维护 |
### 2.2 分工原则 > 当前架构设计外包阶段使用**单仓库并行模式**,详见 [AI 分配方案](../architecture/ai-allocation.md)。
---
### 2.1 单仓库并行模式
**适用场景**:各 AI 修改的文件路径物理隔离(不同 `services/<name>/` 目录),无需 PR 审核。
**核心规则**
1. 每个 AI 只能修改自己负责的目录
2. `pnpm-lock.yaml` 是唯一可能冲突的共享文件,冲突时取远程版本后 `pnpm install` 重新生成
3. proto 变更由 coord 统一管理(其他 AI 只读引用 `packages/shared-proto/`
**提交流程**
```bash
# 每天开始
git pull origin main --rebase
# 在自己目录内工作后提交
git add services/<my-service>/...
git commit -m "feat(<scope>): <描述>"
# 直接 push
git pull origin main --rebase # 先拉最新
git push
```
**pnpm-lock.yaml 冲突处理**
```bash
git pull origin main --rebase
# 若 pnpm-lock.yaml 冲突:
git checkout --theirs pnpm-lock.yaml
pnpm install
git add pnpm-lock.yaml
git rebase --continue
```
---
### 2.2 PR 模式(标准流程)
PR 模式见 §3-§6分支策略、推送流程、PR 流程、审核合并)。
---
### 2.3 模块完整清单
| 类别 | 服务名 | scope | 路径 | 阶段 | 语言 |
| ---- | -------------- | ---------------- | ------------------------ | ------ | -------- |
| 网关 | api-gateway | `api-gateway` | `services/api-gateway/` | P1 | Go |
| 网关 | push-gateway | `push-gateway` | `services/push-gateway/` | P5 | Go |
| BFF | teacher-bff | `teacher-bff` | `services/teacher-bff/` | P2 | TS |
| BFF | student-bff | `student-bff` | `services/student-bff/` | P3 | TS |
| BFF | parent-bff | `parent-bff` | `services/parent-bff/` | P4 | TS |
| 业务 | iam | `iam` | `services/iam/` | P2 | TS |
| 业务 | core-edu | `core-edu` | `services/core-edu/` | P3 | TS |
| 业务 | content | `content` | `services/content/` | P4 | TS |
| 业务 | msg | `msg` | `services/msg/` | P5 | TS |
| 业务 | data-ana | `data-ana` | `services/data-ana/` | P4 | Python |
| 业务 | ai | `ai` | `services/ai/` | P5 | Python |
| 前端 | teacher-portal | `teacher-portal` | `apps/teacher-portal/` | P2 | TS |
| 前端 | student-portal | `student-portal` | `apps/student-portal/` | P3 | TS |
| 前端 | parent-portal | `parent-portal` | `apps/parent-portal/` | P4 | TS |
| 前端 | admin-portal | `admin-portal` | `apps/admin-portal/` | P6 | TS |
| 共享 | shared-proto | `shared-proto` | `packages/shared-proto/` | 跨阶段 | protobuf |
| 共享 | shared-ts | `shared-ts` | `packages/shared-ts/` | 跨阶段 | TS |
| 共享 | shared-go | `shared-go` | `packages/shared-go/` | 跨阶段 | Go |
| 共享 | shared-py | `shared-py` | `packages/shared-py/` | 跨阶段 | Python |
| 基础 | infra | `infra` | `infra/` | 跨阶段 | — |
### 2.4 当前阶段 AI 分配7 AI + 1 coord
详见 [AI 分配方案](../architecture/ai-allocation.md#3-ai-分配方案7-ai--1-coord)。摘要如下:
| AI | 服务 | 语言 |
| ----- | ----------------------------------------------------------- | ------ |
| ai01 | api-gateway、push-gateway | Go |
| ai02 | iam | TS |
| ai03 | teacher-bff、core-edu | TS |
| ai04 | student-bff、parent-bff | TS |
| ai05 | content、msg | TS |
| ai06 | data-ana、ai | Python |
| ai07 | teacher-portal、student-portal、parent-portal、admin-portal | TS |
| coord | shared-proto、shared-*、infra/、docs/、CI/CD | — |
### 2.5 分工原则
1. **单一负责制**:每个模块只有一个 AI 负责,避免并行修改同一文件 1. **单一负责制**:每个模块只有一个 AI 负责,避免并行修改同一文件
2. **契约集中管理**`shared-proto` 由协调 AI 维护,开发 AI 只读引用 2. **同语言内聚**:一个 AI 负责多个同语言服务,降低学习成本
3. **跨模块变更拆分**:需要修改多个模块时,拆成多个 PR按依赖顺序合并 3. **契约集中管理**`shared-proto` 由 coord 维护,开发 AI 只读引用
4. **基础设施独立**`infra/` 由 SRE AI 专门负责,业务 AI 不直接修改 4. **跨模块变更拆分**需要修改多个模块时按依赖顺序proto → service → gateway → BFF → frontend
5. **基础设施独立**`infra/` 由 coord或 SRE AI专门负责
6. **物理路径隔离**:目录级别隔离,单仓库并行几乎零文件冲突
### 2.6 架构设计外包三阶段
当前项目处于**架构设计外包阶段**,所有 AI 需先完成设计再动手写代码。完整文档见 [AI 分配方案](../architecture/ai-allocation.md)。简述如下:
```
阶段 1全局理解 → 交付"理解确认书"
阶段 2模块架构设计 → 交付"模块架构设计文档"coord 交叉审查
阶段 3按图实施 → 按设计文档写代码coord 定期巡检一致性
```
**阶段 1 交付物模板**
```markdown
## 模块理解确认书 — [模块名]
### 1. 我在架构中的位置
- 层级 / 上下游 / 通信方式
### 2. 我的限界上下文
- 聚合/实体 / 业务领域D1-D6/ 边界外
### 3. 我与外部的契约
- proto message / API/事件 / 错误码前缀
### 4. 我的技术栈
### 5. 我的阶段归属P1-P6
### 6. 黄金模板对齐清单(对照 classes 服务)
```
**阶段 2 交付物模板**
```markdown
## 模块架构设计文档 — [模块名]
### 1. 模块内部分层图
### 2. 领域模型(聚合根/实体/值对象)
### 3. 数据模型(表/schema/索引/读写分离)
### 4. API 设计(端点/权限/请求响应)
### 5. 事件设计(发布/消费/Topic
### 6. 横切关注点(权限/错误/可观测/健康/优雅关闭)
### 7. 与其他模块的交互点(契约清单)
### 8. 风险与假设
```
**coord 交叉审查**:收到全部 7 份设计文档后检查接口一致性、端口冲突、Topic 重复、错误码重叠、黄金模板对齐。
--- ---
@@ -726,6 +861,7 @@ git tag -a v<version> -m "..." && git push origin v<version>
## 15. 相关文档 ## 15. 相关文档
- [AI 分配方案](../architecture/ai-allocation.md) — 架构设计外包 AI 分配与三阶段流程
- [Git 工作流](./git-workflow.md) — 提交规范、分支策略、CODEOWNERS - [Git 工作流](./git-workflow.md) — 提交规范、分支策略、CODEOWNERS
- [本地启动手册](./local-dev-runbook.md) — 手动启动服务 - [本地启动手册](./local-dev-runbook.md) — 手动启动服务
- [项目规则](../../.trae/rules/project_rules.md) — 强制约束 - [项目规则](../../.trae/rules/project_rules.md) — 强制约束

View File

@@ -92,7 +92,7 @@
### 1.6 可观测性OTel + Prometheus + Loki ### 1.6 可观测性OTel + Prometheus + Loki
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) | | P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) |
| 三支柱 | Logspino/winston/zap+ Metricsprom-client+ TracesOTel SDK | | 三支柱 | Logspino/winston/zap+ Metricsprom-client+ TracesOTel SDK |
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 | | traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
@@ -107,7 +107,7 @@
| P6 service label | static_configs.labels.service 给所有抓取目标打服务标签,告警规则按 service 聚合 | | P6 service label | static_configs.labels.service 给所有抓取目标打服务标签,告警规则按 service 聚合 |
| P6 collectDefaultMetrics | prom-client 的 `collectDefaultMetrics({ register })` 自动收集进程级指标CPU/内存/事件循环/GC无需业务埋点即可让 /metrics 有数据 | | P6 collectDefaultMetrics | prom-client 的 `collectDefaultMetrics({ register })` 自动收集进程级指标CPU/内存/事件循环/GC无需业务埋点即可让 /metrics 有数据 |
| P6 Counter/Histogram 埋点缺失 | metrics.ts 定义了 Counter/Histogram 但 service/controller 未调用 `.inc()`/`.observe()`,需后续补 HTTP 中间件自动埋点或业务埋点 | | P6 Counter/Histogram 埋点缺失 | metrics.ts 定义了 Counter/Histogram 但 service/controller 未调用 `.inc()`/`.observe()`,需后续补 HTTP 中间件自动埋点或业务埋点 |
| P6 OTel instrumentations 缺失 | tracer.ts 只配置 traceExporter 未注册 auto-instrumentationsHttpInstrumentation/ExpressInstrumentation导致 Jaeger 收不到业务 trace需后续补 `@opentelemetry/auto-instrumentations` | | P6 OTel instrumentations 缺失 | tracer.ts 只配置 traceExporter 未注册 auto-instrumentationsHttpInstrumentation/ExpressInstrumentation导致 Jaeger 收不到业务 trace需后续补 `@opentelemetry/auto-instrumentations`。**已补全**NestJS 6 服务用 `getNodeAutoInstrumentations()`Python 2 服务用 `FastAPIInstrumentor.instrument_app(app)`Go 2 服务用 `otelgin.Middleware()` |
| P6 镜像源配置 | 国内 docker.io 被墙compose image 必须加 `docker.m.daocloud.io/` 前缀Elastic 官方镜像在 docker.elastic.co 不被墙 | | P6 镜像源配置 | 国内 docker.io 被墙compose image 必须加 `docker.m.daocloud.io/` 前缀Elastic 官方镜像在 docker.elastic.co 不被墙 |
| P6 Grafana 端口冲突 | Grafana 默认 3000 与 teacher-portal Next.js dev 冲突,改映射为 3030:3000 | | P6 Grafana 端口冲突 | Grafana 默认 3000 与 teacher-portal Next.js dev 冲突,改映射为 3030:3000 |
| P6 compose --no-deps | mysql/redis 已在另一 compose 项目运行时,启动新服务用 `--no-deps` + 显式指定服务名,避免重建依赖容器 | | | P6 compose --no-deps | mysql/redis 已在另一 compose 项目运行时,启动新服务用 `--no-deps` + 显式指定服务名,避免重建依赖容器 | |
@@ -188,7 +188,7 @@
### 2.2 classesTS/NestJSP1 黄金模板) ### 2.2 classesTS/NestJSP1 黄金模板)
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| ---------------- | ------------------------------------------------------------------------------------------------ | | ---------------- | -------------------------------------------------------------------------------------------------- |
| 黄金模板定位 | P1 完整实现所有横切关注点,后续 8 个服务复制此模板 | | 黄金模板定位 | P1 完整实现所有横切关注点,后续 8 个服务复制此模板 |
| 黄金模板复制流程 | `cp -r services/classes services/xxx` → 改错误码前缀 → 改 proto → 改业务逻辑 → 改 README → 改 CI | | 黄金模板复制流程 | `cp -r services/classes services/xxx` → 改错误码前缀 → 改 proto → 改业务逻辑 → 改 README → 改 CI |
| 横切关注点清单 | 错误处理 / 可观测 / 安全 / 契约 / 测试 / 文档 / 配置 / i18n / CI / Dockerfile | | 横切关注点清单 | 错误处理 / 可观测 / 安全 / 契约 / 测试 / 文档 / 配置 / i18n / CI / Dockerfile |
@@ -203,6 +203,9 @@
| ID 生成 | `@paralleldrive/cuid2` 的 `createId()` | | ID 生成 | `@paralleldrive/cuid2` 的 `createId()` |
| 响应转换 | repository 返回 Dateservice 转换为 `createdAt: number`(时间戳) | | 响应转换 | repository 返回 Dateservice 转换为 `createdAt: number`(时间戳) |
| 阶段特有模式回写 | OutboxP3/ CDCP4/ 长连接P5实现后回写黄金模板 README | | 阶段特有模式回写 | OutboxP3/ CDCP4/ 长连接P5实现后回写黄金模板 README |
| 健康检查依赖 | `readyz` 用 Drizzle `getDb().execute(sql\`SELECT 1\`)` 校验,不要依赖 typeorm DataSource DI |
| AppModule 注册 | HealthModule 必须在 `app.module.ts` imports 数组显式声明,否则 NestFactory 不扫描 HealthController |
| 增量编译陷阱 | `tsconfig.json` 显式 `"incremental": false` 覆盖 base避免 .tsbuildinfo 导致 nest watch 不 emit |
### 2.3 iamTS/NestJSP2 ### 2.3 iamTS/NestJSP2
@@ -331,7 +334,7 @@
### 2.12 teacher-portal微前端宿主P1 测试页) ### 2.12 teacher-portal微前端宿主P1 测试页)
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| -------------------- | --------------------------------------------------------------------------------------- | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 | | P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 |
| API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` | | API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` |
| P1 测试 JWT | 开发工具生成 HS256 tokenP2 起由 IAM 签发 RS256 | | P1 测试 JWT | 开发工具生成 HS256 tokenP2 起由 IAM 签发 RS256 |
@@ -341,6 +344,13 @@
| P2 视口驱动侧边栏 | AppShell fetch `/teacher/viewports` 渲染左侧导航active 路由高亮 | | P2 视口驱动侧边栏 | AppShell fetch `/teacher/viewports` 渲染左侧导航active 路由高亮 |
| P2 根路径重定向 | `app/page.tsx` 客户端组件 `router.replace(isAuthenticated() ? '/dashboard' : '/login')` | | P2 根路径重定向 | `app/page.tsx` 客户端组件 `router.replace(isAuthenticated() ? '/dashboard' : '/login')` |
| fetch headers 类型 | `authHeaders(): Record<string, string>` 显式标注,避免 `{}` 与 `HeadersInit` 不兼容 | | fetch headers 类型 | `authHeaders(): Record<string, string>` 显式标注,避免 `{}` 与 `HeadersInit` 不兼容 |
| P2 前端权限校验等价物 | `usePermission().hasPermission()` Hook + `<RequirePermission>` 组件,镜像后端 `@RequirePermission()` 装饰器,禁止 `role === "xxx"` 硬编码§3.1 |
| P2 MF Shell+Remote 架构 | teacher-portal:3000 为 Shellstudent:3001/parent:3002/admin:3003 为 Remote共享登录态/布局/组件库/权限体系 |
| P2 统一 API 请求层 | ApiClient 封装 401 自动刷新 token 轮转 + ActionState `{success,data,error}` 解析 + 错误码前缀路由 i18n替代页面级 `authHeaders()+fetch` 重复 |
| P2 设计令牌三层模型 | primitive原始色板→ semantic-light/dark语义→ tailwind-theme`@theme inline` 暴露 `bg-*`ESLint `no-restricted-syntax` 禁 `#hex` + `design-tokens/no-hardcoded-fonts` 禁字面量字体§3.10 |
| P2 5 层状态管理 | nuqsURL 同步)/ TanStack Query服务端缓存/ Zustand客户端业务状态/ Zustand UIUI 临时态)/ react-hook-form表单 |
| P2 4 端错误码前缀对齐 | TP_=teacher-bff / SP_=student-bff / PP_=parent-bff / AP_=admin-bff前端按前缀路由 i18n key |
| P2 ErrorBoundary | 路由级 `<ErrorBoundary>` 包裹避免白屏,搭配 `<Suspense>` 流式加载 |
--- ---
@@ -349,7 +359,14 @@
> 按时间倒序50 条上限。AI 发现更好方案时可更新本节。 > 按时间倒序50 条上限。AI 发现更好方案时可更新本节。
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 | | 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ---- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------- | ---- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-09 | 夜间 | data-ana/aiai06 | **ai06 阶段 1+2 交付Python 双服务)**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档README/MIGRATION_GUIDE/004/pending-features/project_rules/coding-standards/multi-ai-collaboration+ classes 黄金模板源码controller/service/repository/schema/dto+ shared-proto 8 份 protoiam/classes/core_edu/events/analytics/ai/msg/push+ ai-allocation.md §5 ai06 分工data-ana P4 + ai P5+ data-ana/ai 现有全部源码main.py/clickhouse_client.py/cdc_consumer.py/llm_client.py/config.py。(2) 阶段 1 产出 docs/architecture/ai06-phase1-understanding.mddata-ana 模块理解确认书(架构定位 L5 业务服务/限界上下文 D6 智能洞察/契约 AnalyticsService 3RPC + 消费 6 CDC topic/技术栈 Python3.12+FastAPI+ClickHouse+aiokafka/P4 阶段归属)+ ai 模块理解确认书(架构定位 L5/限界上下文 D6/契约 AiService 4RPC 含 StreamChat/技术栈 Python+FastAPI+LLM REST/P5 阶段归属)+ §10 服务审计表(两服务均缺 gRPC server 实现/缺权限校验 Depends/data-ana 缺 mastery.updated 事件发布/ai 缺 Redis 限流)+ 8 项跨模块契约待 coord 仲裁。(3) 阶段 2 产出 docs/architecture/ai06-phase2-design.mddata-ana 设计含 Mermaid 内部分层图Entry/Middleware/Service/Repo/Consumer/Storage 6 层)+ ClickHouse 4 张宽表 DDLstudent_dashboard_view ReplacingMergeTree(last_updated)/student_errors/mastery_snapshot/ai_usage_log+ HTTP+gRPC 双入口 API 设计 + 6 个 CDC topic 消费路由 + 发布 edu.insight.mastery.updated 事件 + DATA_ANA_* 错误码清单 + 10 项 metrics + 优雅关闭序列ai 设计含 Mermaid 分层图LLM Provider 适配器模式)+ 无 DB 状态less + 4 LLM ProviderOpenAI/Anthropic/Baichuan/Ollama+ Prompt 模板注册表 + 出题 4 步编排 + AI_* 错误码 + Redis 令牌桶限流 + edu.insight.ai.usage 事件发布 + 9 项待 coord 仲裁决策。(4) **学到**Python 服务权限校验等价 NestJS @RequirePermission 的是 FastAPI Depends 依赖注入 + DataScope 6 级过滤SELF/CLASS/GRADE/SCHOOL/DISTRICT/ALLClickHouse ReplacingMergeTree 查询必须加 FINAL 关键字否则读到重复版本数据Python 服务无 MySQL 写事务故不能用 Outbox 模式派生数据事件mastery.updated/ai.usage建议直接 Kafka producer已提请 coord 仲裁是否豁免 §12.2 Outbox 强制约束ai 服务无 DB 设计stateless与 data-ana 有 ClickHouseOLAP形成对比需在审计表区分标注gRPC streamingStreamChat在 Python 用 grpc.aio + AsyncGenerator与 TS @grpc/grpc-js 双向流实现差异需在阶段 3 实施时对照ai-allocation.md §9.3 提交规范为 `docs(<service>): 模块架构设计文档`,三阶段强制经验沉淀到 known-issues.md 工作经验日志。 |
| 2026-07-09 | 夜间 | teacher-portal/前端4端ai07 | **ai07 阶段 1+2 交付(前端 4 端)**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档README/MIGRATION_GUIDE/004/pending-features/project_rules/coding-standards/multi-ai-collaboration+ classes 黄金模板源码controller/service/repository/schema/dto+ shared-proto 8 份 proto + ai-allocation.md §5 ai07 分工。(2) 运行 `pnpm run arch:scan` 更新 arch.db10 TS/264 符号、2 Go/33 符号、2 Py/45 符号、138 proto 契约)。(3) 阶段 1 产出 4 端模块理解确认书(架构定位/限界上下文/契约依赖/技术栈/阶段归属/黄金模板对齐审计),合并到 apps/teacher-portal/README.md。(4) teacher-portal 现状审计 19 维度,发现 7 项高优违规AppShell.tsx L143 硬编码 `user.roles.join(",")`(违反 §3.1 前端禁 role 硬编码)/ globals.css+tailwind.config.js 硬编码 `hsl()` 字面量与 `#hex`(违反 §3.10 设计令牌)/ layout.tsx 直接 import `'Inter'/'Fraunces'/'JetBrains Mono'` 字面量(违反 §3.10 禁硬编码字体)/ 缺统一 ApiClient 层4 页面重复 local authHeaders()+fetch/ 缺 usePermission Hook前端无 @RequirePermission 等价物)/ 缺 ErrorBoundary白屏风险/ 缺 5 层状态管理nuqs/TanStack Query/Zustand/Zustand-UI/react-hook-form 全缺)/ next.config.js 无 Module Federation 配置4 端无法 Shell+Remote 组合)。(5) 阶段 2 产出模块架构设计文档MF 2.0 Shell+Remote 架构teacher-portal:3000 为 Shellstudent:3001/parent:3002/admin:3003 为 Remote含 mermaid 架构图 + MF config 代码示例 + 领域模型Session/Viewport/Permission TS 接口)+ 数据模型缓存策略表5 层 DataScope × 5 类缓存键)+ API 请求层设计ApiClient 401 自动刷新 + ActionState 解析 + 错误码前缀路由 i18n+ WebSocket/SSE 事件设计 + 横切关注点对齐清单(权限表 4 端前缀/错误码前缀 TP_/SP_/PP_/AP_/logger/metrics/tracer/health。(6) 4 端差异化对照表 5 张(总体/L1 导航/L2 路由/L3 组件/L4 数据)。(7) 交互点契约清单 12 项 + 风险 7 项 + 假设 5 项 + 4 项待 coord 仲裁packages 归属/GraphQL vs REST/i18n key 命名/MF 暴露粒度)。(8) coord 交叉审查信息:端口矩阵 3000-3003、5 个 shared 包待建shared-ts/shared-tokens/shared-ui/shared-mf/shared-perm、11 个后端契约依赖、错误码前缀对齐、无 Kafka 事件消费。**学到**:前端权限校验等价物是 `usePermission().hasPermission()` Hook + `<RequirePermission>` 组件(镜像后端 `@RequirePermission()` 装饰器MF Shell+Remote 架构选择优于 4 独立 Shell共享登录态/布局/组件库/权限体系)和单 Next.js 应用4 端独立部署/独立 CI/独立回滚teacher-portal 现状有 7 项高优违规需 P2 闭环前修复4 端 API 错误码前缀需与后端服务对齐TP_=teacher-bff、SP_=student-bff、PP_=parent-bff、AP_=admin-bff设计令牌三层模型primitive/semantic-light+dark/tailwind-theme必须 ESLint 强制约束no-restricted-syntax 禁 #hex + design-tokens/no-hardcoded-fonts 禁字面量字体)。 |
| 2026-07-09 | 夜间 | api-gateway/push-gatewayai01 | **ai01 阶段 1+2 交付Go 网关层)**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档README/MIGRATION_GUIDE/004/pending-features/project_rules/coding-standards/multi-ai-collaboration+ classes 黄金模板全部源码 + shared-proto 8 份 protoiam/msg/events+ api-gateway/push-gateway 全部 Go 源码。(2) 运行 `pnpm run arch:scan` 更新 arch.db10 TS/264 符号、2 Go/33 符号、2 Py/45 符号、138 proto 契约)。(3) 阶段 1 产出 2 份模块理解确认书services/{api-gateway,push-gateway}/docs/01-understanding.md+ 审计表api-gateway 审计出 13 项差距3 高优先:缺 /metrics 端点、/readyz 是 stub 返回 ok 不检查依赖、auth.go L124-139 死代码 RequestIDMiddleware+generateUUID 与 requestid.go 重复4 中logger 用 fmt 非 log/slog、go.mod go 1.25.0 与 Dockerfile golang:1.22-alpine 版本不匹配、HS256 待 P2 升 RS256、DevMode 生产环境风险6 低push-gateway 审计出 16 项差距6 高:无 Redis Pub/Sub 横向扩展、CheckOrigin 直接 return true 安全风险、用文本 "ping"/"pong" 心跳非 RFC 6455 控制帧、无单用户连接数上限、/internal/* 无鉴权、Dockerfile 单阶段且 root 用户无 healthcheck。(4) 阶段 2 产出 2 份模块架构设计文档02-architecture-design.mdapi-gateway 覆盖 9 节(内部分层图、路由表矩阵 9 下游含端口+鉴权规则、限流策略表按路由差异化 RPS/burst、熔断阈值表按服务、JWT RS256 流程含 JWKS 缓存、CORS 白名单、请求 ID 注入、metrics 7 项指标清单、P0-P3 实施优先级push-gateway 覆盖 13 节内部分层图、Connection/Hub 领域模型、Redis 4 个 key pattern、WebSocket 端点+子协议 JSON 格式、内部推送 API+X-Internal-Token 鉴权、双通道协议 HTTP 同步+Kafka 异步、WebSocket 生命周期状态图、心跳协议 RFC 6455 控制帧 30s 间隔 60s 超时、单用户最大 5 连接、重连协议 P6 预留、多实例架构图、Redis Pub/Sub 跨实例流程、容量目标 10w+ 连接 <50ms 本地推送 <200ms 跨实例)。**学到**gobreaker v2 ReadyToTrip 在 Requests=1 时 1*2>1=true 即 1 次失败就触发 OPEN与 P6 集成测试观察一致gorilla/websocket 不支持并发写同一连接Hub.Send 必须用 send chan + 单写协程串行化(已在 P5 修复但设计文档需明确标注此约束push-gateway 骨架用文本 "ping"/"pong" 违反 RFC 6455应用 SetPongHandler 处理控制帧004 §7.2 事件 topic 用 `edu.teaching.exam.published` 前缀但代码 TOPIC_MAP 用 `edu.exam.events`ai03 已提请 coord 仲裁,本 AI 在 push-gateway 设计中消费 `edu.notification.events` 待 coord 统一命名后同步api-gateway 与 push-gateway 重复 tracer.go/logger.go/jwks.go/env.go建议提取到 `packages/shared-go/`(需 coord 创建包后多 AI 协同迁移Go 服务 .env 不会自动加载DevMode 必须在启动前 `export DEV_MODE=true` 或集成 godotenv。 |
| 2026-07-09 | 夜间 | teacher-bff/core-eduai03 | **ai03 阶段 1 全局理解交付**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档 + classes 黄金模板全部源码 + shared-proto 8 份 proto + teacher-bff/core-edu 现有实现。(2) 运行 arch:scan 更新 arch.db10 TS/264、2 Go/33、2 Py/45、138 proto 契约arch:query deps/stats 发现 arch.db 仅记录模块/符号统计不记录跨模块调用边。(3) 按 §6 模板产出两份模块理解确认书 + §10 审计表,交付 docs/architecture/ai03-phase1-understanding.md。(4) 审计发现 teacher-bff 7 项差距REST 非 gRPC/无 GraphQL/无 DataLoader/无 Redis 缓存/无 readyz/无 Zod/无测试、core-edu 12 项差距(考试状态机缺失/作业状态机不完整/成绩无校验/无并发锁/homework.graded 与 grade.updated 事件未触发/未消费 IAM user.created/Drizzle db 导出 vs classes getDb() 不一致/kafka.ts 用 console 非 logger/classes 模块仅占位待合并/REST 未转 gRPC/无 Zod/无测试)。(5) 提请 coord 交叉审查 6 项跨模块契约对齐iam getEffectivePermissions 聚合 API proto / iam user.created topic / 端口 3004 / Kafka topic 命名 004 文档与代码不一致 / data-ana 消费契约 / msg 消费契约)。**学到**004 §7.2 事件 topic 用 `edu.teaching.exam.published` 前缀,但 core-edu outbox.publisher.ts TOPIC_MAP 用 `edu.exam.events`,文档与代码不一致需 coord 仲裁统一teacher-bff 当前用 REST fetch 但 P2 退出标准要求 GraphQL Yoga + DataLoader阶段 2 设计需补通信方式迁移core-edu 与 classes 黄金模板的 Drizzle 访问方式不一致core-edu 直接 `export const db`classes 用 `getDb()` 函数),建议统一为 `getDb()` 函数式以匹配 HealthController 已有约定。 |
| 2026-07-09 | 傍晚 | 全局 | **全服务代码合规性审查 + 批量修复**:对 10 个服务6 NestJS + 2 Go + 2 Python执行严格代码审查发现 7 critical + 42 major + 35 minor 问题,批量修复如下。(1) **@RequirePermission() 装饰器实现**6 个 NestJS 服务全部实现 `SetMetadata` + `Reflector` 标准模式PermissionGuard 改用 `getAllAndOverride` 读取元数据,注册为 `APP_GUARD` 全局 GuardDEV_MODE 旁路。content/msg 新建 permission.guard.ts + auth.middleware.ts。(2) **as 断言消除**:所有 `req.headers['x-user-id'] as string` 改为 `typeof` 类型守卫,涉及 auth.middleware.ts/global-error.filter.ts/controller.ts。(3) **返回类型补充**`getDb()` 标注 `MySql2Database<typeof schema>`Controller 方法补充 `Promise<{ success: true; data: ... }>`。(4) **import type 修复**express 的 `Request`/`Response`/`NextFunction` 改为 `import type`。(5) **main.ts /metrics 隐式 any 修复**:参数标注 `Request`/`Response` 类型。(6) **原生 Error → ApplicationError**repository.ts 的 `throw new Error` 改为 `DatabaseError`。(7) **typeorm 残留清理**classes lifecycle.service.ts 移除 typeorm DataSource 依赖改用 Drizzle `closeDb()`。(8) **LifecycleService 注册**5 个 NestJS 服务的 AppModule 注册 LifecycleService。(9) **teacher-bff 补全**:新建 logger.ts + ApplicationError 体系 + GlobalErrorFilter下游调用转发真实 userId替换硬编码 "bff");失败时 `logger.warn` + `BadGatewayError`不再静默吞错health.controller 迁移到 shared/health/ 标准结构。(10) **Go 安全修复**CORS 默认 `*` 改为开发白名单 + warningJWT 密钥非 DevMode fail-fastpush-gateway `/internal/*` 添加 `X-Internal-Key` 鉴权;`interface{}` → `any`;删除死代码 `RequestIDMiddleware`/`generateUUID`/`getEnvInt`;补充 doc commentapi-gateway + push-gateway 添加 `/metrics` 端点push-gateway 添加 `/readyz`。(11) **Python 修复**lifespan 返回类型标注 `AsyncGenerator[None, None]``dev_mode` 从 `str` 改为 `bool`data-ana 业务路由改用 `APIRouter`ai POST 端点从 query 参数改为 Pydantic 请求体模型data-ana ClickHouse 同步调用包装在 `asyncio.to_thread()` 中。(12) **core-edu GlobalErrorFilter 统一**:响应契约改为 `{ success: false, error: { code, message, details, traceId } }` 与 content/msg 一致。(13) **msg 修复**notifications.dto.ts 新建 Zod 验证 schemauuid v4 替换为 `node:crypto.randomUUID`ES 模块 console.error 改为结构化 logger。**学到**NestJS `SetMetadata` 从 `@nestjs/common` 导入(非 `@nestjs/core``APP_GUARD` 注册全局 Guard 是标准模式;`Reflector.getAllAndOverride` 支持 handler+class 两级元数据查找PermissionGuard 必须在 DEV_MODE 下旁路否则开发环境无法测试Go 的 `interface{}` 在 1.18+ 应统一用 `any`Python `asyncio.to_thread()` 是包装同步 IO 为异步的标准方式FastAPI `lifespan` 返回类型是 `AsyncGenerator[None, None]`。 |
| 2026-07-09 | 下午 | classes/全局 | **一键启动脚本 NestJS dist/ 不生成根因定位 + classes 健康检查修复**(1) 根因定位:`tsconfig.base.json` 的 `incremental: true` + `nest-cli.json` 的 `deleteOutDir: true` 冲突。`nest start --watch` 启动时先删除 dist/tsc 读残留 .tsbuildinfo 认为无变化跳过 emitdist/ 不生成 → `Cannot find module dist/main`。(2) 修复6 个 NestJS 服务classes/iam/teacher-bff/core-edu/content/msgtsconfig.json 显式加 `"incremental": false` 覆盖 base 配置,删除所有残留 .tsbuildinfo 文件。(3) classes AppModule 缺 HealthModule 导入导致 /healthz 404iam 同样问题,修复 app.module.ts 加 `imports: [..., HealthModule]`。(4) classes HealthController 误用 TypeORM `DataSource` DI与 iam 不一致),运行时报 `Nest can't resolve dependencies of the HealthController (DataSource)`。修复:改为 Drizzle `getDb()` 函数式调用,与 iam 一致。(5) 一键启动验证11/11 应用 + 11/11 基础设施 + 5/5 可观测性端点全绿。**学到**NestJS + TypeScript incremental 编译是陷阱组合——nest-cli deleteOutDir 删 dist 但 tsc 读 tsbuildinfo 认为无变化,必须在服务级 tsconfig 显式 `incremental: false`HealthModule 必须在 AppModule imports 中显式声明才能被 NestFactory 扫描到5 个 NestJS 服务的 HealthController 应统一用 Drizzle `getDb()` 函数式调用而非 TypeORM DataSource DI项目已弃 TypeORM 改 Drizzle。 |
| 2026-07-09 | 下午 | 全局 | **OTel auto-instrumentations 全服务补全**(1) NestJS 6 服务iam/classes/core-edu/content/msg/teacher-bfftracer.ts 补 `getNodeAutoInstrumentations()`NodeSDK 传 instrumentations 参数自动埋点 HTTP/Express/DB。(2) Python 2 服务data-ana/aimain.py 补 `FastAPIInstrumentor.instrument_app(app)`ai 补缺失的 `opentelemetry-exporter-otlp` 依赖。(3) teacher-bff 从零补完整 OTelenv.ts 加 OTEL_EXPORTER_OTLP_ENDPOINT 字段 + 新建 shared/observability/tracer.ts + main.ts 调用 initTracer/shutdownTracer + package.json 加 sdk-node/exporter/auto-instrumentations 依赖。(4) Go 2 服务api-gateway/push-gateway新建 internal/observability/tracer.goOTLP HTTP exporter + resource + TracerProvider + W3C propagator+ main.go 调用 InitTracer + otelgin.Middleware 注册 Gin 中间件push-gateway config.go 补 OTLPEndpoint 字段。(5) 质量校验全通过TS typecheck 9 服务 + ESLint 6 服务 + ruff 2 服务 + go vet/build 2 服务零错误。**学到**`getNodeAutoInstrumentations()` 一次注册所有 Node.js 自动埋点http/express/dns/fs/net/grpc 等),比手动逐个注册 HttpInstrumentation 更简洁Go OTel 用 `otlptracehttp.WithEndpoint(host)` + `WithInsecure()` 需从 "http://host:port" URL 解析出 hostotelgin.Middleware 必须在 Recovery 之后其他中间件之前注册,确保所有后续 handler 都被 tracePython FastAPIInstrumentor.instrument_app(app) 在 app 创建后立即调用lifespan 不受影响。 |
| 2026-07-09 | 下午 | 全局 | **P6 硬化:可观测性 + 部署 + CI 硬化**(1) 可观测性栈完善5 个 NestJS 服务 main.ts 添加 `/metrics` Prometheus 端点(用 `app.getHttpAdapter().get('/metrics', ...)` 绕过 DI 容器 get 方法prometheus.yml 从 2 个目标扩展到 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身 + rule_files + alertmanager 关联monitoring compose 用 Loki + Promtail 替换未配置的 blackbox-exporterGrafana datasource 新增 Loki新建 promtail/config.yml 用 docker_sd_configs 仅采集 `edu-*` 容器日志。(2) 部署 compose 扩展docker-compose.deploy.yml 从 3 服务扩展到 11 服务(+ iam/teacher-bff/core-edu/content/msg/ai/data-ana/push-gateway每个服务带 healthcheck + depends_on 条件 + edu-net/edu-shared 双网络deploy.env.example 补全 Neo4j/ES/ClickHouse/LLM/Kafka 可选依赖配置。(3) teacher-bff 补 health.controller.ts原缺失 /healthz 导致 deploy depends_on service_healthy 失败)。(4) CI 硬化:移除 lint 步骤的 continue-on-errorESLint 9 flat config 已配置完成test 保留 continue-on-error部分服务无 test 脚本)。**学到**NestJS `app.get('/metrics')` 会被解析为 DI 容器 `get(typeOrToken)`,必须用 `app.getHttpAdapter().get()` 才能注册 Express 路由Promtail docker_sd_configs 通过 relabel_configs 的 `regex: '/(edu-.*).*'` 过滤容器名前缀docker-compose.depends_on.condition: service_healthy 要求被依赖服务必须有 healthcheck 配置,否则启动失败。 | | 2026-07-09 | 下午 | 全局 | **P6 硬化:可观测性 + 部署 + CI 硬化**(1) 可观测性栈完善5 个 NestJS 服务 main.ts 添加 `/metrics` Prometheus 端点(用 `app.getHttpAdapter().get('/metrics', ...)` 绕过 DI 容器 get 方法prometheus.yml 从 2 个目标扩展到 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身 + rule_files + alertmanager 关联monitoring compose 用 Loki + Promtail 替换未配置的 blackbox-exporterGrafana datasource 新增 Loki新建 promtail/config.yml 用 docker_sd_configs 仅采集 `edu-*` 容器日志。(2) 部署 compose 扩展docker-compose.deploy.yml 从 3 服务扩展到 11 服务(+ iam/teacher-bff/core-edu/content/msg/ai/data-ana/push-gateway每个服务带 healthcheck + depends_on 条件 + edu-net/edu-shared 双网络deploy.env.example 补全 Neo4j/ES/ClickHouse/LLM/Kafka 可选依赖配置。(3) teacher-bff 补 health.controller.ts原缺失 /healthz 导致 deploy depends_on service_healthy 失败)。(4) CI 硬化:移除 lint 步骤的 continue-on-errorESLint 9 flat config 已配置完成test 保留 continue-on-error部分服务无 test 脚本)。**学到**NestJS `app.get('/metrics')` 会被解析为 DI 容器 `get(typeOrToken)`,必须用 `app.getHttpAdapter().get()` 才能注册 Express 路由Promtail docker_sd_configs 通过 relabel_configs 的 `regex: '/(edu-.*).*'` 过滤容器名前缀docker-compose.depends_on.condition: service_healthy 要求被依赖服务必须有 healthcheck 配置,否则启动失败。 |
| 2026-07-09 | 下午 | data-ana/infra | **CDC 完整链路实现**MySQL binlog → Debezium Connect → Kafka → data-ana 消费者 → ClickHouse 宽表。(1) MySQL binlog 配置log_bin=ON, binlog_format=ROW, binlog_row_image=FULL, server_id=1用 root 创建 `debezium` 用户授予 REPLICATION SLAVE + REPLICATION CLIENT。(2) Debezium Connect 容器daocloud 禁用 debezium 镜像改用 `quay.io/debezium/connect:2.7`MySQL 容器在 edu-minimal_default 网络,需 `docker network connect edu-full_default edu-mysql` 让 Debezium 同时可达Kafka 必须配置双 listenerINSIDE:kafka:29092 + OUTSIDE:localhost:9092否则 Debezium 拿到 advertised.listeners 中的 localhost metadata 后切换失败Debezium 2.x 容器环境变量名用 BOOTSTRAP_SERVERS不带 KAFKA_ 前缀),通过 envsubst 替换到 connect-distributed.properties。(3) 注册 connectorPOST :8083/connectors配置 topic.prefix=edu-cdc, database.include.list=next_edu_cloud, snapshot.mode=initial4 张表core_edu_grades/exams/classes/iam_users成功产生快照事件。(4) data-ana 消费者实现:新建 cdc_consumer.py 用 aiokafka AIOKafkaConsumerlifespan 中 asyncio.create_task 后台运行;按 source.table 路由exams→内存缓存 exam_id→class_id 映射grades→查缓存填 class_id 后 upsert ClickHousereadyz 端点附加 cdc_consumer 状态。(5) ClickHouse 远程访问:默认 default-user.xml 限制 127.0.0.1/::1 无密码,挂载 `clickhouse/users.d/custom-users.xml` 覆盖密码+任意 IP。(6) structlog 24.x API`make_filtering_bound_logger(level)` 替代废弃的 `make_filtering_logger`。(7) E2E 验证MySQL INSERT 成绩 → Debezium op=c 事件 → Kafka → 消费者写 ClickHouse 宽表class_id 通过 exam 缓存正确填充)→ /readyz cdc_consumer=running → /analytics/student/student-002/weakness 返回实时 92 分数据。**学到**Debezium 2.x 容器 bootstrap.servers 默认值是 0.0.0.0:9092 必须显式覆盖Kafka 单 listener 配置 localhost 会让容器间通信的客户端拿到 metadata 后切换失败,必须用双 listenerClickHouse users_xml 存储是 readonly 不能用 ALTER USER 修改密码,必须挂载 users.d 配置文件覆盖;消费者 offset 重置必须先停消费者让 group 处于 Empty 状态才能执行 --reset-offsets。 | | 2026-07-09 | 下午 | data-ana/infra | **CDC 完整链路实现**MySQL binlog → Debezium Connect → Kafka → data-ana 消费者 → ClickHouse 宽表。(1) MySQL binlog 配置log_bin=ON, binlog_format=ROW, binlog_row_image=FULL, server_id=1用 root 创建 `debezium` 用户授予 REPLICATION SLAVE + REPLICATION CLIENT。(2) Debezium Connect 容器daocloud 禁用 debezium 镜像改用 `quay.io/debezium/connect:2.7`MySQL 容器在 edu-minimal_default 网络,需 `docker network connect edu-full_default edu-mysql` 让 Debezium 同时可达Kafka 必须配置双 listenerINSIDE:kafka:29092 + OUTSIDE:localhost:9092否则 Debezium 拿到 advertised.listeners 中的 localhost metadata 后切换失败Debezium 2.x 容器环境变量名用 BOOTSTRAP_SERVERS不带 KAFKA_ 前缀),通过 envsubst 替换到 connect-distributed.properties。(3) 注册 connectorPOST :8083/connectors配置 topic.prefix=edu-cdc, database.include.list=next_edu_cloud, snapshot.mode=initial4 张表core_edu_grades/exams/classes/iam_users成功产生快照事件。(4) data-ana 消费者实现:新建 cdc_consumer.py 用 aiokafka AIOKafkaConsumerlifespan 中 asyncio.create_task 后台运行;按 source.table 路由exams→内存缓存 exam_id→class_id 映射grades→查缓存填 class_id 后 upsert ClickHousereadyz 端点附加 cdc_consumer 状态。(5) ClickHouse 远程访问:默认 default-user.xml 限制 127.0.0.1/::1 无密码,挂载 `clickhouse/users.d/custom-users.xml` 覆盖密码+任意 IP。(6) structlog 24.x API`make_filtering_bound_logger(level)` 替代废弃的 `make_filtering_logger`。(7) E2E 验证MySQL INSERT 成绩 → Debezium op=c 事件 → Kafka → 消费者写 ClickHouse 宽表class_id 通过 exam 缓存正确填充)→ /readyz cdc_consumer=running → /analytics/student/student-002/weakness 返回实时 92 分数据。**学到**Debezium 2.x 容器 bootstrap.servers 默认值是 0.0.0.0:9092 必须显式覆盖Kafka 单 listener 配置 localhost 会让容器间通信的客户端拿到 metadata 后切换失败,必须用双 listenerClickHouse users_xml 存储是 readonly 不能用 ALTER USER 修改密码,必须挂载 users.d 配置文件覆盖;消费者 offset 重置必须先停消费者让 group 处于 Empty 状态才能执行 --reset-offsets。 |
| 2026-07-09 | 下午 | 全局 | **P6 硬化ESLint 9 flat config 配置**(1) 根目录创建 `eslint.config.js`ESLint 9 flat config 格式):用 `typescript-eslint` recommended 规则集 + `@eslint/js` recommended + `eslint-config-prettier` 禁用冲突规则;自定义规则:`no-explicit-any` warn + `no-unused-vars` 允许下划线前缀 + 测试文件放宽。(2) 6 个 TS 服务 package.json lint 脚本从 `eslint src --ext .ts` 改为 `eslint src`flat config 不需要 --ext。(3) `lint-staged.config.js` 恢复 `eslint --fix`。(4) 验证classes/content/msg/core-edu 四服务 lint 全部零错误零警告通过。**学到**ESLint 9 flat config 用 `tseslint.config()` 工厂函数组装配置数组;`--ext` 参数在 flat config 模式下被移除ESLint 自动根据 `eslint.config.js` 中的 `files` 匹配;`@typescript-eslint/consistent-type-assertions` 规则选项格式在 v8 中变化(`objectLiteralType` → `objectLiteralTypeAssertions`),配置时需查最新文档。 | | 2026-07-09 | 下午 | 全局 | **P6 硬化ESLint 9 flat config 配置**(1) 根目录创建 `eslint.config.js`ESLint 9 flat config 格式):用 `typescript-eslint` recommended 规则集 + `@eslint/js` recommended + `eslint-config-prettier` 禁用冲突规则;自定义规则:`no-explicit-any` warn + `no-unused-vars` 允许下划线前缀 + 测试文件放宽。(2) 6 个 TS 服务 package.json lint 脚本从 `eslint src --ext .ts` 改为 `eslint src`flat config 不需要 --ext。(3) `lint-staged.config.js` 恢复 `eslint --fix`。(4) 验证classes/content/msg/core-edu 四服务 lint 全部零错误零警告通过。**学到**ESLint 9 flat config 用 `tseslint.config()` 工厂函数组装配置数组;`--ext` 参数在 flat config 模式下被移除ESLint 自动根据 `eslint.config.js` 中的 `files` 匹配;`@typescript-eslint/consistent-type-assertions` 规则选项格式在 v8 中变化(`objectLiteralType` → `objectLiteralTypeAssertions`),配置时需查最新文档。 |

View File

@@ -1,4 +1,35 @@
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -184,8 +184,14 @@ services:
container_name: edu-prometheus container_name: edu-prometheus
profiles: ["observability"] profiles: ["observability"]
restart: unless-stopped restart: unless-stopped
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=15d"
- "--web.enable-lifecycle"
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
ports: ports:
- "9090:9090" - "9090:9090"
grafana: grafana:
@@ -199,6 +205,49 @@ services:
- "3030:3000" - "3030:3000"
volumes: volumes:
- grafana_data:/var/lib/grafana - grafana_data:/var/lib/grafana
# ============================================================
# Exportersobservability profile与 Prometheus 同网络)
# ============================================================
node-exporter:
image: docker.m.daocloud.io/prom/node-exporter:v1.8.2
container_name: edu-node-exporter
profiles: ["observability"]
restart: unless-stopped
command:
- "--path.rootfs=/host"
ports:
- "9100:9100"
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host:ro
mysql-exporter:
image: docker.m.daocloud.io/prom/mysqld-exporter:v0.15.1
container_name: edu-mysql-exporter
profiles: ["observability"]
restart: unless-stopped
command:
- "--mysqld.address=edu-mysql:3306"
- "--mysqld.username=edu:changeme"
environment:
MYSQLD_EXPORTER_PASSWORD: "changeme"
ports:
- "9104:9104"
depends_on:
mysql:
condition: service_healthy
redis-exporter:
image: docker.m.daocloud.io/oliver006/redis_exporter:v1.67.0
container_name: edu-redis-exporter
profiles: ["observability"]
restart: unless-stopped
environment:
REDIS_ADDR: "redis://edu-redis:6379"
ports:
- "9121:9121"
depends_on:
redis:
condition: service_started
volumes: volumes:
mysql_data: mysql_data:
redis_data: redis_data:
@@ -206,3 +255,4 @@ volumes:
neo4j_data: neo4j_data:
es_data: es_data:
grafana_data: grafana_data:
prometheus_data:

View File

@@ -89,6 +89,6 @@ scrape_configs:
- job_name: 'node-exporter' - job_name: 'node-exporter'
static_configs: static_configs:
- targets: ['node-exporter:9100'] - targets: ['host.docker.internal:9100']
labels: labels:
service: node-exporter service: node-exporter

1078
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

156
scripts/health-check.ps1 Normal file
View File

@@ -0,0 +1,156 @@
<#
.SYNOPSIS
Edu health check script
.DESCRIPTION
Checks health of all infrastructure containers + application services + observability endpoints
.EXAMPLE
.\scripts\health-check.ps1
#>
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Edu Health Check" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# ===== 1. Infrastructure containers =====
Write-Host "[1/3] Infrastructure Containers" -ForegroundColor Yellow
$infraContainers = @(
@{Name="MySQL"; Container="edu-mysql"},
@{Name="Redis"; Container="edu-redis"},
@{Name="Kafka"; Container="edu-kafka"},
@{Name="Zookeeper"; Container="edu-zookeeper"},
@{Name="ClickHouse"; Container="edu-clickhouse"},
@{Name="Debezium"; Container="edu-debezium"},
@{Name="Neo4j"; Container="edu-neo4j"},
@{Name="Elasticsearch"; Container="edu-es"},
@{Name="Jaeger"; Container="edu-jaeger"},
@{Name="Prometheus"; Container="edu-prometheus"},
@{Name="Grafana"; Container="edu-grafana"}
)
$infraOk = 0
$infraFail = 0
foreach ($svc in $infraContainers) {
$running = docker inspect -f '{{.State.Running}}' $svc.Container 2>$null
if ($running -ne "true") {
Write-Host " [FAIL] $($svc.Name) not running" -ForegroundColor Red
$infraFail++
continue
}
# 条件模板Health 不存在时返回空字符串,不报错
$health = docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' $svc.Container 2>$null
if ($health -eq "healthy") {
Write-Host " [OK] $($svc.Name)" -ForegroundColor Green
$infraOk++
} else {
Write-Host " [WARN] $($svc.Name) (running, no healthcheck)" -ForegroundColor Yellow
$infraOk++
}
}
Write-Host ""
# ===== 2. Application services =====
Write-Host "[2/3] Application Services" -ForegroundColor Yellow
$appServices = @(
@{Name="classes"; Url="http://localhost:3001/healthz"},
@{Name="iam"; Url="http://localhost:3002/healthz"},
@{Name="teacher-bff"; Url="http://localhost:3003/healthz"},
@{Name="core-edu"; Url="http://localhost:3004/healthz"},
@{Name="content"; Url="http://localhost:3005/healthz"},
@{Name="data-ana"; Url="http://localhost:3006/healthz"},
@{Name="msg"; Url="http://localhost:3007/healthz"},
@{Name="ai"; Url="http://localhost:3008/healthz"},
@{Name="api-gateway"; Url="http://localhost:8080/healthz"},
@{Name="push-gateway"; Url="http://localhost:8081/healthz"},
@{Name="teacher-portal";Url="http://localhost:3000/"}
)
$appOk = 0
$appFail = 0
foreach ($svc in $appServices) {
try {
$null = Invoke-RestMethod -Uri $svc.Url -Method Get -TimeoutSec 3 -ErrorAction Stop
Write-Host " [OK] $($svc.Name)" -ForegroundColor Green
$appOk++
} catch {
Write-Host " [FAIL] $($svc.Name)" -ForegroundColor Red
$appFail++
}
}
Write-Host ""
# ===== 3. Observability endpoints =====
Write-Host "[3/3] Observability Endpoints" -ForegroundColor Yellow
$obsEndpoints = @(
@{Name="Prometheus API"; Url="http://localhost:9090/api/v1/query?query=up"},
@{Name="Jaeger API"; Url="http://localhost:16686/api/services"},
@{Name="Debezium Connect"; Url="http://localhost:8083/connectors"},
@{Name="data-ana /metrics";Url="http://localhost:3006/metrics"},
@{Name="iam /metrics"; Url="http://localhost:3002/metrics"}
)
$obsOk = 0
$obsFail = 0
foreach ($ep in $obsEndpoints) {
try {
$null = Invoke-RestMethod -Uri $ep.Url -Method Get -TimeoutSec 3 -ErrorAction Stop
Write-Host " [OK] $($ep.Name)" -ForegroundColor Green
$obsOk++
} catch {
Write-Host " [FAIL] $($ep.Name)" -ForegroundColor Red
$obsFail++
}
}
# ===== 4. CDC pipeline status =====
Write-Host ""
Write-Host "[Extra] CDC Pipeline Status" -ForegroundColor Yellow
$connectorStatus = $null
try {
$connectorStatus = Invoke-RestMethod -Uri "http://localhost:8083/connectors/edu-mysql-source/status" -Method Get -TimeoutSec 3 -ErrorAction Stop
} catch {
Write-Host " [FAIL] Debezium connector not registered or error" -ForegroundColor Red
}
if ($connectorStatus) {
$connectorState = $connectorStatus.connector.state
$tasks = @($connectorStatus.tasks)
if ($tasks.Count -gt 0) {
$taskState = $tasks[0].state
} else {
$taskState = "UNKNOWN"
}
if ($connectorState -eq "RUNNING" -and $taskState -eq "RUNNING") {
$color = "Green"
} else {
$color = "Yellow"
}
Write-Host " Connector: $connectorState / Task: $taskState" -ForegroundColor $color
}
$chSql = 'SELECT count(*) FROM edu_analytics.student_dashboard_view'
$chOutput = docker exec edu-clickhouse clickhouse-client --user default --password clickhouse -q $chSql 2>&1
$chCount = "$chOutput".Trim()
if ($chCount -match '^\d+$') {
Write-Host " ClickHouse student_dashboard_view: $chCount records" -ForegroundColor Green
} else {
Write-Host " ClickHouse query failed or empty" -ForegroundColor Yellow
}
# ===== Summary =====
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Infra: $infraOk/$($infraContainers.Count) | App: $appOk/$($appServices.Count) | Obs: $obsOk/$($obsEndpoints.Count)" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if ($infraFail -gt 0 -or $appFail -gt 0 -or $obsFail -gt 0) {
exit 1
}

301
scripts/start-all.ps1 Normal file
View File

@@ -0,0 +1,301 @@
<#
.SYNOPSIS
Edu start all application services script
.DESCRIPTION
Starts 10 application services + 1 frontend (teacher-portal)
Each service runs in a separate terminal window for log visibility
Automatically injects Python service env vars (CLICKHOUSE/KAFKA/OTEL)
Prerequisite: infrastructure containers (MySQL/Redis/Kafka/ClickHouse etc) must be running
.PARAMETER SkipInfraCheck
Skip infrastructure health check (use when infra is known to be running)
.EXAMPLE
.\scripts\start-all.ps1
.\scripts\start-all.ps1 -SkipInfraCheck
#>
param(
[switch]$SkipInfraCheck,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Edu Start All Services" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# ===== 1. Infrastructure health check =====
if (-not $SkipInfraCheck) {
Write-Host "[1/6] Checking infrastructure health..." -ForegroundColor Yellow
$infraServices = @(
@{Name="MySQL"; Container="edu-mysql"},
@{Name="Redis"; Container="edu-redis"},
@{Name="Kafka"; Container="edu-kafka"},
@{Name="ClickHouse"; Container="edu-clickhouse"},
@{Name="Debezium"; Container="edu-debezium"},
@{Name="Jaeger"; Container="edu-jaeger"}
)
$allHealthy = $true
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
foreach ($svc in $infraServices) {
$running = docker inspect -f '{{.State.Running}}' $svc.Container 2>$null
if ($running -ne "true") {
Write-Host " [FAIL] $($svc.Name) not running" -ForegroundColor Red
$allHealthy = $false
continue
}
# 条件模板Health 不存在时返回空字符串,不报错
$health = docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' $svc.Container 2>$null
if ($health -eq "healthy") {
Write-Host " [OK] $($svc.Name) ($($svc.Container))" -ForegroundColor Green
} else {
Write-Host " [WARN] $($svc.Name) running (no healthcheck)" -ForegroundColor Yellow
}
}
$ErrorActionPreference = $prevEAP
if (-not $allHealthy) {
Write-Host ""
Write-Host "Infrastructure not ready. Start it first:" -ForegroundColor Red
Write-Host " docker compose -f infra/docker-compose.yml --profile p6 --profile observability up -d" -ForegroundColor White
exit 1
}
Write-Host ""
}
# ===== 2. Environment variables =====
Write-Host "[2/6] Preparing environment variables..." -ForegroundColor Yellow
$env:DEV_MODE = "true"
$env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"
$env:DATABASE_URL = "mysql://edu:changeme@localhost:3306/next_edu_cloud"
$env:REDIS_URL = "redis://localhost:6379"
$env:JWT_SECRET = "p1-dev-secret-change-in-production"
$env:KAFKA_BROKERS = "localhost:9092"
$pyEnv = @{
CLICKHOUSE_HOST = "localhost"
CLICKHOUSE_PORT = "8123"
CLICKHOUSE_USER = "default"
CLICKHOUSE_PASSWORD = "clickhouse"
CLICKHOUSE_DATABASE = "edu_analytics"
KAFKA_BROKERS = "localhost:9092"
OTEL_ENDPOINT = "http://localhost:4318"
DEV_MODE = "true"
}
Write-Host " DEV_MODE=true (dev-token bypass)" -ForegroundColor Green
Write-Host " OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318" -ForegroundColor Green
Write-Host ""
# ===== 3. Port conflict check =====
Write-Host "[3/6] Checking port conflicts..." -ForegroundColor Yellow
$portMap = @{
3001="classes"; 3002="iam"; 3003="teacher-bff"; 3004="core-edu"
3005="content"; 3006="data-ana"; 3007="msg"; 3008="ai"
8080="api-gateway"; 8081="push-gateway"; 3000="teacher-portal"
}
$conflicts = @()
foreach ($port in $portMap.Keys | Sort-Object) {
$conn = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
if ($conn) {
$svcName = $portMap[$port]
$procId = $conn[0].OwningProcess
$procName = ""
try { $procName = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
Write-Host " [WARN] Port $port ($svcName) occupied by PID $procId ($procName)" -ForegroundColor Yellow
$conflicts += [PSCustomObject]@{Port=$port; Service=$svcName; PID=$procId; Process=$procName}
}
}
if ($conflicts.Count -gt 0) {
Write-Host ""
Write-Host " $($conflicts.Count) port(s) already in use." -ForegroundColor Yellow
$shouldKill = $false
if ($Force) {
Write-Host " -Force specified, killing automatically..." -ForegroundColor White
$shouldKill = $true
} else {
Write-Host " These services may already be running. Options:" -ForegroundColor White
Write-Host " 1. Run .\scripts\stop-all.ps1 -KillByPort first, then re-run start-all" -ForegroundColor White
Write-Host " 2. Re-run with -Force to auto-kill and continue" -ForegroundColor White
Write-Host ""
$answer = Read-Host " Kill existing processes and continue? (y/N)"
if ($answer -eq "y" -or $answer -eq "Y") {
$shouldKill = $true
}
}
if ($shouldKill) {
foreach ($c in $conflicts) {
try {
Stop-Process -Id $c.PID -Force -ErrorAction Stop
Write-Host " [OK] Killed PID $($c.PID) ($($c.Process)) on port $($c.Port)" -ForegroundColor Green
Start-Sleep -Milliseconds 500
} catch {
Write-Host " [WARN] Cannot kill PID $($c.PID): $($_.Exception.Message)" -ForegroundColor Yellow
}
}
Start-Sleep -Seconds 2
} else {
Write-Host " Aborting. Please stop existing services first." -ForegroundColor Red
exit 1
}
} else {
Write-Host " [OK] All app ports are free" -ForegroundColor Green
}
Write-Host ""
# ===== 4. Build NestJS services (required for nest start --watch) =====
Write-Host "[4/6] Building NestJS services (first time required)..." -ForegroundColor Yellow
$nestjsServices = @(
"@edu/classes-service",
"@edu/iam-service",
"@edu/teacher-bff",
"@edu/core-edu-service",
"@edu/content-service",
"@edu/msg-service"
)
# Clean tsbuildinfo cache (incremental mode leftover causes tsc to skip emit)
$nestjsDirs = @("classes", "iam", "teacher-bff", "core-edu", "content", "msg")
foreach ($dir in $nestjsDirs) {
$svcPath = Join-Path $ProjectRoot "services\$dir"
if (Test-Path $svcPath) {
Get-ChildItem -Path $svcPath -Filter "*.tsbuildinfo" -Recurse -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
}
}
foreach ($svc in $nestjsServices) {
Write-Host " Building $svc..." -ForegroundColor Gray -NoNewline
$buildResult = pnpm --filter $svc build 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK]" -ForegroundColor Green
} else {
Write-Host " [FAIL]" -ForegroundColor Red
Write-Host " $buildResult" -ForegroundColor DarkGray
}
}
Write-Host ""
# ===== 5. Start application services =====
Write-Host "[5/6] Starting application services (11 windows)..." -ForegroundColor Yellow
$services = @(
@{Title="edu-app-classes"; Cmd="pnpm"; Args=@("--filter","@edu/classes-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-iam"; Cmd="pnpm"; Args=@("--filter","@edu/iam-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-teacher-bff"; Cmd="pnpm"; Args=@("--filter","@edu/teacher-bff","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-core-edu"; Cmd="pnpm"; Args=@("--filter","@edu/core-edu-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-content"; Cmd="pnpm"; Args=@("--filter","@edu/content-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-msg"; Cmd="pnpm"; Args=@("--filter","@edu/msg-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-data-ana"; Cmd="uv"; Args=@("run","uvicorn","data_ana.main:app","--app-dir","src","--host","0.0.0.0","--port","3006","--reload"); Dir="$ProjectRoot\services\data-ana"; PyEnv=$true},
@{Title="edu-app-ai"; Cmd="uv"; Args=@("run","uvicorn","ai.main:app","--app-dir","src","--host","0.0.0.0","--port","3008","--reload"); Dir="$ProjectRoot\services\ai"; PyEnv=$true},
@{Title="edu-app-api-gateway"; Cmd="go"; Args=@("run","."); Dir="$ProjectRoot\services\api-gateway"; GoEnv=$true},
@{Title="edu-app-push-gateway"; Cmd="go"; Args=@("run","."); Dir="$ProjectRoot\services\push-gateway";GoEnv=$true},
@{Title="edu-app-teacher-portal";Cmd="pnpm";Args=@("--filter","teacher-portal","dev"); Dir="$ProjectRoot"}
)
foreach ($svc in $services) {
$cmdStr = "$($svc.Cmd) $($svc.Args -join ' ')"
$psCmd = "Set-Location '$($svc.Dir)'; "
if ($svc.PyEnv) {
foreach ($kv in $pyEnv.GetEnumerator()) {
$psCmd += "`$env:$($kv.Key)='$($kv.Value)'; "
}
}
if ($svc.GoEnv) {
$psCmd += "`$env:Path = 'C:\Program Files\Go\bin;' + `$env:Path; "
}
$psCmd += "$cmdStr; Write-Host ''; Write-Host 'Service stopped. Press any key to close...' -ForegroundColor Yellow; `$null = `$Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')"
Start-Process -FilePath "powershell" -ArgumentList "-NoExit","-Command",$psCmd -WindowStyle Normal | Out-Null
Write-Host " [START] $($svc.Title)..." -ForegroundColor Green
Start-Sleep -Milliseconds 500
}
Write-Host ""
Write-Host " All services started in new windows. Waiting 40s for init..." -ForegroundColor Yellow
Start-Sleep -Seconds 40
# ===== 6. Health check =====
Write-Host "[6/6] Health check..." -ForegroundColor Yellow
Write-Host ""
$healthServices = @(
@{Name="classes"; Url="http://localhost:3001/healthz"},
@{Name="iam"; Url="http://localhost:3002/healthz"},
@{Name="teacher-bff"; Url="http://localhost:3003/healthz"},
@{Name="core-edu"; Url="http://localhost:3004/healthz"},
@{Name="content"; Url="http://localhost:3005/healthz"},
@{Name="data-ana"; Url="http://localhost:3006/healthz"},
@{Name="msg"; Url="http://localhost:3007/healthz"},
@{Name="ai"; Url="http://localhost:3008/healthz"},
@{Name="api-gateway"; Url="http://localhost:8080/healthz"},
@{Name="push-gateway"; Url="http://localhost:8081/healthz"},
@{Name="teacher-portal";Url="http://localhost:3000/"}
)
$okCount = 0
$failCount = 0
$failedServices = @()
foreach ($svc in $healthServices) {
$retries = 0
$maxRetries = 3
$success = $false
$lastError = ""
while ($retries -lt $maxRetries -and -not $success) {
try {
$null = Invoke-RestMethod -Uri $svc.Url -Method Get -TimeoutSec 5 -ErrorAction Stop
Write-Host " [OK] $($svc.Name)" -ForegroundColor Green
$success = $true
$okCount++
} catch {
$lastError = $_.Exception.Message
$retries++
if ($retries -lt $maxRetries) {
Start-Sleep -Seconds 5
}
}
}
if (-not $success) {
Write-Host " [FAIL] $($svc.Name) (after $maxRetries retries: $lastError)" -ForegroundColor Red
$failCount++
$failedServices += $svc.Name
}
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Started: [OK] $okCount ready / [FAIL] $failCount failed" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if ($failCount -gt 0) {
Write-Host ""
Write-Host "Failed services: $($failedServices -join ', ')" -ForegroundColor Red
Write-Host ""
Write-Host "Troubleshooting:" -ForegroundColor Yellow
Write-Host " 1. Check the service window for error output" -ForegroundColor White
Write-Host " 2. Verify dependencies: pnpm install / uv sync / go mod tidy" -ForegroundColor White
Write-Host " 3. Re-run health check: .\scripts\health-check.ps1" -ForegroundColor White
Write-Host " 4. Stop and retry: .\scripts\stop-all.ps1 -KillByPort then .\scripts\start-all.ps1" -ForegroundColor White
Write-Host ""
exit 1
}
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " Health check: .\scripts\health-check.ps1" -ForegroundColor White
Write-Host " CDC test: .\scripts\test-cdc.ps1" -ForegroundColor White
Write-Host " Stop all: .\scripts\stop-all.ps1" -ForegroundColor White
Write-Host ""

98
scripts/stop-all.ps1 Normal file
View File

@@ -0,0 +1,98 @@
<#
.SYNOPSIS
Edu stop all application services script
.DESCRIPTION
Closes all edu-app-* terminal windows (started by start-all.ps1)
Optional: kill processes by port (fallback when windows are closed but processes linger)
.PARAMETER KillByPort
Kill processes by port (fallback when windows are closed but processes still alive)
.EXAMPLE
.\scripts\stop-all.ps1
.\scripts\stop-all.ps1 -KillByPort
#>
param(
[switch]$KillByPort
)
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Edu Stop All Services" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# ===== 1. Close app service terminal windows =====
Write-Host "[1/2] Closing app service windows..." -ForegroundColor Yellow
$appTitles = @(
"edu-app-classes",
"edu-app-iam",
"edu-app-teacher-bff",
"edu-app-core-edu",
"edu-app-content",
"edu-app-msg",
"edu-app-data-ana",
"edu-app-ai",
"edu-app-api-gateway",
"edu-app-push-gateway",
"edu-app-teacher-portal"
)
$closedCount = 0
foreach ($title in $appTitles) {
$procs = Get-Process -Name "powershell","pwsh","node","python","uvicorn","go" -ErrorAction SilentlyContinue |
Where-Object { $_.MainWindowTitle -like "*$title*" }
if ($procs) {
foreach ($p in $procs) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction Stop
Write-Host " [OK] Closed $title (PID $($p.Id))" -ForegroundColor Green
$closedCount++
} catch {
Write-Host " [WARN] Cannot close $title (PID $($p.Id)): $($_.Exception.Message)" -ForegroundColor Yellow
}
}
} else {
Write-Host " [--] $title window not found" -ForegroundColor Gray
}
}
Write-Host ""
Write-Host " Closed $closedCount windows" -ForegroundColor Green
Write-Host ""
# ===== 2. Kill by port (optional) =====
if ($KillByPort) {
Write-Host "[2/2] Killing processes by port..." -ForegroundColor Yellow
$ports = @(3000,3001,3002,3003,3004,3005,3006,3007,3008,8080,8081)
foreach ($port in $ports) {
$connections = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
if ($connections) {
foreach ($conn in $connections) {
try {
$proc = Get-Process -Id $conn.OwningProcess -ErrorAction Stop
Stop-Process -Id $conn.OwningProcess -Force -ErrorAction Stop
Write-Host " [OK] Port $port -> killed $($proc.ProcessName) (PID $($conn.OwningProcess))" -ForegroundColor Green
} catch {
Write-Host " [WARN] Port $port -> cannot kill PID $($conn.OwningProcess)" -ForegroundColor Yellow
}
}
} else {
Write-Host " [--] Port $port free" -ForegroundColor Gray
}
}
} else {
Write-Host "[2/2] Skipping port kill (use -KillByPort to enable)" -ForegroundColor Gray
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Stop complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "To stop infrastructure:" -ForegroundColor Yellow
Write-Host " docker compose -f infra/docker-compose.yml --profile p6 --profile observability down" -ForegroundColor White
Write-Host ""

167
scripts/test-cdc.ps1 Normal file
View File

@@ -0,0 +1,167 @@
<#
.SYNOPSIS
Edu CDC pipeline end-to-end test script
.DESCRIPTION
Validates the full CDC pipeline: MySQL binlog -> Debezium -> Kafka -> data-ana -> ClickHouse
Steps:
1. Insert test grade into MySQL
2. Wait for Debezium capture + data-ana consume
3. Query ClickHouse to verify data synced
4. Call data-ana API to verify query works
.PARAMETER StudentId
Custom test student_id (default: cdc-test-<timestamp>)
.EXAMPLE
.\scripts\test-cdc.ps1
.\scripts\test-cdc.ps1 -StudentId "my-test-001"
#>
param(
[string]$StudentId = "cdc-test-$(Get-Date -Format 'yyyyMMddHHmmss')"
)
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Edu CDC Pipeline E2E Test" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
$examId = "exam-$StudentId"
$gradeId = "grade-$StudentId"
$testScore = 92.5
$classId = "cls-cdc-test"
Write-Host "Test parameters:" -ForegroundColor Yellow
Write-Host " StudentId: $StudentId"
Write-Host " ExamId: $examId"
Write-Host " GradeId: $gradeId"
Write-Host " Score: $testScore"
Write-Host " ClassId: $classId"
Write-Host ""
# ===== 1. Pre-check =====
Write-Host "[1/5] Pre-check..." -ForegroundColor Yellow
# Check Debezium connector
$connectorStatus = $null
try {
$connectorStatus = Invoke-RestMethod -Uri "http://localhost:8083/connectors/edu-mysql-source/status" -Method Get -TimeoutSec 3 -ErrorAction Stop
} catch {
Write-Host " [FAIL] Debezium Connect not reachable. Start infrastructure first." -ForegroundColor Red
exit 1
}
if ($connectorStatus.connector.state -ne "RUNNING") {
Write-Host " [FAIL] Debezium connector state: $($connectorStatus.connector.state)" -ForegroundColor Red
exit 1
}
Write-Host " [OK] Debezium connector: RUNNING" -ForegroundColor Green
# Check data-ana service
try {
$null = Invoke-RestMethod -Uri "http://localhost:3006/healthz" -Method Get -TimeoutSec 3 -ErrorAction Stop
Write-Host " [OK] data-ana service: running" -ForegroundColor Green
} catch {
Write-Host " [FAIL] data-ana service not reachable. Start application services first." -ForegroundColor Red
exit 1
}
# Check CDC consumer status
try {
$readyz = Invoke-RestMethod -Uri "http://localhost:3006/readyz" -Method Get -TimeoutSec 3 -ErrorAction Stop
$cdcStatus = $readyz.services.cdc_consumer
if ($cdcStatus -ne "running") {
Write-Host " [WARN] CDC consumer status: $cdcStatus (KAFKA_BROKERS may not be set)" -ForegroundColor Yellow
} else {
Write-Host " [OK] CDC consumer: running" -ForegroundColor Green
}
} catch {
Write-Host " [WARN] Cannot get /readyz status" -ForegroundColor Yellow
}
Write-Host ""
# ===== 2. Insert test data into MySQL =====
Write-Host "[2/5] Inserting test data into MySQL..." -ForegroundColor Yellow
$sqlInsert = @"
INSERT INTO core_edu_exams (id, class_id, subject_id, title, exam_date, total_score, created_at, updated_at)
VALUES ('$examId', '$classId', 'sub-math', 'CDC Test Exam', NOW(), 100, NOW(), NOW())
ON DUPLICATE KEY UPDATE updated_at=NOW();
INSERT INTO core_edu_grades (id, exam_id, student_id, score, rank_in_class, created_at, updated_at)
VALUES ('$gradeId', '$examId', '$StudentId', $testScore, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE score=$testScore, updated_at=NOW();
"@
docker exec edu-mysql mysql -uedu -pchangeme next_edu_cloud -e $sqlInsert 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Inserted: exam=$examId / grade=$gradeId / student=$StudentId / score=$testScore" -ForegroundColor Green
} else {
Write-Host " [FAIL] MySQL insert failed" -ForegroundColor Red
exit 1
}
Write-Host ""
# ===== 3. Wait for CDC propagation =====
Write-Host "[3/5] Waiting for CDC propagation (5s)..." -ForegroundColor Yellow
Start-Sleep -Seconds 5
Write-Host " [OK] Wait complete" -ForegroundColor Green
Write-Host ""
# ===== 4. Verify ClickHouse data =====
Write-Host "[4/5] Verifying ClickHouse data..." -ForegroundColor Yellow
$chQuery = "SELECT student_id, class_id, exam_id, score, last_updated FROM edu_analytics.student_dashboard_view WHERE student_id = '$StudentId' ORDER BY last_updated DESC LIMIT 5"
$chResult = docker exec edu-clickhouse clickhouse-client --user default --password clickhouse -q $chQuery 2>$null
if ($chResult) {
Write-Host " [OK] ClickHouse returned data:" -ForegroundColor Green
Write-Host " $chResult" -ForegroundColor White
if ($chResult -match $StudentId -and $chResult -match "$testScore") {
Write-Host ""
Write-Host " [OK] Verified: student_id match + score=$testScore match" -ForegroundColor Green
if ($chResult -match $classId) {
Write-Host " [OK] class_id filled via exam cache: $classId" -ForegroundColor Green
} else {
Write-Host " [WARN] class_id not filled (exam cache may have missed, check event order)" -ForegroundColor Yellow
}
} else {
Write-Host " [FAIL] Data mismatch: expected student=$StudentId, score=$testScore" -ForegroundColor Red
}
} else {
Write-Host " [FAIL] ClickHouse has no data for student_id=$StudentId" -ForegroundColor Red
Write-Host " Possible causes:" -ForegroundColor Yellow
Write-Host " 1. Debezium did not capture MySQL change (check connector status)" -ForegroundColor White
Write-Host " 2. data-ana consumer not running (check /readyz cdc_consumer)" -ForegroundColor White
Write-Host " 3. Kafka topic name mismatch (check debezium-register.json)" -ForegroundColor White
exit 1
}
Write-Host ""
# ===== 5. Verify data-ana API =====
Write-Host "[5/5] Verifying data-ana query API..." -ForegroundColor Yellow
$h = @{Authorization="Bearer dev-token"}
try {
$weakness = Invoke-RestMethod -Uri "http://localhost:3006/analytics/student/$StudentId/weakness" -Method Get -Headers $h -TimeoutSec 5 -ErrorAction Stop
Write-Host " [OK] /analytics/student/$StudentId/weakness" -ForegroundColor Green
Write-Host " Response: $($weakness | ConvertTo-Json -Depth 3)" -ForegroundColor Gray
} catch {
Write-Host " [WARN] /analytics/student/$StudentId/weakness failed: $($_.Exception.Message)" -ForegroundColor Yellow
}
try {
$perf = Invoke-RestMethod -Uri "http://localhost:3006/analytics/class/$classId/performance" -Method Get -Headers $h -TimeoutSec 5 -ErrorAction Stop
Write-Host " [OK] /analytics/class/$classId/performance" -ForegroundColor Green
Write-Host " Response: $($perf | ConvertTo-Json -Depth 3)" -ForegroundColor Gray
} catch {
Write-Host " [WARN] /analytics/class/$classId/performance failed: $($_.Exception.Message)" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " CDC Pipeline Test Complete" -ForegroundColor Cyan
Write-Host " MySQL -> Debezium -> Kafka -> data-ana -> ClickHouse [OK]" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan

View File

@@ -11,6 +11,8 @@ dependencies = [
"httpx>=0.27.0", "httpx>=0.27.0",
"opentelemetry-api>=1.27.0", "opentelemetry-api>=1.27.0",
"opentelemetry-sdk>=1.27.0", "opentelemetry-sdk>=1.27.0",
"opentelemetry-exporter-otlp>=1.27.0",
"opentelemetry-instrumentation-fastapi>=0.48b0",
"prometheus-client>=0.20.0", "prometheus-client>=0.20.0",
"structlog>=24.4.0", "structlog>=24.4.0",
] ]

View File

@@ -12,7 +12,7 @@ class Settings(BaseSettings):
openai_base_url: str = "https://api.openai.com/v1" openai_base_url: str = "https://api.openai.com/v1"
anthropic_api_key: str = "" anthropic_api_key: str = ""
# 开发模式true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错 # 开发模式true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错
dev_mode: str = "false" dev_mode: bool = False
# 可观测性 # 可观测性
otel_endpoint: str = "http://localhost:4318" otel_endpoint: str = "http://localhost:4318"
log_level: str = "info" log_level: str = "info"
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
@property @property
def is_dev(self) -> bool: def is_dev(self) -> bool:
"""是否处于开发模式.""" """是否处于开发模式."""
return self.dev_mode.lower() == "true" return self.dev_mode
@property @property
def llm_available(self) -> bool: def llm_available(self) -> bool:

View File

@@ -9,6 +9,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from opentelemetry import trace from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import make_asgi_app from prometheus_client import make_asgi_app
@@ -40,7 +41,7 @@ def init_tracer() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期.""" """应用生命周期."""
init_tracer() init_tracer()
logger.info( logger.info(
@@ -61,6 +62,9 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# OpenTelemetry FastAPI 自动埋点HTTP 请求/响应 span
FastAPIInstrumentor.instrument_app(app)
app.mount("/metrics", make_asgi_app()) app.mount("/metrics", make_asgi_app())
# 业务路由加 /ai 前缀Gateway 代理 /api/v1/ai/* → /ai/* # 业务路由加 /ai 前缀Gateway 代理 /api/v1/ai/* → /ai/*
@@ -85,6 +89,12 @@ class ChatResponse(BaseModel):
degraded: bool = False degraded: bool = False
class QuestionRequest(BaseModel):
"""题目生成请求."""
prompt: str
def _extract_content(result: dict[str, Any] | None) -> tuple[str, str, dict[str, Any]]: def _extract_content(result: dict[str, Any] | None) -> tuple[str, str, dict[str, Any]]:
"""从 OpenAI 响应中抽取 (content, model, usage)。""" """从 OpenAI 响应中抽取 (content, model, usage)。"""
if result is None: if result is None:
@@ -167,7 +177,7 @@ async def chat_stream(req: ChatRequest) -> StreamingResponse:
@router.post("/generate/question") @router.post("/generate/question")
async def generate_question(prompt: str) -> dict[str, Any]: async def generate_question(req: QuestionRequest) -> dict[str, Any]:
"""生成题目(无 API key 时降级返回骨架).""" """生成题目(无 API key 时降级返回骨架)."""
with tracer.start_as_current_span("generate_question"): with tracer.start_as_current_span("generate_question"):
messages = [ messages = [
@@ -176,7 +186,7 @@ async def generate_question(prompt: str) -> dict[str, Any]:
"content": "You are an educational question generator. " "content": "You are an educational question generator. "
"Generate a clear, concise question based on the user's prompt.", "Generate a clear, concise question based on the user's prompt.",
}, },
{"role": "user", "content": prompt}, {"role": "user", "content": req.prompt},
] ]
result = await chat_completion( result = await chat_completion(
messages=messages, messages=messages,
@@ -186,7 +196,7 @@ async def generate_question(prompt: str) -> dict[str, Any]:
base_url=settings.openai_base_url, base_url=settings.openai_base_url,
) )
if result is None: if result is None:
logger.warning("generate_question_degraded", prompt=prompt[:100]) logger.warning("generate_question_degraded", prompt=req.prompt[:100])
return { return {
"success": True, "success": True,
"data": {"question": "[degraded] question generation skeleton"}, "data": {"question": "[degraded] question generation skeleton"},

View File

@@ -0,0 +1,145 @@
# 模块理解确认书 — api-gateway
> AIai01Go 网关层)
> 阶段:阶段 1 交付物
> 日期2026-07-09
> 关联:[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[AI 分配方案](../../../docs/architecture/ai-allocation.md)
---
## 1. 我在架构中的位置
- **层级**L3 网关层004 §3.1 六层架构)
- **上游**4 个微前端teacher-portal / student-portal / parent-portal / admin-portal通过浏览器/移动端 HTTP 请求
- **下游**3 个 BFF + 6 个业务服务,共 9 个反向代理目标
- BFFteacher-bff3003、student-bff待建、parent-bff待建
- 业务iam3002、core-edu3004、content3005、data-ana3006、msg3007、ai3008
- **通信方式**
- 入口HTTP/REST含 WebSocket 升级透传到 push-gateway不在本服务
- 出口HTTP 反向代理(`httputil.ReverseProxy`P3 起部分链路改 gRPC
- **不持有业务状态**:仅做路由/鉴权/限流/熔断/可观测,无 DB
## 2. 我的限界上下文
- **我负责**所有外部请求的统一入口、JWT 校验、用户身份注入、限流、熔断、CORS、安全头、请求 ID 注入、反向代理
- **聚合/实体**:无(网关无领域模型)
- **业务领域**:不属于 D1-D6 任一业务领域,属于基础设施层
- **我不负责**
- WebSocket 长连接管理push-gateway 负责)
- 用户身份认证逻辑iam 负责,本服务只校验 JWT 签名)
- 权限点解析(业务服务 Controller 通过 `@RequirePermission` 自行校验,本服务只透传 `x-user-id`/`x-user-roles` 头)
- 业务数据持久化(无 DB
## 3. 我与外部的契约
### 3.1 我消费的 proto message
| proto | message | 用途 |
| --------- | ------------------------ | ----------------------------------------------------------------------- |
| iam.proto | `UserInfo` / `TokenPair` | P2 起 RS256 公钥校验时通过 IAM `/auth/jwks` 端点拉公钥HTTP非 gRPC |
> P1 阶段用 HS256 共享密钥,**不消费任何 proto**。P2 起 RS256 通过 IAM 暴露的 JWKS 端点(`/.well-known/jwks.json`)拉取公钥,仍是 HTTP无需 gRPC 客户端。
### 3.2 我暴露的 API 端点
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | --------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------- |
| GET | `/healthz` | 无 | liveness 探针 |
| GET | `/readyz` | 无 | readiness 探针(当前 stub需补真实检查 |
| ANY | `/api/v1/classes` + `/api/v1/classes/*path` | JWT | 代理到 classes/core-edu 服务 |
| ANY | `/api/v1/iam` + `/api/v1/iam/*path` | JWT除 register/login/refresh 白名单) | 代理到 iam 服务 |
| ANY | `/api/v1/teacher` + `/api/v1/teacher/*path` | JWT | 代理到 teacher-bff |
| ANY | `/api/v1/exams` `/homework` `/grades` + `/*path` | JWT | 代理到 core-edu |
| ANY | `/api/v1/textbooks` `/chapters` `/knowledge-points` `/questions` + `/*path` | JWT | 代理到 content |
| ANY | `/api/v1/notifications` + `/*path` | JWT | 代理到 msg |
| ANY | `/api/v1/ai` + `/*path` | JWT | 代理到 ai |
| ANY | `/api/v1/analytics` + `/*path` | JWT | 代理到 data-ana |
| GET | `/metrics` | 无(待实现) | Prometheus 指标端点 |
### 3.3 我发布/消费的 Kafka 事件
**无**。api-gateway 不接入 Kafka是纯同步 HTTP 反向代理。
### 3.4 错误码前缀
| 错误码 | HTTP | 触发条件 |
| ------------------- | ---- | ------------------------------------------------------------------- |
| `UNAUTHORIZED` | 401 | 缺失 Authorization 头 |
| `INVALID_TOKEN` | 401 | JWT 签名/格式错误 |
| `INVALID_CLAIMS` | 401 | JWT claims 解析失败 |
| `RATE_LIMITED` | 429 | 超出令牌桶限流(响应体当前写 `error: rate_limited`,需统一加 code |
| `CIRCUIT_OPEN` | 503 | 下游熔断打开(响应体当前写 `error: circuit_open` |
| `INTERNAL_ERROR` | 500 | panic 兜底Recovery 中间件) |
| `REQUEST_TOO_LARGE` | 413 | 请求体超 10MB由 MaxBytesReader 自动触发,但响应非 JSON 信封) |
> **错误码统一约定**:本服务无业务错误码前缀(无业务),仅上述 7 个 HTTP 语义错误。响应体需统一为 `{ success: false, error: { code, message } }` 信封,与 classes 黄金模板 `ApplicationError` 对齐。
## 4. 我的技术栈
- **语言**Go 1.22+go.mod 声明 1.25.0,需与 Dockerfile 对齐,见审计表)
- **框架**Gin v1.12.0
- **核心依赖**
- `github.com/golang-jwt/jwt/v5` v5.2.1JWT 校验)
- `github.com/sony/gobreaker/v2` v2.1.0(熔断)
- `github.com/google/uuid` v1.6.0(请求 ID
- `go.opentelemetry.io/otel` v1.44.0 + `otelgin` v0.69.0(链路追踪)
- **存储**:无 DB无 Redis限流用内存令牌桶 `sync.Map`
- **构建**:多阶段 Dockerfilegolang:1.22-alpine → alpine:3.20,非 root 用户)
## 5. 我的阶段归属
- **阶段**P1已实现+ P2 升级RS256+ P6 硬化(限流策略表、熔断阈值细化)
- **当前阶段目标**
- P1 退出标准已达成classes 域 CRUD 端到端跑通(见 known-issues 工作日志 2026-07-09 00:24
- P2 待升级JWT HS256 → RS256IAM 签发Gateway 公钥校验)
- **依赖上游阶段产出**
- P2 iam 服务须暴露 JWKS 端点(`/.well-known/jwks.json`)供本服务拉公钥
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
| 项 | classes黄金模板 | api-gateway 现状 | 差距 |
| ----------------- | ------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------- |
| 权限装饰器 | `@RequirePermission()` | N/AGo 无装饰器;用中间件 `AuthMiddleware` 替代) | ✅ 等价实现 |
| 错误码前缀 | `CLASSES_*` | 无前缀(基础设施层) | ✅ 设计合理 |
| loggerpino | `shared/observability/logger.ts` | ❌ 用标准库 `log` | ⚠️ 待补 `log/slog` 结构化日志 |
| metrics | `shared/observability/metrics.ts` 暴露 `/metrics` | ❌ 无 `/metrics` 端点 | ⚠️ 待补 prom-client |
| tracer | `shared/observability/tracer.ts` OTel SDK | ✅ `internal/observability/tracer.go` | ✅ 对齐 |
| `/healthz` | ✅ | ✅ | ✅ 对齐 |
| `/readyz` | ✅ 检查 DB `SELECT 1` | ❌ stub 直接返回 200 | ⚠️ 待补下游服务健康检查 |
| 优雅关闭 | SIGTERM → app.close() | ✅ `srv.Shutdown(ctx)` 5s 超时 | ✅ 对齐 |
| 测试覆盖率 | ≥ 80% | ~25%(仅 circuit-breaker + ratelimit | ⚠️ 待补 auth/cors/security/recovery/requestid/proxy 测试 |
| Dockerfile | 多阶段 + 非 root + healthcheck | ✅ | ✅ 对齐 |
| Zod 输入验证 | `schema.safeParse(body)` | N/AGo 无 Zod`ShouldBindJSON` | ✅ 等价实现 |
| GlobalErrorFilter | `GlobalErrorFilter` | ✅ Recovery 中间件兜底 | ✅ 等价实现 |
## 7. 服务审计表(按 ai-allocation §10 模板)
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ----------- | ------------- | ------------- | ----------- | ------- | ------- | -------- | ------- | ---------- | ---------- | ---------- |
| api-gateway | ⚠️ 中间件替代 | ✅ 无前缀合理 | ❌ 标准 log | ❌ 无 | ✅ OTel | ✅ | ⚠️ stub | ✅ 5s 超时 | ~25% | ✅ 多阶段 |
### 7.1 详细问题清单(按严重度排序)
| # | 严重度 | 文件 | 问题 | 修复建议 |
| --- | ------ | -------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 1 | 高 | `internal/observability/` 缺失 | 无 `/metrics` 端点Prometheus 404 | 新增 `metrics.go`,注册 `http_requests_total`/`http_request_duration_seconds`/`circuit_breaker_state`,在 main.go 暴露 `/metrics` |
| 2 | 高 | `internal/health/health.go` | `/readyz` 直接返回 200未检查下游 | 改为并行 ping 9 个下游 `/healthz`,任一不可达返回 503超时 2s |
| 3 | 高 | `internal/middleware/auth.go` L124-139 | 死代码 `RequestIDMiddleware()` + `generateUUID()` 重复 requestid.go 且未使用;`uuid` 包未导入 | 删除 L124-139已由 `requestid.go` 实现) |
| 4 | 高 | 全文件 | 用 `log.Printf`,不符合 coding-standards §3.8 `log/slog` 结构化日志要求 | 引入 `slog.New(slog.NewJSONHandler(os.Stdout))`,所有日志带 `request_id`/`trace_id` |
| 5 | 中 | `go.mod` L3 vs `Dockerfile` L1 | go.mod 声明 `go 1.25.0`Dockerfile 用 `golang:1.22-alpine` | 统一为 `go 1.22`(与 Dockerfile 一致),或升级 Dockerfile 到 `golang:1.25-alpine` |
| 6 | 中 | `internal/middleware/auth.go` | P2 待升级 HS256 → RS256 | 新增 `JWKSFetcher` 缓存 IAM 公钥TTL 1h`jwt.Parse``jwt.WithKeySet(jwks)` |
| 7 | 中 | `internal/middleware/cors.go` L21 | `CORS_ORIGINS` 直接 `os.Getenv`,未纳入 Config 结构 | 移入 `config.Config.CORSOrigins`,与其他配置统一 |
| 8 | 中 | `internal/middleware/ratelimit.go` | 单实例内存令牌桶,水平扩展后限流失效 | P6 引入 Redis 令牌桶(`redis_rate`)或保留单实例但文档标注 |
| 9 | 中 | `internal/middleware/auth.go` L68 | DevMode 注入固定 `teacher,admin` 角色,生产风险 | 启动时若 `DevMode=true && ENV=production` 则 panic 拒绝启动 |
| 10 | 低 | `README.md` L38 | 提到 `GET /health` 兼容端点,但代码未注册 | 删除 README 描述或补注册 |
| 11 | 低 | `internal/proxy/proxy.go` L24 | 连续两次 `TrimPrefix``/api/v1` 后再 `/api`)逻辑冗余 | 第二次 `TrimPrefix("/api")` 实际无效果(首字符已是 `/`),可删 |
| 12 | 低 | `Dockerfile` L18 | 构建命令 `./main.go` 而非 `./` | 改为 `go build -ldflags="-s -w" -o /app/bin/api-gateway .` 更规范 |
| 13 | 低 | 测试 | auth/cors/security/recovery/requestid/proxy 无测试 | 补 `*_test.go`,目标覆盖率 ≥ 80% |
## 8. 风险与假设
- **假设**iam 服务在 P2 会暴露 JWKS 端点,否则 RS256 升级阻塞
- **假设**:所有下游服务在 P1 都已实现 `/healthz`(用于 `/readyz` 真实检查)
- **风险**单实例限流在多副本部署后失效P6 需迁 Redis
- **风险**DevMode 旁路若误开到生产,可绕过鉴权注入 admin 角色
- **未决**:是否在 Gateway 层做权限校验(当前仅透传角色,由下游服务自校验)?建议保持现状,避免 Gateway 持有权限点常量造成耦合

View File

@@ -0,0 +1,429 @@
# 模块架构设计文档 — api-gateway
> AIai01Go 网关层)
> 阶段:阶段 2 交付物
> 日期2026-07-09
> 关联:[01 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)
>
> 本文档覆盖 ai-allocation §5 设计重点路由表矩阵、限流策略表、熔断阈值、JWT RS256 流程、CORS 白名单、请求 ID 注入。
---
## 1. 模块内部分层图
```mermaid
graph TB
subgraph Client["客户端"]
B[浏览器/移动端]
end
subgraph MW["中间件链(按注册顺序)"]
M1[1. Recovery<br/>panic 兜底]
M2[2. OTelgin<br/>HTTP span]
M3[3. RequestID<br/>X-Request-Id]
M4[4. CORS<br/>Origin 白名单]
M5[5. SecurityHeaders<br/>安全响应头]
M6[6. RequestBodyLimit<br/>10MB]
M7[7. RateLimit<br/>令牌桶]
M8[8. Auth<br/>JWT RS256]
M9[9. CircuitBreaker<br/>gobreaker v2]
M10[10. Metrics<br/>prom-client]
end
subgraph Routes["路由"]
H[/healthz /readyz /metrics/]
V1[/api/v1/* 代理路由/]
end
subgraph Proxy["反向代理"]
P[httputil.ReverseProxy<br/>去 /api/v1 前缀]
end
subgraph Downstream["下游服务"]
D1[iam :3002]
D2[classes/core-edu :3001/:3004]
D3[teacher-bff :3003]
D4[content :3005]
D5[msg :3007]
D6[ai :3008]
D7[data-ana :3006]
end
B --> M1 --> M2 --> M3 --> M4 --> M5 --> M6 --> M7
M7 --> H
M7 --> M8 --> M9 --> M10 --> V1 --> P
P --> D1
P --> D2
P --> D3
P --> D4
P --> D5
P --> D6
P --> D7
```
**拦截点说明**
- Recovery 必须最外层(捕获后续所有 panic
- OTelgin 第二(覆盖全链路 span
- RequestID 第三(后续中间件日志可引用)
- Auth 在 CircuitBreaker 之前(避免未鉴权请求消耗下游配额)
- Metrics 在 CircuitBreaker 之后(统计通过鉴权且未被熔断的请求)
- Health/Metrics 端点旁路 Auth在 Auth 之前注册)
## 2. 领域模型
**无领域模型**。api-gateway 是基础设施层,不持有业务聚合/实体/值对象。
仅有的值对象:
| 值对象 | 字段 | 用途 |
| ----------------------------- | -------------------------------------- | ------------------ |
| `Config` | 见 §3 配置项 | 启动时加载,不可变 |
| `bucket`(限流) | `tokens float64`, `lastTime time.Time` | per-IP 令牌桶 |
| `CircuitBreaker`gobreaker | 内部状态机 | per-服务熔断状态 |
## 3. 数据模型
**无数据库**。所有状态在内存:
- `rateLimiter.buckets sync.Map[string]*bucket`IP → 令牌桶
- `CircuitBreaker` 实例(每个下游服务一个,当前共享一个 "downstream"
**P6 演进**
- 限流迁 Redis`redis_rate` 包),支持多副本一致
- 熔断状态保持本地(每个副本独立判断下游健康)
### 3.1 配置项Config 结构)
| 字段 | 环境变量 | 默认值 | 说明 |
| --------------- | ----------------------------- | --------------------------------------- | ------------------------- |
| Port | `API_GATEWAY_PORT` | 8080 | 监听端口 |
| JWTSecret | `JWT_SECRET` | (必填) | HS256 密钥P1P2 弃用) |
| JWKSURL | `IAM_JWKS_URL` | `http://iam:3002/.well-known/jwks.json` | RS256 公钥端点P2 |
| JWTIssuer | `JWT_ISSUER` | next-edu-cloud | JWT iss 校验 |
| JWTAudience | `JWT_AUDIENCE` | next-edu-cloud | JWT aud 校验 |
| CORSOrigins | `CORS_ORIGINS` | * | 逗号分隔白名单 |
| DevMode | `DEV_MODE` | false | 开发旁路(生产禁用) |
| RateLimitRPS | `RATE_LIMIT_RPS` | 100 | 全局默认 RPS |
| RateLimitBurst | `RATE_LIMIT_BURST` | 20 | 突发容量 |
| BodyLimitBytes | `BODY_LIMIT_BYTES` | 10485760 | 请求体上限10MB |
| OTLPEndpoint | `OTEL_EXPORTER_OTLP_ENDPOINT` | http://localhost:4318 | OTLP trace 端点 |
| LogLevel | `LOG_LEVEL` | info | slog 级别 |
| ServicesURL | `*_SERVICE_URL` | 见路由表 | 9 个下游服务地址 |
| ShutdownTimeout | `SHUTDOWN_TIMEOUT` | 5s | 优雅关闭超时 |
## 4. API 设计
### 4.1 路由表矩阵(核心交付物)
| 路径前缀 | 目标服务 | 默认端口 | 鉴权 | 公开子路径 | 阶段 |
| --------------------------------------------------------------------------- | ------------------------------- | ----------- | ---- | ------------------------------------------- | ---- |
| `/api/v1/iam` + `/*path` | iam | 3002 | JWT | `/iam/register` `/iam/login` `/iam/refresh` | P2 |
| `/api/v1/classes` + `/*path` | classesP3 起合并入 core-edu | 3001 → 3004 | JWT | — | P1 |
| `/api/v1/teacher` + `/*path` | teacher-bff | 3003 | JWT | — | P2 |
| `/api/v1/student` + `/*path` | student-bff | 3009 | JWT | — | P3 |
| `/api/v1/parent` + `/*path` | parent-bff | 3010 | JWT | — | P4 |
| `/api/v1/exams` `/homework` `/grades` + `/*path` | core-edu | 3004 | JWT | — | P3 |
| `/api/v1/textbooks` `/chapters` `/knowledge-points` `/questions` + `/*path` | content | 3005 | JWT | — | P4 |
| `/api/v1/notifications` `/messages` + `/*path` | msg | 3007 | JWT | — | P5 |
| `/api/v1/ai` + `/*path` | ai | 3008 | JWT | — | P5 |
| `/api/v1/analytics` `/dashboard` + `/*path` | data-ana | 3006 | JWT | — | P4 |
| `/healthz` | 本服务 | 8080 | 无 | — | P1 |
| `/readyz` | 本服务 | 8080 | 无 | — | P1 |
| `/metrics` | 本服务 | 8080 | 无 | — | P6 |
**路由注册规则**(强制):
- 每个前缀同时注册无尾斜杠与通配符两条路由(`/classes` + `/classes/*path`
- `r.RedirectTrailingSlash = false`(避免 Next.js rewrites 代理循环,见 known-issues
- 新增服务时按本表追加,禁止改其他服务路由
### 4.2 限流策略表
| 路由类别 | RPS | Burst | 备注 |
| ------------------------------- | --- | ----- | ---------------------------------- |
| `/api/v1/iam/login` | 5 | 5 | 登录接口额外加用户级限流(防爆破) |
| `/api/v1/iam/register` | 10 | 10 | 注册接口 |
| `/api/v1/iam/refresh` | 20 | 20 | 刷新 token |
| `/api/v1/ai/*` | 20 | 10 | AI 接口成本高,单独限流 |
| `/api/v1/analytics/*` | 30 | 20 | 分析查询较重 |
| 其他 `/api/v1/*` | 100 | 20 | 默认全局限流 |
| `/healthz` `/readyz` `/metrics` | ∞ | ∞ | 不限流(探针高频访问) |
**实现方式**
- P1内存令牌桶`sync.Map` per-IP
- P6Redis 令牌桶(`redis_rate`),支持多副本一致 + 用户级限流
### 4.3 熔断阈值配置
| 服务 | Interval | Timeout | MaxRequests | ReadyToTrip | 备注 |
| ---------------- | -------- | ------- | ----------- | ------------ | -------------------- |
| iam | 5s | 30s | 1 | 错误率 > 50% | 鉴权失败影响全链路 |
| classes/core-edu | 5s | 30s | 1 | 错误率 > 50% | 默认 |
| teacher-bff | 5s | 30s | 1 | 错误率 > 50% | 默认 |
| content | 10s | 60s | 1 | 错误率 > 30% | ES 慢查询容忍度低 |
| msg | 5s | 30s | 1 | 错误率 > 50% | 默认 |
| ai | 10s | 60s | 1 | 错误率 > 30% | LLM 延迟高,容忍度低 |
| data-ana | 10s | 60s | 1 | 错误率 > 30% | ClickHouse 查询较重 |
**熔断失败定义**:下游返回 5xx 视为失败4xx/2xx 不计入。
**熔断打开响应**HTTP 503 `{ success: false, error: { code: "CIRCUIT_OPEN", message: "downstream unhealthy", retry_after: 30 } }`
### 4.4 JWT RS256 校验流程P2 升级)
```mermaid
sequenceDiagram
participant U as 用户
participant GW as api-gateway
participant IAM as iam 服务
participant JWKS as JWKS 缓存
rect rgb(255, 250, 240)
Note over GW,JWKS: 启动时与定期刷新
GW->>IAM: GET /.well-known/jwks.json
IAM-->>GW: { keys: [...] }
GW->>JWKS: 缓存公钥集TTL 1h
end
rect rgb(240, 248, 255)
Note over U,GW: 请求鉴权
U->>GW: GET /api/v1/classes + Authorization: Bearer <RS256 token>
GW->>GW: 解析 token headerkid
GW->>JWKS: 查 kid 对应公钥
alt 缓存命中
JWKS-->>GW: 公钥
else 缓存未命中
GW->>IAM: GET /.well-known/jwks.json强制刷新
IAM-->>GW: 新公钥集
GW->>JWKS: 更新缓存
end
GW->>GW: jwt.Verify(token,公钥,iss,aud,exp)
alt 校验通过
GW->>GW: 提取 sub/roles/dataScope
GW->>GW: 注入 x-user-id / x-user-roles / x-data-scope 头
GW-->>U: 代理转发到 classes 服务
else 校验失败
GW-->>U: 401 INVALID_TOKEN
end
end
```
**JWKS 缓存策略**
- TTL 1h到期后台异步刷新不阻塞请求
- kid 未命中时强制同步刷新一次
- 刷新失败保留旧公钥集继续服务fail-open 1 次后 fail-close
- 启动时同步拉取一次,失败则 panic 拒绝启动
### 4.5 CORS 白名单
```go
// 默认白名单(生产环境通过 CORS_ORIGINS 覆盖)
allowedOrigins = []string{
"https://teacher.edu.example.com",
"https://student.edu.example.com",
"https://parent.edu.example.com",
"https://admin.edu.example.com",
"http://localhost:3000", // 开发
}
```
**规则**
- 未配置 `CORS_ORIGINS` 时默认 `*`(仅开发环境)
- 生产环境必须显式配置白名单,禁止 `*`
- 允许方法GET POST PUT DELETE OPTIONS PATCH
- 允许头Authorization Content-Type X-Request-Id X-Trace-Id
- 暴露头X-Request-Id X-Trace-Id
- 预检缓存12 小时(`Access-Control-Max-Age: 43200`
### 4.6 请求 ID 注入
```go
// 规则:
// 1. 优先透传客户端 X-Request-Id 头
// 2. 缺失则生成 req-<uuid-v4>
// 3. 写入 context + 响应头 X-Request-Id
// 4. 注入到下游请求头ReverseProxy.Director 中保留)
// 5. 日志/metrics/trace 全部引用此 request_id
```
**W3C Trace Context 透传**
- `traceparent` / `tracestate` 头透传OTel SDK 自动处理)
- `X-Request-Id` 是业务层 ID`traceparent` 是 OTel 层 ID两者并存
- 日志同时记录两者,便于 Loki → Tempo 关联查询
## 5. 事件设计
**无**。api-gateway 是纯同步代理,不发布/消费任何 Kafka 事件。
## 6. 横切关注点对齐清单
### 6.1 权限装饰器(等价实现)
| 端点 | 鉴权方式 | 备注 |
| ------------------------------------------ | ---------------- | ----------------------- |
| `/healthz` `/readyz` `/metrics` | 无 | 探针端点 |
| `/api/v1/iam/register` `/login` `/refresh` | 白名单 | 公开接口 |
| 其他 `/api/v1/*` | `AuthMiddleware` | JWT RS256 校验 + 头注入 |
> Go 无装饰器用中间件链等价实现。Gateway 不做权限点校验(不持有 Permissions 常量),仅透传 `x-user-id`/`x-user-roles`/`x-data-scope` 头,由下游业务服务 Controller 自校验。
### 6.2 错误码清单
| 错误码 | HTTP | 触发条件 | 响应体 |
| ------------------- | ---- | --------------------- | -------------------------------------- |
| `UNAUTHORIZED` | 401 | 缺失 Authorization 头 | `{success:false,error:{code,message}}` |
| `INVALID_TOKEN` | 401 | JWT 签名/格式错误 | 同上 |
| `INVALID_CLAIMS` | 401 | JWT claims 解析失败 | 同上 |
| `RATE_LIMITED` | 429 | 超出令牌桶限流 | 同上 + `retry_after: 60` |
| `CIRCUIT_OPEN` | 503 | 下游熔断打开 | 同上 + `retry_after: 30` |
| `REQUEST_TOO_LARGE` | 413 | 请求体超 10MB | 同上 |
| `INTERNAL_ERROR` | 500 | panic 兜底 | 同上 + `request_id` |
### 6.3 Logger 初始化
```go
// internal/observability/logger.go待新增
import "log/slog"
var Logger *slog.Logger
func InitLogger(level string) {
var lv slog.Level
_ = lv.UnmarshalText([]byte(level))
Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lv}))
slog.SetDefault(Logger)
}
```
**日志字段规范**
- `timestamp` ISO8601
- `level` INFO/WARN/ERROR
- `service` "api-gateway"
- `request_id` 从 context 取
- `trace_id` 从 OTel span 取
- `user_id` 从注入头取
- `method` `path` `status` `latency_ms`
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| --------------------------------------------- | --------- | ---------------------- | ----------------------------------------- |
| `api_gateway_http_requests_total` | Counter | method,endpoint,status | 请求总数 |
| `api_gateway_http_request_duration_seconds` | Histogram | method,endpoint | 请求延迟 |
| `api_gateway_circuit_breaker_state` | Gauge | service,state | 熔断器状态0=CLOSED 1=OPEN 2=HALF_OPEN |
| `api_gateway_rate_limited_total` | Counter | ip | 被限流请求数 |
| `api_gateway_proxy_upstream_duration_seconds` | Histogram | upstream | 下游响应延迟 |
| `api_gateway_jwks_refresh_total` | Counter | result | JWKS 刷新次数 |
| `go_*` | — | — | prom-client 默认 Go runtime 指标 |
**暴露端点**`GET /metrics`prom-client 默认 handler
### 6.5 Tracer 初始化
已实现:`internal/observability/tracer.go`OTLP HTTP exporter + W3C TraceContext 传播。
**待补**
- 启动时记录 `service.name` / `service.version` / `deployment.environment` 资源属性
- 关键业务 span 命名规范:`HTTP GET /api/v1/classes`otelgin 自动)
### 6.6 /healthz 检查逻辑
```go
func Healthz(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "api-gateway",
"version": Version, // 编译时注入
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
```
### 6.7 /readyz 检查逻辑(待重构)
```go
func Readyz(downstreams []string) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
var wg errgroup.Group
unhealthy := make([]string, 0)
var mu sync.Mutex
for _, url := range downstreams {
url := url
wg.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, "GET", url+"/healthz", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
mu.Lock()
unhealthy = append(unhealthy, url)
mu.Unlock()
}
if resp != nil { resp.Body.Close() }
return nil
})
}
_ = wg.Wait()
if len(unhealthy) > 0 {
c.JSON(503, gin.H{"status":"error","unhealthy":unhealthy})
return
}
c.JSON(200, gin.H{"status":"ok"})
}
}
```
**下游清单**iam / classes / teacher-bff / core-edu / content / msg / ai / data-ana 的 `/healthz`
### 6.8 优雅关闭顺序
```go
// 1. 收到 SIGTERM
// 2. srv.Shutdown(ctx) 停止接受新请求等待在途请求完成5s 超时)
// 3. tracerShutdown() flush 待发送 span
// 4. log.Println("exited")
// 5. os.Exit(0)
```
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ---------- | ---- | -------------------------------------------- | ------------------- |
| 调用 | iam | HTTP | `GET /.well-known/jwks.json` | 拉 RS256 公钥P2 |
| 透传 | 所有下游 | HTTP | `x-user-id` `x-user-roles` `x-data-scope` 头 | 用户身份传递 |
| 透传 | 所有下游 | HTTP | `X-Request-Id` `traceparent` 头 | 链路追踪 |
| 调用 | 所有下游 | HTTP | `GET /healthz` | /readyz 健康检查 |
| 被调用 | 微前端 | HTTP | `/api/v1/*` | 业务请求 |
| 被调用 | Prometheus | HTTP | `GET /metrics` | 指标采集 |
| 被调用 | K8s/Docker | HTTP | `GET /healthz` `GET /readyz` | 探针 |
## 8. 风险与假设
- **假设**iam P2 暴露 JWKS 端点若延期RS256 升级阻塞,临时保留 HS256
- **假设**:所有下游服务实现 `/healthz`;若某服务未实现,`/readyz` 误报
- **风险**单实例限流在多副本部署后失效P6 迁 Redis 解决)
- **风险**JWKS 缓存过期时若 iam 不可达fail-open 1 次后 fail-close可能导致全量 401
- **风险**DevMode 旁路误开到生产 → 启动时强制校验 `DevMode=true && ENV=production` panic
- **未决**:是否在 Gateway 层做 IP 黑名单WAF建议 P6 在 Istio 层做,本服务不介入
## 9. 实施优先级
| 优先级 | 任务 | 阶段 |
| ------ | --------------------------------------- | -------------- |
| P0 | 删除 auth.go 死代码L124-139 | 立即 |
| P0 | 修复 go.mod 与 Dockerfile Go 版本不一致 | 立即 |
| P0 | 新增 `/metrics` 端点 + prom-client | P2 |
| P0 | 引入 `log/slog` 替换标准 log | P2 |
| P1 | `/readyz` 真实健康检查 | P2 |
| P1 | JWT RS256 升级 + JWKS 缓存 | P2依赖 iam |
| P2 | 限流策略表细化per-路由) | P6 |
| P2 | 熔断 per-服务实例 | P6 |
| P3 | 补全测试覆盖率到 80% | P6 |
| P3 | DevMode 生产防护 | P2 |

View File

@@ -1,46 +1,71 @@
module github.com/edu-cloud/api-gateway module github.com/edu-cloud/api-gateway
go 1.22.0 go 1.25.0
require ( require (
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/sony/gobreaker/v2 v2.1.0 github.com/sony/gobreaker/v2 v2.1.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
) )
require ( require (
github.com/bytedance/sonic v1.11.6 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/bytedance/sonic v1.15.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/go-redsync/redsync/v4 v4.13.0 // indirect github.com/go-redsync/redsync/v4 v4.13.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/redis/go-redis/v9 v9.7.0 // indirect github.com/redis/go-redis/v9 v9.7.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
golang.org/x/arch v0.8.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
golang.org/x/crypto v0.23.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
golang.org/x/net v0.25.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
golang.org/x/sys v0.20.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect
golang.org/x/text v0.15.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -2,39 +2,48 @@ github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZp
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI= github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI=
@@ -43,17 +52,23 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkvQ1EkZKA= github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkvQ1EkZKA=
github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ= github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -61,27 +76,49 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo= github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo=
github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sony/gobreaker/v2 v2.1.0 h1:av2BnjtRmVPWBvy5gSFPytm1J8BmN5AGhq875FfGKDM= github.com/sony/gobreaker/v2 v2.1.0 h1:av2BnjtRmVPWBvy5gSFPytm1J8BmN5AGhq875FfGKDM=
github.com/sony/gobreaker/v2 v2.1.0/go.mod h1:dO3Q/nCzxZj6ICjH6J/gM0r4oAwBMVLY8YAQf+NTtUg= github.com/sony/gobreaker/v2 v2.1.0/go.mod h1:dO3Q/nCzxZj6ICjH6J/gM0r4oAwBMVLY8YAQf+NTtUg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -89,42 +126,77 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM= github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM=
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8= github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -1,10 +1,12 @@
package config package config
import ( import (
"log"
"os" "os"
"strconv" "strconv"
) )
// Config 持有 api-gateway 运行时配置
type Config struct { type Config struct {
Port string Port string
JWTSecret string JWTSecret string
@@ -23,10 +25,26 @@ type Config struct {
DevMode bool DevMode bool
} }
// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须配置 JWT_SECRET
const devJWTSecret = "p1-dev-secret-change-in-production"
// Load 从环境变量加载配置。
// 非 DevMode 下若 JWT_SECRET 未配置则 fatal 退出DevMode 下使用默认密钥并打印 warning。
func Load() *Config { func Load() *Config {
devMode := getEnvBool("DEV_MODE", false)
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" {
if devMode {
log.Println("warning: JWT_SECRET not set, using dev default (DEV_MODE=true)")
jwtSecret = devJWTSecret
} else {
log.Fatal("JWT_SECRET must be set in non-dev mode")
}
}
return &Config{ return &Config{
Port: getEnv("API_GATEWAY_PORT", "8080"), Port: getEnv("API_GATEWAY_PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "p1-dev-secret-change-in-production"), JWTSecret: jwtSecret,
JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"), JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"),
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"), JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"), ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
@@ -39,10 +57,11 @@ func Load() *Config {
AiServiceURL: getEnv("AI_SERVICE_URL", "http://localhost:3008"), AiServiceURL: getEnv("AI_SERVICE_URL", "http://localhost:3008"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"), LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: getEnvBool("DEV_MODE", false), DevMode: devMode,
} }
} }
// getEnv 读取环境变量,缺失时返回 fallback
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
@@ -50,15 +69,7 @@ func getEnv(key, fallback string) string {
return fallback return fallback
} }
func getEnvInt(key string, fallback int) int { // getEnvBool 读取环境变量并解析为 bool缺失或解析失败时返回 fallback
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return fallback
}
func getEnvBool(key string, fallback bool) bool { func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil { if b, err := strconv.ParseBool(v); err == nil {

View File

@@ -7,7 +7,6 @@ import (
"github.com/edu-cloud/api-gateway/internal/config" "github.com/edu-cloud/api-gateway/internal/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
) )
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径) // publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
@@ -73,7 +72,7 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return return
} }
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid return nil, jwt.ErrSignatureInvalid
} }
@@ -107,7 +106,7 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
if sub, ok := claims["sub"].(string); ok { if sub, ok := claims["sub"].(string); ok {
c.Request.Header.Set("x-user-id", sub) c.Request.Header.Set("x-user-id", sub)
} }
if roles, ok := claims["roles"].([]interface{}); ok { if roles, ok := claims["roles"].([]any); ok {
roleStrs := make([]string, 0, len(roles)) roleStrs := make([]string, 0, len(roles))
for _, r := range roles { for _, r := range roles {
if s, ok := r.(string); ok { if s, ok := r.(string); ok {
@@ -120,22 +119,3 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
c.Next() c.Next()
} }
} }
// RequestIDMiddleware 注入请求 ID 用于全链路追踪
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateUUID()
}
c.Set("request_id", requestID)
c.Writer.Header().Set("X-Request-ID", requestID)
c.Next()
}
}
// generateUUID 生成带 req- 前缀的唯一请求 ID
// 使用 uuid.New() 基于 RFC 4122 v4 随机 UUID避免 time.Now() 产生的冲突与可预测性
func generateUUID() string {
return "req-" + uuid.New().String()
}

View File

@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"log"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
@@ -12,22 +13,26 @@ import (
// corsMaxAge 预检缓存时长12 小时 // corsMaxAge 预检缓存时长12 小时
const corsMaxAge = 12 * 60 * 60 const corsMaxAge = 12 * 60 * 60
// devCORSOrigins 是未配置 CORS_ORIGINS 时的开发环境默认白名单
const devCORSOrigins = "http://localhost:3000,http://localhost:3001"
// CORS 返回跨域资源共享中间件。 // CORS 返回跨域资源共享中间件。
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔,默认 *)。 // 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔
// 未配置时使用开发环境白名单localhost:3000/3001并打印 warning。
// 允许方法GET POST PUT DELETE OPTIONS PATCH // 允许方法GET POST PUT DELETE OPTIONS PATCH
// 允许头Authorization Content-Type X-Request-Id X-Trace-Id // 允许头Authorization Content-Type X-Request-Id X-Trace-Id
// 暴露头X-Request-Id X-Trace-Id // 暴露头X-Request-Id X-Trace-Id
func CORS() gin.HandlerFunc { func CORS() gin.HandlerFunc {
allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS")) allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS"))
if len(allowed) == 0 {
log.Println("warning: CORS_ORIGINS not set, using dev default whitelist")
allowed = parseCORSOrigins(devCORSOrigins)
}
return func(c *gin.Context) { return func(c *gin.Context) {
origin := c.GetHeader("Origin") origin := c.GetHeader("Origin")
allowOrigin := "" allowOrigin := ""
if allowed[origin] {
if len(allowed) == 0 {
// 未配置则默认允许所有来源
allowOrigin = "*"
} else if allowed[origin] {
allowOrigin = origin allowOrigin = origin
} }
@@ -39,10 +44,8 @@ func CORS() gin.HandlerFunc {
h.Set("Access-Control-Expose-Headers", "X-Request-Id, X-Trace-Id") h.Set("Access-Control-Expose-Headers", "X-Request-Id, X-Trace-Id")
h.Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge)) h.Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge))
// 非通配来源需标注 Vary便于缓存正确区分 // 非通配来源需标注 Vary便于缓存正确区分
if allowOrigin != "*" {
h.Add("Vary", "Origin") h.Add("Vary", "Origin")
} }
}
// 预检请求直接返回 204 // 预检请求直接返回 204
if c.Request.Method == http.MethodOptions { if c.Request.Method == http.MethodOptions {
@@ -53,7 +56,7 @@ func CORS() gin.HandlerFunc {
} }
} }
// parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map(表示通配 * // parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map
func parseCORSOrigins(raw string) map[string]bool { func parseCORSOrigins(raw string) map[string]bool {
allowed := map[string]bool{} allowed := map[string]bool{}
if raw == "" { if raw == "" {

View File

@@ -0,0 +1,73 @@
package observability
import (
"context"
"log"
"net/url"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
// InitTracer 初始化 OpenTelemetry tracer.
//
// endpoint 为 "http://host:port" 格式(如 "http://localhost:4318"
// 为空时跳过初始化tracing disabled
//
// 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span.
func InitTracer(serviceName, endpoint string) func() {
if endpoint == "" {
log.Println("OTEL endpoint not set, tracing disabled")
return func() {}
}
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" {
log.Printf("invalid OTEL endpoint %q, tracing disabled", endpoint)
return func() {}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(u.Host),
otlptracehttp.WithInsecure(),
)
if err != nil {
log.Printf("failed to create OTLP exporter: %v, tracing disabled", err)
return func() {}
}
res, err := resource.New(ctx,
resource.WithAttributes(semconv.ServiceName(serviceName)),
)
if err != nil {
log.Printf("failed to create resource: %v", err)
return func() {}
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
log.Printf("OpenTelemetry tracer initialized for %s (endpoint=%s)", serviceName, u.Host)
return func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := tp.Shutdown(shutdownCtx); err != nil {
log.Printf("failed to shutdown tracer: %v", err)
}
}
}

View File

@@ -12,8 +12,11 @@ import (
"github.com/edu-cloud/api-gateway/internal/config" "github.com/edu-cloud/api-gateway/internal/config"
"github.com/edu-cloud/api-gateway/internal/health" "github.com/edu-cloud/api-gateway/internal/health"
"github.com/edu-cloud/api-gateway/internal/middleware" "github.com/edu-cloud/api-gateway/internal/middleware"
"github.com/edu-cloud/api-gateway/internal/observability"
"github.com/edu-cloud/api-gateway/internal/proxy" "github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
) )
// maxBodyBytes 请求体大小上限10MB // maxBodyBytes 请求体大小上限10MB
@@ -21,6 +24,10 @@ const maxBodyBytes int64 = 10 * 1024 * 1024
func main() { func main() {
cfg := config.Load() cfg := config.Load()
// 初始化 OpenTelemetry tracerendpoint 为空时自动跳过)
tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint)
defer tracerShutdown()
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
// 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环 // 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环
@@ -29,20 +36,24 @@ func main() {
// 全局中间件(按顺序注册) // 全局中间件(按顺序注册)
// 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic // 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic
r.Use(middleware.Recovery()) r.Use(middleware.Recovery())
// 2. 请求 ID 注入 // 2. OpenTelemetry 自动埋点HTTP 请求/响应 span
r.Use(otelgin.Middleware("api-gateway"))
// 3. 请求 ID 注入
r.Use(middleware.RequestID()) r.Use(middleware.RequestID())
// 3. 跨域 // 4. 跨域
r.Use(middleware.CORS()) r.Use(middleware.CORS())
// 4. 安全响应头 // 5. 安全响应头
r.Use(middleware.SecurityHeaders()) r.Use(middleware.SecurityHeaders())
// 5. 请求体大小限制 // 6. 请求体大小限制
r.Use(middleware.RequestBodyLimit(maxBodyBytes)) r.Use(middleware.RequestBodyLimit(maxBodyBytes))
// 6. 限流(每 IP 100 rps突发 20 // 7. 限流(每 IP 100 rps突发 20
r.Use(middleware.RateLimit(100, 20)) r.Use(middleware.RateLimit(100, 20))
// 健康检查路由(无需鉴权,在 Auth 之前) // 健康检查路由(无需鉴权,在 Auth 之前)
r.GET("/healthz", health.Healthz) r.GET("/healthz", health.Healthz)
r.GET("/readyz", health.Readyz) r.GET("/readyz", health.Readyz)
// Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// API v1 组:熔断 + 鉴权 + 反向代理 // API v1 组:熔断 + 鉴权 + 反向代理
api := r.Group("/api/v1") api := r.Group("/api/v1")

View File

@@ -1,7 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { ClassesModule } from './classes/classes.module.js'; import { APP_GUARD } from "@nestjs/core";
import { ClassesModule } from "./classes/classes.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({ @Module({
imports: [ClassesModule], imports: [ClassesModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -7,11 +7,15 @@ import {
Post, Post,
Put, Put,
Req, Req,
} from '@nestjs/common'; } from "@nestjs/common";
import { ClassesService } from './classes.service.js'; import { ClassesService } from "./classes.service.js";
import { createClassSchema, updateClassSchema } from './classes.dto.js'; import { createClassSchema, updateClassSchema } from "./classes.dto.js";
import type { AuthenticatedRequest } from '../middleware/auth.middleware.js'; import {
import type { Class } from './classes.schema.js'; Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import type { Class } from "./classes.schema.js";
interface ClassResponse { interface ClassResponse {
id: string; id: string;
@@ -28,11 +32,12 @@ interface SuccessResponse<T> {
data: T; data: T;
} }
@Controller('classes') @Controller("classes")
export class ClassesController { export class ClassesController {
constructor(private readonly service: ClassesService) {} constructor(private readonly service: ClassesService) {}
@Post() @Post()
@RequirePermission(Permissions.CLASSES_CREATE)
async create(@Body() body: unknown): Promise<SuccessResponse<ClassResponse>> { async create(@Body() body: unknown): Promise<SuccessResponse<ClassResponse>> {
const dto = createClassSchema.parse(body); const dto = createClassSchema.parse(body);
const result = await this.service.create(dto); const result = await this.service.create(dto);
@@ -40,21 +45,32 @@ export class ClassesController {
} }
@Get() @Get()
async list(@Req() req: AuthenticatedRequest): Promise<SuccessResponse<ClassResponse[]>> { @RequirePermission(Permissions.CLASSES_READ)
const gradeId = req.query.gradeId as string | undefined; async list(
@Req() req: AuthenticatedRequest,
): Promise<SuccessResponse<ClassResponse[]>> {
const gradeIdRaw = req.query.gradeId;
const gradeId = typeof gradeIdRaw === "string" ? gradeIdRaw : undefined;
const result = await this.service.list(gradeId); const result = await this.service.list(gradeId);
return { success: true as const, data: result.map((c) => this.toResponse(c)) }; return {
success: true as const,
data: result.map((c) => this.toResponse(c)),
};
} }
@Get(':id') @Get(":id")
async getById(@Param('id') id: string): Promise<SuccessResponse<ClassResponse>> { @RequirePermission(Permissions.CLASSES_READ)
async getById(
@Param("id") id: string,
): Promise<SuccessResponse<ClassResponse>> {
const result = await this.service.getById(id); const result = await this.service.getById(id);
return { success: true as const, data: this.toResponse(result) }; return { success: true as const, data: this.toResponse(result) };
} }
@Put(':id') @Put(":id")
@RequirePermission(Permissions.CLASSES_UPDATE)
async update( async update(
@Param('id') id: string, @Param("id") id: string,
@Body() body: unknown, @Body() body: unknown,
): Promise<SuccessResponse<ClassResponse>> { ): Promise<SuccessResponse<ClassResponse>> {
const dto = updateClassSchema.parse(body); const dto = updateClassSchema.parse(body);
@@ -62,8 +78,9 @@ export class ClassesController {
return { success: true as const, data: this.toResponse(result) }; return { success: true as const, data: this.toResponse(result) };
} }
@Delete(':id') @Delete(":id")
async delete(@Param('id') id: string): Promise<{ success: true }> { @RequirePermission(Permissions.CLASSES_DELETE)
async delete(@Param("id") id: string): Promise<{ success: true }> {
await this.service.delete(id); await this.service.delete(id);
return { success: true as const }; return { success: true as const };
} }

View File

@@ -1,14 +1,18 @@
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import { getDb } from '../config/database.js'; import { getDb } from "../config/database.js";
import { classes, type Class, type NewClass } from './classes.schema.js'; import { classes, type Class, type NewClass } from "./classes.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js";
export class ClassesRepository { export class ClassesRepository {
async create(data: NewClass): Promise<Class> { async create(data: NewClass): Promise<Class> {
const db = getDb(); const db = getDb();
await db.insert(classes).values(data); await db.insert(classes).values(data);
const [result] = await db.select().from(classes).where(eq(classes.id, data.id)); const [result] = await db
.select()
.from(classes)
.where(eq(classes.id, data.id));
if (!result) { if (!result) {
throw new Error('Failed to read created class'); throw new DatabaseError("Failed to read created class");
} }
return result; return result;
} }
@@ -27,7 +31,10 @@ export class ClassesRepository {
return db.select().from(classes); return db.select().from(classes);
} }
async update(id: string, data: Partial<NewClass>): Promise<Class | undefined> { async update(
id: string,
data: Partial<NewClass>,
): Promise<Class | undefined> {
const db = getDb(); const db = getDb();
await db.update(classes).set(data).where(eq(classes.id, id)); await db.update(classes).set(data).where(eq(classes.id, id));
const [result] = await db.select().from(classes).where(eq(classes.id, id)); const [result] = await db.select().from(classes).where(eq(classes.id, id));

View File

@@ -1,10 +1,11 @@
import { drizzle } from 'drizzle-orm/mysql2'; import { drizzle } from "drizzle-orm/mysql2";
import mysql from 'mysql2/promise'; import type { MySql2Database } from "drizzle-orm/mysql2";
import { env } from './env.js'; import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null; let pool: mysql.Pool | null = null;
export function getDb() { export function getDb(): MySql2Database {
if (!pool) { if (!pool) {
pool = mysql.createPool({ pool = mysql.createPool({
uri: env.DATABASE_URL, uri: env.DATABASE_URL,

View File

@@ -6,6 +6,7 @@ import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -19,7 +20,7 @@ async function bootstrap(): Promise<void> {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 // 返回 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.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });

View File

@@ -1,5 +1,9 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; import {
import { Request, Response, NextFunction } from 'express'; Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
@@ -10,15 +14,18 @@ export interface AuthenticatedRequest extends Request {
export class AuthMiddleware implements NestMiddleware { export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void { use(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
// 从 Gateway 注入的头部读取用户信息 // 从 Gateway 注入的头部读取用户信息
const userId = req.headers['x-user-id'] as string | undefined; const userIdHeader = req.headers["x-user-id"];
const rolesHeader = req.headers['x-user-roles'] as string | undefined; const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedException('Missing x-user-id header'); throw new UnauthorizedException("Missing x-user-id header");
} }
req.userId = userId; req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(',') : []; req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next(); next();
} }
} }

View File

@@ -1,50 +1,71 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import {
import type { Reflector } from '@nestjs/core'; Injectable,
import { PermissionDeniedError } from '../shared/errors/application-error.js'; CanActivate,
import type { AuthenticatedRequest } from './auth.middleware.js'; 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 type Permission = export type Permission =
| 'CLASS_CREATE' "CLASSES_CREATE" | "CLASSES_READ" | "CLASSES_UPDATE" | "CLASSES_DELETE";
| 'CLASS_READ'
| 'CLASS_UPDATE'
| 'CLASS_DELETE';
export const Permissions = { export const Permissions = {
CLASS_CREATE: 'CLASS_CREATE' as const, CLASSES_CREATE: "CLASSES_CREATE" as const,
CLASS_READ: 'CLASS_READ' as const, CLASSES_READ: "CLASSES_READ" as const,
CLASS_UPDATE: 'CLASS_UPDATE' as const, CLASSES_UPDATE: "CLASSES_UPDATE" as const,
CLASS_DELETE: 'CLASS_DELETE' as const, CLASSES_DELETE: "CLASSES_DELETE" as const,
}; };
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = { const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE], admin: [
teacher: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE], Permissions.CLASSES_CREATE,
student: [Permissions.CLASS_READ], Permissions.CLASSES_READ,
parent: [Permissions.CLASS_READ], Permissions.CLASSES_UPDATE,
Permissions.CLASSES_DELETE,
],
teacher: [
Permissions.CLASSES_CREATE,
Permissions.CLASSES_READ,
Permissions.CLASSES_UPDATE,
],
student: [Permissions.CLASSES_READ],
parent: [Permissions.CLASSES_READ],
}; };
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {} constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { 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 request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? []; const roles = request.userRoles ?? [];
for (const role of roles) { for (const role of roles) {
const perms = ROLE_PERMISSIONS[role]; const perms = ROLE_PERMISSIONS[role];
if (perms && perms.includes(this.requiredPermission)) { if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true; return true;
} }
} }
throw new PermissionDeniedError(this.requiredPermission); throw new PermissionDeniedError(requiredPermissions.join(", "));
} }
} }
// 工厂函数用于装饰器修复import Reflector 已移至文件顶部)
export function createPermissionGuardFactory(_reflector: Reflector) {
return {
create: (permission: Permission) => new PermissionGuard(permission),
};
}

View File

@@ -1,7 +1,13 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common'; import {
import { Request, Response } from 'express'; Catch,
import { ZodError } from 'zod'; ExceptionFilter,
import { ApplicationError } from './application-error.js'; ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch() @Catch()
export class GlobalErrorFilter implements ExceptionFilter { export class GlobalErrorFilter implements ExceptionFilter {
@@ -12,7 +18,9 @@ export class GlobalErrorFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown'; const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500; let statusCode = 500;
let body: Record<string, unknown>; let body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'CLASSES_VALIDATION_ERROR', code: "CLASSES_VALIDATION_ERROR",
message: 'Validation failed', message: "Validation failed",
details: exception.flatten(), details: exception.flatten(),
traceId, traceId,
}, },
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'HTTP_ERROR', code: "HTTP_ERROR",
message, message,
traceId, traceId,
}, },
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'INTERNAL_ERROR', code: "INTERNAL_ERROR",
message: 'An unexpected error occurred', message: "An unexpected error occurred",
traceId, traceId,
}, },
}; };
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body); response.status(statusCode).json(body);
} }
private extractHttpMessage(res: string | object, exception: HttpException): string { private extractHttpMessage(
if (typeof res === 'string') { res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res; return res;
} }
if (res && typeof res === 'object' && 'message' in res) { if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段) // 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message; const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message; return typeof msg === "string" ? msg : exception.message;
} }
return exception.message; return exception.message;
} }

View File

@@ -1,7 +1,8 @@
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common'; import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { DataSource } from 'typeorm'; import { sql } from "drizzle-orm";
import { getDb } from "../../config/database.js";
const SERVICE_NAME = 'classes'; const SERVICE_NAME = "classes";
/** /**
* 健康检查端点。 * 健康检查端点。
@@ -14,33 +15,37 @@ const SERVICE_NAME = 'classes';
*/ */
@Controller() @Controller()
export class HealthController { export class HealthController {
constructor(private readonly dataSource: DataSource) {} @Get("healthz")
@Get('healthz')
liveness(): { status: string; service: string; timestamp: string } { liveness(): { status: string; service: string; timestamp: string } {
return { return {
status: 'ok', status: "ok",
service: SERVICE_NAME, service: SERVICE_NAME,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }
@Get('readyz') @Get("readyz")
async readiness(): Promise<{ status: string; service: string; timestamp: string }> { async readiness(): Promise<{
status: string;
service: string;
timestamp: string;
}> {
try { try {
await this.dataSource.query('SELECT 1'); const db = getDb();
await db.execute(sql`SELECT 1`);
return { return {
status: 'ok', status: "ok",
service: SERVICE_NAME, service: SERVICE_NAME,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} catch (error) { } catch (error) {
throw new HttpException( throw new HttpException(
{ {
status: 'error', status: "error",
service: SERVICE_NAME, service: SERVICE_NAME,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'database unreachable', error:
error instanceof Error ? error.message : "database unreachable",
}, },
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
); );

View File

@@ -1,9 +1,12 @@
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; import {
import { DataSource } from 'typeorm'; Injectable,
import type { Redis } from 'ioredis'; Logger,
import type { Producer } from 'kafkajs'; OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
const SERVICE_NAME = 'classes'; const SERVICE_NAME = "classes";
/** /**
* 优雅停机服务。 * 优雅停机服务。
@@ -12,52 +15,31 @@ const SERVICE_NAME = 'classes';
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。 * 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。 * K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
* *
* 关闭顺序Kafka producer → Redis → DataSource * Classes 服务仅使用 Drizzle ORMMySQL无 Kafka / Redis 依赖
* 先停外部消息生产避免新事件,再关缓存,最后关 DB * 关闭时仅需关闭数据库连接池
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
*
* 依赖注入 token 约定(需与各服务 provider 注册一致):
* - DataSource由 `TypeOrmModule.forRoot()` 提供。
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
*/ */
@Injectable() @Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown { export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name); private readonly logger = new Logger(LifecycleService.name);
constructor(
private readonly dataSource: DataSource,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
) {}
onModuleInit(): void { onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`); this.logger.log(`service ${SERVICE_NAME} module initialized`);
} }
async onApplicationShutdown(signal?: string): Promise<void> { async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log( this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`, `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
); );
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect()); try {
await this.safeDisconnect('redis', () => this.redis.quit()); await closeDb();
await this.safeDisconnect('datasource', () => this.dataSource.destroy()); this.logger.log("database connection closed");
} catch (error) {
this.logger.error(
`database close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.logger.log(`service ${SERVICE_NAME} shutdown complete`); this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
} }
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
this.logger.log(`${name} closed`);
} catch (error) {
this.logger.error(
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
} }

View File

@@ -1,6 +1,7 @@
import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { env } from '../../config/env.js'; import { NodeSDK } from "@opentelemetry/sdk-node";
import { env } from "../../config/env.js";
let sdk: NodeSDK | null = null; let sdk: NodeSDK | null = null;
@@ -8,14 +9,15 @@ export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return; if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({ sdk = new NodeSDK({
serviceName: 'classes', serviceName: "classes",
traceExporter: new OTLPTraceExporter({ traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`, url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}), }),
instrumentations: [getNodeAutoInstrumentations()],
}); });
sdk.start(); sdk.start();
console.log('Tracer initialized'); console.log("Tracer initialized with auto-instrumentations");
} }
export async function shutdownTracer(): Promise<void> { export async function shutdownTracer(): Promise<void> {

View File

@@ -8,6 +8,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"incremental": false,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],

View File

@@ -26,10 +26,12 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"@opentelemetry/sdk-node": "^0.55.0", "@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0" "@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/auto-instrumentations-node": "^0.55.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"

View File

@@ -1,9 +1,12 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { TextbooksModule } from "./textbooks/textbooks.module.js"; import { TextbooksModule } from "./textbooks/textbooks.module.js";
import { ChaptersModule } from "./chapters/chapters.module.js"; import { ChaptersModule } from "./chapters/chapters.module.js";
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js"; import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
import { QuestionsModule } from "./questions/questions.module.js"; import { QuestionsModule } from "./questions/questions.module.js";
import { HealthModule } from "./shared/health/health.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({ @Module({
imports: [ imports: [
@@ -13,5 +16,9 @@ import { HealthModule } from "./shared/health/health.module.js";
QuestionsModule, QuestionsModule,
HealthModule, HealthModule,
], ],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -13,12 +13,17 @@ import {
type UpdateChapterInput, type UpdateChapterInput,
} from "./chapters.service.js"; } from "./chapters.service.js";
import type { Chapter } from "./chapters.schema.js"; import type { Chapter } from "./chapters.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("chapters") @Controller("chapters")
export class ChaptersController { export class ChaptersController {
constructor(private readonly service: ChaptersService) {} constructor(private readonly service: ChaptersService) {}
@Post() @Post()
@RequirePermission(Permissions.CONTENT_CHAPTER_CREATE)
async create( async create(
@Body() body: CreateChapterInput, @Body() body: CreateChapterInput,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
@@ -27,6 +32,7 @@ export class ChaptersController {
} }
@Get("textbook/:textbookId") @Get("textbook/:textbookId")
@RequirePermission(Permissions.CONTENT_CHAPTER_READ)
async listByTextbook( async listByTextbook(
@Param("textbookId") textbookId: string, @Param("textbookId") textbookId: string,
): Promise<{ success: true; data: Chapter[] }> { ): Promise<{ success: true; data: Chapter[] }> {
@@ -35,6 +41,7 @@ export class ChaptersController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_READ)
async getById( async getById(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: Chapter }> { ): Promise<{ success: true; data: Chapter }> {
@@ -43,6 +50,7 @@ export class ChaptersController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateChapterInput, @Body() body: UpdateChapterInput,
@@ -52,6 +60,7 @@ export class ChaptersController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -14,12 +14,17 @@ import {
type PrerequisiteNode, type PrerequisiteNode,
} from "./knowledge-points.service.js"; } from "./knowledge-points.service.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js"; import type { KnowledgePoint } from "./knowledge-points.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("knowledge-points") @Controller("knowledge-points")
export class KnowledgePointsController { export class KnowledgePointsController {
constructor(private readonly service: KnowledgePointsService) {} constructor(private readonly service: KnowledgePointsService) {}
@Post() @Post()
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE)
async create( async create(
@Body() body: CreateKnowledgePointInput, @Body() body: CreateKnowledgePointInput,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
@@ -28,6 +33,7 @@ export class KnowledgePointsController {
} }
@Get("chapter/:chapterId") @Get("chapter/:chapterId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async listByChapter( async listByChapter(
@Param("chapterId") chapterId: string, @Param("chapterId") chapterId: string,
): Promise<{ success: true; data: KnowledgePoint[] }> { ): Promise<{ success: true; data: KnowledgePoint[] }> {
@@ -36,6 +42,7 @@ export class KnowledgePointsController {
} }
@Get(":id/prerequisites") @Get(":id/prerequisites")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async getPrerequisites( async getPrerequisites(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: PrerequisiteNode[] }> { ): Promise<{ success: true; data: PrerequisiteNode[] }> {
@@ -44,6 +51,7 @@ export class KnowledgePointsController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async getById( async getById(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: KnowledgePoint }> { ): Promise<{ success: true; data: KnowledgePoint }> {
@@ -52,6 +60,7 @@ export class KnowledgePointsController {
} }
@Post(":id/prerequisites/:prerequisiteId") @Post(":id/prerequisites/:prerequisiteId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async addPrerequisite( async addPrerequisite(
@Param("id") id: string, @Param("id") id: string,
@Param("prerequisiteId") prerequisiteId: string, @Param("prerequisiteId") prerequisiteId: string,
@@ -61,6 +70,7 @@ export class KnowledgePointsController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateKnowledgePointInput, @Body() body: UpdateKnowledgePointInput,
@@ -70,6 +80,7 @@ export class KnowledgePointsController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -8,6 +8,7 @@ import { closeDb } from "./config/database.js";
import { closeNeo4j } from "./config/neo4j.js"; import { closeNeo4j } from "./config/neo4j.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -20,8 +21,7 @@ async function bootstrap(): Promise<void> {
app.enableShutdownHooks(); app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", metricsRegistry.contentType); res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });

View File

@@ -0,0 +1,30 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
userRoles?: string[];
}
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -0,0 +1,105 @@
import {
Injectable,
CanActivate,
ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = {
CONTENT_TEXTBOOK_CREATE: "CONTENT_TEXTBOOK_CREATE" as const,
CONTENT_TEXTBOOK_READ: "CONTENT_TEXTBOOK_READ" as const,
CONTENT_TEXTBOOK_UPDATE: "CONTENT_TEXTBOOK_UPDATE" as const,
CONTENT_TEXTBOOK_DELETE: "CONTENT_TEXTBOOK_DELETE" as const,
CONTENT_CHAPTER_CREATE: "CONTENT_CHAPTER_CREATE" as const,
CONTENT_CHAPTER_READ: "CONTENT_CHAPTER_READ" as const,
CONTENT_CHAPTER_UPDATE: "CONTENT_CHAPTER_UPDATE" as const,
CONTENT_CHAPTER_DELETE: "CONTENT_CHAPTER_DELETE" as const,
CONTENT_QUESTION_CREATE: "CONTENT_QUESTION_CREATE" as const,
CONTENT_QUESTION_READ: "CONTENT_QUESTION_READ" as const,
CONTENT_QUESTION_UPDATE: "CONTENT_QUESTION_UPDATE" as const,
CONTENT_QUESTION_DELETE: "CONTENT_QUESTION_DELETE" as const,
CONTENT_KNOWLEDGE_POINT_CREATE: "CONTENT_KNOWLEDGE_POINT_CREATE" as const,
CONTENT_KNOWLEDGE_POINT_READ: "CONTENT_KNOWLEDGE_POINT_READ" as const,
CONTENT_KNOWLEDGE_POINT_UPDATE: "CONTENT_KNOWLEDGE_POINT_UPDATE" as const,
CONTENT_KNOWLEDGE_POINT_DELETE: "CONTENT_KNOWLEDGE_POINT_DELETE" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.CONTENT_TEXTBOOK_CREATE,
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_TEXTBOOK_UPDATE,
Permissions.CONTENT_TEXTBOOK_DELETE,
Permissions.CONTENT_CHAPTER_CREATE,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_CHAPTER_UPDATE,
Permissions.CONTENT_CHAPTER_DELETE,
Permissions.CONTENT_QUESTION_CREATE,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_QUESTION_UPDATE,
Permissions.CONTENT_QUESTION_DELETE,
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
Permissions.CONTENT_KNOWLEDGE_POINT_DELETE,
],
teacher: [
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_CHAPTER_CREATE,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_CHAPTER_UPDATE,
Permissions.CONTENT_QUESTION_CREATE,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_QUESTION_UPDATE,
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
],
student: [
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
],
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}

View File

@@ -13,12 +13,17 @@ import {
type UpdateQuestionInput, type UpdateQuestionInput,
} from "./questions.service.js"; } from "./questions.service.js";
import type { Question } from "./questions.schema.js"; import type { Question } from "./questions.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("questions") @Controller("questions")
export class QuestionsController { export class QuestionsController {
constructor(private readonly service: QuestionsService) {} constructor(private readonly service: QuestionsService) {}
@Post() @Post()
@RequirePermission(Permissions.CONTENT_QUESTION_CREATE)
async create( async create(
@Body() body: CreateQuestionInput, @Body() body: CreateQuestionInput,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
@@ -27,6 +32,7 @@ export class QuestionsController {
} }
@Get("knowledge-point/:knowledgePointId") @Get("knowledge-point/:knowledgePointId")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async listByKnowledgePoint( async listByKnowledgePoint(
@Param("knowledgePointId") knowledgePointId: string, @Param("knowledgePointId") knowledgePointId: string,
): Promise<{ success: true; data: Question[] }> { ): Promise<{ success: true; data: Question[] }> {
@@ -35,6 +41,7 @@ export class QuestionsController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async getById( async getById(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: Question }> { ): Promise<{ success: true; data: Question }> {
@@ -43,6 +50,7 @@ export class QuestionsController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateQuestionInput, @Body() body: UpdateQuestionInput,
@@ -52,6 +60,7 @@ export class QuestionsController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -5,6 +5,7 @@ import {
HttpException, HttpException,
Logger, Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js"; import { ApplicationError } from "./application-error.js";
@@ -14,13 +15,12 @@ export class GlobalErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例, const response = ctx.getResponse<Response>();
// 但 content 服务未引入 @types/express此处按 core-edu 模式不显式标注类型。 const request = ctx.getRequest<Request>();
const response = ctx.getResponse();
const request = ctx.getRequest();
const traceIdHeader = request.headers["x-request-id"];
const traceId = const traceId =
(request.headers["x-request-id"] as string | undefined) ?? "unknown"; typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500; let statusCode = 500;
let body: Record<string, unknown>; let body: Record<string, unknown>;
@@ -30,7 +30,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
statusCode = exception.statusCode; statusCode = exception.statusCode;
body = exception.toJSON(); body = exception.toJSON();
} else if (exception instanceof ZodError) { } else if (exception instanceof ZodError) {
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
statusCode = 400; statusCode = 400;
body = { body = {
success: false, success: false,
@@ -79,7 +78,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
return res; return res;
} }
if (res && typeof res === "object" && "message" in res) { if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message; const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message; return typeof msg === "string" ? msg : exception.message;
} }

View File

@@ -1,13 +1,22 @@
import { Injectable, Logger } from "@nestjs/common"; import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js"; import { closeDb } from "../../config/database.js";
import { closeNeo4j } from "../../config/neo4j.js"; import { closeNeo4j } from "../../config/neo4j.js";
const SERVICE_NAME = "content"; const SERVICE_NAME = "content";
@Injectable() @Injectable()
export class LifecycleService { export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name); private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> { async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log( this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`, `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,

View File

@@ -1,6 +1,7 @@
import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { env } from '../../config/env.js'; import { NodeSDK } from "@opentelemetry/sdk-node";
import { env } from "../../config/env.js";
let sdk: NodeSDK | null = null; let sdk: NodeSDK | null = null;
@@ -8,14 +9,15 @@ export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return; if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({ sdk = new NodeSDK({
serviceName: 'content', serviceName: "content",
traceExporter: new OTLPTraceExporter({ traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`, url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}), }),
instrumentations: [getNodeAutoInstrumentations()],
}); });
sdk.start(); sdk.start();
console.log('Tracer initialized'); console.log("Tracer initialized with auto-instrumentations");
} }
export async function shutdownTracer(): Promise<void> { export async function shutdownTracer(): Promise<void> {

View File

@@ -13,12 +13,17 @@ import {
type UpdateTextbookInput, type UpdateTextbookInput,
} from "./textbooks.service.js"; } from "./textbooks.service.js";
import type { Textbook } from "./textbooks.schema.js"; import type { Textbook } from "./textbooks.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("textbooks") @Controller("textbooks")
export class TextbooksController { export class TextbooksController {
constructor(private readonly service: TextbooksService) {} constructor(private readonly service: TextbooksService) {}
@Post() @Post()
@RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE)
async create( async create(
@Body() body: CreateTextbookInput, @Body() body: CreateTextbookInput,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
@@ -27,12 +32,14 @@ export class TextbooksController {
} }
@Get() @Get()
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
async list(): Promise<{ success: true; data: Textbook[] }> { async list(): Promise<{ success: true; data: Textbook[] }> {
const data = await this.service.list(); const data = await this.service.list();
return { success: true, data }; return { success: true, data };
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
async getById( async getById(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: Textbook }> { ): Promise<{ success: true; data: Textbook }> {
@@ -41,6 +48,7 @@ export class TextbooksController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateTextbookInput, @Body() body: UpdateTextbookInput,
@@ -50,6 +58,7 @@ export class TextbooksController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -8,6 +8,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"incremental": false,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],

View File

@@ -21,6 +21,7 @@
"pino": "^9.4.0", "pino": "^9.4.0",
"prom-client": "^15.1.0", "prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/sdk-node": "^0.53.0", "@opentelemetry/sdk-node": "^0.53.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0", "@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"zod": "^3.23.0", "zod": "^3.23.0",

View File

@@ -1,10 +1,17 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ExamsModule } from "./exams/exams.module.js"; import { ExamsModule } from "./exams/exams.module.js";
import { HomeworkModule } from "./homework/homework.module.js"; import { HomeworkModule } from "./homework/homework.module.js";
import { GradesModule } from "./grades/grades.module.js"; import { GradesModule } from "./grades/grades.module.js";
import { HealthModule } from "./shared/health/health.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({ @Module({
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule], imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -8,23 +8,32 @@ import {
Put, Put,
Req, Req,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Request } from "express";
import { import {
ExamsService, ExamsService,
type CreateExamInput, type CreateExamInput,
type UpdateExamInput, type UpdateExamInput,
} from "./exams.service.js"; } from "./exams.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("exams") @Controller("exams")
export class ExamsController { export class ExamsController {
constructor(private readonly examsService: ExamsService) {} constructor(private readonly examsService: ExamsService) {}
@Post() @Post()
@RequirePermission(Permissions.EXAM_CREATE)
async create( async create(
@Body() body: CreateExamInput, @Body() body: CreateExamInput,
@Req() req: Request, @Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string; const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.examsService.createExam({ const result = await this.examsService.createExam({
...body, ...body,
createdBy: userId, createdBy: userId,
@@ -33,6 +42,7 @@ export class ExamsController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.EXAM_READ)
async findOne(@Param("id") id: string): Promise<{ async findOne(@Param("id") id: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<ExamsService["getExam"]>>; data: Awaited<ReturnType<ExamsService["getExam"]>>;
@@ -42,6 +52,7 @@ export class ExamsController {
} }
@Get("class/:classId") @Get("class/:classId")
@RequirePermission(Permissions.EXAM_READ)
async listByClass(@Param("classId") classId: string): Promise<{ async listByClass(@Param("classId") classId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>; data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>;
@@ -51,6 +62,7 @@ export class ExamsController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.EXAM_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateExamInput, @Body() body: UpdateExamInput,
@@ -60,6 +72,7 @@ export class ExamsController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.EXAM_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -1,17 +1,26 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { GradesService, type RecordGradeInput } from "./grades.service.js"; import { GradesService, type RecordGradeInput } from "./grades.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("grades") @Controller("grades")
export class GradesController { export class GradesController {
constructor(private readonly gradesService: GradesService) {} constructor(private readonly gradesService: GradesService) {}
@Post() @Post()
@RequirePermission(Permissions.GRADE_CREATE)
async record( async record(
@Body() body: RecordGradeInput, @Body() body: RecordGradeInput,
@Req() req: Request, @Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string; const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.gradesService.recordGrade({ const result = await this.gradesService.recordGrade({
...body, ...body,
gradedBy: userId, gradedBy: userId,
@@ -20,6 +29,7 @@ export class GradesController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.GRADE_READ)
async findOne(@Param("id") id: string): Promise<{ async findOne(@Param("id") id: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<GradesService["getGrade"]>>; data: Awaited<ReturnType<GradesService["getGrade"]>>;
@@ -29,6 +39,7 @@ export class GradesController {
} }
@Get("student/:studentId") @Get("student/:studentId")
@RequirePermission(Permissions.GRADE_READ)
async listByStudent(@Param("studentId") studentId: string): Promise<{ async listByStudent(@Param("studentId") studentId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<GradesService["listByStudent"]>>; data: Awaited<ReturnType<GradesService["listByStudent"]>>;
@@ -38,6 +49,7 @@ export class GradesController {
} }
@Get("exam/:examId") @Get("exam/:examId")
@RequirePermission(Permissions.GRADE_READ)
async listByExam(@Param("examId") examId: string): Promise<{ async listByExam(@Param("examId") examId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<GradesService["listByExam"]>>; data: Awaited<ReturnType<GradesService["listByExam"]>>;
@@ -47,6 +59,7 @@ export class GradesController {
} }
@Get("homework/:homeworkId") @Get("homework/:homeworkId")
@RequirePermission(Permissions.GRADE_READ)
async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{ async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<GradesService["listByHomework"]>>; data: Awaited<ReturnType<GradesService["listByHomework"]>>;

View File

@@ -1,20 +1,29 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { import {
HomeworkService, HomeworkService,
type AssignHomeworkInput, type AssignHomeworkInput,
} from "./homework.service.js"; } from "./homework.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("homework") @Controller("homework")
export class HomeworkController { export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {} constructor(private readonly homeworkService: HomeworkService) {}
@Post() @Post()
@RequirePermission(Permissions.HOMEWORK_CREATE)
async assign( async assign(
@Body() body: AssignHomeworkInput, @Body() body: AssignHomeworkInput,
@Req() req: Request, @Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string; const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.homeworkService.assignHomework({ const result = await this.homeworkService.assignHomework({
...body, ...body,
createdBy: userId, createdBy: userId,
@@ -23,6 +32,7 @@ export class HomeworkController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.HOMEWORK_READ)
async findOne(@Param("id") id: string): Promise<{ async findOne(@Param("id") id: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<HomeworkService["getHomework"]>>; data: Awaited<ReturnType<HomeworkService["getHomework"]>>;
@@ -32,6 +42,7 @@ export class HomeworkController {
} }
@Get("class/:classId") @Get("class/:classId")
@RequirePermission(Permissions.HOMEWORK_READ)
async listByClass(@Param("classId") classId: string): Promise<{ async listByClass(@Param("classId") classId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<HomeworkService["listByClass"]>>; data: Awaited<ReturnType<HomeworkService["listByClass"]>>;
@@ -41,6 +52,7 @@ export class HomeworkController {
} }
@Post(":id/submit") @Post(":id/submit")
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
async submit( async submit(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -7,6 +7,7 @@ import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js"; import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { registry } from "./shared/observability/metrics.js"; import { registry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -16,8 +17,7 @@ async function bootstrap(): Promise<void> {
app.enableShutdownHooks(); app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", registry.contentType); res.set("Content-Type", registry.contentType);
res.end(await registry.metrics()); res.end(await registry.metrics());
}); });

View File

@@ -2,39 +2,29 @@ import {
Injectable, Injectable,
NestMiddleware, NestMiddleware,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from "@nestjs/common";
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedUser {
id: string;
role: string;
permissions: string[];
}
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser; userId?: string;
userRoles?: string[];
} }
@Injectable() @Injectable()
export class AuthMiddleware implements NestMiddleware { export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void { use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userId = req.headers['x-user-id'] as string | undefined; const userIdHeader = req.headers["x-user-id"];
const role = req.headers['x-user-role'] as string | undefined; const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const permissionsHeader = req.headers['x-user-permissions'] as const rolesHeaderRaw = req.headers["x-user-roles"];
| string const rolesHeader =
| undefined; typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId || !role) { if (!userId) {
throw new UnauthorizedException( throw new UnauthorizedException("Missing x-user-id header");
'Missing authentication headers (x-user-id, x-user-role)',
);
} }
req.user = { req.userId = userId;
id: userId, req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
role,
permissions: permissionsHeader ? permissionsHeader.split(',') : [],
};
next(); next();
} }
} }

View File

@@ -1,48 +1,92 @@
import { import {
Injectable,
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
Injectable, SetMetadata,
ForbiddenException, } from "@nestjs/common";
} from '@nestjs/common'; import { Reflector } from "@nestjs/core";
import type { AuthenticatedRequest } from './auth.middleware.js'; import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = { export const Permissions = {
EXAM_CREATE: 'exam:create', EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const,
EXAM_READ: 'exam:read', EXAM_READ: "CORE_EDU_EXAM_READ" as const,
EXAM_UPDATE: 'exam:update', EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const,
EXAM_DELETE: 'exam:delete', EXAM_DELETE: "CORE_EDU_EXAM_DELETE" as const,
HOMEWORK_CREATE: 'homework:create', HOMEWORK_CREATE: "CORE_EDU_HOMEWORK_CREATE" as const,
HOMEWORK_READ: 'homework:read', HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const,
HOMEWORK_UPDATE: 'homework:update', HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const,
HOMEWORK_DELETE: 'homework:delete', HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT" as const,
HOMEWORK_GRADE: 'homework:grade', GRADE_CREATE: "CORE_EDU_GRADE_CREATE" as const,
HOMEWORK_SUBMIT: 'homework:submit', GRADE_READ: "CORE_EDU_GRADE_READ" as const,
GRADE_CREATE: 'grade:create',
GRADE_READ: 'grade:read',
GRADE_UPDATE: 'grade:update',
GRADE_DELETE: 'grade:delete',
CLASS_MANAGE: 'class:manage',
CLASS_READ: 'class:read',
CLASS_TRANSFER: 'class:transfer',
} as const; } as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions]; 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.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_UPDATE,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_CREATE,
Permissions.GRADE_READ,
],
teacher: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_UPDATE,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_CREATE,
Permissions.GRADE_READ,
],
student: [
Permissions.EXAM_READ,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_READ,
],
};
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {} constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); if (process.env.DEV_MODE === "true") {
const user = request.user; return true;
if (!user) {
throw new ForbiddenException('User not authenticated');
} }
if (!user.permissions.includes(this.requiredPermission)) {
throw new ForbiddenException( const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
`Missing permission: ${this.requiredPermission}`, 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; return true;
} }
} }
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}

View File

@@ -1,25 +1,45 @@
export enum CoreEduErrorCode { export enum CoreEduErrorCode {
VALIDATION_ERROR = 'CORE_EDU_VALIDATION_ERROR', VALIDATION_ERROR = "CORE_EDU_VALIDATION_ERROR",
NOT_FOUND = 'CORE_EDU_NOT_FOUND', NOT_FOUND = "CORE_EDU_NOT_FOUND",
UNAUTHORIZED = 'CORE_EDU_UNAUTHORIZED', UNAUTHORIZED = "CORE_EDU_UNAUTHORIZED",
FORBIDDEN = 'CORE_EDU_FORBIDDEN', FORBIDDEN = "CORE_EDU_FORBIDDEN",
CONFLICT = 'CORE_EDU_CONFLICT', CONFLICT = "CORE_EDU_CONFLICT",
INTERNAL_ERROR = 'CORE_EDU_INTERNAL_ERROR', INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR",
EXAM_NOT_FOUND = 'CORE_EDU_EXAM_NOT_FOUND', EXAM_NOT_FOUND = "CORE_EDU_EXAM_NOT_FOUND",
HOMEWORK_NOT_FOUND = 'CORE_EDU_HOMEWORK_NOT_FOUND', HOMEWORK_NOT_FOUND = "CORE_EDU_HOMEWORK_NOT_FOUND",
GRADE_NOT_FOUND = 'CORE_EDU_GRADE_NOT_FOUND', GRADE_NOT_FOUND = "CORE_EDU_GRADE_NOT_FOUND",
OUTBOX_PUBLISH_FAILED = 'CORE_EDU_OUTBOX_PUBLISH_FAILED', OUTBOX_PUBLISH_FAILED = "CORE_EDU_OUTBOX_PUBLISH_FAILED",
} }
export class ApplicationError extends Error { export class ApplicationError extends Error {
readonly code: CoreEduErrorCode;
readonly statusCode: number;
readonly details?: unknown;
traceId?: string;
constructor( constructor(
public readonly code: CoreEduErrorCode, code: CoreEduErrorCode,
message: string, message: string,
public readonly statusCode: number = 500, statusCode: number = 500,
public readonly details?: unknown, details?: unknown,
) { ) {
super(message); super(message);
this.name = 'ApplicationError'; this.name = this.constructor.name;
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
details: this.details,
traceId: this.traceId,
},
};
} }
} }
@@ -36,17 +56,25 @@ export class NotFoundError extends ApplicationError {
} }
export class UnauthorizedError extends ApplicationError { export class UnauthorizedError extends ApplicationError {
constructor(message: string = 'Unauthorized') { constructor(message: string = "Unauthorized") {
super(CoreEduErrorCode.UNAUTHORIZED, message, 401); super(CoreEduErrorCode.UNAUTHORIZED, message, 401);
} }
} }
export class ForbiddenError extends ApplicationError { export class ForbiddenError extends ApplicationError {
constructor(message: string = 'Forbidden') { constructor(message: string = "Forbidden") {
super(CoreEduErrorCode.FORBIDDEN, message, 403); super(CoreEduErrorCode.FORBIDDEN, message, 403);
} }
} }
export class PermissionDeniedError extends ApplicationError {
constructor(permission: string) {
super(CoreEduErrorCode.FORBIDDEN, `Permission denied: ${permission}`, 403, {
permission,
});
}
}
export class ConflictError extends ApplicationError { export class ConflictError extends ApplicationError {
constructor(message: string, details?: unknown) { constructor(message: string, details?: unknown) {
super(CoreEduErrorCode.CONFLICT, message, 409, details); super(CoreEduErrorCode.CONFLICT, message, 409, details);
@@ -54,7 +82,7 @@ export class ConflictError extends ApplicationError {
} }
export class InternalError extends ApplicationError { export class InternalError extends ApplicationError {
constructor(message: string = 'Internal server error', details?: unknown) { constructor(message: string = "Internal server error", details?: unknown) {
super(CoreEduErrorCode.INTERNAL_ERROR, message, 500, details); super(CoreEduErrorCode.INTERNAL_ERROR, message, 500, details);
} }
} }

View File

@@ -1,63 +1,95 @@
import { import {
ExceptionFilter,
Catch, Catch,
ExceptionFilter,
ArgumentsHost, ArgumentsHost,
HttpException, HttpException,
HttpStatus, Logger,
} from '@nestjs/common'; } from "@nestjs/common";
import { ZodError } from 'zod'; import type { Request, Response } from "express";
import { ApplicationError, CoreEduErrorCode } from './application-error.js'; import { ZodError } from "zod";
import { logger } from '../observability/logger.js'; import { ApplicationError } from "./application-error.js";
@Catch() @Catch()
export class GlobalErrorFilter implements ExceptionFilter { export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest(); const request = ctx.getRequest<Request>();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; const traceIdHeader = request.headers["x-request-id"];
let code = CoreEduErrorCode.INTERNAL_ERROR; const traceId =
let message = 'Internal server error'; typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let details: unknown;
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) { if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode; statusCode = exception.statusCode;
code = exception.code; body = exception.toJSON();
message = exception.message;
details = exception.details;
} else if (exception instanceof ZodError) { } else if (exception instanceof ZodError) {
statusCode = HttpStatus.BAD_REQUEST; statusCode = 400;
code = CoreEduErrorCode.VALIDATION_ERROR; body = {
message = 'Validation failed'; success: false,
details = exception.flatten().fieldErrors; error: {
code: "CORE_EDU_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) { } else if (exception instanceof HttpException) {
statusCode = exception.getStatus(); statusCode = exception.getStatus();
const resp = exception.getResponse(); const res = exception.getResponse();
message = const message = this.extractHttpMessage(res, exception);
typeof resp === 'string' body = {
? resp success: false,
: (resp as { message?: string }).message ?? exception.message; error: {
} else if (exception instanceof Error) { code: "HTTP_ERROR",
message = exception.message; 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,
},
};
} }
logger.error( this.logger.error(
{ {
err: exception, err: exception,
path: request.url, path: request.url,
method: request.method, method: request.method,
code,
}, },
`Request failed: ${message}`, `Request failed: ${request.method} ${request.url}`,
); );
response.status(statusCode).json({ response.status(statusCode).json(body);
code, }
message,
details, private extractHttpMessage(
timestamp: new Date().toISOString(), res: string | object,
path: request.url, exception: HttpException,
}); ): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === "object" && "message" in res) {
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
} }
} }

View File

@@ -1,12 +1,21 @@
import { Injectable, Logger } from "@nestjs/common"; import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js"; import { closeDb } from "../../config/database.js";
const SERVICE_NAME = "core-edu"; const SERVICE_NAME = "core-edu";
@Injectable() @Injectable()
export class LifecycleService { export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name); private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> { async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log( this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`, `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,

View File

@@ -1,29 +1,31 @@
import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { env } from '../../config/env.js'; import { NodeSDK } from "@opentelemetry/sdk-node";
import { logger } from './logger.js'; import { env } from "../../config/env.js";
import { logger } from "./logger.js";
let sdk: NodeSDK | undefined; let sdk: NodeSDK | undefined;
export function initTracer(): void { export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) { if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
logger.warn('OTEL_EXPORTER_OTLP_ENDPOINT not set, tracing disabled'); logger.warn("OTEL_EXPORTER_OTLP_ENDPOINT not set, tracing disabled");
return; return;
} }
sdk = new NodeSDK({ sdk = new NodeSDK({
serviceName: 'core-edu', serviceName: "core-edu",
traceExporter: new OTLPTraceExporter({ traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`, url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}), }),
instrumentations: [getNodeAutoInstrumentations()],
}); });
sdk.start(); sdk.start();
logger.info('OpenTelemetry tracer initialized'); logger.info("OpenTelemetry tracer initialized with auto-instrumentations");
} }
export async function shutdownTracer(): Promise<void> { export async function shutdownTracer(): Promise<void> {
if (sdk) { if (sdk) {
await sdk.shutdown(); await sdk.shutdown();
sdk = undefined; sdk = undefined;
logger.info('OpenTelemetry tracer shutdown'); logger.info("OpenTelemetry tracer shutdown");
} }
} }

View File

@@ -8,6 +8,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"incremental": false,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],

View File

@@ -5,6 +5,7 @@
保证服务在 ClickHouse 不可用时仍可启动并响应骨架数据。 保证服务在 ClickHouse 不可用时仍可启动并响应骨架数据。
""" """
import asyncio
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -68,7 +69,7 @@ async def close_client() -> None:
global _client, _client_initialized global _client, _client_initialized
if _client is not None: if _client is not None:
try: try:
_client.close() await asyncio.to_thread(_client.close)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("clickhouse_client_close_failed", error=str(exc)) logger.warning("clickhouse_client_close_failed", error=str(exc))
finally: finally:
@@ -86,7 +87,8 @@ async def query_dashboard(student_id: str) -> dict | None:
return None return None
try: try:
rows = client.query( result = await asyncio.to_thread(
client.query,
"SELECT student_id, class_id, exam_id, subject_id, score, " "SELECT student_id, class_id, exam_id, subject_id, score, "
"rank_in_class, knowledge_point_id, mastery_level, error_count, " "rank_in_class, knowledge_point_id, mastery_level, error_count, "
"last_updated " "last_updated "
@@ -95,7 +97,8 @@ async def query_dashboard(student_id: str) -> dict | None:
"ORDER BY last_updated DESC " "ORDER BY last_updated DESC "
"LIMIT 50", "LIMIT 50",
parameters={"sid": student_id}, parameters={"sid": student_id},
).result_rows )
rows = result.result_rows
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("query_dashboard_failed_degraded", error=str(exc), student_id=student_id) logger.warning("query_dashboard_failed_degraded", error=str(exc), student_id=student_id)
return None return None
@@ -131,7 +134,8 @@ async def query_class_performance(class_id: str) -> dict | None:
try: try:
# 平均分、参考人数、及格率(>=60 # 平均分、参考人数、及格率(>=60
agg_rows = client.query( result = await asyncio.to_thread(
client.query,
"SELECT " "SELECT "
" count() AS total_students, " " count() AS total_students, "
" avg(score) AS average_score, " " avg(score) AS average_score, "
@@ -139,7 +143,8 @@ async def query_class_performance(class_id: str) -> dict | None:
"FROM student_dashboard_view " "FROM student_dashboard_view "
"WHERE class_id = {cid:String}", "WHERE class_id = {cid:String}",
parameters={"cid": class_id}, parameters={"cid": class_id},
).result_rows )
agg_rows = result.result_rows
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
"query_class_performance_failed_degraded", "query_class_performance_failed_degraded",
@@ -175,7 +180,8 @@ async def query_student_errors(student_id: str) -> list[dict] | None:
return None return None
try: try:
rows = client.query( result = await asyncio.to_thread(
client.query,
"SELECT student_id, question_id, knowledge_point_id, error_count, " "SELECT student_id, question_id, knowledge_point_id, error_count, "
"last_error_time, content " "last_error_time, content "
"FROM student_errors " "FROM student_errors "
@@ -183,7 +189,8 @@ async def query_student_errors(student_id: str) -> list[dict] | None:
"ORDER BY last_error_time DESC " "ORDER BY last_error_time DESC "
"LIMIT 100", "LIMIT 100",
parameters={"sid": student_id}, parameters={"sid": student_id},
).result_rows )
rows = result.result_rows
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
"query_student_errors_failed_degraded", "query_student_errors_failed_degraded",
@@ -212,7 +219,7 @@ async def ping() -> bool:
if client is None: if client is None:
return False return False
try: try:
client.query("SELECT 1") await asyncio.to_thread(client.query, "SELECT 1")
return True return True
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("clickhouse_ping_failed", error=str(exc)) logger.warning("clickhouse_ping_failed", error=str(exc))
@@ -241,7 +248,8 @@ async def upsert_student_dashboard(
return False return False
try: try:
client.insert( await asyncio.to_thread(
client.insert,
"student_dashboard_view", "student_dashboard_view",
[ [
[ [
@@ -305,7 +313,8 @@ async def upsert_student_error(
return False return False
try: try:
client.insert( await asyncio.to_thread(
client.insert,
"student_errors", "student_errors",
[ [
[ [

View File

@@ -23,8 +23,8 @@ class Settings(BaseSettings):
# 可观测性 # 可观测性
otel_endpoint: str = "http://localhost:4318" otel_endpoint: str = "http://localhost:4318"
log_level: str = "info" log_level: str = "info"
# 开发模式开关"true"/"false" # 开发模式开关
dev_mode: str = "false" dev_mode: bool = False
# Kafka brokersCDC 消费;留空则不启动消费者) # Kafka brokersCDC 消费;留空则不启动消费者)
# 主机访问用 localhost:9092容器内访问用 kafka:29092 # 主机访问用 localhost:9092容器内访问用 kafka:29092
kafka_brokers: str = "" kafka_brokers: str = ""

View File

@@ -9,13 +9,15 @@
import asyncio import asyncio
import contextlib import contextlib
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
import structlog import structlog
from fastapi import FastAPI from fastapi import APIRouter, FastAPI
from opentelemetry import trace from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import make_asgi_app from prometheus_client import make_asgi_app
@@ -88,7 +90,7 @@ def init_tracer() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期. """应用生命周期.
1. 初始化 loggerstructlog 1. 初始化 loggerstructlog
@@ -126,9 +128,15 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# OpenTelemetry FastAPI 自动埋点HTTP 请求/响应 span
FastAPIInstrumentor.instrument_app(app)
# Prometheus 指标 # Prometheus 指标
app.mount("/metrics", make_asgi_app()) app.mount("/metrics", make_asgi_app())
# 业务路由
router = APIRouter()
@app.get("/healthz") @app.get("/healthz")
async def healthz() -> dict: async def healthz() -> dict:
@@ -185,7 +193,7 @@ async def readyz() -> dict:
} }
@app.get("/analytics/class/{class_id}/performance") @router.get("/analytics/class/{class_id}/performance")
async def class_performance(class_id: str) -> dict: async def class_performance(class_id: str) -> dict:
"""班级成绩分析. """班级成绩分析.
@@ -211,7 +219,7 @@ async def class_performance(class_id: str) -> dict:
return {"success": True, "data": {**result, "degraded": False}} return {"success": True, "data": {**result, "degraded": False}}
@app.get("/analytics/student/{student_id}/weakness") @router.get("/analytics/student/{student_id}/weakness")
async def student_weakness(student_id: str) -> dict: async def student_weakness(student_id: str) -> dict:
"""学生薄弱知识点分析. """学生薄弱知识点分析.
@@ -255,7 +263,7 @@ async def student_weakness(student_id: str) -> dict:
} }
@app.get("/analytics/student/{student_id}/errorbook") @router.get("/analytics/student/{student_id}/errorbook")
async def student_errorbook(student_id: str) -> dict: async def student_errorbook(student_id: str) -> dict:
"""学生错题本. """学生错题本.
@@ -286,3 +294,6 @@ async def student_errorbook(student_id: str) -> dict:
"degraded": False, "degraded": False,
}, },
} }
app.include_router(router)

View File

@@ -0,0 +1,241 @@
# 模块理解确认书 — iam
> AIai02TS / 身份认证)
> 阶段:阶段 1 交付物
> 日期2026-07-09
> 关联:[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[AI 分配方案](../../../docs/architecture/ai-allocation.md)、[pending-features P2](../../../docs/architecture/roadmap/pending-features.md)
---
## 1. 我在架构中的位置
- **层级**L5 业务微服务层004 §3.1 六层架构)
- **业务领域****D1 身份认证领域**004 §1.1b),独立限界上下文,不归属其他领域
- **上游(谁调用我)**
- `api-gateway`Go/GinHTTP 反向代理 `/api/v1/iam/*``http://iam:3002/iam/*`(已注册,见 [api-gateway/main.go](../../api-gateway/main.go) 第 76-83 行)
- `teacher-bff`NestJSHTTP 调用 `/iam/me``/iam/viewports` 聚合教师身份与视口(见 [teacher-bff/teacher.service.ts](../../teacher-bff/src/teacher/teacher.service.ts) 第 30/78 行)
- 未来:`student-bff``parent-bff`(同样走 HTTP 聚合)
- **下游(我调用谁)**
- `MySQL`(独占库 `iam_db`,连接串 `DATABASE_URL`
- `Redis`P2 待启用:权限缓存 + refresh token 黑名单env 已留 `REDIS_URL` 占位)
- `Kafka`P2 待启用:发布 `edu.identity.user.*` 事件,由 core-edu/msg 消费)
- **通信方式**
- 入口:**HTTP/REST**(当前实现,端口 3002
- 出口MySQLmysql2 连接池 + Drizzle ORM、Redis待接、Kafka待接
- 演进P3 起对外暴露 gRPC`iam.proto` 已定义 `IamService`REST 与 gRPC 并存过渡期)
- **不持有跨服务状态**:会话/token 黑名单放 Redis不落本地内存
## 2. 我的限界上下文
### 2.1 我负责的聚合 / 实体
| 聚合根 | 实体 / 值对象 | 当前表 | 职责 |
| ----------------------------------- | --------------------- | ---------------------- | ------------------------------------ |
| **User**(用户) | UserStatus、DataScope | `iam_users` | 注册、登录、密码校验、用户信息查询 |
| **Role**(角色) | — | `iam_roles` | 三层角色模型(系统/组织/临时)的载体 |
| **Permission**(权限点) | resource + action | `iam_permissions` | 权限点常量集中表 |
| **RolePermission**(角色-权限映射) | — | `iam_role_permissions` | 多对多,权限并集来源 |
| **UserRole**(用户-角色绑定) | — | `iam_user_roles` | 用户拥有的多层角色 |
| **RoleViewport**(角色-视口配置) | componentConfig(JSON) | `iam_role_viewports` | 4 层视口模型的 L1 导航 + L3 组件配置 |
| **RefreshToken**(刷新令牌) | tokenHash + revokedAt | `iam_refresh_tokens` | refresh token 持久化 + 轮换/撤销 |
### 2.2 业务领域归属
- **D1 身份认证领域**004 §1.1biam 独占此领域
- 上游依赖:无(身份认证是整个系统的权限中枢,不依赖其他业务领域)
- 下游被依赖core-edu消费 `UserRegistered` 初始化默认班级关联、msg消费 `UserRegistered` 发欢迎通知)
### 2.3 我不负责什么(边界外)
- ❌ 班级/学科/年级数据 → `core-edu`D2 教学组织)
- ❌ 考试/作业/成绩 → `core-edu`D3 教学核心)
- ❌ 教材/知识点/题库 → `content`D4 内容资源)
- ❌ 站内信/通知投递 → `msg`D5 沟通通知)
- ❌ 学情分析/掌握度计算 → `data-ana`D6 智能洞察)
- ❌ WebSocket 长连接管理 → `push-gateway`
- ❌ JWT 公钥校验 → `api-gateway`iam 只签发,不校验)
- ❌ 前端权限 Hook → `packages/ui-components/hooks/use-permission.ts`(前端通过 BFF 拉取,不直连 iam
## 3. 我与外部的契约
### 3.1 我消费的 proto message从 shared-proto
- 当前**不消费**任何外部 protoiam 是身份认证源头,不反向依赖业务服务)
- 未来 P3 转 gRPC 时,消费 `google/protobuf/timestamp.proto`(时间字段标准化)
### 3.2 我暴露的契约
#### 当前 REST API已实现见 [iam.controller.ts](../src/iam/iam.controller.ts)、[rbac.controller.ts](../src/iam/rbac.controller.ts)
| Method | Path | 权限 | 说明 |
| ------ | ---------------------------- | ----------------- | ------------------------------------- |
| POST | `/iam/register` | 公开 | 注册(自动分配 teacher 角色) |
| POST | `/iam/login` | 公开 | 登录,返回 accessToken + refreshToken |
| POST | `/iam/refresh` | 公开 | 刷新令牌 |
| GET | `/iam/me` | `IAM_USER_READ` | 当前用户信息(读 `x-user-id` 头) |
| GET | `/iam/viewports` | `IAM_USER_READ` | 当前用户视口L1 导航,按权限过滤) |
| GET | `/iam/permissions/effective` | `IAM_USER_READ` | 当前用户有效权限(多角色去重) |
| GET | `/iam/roles` | `IAM_ROLE_MANAGE` | 所有角色列表(管理端) |
| GET | `/iam/permissions` | `IAM_ROLE_MANAGE` | 所有权限点列表(管理端) |
#### 健康检查([health.controller.ts](../src/shared/health/health.controller.ts)
| Method | Path | 鉴权 | 用途 |
| ------ | ---------- | ---- | ---------------------------------------------------------------- |
| GET | `/healthz` | 无 | liveness进程存活 |
| GET | `/readyz` | 无 | readiness校验 DB `SELECT 1`,失败 503 |
| GET | `/metrics` | 无 | Prometheus 指标抓取([main.ts](../src/main.ts) 第 23-26 行注册) |
#### Proto 契约([iam.proto](../../../packages/shared-proto/proto/iam.proto)
```protobuf
package next_edu_cloud.iam.v1;
service IamService {
rpc Register(RegisterRequest) returns (AuthResponse);
rpc Login(LoginRequest) returns (AuthResponse);
rpc RefreshToken(RefreshTokenRequest) returns (TokenPair);
rpc GetUserInfo(GetUserInfoRequest) returns (UserInfo);
}
```
> **缺口**proto 仅定义 4 个 RPCREST 已实现的 `viewports`、`permissions/effective`、`roles`、`permissions` 未在 proto 中体现。P2 阶段 2 设计文档需补齐 `GetViewports`、`GetEffectivePermissions`、`ListRoles`、`ListPermissions`、`Logout`、`GetPublicKey`RS256 公钥暴露端点)等 RPC。
#### 我发布的领域事件P2 待实现 Outbox
| 事件 | 触发时机 | Topic遵循 004 §7.2 | 消费者 |
| ------------------------------ | ------------- | -------------------------------- | ------------------------------------------------- |
| `UserRegistered` | 注册成功 | `edu.identity.user.created` | core-edu初始化默认班级关联、msg发欢迎通知 |
| `UserUpdated` | 用户信息变更 | `edu.identity.user.updated` | core-edu、msg |
| `UserDeleted` / `UserDisabled` | 用户注销/禁用 | `edu.identity.user.deleted` | core-edu、msg清理关联 |
| `UserRoleChanged` | 角色绑定变更 | `edu.identity.user.role_changed` | 自身缓存失效、msg审计 |
> **Topic 命名遵循 004 §7.2**`edu.identity.user.created` / `edu.identity.user.updated`。
#### 我消费的事件
- 当前:**无**
- 未来不主动消费业务事件iam 是权限中枢,不订阅其他领域事件)
### 3.3 错误码前缀
- **前缀**`IAM_`(见 [application-error.ts](../src/shared/errors/application-error.ts)
- 已定义错误码:
| 错误码 | HTTP | 触发条件 |
| ----------------------- | ---- | ---------------------------- |
| `IAM_VALIDATION_ERROR` | 400 | Zod 校验失败 / 字段非法 |
| `IAM_UNAUTHORIZED` | 401 | 未登录、密码错误、token 失效 |
| `IAM_PERMISSION_DENIED` | 403 | 缺少所需权限点 |
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限不存在 |
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复 |
| `IAM_BUSINESS_ERROR` | 422 | 业务规则违反(如账号禁用) |
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
> **全局错误格式**`{ success: false, error: { code, message, details?, traceId } }`,由 [GlobalErrorFilter](../src/shared/errors/global-error.filter.ts) 统一兜底traceId 从 `x-request-id` 头注入。
## 4. 我的技术栈
| 维度 | 选型 | 版本 | 备注 |
| -------- | --------------------- | -------------- | ------------------------------------------------------------------------------- |
| 语言 | TypeScriptESM | 5.6+ | `tsconfig.json``NodeNext` + `incremental: false`(避免 nest watch 不 emit |
| 框架 | NestJS | 10.4+ | 装饰器 + DI`@nestjs/platform-express` |
| ORM | Drizzle ORMmysql2 | 0.31+ | `getDb()` 单例池,无 typeorm DataSource 依赖 |
| 数据库 | MySQL 8 | — | 独占库 `iam_db`,连接池 connectionLimit=10 |
| 缓存 | RedisP2 待接) | 7 | 权限缓存 TTL 5min + refresh token 黑名单 |
| 密码哈希 | bcrypt | 5.1+ | cost ≥ 12已对齐 project_rules §4 |
| JWT | jsonwebtoken | 9.0+ | **当前 HS256P2 必须切 RS256** |
| 日志 | pino | 9.4+ | 结构化 JSON`service: iam` base 字段 |
| 指标 | prom-client | 15.1+ | `iam_requests_total` + `iam_request_duration_seconds` |
| 链路 | OpenTelemetry SDK | 0.53+ | auto-instrumentations + OTLP HTTP exporter |
| 输入校验 | Zod | 3.23+ | Controller 层 `schema.parse(body)` |
| 测试 | Vitest | 2.1+ | **当前缺失P2 必须补齐** |
| 容器 | Dockerfile 多阶段 | node:20-alpine | builder + runtimeEXPOSE 3002 |
## 5. 我的阶段归属
- **阶段****P2 身份**M4-M6
- **当前阶段目标**pending-features §P2
1. ✅ 已实现骨架:注册/登录/刷新/me/viewports/effective permissions/roles/permissions 列表
2.**RS256 非对称签名**(当前仍是 HS256env.JWT_SECRET 单密钥)
3.**refresh_token 轮换 + Redis 黑名单失效**(当前只存 hash未实现撤销检查
4.**权限缓存**`getEffectivePermissions` 结果 Redis 缓存 TTL 5min角色变更主动失效
5.**Outbox 事件发布**`UserRegistered` / `UserUpdated` / `UserRoleChanged`
6.**2FA**pending-features §P2 提及但优先级低)
7.**补全数据表**`parent_student_relations``class_subject_teachers`pending-features §P2 schema 清单)
8.**gRPC 化**iam.proto 已定义 4 RPC但服务尚未实现 gRPC server
9.**测试覆盖**classes 有 `test/unit/classes.service.test.ts`iam 无任何测试)
10.**RBAC CRUD 完整化**(当前只有读,缺角色/权限/视口的增删改)
- **依赖的上游阶段产出**
- P1 ✅ api-gateway已注册 `/iam/*` 路由,已透传 `x-user-id`/`x-user-roles` 头)
- P1 ✅ classes 黄金模板(横切关注点模板:错误处理/可观测/健康检查/优雅关闭)
- P1 ✅ shared-protoiam.proto 已定义基础 4 RPC
- **P2 退出标准**pending-features §P2 + 004 §14.2
- 教师登录 → 获取 JWTRS256→ 访问 teacher-portal → 侧边栏按 `viewports.L1` 渲染 → 看到空白 Dashboard
- 打 tag `v0.2.0-p2`
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
> 参照 [classes 黄金模板](../../classes/src/) 全部源码与 [known-issues §2.2 classes](../../../docs/troubleshooting/known-issues.md) 经验。
| 对齐项 | classes 状态 | iam 当前状态 | iam 待补齐 |
| --------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| **权限装饰器 `@RequirePermission`** | ✅ 每个 Handler 都有 | ⚠️ Handler 有装饰器,但 `PermissionGuard` 用本地硬编码 `ROLE_PERMISSIONS` mapadmin/teacher未走 DB 查询 | P2 改为 DB 驱动Guard 调用 `IamService.getEffectivePermissions(userId)`(从 Redis 缓存读取) |
| **错误码前缀统一** | ✅ `CLASSES_*` | ✅ `IAM_*` | 无 |
| **loggerpino** | ✅ `shared/observability/logger.ts` | ✅ 同模板 | 无 |
| **metricsprom-client** | ✅ `*_requests_total` + `*_request_duration_seconds` | ✅ `iam_requests_total` + `iam_request_duration_seconds` | 无 |
| **tracerOTel** | ✅ auto-instrumentations + OTLP | ✅ 同模板 | 无 |
| **`/healthz` + `/readyz`** | ✅ Drizzle `SELECT 1` | ✅ 同模板 | 无 |
| **优雅关闭SIGTERM** | ✅ `LifecycleService` 关闭 DB 池 | ✅ 同模板 | P2 接入 Redis/Kafka 后需补关闭顺序HTTP → Kafka → Redis → DB |
| **测试覆盖率 ≥ 80%** | ✅ `test/unit/classes.service.test.ts` | ❌ **完全缺失** | P2 必须补齐:`test/unit/iam.service.test.ts``test/unit/iam.repository.test.ts``test/unit/permission.guard.test.ts` |
| **Dockerfile 多阶段构建** | ✅ builder + runtime | ✅ 同模板 | 无 |
| **Zod 输入验证** | ✅ Controller 层 `schema.parse(body)` | ✅ 同模板 | 无 |
| **GlobalErrorFilter** | ✅ 注册到 `main.ts` | ✅ 同模板 | 无 |
| **ESM `.js` 后缀** | ✅ 相对 import 带 `.js` | ✅ 同模板 | 无 |
| **`tsconfig.json` incremental: false** | ✅ | ✅ | 无 |
| **AppModule 显式 imports HealthModule** | ✅ | ✅ | 无 |
| **Drizzle `getDb()` 单例** | ✅ | ✅ | 无 |
| **Controller 读 `x-user-id` 头** | ✅ | ✅ | 无 |
### 6.1 当前 iam 与 classes 黄金模板的差异点(需在阶段 2 设计文档中明确处理)
1. **PermissionGuard 数据源**classes 用本地 `ROLE_PERMISSIONS` map够用因为 classes 权限点固定iam **必须改为 DB 驱动**——因为 iam 自身就是权限中枢,权限点/RBAC 是动态的,硬编码会导致角色变更不生效。
2. **AuthMiddleware 未注册**[app.module.ts](../src/app.module.ts) 只注册了 `PermissionGuard` 作为 `APP_GUARD``AuthMiddleware` 未在 `AppModule.configure()` 中消费。Controller 直接从 `req.headers['x-user-id']` 读取,这是 known-issues §2.3 记录的决策。P2 设计文档需明确:要么注册 AuthMiddleware要么继续走 header 直读(当前选择后者,与 Gateway 透传策略一致)。
3. **JWT 签名算法**classes 不签发 JWT只校验iam 是**唯一签发方**,必须切 RS256 并暴露公钥端点 `/iam/.well-known/jwks.json``/iam/public-key`
4. **Outbox 模式**classes 无 OutboxP3 才引入iam P2 需要引入 Outbox 发布用户事件——需要 coord 在 shared-ts 中提供 Outbox 工具或 iam 自建(参照 core-edu P3 设计)。
## 7. 服务审计表ai02 自检)
> 依照 ai-allocation §10 模板,对当前 iam 已实现代码进行审计。
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ---- | ---------- | ---------- | ------ | ------- | ------ | -------- | ------- | -------- | ---------- | ---------- |
| iam | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 0% ❌ | ✅ |
**说明**
- 权限装饰器 ⚠️:装饰器已挂载到每个 Handler但 Guard 用本地硬编码 `ROLE_PERMISSIONS`,未走 DB与 iam 作为权限中枢的职责矛盾)
- 测试覆盖率 0%[services/iam/](../) 下无 `test/` 目录Vitest 配置缺失classes 有 `vitest.config.ts` + `test/unit/`
---
## 8. 待 coord 交叉审查的关键决策点
以下决策需在阶段 2 设计文档中明确,并在 coord 交叉审查时确认:
1. **RS256 密钥管理**P2 用本地文件 `/opt/edu/keys/iam-private.pem` + `/opt/edu/keys/iam-public.pem`,还是引入 VaultP6 才落地)?建议 P2 用本地文件 + 环境变量 `IAM_PRIVATE_KEY_PATH`P6 迁 Vault。
2. **公钥暴露端点**:用 `GET /iam/.well-known/jwks.json`JWK Set 标准,支持密钥轮换)还是 `GET /iam/public-key`(简单 PEM建议前者为未来密钥轮换留余地。
3. **权限缓存失效策略**:角色变更时主动 `DEL iam:perms:{userId}`,还是发 `UserRoleChanged` 事件让消费者自行失效?建议两者都做:本地服务内主动 DEL同步事件供其他服务感知。
4. **Outbox 实现位置**iam 自建 `iam_outbox` 表 + relay worker还是复用 shared-ts 提供的通用 Outbox 工具?**需 coord 确认 shared-ts 是否在 P2 提供 Outbox 工具**。若未提供iam 自建轻量 Outbox参照 core-edu P3 设计,但要先于 core-edu 落地)。
5. **gRPC 与 REST 并存策略**P2 是否同步实现 gRPC server还是 P2 仅 RESTP3 再补 gRPC建议 P2 仅 REST保证退出标准达成gRPC server 在 P3 随 core-edu 一起引入(此时 shared-ts 也有 gRPC 工具沉淀)。
6. **DataScope 枚举对齐**:当前 schema 用 `self/class/grade/school/district/all`004 §5.3 用 `L0-SELF` ~ `L5-ALL`。建议保持 schema 字符串枚举DB 友好),在 API/proto 层用 `L0`~`L5` 数值枚举映射,业务代码用语义常量。
7. **parent_student_relations 表归属**pending-features §P2 schema 清单把此表放在 iam但 004 §5.4 提到家长场景域由 parent-bff 聚合。需确认:此表是 iam 管理(家长-学生关系是身份关系),还是 core-edu 管理(教学组织关系)?**建议归 iam**(身份关系优先于教学组织)。
8. **`class_subject_teachers` 表归属**:同上,此表横跨 iam教师身份与 core-edu班级/学科。pending-features §P2 放 iam但 core-edu 的 classes 模块也需要。**建议归 core-edu**(班级-学科-教师是教学组织数据iam 只存 `userId` 与角色,不存具体任教关系。
---
**AI Agent**: ai02 (iam-module)
**Branch**: main单仓库并行模式见 ai-allocation §9.1
**Coordinator**: coord-ai

View File

@@ -0,0 +1,718 @@
# 模块架构设计文档 — iam
> AIai02TS / 身份认证)
> 阶段:阶段 2 交付物
> 日期2026-07-09
> 关联:[阶段 1 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[pending-features P2](../../../docs/architecture/roadmap/pending-features.md)
> 状态:待 coord 交叉审查
---
## 1. 模块内部分层图
### 1.1 调用链总览
```mermaid
flowchart TB
subgraph Client["客户端 / Gateway / BFF"]
Req[HTTP 请求<br/>带 x-user-id / x-user-roles 头]
end
subgraph NestJS["iam 服务NestJS"]
direction TB
MW[AuthMiddleware<br/>❌ 当前未注册P2 仍走 header 直读]
Guard[PermissionGuard<br/>APP_GUARD 全局守卫]
Filter[GlobalErrorFilter<br/>全局异常过滤器]
subgraph Controllers["Controller 层"]
IamCtl[IamController<br/>/iam/register, login, refresh, me]
RbacCtl[RbacController<br/>/iam/viewports, permissions, roles]
UserCtl[UserController<br/>P2 新增: 用户 CRUD]
RoleCtl[RoleController<br/>P2 新增: 角色 CRUD]
ViewportCtl[ViewportController<br/>P2 新增: 视口 CRUD]
JwksCtl[JwksController<br/>P2 新增: RS256 公钥暴露]
end
subgraph Services["Application Service 层"]
IamSvc[IamService<br/>认证编排]
RbacSvc[RbacService<br/>RBAC 编排]
UserSvc[UserService<br/>用户领域编排]
CacheSvc[PermissionCacheService<br/>Redis 权限缓存]
end
subgraph Domain["Domain 层P2 轻量)"]
UserEntity[UserEntity<br/>聚合根]
RoleEntity[RoleEntity<br/>聚合根]
end
subgraph Repo["Repository 层"]
IamRepo[IamRepository<br/>Drizzle 查询]
RbacRepo[RbacRepository<br/>Drizzle 查询]
end
subgraph Outbox["Outbox 模块"]
OutboxTbl[(iam_outbox 表)]
Relay[OutboxRelayWorker<br/>后台轮询 + Kafka 投递]
end
subgraph Infra["基础设施"]
Db[(MySQL<br/>iam_db)]
Redis[(Redis<br/>权限缓存 + token 黑名单)]
Kafka[(Kafka<br/>edu.identity.user.* topic)]
end
end
Req --> MW
MW --> Guard
Guard --> Controllers
Controllers --> Services
Services --> Domain
Services --> Repo
Services --> CacheSvc
Repo --> Db
CacheSvc --> Redis
Services --> OutboxTbl
OutboxTbl --> Relay
Relay --> Kafka
Controllers -.异常.-> Filter
```
### 1.2 中间件 / Guard / Filter 拦截顺序
```
请求进入
→ AuthMiddlewareP2 仍不注册Controller 直读 header
→ PermissionGuardAPP_GUARDDEV_MODE 旁路 + DB 驱动权限校验)
→ Controller HandlerZod 校验 body
→ Application Service业务编排
→ RepositoryDrizzle 查询)
→ 异常抛出
→ GlobalErrorFilter统一兜底注入 traceId
→ 响应返回
```
### 1.3 目录结构P2 目标态)
```
services/iam/src/
├─ iam/ # 限界上下文:认证
│ ├─ iam.controller.ts # 认证端点register/login/refresh/me/logout
│ ├─ iam.service.ts # 认证编排
│ ├─ iam.repository.ts # 用户/refresh_token 查询
│ ├─ iam.schema.ts # users / refresh_tokens 表
│ ├─ iam.dto.ts # Zod schema
│ └─ domain/
│ └─ user.entity.ts # UserEntity 聚合根P2 新增)
├─ rbac/ # 限界上下文RBACP2 从 iam/ 拆出)
│ ├─ rbac.controller.ts # 角色/权限/视口查询端点
│ ├─ role.controller.ts # 角色 CRUDP2 新增)
│ ├─ permission.controller.ts # 权限点 CRUDP2 新增)
│ ├─ viewport.controller.ts # 视口配置 CRUDP2 新增)
│ ├─ rbac.service.ts # RBAC 编排
│ ├─ rbac.repository.ts # 角色/权限/视口查询
│ ├─ rbac.schema.ts # roles / permissions / role_permissions / role_viewports 表
│ └─ domain/
│ └─ role.entity.ts # RoleEntity 聚合根P2 新增)
├─ jwks/ # 限界上下文JWT 公钥暴露P2 新增)
│ ├─ jwks.controller.ts # GET /iam/.well-known/jwks.json
│ ├─ jwks.service.ts # 密钥加载 + JWK Set 生成
│ └─ jwks.repository.ts # 密钥元数据持久化(可选)
├─ cache/ # 限界上下文Redis 缓存P2 新增)
│ ├─ permission-cache.service.ts # getEffectivePermissions 缓存
│ └─ token-blacklist.service.ts # refresh token 黑名单
├─ outbox/ # Outbox 模式P2 新增)
│ ├─ outbox.schema.ts # iam_outbox 表
│ ├─ outbox.publisher.ts # 写入 outbox事务内
│ └─ outbox.relay-worker.ts # 后台轮询 + Kafka 投递
├─ config/
│ ├─ database.ts # Drizzle 池(已有)
│ ├─ redis.ts # Redis 客户端P2 新增)
│ ├─ jwt.ts # RS256 密钥加载P2 新增)
│ └─ env.ts # 环境变量P2 扩展)
├─ middleware/
│ ├─ auth.middleware.ts # 保留P2 仍不注册)
│ └─ permission.guard.ts # 改造DB 驱动 + 缓存
├─ shared/
│ ├─ errors/ # 已有
│ ├─ health/ # 已有
│ ├─ lifecycle/ # 改造:关闭顺序 HTTP→Kafka→Redis→DB
│ └─ observability/ # 已有
├─ app.module.ts # 改造imports 新增 OutboxModule、CacheModule、JwksModule
└─ main.ts # 改造:启动 OutboxRelayWorker
```
## 2. 领域模型
### 2.1 聚合根与实体
```mermaid
classDiagram
class UserEntity {
-id: string
-email: string
-passwordHash: string
-name: string
-status: UserStatus
-dataScope: DataScope
-createdAt: Date
-updatedAt: Date
+create(props) UserEntity$
+rename(name) UserRenamedEvent
+disable() UserDisabledEvent
+changeDataScope(scope) UserDataScopeChangedEvent
+verifyPassword(plain) bool
}
class RoleEntity {
-id: string
-name: string
-description: string?
-roleType: RoleType
+create(props) RoleEntity$
+rename(name) RoleRenamedEvent
}
class Permission {
+id: string
+name: string
+resource: string
+action: string
}
class RoleViewport {
+id: string
+roleId: string
+viewportKey: string
+label: string
+route: string
+sortOrder: string
+requiredPermission: string?
+componentConfig: string?
}
class RefreshToken {
+id: string
+userId: string
+tokenHash: string
+expiresAt: Date
+revokedAt: Date?
+isRevoked() bool
+isExpired() bool
}
UserEntity "1" --> "many" RefreshToken : 拥有
RoleEntity "1" --> "many" Permission : 通过 role_permissions
RoleEntity "1" --> "many" RoleViewport : 配置
```
### 2.2 值对象(枚举)
```typescript
enum UserStatus {
ACTIVE = "active",
DISABLED = "disabled",
PENDING = "pending", // P2 新增:注册后待激活
}
enum DataScope {
SELF = "self", // L0
CLASS = "class", // L1
GRADE = "grade", // L2
SCHOOL = "school", // L3
DISTRICT = "district", // L4
ALL = "all", // L5
}
enum RoleType {
// P2 新增:三层角色模型
SYSTEM = "system", // 系统预设admin/teacher/student/parent
ORGANIZATION = "organization", // 组织分配(年级组长/班主任/学科组长)
TEMPORARY = "temporary", // 临时授权(代课教师)
}
```
### 2.3 聚合间通信
- **同服务内**`IamService` 直接调用 `RbacService``PermissionCacheService`NestJS DI
- **跨服务**:通过 Kafka 事件Outbox 发布),不直接调用其他服务
## 3. 数据模型
### 3.1 表清单P2 目标态)
#### 3.1.1 已有表(保留)
| 表名 | 用途 | 主键 | 唯一索引 |
| ---------------------- | -------------------- | ----------------------------- | ----------------------- |
| `iam_users` | 用户主表 | `id` (char36) | `email` |
| `iam_roles` | 角色表 | `id` | `name` |
| `iam_user_roles` | 用户-角色绑定 | `(userId, roleId)` 复合 | — |
| `iam_permissions` | 权限点表 | `id` | `name` |
| `iam_role_permissions` | 角色-权限映射 | `(roleId, permissionId)` 复合 | — |
| `iam_refresh_tokens` | refresh token 持久化 | `id` | — |
| `iam_role_viewports` | 角色-视口配置 | `id` | `(roleId, viewportKey)` |
#### 3.1.2 P2 新增表
| 表名 | 用途 | 主键 | 唯一索引 |
| ------------------------------ | ------------------------------- | ---- | ----------------------- |
| `iam_outbox` | Outbox 事件表(事务内写入) | `id` | — |
| `iam_parent_student_relations` | 家长-学生关系表 | `id` | `(parentId, studentId)` |
| `iam_user_sessions` | 用户会话记录(审计 + 强制下线) | `id` | `userId + deviceHash` |
> **注**`class_subject_teachers` 表归属 core-edu见 §8.1 决策点 8不在 iam。
#### 3.1.3 P2 表结构定义
```typescript
// iam_outboxOutbox 事件表
export const iamOutbox = mysqlTable(
"iam_outbox",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
aggregateId: char("aggregate_id", { length: 36 }).notNull(),
aggregateType: varchar("aggregate_type", { length: 50 }).notNull(), // 'User' | 'Role'
eventType: varchar("event_type", { length: 100 }).notNull(), // 'UserRegistered' | ...
payload: text("payload").notNull(), // JSON 序列化
topic: varchar("topic", { length: 100 }).notNull(), // 'edu.identity.user.created'
status: mysqlEnum("status", ["pending", "published", "failed"])
.notNull()
.default("pending"),
retryCount: int("retry_count").notNull().default(0),
occurredAt: timestamp("occurred_at").notNull().defaultNow(),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
statusIdx: index("idx_outbox_status").on(table.status), // relay 轮询用
aggregateIdx: index("idx_outbox_aggregate").on(table.aggregateId),
}),
);
// iam_parent_student_relations家长-学生关系
export const parentStudentRelations = mysqlTable(
"iam_parent_student_relations",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
parentId: char("parent_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
relation: varchar("relation", { length: 20 }).notNull(), // 'father' | 'mother' | 'guardian'
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
parentStudentUniq: uniqueIndex("uniq_parent_student").on(
table.parentId,
table.studentId,
),
studentIdx: index("idx_student").on(table.studentId),
}),
);
// iam_roles 表扩展:新增 role_type 字段
// 在现有 iam_roles 表 ALTER ADD:
// role_type ENUM('system','organization','temporary') NOT NULL DEFAULT 'system'
// level INT NOT NULL DEFAULT 0 -- 三层优先级system=0(最高) / organization=1 / temporary=2
```
### 3.2 索引策略
| 表 | 索引 | 用途 |
| ------------------------------ | -------------------------------------------------- | -------------------------------- |
| `iam_users` | PK(`id`)、UNIQUE(`email`) | 主键查询、登录查询 |
| `iam_user_roles` | INDEX(`userId`)、INDEX(`roleId`) | 按用户查角色、按角色查用户 |
| `iam_role_permissions` | INDEX(`roleId`)、INDEX(`permissionId`) | 按角色查权限 |
| `iam_refresh_tokens` | INDEX(`userId`)、INDEX(`tokenHash`) | 按用户查 token、按 hash 校验 |
| `iam_role_viewports` | INDEX(`roleId`) | 按角色查视口 |
| `iam_outbox` | INDEX(`status`)、INDEX(`aggregateId`) | relay 轮询 pending、按聚合查事件 |
| `iam_parent_student_relations` | UNIQUE(`parentId`,`studentId`)、INDEX(`studentId`) | 防重、按学生查家长 |
### 3.3 读写分离策略
- **写路径**:所有 Command 走 MySQL 主库iam 独占库,无读写分离)
- **读路径**P2 暂不引入 ClickHouse 读模型iam 读多写少但数据量小MySQL 足够)
- **缓存层**`getEffectivePermissions` / `getUserViewports` 结果走 Redis 缓存TTL 5min
## 4. API 设计
### 4.1 REST API 完整清单P2 目标态)
| Method | Path | 权限 | 请求体 / 参数 | 响应 | 说明 |
| ------ | ------------------------------------------ | ----------------- | ---------------------------------- | ----------------- | -------------------------------------- |
| POST | `/iam/register` | 公开 | `{email, password, name}` | `{user, tokens}` | 注册 + 自动分配 teacher 角色 |
| POST | `/iam/login` | 公开 | `{email, password}` | `{user, tokens}` | 登录 |
| POST | `/iam/refresh` | 公开 | `{refreshToken}` | `{tokens}` | 刷新令牌(轮换 + 旧 token 加入黑名单) |
| POST | `/iam/logout` | `IAM_USER_READ` | `{refreshToken}` | `{success}` | 登出refresh token 加黑名单) |
| GET | `/iam/me` | `IAM_USER_READ` | — | `{user}` | 当前用户信息 |
| GET | `/iam/viewports` | `IAM_USER_READ` | — | `{viewports[]}` | 当前用户视口L1 导航) |
| GET | `/iam/permissions/effective` | `IAM_USER_READ` | — | `{permissions[]}` | 当前用户有效权限 |
| GET | `/iam/.well-known/jwks.json` | 公开 | — | `{keys[]}` | RS256 公钥 JWK SetGateway 拉取) |
| GET | `/iam/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 角色列表 |
| POST | `/iam/roles` | `IAM_ROLE_MANAGE` | `{name, description, roleType}` | `{role}` | 创建角色 |
| PUT | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | `{name?, description?}` | `{role}` | 更新角色 |
| DELETE | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除角色(系统角色禁止删) |
| GET | `/iam/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 权限点列表 |
| GET | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 用户角色列表 |
| POST | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | `{roleId}` | `{success}` | 给用户分配角色 |
| DELETE | `/iam/users/:id/roles/:roleId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除用户角色(触发缓存失效) |
| GET | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 角色权限列表 |
| POST | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | `{permissionId}` | `{success}` | 给角色授予权限 |
| DELETE | `/iam/roles/:id/permissions/:permissionId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除角色权限(触发缓存失效) |
| GET | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | — | `{viewports[]}` | 角色视口列表 |
| POST | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | `{viewportKey, label, route, ...}` | `{viewport}` | 创建视口配置 |
| PUT | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | `{label?, route?, ...}` | `{viewport}` | 更新视口配置 |
| DELETE | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除视口配置 |
| GET | `/iam/users/:id/parents` | `IAM_USER_READ` | — | `{parents[]}` | 学生家长列表(家长-学生关系) |
| POST | `/iam/users/:studentId/parents` | `IAM_ROLE_MANAGE` | `{parentId, relation}` | `{success}` | 绑定家长-学生关系 |
### 4.2 请求/响应结构示例
```typescript
// 注册响应
interface RegisterResponse {
success: true;
data: {
user: {
id: string;
email: string;
name: string;
roles: string[]; // ['teacher']
permissions: string[]; // ['IAM_USER_READ', 'CLASSES_READ', ...]
dataScope: "self" | "class" | "grade" | "school" | "district" | "all";
};
tokens: {
accessToken: string; // RS256 签名15min
refreshToken: string; // RS256 签名7day
expiresIn: 900; // 秒
};
};
}
// JWK Set 响应RS256 公钥暴露)
interface JwkSet {
keys: Array<{
kty: "RSA";
use: "sig";
alg: "RS256";
kid: string; // 密钥 ID支持轮换
n: string; // modulus base64url
e: string; // exponent base64url
}>;
}
```
### 4.3 JWT PayloadRS256 签发)
```typescript
interface JwtPayload {
sub: string; // userId
email: string;
roles: string[]; // ['teacher', 'grade_leader']
dataScope: DataScope; // 'class' | 'grade' | ...
type: "access" | "refresh";
iat: number; // 签发时间
exp: number; // 过期时间
iss: "next-edu-cloud"; // 签发者
aud: "next-edu-cloud"; // 受众
jti: string; // JWT ID用于黑名单
}
```
## 5. 事件设计
### 5.1 我发布的领域事件
| 事件 | 触发时机 | Topic | 消费者动作 |
| ----------------- | ------------------------------ | -------------------------------- | -------------------------------------------------- |
| `UserRegistered` | 注册成功 | `edu.identity.user.created` | core-edu 初始化默认班级关联msg 发欢迎通知 |
| `UserUpdated` | 用户信息变更name/dataScope | `edu.identity.user.updated` | core-edu 同步用户快照msg 通知 |
| `UserDisabled` | 用户禁用/注销 | `edu.identity.user.deleted` | core-edu 解除关联msg 通知push-gateway 强制下线 |
| `UserRoleChanged` | 用户角色绑定变更 | `edu.identity.user.role_changed` | 自身 Redis 缓存失效msg 审计日志 |
| `RoleCreated` | 角色创建 | `edu.identity.role.created` | msg 审计(仅管理端关注) |
| `RoleUpdated` | 角色权限变更 | `edu.identity.role.updated` | 所有该角色用户的缓存失效msg 审计 |
### 5.2 事件 Schema建议 coord 在 shared-proto 中统一定义)
```protobuf
// 建议在 packages/shared-proto/proto/events.proto 新增:
message UserEvent {
string event_id = 1; // UUID幂等去重
string aggregate_id = 2; // userId
string event_type = 3; // 'UserRegistered' | 'UserUpdated' | ...
int64 occurred_at = 4; // 发生时间戳ms
string user_id = 5;
string email = 6;
string name = 7;
repeated string roles = 8;
string data_scope = 9;
string action = 10; // 'created' | 'updated' | 'disabled' | 'role_changed'
map<string, string> metadata = 11; // trace_id 等
}
message RoleEvent {
string event_id = 1;
string aggregate_id = 2; // roleId
string event_type = 3;
int64 occurred_at = 4;
string role_id = 5;
string role_name = 6;
string action = 7; // 'created' | 'updated' | 'deleted'
map<string, string> metadata = 8;
}
```
> **需 coord 在 shared-proto/events.proto 中统一定义**iam 只负责填充字段并写入 outbox。
### 5.3 我消费的事件
- **当前**:无
- **未来**不主动消费业务事件iam 是权限中枢,单向发布)
### 5.4 Outbox 实现策略
```mermaid
sequenceDiagram
participant Ctl as Controller
participant Svc as IamService
participant DB as MySQL
participant Outbox as iam_outbox 表
participant Relay as OutboxRelayWorker
participant Kafka as Kafka
Ctl->>Svc: register(dto)
Svc->>DB: BEGIN TX
Svc->>DB: INSERT iam_users
Svc->>DB: INSERT iam_user_roles
Svc->>Outbox: INSERT event (status=pending)
Svc->>DB: COMMIT TX
Svc-->>Ctl: {user, tokens}
loop 每 100ms 轮询
Relay->>Outbox: SELECT * WHERE status='pending' LIMIT 100
Outbox-->>Relay: events[]
Relay->>Kafka: produce(topic, payload)
Kafka-->>Relay: ack
Relay->>Outbox: UPDATE status='published', published_at=NOW()
end
```
**Relay Worker 实现**
- 独立 `@Injectable()` 服务,`OnModuleInit` 启动轮询
- 每 100ms 查询 `status='pending'` 的事件,批量投递 Kafka
- 投递失败重试 3 次,超过后标记 `status='failed'`,记录日志
- Kafka 未启动时不阻塞主服务try/catch + 日志警告)
## 6. 横切关注点对齐清单
### 6.1 权限装饰器(所有端点及对应权限常量)
| 端点 | 权限常量 |
| ----------------------------------------------- | ----------------- |
| POST /iam/register | 公开(无装饰器) |
| POST /iam/login | 公开 |
| POST /iam/refresh | 公开 |
| GET /iam/.well-known/jwks.json | 公开 |
| POST /iam/logout | `IAM_USER_READ` |
| GET /iam/me | `IAM_USER_READ` |
| GET /iam/viewports | `IAM_USER_READ` |
| GET /iam/permissions/effective | `IAM_USER_READ` |
| GET /iam/users/:id/parents | `IAM_USER_READ` |
| GET /iam/roles | `IAM_ROLE_MANAGE` |
| POST /iam/roles | `IAM_ROLE_MANAGE` |
| PUT /iam/roles/:id | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id | `IAM_ROLE_MANAGE` |
| GET /iam/permissions | `IAM_ROLE_MANAGE` |
| GET /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
| POST /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
| DELETE /iam/users/:id/roles/:roleId | `IAM_ROLE_MANAGE` |
| GET /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
| POST /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id/permissions/:permissionId | `IAM_ROLE_MANAGE` |
| GET /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
| POST /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
| PUT /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
| POST /iam/users/:studentId/parents | `IAM_ROLE_MANAGE` |
**权限常量清单**P2 完整化):
```typescript
export const Permissions = {
// 用户管理
IAM_USER_CREATE: "IAM_USER_CREATE",
IAM_USER_READ: "IAM_USER_READ",
IAM_USER_UPDATE: "IAM_USER_UPDATE",
IAM_USER_DELETE: "IAM_USER_DELETE",
// 角色管理
IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE",
// 视口管理
IAM_VIEWPORT_MANAGE: "IAM_VIEWPORT_MANAGE",
// 家长-学生关系管理
IAM_RELATION_MANAGE: "IAM_RELATION_MANAGE",
} as const;
```
### 6.2 错误码清单(带前缀)
| 错误码 | HTTP | 触发条件 |
| ----------------------- | ---- | ---------------------------------------------------- |
| `IAM_VALIDATION_ERROR` | 400 | Zod 校验失败 |
| `IAM_UNAUTHORIZED` | 401 | 未登录、密码错误、token 失效、refresh token 在黑名单 |
| `IAM_PERMISSION_DENIED` | 403 | 缺少所需权限点 |
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限/视口不存在 |
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复、家长-学生关系已存在 |
| `IAM_BUSINESS_ERROR` | 422 | 账号禁用、系统角色禁止删除、refresh token 已撤销 |
| `IAM_RATE_LIMITED` | 429 | 登录失败次数过多P2 可选,限流在 Gateway |
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
| `IAM_OUTBOX_ERROR` | 500 | Outbox 写入或投递失败 |
### 6.3 Logger 初始化位置与配置
- **位置**`src/shared/observability/logger.ts`(已有)
- **配置**pino`base: { service: 'iam', version: '0.1.0' }``level: env.LOG_LEVEL`
- **P2 新增**:日志中注入 `traceId`(从 `x-request-id` 头读取OTel auto-instrumentation 已覆盖)
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| ------------------------------------- | --------- | ------------------------ | ------------------------------ |
| `iam_requests_total` | Counter | method, endpoint, status | 请求总数(已有) |
| `iam_request_duration_seconds` | Histogram | method, endpoint | 请求延迟(已有) |
| `iam_login_attempts_total` | Counter | result(success/failure) | 登录尝试次数P2 新增) |
| `iam_login_duration_seconds` | Histogram | — | 登录耗时P2 新增) |
| `iam_jwt_issued_total` | Counter | type(access/refresh) | JWT 签发次数P2 新增) |
| `iam_permission_cache_hits_total` | Counter | — | 权限缓存命中P2 新增) |
| `iam_permission_cache_misses_total` | Counter | — | 权限缓存未命中P2 新增) |
| `iam_outbox_pending` | Gauge | — | Outbox 待投递事件数P2 新增) |
| `iam_outbox_publish_duration_seconds` | Histogram | — | Outbox 投递耗时P2 新增) |
### 6.5 Tracer 初始化位置
- **位置**`src/shared/observability/tracer.ts`(已有)
- **配置**NodeSDK + OTLP HTTP exporter`serviceName: 'iam'`
- **P2 保持**auto-instrumentations 覆盖 HTTP/Express/Drizzlemysql2
### 6.6 /healthz 检查逻辑
- **端点**`GET /healthz`
- **逻辑**:仅返回进程存活,不检查依赖
- **响应**`{ status: 'ok', service: 'iam', timestamp: ISO }`
### 6.7 /readyz 检查逻辑P2 改造)
- **端点**`GET /readyz`
- **逻辑**P2 新增 Redis + Kafka 检查):
```typescript
async readiness() {
const checks = await Promise.allSettled([
this.checkDb(), // db.execute(sql`SELECT 1`)
this.checkRedis(), // redis.ping()
this.checkKafka(), // kafka.admin().listTopics()(轻量探活)
]);
const allOk = checks.every(r => r.status === 'fulfilled');
if (!allOk) throw 503;
return { status: 'ok', service: 'iam', timestamp, checks };
}
```
- **失败**HTTP 503响应体含失败项详情
### 6.8 优雅关闭顺序P2 改造)
```typescript
async onApplicationShutdown(signal?: string) {
// 1. 停止接收新请求NestJS 自动)
// 2. 停止 OutboxRelayWorker停止轮询
await this.relayWorker.stop();
// 3. 关闭 Kafka producer
await this.kafkaProducer.disconnect();
// 4. 关闭 Redis 连接
await this.redisClient.quit();
// 5. 关闭 MySQL 连接池
await closeDb();
// 6. 关闭 Tracer
await shutdownTracer();
}
```
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ----------- | ----- | ------------------------------------------- | -------------------------------- |
| 被调用 | api-gateway | HTTP | `POST /iam/register` 等 | Gateway 反向代理 `/api/v1/iam/*` |
| 被调用 | teacher-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | BFF 聚合用户身份与视口 |
| 被调用 | student-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | 同上P3 |
| 被调用 | parent-bff | HTTP | `GET /iam/me`、`GET /iam/users/:id/parents` | 同上 + 家长-学生关系P4 |
| 被调用 | api-gateway | HTTP | `GET /iam/.well-known/jwks.json` | Gateway 拉取 RS256 公钥校验 JWT |
| 发布 | — | Kafka | `edu.identity.user.created` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.updated` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.deleted` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.role_changed` | 自身缓存失效 / msg 审计 |
| 发布 | — | Kafka | `edu.identity.role.created` | msg 审计 |
| 发布 | — | Kafka | `edu.identity.role.updated` | 自身缓存失效 / msg 审计 |
| 消费 | — | — | — | iam 不消费外部事件 |
### 7.1 端口分配
| 服务 | 端口 | 备注 |
| -------- | ----- | ------------------------------------------------------------ |
| iam HTTP | 3002 | 已有REST 入口 |
| iam gRPC | 50052 | P3 引入(与 core-edu 50053 等区分,需 coord 全局端口表确认) |
### 7.2 Topic 命名(遵循 004 §7.2
- `edu.identity.user.created`
- `edu.identity.user.updated`
- `edu.identity.user.deleted`
- `edu.identity.user.role_changed`
- `edu.identity.role.created`
- `edu.identity.role.updated`
> **需 coord 在全局 Topic 表中登记**,避免与 core-edu 的 `edu.identity.user.created` 冲突004 §7.2 已记录 iam 为生产者)。
## 8. 风险与假设
### 8.1 架构决策点(需 coord 仲裁)
| # | 决策点 | 我的建议 | 风险 |
| --- | ------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 1 | RS256 密钥管理 | P2 本地文件 `IAM_PRIVATE_KEY_PATH` / `IAM_PUBLIC_KEY_PATH`P6 迁 Vault | 密钥文件权限管理;容器挂载 |
| 2 | 公钥暴露端点 | `GET /iam/.well-known/jwks.json`JWK Set 标准) | Gateway 需实现 JWK 解析 |
| 3 | 权限缓存失效 | 角色变更时本地主动 `DEL iam:perms:{userId}` + 发事件 | 缓存与 DB 短暂不一致(< 5min TTL |
| 4 | Outbox 实现 | iam 自建轻量 OutboxP2 先于 core-edu 落地) | 与 core-edu P3 Outbox 模式需对齐(建议 coord 在 shared-ts 提供通用工具) |
| 5 | gRPC 引入时机 | P2 仅 RESTP3 随 core-edu 引入 gRPC server | teacher-bff P2 仍走 HTTPP3 改 gRPC 需协调 ai03 |
| 6 | DataScope 枚举 | schema 用字符串枚举proto 用数值枚举 L0-L5 映射 | 跨层映射需文档化 |
| 7 | `parent_student_relations` 归属 | 归 iam身份关系优先 | core-edu 查询家长需走 iam API |
| 8 | `class_subject_teachers` 归属 | 归 core-edu教学组织数据 | iam 只存 userId + 角色,不存任教关系 |
| 9 | PermissionGuard 改造 | DB 驱动 + Redis 缓存,废弃本地 ROLE_PERMISSIONS map | 性能:每次请求多一次缓存查询 |
| 10 | AuthMiddleware 注册 | P2 仍不注册Controller 直读 header与 Gateway 透传策略一致) | 多 Controller 重复读 header 代码 |
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| -------------------- | ---------------------------------- | -------------------------------------------------------- |
| Redis 不可用 | 权限校验回退到 DB 查询,性能下降 | `getEffectivePermissions` catch 后走 DB不抛错 |
| Kafka 不可用 | Outbox 事件积压,下游服务感知延迟 | Relay Worker 重试 + `status='failed'` 记录,不阻塞主流程 |
| RS256 密钥泄露 | 任何人可伪造 JWT | 密钥文件权限 600 + 容器 Secret 挂载 + 定期轮换P6 |
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 事件驱动失效 + 短 TTL |
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天)或归档到 ClickHouse |
### 8.3 假设
- 假设 coord 在 shared-proto/events.proto 中统一定义 `UserEvent` / `RoleEvent` messageiam 只填充字段)
- 假设 api-gateway 实现 JWK Set 拉取与 RS256 公钥校验ai01 负责)
- 假设 teacher-bff 仍走 HTTP 调用 iamai03 负责P3 改 gRPC 时协调)
- 假设 shared-ts 在 P2 不提供通用 Outbox 工具iam 自建P3 core-edu 落地后回写到 shared-ts
### 8.4 未决问题(需 coord 回复)
1. shared-ts 是否在 P2 提供通用 Outbox 工具?还是 iam 自建?
2. events.proto 中 `UserEvent` / `RoleEvent` 由 coord 统一定义,还是 iam 提交 PR
3. iam gRPC 端口 50052 是否与全局端口表冲突?
4. `class_subject_teachers` 表归属确认(我建议归 core-edupending-features §P2 写在 iam
---
**AI Agent**: ai02 (iam-module)
**Branch**: main单仓库并行模式见 ai-allocation §9.1
**Coordinator**: coord-ai

View File

@@ -20,6 +20,7 @@
"pino": "^9.4.0", "pino": "^9.4.0",
"prom-client": "^15.1.0", "prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/sdk-node": "^0.53.0", "@opentelemetry/sdk-node": "^0.53.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0", "@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"zod": "^3.23.0", "zod": "^3.23.0",

View File

@@ -1,7 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { IamModule } from './iam/iam.module.js'; import { APP_GUARD } from "@nestjs/core";
import { IamModule } from "./iam/iam.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({ @Module({
imports: [IamModule], imports: [IamModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,10 +1,11 @@
import { drizzle } from 'drizzle-orm/mysql2'; import { drizzle } from "drizzle-orm/mysql2";
import mysql from 'mysql2/promise'; import type { MySql2Database } from "drizzle-orm/mysql2";
import { env } from './env.js'; import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null; let pool: mysql.Pool | null = null;
export function getDb() { export function getDb(): MySql2Database {
if (!pool) { if (!pool) {
pool = mysql.createPool({ pool = mysql.createPool({
uri: env.DATABASE_URL, uri: env.DATABASE_URL,

View File

@@ -1,37 +1,53 @@
import { Body, Controller, Get, Post, Req } from "@nestjs/common"; import { Body, Controller, Get, Post, Req } from "@nestjs/common";
import type { Request } from "express"; import type { Request } from "express";
import { IamService } from "./iam.service.js"; import { IamService } from "./iam.service.js";
import type { TokenPair, UserInfo } from "./iam.service.js";
import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js"; import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js"; import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("iam") @Controller("iam")
export class IamController { export class IamController {
constructor(private readonly service: IamService) {} constructor(private readonly service: IamService) {}
// 公开端点:注册,不设权限校验
@Post("register") @Post("register")
async register(@Body() body: unknown) { async register(
@Body() body: unknown,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = registerSchema.parse(body); const dto = registerSchema.parse(body);
const result = await this.service.register(dto); const result = await this.service.register(dto);
return { success: true as const, data: result }; return { success: true as const, data: result };
} }
// 公开端点:登录,不设权限校验
@Post("login") @Post("login")
async login(@Body() body: unknown) { async login(
@Body() body: unknown,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = loginSchema.parse(body); const dto = loginSchema.parse(body);
const result = await this.service.login(dto); const result = await this.service.login(dto);
return { success: true as const, data: result }; return { success: true as const, data: result };
} }
// 公开端点:刷新令牌,不设权限校验
@Post("refresh") @Post("refresh")
async refresh(@Body() body: unknown) { async refresh(
@Body() body: unknown,
): Promise<{ success: true; data: TokenPair }> {
const dto = refreshTokenSchema.parse(body); const dto = refreshTokenSchema.parse(body);
const tokens = await this.service.refresh(dto.refreshToken); const tokens = await this.service.refresh(dto.refreshToken);
return { success: true as const, data: tokens }; return { success: true as const, data: tokens };
} }
@Get("me") @Get("me")
async me(@Req() req: Request) { @RequirePermission(Permissions.IAM_USER_READ)
const userId = req.headers["x-user-id"] as string; async me(@Req() req: Request): Promise<{ success: true; data: UserInfo }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedError("Missing x-user-id header"); throw new UnauthorizedError("Missing x-user-id header");
} }

View File

@@ -10,6 +10,7 @@ import {
roleViewports, roleViewports,
} from "./iam.schema.js"; } from "./iam.schema.js";
import type { User, Role, Permission, RoleViewport } from "./iam.schema.js"; import type { User, Role, Permission, RoleViewport } from "./iam.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js";
export class IamRepository { export class IamRepository {
async createUser(data: { async createUser(data: {
@@ -22,7 +23,7 @@ export class IamRepository {
await db.insert(users).values(data); await db.insert(users).values(data);
const [result] = await db.select().from(users).where(eq(users.id, data.id)); const [result] = await db.select().from(users).where(eq(users.id, data.id));
if (!result) { if (!result) {
throw new Error("Failed to create user"); throw new DatabaseError("Failed to create user");
} }
return result; return result;
} }

View File

@@ -92,7 +92,11 @@ export class IamService {
async refresh(refreshToken: string): Promise<TokenPair> { async refresh(refreshToken: string): Promise<TokenPair> {
let payload: jwt.JwtPayload; let payload: jwt.JwtPayload;
try { try {
payload = jwt.verify(refreshToken, env.JWT_SECRET) as jwt.JwtPayload; const decoded = jwt.verify(refreshToken, env.JWT_SECRET);
if (typeof decoded === "string") {
throw new UnauthorizedError("Invalid refresh token");
}
payload = decoded;
} catch { } catch {
throw new UnauthorizedError("Invalid refresh token"); throw new UnauthorizedError("Invalid refresh token");
} }
@@ -101,9 +105,14 @@ export class IamService {
throw new UnauthorizedError("Invalid token type"); throw new UnauthorizedError("Invalid token type");
} }
const user = await this.repository.findUserById(payload.sub as string); const sub = typeof payload.sub === "string" ? payload.sub : undefined;
if (!sub) {
throw new UnauthorizedError("Invalid token subject");
}
const user = await this.repository.findUserById(sub);
if (!user) { if (!user) {
throw new NotFoundError("User", payload.sub as string); throw new NotFoundError("User", sub);
} }
return this.issueTokens(user).then((r) => r.tokens); return this.issueTokens(user).then((r) => r.tokens);

View File

@@ -1,7 +1,13 @@
import { Controller, Get, Req } from "@nestjs/common"; import { Controller, Get, Req } from "@nestjs/common";
import type { Request } from "express"; import type { Request } from "express";
import { IamService } from "./iam.service.js"; import { IamService } from "./iam.service.js";
import type { ViewportItem } from "./iam.service.js";
import type { Role, Permission } from "./iam.schema.js";
import { UnauthorizedError } from "../shared/errors/application-error.js"; import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
// RBAC 管理端点:角色/权限/视口查询 // RBAC 管理端点:角色/权限/视口查询
@Controller("iam") @Controller("iam")
@@ -10,8 +16,12 @@ export class RbacController {
// 获取当前用户的视口配置L1 导航) // 获取当前用户的视口配置L1 导航)
@Get("viewports") @Get("viewports")
async viewports(@Req() req: Request) { @RequirePermission(Permissions.IAM_USER_READ)
const userId = req.headers["x-user-id"] as string; async viewports(
@Req() req: Request,
): Promise<{ success: true; data: ViewportItem[] }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedError("Missing x-user-id header"); throw new UnauthorizedError("Missing x-user-id header");
} }
@@ -21,8 +31,12 @@ export class RbacController {
// 获取当前用户的有效权限 // 获取当前用户的有效权限
@Get("permissions/effective") @Get("permissions/effective")
async effectivePermissions(@Req() req: Request) { @RequirePermission(Permissions.IAM_USER_READ)
const userId = req.headers["x-user-id"] as string; async effectivePermissions(
@Req() req: Request,
): Promise<{ success: true; data: { permissions: string[] } }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedError("Missing x-user-id header"); throw new UnauthorizedError("Missing x-user-id header");
} }
@@ -32,14 +46,16 @@ export class RbacController {
// 列出所有角色(管理端用) // 列出所有角色(管理端用)
@Get("roles") @Get("roles")
async roles() { @RequirePermission(Permissions.IAM_ROLE_MANAGE)
async roles(): Promise<{ success: true; data: Role[] }> {
const data = await this.service.getAllRoles(); const data = await this.service.getAllRoles();
return { success: true as const, data }; return { success: true as const, data };
} }
// 列出所有权限点(管理端用) // 列出所有权限点(管理端用)
@Get("permissions") @Get("permissions")
async permissions() { @RequirePermission(Permissions.IAM_ROLE_MANAGE)
async permissions(): Promise<{ success: true; data: Permission[] }> {
const data = await this.service.getAllPermissions(); const data = await this.service.getAllPermissions();
return { success: true as const, data }; return { success: true as const, data };
} }

View File

@@ -6,6 +6,7 @@ import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -19,7 +20,7 @@ async function bootstrap(): Promise<void> {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 // 返回 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.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });

View File

@@ -1,5 +1,9 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; import {
import { Request, Response, NextFunction } from 'express'; Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
@@ -10,15 +14,18 @@ export interface AuthenticatedRequest extends Request {
export class AuthMiddleware implements NestMiddleware { export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void { use(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
// 从 Gateway 注入的头部读取用户信息 // 从 Gateway 注入的头部读取用户信息
const userId = req.headers['x-user-id'] as string | undefined; const userIdHeader = req.headers["x-user-id"];
const rolesHeader = req.headers['x-user-roles'] as string | undefined; const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedException('Missing x-user-id header'); throw new UnauthorizedException("Missing x-user-id header");
} }
req.userId = userId; req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(',') : []; req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next(); next();
} }
} }

View File

@@ -1,23 +1,32 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import {
import type { Reflector } from '@nestjs/core'; Injectable,
import { PermissionDeniedError } from '../shared/errors/application-error.js'; CanActivate,
import type { AuthenticatedRequest } from './auth.middleware.js'; 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 type Permission = export type Permission =
| 'IAM_USER_CREATE' | "IAM_USER_CREATE"
| 'IAM_USER_READ' | "IAM_USER_READ"
| 'IAM_USER_UPDATE' | "IAM_USER_UPDATE"
| 'IAM_USER_DELETE' | "IAM_USER_DELETE"
| 'IAM_ROLE_MANAGE'; | "IAM_ROLE_MANAGE";
export const Permissions = { export const Permissions = {
IAM_USER_CREATE: 'IAM_USER_CREATE' as const, IAM_USER_CREATE: "IAM_USER_CREATE" as const,
IAM_USER_READ: 'IAM_USER_READ' as const, IAM_USER_READ: "IAM_USER_READ" as const,
IAM_USER_UPDATE: 'IAM_USER_UPDATE' as const, IAM_USER_UPDATE: "IAM_USER_UPDATE" as const,
IAM_USER_DELETE: 'IAM_USER_DELETE' as const, IAM_USER_DELETE: "IAM_USER_DELETE" as const,
IAM_ROLE_MANAGE: 'IAM_ROLE_MANAGE' as const, IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE" as const,
}; };
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = { const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [ admin: [
Permissions.IAM_USER_CREATE, Permissions.IAM_USER_CREATE,
@@ -31,26 +40,32 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {} constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { 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 request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? []; const roles = request.userRoles ?? [];
for (const role of roles) { for (const role of roles) {
const perms = ROLE_PERMISSIONS[role]; const perms = ROLE_PERMISSIONS[role];
if (perms && perms.includes(this.requiredPermission)) { if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true; return true;
} }
} }
throw new PermissionDeniedError(this.requiredPermission); throw new PermissionDeniedError(requiredPermissions.join(", "));
} }
} }
// 工厂函数,用于装饰器
export function createPermissionGuardFactory(_reflector: Reflector) {
return {
create: (permission: Permission) => new PermissionGuard(permission),
};
}

View File

@@ -1,7 +1,13 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common'; import {
import { Request, Response } from 'express'; Catch,
import { ZodError } from 'zod'; ExceptionFilter,
import { ApplicationError } from './application-error.js'; ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch() @Catch()
export class GlobalErrorFilter implements ExceptionFilter { export class GlobalErrorFilter implements ExceptionFilter {
@@ -12,7 +18,9 @@ export class GlobalErrorFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown'; const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500; let statusCode = 500;
let body: Record<string, unknown>; let body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'IAM_VALIDATION_ERROR', code: "IAM_VALIDATION_ERROR",
message: 'Validation failed', message: "Validation failed",
details: exception.flatten(), details: exception.flatten(),
traceId, traceId,
}, },
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'HTTP_ERROR', code: "HTTP_ERROR",
message, message,
traceId, traceId,
}, },
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'INTERNAL_ERROR', code: "INTERNAL_ERROR",
message: 'An unexpected error occurred', message: "An unexpected error occurred",
traceId, traceId,
}, },
}; };
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body); response.status(statusCode).json(body);
} }
private extractHttpMessage(res: string | object, exception: HttpException): string { private extractHttpMessage(
if (typeof res === 'string') { res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res; return res;
} }
if (res && typeof res === 'object' && 'message' in res) { if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段) // 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message; const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message; return typeof msg === "string" ? msg : exception.message;
} }
return exception.message; return exception.message;
} }

View File

@@ -1,6 +1,7 @@
import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { env } from '../../config/env.js'; import { NodeSDK } from "@opentelemetry/sdk-node";
import { env } from "../../config/env.js";
let sdk: NodeSDK | null = null; let sdk: NodeSDK | null = null;
@@ -8,14 +9,15 @@ export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return; if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({ sdk = new NodeSDK({
serviceName: 'iam', serviceName: "iam",
traceExporter: new OTLPTraceExporter({ traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`, url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}), }),
instrumentations: [getNodeAutoInstrumentations()],
}); });
sdk.start(); sdk.start();
console.log('Tracer initialized'); console.log("Tracer initialized with auto-instrumentations");
} }
export async function shutdownTracer(): Promise<void> { export async function shutdownTracer(): Promise<void> {

View File

@@ -8,6 +8,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"incremental": false,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],

View File

@@ -27,10 +27,12 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"@opentelemetry/sdk-node": "^0.55.0", "@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0" "@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/auto-instrumentations-node": "^0.55.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",

View File

@@ -1,10 +1,15 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { NotificationsModule } from "./notifications/notifications.module.js"; import { NotificationsModule } from "./notifications/notifications.module.js";
import { HealthModule } from "./shared/health/health.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"; import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({ @Module({
imports: [NotificationsModule, HealthModule], imports: [NotificationsModule, HealthModule],
providers: [LifecycleService], providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,35 +1,24 @@
import { Client } from "@elastic/elasticsearch"; import { Client } from "@elastic/elasticsearch";
import { env } from "./env.js"; 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 export const esClient: Client | null = env.ES_URL
? new Client({ node: env.ES_URL }) ? new Client({ node: env.ES_URL })
: null; : null;
/**
* 探活 ES 连接。失败仅记录日志,不抛错(降级模式)。
*/
export async function checkEsConnection(): Promise<void> { export async function checkEsConnection(): Promise<void> {
if (!esClient) { if (!esClient) {
console.log("Elasticsearch disabled (ES_URL not set)"); logger.info("Elasticsearch disabled (ES_URL not set)");
return; return;
} }
try { try {
await esClient.ping(); await esClient.ping();
console.log("Elasticsearch connected"); logger.info("Elasticsearch connected");
} catch (err) { } catch (err) {
console.error("Elasticsearch connection failed:", err); logger.error({ err }, "Elasticsearch connection failed");
} }
} }
/**
* 关闭 ES 客户端连接。
*/
export async function closeEs(): Promise<void> { export async function closeEs(): Promise<void> {
if (esClient) { if (esClient) {
await esClient.close(); await esClient.close();
@@ -51,23 +40,20 @@ export interface SearchResult {
hits: SearchHit[]; hits: SearchHit[];
} }
/**
* 安全索引文档。client 为 null 或失败时跳过(降级模式),返回是否成功。
*/
export async function safeIndex(params: IndexParams): Promise<boolean> { export async function safeIndex(params: IndexParams): Promise<boolean> {
if (!esClient) return false; if (!esClient) return false;
try { try {
await esClient.index(params); await esClient.index(params);
return true; return true;
} catch (err) { } catch (err) {
console.error("Elasticsearch index failed:", err); logger.error(
{ err, index: params.index, id: params.id },
"Elasticsearch index failed",
);
return false; return false;
} }
} }
/**
* 安全搜索。client 为 null 或失败时返回空数组(降级模式)。
*/
export async function safeSearch( export async function safeSearch(
index: string, index: string,
query: Record<string, unknown>, query: Record<string, unknown>,
@@ -75,13 +61,25 @@ export async function safeSearch(
if (!esClient) return { hits: [] }; if (!esClient) return { hits: [] };
try { try {
const result = await esClient.search({ index, query }); const result = await esClient.search({ index, query });
const hits = (result.hits.hits as unknown[]).map((raw) => { const hits: SearchHit[] = [];
const hit = raw as { _id: string; _source: Record<string, unknown> }; for (const raw of result.hits.hits) {
return { _id: hit._id, _source: hit._source }; 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 }; return { hits };
} catch (err) { } catch (err) {
console.error("Elasticsearch search failed:", err); logger.error({ err, index }, "Elasticsearch search failed");
return { hits: [] }; return { hits: [] };
} }
} }

View File

@@ -8,6 +8,7 @@ import { logger } from "./shared/observability/logger.js";
import { checkEsConnection, closeEs } from "./config/elasticsearch.js"; import { checkEsConnection, closeEs } from "./config/elasticsearch.js";
import { closeDb } from "./config/database.js"; import { closeDb } from "./config/database.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -19,13 +20,11 @@ async function bootstrap(): Promise<void> {
logger: ["log", "error", "warn"], logger: ["log", "error", "warn"],
}); });
// 无全局路由前缀Gateway 会去掉 /api/v1再转发至本服务。
app.useGlobalFilters(new GlobalErrorFilter()); app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks(); app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", metricsRegistry.contentType); res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });

View File

@@ -0,0 +1,30 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
userRoles?: string[];
}
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -0,0 +1,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(", "));
}
}

View File

@@ -2,8 +2,6 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
HttpException,
HttpStatus,
Param, Param,
Post, Post,
Put, Put,
@@ -11,42 +9,42 @@ import {
Req, Req,
} from "@nestjs/common"; } from "@nestjs/common";
import { NotificationsService } from "./notifications.service.js"; import { NotificationsService } from "./notifications.service.js";
import type { SendNotificationDto } from "./notifications.service.js"; import {
sendNotificationSchema,
interface AuthedRequest { sendNotificationBatchSchema,
headers: Record<string, string | string[] | undefined>; } from "./notifications.dto.js";
} import type { SendNotificationDto } from "./notifications.dto.js";
import {
/** Permissions,
* 从请求头读取用户身份。 RequirePermission,
* Gateway 在通过鉴权后会注入 x-user-id如未注入开发模式或匿名请求返回 null。 } from "../middleware/permission.guard.js";
*/ import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
function getUserIdFromRequest(req: AuthedRequest): string | null { import { PermissionDeniedError } from "../shared/errors/application-error.js";
const raw = req.headers["x-user-id"];
if (typeof raw === "string" && raw.length > 0) return raw;
return null;
}
@Controller("notifications") @Controller("notifications")
export class NotificationsController { export class NotificationsController {
constructor(private readonly service: NotificationsService) {} constructor(private readonly service: NotificationsService) {}
@Post() @Post()
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async send(@Body() body: unknown): Promise<{ success: true; data: unknown }> { 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 }; return { success: true, data: result };
} }
@Post("batch") @Post("batch")
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async createBatch( async createBatch(
@Body() body: unknown, @Body() body: unknown,
): Promise<{ success: true; data: unknown }> { ): Promise<{ success: true; data: unknown }> {
const dtos = (body as SendNotificationDto[]) ?? []; const dtos: SendNotificationDto[] = sendNotificationBatchSchema.parse(body);
const result = await this.service.createBatch(dtos); const result = await this.service.createBatch(dtos);
return { success: true, data: result }; return { success: true, data: result };
} }
@Get("user/:userId") @Get("user/:userId")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async listByUser( async listByUser(
@Param("userId") userId: string, @Param("userId") userId: string,
@Query("unread") unread: string, @Query("unread") unread: string,
@@ -56,6 +54,7 @@ export class NotificationsController {
} }
@Get("user/:userId/page") @Get("user/:userId/page")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async listByUserPaginated( async listByUserPaginated(
@Param("userId") userId: string, @Param("userId") userId: string,
@Query("page") page: string, @Query("page") page: string,
@@ -72,29 +71,22 @@ export class NotificationsController {
} }
@Put(":id/read") @Put(":id/read")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async markAsRead(@Param("id") id: string): Promise<{ success: true }> { async markAsRead(@Param("id") id: string): Promise<{ success: true }> {
await this.service.markAsRead(id); await this.service.markAsRead(id);
return { success: true }; return { success: true };
} }
@Get("search") @Get("search")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async search( async search(
@Req() req: AuthedRequest, @Req() req: AuthenticatedRequest,
@Query("q") q: string, @Query("q") q: string,
@Query("userId") userIdParam: string, @Query("userId") userIdParam: string,
): Promise<{ success: true; data: unknown }> { ): Promise<{ success: true; data: unknown }> {
const userId = getUserIdFromRequest(req) ?? userIdParam; const userId = req.userId ?? userIdParam;
if (!userId) { if (!userId) {
throw new HttpException( throw new PermissionDeniedError("MSG_NOTIFICATION_READ");
{
success: false,
error: {
code: "MSG_PERMISSION_DENIED",
message: "Missing user identity",
},
},
HttpStatus.FORBIDDEN,
);
} }
const result = await this.service.search(userId, q); const result = await this.service.search(userId, q);
return { success: true, data: result }; return { success: true, data: result };

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

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { eq, and, desc, sql } from "drizzle-orm"; import { eq, and, desc, sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { db } from "../config/database.js"; import { db } from "../config/database.js";
import { import {
notifications, notifications,
@@ -10,19 +10,11 @@ import type {
Notification, Notification,
NotificationPreference, NotificationPreference,
} from "./notifications.schema.js"; } from "./notifications.schema.js";
import type { SendNotificationDto } from "./notifications.dto.js";
import { safeIndex, safeSearch } from "../config/elasticsearch.js"; import { safeIndex, safeSearch } from "../config/elasticsearch.js";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.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 { export interface SendResult {
id: string; id: string;
skipped: boolean; skipped: boolean;
@@ -39,7 +31,7 @@ export interface PaginatedResult {
@Injectable() @Injectable()
export class NotificationsService { export class NotificationsService {
async send(dto: SendNotificationDto): Promise<SendResult> { async send(dto: SendNotificationDto): Promise<SendResult> {
const id = uuidv4(); const id = randomUUID();
const channel = dto.channel ?? "in_app"; const channel = dto.channel ?? "in_app";
const [pref] = await db const [pref] = await db

View File

@@ -5,6 +5,7 @@ import {
HttpException, HttpException,
Logger, Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js"; import { ApplicationError } from "./application-error.js";
@@ -14,13 +15,12 @@ export class GlobalErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例, const response = ctx.getResponse<Response>();
// 但 msg 服务未引入 @types/express此处按 core-edu / content 模式不显式标注类型。 const request = ctx.getRequest<Request>();
const response = ctx.getResponse();
const request = ctx.getRequest();
const traceIdHeader = request.headers["x-request-id"];
const traceId = const traceId =
(request.headers["x-request-id"] as string | undefined) ?? "unknown"; typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500; let statusCode = 500;
let body: Record<string, unknown>; let body: Record<string, unknown>;
@@ -30,7 +30,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
statusCode = exception.statusCode; statusCode = exception.statusCode;
body = exception.toJSON(); body = exception.toJSON();
} else if (exception instanceof ZodError) { } else if (exception instanceof ZodError) {
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
statusCode = 400; statusCode = 400;
body = { body = {
success: false, success: false,
@@ -79,7 +78,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
return res; return res;
} }
if (res && typeof res === "object" && "message" in res) { if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message; const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message; return typeof msg === "string" ? msg : exception.message;
} }

View File

@@ -1,6 +1,7 @@
import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { env } from '../../config/env.js'; import { NodeSDK } from "@opentelemetry/sdk-node";
import { env } from "../../config/env.js";
let sdk: NodeSDK | null = null; let sdk: NodeSDK | null = null;
@@ -8,14 +9,15 @@ export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return; if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({ sdk = new NodeSDK({
serviceName: 'msg', serviceName: "msg",
traceExporter: new OTLPTraceExporter({ traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`, url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}), }),
instrumentations: [getNodeAutoInstrumentations()],
}); });
sdk.start(); sdk.start();
console.log('Tracer initialized'); console.log("Tracer initialized with auto-instrumentations");
} }
export async function shutdownTracer(): Promise<void> { export async function shutdownTracer(): Promise<void> {

View File

@@ -8,6 +8,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"incremental": false,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],

View File

@@ -0,0 +1,346 @@
# 模块理解确认书 — parent-bff
> AI 标识ai04
> 阶段:阶段 1全局理解
> 日期2026-07-09
> 状态:待 coord 审核
> 关联文档:[ai-allocation §6 模板](../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)、[pending-features P4](../../docs/architecture/roadmap/pending-features.md)
---
## 1. 我在架构中的位置
| 维度 | 内容 |
| -------------- | ---------------------------------------------------------------------------- |
| 层级 | **L4 BFF 聚合层**004 §3.1 六层架构) |
| 上游调用方 | api-gatewayGo Gin反向代理 `/api/v1/parent/*` → parent-bff:3010 |
| 下游被调用方 | iam、core-edu按 004 §4 服务依赖图parent-bff 依赖最少) |
| 通信方式(入) | HTTP RESTapi-gateway → parent-bff当前阶段设计意图为 gRPC004 §4.1 |
| 通信方式(出) | HTTP fetch当前阶段对齐 teacher-bff 模式);设计意图为 gRPC004 §4.1 |
| 微前端对接 | parent-portalai07 负责P4 阶段)通过 api-gateway 调用 parent-bff |
| 推送通道 | push-gatewayP5 阶段WebSocket/SSE 推送孩子成绩发布、作业批改等通知) |
**架构定位**004 §1.1a + §5.4
- 按"使用场景域"分 BFFparent-bff 服务于**家长场景域**,复用角色:家长
- 不按角色分 BFF新角色复用现有 BFF 通过视口差异化
- DataScope = **CHILDREN自定义级**家长只能看自己绑定孩子的数据004 §5.3 的 L0 SELF 变体dataScope 枚举值含 `children`,见 004 §5.4 iam 服务职责)
**004 §4 服务依赖图明确**
```
PBFF --> IAM
PBFF --> CoreEdu
```
parent-bff 仅依赖 iam + core-edu**不直接依赖 content / data-ana / msg / ai**。这是设计意图:家长场景的核心是"查看孩子的教学数据",教学数据由 core-edu 提供。但 ai-allocation.md §5 ai04 设计重点提到"家长通知偏好配置",意味着 parent-bff 可能需要调用 msg 服务。**此差异需 coord 仲裁**(见 §7.2)。
---
## 2. 我的限界上下文
### 2.1 我负责什么
parent-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB对齐 teacher-bff 模式)。职责:
1. **多子女账户切换**家长账号可绑定多个孩子parent-bff 维护"当前选中孩子"上下文
2. **聚合**:并行调用 iam查家长信息 + 孩子列表)+ core-edu查孩子的教学数据
3. **裁剪**:将下游返回的领域数据裁剪为家长端所需的最小字段集
4. **协议转换**:对外暴露场景化 HTTP/GraphQL 端点,对内调用下游 REST/gRPC
5. **缓存**:聚合结果 Redis 短缓存 5-30s004 §6.2 BFF 混合读策略)
6. **通知偏好**:家长可配置通知偏好(哪些事件推送、哪些不推送),存于 msg 服务或 iam 服务
### 2.2 我的聚合场景(家长视角)
| 场景 | 聚合的下游服务 | 用途 |
| ------------------ | --------------------------------------------------------------------------------- | ---------------------------------- |
| 家长首页 Dashboard | iam `/iam/me` + iam `/iam/children`(缺失) + core-edu `/grades/student/:childId` | 个人信息 + 孩子列表 + 孩子近期成绩 |
| 切换当前孩子 | iam `/iam/children`(缺失) + core-edu `/homework/class/:classId` | 切换上下文后加载孩子数据 |
| 孩子的考试成绩 | core-edu `/exams/class/:classId` + `/grades/student/:childId` | 孩子所在班级考试 + 孩子成绩 |
| 孩子的作业情况 | core-edu `/homework/class/:classId` | 孩子作业列表 + 提交状态 |
| 孩子的成绩趋势 | data-ana `/analytics/student/:childId/trend`004 未列入依赖,待仲裁) | 历史成绩曲线 |
| 孩子的学情诊断 | data-ana `/analytics/student/:childId/weakness`(待仲裁) | 薄弱知识点 |
| 班级学情对比 | data-ana `/analytics/class/:classId/performance`(待仲裁) | 孩子相对班级的位置 |
| 消息中心 | msg `/notifications`004 未列入依赖,待仲裁) | 家长通知列表 |
| 通知偏好配置 | msg 或 iam缺失 | 配置哪些事件推送 |
| 孩子的出勤 | core-edu `/attendance/student/:childId`(缺失) | 出勤记录 |
### 2.3 我不负责什么(明确边界外)
| 不负责项 | 归属服务 | 说明 |
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
| 业务数据持久化 | core-edu / iam | BFF 不写 DB |
| 权限校验 | 下游业务服务 + iam | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 让下游校验 |
| 用户认证 | iam + api-gateway | JWT 校验在 GatewayBFF 只读 `x-user-id` 头 |
| 学生-家长关系维护 | iam 或 core-edu缺失 | parent-bff 只读取关系,不维护 |
| 领域事件发布 | core-edu | BFF 不发布事件,仅可选订阅事件用于实时推送 |
| 数据范围过滤 | 下游业务服务 Repository 层 | BFF 透传 userId + childId下游按 DataScope=CHILDREN 过滤 |
| 考试批改 | core-edu | 家长不能批改,只能查看孩子成绩 |
| 孩子的作业提交 | core-edu学生端职责 | 家长不能代孩子提交作业 |
---
## 3. 我与外部的契约
### 3.1 我消费的 proto message / 下游接口
> ⚠️ **重要差距**:当前阶段 BFF→Service 走 HTTP fetch对齐 teacher-bff 现状proto 仅作"契约文档"。gRPC 落地需 coord 在 buf.gen.yaml 补 gRPC 插件。
> ⚠️ **核心依赖缺失**iam 没有"家长-学生关联查询"接口parent-bff 无法实现核心场景。
| 下游服务 | proto service设计意图 | 当前 REST 端点(实际可用) | 用途 |
| ------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------- |
| iam | `IamService.GetUserInfo` | `GET /iam/me` | 获取家长个人信息 |
| iam | `IamService.GetViewports`proto 缺失) | `GET /iam/viewports` | 获取家长端导航视口 |
| iam | `IamService.GetEffectivePermissions`proto 缺失) | `GET /iam/permissions/effective` | 获取有效权限列表 |
| iam | **`GetChildrenByParent`proto + REST 双缺失)** | **无** | 查询家长绑定的孩子列表(核心缺失) |
| iam | **`GetParentsByStudent`proto + REST 双缺失)** | **无** | 反向查询(可选) |
| classescore-edu | `ClassService.GetClass` | `GET /classes/:id` | 查孩子所在班级信息 |
| core-edu | `ExamService.GetExam` / `ListExamsByClass` | `GET /exams/class/:classId` | 查孩子班级考试 |
| core-edu | `HomeworkService.ListHomeworkByClass` | `GET /homework/class/:classId` | 查孩子作业 |
| core-edu | `GradeService.GetGrade` / `ListGradesByStudent` / `ListGradesByExam` | `GET /grades/student/:childId` / `GET /grades/exam/:examId` | 查孩子成绩 |
| core-edu | **`AttendanceService`proto + REST 双缺失)** | **无** | 查孩子出勤(全局缺失) |
| data-ana | `AnalyticsService.GetClassPerformance` / `GetStudentWeakness` / `GetLearningTrend` | **REST 未实现** | 学情分析004 未列入依赖,待仲裁) |
| msg | `NotificationService.ListNotifications` / `MarkAsRead` | `GET /notifications`msg 服务 P5 才落地) | 家长通知004 未列入依赖,待仲裁) |
| msg 或 iam | **通知偏好配置proto + REST 双缺失)** | **无** | 配置推送偏好 |
### 3.2 我暴露的 API 端点parent-bff 对外)
> 路由前缀:`/parent`(对齐 teacher-bff 用 `/teacher` 的命名规律)
> 网关路径:`/api/v1/parent/*` → api-gateway 剥离 `/api/v1` 后代理到 parent-bff:3010
| method | path | 聚合下游 | 权限(透传给下游校验) | 说明 |
| ------ | ---------------------------------------------- | ------------------ | ------------------------ | --------------------------------------- |
| GET | `/parent/dashboard` | iam + core-edu | PARENT_DASHBOARD_READ | 家长首页(含孩子列表 + 近期成绩) |
| GET | `/parent/children` | iam | PARENT_CHILDREN_READ | 我的孩子列表 |
| POST | `/parent/children/:childId/select` | BFF 内部状态) | PARENT_CHILDREN_READ | 切换当前选中孩子sessionId 或 cookie |
| GET | `/parent/children/:childId/exams` | core-edu | PARENT_EXAM_READ | 孩子的考试列表 |
| GET | `/parent/children/:childId/homework` | core-edu | PARENT_HOMEWORK_READ | 孩子的作业列表 |
| GET | `/parent/children/:childId/grades` | core-edu | PARENT_GRADE_READ | 孩子的成绩列表 |
| GET | `/parent/children/:childId/analytics/trend` | data-ana待仲裁 | PARENT_ANALYTICS_READ | 孩子成绩趋势 |
| GET | `/parent/children/:childId/analytics/weakness` | data-ana待仲裁 | PARENT_ANALYTICS_READ | 孩子薄弱知识点 |
| GET | `/parent/notifications` | msg待仲裁 | PARENT_NOTIFICATION_READ | 家长通知列表 |
| POST | `/parent/notifications/:id/read` | msg待仲裁 | PARENT_NOTIFICATION_READ | 标记已读 |
| GET | `/parent/notification-preferences` | msg 或 iam缺失 | PARENT_PREFERENCE_READ | 通知偏好配置 |
| PUT | `/parent/notification-preferences` | msg 或 iam缺失 | PARENT_PREFERENCE_UPDATE | 更新通知偏好 |
### 3.3 错误码前缀
| 前缀 | 用途 | 示例 |
| ------------- | ---------------------- | --------------------------------------------------------------------------------- |
| `PARENT_BFF_` | parent-bff 自身错误 | `PARENT_BFF_UNAUTHORIZED``PARENT_BFF_BAD_GATEWAY``PARENT_BFF_CHILD_NOT_BOUND` |
| 下游错误透传 | 下游服务错误码原样返回 | `IAM_USER_NOT_FOUND``CORE_EDU_GRADE_NOT_FOUND` |
错误类清单(对齐 teacher-bff application-error.ts + 新增家长特有错误):
- `UnauthorizedError(401)` — 缺失 `x-user-id`
- `BadGatewayError(502)` — 下游服务返回非 ok 或 fetch rejected
- `ValidationError(400)` — 入参校验失败BFF 层 Zod 校验)
- `BusinessError(422)` — 业务校验失败(如家长查询的 childId 不在自己绑定列表内)
- `NotFoundError(404)` — 资源不存在
- `InternalError(500)` — 未捕获异常
**家长特有错误**
- `PARENT_BFF_CHILD_NOT_BOUND(403)` — 家长查询的 childId 未在自己绑定的孩子列表内DataScope=CHILDREN 越权)
### 3.4 我订阅的 Kafka 事件(可选,用于实时推送)
| Topic | 事件 | 消费动作 |
| --------------------- | ----------------- | ------------------------------ |
| `edu.grade.events` | `grade.recorded` | 推送给家长(孩子成绩发布) |
| `edu.homework.events` | `homework.graded` | 推送给家长(孩子作业批改完成) |
| `edu.exam.events` | `exam.published` | 推送给家长(考试提醒) |
> ⚠️ Kafka 订阅在 P5 阶段 push-gateway 落地后才有意义P4 阶段 parent-bff 可不消费事件,仅做同步聚合。
> ⚠️ 家长通知偏好配置生效后BFF 在订阅事件时应过滤:若家长关闭了"成绩推送"偏好,则不推送该类事件。
---
## 4. 我的技术栈
| 维度 | 选型 | 依据 |
| ------------ | ------------------------------------ | ----------------------------------------------------------------------------- |
| 语言 | TypeScript 5.5+ | 004 §2.1 |
| 框架 | NestJS 10 | 004 §2.1,对齐 teacher-bff 模板 |
| ORM | **无**BFF 不访问 DB | 对齐 teacher-bff无 repository/schema/dto |
| 缓存 | Redis 7短缓存 5-30s | 004 §6.2 BFF 混合读策略 |
| 会话状态 | Redis当前选中孩子 childId | 多子女账户切换,不持久化在 BFF 内存 |
| 可观测性日志 | pino | 对齐 classes/teacher-bff |
| 可观测性指标 | prom-client`/metrics` 端点) | 对齐 teacher-bff main.ts |
| 可观测性链路 | OpenTelemetry SDK + OTLP exporter | 对齐 teacher-bff tracer.ts |
| API 风格 | **HTTP REST**(当前阶段) | 对齐 teacher-bff 现状;与 student-bff 一致,需 coord 仲裁是否统一升级 GraphQL |
| 输入校验 | Zod | 对齐 classes/teacher-bff |
| 错误处理 | GlobalErrorFilter + ApplicationError | 对齐 classes/teacher-bff |
| ESM 模式 | NodeNext + `.js` 后缀 import | 对齐 teacher-bff tsconfig |
| 测试框架 | Jest待定对齐 classes | 黄金模板要求测试覆盖率 ≥ 80% |
### 4.1 关于 GraphQL 的设计决策(待 coord 仲裁)
与 student-bff §4.1 相同的矛盾ai04 建议 P4 阶段 parent-bff **先对齐 teacher-bff 现状REST + fetch + Promise.allSettled**,与 student-bff 保持一致。此决策需 coord 仲裁。
### 4.2 关于多子女账户切换的设计
**问题**:家长账号绑定多个孩子,前端需要知道"当前选中的孩子"。
**方案对比**
| 方案 | 实现 | 优点 | 缺点 |
| -------------- | --------------------------------------------------------- | ---------------- | --------------------------------------------------- |
| A. 前端管理 | 前端在 URL query 或 localStorage 存 childId每次请求带上 | BFF 无状态,简单 | 切换后页面状态丢失风险 |
| B. BFF Session | Redis 存 `parent:userId:currentChildId`BFF 读取 | 切换全局生效 | BFF 引入会话状态,违反"无状态服务"约束004 §12.1 |
| C. JWT Claims | iam 在 JWT 中注入 `currentChildId` claim | 与认证一体化 | 切换孩子需重签 JWT成本高 |
**ai04 建议****方案 A**(前端管理 childIdBFF 无状态),符合 004 §12.1"无状态服务"约束。`POST /parent/children/:childId/select` 端点可选,仅用于记录切换日志(审计),不持久化会话。
---
## 5. 我的阶段归属
| 维度 | 内容 |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 阶段 | **P4 内容分析阶段**M11-M13 |
| 退出标准pending-features P4 | 教师查看知识图谱前置依赖Neo4j 秒级返回)→ 学生查看学情诊断ClickHouse 宽表 5s 内返回)→ CDC 链路延迟 < 5s |
| parent-bff 在 P4 的最小交付 | 家长查看孩子成绩 + 学情诊断(双轨读:实时查 core-edu 主库 + 聚合查 data-ana ClickHouse 宽表) |
| 依赖上游阶段产出 | P1api-gateway + classes 黄金模板 + shared-proto、P2iam 认证 + teacher-bff BFF 模板、P3core-edu 考试/作业/成绩 + student-bff BFF 模式验证) |
| P4 同阶段依赖 | contentP4 落地、data-anaP4 落地,学情诊断 API |
| P5 阶段扩展 | msg 通知 + push-gateway 推送 + 通知偏好配置 |
| 前置阻塞 | iam 必须先补"家长-学生关联查询"接口(见 §7.1 |
### 5.1 P4 阶段最小可行集合MVP
parent-bff 在 P4 阶段优先级:
| 优先级 | 端点 | P4 必需 | 说明 |
| ------ | -------------------------------------------------- | ------- | ------------------------------------------- |
| P0 | `/parent/children` GET | ✅ | 家长核心场景:查孩子列表(依赖 iam 补接口) |
| P0 | `/parent/children/:childId/grades` GET | ✅ | 查孩子成绩 |
| P0 | `/parent/dashboard` GET | ✅ | 家长首页 |
| P1 | `/parent/children/:childId/exams` GET | ✅ | 孩子考试 |
| P1 | `/parent/children/:childId/homework` GET | ✅ | 孩子作业 |
| P1 | `/parent/children/:childId/analytics/trend` GET | ✅ | 学情趋势(依赖 data-ana P4 落地) |
| P1 | `/parent/children/:childId/analytics/weakness` GET | ✅ | 薄弱知识点 |
| P2 | `/parent/notifications` GET | ⚠️ 可选 | P5 msg 服务落地后才有意义 |
| P2 | `/parent/notification-preferences` GET/PUT | ⚠️ 可选 | P5 落地 |
### 5.2 与 student-bff 的协同
parent-bff 与 student-bff 同属 ai04 负责,技术栈完全一致(同语言、同框架、同 BFF 模式)。两者共享:
- **teacher-bff 克隆模板**shared/ 目录结构、main.ts、env.ts、health.controller.ts 等基础代码
- **聚合模式**Promise.allSettled 并行调用 + BadGatewayError 错误处理
- **可观测性**pino + prom-client + OTel 三支柱
- **错误码前缀**`PARENT_BFF_` / `STUDENT_BFF_` 仅前缀不同
**差异点**
- parent-bff 多了"多子女账户切换"上下文
- parent-bff 多了"DataScope=CHILDREN 越权校验"(家长查询的 childId 必须在自己绑定列表内)
- parent-bff 不直接依赖 content / msg / ai按 004 §4但实际场景可能需要待 coord 仲裁)
---
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
> 对照 ai-allocation.md §6 模板第 6 节 + §10 审计模板
| 对齐项 | classes 黄金模板 | parent-bff 计划 | 备注 |
| ------------------------------- | ---------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
| 权限装饰器 `@RequirePermission` | ✅ 全部 Controller 方法 | ⚠️ **不对齐** | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 给下游校验 |
| 错误码前缀统一 | ✅ `CLASSES_` | ✅ `PARENT_BFF_` | 对齐 teacher-bff 的 `TEACHER_BFF_` 模式 |
| loggerpino | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `parent-bff` |
| metricsprom-client | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `parent_bff_` |
| tracerOpenTelemetry | ✅ OTLP exporter + auto-instrumentations | ✅ 复制 teacher-bff tracer.ts | serviceName 改 `parent-bff` |
| `/healthz` 健康检查 | ✅ liveness | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| `/readyz` 健康检查 | ✅ Drizzle `SELECT 1` | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| 优雅关闭SIGTERM | ✅ LifecycleService 关闭 DB 连接池 | ✅ main.ts 注册 SIGTERM → `app.close()` + `shutdownTracer()` | BFF 无 DB 连接 |
| 测试覆盖率 ≥ 80% | ✅ Jest | ⚠️ **待补** | BFF 测试重点是 Service 层聚合逻辑 + DataScope 越权校验 |
| Dockerfile 多阶段构建 | ✅ builder + runtime | ✅ 复制 teacher-bff Dockerfile | EXPOSE 改 3010 |
| Zod 输入验证 | ✅ Controller 层 `schema.parse(body)` | ✅ Controller 层校验 | 通知偏好更新 body 需 Zod 校验 |
| GlobalErrorFilter | ✅ `@Catch()` 全局过滤器 | ✅ 复制 teacher-bff | 注册到 main.ts |
| ESM `.js` 后缀 import | ✅ tsconfig NodeNext | ✅ 复制 teacher-bff tsconfig | 所有相对 import 带 `.js` |
| `import type` 纯类型导入 | ✅ | ✅ | 对齐 classes 规范 |
| 环境变量 Zod 校验 | ✅ `config/env.ts` | ✅ 复制 teacher-bff env.ts | 下游 URL 配置项扩展 |
### 6.1 与 teacher-bff 模板的差异点(克隆时必须改)
| 文件 | teacher-bff 现值 | parent-bff 应改为 |
| ------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
| `package.json` name | `@edu/teacher-bff` | `@edu/parent-bff` |
| `src/config/env.ts` `PORT` default | `"3003"` | `"3010"` |
| `src/config/env.ts` 下游 URL | IamServiceUrl / ClassesServiceUrl / CoreEduServiceUrl | + DataAnaServiceUrl / MsgServiceUrl按仲裁结果 |
| `src/teacher/` 目录名 | `teacher/` | `parent/` |
| `@Controller("teacher")` | `"teacher"` | `"parent"` |
| `health.controller.ts` `SERVICE_NAME` | `"teacher-bff"` | `"parent-bff"` |
| `application-error.ts` 错误码前缀 | `TEACHER_BFF_` | `PARENT_BFF_` |
| `application-error.ts` 新增错误类 | — | `ChildNotBoundError(403)`DataScope=CHILDREN 越权) |
| `metrics.ts` 指标名前缀 | `teacher_bff_` | `parent_bff_` |
| `tracer.ts` serviceName | `"teacher-bff"` | `"parent-bff"` |
| `logger.ts` service | `"teacher-bff"` | `"parent-bff"` |
| `main.ts` 启动日志 | `"Teacher BFF started"` | `"Parent BFF started"` |
| `Dockerfile` `EXPOSE` | `3003` | `3010` |
---
## 7. 风险与依赖(待 coord 仲裁)
### 7.1 上游依赖缺口(核心阻塞)
| 风险 | 影响 | 缓解措施 |
| ------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **iam 缺失"家长-学生关联查询"接口**proto + REST + schema 三缺失) | **P0 阻塞**parent-bff 核心场景无法实现 | 推动 coord 协调 ai02 在 iam 补1) `iam_student_guardians`2) Repository 查询方法3) `GET /iam/children` REST 端点4) proto `GetChildrenByParent` RPC |
| pending-features P2 提到 `parent_student_relations` 表,但 iam.schema.ts 未实现 | 表缺失 | 同上,推动 ai02 补表 |
| data-ana 服务未实现查询 APIanalytics.proto 3 个 method 无 REST 端点) | P4 学情诊断端点无法实现 | 推动 ai06 在 P4 实现 data-ana 查询 API |
| content.proto 缺 Chapter/Question 域 | parent-bff 不直接依赖 content影响较小 | 推动 coord 在 shared-proto 补全P4 content 服务落地前) |
| iam.proto 缺 Viewport/EffectivePermissions | 家长端导航视口 proto 契约不全 | 当前走 REST `/iam/viewports`proto 补全后切换 |
| 出勤attendance全局缺失 | 家长无法查孩子出勤 | 推动 coord 在 core_edu.proto 补 Attendance 域P4 或后续) |
| msg 通知偏好配置接口缺失 | 家长通知偏好无法配置 | 推动 ai05 在 msg 服务补接口P5 阶段) |
### 7.2 设计决策待仲裁
| 决策点 | 选项 | ai04 建议 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| BFF API 风格 | A. REST对齐 teacher-bff 现状)<br/>B. GraphQL对齐 004 §11.3 设计意图) | **A**(与 student-bff 保持一致) |
| BFF 是否做权限校验 | A. 不校验(对齐 teacher-bff透传 x-user-id<br/>B. 加 `@RequirePermission` 装饰器 | **A**BFF 是聚合层,权限由下游服务校验) |
| **DataScope=CHILDREN 越权校验位置** | A. BFF 校验(家长查询的 childId 必须在绑定列表内)<br/>B. 下游服务校验core-edu 接收 childId 时校验) | **A**BFF 层校验更早失败,减少下游调用;但需 iam 提供"查询家长绑定孩子列表"接口) |
| 多子女账户切换方案 | A. 前端管理 childIdBFF 无状态)<br/>B. BFF SessionRedis 存当前 childId<br/>C. JWT Claims | **A**(符合 004 §12.1 无状态约束) |
| **004 §4 依赖图与实际场景的偏差** | 004 列 parent-bff 仅依赖 iam + core-edu但家长通知、学情诊断需要 msg + data-ana | **建议扩展依赖**parent-bff → iam + core-edu + data-ana + msg与 student-bff 对齐),需 coord 仲裁并更新 004 §4 |
| Kafka 事件订阅 | A. P4 不订阅(仅同步聚合)<br/>B. P4 订阅事件推送 | **A**push-gateway P5 才落地) |
| 通知偏好存储位置 | A. msg 服务(通知域内)<br/>B. iam 服务(用户偏好域内) | **A**(通知偏好与通知发送强相关,归 msg 服务) |
| 端口分配 | 3010 | 对齐 full-stack-runbook 端口矩阵3001-3009 已用/将用) |
### 7.3 跨模块协作需求(需提交 coord 协调)
| 需求 | 涉及 AI | 协调内容 |
| ---------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------- |
| **iam 新增家长-学生关联接口**P0 阻塞) | ai02 | 补 `iam_student_guardians` 表 + Repository + `GET /iam/children` 端点 + proto `GetChildrenByParent` RPC |
| api-gateway 新增 `/parent` 路由 | ai01 | 在 main.go + config.go 新增 `ParentBffURL` 字段 + 路由块 |
| docker-compose.deploy.yml 新增 parent-bff 服务定义 | coordinfra | 端口 3010加入 edu-net + edu-shared 网络 |
| full-stack-runbook 端口矩阵更新 | coorddocs | 追加 3010 行 |
| 004 架构图状态更新 + 依赖图仲裁 | coorddocs | parent-bff 状态从"📐 需设计"改为"✅ 已实现";仲裁是否扩展 parent-bff 依赖到 data-ana + msg |
| shared-proto 补全 iam.protoViewport/EffectivePermissions/StudentParentRelation | coord | 推动 ai02 补 proto |
| shared-proto 补全 core_edu.protoAttendance 域) | coord | 推动 ai03 补 proto |
| data-ana 实现 analytics.proto 查询 API | ai06 | P4 阶段落地 |
| buf.gen.yaml 补 gRPC 插件 | coord | 决定是否在 P4 升级到 gRPC 通信 |
---
## 8. 阶段 1 自检结论
| 检查项 | 状态 |
| ------------------------------------ | ------------------------------- |
| 已读必读文档清单ai-allocation §4 | ✅ |
| 已运行 arch:scan 更新 arch.db | ✅ |
| 已查 arch:query modules / stats | ✅ |
| 已读 classes 黄金模板源码 | ✅ |
| 已读 teacher-bff BFF 模板源码 | ✅ |
| 已读 iam 认证服务源码 | ✅ |
| 已读 shared-proto 全部 .proto | ✅ |
| 已识别 proto 契约缺口 | ✅(见 §7.1 |
| 已识别端口/路由预留情况 | ✅3010 可用,路由未预留) |
| 已识别设计决策待仲裁项 | ✅(见 §7.2 |
| 已识别跨模块协作需求 | ✅(见 §7.3 |
| 已识别 P0 阻塞项 | ✅iam 家长-学生关联接口缺失) |
**ai04 阶段 1 交付完成,请 coord 审核。审核通过后进入阶段 2模块架构设计文档**
**特别提示 coord**parent-bff 存在 P0 阻塞项——iam 缺失"家长-学生关联查询"接口。建议 coord 优先协调 ai02 在 P3 阶段parent-bff P4 落地前)补全此接口,否则 P4 阶段 parent-bff 无法启动实施。

View File

@@ -0,0 +1,161 @@
# 模块理解确认书 — push-gateway
> AIai01Go 网关层)
> 阶段:阶段 1 交付物
> 日期2026-07-09
> 关联:[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[AI 分配方案](../../../docs/architecture/ai-allocation.md)
---
## 1. 我在架构中的位置
- **层级**L3 网关层004 §3.1 六层架构,与 api-gateway 并列)
- **上游**
- 4 个微前端(浏览器/移动端通过 WebSocket 长连接接入)
- msg 服务(通过 HTTP `/internal/push` `/internal/broadcast` 调用)
- **下游**
- msg 服务P5 设计重点之一是与 msg 的 gRPC 推送通道协议;当前骨架走 HTTP
- iam 服务(拉取 JWKS 公钥校验 JWTP2 起)
- Redis跨实例 Pub/Sub 广播P5 待实现)
- **通信方式**
- 入口WebSocket`gorilla/websocket`+ HTTP内部 API
- 出口HTTP调 msg/ Redis Pub/Sub跨实例广播
- **不持有业务状态**:仅持有 WebSocket 连接池in-memory `map[userID]map[*Connection]bool`),无 DB
## 2. 我的限界上下文
- **我负责**
- WebSocket 长连接生命周期(认证 → 心跳 → 断线清理)
- 在线用户 session 映射userID → 多个 WebSocket 连接)
- 接收 msg 服务的推送请求,投递到目标用户的在线连接
- 跨实例广播P5 待实现Redis Pub/Sub
- **聚合/实体**:无业务聚合,仅 `Connection` 值对象UserID + conn + send chan
- **业务领域**:不属于 D1-D6属于基础设施层
- **我不负责**
- 通知业务逻辑msg 服务负责:通知模板、已读/未读、通知偏好)
- 推送内容持久化msg 服务落 MySQL
- 离线推送msg 服务通过 SMS/邮件渠道补推)
- JWT 签发iam 服务签发,本服务只校验)
## 3. 我与外部的契约
### 3.1 我消费的 proto message
| proto | message | 用途 |
| --------- | -------------- | ----------------------------------------------------------------------------------- |
| msg.proto | `Notification` | 接收 msg 服务的推送请求时,结构对齐(虽走 HTTP JSON但字段与 `Notification` 一致) |
| iam.proto | `UserInfo` | P2 起 RS256 校验 JWT通过 JWKS 端点,非 gRPC |
> 当前骨架用 HTTP JSON 推送(`{userId, event, data}`**ai-allocation §5 设计重点要求**与 msg 的 gRPC 推送通道协议。阶段 2 设计文档须明确 gRPC 契约(`PushService.PushToUser` / `PushService.Broadcast`)。
### 3.2 我暴露的 API 端点
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | --------------------- | ------------------------------------------- | ------------------------------ |
| GET | `/healthz` | 无 | liveness |
| GET | `/readyz` | 无(待实现) | readiness应检查 Redis 连接) |
| GET | `/ws?token=JWT` | JWTquery 参数或 `Authorization: Bearer` | WebSocket 升级端点 |
| POST | `/internal/push` | 待补(内部 API应校验调用方身份 | msg 服务定向推送 |
| POST | `/internal/broadcast` | 待补 | msg 服务广播 |
| GET | `/metrics` | 无(待实现) | Prometheus 指标 |
### 3.3 我发布/消费的 Kafka 事件
**当前**:无(骨架用 HTTP `/internal/*`)。
**P5 设计目标**ai-allocation §5
- **消费**`edu.notification.events`msg 服务发布的 `NotificationRequested` 事件),消费后投递到对应用户的在线连接
- **不发布**任何 Kafka 事件(推送结果通过 gRPC response 返 msg
> 阶段 2 设计文档须明确:是 HTTP `/internal/push` 还是 Kafka 消费?建议**双通道**
>
> - HTTP `/internal/push`msg 同步调用,需立即知道推送结果(用户在线/离线)
> - Kafka 消费:广播类通知(全校广播),解耦 + 削峰
### 3.4 错误码前缀
| 错误码 | HTTP | 触发条件 |
| ----------------- | ---- | --------------------------------------------------- |
| `UNAUTHORIZED` | 401 | 缺失 token / token 无效 / claims 解析失败 |
| `INVALID_REQUEST` | 400 | 请求体 JSON 解析失败 |
| `INVALID_PAYLOAD` | 400 | event/data 字段缺失或格式错误 |
| `PUSH_FAILED` | 500 | 投递失败(当前不会触发,`SendToUser` 静默返回 nil |
| `INTERNAL_ERROR` | 500 | panic 兜底 |
## 4. 我的技术栈
- **语言**Go 1.22+go.mod 声明 1.25.0,需与 Dockerfile 对齐)
- **框架**Gin v1.12.0
- **核心依赖**
- `github.com/gorilla/websocket` v1.5.3WebSocket
- `github.com/golang-jwt/jwt/v5` v5.2.1JWT 校验)
- `go.opentelemetry.io/otel` v1.44.0 + `otelgin` v0.69.0
- **存储**:无 DBRedisP5 待引入,用于跨实例 Pub/Sub
- **构建**Dockerfile 单阶段builder→runner 但未做非 root 用户、healthcheck、ldflags 优化)
## 5. 我的阶段归属
- **阶段**P5沟通与 AI 阶段)
- **当前阶段目标**pending-features §P5
- WebSocket 长连接管理 + 消费 Kafka 广播 + Redis PubSub 跨实例同步
- 单节点支撑 10w+ 连接
- 全校广播推送实时到达
- **依赖上游阶段产出**
- P2 iam 服务JWT RS256 签发 + JWKS 端点)
- P5 msg 服务(推送请求发起方)
- P1 Redis 基础设施(已就绪)
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
| 项 | classes | push-gateway 现状 | 差距 |
| ----------------- | ------------------------------ | ----------------------------------------- | ------------------------------------ |
| 权限装饰器 | `@RequirePermission()` | N/A基础设施JWT 校验 + 内部 API 鉴权) | ⚠️ 内部 API `/internal/*` 当前无鉴权 |
| 错误码前缀 | `CLASSES_*` | 无前缀 | ⚠️ 待统一为 `PUSH_*` |
| logger | pino | ❌ 标准 `log` | ⚠️ 待补 `log/slog` |
| metrics | `/metrics` | ❌ 无 | ⚠️ 待补:连接数/推送 QPS/心跳 |
| tracer | OTel SDK | ✅ | ✅ 对齐 |
| `/healthz` | ✅ | ✅ | ✅ 对齐 |
| `/readyz` | ✅ 检查 DB | ❌ 无 | ⚠️ 待补:检查 Redis 连接 |
| 优雅关闭 | SIGTERM → app.close() | ✅ 5s 超时 | ⚠️ 但未关闭所有 WebSocket 连接 |
| 测试覆盖率 | ≥ 80% | 0% | ⚠️ 待补 hub/ws/config 测试 |
| Dockerfile | 多阶段 + 非 root + healthcheck | ❌ 单阶段 + root + 无 healthcheck | ⚠️ 待重构 |
| Zod 输入验证 | `safeParse` | ✅ `ShouldBindJSON` 等价 | ✅ 对齐 |
| GlobalErrorFilter | `GlobalErrorFilter` | ⚠️ `gin.Recovery()` 默认 | ⚠️ 待自定义 JSON 信封错误 |
## 7. 服务审计表
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ------------ | ------------------ | ---------- | ----------- | ------- | ------- | -------- | ------- | ---------- | ---------- | ---------- |
| push-gateway | ⚠️ 内部 API 无鉴权 | ❌ 无前缀 | ❌ 标准 log | ❌ 无 | ✅ OTel | ✅ | ❌ 无 | ⚠️ 未关 WS | 0% | ❌ 单阶段 |
### 7.1 详细问题清单(按严重度排序)
| # | 严重度 | 文件 | 问题 | 修复建议 |
| --- | ------ | ------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| 1 | 高 | 全局 | 无 Redis Pub/Sub多实例部署时用户连接分散在各自实例跨实例推送失败 | 引入 Redis Pub/Sub本实例订阅 `edu.push.user.<userID>` channel跨实例推送通过 PUBLISH 投递 |
| 2 | 高 | `internal/ws/handler.go` L22-26 | `CheckOrigin` 永远返回 trueCSRF/Origin 校验缺失 | 从环境变量 `WS_ALLOWED_ORIGINS` 读取白名单 |
| 3 | 高 | `internal/ws/handler.go` L67-75 | 心跳用文本消息 `ping/pong`,不符合 RFC 6455 控制帧规范;无空闲超时,僵尸连接永久占用 | 改用 `conn.SetReadDeadline` + WebSocket Ping/Pong 控制帧60s 无心跳关闭连接 |
| 4 | 高 | `internal/ws/handler.go` L19 | `Send` 通道满时静默丢弃,无指标无日志 | 增加 `messages_dropped_total` 指标 + slog.Warn |
| 5 | 高 | `internal/hub/hub.go` L44-57 | 无单用户最大连接数限制DoS 风险 | 限制每用户 ≤ 5 连接,超限拒绝新连接或踢掉最旧 |
| 6 | 高 | `main.go` L44-46 | `/internal/*` 无鉴权,内网任何服务可调用 | 引入 `X-Internal-Token` 头校验(与 msg 服务共享密钥) |
| 7 | 中 | `internal/ws/handler.go` L75 | 无 reconnect 协议,客户端断线后无法恢复未送达消息 | 协议层支持 `session_id` + `last_seq`,重连后 msg 服务补推(与 msg 协调) |
| 8 | 中 | 全局 | HS256 而非 RS256 | P2 起改 RS256与 api-gateway 共用 JWKS 拉取逻辑 |
| 9 | 中 | `internal/config/config.go` | `getEnvBool``=="true"` 字符串比较,不健壮 | 用 `strconv.ParseBool` |
| 10 | 中 | `Dockerfile` | 单阶段构建、root 用户、无 healthcheck、无 ldflags | 重构为多阶段 + 非 root + `wget /healthz` healthcheck |
| 11 | 中 | `main.go` L66-71 | 优雅关闭未关闭所有 WebSocket 连接 | Hub 新增 `CloseAll()` 方法Shutdown 前调用 |
| 12 | 中 | 全局 | 无 `/metrics` 端点 | 新增 `metrics.go`:连接数 Gauge / 推送 QPS Counter / 心跳 Counter / 丢弃 Counter |
| 13 | 低 | `go.mod` L3 vs `Dockerfile` L1 | go.mod `1.25.0` vs Dockerfile `golang:1.22-alpine` | 统一 |
| 14 | 低 | `internal/hub/hub.go` L17-23 | `Send` 非阻塞丢弃,但 `Outgoing` 暴露 chan 给外部读,封装不彻底 | 改为 `Connection` 内部启动写协程,外部只调 `Send` |
| 15 | 低 | `README.md` L17 | 写 "Gin 1.10",实际 go.mod 是 v1.12.0 | 修正 |
| 16 | 低 | 测试 | 0% 覆盖率 | 补 hub_test.go注册/注销/推送/广播、ws_test.go鉴权/心跳) |
## 8. 风险与假设
- **假设**P5 msg 服务会通过 gRPC 或 HTTP 调用本服务推送(双通道待设计文档定)
- **假设**Redis 在 P5 部署时可用P1 已就绪)
- **风险**:单实例 in-memory 连接池在水平扩展时失效,必须 Redis Pub/Sub
- **风险**:僵尸连接耗尽资源(无空闲超时)
- **风险**:内部 API 无鉴权,内网穿透后可被滥用
- **未决**:是否在 push-gateway 消费 Kafka 广播?建议是,避免 msg 服务同步调用造成推送延迟
- **未决**reconnect 协议是否在本期实现建议本期仅做基础协议reconnect 协议作为 P6 硬化项

Some files were not shown because too many files have changed in this diff Show More