feat(p1): complete P1 foundation stage
- 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:
22
.commitlintrc.js
Normal file
22
.commitlintrc.js
Normal 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
21
.editorconfig
Normal 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
32
.env.example
Normal 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
|
||||
|
||||
# Neo4j(P4 启用)
|
||||
NEO4J_PASSWORD=changeme
|
||||
|
||||
# Kafka(P3 启用)
|
||||
KAFKA_BROKERS=localhost:9092
|
||||
38
.github/workflows/ci-go.yml
vendored
Normal file
38
.github/workflows/ci-go.yml
vendored
Normal 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
25
.github/workflows/ci-proto.yml
vendored
Normal 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
33
.github/workflows/ci-py.yml
vendored
Normal 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
43
.github/workflows/ci-ts.yml
vendored
Normal 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
65
.gitignore
vendored
Normal 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
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
||||
npx --no-install commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx --no-install lint-staged
|
||||
11
CHANGELOG.md
Normal file
11
CHANGELOG.md
Normal 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
41
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
492
MIGRATION_GUIDE.md
Normal 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(网关)+ Python(AI/分析)各取所长 |
|
||||
| 数据规模 | 单 MySQL,跨模块联表与读放大 | 读写分离 + CQRS,ClickHouse 承载分析负载 |
|
||||
| 可演进性 | 模块间隐式耦合,重构成本高 | 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 层状态模型**:URL(nuqs)→ Server(TanStack Query)→ Client Business(Zustand)→ Global UI(Zustand)→ Form(react-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 微服务 |
|
||||
| 语言 | TypeScript(NestJS 10)+ Go(Gin 网关)+ Python(FastAPI 分析/AI) |
|
||||
| 前端 | React + Next.js + Module Federation(4 微前端) |
|
||||
| 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 命名 |
|
||||
| Hook(useAuth、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 ≤ 15min,RTO ≤ 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(内部)+ REST(BFF 对外) | 内部高性能,外部兼容性 |
|
||||
| 事件总线 | 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
52
README.md
Normal 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
13
SECURITY.md
Normal 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,生产用 Vault(P6)
|
||||
2
apps/teacher-portal/next-env.d.ts
vendored
Normal file
2
apps/teacher-portal/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
14
apps/teacher-portal/next.config.js
Normal file
14
apps/teacher-portal/next.config.js
Normal 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;
|
||||
26
apps/teacher-portal/package.json
Normal file
26
apps/teacher-portal/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/teacher-portal/postcss.config.js
Normal file
6
apps/teacher-portal/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
43
apps/teacher-portal/src/app/globals.css
Normal file
43
apps/teacher-portal/src/app/globals.css
Normal 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;
|
||||
}
|
||||
24
apps/teacher-portal/src/app/layout.tsx
Normal file
24
apps/teacher-portal/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
apps/teacher-portal/src/app/page.tsx
Normal file
219
apps/teacher-portal/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/teacher-portal/tailwind.config.js
Normal file
26
apps/teacher-portal/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
17
apps/teacher-portal/tsconfig.json
Normal file
17
apps/teacher-portal/tsconfig.json
Normal 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"]
|
||||
}
|
||||
174
docs/architecture/0010_architecture.md
Normal file
174
docs/architecture/0010_architecture.md
Normal 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 Mesh(Istio)配合,可以在架构图中补上。
|
||||
945
docs/architecture/004_architecture_impact_map.md
Normal file
945
docs/architecture/004_architecture_impact_map.md
Normal 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 | CDC,MySQL 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: 生成 JWT(RS256 私钥签发)
|
||||
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)
|
||||
28
docs/architecture/roadmap/README.md
Normal file
28
docs/architecture/roadmap/README.md
Normal 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 原则)
|
||||
203
docs/architecture/roadmap/pending-features.md
Normal file
203
docs/architecture/roadmap/pending-features.md
Normal 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 分钟可复制新服务。
|
||||
|
||||
### 关键功能
|
||||
|
||||
| 模块 | 功能 |
|
||||
|------|------|
|
||||
| 仓库骨架 | 多语言 monorepo(pnpm + go.work + pyproject)+ 大仓文档(LICENSE/CHANGELOG/CONTRIBUTING/SECURITY) |
|
||||
| Docker 基础设施 | 全量 docker-compose.yml(按 profiles 分阶段启用)+ 最小开发集(MySQL+Redis) |
|
||||
| API Gateway(Go) | 路由转发 + 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 | 三语言 workflow(ci-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 内置鉴权;建首个完整 BFF(Teacher)与微前端骨架。
|
||||
|
||||
**退出标准**:教师登录 → 获取 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 BFF(TS/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 relay(Go) | 独立服务 `services/outbox-relay/`,轻量高吞吐 |
|
||||
| Kafka | 启用业务 topic:exam.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 → Kafka(mysql.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 Gateway(Go) | WebSocket 长连接管理 + 消费 Kafka 广播 + Redis PubSub 跨实例同步 |
|
||||
| AI 网关(Python/FastAPI) | LLM Provider 适配(OpenAI/Anthropic)+ Prompt 模板管理 + 流式 SSE + 用量计费 |
|
||||
| Elasticsearch | 题库全文检索(从 MySQL 同步)+ 全局搜索 API |
|
||||
| Notifications 模块 | 多渠道(站内/SMS/邮件/微信),沿用旧项目 dispatcher 模式 |
|
||||
| BFF/前端扩展 | Teacher BFF 加 AI 辅助出题 mutation;teacher-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 + Jaeger(trace)+ Prometheus(metrics)+ Loki(logs) |
|
||||
| 限流/熔断 | 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/1000,Go ≤ 800,Python ≤ 800)
|
||||
- 阶段特有模式实现后回写黄金模板
|
||||
47
docs/architecture/roadmap/tech-debt.md
Normal file
47
docs/architecture/roadmap/tech-debt.md
Normal 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
81
docs/modules/README.md
Normal 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
111
docs/modules/ai/README.md
Normal 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 服务**:AIService(GenerateQuestions、OptimizeExpression、SuggestDifferentiation、GenerateLayeredQuestions)(待 P5 定义 protobuf)
|
||||
- **消费事件**:无(按需 gRPC 调用)
|
||||
- **发布事件**:`edu.ai.generation.completed`(可选,用于审计)
|
||||
- **缓存**:Redis 存储生成结果(相同 prompt 24 小时缓存)
|
||||
|
||||
> 待 P5 交付时补充完整 protobuf 定义与 Prompt 模板规范。
|
||||
|
||||
---
|
||||
|
||||
## 6. 依赖关系
|
||||
|
||||
- **上游**:teacher-bff(AI 辅助功能)
|
||||
- **下游**:LLM API(OpenAI/Claude)、Elasticsearch、Redis、content(gRPC)、data-ana(gRPC)
|
||||
- **跨服务**:gRPC 调用 content 题库与 data-ana 学情,不消费事件
|
||||
|
||||
---
|
||||
|
||||
## 7. 架构约束
|
||||
|
||||
1. LLM 调用必须经 ModelRouter,支持多模型故障切换
|
||||
2. 生成内容必须经质量校验,禁止直接返回未校验结果
|
||||
3. 调用成本必须记录,按用户/学校维度限额
|
||||
4. Prompt 模板必须版本化管理,禁止硬编码
|
||||
5. RAG 检索必须优先于 LLM 生成,减少幻觉
|
||||
|
||||
> 待 P5 交付时补充完整约束清单。
|
||||
|
||||
---
|
||||
|
||||
## 8. 架构决策
|
||||
|
||||
> 待 P5 交付时补充 ADR 记录,预计包括:Python+FastAPI 选型、LangGraph 编排选型、多模型路由策略、RAG 检索策略、成本控制策略。
|
||||
281
docs/modules/api-gateway/README.md
Normal file
281
docs/modules/api-gateway/README.md
Normal 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: 生成 JWT(RS256 私钥签发)
|
||||
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-002:JWT 签名算法从 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-005:gRPC 转发而非 HTTP 代理
|
||||
|
||||
**决策**:网关到下游 BFF/服务使用 gRPC 而非 HTTP 反向代理。
|
||||
|
||||
**理由**:
|
||||
- gRPC 基于 HTTP/2 多路复用,连接复用率高
|
||||
- protobuf 二进制编码比 JSON 节省 30%+ 带宽
|
||||
- 强类型契约,编译期发现接口变更
|
||||
- Connect-RPC 兼容 HTTP/JSON 客户端,前端仍可用 REST
|
||||
|
||||
### ADR-006:熔断器使用滑动窗口而非简单计数
|
||||
|
||||
**决策**:熔断器基于滑动窗口错误率,而非固定窗口计数。
|
||||
|
||||
**理由**:
|
||||
- 滑动窗口对突发错误更敏感,避免边界效应
|
||||
- 半开状态可逐步放量,避免雪崩后全量恢复冲击
|
||||
- Go 生态有成熟实现(sony/gobreaker)
|
||||
352
docs/modules/classes/README.md
Normal file
352
docs/modules/classes/README.md
Normal 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 + Outbox,Query 读 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-003:CQRS 读写分离
|
||||
|
||||
**决策**:写路径操作 MySQL 主库,读路径优先读 Redis 缓存。
|
||||
|
||||
**理由**:
|
||||
- 班级列表读多写少,缓存可承接 90%+ 读流量
|
||||
- 写路径与读路径分离,可独立扩展
|
||||
- Outbox 保证缓存最终一致(事件触发缓存失效)
|
||||
|
||||
**替代方案**:直接读 MySQL - 高并发下主库压力大;双写 - 一致性难保证。
|
||||
|
||||
### ADR-004:Outbox 模式保证事件可靠发布
|
||||
|
||||
**决策**:所有领域事件通过 Outbox 表同事务写入,Relay Worker 异步发布。
|
||||
|
||||
**理由**:
|
||||
- 业务表与 outbox 表同事务,保证"要么都成功要么都失败"
|
||||
- Relay Worker 轮询 outbox 发布到 Kafka,至少一次语义
|
||||
- 避免业务提交成功但事件丢失的问题
|
||||
|
||||
**替代方案**:直接发 Kafka - 事务提交失败但消息已发,导致幻读;CDC - 增加运维复杂度,P4 引入。
|
||||
|
||||
### ADR-005:Zod 运行时校验
|
||||
|
||||
**决策**:所有 RPC 请求参数使用 Zod schema 校验。
|
||||
|
||||
**理由**:
|
||||
- protobuf 提供编译期类型检查,但运行时仍需业务规则校验
|
||||
- Zod schema 可复用为 TypeScript 类型,单一数据源
|
||||
- NestJS Pipe 集成,校验失败返回结构化错误
|
||||
|
||||
### ADR-006:pino 结构化日志
|
||||
|
||||
**决策**:使用 pino 而非 winston 或 NestJS 默认 Logger。
|
||||
|
||||
**理由**:
|
||||
- pino 性能是 winston 的 3-5 倍,异步写入不阻塞事件循环
|
||||
- JSON 结构化输出,便于 Loki 采集与查询
|
||||
- 支持 traceId/userId 上下文注入,与 OpenTelemetry 对齐
|
||||
|
||||
### ADR-007:P3 阶段合并入 core-edu
|
||||
|
||||
**决策**:classes 服务在 P3 阶段合并入 core-edu 服务,作为 core-edu 的一个模块。
|
||||
|
||||
**理由**:
|
||||
- classes 域与课程、作业、考试域强耦合,跨服务调用频繁
|
||||
- P1 独立服务用于验证黄金模板,P3 合并以减少网络开销
|
||||
- 合并后 protobuf 契约保持不变,仅内部代码迁移
|
||||
- 合并后 outbox 共享 core-edu 事务,简化一致性保证
|
||||
104
docs/modules/content/README.md
Normal file
104
docs/modules/content/README.md
Normal 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、ai(gRPC 查询题库与知识图谱)
|
||||
- **下游**: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 引入决策。
|
||||
105
docs/modules/core-edu/README.md
Normal file
105
docs/modules/core-edu/README.md
Normal 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 服务**:ClassService(P1 迁移)、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 选型、考试并发策略。
|
||||
104
docs/modules/data-ana/README.md
Normal file
104
docs/modules/data-ana/README.md
Normal 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、ai(gRPC 查询学情)
|
||||
- **下游**: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
102
docs/modules/iam/README.md
Normal 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 服务**:UserService(Login/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-gateway(JWKS 拉取)
|
||||
- **下游**: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
105
docs/modules/msg/README.md
Normal 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-gateway(gRPC 推送调用)
|
||||
- **下游**:MySQL、Redis、Kafka、外部邮件/短信网关
|
||||
- **跨服务**:消费所有业务事件触发通知;与 push-gateway 协作实时推送
|
||||
|
||||
---
|
||||
|
||||
## 7. 架构约束
|
||||
|
||||
1. 通知投递必须至少一次语义,失败重试 3 次
|
||||
2. 通知模板必须使用 Handlebars 渲染,禁止字符串拼接
|
||||
3. 未读计数必须 Redis 实时维护,异步同步 MySQL
|
||||
4. 广播通知必须分批投递,避免瞬时流量冲击
|
||||
|
||||
> 待 P5 交付时补充完整约束清单。
|
||||
|
||||
---
|
||||
|
||||
## 8. 架构决策
|
||||
|
||||
> 待 P5 交付时补充 ADR 记录,预计包括:多渠道分发策略、模板引擎选型、推送可靠性保证、广播限流策略。
|
||||
114
docs/modules/push-gateway/README.md
Normal file
114
docs/modules/push-gateway/README.md
Normal 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 服务**:PushService(Push、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-portal(WebSocket 连接)
|
||||
- **共享包**: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 跨节点方案、连接数上限策略、心跳间隔选型。
|
||||
979
docs/standards/coding-standards.md
Normal file
979
docs/standards/coding-standards.md
Normal 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. [TypeScript(NestJS)规范](#二typescriptnestjs规范)
|
||||
3. [Go(Gin)规范](#三gogin规范)
|
||||
4. [Python(FastAPI)规范](#四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 或事件
|
||||
|
||||
---
|
||||
|
||||
## 二、TypeScript(NestJS)规范
|
||||
|
||||
### 2.1 适用范围
|
||||
|
||||
- 业务微服务(NestJS 10):identity、org、teaching、content、comm、insight
|
||||
- 基础设施服务(NestJS 10):auth、notification
|
||||
- BFF 层(NestJS 10):Admin BFF、Teacher BFF、Student/Parent BFF
|
||||
- 微前端(Next.js + React):4 个 shell 应用
|
||||
- 共享包(TS):packages/*
|
||||
|
||||
### 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 |
|
||||
|
||||
---
|
||||
|
||||
## 三、Go(Gin)规范
|
||||
|
||||
### 3.1 适用范围
|
||||
|
||||
- API Gateway(gateway/)
|
||||
- 未来可能的 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` 输出业务日志
|
||||
|
||||
---
|
||||
|
||||
## 四、Python(FastAPI)规范
|
||||
|
||||
### 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 测试覆盖核心业务路径
|
||||
- 集成测试使用 Testcontainers(DB/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; // 用户唯一 ID(UUID)
|
||||
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)
|
||||
- UI:sans-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 数据安全
|
||||
|
||||
- 密码使用 bcrypt(cost ≥ 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/` | 微前端共享 |
|
||||
918
docs/standards/git-workflow.md
Normal file
918
docs/standards/git-workflow.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# Edu Git 工作流规范
|
||||
|
||||
> 版本:1.0
|
||||
> 日期:2026-07-07
|
||||
> 状态:基线发布
|
||||
> 适用范围:Edu 多语言 monorepo(pnpm 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 Change(protobuf 不兼容变更、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 工作流差异
|
||||
|
||||
| 维度 | CICD(Next.js 单应用) | Edu(微服务 monorepo) |
|
||||
|------|----------------------|----------------------|
|
||||
| 仓库结构 | 单一 Next.js 应用 | 多语言 monorepo(pnpm + 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 |
|
||||
1520
docs/standards/ui-design-system.md
Normal file
1520
docs/standards/ui-design-system.md
Normal file
File diff suppressed because it is too large
Load Diff
4244
docs/superpowers/plans/2026-07-07-p1-foundation-implementation.md
Normal file
4244
docs/superpowers/plans/2026-07-07-p1-foundation-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
295
docs/troubleshooting/known-issues.md
Normal file
295
docs/troubleshooting/known-issues.md
Normal 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 workspace(TS)+ go.work(Go)+ pyproject.toml/uv workspace(Python)三套并存 |
|
||||
| 根 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+prettier,commit-msg 校验 Conventional Commits |
|
||||
| .editorconfig 多语言缩进 | Go 用 tab,Python 用 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+Redis;P3 加 Kafka+Zookeeper;P4 加 Debezium+CH+Neo4j;P5 加 ES;P6 加 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` 统一生成 |
|
||||
| 契约先行硬约束 | 任何服务间通信必须先改 proto,CI 检测 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/owned),repository 下推 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 改 RS256(IAM 签发,公钥校验) |
|
||||
| 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 初始化(不引入完整后端) |
|
||||
| 三支柱 | Logs(pino/winston/zap)+ Metrics(prom-client)+ Traces(OTel SDK) |
|
||||
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
|
||||
| P6 完整后端 | Loki(日志)+ Grafana(仪表盘)+ Jaeger(trace)+ Prometheus(metrics) |
|
||||
| Prometheus 指标 | `http_request_duration_seconds`(Histogram)+ `http_requests_total`(Counter) |
|
||||
| 采样策略 | P1 全量 trace,P6 引入采样率降低开销 |
|
||||
| 日志参数顺序 | `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 wrapper(tiptap / @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-gateway(Go)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| P1 鉴权 | Gateway 内置 HS256 JWT,`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 |
|
||||
| P2 鉴权升级 | 改 RS256,IAM 私钥签发,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 classes(TS/NestJS,P1 黄金模板)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| 黄金模板定位 | 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)+ E2E(Playwright) |
|
||||
| 覆盖率门槛 | 领域逻辑 ≥ 80%,Handler ≥ 60%,整体 ≥ 60% |
|
||||
| Drizzle schema | `mysqlTable` + `varchar`/`timestamp` + `index` |
|
||||
| ID 生成 | `@paralleldrive/cuid2` 的 `createId()` |
|
||||
| 响应转换 | repository 返回 Date,service 转换为 `createdAt: number`(时间戳) |
|
||||
| 阶段特有模式回写 | Outbox(P3)/ CDC(P4)/ 长连接(P5)实现后回写黄金模板 README |
|
||||
|
||||
### 2.3 iam(TS/NestJS,P2)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 认证 | 登录/登出/JWT/2FA,RS256 非对称签名 |
|
||||
| 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 7day,refresh 用 Redis 黑名单失效 |
|
||||
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 |
|
||||
| schema 表 | users / roles / permissions / role_permissions / role_viewports / parent_student_relations / class_subject_teachers |
|
||||
|
||||
### 2.4 core-edu(TS/NestJS,P3)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 |
|
||||
| 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 content(TS/NestJS,P4)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 知识图谱 | Neo4j 查询前置依赖图(秒级返回) |
|
||||
| 题库 CRUD | P4 仅 CRUD,P5 引入 ES 实现检索,避免 MySQL FULLTEXT → ES 迁移成本 |
|
||||
| 双写避免 | Neo4j/ES 不直接双写,由消费 Kafka 事件同步,天然最终一致 |
|
||||
| Neo4j 写入 | Content 服务写 MySQL 同时发事件,独立 worker 消费事件同步 Neo4j |
|
||||
|
||||
### 2.6 data-ana(Python/FastAPI,P4)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 学情诊断 | ClickHouse 宽表查询,5s 内返回 |
|
||||
| CDC 链路 | Debezium 监听 MySQL binlog → Kafka(`mysql.cdc.*`)→ DataAna 消费写 ClickHouse |
|
||||
| CDC 延迟监控 | Debezium 暴露 lag metrics,超阈值告警 |
|
||||
| 双轨读策略 | 实时查 MySQL 主库(刚提交的成绩),聚合查 CH 宽表(延迟 1-5s 可接受) |
|
||||
| 幂等消费 | 所有事件消费者必须幂等(基于 event_id 去重) |
|
||||
|
||||
### 2.7 messaging(TS/NestJS,P5)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 |
|
||||
| 通知批量化 | `createNotifications(items)` 单次 INSERT,沿用旧项目 dispatcher 模式 |
|
||||
| 多渠道 | 站内/SMS/邮件/微信,in_app 批量 + 其他渠道并行 |
|
||||
| fan-out 分页 | `getAllUserIds(limit=1000, offset)` 分页遍历 |
|
||||
| 撤回不乐观更新 | 需服务端返回判断 2 分钟窗口 |
|
||||
|
||||
### 2.8 push-gateway(Go,P5)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需发 Kafka 消息 |
|
||||
| 跨实例同步 | Redis PubSub |
|
||||
| 离线消息 | 仅推在线用户,离线消息存 MySQL,上线时拉取 |
|
||||
|
||||
### 2.9 ai-gateway(Python/FastAPI,P5)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| LLM Provider 适配 | OpenAI/Anthropic,langchain/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 用正则提取(函数/类型/import),P2 起替换为 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 token,P2 起由 IAM 签发 RS256 |
|
||||
| P2 Module Federation | next.config.js 配置,按场景域分 4 个稳定 portal |
|
||||
|
||||
---
|
||||
|
||||
## 三、工作经验日志
|
||||
|
||||
> 按时间倒序,50 条上限。AI 发现更好方案时可更新本节。
|
||||
|
||||
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
|
||||
| ---------- | ----- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-07-07 | 全天 | 全局 | 文档体系初始化:从旧项目(e:\Desktop\CICD,Next.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
7
go.work
Normal file
@@ -0,0 +1,7 @@
|
||||
go 1.22
|
||||
|
||||
use (
|
||||
./services/api-gateway
|
||||
./services/push-gateway
|
||||
./packages/shared-go
|
||||
)
|
||||
40
infra/docker-compose.minimal.yml
Normal file
40
infra/docker-compose.minimal.yml
Normal 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
154
infra/docker-compose.yml
Normal 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:
|
||||
17
infra/init-sql/01-init.sql
Normal file
17
infra/init-sql/01-init.sql
Normal 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 服务)
|
||||
-- 各服务独立数据库或独立 schema,P1 先用同库不同表前缀
|
||||
12
infra/prometheus.yml
Normal file
12
infra/prometheus.yml
Normal 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
7
lint-staged.config.js
Normal 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
30
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/shared-proto/buf.gen.yaml
Normal file
8
packages/shared-proto/buf.gen.yaml
Normal 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
|
||||
9
packages/shared-proto/buf.yaml
Normal file
9
packages/shared-proto/buf.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
10
packages/shared-proto/package.json
Normal file
10
packages/shared-proto/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
58
packages/shared-proto/proto/classes.proto
Normal file
58
packages/shared-proto/proto/classes.proto
Normal 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
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- 'services/*'
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
- 'scripts/*'
|
||||
1
project_rules.md
Normal file
1
project_rules.md
Normal file
@@ -0,0 +1 @@
|
||||
4. 若代ç <C3A7>结构å<E2809E>˜åŒ?â†?`pnpm run arch:scan` 确认 arch.db 已更æ–?
|
||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal 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"]
|
||||
22
scripts/arch-scan/package.json
Normal file
22
scripts/arch-scan/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
93
scripts/arch-scan/query.ts
Normal file
93
scripts/arch-scan/query.ts
Normal 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();
|
||||
33
scripts/arch-scan/scanner.ts
Normal file
33
scripts/arch-scan/scanner.ts
Normal 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();
|
||||
12
scripts/arch-scan/scanners/go-scanner.ts
Normal file
12
scripts/arch-scan/scanners/go-scanner.ts
Normal 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 };
|
||||
}
|
||||
10
scripts/arch-scan/scanners/proto-scanner.ts
Normal file
10
scripts/arch-scan/scanners/proto-scanner.ts
Normal 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 };
|
||||
}
|
||||
11
scripts/arch-scan/scanners/py-scanner.ts
Normal file
11
scripts/arch-scan/scanners/py-scanner.ts
Normal 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 };
|
||||
}
|
||||
38
scripts/arch-scan/scanners/ts-scanner.ts
Normal file
38
scripts/arch-scan/scanners/ts-scanner.ts
Normal 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 };
|
||||
}
|
||||
83
scripts/arch-scan/schema.ts
Normal file
83
scripts/arch-scan/schema.ts
Normal 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;
|
||||
}
|
||||
15
services/api-gateway/Dockerfile
Normal file
15
services/api-gateway/Dockerfile
Normal 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"]
|
||||
19
services/api-gateway/README.md
Normal file
19
services/api-gateway/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# API Gateway
|
||||
|
||||
Go (Gin) 实现的 API 网关,所有外部请求的统一入口。
|
||||
|
||||
## 职责
|
||||
- 路由转发:/api/v1/classes/* → classes 服务
|
||||
- JWT 鉴权:P1 HS256,P2 RS256
|
||||
- 请求 ID 注入:全链路追踪
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
cd services/api-gateway
|
||||
go mod tidy
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## 健康检查
|
||||
GET /healthz
|
||||
14
services/api-gateway/go.mod
Normal file
14
services/api-gateway/go.mod
Normal 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
|
||||
)
|
||||
44
services/api-gateway/internal/config/config.go
Normal file
44
services/api-gateway/internal/config/config.go
Normal 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
|
||||
}
|
||||
112
services/api-gateway/internal/middleware/auth.go
Normal file
112
services/api-gateway/internal/middleware/auth.go
Normal 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 用 HS256,P2 改 RS256(IAM 签发)
|
||||
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()
|
||||
}
|
||||
35
services/api-gateway/internal/proxy/proxy.go
Normal file
35
services/api-gateway/internal/proxy/proxy.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
services/api-gateway/internal/routing/routing.go
Normal file
42
services/api-gateway/internal/routing/routing.go
Normal 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",
|
||||
})
|
||||
}
|
||||
47
services/api-gateway/main.go
Normal file
47
services/api-gateway/main.go
Normal 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")
|
||||
}
|
||||
19
services/classes/Dockerfile
Normal file
19
services/classes/Dockerfile
Normal 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"]
|
||||
8
services/classes/nest-cli.json
Normal file
8
services/classes/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
48
services/classes/package.json
Normal file
48
services/classes/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
services/classes/src/app.module.ts
Normal file
7
services/classes/src/app.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClassesModule } from './classes/classes.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [ClassesModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
83
services/classes/src/classes/classes.controller.ts
Normal file
83
services/classes/src/classes/classes.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
services/classes/src/classes/classes.dto.ts
Normal file
17
services/classes/src/classes/classes.dto.ts
Normal 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>;
|
||||
16
services/classes/src/classes/classes.module.ts
Normal file
16
services/classes/src/classes/classes.module.ts
Normal 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 {}
|
||||
41
services/classes/src/classes/classes.repository.ts
Normal file
41
services/classes/src/classes/classes.repository.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
14
services/classes/src/classes/classes.schema.ts
Normal file
14
services/classes/src/classes/classes.schema.ts
Normal 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;
|
||||
48
services/classes/src/classes/classes.service.ts
Normal file
48
services/classes/src/classes/classes.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
services/classes/src/config/database.ts
Normal file
24
services/classes/src/config/database.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
services/classes/src/config/env.ts
Normal file
25
services/classes/src/config/env.ts
Normal 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();
|
||||
31
services/classes/src/main.ts
Normal file
31
services/classes/src/main.ts
Normal 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);
|
||||
});
|
||||
24
services/classes/src/middleware/auth.middleware.ts
Normal file
24
services/classes/src/middleware/auth.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
50
services/classes/src/middleware/permission.guard.ts
Normal file
50
services/classes/src/middleware/permission.guard.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
96
services/classes/src/shared/errors/application-error.ts
Normal file
96
services/classes/src/shared/errors/application-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
77
services/classes/src/shared/errors/global-error.filter.ts
Normal file
77
services/classes/src/shared/errors/global-error.filter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
20
services/classes/src/shared/observability/logger.ts
Normal file
20
services/classes/src/shared/observability/logger.ts
Normal 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;
|
||||
24
services/classes/src/shared/observability/metrics.ts
Normal file
24
services/classes/src/shared/observability/metrics.ts
Normal 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 };
|
||||
25
services/classes/src/shared/observability/tracer.ts
Normal file
25
services/classes/src/shared/observability/tracer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
104
services/classes/test/unit/classes.service.test.ts
Normal file
104
services/classes/test/unit/classes.service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
15
services/classes/tsconfig.json
Normal file
15
services/classes/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
services/classes/vitest.config.ts
Normal file
20
services/classes/vitest.config.ts
Normal 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
20
tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user