- 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
112 KiB
P1 地基阶段实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 搭建 next-edu-cloud 新仓库地基:多语言 monorepo + Docker 基础设施 + API Gateway + classes 黄金模板服务 + 文档体系 + CI/CD,端到端跑通 classes 域 CRUD。
Architecture: pnpm workspace 多语言 monorepo(TS+Go+Python),Go API Gateway 路由转发,TS/NestJS classes 服务作为黄金模板(完整横切关注点),protobuf 契约先行,Docker Compose 本地基础设施。
Tech Stack: pnpm + Node.js 20+ + Go 1.22+ + Python 3.12+ + NestJS + Drizzle ORM + Gin + buf + protobuf + Docker Compose + MySQL 8 + Redis 7 + OpenTelemetry + vitest + Playwright
新仓库本地路径: e:\Desktop\Edu
远程仓库: https://git.eazygame.cn/xiner/Edu.git
Spec 来源: docs/superpowers/specs/2026-07-07-enterprise-microservices-architecture-upgrade-design.md
文件结构总览
P1 完成后将创建以下文件(按职责分组):
e:\Desktop\Edu\ # 新仓库根
├─ .github/workflows/ # CI/CD(Task 12)
│ ├─ ci-go.yml
│ ├─ ci-ts.yml
│ └─ ci-py.yml
├─ .editorconfig
├─ .gitignore
├─ .commitlintrc.js
├─ .husky/commit-msg # Task 2
├─ docs/ # 文档体系(Task 4 + 13)
│ ├─ architecture/0010_architecture.md # 从旧仓库迁移
│ ├─ architecture/004_architecture_impact_map.md
│ ├─ modules/api-gateway/README.md
│ ├─ modules/classes/README.md
│ ├─ standards/coding-standards.md
│ ├─ standards/git-workflow.md
│ ├─ troubleshooting/known-issues.md
│ └─ superpowers/specs/2026-07-07-...md # 从旧仓库复制
├─ packages/ # 共享包(Task 6)
│ └─ shared-proto/
│ ├─ proto/classes.proto
│ ├─ buf.yaml
│ ├─ buf.gen.yaml
│ └─ package.json
├─ services/ # 微服务
│ ├─ api-gateway/ # Task 7
│ │ ├─ main.go
│ │ ├─ go.mod
│ │ ├─ internal/{config,middleware,proxy}/
│ │ └─ Dockerfile
│ └─ classes/ # Task 8-10
│ ├─ src/{main.ts,app.module.ts,config,middleware,classes,shared,generated}/
│ ├─ test/{unit,integration,contract,e2e}/
│ ├─ Dockerfile
│ ├─ nest-cli.json
│ ├─ tsconfig.json
│ ├─ vitest.config.ts
│ └─ package.json
├─ apps/teacher-portal/ # Task 11
│ ├─ src/app/page.tsx
│ ├─ package.json
│ └─ next.config.js
├─ infra/ # Task 3
│ ├─ docker-compose.yml
│ ├─ docker-compose.minimal.yml
│ └─ init-sql/01-init.sql
├─ scripts/arch-scan/ # Task 5
│ ├─ scanner.ts
│ ├─ scanners/{ts,go,py}-scanner.ts
│ ├─ query.ts
│ └─ package.json
├─ arch.db # Task 5 创建
├─ package.json # Task 2 根 workspace
├─ pnpm-workspace.yaml # Task 2
├─ go.work # Task 2
├─ pyproject.toml # Task 2
├─ project_rules.md # Task 4
├─ LICENSE
├─ CHANGELOG.md
├─ CONTRIBUTING.md
├─ SECURITY.md
└─ .env.example
Task 1: 新仓库初始化与 Git 设置
目标: 在 e:\Desktop\Edu 创建新 git 仓库,配置远程,建立基础文件。
Files:
-
Create:
e:\Desktop\Edu\.gitignore -
Create:
e:\Desktop\Edu\.editorconfig -
Create:
e:\Desktop\Edu\LICENSE -
Create:
e:\Desktop\Edu\CHANGELOG.md -
Create:
e:\Desktop\Edu\CONTRIBUTING.md -
Create:
e:\Desktop\Edu\SECURITY.md -
Create:
e:\Desktop\Edu\.env.example -
Create:
e:\Desktop\Edu\README.md -
Step 1: 创建目录并初始化 git
Run:
mkdir -p e:\Desktop\Edu
cd e:\Desktop\Edu
git init -b main
Expected: Initialized empty Git repository in e:/Desktop/Edu/.git/
- Step 2: 创建 .gitignore
Create e:\Desktop\Edu\.gitignore:
# 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/
- Step 3: 创建 .editorconfig
Create e:\Desktop\Edu\.editorconfig:
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
- Step 4: 创建 LICENSE(MIT)
Create e:\Desktop\Edu\LICENSE:
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.
- Step 5: 创建 CHANGELOG.md
Create e:\Desktop\Edu\CHANGELOG.md:
# 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 黄金模板服务
- Step 6: 创建 CONTRIBUTING.md
Create e:\Desktop\Edu\CONTRIBUTING.md:
# 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:
():
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. 运行 `npm 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`
- Step 7: 创建 SECURITY.md
Create e:\Desktop\Edu\SECURITY.md:
# 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)
- Step 8: 创建 .env.example
Create e:\Desktop\Edu\.env.example:
# 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
- Step 9: 创建 README.md
Create e:\Desktop\Edu\README.md:
# 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 扫描
npm run arch:scan
# 5. 启动服务
pnpm dev
文档
开发阶段
- 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 + 生产硬化
- [ ] **Step 10: 配置远程并首次提交**
Run:
```bash
cd e:\Desktop\Edu
git add -A
git commit -m "chore: initialize next-edu-cloud repository"
git remote add origin https://git.eazygame.cn/xiner/Edu.git
git push -u origin main
# 若首次推送失败,再次推送:
git push -u origin main
Expected: 推送成功,远程仓库可见首次提交。
Task 2: 多语言 monorepo 骨架
目标: 建立 pnpm workspace + go.work + pyproject.toml 多语言 monorepo 骨架。
Files:
-
Create:
e:\Desktop\Edu\package.json -
Create:
e:\Desktop\Edu\pnpm-workspace.yaml -
Create:
e:\Desktop\Edu\go.work -
Create:
e:\Desktop\Edu\pyproject.toml -
Create:
e:\Desktop\Edu\.commitlintrc.js -
Create:
e:\Desktop\Edu\.husky\commit-msg -
Step 1: 创建 pnpm workspace 根 package.json
Create e:\Desktop\Edu\package.json:
{
"name": "next-edu-cloud",
"version": "0.1.0",
"private": true,
"description": "Enterprise microservices education cloud platform",
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
},
"scripts": {
"dev": "pnpm -r --parallel run dev",
"build": "pnpm -r run build",
"lint": "pnpm -r run lint",
"test": "pnpm -r run test",
"arch:scan": "tsx scripts/arch-scan/scanner.ts",
"arch:query": "tsx scripts/arch-scan/query.ts",
"proto:gen": "pnpm --filter @next-edu/shared-proto run gen",
"prepare": "husky"
},
"devDependencies": {
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2",
"husky": "^9.1.4",
"tsx": "^4.16.0",
"typescript": "^5.5.0"
}
}
- Step 2: 创建 pnpm-workspace.yaml
Create e:\Desktop\Edu\pnpm-workspace.yaml:
packages:
- 'packages/*'
- 'services/classes'
- 'bff/*'
- 'apps/*'
- 'scripts/*'
- Step 3: 安装根依赖
Run:
cd e:\Desktop\Edu
pnpm install
Expected: pnpm 安装根 devDependencies 成功。
- Step 4: 创建 go.work
Create e:\Desktop\Edu\go.work:
go 1.22
use (
./services/api-gateway
)
- Step 5: 创建 pyproject.toml
Create e:\Desktop\Edu\pyproject.toml:
[tool.uv.workspace]
members = ["services/data-ana", "services/ai-gateway"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
- Step 6: 创建 .commitlintrc.js
Create e:\Desktop\Edu\.commitlintrc.js:
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'chore', 'docs', 'style', 'refactor', 'test', 'perf', 'ci', 'build'],
],
'subject-case': [0],
'header-max-length': [2, 'always', 100],
},
};
- Step 7: 配置 husky commit-msg hook
Run:
cd e:\Desktop\Edu
pnpm exec husky init
echo "pnpm exec commitlint --edit \$1" > .husky/commit-msg
Expected: .husky/commit-msg 文件创建。
- Step 8: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "chore: setup pnpm + go + python monorepo workspace"
Task 3: Docker Compose 基础设施
目标: 创建全量与最小 Docker Compose 配置,P1 仅启动 MySQL + Redis。
Files:
-
Create:
e:\Desktop\Edu\infra\docker-compose.yml -
Create:
e:\Desktop\Edu\infra\docker-compose.minimal.yml -
Create:
e:\Desktop\Edu\infra\init-sql\01-init.sql -
Step 1: 创建全量 docker-compose.yml
Create e:\Desktop\Edu\infra\docker-compose.yml:
name: next-edu-cloud
services:
# === P1 启用 ===
mysql:
image: mysql:8.0
container_name: edu-mysql
restart: unless-stopped
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d:ro
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
mem_limit: 512m
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"
command: redis-server --requirepass ${REDIS_PASSWORD:-}
mem_limit: 128m
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# === P3 启用 ===
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
container_name: edu-zookeeper
profiles: ["full", "kafka"]
environment:
ZOOKEEPER_CLIENT_PORT: 2181
mem_limit: 256m
kafka:
image: confluentinc/cp-kafka:7.6.0
container_name: edu-kafka
profiles: ["full", "kafka"]
depends_on: [zookeeper]
ports:
- "9092:9092"
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
mem_limit: 512m
# === P4 启用 ===
debezium:
image: debezium/connect:2.6
container_name: edu-debezium
profiles: ["full", "cdc"]
depends_on: [kafka]
ports:
- "8083:8083"
environment:
BOOTSTRAP_SERVERS: kafka:9092
GROUP_ID: 1
CONFIG_STORAGE_TOPIC: debezium_configs
OFFSET_STORAGE_TOPIC: debezium_offsets
STATUS_STORAGE_TOPIC: debezium_statuses
mem_limit: 512m
clickhouse:
image: clickhouse/clickhouse-server:24.3
container_name: edu-clickhouse
profiles: ["full", "analytics"]
ports:
- "8123:8123"
- "9000:9000"
volumes:
- clickhouse-data:/var/lib/clickhouse
mem_limit: 1g
neo4j:
image: neo4j:5.20
container_name: edu-neo4j
profiles: ["full", "graph"]
ports:
- "7474:7474"
- "7687:7687"
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD}
volumes:
- neo4j-data:/data
mem_limit: 512m
# === P5 启用 ===
elasticsearch:
image: elasticsearch:8.11.0
container_name: edu-elasticsearch
profiles: ["full", "search"]
ports:
- "9200:9200"
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: "-Xms256m -Xmx256m"
mem_limit: 512m
# === P6 启用 ===
consul:
image: hashicorp/consul:1.18
container_name: edu-consul
profiles: ["full", "config"]
ports:
- "8500:8500"
mem_limit: 128m
# === 可观测性(P6 启用完整栈)===
jaeger:
image: jaegertracing/all-in-one:1.56
container_name: edu-jaeger
profiles: ["full", "observability"]
ports:
- "16686:16686"
- "4318:4318"
environment:
COLLECTOR_OTLP_ENABLED: true
mem_limit: 256m
prometheus:
image: prom/prometheus:v2.53.0
container_name: edu-prometheus
profiles: ["full", "observability"]
ports:
- "9090:9090"
mem_limit: 256m
grafana:
image: grafana/grafana:11.1.0
container_name: edu-grafana
profiles: ["full", "observability"]
ports:
- "3001:3001"
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
mem_limit: 256m
loki:
image: grafana/loki:3.1.0
container_name: edu-loki
profiles: ["full", "observability"]
ports:
- "3100:3100"
mem_limit: 256m
volumes:
mysql-data:
clickhouse-data:
neo4j-data:
- Step 2: 创建最小开发集
Create e:\Desktop\Edu\infra\docker-compose.minimal.yml:
name: next-edu-cloud-min
services:
mysql:
image: mysql:8.0
container_name: edu-mysql
restart: unless-stopped
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d:ro
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
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"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes:
mysql-data:
- Step 3: 创建 MySQL 初始化脚本
Create e:\Desktop\Edu\infra\init-sql\01-init.sql:
-- P1 仅创建 classes 表与权限测试用户表
-- 后续阶段的表在对应服务迁移中创建
CREATE TABLE IF NOT EXISTS classes (
id CHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
grade_id CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_grade_id (grade_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- P1 临时测试用户表(P2 由 IAM 服务接管)
CREATE TABLE IF NOT EXISTS users (
id CHAR(36) NOT NULL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
role VARCHAR(20) NOT NULL DEFAULT 'teacher',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
- Step 4: 验证最小集启动
Run:
cd e:\Desktop\Edu
cp .env.example .env
docker compose -f infra/docker-compose.minimal.yml up -d
docker compose -f infra/docker-compose.minimal.yml ps
Expected: mysql 与 redis 状态为 healthy。
- Step 5: 提交
Run:
cd e:\Desktop\Edu
docker compose -f infra/docker-compose.minimal.yml down
git add -A
git commit -m "feat(infra): add docker-compose full and minimal configurations"
Task 4: 文档体系骨架移植
目标: 从旧仓库迁移 0010 蓝图,创建 004 多服务版骨架、coding-standards、known-issues、project_rules。
Files:
-
Create:
e:\Desktop\Edu\docs\architecture\0010_architecture.md(从旧仓库复制) -
Create:
e:\Desktop\Edu\docs\architecture\004_architecture_impact_map.md -
Create:
e:\Desktop\Edu\docs\standards\coding-standards.md -
Create:
e:\Desktop\Edu\docs\standards\git-workflow.md -
Create:
e:\Desktop\Edu\docs\troubleshooting\known-issues.md -
Create:
e:\Desktop\Edu\project_rules.md -
Step 1: 复制 0010 蓝图
Run:
cp "e:\Desktop\CICD\docs\architecture\0010_architecture.md" "e:\Desktop\Edu\docs\architecture\0010_architecture.md"
- Step 2: 创建 004 多服务版骨架
Create e:\Desktop\Edu\docs\architecture\004_architecture_impact_map.md:
# 架构设计意图(多服务版)
> 本文档是 next-edu-cloud 的架构设计意图唯一源(人类可读)。代码结构现状查询 arch.db。
## 1. 项目概览
- **系统边界**: K12 教育云平台,多端(教师/学生/家长/管理),百万级 DAU
- **规模指标**: 8 服务 / 3 BFF / 4 微前端 / 5 存储 / 1 事件总线
- **实施策略**: 方案 A(基础设施优先)+ 黄金模板服务驱动
## 2. 技术栈选型
| 语言 | 服务 | 框架 |
|------|------|------|
| Go | API Gateway / Push Gateway | Gin |
| TypeScript | IAM / CoreEdu / Content / Msg / BFF / 微前端 | NestJS / Next.js / GraphQL Yoga |
| Python | DataAna / AI 网关 | FastAPI |
## 3. 分层架构
Client(微前端) → Edge(Gateway + BFF) → Service(领域微服务) → Bus(Kafka 事件总线) → Storage(MySQL/CH/Neo4j/ES/Redis) → Infra(CDC/Temporal/Consul/Istio)
## 4. 服务依赖全景图
```mermaid
graph TB
GW[API Gateway] --> IAM & CoreEdu & Content & Msg
BFF_T[Teacher BFF] --> IAM & CoreEdu & Content & DataAna & AI
BFF_S[Student BFF] --> CoreEdu & Content & DataAna
CoreEdu -.->|Outbox| Kafka
Kafka --> DataAna & Push & Content
MySQL -->|CDC| Kafka
5. 认证与权限架构
- 认证: IAM 签发 RS256 JWT,Gateway 公钥校验
- 权限: RBAC + 6 级 DataScope
- 视口: 4 层模型(导航/路由/组件/数据)
6. 数据访问架构
- CQRS: 写入 MySQL 主库,聚合查询 ClickHouse 宽表
- Outbox: 业务事务同写 outbox_events,relay worker 投递 Kafka
- CDC: Debezium 监听 binlog 同步 ClickHouse
- 双轨读: 实时查主库,聚合查宽表
7. 事件驱动架构
| Topic | 生产者 | 消费者 |
|---|---|---|
| exam.published | CoreEdu | Msg / DataAna |
| homework.graded | CoreEdu | DataAna / Msg |
| grade.recorded | CoreEdu | DataAna |
| mysql.cdc.* | Debezium | DataAna |
8. 跨服务协作
- 同步: gRPC + protobuf 契约
- 异步: Outbox + Kafka 事件
- 编排: Temporal 工作流(P3 试点)
9. 核心业务流程
(P3 起补充考试全生命周期 sequence diagram)
10. 可观测性
- Logs: pino/winston/zap 结构化 JSON + traceId
- Metrics: Prometheus /metrics endpoint
- Traces: OpenTelemetry SDK + W3C TraceContext
11. API 与实时通信
- REST/GraphQL: Gateway 路由 + BFF 聚合
- SSE: AI 网关流式响应
- WebSocket: Push Gateway 长连接
12. 架构约束
- 严格服务边界,禁止跨服务直连数据库
- 契约先行(proto + buf)
- 每服务独立部署与扩缩容
- 单文件行数:TS ≤500/800/1000, Go ≤800, Python ≤800
13. ADR
(按阶段补充架构决策记录)
14. 附录
- arch.db 查询:
npm run arch:query -- <command> - 模块文档:
docs/modules/<service>/README.md
- [ ] **Step 3: 创建 coding-standards.md**
Create `e:\Desktop\Edu\docs\standards\coding-standards.md`:
```markdown
# 编码规范(多语言版)
## 通用规则
- 提交前必须通过 `pnpm lint` 和对应语言类型检查
- 函数返回值必须显式标注
- 禁止 `any`/`Object`/`Dynamic`(未知类型用 `unknown` + 类型守卫)
- 单文件行数:TS ≤500/800/1000, Go ≤800, Python ≤800
## TypeScript
- 框架: NestJS(服务)+ Next.js(前端)
- ORM: Drizzle
- 仅用于类型的导入用 `import type`
- 可选链后禁止非空断言 `!`
- Server Action 必须 `requirePermission()`
- 前端权限检查用 `usePermission().hasPermission()`
## Go
- 框架: Gin
- ORM: GORM
- error 必须显式处理,禁止 `_`
- 接口先定义,结构体实现
- 包结构: `internal/{config,middleware,handler,service,repository}`
## Python
- 框架: FastAPI
- ORM: SQLAlchemy 2.0
- 全面类型注解(PEP 484)
- async/await 优先
- ruff 格式化 + lint
## 命名规范
- 目录: kebab-case
- 组件文件: PascalCase
- 变量/函数: camelCase(TS)/ snake_case(Go/Python)
- 常量: UPPER_SNAKE_CASE
- 类/接口: PascalCase(接口不加 I 前缀)
## 契约规范
- protobuf 作为服务间契约唯一源
- `buf lint` + `buf breaking` CI 强制
- 包名带版本: `xxx.v1`、`xxx.v2`
## 安全规范
- JWT RS256(P2 起)
- 参数化 SQL,禁止字符串拼接
- 环境变量校验
- 禁止 `dangerouslySetInnerHTML`(除非 DOMPurify 清洗)
- Step 4: 创建 git-workflow.md
Create e:\Desktop\Edu\docs\standards\git-workflow.md:
# Git 工作流
## 分支策略(trunk-based 简化版)
main # 受保护,仅 PR 合入,始终可发布 ├─ feature/xxx # feature 分支,生命周期 < 1 周 └─ fix/xxx # bugfix 分支
## 提交规范
Conventional Commits: `<type>(<scope>): <description>`
- types: feat, fix, chore, docs, style, refactor, test, perf, ci, build
- scope: 服务名或包名
- header ≤ 100 字符
## PR 流程
1. 创建 feature 分支
2. 开发 + CI 通过(lint + test + build + arch:scan)
3. 提交 PR
4. CI 全绿后合并到 main
5. 每阶段末打 tag: `v0.1.0-p1`
## 文档同步
改码后必须:
1. `npm run arch:scan`
2. 架构意图变化 → 更新 004
3. 接口变化 → 更新模块 README
4. 新经验 → 更新 known-issues
- Step 5: 创建 known-issues.md
Create e:\Desktop\Edu\docs\troubleshooting\known-issues.md:
# 已知问题速查(索引式)
> 场景→技术/规则映射,不写代码示例。新经验按时间倒序追加到"工作经验日志"。
## 全局经验
### Docker Compose 本地基础设施
| 场景 | 技术/规则 |
|------|----------|
| 日常开发启动 | 用 docker-compose.minimal.yml 仅起 MySQL+Redis |
| 全量启动内存不足 | 按阶段 profiles 启用,每服务 mem_limit 限制 |
| Windows 下卷挂载失败 | init-sql 用绝对路径或确保相对路径正确 |
### gRPC 契约管理
| 场景 | 技术/规则 |
|------|----------|
| breaking change 检测 | buf breaking CI 强制,必须升版本号 |
| proto 版本化 | 包名带版本 classes.v1,新旧共存 |
### 多语言 arch.db 扫描
| 场景 | 技术/规则 |
|------|----------|
| Go 扫描 | tree-sitter-go 解析 AST |
| Python 扫描 | tree-sitter-python 解析 AST |
| 扫描后验证 | npm run arch:query -- violations 输出 0 |
## 模块经验
### api-gateway(Go)
| 场景 | 技术/规则 |
|------|----------|
| P1 鉴权 | Gateway 内置 HS256 JWT,P2 改 RS256 |
| 路由转发 | gin + httputil.ReverseProxy |
### classes(TS,黄金模板)
| 场景 | 技术/规则 |
|------|----------|
| 黄金模板复制 | cp -r services/classes services/xxx,改错误码前缀+proto+业务逻辑 |
| 横切关注点 | 错误处理/可观测/安全/契约/测试/文档/配置/i18n/CI/Dockerfile |
## 工作经验日志
| 日期 | 模块 | 做了什么 + 学到什么 |
|------|------|-------------------|
| 2026-07-07 | 全局 | P1 启动:新仓库初始化 + monorepo 骨架 + Docker 基础设施 |
- Step 6: 创建 project_rules.md
Create e:\Desktop\Edu\project_rules.md:
# 项目规则(多语言版)
## 架构图优先规则
**任何任务开始前,必须先查阅架构影响地图,通过图定位代码和模块。**
1. **先图后码**: 执行任何任务时,先运行 `npm run arch:scan` 更新 arch.db,再通过 `npm run arch:query` 查询目标模块/服务/依赖关系,结合阅读 `docs/architecture/004_architecture_impact_map.md` 定位架构设计意图
2. **图未覆盖则先补图**: 发现 arch.db 未记录的模块/函数/服务,先运行 `npm run arch:scan` 重新扫描
3. **改码必同步图**: 修改源码后必须运行 `npm run arch:scan` 更新 arch.db;若架构设计意图变化,同步更新 004
### 需要同步图的场景
- 新增/删除/重命名导出函数、组件、Hook、类型(TS/Go/Python)
- 修改函数签名
- 修改权限点或角色-权限映射
- 新增/删除数据库表
- 新增/删除 gRPC 方法或 proto message
- 新增/删除 Kafka topic 或事件类型
- 新增/删除服务
- 修改服务间依赖关系
## arch.db 元数据库规则
**arch.db 是代码结构唯一源,AI 工作前必须运行 `npm run arch:scan` 更新。**
### 查询命令
- `npm run arch:scan`: 全量扫描
- `npm run arch:query -- sql "<SQL>"`: 自定义 SQL
- `npm run arch:query -- module <module>`: 模块依赖
- `npm run arch:query -- service <service>`: 服务级依赖
- `npm run arch:query -- contracts`: proto 契约清单
- `npm run arch:query -- events`: Kafka 事件清单
- `npm run arch:query -- violations`: 架构违规
## 编码规范
详细见 `docs/standards/coding-standards.md`,核心强制规则:
### 代码质量
- 每次修改后运行 lint 和类型检查确保零错误
- 服务接口必须 `requirePermission()` 权限校验
- 单文件行数: TS ≤500/800/1000, Go ≤800, Python ≤800
### 架构分层
- 严格服务边界,禁止跨服务直连数据库
- 服务间通信用 gRPC + protobuf 契约
- 服务间事件通信用 Outbox + Kafka
- 单一契约源: proto,禁止 REST/gRPC 混用
### 服务标准结构(黄金模板复制)
services// ├─ src/ │ ├─ main.ts # bootstrap + OTel 初始化 │ ├─ config/ # 三层配置 │ ├─ middleware/ # auth/permission/data-scope │ ├─ / # 业务模块 │ ├─ shared/ # errors/observability/i18n │ └─ generated/ # proto 生成代码 ├─ test/{unit,integration,contract,e2e}/ ├─ Dockerfile └─ README.md
## 提交规范
- Conventional Commits: `feat(scope): description`
- 提交前: `pnpm lint && pnpm test`
## 问题记录规则
**所有工作完成后,必须将遇到的问题记录到 `docs/troubleshooting/known-issues.md`。**
### 必须记录的场景
| 场景 | 记录位置 |
|------|---------|
| 构建报错 | 全局经验对应主题 |
| 运行时异常 | 模块经验对应服务 |
| 框架/库兼容问题 | 全局经验 |
| 架构违规 | 全局经验 |
## AI 工作强制流程
### 阶段 1: 上下文加载
1. `npm run arch:scan` 更新 arch.db
2. `npm run arch:query -- service <目标服务>` 查服务依赖
3. 阅读 `docs/modules/<服务>/README.md` 读服务工作流程
4. 查 `docs/troubleshooting/known-issues.md` 对应服务分区
### 阶段 2: 执行工作
1. 按规划执行
2. 修改代码后立即运行 `npm run arch:scan` 更新 arch.db
3. 运行 lint 和类型检查确保零错误
### 阶段 3: 经验沉淀
1. 在 `known-issues.md` 工作经验日志追加一条记录
2. 若发现新"场景→技术"映射,提炼到对应分区
3. 若架构决策变化,更新 004
4. `npm run arch:scan` 确认 arch.db 已更新
- Step 7: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "docs: migrate architecture documents and add multi-language standards"
Task 5: arch.db 多语言扫描器骨架
目标: 创建支持 TS+Go+Python 的 arch.db 扫描器骨架,P1 先实现 TS 扫描 + Go 扫描骨架。
Files:
-
Create:
e:\Desktop\Edu\scripts\arch-scan\package.json -
Create:
e:\Desktop\Edu\scripts\arch-scan\scanner.ts -
Create:
e:\Desktop\Edu\scripts\arch-scan\scanners\ts-scanner.ts -
Create:
e:\Desktop\Edu\scripts\arch-scan\scanners\go-scanner.ts -
Create:
e:\Desktop\Edu\scripts\arch-scan\scanners\py-scanner.ts -
Create:
e:\Desktop\Edu\scripts\arch-scan\query.ts -
Create:
e:\Desktop\Edu\scripts\arch-scan\schema.ts -
Step 1: 创建 arch-scan package.json
Create e:\Desktop\Edu\scripts\arch-scan\package.json:
{
"name": "@next-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.0.0",
"ts-morph": "^24.0.0",
"tree-sitter": "^0.21.0",
"tree-sitter-go": "^0.21.0",
"tree-sitter-python": "^0.21.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.10"
}
}
- Step 2: 安装依赖
Run:
cd e:\Desktop\Edu
pnpm install
- Step 3: 创建 schema.ts(数据库 schema 定义)
Create e:\Desktop\Edu\scripts\arch-scan\schema.ts:
import Database from 'better-sqlite3';
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
path TEXT NOT NULL,
language TEXT,
service TEXT,
type TEXT
);
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,
signature TEXT,
line INTEGER,
FOREIGN KEY (module_id) REFERENCES modules(id)
);
CREATE TABLE IF NOT EXISTS dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_module_id INTEGER NOT NULL,
target_module_id INTEGER,
target_name TEXT NOT NULL,
dependency_type TEXT,
FOREIGN KEY (source_module_id) REFERENCES modules(id)
);
CREATE TABLE IF NOT EXISTS contracts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proto_file TEXT NOT NULL,
service_name TEXT NOT NULL,
method_name TEXT NOT NULL,
request_type TEXT,
response_type TEXT,
breaking_change INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
event_type TEXT,
producer_service TEXT,
consumer_services TEXT,
schema_proto TEXT
);
CREATE TABLE IF NOT EXISTS violations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule TEXT NOT NULL,
file TEXT NOT NULL,
line INTEGER,
message TEXT,
severity TEXT
);
`;
export function initializeDatabase(db: Database.Database): void {
db.exec(SCHEMA_SQL);
}
- Step 4: 创建 ts-scanner.ts
Create e:\Desktop\Edu\scripts\arch-scan\scanners\ts-scanner.ts:
import { Project, SyntaxKind } from 'ts-morph';
import path from 'node:path';
import type Database from 'better-sqlite3';
interface ScanResult {
modules: number;
symbols: number;
dependencies: number;
}
export function scanTypeScript(rootPath: string, db: Database.Database, serviceName?: string): ScanResult {
const project = new Project({
tsConfigFilePath: undefined,
skipAddingFilesFromTsConfig: true,
});
const tsFiles = findTsFiles(rootPath);
let modules = 0;
let symbols = 0;
let dependencies = 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, signature, line) VALUES (?, ?, ?, ?, ?, ?)'
);
const insertDep = db.prepare(
'INSERT INTO dependencies (source_module_id, target_name, dependency_type) VALUES (?, ?, ?)'
);
const findModule = db.prepare('SELECT id FROM modules WHERE name = ?');
for (const filePath of tsFiles) {
const sourceFile = project.addSourceFileAtPath(filePath);
const moduleName = path.relative(rootPath, filePath).replace(/\.tsx?$/, '');
insertModule.run(moduleName, filePath, 'ts', serviceName, 'file');
const moduleRow = findModule.get(moduleName) as { id: number } | undefined;
if (!moduleRow) continue;
modules++;
// 扫描导出符号
for (const exportDecl of sourceFile.getExportDeclarations()) {
for (const specifier of exportDecl.getNamedExports()) {
insertSymbol.run(moduleRow.id, specifier.getName(), 'export', 'ts', null, specifier.getStartLineNumber());
symbols++;
}
}
// 扫描函数声明
for (const func of sourceFile.getFunctions()) {
const name = func.getName();
if (name) {
insertSymbol.run(moduleRow.id, name, 'function', 'ts', func.getSignature().toString(), func.getStartLineNumber());
symbols++;
}
}
// 扫描类声明
for (const cls of sourceFile.getClasses()) {
insertSymbol.run(moduleRow.id, cls.getName() || 'anonymous', 'class', 'ts', null, cls.getStartLineNumber());
symbols++;
}
// 扫描 import 依赖
for (const importDecl of sourceFile.getImportDeclarations()) {
const moduleSpecifier = importDecl.getModuleSpecifierValue();
insertDep.run(moduleRow.id, moduleSpecifier, 'import');
dependencies++;
}
}
return { modules, symbols, dependencies };
}
function findTsFiles(rootPath: string): string[] {
const { globSync } = require('node:fs');
const patterns = ['**/*.ts', '**/*.tsx'];
const ignorePatterns = ['**/node_modules/**', '**/dist/**', '**/.next/**', '**/generated/**'];
const files: string[] = [];
for (const pattern of patterns) {
const matches = globSync(pattern, { cwd: rootPath, ignore: ignorePatterns, absolute: true });
files.push(...matches);
}
return files;
}
- Step 5: 创建 go-scanner.ts(骨架)
Create e:\Desktop\Edu\scripts\arch-scan\scanners\go-scanner.ts:
import fs from 'node:fs';
import path from 'node:path';
import type Database from 'better-sqlite3';
interface ScanResult {
modules: number;
symbols: number;
dependencies: number;
}
export function scanGo(rootPath: string, db: Database.Database, serviceName?: string): ScanResult {
// P1 骨架:用正则提取 Go 符号(tree-sitter-go 原生绑定较复杂,先用简化版)
// TODO P2: 替换为 tree-sitter-go AST 解析
const goFiles = findGoFiles(rootPath);
let modules = 0;
let symbols = 0;
let dependencies = 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, signature, line) VALUES (?, ?, ?, ?, ?, ?)'
);
const insertDep = db.prepare(
'INSERT INTO dependencies (source_module_id, target_name, dependency_type) VALUES (?, ?, ?)'
);
const findModule = db.prepare('SELECT id FROM modules WHERE name = ?');
for (const filePath of goFiles) {
const content = fs.readFileSync(filePath, 'utf-8');
const moduleName = path.relative(rootPath, filePath).replace(/\.go$/, '');
insertModule.run(moduleName, filePath, 'go', serviceName, 'file');
const moduleRow = findModule.get(moduleName) as { id: number } | undefined;
if (!moduleRow) continue;
modules++;
// 正则提取函数
const funcRegex = /^func\s+(?:\([^)]*\)\s+)?([A-Z]\w+)\s*\(/gm;
let match: RegExpExecArray | null;
while ((match = funcRegex.exec(content)) !== null) {
insertSymbol.run(moduleRow.id, match[1], 'function', 'go', null, getLineNumber(content, match.index));
symbols++;
}
// 正则提取类型
const typeRegex = /^type\s+([A-Z]\w+)\s+/gm;
while ((match = typeRegex.exec(content)) !== null) {
insertSymbol.run(moduleRow.id, match[1], 'type', 'go', null, getLineNumber(content, match.index));
symbols++;
}
// 正则提取 import
const importRegex = /"([^"]+)"/g;
const importBlock = content.match(/import\s*\(([\s\S]*?)\)/);
if (importBlock) {
while ((match = importRegex.exec(importBlock[1])) !== null) {
insertDep.run(moduleRow.id, match[1], 'import');
dependencies++;
}
}
}
return { modules, symbols, dependencies };
}
function findGoFiles(rootPath: string): string[] {
const { globSync } = require('node:fs');
return globSync('**/*.go', { cwd: rootPath, ignore: ['**/vendor/**'], absolute: true });
}
function getLineNumber(content: string, index: number): number {
return content.substring(0, index).split('\n').length;
}
- Step 6: 创建 py-scanner.ts(骨架)
Create e:\Desktop\Edu\scripts\arch-scan\scanners\py-scanner.ts:
import fs from 'node:fs';
import path from 'node:path';
import type Database from 'better-sqlite3';
interface ScanResult {
modules: number;
symbols: number;
dependencies: number;
}
export function scanPython(rootPath: string, db: Database.Database, serviceName?: string): ScanResult {
// P1 骨架:用正则提取 Python 符号
// TODO P4: 替换为 tree-sitter-python AST 解析
const pyFiles = findPyFiles(rootPath);
let modules = 0;
let symbols = 0;
let dependencies = 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, signature, line) VALUES (?, ?, ?, ?, ?, ?)'
);
const insertDep = db.prepare(
'INSERT INTO dependencies (source_module_id, target_name, dependency_type) VALUES (?, ?, ?)'
);
const findModule = db.prepare('SELECT id FROM modules WHERE name = ?');
for (const filePath of pyFiles) {
const content = fs.readFileSync(filePath, 'utf-8');
const moduleName = path.relative(rootPath, filePath).replace(/\.py$/, '').replace(/\//g, '.');
insertModule.run(moduleName, filePath, 'python', serviceName, 'file');
const moduleRow = findModule.get(moduleName) as { id: number } | undefined;
if (!moduleRow) continue;
modules++;
// 正则提取函数
const funcRegex = /^(?:async\s+)?def\s+(\w+)\s*\(/gm;
let match: RegExpExecArray | null;
while ((match = funcRegex.exec(content)) !== null) {
insertSymbol.run(moduleRow.id, match[1], 'function', 'python', null, getLineNumber(content, match.index));
symbols++;
}
// 正则提取类
const classRegex = /^class\s+(\w+)/m;
while ((match = classRegex.exec(content)) !== null) {
insertSymbol.run(moduleRow.id, match[1], 'class', 'python', null, getLineNumber(content, match.index));
symbols++;
}
// 正则提取 import
const importRegex = /^(?:from\s+([\w.]+)\s+)?import\s+(.+)/gm;
while ((match = importRegex.exec(content)) !== null) {
const target = match[1] || match[2].split(',')[0].trim();
insertDep.run(moduleRow.id, target, 'import');
dependencies++;
}
}
return { modules, symbols, dependencies };
}
function findPyFiles(rootPath: string): string[] {
const { globSync } = require('node:fs');
return globSync('**/*.py', { cwd: rootPath, ignore: ['**/__pycache__/**', '**/.venv/**'], absolute: true });
}
function getLineNumber(content: string, index: number): number {
return content.substring(0, index).split('\n').length;
}
- Step 7: 创建 scanner.ts 主入口
Create e:\Desktop\Edu\scripts\arch-scan\scanner.ts:
import Database from 'better-sqlite3';
import path from 'node:path';
import { initializeDatabase } from './schema.js';
import { scanTypeScript } from './scanners/ts-scanner.js';
import { scanGo } from './scanners/go-scanner.js';
import { scanPython } from './scanners/py-scanner.js';
const ROOT = path.resolve(import.meta.dirname, '..', '..');
const DB_PATH = path.join(ROOT, 'arch.db');
function main(): void {
console.log('🔍 Starting arch.db scan...');
console.log(` Root: ${ROOT}`);
console.log(` DB: ${DB_PATH}`);
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
// 清空旧数据(全量扫描)
db.exec('DELETE FROM dependencies; DELETE FROM symbols; DELETE FROM modules; DELETE FROM violations;');
initializeDatabase(db);
const stats = {
ts: { modules: 0, symbols: 0, dependencies: 0 },
go: { modules: 0, symbols: 0, dependencies: 0 },
py: { modules: 0, symbols: 0, dependencies: 0 },
};
// 扫描 TS 服务
const tsServiceDirs = ['services/classes', 'bff', 'apps', 'packages'];
for (const dir of tsServiceDirs) {
const fullPath = path.join(ROOT, dir);
if (require('node:fs').existsSync(fullPath)) {
const result = scanTypeScript(fullPath, db, path.basename(dir));
stats.ts.modules += result.modules;
stats.ts.symbols += result.symbols;
stats.ts.dependencies += result.dependencies;
}
}
// 扫描 Go 服务
const goServiceDirs = ['services/api-gateway'];
for (const dir of goServiceDirs) {
const fullPath = path.join(ROOT, dir);
if (require('node:fs').existsSync(fullPath)) {
const result = scanGo(fullPath, db, path.basename(dir));
stats.go.modules += result.modules;
stats.go.symbols += result.symbols;
stats.go.dependencies += result.dependencies;
}
}
// 扫描 Python 服务(P4 起有内容)
const pyServiceDirs = ['services/data-ana', 'services/ai-gateway'];
for (const dir of pyServiceDirs) {
const fullPath = path.join(ROOT, dir);
if (require('node:fs').existsSync(fullPath)) {
const result = scanPython(fullPath, db, path.basename(dir));
stats.py.modules += result.modules;
stats.py.symbols += result.symbols;
stats.py.dependencies += result.dependencies;
}
}
console.log('\n✅ Scan complete:');
console.log(` TypeScript: ${stats.ts.modules} modules, ${stats.ts.symbols} symbols, ${stats.ts.dependencies} deps`);
console.log(` Go: ${stats.go.modules} modules, ${stats.go.symbols} symbols, ${stats.go.dependencies} deps`);
console.log(` Python: ${stats.py.modules} modules, ${stats.py.symbols} symbols, ${stats.py.dependencies} deps`);
db.close();
}
main();
- Step 8: 创建 query.ts 查询工具
Create e:\Desktop\Edu\scripts\arch-scan\query.ts:
import Database from 'better-sqlite3';
import path from 'node:path';
const ROOT = path.resolve(import.meta.dirname, '..', '..');
const DB_PATH = path.join(ROOT, 'arch.db');
function printUsage(): void {
console.log(`Usage: npm run arch:query -- <command> [args]
Commands:
sql "<SQL>" 自定义 SQL 查询
module <name> 模块依赖
service <name> 服务级依赖
symbols <name> 符号引用
contracts proto 契约清单
events Kafka 事件清单
violations 架构违规
stats 统计信息
`);
}
function main(): void {
const [, , command, ...args] = process.argv;
if (!command) {
printUsage();
process.exit(1);
}
const db = new Database(DB_PATH, { readonly: true });
switch (command) {
case 'sql': {
const sql = args.join(' ');
const rows = db.prepare(sql).all();
console.log(JSON.stringify(rows, null, 2));
break;
}
case 'module': {
const name = args[0];
const module = db.prepare('SELECT * FROM modules WHERE name LIKE ?').all(`%${name}%`);
console.log('Module:', module);
const deps = db.prepare(`
SELECT d.*, m.name as source_name FROM dependencies d
JOIN modules m ON d.source_module_id = m.id
WHERE m.name LIKE ?
`).all(`%${name}%`);
console.log('Dependencies:', deps);
break;
}
case 'service': {
const name = args[0];
const modules = db.prepare('SELECT * FROM modules WHERE service = ?').all(name);
console.log(`Service "${name}" modules:`, modules);
break;
}
case 'symbols': {
const name = args[0];
const symbols = db.prepare('SELECT * FROM symbols WHERE name LIKE ?').all(`%${name}%`);
console.log('Symbols:', symbols);
break;
}
case 'contracts': {
const contracts = db.prepare('SELECT * FROM contracts').all();
console.log('Contracts:', contracts);
break;
}
case 'events': {
const events = db.prepare('SELECT * FROM events').all();
console.log('Events:', events);
break;
}
case 'violations': {
const violations = db.prepare('SELECT * FROM violations').all();
console.log('Violations:', violations);
break;
}
case 'stats': {
const moduleCount = db.prepare('SELECT COUNT(*) as count FROM modules').get() as { count: number };
const symbolCount = db.prepare('SELECT COUNT(*) as count FROM symbols').get() as { count: number };
const depCount = db.prepare('SELECT COUNT(*) as count FROM dependencies').get() as { count: number };
console.log(`Modules: ${moduleCount.count}`);
console.log(`Symbols: ${symbolCount.count}`);
console.log(`Dependencies: ${depCount.count}`);
break;
}
default:
printUsage();
process.exit(1);
}
db.close();
}
main();
- Step 9: 验证扫描器运行
Run:
cd e:\Desktop\Edu
npm run arch:scan
npm run arch:query -- stats
Expected: 输出模块/符号/依赖统计(P1 此时可能为 0,因为还没有服务代码)。
- Step 10: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "feat(arch-scan): add multi-language arch.db scanner skeleton"
Task 6: 共享 proto 包与 buf 配置
目标: 创建 shared-proto 包,定义 classes 契约,配置 buf 代码生成。
Files:
-
Create:
e:\Desktop\Edu\packages\shared-proto\package.json -
Create:
e:\Desktop\Edu\packages\shared-proto\buf.yaml -
Create:
e:\Desktop\Edu\packages\shared-proto\buf.gen.yaml -
Create:
e:\Desktop\Edu\packages\shared-proto\proto\classes.proto -
Step 1: 创建 package.json
Create e:\Desktop\Edu\packages\shared-proto\package.json:
{
"name": "@next-edu/shared-proto",
"version": "0.1.0",
"private": true,
"scripts": {
"gen": "buf generate",
"lint": "buf lint",
"breaking": "buf breaking --against '.git#branch=main'"
}
}
- Step 2: 创建 buf.yaml
Create e:\Desktop\Edu\packages\shared-proto\buf.yaml:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
- PACKAGE_VERSION_SUFFIX
breaking:
use:
- FILE
except:
- EXTENSION_NO_DELETE
- Step 3: 创建 buf.gen.yaml
Create e:\Desktop\Edu\packages\shared-proto\buf.gen.yaml:
version: v2
plugins:
# TypeScript(NestJS 用)
- local: ts
out: ../shared-ts/generated
opt: target=ts
# Go(API Gateway 用)
# Go 插件在 Task 7 配置
managed:
enabled: true
- Step 4: 创建 classes.proto
Create e:\Desktop\Edu\packages\shared-proto\proto\classes.proto:
syntax = "proto3";
package next_edu_cloud.classes.v1;
// ClassService 班级服务
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;
int64 created_at = 4;
int64 updated_at = 5;
}
message CreateClassRequest {
string name = 1;
string grade_id = 2;
}
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;
string name = 2;
string grade_id = 3;
}
message DeleteClassRequest {
string id = 1;
}
message Empty {}
- Step 5: 验证 buf lint
Run:
cd e:\Desktop\Edu\packages\shared-proto
pnpm exec buf lint
Expected: 无 lint 错误。
- Step 6: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "feat(shared-proto): add classes proto contract and buf configuration"
Task 7: API Gateway 骨架(Go)
目标: 创建 Go API Gateway,实现路由 + JWT 校验(HS256)+ 转发到 classes 服务。
Files:
-
Create:
e:\Desktop\Edu\services\api-gateway\go.mod -
Create:
e:\Desktop\Edu\services\api-gateway\main.go -
Create:
e:\Desktop\Edu\services\api-gateway\internal\config\config.go -
Create:
e:\Desktop\Edu\services\api-gateway\internal\middleware\auth.go -
Create:
e:\Desktop\Edu\services\api-gateway\internal\proxy\proxy.go -
Create:
e:\Desktop\Edu\services\api-gateway\Dockerfile -
Step 1: 初始化 Go module
Run:
cd e:\Desktop\Edu\services\api-gateway
go mod init next-edu-cloud/api-gateway
go get github.com/gin-gonic/gin@v1.10.0
go get github.com/golang-jwt/jwt/v5@v5.2.1
- Step 2: 创建 config.go
Create e:\Desktop\Edu\services\api-gateway\internal\config\config.go:
package config
import (
"os"
"strconv"
)
// Config API Gateway 配置
type Config struct {
Port string
JWTSecret string
JWTIssuer string
JWTAudience string
ClassesServiceURL string
}
// Load 从环境变量加载配置
func Load() Config {
return Config{
Port: getEnv("API_GATEWAY_PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "p1-dev-secret"),
JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"),
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
}
}
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
}
- Step 3: 创建 auth middleware
Create e:\Desktop\Edu\services\api-gateway\internal\middleware\auth.go:
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// AuthClaims JWT claims
type AuthClaims struct {
UserID string `json:"user_id"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
// Auth JWT 鉴权中间件(P1 用 HS256,P2 改 RS256)
func Auth(secret, issuer, audience string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
return
}
tokenString := parts[1]
claims := &AuthClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// 注入用户上下文到请求头(传递给下游服务)
c.Request.Header.Set("x-user-id", claims.UserID)
c.Request.Header.Set("x-user-roles", strings.Join(claims.Roles, ","))
c.Next()
}
}
- Step 4: 创建 proxy
Create e:\Desktop\Edu\services\api-gateway\internal\proxy\proxy.go:
package proxy
import (
"net/http"
"net/http/httputil"
"net/url"
"github.com/gin-gonic/gin"
)
// ReverseProxy 反向代理
func ReverseProxy(targetURL string) gin.HandlerFunc {
target, err := url.Parse(targetURL)
if err != nil {
panic("invalid target URL: " + targetURL)
}
proxy := httputil.NewSingleHostReverseProxy(target)
return func(c *gin.Context) {
// 重写路径:去掉 /api/v1 前缀
originalPath := c.Request.URL.Path
c.Request.URL.Path = strings.TrimPrefix(originalPath, "/api/v1")
if c.Request.URL.Path == "" {
c.Request.URL.Path = "/"
}
proxy.ServeHTTP(c.Writer, c.Request)
}
}
注:需要
import "strings",实际文件顶部 import 块需包含。
修正 proxy.go 完整内容:
package proxy
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
// ReverseProxy 反向代理
func ReverseProxy(targetURL string) gin.HandlerFunc {
target, err := url.Parse(targetURL)
if err != nil {
panic("invalid target URL: " + targetURL)
}
proxy := httputil.NewSingleHostReverseProxy(target)
return func(c *gin.Context) {
originalPath := c.Request.URL.Path
c.Request.URL.Path = strings.TrimPrefix(originalPath, "/api/v1")
if c.Request.URL.Path == "" {
c.Request.URL.Path = "/"
}
proxy.ServeHTTP(c.Writer, c.Request)
}
}
- Step 5: 创建 main.go
Create e:\Desktop\Edu\services\api-gateway\main.go:
package main
import (
"log"
"time"
"github.com/gin-gonic/gin"
"next-edu-cloud/api-gateway/internal/config"
"next-edu-cloud/api-gateway/internal/middleware"
"next-edu-cloud/api-gateway/internal/proxy"
)
func main() {
cfg := config.Load()
r := gin.New()
r.Use(gin.Logger())
r.Use(gin.Recovery())
r.Use(RequestID())
// 健康检查(无需鉴权)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok", "timestamp": time.Now().Unix()})
})
// API v1 路由(需鉴权)
v1 := r.Group("/api/v1")
v1.Use(middleware.Auth(cfg.JWTSecret, cfg.JWTIssuer, cfg.JWTAudience))
{
v1.Any("/classes/*path", proxy.ReverseProxy(cfg.ClassesServiceURL))
}
log.Printf("API Gateway starting on :%s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("failed to start server: %v", err)
}
}
// RequestID 注入请求 ID
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = time.Now().Format("20060102150405") + "-" + randomString(8)
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[time.Now().UnixNano()%int64(len(letters))]
}
return string(b)
}
- Step 6: 创建 Dockerfile
Create e:\Desktop\Edu\services\api-gateway\Dockerfile:
# 构建阶段
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 .
# 运行阶段
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/api-gateway .
EXPOSE 8080
CMD ["./api-gateway"]
- Step 7: 验证编译
Run:
cd e:\Desktop\Edu\services\api-gateway
go mod tidy
go build ./...
Expected: 编译成功,无错误。
- Step 8: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "feat(api-gateway): add Go API Gateway with JWT auth and reverse proxy"
Task 8: classes 黄金模板服务 - 项目骨架与横切关注点
目标: 创建 NestJS classes 服务骨架,实现所有横切关注点(错误处理、可观测、安全、配置、i18n)。
Files:
-
Create:
e:\Desktop\Edu\services\classes\package.json -
Create:
e:\Desktop\Edu\services\classes\tsconfig.json -
Create:
e:\Desktop\Edu\services\classes\nest-cli.json -
Create:
e:\Desktop\Edu\services\classes\src\main.ts -
Create:
e:\Desktop\Edu\services\classes\src\app.module.ts -
Create:
e:\Desktop\Edu\services\classes\src\config\config.ts -
Create:
e:\Desktop\Edu\services\classes\src\config\env.ts -
Create:
e:\Desktop\Edu\services\classes\src\shared\errors\application-error.ts -
Create:
e:\Desktop\Edu\services\classes\src\shared\observability\logger.ts -
Create:
e:\Desktop\Edu\services\classes\src\shared\observability\metrics.ts -
Create:
e:\Desktop\Edu\services\classes\src\shared\observability\tracer.ts -
Create:
e:\Desktop\Edu\services\classes\src\shared\i18n\error-codes.ts -
Create:
e:\Desktop\Edu\services\classes\src\middleware\auth.middleware.ts -
Create:
e:\Desktop\Edu\services\classes\src\middleware\permission.guard.ts -
Create:
e:\Desktop\Edu\services\classes\src\middleware\data-scope.interceptor.ts -
Create:
e:\Desktop\Edu\services\classes\Dockerfile -
Step 1: 创建 package.json
Create e:\Desktop\Edu\services\classes\package.json:
{
"name": "@next-edu/classes",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"lint": "eslint src --ext .ts",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run --config vitest.e2e.config.ts"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"drizzle-orm": "^0.31.0",
"mysql2": "^3.10.0",
"pino": "^9.3.0",
"pino-http": "^10.2.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-node": "^0.53.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"@opentelemetry/resources": "^1.26.0",
"@opentelemetry/semantic-conventions": "^1.26.0",
"prom-client": "^15.1.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/node": "^20.14.0",
"@types/uuid": "^10.0.0",
"drizzle-kit": "^0.23.0",
"eslint": "^9.7.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0",
"@vitest/coverage-v8": "^2.0.0",
"testcontainers": "^10.13.0"
}
}
- Step 2: 创建 tsconfig.json
Create e:\Desktop\Edu\services\classes\tsconfig.json:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}
- Step 3: 创建 nest-cli.json
Create e:\Desktop\Edu\services\classes\nest-cli.json:
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
- Step 4: 创建 config/env.ts(环境变量校验)
Create e:\Desktop\Edu\services\classes\src\config\env.ts:
import { z } from 'zod';
const envSchema = z.object({
PORT: z.string().default('3001').transform(Number),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(16),
JWT_ISSUER: z.string().default('next-edu-cloud'),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).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 variables');
}
return result.data;
}
注:需要
pnpm add zod到 dependencies。
- Step 5: 创建 config/config.ts(三层配置)
Create e:\Desktop\Edu\services\classes\src\config\config.ts:
import { loadEnv, type Env } from './env.js';
export interface AppConfig {
env: Env;
service: {
name: string;
version: string;
};
database: {
url: string;
poolSize: number;
};
jwt: {
secret: string;
issuer: string;
};
observability: {
otlpEndpoint?: string;
logLevel: string;
};
}
export function loadConfig(): AppConfig {
const env = loadEnv();
return {
env,
service: {
name: 'classes',
version: '0.1.0',
},
database: {
url: env.DATABASE_URL,
poolSize: 10,
},
jwt: {
secret: env.JWT_SECRET,
issuer: env.JWT_ISSUER,
},
observability: {
otlpEndpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT,
logLevel: env.LOG_LEVEL,
},
};
}
- Step 6: 创建 errors/application-error.ts
Create e:\Desktop\Edu\services\classes\src\shared\errors\application-error.ts:
export type ErrorType =
| 'VALIDATION'
| 'NOT_FOUND'
| 'PERMISSION_DENIED'
| 'CONFLICT'
| 'BUSINESS'
| 'DATABASE'
| 'UPSTREAM'
| 'TIMEOUT'
| 'INTERNAL'
| 'CONTRACT';
export interface ErrorDetails {
field?: string;
message?: string;
[key: string]: unknown;
}
export abstract class ApplicationError extends Error {
abstract readonly type: ErrorType;
abstract readonly statusCode: number;
readonly code: string;
readonly details?: ErrorDetails;
readonly traceId?: string;
constructor(code: string, message: 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: ErrorType = 'VALIDATION';
readonly statusCode = 400;
}
export class NotFoundError extends ApplicationError {
readonly type: ErrorType = 'NOT_FOUND';
readonly statusCode = 404;
}
export class PermissionDeniedError extends ApplicationError {
readonly type: ErrorType = 'PERMISSION_DENIED';
readonly statusCode = 403;
}
export class ConflictError extends ApplicationError {
readonly type: ErrorType = 'CONFLICT';
readonly statusCode = 409;
}
export class BusinessError extends ApplicationError {
readonly type: ErrorType = 'BUSINESS';
readonly statusCode = 400;
}
export class DatabaseError extends ApplicationError {
readonly type: ErrorType = 'DATABASE';
readonly statusCode = 500;
}
export class InternalError extends ApplicationError {
readonly type: ErrorType = 'INTERNAL';
readonly statusCode = 500;
}
- Step 7: 创建 observability/logger.ts
Create e:\Desktop\Edu\services\classes\src\shared\observability\logger.ts:
import pino from 'pino';
export interface LogContext {
traceId?: string;
spanId?: string;
userId?: string;
service?: string;
[key: string]: unknown;
}
export type Logger = pino.Logger;
export function createLogger(serviceName: string, logLevel: string): Logger {
return pino({
name: serviceName,
level: logLevel,
formatters: {
level(label: string): Record<string, unknown> {
return { level: label };
},
},
timestamp: pino.stdTimeFunctions.isoTime,
});
}
export function childLogger(parent: Logger, context: LogContext): Logger {
return parent.child(context);
}
- Step 8: 创建 observability/metrics.ts
Create e:\Desktop\Edu\services\classes\src\shared\observability\metrics.ts:
import promClient from 'prom-client';
export function setupMetrics(serviceName: string): promClient.Registry {
const registry = new promClient.Registry();
registry.setDefaultLabels({ service: serviceName });
promClient.collectDefaultMetrics({ register: registry });
// 自定义指标
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [registry],
});
const httpRequestTotal = new promClient.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route', 'status'],
registers: [registry],
});
return registry;
}
export { promClient };
- Step 9: 创建 observability/tracer.ts
Create e:\Desktop\Edu\services\classes\src\shared\observability\tracer.ts:
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
let sdk: NodeSDK | undefined;
export function setupTracing(serviceName: string, otlpEndpoint?: string): void {
if (!otlpEndpoint) {
return;
}
sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
}),
traceExporter: new OTLPTraceExporter({ url: otlpEndpoint }),
});
sdk.start();
}
export async function shutdownTracing(): Promise<void> {
if (sdk) {
await sdk.shutdown();
}
}
- Step 10: 创建 i18n/error-codes.ts
Create e:\Desktop\Edu\services\classes\src\shared\i18n\error-codes.ts:
// 错误码 → i18n key 映射
// 前端根据 key 翻译,后端不返回翻译后文本
export const ERROR_CODES = {
// 班级相关
CLASSES_VAL_NAME_REQUIRED: 'errors.classes.validation.nameRequired',
CLASSES_VAL_GRADE_REQUIRED: 'errors.classes.validation.gradeRequired',
CLASSES_NOT_FOUND: 'errors.classes.notFound',
CLASSES_CONFLICT_DUPLICATE: 'errors.classes.conflict.duplicate',
CLASSES_PERM_CREATE: 'errors.classes.permission.create',
CLASSES_PERM_UPDATE: 'errors.classes.permission.update',
CLASSES_PERM_DELETE: 'errors.classes.permission.delete',
// 通用
INTERNAL_ERROR: 'errors.common.internal',
DATABASE_ERROR: 'errors.common.database',
} as const;
export type ErrorCode = keyof typeof ERROR_CODES;
export function getI18nKey(code: ErrorCode): string {
return ERROR_CODES[code] ?? 'errors.common.unknown';
}
- Step 11: 创建 middleware/auth.middleware.ts
Create e:\Desktop\Edu\services\classes\src\middleware\auth.middleware.ts:
import type { Request, Response, NextFunction } from 'express';
export interface UserContext {
userId: string;
roles: string[];
permissions: string[];
dataScope: DataScope;
}
export type DataScope =
| 'all'
| 'grade_managed'
| 'class_taught'
| 'class_members'
| 'children'
| 'owned';
declare module 'express' {
interface Request {
user?: UserContext;
}
}
// auth.middleware 信任 Gateway 注入的 x-user-* 头
// P2 起由 IAM 签发 JWT,Gateway 解析后注入
export function authMiddleware() {
return (req: Request, res: Response, next: NextFunction): void => {
const userId = req.header('x-user-id');
const rolesHeader = req.header('x-user-roles');
if (!userId) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing user context' } });
return;
}
req.user = {
userId,
roles: rolesHeader ? rolesHeader.split(',') : [],
permissions: [], // P2 由 IAM 解析注入
dataScope: 'all', // P1 默认 all,P2 由 IAM 解析
};
next();
};
}
- Step 12: 创建 middleware/permission.guard.ts
Create e:\Desktop\Edu\services\classes\src\middleware\permission.guard.ts:
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { PermissionDeniedError } from '../shared/errors/application-error.js';
// 权限点常量(沿用旧项目命名)
export const Permissions = {
CLASS_CREATE: 'class:create',
CLASS_READ: 'class:read',
CLASS_UPDATE: 'class:update',
CLASS_DELETE: 'class:delete',
} as const;
export type Permission = typeof Permissions[keyof typeof Permissions];
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const user = request.user;
if (!user) {
throw new PermissionDeniedError('CLASSES_PERM_AUTH', 'User not authenticated');
}
// admin 角色拥有全部权限
if (user.roles.includes('admin')) {
return true;
}
if (!user.permissions.includes(this.requiredPermission)) {
throw new PermissionDeniedError(
this.requiredPermission.replace(':', '_').toUpperCase(),
`Permission ${this.requiredPermission} required`,
);
}
return true;
}
}
// 便捷装饰器函数
export function requirePermission(permission: Permission) {
return new PermissionGuard(permission);
}
- Step 13: 创建 middleware/data-scope.interceptor.ts
Create e:\Desktop\Edu\services\classes\src\middleware\data-scope.interceptor.ts:
import type { NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Observable } from 'rxjs';
import type { Request } from 'express';
// DataScope 过滤拦截器
// P1 简化版:仅注入 dataScope 到 request,供 repository 使用
// P2 起由 IAM 解析 DataScope 并注入 JWT
@Injectable()
export class DataScopeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<Request>();
// dataScope 已由 authMiddleware 注入
// repository 根据 dataScope 添加 WHERE 条件
return next.handle();
}
}
- Step 14: 创建 main.ts
Create e:\Desktop\Edu\services\classes\src\main.ts:
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module.js';
import { loadConfig } from './config/config.js';
import { createLogger } from './shared/observability/logger.js';
import { setupTracing, shutdownTracing } from './shared/observability/tracer.js';
import { setupMetrics } from './shared/observability/metrics.js';
import type { ApplicationError } from './shared/errors/application-error.js';
async function bootstrap(): Promise<void> {
const config = loadConfig();
const logger = createLogger(config.service.name, config.observability.logLevel);
// 初始化 OpenTelemetry
setupTracing(config.service.name, config.observability.otlpEndpoint);
// 初始化 metrics
const metricsRegistry = setupMetrics(config.service.name);
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['log', 'error', 'warn', 'debug', 'verbose'],
});
// 全局异常过滤器
app.useGlobalFilters({
catch(exception: unknown, host: import('@nestjs/common').ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const traceId = (request.headers['x-request-id'] as string) ?? undefined;
if (exception instanceof (require('./shared/errors/application-error.js').ApplicationError)) {
const appError = exception as ApplicationError & { statusCode: number };
appError.traceId = traceId;
response.status(appError.statusCode).json(appError.toJSON());
} else {
logger.error({ err: exception, traceId }, 'Unhandled exception');
response.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Internal server error',
traceId,
},
});
}
},
});
// metrics endpoint
app.use('/metrics', (req, res) => {
res.set('Content-Type', metricsRegistry.contentType);
res.end(metricsRegistry.metrics());
});
// 健康检查
app.use('/health', (req, res) => {
res.json({ status: 'ok', service: config.service.name, version: config.service.version });
});
await app.listen(config.env.PORT);
logger.info(`🚀 ${config.service.name} service started on port ${config.env.PORT}`);
// 优雅关闭
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down...');
await app.close();
await shutdownTracing();
process.exit(0);
});
}
bootstrap().catch((err) => {
console.error('Failed to bootstrap application', err);
process.exit(1);
});
- Step 15: 创建 app.module.ts(骨架,业务在 Task 9 填充)
Create e:\Desktop\Edu\services\classes\src\app.module.ts:
import { Module } from '@nestjs/common';
import { ClassesModule } from './classes/classes.module.js';
@Module({
imports: [ClassesModule],
})
export class AppModule {}
- Step 16: 创建 Dockerfile
Create e:\Desktop\Edu\services\classes\Dockerfile:
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable pnpm
# 缓存依赖
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# 复制源码并构建
COPY . .
RUN pnpm build
# 运行阶段
FROM node:20-alpine
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
COPY --from=builder /app/dist ./dist
RUN pnpm install --frozen-lockfile --prod
EXPOSE 3001
CMD ["node", "dist/main.js"]
- Step 17: 安装依赖并验证编译
Run:
cd e:\Desktop\Edu
pnpm install
cd services/classes
pnpm exec tsc --noEmit
Expected: 编译通过(可能有 classes.module 不存在的错误,下一个 Task 创建)。
- Step 18: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "feat(classes): add NestJS golden template service skeleton with cross-cutting concerns"
Task 9: classes 黄金模板服务 - 业务逻辑 CRUD
目标: 实现 classes 域 CRUD 业务逻辑(controller + service + repository + module)。
Files:
-
Create:
e:\Desktop\Edu\services\classes\src\classes\classes.module.ts -
Create:
e:\Desktop\Edu\services\classes\src\classes\classes.controller.ts -
Create:
e:\Desktop\Edu\services\classes\src\classes\classes.service.ts -
Create:
e:\Desktop\Edu\services\classes\src\classes\classes.repository.ts -
Create:
e:\Desktop\Edu\services\classes\src\classes\classes.dto.ts -
Create:
e:\Desktop\Edu\services\classes\src\classes\classes.schema.ts -
Step 1: 创建 classes.schema.ts(Drizzle schema)
Create e:\Desktop\Edu\services\classes\src\classes\classes.schema.ts:
import { mysqlTable, varchar, timestamp, index } from 'drizzle-orm/mysql-core';
import { createId } from '@paralleldrive/cuid2';
export const classes = mysqlTable(
'classes',
{
id: varchar('id', { length: 36 }).$defaultFn(() => createId()).primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
gradeId: varchar('grade_id', { length: 36 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(table) => ({
gradeIdIdx: index('idx_grade_id').on(table.gradeId),
}),
);
export type Class = typeof classes.$inferSelect;
export type NewClass = typeof classes.$inferInsert;
注:需要
pnpm add @paralleldrive/cuid2到 dependencies。
- Step 2: 创建 classes.dto.ts
Create e:\Desktop\Edu\services\classes\src\classes\classes.dto.ts:
import { z } from 'zod';
export const createClassSchema = z.object({
name: z.string().min(1, 'Class name is required').max(100),
gradeId: z.string().min(1, 'Grade ID is required'),
});
export const updateClassSchema = z.object({
name: z.string().min(1).max(100).optional(),
gradeId: z.string().min(1).optional(),
});
export type CreateClassDto = z.infer<typeof createClassSchema>;
export type UpdateClassDto = z.infer<typeof updateClassSchema>;
export interface ClassResponse {
id: string;
name: string;
gradeId: string;
createdAt: number;
updatedAt: number;
}
- Step 3: 创建 classes.repository.ts
Create e:\Desktop\Edu\services\classes\src\classes\classes.repository.ts:
import { Injectable } from '@nestjs/common';
import { drizzle, type MySql2Database } from 'drizzle-orm/mysql2';
import { eq } from 'drizzle-orm';
import { classes, type Class, type NewClass } from './classes.schema.js';
import { DatabaseError, NotFoundError } from '../shared/errors/application-error.js';
@Injectable()
export class ClassesRepository {
private readonly db: MySql2Database;
constructor() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required');
}
this.db = drizzle(databaseUrl);
}
async create(newClass: NewClass): Promise<Class> {
try {
const [result] = await this.db.insert(classes).values(newClass);
const [created] = await this.db.select().from(classes).where(eq(classes.id, newClass.id)).limit(1);
return created as Class;
} catch (err) {
throw new DatabaseError('CLASSES_DB_CREATE', 'Failed to create class', { error: String(err) });
}
}
async findById(id: string): Promise<Class> {
try {
const [result] = await this.db.select().from(classes).where(eq(classes.id, id)).limit(1);
if (!result) {
throw new NotFoundError('CLASSES_NOT_FOUND', `Class ${id} not found`);
}
return result as Class;
} catch (err) {
if (err instanceof NotFoundError) throw err;
throw new DatabaseError('CLASSES_DB_FIND', 'Failed to find class', { error: String(err) });
}
}
async list(gradeId?: string, limit = 50): Promise<Class[]> {
try {
const query = this.db.select().from(classes).limit(limit);
if (gradeId) {
return await query.where(eq(classes.gradeId, gradeId)) as Class[];
}
return await query as Class[];
} catch (err) {
throw new DatabaseError('CLASSES_DB_LIST', 'Failed to list classes', { error: String(err) });
}
}
async update(id: string, data: Partial<NewClass>): Promise<Class> {
try {
await this.db.update(classes).set(data).where(eq(classes.id, id));
return await this.findById(id);
} catch (err) {
if (err instanceof NotFoundError) throw err;
throw new DatabaseError('CLASSES_DB_UPDATE', 'Failed to update class', { error: String(err) });
}
}
async delete(id: string): Promise<void> {
try {
await this.db.delete(classes).where(eq(classes.id, id));
} catch (err) {
throw new DatabaseError('CLASSES_DB_DELETE', 'Failed to delete class', { error: String(err) });
}
}
}
- Step 4: 创建 classes.service.ts
Create e:\Desktop\Edu\services\classes\src\classes\classes.service.ts:
import { Injectable } from '@nestjs/common';
import { ClassesRepository } from './classes.repository.js';
import type { CreateClassDto, UpdateClassDto, ClassResponse } from './classes.dto.js';
import type { Class } from './classes.schema.js';
import { ValidationError } from '../shared/errors/application-error.js';
@Injectable()
export class ClassesService {
constructor(private readonly repository: ClassesRepository) {}
async create(dto: CreateClassDto): Promise<ClassResponse> {
return this.toResponse(await this.repository.create(dto));
}
async findById(id: string): Promise<ClassResponse> {
return this.toResponse(await this.repository.findById(id));
}
async list(gradeId?: string): Promise<ClassResponse[]> {
const items = await this.repository.list(gradeId);
return items.map((c) => this.toResponse(c));
}
async update(id: string, dto: UpdateClassDto): Promise<ClassResponse> {
if (Object.keys(dto).length === 0) {
throw new ValidationError('CLASSES_VAL_EMPTY_UPDATE', 'No fields to update');
}
return this.toResponse(await this.repository.update(id, dto));
}
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
private toResponse(c: Class): ClassResponse {
return {
id: c.id,
name: c.name,
gradeId: c.gradeId,
createdAt: c.createdAt.getTime(),
updatedAt: c.updatedAt.getTime(),
};
}
}
- Step 5: 创建 classes.controller.ts
Create e:\Desktop\Edu\services\classes\src\classes\classes.controller.ts:
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Req, HttpCode, HttpStatus } from '@nestjs/common';
import { ClassesService } from './classes.service.js';
import { createClassSchema, updateClassSchema } from './classes.dto.js';
import { ValidationError } from '../shared/errors/application-error.js';
import { authMiddleware } from '../middleware/auth.middleware.js';
import type { Request } from 'express';
@Controller('/classes')
export class ClassesController {
constructor(private readonly service: ClassesService) {}
@Post()
@UseGuards(authMiddleware())
@HttpCode(HttpStatus.CREATED)
async create(@Body() body: unknown, @Req() req: Request): Promise<{ success: true; data: unknown }> {
const result = createClassSchema.safeParse(body);
if (!result.success) {
throw new ValidationError('CLASSES_VAL_CREATE', 'Invalid input', { errors: result.error.flatten() });
}
const data = await this.service.create(result.data);
return { success: true, data };
}
@Get()
@UseGuards(authMiddleware())
async list(@Query('gradeId') gradeId?: string): Promise<{ success: true; data: unknown[] }> {
const data = await this.service.list(gradeId);
return { success: true, data };
}
@Get(':id')
@UseGuards(authMiddleware())
async findById(@Param('id') id: string): Promise<{ success: true; data: unknown }> {
const data = await this.service.findById(id);
return { success: true, data };
}
@Put(':id')
@UseGuards(authMiddleware())
async update(@Param('id') id: string, @Body() body: unknown): Promise<{ success: true; data: unknown }> {
const result = updateClassSchema.safeParse(body);
if (!result.success) {
throw new ValidationError('CLASSES_VAL_UPDATE', 'Invalid input', { errors: result.error.flatten() });
}
const data = await this.service.update(id, result.data);
return { success: true, data };
}
@Delete(':id')
@UseGuards(authMiddleware())
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id') id: string): Promise<void> {
await this.service.delete(id);
}
}
- Step 6: 创建 classes.module.ts
Create e:\Desktop\Edu\services\classes\src\classes\classes.module.ts:
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, ClassesRepository],
exports: [ClassesService],
})
export class ClassesModule {}
- Step 7: 安装额外依赖并验证编译
Run:
cd e:\Desktop\Edu\services\classes
pnpm add @paralleldrive/cuid2 zod
pnpm exec tsc --noEmit
Expected: 编译通过。
- Step 8: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "feat(classes): implement CRUD business logic with repository pattern"
Task 10: classes 黄金模板服务 - 测试套件
目标: 创建单元测试 + 集成测试 + 契约测试 + E2E 测试骨架。
Files:
-
Create:
e:\Desktop\Edu\services\classes\vitest.config.ts -
Create:
e:\Desktop\Edu\services\classes\test\unit\classes.service.test.ts -
Create:
e:\Desktop\Edu\services\classes\test\integration\classes.repository.test.ts -
Create:
e:\Desktop\Edu\services\classes\test\contract\classes.contract.test.ts -
Create:
e:\Desktop\Edu\services\classes\test\e2e\classes.e2e.test.ts -
Step 1: 创建 vitest.config.ts
Create e:\Desktop\Edu\services\classes\vitest.config.ts:
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,
},
},
},
});
- Step 2: 创建单元测试
Create e:\Desktop\Edu\services\classes\test\unit\classes.service.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ClassesService } from '../../src/classes/classes.service.js';
import { ValidationError } from '../../src/shared/errors/application-error.js';
describe('ClassesService', () => {
let service: ClassesService;
let mockRepository: any;
beforeEach(() => {
mockRepository = {
create: vi.fn(),
findById: vi.fn(),
list: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
service = new ClassesService(mockRepository);
});
describe('create', () => {
it('should create a class successfully', async () => {
const dto = { name: 'Class 1A', gradeId: 'grade-1' };
const mockClass = {
id: 'cls-1',
name: 'Class 1A',
gradeId: 'grade-1',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
mockRepository.create.mockResolvedValue(mockClass);
const result = await service.create(dto);
expect(result).toEqual({
id: 'cls-1',
name: 'Class 1A',
gradeId: 'grade-1',
createdAt: expect.any(Number),
updatedAt: expect.any(Number),
});
expect(mockRepository.create).toHaveBeenCalledWith(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: 'grade-1',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'),
};
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('list', () => {
it('should list classes without gradeId filter', async () => {
const mockClasses = [
{ id: 'cls-1', name: 'A', gradeId: 'g1', createdAt: new Date(), updatedAt: new Date() },
];
mockRepository.list.mockResolvedValue(mockClasses);
const result = await service.list();
expect(result).toHaveLength(1);
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 delete class', async () => {
mockRepository.delete.mockResolvedValue(undefined);
await service.delete('cls-1');
expect(mockRepository.delete).toHaveBeenCalledWith('cls-1');
});
});
});
- Step 3: 创建集成测试骨架(Testcontainers)
Create e:\Desktop\Edu\services\classes\test\integration\classes.repository.test.ts:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
// P1 骨架:集成测试用 Testcontainers 起 MySQL
// 完整实现需安装 testcontainers 依赖
describe('ClassesRepository (integration)', () => {
// TODO: 用 Testcontainers 启动 MySQL 容器
// beforeAll: 启动容器 + 运行迁移
// afterAll: 停止容器
it('should be defined', () => {
// 骨架测试
expect(true).toBe(true);
});
it.skip('should create and find class', async () => {
// 完整集成测试在 P1 后期补全
});
});
- Step 4: 创建契约测试骨架
Create e:\Desktop\Edu\services\classes\test\contract\classes.contract.test.ts:
import { describe, it, expect } from 'vitest';
// 契约测试:验证 classes.proto 定义的 API 符合消费者期望
// P3 起用 Pact 完整实现
describe('Classes API Contract', () => {
it('should have CreateClass method', () => {
// 读取 proto 文件验证
expect(true).toBe(true);
});
it('should have ListClasses method', () => {
expect(true).toBe(true);
});
it.skip('should validate consumer expectations (Pact)', () => {
// P3 起用 Pact 实现消费端驱动契约测试
});
});
- Step 5: 创建 E2E 测试骨架
Create e:\Desktop\Edu\services\classes\test\e2e\classes.e2e.test.ts:
import { describe, it, expect } from 'vitest';
// E2E 测试:端到端验证 classes API
// P1 末尾用真实 MySQL 容器 + HTTP 请求验证
describe('Classes E2E', () => {
const API_BASE = process.env.E2E_API_BASE ?? 'http://localhost:3001';
it.skip('should create and retrieve class', async () => {
// P1 末尾补全:
// 1. POST /classes 创建班级
// 2. GET /classes/:id 查询
// 3. 验证返回数据一致
});
it.skip('should list classes', async () => {
// P1 末尾补全
});
});
- Step 6: 运行测试
Run:
cd e:\Desktop\Edu\services\classes
pnpm test
Expected: 单元测试通过(4 个 describe 全绿),集成/契约/E2E 骨架测试通过。
- Step 7: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "test(classes): add unit tests and skeleton for integration/contract/e2e"
Task 11: teacher-portal 测试页
目标: 创建 Next.js 测试页,端到端验证 classes CRUD 链路。
Files:
-
Create:
e:\Desktop\Edu\apps\teacher-portal\package.json -
Create:
e:\Desktop\Edu\apps\teacher-portal\next.config.js -
Create:
e:\Desktop\Edu\apps\teacher-portal\tsconfig.json -
Create:
e:\Desktop\Edu\apps\teacher-portal\src\app\page.tsx -
Create:
e:\Desktop\Edu\apps\teacher-portal\src\app\layout.tsx -
Step 1: 创建 package.json
Create e:\Desktop\Edu\apps\teacher-portal\package.json:
{
"name": "@next-edu/teacher-portal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0",
"typescript": "^5.5.0"
}
}
- Step 2: 创建 next.config.js
Create e:\Desktop\Edu\apps\teacher-portal\next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
// P1 简化配置
// P2 起配置 Module Federation
env: {
NEXT_PUBLIC_API_GATEWAY_URL: process.env.NEXT_PUBLIC_API_GATEWAY_URL ?? 'http://localhost:8080',
},
};
module.exports = nextConfig;
- Step 3: 创建 tsconfig.json
Create e:\Desktop\Edu\apps\teacher-portal\tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
- Step 4: 创建 layout.tsx
Create e:\Desktop\Edu\apps\teacher-portal\src\app\layout.tsx:
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
export const metadata: Metadata = {
title: 'Teacher Portal - Next Edu Cloud',
description: 'P1 测试页',
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN">
<body style={{ fontFamily: 'system-ui, sans-serif', margin: 0, padding: '24px' }}>
{children}
</body>
</html>
);
}
- Step 5: 创建 page.tsx(classes CRUD 测试页)
Create e:\Desktop\Edu\apps\teacher-portal\src\app\page.tsx:
'use client';
import { useState, useEffect, useCallback } from 'react';
interface ClassItem {
id: string;
name: string;
gradeId: string;
createdAt: number;
updatedAt: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
const API_BASE = process.env.NEXT_PUBLIC_API_GATEWAY_URL ?? 'http://localhost:8080';
// P1 测试用 JWT(开发工具生成)
const TEST_JWT = process.env.NEXT_PUBLIC_TEST_JWT ?? 'dev-token';
export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [name, setName] = useState('');
const [gradeId, setGradeId] = useState('grade-1');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const fetchClasses = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/classes`, {
headers: { Authorization: `Bearer ${TEST_JWT}` },
});
const json: ApiResponse<ClassItem[]> = await res.json();
if (json.success && json.data) {
setClasses(json.data);
} else {
setMessage(`查询失败: ${json.error?.message ?? '未知错误'}`);
}
} catch (err) {
setMessage(`网络错误: ${String(err)}`);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/classes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TEST_JWT}`,
},
body: JSON.stringify({ name, gradeId }),
});
const json: ApiResponse<ClassItem> = await res.json();
if (json.success) {
setMessage(`创建成功: ${json.data?.name}`);
setName('');
await fetchClasses();
} else {
setMessage(`创建失败: ${json.error?.message}`);
}
} catch (err) {
setMessage(`网络错误: ${String(err)}`);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/classes/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${TEST_JWT}` },
});
if (res.ok) {
setMessage('删除成功');
await fetchClasses();
}
} catch (err) {
setMessage(`网络错误: ${String(err)}`);
} finally {
setLoading(false);
}
};
return (
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<h1>Next Edu Cloud - P1 测试页</h1>
<p>classes 域 CRUD 端到端验证</p>
{message && (
<div style={{ padding: '12px', background: '#f0f0f0', borderRadius: '4px', margin: '12px 0' }}>
{message}
</div>
)}
<form onSubmit={handleCreate} style={{ display: 'flex', gap: '8px', margin: '16px 0' }}>
<input
type="text"
placeholder="班级名称"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ padding: '8px', flex: 1 }}
/>
<input
type="text"
placeholder="年级 ID"
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
style={{ padding: '8px', width: '120px' }}
/>
<button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
创建班级
</button>
<button type="button" onClick={fetchClasses} disabled={loading} style={{ padding: '8px 16px' }}>
刷新
</button>
</form>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f5f5f5', textAlign: 'left' }}>
<th style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>ID</th>
<th style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>名称</th>
<th style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>年级</th>
<th style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>操作</th>
</tr>
</thead>
<tbody>
{classes.map((c) => (
<tr key={c.id}>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', fontSize: '12px' }}>{c.id}</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>{c.name}</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>{c.gradeId}</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>
<button onClick={() => handleDelete(c.id)} disabled={loading}>
删除
</button>
</td>
</tr>
))}
{classes.length === 0 && !loading && (
<tr>
<td colSpan={4} style={{ padding: '16px', textAlign: 'center', color: '#999' }}>
暂无班级
</td>
</tr>
)}
</tbody>
</table>
<div style={{ marginTop: '32px', fontSize: '12px', color: '#999' }}>
<p>API Gateway: {API_BASE}</p>
<p>链路: 浏览器 → teacher-portal → API Gateway → classes service → MySQL</p>
</div>
</div>
);
}
- Step 6: 安装依赖并验证编译
Run:
cd e:\Desktop\Edu
pnpm install
cd apps/teacher-portal
pnpm exec tsc --noEmit
Expected: 编译通过。
- Step 7: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "feat(teacher-portal): add P1 test page for classes CRUD verification"
Task 12: CI/CD 骨架
目标: 创建三语言 CI workflow。
Files:
-
Create:
e:\Desktop\Edu\.github\workflows\ci-go.yml -
Create:
e:\Desktop\Edu\.github\workflows\ci-ts.yml -
Create:
e:\Desktop\Edu\.github\workflows\ci-py.yml -
Step 1: 创建 ci-go.yml
Create e:\Desktop\Edu\.github\workflows\ci-go.yml:
name: CI Go
on:
push:
branches: [main]
paths:
- 'services/api-gateway/**'
- 'services/push-gateway/**'
- '.github/workflows/ci-go.yml'
pull_request:
paths:
- 'services/api-gateway/**'
- 'services/push-gateway/**'
jobs:
lint-test-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: 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 ./...
go vet ./...
- name: Test
working-directory: services/api-gateway
run: go test ./... -v -coverprofile=coverage.out
- Step 2: 创建 ci-ts.yml
Create e:\Desktop\Edu\.github\workflows\ci-ts.yml:
name: CI TypeScript
on:
push:
branches: [main]
paths:
- 'services/classes/**'
- 'bff/**'
- 'apps/**'
- 'packages/**'
- 'scripts/**'
- '.github/workflows/ci-ts.yml'
pull_request:
paths:
- 'services/classes/**'
- 'bff/**'
- 'apps/**'
- 'packages/**'
- 'scripts/**'
jobs:
lint-test-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm -r run lint
- name: Type check
run: pnpm -r exec tsc --noEmit
- name: Test
run: pnpm -r run test
- name: Build
run: pnpm -r run build
- name: Arch scan
run: npm run arch:scan
- Step 3: 创建 ci-py.yml
Create e:\Desktop\Edu\.github\workflows\ci-py.yml:
name: CI Python
on:
push:
branches: [main]
paths:
- 'services/data-ana/**'
- 'services/ai-gateway/**'
- '.github/workflows/ci-py.yml'
pull_request:
paths:
- 'services/data-ana/**'
- 'services/ai-gateway/**'
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
run: pip install uv
- name: Lint
run: uvx ruff check .
- name: Test
run: uvx pytest
- Step 4: 提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "ci: add Go/TypeScript/Python CI workflows"
Task 13: P1 文档完善与验收
目标: 创建模块 README,运行 arch:scan,端到端验证 P1 退出标准。
Files:
-
Create:
e:\Desktop\Edu\docs\modules\api-gateway\README.md -
Create:
e:\Desktop\Edu\docs\modules\classes\README.md -
Step 1: 创建 api-gateway README
Create e:\Desktop\Edu\docs\modules\api-gateway\README.md:
# API Gateway
## 1. 模块职责
API 网关:路由转发 + JWT 鉴权 + 请求 ID 注入。所有外部请求的统一入口。
## 2. 技术栈
| 技术 | 版本 |
|------|------|
| Go | 1.22 |
| Gin | 1.10 |
| golang-jwt | v5 |
## 3. 架构图
```mermaid
graph TB
Client[客户端] --> GW[API Gateway]
GW --> Auth[Auth 中间件 JWT HS256]
Auth --> Router[路由 /api/v1/*]
Router --> Proxy[反向代理]
Proxy --> Classes[classes 服务]
4. 核心流程图
sequenceDiagram
Client->>GW: GET /api/v1/classes
GW->>GW: 验证 JWT
GW->>GW: 注入 x-user-id
GW->>Classes: 转发请求
Classes-->>GW: 响应
GW-->>Client: 响应
5. 对外契约
- 路由:
/api/v1/classes/*→ classes 服务 - P2 起增加:
/api/v1/iam/*、/api/v1/exams/*等 - 鉴权: JWT HS256(P1)/ RS256(P2 起)
6. 依赖关系
- 下游服务: classes 服务(P1)、IAM(P2)、CoreEdu(P3)
- 无上游依赖
7. 架构约束
- 仅路由转发,不含业务逻辑
- P1 用 HS256,P2 改 RS256(IAM 签发)
- 不直接访问数据库
8. 架构决策
- P1 不做限流/熔断(P6 硬化)
- 请求 ID 由 Gateway 注入,全链路传递
- [ ] **Step 2: 创建 classes README**
Create `e:\Desktop\Edu\docs\modules\classes\README.md`:
```markdown
# Classes 服务(黄金模板)
## 1. 模块职责
班级域 CRUD 服务。P1 作为黄金模板,完整实现所有横切关注点,后续服务复制此模板。
## 2. 技术栈
| 技术 | 版本 |
|------|------|
| Node.js | 20 |
| NestJS | 10 |
| Drizzle ORM | 0.31 |
| MySQL | 8.0 |
| pino | 9 |
| OpenTelemetry | 0.53 |
| prom-client | 15 |
| vitest | 2 |
## 3. 架构图
```mermaid
graph TB
GW[API Gateway] --> Ctrl[ClassesController]
Ctrl --> AuthMW[Auth Middleware]
AuthMW --> Svc[ClassesService]
Svc --> Repo[ClassesRepository]
Repo --> DB[(MySQL classes 表)]
Ctrl --> ErrFilter[Global Error Filter]
Obs[Observability] --> Logger[pino Logger]
Obs --> Metrics[Prometheus /metrics]
Obs --> Tracer[OTel Tracer]
4. 核心流程图
sequenceDiagram
participant GW as API Gateway
participant Ctrl as Controller
participant Svc as Service
participant Repo as Repository
participant DB as MySQL
GW->>Ctrl: POST /classes
Ctrl->>Ctrl: Zod 验证
Ctrl->>Svc: create(dto)
Svc->>Repo: create(newClass)
Repo->>DB: INSERT
DB-->>Repo: 结果
Repo-->>Svc: Class
Svc-->>Ctrl: ClassResponse
Ctrl-->>GW: { success: true, data }
5. 对外契约
- REST API:
POST/GET/PUT/DELETE /classes - proto:
packages/shared-proto/proto/classes.proto(P3 起转 gRPC) - 响应信封:
{ success: boolean, data?: T, error?: { code, message, details, traceId } }
6. 依赖关系
- 上游: API Gateway
- 下游: MySQL(classes 表)
- 中间件: auth / permission / data-scope
- 共享: shared/errors、shared/observability、shared/i18n
7. 架构约束
- 黄金模板:所有横切关注点必须落地
- 单文件行数 ≤ 500(组件)/ 800(service/repository)
- 错误处理用 ApplicationError 体系
- 日志含 traceId/userId/service
- 权限校验用 requirePermission()
8. 架构决策(黄金模板复用清单)
后续服务复制时需修改:
- 错误码前缀
CLASSES_*→ 新服务前缀 - proto 文件
- 业务逻辑(controller/service/repository/schema)
- README 模块名与架构图
- CI workflow 服务名
- Dockerfile 入口(如需要)
无需修改(直接复用):
- shared/errors/application-error.ts
- shared/observability/*
- shared/i18n/error-codes.ts 结构
- middleware/* 结构
- config/* 结构
- test/ 目录结构
- [ ] **Step 3: 运行 arch:scan 验证**
Run:
```bash
cd e:\Desktop\Edu
npm run arch:scan
npm run arch:query -- stats
npm run arch:query -- violations
Expected:
-
stats 输出模块/符号/依赖计数 > 0
-
violations 输出为空(0 违规)
-
Step 4: 启动服务端到端验证
Run:
# 终端 1: 启动基础设施
cd e:\Desktop\Edu
docker compose -f infra/docker-compose.minimal.yml up -d
# 终端 2: 启动 classes 服务
cd e:\Desktop\Edu\services\classes
pnpm dev
# 终端 3: 启动 API Gateway
cd e:\Desktop\Edu\services\api-gateway
go run main.go
# 终端 4: 启动 teacher-portal
cd e:\Desktop\Edu\apps\teacher-portal
pnpm dev
打开浏览器访问 http://localhost:3000,创建班级、刷新列表、删除班级,验证全链路。
- Step 5: 验证 P1 退出标准
Run:
cd e:\Desktop\Edu
# 验证契约
cd packages/shared-proto && pnpm exec buf lint && cd ../..
# 验证编译
pnpm -r exec tsc --noEmit
cd services/api-gateway && go build ./... && cd ../..
# 验证测试
pnpm -r run test
# 验证 arch.db
npm run arch:scan
npm run arch:query -- violations
确认全部通过后继续。
- Step 6: 更新 known-issues 工作日志
Edit e:\Desktop\Edu\docs\troubleshooting\known-issues.md,在工作经验日志表格末尾追加:
| 2026-07-07 | 全局 | P1 完成:新仓库 + monorepo + Docker + API Gateway + classes 黄金模板 + 文档体系 + CI。验证 classes CRUD 端到端链路通畅。 |
- Step 7: 打 tag 并提交
Run:
cd e:\Desktop\Edu
git add -A
git commit -m "docs: add module READMEs and complete P1 documentation"
git tag v0.1.0-p1
git push origin main
git push origin v0.1.0-p1
# 若推送失败,再次推送:
git push origin main && git push origin v0.1.0-p1
- Step 8: P1 验收检查清单
逐项确认 P1 退出标准:
- 基础设施:
docker-compose up -d全量容器健康 - 链路:浏览器创建班级 → MySQL 落库 → 列表显示
- 横切关注点:错误处理 + 可观测 + 安全 + 契约 + 测试 + 文档 + 配置 + i18n + CI + Dockerfile 全部落地
- 契约:
buf lint+buf breaking通过,三端代码生成成功 - CI:三语言 workflow 配置就绪
- 测试:单元 + 集成 + 契约 + E2E 四类测试齐备,覆盖率达标
- 文档:004 + 模块 README + known-issues 完整
验收完成
P1 地基阶段全部任务完成后,仓库应包含:
- 多语言 monorepo 骨架(pnpm + go.work + pyproject)
- Docker Compose 全量与最小基础设施配置
- API Gateway(Go)路由转发 + JWT 校验
- classes 黄金模板服务(TS/NestJS)含全横切关注点
- shared-proto 契约包 + buf 配置
- arch.db 多语言扫描器骨架
- teacher-portal 测试页
- 三语言 CI workflow
- 完整文档体系(004 + 模块 README + known-issues + project_rules + coding-standards + git-workflow)
打 tag v0.1.0-p1,进入 P2 身份阶段。