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

- monorepo: pnpm workspace + go.work + pyproject.toml + commitlint/husky
- infra: docker-compose (minimal + full profiles) + init-sql + prometheus
- arch-scan: multi-language scanner skeleton (TS/Go/Python/Proto)
- shared-proto: buf v2 + classes.proto (ClassService CRUD contract)
- api-gateway: Go/Gin + JWT HS256 auth + reverse proxy + request ID
- classes: NestJS golden template (error system + observability + middleware + CRUD + tests)
- teacher-portal: Next.js + paper-feel UI design system
- CI/CD: 4 workflows (go/ts/py/proto)
- docs: migration guide + project_rules + coding-standards + git-workflow + ui-design-system + 004 + 9 module READMEs + known-issues + spec/plan migration + roadmap
This commit is contained in:
SpecialX
2026-07-07 23:39:37 +08:00
commit 2ba4250165
100 changed files with 15242 additions and 0 deletions

22
.commitlintrc.js Normal file
View File

@@ -0,0 +1,22 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'chore', 'docs', 'style', 'refactor', 'test', 'perf', 'ci', 'build', 'revert'],
],
'scope-enum': [
2,
'always',
[
'api-gateway', 'push-gateway',
'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai',
'teacher-bff', 'student-bff', 'parent-bff',
'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal',
'shared-proto', 'shared-ts', 'shared-go', 'shared-py', 'shared-tokens',
'arch-scan', 'infra', 'docs', 'deps', 'release',
],
],
},
};

21
.editorconfig Normal file
View File

@@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{go,mod,sum}]
indent_style = tab
[*.{py,toml}]
indent_size = 4
[Makefile]
indent_style = tab
[*.md]
trim_trailing_whitespace = false

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# MySQL
MYSQL_ROOT_PASSWORD=changeme
MYSQL_DATABASE=next_edu_cloud
MYSQL_USER=edu
MYSQL_PASSWORD=changeme
# Redis
REDIS_PASSWORD=
# JWT (P1 用 HS256 测试密钥P2 改 RS256)
JWT_SECRET=p1-dev-secret-change-in-production
JWT_ISSUER=next-edu-cloud
JWT_AUDIENCE=next-edu-cloud
# 服务端口
API_GATEWAY_PORT=8080
CLASSES_SERVICE_PORT=3001
TEACHER_PORTAL_PORT=3000
# 数据库连接
DATABASE_URL=mysql://edu:changeme@localhost:3306/next_edu_cloud
REDIS_URL=redis://localhost:6379
# 可观测性
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
LOG_LEVEL=info
# Neo4jP4 启用)
NEO4J_PASSWORD=changeme
# KafkaP3 启用)
KAFKA_BROKERS=localhost:9092

38
.github/workflows/ci-go.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: CI Go
on:
push:
branches: [main]
paths:
- 'services/api-gateway/**'
- 'services/push-gateway/**'
- 'packages/shared-go/**'
- 'go.work'
pull_request:
branches: [main]
paths:
- 'services/api-gateway/**'
- 'services/push-gateway/**'
- 'packages/shared-go/**'
- 'go.work'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
working-directory: services/api-gateway
- name: Build
working-directory: services/api-gateway
run: |
go mod download
go build ./...
- name: Test
working-directory: services/api-gateway
run: go test ./... -v -coverprofile=coverage.out

25
.github/workflows/ci-proto.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: CI Proto
on:
push:
branches: [main]
paths:
- 'packages/shared-proto/**'
pull_request:
branches: [main]
paths:
- 'packages/shared-proto/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1
- name: buf lint
working-directory: packages/shared-proto
run: buf lint
- name: buf breaking
if: github.event_name == 'pull_request'
working-directory: packages/shared-proto
run: buf breaking --against https://github.com/${{ github.repository }}.git#branch=main,subdir=packages/shared-proto

33
.github/workflows/ci-py.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: CI Python
on:
push:
branches: [main]
paths:
- 'services/data-ana/**'
- 'services/ai/**'
- 'packages/shared-py/**'
- 'pyproject.toml'
pull_request:
branches: [main]
paths:
- 'services/data-ana/**'
- 'services/ai/**'
- 'packages/shared-py/**'
- 'pyproject.toml'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v3
- name: Install
run: uv sync
- name: Lint (ruff)
run: uv run ruff check .
- name: Test
run: uv run pytest

43
.github/workflows/ci-ts.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: CI TypeScript
on:
push:
branches: [main]
paths:
- 'services/classes/**'
- 'apps/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
pull_request:
branches: [main]
paths:
- 'services/classes/**'
- 'apps/**'
- 'packages/**'
- 'scripts/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm -r run lint
- name: Typecheck
run: pnpm -r run typecheck
- name: Test
run: pnpm -r run test
- name: Build
run: pnpm -r run build

65
.gitignore vendored Normal file
View File

@@ -0,0 +1,65 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
.next/
out/
*.tsbuildinfo
target/
bin/
obj/
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
*.egg-info/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Database
*.db
*.sqlite
arch.db
# Test coverage
coverage/
.nyc_output/
# Docker
docker-compose.override.yml
# Temp
tmp/
temp/

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
npx --no-install commitlint --edit $1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx --no-install lint-staged

11
CHANGELOG.md Normal file
View File

@@ -0,0 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- P1 地基阶段仓库骨架、Docker 基础设施、API Gateway、classes 黄金模板服务

41
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,41 @@
# Contributing
## 开发环境要求
- Node.js 20+
- pnpm 9+
- Go 1.22+
- Python 3.12+
- Docker Desktop
- buf 1.40+ (`go install github.com/bufbuild/buf/cmd/buf@latest`)
## 开发流程
1. 从 main 创建 feature 分支:`git checkout -b feature/xxx`
2. 开发并确保 CI 通过:`pnpm lint && pnpm test && pnpm build`
3. 提交 PR 到 main
4. CI 全绿后合并
## 提交规范
使用 Conventional Commits
```
<type>(<scope>): <description>
types: feat, fix, chore, docs, style, refactor, test, perf, ci
scope: 服务名或包名(如 api-gateway, classes, shared-proto
```
示例:
- `feat(api-gateway): add JWT auth middleware`
- `fix(classes): resolve N+1 query in list classes`
- `docs(004): update service dependency graph`
## 文档同步
修改代码后必须:
1. 运行 `pnpm run arch:scan` 更新 arch.db
2. 若架构意图变化,更新 `docs/architecture/004_architecture_impact_map.md`
3. 若服务接口变化,更新对应 `docs/modules/<service>/README.md`
4. 若发现新经验,更新 `docs/troubleshooting/known-issues.md`

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Edu Cloud
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

