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

@@ -37,36 +37,171 @@
---
## 2. 模块分工矩阵
## 2. 两种协作模式
### 2.1 模块清单与 AI 分配
本项目支持两种 AI 协作模式,根据开发阶段灵活切换:
每个 AI 负责一个"限界上下文",避免跨模块修改导致冲突。
### 2.0 模式选择
| 模 | scope | 路径 | 负责阶段 | 建议 AI 数 |
| -------------- | ---------------- | ------------------------- | -------- | ------------ |
| api-gateway | `api-gateway` | `services/api-gateway/` | P1 | 1 |
| classes | `classes` | `services/classes/` | P1 | 1 |
| 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 维护 |
| 模 | 适用阶段 | 特点 |
| ------------------ | ---------------------------------- | ----------------------------------------------------- |
| **单仓库并行模式** | 架构设计外包、各服务独立功能开发 | 无需 PR直接 push main路径级别物理隔离几乎零冲突 |
| **PR 模式** | 共享文件修改、跨模块变更、代码审核 | 标准 PR 流程coord 审核后 Squash Merge |
### 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 负责,避免并行修改同一文件
2. **契约集中管理**`shared-proto` 由协调 AI 维护,开发 AI 只读引用
3. **跨模块变更拆分**:需要修改多个模块时,拆成多个 PR按依赖顺序合并
4. **基础设施独立**`infra/` 由 SRE AI 专门负责,业务 AI 不直接修改
2. **同语言内聚**:一个 AI 负责多个同语言服务,降低学习成本
3. **契约集中管理**`shared-proto` 由 coord 维护,开发 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. 相关文档
- [AI 分配方案](../architecture/ai-allocation.md) — 架构设计外包 AI 分配与三阶段流程
- [Git 工作流](./git-workflow.md) — 提交规范、分支策略、CODEOWNERS
- [本地启动手册](./local-dev-runbook.md) — 手动启动服务
- [项目规则](../../.trae/rules/project_rules.md) — 强制约束

View File

