- 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
4245 lines
112 KiB
Markdown
4245 lines
112 KiB
Markdown
# 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:
|
||
```bash
|
||
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`:
|
||
```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`:
|
||
```ini
|
||
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`:
|
||
```markdown
|
||
# 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`:
|
||
```markdown
|
||
# Contributing
|
||
|
||
## 开发环境要求
|
||
|
||
- Node.js 20+
|
||
- pnpm 9+
|
||
- Go 1.22+
|
||
- Python 3.12+
|
||
- Docker Desktop
|
||
- buf 1.40+ (`go install github.com/bufbuild/buf/cmd/buf@latest`)
|
||
|
||
## 开发流程
|
||
|
||
1. 从 main 创建 feature 分支:`git checkout -b feature/xxx`
|
||
2. 开发并确保 CI 通过:`pnpm lint && pnpm test && pnpm build`
|
||
3. 提交 PR 到 main
|
||
4. CI 全绿后合并
|
||
|
||
## 提交规范
|
||
|
||
使用 Conventional Commits:
|
||
|
||
```
|
||
<type>(<scope>): <description>
|
||
|
||
types: feat, fix, chore, docs, style, refactor, test, perf, ci
|
||
scope: 服务名或包名(如 api-gateway, classes, shared-proto)
|
||
```
|
||
|
||
示例:
|
||
- `feat(api-gateway): add JWT auth middleware`
|
||
- `fix(classes): resolve N+1 query in list classes`
|
||
- `docs(004): update service dependency graph`
|
||
|
||
## 文档同步
|
||
|
||
修改代码后必须:
|
||
1. 运行 `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`:
|
||
```markdown
|
||
# 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`:
|
||
```bash
|
||
# 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`:
|
||
```markdown
|
||
# 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
|
||
```
|
||
|
||
## 文档
|
||
|
||
- [架构设计](docs/architecture/004_architecture_impact_map.md)
|
||
- [理想蓝图](docs/architecture/0010_architecture.md)
|
||
- [编码规范](docs/standards/coding-standards.md)
|
||
- [已知问题](docs/troubleshooting/known-issues.md)
|
||
- [项目规则](project_rules.md)
|
||
|
||
## 开发阶段
|
||
|
||
- **P1 地基** (M1-M3): 仓库骨架 + 基础设施 + classes 黄金模板
|
||
- **P2 身份** (M4-M6): IAM 服务 + Teacher BFF + 微前端骨架
|
||
- **P3 核心教学** (M7-M10): CoreEdu + Outbox + 考试/作业/成绩
|
||
- **P4 内容分析** (M11-M13): Content + DataAna + CDC
|
||
- **P5 沟通AI** (M14-M16): Msg + Push + AI + ES
|
||
- **P6 硬化** (M17-M18): Service Mesh + 生产硬化
|
||
```
|
||
|
||
- [ ] **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`:
|
||
```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`:
|
||
```yaml
|
||
packages:
|
||
- 'packages/*'
|
||
- 'services/classes'
|
||
- 'bff/*'
|
||
- 'apps/*'
|
||
- 'scripts/*'
|
||
```
|
||
|
||
- [ ] **Step 3: 安装根依赖**
|
||
|
||
Run:
|
||
```bash
|
||
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`:
|
||
```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`:
|
||
```javascript
|
||
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:
|
||
```bash
|
||
cd e:\Desktop\Edu
|
||
pnpm exec husky init
|
||
echo "pnpm exec commitlint --edit \$1" > .husky/commit-msg
|
||
```
|
||
Expected: `.husky/commit-msg` 文件创建。
|
||
|
||
- [ ] **Step 8: 提交**
|
||
|
||
Run:
|
||
```bash
|
||
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`:
|
||
```yaml
|
||
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`:
|
||
```yaml
|
||
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`:
|
||
```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:
|
||
```bash
|
||
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:
|
||
```bash
|
||
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:
|
||
```bash
|
||
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`:
|
||
```markdown
|
||
# 架构设计意图(多服务版)
|
||
|
||
> 本文档是 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`:
|
||
```markdown
|
||
# 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`:
|
||
```markdown
|
||
# 已知问题速查(索引式)
|
||
|
||
> 场景→技术/规则映射,不写代码示例。新经验按时间倒序追加到"工作经验日志"。
|
||
|
||
## 全局经验
|
||
|
||
### 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`:
|
||
```markdown
|
||
# 项目规则(多语言版)
|
||
|
||
## 架构图优先规则
|
||
|
||
**任何任务开始前,必须先查阅架构影响地图,通过图定位代码和模块。**
|
||
|
||
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/<service>/
|
||
├─ src/
|
||
│ ├─ main.ts # bootstrap + OTel 初始化
|
||
│ ├─ config/ # 三层配置
|
||
│ ├─ middleware/ # auth/permission/data-scope
|
||
│ ├─ <domain>/ # 业务模块
|
||
│ ├─ 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:
|
||
```bash
|
||
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`:
|
||
```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:
|
||
```bash
|
||
cd e:\Desktop\Edu
|
||
pnpm install
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 schema.ts(数据库 schema 定义)**
|
||
|
||
Create `e:\Desktop\Edu\scripts\arch-scan\schema.ts`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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:
|
||
```bash
|
||
cd e:\Desktop\Edu
|
||
npm run arch:scan
|
||
npm run arch:query -- stats
|
||
```
|
||
Expected: 输出模块/符号/依赖统计(P1 此时可能为 0,因为还没有服务代码)。
|
||
|
||
- [ ] **Step 10: 提交**
|
||
|
||
Run:
|
||
```bash
|
||
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`:
|
||
```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`:
|
||
```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`:
|
||
```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`:
|
||
```protobuf
|
||
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:
|
||
```bash
|
||
cd e:\Desktop\Edu\packages\shared-proto
|
||
pnpm exec buf lint
|
||
```
|
||
Expected: 无 lint 错误。
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
Run:
|
||
```bash
|
||
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:
|
||
```bash
|
||
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`:
|
||
```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`:
|
||
```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`:
|
||
```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 完整内容:
|
||
```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`:
|
||
```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`:
|
||
```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:
|
||
```bash
|
||
cd e:\Desktop\Edu\services\api-gateway
|
||
go mod tidy
|
||
go build ./...
|
||
```
|
||
Expected: 编译成功,无错误。
|
||
|
||
- [ ] **Step 8: 提交**
|
||
|
||
Run:
|
||
```bash
|
||
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`:
|
||
```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`:
|
||
```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`:
|
||
```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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
// 错误码 → 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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```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:
|
||
```bash
|
||
cd e:\Desktop\Edu
|
||
pnpm install
|
||
cd services/classes
|
||
pnpm exec tsc --noEmit
|
||
```
|
||
Expected: 编译通过(可能有 classes.module 不存在的错误,下一个 Task 创建)。
|
||
|
||
- [ ] **Step 18: 提交**
|
||
|
||
Run:
|
||
```bash
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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:
|
||
```bash
|
||
cd e:\Desktop\Edu\services\classes
|
||
pnpm add @paralleldrive/cuid2 zod
|
||
pnpm exec tsc --noEmit
|
||
```
|
||
Expected: 编译通过。
|
||
|
||
- [ ] **Step 8: 提交**
|
||
|
||
Run:
|
||
```bash
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
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:
|
||
```bash
|
||
cd e:\Desktop\Edu\services\classes
|
||
pnpm test
|
||
```
|
||
Expected: 单元测试通过(4 个 describe 全绿),集成/契约/E2E 骨架测试通过。
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
Run:
|
||
```bash
|
||
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`:
|
||
```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`:
|
||
```javascript
|
||
/** @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`:
|
||
```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`:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
'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:
|
||
```bash
|
||
cd e:\Desktop\Edu
|
||
pnpm install
|
||
cd apps/teacher-portal
|
||
pnpm exec tsc --noEmit
|
||
```
|
||
Expected: 编译通过。
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
Run:
|
||
```bash
|
||
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`:
|
||
```yaml
|
||
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`:
|
||
```yaml
|
||
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`:
|
||
```yaml
|
||
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:
|
||
```bash
|
||
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`:
|
||
```markdown
|
||
# 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. 核心流程图
|
||
|
||
```mermaid
|
||
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. 核心流程图
|
||
|
||
```mermaid
|
||
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. 架构决策(黄金模板复用清单)
|
||
|
||
后续服务复制时需修改:
|
||
1. 错误码前缀 `CLASSES_*` → 新服务前缀
|
||
2. proto 文件
|
||
3. 业务逻辑(controller/service/repository/schema)
|
||
4. README 模块名与架构图
|
||
5. CI workflow 服务名
|
||
6. 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:
|
||
```bash
|
||
# 终端 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:
|
||
```bash
|
||
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`,在工作经验日志表格末尾追加:
|
||
|
||
```markdown
|
||
| 2026-07-07 | 全局 | P1 完成:新仓库 + monorepo + Docker + API Gateway + classes 黄金模板 + 文档体系 + CI。验证 classes CRUD 端到端链路通畅。 |
|
||
```
|
||
|
||
- [ ] **Step 7: 打 tag 并提交**
|
||
|
||
Run:
|
||
```bash
|
||
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 身份阶段。
|