fix: code compliance audit and fix across all services
Some checks failed
CI / quality-ts (push) Failing after 48s
CI / quality-go (push) Failing after 4s
CI / quality-proto (push) Failing after 2s
CI / deploy (push) Has been skipped

NestJS (6 services): implement @RequirePermission decorator with
SetMetadata+Reflector, register APP_GUARD globally, fix as assertions
to type guards, add explicit return types, fix import type for express,
fix /metrics implicit any, replace native Error with ApplicationError,
remove typeorm remnants, register LifecycleService.

teacher-bff: add logger, ApplicationError, GlobalErrorFilter, forward
real userId to downstream, log downstream failures, migrate health
controller to shared/health.

Go (2 services): interface to any, doc comments, CORS dev whitelist,
JWT secret fail-fast, push-gateway internal API auth, metrics and
readyz endpoints, remove dead code.

Python (2 services): lifespan return type, dev_mode to bool, data-ana
APIRouter, ai POST body model, ClickHouse async wrapping.
This commit is contained in:
SpecialX
2026-07-09 17:28:27 +08:00
parent b53a486c6e
commit 0a71b02e04
93 changed files with 5775 additions and 608 deletions

View File

@@ -0,0 +1,858 @@
# ai06 阶段 2 交付物:模块架构设计文档
> AI 标识ai06
> 负责模块data-anaP4、aiP5
> 阶段:架构设计外包 · 阶段 2模块架构设计
> 日期2026-07-09
> 关联文档:[ai06 阶段 1 确认书](./ai06-phase1-understanding.md)、[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)
> 审查请求:本设计文档待 coord 按 ai-allocation.md §8 交叉审查(接口一致性 / 端口冲突 / Topic 重复 / 错误码重叠 / 黄金模板对齐)
---
## 设计原则与全局约束
本设计遵循以下强制约束(来自 project_rules.md + coding-standards.md + 004
1. **契约先行**proto 已定义analytics.proto / ai.proto实现前不修改 proto如需修改走 coord 流程
2. **CQRS 读写分离**data-ana 是纯读模型服务(无 MySQL 写ClickHouse 宽表由 CDC 投影构建
3. **事件驱动**data-ana 消费 CDC + 领域事件ai 不参与事件流(无状态)
4. **gRPC 优先**004 §4.1 明确 BFF → 业务服务走 gRPC两服务需实现 gRPC serverHTTP 保留作 Gateway 直连降级)
5. **DataScope 过滤**004 §5.3 DataScope 6 级在查询层注入 WHERE
6. **三支柱可观测**structlog + prometheus-client + OpenTelemetry已具备需补业务指标
7. **降级模式**外部依赖ClickHouse / LLM / 下游 gRPC不可用时返回骨架数据 + `degraded: true`
8. **Python 规范**pydantic-settings 配置 / Pydantic 模型校验 / async 优先 / 类型注解强制 / ruff 零错误
---
# 模块架构设计文档 — data-ana
## 1. 模块内部分层图
```mermaid
flowchart TB
subgraph Entry["入口层"]
HTTP[FastAPI HTTP Router<br/>/analytics/* + /healthz + /readyz]
GRPC[grpc.aio Server<br/>AnalyticsService]
end
subgraph Middleware["中间件层"]
AUTH[AuthDepends<br/>校验 x-user-id / x-user-roles]
SCOPE[DataScopeDepends<br/>注入 data_scope 元数据]
TRACE[OTel FastAPIInstrumentor<br/>+ grpc.aio server interceptor]
end
subgraph Service["应用服务层 Application Service"]
S1[AnalyticsService<br/>班级/学生/趋势查询编排]
S2[MasteryService<br/>掌握度计算 + 事件发布]
S3[ErrorBookService<br/>错题本查询]
end
subgraph Repo["数据访问层 Repository"]
R1[ClickHouseRepository<br/>宽表查询 + DataScope WHERE 注入]
R2[KafkaProducer<br/>mastery.updated 事件发布]
R3[IamClient<br/>gRPC 调 iam.getEffectiveDataScope]
end
subgraph Consumer["CDC 消费者(后台任务)"]
C1[CdcConsumer<br/>aiokafka AIOKafkaConsumer]
C2[ExamCache<br/>exam_id→class_id 内存映射]
C3[EventHandler<br/>grades/exams/homework/classes 路由]
end
subgraph Storage["存储 / 总线"]
CH[(ClickHouse<br/>edu_analytics 库)]
KAFKA[(Kafka<br/>edu-cdc.* + edu.insight.mastery.updated)]
IAM[iam:3002 gRPC]
end
HTTP --> AUTH --> SCOPE --> S1
HTTP --> S3
GRPC --> S1
S1 --> R1
S3 --> R1
S2 --> R1
S2 --> R2
SCOPE --> R3
R1 --> CH
R2 --> KAFKA
R3 --> IAM
C1 --> C3
C3 --> C2
C3 --> R1
C1 --> KAFKA
```
**分层规则**
- **入口层**HTTP保留作 Gateway 直连降级)+ gRPC主入口BFF 调用)。两入口共享同一 Application Service
- **中间件层**FastAPI Depends 链(`AuthDepends``DataScopeDepends`gRPC 用 server interceptor 注入身份元数据
- **应用服务层**:编排查询 / 计算掌握度 / 发布事件,不直接访问存储
- **数据访问层**ClickHouse 查询封装 + Kafka producer + gRPC client调 iam
- **CDC 消费者**独立后台任务lifespan 启动),与 HTTP/gRPC 入口解耦
## 2. 领域模型
data-ana 是**纯读模型服务**,不持有写聚合根。领域模型为**视图聚合**ClickHouse 物化):
### 聚合根(视图型)
| 聚合根 | 含义 | 物化载体 | 不变式 |
| ------------------ | ---------------- | ----------------------------------------- | ------------------------------------------------------------- |
| `StudentDashboard` | 学生学情宽表 | ClickHouse `student_dashboard_view` | 同一 (student_id, exam_id, knowledge_point_id) 仅保留最新版本 |
| `ClassPerformance` | 班级成绩聚合 | ClickHouse 即时聚合(不物化) | 聚合维度为 class_id + 时间窗 |
| `StudentErrorBook` | 学生错题本 | ClickHouse `student_errors` | 同一 (student_id, question_id) 累计 error_count |
| `MasterySnapshot` | 知识点掌握度快照 | ClickHouse `mastery_snapshot`**新增** | 同一 (student_id, knowledge_point_id) 保留历史版本 |
### 值对象
- `WeakPoint`knowledge_point_id + title + mastery_level
- `TrendPoint`date + score
- `DataScope`level (SELF/CLASS/GRADE/SCHOOL/DISTRICT/ALL) + scope_ids具体可见的 class_id / grade_id 列表)
### 聚合间通信
- 同服务内直接函数调用Application Service → Repository
- 跨服务:仅通过 Kafka 事件(发布 `mastery.updated`+ gRPC调 iam 查 DataScope
## 3. 数据模型ClickHouse DDL
> DDL 文件由 coord 统一管理在 `infra/clickhouse/ddl/`(待 coord 建立data-ana 提供内容。
### 3.1 宽表 `student_dashboard_view`
```sql
-- 学生学情宽表:每次成绩写入产生一行,按 ORDER BY 去重保留最新版本
CREATE TABLE IF NOT EXISTS student_dashboard_view
(
student_id String,
class_id String,
exam_id String,
subject_id String,
score Float64,
rank_in_class UInt32,
knowledge_point_id String,
mastery_level Float32, -- 0.0-1.0
error_count UInt32,
last_updated DateTime64(3, 'UTC')
)
ENGINE = ReplacingMergeTree(last_updated)
PARTITION BY toYYYYMM(last_updated)
ORDER BY (student_id, exam_id, knowledge_point_id)
SETTINGS index_granularity = 8192;
```
**索引策略**
- ORDER BY `(student_id, exam_id, knowledge_point_id)`:主键索引,支持按学生查学情、按考试查成绩、按知识点查掌握度
- PARTITION BY `toYYYYMM(last_updated)`:按月分区,支持历史数据归档
- ReplacingMergeTree(last_updated):同 ORDER BY 自动去重,保留 last_updated 最大版本(幂等消费保证)
### 3.2 错题本 `student_errors`
```sql
CREATE TABLE IF NOT EXISTS student_errors
(
student_id String,
question_id String,
knowledge_point_id String,
error_count UInt32,
last_error_time DateTime64(3, 'UTC'),
content String
)
ENGINE = ReplacingMergeTree(last_error_time)
PARTITION BY toYYYYMM(last_error_time)
ORDER BY (student_id, question_id);
```
### 3.3 掌握度快照 `mastery_snapshot`**新增**
```sql
-- 知识点掌握度历史快照:每次掌握度计算产生新版本,支持趋势查询
CREATE TABLE IF NOT EXISTS mastery_snapshot
(
student_id String,
knowledge_point_id String,
subject_id String,
mastery_level Float32,
calculated_at DateTime64(3, 'UTC'),
calculation_method LowCardinality(String) -- 'weighted_moving_avg' / 'simple_avg'
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(calculated_at)
ORDER BY (student_id, knowledge_point_id, calculated_at);
```
### 3.4 用量计费 `ai_usage_log`**新增,供 ai 服务写入**
```sql
-- AI 用量记录ai 服务通过 Kafka 事件投递data-ana 消费落库
CREATE TABLE IF NOT EXISTS ai_usage_log
(
request_id String,
user_id String,
provider LowCardinality(String), -- 'openai' / 'anthropic' / 'baichuan' / 'local'
model LowCardinality(String),
prompt_tokens UInt32,
completion_tokens UInt32,
total_tokens UInt32,
latency_ms UInt32,
success Boolean,
occurred_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (user_id, occurred_at);
```
### 3.5 读写分离策略
| 操作 | 路径 | 说明 |
| -------------- | ----------------------------------- | ------------------------------------------ |
| 学情查询 | ClickHouse 宽表 | 实时聚合,亚秒级响应 |
| 错题本查询 | ClickHouse student_errors | 实时查询 |
| 掌握度趋势 | ClickHouse mastery_snapshot | 历史快照 |
| 掌握度计算 | CDC 触发 → 内存计算 → 写 ClickHouse | 派生数据,非事务写 |
| DataScope 解析 | gRPC 调 iam | 实时查询,结果 Redis 缓存 5min004 §6.3 |
## 4. API 设计
### 4.1 HTTP 端点(保留作 Gateway 直连降级)
| method | path | 权限 | 请求 | 响应 | 说明 |
| ------ | ------------------------------------------- | ----------------------------- | ------------------------------------------------ | ----------------------------------------------------- | -------------------------------- |
| GET | `/healthz` | — | — | `{status, service}` | liveness |
| GET | `/readyz` | — | — | `{status, ready, degraded, clickhouse, cdc_consumer}` | readiness |
| GET | `/metrics` | — | — | Prometheus 格式 | 指标 |
| GET | `/analytics/class/{class_id}/performance` | `ANALYTICS_CLASS_READ` | query: `subject_id?`, `start_date?`, `end_date?` | `ClassPerformanceResponse` | 班级成绩分析 |
| GET | `/analytics/student/{student_id}/weakness` | `ANALYTICS_STUDENT_READ` | query: `subject_id?` | `StudentWeaknessResponse` | 学生薄弱知识点DataScope 过滤) |
| GET | `/analytics/student/{student_id}/errorbook` | `ANALYTICS_STUDENT_READ` | query: `page?`, `size?` | `StudentErrorBookResponse` | 学生错题本DataScope 过滤) |
| GET | `/analytics/student/{student_id}/trend` | `ANALYTICS_STUDENT_READ` | query: `start_date`, `end_date`, `subject_id?` | `LearningTrendResponse` | 学习趋势(**新增** |
| GET | `/analytics/dashboard/teacher/{user_id}` | `ANALYTICS_TEACHER_DASHBOARD` | query: `class_id?` | `TeacherDashboardResponse` | 教师仪表盘聚合(**新增** |
### 4.2 gRPC 契约analytics.proto待实现 server
| RPC | 请求 | 响应 | 权限 |
| --------------------- | ------------------------------------------------------------------------ | ------------------ | ------------------------ |
| `GetClassPerformance` | `GetClassPerformanceRequest{class_id, subject_id, start_date, end_date}` | `ClassPerformance` | `ANALYTICS_CLASS_READ` |
| `GetStudentWeakness` | `GetStudentWeaknessRequest{student_id, subject_id}` | `StudentWeakness` | `ANALYTICS_STUDENT_READ` |
| `GetLearningTrend` | `GetLearningTrendRequest{student_id, start_date, end_date}` | `LearningTrend` | `ANALYTICS_STUDENT_READ` |
**权限校验**gRPC server interceptor 从 metadata 提取 `x-user-id` / `x-user-roles` / `x-data-scope`,调用 `AuthDepends` 等价逻辑。
### 4.3 Pydantic 请求/响应模型
```python
# 示例:班级成绩分析响应
class ClassPerformanceResponse(BaseModel):
success: bool
data: ClassPerformanceData
degraded: bool = False
class ClassPerformanceData(BaseModel):
class_id: str
average_score: float
pass_rate: float
total_students: int
scores: list[StudentScore] = [] # 详细成绩列表(受 DataScope 过滤)
class StudentScore(BaseModel):
student_id: str
score: float
grade: str
```
## 5. 事件设计
### 5.1 消费的事件
| Topic | 来源 | 消息格式 | 消费动作 |
| ------------------------------------------------------ | ------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| `edu-cdc.next_edu_cloud.core_edu_grades` | Debezium CDCcore-edu MySQL | Debezium JSONbefore/after/source/op/ts_ms | 解析 → 查 ExamCache 填 class_id → 计算掌握度 → upsert student_dashboard_view |
| `edu-cdc.next_edu_cloud.core_edu_exams` | Debezium CDC | Debezium JSON | upsert ExamCacheexam_id → class_id, subject_id |
| `edu-cdc.next_edu_cloud.core_edu_homework_submissions` | Debezium CDC**新增订阅** | Debezium JSON | 记录作业提交行为 → 更新 student_dashboard_view |
| `edu-cdc.next_edu_cloud.classes` | Debezium CDC | Debezium JSON | 同步班级维度head_teacher_id用于教师 DataScope |
| `edu-cdc.next_edu_cloud.iam_users` | Debezium CDC**新增订阅** | Debezium JSON | 同步用户 dataScope 用于查询过滤(避免每次查 iam |
| `edu.insight.ai.usage` | ai 服务 Kafka producer**待 coord 新增 topic** | JSONUsageRecord | 落 ai_usage_log 表 |
**幂等性**
- CDC 事件:依赖 ClickHouse `ReplacingMergeTree(last_updated)` 引擎按 ORDER BY 去重
- 领域事件(若消费):基于 `event_id` 去重Redis SETNXTTL 7 天)
### 5.2 发布的事件
| 事件 | Topic | 触发时机 | 消费者 | Payload |
| ---------------- | ----------------------------- | ----------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
| `MasteryUpdated` | `edu.insight.mastery.updated` | 掌握度计算完成CDC grades 事件触发后异步计算) | core-edu推荐个性化练习、msg掌握度预警 | `{event_id, student_id, knowledge_point_id, mastery_level, calculated_at}` |
**发布实现**Python 无 Outbox 模式):
- 掌握度计算是**派生数据**(非业务事务写),不需要 Outbox 保证事务一致
- 直接用 `aiokafka.AIOKafkaProducer` 发布,`idempotent=true` + 事务性 producer
- 失败重试 3 次,仍失败记录日志 + 落 `mastery_publish_failed` 本地表(待 P6 评估是否引入 Outbox
## 6. 横切关注点对齐清单
### 6.1 权限装饰器等价物FastAPI Depends
```python
# 权限点常量(与 iam 权限点对齐)
class Permissions:
ANALYTICS_CLASS_READ = "analytics:class:read"
ANALYTICS_STUDENT_READ = "analytics:student:read"
ANALYTICS_TEACHER_DASHBOARD = "analytics:teacher:dashboard"
async def require_permission(permission: str) -> UserContext:
"""FastAPI Depends 权限校验.
从 x-user-id / x-user-roles 头提取身份,校验角色是否含 permission。
"""
...
async def inject_data_scope(ctx: UserContext = Depends(require_permission(...)))-> DataScope:
"""注入 DataScope从 iam.getEffectiveDataScope 查询Redis 缓存 5min."""
...
```
### 6.2 错误码清单(前缀 `DATA_ANA_*`
| 错误码 | 触发条件 | HTTP | gRPC status |
| --------------------------------- | --------------------------------------- | ------------------- | ------------------ |
| `DATA_ANA_UNAUTHORIZED` | 缺失 x-user-id 头或 token 无效 | 401 | UNAUTHENTICATED |
| `DATA_ANA_FORBIDDEN` | 角色无对应权限 | 403 | PERMISSION_DENIED |
| `DATA_ANA_DATASCOPE_VIOLATION` | 查询目标超出 DataScope 范围 | 403 | PERMISSION_DENIED |
| `DATA_ANA_CLICKHOUSE_UNAVAILABLE` | ClickHouse 不可达(降级模式仍返回骨架) | 200 + degraded:true | OK + degraded flag |
| `DATA_ANA_INVALID_DATE_RANGE` | start_date > end_date | 400 | INVALID_ARGUMENT |
| `DATA_ANA_STUDENT_NOT_FOUND` | student_id 不存在 | 404 | NOT_FOUND |
| `DATA_ANA_CLASS_NOT_FOUND` | class_id 不存在 | 404 | NOT_FOUND |
| `DATA_ANA_INTERNAL_ERROR` | 未捕获异常 | 500 | INTERNAL |
### 6.3 Logger 初始化
- 位置:`main.py` `init_logger()`(已具备)
- 配置:`structlog.make_filtering_bound_logger(level)` + `TimeStamper(fmt="iso")` + `ConsoleRenderer`
- **改进**:生产环境改用 `structlog.processors.JSONRenderer()`(当前 ConsoleRenderer 适合开发)
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| --------------------------------------------- | --------- | -------------------- | -------------------------- |
| `data_ana_http_requests_total` | Counter | method, path, status | HTTP 请求总数 |
| `data_ana_http_request_duration_seconds` | Histogram | method, path | HTTP 请求延迟 |
| `data_ana_clickhouse_query_duration_seconds` | Histogram | query_type | ClickHouse 查询延迟 |
| `data_ana_clickhouse_query_total` | Counter | query_type, status | ClickHouse 查询总数 |
| `data_ana_cdc_events_consumed_total` | Counter | table, op | CDC 事件消费总数 |
| `data_ana_cdc_event_process_duration_seconds` | Histogram | table | CDC 事件处理延迟 |
| `data_ana_cdc_consumer_lag` | Gauge | topic, partition | CDC 消费者 lag |
| `data_ana_mastery_calculated_total` | Counter | — | 掌握度计算次数 |
| `data_ana_mastery_published_total` | Counter | status | mastery.updated 事件发布数 |
| `data_ana_datascope_cache_hits_total` | Counter | — | DataScope 缓存命中 |
### 6.5 Tracer 初始化
- 位置:`main.py` `init_tracer()`(已具备)
- endpoint`settings.otel_endpoint` + `/v1/traces`
- **改进**gRPC server 注册 `grpc.aio.ServerInterceptor` 透传 W3C trace context
### 6.6 /healthz 检查逻辑
- liveness仅进程存活已具备
### 6.7 /readyz 检查逻辑
```python
async def readyz() -> dict:
return {
"status": "ok" if all_ready else "degraded",
"ready": all_ready,
"degraded": not all_ready,
"clickhouse": "ok" | "unreachable" | "not_configured",
"cdc_consumer": "running" | "disabled" | "failed",
"kafka_brokers": settings.kafka_brokers or None,
"iam_grpc": "ok" | "unreachable", # 新增iam gRPC 连通性
"timestamp": datetime.now(UTC).isoformat(),
}
```
### 6.8 优雅关闭顺序
1. HTTP server stop accepting new requestsuvicorn graceful shutdown
2. gRPC server graceful stop等待在途 RPC 完成30s 超时)
3. CDC consumer stop等待在途消息处理完成commit offset
4. Kafka producer flush + close确保 mastery.updated 事件已投递)
5. ClickHouse client close
6. iam gRPC channel close
**信号处理**:注册 `signal.SIGTERM` handler触发上述顺序。
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ------------------------- | ----- | ------------------------------------------------------------------- | ----------------------------- |
| 被调用 | api-gateway | HTTP | `/analytics/*` | Gateway 代理 |
| 被调用 | teacher-bff / student-bff | gRPC | `AnalyticsService.*` | BFF 聚合查询 |
| 被调用 | ai | gRPC | `AnalyticsService.GetStudentWeakness / GetLearningTrend` | AI 个性化出题上下文 |
| 调用 | iam | gRPC | `IamService.GetEffectiveDataScope`**待 proto 新增** | DataScope 解析 |
| 消费 | core-eduCDC | Kafka | `edu-cdc.next_edu_cloud.core_edu_grades/exams/homework_submissions` | 学情数据投递 |
| 消费 | core-eduCDC | Kafka | `edu-cdc.next_edu_cloud.classes` | 班级维度同步 |
| 消费 | iamCDC | Kafka | `edu-cdc.next_edu_cloud.iam_users` | 用户 dataScope 同步 |
| 消费 | ai | Kafka | `edu.insight.ai.usage`**待 coord 新增** | AI 用量落库 |
| 发布 | — | Kafka | `edu.insight.mastery.updated` | 掌握度更新通知 core-edu / msg |
## 8. 风险与假设
### 8.1 假设
- **假设 1**iam 提供 `GetEffectiveDataScope(userId) → DataScope` gRPC API。若 iam 未提供fallback 为:从 `x-user-roles` 头推导admin=ALL, teacher=CLASS_TAUGHT, student=SELF但无法支持细粒度年级/学校范围
- **假设 2**core-edu 的 `core_edu_homework_submissions` 表存在 binlog。若不存在作业相关学情无法通过 CDC 获取,需 core-edu 补表或走 Outbox 事件
- **假设 3**ClickHouse `ReplacingMergeTree` 在查询时需 `FINAL` 关键字确保去重生效。当前查询未加 `FINAL`,可能读到重复版本。**修复**:所有查询加 `FINAL` 或使用 `argMax` 聚合
- **假设 4**coord 同意新增 `edu.insight.ai.usage` topic。若不同意ai 服务的用量计费需自建记录(破坏 ai 无状态原则)
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| --------------------------- | ------------------------------------ | ------------------------------------------------ |
| ClickHouse 查询延迟超 5s | 违反 P4 退出标准 | 宽表索引优化 + 物化视图预聚合 + 查询超时 3s 降级 |
| CDC 消费者 lag 过大 | 学情数据延迟 > 5s | 监控 lag + 告警 + 水平扩展消费者(分区数提升) |
| ExamCache 内存泄漏 | 长期运行 OOM | LRU 淘汰策略max 10000 条)+ 定期清理过期 exam |
| mastery.updated 事件丢失 | 下游 core-edu/msg 收不到通知 | Kafka producer `acks=all` + 本地失败表重试 |
| iam gRPC 不可达 | DataScope 无法解析 → 查询降级为 SELF | Redis 缓存 5min + fallback SELF 范围(最保守) |
| ClickHouse `FINAL` 查询性能 | 查询变慢 | 使用 `argMax` 替代 `FINAL`,或在写入时去重 |
### 8.3 未决设计决策(需 coord 仲裁)
1. **mastery.updated 发布是否需要 Outbox**Python 无 Outbox 模式,建议直接 producer但 004 §12.2 明确"事件发布Outbox 模式 / 禁止直接 Kafka producer"。**冲突**data-ana 是 Python 服务无 MySQL 写事务Outbox 不适用。建议 coord 裁定:派生数据事件(非业务事务)允许直接 producer
2. **iam GetEffectiveDataScope proto 新增**:当前 iam.proto 仅有 `GetUserInfo`,无 DataScope 解析 API。需 coord 在 shared-proto 新增 `GetEffectiveDataScope` RPC
3. **edu.insight.ai.usage topic 新增**004 §7.2 未列出,需 coord 确认是否新增
---
# 模块架构设计文档 — ai
## 1. 模块内部分层图
```mermaid
flowchart TB
subgraph Entry["入口层"]
HTTP[FastAPI HTTP Router<br/>/ai/* + /healthz + /readyz]
GRPC[grpc.aio Server<br/>AiService 含 StreamChat]
end
subgraph Middleware["中间件层"]
AUTH[AuthDepends<br/>校验 x-user-id / x-user-roles]
RATE[RateLimitDepends<br/>Redis 令牌桶限流]
TRACE[OTel + grpc interceptor]
end
subgraph Service["应用服务层"]
S1[ChatService<br/>聊天编排 + Prompt 模板渲染]
S2[QuestionGenerationService<br/>出题工作流编排]
S3[ExpressionOptimizationService<br/>表达优化]
S4[LessonPreparationWorkflow<br/>备课工作流 4 步编排]
end
subgraph Provider["LLM Provider 适配层"]
P0[LLMProvider 抽象接口<br/>chat / stream_chat]
P1[OpenAIProvider<br/>httpx 异步]
P2[AnthropicProvider<br/>httpx 异步]
P3[BaichuanProvider<br/>httpx 异步]
P4[LocalOllamaProvider<br/>httpx 异步]
end
subgraph Template["Prompt 模板管理"]
T1[PromptTemplateRegistry<br/>模板注册 + 渲染]
T2[模板存储<br/>YAML 文件 / DB]
end
subgraph Client["下游 gRPC client"]
C1[ContentClient<br/>查询知识点 / 题库]
C2[DataAnaClient<br/>查询学情 / 薄弱点]
end
subgraph Usage["用量计费"]
U1[UsageRecorder<br/>token 消耗统计]
U2[KafkaProducer<br/>发布 edu.insight.ai.usage]
end
subgraph External["外部 / 存储"]
LLM[LLM Provider API<br/>OpenAI/Anthropic/百川/Ollama]
CONTENT[content:3005 gRPC]
DATAANA[data-ana:3006 gRPC]
KAFKA[(Kafka)]
REDIS[(Redis<br/>限流 + 缓存)]
end
HTTP --> AUTH --> RATE --> S1
HTTP --> S2
HTTP --> S3
GRPC --> S1
GRPC --> S2
S2 --> S4
S4 --> C1
S4 --> C2
S1 --> T1
S2 --> T1
S1 --> P0
S2 --> P0
P0 --> P1
P0 --> P2
P0 --> P3
P0 --> P4
P1 --> LLM
P2 --> LLM
P3 --> LLM
P4 --> LLM
S1 --> U1
S2 --> U1
U1 --> U2
U2 --> KAFKA
RATE --> REDIS
C1 --> CONTENT
C2 --> DATAANA
```
**分层规则**
- **入口层**HTTP保留作 Gateway 直连)+ gRPC主入口`StreamChat` 流式 RPC
- **中间件层**Auth + RateLimitRedis 令牌桶,按 user_id 限流)
- **应用服务层**4 个 Service每个对应一类 AI 能力
- **Provider 适配层**:抽象 `LLMProvider` 接口,多适配器实现(策略模式)
- **Prompt 模板**:模板注册 + 渲染模板存储可配置YAML 文件 or DB
- **下游 client**gRPC 调 content / data-ana
- **用量计费**token 消耗统计 + Kafka 事件外发
## 2. 领域模型
ai 是**无状态服务**,不持有持久化聚合根。领域模型为**请求/响应模型 + 工作流编排**
### 聚合根(请求型,无持久化)
| 聚合根 | 含义 | 生命周期 |
| ---------------------------- | ------------ | ------------------------------------------------------------ |
| `ChatConversation` | 单次聊天请求 | 单次请求 |
| `QuestionGenerationTask` | 出题任务 | 单次请求(备课工作流中多步) |
| `ExpressionOptimizationTask` | 表达优化任务 | 单次请求 |
| `LessonPreparationWorkflow` | 备课工作流 | 跨多步(分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库) |
### 值对象
- `ChatMessage`role + content
- `Usage`prompt_tokens + completion_tokens + total_tokens
- `GeneratedQuestion`question + answer + explanation
- `PromptTemplate`name + system_prompt + user_template + variables
### 工作流编排(备课)
```mermaid
sequenceDiagram
participant T as 教师
participant BFF as teacher-bff
participant AI as ai 服务
participant Content as content
participant DA as data-ana
T->>BFF: 请求备课class_id, subject_id
BFF->>AI: gRPC GenerateLessonPlan
AI->>DA: gRPC GetStudentWeakness(class_id)
DA-->>AI: 薄弱知识点列表
AI->>Content: gRPC GetPrerequisites(knowledge_point_id)
Content-->>AI: 前置依赖知识点
AI->>AI: LLM 生成题目(基于学情 + 知识点)
AI-->>BFF: 题目列表 + 推荐理由
BFF-->>T: 题目供审核
T->>BFF: 确认入库
BFF->>Content: gRPC CreateQuestions
Content-->>BFF: 入库成功
```
**工作流实现**
- **简单场景**4 步内FastAPI BackgroundTasks + asyncio.gather 并行查询
- **复杂场景**(含教师审核等待):待 coord 仲裁是否引入 Temporal004 §2.3 列出 Temporal 用于 AI 编排)
## 3. 数据模型
ai 服务**无独占数据库**,无 MySQL schema。所有数据通过 Kafka 事件外发(用量计费)或 gRPC 查询下游。
### 用量计费Kafka 事件 → data-ana 落 ClickHouse
```json
{
"event_id": "uuid",
"user_id": "user-xxx",
"request_id": "req-xxx",
"provider": "openai",
"model": "gpt-4o-mini",
"prompt_tokens": 150,
"completion_tokens": 80,
"total_tokens": 230,
"latency_ms": 1200,
"success": true,
"occurred_at": "2026-07-09T12:00:00Z"
}
```
**Topic**`edu.insight.ai.usage`**待 coord 新增**
### 缓存策略
| 数据 | 存储 | TTL | 失效策略 |
| -------------------------------------- | ----------------------------- | -------- | ---------------------- |
| Prompt 模板 | Redis模板变更事件驱动失效 | 1 小时 | 文件/DB 变更时主动失效 |
| LLM 响应(相同 prompt | Redishash 缓存) | 30 分钟 | 短 TTL避免陈旧 |
| DataScopeai 不需要,仅 data-ana 用) | — | — | — |
| 限流计数 | Redis 令牌桶 | 滑动窗口 | 自动过期 |
## 4. API 设计
### 4.1 HTTP 端点(保留作 Gateway 直连降级)
| method | path | 权限 | 请求体 | 响应 | 说明 |
| ------ | ------------------------- | ------------------------ | --------------------------- | ------------------------------------ | ---------------------- |
| GET | `/healthz` | — | — | `{status, service}` | liveness |
| GET | `/readyz` | — | — | `{status, llm_configured, degraded}` | readiness |
| GET | `/metrics` | — | — | Prometheus | 指标 |
| POST | `/ai/chat` | `AI_CHAT` | `ChatRequest` | `ChatResponse` | LLM 聊天 |
| POST | `/ai/chat/stream` | `AI_CHAT` | `ChatRequest` | SSE stream | 流式聊天 |
| POST | `/ai/generate/question` | `AI_QUESTION_GENERATE` | `GenerateQuestionRequest` | `GeneratedQuestionResponse` | 生成题目 |
| POST | `/ai/optimize/expression` | `AI_EXPRESSION_OPTIMIZE` | `OptimizeExpressionRequest` | `OptimizedExpressionResponse` | 优化表达 |
| POST | `/ai/lesson/preparation` | `AI_LESSON_PREPARE` | `LessonPreparationRequest` | `LessonPreparationResponse` | 备课工作流(**新增** |
### 4.2 gRPC 契约ai.proto待实现 server
| RPC | 请求 | 响应 | 权限 | 说明 |
| -------------------- | ------------------------------------------------------ | ------------------------------------- | ------------------------ | ------------------------- |
| `Chat` | `ChatRequest{messages, model, temperature}` | `ChatResponse{content, model, usage}` | `AI_CHAT` | 非流式聊天 |
| `StreamChat` | `ChatRequest` | `stream ChatChunk` | `AI_CHAT` | 流式聊天SSE over gRPC |
| `GenerateQuestion` | `GenerateQuestionRequest{prompt, subject, difficulty}` | `GeneratedQuestion` | `AI_QUESTION_GENERATE` | 生成题目 |
| `OptimizeExpression` | `OptimizeExpressionRequest{text, context}` | `OptimizedExpression` | `AI_EXPRESSION_OPTIMIZE` | 优化表达 |
### 4.3 Pydantic 请求/响应模型
```python
class ChatRequest(BaseModel):
messages: list[ChatMessage]
model: str = "gpt-4o-mini"
temperature: float = Field(0.7, ge=0.0, le=2.0)
stream: bool = False
class ChatMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
class ChatResponse(BaseModel):
success: bool
data: ChatData
degraded: bool = False
class ChatData(BaseModel):
content: str
model: str
usage: Usage
class Usage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class GenerateQuestionRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=2000)
subject: str
difficulty: Literal["easy", "medium", "hard"]
knowledge_point_ids: list[str] = [] # 可选:靶向知识点
class GeneratedQuestionResponse(BaseModel):
success: bool
data: GeneratedQuestionData
degraded: bool = False
```
## 5. 事件设计
### 5.1 消费的事件
ai 服务**不消费任何事件**(无状态,纯请求-响应)。
### 5.2 发布的事件
| 事件 | Topic | 触发时机 | 消费者 | Payload |
| ----------------- | ------------------------------------------- | ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `AIUsageRecorded` | `edu.insight.ai.usage`**待 coord 新增** | 每次 LLM 调用完成 | data-ana落 ClickHouse ai_usage_log | `{event_id, user_id, request_id, provider, model, prompt_tokens, completion_tokens, total_tokens, latency_ms, success, occurred_at}` |
**发布实现**
- 每次 LLM 调用后异步发布(不阻塞响应)
- `aiokafka.AIOKafkaProducer` + `acks=all`
- 失败重试 3 次,仍失败记录日志(不影响主流程)
## 6. 横切关注点对齐清单
### 6.1 权限装饰器等价物
```python
class Permissions:
AI_CHAT = "ai:chat"
AI_QUESTION_GENERATE = "ai:question:generate"
AI_EXPRESSION_OPTIMIZE = "ai:expression:optimize"
AI_LESSON_PREPARE = "ai:lesson:prepare"
async def require_permission(permission: str) -> UserContext:
"""从 x-user-id / x-user-roles 校验权限."""
...
```
### 6.2 错误码清单(前缀 `AI_*`
| 错误码 | 触发条件 | HTTP | gRPC status |
| --------------------------- | ----------------------------------------- | ------------------- | ------------------ |
| `AI_UNAUTHORIZED` | 缺失 x-user-id 或 token 无效 | 401 | UNAUTHENTICATED |
| `AI_FORBIDDEN` | 角色无对应权限 | 403 | PERMISSION_DENIED |
| `AI_RATE_LIMITED` | 触发限流 | 429 | RESOURCE_EXHAUSTED |
| `AI_LLM_UNAVAILABLE` | LLM Provider 不可达(降级模式仍返回骨架) | 200 + degraded:true | OK + degraded flag |
| `AI_LLM_TIMEOUT` | LLM 调用超时30s | 504 | DEADLINE_EXCEEDED |
| `AI_INVALID_MODEL` | model 名不支持 | 400 | INVALID_ARGUMENT |
| `AI_INVALID_DIFFICULTY` | difficulty 不在 easy/medium/hard | 400 | INVALID_ARGUMENT |
| `AI_DOWNSTREAM_UNAVAILABLE` | content / data-ana gRPC 不可达 | 502 | UNAVAILABLE |
| `AI_PROMPT_RENDER_FAILED` | Prompt 模板渲染失败(变量缺失) | 500 | INTERNAL |
| `AI_INTERNAL_ERROR` | 未捕获异常 | 500 | INTERNAL |
### 6.3 Logger 初始化
- 位置:`main.py`(已具备)
- **改进**:生产环境改用 `JSONRenderer`
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| ---------------------------------- | --------- | ----------------------------------------- | ------------------ |
| `ai_http_requests_total` | Counter | method, path, status | HTTP 请求总数 |
| `ai_http_request_duration_seconds` | Histogram | method, path | HTTP 请求延迟 |
| `ai_llm_calls_total` | Counter | provider, model, status | LLM 调用总数 |
| `ai_llm_call_duration_seconds` | Histogram | provider, model | LLM 调用延迟 |
| `ai_llm_tokens_total` | Counter | provider, model, type (prompt/completion) | token 消耗总数 |
| `ai_llm_stream_chunks_total` | Counter | provider, model | 流式 chunk 总数 |
| `ai_grpc_calls_total` | Counter | downstream, method, status | 下游 gRPC 调用总数 |
| `ai_rate_limit_hits_total` | Counter | user_id | 限流命中次数 |
| `ai_usage_events_published_total` | Counter | status | 用量事件发布数 |
| `ai_prompt_template_renders_total` | Counter | template_name, status | 模板渲染次数 |
### 6.5 Tracer 初始化
- 位置:`main.py` `init_tracer()`已具备dev_mode 跳过)
- **改进**gRPC server interceptor + 下游 gRPC client interceptor 透传 trace context
### 6.6 /healthz 检查逻辑
- liveness仅进程存活已具备
### 6.7 /readyz 检查逻辑
```python
async def readyz() -> dict:
return {
"status": "ok",
"service": "ai",
"llm_configured": settings.llm_available,
"degraded": not settings.llm_available,
"providers": {
"openai": bool(settings.openai_api_key),
"anthropic": bool(settings.anthropic_api_key),
"baichuan": bool(settings.baichuan_api_key),
"local_ollama": bool(settings.ollama_base_url),
},
"downstream_grpc": {
"content": "ok" | "unreachable", # 新增
"data_ana": "ok" | "unreachable", # 新增
},
"redis": "ok" | "unreachable", # 新增(限流依赖)
}
```
### 6.8 优雅关闭顺序
1. HTTP server stop accepting new requests
2. gRPC server graceful stop**关键**:等待在途 `StreamChat` 流式 RPC 完成60s 超时,避免截断用户响应)
3. LLM 流式请求 drain等待 httpx stream 完成)
4. Kafka producer flush + close确保用量事件已投递
5. 下游 gRPC channels closecontent / data-ana
6. Redis connection close
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ------------ | ----- | ---------------------------------------------------------- | ------------------ |
| 被调用 | api-gateway | HTTP | `/ai/*` | Gateway 代理 |
| 被调用 | teacher-bff | gRPC | `AiService.*` | BFF 聚合 AI 能力 |
| 调用 | content | gRPC | `KnowledgeGraphService.GetPrerequisites / GetLearningPath` | 出题上下文查询 |
| 调用 | content | gRPC | `TextbookService.*`(若需教材上下文) | 出题教材关联 |
| 调用 | data-ana | gRPC | `AnalyticsService.GetStudentWeakness / GetLearningTrend` | 个性化出题学情查询 |
| 调用 | LLM Provider | HTTP | OpenAI 兼容 REST `/chat/completions` | LLM 推理 |
| 发布 | — | Kafka | `edu.insight.ai.usage`**待 coord 新增** | 用量计费外发 |
## 8. 风险与假设
### 8.1 假设
- **假设 1**content 服务实现了 `KnowledgeGraphService` gRPC server。当前 content.proto 已定义但未实现 gRPC serverai05 阶段 2 设计中。ai 调用前需确认 content gRPC 可用
- **假设 2**data-ana 实现了 `AnalyticsService` gRPC server本设计文档已设计。ai 调用前需确认 data-ana gRPC 可用
- **假设 3**coord 同意新增 `edu.insight.ai.usage` topic。若不同意用量计费降级为 ai 本地日志(不落 ClickHouse影响成本分析
- **假设 4**Redis 可用(限流依赖)。若 Redis 不可用,限流降级为"无限制"风险LLM 成本失控),或降级为内存令牌桶(单实例有效,多实例不一致)
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| ---------------------- | ---------------- | --------------------------------------------------------------- |
| LLM 调用延迟高(>30s | 用户体验差 | 超时 30s + 降级骨架响应 + 流式优先(用户感知首字延迟) |
| LLM 成本失控 | 财务风险 | Redis 令牌桶限流(每用户每分钟 10 次)+ 用量计费监控 + 告警阈值 |
| LLM Provider 单点故障 | 服务不可用 | 多 Provider 适配器 + 自动 failoverOpenAI 失败切 Anthropic |
| 流式 RPC 中断 | 用户响应截断 | gRPC server graceful shutdown 60s drain + 客户端重连机制 |
| Prompt 注入攻击 | LLM 输出恶意内容 | 输入 sanitize + system prompt 加安全约束 + 输出过滤 |
| 下游 gRPC 不可达 | 备课工作流失败 | 降级:跳过学情查询,仅基于 prompt 生成题目 + `degraded: true` |
### 8.3 未决设计决策(需 coord 仲裁)
1. **备课工作流是否引入 Temporal**004 §2.3 列出 Temporal 用于 AI 编排,但简单 4 步工作流可用 FastAPI BackgroundTasks。建议M14 用 BackgroundTasksM15 评估是否迁移 Temporal
2. **`edu.insight.ai.usage` topic 新增**:需 coord 在 shared-proto events.proto 新增 `AIUsageEvent` message + 004 §7.2 新增 topic
3. **LLM Provider 配置化路由**:是否在 shared-py 建立通用 `LLMProvider` 抽象(供未来其他 Python 服务复用)。建议 P5 阶段在 ai 服务内部实现P6 评估是否提取到 shared-py
4. **Prompt 模板存储位置**YAML 文件(简单,无 DBvs DB动态更新。建议 P5 用 YAML 文件(`services/ai/src/ai/prompts/*.yaml`P6 评估迁移 DB
---
# 阶段 2 总结
ai06 已完成阶段 2 模块架构设计,产出 data-ana 与 ai 两份设计文档。核心设计决策:
## data-ana 设计要点
1. **分层**HTTP + gRPC 双入口共享 Application ServiceCDC 消费者独立后台任务
2. **数据模型**4 张 ClickHouse 宽表student_dashboard_view / student_errors / mastery_snapshot / ai_usage_logReplacingMergeTree 引擎保证幂等
3. **权限**FastAPI Depends 链require_permission + inject_data_scopeDataScope WHERE 注入 ClickHouse 查询
4. **事件**:消费 6 个 CDC topic + 发布 `edu.insight.mastery.updated`(直接 producer非 Outbox因派生数据非事务写
5. **gRPC**:实现 `AnalyticsService` serveranalytics.proto 已定义)
6. **降级**ClickHouse 不可达返回骨架 + degraded:trueiam gRPC 不可达降级为 SELF DataScope
## ai 设计要点
1. **分层**HTTP + gRPC 双入口LLM Provider 适配层策略模式4 适配器)
2. **无状态**:无 DB用量计费通过 Kafka 事件外发
3. **权限**FastAPI Depends + Redis 令牌桶限流(每用户每分钟 10 次)
4. **工作流**:备课 4 步编排(学情查询 → 知识点推荐 → 题目生成 → 教师审核入库),简单场景用 BackgroundTasks
5. **gRPC**:实现 `AiService` server`StreamChat` 流式 RPCgRPC client 调 content / data-ana
6. **降级**LLM 不可达返回骨架 + degraded:true下游 gRPC 不可达降级跳过
## 待 coord 交叉审查项(汇总)
| # | 议题 | 涉及文档 |
| --- | ------------------------------------------------------------------------------------ | ----------------------- |
| 1 | data-ana 发布 `edu.insight.mastery.updated` 用直接 producer非 Outbox是否合规 | 004 §12.2 |
| 2 | 新增 `edu.insight.ai.usage` topic + `AIUsageEvent` proto message | 004 §7.2 + events.proto |
| 3 | iam 新增 `GetEffectiveDataScope` gRPC RPC | iam.proto |
| 4 | data-ana / ai 实现 gRPC server 决策 | 004 §4.1 |
| 5 | ClickHouse DDL 管理位置(建议 `infra/clickhouse/ddl/` | infra/ |
| 6 | ai 备课工作流是否引入 Temporal | 004 §2.3 |
| 7 | 端口冲突检查data-ana 3006 / ai 3008无冲突 | full-stack-runbook |
| 8 | 错误码前缀检查:`DATA_ANA_*` / `AI_*`(与其他服务不重叠) | — |
| 9 | 黄金模板对齐Python 服务无 NestJS 装饰器,权限校验用 FastAPI Depends 等价物是否认可 | — |
下一步:等待 coord 交叉审查通过后,进入阶段 3按图实施