Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ea34fe53 | ||
|
|
7474a92e3b | ||
|
|
9850bfcfd1 | ||
|
|
23246ade6d |
107
docs/architecture/004-p6-addendum.md
Normal file
107
docs/architecture/004-p6-addendum.md
Normal file
@@ -0,0 +1,107 @@
|
||||
## 15. P6 生产硬化(补记)
|
||||
|
||||
> 本章为 `004_architecture_impact_map.md` 的 P6 阶段补记,记录生产硬化引入的横切关注点与基础设施栈。
|
||||
> 维护规则与正文一致:源码变更后同步 `npm run arch:scan` 更新 arch.db。
|
||||
|
||||
### 15.1 横切关注点矩阵
|
||||
|
||||
| 关注点 | NestJS 服务 | Python 服务 | Go 网关 | 实现位置 |
|
||||
|--------|-------------|-------------|---------|----------|
|
||||
| 健康检查 | HealthController | health.py | /healthz | shared/health, src/health |
|
||||
| 优雅停机 | LifecycleService | FastAPI lifespan | enableShutdownHooks | shared/lifecycle |
|
||||
| 熔断 | 经 Gateway | 经 Gateway | gobreaker v2 | api-gateway/middleware |
|
||||
| 限流 | 经 Gateway | 经 Gateway | token bucket | api-gateway/middleware |
|
||||
| 链路追踪 | tracer.ts | OpenTelemetry | OpenTelemetry | shared/observability |
|
||||
| 指标 | metrics.ts | prometheus_fastapi | prometheus | shared/observability |
|
||||
| 日志 | logger.ts | structlog | zap | shared/observability |
|
||||
| 错误处理 | global-error.filter | exception handler | middleware | shared/errors |
|
||||
|
||||
### 15.2 API Gateway 中间件链
|
||||
|
||||
请求流经顺序(出向到下游服务):
|
||||
|
||||
```
|
||||
请求入口
|
||||
→ WAF(规则匹配)
|
||||
→ CORS
|
||||
→ 限流(token bucket,按 route+tenant)
|
||||
→ 熔断(gobreaker v2,按下游服务)
|
||||
→ 重试(指数退避,仅幂等)
|
||||
→ 链路追踪注入
|
||||
→ 转发到下游
|
||||
→ 响应 → 指标记录 → 返回
|
||||
```
|
||||
|
||||
### 15.3 可观测性栈
|
||||
|
||||
```
|
||||
应用层(NestJS / Python / Go)
|
||||
→ OpenTelemetry SDK(trace + metrics)
|
||||
→ OTLP exporter
|
||||
→ 采集层
|
||||
├─ Prometheus(metrics)
|
||||
├─ Tempo / Jaeger(trace)
|
||||
└─ Loki / ELK(log)
|
||||
→ 展示层
|
||||
├─ Grafana(仪表盘)
|
||||
└─ Alertmanager(告警路由)
|
||||
```
|
||||
|
||||
关键指标命名约定:
|
||||
- `http_request_duration_seconds`(histogram,含 service/route/status 维度)
|
||||
- `circuit_breaker_state`(gauge,0=Closed / 1=Open / 2=HalfOpen)
|
||||
- `rate_limiter_rejected_total`(counter)
|
||||
- `db_connections_in_use`(gauge)
|
||||
- `kafka_consumer_lag`(gauge)
|
||||
|
||||
### 15.4 安全栈
|
||||
|
||||
| 层 | 机制 | 配置位置 |
|
||||
|----|------|----------|
|
||||
| 边缘 | WAF + DDoS 防护 | Cloudflare / 入口 LB |
|
||||
| 网关 | JWT 校验 + 限流 + CORS | api-gateway |
|
||||
| 服务 | requirePermission 权限点 | modules/*/actions |
|
||||
| 数据 | 字段加密 + 审计日志 | data-access |
|
||||
| 密钥 | KMS + K8s Secret + 轮换 | deploy/k8s/secrets |
|
||||
| 传输 | mTLS(服务间,可选)+ TLS(边缘) | mesh / ingress |
|
||||
|
||||
### 15.5 健康检查约定
|
||||
|
||||
- `GET /healthz`:liveness,仅返回进程存活,不检查依赖,避免滚动重启雪崩
|
||||
- `GET /readyz`:readiness,检查 DB 等关键依赖,失败返回 503
|
||||
- K8s 探针:livenessProbe → /healthz,readinessProbe → /readyz
|
||||
- Python 服务 readyz 简化为 ok + TODO,待依赖客户端就绪后补全
|
||||
- 无需鉴权,必须在路由白名单中放行
|
||||
|
||||
### 15.6 优雅停机约定
|
||||
|
||||
- NestJS:`app.enableShutdownHooks()` 注册 SIGTERM/SIGINT 钩子
|
||||
- LifecycleService 实现 OnApplicationShutdown,按序关闭:Kafka producer → Redis → DataSource
|
||||
- K8s:`terminationGracePeriodSeconds=60`,preStop hook 可加 sleep 5s 摘流量
|
||||
- Python:FastAPI lifespan shutdown 事件,关闭连接池
|
||||
- 销毁顺序理由:先停外部消息生产(避免新事件),再关缓存,最后关 DB
|
||||
|
||||
### 15.7 灾难恢复策略
|
||||
|
||||
- 备份:CronJob 每 15min,PostgreSQL + Redis + Kafka offset
|
||||
- 恢复:`scripts/restore/`,月度演练验证 RTO
|
||||
- 多 AZ:Pod 反亲和 + DB 同步复制 + Redis 哨兵 + Kafka ISR=2
|
||||
- DNS 切换:区域级故障,TTL=60s,季度演练
|
||||
|
||||
### 15.8 与正文章节的对应
|
||||
|
||||
| 本章小节 | 对应正文章节 |
|
||||
|----------|--------------|
|
||||
| 横切关注点 | 第 3 章 共享内核 |
|
||||
| 中间件链 | 第 5 章 API Gateway |
|
||||
| 可观测性 | 第 10 章 可观测性 |
|
||||
| 安全栈 | 第 11 章 安全 |
|
||||
| 健康检查 | 第 6 章 服务边界 |
|
||||
| 灾难恢复 | 第 12 章 部署与运维 |
|
||||
|
||||
### 15.9 同步要求
|
||||
|
||||
新增导出符号需在落地到 Edu 仓库后运行 `npm run arch:scan` 更新 arch.db:
|
||||
- `HealthController`、`HealthModule`(5 个 NestJS 服务)
|
||||
- `LifecycleService`(5 个 NestJS 服务)
|
||||
- `health.py` router(ai、data-ana)
|
||||
171
docs/architecture/runbooks/incident-response.md
Normal file
171
docs/architecture/runbooks/incident-response.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 事件响应手册
|
||||
|
||||
> 目标:规范生产事件的分级、响应、处置与复盘,确保 RTO ≤ 30min
|
||||
> 关联:`docs/architecture/runbooks/p6-hardening.md`
|
||||
|
||||
## 1. 事件分级
|
||||
|
||||
| 级别 | 定义 | 影响 | 响应时效 | 升级 |
|
||||
|------|------|------|----------|------|
|
||||
| P0 | 全站不可用 / 核心数据损坏 | 全部用户 | 5 分钟内响应 | 立即升级至 CTO |
|
||||
| P1 | 核心功能不可用 / 关键 SLO 破坏 | 大量用户 | 10 分钟内响应 | 升级至服务负责人 |
|
||||
| P2 | 部分功能降级 / 非核心故障 | 部分用户 | 30 分钟内响应 | 服务负责人跟进 |
|
||||
| P3 | 单点告警 / 潜在风险 | 少量/无用户 | 工作时间内响应 | On-Call 自行处理 |
|
||||
|
||||
### 1.1 分级示例
|
||||
|
||||
- P0:网关全挂、主 DB 不可用且无法故障转移、数据丢失超过 RPO
|
||||
- P1:登录不可用、课程播放不可用、Kafka 生产阻塞
|
||||
- P2:消息推送延迟、报表生成失败、单个非核心服务宕机
|
||||
- P3:单 Pod 重启、磁盘使用率告警、慢查询告警
|
||||
|
||||
## 2. 响应流程
|
||||
|
||||
```
|
||||
发现 → 确认 → 升级 → 处理 → 恢复 → 复盘
|
||||
```
|
||||
|
||||
### 2.1 发现
|
||||
|
||||
- 告警来源:Prometheus / Alertmanager / 用户反馈 / 人工巡检
|
||||
- 第一动作:在 On-Call 群贴告警,标注收到时间
|
||||
|
||||
### 2.2 确认
|
||||
|
||||
- On-Call 5 分钟内确认告警真实性
|
||||
- 排除误报(探针抖动、已知维护窗口)
|
||||
- 初步定级,创建事故工单(Jira / 飞书项目)
|
||||
|
||||
### 2.3 升级
|
||||
|
||||
- 超出处置能力 → 立即升级
|
||||
- P0/P1 → 拉事故群,通知相关服务 Owner
|
||||
- 涉及外部公告 → 通知客服与公关
|
||||
|
||||
### 2.4 处理
|
||||
|
||||
- 遵循"先恢复,后定位"原则
|
||||
- 优先使用预案:回滚、扩容、降级、熔断、切换
|
||||
- 每个操作记录时间戳与执行人
|
||||
- 关键决策需事故指挥确认
|
||||
|
||||
### 2.5 恢复
|
||||
|
||||
- 健康检查通过、SLO 恢复
|
||||
- 观察 15 分钟确认稳定
|
||||
- 关闭事故工单,进入复盘
|
||||
|
||||
### 2.6 复盘
|
||||
|
||||
- 48 小时内提交复盘报告
|
||||
- 无 blame 文化:对事不对人
|
||||
- 输出改进项,录入 `docs/architecture/roadmap/tech-debt.md`
|
||||
|
||||
## 3. On-Call 轮值
|
||||
|
||||
### 3.1 轮值制度
|
||||
|
||||
- 主备双人轮值,每周轮换
|
||||
- 工作日:9:00-21:00 主,其余备
|
||||
- 节假日:全天主备
|
||||
- 交接:周一 10:00 站会交接,遗留问题清单
|
||||
|
||||
### 3.2 联络方式
|
||||
|
||||
- 电话:主 + 备 + 升级链
|
||||
- 即时通讯:飞书 On-Call 群
|
||||
- 告警:PagerDuty / 飞书机器人
|
||||
|
||||
### 3.3 响应要求
|
||||
|
||||
- P0/P1:电话 5 分钟内接听
|
||||
- 告警确认:群内 5 分钟内回复
|
||||
- 无法响应:自动升级至备值
|
||||
|
||||
## 4. 沟通模板
|
||||
|
||||
### 4.1 事故通报(初报)
|
||||
|
||||
```
|
||||
【事故通报】<P级别> - <简述>
|
||||
时间:<YYYY-MM-DD HH:MM>
|
||||
级别:<P0/P1/P2/P3>
|
||||
影响:<受影响功能/用户范围>
|
||||
现状:<已知信息>
|
||||
负责人:<On-Call>
|
||||
下一步:<计划动作>
|
||||
```
|
||||
|
||||
### 4.2 进展更新(每 30 分钟或重大变化)
|
||||
|
||||
```
|
||||
【进展更新】<事故标题>
|
||||
时间:<HH:MM>
|
||||
进展:<自上次以来发生/完成的事>
|
||||
当前状态:<仍受影响的功能>
|
||||
下一步:<接下来 30 分钟计划>
|
||||
```
|
||||
|
||||
### 4.3 恢复通知
|
||||
|
||||
```
|
||||
【恢复通知】<事故标题>
|
||||
恢复时间:<HH:MM>
|
||||
持续时长:<时长>
|
||||
原因:<根因摘要>
|
||||
影响:<最终影响评估>
|
||||
后续:<复盘会时间>
|
||||
```
|
||||
|
||||
### 4.4 复盘报告
|
||||
|
||||
```
|
||||
【复盘报告】<事故标题>
|
||||
时间:<起止时间>
|
||||
级别:<P级别>
|
||||
影响:<用户/功能/数据>
|
||||
时间线:<关键事件时间轴>
|
||||
根因:<5why 分析>
|
||||
处置:<做了什么、有效/无效>
|
||||
改进项:<TODO + 负责人 + 截止>
|
||||
经验:<可沉淀到 known-issues / runbook 的内容>
|
||||
```
|
||||
|
||||
## 5. 常见事故处置
|
||||
|
||||
### 5.1 DB 主从切换
|
||||
|
||||
1. 确认主库故障(健康检查、连接超时)
|
||||
2. 触发自动故障转移(Patroni / Orchestrator)
|
||||
3. 若自动失败,手动 `./scripts/db/failover.sh --service <svc>`
|
||||
4. 更新连接配置 / 刷新连接池
|
||||
5. 验证读写正常、数据位点
|
||||
6. 旧主恢复后作为从库加入
|
||||
|
||||
### 5.2 Kafka 消费堆积
|
||||
|
||||
1. 查看堆积:`kubectl exec -- kafka-consumer-lag`
|
||||
2. 定位慢消费者:查日志、trace
|
||||
3. 扩容消费者副本(HPA 或手动)
|
||||
4. 若处理逻辑慢:临时降级非核心处理
|
||||
5. 堆积消化后恢复
|
||||
6. 复盘:扩容阈值、消费者并发配置
|
||||
|
||||
### 5.3 服务雪崩
|
||||
|
||||
1. 确认雪崩源头(哪个下游故障)
|
||||
2. 确认熔断器已打开(Gateway 状态)
|
||||
3. 若未打开:手动 `./scripts/gateway/circuit-breaker.sh open <service>`
|
||||
4. 降级非核心功能
|
||||
5. 扩容上游服务应对重试流量
|
||||
6. 修复下游、半开试探、逐步恢复
|
||||
7. 复盘:熔断参数、依赖隔离
|
||||
|
||||
## 附录:升级链
|
||||
|
||||
| 级别 | 第一响应 | 升级 1 | 升级 2 | 升级 3 |
|
||||
|------|----------|--------|--------|--------|
|
||||
| P0 | On-Call | 服务负责人 | 架构负责人 | CTO |
|
||||
| P1 | On-Call | 服务负责人 | 架构负责人 | - |
|
||||
| P2 | On-Call | 服务负责人 | - | - |
|
||||
| P3 | On-Call | - | - | - |
|
||||
264
docs/architecture/runbooks/p6-hardening.md
Normal file
264
docs/architecture/runbooks/p6-hardening.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# P6 生产硬化 Runbook
|
||||
|
||||
> 阶段:P6 生产硬化
|
||||
> 目标指标:RPO ≤ 15min,RTO ≤ 30min,P99 ≤ 500ms
|
||||
> 维护者:SRE 团队
|
||||
> 关联文档:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/roadmap/tech-debt.md`、`docs/architecture/runbooks/incident-response.md`
|
||||
|
||||
## 1. 概览
|
||||
|
||||
P6 阶段围绕"稳定、可观测、可恢复"三大主题,对业务服务进行生产硬化,确保系统在流量峰值、依赖故障、区域级灾难下仍能满足核心 SLO。
|
||||
|
||||
### 1.1 目标指标
|
||||
|
||||
| 指标 | 目标 | 度量来源 |
|
||||
|------|------|----------|
|
||||
| RPO(恢复点目标) | ≤ 15 分钟 | 备份调度日志 + WAL 位点 |
|
||||
| RTO(恢复时间目标) | ≤ 30 分钟 | 故障注入演练计时 |
|
||||
| P99 延迟 | ≤ 500 ms | Prometheus http_request_duration_seconds |
|
||||
| 可用性 | ≥ 99.9% | 多区域健康探针汇总 |
|
||||
| 错误率 | ≤ 0.1% | Gateway 5xx 比率 |
|
||||
|
||||
### 1.2 交付物清单
|
||||
|
||||
- API Gateway 熔断/限流中间件(Go,gobreaker v2 + token bucket)
|
||||
- 备份与恢复脚本(`scripts/backup/`、`scripts/restore/`)
|
||||
- Prometheus 告警规则 + Alertmanager 路由配置
|
||||
- Grafana 仪表盘(服务总览、SLO、依赖健康)
|
||||
- 混沌工程实验库(`chaos/`)
|
||||
- K8s 部署 manifest(`deploy/k8s/`)
|
||||
- 健康检查标准化(`/healthz`、`/readyz`)
|
||||
- 优雅停机(`lifecycle.service.ts` + `app.enableShutdownHooks()`)
|
||||
|
||||
## 2. 熔断与限流
|
||||
|
||||
### 2.1 中间件使用
|
||||
|
||||
API Gateway(Go)在路由链路上统一接入:
|
||||
- 熔断器:`github.com/sony/gobreaker/v2`,按下游服务维度建桶
|
||||
- 限流:基于 `sync.Map` 的令牌桶,按 `route + tenant` 维度限流
|
||||
|
||||
熔断器配置示例(伪代码):
|
||||
|
||||
```go
|
||||
cb := gobreaker.NewCircuitBreaker[any](gobreaker.Settings{
|
||||
Name: "core-edu",
|
||||
MaxRequests: 5, // 半开态最大试探请求
|
||||
Interval: 60 * time.Second, // 计数窗口
|
||||
Timeout: 30 * time.Second, // 开启态冷却
|
||||
ReadyToTrip: func(c gobreaker.Counts) bool {
|
||||
return c.ConsecutiveFailures > 5 || c.TotalFailures/c.TotalRequests > 0.5
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 2.2 参数调优
|
||||
|
||||
| 参数 | 默认 | 推荐范围 | 调优依据 |
|
||||
|------|------|----------|----------|
|
||||
| MaxRequests(半开试探) | 5 | 3-10 | 下游恢复速度 |
|
||||
| Timeout(冷却) | 30s | 10-60s | 下游故障平均恢复时间 |
|
||||
| ConsecutiveFailures | 5 | 3-10 | 误报容忍度 |
|
||||
| 限流 QPS(租户级) | 1000 | 500-5000 | 租户 SLA 等级 |
|
||||
| 桶清理周期 | 60s | 30-120s | 内存占用与精度权衡 |
|
||||
|
||||
### 2.3 故障演练流程
|
||||
|
||||
1. 在预发环境注入下游延迟(tc/netem 500ms+)
|
||||
2. 观察熔断器状态切换:Closed → Open → Half-Open → Closed
|
||||
3. 验证限流桶在窗口边界正确清理(sync.Map + ticker)
|
||||
4. 记录 P99 与错误率到演练报告
|
||||
5. 回滚注入:`./chaos/rollback.sh <experiment>`
|
||||
|
||||
## 3. 备份与恢复
|
||||
|
||||
### 3.1 备份脚本
|
||||
|
||||
- 位置:`scripts/backup/backup-cron.sh`
|
||||
- 调度:K8s CronJob,每 15 分钟一次(满足 RPO ≤ 15min)
|
||||
- 内容:PostgreSQL 全量 + WAL 归档 + Redis RDB + Kafka topic offset 快照
|
||||
- 产物:写入对象存储(MinIO/S3),保留策略 7d(日备 30d)
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
./scripts/backup/backup-cron.sh --service iam --type full
|
||||
./scripts/backup/backup-cron.sh --service iam --type incr
|
||||
```
|
||||
|
||||
### 3.2 恢复演练流程
|
||||
|
||||
1. 在隔离环境拉起空集群
|
||||
2. 执行 `./scripts/restore/restore.sh --service <svc> --backup-id <id>`
|
||||
3. 校验数据一致性(行数、最新位点、校验和)
|
||||
4. 记录恢复耗时,验证 RTO ≤ 30min
|
||||
5. 演练频率:每月一次
|
||||
|
||||
### 3.3 RPO 验证方法
|
||||
|
||||
- 对比最近一次备份时间与当前时间差
|
||||
- 查询 `backup_log` 表 `last_success_at` 字段
|
||||
- 告警:连续 2 个周期(30min)未成功备份 → P1
|
||||
|
||||
## 4. 监控告警
|
||||
|
||||
### 4.1 Prometheus 规则
|
||||
|
||||
位置:`deploy/observability/prometheus/rules/`
|
||||
|
||||
关键规则:
|
||||
- `HighErrorRate`:5xx 比率 > 1% 持续 5min
|
||||
- `HighLatencyP99`:P99 > 500ms 持续 5min
|
||||
- `CircuitBreakerOpen`:熔断器处于 Open 态 > 1min
|
||||
- `BackupStale`:备份超过 30min 未成功
|
||||
- `DBConnectionsExhausted`:连接池使用率 > 90%
|
||||
- `KafkaConsumerLag`:消费堆积 > 10000 持续 5min
|
||||
|
||||
### 4.2 Alertmanager 路由
|
||||
|
||||
- P0/P1 → PagerDuty + 电话
|
||||
- P2 → 飞书群 + 邮件
|
||||
- P3 → 飞书群
|
||||
- 抑制:同一服务 5min 内同告警只发一次(inhibit + group_by)
|
||||
|
||||
### 4.3 Grafana 仪表盘
|
||||
|
||||
| 仪表盘 | 用途 | 关键面板 |
|
||||
|--------|------|----------|
|
||||
| Service Overview | 服务总览 | QPS、P99、错误率、熔断状态 |
|
||||
| SLO Dashboard | SLO 跟踪 | 可用性、错误预算消耗 |
|
||||
| Dependency Health | 依赖健康 | DB/Redis/Kafka 连接与延迟 |
|
||||
| Backup Status | 备份状态 | 最近备份、RPO、恢复演练 |
|
||||
|
||||
## 5. 混沌工程
|
||||
|
||||
### 5.1 实验清单
|
||||
|
||||
| 实验 | 注入方式 | 预期表现 | 频率 |
|
||||
|------|----------|----------|------|
|
||||
| DB 主节点宕机 | kill postgres | 自动故障转移,RTO<30min | 月 |
|
||||
| Redis 不可达 | iptables 拒绝 | 降级到本地缓存 | 月 |
|
||||
| Kafka broker 宕机 | kill broker | 生产重试,消费堆积可控 | 月 |
|
||||
| 下游服务延迟 | tc netem +500ms | 熔断器打开,错误率<1% | 周 |
|
||||
| 网络分区 | iptables 隔离 | 多可用区切换 | 季 |
|
||||
| 磁盘满 | fill disk | 告警触发,写入降级 | 季 |
|
||||
|
||||
### 5.2 执行流程
|
||||
|
||||
1. 选择实验 → 在 `chaos/` 选择对应 YAML
|
||||
2. 预检:确认告警通道、回滚脚本就绪
|
||||
3. 执行:`kubectl apply -f chaos/<experiment>.yaml`
|
||||
4. 观察:Grafana + 日志
|
||||
5. 回滚:`./chaos/rollback.sh <experiment>`
|
||||
6. 复盘:记录到 `docs/architecture/runbooks/incident-response.md`
|
||||
|
||||
## 6. 安全加固
|
||||
|
||||
### 6.1 WAF 规则
|
||||
|
||||
- SQL 注入、XSS 模式匹配
|
||||
- 路径穿越、命令注入
|
||||
- 限速:单 IP > 100req/s 拦截
|
||||
- 地域封禁(按需)
|
||||
|
||||
### 6.2 密钥管理
|
||||
|
||||
- 密钥存储:K8s Secret + 外部 KMS(Vault)
|
||||
- 轮换:DB 密码每 90 天,JWT 签名密钥每 180 天
|
||||
- 注入:通过环境变量 / 挂载卷,禁止入镜像
|
||||
- 审计:所有密钥访问记录到 audit log
|
||||
|
||||
### 6.3 CORS 策略
|
||||
|
||||
- 允许来源:白名单域名(生产环境严格)
|
||||
- 允许方法:GET/POST/PUT/PATCH/DELETE
|
||||
- 凭证:允许(Cookie)
|
||||
- 预检缓存:600s
|
||||
|
||||
## 7. K8s 部署
|
||||
|
||||
### 7.1 Manifest 说明
|
||||
|
||||
位置:`deploy/k8s/`,每个服务一组 manifest:
|
||||
|
||||
- `deployment.yaml`:副本数、资源、探针、优雅停机
|
||||
- `service.yaml`:ClusterIP
|
||||
- `hpa.yaml`:CPU>70% 扩容,min=3 max=20
|
||||
- `poddisruptionbudget.yaml`:minAvailable=2
|
||||
- `networkpolicy.yaml`:限制出向
|
||||
|
||||
探针配置:
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet: { path: /healthz, port: 3000 }
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet: { path: /readyz, port: 3000 }
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
```
|
||||
|
||||
优雅停机:
|
||||
|
||||
```yaml
|
||||
terminationGracePeriodSeconds: 60
|
||||
```
|
||||
|
||||
### 7.2 Helm 化路线
|
||||
|
||||
- P6:原生 manifest(快速验证)
|
||||
- P7:抽取 Helm Chart,values.yaml 按环境区分
|
||||
- P8:引入 Argo CD GitOps 自动同步
|
||||
|
||||
## 8. 灾难恢复
|
||||
|
||||
### 8.1 RTO/RPO 目标
|
||||
|
||||
| 场景 | RTO | RPO |
|
||||
|------|-----|-----|
|
||||
| 单 Pod 故障 | 30s | 0 |
|
||||
| 单节点故障 | 2min | 0 |
|
||||
| 单可用区故障 | 10min | 0 |
|
||||
| 区域级灾难 | 30min | 15min |
|
||||
|
||||
### 8.2 多可用区策略
|
||||
|
||||
- K8s 集群跨 3 可用区,Pod 反亲和
|
||||
- DB 主从跨可用区同步复制
|
||||
- Redis 哨兵跨可用区
|
||||
- Kafka min.insync.replicas=2,跨可用区 broker
|
||||
|
||||
### 8.3 DNS 切换
|
||||
|
||||
- 区域级故障:通过全局 DNS(Cloudflare/Route53)切换到备用区域
|
||||
- 健康检查:每 10s 探测,连续 3 次失败自动切换
|
||||
- TTL:60s(快速切换)
|
||||
- 演练:每季度一次 DNS 切换演练
|
||||
|
||||
## 9. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|
||||
|------|----------|----------|----------|
|
||||
| 5xx 激增 | 下游服务故障 | 查 Grafana 熔断状态、下游健康 | 确认熔断器已打开,扩容下游 |
|
||||
| P99 升高 | DB 慢查询/连接耗尽 | 查 PG 慢日志、连接池 | 加索引/扩连接池/限流 |
|
||||
| 消费堆积 | 消费者慢/宕机 | 查 Kafka lag、消费者日志 | 扩消费者、修 bug |
|
||||
| 备份失败 | 存储/网络/凭证 | 查 backup-cron 日志 | 修凭证、清理旧备份 |
|
||||
| 健康检查失败 | DB 不可达 | 查 DB 状态、网络 | 故障转移、恢复 DB |
|
||||
| Pod 频繁重启 | OOM/探针失败 | 查 kubectl describe、内存 | 调资源/修探针 |
|
||||
| 熔断不恢复 | 下游未恢复 | 查 Half-Open 试探结果 | 修复下游、调 Timeout |
|
||||
| 限流误杀 | 桶配置过低 | 查限流日志、QPS | 调高桶容量 |
|
||||
| DNS 切换无效 | TTL 缓存 | 查 DNS 解析链 | 等待 TTL / 清缓存 |
|
||||
| 跨区延迟高 | 跨区流量 | 查网络拓扑 | 调亲和性就近访问 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:关联文档
|
||||
|
||||
- 架构影响地图:`docs/architecture/004_architecture_impact_map.md`
|
||||
- P6 架构补记:`docs/architecture/004-p6-addendum.md`
|
||||
- 事件响应:`docs/architecture/runbooks/incident-response.md`
|
||||
- 已知问题:`docs/troubleshooting/known-issues.md`
|
||||
- P6 已知问题补丁:`docs/troubleshooting/known-issues-p6-addendum.md`
|
||||
- 技术债务:`docs/architecture/roadmap/tech-debt.md`
|
||||
24
docs/troubleshooting/known-issues-p6-addendum.md
Normal file
24
docs/troubleshooting/known-issues-p6-addendum.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# known-issues P6 补丁
|
||||
|
||||
> 本文件为 `docs/troubleshooting/known-issues.md` 的 P6 阶段补丁,列出 P6 新增的"场景→技术"映射。
|
||||
> 合并方式:将下表条目追加到原文件对应分区,遵循索引式速查规范,不写代码示例。
|
||||
|
||||
## P6 生产硬化新增场景
|
||||
|
||||
| 场景 | 技术方案 |
|
||||
|------|----------|
|
||||
| 熔断器状态切换(Closed/Open/Half-Open) | gobreaker v2 ReadyToTrip 回调按连续失败数 + 失败率判定 |
|
||||
| 限流桶按租户维度清理 | sync.Map + ticker 周期清理过期桶,避免内存泄漏 |
|
||||
| 备份脚本定时调度 | K8s CronJob + backup-cron.sh,15min 一次满足 RPO |
|
||||
| PostgreSQL WAL 归档恢复到时间点 | pg_receivewal 归档 + PITR 恢复 |
|
||||
| Redis 增量备份 | BGSAVE 触发 + RDB 文件上传对象存储 |
|
||||
| Kafka 消费位点快照 | __consumer_offsets topic dump 到对象存储 |
|
||||
| 熔断器半开态试探限流 | MaxRequests 限制并发试探,避免恢复期二次过载 |
|
||||
| 健康探针分流 liveness/readiness | /healthz 仅进程存活,/readyz 检查依赖,避免滚动重启雪崩 |
|
||||
| 优雅停机等待 in-flight 请求 | app.enableShutdownHooks + terminationGracePeriodSeconds=60 |
|
||||
| Kafka producer 关闭前 flush | producer.disconnect() 内部 flush,避免消息丢失 |
|
||||
| 数据源销毁顺序 | 先 Kafka、再 Redis、最后 DB,避免反向依赖阻塞 |
|
||||
| 混沌实验回滚 | rollback.sh 按实验名清理 tc/iptables 规则 |
|
||||
| DNS 切换消除缓存 | TTL=60s + 等待 + 客户端清缓存,避免切换无效 |
|
||||
| 告警抑制去重 | Alertmanager inhibit + group_by + group_wait |
|
||||
| Python 服务就绪检查延迟初始化 | readyz 返回 ok + TODO,避免启动期依赖未就绪导致探针失败 |
|
||||
69
infra/alertmanager/alertmanager.yml
Normal file
69
infra/alertmanager/alertmanager.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
# Alertmanager 配置 - Edu 平台
|
||||
# 全局配置
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
# 告警模板(可扩展)
|
||||
templates:
|
||||
- /etc/alertmanager/templates/*.tmpl
|
||||
|
||||
# 路由树
|
||||
route:
|
||||
# 顶层默认接收器
|
||||
receiver: webhook-default
|
||||
# 按 alertname + service 分组
|
||||
group_by: ['alertname', 'service']
|
||||
# 首次告警等待时间(聚合相同组)
|
||||
group_wait: 30s
|
||||
# 同组新告警发送间隔
|
||||
group_interval: 5m
|
||||
# 重复告警发送间隔
|
||||
repeat_interval: 4h
|
||||
routes:
|
||||
# critical 告警走 webhook
|
||||
- matchers:
|
||||
- severity="critical"
|
||||
receiver: webhook-default
|
||||
continue: false
|
||||
# warning 告警走 webhook
|
||||
- matchers:
|
||||
- severity="warning"
|
||||
receiver: webhook-default
|
||||
continue: false
|
||||
|
||||
# 接收器列表
|
||||
receivers:
|
||||
# 默认 Webhook 接收器(指向内部告警路由服务)
|
||||
- name: webhook-default
|
||||
webhook_configs:
|
||||
- url: http://alert-router:5001/alert
|
||||
send_resolved: true
|
||||
max_alerts: 0
|
||||
|
||||
# 邮件接收器(注释示例,留作扩展)
|
||||
# 启用前需在 global.smtp_* 配置 SMTP 服务器
|
||||
# - name: mail-ops
|
||||
# email_configs:
|
||||
# - to: ops-team@example.com
|
||||
# from: alertmanager@example.com
|
||||
# smarthost: smtp.example.com:587
|
||||
# auth_username: alertmanager@example.com
|
||||
# auth_password: <SMTP_PASSWORD>
|
||||
# require_tls: true
|
||||
# headers:
|
||||
# Subject: '[Edu Alert] {{ .GroupLabels.alertname }}'
|
||||
|
||||
# 钉钉/企业微信接收器(注释示例,留作扩展)
|
||||
# - name: dingtalk-ops
|
||||
# webhook_configs:
|
||||
# - url: http://dingtalk-webhook:8060/dingtalk/ops/send
|
||||
# send_resolved: true
|
||||
|
||||
# 抑制规则:critical 告警抑制同服务同名的 warning 告警
|
||||
inhibit_rules:
|
||||
- source_matchers:
|
||||
- severity="critical"
|
||||
target_matchers:
|
||||
- severity="warning"
|
||||
# 相同 alertname + service 才抑制
|
||||
equal: ['alertname', 'service']
|
||||
74
infra/backup/backup-cron.sh
Normal file
74
infra/backup/backup-cron.sh
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# MySQL 备份 cron 调度入口
|
||||
# 调用 backup-mysql.sh 备份所有微服务数据库,单个失败不阻塞其他
|
||||
# 用法: backup-cron.sh [--keep <days>]
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- 参数解析 ----------
|
||||
KEEP_DAYS=7
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--keep)
|
||||
KEEP_DAYS="${2:-7}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--keep <days>]" >&2
|
||||
echo " --keep 保留天数(默认 7)" >&2
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] 未知参数: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------- 配置 ----------
|
||||
# 每个服务对应一个独立数据库
|
||||
SERVICES=(iam core-edu content msg classes)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_SCRIPT="${SCRIPT_DIR}/backup-mysql.sh"
|
||||
|
||||
# ---------- 日志函数 ----------
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
|
||||
}
|
||||
|
||||
if [[ ! -x "$BACKUP_SCRIPT" ]]; then
|
||||
log "[ERROR] 备份脚本不存在或不可执行: ${BACKUP_SCRIPT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
log "[INFO] 开始批量备份,共 ${#SERVICES[@]} 个服务"
|
||||
|
||||
TOTAL=0
|
||||
SUCCESS=0
|
||||
FAILED=0
|
||||
FAILED_LIST=()
|
||||
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
TOTAL=$((TOTAL + 1))
|
||||
log "[INFO] ---- 备份服务: ${svc} ----"
|
||||
if "$BACKUP_SCRIPT" --service "$svc" --keep "$KEEP_DAYS"; then
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
log "[INFO] 服务 ${svc} 备份成功"
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
FAILED_LIST+=("$svc")
|
||||
log "[ERROR] 服务 ${svc} 备份失败,继续下一个"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------- 汇总 ----------
|
||||
log "[INFO] 批量备份结束: 总计=${TOTAL} 成功=${SUCCESS} 失败=${FAILED}"
|
||||
|
||||
if [[ ${FAILED} -gt 0 ]]; then
|
||||
log "[ERROR] 失败服务列表: ${FAILED_LIST[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
99
infra/backup/backup-mysql.sh
Normal file
99
infra/backup/backup-mysql.sh
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
# MySQL 全量备份脚本(Linux 容器内运行)
|
||||
# 用法: backup-mysql.sh --service <name> [--keep <days>]
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- 参数解析 ----------
|
||||
SERVICE=""
|
||||
KEEP_DAYS=7
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--service)
|
||||
SERVICE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--keep)
|
||||
KEEP_DAYS="${2:-7}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 --service <name> [--keep <days>]" >&2
|
||||
echo " --service 目标微服务数据库名(必填,如 iam/core-edu/content/msg/classes)" >&2
|
||||
echo " --keep 保留天数(默认 7)" >&2
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] 未知参数: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------- 参数校验 ----------
|
||||
if [[ -z "$SERVICE" ]]; then
|
||||
echo "[ERROR] --service 参数必填" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$KEEP_DAYS" =~ ^[0-9]+$ ]] || [[ "$KEEP_DAYS" -lt 1 ]]; then
|
||||
echo "[ERROR] --keep 必须为正整数" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 环境变量 ----------
|
||||
: "${MYSQL_HOST:=mysql}"
|
||||
: "${MYSQL_PORT:=3306}"
|
||||
: "${MYSQL_USER:=root}"
|
||||
: "${MYSQL_PASSWORD:?MYSQL_PASSWORD 环境变量未设置}"
|
||||
|
||||
BACKUP_ROOT="/backups"
|
||||
BACKUP_DIR="${BACKUP_ROOT}/${SERVICE}"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP_FILE="${BACKUP_DIR}/${TIMESTAMP}.sql.gz"
|
||||
|
||||
# ---------- 日志函数 ----------
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
|
||||
}
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
log "[INFO] 开始备份服务: ${SERVICE} (保留 ${KEEP_DAYS} 天)"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
log "[INFO] 执行 mysqldump -> ${BACKUP_FILE}"
|
||||
if ! mysqldump \
|
||||
--host="$MYSQL_HOST" \
|
||||
--port="$MYSQL_PORT" \
|
||||
--user="$MYSQL_USER" \
|
||||
--password="$MYSQL_PASSWORD" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--databases "$SERVICE" \
|
||||
2>/dev/null \
|
||||
| gzip -c > "$BACKUP_FILE"; then
|
||||
log "[ERROR] mysqldump 失败 (service=${SERVICE})"
|
||||
rm -f "$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 校验产物非空
|
||||
if [[ ! -s "$BACKUP_FILE" ]]; then
|
||||
log "[ERROR] 备份文件为空: ${BACKUP_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE_SIZE="$(stat -c %s "$BACKUP_FILE" 2>/dev/null || stat -f %z "$BACKUP_FILE")"
|
||||
log "[INFO] 备份完成: ${BACKUP_FILE} (${FILE_SIZE} bytes)"
|
||||
|
||||
# ---------- 清理过期备份 ----------
|
||||
log "[INFO] 清理超过 ${KEEP_DAYS} 天的旧备份"
|
||||
find "$BACKUP_DIR" -type f -name '*.sql.gz' -mtime +${KEEP_DAYS} -print -delete \
|
||||
| while read -r old_file; do
|
||||
log "[INFO] 已删除: ${old_file}"
|
||||
done
|
||||
|
||||
log "[INFO] 备份流程结束: ${SERVICE}"
|
||||
exit 0
|
||||
94
infra/backup/restore-mysql.sh
Normal file
94
infra/backup/restore-mysql.sh
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# MySQL 恢复脚本(Linux 容器内运行)
|
||||
# 用法: restore-mysql.sh --service <name> --file <path> [--yes]
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- 参数解析 ----------
|
||||
SERVICE=""
|
||||
BACKUP_FILE=""
|
||||
ASSUME_YES=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--service)
|
||||
SERVICE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--file)
|
||||
BACKUP_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--yes)
|
||||
ASSUME_YES=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 --service <name> --file <path> [--yes]" >&2
|
||||
echo " --service 目标微服务数据库名(必填)" >&2
|
||||
echo " --file 备份文件路径 sql.gz(必填)" >&2
|
||||
echo " --yes 跳过确认提示" >&2
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] 未知参数: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------- 参数校验 ----------
|
||||
if [[ -z "$SERVICE" ]]; then
|
||||
echo "[ERROR] --service 参数必填" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$BACKUP_FILE" ]]; then
|
||||
echo "[ERROR] --file 参数必填" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$BACKUP_FILE" ]]; then
|
||||
echo "[ERROR] 备份文件不存在: ${BACKUP_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 环境变量 ----------
|
||||
: "${MYSQL_HOST:=mysql}"
|
||||
: "${MYSQL_PORT:=3306}"
|
||||
: "${MYSQL_USER:=root}"
|
||||
: "${MYSQL_PASSWORD:?MYSQL_PASSWORD 环境变量未设置}"
|
||||
|
||||
# ---------- 日志函数 ----------
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
|
||||
}
|
||||
|
||||
# ---------- 确认提示 ----------
|
||||
log "[WARN] 即将向数据库 [${SERVICE}] 导入备份文件: ${BACKUP_FILE}"
|
||||
log "[WARN] 此操作会覆盖目标数据库现有数据,不可撤销!"
|
||||
|
||||
if [[ "$ASSUME_YES" != "true" ]]; then
|
||||
read -r -p "确认继续? (输入 YES 继续): " confirm
|
||||
if [[ "$confirm" != "YES" ]]; then
|
||||
log "[INFO] 用户取消操作"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
log "[INFO] 开始恢复服务: ${SERVICE}"
|
||||
|
||||
log "[INFO] 解压并导入: ${BACKUP_FILE}"
|
||||
if ! gunzip -c "$BACKUP_FILE" \
|
||||
| mysql \
|
||||
--host="$MYSQL_HOST" \
|
||||
--port="$MYSQL_PORT" \
|
||||
--user="$MYSQL_USER" \
|
||||
--password="$MYSQL_PASSWORD" \
|
||||
"$SERVICE"; then
|
||||
log "[ERROR] mysql 导入失败 (service=${SERVICE})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "[INFO] 恢复完成: ${SERVICE} <- ${BACKUP_FILE}"
|
||||
exit 0
|
||||
45
infra/chaos/README.md
Normal file
45
infra/chaos/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 混沌工程说明
|
||||
|
||||
## 适用范围
|
||||
|
||||
- **仅 Staging 环境**:禁止在生产环境运行混沌实验
|
||||
- **每月 1 次演练**:建议每月第一个工作周执行
|
||||
- **演练窗口**:业务低峰期(02:00 - 05:00)
|
||||
|
||||
## 实验清单
|
||||
|
||||
| 实验 | 注入故障 | 验证目标 |
|
||||
|------|----------|----------|
|
||||
| `pod-delete` | 杀死 50% Pod | 自愈能力、副本数冗余 |
|
||||
| `pod-network-latency` | 注入 200ms 延迟 | 超时与重试、降级策略 |
|
||||
| `disk-fill` | 填充磁盘至 80% | 日志写入、磁盘告警 |
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. Staging 环境已部署完整监控(Prometheus + Alertmanager + Grafana)
|
||||
2. 已配置稳态探针(`steadyStateHypothesis`)
|
||||
3. 已配置告警通道并验证可达
|
||||
4. 演练参与者已就位(运维 + 研发 oncall)
|
||||
|
||||
## 演练流程
|
||||
|
||||
1. **演练前 30 分钟**:通知相关人员,确认稳态基线
|
||||
2. **启动实验**:按顺序执行,单实验单次注入
|
||||
3. **观察监控**:关注告警是否按预期触发、服务是否自愈
|
||||
4. **演练后**:归档实验结果,更新本目录 README
|
||||
|
||||
## 回滚预案
|
||||
|
||||
- **5 分钟内未恢复自动回滚**:通过 Litmus `steadyStateHypothesis` 失败触发实验终止
|
||||
- 手动回滚:
|
||||
```bash
|
||||
kubectl delete chaosengine edu-chaos-experiments -n edu-monitoring
|
||||
kubectl rollout restart deployment/api-gateway -n edu-services
|
||||
```
|
||||
- 极端情况:直接缩容 / 扩容受影响 Deployment
|
||||
|
||||
## 安全约束
|
||||
|
||||
- 每次实验**只注入一种故障**,避免叠加影响判断
|
||||
- 实验前快照数据库(参考 `infra/backup/backup-cron.sh`)
|
||||
- 实验期间禁止发布任何业务变更
|
||||
139
infra/chaos/experiments.yaml
Normal file
139
infra/chaos/experiments.yaml
Normal file
@@ -0,0 +1,139 @@
|
||||
# 混沌实验配置(Litmus Chaos 骨架)
|
||||
# 仅作 YAML 骨架,注释说明用途。实际运行需配合 Litmus Chaos Operator。
|
||||
# 文档:https://litmuschaos.github.io/litmus/
|
||||
apiVersion: litmuschaos.io/v1alpha1
|
||||
kind: ChaosEngine
|
||||
metadata:
|
||||
name: edu-chaos-experiments
|
||||
namespace: edu-monitoring
|
||||
labels:
|
||||
app.kubernetes.io/part-of: edu-platform
|
||||
app.kubernetes.io/component: chaos
|
||||
spec:
|
||||
appinfo:
|
||||
appns: edu-services
|
||||
applabel: "app.kubernetes.io/name=api-gateway"
|
||||
appkind: deployment
|
||||
chaosServiceAccount: litmus-chaos-sa
|
||||
components:
|
||||
experiments:
|
||||
# ============================================================
|
||||
# 实验 1:服务 Pod 杀死(验证自愈与副本数)
|
||||
# ============================================================
|
||||
- name: pod-delete
|
||||
spec:
|
||||
components:
|
||||
env:
|
||||
# 杀死 1 个 Pod
|
||||
- name: TOTAL_CHAOS_DURATION
|
||||
value: "30"
|
||||
- name: CHAOS_INTERVAL
|
||||
value: "10"
|
||||
- name: FORCE
|
||||
value: "false"
|
||||
- name: PODS_AFFECTED_PERC
|
||||
value: "50"
|
||||
- name: TARGET_CONTAINER
|
||||
value: "api-gateway"
|
||||
# 假设:杀死 50% Pod 后,服务仍可对外可用(最少 1 个副本健康)
|
||||
# 稳态:up{job="api-gateway"} >= 1
|
||||
steadyStateHypothesis:
|
||||
steadyStateHypothesis:
|
||||
probe:
|
||||
- name: api-gateway-still-up
|
||||
type: httpProbe
|
||||
mode: Continuous
|
||||
runProperties:
|
||||
probeTimeout: 5
|
||||
httpProbeInputs:
|
||||
url: http://api-gateway.edu-services.svc:8080/healthz
|
||||
method:
|
||||
get: {}
|
||||
phases:
|
||||
- name: pre-chaos
|
||||
description: "混沌前稳态验证"
|
||||
- name: inject
|
||||
description: "注入 Pod 删除"
|
||||
- name: post-chaos
|
||||
description: "混沌后自愈验证"
|
||||
# ============================================================
|
||||
# 实验 2:网络延迟注入(验证超时与重试)
|
||||
# ============================================================
|
||||
- name: pod-network-latency
|
||||
spec:
|
||||
components:
|
||||
env:
|
||||
# 注入 200ms 网络延迟
|
||||
- name: NETWORK_LATENCY
|
||||
value: "200"
|
||||
- name: TOTAL_CHAOS_DURATION
|
||||
value: "60"
|
||||
- name: CHAOS_INTERVAL
|
||||
value: "10"
|
||||
- name: NETWORK_INTERFACE
|
||||
value: "eth0"
|
||||
- name: TARGET_CONTAINER
|
||||
value: "api-gateway"
|
||||
# 假设:200ms 延迟下 P99 < 2s,无 5xx 雪崩
|
||||
# 稳态:错误率 < 5%
|
||||
steadyStateHypothesis:
|
||||
steadyStateHypothesis:
|
||||
probe:
|
||||
- name: error-rate-below-threshold
|
||||
type: promProbe
|
||||
mode: Continuous
|
||||
runProperties:
|
||||
probeTimeout: 5
|
||||
promProbeInputs:
|
||||
source:
|
||||
url: http://prometheus.edu-monitoring.svc:9090
|
||||
query: |
|
||||
sum(rate(http_requests_total{service="api-gateway",status=~"5.."}[1m]))
|
||||
/ sum(rate(http_requests_total{service="api-gateway"}[1m]))
|
||||
comparator:
|
||||
criteria: "<="
|
||||
value: "0.05"
|
||||
phases:
|
||||
- name: pre-chaos
|
||||
description: "混沌前稳态验证"
|
||||
- name: inject
|
||||
description: "注入 200ms 网络延迟"
|
||||
- name: post-chaos
|
||||
description: "混沌后恢复验证"
|
||||
# ============================================================
|
||||
# 实验 3:磁盘填充(验证磁盘压力下日志写入与告警)
|
||||
# ============================================================
|
||||
- name: disk-fill
|
||||
spec:
|
||||
components:
|
||||
env:
|
||||
# 填充至 80% 磁盘使用率
|
||||
- name: FILL_PERCENTAGE
|
||||
value: "80"
|
||||
- name: TOTAL_CHAOS_DURATION
|
||||
value: "120"
|
||||
- name: CHAOS_INTERVAL
|
||||
value: "30"
|
||||
- name: TARGET_CONTAINER
|
||||
value: "api-gateway"
|
||||
# 假设:磁盘 80% 使用率下服务仍可写入日志,触发 DiskSpaceLow 告警
|
||||
# 稳态:服务 /healthz 可用
|
||||
steadyStateHypothesis:
|
||||
steadyStateHypothesis:
|
||||
probe:
|
||||
- name: api-gateway-healthz
|
||||
type: httpProbe
|
||||
mode: Continuous
|
||||
runProperties:
|
||||
probeTimeout: 5
|
||||
httpProbeInputs:
|
||||
url: http://api-gateway.edu-services.svc:8080/healthz
|
||||
method:
|
||||
get: {}
|
||||
phases:
|
||||
- name: pre-chaos
|
||||
description: "混沌前稳态验证"
|
||||
- name: inject
|
||||
description: "填充磁盘至 80%"
|
||||
- name: post-chaos
|
||||
description: "混沌后清理与恢复验证"
|
||||
119
infra/docker-compose.monitoring.yml
Normal file
119
infra/docker-compose.monitoring.yml
Normal file
@@ -0,0 +1,119 @@
|
||||
# 监控栈 docker-compose(独立 profile)
|
||||
# 使用方式: docker compose -f docker-compose.monitoring.yml --profile monitoring up -d
|
||||
version: "3.9"
|
||||
|
||||
networks:
|
||||
edu-network:
|
||||
name: edu-network
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
prometheus-data:
|
||||
name: edu-prometheus-data
|
||||
alertmanager-data:
|
||||
name: edu-alertmanager-data
|
||||
grafana-data:
|
||||
name: edu-grafana-data
|
||||
|
||||
services:
|
||||
# ============================================================
|
||||
# Prometheus - 指标采集与存储
|
||||
# ============================================================
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.54.1
|
||||
container_name: edu-prometheus
|
||||
profiles: ["monitoring"]
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=15d"
|
||||
- "--web.enable-lifecycle"
|
||||
- "--web.enable-admin-api"
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./prometheus/rules.yml:/etc/prometheus/rules.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
networks:
|
||||
- edu-network
|
||||
|
||||
# ============================================================
|
||||
# Alertmanager - 告警路由与抑制
|
||||
# ============================================================
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0
|
||||
container_name: edu-alertmanager
|
||||
profiles: ["monitoring"]
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--config.file=/etc/alertmanager/alertmanager.yml"
|
||||
- "--storage.path=/alertmanager"
|
||||
ports:
|
||||
- "9093:9093"
|
||||
volumes:
|
||||
- ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- alertmanager-data:/alertmanager
|
||||
networks:
|
||||
- edu-network
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
# ============================================================
|
||||
# Grafana - 可视化
|
||||
# ============================================================
|
||||
grafana:
|
||||
image: grafana/grafana:11.2.2
|
||||
container_name: edu-grafana
|
||||
profiles: ["monitoring"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin}
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=false
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- grafana-data:/var/lib/grafana
|
||||
networks:
|
||||
- edu-network
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
# ============================================================
|
||||
# node-exporter - 主机指标采集
|
||||
# ============================================================
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.8.2
|
||||
container_name: edu-node-exporter
|
||||
profiles: ["monitoring"]
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--path.rootfs=/host"
|
||||
ports:
|
||||
- "9100:9100"
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/host:ro
|
||||
networks:
|
||||
- edu-network
|
||||
|
||||
# ============================================================
|
||||
# blackbox-exporter - 黑盒探测(HTTP / TCP / ICMP)
|
||||
# ============================================================
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:v0.25.0
|
||||
container_name: edu-blackbox-exporter
|
||||
profiles: ["monitoring"]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9115:9115"
|
||||
volumes:
|
||||
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||
networks:
|
||||
- edu-network
|
||||
394
infra/grafana/dashboards/microservices-overview.json
Normal file
394
infra/grafana/dashboards/microservices-overview.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Edu 平台微服务总览 - QPS / 错误率 / P99 延迟 / 熔断器状态",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": true,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"expr": "sum(rate(http_requests_total[1m])) by (service)",
|
||||
"legendFormat": "{{service}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "请求 QPS(按服务)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": true,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "line"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 0.05
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) by (service) / sum(rate(http_requests_total[5m])) by (service)",
|
||||
"legendFormat": "{{service}} 5xx",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "错误率(5xx 占比)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": true,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "line"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["mean", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service))",
|
||||
"legendFormat": "{{service}} P99",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "P99 延迟",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": {
|
||||
"color": "green",
|
||||
"index": 0,
|
||||
"text": "CLOSED"
|
||||
},
|
||||
"1": {
|
||||
"color": "red",
|
||||
"index": 1,
|
||||
"text": "OPEN"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"expr": "max(circuit_breaker_state{state=\"open\"}) by (service)",
|
||||
"legendFormat": "{{service}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "熔断器状态(0=CLOSED, 1=OPEN)",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": ["edu", "microservices", "overview"],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Edu 微服务总览",
|
||||
"uid": "edu-microservices-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
15
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
15
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Grafana 仪表盘 provisioning
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: edu-dashboards
|
||||
orgId: 1
|
||||
folder: "Edu"
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
# 从该目录加载所有仪表盘 JSON
|
||||
path: /var/lib/grafana/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
15
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
15
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Grafana 数据源 provisioning
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
timeInterval: "15s"
|
||||
httpMethod: POST
|
||||
manageAlerts: false
|
||||
55
infra/k8s/README.md
Normal file
55
infra/k8s/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# K8s 部署说明(骨架)
|
||||
|
||||
> **说明**:本目录为 K8s 部署**骨架**,仅提供最小可用 manifest,**生产环境请使用 Helm Chart**。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 开发 / Staging 环境快速拉起
|
||||
- 验证服务在 K8s 上的基本可用性
|
||||
- 作为后续 Helm 化的过渡参考
|
||||
|
||||
## Manifest 类别
|
||||
|
||||
| 文件 | 类别 | 用途 |
|
||||
|------|------|------|
|
||||
| `namespace.yaml` | namespace | 划分 4 个命名空间:system / services / monitoring / ingress |
|
||||
| `configmap-*.yaml` | configmap | 非敏感配置(服务端口、特性开关等) |
|
||||
| `secret-*.yaml` | secret | 敏感配置(密钥、连接串),生产用 External Secrets |
|
||||
| `*-deployment.yaml` | deployment | 服务无状态部署,含探针 / 资源 / 副本数 |
|
||||
| `*-service.yaml` | service | ClusterIP 服务发现 |
|
||||
| `ingress.yaml` | ingress | 对外入口,TLS 终结 + 路由 |
|
||||
| `*-hpa.yaml` | hpa | 水平自动扩缩容 |
|
||||
|
||||
## 命名空间规划
|
||||
|
||||
| Namespace | 用途 |
|
||||
|-----------|------|
|
||||
| `edu-system` | 系统组件(数据库代理、配置等) |
|
||||
| `edu-services` | 业务微服务(api-gateway / iam / core-edu / content / msg) |
|
||||
| `edu-monitoring` | 监控栈(Prometheus / Grafana / Alertmanager) |
|
||||
| `edu-ingress` | 入口控制器(NGINX Ingress / cert-manager) |
|
||||
|
||||
## 部署顺序
|
||||
|
||||
```bash
|
||||
kubectl apply -f namespace.yaml
|
||||
kubectl apply -f configmap-*.yaml
|
||||
kubectl apply -f secret-*.yaml # 生产请改用 External Secrets
|
||||
kubectl apply -f *-deployment.yaml
|
||||
kubectl apply -f *-service.yaml
|
||||
kubectl apply -f ingress.yaml
|
||||
kubectl apply -f *-hpa.yaml
|
||||
```
|
||||
|
||||
## Helm 化路线(后续)
|
||||
|
||||
1. 将骨架迁移为 Helm Chart(`charts/edu-platform/`)
|
||||
2. 每个微服务一个子 Chart,统一通过 umbrella chart 编排
|
||||
3. 环境差异通过 values-<env>.yaml 管理
|
||||
4. 敏感配置接入 External Secrets Operator(对接 Vault / KMS)
|
||||
5. CI/CD 通过 ArgoCD / Flux 做 GitOps 部署
|
||||
|
||||
## 当前文件
|
||||
|
||||
- `namespace.yaml`:4 个命名空间定义
|
||||
- `api-gateway-deployment.yaml`:api-gateway Deployment + Service 骨架
|
||||
125
infra/k8s/api-gateway-deployment.yaml
Normal file
125
infra/k8s/api-gateway-deployment.yaml
Normal file
@@ -0,0 +1,125 @@
|
||||
# api-gateway Deployment + Service 骨架
|
||||
# 生产环境请通过 Helm Chart 管理,此处仅作骨架参考
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: api-gateway
|
||||
namespace: edu-services
|
||||
labels:
|
||||
app.kubernetes.io/name: api-gateway
|
||||
app.kubernetes.io/part-of: edu-platform
|
||||
app.kubernetes.io/component: gateway
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: api-gateway
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: api-gateway
|
||||
app.kubernetes.io/part-of: edu-platform
|
||||
app.kubernetes.io/component: gateway
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
containers:
|
||||
- name: api-gateway
|
||||
image: edu/api-gateway:latest # 生产请固定 tag,避免 latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
# 存活探针:失败触发重启
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
# 就绪探针:失败从 Service Endpoints 摘除
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 2
|
||||
resources:
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "256Mi"
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "1Gi"
|
||||
env:
|
||||
# 从 ConfigMap 引用非敏感配置
|
||||
- name: NODE_ENV
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: api-gateway-config
|
||||
key: NODE_ENV
|
||||
- name: LOG_LEVEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: api-gateway-config
|
||||
key: LOG_LEVEL
|
||||
- name: MYSQL_HOST
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: api-gateway-config
|
||||
key: MYSQL_HOST
|
||||
# 从 Secret 引用敏感配置
|
||||
- name: MYSQL_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api-gateway-secret
|
||||
key: MYSQL_PASSWORD
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api-gateway-secret
|
||||
key: JWT_SECRET
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api-gateway-secret
|
||||
key: REDIS_PASSWORD
|
||||
# 生产建议挂载 /tmp 并设置 readOnlyRootFilesystem: true
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
---
|
||||
# api-gateway Service(ClusterIP)
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: api-gateway
|
||||
namespace: edu-services
|
||||
labels:
|
||||
app.kubernetes.io/name: api-gateway
|
||||
app.kubernetes.io/part-of: edu-platform
|
||||
app.kubernetes.io/component: gateway
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: api-gateway
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
33
infra/k8s/namespace.yaml
Normal file
33
infra/k8s/namespace.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
# K8s 命名空间定义 - Edu 平台
|
||||
# 划分 4 个命名空间:system / services / monitoring / ingress
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: edu-system
|
||||
labels:
|
||||
app.kubernetes.io/part-of: edu-platform
|
||||
app.kubernetes.io/component: system
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: edu-services
|
||||
labels:
|
||||
app.kubernetes.io/part-of: edu-platform
|
||||
app.kubernetes.io/component: services
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: edu-monitoring
|
||||
labels:
|
||||
app.kubernetes.io/part-of: edu-platform
|
||||
app.kubernetes.io/component: monitoring
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: edu-ingress
|
||||
labels:
|
||||
app.kubernetes.io/part-of: edu-platform
|
||||
app.kubernetes.io/component: ingress
|
||||
122
infra/prometheus/rules.yml
Normal file
122
infra/prometheus/rules.yml
Normal file
@@ -0,0 +1,122 @@
|
||||
# Prometheus 告警规则 - Edu 平台
|
||||
# 分组:服务可用性 / 性能 / 资源
|
||||
groups:
|
||||
# ============================================================
|
||||
# 服务可用性
|
||||
# ============================================================
|
||||
- name: service-availability
|
||||
rules:
|
||||
# 服务宕机
|
||||
- alert: ServiceDown
|
||||
expr: up == 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
category: availability
|
||||
annotations:
|
||||
summary: "服务宕机 {{ $labels.instance }}"
|
||||
description: "目标 {{ $labels.job }} / {{ $labels.instance }} 已离线超过 1 分钟。"
|
||||
|
||||
# 熔断器打开
|
||||
- alert: CircuitBreakerOpen
|
||||
expr: circuit_breaker_state{state="open"} == 1
|
||||
for: 30s
|
||||
labels:
|
||||
severity: critical
|
||||
category: availability
|
||||
annotations:
|
||||
summary: "熔断器打开 {{ $labels.service }}"
|
||||
description: "服务 {{ $labels.service }} 熔断器处于 open 状态,可能正在拒绝请求。"
|
||||
|
||||
# ============================================================
|
||||
# 性能
|
||||
# ============================================================
|
||||
- name: performance
|
||||
rules:
|
||||
# 高错误率(5xx 占比 > 5%)
|
||||
- alert: HighErrorRate
|
||||
expr: |
|
||||
(sum(rate(http_requests_total{status=~"5.."}[5m])) by (service)
|
||||
/ sum(rate(http_requests_total[5m])) by (service))
|
||||
> 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
category: performance
|
||||
annotations:
|
||||
summary: "错误率过高 {{ $labels.service }}"
|
||||
description: "服务 {{ $labels.service }} 5xx 错误率超过 5%,持续 5 分钟。"
|
||||
|
||||
# P99 延迟过高
|
||||
- alert: HighLatencyP99
|
||||
expr: |
|
||||
histogram_quantile(0.99,
|
||||
sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service))
|
||||
> 1
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
category: performance
|
||||
annotations:
|
||||
summary: "P99 延迟过高 {{ $labels.service }}"
|
||||
description: "服务 {{ $labels.service }} P99 延迟超过 1 秒,持续 5 分钟。"
|
||||
|
||||
# Kafka 消费者积压
|
||||
- alert: KafkaConsumerLag
|
||||
expr: kafka_consumergroup_lag > 1000
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
category: performance
|
||||
annotations:
|
||||
summary: "Kafka 消费者积压 {{ $labels.consumergroup }}"
|
||||
description: "消费组 {{ $labels.consumergroup }} 在 topic {{ $labels.topic }} 上积压超过 1000 条,持续 10 分钟。"
|
||||
|
||||
# ============================================================
|
||||
# 资源
|
||||
# ============================================================
|
||||
- name: resources
|
||||
rules:
|
||||
# MySQL 连接数过高
|
||||
- alert: MySQLConnectionsHigh
|
||||
expr: |
|
||||
(mysql_global_status_threads_connected
|
||||
/ mysql_global_variables_max_connections)
|
||||
> 0.8
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
category: resource
|
||||
annotations:
|
||||
summary: "MySQL 连接数过高 {{ $labels.instance }}"
|
||||
description: "MySQL 实例 {{ $labels.instance }} 连接数超过最大连接数的 80%,持续 5 分钟。"
|
||||
|
||||
# 磁盘空间不足
|
||||
- alert: DiskSpaceLow
|
||||
expr: |
|
||||
(100
|
||||
- (node_filesystem_avail_bytes
|
||||
/ node_filesystem_size_bytes * 100))
|
||||
> 85
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
category: resource
|
||||
annotations:
|
||||
summary: "磁盘空间不足 {{ $labels.instance }}"
|
||||
description: "节点 {{ $labels.instance }} 挂载点 {{ $labels.mountpoint }} 磁盘使用率超过 85%,持续 10 分钟。"
|
||||
|
||||
# 内存使用过高
|
||||
- alert: MemoryHigh
|
||||
expr: |
|
||||
(1
|
||||
- (node_memory_MemAvailable_bytes
|
||||
/ node_memory_MemTotal_bytes))
|
||||
> 0.9
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
category: resource
|
||||
annotations:
|
||||
summary: "内存使用过高 {{ $labels.instance }}"
|
||||
description: "节点 {{ $labels.instance }} 内存使用率超过 90%,持续 5 分钟。"
|
||||
50
infra/security/secrets.example.env
Normal file
50
infra/security/secrets.example.env
Normal file
@@ -0,0 +1,50 @@
|
||||
# Edu 平台密钥示例环境变量文件
|
||||
# ============================================================
|
||||
# 警告:本文件仅作示例,禁止包含真实密钥。
|
||||
# 生产环境请通过 K8s Secret / External Secrets / Vault 注入。
|
||||
# 复制为 .env 后用真实值替换所有 <placeholder>。
|
||||
|
||||
# ---------- MySQL ----------
|
||||
# 用途:MySQL root 用户密码,用于初始化与备份/恢复
|
||||
# 最小长度:32 字符
|
||||
# 复杂度:含大小写字母 + 数字 + 特殊符号
|
||||
MYSQL_ROOT_PASSWORD=<replace-with-32-char-strong-password>
|
||||
|
||||
# ---------- JWT ----------
|
||||
# 用途:JWT Access Token 签名密钥(HS256)
|
||||
# 最小长度:64 字符(建议使用 openssl rand -base64 48 生成)
|
||||
# 注意:旋转后所有已签发的 Access Token 立即失效
|
||||
JWT_SECRET=<replace-with-64-char-jwt-signing-secret>
|
||||
|
||||
# 用途:JWT Refresh Token 签名密钥(HS256)
|
||||
# 最小长度:64 字符
|
||||
# 注意:与 JWT_SECRET 必须不同;旋转后所有用户需重新登录
|
||||
JWT_REFRESH_SECRET=<replace-with-64-char-refresh-signing-secret>
|
||||
|
||||
# ---------- Kafka ----------
|
||||
# 用途:Kafka SASL/PLAIN 认证密码
|
||||
# 最小长度:24 字符
|
||||
KAFKA_SASL_PASSWORD=<replace-with-24-char-kafka-password>
|
||||
|
||||
# ---------- Elasticsearch ----------
|
||||
# 用途:Elasticsearch 内置 elastic 用户密码
|
||||
# 最小长度:24 字符
|
||||
ES_PASSWORD=<replace-with-24-char-es-password>
|
||||
|
||||
# ---------- Neo4j ----------
|
||||
# 用途:Neo4j 数据库管理员密码
|
||||
# 最小长度:24 字符
|
||||
NEO4J_PASSWORD=<replace-with-24-char-neo4j-password>
|
||||
|
||||
# ---------- Redis ----------
|
||||
# 用途:Redis ACL 默认用户密码
|
||||
# 最小长度:24 字符
|
||||
# 注意:生产环境建议启用 ACL,按用户分配最小权限
|
||||
REDIS_PASSWORD=<replace-with-24-char-redis-password>
|
||||
|
||||
# ---------- 应用层加密 ----------
|
||||
# 用途:应用层字段级加密密钥(AES-256-GCM)
|
||||
# 最小长度:32 字节(base64 编码后约 44 字符)
|
||||
# 生成:openssl rand -base64 32
|
||||
# 注意:旋转前需先解密所有已加密字段,旋转后重新加密
|
||||
ENCRYPTION_KEY=<replace-with-base64-32-byte-aes-key>
|
||||
64
infra/security/waf-rules.conf
Normal file
64
infra/security/waf-rules.conf
Normal file
@@ -0,0 +1,64 @@
|
||||
# ModSecurity 兼容 WAF 规则骨架 - Edu 平台
|
||||
# ============================================================
|
||||
# 说明:本文件为 ModSecurity SecRule 语法骨架,
|
||||
# 生产部署前请配合 OWASP CRS 使用并完成规则调优。
|
||||
# 参考:https://github.com/SpiderLabs/ModSecurity
|
||||
# https://github.com/coreruleset/coreruleset
|
||||
|
||||
# ---------- 基础配置 ----------
|
||||
# 启用规则引擎
|
||||
SecRuleEngine On
|
||||
|
||||
# 请求体大小限制(10MB)
|
||||
SecRequestBodyLimit 10485760
|
||||
|
||||
# 默认动作:记录日志 + 拒绝(生产前调整)
|
||||
SecDefaultAction "phase:2,log,deny,status:403"
|
||||
|
||||
# ============================================================
|
||||
# 规则 1:SQL 注入检测
|
||||
# 检测常见 SQL 注入关键字与元字符
|
||||
# ============================================================
|
||||
SecRule ARGS|ARGS_NAMES|REQUEST_COOKIES|REQUEST_COOKIES_NAMES|REQUEST_BODY|XML:/* \
|
||||
"(?i)(union\s+select|select\s+.*\s+from|insert\s+into|update\s+.*\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|exec\s*\(|;\s*drop\s|--\s|/\*.*\*/|or\s+1\s*=\s*1|and\s+1\s*=\s*1|'\s*or\s*'|sleep\s*\(|benchmark\s*\(|load_file\s*\(|into\s+outfile)" \
|
||||
"id:1001,phase:2,log,deny,status:403,msg:'SQL Injection attempt',tag:'attack-sqli',severity:CRITICAL"
|
||||
|
||||
# ============================================================
|
||||
# 规则 2:XSS 检测
|
||||
# 检测常见 XSS payload 与事件处理器
|
||||
# ============================================================
|
||||
SecRule ARGS|ARGS_NAMES|REQUEST_COOKIES|REQUEST_COOKIES_NAMES|REQUEST_BODY|XML:/* \
|
||||
"(?i)(<script|</script>|javascript:|onerror\s*=|onload\s*=|onclick\s*=|onmouseover\s*=|onfocus\s*=|onblur\s*=|<iframe|<img[^>]+src\s*=|<svg[^>]+onload|document\.cookie|window\.location|eval\s*\(|alert\s*\(|prompt\s*\(|String\.fromCharCode)" \
|
||||
"id:1002,phase:2,log,deny,status:403,msg:'XSS attempt',tag:'attack-xss',severity:CRITICAL"
|
||||
|
||||
# ============================================================
|
||||
# 规则 3:路径遍历检测
|
||||
# 检测 ../ 与编码变体,防止读取敏感文件
|
||||
# ============================================================
|
||||
SecRule ARGS|ARGS_NAMES|REQUEST_FILENAME|REQUEST_URI|REQUEST_BODY \
|
||||
"(?i)(\.\./|\.\.\\|\.\.%2f|\.\.%5c|%2e%2e%2f|%2e%2e%5c|/etc/passwd|/etc/shadow|/etc/hosts|/proc/self/environ|c:\\windows\\win\.ini|c:\\boot\.ini|\.\./\.\./\.\./)" \
|
||||
"id:1003,phase:2,log,deny,status:403,msg:'Path Traversal attempt',tag:'attack-lfi',severity:CRITICAL"
|
||||
|
||||
# ============================================================
|
||||
# 规则 4:User-Agent 黑名单
|
||||
# 拦截已知恶意扫描器与爬虫
|
||||
# ============================================================
|
||||
SecRule REQUEST_HEADERS:User-Agent \
|
||||
"(?i)(sqlmap|nikto|nmap|nessus|acunetix|wpscan|hydra|metasploit|burpcollaborator|masscan|zgrab|dirbuster|gobuster|fuzzer|webinspect|appscan|jaeles|xrkmd)" \
|
||||
"id:1004,phase:1,log,deny,status:403,msg:'Malicious User-Agent blocked',tag:'attack-scanner',severity:WARNING"
|
||||
|
||||
# ============================================================
|
||||
# 规则 5:远程文件包含(RFI)检测
|
||||
# 检测通过 URL 参数引入远程文件
|
||||
# ============================================================
|
||||
SecRule ARGS|ARGS_NAMES \
|
||||
"(?i)(^(http|https|ftp|php|data)://|(http|https|ftp|php|data)://.*\?(php|http|https|ftp|data)://)" \
|
||||
"id:1005,phase:2,log,deny,status:403,msg:'Remote File Inclusion attempt',tag:'attack-rfi',severity:CRITICAL"
|
||||
|
||||
# ============================================================
|
||||
# 规则 6:命令注入检测
|
||||
# 检测 shell 元字符与命令执行关键字
|
||||
# ============================================================
|
||||
SecRule ARGS|ARGS_NAMES|REQUEST_BODY|REQUEST_COOKIES \
|
||||
"(?i)(;\s*(ls|cat|id|whoami|uname|pwd|wget|curl|nc|bash|sh|python|perl|ruby)\s|\$\(|`|&&\s*\w+\s|\|\|\s*\w+\s|\|\s*(ls|cat|id|whoami|uname|pwd|wget|curl|nc|bash|sh|python|perl|ruby)\s|/bin/(ba)?sh|/bin/cat|/usr/bin/(wget|curl|python|perl))" \
|
||||
"id:1006,phase:2,log,deny,status:403,msg:'Command Injection attempt',tag:'attack-rce',severity:CRITICAL"
|
||||
60
packages/shared-proto/proto/ai.proto
Normal file
60
packages/shared-proto/proto/ai.proto
Normal file
@@ -0,0 +1,60 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package next_edu_cloud.ai.v1;
|
||||
|
||||
service AiService {
|
||||
rpc Chat(ChatRequest) returns (ChatResponse);
|
||||
rpc StreamChat(ChatRequest) returns (stream ChatChunk);
|
||||
rpc GenerateQuestion(GenerateQuestionRequest) returns (GeneratedQuestion);
|
||||
rpc OptimizeExpression(OptimizeExpressionRequest) returns (OptimizedExpression);
|
||||
}
|
||||
|
||||
message ChatRequest {
|
||||
repeated ChatMessage messages = 1;
|
||||
string model = 2;
|
||||
double temperature = 3;
|
||||
}
|
||||
|
||||
message ChatMessage {
|
||||
string role = 1;
|
||||
string content = 2;
|
||||
}
|
||||
|
||||
message ChatResponse {
|
||||
string content = 1;
|
||||
string model = 2;
|
||||
Usage usage = 3;
|
||||
}
|
||||
|
||||
message Usage {
|
||||
int32 prompt_tokens = 1;
|
||||
int32 completion_tokens = 2;
|
||||
int32 total_tokens = 3;
|
||||
}
|
||||
|
||||
message ChatChunk {
|
||||
string content = 1;
|
||||
bool done = 2;
|
||||
}
|
||||
|
||||
message GenerateQuestionRequest {
|
||||
string prompt = 1;
|
||||
string subject = 2;
|
||||
string difficulty = 3;
|
||||
}
|
||||
|
||||
message GeneratedQuestion {
|
||||
string question = 1;
|
||||
string answer = 2;
|
||||
string explanation = 3;
|
||||
}
|
||||
|
||||
message OptimizeExpressionRequest {
|
||||
string text = 1;
|
||||
string context = 2;
|
||||
}
|
||||
|
||||
message OptimizedExpression {
|
||||
string optimized = 1;
|
||||
repeated string suggestions = 2;
|
||||
}
|
||||
61
packages/shared-proto/proto/analytics.proto
Normal file
61
packages/shared-proto/proto/analytics.proto
Normal file
@@ -0,0 +1,61 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package next_edu_cloud.analytics.v1;
|
||||
|
||||
service AnalyticsService {
|
||||
rpc GetClassPerformance(GetClassPerformanceRequest) returns (ClassPerformance);
|
||||
rpc GetStudentWeakness(GetStudentWeaknessRequest) returns (StudentWeakness);
|
||||
rpc GetLearningTrend(GetLearningTrendRequest) returns (LearningTrend);
|
||||
}
|
||||
|
||||
message GetClassPerformanceRequest {
|
||||
string class_id = 1;
|
||||
string subject_id = 2;
|
||||
int64 start_date = 3;
|
||||
int64 end_date = 4;
|
||||
}
|
||||
|
||||
message ClassPerformance {
|
||||
string class_id = 1;
|
||||
double average_score = 2;
|
||||
double pass_rate = 3;
|
||||
repeated StudentScore scores = 4;
|
||||
}
|
||||
|
||||
message StudentScore {
|
||||
string student_id = 1;
|
||||
double score = 2;
|
||||
string grade = 3;
|
||||
}
|
||||
|
||||
message GetStudentWeaknessRequest {
|
||||
string student_id = 1;
|
||||
string subject_id = 2;
|
||||
}
|
||||
|
||||
message StudentWeakness {
|
||||
string student_id = 1;
|
||||
repeated WeakPoint weak_points = 2;
|
||||
}
|
||||
|
||||
message WeakPoint {
|
||||
string knowledge_point_id = 1;
|
||||
string title = 2;
|
||||
double mastery = 3;
|
||||
}
|
||||
|
||||
message GetLearningTrendRequest {
|
||||
string student_id = 1;
|
||||
int64 start_date = 2;
|
||||
int64 end_date = 3;
|
||||
}
|
||||
|
||||
message LearningTrend {
|
||||
string student_id = 1;
|
||||
repeated TrendPoint points = 2;
|
||||
}
|
||||
|
||||
message TrendPoint {
|
||||
int64 date = 1;
|
||||
double score = 2;
|
||||
}
|
||||
47
packages/shared-proto/proto/content.proto
Normal file
47
packages/shared-proto/proto/content.proto
Normal file
@@ -0,0 +1,47 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package next_edu_cloud.content.v1;
|
||||
|
||||
service TextbookService {
|
||||
rpc CreateTextbook(CreateTextbookRequest) returns (Textbook);
|
||||
rpc GetTextbook(GetTextbookRequest) returns (Textbook);
|
||||
rpc ListTextbooks(ListTextbooksRequest) returns (ListTextbooksResponse);
|
||||
}
|
||||
|
||||
service KnowledgeGraphService {
|
||||
rpc GetPrerequisites(GetPrerequisitesRequest) returns (KnowledgePointsResponse);
|
||||
rpc GetLearningPath(GetLearningPathRequest) returns (LearningPath);
|
||||
}
|
||||
|
||||
message Textbook {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
string subject_id = 3;
|
||||
string grade_id = 4;
|
||||
string version = 5;
|
||||
}
|
||||
|
||||
message CreateTextbookRequest {
|
||||
string title = 1;
|
||||
string subject_id = 2;
|
||||
string grade_id = 3;
|
||||
string version = 4;
|
||||
}
|
||||
|
||||
message GetTextbookRequest { string id = 1; }
|
||||
message ListTextbooksRequest { string subject_id = 1; string grade_id = 2; }
|
||||
message ListTextbooksResponse { repeated Textbook textbooks = 1; }
|
||||
|
||||
message KnowledgePoint {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
}
|
||||
|
||||
message GetPrerequisitesRequest { string knowledge_point_id = 1; }
|
||||
message KnowledgePointsResponse { repeated KnowledgePoint points = 1; }
|
||||
|
||||
message GetLearningPathRequest { string student_id = 1; string subject_id = 2; }
|
||||
message LearningPath {
|
||||
repeated KnowledgePoint points = 1;
|
||||
repeated string recommended_order = 2;
|
||||
}
|
||||
181
packages/shared-proto/proto/core_edu.proto
Normal file
181
packages/shared-proto/proto/core_edu.proto
Normal file
@@ -0,0 +1,181 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package next_edu_cloud.core_edu.v1;
|
||||
|
||||
// CoreEdu service contracts - P3 core teaching domain.
|
||||
// Covers exam management, homework assignment, and grade recording.
|
||||
// Event contracts live in events.proto under next_edu_cloud.events.v1.
|
||||
|
||||
service ExamService {
|
||||
rpc CreateExam(CreateExamRequest) returns (CreateExamResponse);
|
||||
rpc GetExam(GetExamRequest) returns (Exam);
|
||||
rpc ListExamsByClass(ListExamsByClassRequest) returns (ListExamsResponse);
|
||||
rpc UpdateExam(UpdateExamRequest) returns (UpdateExamResponse);
|
||||
rpc DeleteExam(DeleteExamRequest) returns (DeleteExamResponse);
|
||||
}
|
||||
|
||||
service HomeworkService {
|
||||
rpc AssignHomework(AssignHomeworkRequest) returns (AssignHomeworkResponse);
|
||||
rpc GetHomework(GetHomeworkRequest) returns (Homework);
|
||||
rpc ListHomeworkByClass(ListHomeworkByClassRequest) returns (ListHomeworkResponse);
|
||||
rpc SubmitHomework(SubmitHomeworkRequest) returns (SubmitHomeworkResponse);
|
||||
}
|
||||
|
||||
service GradeService {
|
||||
rpc RecordGrade(RecordGradeRequest) returns (RecordGradeResponse);
|
||||
rpc GetGrade(GetGradeRequest) returns (Grade);
|
||||
rpc ListGradesByStudent(ListGradesByStudentRequest) returns (ListGradesResponse);
|
||||
rpc ListGradesByExam(ListGradesByExamRequest) returns (ListGradesResponse);
|
||||
rpc ListGradesByHomework(ListGradesByHomeworkRequest) returns (ListGradesResponse);
|
||||
}
|
||||
|
||||
message Exam {
|
||||
string id = 1;
|
||||
string class_id = 2;
|
||||
string title = 3;
|
||||
string description = 4;
|
||||
string exam_date = 5;
|
||||
string duration = 6;
|
||||
string total_score = 7;
|
||||
string status = 8;
|
||||
string created_by = 9;
|
||||
string created_at = 10;
|
||||
string updated_at = 11;
|
||||
}
|
||||
|
||||
message Homework {
|
||||
string id = 1;
|
||||
string class_id = 2;
|
||||
string title = 3;
|
||||
string description = 4;
|
||||
string due_date = 5;
|
||||
string status = 6;
|
||||
string created_by = 7;
|
||||
string created_at = 8;
|
||||
string updated_at = 9;
|
||||
}
|
||||
|
||||
message Grade {
|
||||
string id = 1;
|
||||
string student_id = 2;
|
||||
string exam_id = 3;
|
||||
string homework_id = 4;
|
||||
string score = 5;
|
||||
string feedback = 6;
|
||||
string graded_by = 7;
|
||||
string created_at = 8;
|
||||
string updated_at = 9;
|
||||
}
|
||||
|
||||
message CreateExamRequest {
|
||||
string class_id = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string exam_date = 4;
|
||||
string duration = 5;
|
||||
string total_score = 6;
|
||||
string created_by = 7;
|
||||
}
|
||||
|
||||
message CreateExamResponse {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message GetExamRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message ListExamsByClassRequest {
|
||||
string class_id = 1;
|
||||
}
|
||||
|
||||
message ListExamsResponse {
|
||||
repeated Exam exams = 1;
|
||||
}
|
||||
|
||||
message UpdateExamRequest {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string exam_date = 4;
|
||||
string duration = 5;
|
||||
string total_score = 6;
|
||||
string status = 7;
|
||||
}
|
||||
|
||||
message UpdateExamResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
message DeleteExamRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message DeleteExamResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
message AssignHomeworkRequest {
|
||||
string class_id = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string due_date = 4;
|
||||
string created_by = 5;
|
||||
}
|
||||
|
||||
message AssignHomeworkResponse {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message GetHomeworkRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message ListHomeworkByClassRequest {
|
||||
string class_id = 1;
|
||||
}
|
||||
|
||||
message ListHomeworkResponse {
|
||||
repeated Homework homework = 1;
|
||||
}
|
||||
|
||||
message SubmitHomeworkRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message SubmitHomeworkResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
message RecordGradeRequest {
|
||||
string student_id = 1;
|
||||
string exam_id = 2;
|
||||
string homework_id = 3;
|
||||
string score = 4;
|
||||
string feedback = 5;
|
||||
string graded_by = 6;
|
||||
}
|
||||
|
||||
message RecordGradeResponse {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message GetGradeRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message ListGradesByStudentRequest {
|
||||
string student_id = 1;
|
||||
}
|
||||
|
||||
message ListGradesByExamRequest {
|
||||
string exam_id = 1;
|
||||
}
|
||||
|
||||
message ListGradesByHomeworkRequest {
|
||||
string homework_id = 1;
|
||||
}
|
||||
|
||||
message ListGradesResponse {
|
||||
repeated Grade grades = 1;
|
||||
}
|
||||
60
packages/shared-proto/proto/events.proto
Normal file
60
packages/shared-proto/proto/events.proto
Normal file
@@ -0,0 +1,60 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package next_edu_cloud.events.v1;
|
||||
|
||||
// Cross-service event contracts published by CoreEdu via the transactional
|
||||
// outbox pattern and consumed by downstream services (notifications, analytics,
|
||||
// audit, etc.). Topics follow the convention edu.{domain}.events.
|
||||
//
|
||||
// Event routing (TOPIC_MAP in outbox.publisher.ts):
|
||||
// edu.exam.events <- exam.created / exam.updated / exam.deleted
|
||||
// edu.homework.events <- homework.assigned / homework.submitted / homework.graded
|
||||
// edu.grade.events <- grade.recorded / grade.updated
|
||||
// edu.class.events <- class.transferred
|
||||
|
||||
message ClassEvent {
|
||||
string event_id = 1;
|
||||
string aggregate_id = 2;
|
||||
string event_type = 3;
|
||||
int64 occurred_at = 4;
|
||||
string class_id = 5;
|
||||
string name = 6;
|
||||
string action = 7;
|
||||
map<string, string> metadata = 8;
|
||||
}
|
||||
|
||||
message ExamEvent {
|
||||
string event_id = 1;
|
||||
string aggregate_id = 2;
|
||||
string event_type = 3;
|
||||
int64 occurred_at = 4;
|
||||
string exam_id = 5;
|
||||
string class_id = 6;
|
||||
string title = 7;
|
||||
string action = 8;
|
||||
map<string, string> metadata = 9;
|
||||
}
|
||||
|
||||
message HomeworkEvent {
|
||||
string event_id = 1;
|
||||
string aggregate_id = 2;
|
||||
string event_type = 3;
|
||||
int64 occurred_at = 4;
|
||||
string homework_id = 5;
|
||||
string class_id = 6;
|
||||
string title = 7;
|
||||
string action = 8;
|
||||
map<string, string> metadata = 9;
|
||||
}
|
||||
|
||||
message GradeEvent {
|
||||
string event_id = 1;
|
||||
string aggregate_id = 2;
|
||||
string event_type = 3;
|
||||
int64 occurred_at = 4;
|
||||
string grade_id = 5;
|
||||
string student_id = 6;
|
||||
string score = 7;
|
||||
string action = 8;
|
||||
map<string, string> metadata = 9;
|
||||
}
|
||||
48
packages/shared-proto/proto/msg.proto
Normal file
48
packages/shared-proto/proto/msg.proto
Normal file
@@ -0,0 +1,48 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package next_edu_cloud.msg.v1;
|
||||
|
||||
service NotificationService {
|
||||
rpc SendNotification(SendNotificationRequest) returns (Notification);
|
||||
rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse);
|
||||
rpc MarkAsRead(MarkAsReadRequest) returns (Empty);
|
||||
rpc SearchNotifications(SearchNotificationsRequest) returns (SearchNotificationsResponse);
|
||||
}
|
||||
|
||||
message Notification {
|
||||
string id = 1;
|
||||
string user_id = 2;
|
||||
string type = 3;
|
||||
string title = 4;
|
||||
string content = 5;
|
||||
string channel = 6;
|
||||
bool is_read = 7;
|
||||
int64 created_at = 8;
|
||||
}
|
||||
|
||||
message SendNotificationRequest {
|
||||
string user_id = 1;
|
||||
string type = 2;
|
||||
string title = 3;
|
||||
string content = 4;
|
||||
string channel = 5;
|
||||
}
|
||||
|
||||
message ListNotificationsRequest {
|
||||
string user_id = 1;
|
||||
bool only_unread = 2;
|
||||
}
|
||||
|
||||
message ListNotificationsResponse {
|
||||
repeated Notification notifications = 1;
|
||||
}
|
||||
|
||||
message MarkAsReadRequest { string id = 1; }
|
||||
message SearchNotificationsRequest {
|
||||
string user_id = 1;
|
||||
string query = 2;
|
||||
}
|
||||
message SearchNotificationsResponse {
|
||||
repeated Notification notifications = 1;
|
||||
}
|
||||
message Empty {}
|
||||
8
services/ai/Dockerfile
Normal file
8
services/ai/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN pip install uv
|
||||
COPY pyproject.toml .
|
||||
RUN uv sync --no-dev
|
||||
COPY src ./src
|
||||
EXPOSE 3008
|
||||
CMD ["uv", "run", "uvicorn", "src.ai.main:app", "--host", "0.0.0.0", "--port", "3008"]
|
||||
45
services/ai/README.md
Normal file
45
services/ai/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# AI 网关服务
|
||||
|
||||
> 版本:0.1(P5 骨架)
|
||||
> 端口:3008
|
||||
|
||||
## 职责
|
||||
|
||||
AI 网关限界上下文(Python 实现),统一封装 LLM 调用(多模型路由、重试、限流、成本控制)。
|
||||
提供辅助出题、表达优化、分层提问等能力。通过 gRPC 查询 content 题库与 data-ana 学情数据。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Python 3.12 + FastAPI 0.115
|
||||
- Pydantic 2 + pydantic-settings
|
||||
- OpenTelemetry(LLM 调用链追踪)
|
||||
- prometheus-client + structlog
|
||||
- SSE 流式响应
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run uvicorn src.ai.main:app --reload --port 3008
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /healthz | 健康检查 |
|
||||
| POST | /chat | LLM 聊天接口 |
|
||||
| POST | /chat/stream | 流式聊天(SSE) |
|
||||
| POST | /generate/question | 生成题目 |
|
||||
| POST | /optimize/expression | 优化表达 |
|
||||
| GET | /metrics | Prometheus 指标 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| port | 3008 | 服务端口 |
|
||||
| openai_api_key | - | OpenAI API 密钥 |
|
||||
| anthropic_api_key | - | Anthropic API 密钥 |
|
||||
| otel_endpoint | http://localhost:4318 | OpenTelemetry OTLP 端点 |
|
||||
| log_level | info | 日志级别 |
|
||||
23
services/ai/pyproject.toml
Normal file
23
services/ai/pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[project]
|
||||
name = "ai-service"
|
||||
version = "0.1.0"
|
||||
description = "AI 网关服务 - LLM 集成 + RAG"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"pydantic>=2.9.0",
|
||||
"pydantic-settings>=2.5.0",
|
||||
"httpx>=0.27.0",
|
||||
"opentelemetry-api>=1.27.0",
|
||||
"opentelemetry-sdk>=1.27.0",
|
||||
"prometheus-client>=0.20.0",
|
||||
"structlog>=24.4.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"]
|
||||
0
services/ai/src/ai/__init__.py
Normal file
0
services/ai/src/ai/__init__.py
Normal file
18
services/ai/src/ai/config.py
Normal file
18
services/ai/src/ai/config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""配置管理."""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置."""
|
||||
|
||||
port: int = 3008
|
||||
openai_api_key: str = ""
|
||||
anthropic_api_key: str = ""
|
||||
otel_endpoint: str = "http://localhost:4318"
|
||||
log_level: str = "info"
|
||||
|
||||
model_config = {"env_file": ".env", "env_prefix": ""}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
111
services/ai/src/ai/main.py
Normal file
111
services/ai/src/ai/main.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""AI 网关服务入口."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from prometheus_client import make_asgi_app
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = structlog.get_logger()
|
||||
tracer = trace.get_tracer(__name__)
|
||||
|
||||
|
||||
def init_tracer() -> None:
|
||||
"""初始化 OpenTelemetry."""
|
||||
provider = TracerProvider()
|
||||
exporter = OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")
|
||||
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期."""
|
||||
init_tracer()
|
||||
logger.info("ai service starting")
|
||||
yield
|
||||
logger.info("ai service stopping")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="AI Gateway Service",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.mount("/metrics", make_asgi_app())
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
"""聊天请求."""
|
||||
|
||||
messages: list[dict]
|
||||
model: str = "gpt-4o-mini"
|
||||
temperature: float = 0.7
|
||||
stream: bool = False
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
"""聊天响应."""
|
||||
|
||||
content: str
|
||||
model: str
|
||||
usage: dict
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
"""健康检查."""
|
||||
return {"status": "ok", "service": "ai"}
|
||||
|
||||
|
||||
@app.post("/chat", response_model=ChatResponse)
|
||||
async def chat(req: ChatRequest):
|
||||
"""LLM 聊天接口."""
|
||||
with tracer.start_as_current_span("ai_chat"):
|
||||
# P5 骨架:实际调用 OpenAI/Anthropic API
|
||||
# 需要从环境变量获取 API key
|
||||
return {
|
||||
"content": "P5 skeleton - LLM integration pending",
|
||||
"model": req.model,
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/chat/stream")
|
||||
async def chat_stream(req: ChatRequest):
|
||||
"""流式聊天(SSE)."""
|
||||
|
||||
async def generate():
|
||||
with tracer.start_as_current_span("ai_chat_stream"):
|
||||
# P5 骨架:流式调用 LLM
|
||||
yield "data: P5 skeleton\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.post("/generate/question")
|
||||
async def generate_question(prompt: str):
|
||||
"""生成题目."""
|
||||
with tracer.start_as_current_span("generate_question"):
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"question": "P5 skeleton - question generation pending"},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/optimize/expression")
|
||||
async def optimize_expression(text: str):
|
||||
"""优化表达."""
|
||||
with tracer.start_as_current_span("optimize_expression"):
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"optimized": "P5 skeleton - expression optimization pending"},
|
||||
}
|
||||
36
services/ai/src/health/health.py
Normal file
36
services/ai/src/health/health.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""健康检查端点(ai 服务)。
|
||||
|
||||
- GET /healthz:liveness,仅返回进程存活。
|
||||
- GET /readyz:readiness,简化版返回 ok + TODO,待补全 Elasticsearch
|
||||
连通性与 OpenAI 配置校验。
|
||||
|
||||
集成说明:在 FastAPI app 中挂载 router:
|
||||
|
||||
from health import router as health_router
|
||||
app.include_router(health_router)
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SERVICE_NAME = "ai"
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
async def healthz() -> dict:
|
||||
return {"status": "ok", "service": SERVICE_NAME}
|
||||
|
||||
|
||||
@router.get("/readyz")
|
||||
async def readyz() -> dict:
|
||||
# TODO: 校验关键依赖
|
||||
# 1. Elasticsearch 连通性:es_client.ping()
|
||||
# 2. OpenAI 配置:API key 是否注入、超时是否合理
|
||||
# 依赖客户端就绪后再补全检查逻辑,失败时返回 503。
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": SERVICE_NAME,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -6,6 +6,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/sony/gobreaker/v2 v2.1.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0
|
||||
go.opentelemetry.io/otel v1.27.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0
|
||||
|
||||
23
services/api-gateway/internal/health/health.go
Normal file
23
services/api-gateway/internal/health/health.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Healthz 存活探针(liveness)。
|
||||
// GET /healthz:返回 200 {"status":"ok"} 表示进程存活。
|
||||
func Healthz(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// Readyz 就绪探针(readiness)。
|
||||
// GET /readyz:检查下游服务可达性。
|
||||
// TODO: P7 接入服务发现后,检查 classes / iam / teacher-bff 等下游服务健康状态,
|
||||
// 任一不可达则返回 503,当前简化为直接返回 200。
|
||||
func Readyz(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
65
services/api-gateway/internal/middleware/circuit-breaker.go
Normal file
65
services/api-gateway/internal/middleware/circuit-breaker.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
)
|
||||
|
||||
// CircuitBreaker 返回针对指定服务的熔断中间件。
|
||||
//
|
||||
// 基于 gobreaker v2 实现:
|
||||
// - Interval=5s:CLOSED 状态下的统计窗口
|
||||
// - ReadyToTrip:错误率 > 50% 触发 OPEN
|
||||
// - Timeout=30s:OPEN 持续 30 秒后转 HALF_OPEN
|
||||
// - MaxRequests=1:HALF_OPEN 仅允许 1 个探测请求
|
||||
//
|
||||
// 仅当下游返回 5xx 视为失败;4xx 与 2xx 不计入熔断。
|
||||
// 熔断打开或半开探测名额已满时返回 503 + JSON {"error":"circuit_open","retry_after":30}。
|
||||
func CircuitBreaker(serviceName string) gin.HandlerFunc {
|
||||
cb := gobreaker.NewCircuitBreaker[struct{}](gobreaker.Settings{
|
||||
Name: serviceName,
|
||||
MaxRequests: 1,
|
||||
Interval: 5 * time.Second,
|
||||
Timeout: 30 * time.Second,
|
||||
ReadyToTrip: func(counts gobreaker.Counts) bool {
|
||||
// 请求数为 0 时不触发,避免除零
|
||||
if counts.Requests == 0 {
|
||||
return false
|
||||
}
|
||||
// 错误率 > 50%
|
||||
return counts.TotalFailures*2 > counts.Requests
|
||||
},
|
||||
OnStateChange: func(name string, from, to gobreaker.State) {
|
||||
log.Printf("[circuit-breaker] service=%s state: %s -> %s", name, from, to)
|
||||
},
|
||||
})
|
||||
|
||||
return func(c *gin.Context) {
|
||||
_, err := cb.Execute(func() (struct{}, error) {
|
||||
c.Next()
|
||||
// 下游 5xx 视为熔断失败
|
||||
if c.Writer.Status() >= 500 {
|
||||
return struct{}{}, errors.New("downstream_error")
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// 熔断打开或半开探测名额已满:返回 503
|
||||
if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "circuit_open",
|
||||
"retry_after": 30,
|
||||
})
|
||||
return
|
||||
}
|
||||
// 其他情况(下游已写 5xx 响应):响应已写入,不覆盖
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
69
services/api-gateway/internal/middleware/cors.go
Normal file
69
services/api-gateway/internal/middleware/cors.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// corsMaxAge 预检缓存时长:12 小时
|
||||
const corsMaxAge = 12 * 60 * 60
|
||||
|
||||
// CORS 返回跨域资源共享中间件。
|
||||
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔,默认 *)。
|
||||
// 允许方法:GET POST PUT DELETE OPTIONS PATCH
|
||||
// 允许头:Authorization Content-Type X-Request-Id X-Trace-Id
|
||||
// 暴露头:X-Request-Id X-Trace-Id
|
||||
func CORS() gin.HandlerFunc {
|
||||
allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS"))
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
allowOrigin := ""
|
||||
|
||||
if len(allowed) == 0 {
|
||||
// 未配置则默认允许所有来源
|
||||
allowOrigin = "*"
|
||||
} else if allowed[origin] {
|
||||
allowOrigin = origin
|
||||
}
|
||||
|
||||
if allowOrigin != "" {
|
||||
h := c.Writer.Header()
|
||||
h.Set("Access-Control-Allow-Origin", allowOrigin)
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id, X-Trace-Id")
|
||||
h.Set("Access-Control-Expose-Headers", "X-Request-Id, X-Trace-Id")
|
||||
h.Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge))
|
||||
// 非通配来源需标注 Vary,便于缓存正确区分
|
||||
if allowOrigin != "*" {
|
||||
h.Add("Vary", "Origin")
|
||||
}
|
||||
}
|
||||
|
||||
// 预检请求直接返回 204
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map(表示通配 *)
|
||||
func parseCORSOrigins(raw string) map[string]bool {
|
||||
allowed := map[string]bool{}
|
||||
if raw == "" {
|
||||
return allowed
|
||||
}
|
||||
for _, o := range strings.Split(raw, ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o != "" {
|
||||
allowed[o] = true
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
94
services/api-gateway/internal/middleware/ratelimit.go
Normal file
94
services/api-gateway/internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// bucket 是单个客户端的令牌桶
|
||||
type bucket struct {
|
||||
mu sync.Mutex
|
||||
tokens float64
|
||||
lastTime time.Time
|
||||
}
|
||||
|
||||
// rateLimiter 持有所有客户端 IP 的令牌桶
|
||||
type rateLimiter struct {
|
||||
rps float64
|
||||
burst int
|
||||
buckets sync.Map // map[string]*bucket
|
||||
}
|
||||
|
||||
// RateLimit 返回基于令牌桶的限流中间件(内存版,不依赖 Redis)。
|
||||
// 每个客户端 IP 独立桶,按 rps(每秒令牌数)补充,最大容量为 burst。
|
||||
// 超限返回 429 + JSON {"error":"rate_limited","retry_after":60}。
|
||||
// 内部启动 cleanup goroutine,每 5 分钟清理 10 分钟未访问的桶。
|
||||
func RateLimit(rps float64, burst int) gin.HandlerFunc {
|
||||
rl := &rateLimiter{rps: rps, burst: burst}
|
||||
// 启动清理 goroutine
|
||||
go rl.cleanup(5*time.Minute, 10*time.Minute)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
b := rl.getBucket(ip)
|
||||
|
||||
b.mu.Lock()
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(b.lastTime).Seconds()
|
||||
b.lastTime = now
|
||||
// 按经过时间补充令牌
|
||||
b.tokens += elapsed * rl.rps
|
||||
if b.tokens > float64(rl.burst) {
|
||||
b.tokens = float64(rl.burst)
|
||||
}
|
||||
// 令牌不足:拒绝
|
||||
if b.tokens < 1 {
|
||||
b.mu.Unlock()
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate_limited",
|
||||
"retry_after": 60,
|
||||
})
|
||||
return
|
||||
}
|
||||
b.tokens--
|
||||
b.mu.Unlock()
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// getBucket 获取指定 IP 的令牌桶,不存在则新建(初始满桶)
|
||||
func (rl *rateLimiter) getBucket(ip string) *bucket {
|
||||
if v, ok := rl.buckets.Load(ip); ok {
|
||||
return v.(*bucket)
|
||||
}
|
||||
b := &bucket{
|
||||
tokens: float64(rl.burst),
|
||||
lastTime: time.Now(),
|
||||
}
|
||||
actual, _ := rl.buckets.LoadOrStore(ip, b)
|
||||
return actual.(*bucket)
|
||||
}
|
||||
|
||||
// cleanup 周期性清理长时间未访问的桶,避免内存无限增长
|
||||
func (rl *rateLimiter) cleanup(interval, maxIdle time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
rl.buckets.Range(func(key, value any) bool {
|
||||
b := value.(*bucket)
|
||||
b.mu.Lock()
|
||||
idle := now.Sub(b.lastTime)
|
||||
b.mu.Unlock()
|
||||
if idle > maxIdle {
|
||||
rl.buckets.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
32
services/api-gateway/internal/middleware/recovery.go
Normal file
32
services/api-gateway/internal/middleware/recovery.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Recovery 捕获 panic 并返回 500,记录堆栈日志。
|
||||
// 返回 JSON {"error":"internal_error","request_id":"<uuid>"}。
|
||||
// request_id 使用 uuid.New() 生成(Recovery 在 RequestID 之前注册,
|
||||
// panic 发生时上下文中可能尚无 request_id,故独立生成)。
|
||||
func Recovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stack := debug.Stack()
|
||||
requestID := uuid.New().String()
|
||||
log.Printf("[recovery] panic recovered, request_id=%s: %v\n%s", requestID, r, stack)
|
||||
// 若已写入部分响应,AbortWithStatusJSON 仍会设置状态并尝试写 JSON
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "internal_error",
|
||||
"request_id": requestID,
|
||||
})
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
26
services/api-gateway/internal/middleware/requestid.go
Normal file
26
services/api-gateway/internal/middleware/requestid.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// requestIDHeader 请求 ID 响应头名称
|
||||
const requestIDHeader = "X-Request-Id"
|
||||
|
||||
// requestIDContextKey context 中存储请求 ID 的键
|
||||
const requestIDContextKey = "request_id"
|
||||
|
||||
// RequestID 注入请求 ID 中间件。
|
||||
// 从 X-Request-Id 头读取,无则生成 uuid,写入 context 与响应头。
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rid := c.GetHeader(requestIDHeader)
|
||||
if rid == "" {
|
||||
rid = uuid.New().String()
|
||||
}
|
||||
c.Set(requestIDContextKey, rid)
|
||||
c.Writer.Header().Set(requestIDHeader, rid)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
39
services/api-gateway/internal/middleware/security.go
Normal file
39
services/api-gateway/internal/middleware/security.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SecurityHeaders 设置安全响应头中间件。
|
||||
// 设置:X-Content-Type-Options: nosniff
|
||||
//
|
||||
// X-Frame-Options: DENY
|
||||
// Referrer-Policy: strict-origin-when-cross-origin
|
||||
// Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||
// Content-Security-Policy: default-src 'self'
|
||||
// 移除 Server 头
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.Writer.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'")
|
||||
h.Del("Server")
|
||||
c.Next()
|
||||
// 响应处理结束后再次移除 Server 头,防止处理过程中被写入
|
||||
c.Writer.Header().Del("Server")
|
||||
}
|
||||
}
|
||||
|
||||
// RequestBodyLimit 限制请求体大小中间件。
|
||||
// 通过 http.MaxBytesReader 包装 Body,超限读取时返回 413。
|
||||
func RequestBodyLimit(maxBytes int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,66 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/edu-cloud/api-gateway/internal/config"
|
||||
"github.com/edu-cloud/api-gateway/internal/routing"
|
||||
"github.com/edu-cloud/api-gateway/internal/health"
|
||||
"github.com/edu-cloud/api-gateway/internal/middleware"
|
||||
"github.com/edu-cloud/api-gateway/internal/proxy"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// maxBodyBytes 请求体大小上限:10MB
|
||||
const maxBodyBytes int64 = 10 * 1024 * 1024
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
|
||||
r := routing.Setup(cfg)
|
||||
// 全局中间件(按顺序注册)
|
||||
// 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic)
|
||||
r.Use(middleware.Recovery())
|
||||
// 2. 请求 ID 注入
|
||||
r.Use(middleware.RequestID())
|
||||
// 3. 跨域
|
||||
r.Use(middleware.CORS())
|
||||
// 4. 安全响应头
|
||||
r.Use(middleware.SecurityHeaders())
|
||||
// 5. 请求体大小限制
|
||||
r.Use(middleware.RequestBodyLimit(maxBodyBytes))
|
||||
// 6. 限流(每 IP 100 rps,突发 20)
|
||||
r.Use(middleware.RateLimit(100, 20))
|
||||
|
||||
// 健康检查路由(无需鉴权,在 Auth 之前)
|
||||
r.GET("/healthz", health.Healthz)
|
||||
r.GET("/readyz", health.Readyz)
|
||||
|
||||
// API v1 组:熔断 + 鉴权 + 反向代理
|
||||
api := r.Group("/api/v1")
|
||||
// 7. 熔断(仅作用于代理路由,下游 5xx 触发)
|
||||
api.Use(middleware.CircuitBreaker("downstream"))
|
||||
// 8. JWT 鉴权
|
||||
api.Use(middleware.AuthMiddleware(cfg))
|
||||
{
|
||||
// classes 服务路由
|
||||
classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create classes proxy: %v", err)
|
||||
}
|
||||
api.Any("/classes/*path", proxy.ProxyHandler(classesProxy))
|
||||
|
||||
// IAM 服务路由(身份与访问管理)
|
||||
iamProxy, err := proxy.NewProxy(cfg.IamServiceURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create iam proxy: %v", err)
|
||||
}
|
||||
api.Any("/iam/*path", proxy.ProxyHandler(iamProxy))
|
||||
|
||||
// Teacher BFF 路由(教师聚合层)
|
||||
bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create teacher-bff proxy: %v", err)
|
||||
}
|
||||
api.Any("/teacher/*path", proxy.ProxyHandler(bffProxy))
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
@@ -29,7 +82,7 @@ func main() {
|
||||
go func() {
|
||||
log.Printf("API Gateway listening on :%s", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
log.Fatalf("listen: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
49
services/classes/src/shared/health/health.controller.ts
Normal file
49
services/classes/src/shared/health/health.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const SERVICE_NAME = 'classes';
|
||||
|
||||
/**
|
||||
* 健康检查端点。
|
||||
*
|
||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||
* - GET /readyz:readiness,检查 DB 连接,失败返回 503。
|
||||
*
|
||||
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
|
||||
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
|
||||
*/
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
@Get('healthz')
|
||||
liveness(): { status: string; service: string; timestamp: string } {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('readyz')
|
||||
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
|
||||
try {
|
||||
await this.dataSource.query('SELECT 1');
|
||||
return {
|
||||
status: 'ok',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'database unreachable',
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
services/classes/src/shared/health/health.module.ts
Normal file
23
services/classes/src/shared/health/health.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
/**
|
||||
* 健康检查模块。
|
||||
*
|
||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
||||
*
|
||||
* 在 `app.module.ts` 的 imports 数组中加入 `HealthModule`:
|
||||
*
|
||||
* ```ts
|
||||
* import { HealthModule } from './shared/health/health.module';
|
||||
*
|
||||
* @Module({ imports: [ ..., HealthModule ], ... })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*
|
||||
* DataSource 由 `TypeOrmModule.forRoot(...)` 提供,本模块无需额外 provider。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
63
services/classes/src/shared/lifecycle/lifecycle.service.ts
Normal file
63
services/classes/src/shared/lifecycle/lifecycle.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { Producer } from 'kafkajs';
|
||||
|
||||
const SERVICE_NAME = 'classes';
|
||||
|
||||
/**
|
||||
* 优雅停机服务。
|
||||
*
|
||||
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
||||
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
|
||||
*
|
||||
* 关闭顺序:Kafka producer → Redis → DataSource。
|
||||
* 先停外部消息生产避免新事件,再关缓存,最后关 DB。
|
||||
*
|
||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
||||
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
|
||||
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
|
||||
*
|
||||
* 依赖注入 token 约定(需与各服务 provider 注册一致):
|
||||
* - DataSource:由 `TypeOrmModule.forRoot()` 提供。
|
||||
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
|
||||
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
|
||||
*/
|
||||
@Injectable()
|
||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(LifecycleService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject('REDIS_CLIENT') private readonly redis: Redis,
|
||||
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.logger.log(`service ${SERVICE_NAME} module initialized`);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||
this.logger.log(
|
||||
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
|
||||
);
|
||||
|
||||
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
|
||||
await this.safeDisconnect('redis', () => this.redis.quit());
|
||||
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
|
||||
|
||||
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
||||
}
|
||||
|
||||
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
this.logger.log(`${name} closed`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
services/content/Dockerfile
Normal file
19
services/content/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY tsconfig.json nest-cli.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3005
|
||||
CMD ["node", "dist/main.js"]
|
||||
65
services/content/README.md
Normal file
65
services/content/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# content 内容资源服务
|
||||
|
||||
> 版本:0.1(P4 骨架)
|
||||
> 端口:3005
|
||||
|
||||
## 职责
|
||||
|
||||
内容资源限界上下文,管理 Textbook、Chapter、KnowledgePoint 聚合。
|
||||
支持多存储:MySQL(教材/章节/知识点写模型)+ Neo4j(知识图谱前置依赖)+ Elasticsearch(题库全文检索,P4 后续补充)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- NestJS 10.x + TypeScript 5.6(ESM)
|
||||
- Drizzle ORM(MySQL)
|
||||
- neo4j-driver(知识图谱)
|
||||
- Zod(运行时校验)
|
||||
- pino(日志)+ prom-client(指标)+ OpenTelemetry(追踪)
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # 端口 3005
|
||||
pnpm build
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `PORT` | 服务端口(默认 3005) |
|
||||
| `DATABASE_URL` | MySQL 连接串 |
|
||||
| `NEO4J_URL` | Neo4j Bolt 连接 URL |
|
||||
| `NEO4J_PASSWORD` | Neo4j 密码 |
|
||||
| `ES_URL` | Elasticsearch 地址 |
|
||||
| `JWT_SECRET` | JWT 密钥 |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry OTLP 端点(可选) |
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/
|
||||
├─ config/ # env、database(MySQL)、neo4j
|
||||
├─ shared/
|
||||
│ ├─ errors/ # ApplicationError + GlobalErrorFilter(CONTENT_* 前缀)
|
||||
│ └─ observability/# logger、metrics、tracer
|
||||
├─ textbooks/ # 教材/章节/知识点 + 知识图谱
|
||||
├─ app.module.ts
|
||||
└─ main.ts
|
||||
```
|
||||
|
||||
## 关键端点
|
||||
|
||||
- `POST /textbooks` 创建教材
|
||||
- `GET /textbooks` 教材列表
|
||||
- `GET /textbooks/:id` 教材详情
|
||||
- `TextbooksService.createKnowledgeGraph` 在 Neo4j 构建知识点前置依赖
|
||||
- `TextbooksService.getPrerequisites` 查询知识点前置链路
|
||||
|
||||
## 对外契约
|
||||
|
||||
gRPC 服务 `TextbookService`、`KnowledgeGraphService` 定义见 `packages/shared-proto/proto/content.proto`。
|
||||
8
services/content/nest-cli.json
Normal file
8
services/content/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
37
services/content/package.json
Normal file
37
services/content/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@edu/content-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"drizzle-orm": "^0.31.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"neo4j-driver": "^5.23.0",
|
||||
"pino": "^9.4.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"zod": "^3.23.0",
|
||||
"uuid": "^10.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"@opentelemetry/sdk-node": "^0.55.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
7
services/content/src/app.module.ts
Normal file
7
services/content/src/app.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TextbooksModule } from './textbooks/textbooks.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [TextbooksModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
24
services/content/src/config/database.ts
Normal file
24
services/content/src/config/database.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { env } from './env.js';
|
||||
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
export function getDb() {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
uri: env.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
}
|
||||
return drizzle(pool);
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
28
services/content/src/config/env.ts
Normal file
28
services/content/src/config/env.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('3005'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
NEO4J_URL: z.string().url(),
|
||||
NEO4J_PASSWORD: z.string(),
|
||||
ES_URL: z.string().url(),
|
||||
JWT_SECRET: z.string(),
|
||||
JWT_ISSUER: z.string().default('next-edu-cloud'),
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment configuration');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const env = loadEnv();
|
||||
11
services/content/src/config/neo4j.ts
Normal file
11
services/content/src/config/neo4j.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import neo4j from 'neo4j-driver';
|
||||
import { env } from './env.js';
|
||||
|
||||
export const neo4jDriver = neo4j.driver(
|
||||
env.NEO4J_URL,
|
||||
neo4j.auth.basic('neo4j', env.NEO4J_PASSWORD)
|
||||
);
|
||||
|
||||
export async function closeNeo4j(): Promise<void> {
|
||||
await neo4jDriver.close();
|
||||
}
|
||||
31
services/content/src/main.ts
Normal file
31
services/content/src/main.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
||||
import { env } from './config/env.js';
|
||||
import { logger } from './shared/observability/logger.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['log', 'error', 'warn'],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, 'Content service started');
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await app.close();
|
||||
await shutdownTracer();
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
logger.error({ err }, 'Failed to start content service');
|
||||
process.exit(1);
|
||||
});
|
||||
96
services/content/src/shared/errors/application-error.ts
Normal file
96
services/content/src/shared/errors/application-error.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type ErrorType =
|
||||
| 'validation'
|
||||
| 'not_found'
|
||||
| 'permission_denied'
|
||||
| 'conflict'
|
||||
| 'business'
|
||||
| 'database'
|
||||
| 'internal';
|
||||
|
||||
export interface ErrorDetails {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export abstract class ApplicationError extends Error {
|
||||
abstract readonly type: ErrorType;
|
||||
abstract readonly statusCode: number;
|
||||
readonly code: string;
|
||||
readonly details?: ErrorDetails;
|
||||
// FIX #1: traceId 改为可写,以便 GlobalErrorFilter 注入请求级 traceId
|
||||
traceId?: string;
|
||||
|
||||
constructor(message: string, code: string, details?: ErrorDetails) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
details: this.details,
|
||||
traceId: this.traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends ApplicationError {
|
||||
readonly type = 'validation' as const;
|
||||
readonly statusCode = 400;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_VALIDATION_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApplicationError {
|
||||
readonly type = 'not_found' as const;
|
||||
readonly statusCode = 404;
|
||||
constructor(resource: string, id: string) {
|
||||
super(`${resource} not found: ${id}`, 'CONTENT_NOT_FOUND', { resource, id });
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionDeniedError extends ApplicationError {
|
||||
readonly type = 'permission_denied' as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`, 'CONTENT_PERMISSION_DENIED', { permission });
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends ApplicationError {
|
||||
readonly type = 'conflict' as const;
|
||||
readonly statusCode = 409;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_CONFLICT', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class BusinessError extends ApplicationError {
|
||||
readonly type = 'business' as const;
|
||||
readonly statusCode = 422;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_BUSINESS_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends ApplicationError {
|
||||
readonly type = 'database' as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_DATABASE_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends ApplicationError {
|
||||
readonly type = 'internal' as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_INTERNAL_ERROR', details);
|
||||
}
|
||||
}
|
||||
77
services/content/src/shared/errors/global-error.filter.ts
Normal file
77
services/content/src/shared/errors/global-error.filter.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import { ApplicationError } from './application-error.js';
|
||||
|
||||
@Catch()
|
||||
export class GlobalErrorFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalErrorFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
|
||||
|
||||
let statusCode = 500;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (exception instanceof ApplicationError) {
|
||||
exception.traceId = traceId;
|
||||
statusCode = exception.statusCode;
|
||||
body = exception.toJSON();
|
||||
} else if (exception instanceof ZodError) {
|
||||
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
|
||||
statusCode = 400;
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'CONTENT_VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
details: exception.flatten(),
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else if (exception instanceof HttpException) {
|
||||
statusCode = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
const message = this.extractHttpMessage(res, exception);
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'HTTP_ERROR',
|
||||
message,
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Unhandled exception: ${exception}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unexpected error occurred',
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
response.status(statusCode).json(body);
|
||||
}
|
||||
|
||||
private extractHttpMessage(res: string | object, exception: HttpException): string {
|
||||
if (typeof res === 'string') {
|
||||
return res;
|
||||
}
|
||||
if (res && typeof res === 'object' && 'message' in res) {
|
||||
// 从 HttpException 响应体收窄类型(NestJS 约定包含 message 字段)
|
||||
const msg = (res as { message: unknown }).message;
|
||||
return typeof msg === 'string' ? msg : exception.message;
|
||||
}
|
||||
return exception.message;
|
||||
}
|
||||
}
|
||||
49
services/content/src/shared/health/health.controller.ts
Normal file
49
services/content/src/shared/health/health.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const SERVICE_NAME = 'content';
|
||||
|
||||
/**
|
||||
* 健康检查端点。
|
||||
*
|
||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||
* - GET /readyz:readiness,检查 DB 连接,失败返回 503。
|
||||
*
|
||||
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
|
||||
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
|
||||
*/
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
@Get('healthz')
|
||||
liveness(): { status: string; service: string; timestamp: string } {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('readyz')
|
||||
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
|
||||
try {
|
||||
await this.dataSource.query('SELECT 1');
|
||||
return {
|
||||
status: 'ok',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'database unreachable',
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
services/content/src/shared/health/health.module.ts
Normal file
23
services/content/src/shared/health/health.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
/**
|
||||
* 健康检查模块。
|
||||
*
|
||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
||||
*
|
||||
* 在 `app.module.ts` 的 imports 数组中加入 `HealthModule`:
|
||||
*
|
||||
* ```ts
|
||||
* import { HealthModule } from './shared/health/health.module';
|
||||
*
|
||||
* @Module({ imports: [ ..., HealthModule ], ... })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*
|
||||
* DataSource 由 `TypeOrmModule.forRoot(...)` 提供,本模块无需额外 provider。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
64
services/content/src/shared/lifecycle/lifecycle.service.ts
Normal file
64
services/content/src/shared/lifecycle/lifecycle.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { Producer } from 'kafkajs';
|
||||
|
||||
const SERVICE_NAME = 'content';
|
||||
|
||||
/**
|
||||
* 优雅停机服务。
|
||||
*
|
||||
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
||||
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
|
||||
*
|
||||
* 关闭顺序:Kafka producer → Redis → DataSource。
|
||||
* 先停外部消息生产避免新事件,再关缓存,最后关 DB。
|
||||
* 注:content 服务额外使用 S3/MinIO 客户端,其连接由 SDK 内部管理,无需显式关闭。
|
||||
*
|
||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
||||
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
|
||||
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
|
||||
*
|
||||
* 依赖注入 token 约定(需与各服务 provider 注册一致):
|
||||
* - DataSource:由 `TypeOrmModule.forRoot()` 提供。
|
||||
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
|
||||
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
|
||||
*/
|
||||
@Injectable()
|
||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(LifecycleService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject('REDIS_CLIENT') private readonly redis: Redis,
|
||||
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.logger.log(`service ${SERVICE_NAME} module initialized`);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||
this.logger.log(
|
||||
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
|
||||
);
|
||||
|
||||
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
|
||||
await this.safeDisconnect('redis', () => this.redis.quit());
|
||||
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
|
||||
|
||||
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
||||
}
|
||||
|
||||
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
this.logger.log(`${name} closed`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
services/content/src/shared/observability/logger.ts
Normal file
20
services/content/src/shared/observability/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
export const logger = pino({
|
||||
level: env.LOG_LEVEL,
|
||||
// 修复:pino 默认字段选项为 `base`,而非 `defaultFields`
|
||||
base: {
|
||||
service: 'content',
|
||||
version: '0.1.0',
|
||||
},
|
||||
transport:
|
||||
env.NODE_ENV === 'development'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true },
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export type Logger = typeof logger;
|
||||
24
services/content/src/shared/observability/metrics.ts
Normal file
24
services/content/src/shared/observability/metrics.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import promClient from 'prom-client';
|
||||
|
||||
const registry = new promClient.Registry();
|
||||
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register)
|
||||
registry.setDefaultLabels({ service: 'content' });
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: 'content_requests_total',
|
||||
help: 'Total number of content requests',
|
||||
labelNames: ['method', 'endpoint', 'status'],
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Histogram({
|
||||
name: 'content_request_duration_seconds',
|
||||
help: 'Content request duration in seconds',
|
||||
labelNames: ['method', 'endpoint'],
|
||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||
}),
|
||||
);
|
||||
|
||||
export { registry as metricsRegistry };
|
||||
25
services/content/src/shared/observability/tracer.ts
Normal file
25
services/content/src/shared/observability/tracer.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
let sdk: NodeSDK | null = null;
|
||||
|
||||
export function initTracer(): void {
|
||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
||||
|
||||
sdk = new NodeSDK({
|
||||
serviceName: 'content',
|
||||
traceExporter: new OTLPTraceExporter({
|
||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||
}),
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
console.log('Tracer initialized');
|
||||
}
|
||||
|
||||
export async function shutdownTracer(): Promise<void> {
|
||||
if (sdk) {
|
||||
await sdk.shutdown();
|
||||
}
|
||||
}
|
||||
25
services/content/src/textbooks/textbooks.controller.ts
Normal file
25
services/content/src/textbooks/textbooks.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { TextbooksService } from './textbooks.service.js';
|
||||
|
||||
@Controller('textbooks')
|
||||
export class TextbooksController {
|
||||
constructor(private readonly service: TextbooksService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() body: unknown) {
|
||||
const result = await this.service.create(body as any);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
const result = await this.service.list();
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getById(@Param('id') id: string) {
|
||||
const result = await this.service.getById(id);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
}
|
||||
9
services/content/src/textbooks/textbooks.module.ts
Normal file
9
services/content/src/textbooks/textbooks.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TextbooksController } from './textbooks.controller.js';
|
||||
import { TextbooksService } from './textbooks.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [TextbooksController],
|
||||
providers: [TextbooksService],
|
||||
})
|
||||
export class TextbooksModule {}
|
||||
30
services/content/src/textbooks/textbooks.schema.ts
Normal file
30
services/content/src/textbooks/textbooks.schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { mysqlTable, varchar, char, timestamp, text, integer } from 'drizzle-orm/mysql-core';
|
||||
|
||||
export const textbooks = mysqlTable('content_textbooks', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
subjectId: char('subject_id', { length: 36 }).notNull(),
|
||||
gradeId: char('grade_id', { length: 36 }).notNull(),
|
||||
version: varchar('version', { length: 50 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export const chapters = mysqlTable('content_chapters', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
textbookId: char('textbook_id', { length: 36 }).notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
order: integer('order_num').notNull(),
|
||||
parentId: char('parent_id', { length: 36 }),
|
||||
});
|
||||
|
||||
export const knowledgePoints = mysqlTable('content_knowledge_points', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
chapterId: char('chapter_id', { length: 36 }).notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
});
|
||||
|
||||
export type Textbook = typeof textbooks.$inferSelect;
|
||||
export type Chapter = typeof chapters.$inferSelect;
|
||||
export type KnowledgePoint = typeof knowledgePoints.$inferSelect;
|
||||
69
services/content/src/textbooks/textbooks.service.ts
Normal file
69
services/content/src/textbooks/textbooks.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { neo4jDriver } from '../config/neo4j.js';
|
||||
import { textbooks, chapters, knowledgePoints } from './textbooks.schema.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
@Injectable()
|
||||
export class TextbooksService {
|
||||
async create(data: { title: string; subjectId: string; gradeId: string; version: string }) {
|
||||
const id = uuidv4();
|
||||
const db = getDb();
|
||||
await db.insert(textbooks).values({ id, ...data });
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
async list() {
|
||||
const db = getDb();
|
||||
return db.select().from(textbooks);
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
const db = getDb();
|
||||
const [result] = await db.select().from(textbooks).where(eq(textbooks.id, id));
|
||||
return result;
|
||||
}
|
||||
|
||||
// 知识图谱:在 Neo4j 中创建知识点节点和关系
|
||||
async createKnowledgeGraph(knowledgePointId: string, title: string, prerequisiteIds: string[]): Promise<void> {
|
||||
const session = neo4jDriver.session();
|
||||
try {
|
||||
await session.executeWrite((tx) =>
|
||||
tx.run(
|
||||
'MERGE (kp:KnowledgePoint {id: $id, title: $title})',
|
||||
{ id: knowledgePointId, title }
|
||||
)
|
||||
);
|
||||
|
||||
for (const prereqId of prerequisiteIds) {
|
||||
await session.executeWrite((tx) =>
|
||||
tx.run(
|
||||
`MATCH (prereq:KnowledgePoint {id: $prereqId}), (kp:KnowledgePoint {id: $kpId})
|
||||
MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`,
|
||||
{ prereqId, kpId: knowledgePointId }
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 查询知识图谱(前置知识点)
|
||||
async getPrerequisites(knowledgePointId: string): Promise<unknown[]> {
|
||||
const session = neo4jDriver.session();
|
||||
try {
|
||||
const result = await session.executeRead((tx) =>
|
||||
tx.run(
|
||||
`MATCH (kp:KnowledgePoint {id: $id})<-[:PREREQUISITE_OF*1..5]-(prereq)
|
||||
RETURN prereq.id as id, prereq.title as title`,
|
||||
{ id: knowledgePointId }
|
||||
)
|
||||
);
|
||||
return result.records.map((r) => ({ id: r.get('id'), title: r.get('title') }));
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
15
services/content/tsconfig.json
Normal file
15
services/content/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
19
services/core-edu/Dockerfile
Normal file
19
services/core-edu/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY tsconfig.json nest-cli.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3004
|
||||
CMD ["node", "dist/main.js"]
|
||||
52
services/core-edu/README.md
Normal file
52
services/core-edu/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# core-edu 教学核心服务
|
||||
|
||||
> P3 核心教学阶段骨架 | 端口 3004
|
||||
|
||||
## 职责
|
||||
|
||||
core-edu 是教学核心限界上下文,合并 P1 的 classes 域,管理考试 / 作业 / 成绩聚合。
|
||||
覆盖教学全链路:考试发布、作业提交、成绩录入与查询。
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/
|
||||
├─ config/ # env / database / kafka
|
||||
├─ shared/
|
||||
│ ├─ outbox/ # 事务性发件箱(schema + repository + publisher)
|
||||
│ ├─ errors/ # 应用错误 + 全局错误过滤器
|
||||
│ └─ observability/ # logger / metrics / tracer
|
||||
├─ middleware/ # auth.middleware / permission.guard
|
||||
├─ classes/ # P3 待合并(骨架)
|
||||
├─ exams/ # 考试域
|
||||
├─ homework/ # 作业域
|
||||
├─ grades/ # 成绩域
|
||||
├─ app.module.ts
|
||||
└─ main.ts
|
||||
```
|
||||
|
||||
## 关键机制
|
||||
|
||||
- **Outbox 模式**:写业务数据同事务写 `core_edu_outbox`,OutboxPublisher 定时扫描并发送到 Kafka,保证事件可靠投递。
|
||||
- **Kafka 集成**:idempotent + transactional producer,按 eventType 路由到 `edu.{domain}.events` 主题。
|
||||
- **事件契约**:见 `packages/shared-proto/proto/events.proto`。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # 端口 3004
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| `PORT` | 服务端口 | 3004 |
|
||||
| `DATABASE_URL` | MySQL 连接串 | - |
|
||||
| `KAFKA_BROKERS` | Kafka broker 列表(逗号分隔) | localhost:9092 |
|
||||
| `JWT_SECRET` | JWT 密钥 | - |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP 上报地址(可选) | - |
|
||||
8
services/core-edu/nest-cli.json
Normal file
8
services/core-edu/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
42
services/core-edu/package.json
Normal file
42
services/core-edu/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@edu/core-edu-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"drizzle-orm": "^0.31.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"kafkajs": "^2.2.0",
|
||||
"pino": "^9.4.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/sdk-node": "^0.53.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
||||
"zod": "^3.23.0",
|
||||
"uuid": "^10.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.2.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9.10.0",
|
||||
"pino-pretty": "^11.2.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
15
services/core-edu/src/app.module.ts
Normal file
15
services/core-edu/src/app.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { ExamsModule } from './exams/exams.module.js';
|
||||
import { HomeworkModule } from './homework/homework.module.js';
|
||||
import { GradesModule } from './grades/grades.module.js';
|
||||
import { ClassesModule } from './classes/classes.module.js';
|
||||
import { AuthMiddleware } from './middleware/auth.middleware.js';
|
||||
|
||||
@Module({
|
||||
imports: [ExamsModule, HomeworkModule, GradesModule, ClassesModule],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(AuthMiddleware).forRoutes('/api/*');
|
||||
}
|
||||
}
|
||||
15
services/core-edu/src/classes/classes.module.ts
Normal file
15
services/core-edu/src/classes/classes.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Classes module - P3 skeleton placeholder.
|
||||
*
|
||||
* The classes domain was merged into CoreEdu in P3. This empty module
|
||||
* exists so that future class-transfer features and event handlers can
|
||||
* be registered without restructuring the AppModule imports.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [],
|
||||
providers: [],
|
||||
exports: [],
|
||||
})
|
||||
export class ClassesModule {}
|
||||
24
services/core-edu/src/config/database.ts
Normal file
24
services/core-edu/src/config/database.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { env } from './env.js';
|
||||
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
export function getDb() {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
uri: env.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
}
|
||||
return drizzle(pool);
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
26
services/core-edu/src/config/env.ts
Normal file
26
services/core-edu/src/config/env.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('3004'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
JWT_SECRET: z.string(),
|
||||
JWT_ISSUER: z.string().default('next-edu-cloud'),
|
||||
KAFKA_BROKERS: z.string().default('localhost:9092'),
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment configuration');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const env = loadEnv();
|
||||
25
services/core-edu/src/config/kafka.ts
Normal file
25
services/core-edu/src/config/kafka.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Kafka } from 'kafkajs';
|
||||
import { env } from './env.js';
|
||||
|
||||
export const kafka = new Kafka({
|
||||
brokers: env.KAFKA_BROKERS.split(','),
|
||||
clientId: 'core-edu-service',
|
||||
});
|
||||
|
||||
export const producer = kafka.producer({
|
||||
idempotent: true,
|
||||
transactionalId: 'core-edu-tx',
|
||||
});
|
||||
|
||||
export const consumer = kafka.consumer({ groupId: 'core-edu-group' });
|
||||
|
||||
export async function connectKafka(): Promise<void> {
|
||||
await producer.connect();
|
||||
await consumer.connect();
|
||||
console.log('Kafka connected');
|
||||
}
|
||||
|
||||
export async function disconnectKafka(): Promise<void> {
|
||||
await producer.disconnect();
|
||||
await consumer.disconnect();
|
||||
}
|
||||
57
services/core-edu/src/exams/exams.controller.ts
Normal file
57
services/core-edu/src/exams/exams.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ExamsService, type CreateExamInput, type UpdateExamInput } from './exams.service.js';
|
||||
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
|
||||
|
||||
interface SuccessResponse<T> {
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Controller('api/v1/exams')
|
||||
export class ExamsController {
|
||||
constructor(private readonly examsService: ExamsService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(new PermissionGuard(Permissions.EXAM_CREATE))
|
||||
async create(@Body() body: CreateExamInput): Promise<SuccessResponse<{ id: string }>> {
|
||||
const result = await this.examsService.createExam(body);
|
||||
return { data: result, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
|
||||
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['getExam']>>>> {
|
||||
const data = await this.examsService.getExam(id);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('class/:classId')
|
||||
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
|
||||
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['listExamsByClass']>>>> {
|
||||
const data = await this.examsService.listExamsByClass(classId);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(new PermissionGuard(Permissions.EXAM_UPDATE))
|
||||
async update(@Param('id') id: string, @Body() body: UpdateExamInput): Promise<SuccessResponse<{ success: true }>> {
|
||||
await this.examsService.updateExam(id, body);
|
||||
return { data: { success: true }, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(new PermissionGuard(Permissions.EXAM_DELETE))
|
||||
async remove(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
|
||||
await this.examsService.deleteExam(id);
|
||||
return { data: { success: true }, timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
10
services/core-edu/src/exams/exams.module.ts
Normal file
10
services/core-edu/src/exams/exams.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExamsController } from './exams.controller.js';
|
||||
import { ExamsService } from './exams.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ExamsController],
|
||||
providers: [ExamsService],
|
||||
exports: [ExamsService],
|
||||
})
|
||||
export class ExamsModule {}
|
||||
28
services/core-edu/src/exams/exams.repository.ts
Normal file
28
services/core-edu/src/exams/exams.repository.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../../config/database.js';
|
||||
import { exams, type Exam, type NewExam } from './exams.schema.js';
|
||||
|
||||
export class ExamsRepository {
|
||||
async findById(id: string): Promise<Exam | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(exams)
|
||||
.where(eq(exams.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findByClassId(classId: string): Promise<Exam[]> {
|
||||
return db.select().from(exams).where(eq(exams.classId, classId));
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewExam>): Promise<void> {
|
||||
await db.update(exams).set(data).where(eq(exams.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await db.delete(exams).where(eq(exams.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
export const examsRepository = new ExamsRepository();
|
||||
25
services/core-edu/src/exams/exams.schema.ts
Normal file
25
services/core-edu/src/exams/exams.schema.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
char,
|
||||
datetime,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
|
||||
export const exams = mysqlTable('core_edu_exams', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
classId: char('class_id', { length: 36 }).notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
examDate: datetime('exam_date').notNull(),
|
||||
duration: varchar('duration', { length: 50 }).notNull(),
|
||||
totalScore: varchar('total_score', { length: 10 }).notNull(),
|
||||
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
||||
createdBy: char('created_by', { length: 36 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export type Exam = typeof exams.$inferSelect;
|
||||
export type NewExam = typeof exams.$inferInsert;
|
||||
127
services/core-edu/src/exams/exams.service.ts
Normal file
127
services/core-edu/src/exams/exams.service.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../config/database.js';
|
||||
import { exams } from './exams.schema.js';
|
||||
import { examsRepository } from './exams.repository.js';
|
||||
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
|
||||
import type { Exam, NewExam } from './exams.schema.js';
|
||||
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
|
||||
|
||||
export interface CreateExamInput {
|
||||
classId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
examDate: Date;
|
||||
duration: string;
|
||||
totalScore: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface UpdateExamInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
examDate?: Date;
|
||||
duration?: string;
|
||||
totalScore?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExamsService {
|
||||
async createExam(input: CreateExamInput): Promise<{ id: string }> {
|
||||
if (!input.classId || !input.title || !input.createdBy) {
|
||||
throw new ValidationError('classId, title, createdBy are required');
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const exam: NewExam = {
|
||||
id,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
examDate: input.examDate,
|
||||
duration: input.duration,
|
||||
totalScore: input.totalScore,
|
||||
status: 'draft',
|
||||
createdBy: input.createdBy,
|
||||
};
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values(exam);
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
aggregateId: id,
|
||||
aggregateType: 'exam',
|
||||
eventType: 'exam.created',
|
||||
payload: JSON.stringify({
|
||||
id,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
}),
|
||||
status: 'pending',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getExam(id: string): Promise<Exam> {
|
||||
const exam = await examsRepository.findById(id);
|
||||
if (!exam) {
|
||||
throw new NotFoundError(`Exam ${id} not found`);
|
||||
}
|
||||
return exam;
|
||||
}
|
||||
|
||||
async listExamsByClass(classId: string): Promise<Exam[]> {
|
||||
return examsRepository.findByClassId(classId);
|
||||
}
|
||||
|
||||
async updateExam(id: string, data: UpdateExamInput): Promise<void> {
|
||||
const existing = await examsRepository.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundError(`Exam ${id} not found`);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(exams).set(data).where(eq(exams.id, id));
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
aggregateId: id,
|
||||
aggregateType: 'exam',
|
||||
eventType: 'exam.updated',
|
||||
payload: JSON.stringify({ id, changes: data }),
|
||||
status: 'pending',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExam(id: string): Promise<void> {
|
||||
const existing = await examsRepository.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundError(`Exam ${id} not found`);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(exams).where(eq(exams.id, id));
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
aggregateId: id,
|
||||
aggregateType: 'exam',
|
||||
eventType: 'exam.deleted',
|
||||
payload: JSON.stringify({ id }),
|
||||
status: 'pending',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
55
services/core-edu/src/grades/grades.controller.ts
Normal file
55
services/core-edu/src/grades/grades.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { GradesService, type RecordGradeInput } from './grades.service.js';
|
||||
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
|
||||
|
||||
interface SuccessResponse<T> {
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Controller('api/v1/grades')
|
||||
export class GradesController {
|
||||
constructor(private readonly gradesService: GradesService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_CREATE))
|
||||
async record(@Body() body: RecordGradeInput): Promise<SuccessResponse<{ id: string }>> {
|
||||
const result = await this.gradesService.recordGrade(body);
|
||||
return { data: result, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
||||
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['getGrade']>>>> {
|
||||
const data = await this.gradesService.getGrade(id);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('student/:studentId')
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
||||
async listByStudent(@Param('studentId') studentId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByStudent']>>>> {
|
||||
const data = await this.gradesService.listByStudent(studentId);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('exam/:examId')
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
||||
async listByExam(@Param('examId') examId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByExam']>>>> {
|
||||
const data = await this.gradesService.listByExam(examId);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('homework/:homeworkId')
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
||||
async listByHomework(@Param('homeworkId') homeworkId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByHomework']>>>> {
|
||||
const data = await this.gradesService.listByHomework(homeworkId);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
10
services/core-edu/src/grades/grades.module.ts
Normal file
10
services/core-edu/src/grades/grades.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GradesController } from './grades.controller.js';
|
||||
import { GradesService } from './grades.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [GradesController],
|
||||
providers: [GradesService],
|
||||
exports: [GradesService],
|
||||
})
|
||||
export class GradesModule {}
|
||||
22
services/core-edu/src/grades/grades.schema.ts
Normal file
22
services/core-edu/src/grades/grades.schema.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
char,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
|
||||
export const grades = mysqlTable('core_edu_grades', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
studentId: char('student_id', { length: 36 }).notNull(),
|
||||
examId: char('exam_id', { length: 36 }),
|
||||
homeworkId: char('homework_id', { length: 36 }),
|
||||
score: varchar('score', { length: 10 }).notNull(),
|
||||
feedback: text('feedback'),
|
||||
gradedBy: char('graded_by', { length: 36 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export type Grade = typeof grades.$inferSelect;
|
||||
export type NewGrade = typeof grades.$inferInsert;
|
||||
87
services/core-edu/src/grades/grades.service.ts
Normal file
87
services/core-edu/src/grades/grades.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../config/database.js';
|
||||
import { grades } from './grades.schema.js';
|
||||
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
|
||||
import type { Grade, NewGrade } from './grades.schema.js';
|
||||
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
|
||||
|
||||
export interface RecordGradeInput {
|
||||
studentId: string;
|
||||
examId?: string;
|
||||
homeworkId?: string;
|
||||
score: string;
|
||||
feedback?: string;
|
||||
gradedBy: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GradesService {
|
||||
async recordGrade(input: RecordGradeInput): Promise<{ id: string }> {
|
||||
if (!input.studentId || !input.score || !input.gradedBy) {
|
||||
throw new ValidationError('studentId, score, gradedBy are required');
|
||||
}
|
||||
if (!input.examId && !input.homeworkId) {
|
||||
throw new ValidationError('Either examId or homeworkId must be provided');
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const record: NewGrade = {
|
||||
id,
|
||||
studentId: input.studentId,
|
||||
examId: input.examId,
|
||||
homeworkId: input.homeworkId,
|
||||
score: input.score,
|
||||
feedback: input.feedback,
|
||||
gradedBy: input.gradedBy,
|
||||
};
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(grades).values(record);
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
aggregateId: id,
|
||||
aggregateType: 'grade',
|
||||
eventType: 'grade.recorded',
|
||||
payload: JSON.stringify({
|
||||
id,
|
||||
studentId: input.studentId,
|
||||
score: input.score,
|
||||
examId: input.examId,
|
||||
homeworkId: input.homeworkId,
|
||||
}),
|
||||
status: 'pending',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getGrade(id: string): Promise<Grade> {
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(grades)
|
||||
.where(eq(grades.id, id))
|
||||
.limit(1);
|
||||
if (!record) {
|
||||
throw new NotFoundError(`Grade ${id} not found`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async listByStudent(studentId: string): Promise<Grade[]> {
|
||||
return db.select().from(grades).where(eq(grades.studentId, studentId));
|
||||
}
|
||||
|
||||
async listByExam(examId: string): Promise<Grade[]> {
|
||||
return db.select().from(grades).where(eq(grades.examId, examId));
|
||||
}
|
||||
|
||||
async listByHomework(homeworkId: string): Promise<Grade[]> {
|
||||
return db.select().from(grades).where(eq(grades.homeworkId, homeworkId));
|
||||
}
|
||||
}
|
||||
48
services/core-edu/src/homework/homework.controller.ts
Normal file
48
services/core-edu/src/homework/homework.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { HomeworkService, type AssignHomeworkInput } from './homework.service.js';
|
||||
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
|
||||
|
||||
interface SuccessResponse<T> {
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Controller('api/v1/homework')
|
||||
export class HomeworkController {
|
||||
constructor(private readonly homeworkService: HomeworkService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_CREATE))
|
||||
async assign(@Body() body: AssignHomeworkInput): Promise<SuccessResponse<{ id: string }>> {
|
||||
const result = await this.homeworkService.assignHomework(body);
|
||||
return { data: result, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
|
||||
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['getHomework']>>>> {
|
||||
const data = await this.homeworkService.getHomework(id);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('class/:classId')
|
||||
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
|
||||
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['listByClass']>>>> {
|
||||
const data = await this.homeworkService.listByClass(classId);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_SUBMIT))
|
||||
async submit(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
|
||||
await this.homeworkService.submitHomework(id);
|
||||
return { data: { success: true }, timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
10
services/core-edu/src/homework/homework.module.ts
Normal file
10
services/core-edu/src/homework/homework.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HomeworkController } from './homework.controller.js';
|
||||
import { HomeworkService } from './homework.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [HomeworkController],
|
||||
providers: [HomeworkService],
|
||||
exports: [HomeworkService],
|
||||
})
|
||||
export class HomeworkModule {}
|
||||
23
services/core-edu/src/homework/homework.schema.ts
Normal file
23
services/core-edu/src/homework/homework.schema.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
char,
|
||||
datetime,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
|
||||
export const homework = mysqlTable('core_edu_homework', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
classId: char('class_id', { length: 36 }).notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
dueDate: datetime('due_date').notNull(),
|
||||
status: varchar('status', { length: 20 }).notNull().default('assigned'),
|
||||
createdBy: char('created_by', { length: 36 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export type Homework = typeof homework.$inferSelect;
|
||||
export type NewHomework = typeof homework.$inferInsert;
|
||||
98
services/core-edu/src/homework/homework.service.ts
Normal file
98
services/core-edu/src/homework/homework.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../config/database.js';
|
||||
import { homework } from './homework.schema.js';
|
||||
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
|
||||
import type { Homework, NewHomework } from './homework.schema.js';
|
||||
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
|
||||
|
||||
export interface AssignHomeworkInput {
|
||||
classId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
dueDate: Date;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HomeworkService {
|
||||
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
|
||||
if (!input.classId || !input.title || !input.createdBy) {
|
||||
throw new ValidationError('classId, title, createdBy are required');
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const record: NewHomework = {
|
||||
id,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
dueDate: input.dueDate,
|
||||
status: 'assigned',
|
||||
createdBy: input.createdBy,
|
||||
};
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(homework).values(record);
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
aggregateId: id,
|
||||
aggregateType: 'homework',
|
||||
eventType: 'homework.assigned',
|
||||
payload: JSON.stringify({
|
||||
id,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
}),
|
||||
status: 'pending',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getHomework(id: string): Promise<Homework> {
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(homework)
|
||||
.where(eq(homework.id, id))
|
||||
.limit(1);
|
||||
if (!record) {
|
||||
throw new NotFoundError(`Homework ${id} not found`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async listByClass(classId: string): Promise<Homework[]> {
|
||||
return db.select().from(homework).where(eq(homework.classId, classId));
|
||||
}
|
||||
|
||||
async submitHomework(id: string): Promise<void> {
|
||||
const existing = await this.getHomework(id);
|
||||
if (existing.status === 'submitted') {
|
||||
throw new ValidationError(`Homework ${id} already submitted`);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(homework)
|
||||
.set({ status: 'submitted' })
|
||||
.where(eq(homework.id, id));
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
aggregateId: id,
|
||||
aggregateType: 'homework',
|
||||
eventType: 'homework.submitted',
|
||||
payload: JSON.stringify({ id }),
|
||||
status: 'pending',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
50
services/core-edu/src/main.ts
Normal file
50
services/core-edu/src/main.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { env } from './config/env.js';
|
||||
import { connectKafka, disconnectKafka } from './config/kafka.js';
|
||||
import { outboxPublisher } from './shared/outbox/outbox.publisher.js';
|
||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
||||
import { logger } from './shared/observability/logger.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Connect Kafka producer/consumer before starting the outbox publisher
|
||||
await connectKafka();
|
||||
|
||||
// Start the transactional outbox publisher - polls pending messages
|
||||
// and publishes them to Kafka topics defined in TOPIC_MAP.
|
||||
await outboxPublisher.start();
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info(
|
||||
{ port: env.PORT, service: 'core-edu' },
|
||||
'CoreEdu service is listening',
|
||||
);
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully...');
|
||||
await outboxPublisher.stop();
|
||||
await disconnectKafka();
|
||||
await shutdownTracer();
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('SIGINT received, shutting down gracefully...');
|
||||
await outboxPublisher.stop();
|
||||
await disconnectKafka();
|
||||
await shutdownTracer();
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
40
services/core-edu/src/middleware/auth.middleware.ts
Normal file
40
services/core-edu/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
|
||||
const userId = req.headers['x-user-id'] as string | undefined;
|
||||
const role = req.headers['x-user-role'] as string | undefined;
|
||||
const permissionsHeader = req.headers['x-user-permissions'] as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!userId || !role) {
|
||||
throw new UnauthorizedException(
|
||||
'Missing authentication headers (x-user-id, x-user-role)',
|
||||
);
|
||||
}
|
||||
|
||||
req.user = {
|
||||
id: userId,
|
||||
role,
|
||||
permissions: permissionsHeader ? permissionsHeader.split(',') : [],
|
||||
};
|
||||
next();
|
||||
}
|
||||
}
|
||||
48
services/core-edu/src/middleware/permission.guard.ts
Normal file
48
services/core-edu/src/middleware/permission.guard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from './auth.middleware.js';
|
||||
|
||||
export const Permissions = {
|
||||
EXAM_CREATE: 'exam:create',
|
||||
EXAM_READ: 'exam:read',
|
||||
EXAM_UPDATE: 'exam:update',
|
||||
EXAM_DELETE: 'exam:delete',
|
||||
HOMEWORK_CREATE: 'homework:create',
|
||||
HOMEWORK_READ: 'homework:read',
|
||||
HOMEWORK_UPDATE: 'homework:update',
|
||||
HOMEWORK_DELETE: 'homework:delete',
|
||||
HOMEWORK_GRADE: 'homework:grade',
|
||||
HOMEWORK_SUBMIT: 'homework:submit',
|
||||
GRADE_CREATE: 'grade:create',
|
||||
GRADE_READ: 'grade:read',
|
||||
GRADE_UPDATE: 'grade:update',
|
||||
GRADE_DELETE: 'grade:delete',
|
||||
CLASS_MANAGE: 'class:manage',
|
||||
CLASS_READ: 'class:read',
|
||||
CLASS_TRANSFER: 'class:transfer',
|
||||
} 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<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new ForbiddenException('User not authenticated');
|
||||
}
|
||||
if (!user.permissions.includes(this.requiredPermission)) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${this.requiredPermission}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
60
services/core-edu/src/shared/errors/application-error.ts
Normal file
60
services/core-edu/src/shared/errors/application-error.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export enum CoreEduErrorCode {
|
||||
VALIDATION_ERROR = 'CORE_EDU_VALIDATION_ERROR',
|
||||
NOT_FOUND = 'CORE_EDU_NOT_FOUND',
|
||||
UNAUTHORIZED = 'CORE_EDU_UNAUTHORIZED',
|
||||
FORBIDDEN = 'CORE_EDU_FORBIDDEN',
|
||||
CONFLICT = 'CORE_EDU_CONFLICT',
|
||||
INTERNAL_ERROR = 'CORE_EDU_INTERNAL_ERROR',
|
||||
EXAM_NOT_FOUND = 'CORE_EDU_EXAM_NOT_FOUND',
|
||||
HOMEWORK_NOT_FOUND = 'CORE_EDU_HOMEWORK_NOT_FOUND',
|
||||
GRADE_NOT_FOUND = 'CORE_EDU_GRADE_NOT_FOUND',
|
||||
OUTBOX_PUBLISH_FAILED = 'CORE_EDU_OUTBOX_PUBLISH_FAILED',
|
||||
}
|
||||
|
||||
export class ApplicationError extends Error {
|
||||
constructor(
|
||||
public readonly code: CoreEduErrorCode,
|
||||
message: string,
|
||||
public readonly statusCode: number = 500,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApplicationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends ApplicationError {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(CoreEduErrorCode.VALIDATION_ERROR, message, 400, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApplicationError {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(CoreEduErrorCode.NOT_FOUND, message, 404, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends ApplicationError {
|
||||
constructor(message: string = 'Unauthorized') {
|
||||
super(CoreEduErrorCode.UNAUTHORIZED, message, 401);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends ApplicationError {
|
||||
constructor(message: string = 'Forbidden') {
|
||||
super(CoreEduErrorCode.FORBIDDEN, message, 403);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends ApplicationError {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(CoreEduErrorCode.CONFLICT, message, 409, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends ApplicationError {
|
||||
constructor(message: string = 'Internal server error', details?: unknown) {
|
||||
super(CoreEduErrorCode.INTERNAL_ERROR, message, 500, details);
|
||||
}
|
||||
}
|
||||
63
services/core-edu/src/shared/errors/global-error.filter.ts
Normal file
63
services/core-edu/src/shared/errors/global-error.filter.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ZodError } from 'zod';
|
||||
import { ApplicationError, CoreEduErrorCode } from './application-error.js';
|
||||
import { logger } from '../observability/logger.js';
|
||||
|
||||
@Catch()
|
||||
export class GlobalErrorFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = CoreEduErrorCode.INTERNAL_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let details: unknown;
|
||||
|
||||
if (exception instanceof ApplicationError) {
|
||||
statusCode = exception.statusCode;
|
||||
code = exception.code;
|
||||
message = exception.message;
|
||||
details = exception.details;
|
||||
} else if (exception instanceof ZodError) {
|
||||
statusCode = HttpStatus.BAD_REQUEST;
|
||||
code = CoreEduErrorCode.VALIDATION_ERROR;
|
||||
message = 'Validation failed';
|
||||
details = exception.flatten().fieldErrors;
|
||||
} else if (exception instanceof HttpException) {
|
||||
statusCode = exception.getStatus();
|
||||
const resp = exception.getResponse();
|
||||
message =
|
||||
typeof resp === 'string'
|
||||
? resp
|
||||
: (resp as { message?: string }).message ?? exception.message;
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
err: exception,
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
code,
|
||||
},
|
||||
`Request failed: ${message}`,
|
||||
);
|
||||
|
||||
response.status(statusCode).json({
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
49
services/core-edu/src/shared/health/health.controller.ts
Normal file
49
services/core-edu/src/shared/health/health.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const SERVICE_NAME = 'core-edu';
|
||||
|
||||
/**
|
||||
* 健康检查端点。
|
||||
*
|
||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||
* - GET /readyz:readiness,检查 DB 连接,失败返回 503。
|
||||
*
|
||||
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
|
||||
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
|
||||
*/
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
@Get('healthz')
|
||||
liveness(): { status: string; service: string; timestamp: string } {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('readyz')
|
||||
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
|
||||
try {
|
||||
await this.dataSource.query('SELECT 1');
|
||||
return {
|
||||
status: 'ok',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'database unreachable',
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
services/core-edu/src/shared/health/health.module.ts
Normal file
23
services/core-edu/src/shared/health/health.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
/**
|
||||
* 健康检查模块。
|
||||
*
|
||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
||||
*
|
||||
* 在 `app.module.ts` 的 imports 数组中加入 `HealthModule`:
|
||||
*
|
||||
* ```ts
|
||||
* import { HealthModule } from './shared/health/health.module';
|
||||
*
|
||||
* @Module({ imports: [ ..., HealthModule ], ... })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*
|
||||
* DataSource 由 `TypeOrmModule.forRoot(...)` 提供,本模块无需额外 provider。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
63
services/core-edu/src/shared/lifecycle/lifecycle.service.ts
Normal file
63
services/core-edu/src/shared/lifecycle/lifecycle.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { Producer } from 'kafkajs';
|
||||
|
||||
const SERVICE_NAME = 'core-edu';
|
||||
|
||||
/**
|
||||
* 优雅停机服务。
|
||||
*
|
||||
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
||||
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
|
||||
*
|
||||
* 关闭顺序:Kafka producer → Redis → DataSource。
|
||||
* 先停外部消息生产避免新事件,再关缓存,最后关 DB。
|
||||
*
|
||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
||||
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
|
||||
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
|
||||
*
|
||||
* 依赖注入 token 约定(需与各服务 provider 注册一致):
|
||||
* - DataSource:由 `TypeOrmModule.forRoot()` 提供。
|
||||
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
|
||||
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
|
||||
*/
|
||||
@Injectable()
|
||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(LifecycleService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject('REDIS_CLIENT') private readonly redis: Redis,
|
||||
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.logger.log(`service ${SERVICE_NAME} module initialized`);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||
this.logger.log(
|
||||
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
|
||||
);
|
||||
|
||||
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
|
||||
await this.safeDisconnect('redis', () => this.redis.quit());
|
||||
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
|
||||
|
||||
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
||||
}
|
||||
|
||||
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
this.logger.log(`${name} closed`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
services/core-edu/src/shared/observability/logger.ts
Normal file
16
services/core-edu/src/shared/observability/logger.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
export const logger = pino({
|
||||
name: 'core-edu',
|
||||
level: env.LOG_LEVEL,
|
||||
base: { service: 'core-edu' },
|
||||
...(env.NODE_ENV === 'development'
|
||||
? {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true, translateTime: 'SYS:standard' },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
38
services/core-edu/src/shared/observability/metrics.ts
Normal file
38
services/core-edu/src/shared/observability/metrics.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Registry,
|
||||
Counter,
|
||||
Histogram,
|
||||
Gauge,
|
||||
collectDefaultMetrics,
|
||||
} from 'prom-client';
|
||||
|
||||
export const registry = new Registry();
|
||||
collectDefaultMetrics({ register: registry });
|
||||
|
||||
export const httpRequestCounter = new Counter({
|
||||
name: 'core_edu_requests_total',
|
||||
help: 'Total HTTP requests',
|
||||
labelNames: ['method', 'route', 'status'] as const,
|
||||
});
|
||||
registry.registerMetric(httpRequestCounter);
|
||||
|
||||
export const httpRequestDuration = new Histogram({
|
||||
name: 'core_edu_request_duration_seconds',
|
||||
help: 'HTTP request duration in seconds',
|
||||
labelNames: ['method', 'route', 'status'] as const,
|
||||
buckets: [0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
|
||||
});
|
||||
registry.registerMetric(httpRequestDuration);
|
||||
|
||||
export const outboxPendingGauge = new Gauge({
|
||||
name: 'core_edu_outbox_pending',
|
||||
help: 'Number of pending outbox messages',
|
||||
});
|
||||
registry.registerMetric(outboxPendingGauge);
|
||||
|
||||
export const outboxPublishedCounter = new Counter({
|
||||
name: 'core_edu_outbox_published_total',
|
||||
help: 'Total outbox messages published to Kafka',
|
||||
labelNames: ['eventType', 'topic'] as const,
|
||||
});
|
||||
registry.registerMetric(outboxPublishedCounter);
|
||||
29
services/core-edu/src/shared/observability/tracer.ts
Normal file
29
services/core-edu/src/shared/observability/tracer.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { env } from '../../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
let sdk: NodeSDK | undefined;
|
||||
|
||||
export function initTracer(): void {
|
||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
||||
logger.warn('OTEL_EXPORTER_OTLP_ENDPOINT not set, tracing disabled');
|
||||
return;
|
||||
}
|
||||
sdk = new NodeSDK({
|
||||
serviceName: 'core-edu',
|
||||
traceExporter: new OTLPTraceExporter({
|
||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||
}),
|
||||
});
|
||||
sdk.start();
|
||||
logger.info('OpenTelemetry tracer initialized');
|
||||
}
|
||||
|
||||
export async function shutdownTracer(): Promise<void> {
|
||||
if (sdk) {
|
||||
await sdk.shutdown();
|
||||
sdk = undefined;
|
||||
logger.info('OpenTelemetry tracer shutdown');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user