492
MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,492 @@
# 迁移指南CICD → Edu
> 版本1.0
> 日期2026-07-07
> 状态:基线发布
> 维护者:架构组
> 关联文档:
> - [项目规则](./project_rules.md)
> - [编码规范](./docs/standards/coding-standards.md)
> - [Git 工作流](./docs/standards/git-workflow.md)
---
## 目录
1. [迁移背景](#一迁移背景)
2. [原始项目说明](#二原始项目说明cicd)
3. [新项目说明](#三新项目说明edu)
4. [迁移策略矩阵](#四迁移策略矩阵)
5. [文档体系映射表](#五文档体系映射表)
6. [六阶段路线图概览](#六六阶段路线图概览)
7. [原始项目可复用资产清单](#七原始项目可复用资产清单)
8. [迁移验收标准](#八迁移验收标准)
---
## 一、迁移背景
### 1.1 迁移动机
原始项目 CICD 是基于 Next.js 16 的单应用架构,承载 K12 智慧教务系统 35 个业务模块。随着业务规模扩张与团队增长,单应用架构在以下方面暴露瓶颈:
| 维度 | 单应用瓶颈 | 微服务目标 |
|------|-----------|-----------|
| 团队协作 | 35 模块挤在同一仓库,合并冲突频繁 | 按领域拆分6 领域团队独立迭代 |
| 部署节奏 | 全量构建发布,单模块变更牵动全站 | 服务粒度独立部署,故障爆炸半径缩小 |
| 技术选型 | TypeScript 单语言AI/分析场景受限 | TS业务+ Go网关+ PythonAI/分析)各取所长 |
| 数据规模 | 单 MySQL跨模块联表与读放大 | 读写分离 + CQRSClickHouse 承载分析负载 |
| 可演进性 | 模块间隐式耦合,重构成本高 | DDD 限界上下文显式契约,演化可控 |
### 1.2 迁移原则
1. **契约先行**:服务间通信先定 protobuf 契约,再写实现,禁止"先实现再补契约"
2. **资产复用**设计令牌、权限模型、缓存策略、UI 组件模式等优质资产尽量平移,不重复造轮子
3. ** strangler fig**:不一次性切流,按阶段灰度迁移,旧系统与新系统在过渡期并存
4. **数据自治**:每个微服务独占自身数据库,跨服务查询走 BFF 聚合或读模型
5. **可观测优先**:迁移同步建立 tracing/metrics/logging 三件套,禁止"先上线后补监控"
---
## 二、原始项目说明CICD
### 2.1 基本概况
| 属性 | 值 |
|------|-----|
| 仓库路径 | `e:\Desktop\CICD` |
| 技术栈 | Next.js 16 + React 19 + Tailwind v4 + Drizzle ORM |
| 语言 | TypeScript全栈 |
| 架构 | 单应用 + 严格模块化app → modules → shared 三层) |
| 业务模块数 | 35 个 |
| 数据库 | MySQL单库 |
| 认证 | NextAuth.js |
| 部署 | Gitea Actions + Docker standalone |
### 2.2 模块清单(按领域归类)
| 领域 | 模块 |
|------|------|
| 身份与权限 | auth、users、onboarding、settings、permissions |
| 教学组织 | classes、teachers、students、parents、subjects |
| 教学核心 | courses、lessons、schedule、attendance、leave-requests |
| 考试评价 | exams、questions、grading、scores、analytics |
| 作业内容 | homework、textbooks、resources |
| 沟通通知 | messaging、notifications、announcements |
| 智能辅助 | ai备课/出题/分析、search |
| 系统支撑 | audit-logs、reports、dashboard、layout |
### 2.3 已沉淀的工程资产
- **arch.db 架构元数据库**:通过 `npm run arch:scan` 扫描得到模块、函数、调用关系、依赖关系、技术标签的结构化数据
- **设计令牌体系**primitive / semantic-light / semantic-dark / 模块命名空间 / Tailwind 暴露 五层分层
- **权限模型**`requirePermission()` 服务端校验 + `usePermission().hasPermission()` 客户端校验,权限点常量集中维护
- **缓存策略**`cacheFn`React `cache()` + 自定义缓存层),权限数据不跨请求缓存
- **ActionState 模式**Server Action 统一返回 `{ success, message, errors, data }` 结构
- **5 层状态模型**URLnuqs→ ServerTanStack Query→ Client BusinessZustand→ Global UIZustand→ Formreact-hook-form
- **A11y 工具集**`useA11yId``mergeA11yProps``describeInput``loadingAria`、aria-live Hook、focus-trap 组件
- **CI/CD 流水线**CI + 安全扫描 + 灾备演练三套 Gitea Actions 工作流
- **审计日志三件套**`login-logger``change-logger``audit-logger`
---
## 三、新项目说明Edu
### 3.1 基本概况
| 属性 | 值 |
|------|-----|
| 仓库路径 | `e:\Desktop\Edu` |
| 远程仓库 | https://git.eazygame.cn/xiner/Edu.git |
| 架构范式 | DDD + EDA + CQRS 微服务 |
| 语言 | TypeScriptNestJS 10+ GoGin 网关)+ PythonFastAPI 分析/AI |
| 前端 | React + Next.js + Module Federation4 微前端) |
| monorepo 策略 | pnpm workspace + go.work + pyproject.toml (uv) |
| 事件总线 | Kafka + Debezium CDC |
| 契约 | protobuf + buf |
| 存储 | MySQL / Redis / ClickHouse / Neo4j / Elasticsearch |
### 3.2 整体架构
```mermaid
flowchart TB
subgraph Client[客户端]
WEB[Web 浏览器]
APP[移动端]
end
subgraph Gateway[网关层 Go Gin]
GW[API Gateway<br/>路由/鉴权/限流/熔断]
end
subgraph BFF[聚合层 BFF]
BFF1[Admin BFF]
BFF2[Teacher BFF]
BFF3[Student/Parent BFF]
end
subgraph Services[业务微服务 NestJS]
S1[identity 身份]
S2[org 教学组织]
S3[teaching 教学核心]
S4[content 内容分析]
S5[comm 沟通]
S6[insight 智能洞察]
end
subgraph Infra[基础设施服务]
I1[auth 认证授权]
I2[notification 通知]
end
subgraph EventBus[事件总线]
K[Kafka]
DBZ[Debezium CDC]
end
subgraph Storage[存储]
MY[(MySQL)]
RD[(Redis)]
CH[(ClickHouse)]
NEO[(Neo4j)]
ES[(Elasticsearch)]
end
Client --> GW
GW --> BFF
BFF --> Services
Services --> Infra
Services <--> K
DBZ --> K
Services --> Storage
Infra --> Storage
```
### 3.3 微前端与领域服务映射
| 微前端 | 路由前缀 | 对接 BFF | 主要消费的服务 |
|--------|---------|---------|---------------|
| Admin Shell | `/admin` | Admin BFF | identity、org、insight |
| Teacher Shell | `/teacher` | Teacher BFF | teaching、content、comm |
| Student Shell | `/student` | Student/Parent BFF | teaching、content |
| Parent Shell | `/parent` | Student/Parent BFF | teaching、comm |
| 微服务 | 原始模块映射 | 主存储 | 对外契约 |
|--------|------------|--------|---------|
| identity | auth、users、onboarding、settings、permissions | MySQL + Redis | identity.proto |
| org | classes、teachers、students、parents、subjects | MySQL | org.proto |
| teaching | courses、lessons、schedule、attendance、leave-requests、exams、homework | MySQL | teaching.proto |
| content | textbooks、resources、questions、grading | MySQL + Elasticsearch | content.proto |
| comm | messaging、notifications、announcements | MySQL + Redis | comm.proto |
| insight | scores、analytics、ai、search、reports、dashboard | ClickHouse + Neo4j | insight.proto |
| auth基础设施 | NextAuth 逻辑下沉 | MySQL + Redis | auth.proto |
| notification基础设施 | notifications channel 抽离 | MySQL + Redis | notification.proto |
---
## 四、迁移策略矩阵
按"直接迁移 / 调整迁移 / 重写 / 新建"四档分类。
### 4.1 规范类资产
| 资产 | 策略 | 说明 |
|------|------|------|
| 项目规则project_rules | 调整 | 架构图优先保留,分层规则从三层改为微服务分层,新增 DDD/契约/事件驱动规则 |
| 编码规范coding-standards | 调整 | TS 部分保留并补充 NestJS 装饰器/DI 规则,新增 Go、Python、protobuf 章节 |
| Git 工作流 | 调整 | trunk-based 替代分支策略scope 改为服务/包名,新增多语言 monorepo 提交规则 |
| 提交规范Conventional Commits | 直接迁移 | 类型与格式完全沿用 |
| 设计令牌规范 | 调整 | 分层模型保留,分布位置从 `src/app/styles/tokens/` 改为微前端共享包 |
| 安全规范 | 直接迁移 | Cookie 策略、env 校验、XSS 防护、权限校验规则全部沿用 |
| 问题记录规则 | 直接迁移 | known-issues.md 索引式速查手册模式沿用 |
| A11y 规范 | 直接迁移 | WCAG 2.2 AA 目标与工具集沿用 |
### 4.2 代码类资产
| 资产 | 策略 | 说明 |
|------|------|------|
| Zod schema 定义 | 直接迁移 | 各模块 schema.ts 平移至对应微服务,复用验证规则 |
| 权限点常量Permissions | 直接迁移 | 集中迁入 identity 服务共享包 |
| Drizzle schema表结构 | 调整 | 按领域拆分到各微服务独占库,关系型字段保持不变 |
| ActionState 类型 | 直接迁移 | 升级为 protobuf message结构不变 |
| Server Actions | 重写 | 改写为 NestJS Controller + Service + Application Service 三层 |
| data-access 层 | 重写 | 改写为 NestJS Repository + Domain Entity |
| UI 组件shared/components | 直接迁移 | 平移至微前端共享包,保持 PascalCase 命名 |
| HookuseAuth、usePermission | 调整 | 改为通过 BFF/gRPC client 获取,接口签名保持不变 |
| 缓存层cacheFn | 重写 | 改为 NestJS Cache 模块 + Redis去除 React cache() |
| arch:scan 工具 | 调整 | 扫描器扩展为多语言TS+Go+Python数据库结构保持 |
| 审计日志三件套 | 直接迁移 | 平移至 notification 服务,日志结构保持 |
| CI/CD 流水线 | 重写 | Gitea Actions 改为多服务并行流水线,新增契约校验阶段 |
### 4.3 文档类资产
| 资产 | 策略 | 说明 |
|------|------|------|
| 架构影响地图004 | 重写 | 从单应用模块图改为微服务限界上下文图 |
| K12 功能清单006 | 直接迁移 | 功能清单与架构无关,直接平移 |
| 差距审计报告007 | 调整 | 重新审计各微服务的功能完成度 |
| 模块角色映射008 | 直接迁移 | 角色权限矩阵不变 |
| 路线图roadmap/ | 重写 | 6 阶段微服务路线图 |
| known-issues.md | 直接迁移 | 经验日志平移,新增"微服务"分区 |
| 各模块 README | 重写 | 改为各微服务 README按 DDD 上下文描述 |
---
## 五、文档体系映射表
| CICD 文档 | Edu 对应文档 | 关系 |
|-----------|------------|------|
| `.trae/rules/project_rules.md` | `project_rules.md` | 调整(微服务版) |
| `docs/standards/coding-standards.md` | `docs/standards/coding-standards.md` | 调整(多语言版) |
| —(散落在 project_rules | `docs/standards/git-workflow.md` | 新建 |
| `docs/architecture/004_architecture_impact_map.md` | `docs/architecture/001_architecture_overview.md` | 重写 |
| `docs/architecture/006_k12_feature_checklist.md` | `docs/architecture/feature_checklist.md` | 直接迁移 |
| `docs/architecture/007_gap_audit_report.md` | `docs/architecture/gap_audit.md` | 调整 |
| `docs/architecture/008_module_role_mapping.md` | `docs/architecture/role_mapping.md` | 直接迁移 |
| `docs/architecture/roadmap/README.md` | `docs/architecture/roadmap/README.md` | 重写 |
| `docs/architecture/roadmap/tech-debt.md` | `docs/architecture/roadmap/tech-debt.md` | 重写 |
| `docs/architecture/roadmap/decoupling.md` | `docs/architecture/roadmap/migration_phases.md` | 重写(迁移阶段化) |
| `docs/troubleshooting/known-issues.md` | `docs/troubleshooting/known-issues.md` | 直接迁移 + 新分区 |
| `src/modules/[module]/README.md` | `services/[service]/README.md` | 重写 |
| `docs/standards/coding-standards.md` §A11y | `docs/standards/accessibility.md` | 拆分独立 |
---
## 六、六阶段路线图概览
### 6.1 阶段总览
```mermaid
gantt
title Edu 微服务迁移六阶段路线图
dateFormat YYYY-MM-DD
axisFormat %m月
section P1 地基
仓库结构/契约/CI/可观测 :p1, 2026-07-01, 30d
section P2 身份
identity/auth/notification :p2, after p1, 30d
section P3 核心教学
org/teaching/content :p3, after p2, 45d
section P4 内容分析
insight+CQRS读模型/ES :p4, after p3, 30d
section P5 沟通AI
comm/AI增强 :p5, after p4, 30d
section P6 硬化
安全/灾备/性能/混沌 :p6, after p5, 30d
```
### 6.2 各阶段目标
| 阶段 | 名称 | 目标 | 关键交付物 | 验收信号 |
|------|------|------|-----------|---------|
| P1 | 地基 | 仓库骨架、契约工具链、CI、可观测平台 | monorepo 结构、buf 配置、Kafka 集群、OpenTelemetry | 契约生成 + 一次端到端 trace |
| P2 | 身份 | identity + auth + notification 三服务打通 | JWT 颁发、RBAC、邮件/短信/站内通知 | 用户注册→登录→收到通知 |
| P3 | 核心教学 | org + teaching + content | 班级/课表/作业/题库/考试 | 教师创建作业→学生提交→批改闭环 |
| P4 | 内容分析 | insight + CQRS 读模型 + ES 全文检索 | ClickHouse 报表、ES 搜索、Neo4j 知识图谱 | 多维分析报表 + 全文搜索可用 |
| P5 | 沟通AI | comm + AI 辅助 | 站内信/通知中心、AI 备课/出题/答疑 | 教师用 AI 出题并发布到班级 |
| P6 | 硬化 | 安全加固、灾备、性能、混沌工程 | WAF、定期备份、压测报告、混沌演练 | RPO≤15min、RTO≤30min、P99≤500ms |
### 6.3 阶段交付门槛
每个阶段进入下一阶段前必须满足:
1. **契约稳定**:本阶段涉及服务的 protobuf 契约冻结breaking change 走 deprecation 流程
2. **可观测完整**tracing 覆盖率 ≥ 95%关键业务指标QPS/延迟/错误率)接入 Grafana
3. **测试覆盖**:单元测试 ≥ 80%,关键流程 E2E 通过
4. **文档同步**:服务 README + 004 架构图 + known-issues 已更新
5. **arch.db 同步**`pnpm run arch:scan` 已扫描最新代码状态
---
## 七、原始项目可复用资产清单
### 7.1 设计令牌思路
**复用方式**:将 CICD 的 `src/app/styles/tokens/` 五层分层模型平移至微前端共享包。
| CICD 位置 | Edu 位置 | 调整 |
|-----------|---------|------|
| `src/app/styles/tokens/primitive.css` | `packages/ui-tokens/primitive.css` | 直接平移 |
| `src/app/styles/tokens/semantic-light.css` | `packages/ui-tokens/semantic-light.css` | 直接平移 |
| `src/app/styles/tokens/semantic-dark.css` | `packages/ui-tokens/semantic-dark.css` | 直接平移 |
| `src/app/styles/tokens/lesson-preparation.css` | `packages/ui-tokens/lesson-preparation.css` | 直接平移 |
| `src/app/styles/tokens/tailwind-theme.css` | `packages/ui-tokens/tailwind-theme.css` | 直接平移 |
**强制规则保持不变**
- 禁止硬编码颜色(`#hex`
- 禁止硬编码字体(`'Inter'`/`'Fraunces'`/`'JetBrains Mono'`
- 禁止硬编码字号(`font-size: Npx`
- 禁止 Tailwind 任意值(`w-[Npx]`
- ESLint `no-restricted-syntax` + `design-tokens/no-hardcoded-fonts` 规则沿用
### 7.2 UI 组件模式
**复用方式**:将 CICD 的 `src/shared/components/ui/` 平移至 `packages/ui-components/`,作为 Module Federation 共享依赖。
| 组件类别 | CICD 路径 | Edu 路径 | 复用要点 |
|---------|----------|---------|---------|
| 基础组件Button/Input/Dialog | `shared/components/ui/` | `packages/ui-components/` | 全部平移,保持 PascalCase 命名 |
| A11y 组件 | `shared/components/a11y/` | `packages/ui-components/a11y/` | skip-link、visually-hidden、focus-trap、aria-status 全部平移 |
| 图表组件 | 各模块内 | `packages/ui-components/charts/` | 收集 recharts 封装,统一暴露 |
| 表单组件 | react-hook-form 封装 | `packages/ui-components/form/` | 与 zod resolver 一同平移 |
**复用规则**
- 组件必须为纯函数,使用 `function` 声明
- 不使用 `React.FC`,直接用函数声明 + 显式标注 props 类型
- 默认服务端组件(微前端 host需要交互时才添加 `"use client"`
- 组件行数 ≤ 500 行(复杂表单/大型表格可放宽至 800 行)
### 7.3 权限模型
**复用方式**:将 CICD 的 `requirePermission()` + `usePermission().hasPermission()` 模式平移至 identity 服务 + auth 基础设施服务。
| CICD 资产 | Edu 位置 | 调整 |
|-----------|---------|------|
| `shared/lib/auth-guard.ts`requirePermission | `services/auth/src/guards/permission.guard.ts`NestJS Guard | 改为 NestJS Guard 装饰器 |
| `shared/types/permissions.ts`(权限点常量) | `packages/contracts/src/permissions.ts` | 集中到 contracts 包,多服务共享 |
| `usePermission` Hook | `packages/ui-components/hooks/use-permission.ts` | 通过 BFF 拉取权限Hook 接口不变 |
| 角色权限矩阵008 | `docs/architecture/role_mapping.md` | 直接平移 |
**强制规则保持不变**
- 每个 Controller/Action 必须调用 `requirePermission()` 等价物
- 前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()`
### 7.4 缓存策略
**复用方式**CICD 的 `cacheFn`React `cache()` + 自定义缓存层)改为 NestJS Cache 模块 + Redis**权限数据不跨请求缓存**的规则沿用。
| CICD 模式 | Edu 模式 | 备注 |
|-----------|---------|------|
| `cacheFn`React cache | NestJS `@UseInterceptors(CacheInterceptor)` + Redis | 单请求内缓存改为 NestJS REQUEST scope |
| `unstable_cache` | 禁用 | CICD 已禁用Edu 沿用禁用决策 |
| 权限数据缓存 | 仅在 auth 服务内部缓存TTL ≤ 60s | 跨服务不缓存权限 |
| 静态资源缓存 | CDN + 短缓存 | 配置不变 |
### 7.5 Server Action 模式 → Application Service 模式
**复用方式**CICD 的 Server Action 编排模式(权限 + Zod 验证 + 调用 data-access + revalidate平移为 NestJS Application Service 编排模式。
| CICD Server Action 步骤 | NestJS Application Service 对应 |
|------------------------|-------------------------------|
| `requirePermission(perm)` | `@RequirePermission(perm)` 装饰器 + Guard |
| Zod `safeParse` | `ValidationPipe` + DTO class-validator |
| 调用 `data-access` | 调用 Domain Service / Repository |
| `revalidatePath` | 发布领域事件触发读模型更新 |
| 返回 `ActionState<T>` | 返回 protobuf message结构同 ActionState |
### 7.6 状态管理 5 层模型
**复用方式**CICD 的 5 层状态模型平移至微前端 host 应用。
| 层级 | CICD 方案 | Edu 方案 | 备注 |
|------|----------|---------|------|
| L1 URL | nuqs | nuqs | 直接平移 |
| L2 Server | TanStack Query | TanStack Query | 直接平移 |
| L3 Client Business | Zustand slice | Zustand slice | 直接平移 |
| L4 Global UI | Zustand ui-store + ModalRoot | Zustand ui-store + ModalRoot | 直接平移 |
| L5 Form | react-hook-form + zodResolver | react-hook-form + zodResolver | 直接平移 |
### 7.7 A11y 工具集
**复用方式**:全部平移至 `packages/ui-components/a11y/`
- `useA11yId`:自动生成唯一 ARIA ID
- `mergeA11yProps`:合并 ARIA props
- `describeInput`:关联 input 与描述
- `loadingAria`:加载状态 ARIA 属性
- `useAriaLive`aria-live 区域管理 Hook
- `skip-link``visually-hidden``focus-trap``aria-status` 组件
**目标**WCAG 2.2 AA 合规,`eslint-plugin-jsx-a11y` 设为 `error`
### 7.8 审计日志三件套
**复用方式**:平移至 notification 服务,日志结构保持。
| CICD 资产 | Edu 位置 | 备注 |
|-----------|---------|------|
| `shared/lib/login-logger.ts` | `services/notification/src/loggers/login-logger.ts` | 登录尝试日志 |
| `shared/lib/change-logger.ts` | `services/notification/src/loggers/change-logger.ts` | 数据变更日志(监听领域事件) |
| `shared/lib/audit-logger.ts` | `services/notification/src/loggers/audit-logger.ts` | 关键业务操作日志 |
### 7.9 CI/CD 流水线模式
**复用方式**三套工作流模式沿用CI + 安全扫描 + 灾备演练),但执行方式改为多服务并行。
| CICD 工作流 | Edu 工作流 | 调整 |
|------------|-----------|------|
| `ci.yml` | `.gitea/workflows/ci.yml` | 单体改为矩阵并行(每个服务一个 job |
| `security.yml` | `.gitea/workflows/security.yml` | 新增 Trivy 扫描 Docker 镜像 |
| `dr-drill.yml` | `.gitea/workflows/dr-drill.yml` | 灾备演练改为多服务恢复顺序演练 |
**CI 必须包含**(沿用 CICD 规则):
1. 安装依赖多语言pnpm install / go mod download / uv sync
2. Lint 检查ESLint + golangci-lint + ruff
3. 类型检查tsc --noEmit + go vet + mypy
4. 契约校验buf lint + buf breaking
5. 单元测试 + 覆盖率
6. 构建(多服务并行)
7. 安全审计npm audit + Snyk + Trivy
8. E2E 测试(仅主分支)
---
## 八、迁移验收标准
### 8.1 阶段验收清单
每个阶段完成时必须勾选所有项:
- [ ] 本阶段服务的 protobuf 契约已冻结并通过 `buf breaking` 校验
- [ ] 服务 README 已编写,包含限界上下文图与依赖关系
- [ ] `docs/architecture/001_architecture_overview.md` 已同步更新
- [ ] `docs/troubleshooting/known-issues.md` 已追加本阶段经验日志
- [ ] `pnpm run arch:scan` 已扫描最新代码状态(含 TS+Go+Python
- [ ] 单元测试覆盖率 ≥ 80%
- [ ] tracing 覆盖率 ≥ 95%,关键指标已接入 Grafana
- [ ] `pnpm run lint``pnpm run typecheck` 零错误
- [ ] 安全扫描无高危漏洞
### 8.2 整体迁移完成标准
- [ ] 6 阶段全部交付且验收通过
- [ ] CICD 35 个业务模块全部在对应微服务中找到归宿
- [ ] 旧系统流量完全切流至新系统strangler fig 完成)
- [ ] RPO ≤ 15minRTO ≤ 30min
- [ ] P99 延迟 ≤ 500ms
- [ ] 全链路追踪可用,任意请求可一键定位调用链
- [ ] 文档体系完整架构图、契约、README、known-issues 全部同步
### 8.3 回滚预案
每个阶段均需准备回滚方案:
1. **P1 地基**基础设施回滚Kafka/可观测平台降级)
2. **P2 身份**:双写期间保留 CICD NextAuth 作为 fallback
3. **P3 核心教学**:双写 + 读流量按比例切流,异常即回切
4. **P4 内容分析**CQRS 读模型失败时降级为直查 MySQL
5. **P5 沟通AI**AI 服务降级为兜底响应,不影响主流程
6. **P6 硬化**:混沌演练发现问题后回滚至上一稳定版本
---
## 附录:迁移过程中的关键决策点
| 决策点 | 选择 | 理由 |
|--------|------|------|
| 服务拆分粒度 | 6 业务 + 2 基础设施 | 平衡团队规模与拆分收益,避免过细导致 RPC 开销 |
| 通信协议 | gRPC内部+ RESTBFF 对外) | 内部高性能,外部兼容性 |
| 事件总线 | Kafka + Debezium CDC | CDC 减少业务代码侵入Outbox 模式保证一致性 |
| 契约工具 | protobuf + buf | 多语言代码生成breaking change 检测 |
| 前端架构 | Module Federation | 微前端独立部署,运行时共享依赖 |
| 状态管理 | 沿用 5 层模型 | 团队熟悉度高,迁移成本低 |
| 数据库拆分 | 每服务独占库 | 杜绝跨服务联表,强制契约化通信 |
| 缓存策略 | NestJS Cache + Redis | 替代 React cache(),规则(权限不跨请求缓存)沿用 |
| 迁移方式 | strangler fig | 风险可控,渐进式切换 |
| arch.db | 多语言扫描扩展 | 复用 CICD 元数据库思路,扩展 Go/Python 扫描器 |

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# Next Edu Cloud
基于 DDD + EDA + CQRS 的企业级微服务架构教育云平台。
## 技术栈
- **网关层**: Go (Gin)
- **业务服务**: TypeScript (NestJS)
- **分析/AI**: Python (FastAPI)
- **前端**: React (Next.js + Module Federation)
- **存储**: MySQL / Redis / ClickHouse / Neo4j / Elasticsearch
- **事件总线**: Kafka + Debezium CDC
- **契约**: protobuf + buf
## 快速开始
```bash
# 1. 安装依赖
pnpm install
# 2. 启动最小基础设施MySQL + Redis
docker compose -f infra/docker-compose.minimal.yml up -d
# 3. 复制环境变量
cp .env.example .env
# 4. 运行 arch.db 扫描
pnpm run arch:scan
# 5. 启动服务
pnpm dev
```
## 文档
- [架构设计](docs/architecture/004_architecture_impact_map.md)
- [理想蓝图](docs/architecture/0010_architecture.md)
- [编码规范](docs/standards/coding-standards.md)
- [UI 设计系统](docs/standards/ui-design-system.md)
- [Git 工作流](docs/standards/git-workflow.md)
- [已知问题](docs/troubleshooting/known-issues.md)
- [项目规则](project_rules.md)
- [迁移指南](MIGRATION_GUIDE.md)
## 开发阶段
- **P1 地基** (M1-M3): 仓库骨架 + 基础设施 + classes 黄金模板
- **P2 身份** (M4-M6): IAM 服务 + Teacher BFF + 微前端骨架
- **P3 核心教学** (M7-M10): CoreEdu + Outbox + 考试/作业/成绩
- **P4 内容分析** (M11-M13): Content + DataAna + CDC
- **P5 沟通AI** (M14-M16): Msg + Push + AI + ES
- **P6 硬化** (M17-M18): Service Mesh + 生产硬化

13
SECURITY.md Normal file
View File

@@ -0,0 +1,13 @@
# Security Policy
## 报告漏洞
发现安全漏洞请勿公开提交 issue请发送邮件至 security@example.com。
## 安全架构
- JWT RS256 非对称签名IAM 私钥签发Gateway/服务公钥校验)
- DataScope 6 级数据范围过滤
- 参数化 SQL 查询(禁止字符串拼接)
- 环境变量校验(@t3-oss/env-nextjs / viper / pydantic-settings
- 密钥管理:开发用 .env + docker secrets生产用 VaultP6

2
apps/teacher-portal/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

View File

@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
async rewrites() {
return [
{
source: '/api/v1/:path*',
destination: `${process.env.API_GATEWAY_URL || 'http://localhost:8080'}/api/v1/:path*`,
},
];
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1,26 @@
{
"name": "@edu/teacher-portal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-paper: hsl(40, 20%, 98%);
--color-ink: hsl(25, 3%, 15%);
--color-ink-muted: hsl(30, 5%, 45%);
--color-accent: hsl(220, 60%, 35%);
--color-rule: hsl(30, 10%, 90%);
--font-serif: 'Fraunces', Georgia, serif;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
html, body {
background: var(--bg-paper);
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-serif);
font-weight: 600;
letter-spacing: -0.01em;
}
/* 纸感分隔线 */
.rule {
border-top: 1px solid var(--color-rule);
}
.rule-thin {
border-top: 2px solid var(--color-rule);
}
/* 左侧竖线标记(节点展开样式)*/
.mark-left {
border-left: 2px solid var(--color-rule);
padding-left: 12px;
}

View File

@@ -0,0 +1,24 @@
import './globals.css';
import type { Metadata } from 'next';
import { Inter, Fraunces, JetBrains_Mono } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
const fraunces = Fraunces({ subsets: ['latin'], variable: '--font-fraunces' });
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' });
export const metadata: Metadata = {
title: 'Edu Teacher Portal',
description: 'K12 智慧教务平台 - 教师端',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}>
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
interface ClassItem {
id: string;
name: string;
gradeId: string;
description?: string;
createdAt: number;
updatedAt: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false);
const [name, setName] = useState('');
const [gradeId, setGradeId] = useState('550e8400-e29b-41d4-a716-446655440000');
const [description, setDescription] = useState('');
const [error, setError] = useState<string | null>(null);
const fetchClasses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/v1/classes');
const json: ApiResponse<ClassItem[]> = await res.json();
if (json.success && json.data) {
setClasses(json.data);
} else {
setError(json.error?.message || 'Failed to load');
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Network error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
const res = await fetch('/api/v1/classes', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer dev-token' },
body: JSON.stringify({ name, gradeId, description }),
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || 'Create failed');
return;
}
setName('');
setDescription('');
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : 'Network error');
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/v1/classes/${id}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer dev-token' },
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || 'Delete failed');
return;
}
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : 'Network error');
}
};
return (
<div className="min-h-screen" style={{ background: 'var(--bg-paper)' }}>
<header className="border-b" style={{ borderColor: 'var(--color-rule)' }}>
<div className="max-w-6xl mx-auto px-8 py-6">
<h1 className="text-3xl" style={{ fontFamily: 'var(--font-serif)', color: 'var(--color-ink)' }}>
</h1>
<p className="mt-1 text-sm" style={{ color: 'var(--color-ink-muted)' }}>
P1 - classes CRUD
</p>
</div>
</header>
<main className="max-w-6xl mx-auto px-8 py-8 grid grid-cols-12 gap-8">
{/* 左侧:创建表单 */}
<aside className="col-span-4">
<h2 className="text-xl mb-4" style={{ fontFamily: 'var(--font-serif)' }}></h2>
<div className="rule-thin mb-4" />
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }}
placeholder="如:高三(1)班"
required
/>
</div>
<div>
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}>
ID
</label>
<input
type="text"
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }}
/>
</div>
<div>
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b resize-none"
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }}
rows={3}
/>
</div>
<button
type="submit"
className="px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90"
style={{ background: 'var(--color-accent)', borderRadius: '6px' }}
>
</button>
</form>
</aside>
{/* 中间:班级列表(纸面)*/}
<section className="col-span-8">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl" style={{ fontFamily: 'var(--font-serif)' }}>
<span className="ml-2 text-sm font-sans" style={{ color: 'var(--color-ink-muted)' }}>
{classes.length}
</span>
</h2>
<button
onClick={fetchClasses}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: 'var(--color-accent)' }}
>
</button>
</div>
<div className="rule-thin mb-6" />
{error && (
<div className="mark-left mb-4 py-2" style={{ borderColor: 'var(--color-accent)' }}>
<p className="text-sm" style={{ color: 'var(--color-accent)' }}>{error}</p>
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: 'var(--color-ink-muted)' }}>...</p>
) : classes.length === 0 ? (
<p className="text-sm italic" style={{ color: 'var(--color-ink-muted)' }}>
</p>
) : (
<ul className="space-y-0">
{classes.map((cls) => (
<li key={cls.id} className="py-4 grid grid-cols-12 gap-4 items-baseline" style={{ borderBottom: '1px solid var(--color-rule)' }}>
<div className="col-span-7">
<h3 className="text-lg" style={{ fontFamily: 'var(--font-serif)', color: 'var(--color-ink)' }}>
{cls.name}
</h3>
{cls.description && (
<p className="mt-1 text-sm" style={{ color: 'var(--color-ink-muted)' }}>{cls.description}</p>
)}
</div>
<div className="col-span-3 text-xs font-mono" style={{ color: 'var(--color-ink-muted)' }}>
{cls.id.slice(0, 8)}...
</div>
<div className="col-span-2 text-right">
<button
onClick={() => handleDelete(cls.id)}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: 'var(--color-ink-muted)' }}
>
</button>
</div>
</li>
))}
</ul>
)}
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
paper: 'hsl(40, 20%, 98%)',
ink: {
DEFAULT: 'hsl(25, 3%, 15%)',
muted: 'hsl(30, 5%, 45%)',
},
accent: 'hsl(220, 60%, 35%)',
rule: 'hsl(30, 10%, 90%)',
},
fontFamily: {
serif: ['Fraunces', 'Georgia', 'serif'],
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
borderRadius: {
DEFAULT: '6px',
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"allowJs": true,
"noEmit": true,
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "src/**/*", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,174 @@
抛开历史包袱和重构成本,如果我们站在 2026 年的上帝视角为一个支撑百万级日活、多端Web、App、小程序、高并发如全校同时交卷的 K12 教育生态系统设计企业级理想架构,那么整个系统必须走向 “领域驱动设计 (DDD) + 彻底的事件驱动 (EDA) + 读写分离 (CQRS) + 异构微服务” 的云原生架构。
以下是现代企业级最优架构的设计蓝图:
一、 现代企业级架构全景图
在这种规模下Next.js 不再充当“全栈巨石”,而是退化为纯粹的展示层 (Micro-Frontends) 和 BFF (Backend for Frontend)。
code
Mermaid
graph TB
subgraph ClientLayer["1. 多端访问层 (Micro-Frontends)"]
direction LR
MFE1[Teacher Portal]
MFE2[Student Portal]
MFE3[Parent App]
MFE4[Admin Console]
end
subgraph EdgeLayer["2. 边缘与网关层 (API Gateway & BFF)"]
WAF[WAF / CDN / 防火墙]
Gateway[全局统一网关 API Gateway<br/>Kong / APISIX / 鉴权 / 限流]
BFF1[Teacher BFF<br/>GraphQL]
BFF2[Student BFF<br/>GraphQL]
BFF3[Parent BFF<br/>GraphQL]
end
subgraph ServiceLayer["3. 领域微服务层 (Domain Services)"]
IAM[IAM 服务<br/>认证授权/RBAC]
CoreEdu[核心教学服务<br/>班级/排课/试卷/作业]
Content[教研内容服务<br/>教材/知识图谱]
DataAna[学情分析服务<br/>诊断/错题/成绩]
Msg[消息调度服务<br/>通知/短信/SSE长连接]
AI[AI 调度网关<br/>大模型接入/Prompt管理]
end
subgraph EventLayer["4. 事件驱动总线 (Event Data Platform)"]
Kafka[Kafka / Pulsar 集群<br/>统一企业级事件总线]
end
subgraph DataLayer["5. 异构存储与数据层 (Polyglot Persistence)"]
MySQL[(关系型主库<br/>分布式 MySQL/TiDB)]
Redis[(缓存/分布式锁<br/>Redis Cluster)]
ClickHouse[(分析型数据仓库<br/>ClickHouse / Doris)]
Neo4j[(图数据库<br/>Neo4j / 知识图谱)]
ES[(搜索引擎<br/>Elasticsearch)]
end
ClientLayer --> WAF
WAF --> Gateway
Gateway --> BFF1 & BFF2 & BFF3
BFF1 & BFF2 & BFF3 --> ServiceLayer
%% 服务与存储的交互
CoreEdu --> MySQL
IAM --> MySQL
Content --> Neo4j
DataAna --> ClickHouse
ServiceLayer --> Redis
%% CDC 与总线
MySQL -- "CDC (Debezium)<br/>捕获变更" --> Kafka
ServiceLayer -- "发布领域事件<br/>Outbox Pattern" --> Kafka
Kafka -- "消费事件" --> ServiceLayer
Kafka -- "同步读视图" --> ClickHouse
Kafka -- "索引同步" --> ES
二、 彻底解耦的四大核心设计模式
在最优架构中,系统通过以下企业级模式解决耦合问题:
1. 读写分离 (CQRS):解决“上帝模块”与复杂聚合查询
在当前的 Next_Edu 中Dashboard仪表盘需要查询所有模块。在企业级架构中写入逻辑和查询逻辑是完全物理隔离的。
Command (写) 链路:教师批改作业,请求只打到 CoreEdu 微服务,该服务只更新 MySQL 中的作业表,并迅速返回。
Query (读) 链路:系统背后通过 CDC如 Debezium监听 MySQL 的 binlog将数据变更实时推送到 Kafka。
物化视图构建DataAna (数据分析微服务) 消费 Kafka 消息,在 ClickHouse 或 ES 中构建一个高度扁平的“宽表”(如 student_dashboard_view
收益:前端 Dashboard 请求 BFF 时BFF 只查 ClickHouse 中的那一张宽表,毫秒级返回。没有 Join没有跨服务调用系统彻底解耦。
2. 发件箱模式 (Transactional Outbox):解决跨模块数据一致性
如果模块 A 成功了,模块 B 失败了怎么办?企业级系统绝对不允许跨服务的数据库事务 (如 2PC),因为会拖垮性能。
设计教师发布考试时CoreEdu 服务在同一个本地事务中做两件事:
往 exams 表写入考试数据。
往 outbox_events 表写入一条事件:{"type": "EXAM_PUBLISHED", "data": {...}}。
流转:后台的 CDC 组件Debezium监控到 outbox_events 表有新数据,自动将其投递到 Kafka。
收益:实现了 100% 保证的不丢消息的最终一致性。业务逻辑无需关心外部模块是否存活。
3. 编排与协同 (Orchestration vs. Choreography)
企业级架构处理复杂业务流(例如:考试创建 -> 智能组卷 -> 题目查重 -> 通知分发 -> 家长推送)必须区分两种模式:
协同 (Choreography - 基于事件):适用于低耦合业务。发完作业后,发出 HomeworkCreated 事件,通知服务、积分服务各自监听,互相不知道对方存在。
编排 (Orchestration - 基于工作流):适用于强状态依赖的业务。引入工作流引擎(如 Temporal 或 Camunda。由一个中央 Coordinator 负责指挥:“先调 AI 生成题目,成功后再调试卷服务,如果失败就执行补偿逻辑回滚”。
4. 前端微前端化 (Micro-Frontends)
后端的解耦如果不配合前端的解耦,依然是一场灾难。
设计:通过 Module Federation模块联邦或 qiankun将巨大的前端应用拆解。
收益:“排课组”的前端和后端可以独立发版,“题库组”的前端和后端可以独立发版。页面的组装在运行时由宿主框架完成。
三、 基础设施与扩展性设计
统一 API 网关 (API Gateway)
所有端点不再直接暴露。网关(如 Kong 或 APISIX负责统一的 JWT 鉴权校验、黑名单拦截、全局限流(防 CC 攻击)。业务微服务默认收到请求时,用户身份已经是合法的。
长连接与推送网关独立
SSE 或 WebSocket 不再由业务容器承载。设立专门的 Push Gateway 服务(底层可基于 Go 或 Netty 编写,单节点支撑十万级连接)。业务服务只需往 Redis Pub/Sub 或 Kafka 发送消息Push Gateway 负责广播给对应连接的客户端。
异构存储 (Polyglot Persistence)
知识图谱:教材和知识点的层级不再强行用 MySQL 存储,改为 Neo4j图数据库在执行“求某知识点的前置知识树”时性能提升百倍。
错题本/诊断:这些包含海量日志和时序数据的业务,直接存入 ClickHouse列式数据库亿级数据聚合秒级返回。
题库检索:题目内容实时同步到 Elasticsearch支持分词、拼音、公式模糊全文搜索而不是 MySQL 的 FULLTEXT。
四、 对比:为什么这是“最优”?
维度 当前的单体/模块化 (Next_Edu V3) 现代企业级架构 结果差异
模块依赖 物理隔离,但代码级强引用 (import) 纯事件通信与 API 契约 任意微服务宕机/重构,完全不影响其他服务
聚合查询 并行查 5 个模块的 DB 再在内存拼装 后台预计算,直接查 ClickHouse 宽表 Dashboard 响应从 1.5s 降至 50ms
技术栈绑定 全部被绑死在 TypeScript + Next.js 异构。AI网关用Python高并发网关用Go 能够根据业务特性选择最优技术
实时推送 Next.js SSE连接数多了容易爆内存 独立的 Push Gateway + Redis PubSub 支持全校十万人同时在线答题的广播推送
容灾与扩容 只能整个应用一起扩容 核心教学扩 50 个 Pod后台审计缩至 1 个 Pod 资源利用率极高,抗高并发能力呈指数级提升
总结
企业级架构的本质是承认分布式环境下的不完美,用基础设施的复杂性来换取业务代码的简单性。
业务模块不再需要操心“谁要我的数据”、“如何拼装别人的数据”、“事务失败怎么回滚”。它们只做一件事:接收指令 -> 改变自身领域模型 -> 将改变广播给全宇宙Event Bus.
蓝图设计的合理性(几乎全对,但有几个地方需要微调)
1. 各层选型精准,但有一处“过度理想”
API Gateway + BFF + GraphQL
Gateway 做统一鉴权限流BFF 做面向前端的聚合GraphQL 允许不同端按需取数——这在多端下非常正确。
✅ 唯一需要警惕的是 GraphQL 的 N+1 和深度嵌套,必须在 BFF 层用 DataLoader 批量化解,否则可能引入严重的下游放大调用。
事件总线选 Kafka/Pulsar
对业务事件和 CDC 均用同一总线,完全可行。
✅ 建议明确划分 领域事件 topic 与 数据同步 topic两者消费模型不同业务事件可能需回溯、重试数据同步更适合顺序日志在企业级通常用不同集群或至少独立 namespace。
CQRS + CDC + ClickHouse 宽表:
这是“彻底解耦聚合查询”的王道方案。
⚠️ 但实时性要谨慎定义:从 MySQL binlog → Kafka → 消费者写入 ClickHouse一般有 1-5 秒延迟。对于需要实时成绩展示的场景,可能需要 BFF 支持“查询主库 + 降级宽表”的混合读。
⚠️ ClickHouse 宽表可能会变得非常庞大列特别多需要设计好聚合引擎表SummingMergeTree / AggregatingMergeTree和合理的分区键如按学校+日期),否则维护成本极高。
图数据库 Neo4j 用于知识图谱:
正确,前置依赖查询性能会远远优于 MySQL 递归 CTE。但也要考虑知识图谱写入频率低、读频率高Neo4j 单机足够,如果为了运维一致性,也可以用 PostgreSQL + Apache AGE 插件,但你的蓝图用 Neo4j 没问题。
Push Gateway 独立 + Redis Pub/Sub
十万级连接通过独立长连接服务处理,是正确的。
✅ 注意Redis Pub/Sub 不持久化,如果有消息可靠性要求(离线再上线收不到历史推送),需要引入一个持久化队列或消息主题,由 Push Gateway 消费后推送给在线用户,同时标记已读。
2. 四大核心模式评价
CQRS正确这是消除“上帝模块”的根本解。
Transactional Outbox + CDC
这是 唯一能保证最终一致性的分布式事务方案,比 2PC 和 Saga 都更适合高并发教育场景。
✅ 但要注意Outbox 表必须随业务服务独立部署CDC 连接器Debezium需要监控其心跳和落后量否则可能成为链路瓶颈。
Choreography vs Orchestration
你提到用 Temporal/Camunda 做编排,这非常对。
⚠️ 但要小心:“考试创建 → AI组卷 → 查重 → 通知分发”这类流程,如果使用全事件协同,会出现隐式状态机,维护复杂。用 Temporal 作为工作流引擎是明智的Temporal 本身已经内置了重试、补偿、超时,比自研编排器强得多。
微前端:
在多团队并行开发下必要,但教育产品交互一致性要求高,需要强约束的设计系统和跨应用状态共享机制(如通过 BFF 或客户端共享 token + 用户上下文)。模块联邦 + 统一 UI 库是标配。
3. 基础设施层的一个潜在疏漏
你提到了 WAF、Gateway、Kafka、异构存储但没有提到统一的配置中心与服务发现如 Consul/Nacos/K8s Service。在微服务体系下服务动态扩缩容、配置热更新如限流阈值、功能开关必须同步规划否则运维会变得困难。这一层通常与 Service MeshIstio配合可以在架构图中补上。

View File

@@ -0,0 +1,945 @@
# 架构影响地图(微服务版)
> 版本1.0
> 日期2026-07-07
> 状态:基线发布
> 适用范围Edu 微服务架构DDD + EDA + CQRS
> 关联文档:
> - [理想蓝图](./0010_architecture.md)
> - [项目规则](../../project_rules.md)
> - [路线图](./roadmap/README.md)
---
## 目录
1. [项目概述](#1-项目概述)
2. [技术栈](#2-技术栈)
3. [分层架构](#3-分层架构)
4. [服务依赖图](#4-服务依赖图)
5. [认证与权限](#5-认证与权限)
6. [数据访问与缓存](#6-数据访问与缓存)
7. [事件驱动架构](#7-事件驱动架构)
8. [跨服务协作](#8-跨服务协作)
9. [核心业务流程](#9-核心业务流程)
10. [可观测性](#10-可观测性)
11. [契约与 API 架构](#11-契约与-api-架构)
12. [架构约束](#12-架构约束)
13. [ADR 记录](#13-adr-记录)
14. [附录6 阶段路线图](#14-附录6-阶段路线图)
---
## 1. 项目概述
### 1.1 系统边界
```mermaid
graph TB
subgraph Users["用户层"]
Teacher[教师]
Student[学生]
Parent[家长]
Admin[管理员]
end
subgraph MFE["微前端层Module Federation"]
TeacherPortal[teacher-portal]
StudentPortal[student-portal]
ParentPortal[parent-portal]
AdminPortal[admin-portal]
end
subgraph Gateway["网关层Go"]
APIGateway[api-gateway<br/>Gin + JWT + 限流]
PushGateway[push-gateway<br/>WebSocket/SSE]
end
subgraph BFF["BFF 聚合层NestJS"]
TeacherBFF[teacher-bff<br/>GraphQL]
StudentBFF[student-bff<br/>GraphQL]
ParentBFF[parent-bff<br/>GraphQL]
end
subgraph Services["业务微服务NestJS + FastAPI"]
IAM[iam<br/>身份认证]
CoreEdu[core-edu<br/>教学核心]
Content[content<br/>内容资源]
DataAna[data-ana<br/>数据分析 Python]
Msg[msg<br/>消息通知]
AI[ai<br/>AI 网关 Python]
end
subgraph Bus["事件总线"]
Kafka[(Kafka)]
Debezium[Debezium CDC]
end
subgraph Data["数据层"]
MySQL[(MySQL<br/>每服务独占)]
Redis[(Redis<br/>缓存/会话)]
ClickHouse[(ClickHouse<br/>读模型宽表)]
Neo4j[(Neo4j<br/>知识图谱)]
ES[(Elasticsearch<br/>题库检索)]
end
Teacher --> TeacherPortal
Student --> StudentPortal
Parent --> ParentPortal
Admin --> AdminPortal
TeacherPortal --> APIGateway
StudentPortal --> APIGateway
ParentPortal --> APIGateway
AdminPortal --> APIGateway
TeacherPortal -.推送.-> PushGateway
StudentPortal -.推送.-> PushGateway
APIGateway --> TeacherBFF
APIGateway --> StudentBFF
APIGateway --> ParentBFF
TeacherBFF --> IAM
TeacherBFF --> CoreEdu
TeacherBFF --> Content
TeacherBFF --> DataAna
StudentBFF --> IAM
StudentBFF --> CoreEdu
ParentBFF --> IAM
ParentBFF --> CoreEdu
CoreEdu <--> Kafka
Content <--> Kafka
DataAna <--> Kafka
Msg <--> Kafka
IAM <--> Kafka
MySQL --> Debezium
Debezium --> Kafka
IAM --> MySQL
CoreEdu --> MySQL
Content --> Neo4j
DataAna --> ClickHouse
Msg --> MySQL
AI --> ES
IAM --> Redis
CoreEdu --> Redis
```
### 1.2 服务清单
| 类别 | 服务名 | 语言/框架 | 限界上下文 | 阶段 |
|------|--------|-----------|-----------|------|
| 基础设施 | api-gateway | Go (Gin) | 网关 | 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) | 教学核心 | P3 |
| 业务 | content | TS (NestJS) | 内容资源 | P4 |
| 业务 | data-ana | Python (FastAPI) | 数据分析 | P4 |
| 业务 | msg | TS (NestJS) | 消息通知 | P5 |
| 业务 | 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 | TS | protobuf 契约 | P1 |
| 共享包 | shared-ts | TS | TS 共享工具 | P1 |
| 共享包 | shared-go | Go | Go 共享工具 | P1 |
| 共享包 | shared-py | Python | Python 共享工具 | P4 |
### 1.3 CICD → Edu 模块映射
| CICD 模块(旧) | Edu 服务(新) | 迁移阶段 |
|----------------|---------------|----------|
| auth + users + rbac | iam | P2 |
| classes + subjects + enrollment | core-edu | P3 |
| courses + lessons + schedule + attendance | core-edu | P3 |
| assignments + grades + exams | core-edu | P3 |
| textbooks + knowledge-points | content | P4 |
| questions + grading | content | P4 |
| messaging + notifications | msg | P5 |
| analytics + dashboard + diagnostic | data-ana | P4 |
| ai + lesson-preparation | ai | P5 |
| search | content (ES) | P4 |
---
## 2. 技术栈
### 2.1 多语言技术栈矩阵
| 层级 | 语言 | 框架 | 用途 |
|------|------|------|------|
| 网关层 | Go 1.22+ | Gin | API Gateway、Push Gateway |
| 业务服务 | TypeScript 5.5+ | NestJS 10 | IAM、CoreEdu、Content、Msg |
| 分析/AI | Python 3.12+ | FastAPI | DataAna、AI 网关 |
| BFF | TypeScript 5.5+ | NestJS 10 + GraphQL | 教师/学生/家长聚合 |
| 微前端 | TypeScript 5.5+ | Next.js 15 + Module Federation | 4 端门户 |
| 契约 | protobuf | buf | 跨语言契约定义 |
### 2.2 多存储矩阵
| 存储 | 用途 | 使用服务 |
|------|------|----------|
| MySQL 8 | 写模型主库(每服务独占) | IAM、CoreEdu、Content、Msg |
| Redis 7 | 缓存、会话、限流计数 | 全部服务 |
| ClickHouse | 读模型宽表、分析聚合 | DataAna、CoreEdu读模型 |
| Neo4j | 知识图谱、前置依赖 | Content |
| Elasticsearch | 题库全文检索 | Content、AI |
### 2.3 基础设施矩阵
| 组件 | 用途 |
|------|------|
| Kafka | 事件总线,领域事件异步通信 |
| Debezium | CDCMySQL Binlog → Kafka 实时同步 |
| Temporal | 工作流编排考试生命周期、AI 编排) |
| OpenTelemetry | 分布式追踪 |
| Loki | 日志聚合 |
| Tempo | 分布式 Trace 存储 |
| Prometheus | 指标采集 |
| Grafana | 可观测性可视化 |
| Vault | 密钥管理P6 |
---
## 3. 分层架构
### 3.1 六层架构
```mermaid
graph TB
subgraph L1["L1 客户端层"]
Browser[浏览器]
Mobile[移动端]
end
subgraph L2["L2 微前端层"]
MFE[Module Federation<br/>4 端门户]
end
subgraph L3["L3 网关层"]
GW[API Gateway Go<br/>路由+鉴权+限流+熔断]
Push[Push Gateway Go<br/>WebSocket/SSE]
end
subgraph L4["L4 BFF 聚合层"]
BFF[BFF NestJS<br/>聚合+裁剪+协议转换]
end
subgraph L5["L5 业务微服务层"]
SVC[6 业务服务<br/>DDD+CQRS]
end
subgraph L6["L6 数据与总线层"]
DB[(MySQL/CH/Neo4j/ES)]
KAFKA[(Kafka)]
TEMPORAL[Temporal]
end
Browser --> MFE
Mobile --> MFE
MFE --> GW
MFE -.推送.-> Push
GW --> BFF
BFF --> SVC
SVC --> DB
SVC <--> KAFKA
SVC --> TEMPORAL
```
### 3.2 依赖方向
```
L1 客户端 → L2 微前端 → L3 网关 → L4 BFF → L5 业务服务 → L6 数据/总线
```
**严格规则**
1. L3 网关层只做路由、鉴权、限流、熔断,**不写业务逻辑**
2. L4 BFF 层只做聚合、裁剪、协议转换,**不持有业务状态**
3. L5 业务服务之间通过 gRPC同步或 Kafka 事件(异步)通信,**不直接访问对方数据库**
4. L6 数据层每个微服务独占自身数据库,**禁止跨库联表**
---
## 4. 服务依赖图
```mermaid
graph TB
subgraph Gateway["网关层"]
APIGW[api-gateway]
PushGW[push-gateway]
end
subgraph BFF["BFF 层"]
TBFF[teacher-bff]
SBFF[student-bff]
PBFF[parent-bff]
end
subgraph Services["业务服务"]
IAM[iam]
CoreEdu[core-edu]
Content[content]
DataAna[data-ana]
Msg[msg]
AI[ai]
end
subgraph Shared["共享包"]
Proto[shared-proto]
SharedTS[shared-ts]
end
APIGW --> TBFF
APIGW --> SBFF
APIGW --> PBFF
PushGW --> Msg
TBFF --> IAM
TBFF --> CoreEdu
TBFF --> Content
TBFF --> DataAna
TBFF --> AI
SBFF --> IAM
SBFF --> CoreEdu
SBFF --> Content
SBFF --> DataAna
PBFF --> IAM
PBFF --> CoreEdu
CoreEdu -.事件.-> Content
CoreEdu -.事件.-> DataAna
CoreEdu -.事件.-> Msg
Content -.事件.-> DataAna
IAM -.事件.-> CoreEdu
IAM -.事件.-> Msg
AI -.gRPC.-> Content
AI -.gRPC.-> DataAna
IAM --> Proto
CoreEdu --> Proto
Content --> Proto
DataAna --> Proto
Msg --> Proto
AI --> Proto
IAM --> SharedTS
CoreEdu --> SharedTS
Content --> SharedTS
Msg --> SharedTS
```
### 4.1 服务间通信矩阵
| 调用方 → 被调用方 | 协议 | 场景 |
|-------------------|------|------|
| api-gateway → BFF | gRPC | 请求路由 |
| BFF → 业务服务 | gRPC | 同步查询聚合 |
| CoreEdu → Content | Kafka 事件 | 教学内容变更通知 |
| CoreEdu → DataAna | Kafka 事件 | 学情数据投递 |
| CoreEdu → Msg | Kafka 事件 | 通知触发 |
| IAM → CoreEdu | Kafka 事件 | 用户变更同步 |
| AI → Content | gRPC | 题库查询 |
| AI → DataAna | gRPC | 学情数据查询 |
| push-gateway → Msg | gRPC | 推送通道建立 |
---
## 5. 认证与权限
### 5.1 JWT RS256 认证流程
```mermaid
sequenceDiagram
participant U as 用户
participant GW as API Gateway
participant IAM as IAM 服务
participant SVC as 业务服务
rect rgb(240, 248, 255)
Note over U,IAM: 阶段 1: 登录签发
U->>GW: POST /auth/login {username, password}
GW->>IAM: gRPC Login(username, password)
IAM->>IAM: 校验密码 + 查询权限
IAM->>IAM: 生成 JWTRS256 私钥签发)
IAM-->>GW: {accessToken, refreshToken, userInfo}
GW-->>U: 200 {accessToken, refreshToken}
end
rect rgb(240, 255, 240)
Note over U,SVC: 阶段 2: 请求鉴权
U->>GW: GET /api/classes + Authorization: Bearer <token>
GW->>GW: RS256 公钥校验签名
GW->>GW: 校验 exp/iss/aud
GW->>GW: 提取 userId/role/dataScope
GW->>SVC: gRPC 请求 + 透传 userId/role/dataScope metadata
SVC->>SVC: 权限校验requirePermission
SVC-->>GW: 响应数据
GW-->>U: 200 响应
end
```
### 5.2 三层角色模型
| 层级 | 来源 | 示例 | 优先级 |
|------|------|------|--------|
| 系统角色 | 系统预设 | admin、teacher、student、parent | 最高 |
| 组织角色 | 学校/班级分配 | 年级组长、班主任、学科组长 | 中 |
| 临时角色 | 临时授权 | 代课教师、临时代理 | 最低 |
**规则**:权限取三层角色权限的并集,拒绝权限取交集(任一层拒绝则拒绝)。
### 5.3 DataScope 6 级数据范围
| 级别 | 名称 | 数据范围 | 典型角色 |
|------|------|----------|----------|
| L0 | SELF | 仅本人数据 | 学生、家长 |
| L1 | CLASS | 本班数据 | 班主任、学生 |
| L2 | GRADE | 本年级数据 | 年级组长 |
| L3 | SCHOOL | 本校数据 | 校管理员 |
| L4 | DISTRICT | 本区数据 | 区教研员 |
| L5 | ALL | 全部数据 | 系统管理员 |
**实现**:业务服务在 Repository 层根据 dataScope 级别动态注入 WHERE 条件。
---
## 6. 数据访问与缓存
### 6.1 CQRS 读写分离
```mermaid
graph LR
subgraph Write["写路径"]
Cmd[Command 命令] --> App[Application Service]
App --> Domain[Domain 领域模型]
Domain --> Repo[Repository 写模型]
Repo -->[(MySQL 主库)]
App --> Outbox[(Outbox 表<br/>同事务)]
end
subgraph Sync["同步链路"]
Outbox --> Relay[Relay Worker]
Relay --> Kafka[(Kafka)]
Kafka --> Proj[Projection]
Proj -->[(ClickHouse 宽表)]
Proj -->[(Redis 缓存)]
Proj -->[(ES 索引)]
end
subgraph Read["读路径"]
Query[Query 查询] --> ReadModel[Read Model]
ReadModel -->[(ClickHouse 宽表)]
ReadModel -->[(Redis 缓存)]
ReadModel -->[(ES 索引)]
end
```
### 6.2 BFF 混合读策略
```mermaid
flowchart TD
Q[BFF 收到查询请求] --> C1{Redis 命中?}
C1 -- 是 --> R1[返回缓存]
C1 -- 否 --> C2{需要聚合多服务?}
C2 -- 否 --> C3[直接 gRPC 调用单一服务]
C3 --> C4[写入 Redis]
C4 --> R2[返回]
C2 -- 是 --> C5[并行 gRPC 调用多服务]
C5 --> C6[内存聚合裁剪]
C6 --> C7[写入 Redis 5-30s 短缓存]
C7 --> R3[返回聚合结果]
```
### 6.3 缓存策略矩阵
| 数据类型 | 存储 | TTL | 失效策略 |
|----------|------|-----|----------|
| 用户会话 | Redis | 30 分钟 | 滑动过期 |
| 权限列表 | Redis | 5 分钟 | 事件驱动失效 |
| 班级/年级列表 | Redis | 5 分钟 | 事件驱动失效 |
| 教学资源详情 | Redis | 30 秒 | 短 TTL |
| BFF 聚合结果 | Redis | 5-30 秒 | 短 TTL |
| 学情宽表 | ClickHouse | 实时 | CDC 同步 |
| 题库检索 | ES | 实时 | CDC 同步 |
---
## 7. 事件驱动架构
### 7.1 Outbox + Kafka + CDC 全链路
```mermaid
graph LR
subgraph Service["业务服务"]
Cmd[Command 处理]
Domain[Domain 聚合]
Repo[Repository]
Outbox[(Outbox 表)]
end
subgraph MySQL[("MySQL 主库")]
BizTable[(业务表)]
OutboxTable[(outbox 表)]
end
subgraph Relay["Relay Worker"]
Poll[轮询 outbox<br/>每 100ms]
Publish[发布到 Kafka]
Mark[标记 processed]
end
subgraph Bus["事件总线"]
Kafka[(Kafka topic)]
end
subgraph Consumers["消费者"]
Proj[Projection<br/>更新读模型]
OtherSvc[其他服务<br/>业务订阅]
end
Cmd --> Domain
Domain --> Repo
Repo --> BizTable
Repo --> OutboxTable
OutboxTable --> Poll
Poll --> Publish
Publish --> Kafka
Kafka --> Proj
Kafka --> OtherSvc
Proj -->[(ClickHouse/Redis/ES)]
```
### 7.2 事件 Topic 分类
| Topic 模式 | 示例 | 生产者 | 消费者 |
|-----------|------|--------|--------|
| `edu.identity.user.created` | 用户创建 | IAM | CoreEdu、Msg |
| `edu.identity.user.updated` | 用户更新 | IAM | CoreEdu、Msg |
| `edu.org.class.created` | 班级创建 | CoreEdu | DataAna |
| `edu.teaching.assignment.submitted` | 作业提交 | CoreEdu | DataAna、Msg |
| `edu.teaching.exam.published` | 考试发布 | CoreEdu | Msg |
| `edu.teaching.grade.recorded` | 成绩录入 | CoreEdu | DataAna、Msg |
| `edu.content.question.published` | 题目发布 | Content | AI、ES |
| `edu.insight.mastery.updated` | 掌握度更新 | DataAna | CoreEdu、Msg |
### 7.3 核心领域事件
| 事件 | 触发场景 | 消费者动作 |
|------|----------|-----------|
| UserRegistered | 新用户注册 | CoreEdu 初始化默认班级关联Msg 发送欢迎通知 |
| ExamPublished | 考试发布 | Msg 推送考试通知给学生DataAna 创建考试分析骨架 |
| HomeworkSubmitted | 学生提交作业 | DataAna 记录提交行为Msg 通知教师 |
| HomeworkGraded | 教师批改完成 | DataAna 更新掌握度Msg 通知学生 |
| MasteryUpdated | 掌握度计算完成 | CoreEdu 推荐个性化练习Msg 触发预警 |
| NotificationRequested | 通知请求 | Msg 投递通知到多渠道 |
---
## 8. 跨服务协作
### 8.1 同步 vs 异步决策
```mermaid
flowchart TD
Req[跨服务协作需求] --> C1{需要强一致性?}
C1 -- 是 --> C2{调用方需要立即结果?}
C2 -- 是 --> Sync[同步 gRPC 调用]
C2 -- 否 --> Saga[Saga 编排<br/>Temporal]
C1 -- 否 --> C3{需要事件最终一致?}
C3 -- 是 --> Async[异步 Kafka 事件]
C3 -- 否 --> C4{仅查询读取?}
C4 -- 是 --> Read[读模型冗余]
C4 -- 否 --> Sync
```
### 8.2 BFF 同步聚合
```mermaid
sequenceDiagram
participant U as 教师端
participant BFF as teacher-bff
participant IAM as iam
participant CoreEdu as core-edu
participant DataAna as data-ana
U->>BFF: 查询班级仪表盘
BFF->>BFF: 解析 token 获取 userId
par 并行查询
BFF->>IAM: gRPC GetUser(userId)
IAM-->>BFF: 用户信息
and
BFF->>CoreEdu: gRPC GetClassesByTeacher(userId)
CoreEdu-->>BFF: 班级列表
and
BFF->>DataAna: gRPC GetTeacherDashboardStats(userId)
DataAna-->>BFF: 统计数据
end
BFF->>BFF: 内存聚合裁剪
BFF-->>U: 仪表盘聚合数据
```
### 8.3 异步事件闭环
```mermaid
flowchart LR
A[教师录入成绩] --> B[CoreEdu 写入 MySQL + Outbox]
B --> C[Outbox Relay 发布事件]
C --> D[teaching.grade.recorded]
D --> E[DataAna 消费更新掌握度]
D --> F[Msg 消费通知学生]
E --> G[insight.mastery.updated]
G --> H[CoreEdu 消费推荐练习]
G --> I[Msg 消费掌握度预警]
```
### 8.4 Temporal 工作流编排
```mermaid
flowchart TD
Start[考试发布] --> A1[Activity: 创建考试实例]
A1 --> A2[Activity: 通知学生]
A2 --> A3[Activity: 等待作答窗口]
A3 --> A4[Activity: 收集提交]
A4 --> C1{是否全部提交?}
C1 -- 否 --> A5[Activity: 自动提交未答]
C1 -- 是 --> A6[Activity: 批改]
A5 --> A6
A6 --> A7[Activity: 发布成绩]
A7 --> A8[Activity: 生成分析]
A8 --> End[工作流完成]
```
---
## 9. 核心业务流程
### 9.1 考试生命周期
```mermaid
sequenceDiagram
participant T as 教师
participant BFF as teacher-bff
participant Core as core-edu
participant Msg as msg
participant DA as data-ana
participant S as 学生
rect rgb(240, 248, 255)
Note over T,Core: 阶段 1: 创建发布
T->>BFF: 创建考试
BFF->>Core: gRPC CreateExam
Core->>Core: 写入 MySQL + Outbox
Core-->>BFF: examId
BFF-->>T: 创建成功
Core->>Msg: ExamPublished 事件
Msg->>S: 推送考试通知
end
rect rgb(240, 255, 240)
Note over S,Core: 阶段 2: 作答提交
S->>BFF: 提交答卷
BFF->>Core: gRPC SubmitExam
Core->>Core: 写入答卷 + Outbox
Core-->>BFF: 提交成功
Core->>DA: ExamSubmitted 事件
DA->>DA: 记录提交行为
end
rect rgb(255, 240, 245)
Note over T,DA: 阶段 3: 批改出分
T->>BFF: 批改答卷
BFF->>Core: gRPC GradeExam
Core->>Core: 写入成绩 + Outbox
Core-->>BFF: 批改完成
Core->>DA: GradeRecorded 事件
DA->>DA: 更新掌握度
Core->>Msg: GradeRecorded 事件
Msg->>S: 推送成绩通知
end
```
### 9.2 高并发提交
```mermaid
flowchart TD
S[学生提交] --> GW[API Gateway]
GW --> GW1{限流检查}
GW1 -- 通过 --> BFF[BFF]
GW1 -- 拒绝 --> R1[429 限流响应]
BFF --> Core[CoreEdu]
Core --> C1{Redis 分布式锁}
C1 -- 获取锁 --> C2[写入答卷]
C2 --> C3[Outbox 事件]
C3 --> C4[释放锁]
C4 --> R2[成功]
C1 -- 锁竞争 --> C5[排队等待 500ms]
C5 --> C1
C5 --> C6{超时?}
C6 -- 是 --> R3[排队中,请稍后]
```
### 9.3 AI 辅助出题
```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: 请求 AI 出题
BFF->>AI: gRPC GenerateQuestions
AI->>Content: gRPC 查询知识点
Content-->>AI: 知识点列表
AI->>DA: gRPC 查询班级学情
DA-->>AI: 学情数据
AI->>AI: LLM 调用生成题目
AI-->>BFF: 生成的题目列表
BFF-->>T: 返回题目供教师审核
T->>BFF: 确认入库
BFF->>Content: gRPC CreateQuestions
Content-->>BFF: 入库成功
```
---
## 10. 可观测性
### 10.1 三支柱可观测性
```mermaid
graph TB
subgraph App["应用层"]
SVC[业务服务]
GW[网关]
BFF[BFF]
end
subgraph Collect["采集层"]
OTEL[OpenTelemetry SDK]
PROM[Prometheus Exporter]
end
subgraph Storage["存储层"]
LOKI[(Loki<br/>日志)]
TEMPO[(Tempo<br/>Trace)]
PROMDB[(Prometheus<br/>指标)]
end
subgraph Vis["可视化层"]
GRAFANA[Grafana<br/>统一面板]
ALERT[AlertManager<br/>告警]
end
SVC --> OTEL
GW --> OTEL
BFF --> OTEL
OTEL --> LOKI
OTEL --> TEMPO
SVC --> PROM
GW --> PROM
PROM --> PROMDB
LOKI --> GRAFANA
TEMPO --> GRAFANA
PROMDB --> GRAFANA
PROMDB --> ALERT
```
### 10.2 Trace 上下文传播
```mermaid
flowchart LR
A[客户端请求] --> B[API Gateway<br/>生成 traceId]
B --> C[BFF<br/>继承 traceId]
C --> D[业务服务<br/>继承 traceId]
D --> E[Kafka 事件<br/>traceId 写入 header]
E --> F[消费者服务<br/>继承 traceId]
F --> G[数据存储<br/>span 记录]
```
**规则**
- 所有跨服务调用必须透传 W3C Trace Context
- Kafka 事件必须将 traceId 写入消息 header
- 日志必须包含 traceId 用于关联查询
- 关键业务操作必须创建 span创建考试、提交作业、批改等
---
## 11. 契约与 API 架构
### 11.1 Protobuf 契约体系
```mermaid
graph TB
subgraph Proto["protobuf 契约仓库"]
Identity[identity/v1<br/>user/role/permission]
Org[org/v1<br/>class/subject/enrollment]
Teaching[teaching/v1<br/>course/assignment/exam]
Content[content/v1<br/>textbook/question]
Comm[comm/v1<br/>message/notification]
Insight[insight/v1<br/>report/mastery]
end
subgraph Gen["代码生成"]
Buf[buf generate]
TS[TS 生成代码<br/>@bufbuild/protobuf]
Go[Go 生成代码<br/>protobuf-go]
Py[Python 生成代码<br/>betterproto]
end
subgraph Services["消费服务"]
SvcTS[NestJS 服务]
SvcGo[Go 网关]
SvcPy[Python 服务]
end
Identity --> Buf
Org --> Buf
Teaching --> Buf
Content --> Buf
Comm --> Buf
Insight --> Buf
Buf --> TS
Buf --> Go
Buf --> Py
TS --> SvcTS
Go --> SvcGo
Py --> SvcPy
```
### 11.2 契约规则
| 规则 | 说明 |
|------|------|
| 包命名 | `edu.[context].[aggregate].v[version]` |
| 版本化 | 破坏性变更必须升版本v1 → v2 |
| 字段编号 | 禁止复用已删除字段编号,使用 reserved |
| 消息命名 | PascalCase |
| 字段命名 | snake_case |
| 注释 | 每个 message 和字段必须注释 |
| CI 强制 | buf lint + buf breaking 必须通过 |
### 11.3 BFF 聚合模式
```mermaid
graph LR
subgraph Client["客户端"]
Q[GraphQL 查询]
end
subgraph BFF["BFF 层"]
Resolver[GraphQL Resolver]
DataLoader[DataLoader 批量去重]
Cache[Redis 短缓存]
end
subgraph Services["业务服务"]
S1[服务 A]
S2[服务 B]
S3[服务 C]
end
Q --> Resolver
Resolver --> Cache
Cache --> DataLoader
DataLoader --> S1
DataLoader --> S2
DataLoader --> S3
```
---
## 12. 架构约束
### 12.1 服务独立性约束
| 约束 | 说明 |
|------|------|
| 数据库独占 | 每个微服务独占自身数据库,禁止跨库联表 |
| 契约先行 | 所有跨服务通信必须先定义 protobuf 契约 |
| Outbox 强制 | 所有领域事件必须通过 Outbox 模式发布 |
| 幂等消费 | 所有事件消费者必须实现幂等性 |
| 单一职责 | 每个服务只负责一个限界上下文 |
| 无状态服务 | 业务服务不持有会话状态Redis 承载) |
### 12.2 通信约束
| 场景 | 允许 | 禁止 |
|------|------|------|
| 客户端 → Gateway | REST + WebSocket | 直连业务服务 |
| Gateway → BFF | gRPC | REST |
| BFF → 业务服务 | gRPC | 直接访问 DB |
| 业务服务之间(同步) | gRPC + 必要时 | REST、直接 DB |
| 业务服务之间(异步) | Kafka 事件 | 直接 producer 调用 |
| 事件发布 | Outbox 模式 | 直接 Kafka producer |
### 12.3 数据一致性约束
| 场景 | 一致性级别 | 实现 |
|------|-----------|------|
| 聚合内 | 强一致 | 单事务 |
| 聚合间 | 最终一致 | Kafka 事件 |
| 服务间 | 最终一致 | Kafka 事件 / Saga |
| 读模型 | 最终一致 | Projection 异步更新 |
| 缓存 | 最终一致 | 事件驱动失效 + 短 TTL |
---
## 13. ADR 记录
| 编号 | 决策 | 原因 | 状态 |
|------|------|------|------|
| ADR-001 | 采用 DDD 限界上下文划分服务 | 业务边界清晰,独立演进 | 已采纳 |
| ADR-002 | 采用 NestJS 作为业务服务框架 | TS 生态成熟,装饰器 + DI 适合 DDD | 已采纳 |
| ADR-003 | 采用 Go 作为网关语言 | 高并发、低内存、适合网关场景 | 已采纳 |
| ADR-004 | 采用 Python 作为分析/AI 语言 | 数据科学/AI 生态丰富 | 已采纳 |
| ADR-005 | 采用 CQRS 读写分离 | 读多写少,读模型可独立优化 | 已采纳 |
| ADR-006 | 采用 Outbox 模式发布事件 | 保证事务与事件最终一致 | 已采纳 |
| ADR-007 | 采用 Kafka 作为事件总线 | 高吞吐、持久化、成熟生态 | 已采纳 |
| ADR-008 | 采用 Debezium CDC | 解耦 Outbox Relay减少业务侵入 | 已采纳 |
| ADR-009 | 采用 protobuf + buf 契约先行 | 多语言契约统一、版本化、CI 强制 | 已采纳 |
| ADR-010 | 采用 JWT RS256 非对称签名 | 网关公钥校验无需共享私钥 | 已采纳 |
| ADR-011 | 采用 DataScope 6 级数据范围 | 满足 K12 多层级数据隔离 | 已采纳 |
| ADR-012 | 采用 Module Federation 微前端 | 独立部署、技术栈无关、渐进迁移 | 已采纳 |
| ADR-013 | 采用 Temporal 工作流编排 | 长流程编排、可观测、可回滚 | 已采纳 |
| ADR-014 | 采用 ClickHouse 读模型宽表 | 分析查询亚秒级响应 | 已采纳 |
---
## 14. 附录6 阶段路线图
### 14.1 阶段总览
```mermaid
graph LR
P1[P1 地基<br/>M1-M3] --> P2[P2 身份<br/>M4-M6]
P2 --> P3[P3 核心教学<br/>M7-M10]
P3 --> P4[P4 内容分析<br/>M11-M13]
P4 --> P5[P5 沟通AI<br/>M14-M16]
P5 --> P6[P6 硬化<br/>M17-M18]
```
### 14.2 服务与阶段映射
| 阶段 | 周期 | 交付服务 | 退出标准 |
|------|------|----------|----------|
| P1 地基 | M1-M3 | api-gateway、classes黄金模板、shared-proto | classes 域 CRUD 端到端跑通 |
| P2 身份 | M4-M6 | iam、teacher-bff、teacher-portal 骨架 | 教师可登录并看到空白 Dashboard |
| P3 核心教学 | M7-M10 | core-edu合并 classes、student-bff、student-portal | 考试→作答→批改→成绩全链路 |
| P4 内容分析 | M11-M13 | content、data-ana、parent-bff、parent-portal | 知识图谱查询 + 学情宽表 5s |
| P5 沟通AI | M14-M16 | msg、push-gateway、ai | 全校广播 + AI 辅助出题 |
| P6 硬化 | M17-M18 | admin-portal、Service Mesh | 99.9% 可用性 + 独立扩缩容 |
> 详细规划见 [路线图目录](./roadmap/README.md)

View File

@@ -0,0 +1,28 @@
# 路线图索引
> 本目录存放项目长远规划与架构事实004分离。
## 文档清单
| 文档 | 用途 |
|------|------|
| [tech-debt.md](./tech-debt.md) | 技术债清单(按 P0/P1/P2 优先级 + 已解决项) |
| [pending-features.md](./pending-features.md) | 待开发功能路线图6 阶段P1 地基 → P6 硬化) |
## 6 阶段路线图总览
| 阶段 | 周期 | 核心交付 | 退出标准 |
|------|------|----------|----------|
| **P1 地基** | M1-M3 | 仓库骨架 + Docker 全量 infra + API Gateway + 契约层 + CI/CD + 文档体系 + classes 黄金模板 | classes 域 CRUD 端到端跑通 + 全横切关注点落地 + 30 分钟可复制新服务 |
| **P2 身份** | M4-M6 | IAM 服务 + Teacher BFF(GraphQL) + 微前端骨架 | 教师可登录并看到空白 Dashboard |
| **P3 核心教学** | M7-M10 | CoreEdu 服务 + Outbox 模式 + 考试/作业/成绩域 | 教师创建考试→学生作答→教师批改→成绩统计 全链路 |
| **P4 内容分析** | M11-M13 | Content 服务(Neo4j) + DataAna(Python+CH) + CDC 链路 | 知识图谱查询 + 学情诊断宽表 5s 内返回 |
| **P5 沟通AI** | M14-M16 | Msg 服务 + Push Gateway + AI 网关 + ES 题库检索 | 全校广播推送 + AI 辅助出题 + 题库检索 < 200ms |
| **P6 硬化** | M17-M18 | Service Mesh + 全链路可观测 + 生产硬化 | 单服务可独立扩缩容 + 99.9% 可用性 |
## 维护规则
- 规划实现后从本目录删除,迁入 004 架构事实或 git 历史
- 不含架构事实,不含经验(经验查 [../troubleshooting/known-issues.md](../troubleshooting/known-issues.md)
- 每阶段完成后打 tag`v0.1.0-p1``v0.2.0-p2`...
- 发现的新需求一律记入 `tech-debt.md`不在当前阶段实现YAGNI 原则)

View File

@@ -0,0 +1,203 @@
# 待开发功能路线图
> 按 6 阶段组织的功能路线图,每阶段列出关键功能和交付物。
> 阶段依赖P1 → P2 → P3 → P4/P5可并行但建议串行 → P6
---
## P1 地基阶段M1-M3
**目标**:搭建新仓库地基,多语言 monorepo + Docker 基础设施 + API Gateway + classes 黄金模板服务 + 文档体系 + CI/CD端到端跑通 classes 域 CRUD。
**退出标准**classes 域 CRUD 端到端跑通 + 全横切关注点落地 + 30 分钟可复制新服务。
### 关键功能
| 模块 | 功能 |
|------|------|
| 仓库骨架 | 多语言 monorepopnpm + go.work + pyproject+ 大仓文档LICENSE/CHANGELOG/CONTRIBUTING/SECURITY |
| Docker 基础设施 | 全量 docker-compose.yml按 profiles 分阶段启用)+ 最小开发集MySQL+Redis |
| API GatewayGo | 路由转发 + JWT HS256 校验 + 请求 ID 注入 + 健康检查 |
| 契约层 | shared-proto 包 + classes.proto + buf lint/breaking + 三端代码生成配置 |
| classes 黄金模板TS/NestJS | CRUD + 全横切关注点(错误处理/可观测/安全/契约/测试/文档/配置/i18n/CI/Dockerfile |
| arch.db 多语言扫描器 | TS 扫描ts-morph+ Go 扫描(正则骨架)+ Python 扫描(正则骨架)+ 查询工具 |
| teacher-portal 测试页 | Next.js 单页验证 classes CRUD 端到端链路 |
| CI/CD | 三语言 workflowci-go.yml / ci-ts.yml / ci-py.yml |
### 交付物
- `docker-compose up -d` 全量容器健康
- 浏览器创建班级 → MySQL 落库 → 列表显示
- `buf lint` + `buf breaking` 通过,三端代码生成成功
- 三语言 CI workflow 全绿lint+test+build
- 单元 + 集成 + 契约 + E2E 四类测试齐备,覆盖率达标
- 004 + 2 个模块 README + known-issues + project_rules 齐全
- `npm run arch:query -- violations` 输出 0
- 30 分钟内复制 classes 模板跑起新服务
- 打 tag `v0.1.0-p1`
---
## P2 身份阶段M4-M6
**目标**:建 IAM 服务替换 P1 Gateway 内置鉴权;建首个完整 BFFTeacher与微前端骨架。
**退出标准**:教师登录 → 获取 JWT → 访问 teacher-portal → 侧边栏按 `viewports.L1` 渲染 → 看到空白 Dashboard。
### 关键功能
| 模块 | 功能 |
|------|------|
| IAM 服务TS/NestJS | 认证(登录/登出/JWT/2FA+ RBAC角色/权限/角色-权限 CRUD+ 视口配置4 层模型)+ DataScope 解析 + `getEffectivePermissions(userId)` |
| MySQL schema | users / roles / permissions / role_permissions / role_viewports / parent_student_relations / class_subject_teachers |
| JWT RS256 | IAM 私钥签发Gateway/服务公钥校验access 15min / refresh 7day |
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 |
| Teacher BFFTS/GraphQL | GraphQL Yoga + DataLoader防 N+1+ 调 IAM 注入视口 |
| teacher-portal微前端宿主 | Module Federation + 路由骨架 + 侧边栏viewports.L1 驱动)+ 登录页 |
| API Gateway 升级 | 移除内置 JWT改调 IAM 校验;路由表扩展 `/api/v1/iam/*` |
### 交付物
- IAM 服务完整实现认证 + RBAC + 视口 + DataScope
- JWT RS256 非对称签名链路通畅
- Teacher BFF GraphQL 接口可用
- teacher-portal 微前端骨架 + 登录流程
- 教师登录后侧边栏按角色渲染
- 打 tag `v0.2.0-p2`
---
## P3 核心教学阶段M7-M10
**目标**:建 CoreEdu 服务实现考试全生命周期;落地 Outbox + Kafka 事件模式。
**退出标准**:教师创建考试 → 发布 → 学生作答提交 → 教师批改 → 事件发到 Kafka → 成绩统计更新 → 全链路可观测。
### 关键功能
| 模块 | 功能 |
|------|------|
| CoreEdu 服务TS/NestJS | 考试/作业/成绩域 CRUD + 批改业务编排 + Outbox 事件发布 |
| MySQL schema | exams / exam_questions / homework_assignments / homework_submissions / homework_answers / grade_records / outbox_events |
| Outbox 模式 | 业务事务同写 outbox_events 表;后台 relay worker 投递 Kafka |
| Outbox relayGo | 独立服务 `services/outbox-relay/`,轻量高吞吐 |
| Kafka | 启用业务 topicexam.published / homework.graded / grade.recorded |
| Teacher BFF 扩展 | 考试/作业/成绩的 GraphQL 查询与 mutation |
| teacher-portal 扩展 | 考试创建/作业批改/成绩查看页面 |
| student-portal微前端 | 学生作答作业页面Module Federation 子应用) |
| Temporal | 引入但仅做 1 个工作流(考试发布编排:创建作业→通知)试点 |
### 交付物
- CoreEdu 服务考试全生命周期链路通畅
- Outbox + Kafka 事件模式落地
- Outbox relay worker 稳定投递
- student-portal 微前端子应用接入
- 全链路 trace 可追OTel
- Outbox 模式回写黄金模板 README
- 打 tag `v0.3.0-p3`
---
## P4 内容分析阶段M11-M13
**目标**:建 Content 服务Neo4j 知识图谱)+ DataAna 服务Python+ClickHouse落地 CDC 链路。
**退出标准**教师查看知识图谱前置依赖Neo4j 秒级返回)→ 学生查看学情诊断ClickHouse 宽表 5s 内返回)→ CDC 链路延迟 < 5s。
### 关键功能
| 模块 | 功能 |
|------|------|
| Content 服务TS/NestJS | 教材/章节/知识点 CRUD仅 CRUD不实现检索+ 知识图谱查询Neo4j+ 题库 CRUD不实现检索 |
| MySQL schema | textbooks / chapters / knowledge_points / questions |
| Neo4j 数据 | 知识点前置依赖图(从 MySQL 同步) |
| DataAna 服务Python/FastAPI | 学情诊断 API + 错题本 API + 消费 Kafka 构建宽表 |
| ClickHouse | student_dashboard_view 宽表DataAna 消费 Kafka 填充) |
| CDC 链路 | Debezium 监听 MySQL binlog → Kafkamysql.cdc.*)→ DataAna 消费写 ClickHouse |
| BFF 扩展 | Teacher BFF 加知识图谱查询Student BFF 加学情诊断查询(双轨读) |
### 交付物
- Content 服务知识图谱查询秒级返回
- DataAna 学情诊断宽表 5s 内返回
- CDC 链路延迟 < 5s
- Neo4j 双写避免(消费事件同步)
- 双轨读策略落地(实时查主库 + 聚合查宽表)
- CDC 模式回写黄金模板 README
- 打 tag `v0.4.0-p4`
---
## P5 沟通与 AI 阶段M14-M16
**目标**:建 Msg 服务 + Push Gateway + AI 网关;全文检索迁 ES。
**退出标准**:教师发广播通知 → 全在线学生实时收到Push Gateway→ AI 辅助出题流式返回 → 题库全文检索 < 200ms。
### 关键功能
| 模块 | 功能 |
|------|------|
| Msg 服务TS/NestJS | 会话/消息 CRUD + 调 Push Gateway 推送 + 通知偏好 |
| Push GatewayGo | WebSocket 长连接管理 + 消费 Kafka 广播 + Redis PubSub 跨实例同步 |
| AI 网关Python/FastAPI | LLM Provider 适配OpenAI/Anthropic+ Prompt 模板管理 + 流式 SSE + 用量计费 |
| Elasticsearch | 题库全文检索(从 MySQL 同步)+ 全局搜索 API |
| Notifications 模块 | 多渠道(站内/SMS/邮件/微信),沿用旧项目 dispatcher 模式 |
| BFF/前端扩展 | Teacher BFF 加 AI 辅助出题 mutationteacher-portal 加 SSE 流式 AI 对话 |
### 交付物
- Msg 服务会话/消息 CRUD + 推送链路
- Push Gateway 单节点支撑 10w+ 连接
- AI 网关流式 SSE 三层透传AI → BFF → 前端)
- ES 题库全文检索 < 200ms
- 全校广播推送实时到达
- 长连接模式回写黄金模板 README
- 打 tag `v0.5.0-p5`
---
## P6 硬化阶段M17-M18
**目标**:生产硬化,达到可部署状态。
**退出标准**单服务可独立扩缩容HPA 生效)→ 全链路 trace 可追 → 模拟单服务宕机不影响核心链路 → 99.9% 可用性压测通过。
### 关键功能
| 模块 | 功能 |
|------|------|
| Service Mesh | Istio 服务网格mTLS + 流量治理 + 可观测) |
| 配置中心 | Consul 接入,配置热更新(限流阈值、功能开关) |
| 全链路可观测 | OpenTelemetry + Jaegertrace+ Prometheusmetrics+ Lokilogs |
| 限流/熔断 | Gateway 层限流(令牌桶)+ 服务间熔断Envoy |
| 灰度发布 | Istio 流量拆分(金丝雀) |
| K8s 部署 | 每服务 K8s manifest + HPA 自动扩缩容 |
| 生产 CI/CD | Git tag 触发镜像构建 + 滚动部署 |
| 灾备 | MySQL 主从 + Redis 哨兵 + Kafka 多副本 |
### 交付物
- Istio Service Mesh 全网格覆盖mTLS
- 全链路 trace + Grafana 仪表盘 + Loki 日志查询
- 限流熔断灰度发布可用
- K8s HPA 自动扩缩容生效
- 单服务宕机不影响核心链路
- 99.9% 可用性压测通过
- 降级方案若时间紧张Service Mesh 可降级为 K8s 原生 Service + Ingress
- 打 tag `v1.0.0-p6`
---
## 跨阶段不变约束
每阶段都必须遵守:
- 契约先行(先改 proto后写实现
- 文档同步004 + 模块 README + arch.db + known-issues 实时更新)
- CI 全绿才能合入 main
- 每阶段末打 tag
- Server/Handler 接口必须权限校验
- 单文件行数遵循语言规范TS ≤ 500/800/1000Go ≤ 800Python ≤ 800
- 阶段特有模式实现后回写黄金模板

View File

@@ -0,0 +1,47 @@
# 技术债清单
> 记录已知的架构问题和技术债,按优先级排序。
> 新项目从空白起步,随各阶段实施过程中发现的技术债记录于此。
## P0 - 高优先级(影响安全/性能/可维护性)
> 暂无。P1 实施过程中识别的高优先级技术债记录于此。
## P1 - 中优先级(影响开发效率/代码质量)
> 暂无。各阶段实施过程中识别的中优先级技术债记录于此。
## P2 - 低优先级(改进项)
> 暂无。各阶段实施过程中识别的低优先级改进项记录于此。
## 已解决项
> 暂无。已解决的技术债记录于此,含解决时间、方案、验证方式。
---
## 维护规则
### 优先级定义
| 优先级 | 含义 | 处理时机 |
|--------|------|----------|
| P0 | 影响安全/性能/可维护性 | 当前阶段必须解决,或立即修复 |
| P1 | 影响开发效率/代码质量 | 计划在下一阶段或独立迭代解决 |
| P2 | 改进项 | 时间允许时解决,不阻塞阶段推进 |
### 记录格式
每条技术债包含:
- **编号**: `TD-{P0|P1|P2}-{序号}`,如 `TD-P0-001`
- **状态**: 已识别 / 短期方案已实施 / 计划在某阶段实施 / 已解决
- **影响**: 简述对系统的影响
- **方案**: 短期方案 + 长期方案
- **关联文件/模块**: 便于定位
### 同步规则
- 发现技术债时立即记录不在当前阶段实现YAGNI 原则)
- 解决后移至"已解决项"分区,含解决时间与验证方式
- 每阶段末回顾技术债清单,调整优先级

81
docs/modules/README.md Normal file
View File

@@ -0,0 +1,81 @@
# 模块索引
> 版本1.0
> 日期2026-07-07
> 状态:基线发布
> 关联文档:[架构影响地图](../architecture/004_architecture_impact_map.md)
---
## 模块分类总览
本项目采用 DDD + EDA + CQRS 微服务架构,包含 6 大业务限界上下文、2 个基础设施服务、3 个 BFF 聚合层、4 个微前端和 4 个共享包。
---
## 一、基础设施服务
| 服务 | 语言/框架 | 职责 | 阶段 | README |
|------|-----------|------|------|--------|
| api-gateway | Go (Gin) | 统一入口、路由转发、JWT 鉴权、限流熔断 | P1 | [README](./api-gateway/README.md) |
| push-gateway | Go (Gin) | WebSocket/SSE 长连接、实时消息推送 | P5 | [README](./push-gateway/README.md) |
---
## 二、领域微服务
| 服务 | 语言/框架 | 限界上下文 | 主要聚合 | 阶段 | README |
|------|-----------|-----------|----------|------|--------|
| iam | TS (NestJS) | 身份认证 | User、Role、Permission | P2 | [README](./iam/README.md) |
| core-edu | TS (NestJS) | 教学核心 | Class、Course、Assignment、Exam、Grade | P3 | [README](./core-edu/README.md) |
| content | TS (NestJS) | 内容资源 | Textbook、Question、KnowledgePoint | P4 | [README](./content/README.md) |
| data-ana | Python (FastAPI) | 数据分析 | Report、Mastery、Dashboard | P4 | [README](./data-ana/README.md) |
| msg | TS (NestJS) | 消息通知 | Message、Notification、Announcement | P5 | [README](./msg/README.md) |
| ai | Python (FastAPI) | AI 网关 | LLMCall、Prompt、Generation | P5 | [README](./ai/README.md) |
---
## 三、BFF 聚合层
| BFF | 语言/框架 | 服务对象 | 阶段 | README |
|-----|-----------|----------|------|--------|
| teacher-bff | TS (NestJS + GraphQL) | 教师端 | P2 | 待创建 |
| student-bff | TS (NestJS + GraphQL) | 学生端 | P3 | 待创建 |
| parent-bff | TS (NestJS + GraphQL) | 家长端 | P4 | 待创建 |
---
## 四、微前端
| 微前端 | 语言/框架 | 服务对象 | 阶段 | README |
|--------|-----------|----------|------|--------|
| teacher-portal | TS (Next.js + Module Federation) | 教师端 | P2 | 待创建 |
| student-portal | TS (Next.js + Module Federation) | 学生端 | P3 | 待创建 |
| parent-portal | TS (Next.js + Module Federation) | 家长端 | P4 | 待创建 |
| admin-portal | TS (Next.js + Module Federation) | 管理端 | P6 | 待创建 |
---
## 五、共享包
| 包 | 语言 | 用途 | 阶段 | README |
|----|------|------|------|--------|
| shared-proto | TS | protobuf 契约定义与代码生成 | P1 | 待创建 |
| shared-ts | TS | TypeScript 共享工具类型 | P1 | 待创建 |
| shared-go | Go | Go 共享工具 | P1 | 待创建 |
| shared-py | Python | Python 共享工具 | P4 | 待创建 |
---
## 阶段交付路线
| 阶段 | 周期 | 交付模块 | 退出标准 |
|------|------|----------|----------|
| P1 地基 | M1-M3 | api-gateway、classes黄金模板、shared-proto | classes 域 CRUD 端到端跑通 |
| P2 身份 | M4-M6 | iam、teacher-bff、teacher-portal | 教师可登录并看到空白 Dashboard |
| P3 核心教学 | M7-M10 | core-edu合并 classes、student-bff、student-portal | 考试→作答→批改→成绩全链路 |
| P4 内容分析 | M11-M13 | content、data-ana、parent-bff、parent-portal | 知识图谱查询 + 学情宽表 5s |
| P5 沟通AI | M14-M16 | msg、push-gateway、ai | 全校广播 + AI 辅助出题 |
| P6 硬化 | M17-M18 | admin-portal、Service Mesh | 99.9% 可用性 + 独立扩缩容 |
> 详细规划见 [路线图目录](../architecture/roadmap/README.md)

111
docs/modules/ai/README.md Normal file
View File

@@ -0,0 +1,111 @@
# ai AI 网关服务
> 版本0.1(骨架)
> 日期2026-07-07
> 状态P5 待交付
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
ai 是 AI 网关限界上下文Python 实现),管理 LLMCall、Prompt、Generation 聚合。
统一封装 LLM 调用(多模型路由、重试、限流、成本控制),提供辅助出题、表达优化、分层提问等能力。
通过 gRPC 查询 content 题库与 data-ana 学情数据,结合知识图谱生成个性化内容。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | Python | 3.12+ | AI 生态 |
| 框架 | FastAPI | 0.110+ | 异步 API |
| LLM SDK | openai / anthropic | - | 多模型调用 |
| 编排 | LangGraph | - | LLM 工作流编排 |
| 检索 | Elasticsearch | 8.x | 题库检索RAG |
| 缓存 | Redis | 7.x | Prompt 缓存、结果缓存 |
| 校验 | pydantic | 2.x | 运行时校验 |
| 追踪 | OpenTelemetry | - | LLM 调用链追踪 |
---
## 3. 架构图
```mermaid
graph TB
BFF[BFF 聚合层] -->|gRPC| AI[ai 服务]
AI --> ROUTER[ModelRouter<br/>多模型路由]
AI --> ORCH[LangGraph 编排]
AI --> RAG[RAG 检索增强]
RAG -->|gRPC| CONTENT[content 题库]
RAG -->|gRPC| ANA[data-ana 学情]
AI -->[(ES<br/>题库检索)]
AI -->[(Redis<br/>结果缓存)]
AI --> LLM[LLM API<br/>OpenAI/Claude]
```
> 待 P5 交付时补充完整的分层架构图与 LLM 编排图。
---
## 4. 核心流程图
```mermaid
sequenceDiagram
participant T as 教师
participant BFF as BFF
participant AI as ai 服务
participant CONTENT as content
participant ANA as data-ana
participant LLM as LLM API
T->>BFF: AI 辅助出题
BFF->>AI: gRPC GenerateQuestions(topic, difficulty)
AI->>CONTENT: gRPC 查询相关题目 + 知识点
AI->>ANA: gRPC 查询学情掌握度
AI->>AI: LangGraph 编排 Prompt
AI->>LLM: 调用 LLM 生成
LLM-->>AI: 生成结果
AI->>AI: 质量校验 + 缓存
AI-->>BFF: 生成的题目列表
```
> 待 P5 交付时补充:表达优化流程、分层提问流程、差异化建议流程。
---
## 5. 对外契约
- **gRPC 服务**AIServiceGenerateQuestions、OptimizeExpression、SuggestDifferentiation、GenerateLayeredQuestions待 P5 定义 protobuf
- **消费事件**:无(按需 gRPC 调用)
- **发布事件**`edu.ai.generation.completed`(可选,用于审计)
- **缓存**Redis 存储生成结果(相同 prompt 24 小时缓存)
> 待 P5 交付时补充完整 protobuf 定义与 Prompt 模板规范。
---
## 6. 依赖关系
- **上游**teacher-bffAI 辅助功能)
- **下游**LLM APIOpenAI/Claude、Elasticsearch、Redis、contentgRPC、data-anagRPC
- **跨服务**gRPC 调用 content 题库与 data-ana 学情,不消费事件
---
## 7. 架构约束
1. LLM 调用必须经 ModelRouter支持多模型故障切换
2. 生成内容必须经质量校验,禁止直接返回未校验结果
3. 调用成本必须记录,按用户/学校维度限额
4. Prompt 模板必须版本化管理,禁止硬编码
5. RAG 检索必须优先于 LLM 生成,减少幻觉
> 待 P5 交付时补充完整约束清单。
---
## 8. 架构决策
> 待 P5 交付时补充 ADR 记录预计包括Python+FastAPI 选型、LangGraph 编排选型、多模型路由策略、RAG 检索策略、成本控制策略。

View File

@@ -0,0 +1,281 @@
# api-gateway 网关服务
> 版本1.0
> 日期2026-07-07
> 状态P1 已交付
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
api-gateway 是整个 Edu 平台的统一入口网关,承担以下核心职责:
- **路由转发**:将外部 HTTP/REST 请求按路径前缀转发到对应 BFF 或业务服务
- **JWT 鉴权**:使用 RS256 公钥校验 JWT 签名,提取 userId/role/dataScope 注入下游 metadata
- **限流熔断**:基于 Redis 令牌桶限流,基于错误率的滑动窗口熔断
- **CORS 跨域**:处理浏览器跨域预检请求
- **访问日志**结构化访问日志zap含 traceId/userId/latency/statusCode
- **健康检查**`/healthz``/readyz` 端点供 Kubernetes 探针使用
**不做的事**:不写业务逻辑、不访问数据库、不持有业务状态。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | Go | 1.22+ | 高性能网关 |
| Web 框架 | Gin | 1.10+ | HTTP 路由与中间件链 |
| JWT | golang-jwt/v5 | 5.2+ | RS256 签名校验 |
| 缓存/限流 | Redis | 7.x | 令牌桶计数、会话缓存 |
| 配置 | viper | 1.18+ | 多源配置env/yaml/configmap |
| 日志 | zap | 1.27+ | 结构化高性能日志 |
| 链路追踪 | OpenTelemetry | 1.24+ | 分布式追踪上下文注入 |
| 指标 | prometheus/client_golang | 1.19+ | RED 指标暴露 |
| 容器化 | Docker | - | 多阶段构建 |
| 编排 | Kubernetes | 1.28+ | 生产部署 |
---
## 3. 架构图
```mermaid
graph TB
subgraph Client["客户端"]
MFE[微前端 4 端]
end
subgraph Gateway["api-gateway"]
subgraph Middleware["中间件链"]
CORS[CORS 中间件]
LOG[访问日志 zap]
TRACE[OpenTelemetry trace]
AUTH[JWT RS256 鉴权]
RATE[限流 Redis 令牌桶]
CB[熔断器 滑动窗口]
end
ROUTER[Gin Router]
PROXY[gRPC 反向代理]
end
subgraph Downstream["下游服务"]
TBFF[teacher-bff]
SBFF[student-bff]
PBFF[parent-bff]
IAM[iam 登录特殊路由]
end
subgraph Infra["基础设施"]
REDIS[(Redis<br/>限流计数)]
JWKSIAM[(IAM JWKS 公钥)]
OTEL[OTLP Collector]
end
MFE -->|HTTP/REST| CORS
CORS --> LOG --> TRACE --> AUTH --> RATE --> CB --> ROUTER
ROUTER --> PROXY
PROXY -->|gRPC| TBFF
PROXY -->|gRPC| SBFF
PROXY -->|gRPC| PBFF
ROUTER -.特殊路由.-> IAM
RATE --> REDIS
AUTH --> JWKSIAM
LOG --> OTEL
TRACE --> OTEL
```
---
## 4. 核心流程图
### 4.1 鉴权与路由流程
```mermaid
sequenceDiagram
participant U as 微前端
participant GW as API Gateway
participant R as Redis
participant SVC as 下游服务
U->>GW: GET /api/classes + Bearer token
GW->>GW: CORS 校验
GW->>GW: 记录访问日志traceId 生成)
GW->>GW: OpenTelemetry span 创建
GW->>GW: 提取 Authorization header
GW->>GW: RS256 公钥校验签名
GW->>GW: 校验 exp/iss/aud
GW->>GW: 提取 userId/role/dataScope
GW->>R: 令牌桶限流检查userId 维度)
R-->>GW: 通过
GW->>GW: 熔断器状态检查
GW->>SVC: gRPC 请求 + metadata 透传
SVC-->>GW: gRPC 响应
GW-->>U: HTTP 响应 + traceId header
```
### 4.2 限流流程
```mermaid
flowchart TD
REQ[请求到达] --> Q1{Redis 令牌桶<br/>剩余令牌 >= 1?}
Q1 -- 是 --> DEC[扣减令牌<br/>INCR + EXPIRE]
DEC --> PASS[放行]
Q1 -- 否 --> Q2{是否超量请求?}
Q2 -- 是 --> REJ1[429 Too Many Requests<br/>Retry-After header]
Q2 -- 否 --> WAIT[排队等待<br/>短超时]
WAIT --> Q1
```
### 4.3 登录特殊处理流程
```mermaid
sequenceDiagram
participant U as 用户
participant GW as API Gateway
participant IAM as IAM 服务
Note over GW: /auth/* 路径跳过 JWT 鉴权
U->>GW: POST /auth/login {username, password}
GW->>GW: 限流检查IP 维度)
GW->>IAM: gRPC Login(username, password)
IAM->>IAM: 校验密码
IAM->>IAM: 生成 JWTRS256 私钥签发)
IAM-->>GW: {accessToken, refreshToken, userInfo}
GW-->>U: 200 Set-Cookie httpOnly + JSON body
```
---
## 5. 对外契约
### 5.1 REST API 路由表
| 方法 | 路径 | 鉴权 | 转发目标 | 说明 |
|------|------|------|----------|------|
| POST | /auth/login | 否 | iam | 登录签发 JWT |
| POST | /auth/refresh | 否 | iam | 刷新令牌 |
| POST | /auth/logout | 是 | iam | 登出 |
| GET | /api/classes/* | 是 | teacher-bff/student-bff | 班级相关 |
| GET | /api/courses/* | 是 | teacher-bff/student-bff | 课程相关 |
| GET | /api/assignments/* | 是 | teacher-bff/student-bff | 作业相关 |
| GET | /api/grades/* | 是 | teacher-bff/student-bff | 成绩相关 |
| GET | /api/exams/* | 是 | teacher-bff/student-bff | 考试相关 |
| GET | /api/messages/* | 是 | teacher-bff/student-bff | 消息相关 |
| GET | /api/insights/* | 是 | teacher-bff/student-bff | 学情分析 |
| GET | /healthz | 否 | 本地 | 存活探针 |
| GET | /readyz | 否 | 本地 | 就绪探针 |
| GET | /metrics | 否 | 本地 | Prometheus 指标 |
### 5.2 gRPC 服务契约
api-gateway 不对外暴露 gRPC 服务,仅作为 gRPC 客户端调用下游。
### 5.3 事件契约
api-gateway 不发布也不消费任何 Kafka 事件。
---
## 6. 依赖关系
### 6.1 上游依赖(调用方)
| 上游 | 协议 | 说明 |
|------|------|------|
| teacher-portal | HTTP/REST | 教师端微前端 |
| student-portal | HTTP/REST | 学生端微前端 |
| parent-portal | HTTP/REST | 家长端微前端 |
| admin-portal | HTTP/REST | 管理端微前端 |
### 6.2 下游依赖(被调用方)
| 下游 | 协议 | 说明 |
|------|------|------|
| teacher-bff | gRPC | 教师聚合层 |
| student-bff | gRPC | 学生聚合层 |
| parent-bff | gRPC | 家长聚合层 |
| iam | gRPC | 登录/刷新令牌特殊路由 |
| Redis | TCP | 限流计数、JWKS 缓存 |
| IAM JWKS | HTTP | RS256 公钥拉取5 分钟缓存) |
### 6.3 共享包依赖
| 包 | 用途 |
|----|------|
| shared-proto | gRPC stub 与 protobuf 类型 |
| shared-go | Go 通用工具(错误码、日志字段约定) |
---
## 7. 架构约束
1. **无业务逻辑**:网关只做路由、鉴权、限流、熔断,禁止写任何业务规则
2. **无状态**:不持有业务状态,会话状态由 Redis 管理,支持水平扩缩容
3. **无数据库**:禁止直连任何业务数据库
4. **单一职责**所有横切关注点以中间件形式实现顺序明确CORS → 日志 → 追踪 → 鉴权 → 限流 → 熔断)
5. **配置驱动**:路由表、限流阈值、熔断阈值通过 viper 配置注入,支持热更新
6. **可观测性强制**:每个请求必须生成 traceId记录 userId/latency/statusCode
7. **优雅停机**:收到 SIGTERM 后停止接受新请求,等待已有请求完成(最长 30s
---
## 8. 架构决策
### ADR-001选用 Go + Gin 而非 Node.js
**决策**:网关层使用 Go 1.22 + Gin 框架。
**理由**
- 网关是 IO 密集型场景Go 的 goroutine 模型在高并发下内存占用极低
- 单实例可承载 10k+ QPS是 Node.js 的 3-5 倍
- 编译为单二进制,容器镜像 < 20MB启动 < 1s
**替代方案**Node.js (Fastify) - 内存占用高、GC 压力大,不适合作纯转发层。
### ADR-002JWT 签名算法从 HS256 迁移到 RS256
**决策**P1 阶段使用 HS256共享密钥快速验证P2 阶段迁移到 RS256非对称签名
**理由**
- RS256 允许 IAM 私钥签发、网关/服务公钥校验,密钥不需共享
- 公钥可通过 JWKS endpoint 分发,轮换密钥无需重启服务
- HS256 适合 P1 单体调试,但生产环境密钥分发风险高
### ADR-003限流使用 Redis 令牌桶而非本地内存
**决策**:限流计数器存储在 Redis使用令牌桶算法。
**理由**
- 多副本部署下,本地内存限流无法保证全局配额
- Redis 原子操作INCR + EXPIRE保证计数准确性
- 令牌桶支持突发流量,优于漏桶的严格匀速
### ADR-004路由配置通过 viper 静态注入而非服务发现
**决策**P1 阶段路由表通过 yaml/env 静态配置,不引入服务发现。
**理由**
- P1 服务数量少api-gateway + classes静态配置足够
- 服务发现Consul/Nacos增加运维复杂度留待 P6 引入
- viper 支持热重载,路由变更无需重启
### ADR-005gRPC 转发而非 HTTP 代理
**决策**:网关到下游 BFF/服务使用 gRPC 而非 HTTP 反向代理。
**理由**
- gRPC 基于 HTTP/2 多路复用,连接复用率高
- protobuf 二进制编码比 JSON 节省 30%+ 带宽
- 强类型契约,编译期发现接口变更
- Connect-RPC 兼容 HTTP/JSON 客户端,前端仍可用 REST
### ADR-006熔断器使用滑动窗口而非简单计数
**决策**:熔断器基于滑动窗口错误率,而非固定窗口计数。
**理由**
- 滑动窗口对突发错误更敏感,避免边界效应
- 半开状态可逐步放量,避免雪崩后全量恢复冲击
- Go 生态有成熟实现sony/gobreaker

View File

@@ -0,0 +1,352 @@
# classes 班级服务P1 黄金模板)
> 版本1.0
> 日期2026-07-07
> 状态P1 已交付P3 合并入 core-edu
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
classes 服务是 P1 阶段的**黄金模板服务**,承担以下双重职责:
### 1.1 业务职责
- **班级 CRUD**创建、查询、更新、删除班级Class 聚合根)
- **班级成员管理**:学生加入/退出班级、教师分配
- **班级查询**:按年级/学校/教师维度查询班级列表
### 1.2 模板职责(供后续服务复制)
- **横切关注点验证**JWT 鉴权、权限校验、DataScope 过滤、OpenTelemetry 追踪、结构化日志、错误处理、参数校验、限流降级
- **CQRS 模式验证**Command 写 MySQL + OutboxQuery 读 Redis 缓存
- **契约验证**protobuf 定义 + buf 生成 + gRPC + Connect-RPC
- **测试范式验证**单元测试vitest+ 集成测试 + 契约测试 + E2E 测试
**P3 阶段**classes 域将合并入 core-edu 服务,作为 core-edu 的一个聚合模块。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | TypeScript | 5.5+ | 类型安全 |
| 框架 | NestJS | 10.x | 依赖注入、模块化、装饰器 |
| ORM | Drizzle ORM | 0.31+ | 类型安全 SQL 构建器 |
| 数据库 | MySQL | 8.x | 写模型主库 |
| 缓存 | Redis | 7.x | 读模型缓存、会话 |
| 校验 | Zod | 3.23+ | 运行时类型校验 |
| 链路追踪 | OpenTelemetry | 1.24+ | 分布式追踪 |
| 日志 | pino | 9.x | 结构化高性能日志 |
| 测试 | vitest | 2.x | 单元/集成测试 |
| 契约测试 | buf | 1.40+ | protobuf 契约与生成 |
| E2E | Playwright | 1.45+ | 端到端测试 |
| 容器化 | Docker | - | 多阶段构建 |
---
## 3. 架构图
```mermaid
graph TB
subgraph Client["调用方"]
BFF[BFF 聚合层]
end
subgraph Classes["classes 服务"]
subgraph API["API 层"]
CTRL[ClassController<br/>gRPC + Connect-RPC]
GUARD[AuthGuard<br/>JWT metadata 校验]
PERM[PermissionGuard<br/>requirePermission]
SCOPE[DataScopeInterceptor<br/>6 级数据范围]
end
subgraph App["Application 层"]
CMD[CommandHandler<br/>CreateClass/UpdateClass/DeleteClass]
QRY[QueryHandler<br/>ListClasses/GetClass]
OUTBOX[OutboxWriter<br/>同事务写 outbox]
end
subgraph Domain["Domain 层"]
AGG[ClassAggregate<br/>聚合根 + 不变量]
EVT[DomainEvent<br/>ClassCreated/Updated/Deleted]
end
subgraph Infra["Infrastructure 层"]
REPO[ClassRepository<br/>Drizzle ORM]
CACHE[ClassCache<br/>Redis 读模型]
PROJ[Projection<br/>outbox → Redis]
end
end
subgraph Store["存储"]
MYSQL[(MySQL<br/>classes + outbox)]
REDIS[(Redis<br/>班级缓存)]
KAFKA[(Kafka<br/>领域事件)]
end
BFF -->|gRPC| GUARD
GUARD --> PERM
PERM --> SCOPE
SCOPE --> CTRL
CTRL --> CMD
CTRL --> QRY
CMD --> AGG
AGG --> EVT
CMD --> OUTBOX
CMD --> REPO
QRY --> CACHE
REPO --> MYSQL
OUTBOX --> MYSQL
CACHE --> REDIS
PROJ --> MYSQL
PROJ --> REDIS
PROJ -->|Relay Worker| KAFKA
```
---
## 4. 核心流程图
### 4.1 CreateClass 命令流程(写路径)
```mermaid
sequenceDiagram
participant BFF as BFF
participant CTRL as ClassController
participant CMD as CreateClassHandler
participant AGG as ClassAggregate
participant REPO as ClassRepository
participant OUTBOX as OutboxWriter
participant DB as MySQL
participant PROJ as Projection
participant K as Kafka
BFF->>CTRL: gRPC CreateClass(req)
CTRL->>CTRL: AuthGuard 校验 JWT metadata
CTRL->>CTRL: PermissionGuard 校验 class:create
CTRL->>CTRL: DataScopeInterceptor 注入 schoolId
CTRL->>CMD: execute(CreateClassCommand)
CMD->>AGG: ClassAggregate.create(name, gradeId, schoolId)
AGG->>AGG: 校验不变量(名称非空、年级存在)
AGG-->>CMD: ClassCreatedEvent
CMD->>REPO: save(classAggregate)
REPO->>DB: INSERT INTO classes ...
CMD->>OUTBOX: append(ClassCreatedEvent)
OUTBOX->>DB: INSERT INTO outbox (同事务)
DB-->>CMD: 事务提交成功
CMD-->>CTRL: ClassDTO
CTRL-->>BFF: gRPC 响应
Note over PROJ,K: 异步投影
PROJ->>DB: 轮询 outbox (100ms)
PROJ->>K: 发布 edu.org.class.created
PROJ->>DB: 标记 outbox processed
```
### 4.2 ListClasses 查询流程(读路径)
```mermaid
sequenceDiagram
participant BFF as BFF
participant CTRL as ClassController
participant QRY as ListClassesHandler
participant CACHE as ClassCache
participant REPO as ClassRepository
participant DB as MySQL
BFF->>CTRL: gRPC ListClasses(filter)
CTRL->>CTRL: AuthGuard + PermissionGuard(class:read)
CTRL->>CTRL: DataScopeInterceptor 注入 dataScope=L2/gradeId
CTRL->>QRY: execute(ListClassesQuery)
QRY->>CACHE: getClassesByGrade(gradeId)
alt 缓存命中
CACHE-->>QRY: ClassDTO[]
else 缓存未命中
CACHE-->>QRY: null
QRY->>REPO: findByGrade(gradeId, dataScope)
REPO->>DB: SELECT * FROM classes WHERE grade_id=? AND ...
DB-->>REPO: 行数据
REPO-->>QRY: ClassAggregate[]
QRY->>CACHE: setClassesByGrade(gradeId, dtos, TTL=5min)
end
QRY-->>CTRL: ClassDTO[]
CTRL-->>BFF: gRPC 响应
```
### 4.3 事件发布与投影流程
```mermaid
flowchart TD
CMD[Command 处理完成] --> TX[MySQL 事务提交<br/>classes 表 + outbox 表]
TX --> SUCC{提交成功?}
SUCC -- 是 --> RELAY[Relay Worker 轮询<br/>每 100ms]
SUCC -- 否 --> ROLL[回滚<br/>无事件产生]
RELAY --> READ[读取 outbox 未处理记录]
READ --> PUB[发布到 Kafka<br/>edu.org.class.created]
PUB --> MARK[标记 outbox processed=true]
MARK --> CONSUME[消费者处理]
CONSUME --> PROJ1[Projection 更新<br/>Redis 读模型]
CONSUME --> PROJ2[DataAna 投影<br/>ClickHouse 宽表]
CONSUME --> PROJ3[Msg 服务<br/>通知触发]
```
---
## 5. 对外契约
### 5.1 gRPC 服务契约protobuf
```protobuf
service ClassService {
rpc CreateClass(CreateClassRequest) returns (Class);
rpc GetClass(GetClassRequest) returns (Class);
rpc ListClasses(ListClassesRequest) returns (ListClassesResponse);
rpc UpdateClass(UpdateClassRequest) returns (Class);
rpc DeleteClass(DeleteClassRequest) returns (Empty);
}
message Class {
string id = 1;
string name = 2;
string grade_id = 3;
string school_id = 4;
int32 student_count = 5;
string homeroom_teacher_id = 6;
int64 created_at = 7;
int64 updated_at = 8;
}
```
### 5.2 领域事件契约
| 事件 | Topic | 生产者 | 消费者 | 字段 |
|------|-------|--------|--------|------|
| ClassCreated | `edu.org.class.created` | classes | DataAna、Msg | classId, name, gradeId, schoolId |
| ClassUpdated | `edu.org.class.updated` | classes | DataAna、Msg | classId, changes, updatedAt |
| ClassDeleted | `edu.org.class.deleted` | classes | DataAna、Msg | classId, deletedAt |
### 5.3 数据库表
| 表 | 用途 | 关键字段 |
|----|------|----------|
| classes | 班级写模型主表 | id, name, grade_id, school_id, homeroom_teacher_id, student_count, created_at, updated_at |
| outbox | 事件 Outbox 表 | id, aggregate_id, event_type, payload, processed, created_at |
---
## 6. 依赖关系
### 6.1 上游依赖(调用方)
| 上游 | 协议 | 说明 |
|------|------|------|
| teacher-bff | gRPC | 教师端班级管理 |
| student-bff | gRPC | 学生端班级查询 |
| parent-bff | gRPC | 家长端子女班级查询 |
### 6.2 下游依赖(被调用方)
| 下游 | 协议 | 说明 |
|------|------|------|
| MySQL | TCP | 写模型主库classes + outbox |
| Redis | TCP | 读模型缓存5 分钟 TTL |
| Kafka | TCP | 领域事件发布 |
### 6.3 共享包依赖
| 包 | 用途 |
|----|------|
| shared-proto | ClassService protobuf 定义与生成代码 |
| shared-ts | 通用类型、错误码、日志字段约定 |
---
## 7. 架构约束
1. **黄金模板强制**本服务的横切关注点实现必须可复制后续服务iam/core-edu/content 等)以本服务为模板
2. **CQRS 强制**:写路径必须经 Command → Aggregate → Repository + Outbox读路径必须先查缓存再查库
3. **Outbox 强制**:所有写操作必须同事务写入 outbox 表,禁止直接发布 Kafka 消息
4. **权限校验强制**:每个 RPC 方法必须经 PermissionGuard 校验 `class:create`/`class:read`/`class:update`/`class:delete`
5. **DataScope 强制**:查询必须经 DataScopeInterceptor 注入数据范围,禁止全表查询
6. **不变量保护**ClassAggregate 必须在创建/更新时校验业务不变量(名称非空、学生数 ≤ 上限)
7. **事务边界**:单聚合事务,禁止跨聚合事务
8. **P3 合并约束**:合并入 core-edu 时protobuf 契约保持不变,仅内部模块迁移
---
## 8. 架构决策
### ADR-001选用 NestJS 而非 Express/Fastify
**决策**:业务服务统一使用 NestJS 10 框架。
**理由**
- 依赖注入容器便于横切关注点组合Guard/Interceptor/Pipe
- 模块化系统天然匹配 DDD 限界上下文
- 装饰器声明式编程,代码可读性高
- 原生支持 gRPC、Microservices、OpenTelemetry
**替代方案**Express + 手工组合中间件 - 缺乏依赖注入,横切关注点难以复用。
### ADR-002选用 Drizzle ORM 而非 Prisma/TypeORM
**决策**:数据访问层使用 Drizzle ORM。
**理由**
- SQL-first 设计,生成的 SQL 可预测、可优化
- 类型安全且零运行时开销(不像 Prisma 有查询引擎)
- 支持 MySQL 事务、Outbox 模式所需的同事务多表写入
- Schema 即代码,迁移文件可读性强
**替代方案**Prisma - 查询引擎二进制大、事务控制弱TypeORM - 装饰器模型过重。
### ADR-003CQRS 读写分离
**决策**:写路径操作 MySQL 主库,读路径优先读 Redis 缓存。
**理由**
- 班级列表读多写少,缓存可承接 90%+ 读流量
- 写路径与读路径分离,可独立扩展
- Outbox 保证缓存最终一致(事件触发缓存失效)
**替代方案**:直接读 MySQL - 高并发下主库压力大;双写 - 一致性难保证。
### ADR-004Outbox 模式保证事件可靠发布
**决策**:所有领域事件通过 Outbox 表同事务写入Relay Worker 异步发布。
**理由**
- 业务表与 outbox 表同事务,保证"要么都成功要么都失败"
- Relay Worker 轮询 outbox 发布到 Kafka至少一次语义
- 避免业务提交成功但事件丢失的问题
**替代方案**:直接发 Kafka - 事务提交失败但消息已发导致幻读CDC - 增加运维复杂度P4 引入。
### ADR-005Zod 运行时校验
**决策**:所有 RPC 请求参数使用 Zod schema 校验。
**理由**
- protobuf 提供编译期类型检查,但运行时仍需业务规则校验
- Zod schema 可复用为 TypeScript 类型,单一数据源
- NestJS Pipe 集成,校验失败返回结构化错误
### ADR-006pino 结构化日志
**决策**:使用 pino 而非 winston 或 NestJS 默认 Logger。
**理由**
- pino 性能是 winston 的 3-5 倍,异步写入不阻塞事件循环
- JSON 结构化输出,便于 Loki 采集与查询
- 支持 traceId/userId 上下文注入,与 OpenTelemetry 对齐
### ADR-007P3 阶段合并入 core-edu
**决策**classes 服务在 P3 阶段合并入 core-edu 服务,作为 core-edu 的一个模块。
**理由**
- classes 域与课程、作业、考试域强耦合,跨服务调用频繁
- P1 独立服务用于验证黄金模板P3 合并以减少网络开销
- 合并后 protobuf 契约保持不变,仅内部代码迁移
- 合并后 outbox 共享 core-edu 事务,简化一致性保证

View File

@@ -0,0 +1,104 @@
# content 内容资源服务
> 版本0.1(骨架)
> 日期2026-07-07
> 状态P4 待交付
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
content 是内容资源限界上下文,管理 Textbook、Question、KnowledgePoint 三大聚合。
负责教材管理、题库管理(含 Elasticsearch 全文检索、知识图谱构建Neo4j 存储前置依赖关系)。
为 core-edu 提供题目来源,为 ai 服务提供出题素材与知识图谱查询。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | TypeScript | 5.5+ | 类型安全 |
| 框架 | NestJS | 10.x | 依赖注入、模块化 |
| ORM | Drizzle ORM | 0.31+ | 关系数据访问 |
| 关系库 | MySQL | 8.x | 教材/题目写模型 |
| 图数据库 | Neo4j | 5.x | 知识图谱、前置依赖 |
| 搜索 | Elasticsearch | 8.x | 题库全文检索 |
| 校验 | Zod | 3.23+ | 运行时校验 |
---
## 3. 架构图
```mermaid
graph TB
BFF[BFF 聚合层] -->|gRPC| CONTENT[content 服务]
AI[ai 服务] -->|gRPC| CONTENT
CONTENT --> TB[Textbook 模块]
CONTENT --> Q[Question 模块]
CONTENT --> KP[KnowledgePoint 模块]
CONTENT -->[(MySQL<br/>教材/题目)]
CONTENT -->[(Neo4j<br/>知识图谱)]
CONTENT -->[(ES<br/>题库索引)]
CONTENT -.事件.-> K[(Kafka<br/>edu.content.*)]
```
> 待 P4 交付时补充完整的分层架构图与多存储协同图。
---
## 4. 核心流程图
```mermaid
sequenceDiagram
participant T as 教师
participant BFF as BFF
participant CONTENT as content
participant ES as Elasticsearch
participant N as Neo4j
T->>BFF: 创建题目
BFF->>CONTENT: gRPC CreateQuestion
CONTENT->>CONTENT: 写 MySQL + Outbox
CONTENT->>ES: 异步索引题目
CONTENT->>N: 异步更新知识点关联
CONTENT-->>BFF: 成功
```
> 待 P4 交付时补充:知识图谱查询流程、题库检索流程、教材发布流程。
---
## 5. 对外契约
- **gRPC 服务**TextbookService、QuestionService、KnowledgePointService待 P4 定义 protobuf
- **领域事件**`edu.content.question.published``edu.content.textbook.updated``edu.content.knowledge.linked`
- **存储表**textbooks、questions、knowledge_points、knowledge_edges、outbox
> 待 P4 交付时补充完整 protobuf 定义与多存储 schema。
---
## 6. 依赖关系
- **上游**teacher-bff、student-bff、aigRPC 查询题库与知识图谱)
- **下游**MySQL、Neo4j、Elasticsearch、Kafka
- **跨服务**:消费 core-edu 的教学事件触发内容关联;被 data-ana 消费内容发布事件
---
## 7. 架构约束
1. 题库检索必须走 Elasticsearch禁止 MySQL LIKE 全表扫描
2. 知识图谱前置依赖查询必须走 Neo4j禁止关系库递归查询
3. MySQL 写入后必须异步同步 ES 索引与 Neo4j 关系
4. Outbox 模式强制
> 待 P4 交付时补充完整约束清单。
---
## 8. 架构决策
> 待 P4 交付时补充 ADR 记录预计包括Neo4j 选型、ES 索引策略、多存储一致性保证、CDC 引入决策。

View File

@@ -0,0 +1,105 @@
# core-edu 教学核心服务
> 版本0.1(骨架)
> 日期2026-07-07
> 状态P3 待交付
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
core-edu 是教学核心限界上下文,合并 P1 的 classes 域,管理 Class、Course、Assignment、Exam、Grade 等聚合。
覆盖教学全链路:班级管理、课程编排、作业布置与提交、考试发布与批改、成绩录入与查询。
是整个平台最大的业务服务,承载考试→作答→批改→成绩核心链路。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | TypeScript | 5.5+ | 类型安全 |
| 框架 | NestJS | 10.x | 依赖注入、模块化 |
| ORM | Drizzle ORM | 0.31+ | 数据访问 |
| 数据库 | MySQL | 8.x | 写模型主库 |
| 缓存 | Redis | 7.x | 读模型缓存 |
| 读模型 | ClickHouse | - | 学情宽表P4 接入) |
| 校验 | Zod | 3.23+ | 运行时校验 |
| 编排 | Temporal | - | 考试生命周期编排 |
---
## 3. 架构图
```mermaid
graph TB
BFF[BFF 聚合层] -->|gRPC| CORE[core-edu 服务]
CORE --> CLASS[Class 模块<br/>P3 合并]
CORE --> COURSE[Course 模块]
CORE --> ASSIGN[Assignment 模块]
CORE --> EXAM[Exam 模块]
CORE --> GRADE[Grade 模块]
CORE -->[(MySQL)]
CORE -->[(Redis)]
CORE -->[(ClickHouse<br/>读模型)]
CORE -.事件.-> K[(Kafka<br/>edu.teaching.*)]
```
> 待 P3 交付时补充完整的分层架构图与 CQRS 读写分离图。
---
## 4. 核心流程图
```mermaid
sequenceDiagram
participant T as 教师
participant BFF as BFF
participant CORE as core-edu
participant K as Kafka
T->>BFF: 发布考试
BFF->>CORE: gRPC PublishExam
CORE->>CORE: 校验 + Outbox 写入
CORE-->>BFF: 成功
CORE->>K: edu.teaching.exam.published
K->>K: Msg 通知学生 / DataAna 创建分析骨架
```
> 待 P3 交付时补充:作业提交流程、批改流程、成绩录入流程、考试生命周期 Saga。
---
## 5. 对外契约
- **gRPC 服务**ClassServiceP1 迁移、CourseService、AssignmentService、ExamService、GradeService待 P3 定义 protobuf
- **领域事件**`edu.teaching.assignment.submitted``edu.teaching.exam.published``edu.teaching.grade.recorded``edu.org.class.created`
- **数据库表**classes、courses、assignments、submissions、exams、exam_responses、grades、outbox
> 待 P3 交付时补充完整 protobuf 定义与表结构。
---
## 6. 依赖关系
- **上游**teacher-bff、student-bff、parent-bff
- **下游**MySQL、Redis、ClickHouse读模型、Kafka、Temporal
- **跨服务**:消费 iam 的 `edu.identity.user.*` 事件初始化关联;被 data-ana 消费学情事件
---
## 7. 架构约束
1. P3 合并 classes 服务后protobuf 契约保持不变
2. 考试生命周期使用 Temporal Saga 编排,禁止单体事务
3. 成绩录入后必须发布事件触发 DataAna 掌握度更新
4. Outbox 模式强制,所有写操作同事务写 outbox
> 待 P3 交付时补充完整约束清单。
---
## 8. 架构决策
> 待 P3 交付时补充 ADR 记录预计包括classes 合并决策、Temporal 编排选型、CQRS 读模型 ClickHouse 选型、考试并发策略。

View File

@@ -0,0 +1,104 @@
# data-ana 数据分析服务
> 版本0.1(骨架)
> 日期2026-07-07
> 状态P4 待交付
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
data-ana 是数据分析限界上下文Python 实现),管理 Report、Mastery、Dashboard 聚合。
消费 core-edu 与 content 的领域事件,构建 ClickHouse 学情宽表,计算知识点掌握度。
对外提供学情仪表盘查询、班级/年级/学校维度报表、个性化推荐数据支撑。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | Python | 3.12+ | 数据分析 |
| 框架 | FastAPI | 0.110+ | 异步 API |
| ORM | SQLAlchemy | 2.x | ClickHouse 查询 |
| 分析库 | ClickHouse | - | 学情宽表、实时聚合 |
| 任务 | Celery | 5.x | 异步计算任务 |
| 校验 | pydantic | 2.x | 运行时校验 |
| 追踪 | OpenTelemetry | - | 分布式追踪 |
---
## 3. 架构图
```mermaid
graph TB
BFF[BFF 聚合层] -->|gRPC| ANA[data-ana 服务]
K[(Kafka)] -->|消费事件| ANA
ANA --> PROJ[Projection<br/>事件 → ClickHouse]
ANA --> CALC[MasteryCalculator<br/>掌握度计算]
ANA --> RPT[ReportGenerator<br/>报表生成]
ANA -->[(ClickHouse<br/>学情宽表)]
ANA -.事件.-> K2[(Kafka<br/>edu.insight.mastery.updated)]
```
> 待 P4 交付时补充完整的分层架构图与事件消费管线图。
---
## 4. 核心流程图
```mermaid
sequenceDiagram
participant K as Kafka
participant ANA as data-ana
participant CH as ClickHouse
participant BFF as BFF
K->>ANA: edu.teaching.grade.recorded
ANA->>ANA: Projection 写入宽表
ANA->>ANA: 触发掌握度计算Celery
ANA->>CH: 更新 mastery 表
ANA->>K: 发布 edu.insight.mastery.updated
BFF->>ANA: gRPC GetDashboard(studentId)
ANA->>CH: 查询宽表聚合
ANA-->>BFF: DashboardDTO
```
> 待 P4 交付时补充报表生成流程、掌握度算法流程、CDC 同步流程。
---
## 5. 对外契约
- **gRPC 服务**DashboardService、MasteryService、ReportService待 P4 定义 protobuf
- **消费事件**`edu.teaching.*``edu.content.*`
- **发布事件**`edu.insight.mastery.updated`
- **ClickHouse 宽表**student_wide_table、mastery_table、exam_analysis_table
> 待 P4 交付时补充完整 protobuf 定义与宽表 schema。
---
## 6. 依赖关系
- **上游**teacher-bff、student-bff、parent-bff、aigRPC 查询学情)
- **下游**ClickHouse、Kafka消费与发布、Celery异步任务
- **跨服务**:消费 core-edu/content 事件;发布掌握度事件被 core-edu 消费触发推荐
---
## 7. 架构约束
1. 查询必须走 ClickHouse 宽表,禁止实时计算
2. 事件消费必须幂等,基于 event_id 去重
3. 掌握度计算必须异步Celery不阻塞事件消费
4. 宽表查询响应时间 ≤ 5 秒
> 待 P4 交付时补充完整约束清单。
---
## 8. 架构决策
> 待 P4 交付时补充 ADR 记录预计包括Python+FastAPI 选型、ClickHouse 选型、Celery 异步框架选型、掌握度算法选型。

102
docs/modules/iam/README.md Normal file
View File

@@ -0,0 +1,102 @@
# iam 身份认证服务
> 版本0.1(骨架)
> 日期2026-07-07
> 状态P2 待交付
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
iam 是身份认证限界上下文,管理 User、Role、Permission 三大聚合。
负责用户注册/登录/登出、JWT RS256 签发与刷新、三层角色模型(系统/组织/临时)分配与校验。
同时对外提供权限列表查询(供 api-gateway 与业务服务缓存)。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | TypeScript | 5.5+ | 类型安全 |
| 框架 | NestJS | 10.x | 依赖注入、模块化 |
| ORM | Drizzle ORM | 0.31+ | 数据访问 |
| 数据库 | MySQL | 8.x | 用户/角色/权限主库 |
| 缓存 | Redis | 7.x | 权限列表缓存、会话 |
| 密码 | bcrypt | 5.x | 密码哈希 |
| 校验 | Zod | 3.23+ | 运行时校验 |
| JWT | jose | 5.x | RS256 签发与公钥暴露 |
---
## 3. 架构图
```mermaid
graph TB
BFF[BFF/网关] -->|gRPC| IAM[iam 服务]
IAM --> USER[UserAggregate]
IAM --> ROLE[RoleAggregate]
IAM --> PERM[PermissionAggregate]
IAM -->[(MySQL)]
IAM -->[(Redis<br/>权限缓存)]
IAM -->|JWKS| GW[api-gateway 公钥拉取]
IAM -.事件.-> K[(Kafka<br/>edu.identity.user.*)]
```
> 待 P2 交付时补充完整的分层架构图Controller/Application/Domain/Infrastructure
---
## 4. 核心流程图
```mermaid
sequenceDiagram
participant GW as API Gateway
participant IAM as iam
participant DB as MySQL
participant K as Kafka
GW->>IAM: gRPC Login(username, password)
IAM->>DB: 查询用户 + bcrypt 校验
IAM->>IAM: 生成 JWT RS256
IAM-->>GW: {accessToken, refreshToken}
IAM->>K: 发布 edu.identity.user.logged_in
```
> 待 P2 交付时补充:刷新令牌流程、权限校验流程、角色分配流程。
---
## 5. 对外契约
- **gRPC 服务**UserServiceLogin/Refresh/Logout/Register/GetUserProfile、RoleService、PermissionService待 P2 定义 protobuf
- **HTTP 端点**`/.well-known/jwks.json` 暴露 RS256 公钥
- **领域事件**`edu.identity.user.created``edu.identity.user.updated``edu.identity.role.assigned`
> 待 P2 交付时补充完整 protobuf 定义与事件 schema。
---
## 6. 依赖关系
- **上游**teacher-bff、student-bff、parent-bff、api-gatewayJWKS 拉取)
- **下游**MySQL用户/角色/权限表、Redis权限缓存、Kafka领域事件
- **共享包**shared-proto、shared-ts
---
## 7. 架构约束
1. JWT 必须使用 RS256 非对称签名,私钥仅 iam 持有
2. 密码必须 bcrypt 哈希,禁止明文存储
3. 权限列表缓存 TTL 5 分钟,事件驱动失效
4. 三层角色权限取并集,拒绝权限取交集
> 待 P2 交付时补充完整约束清单。
---
## 8. 架构决策
> 待 P2 交付时补充 ADR 记录预计包括RS256 选型、bcrypt 参数、会话管理策略、权限缓存策略。

105
docs/modules/msg/README.md Normal file
View File

@@ -0,0 +1,105 @@
# msg 消息通知服务
> 版本0.1(骨架)
> 日期2026-07-07
> 状态P5 待交付
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
msg 是消息通知限界上下文,管理 Message、Notification、Announcement 聚合。
消费各服务领域事件触发多渠道通知投递站内信、邮件、短信、App 推送)。
与 push-gateway 协作建立 WebSocket/SSE 长连接,实现实时消息推送。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | TypeScript | 5.5+ | 类型安全 |
| 框架 | NestJS | 10.x | 依赖注入、模块化 |
| ORM | Drizzle ORM | 0.31+ | 数据访问 |
| 数据库 | MySQL | 8.x | 消息持久化 |
| 缓存 | Redis | 7.x | 未读计数、会话 |
| 队列 | Kafka | - | 事件消费 |
| 模板 | Handlebars | - | 通知模板渲染 |
| 校验 | Zod | 3.23+ | 运行时校验 |
---
## 3. 架构图
```mermaid
graph TB
K[(Kafka)] -->|消费事件| MSG[msg 服务]
MSG --> DISP[Dispatcher<br/>渠道分发]
MSG -->[(MySQL<br/>消息持久化)]
MSG -->[(Redis<br/>未读计数)]
DISP --> INAPP[站内信]
DISP --> EMAIL[邮件]
DISP --> SMS[短信]
DISP --> PUSH[push-gateway<br/>实时推送]
MSG -.事件.-> K2[(Kafka<br/>edu.msg.*)]
```
> 待 P5 交付时补充完整的分层架构图与多渠道分发图。
---
## 4. 核心流程图
```mermaid
sequenceDiagram
participant K as Kafka
participant MSG as msg
participant PG as push-gateway
participant U as 用户
K->>MSG: edu.teaching.exam.published
MSG->>MSG: 创建 Notification 记录
MSG->>MSG: Dispatcher 渠道分发
MSG->>PG: gRPC Push(userId, payload)
PG->>U: WebSocket 实时推送
MSG->>MSG: 更新未读计数Redis
```
> 待 P5 交付时补充:广播通知流程、通知模板渲染流程、未读计数同步流程。
---
## 5. 对外契约
- **gRPC 服务**MessageService、NotificationService、AnnouncementService待 P5 定义 protobuf
- **消费事件**`edu.identity.user.*``edu.teaching.*``edu.insight.*`
- **发布事件**`edu.msg.notification.sent``edu.msg.announcement.published`
- **数据库表**messages、notifications、announcements、notification_templates、outbox
> 待 P5 交付时补充完整 protobuf 定义与表结构。
---
## 6. 依赖关系
- **上游**teacher-bff、student-bff、parent-bff、push-gatewaygRPC 推送调用)
- **下游**MySQL、Redis、Kafka、外部邮件/短信网关
- **跨服务**:消费所有业务事件触发通知;与 push-gateway 协作实时推送
---
## 7. 架构约束
1. 通知投递必须至少一次语义,失败重试 3 次
2. 通知模板必须使用 Handlebars 渲染,禁止字符串拼接
3. 未读计数必须 Redis 实时维护,异步同步 MySQL
4. 广播通知必须分批投递,避免瞬时流量冲击
> 待 P5 交付时补充完整约束清单。
---
## 8. 架构决策
> 待 P5 交付时补充 ADR 记录,预计包括:多渠道分发策略、模板引擎选型、推送可靠性保证、广播限流策略。

View File

@@ -0,0 +1,114 @@
# push-gateway 推送网关服务
> 版本0.1(骨架)
> 日期2026-07-07
> 状态P5 待交付
> 关联文档:[架构影响地图](../../architecture/004_architecture_impact_map.md)
---
## 1. 模块职责
push-gateway 是实时推送基础设施服务Go 实现),管理 WebSocket/SSE 长连接。
接收 msg 服务的推送请求,维护用户在线连接池,将消息实时投递到浏览器/移动端。
支持连接鉴权、心跳保活、断线重连、多设备同步、连接数监控。
---
## 2. 技术栈
| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | Go | 1.22+ | 高并发长连接 |
| Web 框架 | Gin | 1.10+ | HTTP 升级 WebSocket |
| WebSocket | gorilla/websocket | 1.5+ | WebSocket 协议 |
| 缓存 | Redis | 7.x | 在线连接表、pub/sub |
| 配置 | viper | 1.18+ | 配置注入 |
| 日志 | zap | 1.27+ | 结构化日志 |
| 追踪 | OpenTelemetry | 1.24+ | 分布式追踪 |
| 编排 | Kubernetes | 1.28+ | 生产部署 |
---
## 3. 架构图
```mermaid
graph TB
MSG[msg 服务] -->|gRPC Push| PG[push-gateway]
PG --> HUB[ConnectionHub<br/>连接池管理]
HUB --> C1[用户 1 连接]
HUB --> C2[用户 2 连接]
HUB --> CN[用户 N 连接]
PG -->[(Redis<br/>在线连接表 + pub/sub)]
MFE[微前端] -.WebSocket.-> PG
PG --> HEART[心跳保活<br/>30s ping/pong]
```
> 待 P5 交付时补充完整的分层架构图与多副本连接同步图。
---
## 4. 核心流程图
```mermaid
sequenceDiagram
participant U as 用户
participant PG as push-gateway
participant R as Redis
participant MSG as msg 服务
U->>PG: WebSocket 连接 + JWT 鉴权
PG->>R: 注册在线连接userId → nodeId
PG-->>U: 连接建立
Note over PG: 心跳保活 30s ping/pong
MSG->>PG: gRPC Push(userId, payload)
PG->>R: 查询 userId 在线 nodeId
alt 本节点在线
PG->>U: WebSocket 推送消息
else 跨节点在线
PG->>R: pub/sub 转发到目标节点
R->>PG: 目标节点订阅收到
PG->>U: 推送消息
end
```
> 待 P5 交付时补充:断线重连流程、多设备同步流程、连接数监控流程。
---
## 5. 对外契约
- **gRPC 服务**PushServicePush、Broadcast、GetOnlineStatus待 P5 定义 protobuf
- **HTTP 端点**`/ws`WebSocket 升级)、`/healthz``/readyz``/metrics`
- **事件**:不发布也不消费 Kafka 事件,仅接收 msg 的 gRPC 推送调用
- **Redis 协议**在线连接表HASH、跨节点推送pub/sub channel `push:{nodeId}`
> 待 P5 交付时补充完整 protobuf 定义与 WebSocket 消息协议。
---
## 6. 依赖关系
- **上游**msg 服务gRPC Push 调用)
- **下游**Redis在线连接表、pub/sub 跨节点同步)
- **客户端**teacher-portal、student-portal、parent-portalWebSocket 连接)
- **共享包**shared-proto、shared-go
---
## 7. 架构约束
1. 无业务逻辑,仅做连接管理与消息转发
2. 多副本部署下,通过 Redis pub/sub 同步跨节点推送
3. 连接鉴权必须校验 JWT禁止未认证连接
4. 心跳超时 60s 无响应则断开连接
5. 单节点最大连接数 50k超出时拒绝新连接
> 待 P5 交付时补充完整约束清单。
---
## 8. 架构决策
> 待 P5 交付时补充 ADR 记录预计包括Go+gorilla/websocket 选型、Redis pub/sub 跨节点方案、连接数上限策略、心跳间隔选型。

View File

@@ -0,0 +1,979 @@
# Edu 多语言编码规范
> 版本1.0
> 日期2026-07-07
> 状态:基线发布
> 适用范围Edu 微服务架构TS + Go + Python + protobuf
> 关联文档:
> - [项目规则](../../project_rules.md)
> - [迁移指南](../../MIGRATION_GUIDE.md)
> - [Git 工作流](./git-workflow.md)
> - [架构总览](../architecture/001_architecture_overview.md)
---
## 目录
1. [项目原则与理念](#一项目原则与理念)
2. [TypeScriptNestJS规范](#二typescriptnestjs规范)
3. [GoGin规范](#三gogin规范)
4. [PythonFastAPI规范](#四pythonfastapi规范)
5. [跨语言通用规范](#五跨语言通用规范)
6. [protobuf 契约规范](#六protobuf-契约规范)
7. [设计令牌规范](#七设计令牌规范)
8. [安全规范](#八安全规范)
9. [测试规范](#九测试规范)
10. [代码审查清单](#十代码审查清单)
---
## 一、项目原则与理念
1. **可读性优先于机巧**:代码首先是写给队友看的
2. **显式优于隐式**:避免魔法值、隐式类型转换、隐式全局副作用
3. **单一职责**:每个文件、函数、组件只做一件事
4. **防御性编程**:永远假设输入可能是 null/None/nil 或非法格式
5. **契约先行**:跨服务通信先定 protobuf再写实现
6. **架构图优先**:任何任务开始前先查阅 [001 架构总览](../architecture/001_architecture_overview.md)
7. **限界上下文封装**:跨上下文不直接查询对方 DB必须通过 gRPC 或事件
---
## 二、TypeScriptNestJS规范
### 2.1 适用范围
- 业务微服务NestJS 10identity、org、teaching、content、comm、insight
- 基础设施服务NestJS 10auth、notification
- BFF 层NestJS 10Admin BFF、Teacher BFF、Student/Parent BFF
- 微前端Next.js + React4 个 shell 应用
- 共享包TSpackages/*
### 2.2 TypeScript 类型规则(沿用 CICD
1. **禁止 `any`**:未知类型用 `unknown` 并做类型守卫。极特殊情况需 `// eslint-disable-next-line @typescript-eslint/no-explicit-any` 并注释原因
2. **优先 `interface` 描述对象形状**`type` 用于联合、交叉、映射类型
3. **不使用 `as` 断言**,除非从 `unknown` 强制转换或测试中(需注释原因)。可用 `satisfies` 保持类型推导
4. **函数返回值必须显式标注**,特别是 `Promise<T>`
5. **可选链后禁止跟非空断言 `!`**`x?.y!` 是矛盾的)
6. **所有仅用于类型的导入必须使用 `import type`**
7. **避免 `object` 或 `{}` 作为类型**,使用 `Record<string, unknown>` 或具体接口
8. **泛型使用有意义的名称**`TData``TResponse`,避免单字母 `T`
### 2.3 导入顺序(强制执行)
```typescript
// 1. Node.js 内置
import { readFile } from "node:fs/promises";
// 2. 第三方库
import { Controller, Get } from "@nestjs/common";
import { z } from "zod";
// 3. monorepo 内部包(@edu/* 别名)
import { requirePermission } from "@edu/auth";
import { User } from "@edu/contracts";
// 4. 服务内绝对路径(@/ 别名,仅服务内)
import { UserService } from "@/modules/user/user.service";
// 5. 相对路径导入
import { CreateUserDto } from "./dto/create-user.dto";
// 6. 类型导入
import type { UserEntity } from "@/modules/user/domain/user.entity";
```
使用 `eslint-plugin-import` 规则 `import/order` 自动排序,分组间空一行。
### 2.4 NestJS 模块规范
#### 2.4.1 模块组织
每个 NestJS 模块对应一个 DDD 聚合,结构见 [project_rules.md §4.1](../../project_rules.md#41-nestjs-业务微服务标准结构)。
#### 2.4.2 装饰器规则
```typescript
@Controller("users")
@RequirePermission(Permissions.USER_READ)
export class UserController {
constructor(private readonly userService: UserService) {}
@Get(":id")
@RequirePermission(Permissions.USER_READ)
async getUser(@Param("id", ParseUUIDPipe) id: string): Promise<UserResponse> {
return this.userService.findById(id);
}
@Post()
@RequirePermission(Permissions.USER_CREATE)
@HttpCode(HttpStatus.CREATED)
async createUser(@Body() dto: CreateUserDto): Promise<UserResponse> {
return this.userService.create(dto);
}
}
```
**规则**
- Controller 必须使用 `@Controller(path)` 装饰器path 使用 kebab-case 复数
- Controller 类必须使用 `@RequirePermission()` 装饰器(类级默认权限)
- 每个 Handler 可选覆盖类级权限(更细粒度)
- Handler 必须显式标注返回类型(`Promise<T>`
- DTO 必须使用 `class-validator` 装饰器校验
- 参数校验使用 Pipe`ParseUUIDPipe``ValidationPipe` 等)
#### 2.4.3 依赖注入规则
```typescript
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly eventBus: EventBus,
) {}
}
```
**规则**
- 依赖通过构造函数注入,使用 `readonly` 修饰符
- 接口绑定在 Module 的 `providers` 中:`{ provide: "UserRepository", useClass: UserRepoImpl }`
- 禁止使用属性注入(`@Inject()` 属性装饰器)
- 循环依赖通过 `forwardRef` 解决,但需在 PR 中说明原因
#### 2.4.4 Module 规则
```typescript
@Module({
imports: [DatabaseModule, AuthModule],
controllers: [UserController],
providers: [
UserService,
{ provide: "UserRepository", useClass: UserRepoImpl },
],
exports: [UserService],
})
export class UserModule {}
```
**规则**
- Module 类名 PascalCase + `Module` 后缀
- `exports` 仅暴露 Application Service不暴露 Repository
- 跨 Module 通信通过 exports 的 Service不直接访问对方 Repository
### 2.5 CQRS 规范
```typescript
export class CreateUserCommand {
constructor(
public readonly email: string,
public readonly name: string,
) {}
}
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
async execute(command: CreateUserCommand): Promise<string> {
// 1. 加载聚合(如有)
// 2. 调用聚合方法(领域逻辑)
// 3. 持久化Repository
// 4. 写入 Outbox同一事务
// 5. 返回结果
}
}
export class GetUserByIdQuery {
constructor(public readonly id: string) {}
}
@QueryHandler(GetUserByIdQuery)
export class GetUserByIdHandler implements IQueryHandler<GetUserByIdQuery> {
async execute(query: GetUserByIdQuery): Promise<UserReadModel> {
// 直接查询读模型ClickHouse / ES / Redis不走主库
}
}
```
**规则**
- Command 走写路径Command → Handler → Domain → Repository → MySQL + Outbox
- Query 走读路径Query → Handler → Read Model禁止查主库
- Command Handler 必须在事务内写 Outbox 表
- Query Handler 必须从读模型读,**禁止在 Query 中调用主库**
### 2.6 Domain Entity 规范
```typescript
export class UserEntity {
private constructor(
public readonly id: string,
private email: string,
private name: string,
private readonly createdAt: Date,
) {}
static create(props: UserCreateProps): UserEntity {
return new UserEntity(crypto.randomUUID(), props.email, props.name, new Date());
}
rename(newName: string): UserRenamedEvent {
this.name = newName;
return new UserRenamedEvent(this.id, newName);
}
}
```
**规则**
- Entity 构造函数私有,通过静态工厂方法创建
- Entity 字段私有,通过方法变更状态
- 状态变更方法返回领域事件,由 Application Service 发布
- 聚合根负责维护自身不变式
### 2.7 DTO 与验证
```typescript
import { IsEmail, IsString, MinLength } from "class-validator";
export class CreateUserDto {
@IsEmail()
readonly email!: string;
@IsString()
@MinLength(2)
readonly name!: string;
}
```
**规则**
- DTO 类名 `Create[Entity]Dto` / `Update[Entity]Dto` / `[Entity]Response`
- DTO 字段使用 `readonly` 修饰
- 使用 `class-validator` 装饰器校验
- 全局启用 `ValidationPipe``whitelist: true, forbidNonWhitelisted: true, transform: true`
### 2.8 React 组件规范(沿用 CICD
- 组件必须为**纯函数**,使用 `function` 声明(非箭头函数)
- 页面组件(`page.tsx`)使用**默认导出**;其余所有组件使用**具名导出**
- 默认服务端组件,需要交互时才在文件第一行添加 `"use client"`
- **禁止在渲染期间**修改外部变量、执行网络请求、读取/写入 DOM
- **不使用 `React.FC`**,直接用函数声明 + 显式标注 props 类型
- 组件行数 ≤ 500 行(复杂表单/大型表格可放宽至 800 行)
### 2.9 Hook 规范(沿用 CICD
- 命名以 `use` 开头,驼峰式
- **单一职责**:一个 Hook 只做一件事
- 返回值使用**对象形式**(非数组)
- 必须编写 JSDoc
- 所有 `useEffect` 必须提供**清理函数**
- `useEffect` 依赖数组必须**完整**
### 2.10 状态管理(沿用 CICD 5 层模型)
| 层级 | 场景 | 方案 |
|------|------|------|
| L1 URL | 可分享、可刷新的状态 | nuqs |
| L2 Server | 服务端数据 | TanStack Query |
| L3 Client Business | 客户端业务状态 | Zustand slice |
| L4 Global UI | 全局 UI 状态 | Zustand ui-store + ModalRoot |
| L5 Form | 表单状态 | react-hook-form + zodResolver |
---
## 三、GoGin规范
### 3.1 适用范围
- API Gatewaygateway/
- 未来可能的 sidecar 或 CLI 工具
### 3.2 包结构
```
gateway/
├─ cmd/server/main.go
├─ internal/
│ ├─ config/ # 配置加载
│ ├─ handler/ # HTTP 处理器
│ ├─ middleware/ # 中间件
│ ├─ router/ # 路由注册
│ ├─ client/ # 下游 gRPC 客户端
│ └─ pkg/ # 服务内工具
└─ api/ # OpenAPI 定义
```
### 3.3 命名规范
- 包名:小写单数,不使用下划线或驼峰(`config``handler``middleware`
- 文件名snake_case`user_handler.go``rate_limiter.go`
- 导出标识符PascalCase`UserService``HandleLogin`
- 未导出标识符camelCase`userService``validateToken`
- 接口PascalCase倾向于在消费方定义命名按行为描述`UserFetcher``TokenValidator`
### 3.4 错误处理
```go
// ✅ 显式处理错误
func (h *UserHandler) GetUser(c *gin.Context) {
id := c.Param("id")
user, err := h.userService.GetUser(c.Request.Context(), id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
h.logger.Error("get user failed", "err", err, "id", id)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
c.JSON(http.StatusOK, user)
}
// ❌ 禁止忽略错误
user, _ := h.userService.GetUser(ctx, id)
```
**规则**
- 错误必须显式处理,**禁止 `_ = err`**
- 使用 `errors.Is``errors.As` 判断错误类型,禁止字符串匹配
- 自定义错误类型使用 `fmt.Errorf("...: %w", err)` 包装
- 错误日志必须包含上下文(请求 ID、用户 ID、关键参数
- 对外返回的错误信息禁止泄露内部实现堆栈、SQL 语句等)
### 3.5 Context 传递
```go
// ✅ context 作为第一个参数
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
// ❌ 禁止 context 作为结构体字段
type UserService struct {
ctx context.Context // 禁止
}
```
**规则**
- `context.Context` 作为函数第一个参数传递
- **禁止**将 context 存储在结构体字段中
- 超时/取消通过 context 传递,禁止使用 `time.Sleep` 等待
- 使用 `ctx.Err()` 检查取消信号
### 3.6 并发规范
```go
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchUser(ctx) })
g.Go(func() error { return fetchProfile(ctx) })
if err := g.Wait(); err != nil {
return err
}
```
**规则**
- 优先使用 `errgroup` 管理并发 goroutine
- 禁止裸 `go func()` 不带 recover 和 context
- 共享状态使用 channel 或 `sync` 包,禁止使用 `sync.Mutex` 嵌套锁
- goroutine 必须可被 context 取消
### 3.7 Gin 处理器规范
```go
func RegisterRoutes(r *gin.Engine, h *Handler, mw *Middleware) {
r.Use(gin.Recovery(), mw.RequestID(), mw.Logger(), mw.RateLimit())
v1 := r.Group("/api/v1")
{
v1.GET("/users/:id", mw.Auth(), h.GetUser)
v1.POST("/users", mw.Auth(), mw.RequirePermission("user:create"), h.CreateUser)
}
}
```
**规则**
- 路由分组按 API 版本(`/api/v1`
- 中间件链顺序Recovery → RequestID → Logger → RateLimit → Auth → RequirePermission
- Handler 函数签名固定:`func(c *gin.Context)`
- 响应统一使用 `c.JSON(status, body)`,禁止直接写 `c.Writer.Write`
- 错误响应格式统一:`{"error": "message", "code": "ERROR_CODE", "request_id": "..."}`
### 3.8 日志规范
```go
import "log/slog"
logger := slog.With("service", "gateway", "request_id", requestID)
logger.Info("user login", "user_id", userID, "ip", ip)
```
**规则**
- 使用标准库 `log/slog` 结构化日志
- 日志字段使用 kebab-case key
- 必须包含 `request_id` 用于链路追踪
- 禁止使用 `fmt.Println``log.Printf` 输出业务日志
---
## 四、PythonFastAPI规范
### 4.1 适用范围
- 分析/AI 服务insight-ai知识图谱、AI 备课、智能推荐等)
- 数据处理脚本
### 4.2 包结构
```
services/insight-ai/
├─ src/
│ ├─ main.py # FastAPI 启动入口
│ ├─ core/
│ │ ├─ config.py # Pydantic Settings
│ │ └─ logging.py # 结构化日志
│ ├─ api/v1/endpoints/ # 路由处理器
│ ├─ models/ # Pydantic 模型
│ ├─ services/ # 业务逻辑
│ ├─ repositories/ # 数据访问
│ └─ clients/ # 外部客户端
└─ tests/
```
### 4.3 命名规范
- 模块/包snake_case`user_service.py``knowledge_graph`
-PascalCase`UserService``KnowledgeGraphModel`
- 函数/变量snake_case`get_user``is_active`
- 常量UPPER_SNAKE_CASE`MAX_RETRY_COUNT`
- 布尔变量:`is/has/can/should` 前缀(`is_active``has_permission`
### 4.4 类型注解(强制)
```python
# ✅ 所有函数必须标注类型
async def get_user(user_id: str) -> UserResponse:
user = await user_repository.get_by_id(user_id)
if user is None:
raise UserNotFoundError(user_id)
return UserResponse.model_validate(user)
# ❌ 禁止无类型注解
def get_user(user_id):
...
```
**规则**
- 所有函数必须标注参数和返回值类型
- 使用 `from __future__ import annotations` 启用延迟注解求值
- 使用 `Optional[T]``T | None`Python 3.10+)标注可选类型
- 容器类型使用泛型:`list[User]``dict[str, Any]`(不用 `List``Dict`
- mypy 严格模式:`disallow_untyped_defs = true`
### 4.5 异步规范
```python
# ✅ I/O 操作必须 async
async def fetch_user(user_id: str) -> User:
async with httpx.AsyncClient() as client:
response = await client.get(f"/users/{user_id}")
return User.model_validate(response.json())
# ❌ 禁止在 async 函数中调用同步 I/O
async def fetch_user(user_id: str) -> User:
requests.get(f"/users/{user_id}") # 阻塞事件循环
```
**规则**
- I/O 操作HTTP、DB、文件必须使用 async/await
- 禁止在 async 函数中调用同步阻塞 I/O必须用 `asyncio.to_thread` 或 async 客户端
- CPU 密集任务用 `asyncio.to_thread` 或进程池
- 并发请求使用 `asyncio.gather``asyncio.TaskGroup`
### 4.6 Pydantic 模型
```python
from pydantic import BaseModel, Field, field_validator
class UserCreateRequest(BaseModel):
email: str = Field(..., description="用户邮箱")
name: str = Field(..., min_length=2, max_length=50, description="用户姓名")
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("invalid email")
return v
class UserResponse(BaseModel):
id: str
email: str
name: str
created_at: datetime
```
**规则**
- 请求模型命名 `[Action][Entity]Request`,响应模型命名 `[Entity]Response`
- 必须使用 `Field` 添加描述、约束
- 复杂校验使用 `@field_validator``@model_validator`
- ORM 模型转换使用 `model_config = ConfigDict(from_attributes=True)`
### 4.7 FastAPI 路由规范
```python
from fastapi import APIRouter, Depends, HTTPException
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
current_user: User = Depends(get_current_user),
) -> UserResponse:
require_permission(current_user, "user:read")
user = await user_service.get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="user not found")
return user
```
**规则**
- 路由分组使用 `APIRouter`,按 API 版本组织
- 必须标注 `response_model`
- 权限校验通过 `Depends` 注入,每个 endpoint 显式调用 `require_permission`
- 路径参数使用 `str` 类型注解
- 错误通过 `HTTPException` 抛出,禁止直接返回错误 dict
### 4.8 配置规范
```python
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="INSIGHT_AI_")
database_url: str
kafka_servers: list[str]
log_level: str = "INFO"
enable_ai_cache: bool = True
settings = Settings()
```
**规则**
- 配置使用 `pydantic-settings``BaseSettings`
- 环境变量前缀按服务名(`INSIGHT_AI_`
- 禁止在业务代码中直接读取环境变量(`os.getenv`),统一通过 `settings`
---
## 五、跨语言通用规范
### 5.1 命名通用规则
| 对象 | 风格 | 示例 |
|------|------|------|
| 目录 | kebab-case | `user-profile/` |
| 常量 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
| 布尔值 | `is/has/can/should` 前缀 | `isVisible``is_active``hasPermission` |
| 类/接口/结构体 | PascalCase | `UserService``UserFetcher` |
| 服务名 | 小写单数 | `identity``teaching` |
| Kafka topic | 点分小写 | `edu.identity.user.created` |
| protobuf message | PascalCase | `UserCreatedEvent` |
| protobuf 字段 | snake_case | `user_id``created_at` |
### 5.2 文件行数通用规则
| 文件类型 | 建议行数 | 硬性上限 |
|---------|---------|---------|
| 配置/常量/类型/proto | 无限制 | 无限制 |
| React 组件 | ≤ 500 | 800 |
| NestJS Controller/Service | ≤ 500 | 800 |
| Go handler/middleware | ≤ 400 | 600 |
| Python endpoint/service | ≤ 400 | 600 |
| 工具函数 | ≤ 40 | - |
| 自定义 Hook | ≤ 80 | - |
| **任何文件** | - | **1000超过必须拆分** |
### 5.3 错误处理通用规则
| 语言 | 规则 |
|------|------|
| TypeScript | 错误通过抛出异常Application Service 必须捕获并转为结构化响应 |
| Go | 错误必须显式处理,禁止 `_ = err`,使用 `errors.Is/As` 判断类型 |
| Python | 使用异常层次结构,自定义异常继承 `Exception`,禁止裸 `except:` |
**通用规则**
- 错误信息对内详细(含上下文、堆栈),对外脱敏(不泄露实现细节)
- 错误必须分类业务错误4xx、系统错误5xx、依赖错误502/503
- 错误必须记录日志,包含 request_id 用于链路追踪
- 重试逻辑仅用于幂等操作和瞬时错误(网络抖动、超时)
### 5.4 日志通用规则
| 语言 | 工具 | 说明 |
|------|------|------|
| TypeScript | NestJS Logger + pino | 结构化 JSON 日志 |
| Go | log/slog | 标准库结构化日志 |
| Python | structlog 或 loguru | 结构化 JSON 日志 |
**通用规则**
- 日志必须结构化JSON禁止纯文本
- 必须包含 `timestamp``level``service``request_id``trace_id`
- 日志级别DEBUG开发、INFO关键业务、WARN异常可恢复、ERROR系统错误
- 禁止打印敏感信息密码、token、身份证号、信用卡号
- 采样规则:高频日志(如请求日志)按比例采样,错误日志全量保留
### 5.5 测试通用规则
| 语言 | 单元测试框架 | 覆盖率目标 |
|------|------------|-----------|
| TypeScript | Vitest + nestjs/testing | ≥ 80% |
| Go | 标准 testing 包 + testify | ≥ 80% |
| Python | pytest + pytest-asyncio | ≥ 80% |
**通用规则**
- 测试文件与源文件同目录或 `tests/` 子目录
- 命名:`*.test.ts` / `*_test.go` / `test_*.py`
- 测试描述说明预期行为("should disable button while loading"
- Mock 仅用于外部边界(数据库、外部 API内部逻辑须真实运行
- E2E 测试覆盖核心业务路径
- 集成测试使用 TestcontainersDB/Kafka
### 5.6 注释通用规则
- **不写废话注释**:不重复代码已表达的信息
- **写 why 不写 what**:解释为什么这样做,不解释做了什么
- 复杂业务逻辑必须有注释,引用需求文档或 PR 链接
- TODO 必须附带 issue 编号:`// TODO(JIRA-123): handle edge case`
- 公共 API 必须有文档注释JSDoc / godoc / docstring
---
## 六、protobuf 契约规范
### 6.1 文件组织
```
packages/contracts/proto/
├─ identity/
│ ├─ user.proto
│ ├─ role.proto
│ └─ permission.proto
├─ org/
├─ teaching/
├─ content/
├─ comm/
├─ insight/
├─ auth/
└─ notification/
```
### 6.2 文件头模板
```protobuf
syntax = "proto3";
package edu.identity.user.v1;
import "google/protobuf/timestamp.proto";
option go_package = "edu/contracts/identity/user/v1;userv1";
// 用户服务契约。
// 提供用户 CRUD、角色绑定、权限查询能力。
service UserService {
// 创建用户
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
// 查询用户
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
```
### 6.3 命名规则
- **包名**`edu.[context].[aggregate].v[version]`,如 `edu.identity.user.v1`
- **Service 名**PascalCase + `Service` 后缀,如 `UserService``NotificationService`
- **RPC 方法名**PascalCase 动词开头,如 `CreateUser``GetUser``ListUsers`
- **Message 名**PascalCase + 请求/响应后缀,如 `CreateUserRequest``CreateUserResponse`
- **字段名**snake_case`user_id``created_at``is_active`
- **枚举名**PascalCase枚举值 UPPER_SNAKE_CASE 并以枚举名为前缀
### 6.4 字段规则
```protobuf
message User {
string user_id = 1; // 用户唯一 IDUUID
string email = 2; // 邮箱(唯一)
string name = 3; // 姓名
google.protobuf.Timestamp created_at = 4; // 创建时间
UserStatus status = 5; // 用户状态
}
enum UserStatus {
USER_STATUS_UNSPECIFIED = 0; // 未指定(必须从 0 开始)
USER_STATUS_ACTIVE = 1; // 活跃
USER_STATUS_DISABLED = 2; // 禁用
}
```
**规则**
- 字段编号禁止复用,删除字段必须 `reserved` 标记
- 枚举第一个值必须为 `*_UNSPECIFIED = 0`
- 时间使用 `google.protobuf.Timestamp`,不使用 string
- 字段必须有 `//` 注释说明用途
- 字段命名与领域事件保持一致
### 6.5 事件 schema
```protobuf
message UserCreatedEvent {
string event_id = 1; // 事件唯一 ID用于幂等
string aggregate_id = 2; // 聚合根 ID
string aggregate_type = 3; // 聚合类型(如 "User"
string event_type = 4; // 事件类型(如 "UserCreated"
google.protobuf.Timestamp occurred_at = 5; // 发生时间
bytes payload = 6; // 事件负载JSON 序列化)
map<string, string> headers = 7; // 元数据trace_id 等)
}
```
### 6.6 版本化
- 破坏性变更必须升版本:`v1``v2`
- 新版本与旧版本并存,消费者逐步迁移
- 旧版本标记 `deprecated`,至少保留 2 个迭代周期
- 字段新增使用 `optional``repeated` 保持向后兼容
- 字段类型变更视为破坏性变更
### 6.7 校验工具
```bash
# Lint 检查
buf lint
# 破坏性变更检测
buf breaking --against '.git#branch=main'
# 代码生成TS / Go / Python
buf generate
```
**CI 强制**:每次 PR 必须通过 `buf lint` + `buf breaking`
### 6.8 生成代码规则
- 生成代码位于各服务 `src/generated/`TS/ `gen/`Go/ `generated/`Python
- **禁止手改生成代码**
- 生成代码不纳入行数统计
- proto 变更后必须立即 `buf generate` 并提交生成代码
---
## 七、设计令牌规范
### 7.1 令牌分层(沿用 CICD 模型,迁移至微前端共享包)
| Layer | 位置 | 用途 |
|-------|------|------|
| Layer 1 Primitive | `packages/ui-tokens/primitive.css` | 原始色板/字号/间距/阴影,业务代码不直接引用 |
| Layer 2 Semantic | `packages/ui-tokens/semantic-light.css` + `semantic-dark.css` | 语义令牌,业务代码唯一引用入口 |
| 模块命名空间 | `packages/ui-tokens/lesson-preparation.css` | `--lp-*` 令牌,明暗双份 |
| Tailwind 暴露 | `packages/ui-tokens/tailwind-theme.css` | `@theme inline` 暴露为 `bg-*`/`text-*`/`font-*` 类 |
### 7.2 强制规则
- **禁止硬编码颜色**TSX/TS/CSS 中不得出现 `#hex` 颜色字面量,使用 `hsl(var(--*))` 或 Tailwind 类 `bg-*`
- **禁止硬编码字体**:不得出现 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量,使用 `var(--font-family-sans/serif/mono)`
- **禁止硬编码字号**:不得出现 `font-size: Npx`,使用 `var(--font-size-1~9)`
- **禁止 Tailwind 任意值**:不得使用 `w-[Npx]`/`h-[Npx]`/`p-[Npx]`,映射到 `--space-*` 或 Tailwind 默认阶梯
### 7.3 豁免场景(需注释)
- PWA manifest`apps/*/public/manifest.ts`
- 邮件 HTML 内联样式(`services/notification/src/channels/`
- 图表 SVG 固定画布尺寸
- loading.tsx 占位骨架
- Dialog 固定宽度等无法令牌化的设计固定尺寸
豁免写法:`// eslint-disable-next-line no-restricted-syntax -- <reason>`
### 7.4 排版差异化(用户偏好)
- 主文serif 字体(`var(--font-family-serif)`,对应 Fraunces
- UIsans-serif 字体(`var(--font-family-sans)`,对应 Inter
- 代码mono 字体(`var(--font-family-mono)`,对应 JetBrains Mono
- 界面风格:简洁干净,最少图标
### 7.5 ESLint 强制约束
- `no-restricted-syntax`:禁止 `#hex` 字面量
- `design-tokens/no-hardcoded-fonts`:禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量
- 白名单:`packages/ui-tokens/primitive.css``services/notification/src/channels/``apps/*/public/manifest.ts`
### 7.6 改令牌必同步图
修改令牌定义后,同步更新 `docs/architecture/001_architecture_overview.md` 与 arch.db`pnpm run arch:scan`)。
---
## 八、安全规范
### 8.1 XSS 防护
- **禁止 `dangerlySetInnerHTML`**(如必须使用,先用 DOMPurify 清洗)
- React 默认转义,禁止绕过
### 8.2 认证与 Cookie
- JWT/session ID 存储在 `httpOnly` + `Secure` + `SameSite=Strict` 的 Cookie 中
- JWT 过期时间 ≤ 1 小时refresh token ≤ 7 天
- refresh token 必须支持撤销
### 8.3 环境变量
- 服务端环境变量**不加** `NEXT_PUBLIC_` 前缀
- 客户端环境变量**必须加** `NEXT_PUBLIC_` 前缀,且仅暴露非敏感信息
- TS 使用 Zod / Go 使用 viper / Python 使用 `pydantic-settings` 在应用启动时校验
- 敏感配置通过 Vault 或 Kubernetes Secret 注入,禁止入仓
### 8.4 权限校验
- 每个 Controller/Handler/endpoint 必须调用 `@RequirePermission()` 等价物
- 前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()`
- 权限点常量集中维护在 `packages/contracts/src/permissions.ts`
### 8.5 CSRF 防护
- 所有状态变更操作必须校验 Origin/Referer 头
- 对 SameSite Cookie 不足的场景,使用 CSRF token
### 8.6 依赖扫描
| 语言 | 工具 |
|------|------|
| TS | `npm audit` + Snyk + Trivy |
| Go | `govulncheck` |
| Python | `pip-audit` + `safety` |
高危漏洞阻断合并。
### 8.7 数据安全
- 密码使用 bcryptcost ≥ 12或 argon2id 哈希
- 敏感字段(身份证号、手机号)加密存储
- 日志中禁止打印敏感信息密码、token、身份证号
- 审计日志保留 ≥ 180 天
### 8.8 gRPC 安全
- 内部 gRPC 启用 mTLS
- gRPC 请求必须携带调用方身份metadata 中 `x-caller-id``x-trace-id`
- 限制 gRPC 消息大小(默认 4MB防止 DoS
---
## 九、测试规范
### 9.1 测试分层
| 层级 | TS | Go | Python | 覆盖率 |
|------|-----|-----|--------|--------|
| 单元测试 | Vitest | testing + testify | pytest | ≥ 80% |
| 集成测试 | Vitest + Testcontainers | testing + Testcontainers | pytest + Testcontainers | 关键流程 |
| E2E 测试 | Playwright | - | - | 核心业务路径 |
| 契约测试 | pact + buf | pact + buf | pact + buf | 服务间契约 |
### 9.2 测试命令
```bash
# TypeScript
pnpm run test:unit
pnpm run test:integration
pnpm run test:e2e
# Go
go test ./... -race -coverprofile=coverage.out
# Python
pytest tests/unit -v --cov=src --cov-report=term-missing
pytest tests/integration -v
```
### 9.3 编写规范
- 测试文件与源文件同目录或 `tests/` 子目录
- 命名:`*.test.ts` / `*_test.go` / `test_*.py`
- 测试描述说明预期行为:`it("should disable button while loading")`
- Mock 仅用于外部边界API、数据库内部逻辑须真实运行
- 异步测试必须等待结果,**禁止固定 `setTimeout` / `time.Sleep`**
- 集成测试使用 Testcontainers 启动真实依赖MySQL、Redis、Kafka
### 9.4 契约测试
- 每个微服务必须为自身对外暴露的 gRPC 接口编写 contract test
- 契约测试使用 pact 或 buf 的 breaking check
- 契约变更必须同步更新消费者,消费者 CI 必须验证契约兼容性
---
## 十、代码审查清单
审查者必须逐一确认:
### 10.1 架构与设计
- [ ] 命名表意清晰,无歧义
- [ ] 类型安全:无 `any` / `interface{}` / `Any`,无多余断言
- [ ] 文件/函数/组件行数符合规范
- [ ] 单一职责:每个文件/函数/组件只做一件事
- [ ] 限界上下文封装:无跨服务直接 DB 查询
- [ ] CQRS 分层Command 不查读模型Query 不写主库
### 10.2 实现质量
- [ ] 设计令牌符合主题,无硬编码颜色/字体/字号
- [ ] 错误处理完善:业务错误 4xx系统错误 5xx
- [ ] 权限校验到位Controller/Handler/endpoint + 前端 usePermission
- [ ] 日志结构化,含 request_id/trace_id
- [ ] Outbox 模式:领域事件通过 Outbox 发布,未直接调 Kafka producer
- [ ] 幂等性:事件消费者已实现幂等
### 10.3 契约与事件
- [ ] proto 变更通过 `buf lint` + `buf breaking`
- [ ] proto 变更已 `buf generate` 并提交生成代码
- [ ] 新增 Kafka topic 已在 001 文档登记
- [ ] 事件 schema 已在 proto 中定义
- [ ] 事件版本化:破坏性变更已升版本
### 10.4 安全与合规
- [ ] 无 XSS 风险(无 `dangerlySetInnerHTML`
- [ ] Cookie 安全httpOnly + Secure + SameSite
- [ ] 环境变量未泄露(服务端变量无 `NEXT_PUBLIC_` 前缀)
- [ ] 敏感信息未入日志
- [ ] 依赖无高危漏洞
### 10.5 测试与文档
- [ ] 关键逻辑有单元测试
- [ ] 新增接口有契约测试
- [ ] 服务 README 已更新
- [ ] `docs/architecture/001_architecture_overview.md` 已同步
- [ ] `docs/troubleshooting/known-issues.md` 已追加经验日志
- [ ] arch.db 已通过 `pnpm run arch:scan` 更新
- [ ] 提交信息规范scope 为服务名/包名
---
## 附录:与 CICD 单应用规范的差异
| 项目 | CICD 单应用 | Edu 微服务 | 原因 |
|------|-----------|-----------|------|
| 项目结构 | 单 Next.js 应用 | 多语言 monorepo | 微服务拆分 |
| 数据获取层 | `modules/[module]/data-access.ts` | NestJS Repository + Domain Entity | DDD 分层 |
| 中间件 | `proxy.ts`Next.js 16 | Go Gin Gateway | 网关独立 |
| 通信 | 函数调用 | gRPC + Kafka | 跨进程通信 |
| 状态管理 | Zustand + Context + nuqs | 沿用 | 团队熟悉 |
| 环境变量校验 | `@t3-oss/env-nextjs` + Zod | TS Zod / Go viper / Python pydantic-settings | 多语言 |
| 行数限制 | 单一规范 | 按语言分档 | 各语言惯例 |
| 契约 | 无(内部函数调用) | protobuf + buf | 跨服务通信需要 |
| 事件驱动 | 无 | Kafka + Outbox | 微服务最终一致 |
| 设计令牌 | `src/app/styles/tokens/` | `packages/ui-tokens/` | 微前端共享 |

View File

@@ -0,0 +1,918 @@
# Edu Git 工作流规范
> 版本1.0
> 日期2026-07-07
> 状态:基线发布
> 适用范围Edu 多语言 monorepopnpm workspace + go.work + pyproject.toml
> 关联文档:
> - [项目规则](../../project_rules.md)
> - [编码规范](./coding-standards.md)
> - [迁移指南](../../MIGRATION_GUIDE.md)
> - [架构总览](../architecture/001_architecture_overview.md)
---
## 目录
1. [分支策略](#一分支策略)
2. [Conventional Commits 规范](#二conventional-commits-规范)
3. [commitlint + husky 配置](#三commitlint--husky-配置)
4. [PR 与 Code Review](#四pr-与-code-review)
5. [文档同步规则](#五文档同步规则)
6. [多语言 monorepo 提交规则](#六多语言-monorepo-提交规则)
7. [版本与发布](#七版本与发布)
8. [紧急回滚](#八紧急回滚)
9. [附录CICD 与 Edu Git 工作流差异](#九附录cicd-与-edu-git-工作流差异)
---
## 一、分支策略
### 1.1 主干开发Trunk-Based Development
本项目采用**主干开发**模式,所有变更最终合并至 `main` 分支。
**核心原则**
- `main` 分支始终保持可发布状态
- 短生命周期特性分支(通常 ≤ 3 天)
- 频繁集成,每天至少一次 rebase/merge 至最新 `main`
- 通过特性开关Feature Flag控制未完成功能的暴露
### 1.2 分支模型
```mermaid
gitGraph
commit id: "init"
commit id: "P1-foundation"
branch feat/identity-service
checkout feat/identity-service
commit id: "scaffold"
commit id: "implement-login"
commit id: "add-tests"
checkout main
merge feat/identity-service tag: "v0.2.0"
branch fix/jwt-expiry
checkout fix/jwt-expiry
commit id: "fix-token-refresh"
checkout main
merge fix/jwt-expiry tag: "v0.2.1"
branch release/v0.3
checkout release/v0.3
commit id: "freeze"
commit id: "hotfix"
checkout main
merge release/v0.3 tag: "v0.3.0"
```
### 1.3 分支命名规范
| 分支类型 | 前缀 | 示例 | 生命周期 |
|---------|------|------|---------|
| 主干 | `main` | `main` | 永久 |
| 特性 | `feat/` | `feat/identity-service` | ≤ 3 天 |
| 修复 | `fix/` | `fix/jwt-expiry` | ≤ 1 天 |
| 重构 | `refactor/` | `refactor/split-data-access` | ≤ 5 天 |
| 性能 | `perf/` | `perf/query-optimization` | ≤ 3 天 |
| 文档 | `docs/` | `docs/api-specification` | ≤ 2 天 |
| 发布 | `release/v` | `release/v0.3.0` | 发布周期内 |
| 热修复 | `hotfix/` | `hotfix/v0.3.1` | ≤ 1 天 |
**规则**
- 分支名使用 kebab-case
- 一个分支只做一件事,禁止在一个分支内混合多个无关变更
- 特性分支命名包含服务/模块名(`feat/identity-service` 而非 `feat/login`
### 1.4 分支保护规则
**`main` 分支保护**
- 禁止直接 push必须通过 PR
- 至少 1 名 Reviewer 审批通过(核心模块需 2 名)
- 所有 CI 检查通过lint + typecheck + test + build
- 分支必须与 `main` 保持最新(无冲突或已 rebase
- 禁止 force push
**`release/*` 分支保护**
- 禁止直接 push仅接受 cherry-pick 或特定 hotfix PR
- 至少 2 名 Reviewer 审批
- 发布完成后打 tag 并归档
---
## 二、Conventional Commits 规范
### 2.1 提交信息格式
所有提交信息必须遵循 [Conventional Commits 1.0.0](https://www.conventionalcommits.org/zh-hans/)
```
<type>(<scope>): <subject>
<body>
<footer>
```
### 2.2 类型type
| 类型 | 含义 | 是否触发发布 |
|------|------|-------------|
| `feat` | 新功能 | 是MINOR |
| `fix` | Bug 修复 | 是PATCH |
| `perf` | 性能优化 | 是PATCH |
| `refactor` | 重构(不改变行为) | 否 |
| `style` | 代码风格(格式化、空白) | 否 |
| `test` | 新增/修改测试 | 否 |
| `docs` | 文档变更 | 否 |
| `build` | 构建系统或依赖变更 | 否 |
| `ci` | CI 配置变更 | 否 |
| `chore` | 杂项(不修改 src 或 test | 否 |
| `revert` | 回滚某次提交 | 是 |
### 2.3 范围scope
scope 必须是服务名或包名,详见 [§3.3 scope-enum](#33-scope-enum-完整清单)。
**示例**
- `feat(identity): 实现用户注册接口`
- `fix(gateway): 修复路由匹配优先级`
- `perf(teaching): 优化课表查询 N+1 问题`
- `docs(contracts): 补充 identity.proto 字段说明`
### 2.4 主题subject
**规则**
- 使用中文简短描述
- 不超过 50 个字符
- 不以句号结尾
- 使用祈使句("实现用户注册"而非"实现了用户注册"
- 不包含 type/scope已在前面体现
### 2.5 正文body
**规则**
- 解释"为什么"而非"做了什么"(代码已说明做了什么)
- 每行不超过 72 个字符
- 使用无序列表列出关键变更点
- 涉及 breaking change 必须在正文开头说明
**示例**
```
feat(teaching): 作业提交支持附件上传
- 新增 Attachment 聚合根,支持多文件关联
- Outbox 事件 HomeworkSubmitted 新增 attachments 字段
- BFF 接口新增 multipart/form-data 支持
BREAKING CHANGE: HomeworkSubmitted 事件 schema 变更,
消费方需升级 proto 至 v2 才能消费新事件
```
### 2.6 脚注footer
用于关联 Issue、PR、Jira 卡片:
```
fix(content): 修复题库导入时重复题目标题去重逻辑
Closes #123
Refs EDU-456
Reviewed-by: @reviewer-name
```
---
## 三、commitlint + husky 配置
### 3.1 安装与初始化
```bash
# 在 monorepo 根目录
pnpm add -Dw @commitlint/cli @commitlint/config-conventional husky lint-staged
# 初始化 husky
pnpm exec husky init
```
### 3.2 commitlint 配置
创建 `commitlint.config.cjs`
```javascript
/** @type {import('@commitlint/types').UserConfig} */
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
// type 枚举
"type-enum": [
2,
"always",
[
"feat",
"fix",
"perf",
"refactor",
"style",
"test",
"docs",
"build",
"ci",
"chore",
"revert",
],
],
// type 小写
"type-case": [2, "always", "lower-case"],
// type 不能为空
"type-empty": [2, "never"],
// scope 枚举(见 3.3
"scope-enum": [
2,
"always",
[
// 业务微服务
"identity",
"org",
"teaching",
"content",
"comm",
"insight",
// 基础设施服务
"auth",
"notification",
// AI 服务
"insight-ai",
// 网关
"gateway",
// BFF
"admin-bff",
"teacher-bff",
"student-bff",
// 微前端
"admin-shell",
"teacher-shell",
"student-shell",
"parent-shell",
// 共享包
"contracts",
"ui-tokens",
"ui-components",
"shared-ts",
// protobuf 契约
"proto",
// 平台级
"deps",
"docs",
"ci",
"chore",
"release",
],
],
// scope 小写
"scope-case": [2, "always", "lower-case"],
// subject 不能为空
"subject-empty": [2, "never"],
// subject 不超过 50 字符
"subject-max-length": [2, "always", 72],
// subject 不以句号结尾
"subject-full-stop": [2, "never", "."],
// body 每行不超过 100 字符
"body-max-line-length": [1, "always", 100],
// footer 每行不超过 100 字符
"footer-max-line-length": [1, "always", 100],
// header 不超过 100 字符
"header-max-length": [2, "always", 100],
},
};
```
### 3.3 scope-enum 完整清单
| 分类 | scope | 说明 |
|------|-------|------|
| 业务微服务 | `identity` | 身份与权限服务 |
| 业务微服务 | `org` | 教学组织服务 |
| 业务微服务 | `teaching` | 教学核心服务 |
| 业务微服务 | `content` | 内容分析服务 |
| 业务微服务 | `comm` | 沟通服务 |
| 业务微服务 | `insight` | 智能洞察服务 |
| 基础设施 | `auth` | 认证授权服务 |
| 基础设施 | `notification` | 通知服务 |
| AI 服务 | `insight-ai` | AI 分析服务Python |
| 网关 | `gateway` | API 网关Go |
| BFF | `admin-bff` | 管理端 BFF |
| BFF | `teacher-bff` | 教师端 BFF |
| BFF | `student-bff` | 学生/家长端 BFF |
| 微前端 | `admin-shell` | 管理端 Shell |
| 微前端 | `teacher-shell` | 教师端 Shell |
| 微前端 | `student-shell` | 学生端 Shell |
| 微前端 | `parent-shell` | 家长端 Shell |
| 共享包 | `contracts` | protobuf 生成契约包 |
| 共享包 | `ui-tokens` | 设计令牌包 |
| 共享包 | `ui-components` | UI 组件库 |
| 共享包 | `shared-ts` | TS 共享工具包 |
| 契约 | `proto` | protobuf 定义文件 |
| 平台级 | `deps` | 依赖升级 |
| 平台级 | `docs` | 平台级文档 |
| 平台级 | `ci` | CI/CD 配置 |
| 平台级 | `chore` | 杂项 |
| 平台级 | `release` | 发布相关 |
### 3.4 husky hooks
`.husky/commit-msg`
```bash
#!/usr/bin/env sh
pnpm exec commitlint --edit "$1"
```
`.husky/pre-commit`
```bash
#!/usr/bin/env sh
pnpm exec lint-staged
```
`.husky/pre-push`
```bash
#!/usr/bin/env sh
# 推送前运行类型检查
pnpm -r run typecheck
```
### 3.5 lint-staged 配置
`package.json`(根目录):
```json
{
"lint-staged": {
// TypeScript / NestJS / Next.js
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
// Go
"*.go": [
"gofmt -w",
"golangci-lint run --fix"
],
// Python
"*.py": [
"ruff check --fix",
"ruff format"
],
// protobuf
"*.proto": [
"buf format --write"
],
// Markdown
"*.md": [
"prettier --write"
],
// JSON / YAML
"*.{json,yaml,yml}": [
"prettier --write"
]
}
}
```
---
## 四、PR 与 Code Review
### 4.1 PR 流程
```mermaid
flowchart TD
A[开发者创建特性分支] --> B[提交代码]
B --> C[本地通过 lint + typecheck + test]
C --> D[rebase 至最新 main]
D --> E[推送至远程]
E --> F[创建 PR]
F --> G[CI 自动检查]
G --> H{CI 通过?}
H -->|否| I[修复问题]
I --> B
H -->|是| J[Reviewer 审查]
J --> K{审查通过?}
K -->|需修改| L[根据反馈修改]
L --> B
K -->|通过| M[Squash Merge]
M --> N[删除特性分支]
N --> O[CI 部署至 staging]
```
### 4.2 PR 标题
PR 标题必须符合 Conventional Commits 规范(与最终 squash merge 的 commit message 一致):
```
feat(identity): 实现用户注册接口
```
### 4.3 PR 模板
`.github/pull_request_template.md`(或 Gitea 等价路径):
```markdown
## 变更说明
<!-- 简述本次变更的目的和实现方式 -->
## 变更类型
- [ ] feat: 新功能
- [ ] fix: Bug 修复
- [ ] perf: 性能优化
- [ ] refactor: 重构
- [ ] test: 测试
- [ ] docs: 文档
- [ ] build/ci: 构建/CI
## 影响范围
<!-- 列出受影响的服务/包 -->
- 服务:
- 包:
- 数据库迁移:是 / 否
- protobuf 契约变更:是 / 否
- Kafka topic 变更:是 / 否
## 测试情况
- [ ] 单元测试通过
- [ ] 集成测试通过
- [ ] 本地手动测试通过
- [ ] 新增测试覆盖新功能
## 文档同步
- [ ] 已更新服务 README如涉及服务结构变更
- [ ] 已更新架构文档(如涉及架构变更)
- [ ] 已运行 `pnpm run arch:scan` 更新 arch.db
- [ ] 已更新 known-issues.md如遇到新问题
## Breaking Change
- [ ]
- [ ] 是(请在下方说明影响和迁移路径)
## 关联 Issue
Closes #
```
### 4.4 Reviewer 要求
| 变更类型 | 最少 Reviewer | 备注 |
|---------|--------------|------|
| 普通业务变更 | 1 | 默认 |
| 跨服务变更 | 2 | 涉及 ≥ 2 个服务 |
| protobuf 契约变更 | 2 | 需包含架构组成员 |
| 数据库 schema 变更 | 2 | 需包含 DBA 或架构组 |
| 安全相关变更 | 2 | 需包含安全负责人 |
| 核心模块auth/identity | 2 | 核心模块强制 2 人 |
### 4.5 Code Review 清单
**通用检查**
- [ ] 代码是否符合 [编码规范](./coding-standards.md)
- [ ] 是否有明显的逻辑错误
- [ ] 错误处理是否完整(不忽略 error/err/exception
- [ ] 日志是否包含足够的上下文request_id、user_id 等)
- [ ] 是否存在硬编码的密钥、token、连接字符串
**架构检查**
- [ ] 是否违反限界上下文边界(跨服务直接查 DB
- [ ] 是否违反依赖方向shared 反向依赖 services
- [ ] protobuf 变更是否向后兼容
- [ ] 事件 schema 变更是否向后兼容
**性能检查**
- [ ] 是否有 N+1 查询
- [ ] 是否有未加索引的查询
- [ ] 是否有不必要的大对象拷贝
- [ ] 是否有阻塞事件循环的同步操作Python/Node
**安全检查**
- [ ] 所有入口是否经过权限校验
- [ ] 用户输入是否经过验证
- [ ] SQL 是否使用参数化查询
- [ ] 是否有 SQL 注入、XSS、SSRF 风险
### 4.6 合并策略
**默认使用 Squash Merge**
- 保留 PR 的完整变更作为一个 commit
- commit message 使用 PR 标题
- 删除特性分支
**禁止使用 Merge Commit**(除非是发布分支合并回 main
- 避免历史中充斥 "Merge branch" 噪音
- 保持线性历史
**Rebase Merge**
- 仅用于需要保留多个有意义 commit 的特性分支
- 需在 PR 中说明原因
---
## 五、文档同步规则
### 5.1 文档同步矩阵
| 代码变更类型 | 需同步的文档 | 同步时机 |
|-------------|-------------|---------|
| 新增/删除服务 | `001_architecture_overview.md` + 服务 README | PR 内同步 |
| 新增/删除模块 | 服务 README + `arch:scan` | PR 内同步 |
| 新增/删除导出函数 | `pnpm run arch:scan` | 提交前 |
| 修改函数签名 | `pnpm run arch:scan` | 提交前 |
| 修改权限点 | `project_rules.md` + 权限文档 | PR 内同步 |
| 新增/删除数据库表 | 架构文档 + 服务 README | PR 内同步 |
| 新增/删除路由 | 服务 README + OpenAPI | PR 内同步 |
| 修改模块间依赖 | `arch:scan` + 架构文档 | PR 内同步 |
| 新增 protobuf message | `proto/` README + `arch:scan` | PR 内同步 |
| 新增 Kafka topic | 架构文档 + 服务 README | PR 内同步 |
| 遇到新问题/经验 | `known-issues.md` | PR 内同步 |
### 5.2 arch.db 同步规则
**强制规则**:任何代码变更提交前必须运行 `pnpm run arch:scan` 更新 arch.db。
```bash
# 提交前流程
pnpm run arch:scan # 更新 arch.db
pnpm run arch:query -- violations # 检查是否有违规
git add arch.db # 将 arch.db 纳入提交
git commit -m "feat(identity): 实现用户注册接口"
```
**违规检查**
- 长文件(> 1000 行)
- 未校验权限的 Handler
- 循环依赖
- 跨限界上下文直接 DB 访问
### 5.3 服务 README 要求
每个服务必须包含 `README.md`,结构如下:
```markdown
# [服务名] 服务
> 版本1.0
> 限界上下文:[上下文名]
> 技术栈:[语言 + 框架]
## 职责
[一段话描述]
## 架构
[mermaid 架构图]
## 核心流程
[mermaid 时序图]
## 目录结构
[树形结构 + 说明]
## 依赖
- 上游服务:[列表]
- 下游服务:[列表]
- 共享包:[列表]
## 约束
[业务规则、技术约束]
## 架构决策
[关键设计决策记录]
```
### 5.4 known-issues.md 格式
`docs/troubleshooting/known-issues.md` 采用索引式速查手册格式:
```markdown
### X.X 主题分区
| 场景 | 技术/规则 |
|------|----------|
| 简述场景 | 正确做法(一句话) |
```
**规则**
- 索引式:场景→技术/规则映射
- 不写代码示例和错误示范
- 同类问题在原条目补充,不重复创建
- 在"工作经验日志"区按时间倒序追加记录
---
## 六、多语言 monorepo 提交规则
### 6.1 单服务单提交原则
**规则**:一个 commit 只涉及一个服务或一个包的变更。
**原因**
- 便于回滚(按服务粒度回滚)
- 便于追踪changelog 清晰)
- 便于 review聚焦单一职责
**例外**:跨服务重构或契约变更可在同一 commit 中涉及多个服务,但需在 body 中说明。
### 6.2 生成代码单独提交
**规则**protobuf 生成的代码必须与手写代码分开提交。
```bash
# 正确流程
# 1. 修改 proto 定义
git commit -m "feat(proto): 新增 UserRegistered 事件 schema"
# 2. 生成代码
pnpm run proto:generate
# 3. 单独提交生成代码
git commit -m "build(contracts): 重新生成 proto 代码"
```
### 6.3 跨语言变更拆分
当一个功能涉及多个语言(如 TS 服务 + Go 网关 + Python AI按以下顺序拆分提交
1. **契约先行**`feat(proto): ...`
2. **生成代码**`build(contracts): ...`
3. **后端服务**`feat(teaching): ...`
4. **网关层**`feat(gateway): ...`
5. **前端**`feat(teacher-shell): ...`
### 6.4 依赖升级规则
**规则**
- 依赖升级使用 `build(deps):` 类型
- 必须说明升级原因(安全、功能、兼容性)
- 安全漏洞修复必须包含 CVE 编号
- 大版本升级需单独 PR 并完整测试
**示例**
```
build(deps): 升级 nestjs 至 10.3.0
- 修复 CVE-2024-1234 漏洞
- 升级至最新 patch 版本
- 全部测试通过
```
### 6.5 跨服务依赖更新
当一个服务的 protobuf 变更影响其他服务:
```bash
# 1. 提交 proto 变更
git commit -m "feat(proto): UserRegistered 事件新增 attachments 字段"
# 2. 生成新契约
pnpm run proto:generate
git commit -m "build(contracts): 生成 UserRegistered v2"
# 3. 更新消费方服务
git commit -m "feat(notification): 适配 UserRegistered v2 事件"
```
---
## 七、版本与发布
### 7.1 版本号体系
本项目采用**双层版本号**
| 层级 | 格式 | 说明 |
|------|------|------|
| 平台版本 | `v{阶段}.{迭代}.{patch}` | 如 `v0.3.1`P3 阶段第 1 次迭代 patch 1 |
| 服务版本 | `{service}:{semver}` | 如 `identity:1.2.0` |
### 7.2 阶段版本范围
| 阶段 | 平台版本范围 | 说明 |
|------|-------------|------|
| P1 地基 | `v0.1.x` | monorepo 初始化、CI/CD、arch.db |
| P2 身份 | `v0.2.x` | identity + auth + notification |
| P3 核心教学 | `v0.3.x` | org + teaching + content |
| P4 内容分析 | `v0.4.x` | insight + CQRS 读模型 |
| P5 沟通AI | `v0.5.x` | comm + AI 增强 |
| P6 硬化 | `v1.0.x` | 正式发布版 |
### 7.3 Docker 镜像标签
**格式**`{registry}/edu/{service}:{tag}`
| tag 类型 | 格式 | 示例 | 用途 |
|---------|------|------|------|
| 版本号 | `v{version}` | `v0.3.1` | 正式发布 |
| 服务版本 | `{service}-{semver}` | `identity-1.2.0` | 服务独立版本 |
| Git SHA | `sha-{short}` | `sha-a1b2c3d` | 精确追溯 |
| 最新 | `latest` | `latest` | 开发环境 |
| 阶段 | `{stage}-{sha}` | `staging-a1b2c3d` | 阶段环境 |
### 7.4 发布流程
```mermaid
flowchart TD
A[main 分支达到发布标准] --> B[创建 release/v0.3.x 分支]
B --> C[运行完整测试套件]
C --> D{测试通过?}
D -->|否| E[修复问题]
E --> C
D -->|是| F[更新 CHANGELOG.md]
F --> G[打 tag v0.3.0]
G --> H[CI 自动构建镜像]
H --> I[部署至 staging]
I --> J[冒烟测试]
J --> K{冒烟通过?}
K -->|否| L[修复并重新发布]
L --> C
K -->|是| M[审批发布至 production]
M --> N[monitoring 观察指标]
N --> O{指标正常?}
O -->|否| P[紧急回滚]
O -->|是| Q[发布完成]
Q --> R[合并 release 分支回 main]
```
### 7.5 CHANGELOG 格式
`CHANGELOG.md` 按 [Keep a Changelog](https://keepachangelog.com/zh-CN/) 格式维护:
```markdown
## [v0.3.0] - 2026-08-15
### Added
- 教学核心服务新增作业管理功能
- 内容服务支持题库导入
- 教师端 Shell 新增作业批改界面
### Changed
- identity 服务升级至 NestJS 10.3
- gateway 路由匹配算法优化
### Fixed
- 修复 JWT 刷新 token 过期判断错误
- 修复课表查询时区问题
### Breaking Changes
- UserRegistered 事件 schema 变更至 v2消费方需升级
```
### 7.6 服务独立版本
每个服务维护独立的 `VERSION` 文件,内容为 semver
```
# services/identity/VERSION
1.2.0
```
**版本号升级规则**
- **MAJOR**Breaking Changeprotobuf 不兼容变更、API 破坏性修改)
- **MINOR**:新增功能,向后兼容
- **PATCH**Bug 修复,向后兼容
---
## 八、紧急回滚
### 8.1 回滚策略
| 场景 | 回滚方式 | 耗时 |
|------|---------|------|
| 代码缺陷 | `git revert` + 重新部署 | 5-10 分钟 |
| 镜像问题 | `kubectl rollout undo` | 1-2 分钟 |
| 数据库迁移问题 | 执行迁移 down 脚本 | 5-30 分钟 |
| 配置错误 | 回滚 ConfigMap/Secret | 1-2 分钟 |
| 全站故障 | 回滚至上一稳定 tag | 10-30 分钟 |
### 8.2 代码回滚
```bash
# 1. 确认要回滚的 commit
git log --oneline -10
# 2. 创建回滚分支
git checkout -b hotfix/v0.3.1
# 3. 回滚指定 commit
git revert <commit-sha>
# 4. 推送并创建 PR
git push origin hotfix/v0.3.1
# 5. 紧急审批合并后部署
```
### 8.3 K8s 部署回滚
```bash
# 查看部署历史
kubectl rollout history deployment/identity -n edu-prod
# 回滚至上一版本
kubectl rollout undo deployment/identity -n edu-prod
# 回滚至指定版本
kubectl rollout undo deployment/identity -n edu-prod --to-revision=3
# 监控回滚状态
kubectl rollout status deployment/identity -n edu-prod
```
### 8.4 数据库迁移回滚
**规则**
- 所有迁移必须提供 `up``down` 脚本
- `down` 脚本必须在 CI 中测试
- 回滚前必须备份生产数据
```bash
# 回滚最近一次迁移
pnpm --filter identity run migrate:down
# 回滚至指定版本
pnpm --filter identity run migrate:down -- --to <version>
```
### 8.5 回滚原则
1. **优先保证可用性**:回滚决策应在 5 分钟内做出,无需 root cause
2. **保留现场**回滚前保存日志、metrics、错误堆栈
3. **通知相关方**:回滚后立即通知运维、产品、相关开发
4. **复盘改进**:回滚后 24 小时内完成事故复盘,记录至 `docs/troubleshooting/incidents/`
### 8.6 回滚记录格式
`docs/troubleshooting/incidents/YYYY-MM-DD-incident.md`
```markdown
# 事故记录:[简述]
> 日期YYYY-MM-DD
> 影响范围:[服务/用户]
> 持续时间:[开始-结束]
> 严重等级P0/P1/P2/P3
## 时间线
- HH:MM 告警触发
- HH:MM 确认问题
- HH:MM 决定回滚
- HH:MM 回滚完成
- HH:MM 服务恢复
## 影响分析
[受影响的功能、用户数、业务损失]
## 根本原因
[技术原因 + 流程原因]
## 回滚过程
[执行的操作]
## 改进措施
- [ ] 短期:[立即修复项]
- [ ] 中期:[流程改进项]
- [ ] 长期:[架构改进项]
```
---
## 九、附录CICD 与 Edu Git 工作流差异
| 维度 | CICDNext.js 单应用) | Edu微服务 monorepo |
|------|----------------------|----------------------|
| 仓库结构 | 单一 Next.js 应用 | 多语言 monorepopnpm + go.work + uv |
| 分支策略 | trunk-based | trunk-based沿用 |
| 提交规范 | Conventional Commits | Conventional Commits沿用scope 扩展至服务/包) |
| scope 范围 | 模块名(如 `exams``homework` | 服务/包名(如 `identity``contracts` |
| commitlint scope-enum | 35 个模块 | 27 个服务/包 |
| PR Reviewer | 1 人 | 1-2 人(核心模块/跨服务 2 人) |
| 合并策略 | Squash Merge | Squash Merge沿用 |
| 版本号 | 单一应用版本 | 双层(平台版本 + 服务独立版本) |
| 发布粒度 | 整体发布 | 按服务独立发布 |
| Docker 镜像 | 单一镜像 | 每服务一镜像 |
| 回滚粒度 | 整体回滚 | 按服务回滚 |
| 数据库迁移 | Drizzle 单库 | 每服务独立库 + 独立迁移 |
| 文档同步 | `npm run arch:scan` | `pnpm run arch:scan`(多语言扫描) |
| CI 检查 | lint + tsc + test | lint + typecheck + test按语言分别执行 |
| 紧急回滚 | `git revert` + 重新部署 | `git revert` + `kubectl rollout undo` |
| 事故复盘 | known-issues.md | `incidents/` 目录独立记录 |
---
## 变更记录
| 版本 | 日期 | 变更内容 |
|------|------|---------|
| 1.0 | 2026-07-07 | 基线发布,从 CICD 单应用规范迁移至微服务多语言 monorepo |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,295 @@
# 已知问题速查
> 索引式速查手册:场景 → 技术/规则映射。不写代码示例。
> 架构规则见 [../architecture/004_architecture_impact_map.md](../architecture/004_architecture_impact_map.md) 与 [../../project_rules.md](../../project_rules.md)
> 工作经验日志按时间倒序追加50 条上限AI 发现更好方案时可更新本节。
---
## 一、全局经验
### 1.1 多语言 monorepo 配置
| 场景 | 技术/规则 |
| ----------------------------- | ------------------------------------------------------------------------------------------ |
| 多语言 workspace | pnpm workspaceTS+ go.workGo+ pyproject.toml/uv workspacePython三套并存 |
| 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` |
| pnpm-workspace.yaml | 仅声明 TS 包路径packages/*、services/classes、bff/*、apps/*、scripts/*Go/Python 不入 |
| go.work | 列出所有 Go 服务模块services/api-gateway、services/push-gateway |
| pyproject.toml | uv workspace members 列 Python 服务services/data-ana、services/ai-gateway |
| 跨语言共享类型 | protobuf 生成三端代码TS/Go/Python单一契约源 |
| tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 |
| husky + commitlint | pre-commit 跑 eslint+prettiercommit-msg 校验 Conventional Commits |
| .editorconfig 多语言缩进 | Go 用 tabPython 用 4 空格TS/默认用 2 空格 |
### 1.2 Docker Compose 基础设施
| 场景 | 技术/规则 |
| ---------------------------- | ---------------------------------------------------------------------------------- |
| 日常开发启动 | 用 `docker-compose.minimal.yml` 仅起 MySQL+Redis |
| 全量启动内存不足 | 按 `profiles` 分阶段启用full/kafka/cdc/analytics/graph/search/config/observability |
| 每服务 mem_limit | 避免单服务吃满内存MySQL 512m、Redis 128m、Kafka 512m、ClickHouse 1g |
| 按阶段启用容器 | P1 仅 MySQL+RedisP3 加 Kafka+ZookeeperP4 加 Debezium+CH+Neo4jP5 加 ESP6 加 Consul+Istio |
| MySQL 初始化 | `init-sql/01-init.sql` 挂载到 `/docker-entrypoint-initdb.d:ro` |
| healthcheck | MySQL 用 `mysqladmin ping`Redis 用 `redis-cli ping` |
| Windows 下卷挂载 | init-sql 用绝对路径或确保相对路径正确 |
| 容器名固定 | `container_name: edu-mysql` 便于服务连接配置 |
### 1.3 protobuf + buf 契约
| 场景 | 技术/规则 |
| ----------------------- | ------------------------------------------------------------------------------------------ |
| 契约唯一源 | `packages/shared-proto/proto/*.proto`,禁止 REST/gRPC 混用 |
| breaking change 检测 | `buf breaking --against '.git#branch=main'` CI 强制,必须升版本号 |
| proto 版本化 | 包名带版本 `xxx.v1``xxx.v2`,新旧版本共存 |
| buf lint | `buf lint` 使用 STANDARD 规则集,可按需 except如 PACKAGE_VERSION_SUFFIX |
| 三端代码生成 | `buf.gen.yaml` 配置 TS/Go/Python 插件,`pnpm proto:gen` 统一生成 |
| 契约先行硬约束 | 任何服务间通信必须先改 protoCI 检测 breaking change 阻止违规合并 |
| 事件 schema 版本化 | Kafka 事件带 `schema_version` 字段,消费端按版本处理 |
| 错误码定义在 proto | 错误码三端共享,格式 `{服务前缀}_{错误类型}_{资源}`(如 `IAM_PERM_USER` |
### 1.4 NestJS 服务开发TS
| 场景 | 技术/规则 |
| ------------------------- | ----------------------------------------------------------------------------------------------- |
| 服务标准结构 | `src/{main.ts,app.module.ts,config,middleware,<domain>,shared,generated}` 黄金模板复制 |
| 三层配置 | env环境变量< yaml业务参数< 配置中心P6 Consul每服务启动时合并 |
| 环境变量校验 | Zod schema 校验 process.env失败立即抛错终止启动 |
| ApplicationError 基类 | 统一错误体系ValidationError(400)/NotFoundError(404)/PermissionDeniedError(403)/ConflictError(409) 等 |
| 响应信封 | `{ success: boolean, data?: T, error?: { code, message, details, traceId } }` 统一跨服务 |
| 权限校验 | `requirePermission(perm)` 装饰器/中间件admin 角色拥有全部权限 |
| DataScope 过滤 | 6 级all/grade_managed/class_taught/class_members/children/ownedrepository 下推 WHERE 条件 |
| 结构化日志 | pino + `traceId`/`spanId`/`userId`/`service` 字段,禁止 `console.*` |
| Metrics endpoint | prom-client 暴露 `/metrics`QPS/延迟/错误率) |
| OpenTelemetry trace | OTel SDK 初始化W3C TraceContext header 跨服务传播 |
| 健康检查 | `/health` endpoint 返回 `{ status, service, version }` |
| 优雅关闭 | SIGTERM → app.close() → shutdownTracing() → process.exit(0) |
| i18n 错误码映射 | 错误码 → i18n key 映射表,后端返回 key + 参数,前端翻译 |
| Drizzle ORM | 参数化查询,禁止字符串拼接 SQL |
| Zod 输入校验 | controller 层 `schema.safeParse(body)`,失败抛 ValidationError |
### 1.5 Go Gateway 开发
| 场景 | 技术/规则 |
| ------------------- | ------------------------------------------------------------------------------------------ |
| 框架 | Gin + httputil.ReverseProxy 路由转发 |
| P1 鉴权 | Gateway 内置 HS256 JWT 校验测试密钥P2 改 RS256IAM 签发,公钥校验) |
| JWT claims | `{ user_id, roles[], registered_claims }`,校验后注入 `x-user-id`/`x-user-roles` 头转发 |
| 请求 ID 注入 | Gateway 生成或透传 `X-Request-ID`,全链路传递 |
| 健康检查 | `GET /health` 无需鉴权,返回 `{ status, timestamp }` |
| 包结构 | `internal/{config,middleware,proxy}`P6 扩展 handler/service/repository |
| error 处理 | 显式处理,禁止 `_` 忽略;`gin.AbortWithStatusJSON` 统一错误响应 |
| 配置加载 | 环境变量 + 默认值(`getEnv(key, fallback)`P6 引入 viper |
| Dockerfile 多阶段 | golang:1.22-alpine 构建 → alpine:3.20 运行CGO_ENABLED=0 |
### 1.6 可观测性OTel + Prometheus + Loki
| 场景 | 技术/规则 |
| --------------------- | ---------------------------------------------------------------------------------- |
| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) |
| 三支柱 | Logspino/winston/zap+ Metricsprom-client+ TracesOTel SDK |
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
| P6 完整后端 | Loki日志+ Grafana仪表盘+ Jaegertrace+ Prometheusmetrics |
| Prometheus 指标 | `http_request_duration_seconds`Histogram+ `http_requests_total`Counter |
| 采样策略 | P1 全量 traceP6 引入采样率降低开销 |
| 日志参数顺序 | `log.error({ err: error, userId, traceId }, "操作描述")`,错误对象字段名用 `err` |
### 1.7 微前端 Module Federation
| 场景 | 技术/规则 |
| --------------------- | -------------------------------------------------------------------------------------------- |
| 4 个稳定 portal | teacher-portal / student-portal / parent-app / admin-console按场景域划分 |
| P1 测试页 | teacher-portal 仅一个测试页验证 classes CRUD 链路P2 起配 Module Federation |
| 视口驱动渲染 | 侧边栏由 `viewports.L1` 驱动渲染,路由由 `viewports.L2` 控制 |
| 共享翻译资源包 | next-intl 微前端共享BFF/服务返回 i18n key 不返回翻译文本 |
| Module Federation 配置 | P2 起在 next.config.js 配置P1 用单一 Next.js 应用 |
### 1.8 从旧项目迁移的通用经验
| 场景 | 技术/规则 |
| ----------------------------- | ----------------------------------------------------------------------------------------------- |
| React 19 乐观更新 | `useOptimistic` 替代手动 isPending配合 `useTransition` 自动管理回滚 |
| Zustand 细粒度选择器 | 单字段 selector 优于 `useShallow` 多字段包装 |
| Tiptap SSR | 必须 `immediatelyRender: false` 避免 hydration mismatch |
| 请求级去重 | React `cache()` 包装优于跨请求缓存(权限数据易变,不用 `unstable_cache` |
| 批量 SQL | INSERT batch `db.insert().values([...])` + UPDATE `CASE WHEN` 单次执行,禁止循环单条 |
| 动态导入重 UI 库 | `xxx-inner.tsx` + `xxx.tsx` lazy wrappertiptap / @xyflow/react / AI SDK |
| ReactFlowProvider | 留在外层同步渲染,保证 lazy 子组件 hook 可用 |
| 递归删除 | 批量收集后代 ID + `inArray` 单次删除,禁止递归逐个 |
| 统计走 SQL 聚合 | `COUNT(*)`/`SUM(CASE WHEN ...)` 替代拉全表内存循环 |
| 高频子组件 React.memo | `AssignmentCard` / `StatusBadge` / `EmptyState` 等 |
| 虚拟化大表 | `@tanstack/react-virtual` |
| 破坏性操作独立权限 | `AUDIT_LOG_PURGE``AUDIT_LOG_READ`,沿用旧项目规则 |
| i18n key 命名 | 禁止包含 `.`,动态 key 必须包含完整嵌套路径(`t(\`type.${type}\`)` |
| i18n 翻译文件对称性 | zh-CN 与 en 必须同步新增/删除 key |
| 类型守卫优先 | JSON.parse 结果经 `unknown` 中转 + 守卫;禁止 `as` 断言(除非从 `unknown` 转换) |
| 函数返回值显式标注 | 特别是 `Promise<T>` |
| 仅类型导入 | `import type` |
### 1.9 架构工具与验证命令
| 场景 | 命令/规则 |
| --------------------- | ---------------------------------------------------------------------- |
| arch.db 扫描 | `npm run arch:scan`多语言TS+Go+Python |
| arch.db 查询 | `npm run arch:query -- <command>` |
| 查服务依赖 | `npm run arch:query -- service <service>` |
| 查 proto 契约 | `npm run arch:query -- contracts` |
| 查 Kafka 事件 | `npm run arch:query -- events` |
| 查架构违规 | `npm run arch:query -- violations`(输出 0 为合格) |
| proto lint | `cd packages/shared-proto && pnpm exec buf lint` |
| proto breaking 检测 | `pnpm exec buf breaking --against '.git#branch=main'` |
| proto 代码生成 | `pnpm proto:gen` |
| Go 编译验证 | `cd services/api-gateway && go build ./... && go vet ./...` |
| TS 类型检查 | `pnpm -r exec tsc --noEmit` |
| 全量 lint | `pnpm lint` |
| 全量测试 | `pnpm test` |
| 启动最小基础设施 | `docker compose -f infra/docker-compose.minimal.yml up -d` |
---
## 二、模块经验
### 2.1 api-gatewayGo
| 场景 | 技术/规则 |
| ------------------- | ------------------------------------------------------------------------------------------ |
| P1 鉴权 | Gateway 内置 HS256 JWT`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 |
| P2 鉴权升级 | 改 RS256IAM 私钥签发Gateway 公钥校验,无需调 IAM |
| 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` |
| 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务 |
| 用户上下文注入 | `c.Request.Header.Set("x-user-id", claims.UserID)` 传递给下游 |
| 请求 ID | Gateway 生成或透传 `X-Request-ID``c.Set("request_id", ...)` + `c.Header(...)` |
| P1 不做 | 限流/熔断/灰度P6 硬化阶段实现) |
| 健康检查 | `GET /health` 无需鉴权 |
### 2.2 classesTS/NestJSP1 黄金模板)
| 场景 | 技术/规则 |
| ------------------------- | ----------------------------------------------------------------------------------------------- |
| 黄金模板定位 | P1 完整实现所有横切关注点,后续 8 个服务复制此模板 |
| 黄金模板复制流程 | `cp -r services/classes services/xxx` → 改错误码前缀 → 改 proto → 改业务逻辑 → 改 README → 改 CI |
| 横切关注点清单 | 错误处理 / 可观测 / 安全 / 契约 / 测试 / 文档 / 配置 / i18n / CI / Dockerfile |
| 错误处理 | `ApplicationError` 基类 + 子类,错误码 `CLASSES_*` 前缀 |
| 可观测 | pino logger + prom-client metrics + OTel tracer |
| 安全 | auth.middleware信任 Gateway 头)+ permission.guard + data-scope.interceptor |
| 配置 | 三层配置 + Zod 校验 env |
| i18n | `ERROR_CODES` 映射表(错误码 → i18n key |
| 测试四类 | 单元vitest+ 集成Testcontainers+ 契约Pact+ E2EPlaywright |
| 覆盖率门槛 | 领域逻辑 ≥ 80%Handler ≥ 60%,整体 ≥ 60% |
| Drizzle schema | `mysqlTable` + `varchar`/`timestamp` + `index` |
| ID 生成 | `@paralleldrive/cuid2` 的 `createId()` |
| 响应转换 | repository 返回 Dateservice 转换为 `createdAt: number`(时间戳) |
| 阶段特有模式回写 | OutboxP3/ CDCP4/ 长连接P5实现后回写黄金模板 README |
### 2.3 iamTS/NestJSP2
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| 认证 | 登录/登出/JWT/2FARS256 非对称签名 |
| RBAC | 角色/权限/角色-权限 CRUD + `getEffectivePermissions(userId)` API |
| 视口配置 | 4 层模型(导航/路由/组件/数据),`role_viewports` 表 |
| DataScope 解析 | 6 级数据范围,注入 JWT payload |
| JWT payload | `{ userId, roles, permissions(bitmap), dataScope, exp }` |
| Token TTL | access 15min / refresh 7dayrefresh 用 Redis 黑名单失效 |
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 |
| schema 表 | users / roles / permissions / role_permissions / role_viewports / parent_student_relations / class_subject_teachers |
### 2.4 core-eduTS/NestJSP3
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 |
| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka |
| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 |
| Kafka topic | `exam.published` / `homework.graded` / `grade.recorded` |
| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 |
| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费DataAna/Msg |
| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) |
| schema 表 | exams / exam_questions / homework_assignments / homework_submissions / homework_answers / grade_records / outbox_events |
### 2.5 contentTS/NestJSP4
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| 知识图谱 | Neo4j 查询前置依赖图(秒级返回) |
| 题库 CRUD | P4 仅 CRUDP5 引入 ES 实现检索,避免 MySQL FULLTEXT → ES 迁移成本 |
| 双写避免 | Neo4j/ES 不直接双写,由消费 Kafka 事件同步,天然最终一致 |
| Neo4j 写入 | Content 服务写 MySQL 同时发事件,独立 worker 消费事件同步 Neo4j |
### 2.6 data-anaPython/FastAPIP4
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| 学情诊断 | ClickHouse 宽表查询5s 内返回 |
| CDC 链路 | Debezium 监听 MySQL binlog → Kafka`mysql.cdc.*`)→ DataAna 消费写 ClickHouse |
| CDC 延迟监控 | Debezium 暴露 lag metrics超阈值告警 |
| 双轨读策略 | 实时查 MySQL 主库(刚提交的成绩),聚合查 CH 宽表(延迟 1-5s 可接受) |
| 幂等消费 | 所有事件消费者必须幂等(基于 event_id 去重) |
### 2.7 messagingTS/NestJSP5
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 |
| 通知批量化 | `createNotifications(items)` 单次 INSERT沿用旧项目 dispatcher 模式 |
| 多渠道 | 站内/SMS/邮件/微信in_app 批量 + 其他渠道并行 |
| fan-out 分页 | `getAllUserIds(limit=1000, offset)` 分页遍历 |
| 撤回不乐观更新 | 需服务端返回判断 2 分钟窗口 |
### 2.8 push-gatewayGoP5
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需发 Kafka 消息 |
| 跨实例同步 | Redis PubSub |
| 离线消息 | 仅推在线用户,离线消息存 MySQL上线时拉取 |
### 2.9 ai-gatewayPython/FastAPIP5
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| LLM Provider 适配 | OpenAI/Anthropiclangchain/litellm 生态 |
| Prompt 模板管理 | 版本管理友好 |
| 流式 SSE | AI 网关 → BFF → 前端三层透传BFF 不缓冲 |
| 用量计费 | 按 token 计费 |
| AI 模块纯服务端 | Zod 验证 + 失败降级返回空(沿用旧项目模式) |
### 2.10 shared-proto契约包
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| 目录结构 | `proto/*.proto` + `buf.yaml` + `buf.gen.yaml` |
| P1 契约 | 仅 `classes.proto``iam.proto`/`core_edu.proto` 占位 |
| 代码生成输出 | TS → `shared-ts/generated`Go → `shared-go/`Python → `services/*/generated/` |
### 2.11 arch-scan多语言扫描器
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| TS 扫描 | ts-morph 解析 AST提取导出/函数/类/import |
| Go 扫描 | P1 用正则提取(函数/类型/importP2 起替换为 tree-sitter-go AST |
| Python 扫描 | P1 用正则提取P4 起替换为 tree-sitter-python AST |
| arch.db schema | modules/symbols/dependencies/contracts/events/violations 六表 |
| 全量扫描 | 先清空旧数据再扫描,避免残留 |
| 并行扫描风险 | 并行子代理执行 arch:scan 可能因竞争报 FOREIGN KEY 错误,必须串行执行 |
| 扫描后验证 | `npm run arch:query -- violations` 输出 0 为合格 |
| Windows 路径 | 自定义 ESLint 规则加载用 `path.join`,不用 `path.posix.join` |
### 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 |
---
## 三、工作经验日志
> 按时间倒序50 条上限。AI 发现更好方案时可更新本节。
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ----- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 硬化。 |

7
go.work Normal file
View File

@@ -0,0 +1,7 @@
go 1.22
use (
./services/api-gateway
./services/push-gateway
./packages/shared-go
)

View File

@@ -0,0 +1,40 @@
name: edu-minimal
services:
mysql:
image: mysql:8.0
container_name: edu-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-changeme}
MYSQL_DATABASE: ${MYSQL_DATABASE:-next_edu_cloud}
MYSQL_USER: ${MYSQL_USER:-edu}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-changeme}
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-changeme}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: edu-redis
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes:
mysql_data:
redis_data:

154
infra/docker-compose.yml Normal file
View File

@@ -0,0 +1,154 @@
name: edu-full
services:
mysql:
image: mysql:8.0
container_name: edu-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-changeme}
MYSQL_DATABASE: ${MYSQL_DATABASE:-next_edu_cloud}
MYSQL_USER: ${MYSQL_USER:-edu}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-changeme}
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: edu-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
kafka:
image: confluentinc/cp-kafka:7.6.0
container_name: edu-kafka
profiles: ["p3", "p4", "p5", "p6"]
restart: unless-stopped
depends_on:
zookeeper:
condition: service_healthy
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
ports:
- "9092:9092"
healthcheck:
test: ["CMD", "kafka-topics", "--bootstrap-server", "localhost:9092", "--list"]
interval: 15s
timeout: 10s
retries: 5
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
container_name: edu-zookeeper
profiles: ["p3", "p4", "p5", "p6"]
restart: unless-stopped
environment:
ZOOKEEPER_CLIENT_PORT: 2181
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "2181"]
interval: 10s
timeout: 5s
retries: 5
clickhouse:
image: clickhouse/clickhouse-server:24.3
container_name: edu-clickhouse
profiles: ["p4", "p5", "p6"]
restart: unless-stopped
ports:
- "8123:8123"
- "9000:9000"
volumes:
- clickhouse_data:/var/lib/clickhouse
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
interval: 10s
timeout: 5s
retries: 5
neo4j:
image: neo4j:5.20
container_name: edu-neo4j
profiles: ["p4", "p5", "p6"]
restart: unless-stopped
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-changeme}
ports:
- "7474:7474"
- "7687:7687"
volumes:
- neo4j_data:/data
healthcheck:
test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "${NEO4J_PASSWORD:-changeme}", "RETURN 1"]
interval: 15s
timeout: 10s
retries: 5
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
container_name: edu-es
profiles: ["p5", "p6"]
restart: unless-stopped
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
ports:
- "9200:9200"
volumes:
- es_data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9200/_cluster/health"]
interval: 15s
timeout: 10s
retries: 5
jaeger:
image: jaegertracing/all-in-one:1.57
container_name: edu-jaeger
profiles: ["observability"]
restart: unless-stopped
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- "16686:16686"
- "4318:4318"
prometheus:
image: prom/prometheus:v0.51.0
container_name: edu-prometheus
profiles: ["observability"]
restart: unless-stopped
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
grafana:
image: grafana/grafana:10.4.0
container_name: edu-grafana
profiles: ["observability"]
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
ports:
- "3001:3001"
volumes:
- grafana_data:/var/lib/grafana
volumes:
mysql_data:
redis_data:
clickhouse_data:
neo4j_data:
es_data:
grafana_data:

View File

@@ -0,0 +1,17 @@
-- P1: classes 域 schema
CREATE TABLE IF NOT EXISTS `classes` (
`id` CHAR(36) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`grade_id` CHAR(36) NOT NULL,
`head_teacher_id` CHAR(36) NULL,
`description` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_classes_grade_id` (`grade_id`),
INDEX `idx_classes_head_teacher` (`head_teacher_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- P2 起增加 users / roles / permissions 表IAM 服务)
-- P3 起增加 exams / homework / grades 表CoreEdu 服务)
-- 各服务独立数据库或独立 schemaP1 先用同库不同表前缀

12
infra/prometheus.yml Normal file
View File

@@ -0,0 +1,12 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'api-gateway'
static_configs:
- targets: ['host.docker.internal:8080']
- job_name: 'classes-service'
static_configs:
- targets: ['host.docker.internal:3001']

7
lint-staged.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
'*.{ts,tsx}': ['eslint --fix', 'prettier --write'],
'*.{go,mod,sum}': ['gofmt -w', 'golangci-lint run --fix'],
'*.{py}': ['ruff check --fix', 'ruff format'],
'*.proto': ['buf format --write'],
'*.md': ['prettier --write'],
};

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "next-edu-cloud",
"version": "0.1.0",
"private": true,
"description": "DDD+EDA+CQRS 企业级微服务架构教育云平台",
"engines": {
"node": ">=20",
"pnpm": ">=9"
},
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev": "pnpm -r --parallel run dev",
"build": "pnpm -r run build",
"test": "pnpm -r run test",
"lint": "pnpm -r run lint",
"arch:scan": "tsx scripts/arch-scan/scanner.ts",
"arch:query": "tsx scripts/arch-scan/query.ts",
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.1.0",
"lint-staged": "^15.0.0",
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"@types/node": "^22.0.0",
"prettier": "^3.3.0"
}
}

View File

@@ -0,0 +1,8 @@
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: ../shared-go/gen/proto
- remote: buf.build/protocolbuffers/js
out: ../shared-ts/gen/proto
- remote: buf.build/protocolbuffers/python
out: ../shared-py/gen/proto

View File

@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE

View File

@@ -0,0 +1,10 @@
{
"name": "@edu/shared-proto",
"version": "0.1.0",
"private": true,
"scripts": {
"lint": "buf lint",
"breaking": "buf breaking --against .git#branch=main",
"generate": "buf generate"
}
}

View File

@@ -0,0 +1,58 @@
syntax = "proto3";
package next_edu_cloud.classes.v1;
// ClassService 定义班级域 CRUD 契约
// P1: REST 实现P3 起转 gRPC
service ClassService {
rpc CreateClass(CreateClassRequest) returns (Class);
rpc GetClass(GetClassRequest) returns (Class);
rpc ListClasses(ListClassesRequest) returns (ListClassesResponse);
rpc UpdateClass(UpdateClassRequest) returns (Class);
rpc DeleteClass(DeleteClassRequest) returns (Empty);
}
message Class {
string id = 1;
string name = 2;
string grade_id = 3;
string head_teacher_id = 4;
string description = 5;
int64 created_at = 6;
int64 updated_at = 7;
}
message CreateClassRequest {
string name = 1;
string grade_id = 2;
string head_teacher_id = 3;
string description = 4;
}
message GetClassRequest {
string id = 1;
}
message ListClassesRequest {
string grade_id = 1;
int32 page_size = 2;
string page_token = 3;
}
message ListClassesResponse {
repeated Class classes = 1;
string next_page_token = 2;
}
message UpdateClassRequest {
string id = 1;
optional string name = 2;
optional string head_teacher_id = 3;
optional string description = 4;
}
message DeleteClassRequest {
string id = 1;
}
message Empty {}

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
packages:
- 'services/*'
- 'apps/*'
- 'packages/*'
- 'scripts/*'

1
project_rules.md Normal file
View File

@@ -0,0 +1 @@
4. è¥ä»£ç <C3A7>结构å<E2809E>˜åŒ?â†?`pnpm run arch:scan` 确认 arch.db 已更æ–?

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[tool.uv.workspace]
members = ["services/data-ana", "services/ai"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"]

View File

@@ -0,0 +1,22 @@
{
"name": "@edu/arch-scan",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"arch-scan": "./scanner.ts",
"arch-query": "./query.ts"
},
"scripts": {
"scan": "tsx scanner.ts",
"query": "tsx query.ts"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"ts-morph": "^24.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"tsx": "^4.19.0"
}
}

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env tsx
import { createDb } from './schema.js';
const args = process.argv.slice(2);
const command = args[0];
function main(): void {
const db = createDb('arch.db');
switch (command) {
case 'stats':
printStats(db);
break;
case 'modules':
printModules(db);
break;
case 'symbols':
printSymbols(db, args[1]);
break;
case 'deps':
printDependencies(db, args[1]);
break;
case 'violations':
printViolations(db);
break;
case 'sql':
printSql(db, args[1]);
break;
default:
console.log(`Usage: arch-query <command> [args]
Commands:
stats 显示统计信息
modules 列出所有模块
symbols <name> 查询符号
deps <module> 查模块依赖
violations 查架构违规
sql <SQL> 自定义 SQL 查询`);
}
db.close();
}
function printStats(db: ReturnType<typeof createDb>): void {
const modules = (db.prepare('SELECT COUNT(*) as c FROM modules').get() as { c: number }).c;
const symbols = (db.prepare('SELECT COUNT(*) as c FROM symbols').get() as { c: number }).c;
const calls = (db.prepare('SELECT COUNT(*) as c FROM calls').get() as { c: number }).c;
const deps = (db.prepare('SELECT COUNT(*) as c FROM dependencies').get() as { c: number }).c;
console.log(`模块: ${modules}\n符号: ${symbols}\n调用: ${calls}\n依赖: ${deps}`);
}
function printModules(db: ReturnType<typeof createDb>): void {
const rows = db.prepare('SELECT name, language, service, type FROM modules ORDER BY name').all();
rows.forEach((r) => console.log(r));
}
function printSymbols(db: ReturnType<typeof createDb>, name: string | undefined): void {
if (!name) {
console.log('Usage: arch-query symbols <name>');
return;
}
const rows = db.prepare('SELECT * FROM symbols WHERE name LIKE ?').all(`%${name}%`);
rows.forEach((r) => console.log(r));
}
function printDependencies(db: ReturnType<typeof createDb>, module: string | undefined): void {
if (!module) {
console.log('Usage: arch-query deps <module>');
return;
}
const rows = db.prepare(`
SELECT m2.name as depends_on FROM dependencies d
JOIN modules m1 ON d.source_module_id = m1.id
JOIN modules m2 ON d.target_module_id = m2.id
WHERE m1.name = ?
`).all(module);
rows.forEach((r) => console.log(r));
}
function printViolations(db: ReturnType<typeof createDb>): void {
// 骨架:检查长文件、未校验权限的 Action 等
console.log('骨架实现P1 后期补全违规检测');
}
function printSql(db: ReturnType<typeof createDb>, sql: string | undefined): void {
if (!sql) {
console.log('Usage: arch-query sql "<SQL>"');
return;
}
const rows = db.prepare(sql).all();
rows.forEach((r) => console.log(r));
}
main();

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env tsx
import { createDb } from './schema.js';
import { scanTypeScript } from './scanners/ts-scanner.js';
import { scanGo } from './scanners/go-scanner.js';
import { scanPython } from './scanners/py-scanner.js';
import { scanProtobuf } from './scanners/proto-scanner.js';
import path from 'node:path';
const ROOT = process.cwd();
function main(): void {
const db = createDb(path.join(ROOT, 'arch.db'));
console.log('🔍 arch-scan: 开始多语言扫描...');
// 串行扫描(并行会导致 FOREIGN KEY 错误)
const tsStats = scanTypeScript(db, ROOT);
console.log(` TypeScript: ${tsStats.modules} 模块, ${tsStats.symbols} 符号`);
const goStats = scanGo(db, ROOT);
console.log(` Go: ${goStats.modules} 模块, ${goStats.symbols} 符号`);
const pyStats = scanPython(db, ROOT);
console.log(` Python: ${pyStats.modules} 模块, ${pyStats.symbols} 符号`);
const protoStats = scanProtobuf(db, ROOT);
console.log(` Protobuf: ${protoStats.contracts} 契约`);
console.log('✅ arch-scan 完成');
db.close();
}
main();

View File

@@ -0,0 +1,12 @@
import type { Database as DBType } from 'better-sqlite3';
interface ScanStats {
modules: number;
symbols: number;
}
export function scanGo(db: DBType, root: string): ScanStats {
// Go 扫描器骨架P1 后期用 tree-sitter-go 实现
// 当前仅扫描 go.mod 识别模块
return { modules: 0, symbols: 0 };
}

View File

@@ -0,0 +1,10 @@
import type { Database as DBType } from 'better-sqlite3';
interface ScanStats {
contracts: number;
}
export function scanProtobuf(db: DBType, root: string): ScanStats {
// Protobuf 扫描器骨架:扫描 packages/shared-proto/proto/*.proto
return { contracts: 0 };
}

View File

@@ -0,0 +1,11 @@
import type { Database as DBType } from 'better-sqlite3';
interface ScanStats {
modules: number;
symbols: number;
}
export function scanPython(db: DBType, root: string): ScanStats {
// Python 扫描器骨架P1 后期用 tree-sitter-python 实现
return { modules: 0, symbols: 0 };
}

View File

@@ -0,0 +1,38 @@
import { Project, SyntaxKind } from 'ts-morph';
import path from 'node:path';
import type { Database as DBType } from 'better-sqlite3';
interface ScanStats {
modules: number;
symbols: number;
}
export function scanTypeScript(db: DBType, root: string): ScanStats {
const project = new Project({
tsConfigFilePath: undefined,
skipAddingFilesFromTsConfig: true,
compilerOptions: {
allowJs: true,
declaration: false,
resolveJsonModule: true,
},
});
const patterns = ['services/*/src/**/*.ts', 'apps/*/src/**/*.ts', 'packages/*/src/**/*.ts'];
let modules = 0;
let symbols = 0;
const insertModule = db.prepare(
'INSERT OR IGNORE INTO modules (name, path, language, service, type) VALUES (?, ?, ?, ?, ?)'
);
const insertSymbol = db.prepare(
'INSERT INTO symbols (module_id, name, kind, language, file_path, line_start, line_end, is_exported) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
// 简化实现:扫描 services/*/src 目录作为模块
const servicesDir = path.join(root, 'services');
// 实际实现用 fast-glob 模式匹配
// 此处为骨架P1 后期补全
return { modules, symbols };
}

View File

@@ -0,0 +1,83 @@
import Database from 'better-sqlite3';
import type { Database as DBType } from 'better-sqlite3';
export function initSchema(db: DBType): void {
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
path TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'ts',
service TEXT,
type TEXT NOT NULL DEFAULT 'module'
);
CREATE TABLE IF NOT EXISTS symbols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_id INTEGER NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL,
language TEXT NOT NULL,
file_path TEXT NOT NULL,
line_start INTEGER,
line_end INTEGER,
is_exported INTEGER DEFAULT 0,
is_public INTEGER DEFAULT 0,
FOREIGN KEY (module_id) REFERENCES modules(id)
);
CREATE TABLE IF NOT EXISTS calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
caller_id INTEGER,
callee_id INTEGER,
callee_name TEXT NOT NULL,
file_path TEXT NOT NULL,
line INTEGER,
FOREIGN KEY (caller_id) REFERENCES symbols(id),
FOREIGN KEY (callee_id) REFERENCES symbols(id)
);
CREATE TABLE IF NOT EXISTS dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_module_id INTEGER NOT NULL,
target_module_id INTEGER NOT NULL,
FOREIGN KEY (source_module_id) REFERENCES modules(id),
FOREIGN KEY (target_module_id) REFERENCES modules(id),
UNIQUE (source_module_id, target_module_id)
);
CREATE TABLE IF NOT EXISTS contracts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
file_path TEXT NOT NULL,
service TEXT,
content TEXT
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
producer_module_id INTEGER,
consumer_count INTEGER DEFAULT 0,
file_path TEXT,
FOREIGN KEY (producer_module_id) REFERENCES modules(id)
);
CREATE INDEX IF NOT EXISTS idx_symbols_module ON symbols(module_id);
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_id);
CREATE INDEX IF NOT EXISTS idx_calls_callee_name ON calls(callee_name);
CREATE INDEX IF NOT EXISTS idx_deps_source ON dependencies(source_module_id);
CREATE INDEX IF NOT EXISTS idx_deps_target ON dependencies(target_module_id);
`);
}
export function createDb(dbPath: string = 'arch.db'): DBType {
const db = new Database(dbPath);
initSchema(db);
return db;
}

View File

@@ -0,0 +1,15 @@
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o api-gateway .
# Runtime stage
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/api-gateway .
EXPOSE 8080
CMD ["./api-gateway"]

View File

@@ -0,0 +1,19 @@
# API Gateway
Go (Gin) 实现的 API 网关,所有外部请求的统一入口。
## 职责
- 路由转发:/api/v1/classes/* → classes 服务
- JWT 鉴权P1 HS256P2 RS256
- 请求 ID 注入:全链路追踪
## 开发
```bash
cd services/api-gateway
go mod tidy
go run main.go
```
## 健康检查
GET /healthz

View File

@@ -0,0 +1,14 @@
module github.com/edu-cloud/api-gateway
go 1.22
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0
go.opentelemetry.io/otel v1.27.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0
go.opentelemetry.io/otel/sdk v1.27.0
go.uber.org/zap v1.27.0
)

View File

@@ -0,0 +1,44 @@
package config
import (
"os"
"strconv"
)
type Config struct {
Port string
JWTSecret string
JWTIssuer string
JWTAudience string
ClassesServiceURL string
OTLPEndpoint string
LogLevel string
}
func Load() *Config {
return &Config{
Port: getEnv("API_GATEWAY_PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "p1-dev-secret-change-in-production"),
JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"),
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
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
}

View File

@@ -0,0 +1,112 @@
package middleware
import (
"net/http"
"strings"
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AuthMiddleware 验证 JWT 并注入用户信息到请求头
// P1 用 HS256P2 改 RS256IAM 签发)
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// 健康检查跳过鉴权
if c.Request.URL.Path == "/healthz" {
c.Next()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "missing authorization header",
},
})
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if tokenStr == authHeader {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "invalid authorization scheme, expected Bearer",
},
})
return
}
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_TOKEN",
"message": "token validation failed",
},
})
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_CLAIMS",
"message": "invalid token claims",
},
})
return
}
// 注入用户信息到下游请求头
if sub, ok := claims["sub"].(string); ok {
c.Request.Header.Set("x-user-id", sub)
}
if roles, ok := claims["roles"].([]interface{}); ok {
roleStrs := make([]string, 0, len(roles))
for _, r := range roles {
if s, ok := r.(string); ok {
roleStrs = append(roleStrs, s)
}
}
c.Request.Header.Set("x-user-roles", strings.Join(roleStrs, ","))
}
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

@@ -0,0 +1,35 @@
package proxy
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
// NewProxy 创建反向代理
func NewProxy(targetURL string) (*httputil.ReverseProxy, error) {
target, err := url.Parse(targetURL)
if err != nil {
return nil, err
}
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// 去除 /api/v1 前缀
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api/v1")
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api")
req.Host = target.Host
}
return proxy, nil
}
// ProxyHandler 返回 Gin 处理函数
func ProxyHandler(proxy *httputil.ReverseProxy) gin.HandlerFunc {
return func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
}
}

View File

@@ -0,0 +1,42 @@
package routing
import (
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/edu-cloud/api-gateway/internal/middleware"
"github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/gin-gonic/gin"
)
// Setup 配置路由
func Setup(cfg *config.Config) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// 中间件
r.Use(middleware.RequestIDMiddleware())
r.Use(gin.Recovery())
// 健康检查(无需鉴权)
r.GET("/healthz", healthz)
// API v1 组(需要鉴权)
api := r.Group("/api/v1")
api.Use(middleware.AuthMiddleware(cfg))
{
// classes 服务路由
classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL)
if err != nil {
panic("failed to create classes proxy: " + err.Error())
}
api.Any("/classes/*path", proxy.ProxyHandler(classesProxy))
}
return r
}
func healthz(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "api-gateway",
})
}

View File

@@ -0,0 +1,47 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/edu-cloud/api-gateway/internal/routing"
)
func main() {
cfg := config.Load()
r := routing.Setup(cfg)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
// 优雅关闭
go func() {
log.Printf("API Gateway listening on :%s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down API Gateway...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("API Gateway exited")
}

View File

@@ -0,0 +1,19 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json nest-cli.json ./
COPY src ./src
RUN pnpm build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -0,0 +1,48 @@
{
"name": "@edu/classes-service",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"drizzle-orm": "^0.31.0",
"mysql2": "^3.11.0",
"pino": "^9.4.0",
"pino-http": "^10.0.0",
"prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-node": "^0.53.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"zod": "^3.23.0",
"uuid": "^10.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.2.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^2.1.0",
"drizzle-kit": "^0.24.0",
"eslint": "^9.10.0",
"pino-pretty": "^11.2.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0",
"tsx": "^4.19.0"
}
}

View File

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

View File

@@ -0,0 +1,83 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Req,
} from '@nestjs/common';
import { ClassesService } from './classes.service.js';
import { createClassSchema, updateClassSchema } from './classes.dto.js';
import type { AuthenticatedRequest } from '../middleware/auth.middleware.js';
import type { Class } from './classes.schema.js';
interface ClassResponse {
id: string;
name: string;
gradeId: string;
headTeacherId: string | null;
description: string | null;
createdAt: number;
updatedAt: number;
}
interface SuccessResponse<T> {
success: true;
data: T;
}
@Controller('classes')
export class ClassesController {
constructor(private readonly service: ClassesService) {}
@Post()
async create(@Body() body: unknown): Promise<SuccessResponse<ClassResponse>> {
const dto = createClassSchema.parse(body);
const result = await this.service.create(dto);
return { success: true as const, data: this.toResponse(result) };
}
@Get()
async list(@Req() req: AuthenticatedRequest): Promise<SuccessResponse<ClassResponse[]>> {
const gradeId = req.query.gradeId as string | undefined;
const result = await this.service.list(gradeId);
return { success: true as const, data: result.map((c) => this.toResponse(c)) };
}
@Get(':id')
async getById(@Param('id') id: string): Promise<SuccessResponse<ClassResponse>> {
const result = await this.service.getById(id);
return { success: true as const, data: this.toResponse(result) };
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() body: unknown,
): Promise<SuccessResponse<ClassResponse>> {
const dto = updateClassSchema.parse(body);
const result = await this.service.update(id, dto);
return { success: true as const, data: this.toResponse(result) };
}
@Delete(':id')
async delete(@Param('id') id: string): Promise<{ success: true }> {
await this.service.delete(id);
return { success: true as const };
}
// 修复 #2: 消除 any使用 Class 类型drizzle timestamp 返回 Date统一转毫秒数epoch ms
private toResponse(c: Class): ClassResponse {
return {
id: c.id,
name: c.name,
gradeId: c.gradeId,
headTeacherId: c.headTeacherId ?? null,
description: c.description ?? null,
createdAt: c.createdAt.getTime(),
updatedAt: c.updatedAt.getTime(),
};
}
}

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createClassSchema = z.object({
name: z.string().min(1).max(100),
gradeId: z.string().uuid(),
headTeacherId: z.string().uuid().optional(),
description: z.string().max(2000).optional(),
});
export const updateClassSchema = z.object({
name: z.string().min(1).max(100).optional(),
headTeacherId: z.string().uuid().optional().nullable(),
description: z.string().max(2000).optional().nullable(),
});
export type CreateClassDto = z.infer<typeof createClassSchema>;
export type UpdateClassDto = z.infer<typeof updateClassSchema>;

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ClassesController } from './classes.controller.js';
import { ClassesService } from './classes.service.js';
import { ClassesRepository } from './classes.repository.js';
@Module({
controllers: [ClassesController],
providers: [
ClassesService,
{
provide: ClassesRepository,
useFactory: () => new ClassesRepository(),
},
],
})
export class ClassesModule {}

View File

@@ -0,0 +1,41 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../config/database.js';
import { classes, type Class, type NewClass } from './classes.schema.js';
export class ClassesRepository {
async create(data: NewClass): Promise<Class> {
const db = getDb();
await db.insert(classes).values(data);
const [result] = await db.select().from(classes).where(eq(classes.id, data.id));
if (!result) {
throw new Error('Failed to read created class');
}
return result;
}
async findById(id: string): Promise<Class | undefined> {
const db = getDb();
const [result] = await db.select().from(classes).where(eq(classes.id, id));
return result;
}
async list(gradeId?: string): Promise<Class[]> {
const db = getDb();
if (gradeId) {
return db.select().from(classes).where(eq(classes.gradeId, gradeId));
}
return db.select().from(classes);
}
async update(id: string, data: Partial<NewClass>): Promise<Class | undefined> {
const db = getDb();
await db.update(classes).set(data).where(eq(classes.id, id));
const [result] = await db.select().from(classes).where(eq(classes.id, id));
return result;
}
async delete(id: string): Promise<void> {
const db = getDb();
await db.delete(classes).where(eq(classes.id, id));
}
}

View File

@@ -0,0 +1,14 @@
import { mysqlTable, varchar, text, timestamp, char } from 'drizzle-orm/mysql-core';
export const classes = mysqlTable('classes', {
id: char('id', { length: 36 }).notNull().primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
gradeId: char('grade_id', { length: 36 }).notNull(),
headTeacherId: char('head_teacher_id', { length: 36 }),
description: text('description'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
});
export type Class = typeof classes.$inferSelect;
export type NewClass = typeof classes.$inferInsert;

View File

@@ -0,0 +1,48 @@
import { v4 as uuidv4 } from 'uuid';
import { ClassesRepository } from './classes.repository.js';
import { ValidationError, NotFoundError } from '../shared/errors/application-error.js';
import type { CreateClassDto, UpdateClassDto } from './classes.dto.js';
import type { Class, NewClass } from './classes.schema.js';
export class ClassesService {
constructor(private readonly repository: ClassesRepository) {}
async create(dto: CreateClassDto): Promise<Class> {
const newClass: NewClass = {
id: uuidv4(),
...dto,
};
return this.repository.create(newClass);
}
async getById(id: string): Promise<Class> {
const result = await this.repository.findById(id);
if (!result) {
throw new NotFoundError('Class', id);
}
return result;
}
async list(gradeId?: string): Promise<Class[]> {
return this.repository.list(gradeId);
}
async update(id: string, dto: UpdateClassDto): Promise<Class> {
if (Object.keys(dto).length === 0) {
throw new ValidationError('No fields to update');
}
const result = await this.repository.update(id, dto);
if (!result) {
throw new NotFoundError('Class', id);
}
return result;
}
async delete(id: string): Promise<void> {
const existing = await this.repository.findById(id);
if (!existing) {
throw new NotFoundError('Class', id);
}
await this.repository.delete(id);
}
}

View File

@@ -0,0 +1,24 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from './env.js';
let pool: mysql.Pool | null = null;
export function getDb() {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return drizzle(pool);
}
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.string().default('3001'),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string(),
JWT_ISSUER: z.string().default('next-edu-cloud'),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
throw new Error('Invalid environment configuration');
}
return result.data;
}
export const env = loadEnv();

View File

@@ -0,0 +1,31 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
import { env } from './config/env.js';
import { logger } from './shared/observability/logger.js';
async function bootstrap(): Promise<void> {
initTracer();
const app = await NestFactory.create(AppModule, {
logger: ['log', 'error', 'warn'],
});
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
await app.listen(env.PORT);
logger.info({ port: env.PORT }, 'Classes service started');
process.on('SIGTERM', async () => {
await app.close();
await shutdownTracer();
});
}
bootstrap().catch((err: unknown) => {
logger.error({ err }, 'Failed to start classes service');
process.exit(1);
});

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
export type ErrorType =
| 'validation'
| 'not_found'
| 'permission_denied'
| 'conflict'
| 'business'
| 'database'
| 'internal';
export interface ErrorDetails {
[key: string]: unknown;
}
export abstract class ApplicationError extends Error {
abstract readonly type: ErrorType;
abstract readonly statusCode: number;
readonly code: string;
readonly details?: ErrorDetails;
// FIX #1: traceId 改为可写,以便 GlobalErrorFilter 注入请求级 traceId
traceId?: string;
constructor(message: string, code: string, details?: ErrorDetails) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.details = details;
}
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
details: this.details,
traceId: this.traceId,
},
};
}
}
export class ValidationError extends ApplicationError {
readonly type = 'validation' as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, 'CLASSES_VALIDATION_ERROR', details);
}
}
export class NotFoundError extends ApplicationError {
readonly type = 'not_found' as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'CLASSES_NOT_FOUND', { resource, id });
}
}
export class PermissionDeniedError extends ApplicationError {
readonly type = 'permission_denied' as const;
readonly statusCode = 403;
constructor(permission: string) {
super(`Permission denied: ${permission}`, 'CLASSES_PERMISSION_DENIED', { permission });
}
}
export class ConflictError extends ApplicationError {
readonly type = 'conflict' as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, 'CLASSES_CONFLICT', details);
}
}
export class BusinessError extends ApplicationError {
readonly type = 'business' as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, 'CLASSES_BUSINESS_ERROR', details);
}
}
export class DatabaseError extends ApplicationError {
readonly type = 'database' as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, 'CLASSES_DATABASE_ERROR', details);
}
}
export class InternalError extends ApplicationError {
readonly type = 'internal' as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, 'CLASSES_INTERNAL_ERROR', details);
}
}

View File

@@ -0,0 +1,77 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { ZodError } from 'zod';
import { ApplicationError } from './application-error.js';
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof ZodError) {
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
statusCode = 400;
body = {
success: false,
error: {
code: 'CLASSES_VALIDATION_ERROR',
message: 'Validation failed',
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: 'HTTP_ERROR',
message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
traceId,
},
};
}
response.status(statusCode).json(body);
}
private extractHttpMessage(res: string | object, exception: HttpException): string {
if (typeof res === 'string') {
return res;
}
if (res && typeof res === 'object' && 'message' in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message;
}
return exception.message;
}
}

View File

@@ -0,0 +1,20 @@
import pino from 'pino';
import { env } from '../../config/env.js';
export const logger = pino({
level: env.LOG_LEVEL,
// 修复pino 默认字段选项为 `base`,而非 `defaultFields`
base: {
service: 'classes',
version: '0.1.0',
},
transport:
env.NODE_ENV === 'development'
? {
target: 'pino-pretty',
options: { colorize: true },
}
: undefined,
});
export type Logger = typeof logger;

View File

@@ -0,0 +1,24 @@
import promClient from 'prom-client';
const registry = new promClient.Registry();
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register
registry.setDefaultLabels({ service: 'classes' });
registry.registerMetric(
new promClient.Counter({
name: 'classes_requests_total',
help: 'Total number of class requests',
labelNames: ['method', 'endpoint', 'status'],
}),
);
registry.registerMetric(
new promClient.Histogram({
name: 'classes_request_duration_seconds',
help: 'Class request duration in seconds',
labelNames: ['method', 'endpoint'],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
export { registry as metricsRegistry };

View File

@@ -0,0 +1,25 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { env } from '../../config/env.js';
let sdk: NodeSDK | null = null;
export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({
serviceName: 'classes',
traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
});
sdk.start();
console.log('Tracer initialized');
}
export async function shutdownTracer(): Promise<void> {
if (sdk) {
await sdk.shutdown();
}
}

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Mock } from 'vitest';
import { ClassesService } from '../../src/classes/classes.service.js';
import type { ClassesRepository } from '../../src/classes/classes.repository.js';
import { ValidationError, NotFoundError } from '../../src/shared/errors/application-error.js';
describe('ClassesService', () => {
let service: ClassesService;
let mockRepository: {
create: Mock;
findById: Mock;
list: Mock;
update: Mock;
delete: Mock;
};
beforeEach(() => {
mockRepository = {
create: vi.fn(),
findById: vi.fn(),
list: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
// 测试场景mock 对象通过 unknown 转换为 ClassesRepository规则允许测试中 as 转换)
service = new ClassesService(mockRepository as unknown as ClassesRepository);
});
describe('create', () => {
it('should create a class successfully', async () => {
const dto = { name: 'Class 1A', gradeId: '550e8400-e29b-41d4-a716-446655440000' };
const mockClass = {
id: 'cls-1',
name: 'Class 1A',
gradeId: '550e8400-e29b-41d4-a716-446655440000',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
mockRepository.create.mockResolvedValue(mockClass);
const result = await service.create(dto);
expect(result.id).toBe('cls-1');
expect(mockRepository.create).toHaveBeenCalledWith(expect.objectContaining(dto));
});
});
describe('update', () => {
it('should throw ValidationError when no fields provided', async () => {
await expect(service.update('cls-1', {})).rejects.toThrow(ValidationError);
});
it('should update class successfully', async () => {
const mockClass = {
id: 'cls-1',
name: 'Updated',
gradeId: 'g1',
createdAt: new Date(),
updatedAt: new Date(),
};
mockRepository.update.mockResolvedValue(mockClass);
const result = await service.update('cls-1', { name: 'Updated' });
expect(result.name).toBe('Updated');
expect(mockRepository.update).toHaveBeenCalledWith('cls-1', { name: 'Updated' });
});
});
describe('getById', () => {
it('should throw NotFoundError when class not found', async () => {
mockRepository.findById.mockResolvedValue(undefined);
await expect(service.getById('not-exist')).rejects.toThrow(NotFoundError);
});
});
describe('list', () => {
it('should list classes without gradeId filter', async () => {
mockRepository.list.mockResolvedValue([]);
await service.list();
expect(mockRepository.list).toHaveBeenCalledWith(undefined);
});
it('should list classes with gradeId filter', async () => {
mockRepository.list.mockResolvedValue([]);
await service.list('grade-1');
expect(mockRepository.list).toHaveBeenCalledWith('grade-1');
});
});
describe('delete', () => {
it('should throw NotFoundError when class not found', async () => {
mockRepository.findById.mockResolvedValue(undefined);
await expect(service.delete('not-exist')).rejects.toThrow(NotFoundError);
});
it('should delete class', async () => {
mockRepository.findById.mockResolvedValue({ id: 'cls-1' });
mockRepository.delete.mockResolvedValue(undefined);
await service.delete('cls-1');
expect(mockRepository.delete).toHaveBeenCalledWith('cls-1');
});
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/unit/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', 'test/', '**/*.d.ts'],
thresholds: {
statements: 60,
branches: 60,
functions: 60,
lines: 60,
},
},
},
});

20
tsconfig.base.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"incremental": true
}
}