diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34eae37..67dafda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,14 +45,13 @@ jobs: - name: Lint run: pnpm -r run lint - continue-on-error: true # P1: ESLint 9 flat config 迁移未完成 - name: Typecheck run: pnpm -r run typecheck - name: Test run: pnpm -r run test - continue-on-error: true # P1: 部分服务无 test 脚本 + continue-on-error: true # P6: 部分服务无 test 脚本,待补全 - name: Build run: pnpm -r run build diff --git a/apps/teacher-portal/package.json b/apps/teacher-portal/package.json index 7a2da05..e6ec4bd 100644 --- a/apps/teacher-portal/package.json +++ b/apps/teacher-portal/package.json @@ -6,7 +6,7 @@ "dev": "next dev -p 3000", "build": "next build", "start": "next start -p 3000", - "lint": "next lint", + "lint": "eslint src", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -19,6 +19,7 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.0", + "eslint": "^9.0.0", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.6.0" diff --git a/apps/teacher-portal/src/app/(app)/classes/page.tsx b/apps/teacher-portal/src/app/(app)/classes/page.tsx index 3b9d025..b546990 100644 --- a/apps/teacher-portal/src/app/(app)/classes/page.tsx +++ b/apps/teacher-portal/src/app/(app)/classes/page.tsx @@ -51,7 +51,6 @@ export default function ClassesPage() { } finally { setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/apps/teacher-portal/src/app/(app)/exams/page.tsx b/apps/teacher-portal/src/app/(app)/exams/page.tsx index 088e326..59032c3 100644 --- a/apps/teacher-portal/src/app/(app)/exams/page.tsx +++ b/apps/teacher-portal/src/app/(app)/exams/page.tsx @@ -56,7 +56,6 @@ export default function ExamsPage() { } finally { setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [classId]); useEffect(() => { diff --git a/apps/teacher-portal/src/app/(app)/grades/page.tsx b/apps/teacher-portal/src/app/(app)/grades/page.tsx index c797f0d..54bff4f 100644 --- a/apps/teacher-portal/src/app/(app)/grades/page.tsx +++ b/apps/teacher-portal/src/app/(app)/grades/page.tsx @@ -52,7 +52,6 @@ export default function GradesPage() { } finally { setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [examId]); useEffect(() => { diff --git a/apps/teacher-portal/src/app/(app)/homework/page.tsx b/apps/teacher-portal/src/app/(app)/homework/page.tsx index 3db20b1..6940685 100644 --- a/apps/teacher-portal/src/app/(app)/homework/page.tsx +++ b/apps/teacher-portal/src/app/(app)/homework/page.tsx @@ -54,7 +54,6 @@ export default function HomeworkPage() { } finally { setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [classId]); useEffect(() => { diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index 5bcf23c..edddee1 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -86,15 +86,20 @@ ### 1.6 可观测性(OTel + Prometheus + Loki) -| 场景 | 技术/规则 | -| --------------- | -------------------------------------------------------------------------------- | -| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) | -| 三支柱 | Logs(pino/winston/zap)+ Metrics(prom-client)+ Traces(OTel SDK) | -| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 | -| P6 完整后端 | Loki(日志)+ Grafana(仪表盘)+ Jaeger(trace)+ Prometheus(metrics) | -| Prometheus 指标 | `http_request_duration_seconds`(Histogram)+ `http_requests_total`(Counter) | -| 采样策略 | P1 全量 trace,P6 引入采样率降低开销 | -| 日志参数顺序 | `log.error({ err: error, userId, traceId }, "操作描述")`,错误对象字段名用 `err` | +| 场景 | 技术/规则 | +| ----------------- | ------------------------------------------------------------------------------------------------------------------- | +| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) | +| 三支柱 | Logs(pino/winston/zap)+ Metrics(prom-client)+ Traces(OTel SDK) | +| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 | +| P6 完整后端 | Loki(日志)+ Grafana(仪表盘)+ Jaeger(trace)+ Prometheus(metrics) | +| Prometheus 指标 | `http_request_duration_seconds`(Histogram)+ `http_requests_total`(Counter) | +| 采样策略 | P1 全量 trace,P6 引入采样率降低开销 | +| 日志参数顺序 | `log.error({ err: error, userId, traceId }, "操作描述")`,错误对象字段名用 `err` | +| P6 /metrics 端点 | NestJS 用 `app.getHttpAdapter().get('/metrics', handler)`,不能用 `app.get()`(会被解析为 DI 容器 get) | +| P6 prometheus.yml | 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身;rule_files 引用 rules.yml;alerting 关联 alertmanager | +| P6 Grafana 数据源 | provisioning/datasources 同时声明 Prometheus(默认)和 Loki(uid=loki) | +| P6 Promtail 采集 | docker_sd_configs + relabel_configs 仅采集 `edu-*` 前缀容器日志,避免无关日志 | +| P6 service label | static_configs.labels.service 给所有抓取目标打服务标签,告警规则按 service 聚合 | ### 1.7 微前端 Module Federation @@ -328,6 +333,7 @@ | 日期 | 时间 | 模块 | 做了什么 + 学到什么 | | ---------- | ---- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-09 | 下午 | 全局 | **P6 硬化:可观测性 + 部署 + CI 硬化**:(1) 可观测性栈完善:5 个 NestJS 服务 main.ts 添加 `/metrics` Prometheus 端点(用 `app.getHttpAdapter().get('/metrics', ...)` 绕过 DI 容器 get 方法);prometheus.yml 从 2 个目标扩展到 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身 + rule_files + alertmanager 关联;monitoring compose 用 Loki + Promtail 替换未配置的 blackbox-exporter;Grafana datasource 新增 Loki;新建 promtail/config.yml 用 docker_sd_configs 仅采集 `edu-*` 容器日志。(2) 部署 compose 扩展:docker-compose.deploy.yml 从 3 服务扩展到 11 服务(+ iam/teacher-bff/core-edu/content/msg/ai/data-ana/push-gateway),每个服务带 healthcheck + depends_on 条件 + edu-net/edu-shared 双网络;deploy.env.example 补全 Neo4j/ES/ClickHouse/LLM/Kafka 可选依赖配置。(3) teacher-bff 补 health.controller.ts(原缺失 /healthz 导致 deploy depends_on service_healthy 失败)。(4) CI 硬化:移除 lint 步骤的 continue-on-error(ESLint 9 flat config 已配置完成),test 保留 continue-on-error(部分服务无 test 脚本)。**学到**:NestJS `app.get('/metrics')` 会被解析为 DI 容器 `get(typeOrToken)`,必须用 `app.getHttpAdapter().get()` 才能注册 Express 路由;Promtail docker_sd_configs 通过 relabel_configs 的 `regex: '/(edu-.*).*'` 过滤容器名前缀;docker-compose.depends_on.condition: service_healthy 要求被依赖服务必须有 healthcheck 配置,否则启动失败。 | | 2026-07-09 | 下午 | 全局 | **P6 硬化:ESLint 9 flat config 配置**:(1) 根目录创建 `eslint.config.js`(ESLint 9 flat config 格式):用 `typescript-eslint` recommended 规则集 + `@eslint/js` recommended + `eslint-config-prettier` 禁用冲突规则;自定义规则:`no-explicit-any` warn + `no-unused-vars` 允许下划线前缀 + 测试文件放宽。(2) 6 个 TS 服务 package.json lint 脚本从 `eslint src --ext .ts` 改为 `eslint src`(flat config 不需要 --ext)。(3) `lint-staged.config.js` 恢复 `eslint --fix`。(4) 验证:classes/content/msg/core-edu 四服务 lint 全部零错误零警告通过。**学到**:ESLint 9 flat config 用 `tseslint.config()` 工厂函数组装配置数组;`--ext` 参数在 flat config 模式下被移除,ESLint 自动根据 `eslint.config.js` 中的 `files` 匹配;`@typescript-eslint/consistent-type-assertions` 规则选项格式在 v8 中变化(`objectLiteralType` → `objectLiteralTypeAssertions`),配置时需查最新文档。 | | 2026-07-09 | 中午 | msg/push-gateway/ai/api-gateway | **P5 沟通与 AI 阶段三服务完善**:(1) msg 服务修复:database.ts 导出 db 常量;env.ts JWT_SECRET/ES_URL 改 optional 加 DEV_MODE/PUSH_GATEWAY_URL;elasticsearch.ts ES 降级(esClient=null 时 safeIndex/safeSearch 跳过);notifications.service.ts 加 createBatch + listByUserWithPagination + Push Gateway 推送调用(try/catch 降级);新建 msg-init.sql 2 张表。(2) push-gateway 完善:hub.go 重写用 send chan + 单写协程模式修复 gorilla/websocket 并发写竞争;handler.go 加 DEV_MODE dev-token 支持 + broadcast 端点;config.go 加 DevMode/RedisURL。(3) ai 服务完善:config.py 加 openai_api_key/base_url/dev_mode;新建 llm_client.py(httpx 异步调 OpenAI REST API);main.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true)+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg,/ai → ai 服务。**学到**:gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入;FastAPI APIRouter prefix 与 Gateway 代理路径要协调(ai 服务加 /ai 前缀,Gateway 代理 /ai/*path);LLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 | | 2026-07-09 | 上午 | content/api-gateway | **P4 内容分析服务端到端打通**:(1) content 服务系统性修复:database.ts 导出 db 常量;env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODE;neo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000;health/lifecycle 改用 Drizzle;global-error.filter 移除 @types/express 依赖;textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型;textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块:chapters(CRUD + 按 textbook 查询)、knowledge-points(CRUD + Neo4j 前置依赖图非阻塞查询)、questions(CRUD + 4 种题型校验)。(3) Gateway 路由扩展:textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库:content-init.sql 4 张表。(5) E2E 验证:POST /textbooks 201 → POST /chapters 201(字段用 order 非 orderNum)→ POST /knowledge-points 201(Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**:Drizzle schema TS 字段名与 DB 列名解耦(order→order_num),API 请求体用 TS 字段名;Neo4j 不可用时必须 driver=null(不设 NEO4J_URL),否则每次请求尝试连接拖慢响应;neo4j-driver safeCreateNode 用 try/catch 非阻塞,MySQL 数据始终先落库。 | diff --git a/infra/deploy.env.example b/infra/deploy.env.example index ff3c056..89f640f 100644 --- a/infra/deploy.env.example +++ b/infra/deploy.env.example @@ -27,12 +27,29 @@ JWT_AUDIENCE=next-edu-cloud API_GATEWAY_PORT=8080 TEACHER_PORTAL_PORT=3000 -# ============ Kafka(P3 阶段启用,P1 留空)============ +# ============ Kafka(P3+ 启用,留空则 Outbox publisher 持续重试)============ KAFKA_BROKERS= -# ============ 可观测性(P6 阶段启用,P1 留空)============ -OTEL_EXPORTER_OTLP_ENDPOINT= +# ============ 可观测性(P6 启用)============ +# OTLP collector 端点,留空则服务跳过 trace 上报 +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 LOG_LEVEL=info -# ============ 镜像 tag(由 CI 注入,手动部署时可改)============ -# IMAGE_TAG=latest ← 默认 latest,CI 通过 export IMAGE_TAG 覆盖 +# ============ Neo4j(content 服务,留空则降级模式)============ +NEO4J_URL= +NEO4J_PASSWORD= + +# ============ Elasticsearch(msg 服务,留空则降级模式)============ +ES_URL= + +# ============ ClickHouse(data-ana 服务,留空则降级模式)============ +CLICKHOUSE_HOST= +CLICKHOUSE_PORT=8123 +CLICKHOUSE_DATABASE=edu_analytics +CLICKHOUSE_USER= +CLICKHOUSE_PASSWORD= + +# ============ LLM 配置(ai 服务,留空则降级模式)============ +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.openai.com/v1 +ANTHROPIC_API_KEY= diff --git a/infra/docker-compose.deploy.yml b/infra/docker-compose.deploy.yml index da216c9..b00b27c 100644 --- a/infra/docker-compose.deploy.yml +++ b/infra/docker-compose.deploy.yml @@ -18,6 +18,9 @@ name: edu services: + # ============================================================ + # 应用服务(10 个:api-gateway + 3 Go/Python + 6 NestJS) + # ============================================================ api-gateway: build: context: ./repo/services/api-gateway @@ -34,6 +37,11 @@ services: CLASSES_SERVICE_URL: http://classes:3001 IAM_SERVICE_URL: http://iam:3002 TEACHER_BFF_URL: http://teacher-bff:3003 + CORE_EDU_SERVICE_URL: http://core-edu:3004 + CONTENT_SERVICE_URL: http://content:3005 + DATA_ANA_SERVICE_URL: http://data-ana:3006 + MSG_SERVICE_URL: http://msg:3007 + AI_SERVICE_URL: http://ai:3008 OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} ports: @@ -59,7 +67,6 @@ services: restart: unless-stopped environment: PORT: 3001 - # 连接服务器已有的 MySQL(容器名 edu-mysql,需在 edu-shared 网络) DATABASE_URL: ${DATABASE_URL} REDIS_URL: ${REDIS_URL} KAFKA_BROKERS: ${KAFKA_BROKERS:-} @@ -75,6 +82,214 @@ services: - edu-net - edu-shared + iam: + build: + context: ./repo + dockerfile: services/iam/Dockerfile + container_name: edu-iam + restart: unless-stopped + environment: + PORT: 3002 + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + JWT_SECRET: ${JWT_SECRET} + JWT_ISSUER: ${JWT_ISSUER:-next-edu-cloud} + JWT_AUDIENCE: ${JWT_AUDIENCE:-next-edu-cloud} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} + LOG_LEVEL: ${LOG_LEVEL:-info} + NODE_ENV: production + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3002/healthz"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 5 + networks: + - edu-net + - edu-shared + + teacher-bff: + build: + context: ./repo + dockerfile: services/teacher-bff/Dockerfile + container_name: edu-teacher-bff + restart: unless-stopped + environment: + PORT: 3003 + IAM_SERVICE_URL: http://iam:3002 + CLASSES_SERVICE_URL: http://classes:3001 + CORE_EDU_SERVICE_URL: http://core-edu:3004 + LOG_LEVEL: ${LOG_LEVEL:-info} + NODE_ENV: production + depends_on: + iam: + condition: service_healthy + classes: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3003/healthz"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + networks: + - edu-net + - edu-shared + + core-edu: + build: + context: ./repo + dockerfile: services/core-edu/Dockerfile + container_name: edu-core-edu + restart: unless-stopped + environment: + PORT: 3004 + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + KAFKA_BROKERS: ${KAFKA_BROKERS:-} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} + LOG_LEVEL: ${LOG_LEVEL:-info} + NODE_ENV: production + DEV_MODE: "false" + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3004/healthz"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 5 + networks: + - edu-net + - edu-shared + + content: + build: + context: ./repo + dockerfile: services/content/Dockerfile + container_name: edu-content + restart: unless-stopped + environment: + PORT: 3005 + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + NEO4J_URL: ${NEO4J_URL:-} + NEO4J_PASSWORD: ${NEO4J_PASSWORD:-} + ES_URL: ${ES_URL:-} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} + LOG_LEVEL: ${LOG_LEVEL:-info} + NODE_ENV: production + DEV_MODE: "false" + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3005/healthz"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 5 + networks: + - edu-net + - edu-shared + + msg: + build: + context: ./repo + dockerfile: services/msg/Dockerfile + container_name: edu-msg + restart: unless-stopped + environment: + PORT: 3007 + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + KAFKA_BROKERS: ${KAFKA_BROKERS:-} + ES_URL: ${ES_URL:-} + PUSH_GATEWAY_URL: http://push-gateway:8081 + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} + LOG_LEVEL: ${LOG_LEVEL:-info} + NODE_ENV: production + DEV_MODE: "false" + depends_on: + push-gateway: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3007/healthz"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 5 + networks: + - edu-net + - edu-shared + + ai: + build: + context: ./repo/services/ai + dockerfile: Dockerfile + container_name: edu-ai + restart: unless-stopped + environment: + PORT: 3008 + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} + LOG_LEVEL: ${LOG_LEVEL:-info} + DEV_MODE: "false" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3008/healthz')"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + networks: + - edu-net + - edu-shared + + data-ana: + build: + context: ./repo/services/data-ana + dockerfile: Dockerfile + container_name: edu-data-ana + restart: unless-stopped + environment: + PORT: 3006 + CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-} + CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123} + CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-edu_analytics} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-} + OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} + LOG_LEVEL: ${LOG_LEVEL:-info} + DEV_MODE: "false" + KAFKA_BROKERS: ${KAFKA_BROKERS:-} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3006/healthz')"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + networks: + - edu-net + - edu-shared + + push-gateway: + build: + context: ./repo/services/push-gateway + dockerfile: Dockerfile + container_name: edu-push-gateway + restart: unless-stopped + environment: + PUSH_GATEWAY_PORT: 8081 + JWT_SECRET: ${JWT_SECRET} + REDIS_URL: ${REDIS_URL} + DEV_MODE: "false" + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8081/healthz"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + networks: + - edu-net + - edu-shared + teacher-portal: build: context: ./repo diff --git a/infra/docker-compose.monitoring.yml b/infra/docker-compose.monitoring.yml index a73d71d..750b402 100644 --- a/infra/docker-compose.monitoring.yml +++ b/infra/docker-compose.monitoring.yml @@ -14,6 +14,8 @@ volumes: name: edu-alertmanager-data grafana-data: name: edu-grafana-data + loki-data: + name: edu-loki-data services: # ============================================================ @@ -104,16 +106,35 @@ services: - edu-network # ============================================================ - # blackbox-exporter - 黑盒探测(HTTP / TCP / ICMP) + # Loki - 日志聚合(P6 硬化新增) # ============================================================ - blackbox-exporter: - image: prom/blackbox-exporter:v0.25.0 - container_name: edu-blackbox-exporter + loki: + image: grafana/loki:3.2.1 + container_name: edu-loki profiles: ["monitoring"] restart: unless-stopped + command: -config.file=/etc/loki/local-config.yaml ports: - - "9115:9115" + - "3100:3100" volumes: - - ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro + - loki-data:/loki networks: - edu-network + + # ============================================================ + # Promtail - 日志采集(收集 Docker 容器日志发送到 Loki) + # ============================================================ + promtail: + image: grafana/promtail:3.2.1 + container_name: edu-promtail + profiles: ["monitoring"] + restart: unless-stopped + command: -config.file=/etc/promtail/config.yml + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - edu-network + depends_on: + - loki diff --git a/infra/grafana/provisioning/datasources/prometheus.yml b/infra/grafana/provisioning/datasources/prometheus.yml index fa95315..69bd0bd 100644 --- a/infra/grafana/provisioning/datasources/prometheus.yml +++ b/infra/grafana/provisioning/datasources/prometheus.yml @@ -13,3 +13,12 @@ datasources: timeInterval: "15s" httpMethod: POST manageAlerts: false + + - name: Loki + type: loki + uid: loki + access: proxy + url: http://loki:3100 + editable: false + jsonData: + maxLines: 1000 diff --git a/infra/prometheus.yml b/infra/prometheus.yml index dc8e82b..9422c7e 100644 --- a/infra/prometheus.yml +++ b/infra/prometheus.yml @@ -2,11 +2,93 @@ global: scrape_interval: 15s evaluation_interval: 15s -scrape_configs: - - job_name: 'api-gateway' - static_configs: - - targets: ['host.docker.internal:8080'] +# 告警规则文件 +rule_files: + - /etc/prometheus/rules.yml +# 告警管理器 +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:9093 + +scrape_configs: + # ============================================================ + # 应用服务(NestJS / FastAPI 暴露 /metrics) + # ============================================================ - job_name: 'classes-service' static_configs: - - targets: ['host.docker.internal:3001'] \ No newline at end of file + - targets: ['host.docker.internal:3001'] + labels: + service: classes + + - job_name: 'iam-service' + static_configs: + - targets: ['host.docker.internal:3002'] + labels: + service: iam + + - job_name: 'teacher-bff' + static_configs: + - targets: ['host.docker.internal:3003'] + labels: + service: teacher-bff + + - job_name: 'core-edu-service' + static_configs: + - targets: ['host.docker.internal:3004'] + labels: + service: core-edu + + - job_name: 'content-service' + static_configs: + - targets: ['host.docker.internal:3005'] + labels: + service: content + + - job_name: 'data-ana-service' + static_configs: + - targets: ['host.docker.internal:3006'] + labels: + service: data-ana + + - job_name: 'msg-service' + static_configs: + - targets: ['host.docker.internal:3007'] + labels: + service: msg + + - job_name: 'ai-service' + static_configs: + - targets: ['host.docker.internal:3008'] + labels: + service: ai + + # ============================================================ + # 基础设施(docker-compose.minimal/minimal.override) + # ============================================================ + - job_name: 'mysql' + static_configs: + - targets: ['host.docker.internal:9104'] + labels: + service: mysql + + - job_name: 'redis' + static_configs: + - targets: ['host.docker.internal:9121'] + labels: + service: redis + + # ============================================================ + # 监控栈自身 + # ============================================================ + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + labels: + service: node-exporter diff --git a/infra/promtail/config.yml b/infra/promtail/config.yml new file mode 100644 index 0000000..e94bbe7 --- /dev/null +++ b/infra/promtail/config.yml @@ -0,0 +1,28 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + # 采集 Docker 容器日志 + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + # 只采集带 edu- 前缀的容器日志 + - source_labels: ['__meta_docker_container_name'] + regex: '/(edu-.*).*' + action: keep + # 提取容器名作为 label + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: container_name + # 提取镜像名 + - source_labels: ['__meta_docker_container_log_stream'] + target_label: stream diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb609cd..72e709a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: autoprefixer: specifier: ^10.4.0 version: 10.5.2(postcss@8.5.16) + eslint: + specifier: ^9.0.0 + version: 9.39.4(jiti@1.21.7) postcss: specifier: ^8.4.0 version: 8.5.16 @@ -5906,6 +5909,11 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -8306,6 +8314,47 @@ snapshots: eslint-visitor-keys@5.0.1: {} + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) diff --git a/services/classes/src/main.ts b/services/classes/src/main.ts index bc3d972..8ab323a 100644 --- a/services/classes/src/main.ts +++ b/services/classes/src/main.ts @@ -1,31 +1,39 @@ -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'; +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"; +import { metricsRegistry } from "./shared/observability/metrics.js"; async function bootstrap(): Promise { initTracer(); const app = await NestFactory.create(AppModule, { - logger: ['log', 'error', 'warn'], + logger: ["log", "error", "warn"], }); app.useGlobalFilters(new GlobalErrorFilter()); app.enableShutdownHooks(); - await app.listen(env.PORT); - logger.info({ port: env.PORT }, 'Classes service started'); + // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 + // 返回 register.metrics()(Promise,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。 + app.getHttpAdapter().get("/metrics", async (req, res) => { + res.set("Content-Type", metricsRegistry.contentType); + res.end(await metricsRegistry.metrics()); + }); - process.on('SIGTERM', async () => { + await app.listen(env.PORT); + logger.info({ port: env.PORT }, "Classes service started"); + + process.on("SIGTERM", async () => { await app.close(); await shutdownTracer(); }); } bootstrap().catch((err: unknown) => { - logger.error({ err }, 'Failed to start classes service'); + logger.error({ err }, "Failed to start classes service"); process.exit(1); }); diff --git a/services/content/src/main.ts b/services/content/src/main.ts index bc95381..e410450 100644 --- a/services/content/src/main.ts +++ b/services/content/src/main.ts @@ -7,6 +7,7 @@ import { env } from "./config/env.js"; import { closeDb } from "./config/database.js"; import { closeNeo4j } from "./config/neo4j.js"; import { logger } from "./shared/observability/logger.js"; +import { metricsRegistry } from "./shared/observability/metrics.js"; async function bootstrap(): Promise { initTracer(); @@ -18,6 +19,13 @@ async function bootstrap(): Promise { app.useGlobalFilters(new GlobalErrorFilter()); app.enableShutdownHooks(); + // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 + // 返回 register.metrics()(Promise,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。 + app.getHttpAdapter().get("/metrics", async (req, res) => { + res.set("Content-Type", metricsRegistry.contentType); + res.end(await metricsRegistry.metrics()); + }); + await app.listen(env.PORT); logger.info({ port: env.PORT }, "Content service started"); diff --git a/services/core-edu/src/main.ts b/services/core-edu/src/main.ts index 4589f71..48d2c1e 100644 --- a/services/core-edu/src/main.ts +++ b/services/core-edu/src/main.ts @@ -6,6 +6,7 @@ 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"; +import { registry } from "./shared/observability/metrics.js"; async function bootstrap(): Promise { initTracer(); @@ -14,6 +15,13 @@ async function bootstrap(): Promise { app.useGlobalFilters(new GlobalErrorFilter()); app.enableShutdownHooks(); + // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 + // 返回 register.metrics()(Promise,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。 + app.getHttpAdapter().get("/metrics", async (req, res) => { + res.set("Content-Type", registry.contentType); + res.end(await registry.metrics()); + }); + // Connect Kafka producer/consumer before starting the outbox publisher. // Non-blocking: if Kafka is unavailable, service still starts; outbox // publisher will retry sends and messages stay pending until Kafka recovers. diff --git a/services/iam/src/main.ts b/services/iam/src/main.ts index 922acf6..b046c8c 100644 --- a/services/iam/src/main.ts +++ b/services/iam/src/main.ts @@ -1,31 +1,39 @@ -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'; +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"; +import { metricsRegistry } from "./shared/observability/metrics.js"; async function bootstrap(): Promise { initTracer(); const app = await NestFactory.create(AppModule, { - logger: ['log', 'error', 'warn'], + logger: ["log", "error", "warn"], }); app.useGlobalFilters(new GlobalErrorFilter()); app.enableShutdownHooks(); - await app.listen(env.PORT); - logger.info({ port: env.PORT }, 'IAM service started'); + // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 + // 返回 register.metrics()(Promise,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。 + app.getHttpAdapter().get("/metrics", async (req, res) => { + res.set("Content-Type", metricsRegistry.contentType); + res.end(await metricsRegistry.metrics()); + }); - process.on('SIGTERM', async () => { + await app.listen(env.PORT); + logger.info({ port: env.PORT }, "IAM service started"); + + process.on("SIGTERM", async () => { await app.close(); await shutdownTracer(); }); } bootstrap().catch((err: unknown) => { - logger.error({ err }, 'Failed to start IAM service'); + logger.error({ err }, "Failed to start IAM service"); process.exit(1); }); diff --git a/services/msg/src/main.ts b/services/msg/src/main.ts index b371fdd..2140872 100644 --- a/services/msg/src/main.ts +++ b/services/msg/src/main.ts @@ -7,6 +7,7 @@ import { env } from "./config/env.js"; import { logger } from "./shared/observability/logger.js"; import { checkEsConnection, closeEs } from "./config/elasticsearch.js"; import { closeDb } from "./config/database.js"; +import { metricsRegistry } from "./shared/observability/metrics.js"; async function bootstrap(): Promise { initTracer(); @@ -22,6 +23,13 @@ async function bootstrap(): Promise { app.useGlobalFilters(new GlobalErrorFilter()); app.enableShutdownHooks(); + // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 + // 返回 register.metrics()(Promise,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。 + app.getHttpAdapter().get("/metrics", async (req, res) => { + res.set("Content-Type", metricsRegistry.contentType); + res.end(await metricsRegistry.metrics()); + }); + await app.listen(env.PORT); logger.info({ port: env.PORT }, "Msg service started"); diff --git a/services/teacher-bff/src/app.module.ts b/services/teacher-bff/src/app.module.ts index 59d0dab..8c342e8 100644 --- a/services/teacher-bff/src/app.module.ts +++ b/services/teacher-bff/src/app.module.ts @@ -1,7 +1,9 @@ -import { Module } from '@nestjs/common'; -import { TeacherModule } from './teacher/teacher.module.js'; +import { Module } from "@nestjs/common"; +import { TeacherModule } from "./teacher/teacher.module.js"; +import { HealthController } from "./health.controller.js"; @Module({ imports: [TeacherModule], + controllers: [HealthController], }) export class AppModule {} diff --git a/services/teacher-bff/src/health.controller.ts b/services/teacher-bff/src/health.controller.ts new file mode 100644 index 0000000..e24c05f --- /dev/null +++ b/services/teacher-bff/src/health.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get } from "@nestjs/common"; + +/** + * 健康检查端点。 + * + * - GET /healthz:liveness,仅返回进程存活 + * - GET /readyz:readiness,无外部依赖可直接返回 ok + * + * 不需要鉴权,必须在路由白名单中放行。 + */ +@Controller() +export class HealthController { + @Get("healthz") + liveness(): { status: string; service: string; timestamp: string } { + return { + status: "ok", + service: "teacher-bff", + timestamp: new Date().toISOString(), + }; + } + + @Get("readyz") + readiness(): { status: string; service: string; timestamp: string } { + return { + status: "ok", + service: "teacher-bff", + timestamp: new Date().toISOString(), + }; + } +}