@@ -333,17 +333,24 @@
### 2.12 teacher-portal微前端宿主P1 测试页)
| 场景 | 技术/规则 |
| -------------------- | --------------------------------------------------------------------------------------- |
| P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 |
| API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` |
| P1 测试 JWT | 开发工具生成 HS256 tokenP2 起由 IAM 签发 RS256 |
| P2 Module Federation | next.config.js 配置,按场景域分 4 个稳定 portal |
| P2 路由组 + AppShell | `app/(app)/layout.tsx` 用 AppShell 包裹受保护页;`/login` 与 `/` 不套壳 |
| P2 真实 JWT | 登录后 token 存 localStorage`authHeaders()` 读 `Bearer ${getToken()}` |
| P2 视口驱动侧边栏 | AppShell fetch `/teacher/viewports` 渲染左侧导航active 路由高亮 |
| P2 根路径重定向 | `app/page.tsx` 客户端组件 `router.replace(isAuthenticated() ? '/dashboard' : '/login')` |
| fetch headers 类型 | `authHeaders(): Record<string, string>` 显式标注,避免 `{}` 与 `HeadersInit` 不兼容 |
| 场景 | 技术/规则 |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 |
| API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` |
| P1 测试 JWT | 开发工具生成 HS256 tokenP2 起由 IAM 签发 RS256 |
| P2 Module Federation | next.config.js 配置,按场景域分 4 个稳定 portal |
| P2 路由组 + AppShell | `app/(app)/layout.tsx` 用 AppShell 包裹受保护页;`/login` 与 `/` 不套壳 |
| P2 真实 JWT | 登录后 token 存 localStorage`authHeaders()` 读 `Bearer ${getToken()}` |
| P2 视口驱动侧边栏 | AppShell fetch `/teacher/viewports` 渲染左侧导航active 路由高亮 |
| P2 根路径重定向 | `app/page.tsx` 客户端组件 `router.replace(isAuthenticated() ? '/dashboard' : '/login')` |
| 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>` 流式加载 |
---
@@ -351,27 +358,32 @@
> 按时间倒序50 条上限。AI 发现更好方案时可更新本节。
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ---- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 | 下午 | 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 | 午 | msg/push-gateway/ai/api-gateway | **P5 沟通与 AI 阶段三服务完善**(1) msg 服务修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL 改 optional 加 DEV_MODE/PUSH_GATEWAY_URLelasticsearch.ts ES 降级esClient=null 时 safeIndex/safeSearch 跳过notifications.service.ts 加 createBatch + listByUserWithPagination + Push Gateway 推送调用try/catch 降级);新建 msg-init.sql 2 张表。(2) push-gateway 完善hub.go 重写用 send chan + 单写协程模式修复 gorilla/websocket 并发写竞争handler.go 加 DEV_MODE dev-token 支持 + broadcast 端点config.go 加 DevMode/RedisURL。(3) ai 服务完善config.py 加 openai_api_key/base_url/dev_mode新建 llm_client.pyhttpx 异步调 OpenAI REST APImain.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg/ai → ai 服务。**学到**gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入FastAPI APIRouter prefix 与 Gateway 代理路径要协调ai 服务加 /ai 前缀Gateway 代理 /ai/*pathLLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 |
| 2026-07-09 | 午 | content/api-gateway | **P4 内容分析服务端到端打通**(1) content 服务系统性修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODEneo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000health/lifecycle 改用 Drizzleglobal-error.filter 移除 @types/express 依赖textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块chaptersCRUD + 按 textbook 查询、knowledge-pointsCRUD + Neo4j 前置依赖图非阻塞查询、questionsCRUD + 4 种题型校验)。(3) Gateway 路由扩展textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库content-init.sql 4 张表。(5) E2E 验证POST /textbooks 201 → POST /chapters 201字段用 order 非 orderNum→ POST /knowledge-points 201Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**Drizzle schema TS 字段名与 DB 列名解耦order→order_numAPI 请求体用 TS 字段名Neo4j 不可用时必须 driver=null不设 NEO4J_URL否则每次请求尝试连接拖慢响应neo4j-driver safeCreateNode 用 try/catch 非阻塞MySQL 数据始终先落库。 |
| 2026-07-09 | 凌晨 | core-edu/api-gateway | **P3 核心教学服务端到端打通**(1) core-edu 服务系统性修复 13 项database.ts 导出 db 常量替代 getDb()env.ts JWT_SECRET 改 optional 加 DEV_MODEkafka.ts connectKafka 加 try/catch 不阻塞启动main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule3 个 controller 路由去前缀去 UseGuards 从 x-user-id 读身份exams/homework service datetime 列 ISO 字符串转 Date 修复 drizzle toISOString 错误;修正 10 处相对 import 路径health/lifecycle 改用 Drizzle 原生查询;新增 core-edu-init.sql 4 张表。(2) Gateway 路由扩展:发现 internal/routing/routing.go 是死代码(未被 main 引用),真正路由在 main.go在 main.go 添加 exams/homework/grades 三组路由(无尾斜杠+通配符);删除 routing.goconfig.go 加 CoreEduServiceURL。(3) DEV_MODE 环境变量问题Go 不自动加载 .env必须在启动前 export DEV_MODE=true 否则 dev-token 被拒 401。(4) E2E 验证POST /exams 201 → GET /exams/:id 200 → GET /exams/class/:id 200 → POST /homework 201 → POST /grades 201 → Outbox 3 条事件正确写入exam.failed 因 Kafka 未启动homework/grade pending。**学到**drizzle datetime 列需 Date 对象不是 ISO 字符串mapToDriverValue 调 toISOStringGo 项目 .env 不会自动加载需显式 export 或 godotenv 库NestJS controller 路由前缀与 Gateway 代理路径要协调Gateway 去掉 /api/v1 后转发controller 用裸路径如 'exams'Outbox 模式业务事务同写验证通过Kafka 未启动时事件 status=failed 但业务数据已落库。 |
| 2026-07-09 | 午 | iam/teacher-bff/teacher-portal | **P2 身份阶段完整实现**(1) Gateway 公开路径白名单register/login/refresh解决无 token 死锁。(2) IAM schema 扩展users 加 dataScope新增 role_viewports 表。(3) RBAC 端点 4 个 GET。(4) 视口按 requiredPermission 过滤 + sortOrder 排序getEffectivePermissions 用 Set 去重。(5) JWT payload 含 dataScoperegister 自动分配 teacher 角色。(6) 种子数据 7 权限+12 映射+7 视口。(7) Teacher BFF 视口聚合。(8) 前端lib/auth.ts + login + AppShell + (app) 路由组 + dashboard + classes真实 JWT+ 根重定向。(9) E2E 全链路通过。**学到**Next.js 路由组 (app) 不影响 URL/login 与 /dashboard 共存只后者套壳fetch headers 函数返回 Record<string,string> 避免 TS2769ESLint 9 需 flat config 留 P6AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 |
| 2026-07-08 | 晚上 | iam/classes/api-gateway | **P1 端到端链路验证 + IAM 服务修复**:验证 register → JWT → Gateway /iam/me → Gateway /classes CRUD → teacher-portal 前端渲染全链路打通。(1) IAM 服务 14 个 TS 编译错误修复:移除 typeorm/ioredis/kafkajs 依赖IAM 用 Drizzlehealth.controller.ts 改用 `db.execute(sql\`SELECT 1\`)`lifecycle.service.ts 简化为只关闭 Drizzle 连接池Drizzle API 修正(`r.roles`→`r.iam_roles``.in()`→`inArray()`)。(2) NestJS ESM DI 修复iam.module.ts 简化 providers 为 `[IamService, IamRepository]`iam.service.ts 构造器加 `@Inject(IamRepository)`(参考 classes 黄金模板),修复运行时 `Cannot read properties of undefined (reading 'findUserByEmail')`。(3) Gateway /iam/me 404 修复iam.controller.ts 直接读 `req.headers['x-user-id']`替代未注册的`AuthenticatedRequest`。(4) 创建 `scripts/iam-init.sql`建 6 张 IAM 表 + 种子数据。(5) E2E 验证iam:3002 注册/登录 → Gateway /iam/me 200 → Gateway GET /classes 200 → Gateway POST /classes合法 UUID gradeId201 → teacher-portal:3000 首页渲染 200 + 含"班级管理" → Next.js rewrites 透传 dev-token 到 Gateway 全链路通。**学到**NestJS ESM 模式下 DI 无法通过类型推断解析 token必须显式`@Inject(Token)`Drizzle select 返回字段名按 schema 定义而非表名classes.dto.ts 的 gradeId 要求 UUID 格式,测试数据不能用 "grade-12" 这类字符串PowerShell 控制台中文显示为 `?`是编码问题数据库实际存储正确DEV_MODE 下前端用`Bearer dev-token` 即可走通链路,无需真实 JWT。 |
| 2026-07-08 | 午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.ymlci-ts.yml 加 arch-scan + docker-build jobci-go.yml 去掉 golangci-lintlint-staged 预存问题),加 docker-buildci-proto.yml 修复 buf breaking URL从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registrygit.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`workflow_run 触发 + 手动 dispatchRunner 直接执行 docker compose pull && up -d10 次健康检查轮询,失败输出日志。(5) 新增 `infra/docker-compose.deploy.yml`(部署用,镜像来自 Gitea registry连接服务器已有 MySQL/Redis 通过 edu-shared 外部网络)+ `infra/deploy.env.example`(部署环境变量模板)。(6) 编写 `docs/standards/cicd-runbook.md`CI/CD 使用手册,含架构总览/一次性配置/日常使用/镜像管理/部署验证/回滚/常见问题/排查命令/安全注意事项)。**学到**Docker Compose 不支持 `restart_policy`(是 swarm 字段),用 `restart: unless-stopped` 替代Gitea Actions 兼容 GitHub Actions 语法但 `workflow_run` 触发可能不完整,备选手动 dispatch应用容器访问宿主机已有 MySQL/Redis 需通过共享外部网络(`docker network create edu-shared` + `docker network connect`)而非 `host.docker.internal`。 |
| 2026-07-08 | 午 | api-gateway | **重定向循环修复 + 生产模式部署准备 + 多AI协作文档**(1) 修复 `ERR_TOO_MANY_REDIRECTS`Gin 默认 `RedirectTrailingSlash=true` 导致 `/api/v1/classes` → 301 → `/classes/`Next.js rewrites 代理时形成循环。**修复**`r.RedirectTrailingSlash=false` + 同时注册无尾斜杠路由(`/classes`)与通配符路由(`/classes/*path`)。(2) 新增 DEV_MODE 旁路:`config.go` 加 `DevMode` 字段,`auth.go` 在 `DEV_MODE=true` 时接受 `dev-token` 注入固定身份(生产必须 false。(3) 生产 Docker 化:新建 `apps/teacher-portal/Dockerfile`(多阶段 Next.js build+ `services/api-gateway/Dockerfile`(多阶段 Go 静态编译)+ `infra/docker-compose.prod.yml`(三服务编排,强制 DEV_MODE=false。(4) 编写 `docs/standards/local-dev-runbook.md`(本地启动手册,含端口表/开发模式/生产模式/常见问题)+ `docs/standards/multi-ai-collaboration.md`多AI协作文档含模块分工矩阵/分支命名/PR流程/合并策略/冲突处理/权限矩阵)。**学到**Gin `RedirectTrailingSlash=false` 后需显式注册无尾斜杠路由(`Any("/classes")` + `Any("/classes/*path")`),否则 404Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 |
| 2026-07-08 | 全天 | 全局 | **P6 后续工作手册执行**:完整执行 post-p6-followup.md 12 节任务。环境准备pnpm 925 包 + go mod tidy 双服务 + uv sync 双服务 + buf 安装)→ 代码质量校验Go vet/build 0 错误Python ruff 8 错误自动修复)→ arch.db 同步(实现 4 个扫描器骨架,输出 12 模块/233 符号/138 契约)→ project_rules.md P0 修复(迁移到 .trae/rules/17881 字节)→ 004 架构图修复1.1a/1.1b 双图 + 1.2 业务领域列 + 5.4 视口四层)→ P6 集成测试10 Go + 17 bash = 27 用例全通过)→ Helm Chart 演化8 chart lint 通过)。**学到**:多语言 monorepo 工具链配置需统一镜像源npmmirror/goproxy.cn/tunago.work BOM 字符会导致 `unexpected input character` 错误必须重写文件。 |
| 2026-07-08 | 上午 | 全局 | pnpm install 网络失败ECONNRESET→ 配置 `npm config set registry https://registry.npmmirror.com` + `pnpm config set registry https://registry.npmmirror.com` 重试成功。**学到**Windows 下 pnpm 还需配置 `PNPM_HOME` 和 `TMP` 环境变量避免 `_tmp_` 文件 ENOENT 错误。 |
| 2026-07-08 | 上 | 全局 | project_rules.md 损坏72 字节乱码,从 P1 提交 2ba4250 就损坏git 历史无完整版本)→ 从 CICD 项目完整版迁移到 `e:\Desktop\Edu\.trae\rules\project_rules.md`(按用户要求放 .trae/rules/),按 MIGRATION_GUIDE 4.1 策略矩阵调整为微服务版13 章 17881 字节),删除根目录损坏文件,更新 7 处引用README/MIGRATION_GUIDE/004/known-issues/git-workflow/coding-standards。**学到**:迁移文件后必须 `Get-Item | Select Length` 验证完整性 + 全文搜索引用更新git commit 前运行 cat 检查内容。 |
| 2026-07-08 | 午 | api-gateway | go.work BOM 字符 + 版本不匹配:`unexpected input character '\ufeff'` 和 `module requires go >= 1.22.0, but go.work lists go 1.22`。**修复**:重写 go.work 去除 BOM版本改为 `go 1.26.0`,移除不存在的 `./packages/shared-go`。**学到**PowerShell `Out-File` 默认加 BOM写 go.work 这类敏感文件应用 `Write` 工具或 `[System.IO.File]::WriteAllText` 指定 UTF8 无 BOM。 |
| 2026-07-08 | 午 | arch-scan | arch:scan 返回 0 模块 0 符号 → 4 个扫描器ts/go/py/proto都是骨架实现。**修复**:完整实现 4 个扫描器TS 用 regex 提取(避免 ts-morph 对未安装依赖文件解析失败Go/Python 用行首锚定正则Proto 扫描 service/message/rpc。结果12 模块≥10 ✓、233 符号≥100 ✓、138 契约。**学到**ts-morph Project 对未 `pnpm install` 的 workspace 文件会报模块解析失败,改用 regex 更鲁棒scanner.ts main() 开头需 `DELETE FROM` 清空旧数据避免重跑重复。 |
| 2026-07-08 | 下午 | 004 | 架构图视角讨论(技术分层 vs 业务领域)→ 双图并存方案1.1a 技术分层视角(部署/流量/网络边界Users 层标注"场景域用户"BFF 层标注"按场景域分"+ 1.1b 业务领域视角6 DDD 限界上下文 subgraphD1 身份/D2 教学组织/D3 教学核心/D4 内容/D5 沟通/D6 智能洞察。1.2 服务清单新增"业务领域"列。**学到**双图互补1.1a 服务运维/SRE 视角1.1b 服务产品/架构视角同一服务可横跨多领域core-edu 同时承载 D2+D3 |
| 2026-07-08 | 午 | 004 | 视口四层模型补充5.4 章节L1 导航navigation_config 表)/ L2 路由route_permission + Gateway 校验)/ L3 组件usePermission().hasPermission/ L4 数据DataScope 枚举)。场景域 BFF 复用策略:按使用场景域分 BFF 而非按角色分,教导主任复用 Teacher BFF + 额外管理视口。iam 服务职责:认证 + RBAC + 视口配置 + DataScope + 权限解析 API。**学到**视口既可独立配置RoleViewport 表)也可由权限推导,新角色只需配权限集,视口自动推导。 |
| 2026-07-08 | 午 | api-gateway | P6 集成测试补充circuit-breaker_test.go5 用例ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted+ ratelimit_test.go5 用例AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets+ test-backup-mysql.sh8 用例 17 断言)。**学到**gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=trueHALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN测试需通过行为503 vs 500而非状态字段验证rateLimiter cleanup 测试需用短周期参数50ms/500ms加速且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
| 2026-07-08 | 午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chartnamespace/configmap/secret/ingress/hpa + 4 环境 values 文件)+ api-gateway 服务级 chart完整迁移自原 deployment.yaml参数化所有字段+ 6 业务服务 chart 桩iam/core-edu/content/msg/data-ana/ai。删除原 api-gateway-deployment.yaml保留 namespace.yaml。**学到**Helm `{{- with ... -}}` 双向修剪会导致标签连在一行(`managed-by: Helmpart-of: edu-platform`),应改为 `{{- with ... }}` 只修剪左侧;`helm lint` 全部通过但 `helm template` 才能发现 YAML 渲染错误,验证时两个都要跑。 |
| 2026-07-07 | 全天 | 全局 | 文档体系初始化从旧项目e:\Desktop\CICDNext.js 单体)迁移 spec + plan + known-issues 模板到新仓库e:\Desktop\Edu微服务架构。known-issues 重组为微服务分区:多语言 monorepo / Docker Compose / protobuf+buf / NestJS / Go Gateway / 可观测性 / 微前端。从旧项目提炼可迁移经验React 19 useOptimistic / Zustand 细粒度选择器 / Tiptap SSR / 请求级去重 / 批量 SQL / 动态导入模式 / arch:scan 串行执行。新增微服务特有经验:契约先行 / Outbox / CDC / 双轨读 / DataScope / 黄金模板复制流程。路线图按 6 阶段组织P1 地基 → P2 身份 → P3 核心教学 → P4 内容分析 → P5 沟通AI → P6 硬化。 |
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ---- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 | 午 | 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 | 午 | msg/push-gateway/ai/api-gateway | **P5 沟通与 AI 阶段三服务完善**(1) msg 服务修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL 改 optional 加 DEV_MODE/PUSH_GATEWAY_URLelasticsearch.ts ES 降级esClient=null 时 safeIndex/safeSearch 跳过notifications.service.ts 加 createBatch + listByUserWithPagination + Push Gateway 推送调用try/catch 降级);新建 msg-init.sql 2 张表。(2) push-gateway 完善hub.go 重写用 send chan + 单写协程模式修复 gorilla/websocket 并发写竞争handler.go 加 DEV_MODE dev-token 支持 + broadcast 端点config.go 加 DevMode/RedisURL。(3) ai 服务完善config.py 加 openai_api_key/base_url/dev_mode新建 llm_client.pyhttpx 异步调 OpenAI REST APImain.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg/ai → ai 服务。**学到**gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入FastAPI APIRouter prefix 与 Gateway 代理路径要协调ai 服务加 /ai 前缀Gateway 代理 /ai/*pathLLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 |
| 2026-07-09 | 午 | content/api-gateway | **P4 内容分析服务端到端打通**(1) content 服务系统性修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODEneo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000health/lifecycle 改用 Drizzleglobal-error.filter 移除 @types/express 依赖textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块chaptersCRUD + 按 textbook 查询、knowledge-pointsCRUD + Neo4j 前置依赖图非阻塞查询、questionsCRUD + 4 种题型校验)。(3) Gateway 路由扩展textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库content-init.sql 4 张表。(5) E2E 验证POST /textbooks 201 → POST /chapters 201字段用 order 非 orderNum→ POST /knowledge-points 201Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**Drizzle schema TS 字段名与 DB 列名解耦order→order_numAPI 请求体用 TS 字段名Neo4j 不可用时必须 driver=null不设 NEO4J_URL否则每次请求尝试连接拖慢响应neo4j-driver safeCreateNode 用 try/catch 非阻塞MySQL 数据始终先落库。 |
| 2026-07-09 | 凌晨 | core-edu/api-gateway | **P3 核心教学服务端到端打通**(1) core-edu 服务系统性修复 13 项database.ts 导出 db 常量替代 getDb()env.ts JWT_SECRET 改 optional 加 DEV_MODEkafka.ts connectKafka 加 try/catch 不阻塞启动main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule3 个 controller 路由去前缀去 UseGuards 从 x-user-id 读身份exams/homework service datetime 列 ISO 字符串转 Date 修复 drizzle toISOString 错误;修正 10 处相对 import 路径health/lifecycle 改用 Drizzle 原生查询;新增 core-edu-init.sql 4 张表。(2) Gateway 路由扩展:发现 internal/routing/routing.go 是死代码(未被 main 引用),真正路由在 main.go在 main.go 添加 exams/homework/grades 三组路由(无尾斜杠+通配符);删除 routing.goconfig.go 加 CoreEduServiceURL。(3) DEV_MODE 环境变量问题Go 不自动加载 .env必须在启动前 export DEV_MODE=true 否则 dev-token 被拒 401。(4) E2E 验证POST /exams 201 → GET /exams/:id 200 → GET /exams/class/:id 200 → POST /homework 201 → POST /grades 201 → Outbox 3 条事件正确写入exam.failed 因 Kafka 未启动homework/grade pending。**学到**drizzle datetime 列需 Date 对象不是 ISO 字符串mapToDriverValue 调 toISOStringGo 项目 .env 不会自动加载需显式 export 或 godotenv 库NestJS controller 路由前缀与 Gateway 代理路径要协调Gateway 去掉 /api/v1 后转发controller 用裸路径如 'exams'Outbox 模式业务事务同写验证通过Kafka 未启动时事件 status=failed 但业务数据已落库。 |
| 2026-07-09 | 上午 | iam/teacher-bff/teacher-portal | **P2 身份阶段完整实现**(1) Gateway 公开路径白名单register/login/refresh解决无 token 死锁。(2) IAM schema 扩展users 加 dataScope新增 role_viewports 表。(3) RBAC 端点 4 个 GET。(4) 视口按 requiredPermission 过滤 + sortOrder 排序getEffectivePermissions 用 Set 去重。(5) JWT payload 含 dataScoperegister 自动分配 teacher 角色。(6) 种子数据 7 权限+12 映射+7 视口。(7) Teacher BFF 视口聚合。(8) 前端lib/auth.ts + login + AppShell + (app) 路由组 + dashboard + classes真实 JWT+ 根重定向。(9) E2E 全链路通过。**学到**Next.js 路由组 (app) 不影响 URL/login 与 /dashboard 共存只后者套壳fetch headers 函数返回 Record<string,string> 避免 TS2769ESLint 9 需 flat config 留 P6AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 |
| 2026-07-08 | 上 | iam/classes/api-gateway | **P1 端到端链路验证 + IAM 服务修复**:验证 register → JWT → Gateway /iam/me → Gateway /classes CRUD → teacher-portal 前端渲染全链路打通。(1) IAM 服务 14 个 TS 编译错误修复:移除 typeorm/ioredis/kafkajs 依赖IAM 用 Drizzlehealth.controller.ts 改用 `db.execute(sql\`SELECT 1\`)`lifecycle.service.ts 简化为只关闭 Drizzle 连接池Drizzle API 修正(`r.roles`→`r.iam_roles``.in()`→`inArray()`)。(2) NestJS ESM DI 修复iam.module.ts 简化 providers 为 `[IamService, IamRepository]`iam.service.ts 构造器加 `@Inject(IamRepository)`(参考 classes 黄金模板),修复运行时 `Cannot read properties of undefined (reading 'findUserByEmail')`。(3) Gateway /iam/me 404 修复iam.controller.ts 直接读 `req.headers['x-user-id']`替代未注册的`AuthenticatedRequest`。(4) 创建 `scripts/iam-init.sql`建 6 张 IAM 表 + 种子数据。(5) E2E 验证iam:3002 注册/登录 → Gateway /iam/me 200 → Gateway GET /classes 200 → Gateway POST /classes(合法 UUID gradeId201 → teacher-portal:3000 首页渲染 200 + 含"班级管理" → Next.js rewrites 透传 dev-token 到 Gateway 全链路通。**学到**NestJS ESM 模式下 DI 无法通过类型推断解析 token必须显式`@Inject(Token)`Drizzle select 返回字段名按 schema 定义而非表名classes.dto.ts 的 gradeId 要求 UUID 格式,测试数据不能用 "grade-12" 这类字符串PowerShell 控制台中文显示为 `?`是编码问题数据库实际存储正确DEV_MODE 下前端用`Bearer dev-token` 即可走通链路,无需真实 JWT。 |
| 2026-07-08 | 午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.ymlci-ts.yml 加 arch-scan + docker-build jobci-go.yml 去掉 golangci-lintlint-staged 预存问题),加 docker-buildci-proto.yml 修复 buf breaking URL从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registrygit.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`workflow_run 触发 + 手动 dispatchRunner 直接执行 docker compose pull && up -d10 次健康检查轮询,失败输出日志。(5) 新增 `infra/docker-compose.deploy.yml`(部署用,镜像来自 Gitea registry连接服务器已有 MySQL/Redis 通过 edu-shared 外部网络)+ `infra/deploy.env.example`(部署环境变量模板)。(6) 编写 `docs/standards/cicd-runbook.md`CI/CD 使用手册,含架构总览/一次性配置/日常使用/镜像管理/部署验证/回滚/常见问题/排查命令/安全注意事项)。**学到**Docker Compose 不支持 `restart_policy`(是 swarm 字段),用 `restart: unless-stopped` 替代Gitea Actions 兼容 GitHub Actions 语法但 `workflow_run` 触发可能不完整,备选手动 dispatch应用容器访问宿主机已有 MySQL/Redis 需通过共享外部网络(`docker network create edu-shared` + `docker network connect`)而非 `host.docker.internal`。 |
| 2026-07-08 | 午 | api-gateway | **重定向循环修复 + 生产模式部署准备 + 多AI协作文档**(1) 修复 `ERR_TOO_MANY_REDIRECTS`Gin 默认 `RedirectTrailingSlash=true` 导致 `/api/v1/classes` → 301 → `/classes/`Next.js rewrites 代理时形成循环。**修复**`r.RedirectTrailingSlash=false` + 同时注册无尾斜杠路由(`/classes`)与通配符路由(`/classes/*path`)。(2) 新增 DEV_MODE 旁路:`config.go` 加 `DevMode` 字段,`auth.go` 在 `DEV_MODE=true` 时接受 `dev-token` 注入固定身份(生产必须 false。(3) 生产 Docker 化:新建 `apps/teacher-portal/Dockerfile`(多阶段 Next.js build+ `services/api-gateway/Dockerfile`(多阶段 Go 静态编译)+ `infra/docker-compose.prod.yml`(三服务编排,强制 DEV_MODE=false。(4) 编写 `docs/standards/local-dev-runbook.md`(本地启动手册,含端口表/开发模式/生产模式/常见问题)+ `docs/standards/multi-ai-collaboration.md`多AI协作文档含模块分工矩阵/分支命名/PR流程/合并策略/冲突处理/权限矩阵)。**学到**Gin `RedirectTrailingSlash=false` 后需显式注册无尾斜杠路由(`Any("/classes")` + `Any("/classes/*path")`),否则 404Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 |
| 2026-07-08 | 全天 | 全局 | **P6 后续工作手册执行**:完整执行 post-p6-followup.md 12 节任务。环境准备pnpm 925 包 + go mod tidy 双服务 + uv sync 双服务 + buf 安装)→ 代码质量校验Go vet/build 0 错误Python ruff 8 错误自动修复)→ arch.db 同步(实现 4 个扫描器骨架,输出 12 模块/233 符号/138 契约)→ project_rules.md P0 修复(迁移到 .trae/rules/17881 字节)→ 004 架构图修复1.1a/1.1b 双图 + 1.2 业务领域列 + 5.4 视口四层)→ P6 集成测试10 Go + 17 bash = 27 用例全通过)→ Helm Chart 演化8 chart lint 通过)。**学到**:多语言 monorepo 工具链配置需统一镜像源npmmirror/goproxy.cn/tunago.work BOM 字符会导致 `unexpected input character` 错误必须重写文件。 |
| 2026-07-08 | 午 | 全局 | pnpm install 网络失败ECONNRESET→ 配置 `npm config set registry https://registry.npmmirror.com` + `pnpm config set registry https://registry.npmmirror.com` 重试成功。**学到**Windows 下 pnpm 还需配置 `PNPM_HOME` 和 `TMP` 环境变量避免 `_tmp_` 文件 ENOENT 错误。 |
| 2026-07-08 | 午 | 全局 | project_rules.md 损坏72 字节乱码,从 P1 提交 2ba4250 就损坏git 历史无完整版本)→ 从 CICD 项目完整版迁移到 `e:\Desktop\Edu\.trae\rules\project_rules.md`(按用户要求放 .trae/rules/),按 MIGRATION_GUIDE 4.1 策略矩阵调整为微服务版13 章 17881 字节),删除根目录损坏文件,更新 7 处引用README/MIGRATION_GUIDE/004/known-issues/git-workflow/coding-standards。**学到**:迁移文件后必须 `Get-Item | Select Length` 验证完整性 + 全文搜索引用更新git commit 前运行 cat 检查内容。 |
| 2026-07-08 | 午 | api-gateway | go.work BOM 字符 + 版本不匹配:`unexpected input character '\ufeff'` 和 `module requires go >= 1.22.0, but go.work lists go 1.22`。**修复**重写 go.work 去除 BOM版本改为 `go 1.26.0`,移除不存在的 `./packages/shared-go`。**学到**PowerShell `Out-File` 默认加 BOM写 go.work 这类敏感文件应用 `Write` 工具或 `[System.IO.File]::WriteAllText` 指定 UTF8 无 BOM。 |
| 2026-07-08 | 上午 | arch-scan | arch:scan 返回 0 模块 0 符号 → 4 个扫描器ts/go/py/proto都是骨架实现。**修复**:完整实现 4 个扫描器TS 用 regex 提取(避免 ts-morph 对未安装依赖文件解析失败Go/Python 用行首锚定正则Proto 扫描 service/message/rpc。结果12 模块≥10 ✓、233 符号≥100 ✓、138 契约。**学到**ts-morph Project 对未 `pnpm install` 的 workspace 文件会报模块解析失败,改用 regex 更鲁棒scanner.ts main() 开头需 `DELETE FROM` 清空旧数据避免重跑重复。 |
| 2026-07-08 | 下午 | 004 | 架构图视角讨论(技术分层 vs 业务领域)→ 双图并存方案1.1a 技术分层视角(部署/流量/网络边界Users 层标注"场景域用户"BFF 层标注"按场景域分"+ 1.1b 业务领域视角6 DDD 限界上下文 subgraphD1 身份/D2 教学组织/D3 教学核心/D4 内容/D5 沟通/D6 智能洞察。1.2 服务清单新增"业务领域"列。**学到**双图互补1.1a 服务运维/SRE 视角1.1b 服务产品/架构视角同一服务可横跨多领域core-edu 同时承载 D2+D3。 |
| 2026-07-08 | 下午 | 004 | 视口四层模型补充5.4 章节L1 导航navigation_config 表)/ L2 路由route_permission + Gateway 校验)/ L3 组件usePermission().hasPermission/ L4 数据DataScope 枚举)。场景域 BFF 复用策略:按使用场景域分 BFF 而非按角色分,教导主任复用 Teacher BFF + 额外管理视口。iam 服务职责:认证 + RBAC + 视口配置 + DataScope + 权限解析 API。**学到**视口既可独立配置RoleViewport 表)也可由权限推导,新角色只需配权限集,视口自动推导。 |
| 2026-07-08 | 下午 | api-gateway | P6 集成测试补充circuit-breaker_test.go5 用例ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted+ ratelimit_test.go5 用例AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets+ test-backup-mysql.sh8 用例 17 断言)。**学到**gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=trueHALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN测试需通过行为503 vs 500而非状态字段验证rateLimiter cleanup 测试需用短周期参数50ms/500ms加速且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
| 2026-07-08 | 下午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chartnamespace/configmap/secret/ingress/hpa + 4 环境 values 文件)+ api-gateway 服务级 chart完整迁移自原 deployment.yaml参数化所有字段+ 6 业务服务 chart 桩iam/core-edu/content/msg/data-ana/ai。删除原 api-gateway-deployment.yaml保留 namespace.yaml。**学到**Helm `{{- with ... -}}` 双向修剪会导致标签连在一行(`managed-by: Helmpart-of: edu-platform`),应改为 `{{- with ... }}` 只修剪左侧;`helm lint` 全部通过但 `helm template` 才能发现 YAML 渲染错误,验证时两个都要跑。 |
| 2026-07-07 | 全天 | 全局 | 文档体系初始化从旧项目e:\Desktop\CICDNext.js 单体)迁移 spec + plan + known-issues 模板到新仓库e:\Desktop\Edu微服务架构。known-issues 重组为微服务分区:多语言 monorepo / Docker Compose / protobuf+buf / NestJS / Go Gateway / 可观测性 / 微前端。从旧项目提炼可迁移经验React 19 useOptimistic / Zustand 细粒度选择器 / Tiptap SSR / 请求级去重 / 批量 SQL / 动态导入模式 / arch:scan 串行执行。新增微服务特有经验:契约先行 / Outbox / CDC / 双轨读 / DataScope / 黄金模板复制流程。路线图按 6 阶段组织P1 地基 → P2 身份 → P3 核心教学 → P4 内容分析 → P5 沟通AI → P6 硬化。 |

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/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.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.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
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=

9
pnpm-lock.yaml generated
View File

@@ -251,6 +251,9 @@ importers:
'@nestjs/cli':
specifier: ^10.4.0
version: 10.4.9
'@types/express':
specifier: ^4.17.0
version: 4.17.25
'@types/node':
specifier: ^22.0.0
version: 22.20.0
@@ -479,6 +482,9 @@ importers:
'@nestjs/cli':
specifier: ^10.4.0
version: 10.4.9
'@types/express':
specifier: ^4.17.0
version: 4.17.25
'@types/node':
specifier: ^22.0.0
version: 22.20.0
@@ -543,6 +549,9 @@ importers:
eslint:
specifier: ^9.10.0
version: 9.39.4(jiti@2.6.1)
pino-pretty:
specifier: ^11.2.0
version: 11.3.0
typescript:
specifier: ^5.6.0
version: 5.9.3

View File

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

View File

@@ -41,7 +41,7 @@ def init_tracer() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期."""
init_tracer()
logger.info(
@@ -89,6 +89,12 @@ class ChatResponse(BaseModel):
degraded: bool = False
class QuestionRequest(BaseModel):
"""题目生成请求."""
prompt: str
def _extract_content(result: dict[str, Any] | None) -> tuple[str, str, dict[str, Any]]:
"""从 OpenAI 响应中抽取 (content, model, usage)。"""
if result is None:
@@ -171,7 +177,7 @@ async def chat_stream(req: ChatRequest) -> StreamingResponse:
@router.post("/generate/question")
async def generate_question(prompt: str) -> dict[str, Any]:
async def generate_question(req: QuestionRequest) -> dict[str, Any]:
"""生成题目(无 API key 时降级返回骨架)."""
with tracer.start_as_current_span("generate_question"):
messages = [
@@ -180,7 +186,7 @@ async def generate_question(prompt: str) -> dict[str, Any]:
"content": "You are an educational question generator. "
"Generate a clear, concise question based on the user's prompt.",
},
{"role": "user", "content": prompt},
{"role": "user", "content": req.prompt},
]
result = await chat_completion(
messages=messages,
@@ -190,7 +196,7 @@ async def generate_question(prompt: str) -> dict[str, Any]:
base_url=settings.openai_base_url,
)
if result is None:
logger.warning("generate_question_degraded", prompt=prompt[:100])
logger.warning("generate_question_degraded", prompt=req.prompt[:100])
return {
"success": True,
"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

@@ -6,6 +6,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
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
@@ -14,6 +15,7 @@ require (
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
@@ -40,7 +42,11 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.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
@@ -52,6 +58,7 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // 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

View File

@@ -2,6 +2,8 @@ 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/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -74,8 +76,16 @@ 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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
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/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -85,10 +95,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
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/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
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/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=
@@ -97,6 +117,8 @@ github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa
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/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/go.mod h1:dO3Q/nCzxZj6ICjH6J/gM0r4oAwBMVLY8YAQf+NTtUg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -148,6 +170,8 @@ 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=
@@ -171,6 +195,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN
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 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,10 +1,12 @@
package config
import (
"log"
"os"
"strconv"
)
// Config 持有 api-gateway 运行时配置
type Config struct {
Port string
JWTSecret string
@@ -23,10 +25,26 @@ type Config struct {
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 {
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{
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"),
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
@@ -39,10 +57,11 @@ func Load() *Config {
AiServiceURL: getEnv("AI_SERVICE_URL", "http://localhost:3008"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: getEnvBool("DEV_MODE", false),
DevMode: devMode,
}
}
// getEnv 读取环境变量,缺失时返回 fallback
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
@@ -50,15 +69,7 @@ func getEnv(key, fallback string) string {
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return fallback
}
// getEnvBool 读取环境变量并解析为 bool缺失或解析失败时返回 fallback
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
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/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
@@ -73,7 +72,7 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
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 {
return nil, jwt.ErrSignatureInvalid
}
@@ -107,7 +106,7 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
if sub, ok := claims["sub"].(string); ok {
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))
for _, r := range roles {
if s, ok := r.(string); ok {
@@ -120,22 +119,3 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
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
import (
"log"
"net/http"
"os"
"strconv"
@@ -12,22 +13,26 @@ import (
// corsMaxAge 预检缓存时长12 小时
const corsMaxAge = 12 * 60 * 60
// devCORSOrigins 是未配置 CORS_ORIGINS 时的开发环境默认白名单
const devCORSOrigins = "http://localhost:3000,http://localhost:3001"
// CORS 返回跨域资源共享中间件。
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔,默认 *)。
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔
// 未配置时使用开发环境白名单localhost:3000/3001并打印 warning。
// 允许方法GET POST PUT DELETE OPTIONS PATCH
// 允许头Authorization Content-Type X-Request-Id X-Trace-Id
// 暴露头X-Request-Id X-Trace-Id
func CORS() gin.HandlerFunc {
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) {
origin := c.GetHeader("Origin")
allowOrigin := ""
if len(allowed) == 0 {
// 未配置则默认允许所有来源
allowOrigin = "*"
} else if allowed[origin] {
if allowed[origin] {
allowOrigin = origin
}
@@ -39,9 +44,7 @@ func CORS() gin.HandlerFunc {
h.Set("Access-Control-Expose-Headers", "X-Request-Id, X-Trace-Id")
h.Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge))
// 非通配来源需标注 Vary便于缓存正确区分
if allowOrigin != "*" {
h.Add("Vary", "Origin")
}
h.Add("Vary", "Origin")
}
// 预检请求直接返回 204
@@ -53,7 +56,7 @@ func CORS() gin.HandlerFunc {
}
}
// parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map(表示通配 *
// parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map
func parseCORSOrigins(raw string) map[string]bool {
allowed := map[string]bool{}
if raw == "" {

View File

@@ -15,6 +15,7 @@ import (
"github.com/edu-cloud/api-gateway/internal/observability"
"github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
@@ -51,6 +52,8 @@ func main() {
// 健康检查路由(无需鉴权,在 Auth 之前)
r.GET("/healthz", health.Healthz)
r.GET("/readyz", health.Readyz)
// Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// API v1 组:熔断 + 鉴权 + 反向代理
api := r.Group("/api/v1")

View File

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

View File

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

View File

@@ -1,14 +1,18 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../config/database.js';
import { classes, type Class, type NewClass } from './classes.schema.js';
import { eq } from "drizzle-orm";
import { getDb } from "../config/database.js";
import { classes, type Class, type NewClass } from "./classes.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js";
export class ClassesRepository {
async create(data: NewClass): Promise<Class> {
const db = getDb();
await db.insert(classes).values(data);
const [result] = await db.select().from(classes).where(eq(classes.id, data.id));
const [result] = await db
.select()
.from(classes)
.where(eq(classes.id, data.id));
if (!result) {
throw new Error('Failed to read created class');
throw new DatabaseError("Failed to read created class");
}
return result;
}
@@ -27,7 +31,10 @@ export class ClassesRepository {
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();
await db.update(classes).set(data).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 mysql from 'mysql2/promise';
import { env } from './env.js';
import { drizzle } from "drizzle-orm/mysql2";
import type { MySql2Database } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null;
export function getDb() {
export function getDb(): MySql2Database {
if (!pool) {
pool = mysql.createPool({
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 { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -19,7 +20,7 @@ async function bootstrap(): Promise<void> {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});

View File

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

View File

@@ -1,50 +1,71 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import { PermissionDeniedError } from '../shared/errors/application-error.js';
import type { AuthenticatedRequest } from './auth.middleware.js';
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 type Permission =
| 'CLASS_CREATE'
| 'CLASS_READ'
| 'CLASS_UPDATE'
| 'CLASS_DELETE';
"CLASSES_CREATE" | "CLASSES_READ" | "CLASSES_UPDATE" | "CLASSES_DELETE";
export const Permissions = {
CLASS_CREATE: 'CLASS_CREATE' as const,
CLASS_READ: 'CLASS_READ' as const,
CLASS_UPDATE: 'CLASS_UPDATE' as const,
CLASS_DELETE: 'CLASS_DELETE' as const,
CLASSES_CREATE: "CLASSES_CREATE" as const,
CLASSES_READ: "CLASSES_READ" as const,
CLASSES_UPDATE: "CLASSES_UPDATE" 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[]> = {
admin: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE],
teacher: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE],
student: [Permissions.CLASS_READ],
parent: [Permissions.CLASS_READ],
admin: [
Permissions.CLASSES_CREATE,
Permissions.CLASSES_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()
export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {}
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 && perms.includes(this.requiredPermission)) {
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
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 { Request, Response } from 'express';
import { ZodError } from 'zod';
import { ApplicationError } from './application-error.js';
import {
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
@@ -12,7 +18,9 @@ export class GlobalErrorFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
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 body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'CLASSES_VALIDATION_ERROR',
message: 'Validation failed',
code: "CLASSES_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'HTTP_ERROR',
code: "HTTP_ERROR",
message,
traceId,
},
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body);
}
private extractHttpMessage(res: string | object, exception: HttpException): string {
if (typeof res === 'string') {
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
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;
return typeof msg === 'string' ? msg : exception.message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}

View File

@@ -1,9 +1,12 @@
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { Redis } from 'ioredis';
import type { Producer } from 'kafkajs';
import {
Injectable,
Logger,
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 钩子。
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
*
* 关闭顺序Kafka producer → Redis → DataSource
* 先停外部消息生产避免新事件,再关缓存,最后关 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: ... }`。
* Classes 服务仅使用 Drizzle ORMMySQL无 Kafka / Redis 依赖
* 关闭时仅需关闭数据库连接池
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
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 {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
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());
await this.safeDisconnect('redis', () => this.redis.quit());
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
try {
await closeDb();
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`);
}
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

@@ -31,6 +31,7 @@
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"

View File

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

View File

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

View File

@@ -14,12 +14,17 @@ import {
type PrerequisiteNode,
} from "./knowledge-points.service.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("knowledge-points")
export class KnowledgePointsController {
constructor(private readonly service: KnowledgePointsService) {}
@Post()
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE)
async create(
@Body() body: CreateKnowledgePointInput,
): Promise<{ success: true; data: { id: string } }> {
@@ -28,6 +33,7 @@ export class KnowledgePointsController {
}
@Get("chapter/:chapterId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async listByChapter(
@Param("chapterId") chapterId: string,
): Promise<{ success: true; data: KnowledgePoint[] }> {
@@ -36,6 +42,7 @@ export class KnowledgePointsController {
}
@Get(":id/prerequisites")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async getPrerequisites(
@Param("id") id: string,
): Promise<{ success: true; data: PrerequisiteNode[] }> {
@@ -44,6 +51,7 @@ export class KnowledgePointsController {
}
@Get(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: KnowledgePoint }> {
@@ -52,6 +60,7 @@ export class KnowledgePointsController {
}
@Post(":id/prerequisites/:prerequisiteId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async addPrerequisite(
@Param("id") id: string,
@Param("prerequisiteId") prerequisiteId: string,
@@ -61,6 +70,7 @@ export class KnowledgePointsController {
}
@Put(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateKnowledgePointInput,
@@ -70,6 +80,7 @@ export class KnowledgePointsController {
}
@Delete(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_DELETE)
async remove(
@Param("id") id: string,
): 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 { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -20,8 +21,7 @@ async function bootstrap(): Promise<void> {
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});

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

View File

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

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 { closeNeo4j } from "../../config/neo4j.js";
const SERVICE_NAME = "content";
@Injectable()
export class LifecycleService {
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,

View File

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

View File

@@ -1,10 +1,17 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ExamsModule } from "./exams/exams.module.js";
import { HomeworkModule } from "./homework/homework.module.js";
import { GradesModule } from "./grades/grades.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
})
export class AppModule {}

View File

@@ -8,23 +8,32 @@ import {
Put,
Req,
} from "@nestjs/common";
import type { Request } from "express";
import {
ExamsService,
type CreateExamInput,
type UpdateExamInput,
} 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")
export class ExamsController {
constructor(private readonly examsService: ExamsService) {}
@Post()
@RequirePermission(Permissions.EXAM_CREATE)
async create(
@Body() body: CreateExamInput,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
): 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({
...body,
createdBy: userId,
@@ -33,6 +42,7 @@ export class ExamsController {
}
@Get(":id")
@RequirePermission(Permissions.EXAM_READ)
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<ExamsService["getExam"]>>;
@@ -42,6 +52,7 @@ export class ExamsController {
}
@Get("class/:classId")
@RequirePermission(Permissions.EXAM_READ)
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>;
@@ -51,6 +62,7 @@ export class ExamsController {
}
@Put(":id")
@RequirePermission(Permissions.EXAM_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateExamInput,
@@ -60,6 +72,7 @@ export class ExamsController {
}
@Delete(":id")
@RequirePermission(Permissions.EXAM_DELETE)
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {

View File

@@ -1,17 +1,26 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
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")
export class GradesController {
constructor(private readonly gradesService: GradesService) {}
@Post()
@RequirePermission(Permissions.GRADE_CREATE)
async record(
@Body() body: RecordGradeInput,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
): 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({
...body,
gradedBy: userId,
@@ -20,6 +29,7 @@ export class GradesController {
}
@Get(":id")
@RequirePermission(Permissions.GRADE_READ)
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["getGrade"]>>;
@@ -29,6 +39,7 @@ export class GradesController {
}
@Get("student/:studentId")
@RequirePermission(Permissions.GRADE_READ)
async listByStudent(@Param("studentId") studentId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByStudent"]>>;
@@ -38,6 +49,7 @@ export class GradesController {
}
@Get("exam/:examId")
@RequirePermission(Permissions.GRADE_READ)
async listByExam(@Param("examId") examId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByExam"]>>;
@@ -47,6 +59,7 @@ export class GradesController {
}
@Get("homework/:homeworkId")
@RequirePermission(Permissions.GRADE_READ)
async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByHomework"]>>;

View File

@@ -1,20 +1,29 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import {
HomeworkService,
type AssignHomeworkInput,
} 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")
export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {}
@Post()
@RequirePermission(Permissions.HOMEWORK_CREATE)
async assign(
@Body() body: AssignHomeworkInput,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
): 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({
...body,
createdBy: userId,
@@ -23,6 +32,7 @@ export class HomeworkController {
}
@Get(":id")
@RequirePermission(Permissions.HOMEWORK_READ)
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<HomeworkService["getHomework"]>>;
@@ -32,6 +42,7 @@ export class HomeworkController {
}
@Get("class/:classId")
@RequirePermission(Permissions.HOMEWORK_READ)
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<HomeworkService["listByClass"]>>;
@@ -41,6 +52,7 @@ export class HomeworkController {
}
@Post(":id/submit")
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
async submit(
@Param("id") id: string,
): 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 { logger } from "./shared/observability/logger.js";
import { registry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -16,8 +17,7 @@ async function bootstrap(): Promise<void> {
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", registry.contentType);
res.end(await registry.metrics());
});

View File

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

View File

@@ -1,48 +1,92 @@
import {
Injectable,
CanActivate,
ExecutionContext,
Injectable,
ForbiddenException,
} from '@nestjs/common';
import type { AuthenticatedRequest } from './auth.middleware.js';
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 = {
EXAM_CREATE: 'exam:create',
EXAM_READ: 'exam:read',
EXAM_UPDATE: 'exam:update',
EXAM_DELETE: 'exam:delete',
HOMEWORK_CREATE: 'homework:create',
HOMEWORK_READ: 'homework:read',
HOMEWORK_UPDATE: 'homework:update',
HOMEWORK_DELETE: 'homework:delete',
HOMEWORK_GRADE: 'homework:grade',
HOMEWORK_SUBMIT: 'homework:submit',
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',
EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const,
EXAM_READ: "CORE_EDU_EXAM_READ" as const,
EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const,
EXAM_DELETE: "CORE_EDU_EXAM_DELETE" as const,
HOMEWORK_CREATE: "CORE_EDU_HOMEWORK_CREATE" as const,
HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const,
HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const,
HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT" as const,
GRADE_CREATE: "CORE_EDU_GRADE_CREATE" as const,
GRADE_READ: "CORE_EDU_GRADE_READ" 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.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()
export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {}
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 user = request.user;
if (!user) {
throw new ForbiddenException('User not authenticated');
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
if (!user.permissions.includes(this.requiredPermission)) {
throw new ForbiddenException(
`Missing permission: ${this.requiredPermission}`,
);
}
return true;
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}

View File

@@ -1,25 +1,45 @@
export enum CoreEduErrorCode {
VALIDATION_ERROR = 'CORE_EDU_VALIDATION_ERROR',
NOT_FOUND = 'CORE_EDU_NOT_FOUND',
UNAUTHORIZED = 'CORE_EDU_UNAUTHORIZED',
FORBIDDEN = 'CORE_EDU_FORBIDDEN',
CONFLICT = 'CORE_EDU_CONFLICT',
INTERNAL_ERROR = 'CORE_EDU_INTERNAL_ERROR',
EXAM_NOT_FOUND = 'CORE_EDU_EXAM_NOT_FOUND',
HOMEWORK_NOT_FOUND = 'CORE_EDU_HOMEWORK_NOT_FOUND',
GRADE_NOT_FOUND = 'CORE_EDU_GRADE_NOT_FOUND',
OUTBOX_PUBLISH_FAILED = 'CORE_EDU_OUTBOX_PUBLISH_FAILED',
VALIDATION_ERROR = "CORE_EDU_VALIDATION_ERROR",
NOT_FOUND = "CORE_EDU_NOT_FOUND",
UNAUTHORIZED = "CORE_EDU_UNAUTHORIZED",
FORBIDDEN = "CORE_EDU_FORBIDDEN",
CONFLICT = "CORE_EDU_CONFLICT",
INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR",
EXAM_NOT_FOUND = "CORE_EDU_EXAM_NOT_FOUND",
HOMEWORK_NOT_FOUND = "CORE_EDU_HOMEWORK_NOT_FOUND",
GRADE_NOT_FOUND = "CORE_EDU_GRADE_NOT_FOUND",
OUTBOX_PUBLISH_FAILED = "CORE_EDU_OUTBOX_PUBLISH_FAILED",
}
export class ApplicationError extends Error {
readonly code: CoreEduErrorCode;
readonly statusCode: number;
readonly details?: unknown;
traceId?: string;
constructor(
public readonly code: CoreEduErrorCode,
code: CoreEduErrorCode,
message: string,
public readonly statusCode: number = 500,
public readonly details?: unknown,
statusCode: number = 500,
details?: unknown,
) {
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 {
constructor(message: string = 'Unauthorized') {
constructor(message: string = "Unauthorized") {
super(CoreEduErrorCode.UNAUTHORIZED, message, 401);
}
}
export class ForbiddenError extends ApplicationError {
constructor(message: string = 'Forbidden') {
constructor(message: string = "Forbidden") {
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 {
constructor(message: string, details?: unknown) {
super(CoreEduErrorCode.CONFLICT, message, 409, details);
@@ -54,7 +82,7 @@ export class ConflictError 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -9,11 +9,12 @@
import asyncio
import contextlib
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import structlog
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
@@ -89,7 +90,7 @@ def init_tracer() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期.
1. 初始化 loggerstructlog
@@ -133,6 +134,9 @@ FastAPIInstrumentor.instrument_app(app)
# Prometheus 指标
app.mount("/metrics", make_asgi_app())
# 业务路由
router = APIRouter()
@app.get("/healthz")
async def healthz() -> dict:
@@ -189,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:
"""班级成绩分析.
@@ -215,7 +219,7 @@ async def class_performance(class_id: str) -> dict:
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:
"""学生薄弱知识点分析.
@@ -259,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:
"""学生错题本.
@@ -290,3 +294,6 @@ async def student_errorbook(student_id: str) -> dict:
"degraded": False,
},
}
app.include_router(router)