feat(infra): p6 hardening - observability and deploy compose

- 5 NestJS services add /metrics endpoint via app.getHttpAdapter()
- prometheus.yml scales to 8 services with rule_files and alertmanager
- monitoring compose replaces blackbox with Loki+Promtail
- Grafana datasource adds Loki
- docker-compose.deploy.yml scales to 11 services
- deploy.env.example completes Neo4j/ES/ClickHouse/LLM vars
- teacher-bff adds health.controller
- CI removes continue-on-error on lint step
- teacher-portal lint script changed to eslint src
This commit is contained in:
SpecialX
2026-07-09 10:21:06 +08:00
parent 3ca654619f
commit 566060fade
21 changed files with 554 additions and 59 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -51,7 +51,6 @@ export default function ClassesPage() {
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {

View File

@@ -56,7 +56,6 @@ export default function ExamsPage() {
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [classId]);
useEffect(() => {

View File

@@ -52,7 +52,6 @@ export default function GradesPage() {
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [examId]);
useEffect(() => {

View File

@@ -54,7 +54,6 @@ export default function HomeworkPage() {
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [classId]);
useEffect(() => {

View File

@@ -87,7 +87,7 @@
### 1.6 可观测性OTel + Prometheus + Loki
| 场景 | 技术/规则 |
| --------------- | -------------------------------------------------------------------------------- |
| ----------------- | ------------------------------------------------------------------------------------------------------------------- |
| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) |
| 三支柱 | Logspino/winston/zap+ Metricsprom-client+ TracesOTel SDK |
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
@@ -95,6 +95,11 @@
| Prometheus 指标 | `http_request_duration_seconds`Histogram+ `http_requests_total`Counter |
| 采样策略 | P1 全量 traceP6 引入采样率降低开销 |
| 日志参数顺序 | `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.ymlalerting 关联 alertmanager |
| P6 Grafana 数据源 | provisioning/datasources 同时声明 Prometheus默认和 Lokiuid=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-exporterGrafana 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-errorESLint 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_URLelasticsearch.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.pyhttpx 异步调 OpenAI REST APImain.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg/ai → ai 服务。**学到**gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入FastAPI APIRouter prefix 与 Gateway 代理路径要协调ai 服务加 /ai 前缀Gateway 代理 /ai/*pathLLM 降级策略统一返回 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_MODEneo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000health/lifecycle 改用 Drizzleglobal-error.filter 移除 @types/express 依赖textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块chaptersCRUD + 按 textbook 查询、knowledge-pointsCRUD + Neo4j 前置依赖图非阻塞查询、questionsCRUD + 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 201Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**Drizzle schema TS 字段名与 DB 列名解耦order→order_numAPI 请求体用 TS 字段名Neo4j 不可用时必须 driver=null不设 NEO4J_URL否则每次请求尝试连接拖慢响应neo4j-driver safeCreateNode 用 try/catch 非阻塞MySQL 数据始终先落库。 |

View File

@@ -27,12 +27,29 @@ JWT_AUDIENCE=next-edu-cloud
API_GATEWAY_PORT=8080
TEACHER_PORTAL_PORT=3000
# ============ KafkaP3 阶段启用P1 留空============
# ============ KafkaP3+ 启用,留空则 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 ← 默认 latestCI 通过 export IMAGE_TAG 覆盖
# ============ Neo4jcontent 服务,留空则降级模式============
NEO4J_URL=
NEO4J_PASSWORD=
# ============ Elasticsearchmsg 服务,留空则降级模式)============
ES_URL=
# ============ ClickHousedata-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=

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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']
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

28
infra/promtail/config.yml Normal file
View File

@@ -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

49
pnpm-lock.yaml generated
View File

@@ -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))

View File

@@ -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<void> {
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<string>,含 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);
});

View File

@@ -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<void> {
initTracer();
@@ -18,6 +19,13 @@ async function bootstrap(): Promise<void> {
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 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");

View File

@@ -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<void> {
initTracer();
@@ -14,6 +15,13 @@ async function bootstrap(): Promise<void> {
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 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.

View File

@@ -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<void> {
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<string>,含 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);
});

View File

@@ -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<void> {
initTracer();
@@ -22,6 +23,13 @@ async function bootstrap(): Promise<void> {
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 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");

View File

@@ -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 {}

View File

@@ -0,0 +1,30 @@
import { Controller, Get } from "@nestjs/common";
/**
* 健康检查端点。
*
* - GET /healthzliveness仅返回进程存活
* - GET /readyzreadiness无外部依赖可直接返回 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(),
};
}
}