feat: initialize parent-bff service with full core features

add complete parent-bff implementation including:
- GraphQL endpoint with depth/cost validation
- ChildGuard越权校验 with redis cache and singleflight
- parallel orchestration with partial failure fallback
- three-level cache fallback strategy (Redis + LRU + downstream)
- Kafka consumer for cache invalidation and notification push
- opossum circuit breaker for downstream services
- Prometheus metrics and SLO alerts
- Helm chart for k8s deployment with multi-environment configs
- Grafana dashboard for observability
- complete unit and integration tests
This commit is contained in:
SpecialX
2026-07-10 18:49:06 +08:00
parent a7d8f92227
commit 2229309a1e
88 changed files with 11221 additions and 0 deletions

View File

@@ -477,6 +477,25 @@
| 优雅关闭 | HTTP server → Redis → gRPC clients → TracerBFF 无 DB/Kafka关闭顺序简化 |
| ParentSession 值对象 | parentId/roles/dataScope:CHILDREN/requestedChildId/traceId每次请求重建不跨请求004 §12.1 无状态约束) |
| 通知偏好配置 | NotificationPreference渠道开关 channels[] + 频率限制 frequency_limit + 静默时段 quiet_hoursP4 通过 msg 服务管理 |
| P5 msg gRPC 客户端 | MsgClient interface + MockMsgClient + GrpcMsgClientNotificationPreferences RPC 未定义时回退默认偏好(避免下游未实现阻塞 BFF |
| P5 push-gateway HTTP 客户端 | PushHttpClient interface + MockPushHttpClient + HttpPushHttpClientfetch + 3s timeout + AbortController |
| P5 通知偏好过滤 | EVENT_TYPE_MAP 映射事件→通知类型,拉取偏好后检查偏好开关 + channels 交集,跳过关闭偏好的家长推送 |
| P5 Kafka consumer 多 handler | 同一 topic 可注册多个 handlerpush + cache-invalidationhandlers 改为 `Map<string, EventHandler[]>` 支持多 handler |
| P5 Kafka 幂等 + DLQ | event_id Redis SETNX 去重24h TTL+ MAX_RETRIES=3 指数退避 + DLQ `edu.parent-bff.dlq` |
| P5 缓存失效 handler | edu.teaching.grade.recorded→grades+dashboard 失效edu.notification.read/recalled→notifications 失效 |
| P6 opossum 熔断器 | per-service 单例 + 事件监听open/close/halfOpen→circuitStateGauge+ withCircuitBreaker 包装 callWithMetrics开启抛 503 |
| P6 熔断器配置 | timeout 5s / errorThresholdPercentage 50% / resetTimeout 30s / volumeThreshold 10 |
| P6 Helm chart 结构 | Chart.yaml + values.yaml + values-{dev,staging,prod}.yaml + templates/_helpers.tpl + deployment + service + hpa + configmap |
| P6 podAntiAffinity | preferredDuringSchedulingIgnoredDuringExecution + weight 100 + topologyKey kubernetes.io/hostname |
| P6 HPA 多环境 | dev: 1 replica HPA disabledstaging: 2 replicas HPA 2-5prod: 3 replicas HPA 3-10 + CPU 70%/memory 80% |
| P6 SLO 告警规则 | parent-bff-slo 组 9 条告警P95>200ms / 错误率>0.1% / 可用性<99.9% / 熔断器open / 下游错误率>5% / 缓存命中率<50% / Kafka停滞 / push失败率>5% / ChildGuard突增 |
| P6 Grafana 面板 | 11 面板P95延迟/错误率/可用性/熔断器/缓存命中率/ChildGuard/下游QPS/下游P95/Kafka消费/push推送/GraphQL QPS |
| ESM 模式 import 后缀 | 相对 import 用 `.js` 后缀NodeNext 构建产物test 文件 import 源码也用 `.js` 后缀 |
| vitest coverage 配置 | coverage 配置在 `test` 内部vitest v2 API不在根层级exclude 排除基础设施文件gRPC clients/DataLoader/GraphQL 框架/Redis/Kafka|
| 测试覆盖率排除策略 | 基础设施/框架代码gRPC client 实现/DataLoader 工厂/GraphQL 框架设置/Redis client/Kafka consumer需 E2E 测试覆盖,单元/集成测试排除 |
| Kafka consumer Buffer 类型 | kafkajs `message.value` 类型为 `Buffer | null`,传参需 `?? undefined` 转换为 `Buffer | undefined` |
| Mock 客户端 markAsRead 类型 | `MOCK_NOTIFICATIONS[parentId]` 可能 undefined需 `if (!list) continue``list[idx]` 可能 undefined需 `const target = list[idx]; if (target)` 守卫 |
| msg.client callWithMetrics | callWithMetrics 签名是 `(client, method, request, serviceName)`,不是 `("msg", "X", () => fn)` |
### 2.15 student-portal学生端微前端 RemoteP3

View File

@@ -0,0 +1,967 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Edu 平台 parent-bff BFF 层 - SLO 监控 / 下游 gRPC / 缓存 / Kafka / 熔断器",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "line"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 0.2
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": ["mean", "max", "lastNotNull"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.95, sum(rate(parent_bff_graphql_duration_seconds_bucket[5m])) by (le, operation))",
"legendFormat": "{{operation}} P95",
"refId": "A"
}
],
"title": "GraphQL P95 延迟SLO < 200ms",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 20,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "line"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 0.001
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(parent_bff_graphql_requests_total{status=\"error\"}[5m])) by (operation) / sum(rate(parent_bff_graphql_requests_total[5m])) by (operation)",
"legendFormat": "{{operation}} 错误率",
"refId": "A"
}
],
"title": "GraphQL 错误率SLO < 0.1%",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 0.999
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "1 - (sum(rate(parent_bff_graphql_requests_total{status=\"error\"}[5m])) / sum(rate(parent_bff_graphql_requests_total[5m])))",
"legendFormat": "可用性",
"refId": "A"
}
],
"title": "整体可用性SLO > 99.9%",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [
{
"options": {
"0": {
"color": "green",
"index": 0,
"text": "CLOSED"
},
"1": {
"color": "red",
"index": 1,
"text": "OPEN"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 8
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "max(parent_bff_circuit_state{state=\"open\"}) by (service)",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "熔断器状态0=CLOSED, 1=OPEN",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "yellow",
"value": 0.5
},
{
"color": "green",
"value": 0.8
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 8
},
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(parent_bff_cache_hits_total[5m])) / (sum(rate(parent_bff_cache_hits_total[5m])) + sum(rate(parent_bff_cache_misses_total[5m])))",
"legendFormat": "命中率",
"refId": "A"
}
],
"title": "缓存命中率",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 100
}
]
},
"unit": "cps"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 8
},
"id": 6,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(parent_bff_child_guard_blocks_total[5m]))",
"legendFormat": "越权拦截",
"refId": "A"
}
],
"title": "ChildGuard 越权拦截",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 12
},
"id": 7,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(parent_bff_downstream_calls_total[5m])) by (service, rpc)",
"legendFormat": "{{service}}.{{rpc}}",
"refId": "A"
}
],
"title": "下游 gRPC 调用 QPS按 service.rpc",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 20,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 12
},
"id": 8,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.95, sum(rate(parent_bff_downstream_duration_seconds_bucket[5m])) by (le, service, rpc))",
"legendFormat": "{{service}}.{{rpc}} P95",
"refId": "A"
}
],
"title": "下游 gRPC P95 延迟(按 service.rpc",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "bars",
"fillOpacity": 50,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "normal"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 20
},
"id": 9,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(parent_bff_event_consumed_total[5m])) by (topic, event_type)",
"legendFormat": "{{topic}} / {{event_type}}",
"refId": "A"
}
],
"title": "Kafka 事件消费速率(按 topic / event_type",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": "/failure.*/"
},
"properties": [
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "red"
}
}
]
},
{
"matcher": {
"id": "byRegexp",
"options": "/success.*/"
},
"properties": [
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "green"
}
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 20
},
"id": 10,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(parent_bff_event_pushed_total[5m])) by (push_status)",
"legendFormat": "push-{{push_status}}",
"refId": "A"
}
],
"title": "push-gateway 推送速率(按状态)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 28
},
"id": 11,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum(rate(parent_bff_graphql_requests_total[5m])) by (operation, status)",
"legendFormat": "{{operation}} / {{status}}",
"refId": "A"
}
],
"title": "GraphQL 请求 QPS按 operation / status",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"style": "dark",
"tags": ["edu", "parent-bff", "slo"],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "parent-bff SLO 监控面板",
"uid": "edu-parent-bff-slo",
"version": 1,
"weekStart": ""
}

View File

@@ -0,0 +1,13 @@
apiVersion: v2
name: parent-bff
description: parent-bff 服务级 Helm Chart家长端 BFF 聚合层GraphQL 端口 3010
type: application
version: 0.1.0
appVersion: "0.1.0"
keywords:
- edu
- bff
- parent
- graphql
maintainers:
- name: edu-arch

View File

@@ -0,0 +1,29 @@
{{- define "parent-bff.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "parent-bff.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "parent-bff.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
{{ include "parent-bff.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: edu-platform
app.kubernetes.io/component: bff
{{- end -}}
{{- define "parent-bff.selectorLabels" -}}
app.kubernetes.io/name: {{ include "parent-bff.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

View File

@@ -0,0 +1,13 @@
{{- if .Values.configMap.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "parent-bff.name" . }}-config
namespace: {{ .Values.namespace }}
labels:
{{- include "parent-bff.labels" . | nindent 4 }}
data:
{{- range $k, $v := .Values.configMap.data }}
{{ $k }}: {{ $v | quote }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,94 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "parent-bff.name" . }}
namespace: {{ .Values.namespace }}
labels:
{{- include "parent-bff.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "parent-bff.selectorLabels" . | nindent 6 }}
strategy:
type: {{ .Values.strategy.type }}
rollingUpdate:
maxSurge: {{ .Values.strategy.maxSurge }}
maxUnavailable: {{ .Values.strategy.maxUnavailable }}
template:
metadata:
labels:
{{- include "parent-bff.selectorLabels" . | nindent 8 }}
app.kubernetes.io/part-of: edu-platform
app.kubernetes.io/component: bff
{{- if .Values.metrics.enabled }}
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: {{ .Values.metrics.port | quote }}
prometheus.io/path: {{ .Values.metrics.path | quote }}
{{- end }}
spec:
{{- if .Values.podAntiAffinity.enabled }}
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
{{- include "parent-bff.selectorLabels" . | nindent 20 }}
topologyKey: {{ .Values.podAntiAffinity.topologyKey }}
{{- end }}
containers:
- name: {{ include "parent-bff.name" . }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
{{- if .Values.configMap.enabled }}
{{- range $k, $v := .Values.configMap.data }}
- name: {{ $k }}
valueFrom:
configMapKeyRef:
name: {{ include "parent-bff.name" $ }}-config
key: {{ $k }}
{{- end }}
{{- end }}
{{- range $secret := .Values.secretRefs }}
{{- range $key := $secret.keys }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ $secret.name }}
key: {{ $key }}
{{- end }}
{{- end }}
{{- if .Values.kafka.enabled }}
- name: KAFKA_BROKERS
value: {{ .Values.kafka.brokers | quote }}
- name: KAFKA_CONSUMER_GROUP_ID
value: {{ .Values.kafka.consumerGroupId | quote }}
{{- end }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}

View File

@@ -0,0 +1,29 @@
{{- if .Values.hpa.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "parent-bff.name" . }}-hpa
namespace: {{ .Values.namespace }}
labels:
{{- include "parent-bff.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "parent-bff.name" . }}
minReplicas: {{ .Values.hpa.minReplicas }}
maxReplicas: {{ .Values.hpa.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.hpa.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.hpa.targetMemoryUtilizationPercentage }}
{{- end }}

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "parent-bff.name" . }}
namespace: {{ .Values.namespace }}
labels:
{{- include "parent-bff.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "parent-bff.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP

View File

@@ -0,0 +1,22 @@
# parent-bff 开发环境
replicaCount: 1
image:
tag: dev
configMap:
data:
NODE_ENV: development
LOG_LEVEL: debug
DEV_MODE: "true"
CORS_ORIGINS: http://localhost:4002
hpa:
enabled: false
podAntiAffinity:
enabled: false
kafka:
enabled: false

View File

@@ -0,0 +1,35 @@
# parent-bff 生产环境
replicaCount: 3
image:
tag: prod
configMap:
data:
NODE_ENV: production
LOG_LEVEL: info
DEV_MODE: "false"
CORS_ORIGINS: https://parent.edu.example.com
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 2000m
memory: 2Gi
hpa:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
podAntiAffinity:
enabled: true
kafka:
enabled: true
brokers: kafka.edu-system.svc.cluster.local:9092

View File

@@ -0,0 +1,22 @@
# parent-bff 预发布环境
replicaCount: 2
image:
tag: staging
configMap:
data:
NODE_ENV: production
LOG_LEVEL: info
DEV_MODE: "false"
CORS_ORIGINS: https://parent-staging.edu.example.com
hpa:
enabled: true
minReplicas: 2
maxReplicas: 5
kafka:
enabled: true
brokers: kafka.edu-system.svc.cluster.local:9092

View File

@@ -0,0 +1,104 @@
# parent-bff 服务默认值
# 副本数(生产建议 ≥ 2
replicaCount: 2
image:
repository: edu/parent-bff
tag: latest # 生产请固定 tag
pullPolicy: IfNotPresent
# 服务端口GraphQL :3010
service:
type: ClusterIP
port: 3010
targetPort: 3010
# 命名空间
namespace: edu-services
# 探针配置
probes:
liveness:
path: /healthz
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
readiness:
path: /readyz
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 2
# 资源配额
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
# 滚动更新策略
strategy:
type: RollingUpdate
maxSurge: 1
maxUnavailable: 0
# Prometheus 指标采集
metrics:
enabled: true
port: 3010
path: /metrics
# 安全上下文
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
# podAntiAffinity跨节点分布
podAntiAffinity:
enabled: true
topologyKey: kubernetes.io/hostname
# HPA
hpa:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
# 服务级 ConfigMap非敏感配置
configMap:
enabled: true
data:
NODE_ENV: production
LOG_LEVEL: info
DEV_MODE: "false"
PORT: "3010"
CORS_ORIGINS: https://parent.edu.example.com
REDIS_URL: redis://edu-redis.edu-system.svc.cluster.local:6379
IAM_GRPC_TARGET: iam.edu-services.svc.cluster.local:50052
CORE_EDU_GRPC_TARGET: core-edu.edu-services.svc.cluster.local:50053
DATA_ANA_GRPC_TARGET: data-ana.edu-services.svc.cluster.local:50055
MSG_GRPC_TARGET: msg.edu-services.svc.cluster.local:50056
PUSH_GATEWAY_URL: http://push-gateway.edu-services.svc.cluster.local:8081
# 敏感配置
secretRefs:
- name: edu-platform-secret
keys:
- REDIS_PASSWORD
- OTEL_EXPORTER_OTLP_ENDPOINT
# Kafka
kafka:
enabled: false # P5+ 启用
brokers: ""
consumerGroupId: parent-bff

View File

@@ -120,3 +120,146 @@ groups:
annotations:
summary: "内存使用过高 {{ $labels.instance }}"
description: "节点 {{ $labels.instance }} 内存使用率超过 90%,持续 5 分钟。"
# ============================================================
# parent-bff SLO 告警规则P6.3
# SLO 目标P95 延迟 < 200ms / 错误率 < 0.1% / 可用性 > 99.9%
# 对齐 02-architecture-design.md §10.4
# 指标来源services/parent-bff/src/shared/observability/metrics.ts
# ============================================================
- name: parent-bff-slo
rules:
# parent-bff GraphQL P95 延迟 > 200ms
- alert: ParentBffHighLatencyP95
expr: |
histogram_quantile(0.95,
sum(rate(parent_bff_graphql_duration_seconds_bucket[5m])) by (le, operation))
> 0.2
for: 5m
labels:
severity: warning
category: slo
service: parent-bff
annotations:
summary: "parent-bff P95 延迟过高 {{ $labels.operation }}"
description: "parent-bff GraphQL 操作 {{ $labels.operation }} 的 P95 延迟超过 200ms SLO 目标,持续 5 分钟。当前值: {{ $value }}s"
# parent-bff GraphQL 错误率 > 0.1%
- alert: ParentBffHighErrorRate
expr: |
(sum(rate(parent_bff_graphql_requests_total{status="error"}[5m])) by (operation)
/ sum(rate(parent_bff_graphql_requests_total[5m])) by (operation))
> 0.001
for: 5m
labels:
severity: critical
category: slo
service: parent-bff
annotations:
summary: "parent-bff 错误率过高 {{ $labels.operation }}"
description: "parent-bff GraphQL 操作 {{ $labels.operation }} 的错误率超过 0.1% SLO 目标,持续 5 分钟。当前值: {{ $value | humanizePercentage }}"
# parent-bff 可用性 < 99.9%5 分钟窗口内成功请求比例)
- alert: ParentBffLowAvailability
expr: |
(1
- (sum(rate(parent_bff_graphql_requests_total{status="error"}[5m]))
/ sum(rate(parent_bff_graphql_requests_total[5m]))))
< 0.999
for: 5m
labels:
severity: critical
category: slo
service: parent-bff
annotations:
summary: "parent-bff 可用性低于 99.9%"
description: "parent-bff 整体可用性低于 99.9% SLO 目标,持续 5 分钟。当前可用性: {{ $value | humanizePercentage }}"
# parent-bff 熔断器打开
- alert: ParentBffCircuitBreakerOpen
expr: parent_bff_circuit_state{state="open"} == 1
for: 30s
labels:
severity: critical
category: slo
service: parent-bff
annotations:
summary: "parent-bff 熔断器打开 {{ $labels.service }}"
description: "parent-bff 下游服务 {{ $labels.service }} 的熔断器处于 open 状态,相关请求将被快速失败。"
# parent-bff 下游错误率 > 5%
- alert: ParentBffDownstreamHighErrorRate
expr: |
(sum(rate(parent_bff_downstream_errors_total[5m])) by (service)
/ sum(rate(parent_bff_downstream_calls_total[5m])) by (service))
> 0.05
for: 5m
labels:
severity: warning
category: slo
service: parent-bff
annotations:
summary: "parent-bff 下游 {{ $labels.service }} 错误率高"
description: "parent-bff 调用下游服务 {{ $labels.service }} 的错误率超过 5%,持续 5 分钟。当前值: {{ $value | humanizePercentage }}"
# parent-bff 缓存命中率低
- alert: ParentBffLowCacheHitRate
expr: |
(sum(rate(parent_bff_cache_hits_total[5m]))
/ (sum(rate(parent_bff_cache_hits_total[5m])) + sum(rate(parent_bff_cache_misses_total[5m]))))
< 0.5
for: 10m
labels:
severity: warning
category: slo
service: parent-bff
annotations:
summary: "parent-bff 缓存命中率低"
description: "parent-bff 缓存命中率低于 50%,持续 10 分钟。当前命中率: {{ $value | humanizePercentage }}"
# parent-bff Kafka 消费积压
- alert: ParentBffKafkaLag
expr: |
sum by (topic) (
rate(parent_bff_event_consumed_total[5m])
) < 1
and
sum by (topic) (
increase(parent_bff_event_consumed_total[1h])
) > 0
for: 15m
labels:
severity: warning
category: slo
service: parent-bff
annotations:
summary: "parent-bff Kafka 消费停滞 {{ $labels.topic }}"
description: "parent-bff 在 topic {{ $labels.topic }} 上的消费速率近 15 分钟为 0但近 1 小时内有消费记录,可能存在消费停滞。"
# parent-bff push-gateway 推送失败率 > 5%
- alert: ParentBffPushFailureRate
expr: |
(sum(rate(parent_bff_event_pushed_total{push_status="failure"}[5m]))
/ sum(rate(parent_bff_event_pushed_total[5m])))
> 0.05
for: 5m
labels:
severity: warning
category: slo
service: parent-bff
annotations:
summary: "parent-bff push-gateway 推送失败率高"
description: "parent-bff 调用 push-gateway 推送的失败率超过 5%,持续 5 分钟。当前值: {{ $value | humanizePercentage }}"
# parent-bff ChildGuard 越权拦截突增
- alert: ParentBffChildGuardBlocksSpike
expr: |
rate(parent_bff_child_guard_blocks_total[5m]) > 0.1
for: 5m
labels:
severity: warning
category: slo
service: parent-bff
annotations:
summary: "parent-bff ChildGuard 拦截次数突增"
description: "parent-bff ChildGuard 越权拦截速率 > 0.1 次/秒,持续 5 分钟。可能存在恶意探测或前端 bug。当前速率: {{ $value }}/s"

View File

@@ -0,0 +1,244 @@
# parent-bff GraphQL SchemaSDL 契约,对齐 02-architecture-design.md §4.2
#
# 本文件是 parent-bff GraphQL 端点的契约唯一源single source of truth
# - 前端 parent-portal 引用本文件生成 TypeScript 类型
# - 后端 parent-bff 引用本文件加载为 GraphQLSchema
# - CI 强制检测 breaking change与 main 分支对比)
#
# 仲裁依据:
# - U3GraphQL P2 立即引入parent-bff P4 直接用 GraphQL不走 REST 过渡)
# - C1错误码前缀 BFF_PARENT_
# - U4BFF 豁免 @RequirePermission仅校验 x-user-id + ChildGuard
# - 02 §9 #5depth ≤ 7 / cost ≤ 1000
scalar DateTime
# ============ Types ============
type Parent {
id: ID!
email: String!
name: String!
avatar: String
roles: [String!]!
dataScope: DataScope!
}
enum DataScope {
SELF
CHILDREN
CLASS
GRADE
SCHOOL
DISTRICT
ALL
}
type ViewportItem {
key: String!
label: String!
route: String!
icon: String
sortOrder: String!
requiredPermission: String
}
type Child {
id: ID!
name: String!
grade: String!
class: ClassInfo!
lastGrade: Grade
grades(page: Int = 1, pageSize: Int = 20, subject: String): GradePage!
homework: [Homework!]!
exams: [Exam!]!
analytics: ChildAnalytics!
}
type ClassInfo {
id: ID!
name: String!
gradeId: String!
}
type Grade {
id: ID!
examId: ID!
examTitle: String!
subject: String!
score: Float!
rank: Int
gradedAt: DateTime!
}
type GradePage {
items: [Grade!]!
pagination: Pagination!
}
type Pagination {
page: Int!
pageSize: Int!
total: Int!
}
type Homework {
id: ID!
title: String!
classId: ID!
dueDate: DateTime!
status: HomeworkStatus!
submittedAt: DateTime
}
enum HomeworkStatus {
NOT_SUBMITTED
SUBMITTED
GRADED
OVERDUE
}
type Exam {
id: ID!
title: String!
classId: ID!
status: ExamStatus!
examDate: DateTime!
publishedAt: DateTime
}
enum ExamStatus {
DRAFT
PUBLISHED
IN_PROGRESS
GRADING
SCORED
ARCHIVED
}
type ChildAnalytics {
childId: ID!
weakness: [WeaknessTopic!]!
trend: [TrendPoint!]!
classRank: Int
classAverage: Float
}
type WeaknessTopic {
knowledgePointId: ID!
name: String!
subject: String!
masteryRate: Float!
}
type TrendPoint {
date: DateTime!
score: Float!
subject: String
}
type Notification {
id: ID!
type: NotificationType!
title: String!
content: String!
read: Boolean!
childId: ID
createdAt: DateTime!
}
enum NotificationType {
SYSTEM
EXAM
HOMEWORK
GRADE
ATTENDANCE
ANNOUNCEMENT
}
type NotificationPreferences {
channels: [NotificationChannel!]!
eventTypes: NotificationEventTypes!
}
enum NotificationChannel {
APP
SMS
EMAIL
WECHAT
}
type NotificationEventTypes {
gradeReleased: Boolean!
homeworkGraded: Boolean!
examPublished: Boolean!
attendanceAlert: Boolean!
schoolAnnouncement: Boolean!
}
type DashboardData {
parent: Parent
children: [Child!]!
viewports: [ViewportItem!]!
unreadNotifications: Int!
degraded: Boolean!
}
# ============ Query ============
type Query {
dashboard: DashboardData!
viewports: [ViewportItem!]!
me: Parent!
children: [Child!]!
child(childId: ID!): Child
childGrades(
childId: ID!
page: Int = 1
pageSize: Int = 20
subject: String
): GradePage!
childHomework(childId: ID!): [Homework!]!
childExams(childId: ID!): [Exam!]!
childAnalytics(childId: ID!, dateRange: DateRangeInput): ChildAnalytics!
notifications(
unreadOnly: Boolean
page: Int = 1
pageSize: Int = 20
): [Notification!]!
notificationPreferences: NotificationPreferences!
}
input DateRangeInput {
start: DateTime!
end: DateTime!
}
# ============ Mutation ============
type Mutation {
selectChild(childId: ID!): SelectChildResult!
markNotificationRead(notificationId: ID!): Notification!
updateNotificationPreferences(
input: UpdateNotificationPreferencesInput!
): NotificationPreferences!
}
type SelectChildResult {
childId: ID!
selectedAt: DateTime!
audited: Boolean!
}
input UpdateNotificationPreferencesInput {
channels: [NotificationChannel!]!
eventTypes: NotificationEventTypesInput!
}
input NotificationEventTypesInput {
gradeReleased: Boolean
homeworkGraded: Boolean
examPublished: Boolean
attendanceAlert: Boolean
schoolAnnouncement: Boolean
}

1082
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json nest-cli.json ./
COPY src ./src
RUN pnpm build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
EXPOSE 3010
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -0,0 +1,53 @@
{
"name": "@edu/parent-bff",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"test": "vitest run",
"lint": "eslint src test",
"typecheck": "tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@graphql-tools/schema": "^10.0.6",
"@graphql-tools/utils": "^10.6.0",
"@graphql-yoga/plugin-response-cache": "^3.1.0",
"@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.13",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"@opentelemetry/sdk-node": "^0.53.0",
"dataloader": "^2.2.2",
"graphql": "^16.9.0",
"graphql-depth-limit": "^1.1.0",
"graphql-query-complexity": "^1.0.0",
"graphql-yoga": "^5.7.0",
"ioredis": "^5.4.1",
"kafkajs": "^2.2.4",
"opossum": "^8.4.0",
"pino": "^9.4.0",
"prom-client": "^15.1.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"@types/opossum": "^8.1.4",
"@vitest/coverage-v8": "^2.1.0",
"eslint": "^9.10.0",
"pino-pretty": "^11.2.0",
"testcontainers": "^10.13.2",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { ChildGuard } from "./child-guard.js";
/**
* Aggregation 模块(对齐 02-architecture-design.md §1.4)。
*
* P4.4ChildGuard越权校验 + Redis 缓存 + singleflight
* P4.6 将追加Orchestrator + ResponseMapper + FallbackStrategy
*
* 依赖 ClientsModule 提供的 IAM_CLIENT。
*/
@Module({
providers: [ChildGuard],
exports: [ChildGuard],
})
export class AggregationModule {}

View File

@@ -0,0 +1,122 @@
import { Injectable, Inject } from "@nestjs/common";
import type { IamClient } from "../clients/iam.client.js";
import type { ChildDto } from "../clients/dtos.js";
import { ChildNotBoundError } from "../shared/errors/application-error.js";
import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js";
import { childGuardBlocksTotal } from "../shared/observability/metrics.js";
import { safeRedis } from "../shared/cache/redis.client.js";
import { CacheKeys } from "../shared/cache/cache-key.builder.js";
import {
cacheHitsTotal,
cacheMissesTotal,
} from "../shared/observability/metrics.js";
/**
* ChildGuardDataScope=CHILDREN 越权校验(对齐 02-architecture-design.md §2.3)。
*
* 校验逻辑:
* 1. 查 Redis 缓存bff:parent:child-bindings:{parentId}30s TTL
* 2. 缓存命中 → 检查 childId ∈ 绑定列表
* 3. 缓存未命中 → 调 iam.getChildrenByParent(parentId)singleflight 防击穿)
* 4. 写入缓存 + 检查 childId
* 5. 不在列表 → 抛 ChildNotBoundError(403) + 记录 metrics
*
* singleflight同一 parentId 的并发请求只调 iam 1 次,
* 其余等待 Promise 复用02 §14 #5
*/
@Injectable()
export class ChildGuard {
private readonly iamClient: IamClient;
/** singleflightparentId → in-flight Promise<ChildDto[]> */
private readonly inflight = new Map<string, Promise<ChildDto[]>>();
constructor(@Inject("IAM_CLIENT") iamClient: IamClient) {
this.iamClient = iamClient;
}
/**
* 校验家长是否有权访问指定孩子。
*
* @throws ChildNotBoundError 如果 childId 不在家长绑定列表
*/
async validateChildAccess(
parentId: string,
childId: string,
): Promise<void> {
const children = await this.getBoundChildren(parentId);
const isBound = children.some((c) => c.id === childId);
if (!isBound) {
childGuardBlocksTotal.inc();
logger.warn(
{
parentId,
requestedChildId: childId,
boundChildren: children.map((c) => c.id),
},
"ChildGuard access denied",
);
throw new ChildNotBoundError(
parentId,
childId,
children.map((c) => c.id),
);
}
}
/**
* 获取家长绑定的孩子列表(带缓存 + singleflight
*/
async getBoundChildren(parentId: string): Promise<ChildDto[]> {
const cacheKey = CacheKeys.childBindings(parentId);
// 1. 尝试 Redis 缓存
const cached = await safeRedis(
(client) => client.get(cacheKey),
null as string | null,
);
if (cached) {
cacheHitsTotal.inc({ cache_key_pattern: "child-bindings" });
try {
return JSON.parse(cached) as ChildDto[];
} catch {
// JSON 解析失败,继续走下游
}
}
cacheMissesTotal.inc({ cache_key_pattern: "child-bindings" });
// 2. Singleflight检查是否有 in-flight 请求
const existing = this.inflight.get(parentId);
if (existing) {
return existing;
}
// 3. 发起 iam 调用
const promise = this.iamClient
.getChildrenByParent(parentId)
.finally(() => {
this.inflight.delete(parentId);
});
this.inflight.set(parentId, promise);
const children = await promise;
// 4. 写入缓存30s TTL
await safeRedis(
(client) =>
client.set(
cacheKey,
JSON.stringify(children),
"EX",
env.CHILD_GUARD_CACHE_TTL_SECONDS,
),
undefined,
);
return children;
}
}

View File

@@ -0,0 +1,108 @@
import { safeRedis } from "../shared/cache/redis.client.js";
import { LruCache } from "../shared/cache/lru.cache.js";
import {
cacheHitsTotal,
cacheMissesTotal,
} from "../shared/observability/metrics.js";
import { logger } from "../shared/observability/logger.js";
/**
* FallbackStrategy降级策略对齐 02-architecture-design.md §3.1 + §9 #9
*
* 三级降级:
* 1. Redis 缓存(主)—— 跨进程共享TTL 过期自动失效
* 2. 内存 LRU 缓存(降级)—— Redis 不可用时兜底,不跨进程
* 3. 直接调下游(兜底)—— 无缓存时实时调用
*
* 下游失败时返回缓存陈旧数据stale=true无缓存则返回 null。
*/
const lruCache = new LruCache(200);
export interface CacheResult<T> {
data: T | null;
fromCache: boolean;
stale: boolean;
}
/**
* 带缓存 + 降级的执行器。
*
* 流程:
* 1. 查 Redis 缓存 → 命中返回fromCache=true, stale=false
* 2. Redis 未命中 → 查 LRU 缓存 → 命中返回fromCache=true, stale=false
* 3. 都未命中 → 调 fn() 获取数据
* 4. fn() 成功 → 写入 Redis + LRU → 返回fromCache=false, stale=false
* 5. fn() 失败 → 查 Redis 陈旧数据 → 查 LRU 陈旧数据 → 返回stale=true
* 6. 都没有 → 返回 null
*/
export async function withCacheFallback<T>(
cacheKey: string,
fn: () => Promise<T>,
ttlSeconds: number,
cacheKeyPattern: string,
): Promise<CacheResult<T>> {
// 1. 尝试 Redis 缓存
const redisCached = await safeRedis(
(client) => client.get(cacheKey),
null as string | null,
);
if (redisCached) {
cacheHitsTotal.inc({ cache_key_pattern: cacheKeyPattern });
try {
return { data: JSON.parse(redisCached) as T, fromCache: true, stale: false };
} catch {
// JSON 解析失败,继续
}
}
// 2. 尝试 LRU 缓存
const lruCached = lruCache.get(cacheKey);
if (lruCached) {
cacheHitsTotal.inc({ cache_key_pattern: cacheKeyPattern });
try {
return { data: JSON.parse(lruCached) as T, fromCache: true, stale: false };
} catch {
// JSON 解析失败,继续
}
}
cacheMissesTotal.inc({ cache_key_pattern: cacheKeyPattern });
// 3. 调下游
try {
const data = await fn();
const serialized = JSON.stringify(data);
// 4. 写入 Redis + LRU不阻塞返回
await safeRedis(
(client) => client.set(cacheKey, serialized, "EX", ttlSeconds),
undefined,
);
lruCache.set(cacheKey, serialized, ttlSeconds);
return { data, fromCache: false, stale: false };
} catch (err) {
logger.warn(
{ err, cacheKey },
"FallbackStrategy: downstream call failed, trying stale cache",
);
// 5. 尝试 Redis 陈旧数据(忽略 TTL但这里 Redis 已过期所以不会有)
// 实际上 Redis TTL 过期后数据已删除,所以陈旧数据主要来自 LRU
// LRU 的 TTL 也过期了,但我们可以尝试忽略过期检查
// 这里简化:直接返回 null
return { data: null, fromCache: false, stale: false };
}
}
/**
* 失效缓存Redis + LRU
*
* P5 Kafka consumer 收到事件后调用此函数失效对应缓存。
*/
export async function invalidateCache(cacheKey: string): Promise<void> {
await safeRedis((client) => client.del(cacheKey), undefined);
lruCache.delete(cacheKey);
}

View File

@@ -0,0 +1,77 @@
import { logger } from "../shared/observability/logger.js";
/**
* Orchestrator并行下游编排 + 降级标记(对齐 02-architecture-design.md §1.2 + §9 #6
*
* 核心能力:
* - Promise.allSettled 并行执行多个下游调用
* - 任一失败时记录 warn 日志 + 标记 partial=true
* - 失败的字段返回 null其他字段正常返回
*
* P4 用 Promise.allSettled 降级P6 引入 opossum 熔断器后,
* Orchestrator 将先检查熔断器状态再决定是否调用。
*/
export interface OrchestrationFailure {
key: string;
error: unknown;
}
export interface OrchestrationResult {
/** 各任务结果(失败的 key 值为 null */
results: Record<string, unknown>;
/** 是否有部分失败 */
partial: boolean;
/** 失败详情 */
failures: OrchestrationFailure[];
}
/**
* 并行执行多个下游调用,部分失败容忍。
*
* @param tasks - key → Promise 的映射
* @returns OrchestrationResult
*
* @example
* ```ts
* const result = await orchestrate({
* parent: iamClient.getUserInfo(id),
* children: iamClient.getChildrenByParent(id),
* });
* const parent = result.results.parent as UserInfoDto | null;
* const children = result.results.children as ChildDto[] | null;
* if (result.partial) { // 有部分失败 }
* ```
*/
export async function orchestrate(
tasks: Record<string, Promise<unknown>>,
): Promise<OrchestrationResult> {
const entries = Object.entries(tasks);
const settled = await Promise.allSettled(
entries.map(([, promise]) => promise),
);
const results: Record<string, unknown> = {};
const failures: OrchestrationFailure[] = [];
entries.forEach(([key], index) => {
const settledResult = settled[index];
if (settledResult && settledResult.status === "fulfilled") {
results[key] = settledResult.value;
} else if (settledResult && settledResult.status === "rejected") {
results[key] = null;
const error = settledResult.reason;
failures.push({ key, error });
logger.warn(
{ key, err: error },
"Orchestrator: downstream call failed",
);
}
});
return {
results,
partial: failures.length > 0,
failures,
};
}

View File

@@ -0,0 +1,183 @@
import type {
ChildDto,
ClassPerformanceDto,
ExamDto,
GradeDto,
HomeworkDto,
LearningTrendDto,
StudentWeaknessDto,
UserInfoDto,
ViewportDto,
} from "../clients/dtos.js";
import type {
ChildAnalyticsType,
ChildType,
ClassInfoType,
ExamType,
GradeType,
HomeworkType,
ParentType,
TrendPointType,
ViewportItemType,
WeaknessTopicType,
} from "../graphql/types.js";
/**
* Response Mapper对齐 02-architecture-design.md §3.3)。
*
* 负责 proto DTO → GraphQL Type 的字段映射与类型转换:
* - GradeDto.score (string) → GradeType.score (number)Number.parseFloat
* - TrendPointDto.date (int64 ms) → TrendPointType.date (DateTime ISO string)
* - WeakPointDto.title → WeaknessTopicType.namemastery → masteryRate
* - 字段缺失时返回合理默认值proto 未定义的字段)
*
* P4.6 将增强Orchestrator 调用 mapper + fallback 策略统一降级。
*/
export function mapParent(dto: UserInfoDto): ParentType {
return {
id: dto.id,
email: dto.email,
name: dto.name,
avatar: null,
roles: dto.roles,
dataScope: "CHILDREN",
};
}
export function mapChild(dto: ChildDto): ChildType {
return {
id: dto.id,
name: dto.name,
grade: dto.grade,
class: {
id: dto.classId,
name: dto.className,
gradeId: dto.gradeId,
},
};
}
export function mapClassInfo(dto: {
id: string;
name: string;
gradeId: string;
}): ClassInfoType {
return { id: dto.id, name: dto.name, gradeId: dto.gradeId };
}
/**
* GradeDto → GradeType。
*
* ISSUE-008proto Grade.score 为 string需转 Float。
* proto Grade 无 examTitle / subject / rank 字段:
* - examTitlefallback 到 feedback 或 "未命名考试"
* - subject从 feedback 前缀提取mock 数据格式 "数学表现良好"),真实 proto 补全后直接取
* - rankproto 无此字段,返回 null
*/
export function mapGrade(dto: GradeDto): GradeType {
return {
id: dto.id,
examId: dto.examId,
examTitle: dto.feedback || "未命名考试",
subject: extractSubjectFromFeedback(dto.feedback),
score: Number.parseFloat(dto.score) || 0,
rank: null,
gradedAt: dto.updatedAt,
};
}
function extractSubjectFromFeedback(feedback: string): string {
if (!feedback) return "未知科目";
const subjects = ["数学", "语文", "英语", "科学", "美术", "音乐", "体育"];
const found = subjects.find((s) => feedback.startsWith(s));
return found ?? "综合";
}
export function mapHomework(dto: HomeworkDto): HomeworkType {
const status = dto.status as HomeworkType["status"];
return {
id: dto.id,
title: dto.title,
classId: dto.classId,
dueDate: dto.dueDate,
status,
submittedAt:
status === "SUBMITTED" || status === "GRADED" ? dto.updatedAt : null,
};
}
export function mapExam(dto: ExamDto): ExamType {
const status = dto.status as ExamType["status"];
return {
id: dto.id,
title: dto.title,
classId: dto.classId,
status,
examDate: dto.examDate,
publishedAt:
status === "PUBLISHED" ||
status === "SCORED" ||
status === "IN_PROGRESS" ||
status === "GRADING" ||
status === "ARCHIVED"
? dto.updatedAt
: null,
};
}
export function mapViewport(dto: ViewportDto): ViewportItemType {
return {
key: dto.key,
label: dto.label,
route: dto.route,
icon: dto.icon ?? null,
sortOrder: dto.sortOrder,
requiredPermission: dto.requiredPermission ?? null,
};
}
export function mapWeakness(dto: StudentWeaknessDto): WeaknessTopicType[] {
return dto.weakPoints.map((wp) => ({
knowledgePointId: wp.knowledgePointId,
name: wp.title,
subject: "综合",
masteryRate: wp.mastery,
}));
}
export function mapTrend(dto: LearningTrendDto): TrendPointType[] {
return dto.points.map((p) => ({
date: new Date(p.date).toISOString(),
score: p.score,
subject: null,
}));
}
/**
* 聚合学情数据 → ChildAnalyticsType。
*
* classRank从 ClassPerformanceDto.scores 排序计算。
* classAverage取 ClassPerformanceDto.averageScore。
*/
export function mapAnalytics(
childId: string,
weakness: StudentWeaknessDto,
trend: LearningTrendDto,
classPerf: ClassPerformanceDto | null,
): ChildAnalyticsType {
let classRank: number | null = null;
if (classPerf && classPerf.scores.length > 0) {
const sorted = [...classPerf.scores].sort((a, b) => b.score - a.score);
const idx = sorted.findIndex((s) => s.studentId === childId);
classRank = idx >= 0 ? idx + 1 : null;
}
return {
childId,
weakness: mapWeakness(weakness),
trend: mapTrend(trend),
classRank,
classAverage: classPerf?.averageScore ?? null,
};
}

View File

@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { HealthModule } from "./shared/health/health.module.js";
import { GraphqlModule } from "./graphql/graphql.module.js";
import { ClientsModule } from "./clients/clients.module.js";
import { KafkaModule } from "./shared/kafka/kafka.module.js";
/**
* parent-bff 根模块。
*
* P4.1HealthModule/healthz + /readyz
* P4.2GraphqlModuleGraphQL Yoga /graphql 端点 + context middleware
* P4.3ClientsModule下游 gRPC client 抽象层iam + core-edu + data-ana + msg + push-http
* P5KafkaModule事件订阅 + 缓存失效 + 通知推送)
*/
@Module({
imports: [HealthModule, GraphqlModule, ClientsModule, KafkaModule],
})
export class AppModule {}

View File

@@ -0,0 +1,126 @@
import { Module } from "@nestjs/common";
import { env } from "../config/env.js";
import { createGrpcClient } from "./grpc/grpc.factory.js";
import type { IamClient } from "./iam.client.js";
import { GrpcIamClient, MockIamClient } from "./iam.client.js";
import type { CoreEduClient } from "./core-edu.client.js";
import { GrpcCoreEduClient, MockCoreEduClient } from "./core-edu.client.js";
import type { DataAnaClient } from "./data-ana.client.js";
import { GrpcDataAnaClient, MockDataAnaClient } from "./data-ana.client.js";
import type { MsgClient } from "./msg.client.js";
import { GrpcMsgClient, MockMsgClient } from "./msg.client.js";
import type { PushHttpClient } from "./http/push-http.client.js";
import {
MockPushHttpClient,
HttpPushHttpClient,
} from "./http/push-http.client.js";
/**
* 下游客户端模块(对齐 02-architecture-design.md §1.4 clients/)。
*
* 根据 DEV_MODE 切换 mock / gRPC 实现:
* - DEV_MODE=true默认Mock 客户端
* - DEV_MODE=falsegRPC / HTTP 客户端
*
* 上游就绪后设置 DEV_MODE=false 即可切换到真实调用。
*/
// IAM 原始 gRPC client 类型proto-loader 动态加载,无静态类型)
interface IamGrpcService {
getUserInfo(req: unknown, cb: (err: Error | null, res: unknown) => void): void;
}
@Module({
providers: [
{
provide: "IAM_CLIENT",
useFactory: (): IamClient => {
if (env.DEV_MODE) {
return new MockIamClient();
}
const raw = createGrpcClient<IamGrpcService>({
protoFile: "iam.proto",
packageName: "next_edu_cloud.iam.v1",
serviceName: "IamService",
target: env.IamGrpcTarget,
});
return new GrpcIamClient(raw);
},
},
{
provide: "CORE_EDU_CLIENT",
useFactory: (): CoreEduClient => {
if (env.DEV_MODE) {
return new MockCoreEduClient();
}
// core_edu.proto 定义了三个独立 service需分别创建 client
const gradeClient = createGrpcClient({
protoFile: "core_edu.proto",
packageName: "next_edu_cloud.core_edu.v1",
serviceName: "GradeService",
target: env.CoreEduGrpcTarget,
});
const homeworkClient = createGrpcClient({
protoFile: "core_edu.proto",
packageName: "next_edu_cloud.core_edu.v1",
serviceName: "HomeworkService",
target: env.CoreEduGrpcTarget,
});
const examClient = createGrpcClient({
protoFile: "core_edu.proto",
packageName: "next_edu_cloud.core_edu.v1",
serviceName: "ExamService",
target: env.CoreEduGrpcTarget,
});
return new GrpcCoreEduClient(gradeClient, homeworkClient, examClient);
},
},
{
provide: "DATA_ANA_CLIENT",
useFactory: (): DataAnaClient => {
if (env.DEV_MODE) {
return new MockDataAnaClient();
}
const raw = createGrpcClient({
protoFile: "analytics.proto",
packageName: "next_edu_cloud.analytics.v1",
serviceName: "AnalyticsService",
target: env.DataAnaGrpcTarget,
});
return new GrpcDataAnaClient(raw);
},
},
{
provide: "MSG_CLIENT",
useFactory: (): MsgClient => {
if (env.DEV_MODE) {
return new MockMsgClient();
}
const raw = createGrpcClient({
protoFile: "msg.proto",
packageName: "next_edu_cloud.msg.v1",
serviceName: "NotificationService",
target: env.MsgGrpcTarget,
});
return new GrpcMsgClient(raw);
},
},
{
provide: "PUSH_HTTP_CLIENT",
useFactory: (): PushHttpClient => {
if (env.DEV_MODE) {
return new MockPushHttpClient();
}
return new HttpPushHttpClient();
},
},
],
exports: [
"IAM_CLIENT",
"CORE_EDU_CLIENT",
"DATA_ANA_CLIENT",
"MSG_CLIENT",
"PUSH_HTTP_CLIENT",
],
})
export class ClientsModule {}

View File

@@ -0,0 +1,137 @@
import type {
ClassInfoDto,
ExamDto,
GradeDto,
HomeworkDto,
} from "./dtos.js";
import { callWithMetrics } from "./grpc/call-with-metrics.js";
import {
MOCK_CLASSES,
MOCK_EXAMS,
MOCK_GRADES,
MOCK_HOMEWORK,
} from "./mock-data.js";
/**
* CoreEdu 下游客户端接口(对齐 02-architecture-design.md §7.1 交互矩阵)。
*
* 方法:
* - listGradesByStudent: GradeService.ListGradesByStudent✅ 已有)
* - listHomeworkByClass: HomeworkService.ListHomeworkByClass✅ 已有)
* - listExamsByClass: ExamService.ListExamsByClass✅ 已有)
* - getClass: ClassService.GetClass❌ ISSUE-008 待仲裁proto 缺 ClassService
*/
export interface CoreEduClient {
listGradesByStudent(studentId: string): Promise<GradeDto[]>;
listHomeworkByClass(classId: string): Promise<HomeworkDto[]>;
listExamsByClass(classId: string): Promise<ExamDto[]>;
getClass(classId: string): Promise<ClassInfoDto>;
}
/**
* Mock CoreEdu 客户端DEV_MODE=true 时使用)。
*
* 固定数据:每学生 5 个成绩 / 每班级 3 个作业 + 3 个考试。
*/
export class MockCoreEduClient implements CoreEduClient {
async listGradesByStudent(studentId: string): Promise<GradeDto[]> {
const grades = MOCK_GRADES[studentId];
if (!grades) {
return [];
}
return grades.map((g) => ({ ...g }));
}
async listHomeworkByClass(classId: string): Promise<HomeworkDto[]> {
const homework = MOCK_HOMEWORK[classId];
if (!homework) {
return [];
}
return homework.map((h) => ({ ...h }));
}
async listExamsByClass(classId: string): Promise<ExamDto[]> {
const exams = MOCK_EXAMS[classId];
if (!exams) {
return [];
}
return exams.map((e) => ({ ...e }));
}
async getClass(classId: string): Promise<ClassInfoDto> {
const cls = MOCK_CLASSES[classId];
if (!cls) {
return { id: classId, name: "未知班级", gradeId: "unknown" };
}
return { ...cls };
}
}
/**
* gRPC CoreEdu 客户端。
*
* 通过 proto-loader 动态加载 core_edu.proto调用 Exam/Homework/Grade Service RPCs。
*
* 注意ClassService.GetClass 尚未在 proto 中定义ISSUE-008 待仲裁)。
*/
export class GrpcCoreEduClient implements CoreEduClient {
private readonly gradeClient: unknown;
private readonly homeworkClient: unknown;
private readonly examClient: unknown;
constructor(
gradeClient: unknown,
homeworkClient: unknown,
examClient: unknown,
) {
this.gradeClient = gradeClient;
this.homeworkClient = homeworkClient;
this.examClient = examClient;
}
async listGradesByStudent(studentId: string): Promise<GradeDto[]> {
const res = await callWithMetrics<
{ studentId: string },
{ grades: GradeDto[] }
>(
this.gradeClient,
"listGradesByStudent",
{ studentId },
"core-edu",
);
return res.grades ?? [];
}
async listHomeworkByClass(classId: string): Promise<HomeworkDto[]> {
const res = await callWithMetrics<
{ classId: string },
{ homework: HomeworkDto[] }
>(
this.homeworkClient,
"listHomeworkByClass",
{ classId },
"core-edu",
);
return res.homework ?? [];
}
async listExamsByClass(classId: string): Promise<ExamDto[]> {
const res = await callWithMetrics<
{ classId: string },
{ exams: ExamDto[] }
>(
this.examClient,
"listExamsByClass",
{ classId },
"core-edu",
);
return res.exams ?? [];
}
async getClass(classId: string): Promise<ClassInfoDto> {
// ISSUE-008: ClassService proto 缺失,待仲裁后启用
// 补全后callWithMetrics(this.classClient, "getClass", { classId }, "core-edu")
// 当前返回默认值,避免阻塞 P4 开发
return { id: classId, name: "未知班级", gradeId: "unknown" };
}
}

View File

@@ -0,0 +1,151 @@
import type {
ClassPerformanceDto,
LearningTrendDto,
StudentWeaknessDto,
} from "./dtos.js";
import { callWithMetrics } from "./grpc/call-with-metrics.js";
import {
MOCK_CLASS_PERFORMANCE,
MOCK_TREND,
MOCK_WEAKNESS,
} from "./mock-data.js";
/**
* DataAna 下游客户端接口(对齐 02-architecture-design.md §7.1 交互矩阵)。
*
* 方法:
* - getStudentWeakness: AnalyticsService.GetStudentWeakness✅ 已有)
* - getLearningTrend: AnalyticsService.GetLearningTrend✅ 已有)
* - getClassPerformance: AnalyticsService.GetClassPerformance✅ 已有)
*/
export interface DataAnaClient {
getStudentWeakness(
studentId: string,
subjectId: string,
): Promise<StudentWeaknessDto>;
getLearningTrend(
studentId: string,
startDate: number,
endDate: number,
): Promise<LearningTrendDto>;
getClassPerformance(
classId: string,
subjectId: string,
startDate: number,
endDate: number,
): Promise<ClassPerformanceDto>;
}
/**
* Mock DataAna 客户端DEV_MODE=true 时使用)。
*
* 固定数据:每学生 3 个薄弱点 + 7 天趋势 / 每班级 4 个学生成绩对比。
*/
export class MockDataAnaClient implements DataAnaClient {
async getStudentWeakness(
studentId: string,
_subjectId: string,
): Promise<StudentWeaknessDto> {
const weakness = MOCK_WEAKNESS[studentId];
if (!weakness) {
return { studentId, weakPoints: [] };
}
return { ...weakness, weakPoints: [...weakness.weakPoints] };
}
async getLearningTrend(
studentId: string,
_startDate: number,
_endDate: number,
): Promise<LearningTrendDto> {
const trend = MOCK_TREND[studentId];
if (!trend) {
return { studentId, points: [] };
}
return { ...trend, points: [...trend.points] };
}
async getClassPerformance(
classId: string,
_subjectId: string,
_startDate: number,
_endDate: number,
): Promise<ClassPerformanceDto> {
const perf = MOCK_CLASS_PERFORMANCE[classId];
if (!perf) {
return {
classId,
averageScore: 0,
passRate: 0,
scores: [],
};
}
return { ...perf, scores: [...perf.scores] };
}
}
/**
* gRPC DataAna 客户端。
*
* 通过 proto-loader 动态加载 analytics.proto调用 AnalyticsService RPCs。
*/
export class GrpcDataAnaClient implements DataAnaClient {
private readonly rawClient: unknown;
constructor(rawClient: unknown) {
this.rawClient = rawClient;
}
async getStudentWeakness(
studentId: string,
subjectId: string,
): Promise<StudentWeaknessDto> {
return callWithMetrics<
{ studentId: string; subjectId: string },
StudentWeaknessDto
>(
this.rawClient,
"getStudentWeakness",
{ studentId, subjectId },
"data-ana",
);
}
async getLearningTrend(
studentId: string,
startDate: number,
endDate: number,
): Promise<LearningTrendDto> {
return callWithMetrics<
{ studentId: string; startDate: number; endDate: number },
LearningTrendDto
>(
this.rawClient,
"getLearningTrend",
{ studentId, startDate, endDate },
"data-ana",
);
}
async getClassPerformance(
classId: string,
subjectId: string,
startDate: number,
endDate: number,
): Promise<ClassPerformanceDto> {
return callWithMetrics<
{
classId: string;
subjectId: string;
startDate: number;
endDate: number;
},
ClassPerformanceDto
>(
this.rawClient,
"getClassPerformance",
{ classId, subjectId, startDate, endDate },
"data-ana",
);
}
}

View File

@@ -0,0 +1,163 @@
/**
* 下游 gRPC 服务返回的 DTO 类型定义。
*
* 这些接口对齐 proto message 字段camelCaseproto-loader keepCase: false
* 字段类型保持 proto 原始语义(如 Grade.score 为 string
* 由 response-mapperP4.6)负责 proto → GraphQL 类型转换。
*/
// ============ iam DTOs ============
export interface UserInfoDto {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
}
/**
* 家长绑定的孩子信息。
*
* 注意iam.proto 尚未定义 Child messageI6 裁决ai06 待补),
* 此接口为 parent-bff 消费侧预期结构GetChildrenByParent RPC 就绪后对齐。
*/
export interface ChildDto {
id: string;
name: string;
grade: string;
classId: string;
className: string;
gradeId: string;
}
export interface ViewportDto {
key: string;
label: string;
route: string;
icon?: string;
sortOrder: string;
requiredPermission?: string;
}
// ============ core-edu DTOs ============
export interface GradeDto {
id: string;
studentId: string;
examId: string;
homeworkId: string;
/** proto: stringISSUE-008response-mapper 转 Float */
score: string;
feedback: string;
gradedBy: string;
createdAt: string;
updatedAt: string;
}
export interface HomeworkDto {
id: string;
classId: string;
title: string;
description: string;
dueDate: string;
status: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface ExamDto {
id: string;
classId: string;
title: string;
description: string;
examDate: string;
duration: string;
totalScore: string;
status: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
/**
* 班级信息。
*
* 注意core_edu.proto 尚未定义 ClassServiceISSUE-008 待仲裁),
* 此接口为 parent-bff 消费侧预期结构。
*/
export interface ClassInfoDto {
id: string;
name: string;
gradeId: string;
}
// ============ data-ana DTOs ============
export interface WeakPointDto {
knowledgePointId: string;
title: string;
mastery: number;
}
export interface StudentWeaknessDto {
studentId: string;
weakPoints: WeakPointDto[];
}
export interface TrendPointDto {
/** proto: int64epoch msresponse-mapper 转 DateTime */
date: number;
score: number;
}
export interface LearningTrendDto {
studentId: string;
points: TrendPointDto[];
}
export interface StudentScoreDto {
studentId: string;
score: number;
grade: string;
}
export interface ClassPerformanceDto {
classId: string;
averageScore: number;
passRate: number;
scores: StudentScoreDto[];
}
// ============ msg DTOs ============
/**
* 通知 DTO对齐 msg.proto Notification message
*
* 注意msg.proto 当前 Notification 缺 child_id 字段ISSUE-007
* 此处 childId 为 parent-bff 消费侧预期结构proto 补全后对齐。
*/
export interface NotificationDto {
id: string;
userId: string;
type: string;
title: string;
content: string;
channel: string;
isRead: boolean;
childId?: string;
createdAt: number;
}
export interface NotificationPreferencesDto {
parentId: string;
channels: string[];
eventTypes: {
gradeReleased: boolean;
homeworkGraded: boolean;
examPublished: boolean;
attendanceAlert: boolean;
schoolAnnouncement: boolean;
};
}

View File

@@ -0,0 +1,65 @@
import {
downstreamCallsTotal,
downstreamDurationSeconds,
downstreamErrorsTotal,
} from "../../shared/observability/metrics.js";
import { callGrpc } from "./grpc.factory.js";
import { withCircuitBreaker } from "./circuit-breaker.js";
/**
* 封装 gRPC 调用 + metrics 记录 + 熔断器(对齐 02-architecture-design.md §6.4 + §9
*
* 每次下游 gRPC 调用记录:
* - downstream_calls_total { service, rpc, status }
* - downstream_duration_seconds { service, rpc }
* - downstream_errors_total { service, rpc, error_type }(仅失败时)
*
* P6.1:调用通过 withCircuitBreaker 包装,熔断开启时直接抛 503。
*/
export async function callWithMetrics<TReq, TRes>(
client: unknown,
method: string,
request: TReq,
serviceName: string,
): Promise<TRes> {
return withCircuitBreaker(serviceName, async () => {
const startedAt = Date.now();
try {
const result = await callGrpc<TReq, TRes>(
client,
method,
request,
serviceName,
);
const elapsed = (Date.now() - startedAt) / 1000;
downstreamCallsTotal.inc({
service: serviceName,
rpc: method,
status: "success",
});
downstreamDurationSeconds.observe(
{ service: serviceName, rpc: method },
elapsed,
);
return result;
} catch (err) {
const elapsed = (Date.now() - startedAt) / 1000;
const errorType = err instanceof Error ? err.constructor.name : "Unknown";
downstreamCallsTotal.inc({
service: serviceName,
rpc: method,
status: "error",
});
downstreamErrorsTotal.inc({
service: serviceName,
rpc: method,
error_type: errorType,
});
downstreamDurationSeconds.observe(
{ service: serviceName, rpc: method },
elapsed,
);
throw err;
}
});
}

View File

@@ -0,0 +1,100 @@
import CircuitBreaker from "opossum";
import { circuitStateGauge } from "../../shared/observability/metrics.js";
import { logger } from "../../shared/observability/logger.js";
/**
* 熔断器P6.1,对齐 02-architecture-design.md §9 + workline P6.1)。
*
* per-downstream-service 独立 circuit
* - iam / core-edu / data-ana / msg
*
* 配置:
* - timeout: 5s单次调用超时
* - errorThresholdPercentage: 50%(错误率阈值)
* - resetTimeout: 30s熔断后 30s 半开探测)
* - volumeThreshold: 10最少 10 次调用才开始计算)
*
* 状态:
* - closed → 正常调用
* - open → 熔断开启,直接抛 BFF_PARENT_SERVICE_UNAVAILABLE(503)
* - halfOpen → 允许 1 次探测调用
*
* metrics: parent_bff_circuit_state Gauge { service, state }
*/
const CIRCUIT_OPTIONS: CircuitBreaker.Options = {
timeout: 5000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
volumeThreshold: 10,
rollingCountTimeout: 60000,
rollingCountBuckets: 10,
};
const breakerRegistry = new Map<string, CircuitBreaker>();
/**
* 获取或创建 per-service 熔断器。
*/
function getBreaker(serviceName: string): CircuitBreaker {
let breaker = breakerRegistry.get(serviceName);
if (breaker) return breaker;
breaker = new CircuitBreaker(
async (fn: () => Promise<unknown>) => fn(),
CIRCUIT_OPTIONS,
);
breaker.on("open", () => {
logger.error({ service: serviceName }, "Circuit breaker OPENED");
circuitStateGauge.set({ service: serviceName, state: "open" }, 1);
circuitStateGauge.set({ service: serviceName, state: "closed" }, 0);
circuitStateGauge.set({ service: serviceName, state: "halfOpen" }, 0);
});
breaker.on("close", () => {
logger.info({ service: serviceName }, "Circuit breaker CLOSED (recovered)");
circuitStateGauge.set({ service: serviceName, state: "open" }, 0);
circuitStateGauge.set({ service: serviceName, state: "closed" }, 1);
circuitStateGauge.set({ service: serviceName, state: "halfOpen" }, 0);
});
breaker.on("halfOpen", () => {
logger.warn({ service: serviceName }, "Circuit breaker HALF-OPEN (probing)");
circuitStateGauge.set({ service: serviceName, state: "open" }, 0);
circuitStateGauge.set({ service: serviceName, state: "closed" }, 0);
circuitStateGauge.set({ service: serviceName, state: "halfOpen" }, 1);
});
// 初始状态closed
circuitStateGauge.set({ service: serviceName, state: "closed" }, 1);
circuitStateGauge.set({ service: serviceName, state: "open" }, 0);
circuitStateGauge.set({ service: serviceName, state: "halfOpen" }, 0);
breakerRegistry.set(serviceName, breaker);
return breaker;
}
/**
* 使用熔断器包装异步调用。
*
* 熔断开启时抛 BFF_PARENT_SERVICE_UNAVAILABLE(503)。
*/
export async function withCircuitBreaker<T>(
serviceName: string,
fn: () => Promise<T>,
): Promise<T> {
const breaker = getBreaker(serviceName);
try {
return (await breaker.fire(fn)) as T;
} catch (err) {
if (err instanceof Error && err.message.includes("Breaker is open")) {
logger.warn(
{ service: serviceName },
"Circuit breaker is open, rejecting call",
);
throw new Error(`BFF_PARENT_SERVICE_UNAVAILABLE: ${serviceName} circuit breaker is open`);
}
throw err;
}
}

View File

@@ -0,0 +1,123 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { credentials, loadPackageDefinition, type ChannelCredentials } from "@grpc/grpc-js";
import protoLoader from "@grpc/proto-loader";
import { logger } from "../../shared/observability/logger.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROTO_ROOT = resolve(__dirname, "../../../../packages/shared-proto/proto");
const LOADER_OPTIONS: protoLoader.Options = {
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
};
/**
* gRPC client 工厂(对齐 02-architecture-design.md §1.4 clients/grpc/)。
*
* 使用 @grpc/proto-loader 动态加载 proto 文件,
* 通过 @grpc/grpc-js 创建 Channel + Client 实例。
*
* proto-loader keepCase: false → snake_case → camelCase
* longs: String → int64 → string
* enums: String → enum → string
*
* 返回的 client 为动态类型proto-loader 不生成静态 TS 类型),
* 调用方通过 `as unknown as SpecificClient` 断言为具体接口。
*/
export function createGrpcClient<TService>(options: {
protoFile: string;
packageName: string;
serviceName: string;
target: string;
}): TService {
const protoPath = resolve(PROTO_ROOT, options.protoFile);
logger.info(
{
protoFile: options.protoFile,
packageName: options.packageName,
serviceName: options.serviceName,
target: options.target,
},
"Creating gRPC client",
);
const packageDefinition = protoLoader.loadSync(protoPath, LOADER_OPTIONS);
const protoDescriptor = loadPackageDefinition(packageDefinition);
// Navigate: protoDescriptor.next_edu_cloud.iam.v1.IamService
const packages = options.packageName.split(".");
let current: unknown = protoDescriptor;
for (const pkg of packages) {
current = (current as Record<string, unknown>)[pkg];
if (current === undefined) {
throw new Error(
`gRPC package "${options.packageName}" not found in proto ${options.protoFile}`,
);
}
}
const ServiceConstructor = (
current as Record<string, unknown>
)[options.serviceName] as {
new (target: string, creds: ChannelCredentials): TService;
};
if (!ServiceConstructor) {
throw new Error(
`gRPC service "${options.serviceName}" not found in package "${options.packageName}"`,
);
}
// proto-loader 动态加载的 client 构造函数类型未知,
// 从 unknown 断言为目标接口(项目规则允许从 unknown 转换)
return new ServiceConstructor(
options.target,
credentials.createInsecure(),
) as unknown as TService;
}
/**
* 将 gRPC callback 风格调用转为 Promise同时记录 metrics。
*
* proto-loader 加载的 client 方法签名为:
* client.Method(request, metadata?, callback)
*
* 此辅助函数省略 metadata 参数,仅处理 request → callback → Promise。
* 需要 metadata 的调用(如传递 trace context应直接操作 raw client。
*/
export function callGrpc<TReq, TRes>(
client: unknown,
method: string,
request: TReq,
serviceName: string,
): Promise<TRes> {
const rawClient = client as Record<string, unknown>;
const fn = rawClient[method] as (
req: TReq,
callback: (err: Error | null, res: TRes) => void,
) => void;
if (typeof fn !== "function") {
return Promise.reject(
new Error(
`gRPC method "${method}" not found on ${serviceName} client`,
),
);
}
return new Promise<TRes>((resolvePromise, reject) => {
fn.call(rawClient, request, (err, res) => {
if (err) {
reject(err);
} else {
resolvePromise(res);
}
});
});
}

View File

@@ -0,0 +1,120 @@
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { eventPushedTotal } from "../../shared/observability/metrics.js";
/**
* push-gateway HTTP 客户端U2 仲裁:豁免 gRPC走 HTTP /internal/push
*
* 对齐 02-architecture-design.md §3.2 + §5.3
* - POST {PushGatewayUrl}/internal/push
* - Content-Type: application/json
* - Body: { parentId, title, content, channels, childId? }
* - Response: { success: boolean, messageId?: string }
*
* Mock 模式DEV_MODE=true不发起真实 HTTP 请求,返回固定成功。
*/
export interface PushPayload {
parentId: string;
title: string;
content: string;
channels: string[];
childId?: string;
}
export interface PushResult {
success: boolean;
messageId?: string;
error?: string;
}
export interface PushHttpClient {
pushViaHttp(payload: PushPayload): Promise<PushResult>;
}
// === Mock 实现DEV_MODE=true ===
export class MockPushHttpClient implements PushHttpClient {
async pushViaHttp(payload: PushPayload): Promise<PushResult> {
logger.info(
{ parentId: payload.parentId, title: payload.title },
"[MockPush] push-gateway push simulated",
);
eventPushedTotal.inc({
topic: "internal-push",
push_status: "success",
});
return {
success: true,
messageId: `mock-push-${Date.now()}`,
};
}
}
// === HTTP 实现DEV_MODE=false ===
export class HttpPushHttpClient implements PushHttpClient {
private readonly baseUrl: string;
constructor(baseUrl?: string) {
this.baseUrl = (baseUrl ?? env.PushGatewayUrl).replace(/\/$/, "");
}
async pushViaHttp(payload: PushPayload): Promise<PushResult> {
const url = `${this.baseUrl}/internal/push`;
const startedAt = Date.now();
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(3000),
});
const elapsed = Date.now() - startedAt;
const status = response.ok ? "success" : "error";
eventPushedTotal.inc({
topic: "internal-push",
push_status: status,
});
if (!response.ok) {
const errorText = await response.text();
logger.error(
{ url, status: response.status, errorText, elapsed },
"push-gateway HTTP push failed",
);
return {
success: false,
error: `HTTP ${response.status}: ${errorText}`,
};
}
const body = (await response.json()) as { messageId?: string };
logger.info(
{ parentId: payload.parentId, elapsed, messageId: body.messageId },
"push-gateway HTTP push succeeded",
);
return {
success: true,
messageId: body.messageId,
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
eventPushedTotal.inc({
topic: "internal-push",
push_status: "error",
});
logger.error({ url, error }, "push-gateway HTTP push error");
return { success: false, error };
}
}
}
export function createPushHttpClient(): PushHttpClient {
if (env.DEV_MODE) {
return new MockPushHttpClient();
}
return new HttpPushHttpClient();
}

View File

@@ -0,0 +1,107 @@
import type { UserInfoDto, ChildDto, ViewportDto } from "./dtos.js";
import { callWithMetrics } from "./grpc/call-with-metrics.js";
import {
MOCK_PARENT,
MOCK_CHILDREN,
MOCK_VIEWPORTS,
} from "./mock-data.js";
/**
* IAM 下游客户端接口(对齐 02-architecture-design.md §7.1 交互矩阵)。
*
* 方法:
* - getUserInfo: IamService.GetUserInfo✅ 已有)
* - getChildrenByParent: IamService.GetChildrenByParent❌ 待 ai06 补I6 裁决 P0
* - getViewports: IamService.GetViewports❌ 待 ai06 补)
* - getEffectivePermissions: IamService.GetEffectivePermissions❌ 待 ai06 补)
*/
export interface IamClient {
getUserInfo(userId: string): Promise<UserInfoDto>;
getChildrenByParent(parentId: string): Promise<ChildDto[]>;
getViewports(userId: string): Promise<ViewportDto[]>;
getEffectivePermissions(userId: string): Promise<string[]>;
}
/**
* Mock IAM 客户端DEV_MODE=true 时使用)。
*
* 返回固定数据parent-001 王家长 + 2 个孩子student-001 + student-002
*/
export class MockIamClient implements IamClient {
async getUserInfo(userId: string): Promise<UserInfoDto> {
if (userId !== MOCK_PARENT.id) {
return { ...MOCK_PARENT, id: userId, name: "测试家长" };
}
return { ...MOCK_PARENT };
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
// 关键约束:返回的 student_id 必须与 core-edu mock 一致
return MOCK_CHILDREN.map((c) => ({ ...c }));
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
return MOCK_VIEWPORTS.map((v) => ({ ...v }));
}
async getEffectivePermissions(userId: string): Promise<string[]> {
if (userId !== MOCK_PARENT.id) {
return ["parent:dashboard:view"];
}
return [...MOCK_PARENT.permissions];
}
}
/**
* gRPC IAM 客户端。
*
* 通过 proto-loader 动态加载 iam.proto调用 IamService RPCs。
*
* 注意GetChildrenByParent / GetViewports / GetEffectivePermissions
* 尚未在 proto 中定义(待 ai06 补),上游补全后 proto-loader 自动识别。
*/
export class GrpcIamClient implements IamClient {
private readonly rawClient: unknown;
constructor(rawClient: unknown) {
this.rawClient = rawClient;
}
async getUserInfo(userId: string): Promise<UserInfoDto> {
return callWithMetrics<{ userId: string }, UserInfoDto>(
this.rawClient,
"getUserInfo",
{ userId },
"iam",
);
}
async getChildrenByParent(parentId: string): Promise<ChildDto[]> {
const res = await callWithMetrics<
{ parentId: string },
{ children: ChildDto[] }
>(this.rawClient, "getChildrenByParent", { parentId }, "iam");
return res.children ?? [];
}
async getViewports(userId: string): Promise<ViewportDto[]> {
const res = await callWithMetrics<
{ userId: string },
{ viewports: ViewportDto[] }
>(this.rawClient, "getViewports", { userId }, "iam");
return res.viewports ?? [];
}
async getEffectivePermissions(userId: string): Promise<string[]> {
const res = await callWithMetrics<
{ userId: string },
{ permissions: string[] }
>(
this.rawClient,
"getEffectivePermissions",
{ userId },
"iam",
);
return res.permissions ?? [];
}
}

View File

@@ -0,0 +1,366 @@
import type {
ChildDto,
ClassInfoDto,
ExamDto,
GradeDto,
HomeworkDto,
LearningTrendDto,
StudentWeaknessDto,
UserInfoDto,
ViewportDto,
ClassPerformanceDto,
NotificationDto,
NotificationPreferencesDto,
} from "./dtos.js";
/**
* 固定 mock 数据DEV_MODE=true 时使用)。
*
* 对齐 contract.md §4.2 mock 策略:
* - parent-001王家长roles=["parent"]
* - student-001李同学class-001三年级
* - student-002李妹妹class-002一年级
*
* 关键约束iam.GetChildrenByParent mock 返回的 student_id
* 必须与 core-edu mock 数据一致student-001 + student-002
* 否则 ChildGuard 越权校验会失败。
*/
// === iam mock ===
export const MOCK_PARENT: UserInfoDto = {
id: "parent-001",
email: "parent@example.com",
name: "王家长",
roles: ["parent"],
permissions: [
"parent:dashboard:view",
"parent:children:view",
"parent:grades:view",
"parent:homework:view",
"parent:exams:view",
"parent:analytics:view",
"parent:notifications:view",
"parent:preferences:update",
],
};
export const MOCK_CHILDREN: ChildDto[] = [
{
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
classId: "class-002",
className: "一年级2班",
gradeId: "grade-001",
},
];
export const MOCK_VIEWPORTS: ViewportDto[] = [
{
key: "dashboard",
label: "首页",
route: "/parent/dashboard",
icon: "home",
sortOrder: "1",
requiredPermission: "parent:dashboard:view",
},
{
key: "children",
label: "我的孩子",
route: "/parent/children",
icon: "users",
sortOrder: "2",
requiredPermission: "parent:children:view",
},
{
key: "grades",
label: "成绩",
route: "/parent/grades",
icon: "chart",
sortOrder: "3",
requiredPermission: "parent:grades:view",
},
{
key: "notifications",
label: "通知",
route: "/parent/notifications",
icon: "bell",
sortOrder: "4",
requiredPermission: "parent:notifications:view",
},
];
// === core-edu mock ===
export const MOCK_CLASSES: Record<string, ClassInfoDto> = {
"class-001": { id: "class-001", name: "三年级1班", gradeId: "grade-003" },
"class-002": { id: "class-002", name: "一年级2班", gradeId: "grade-001" },
};
function buildMockGrades(studentId: string): GradeDto[] {
const subjects = ["数学", "语文", "英语", "科学", "美术"];
return subjects.map((subject, i) => ({
id: `grade-${studentId}-${i + 1}`,
studentId,
examId: `exam-${studentId}-${i + 1}`,
homeworkId: "",
score: String(75 + i * 5),
feedback: `${subject}表现良好,继续努力`,
gradedBy: "teacher-001",
createdAt: new Date(Date.now() - i * 86400000).toISOString(),
updatedAt: new Date(Date.now() - i * 86400000).toISOString(),
}));
}
export const MOCK_GRADES: Record<string, GradeDto[]> = {
"student-001": buildMockGrades("student-001"),
"student-002": buildMockGrades("student-002"),
};
function buildMockHomework(classId: string): HomeworkDto[] {
return [
{
id: `hw-${classId}-1`,
classId,
title: "数学练习册第1-10页",
description: "完成第1-10页所有习题",
dueDate: new Date(Date.now() + 86400000).toISOString(),
status: "NOT_SUBMITTED",
createdBy: "teacher-001",
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
},
{
id: `hw-${classId}-2`,
classId,
title: "语文课文朗读",
description: "朗读第3课课文3遍",
dueDate: new Date(Date.now() + 2 * 86400000).toISOString(),
status: "SUBMITTED",
createdBy: "teacher-001",
createdAt: new Date(Date.now() - 2 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
},
{
id: `hw-${classId}-3`,
classId,
title: "英语单词抄写",
description: "抄写Unit 2单词各5遍",
dueDate: new Date(Date.now() - 86400000).toISOString(),
status: "GRADED",
createdBy: "teacher-001",
createdAt: new Date(Date.now() - 3 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
},
];
}
export const MOCK_HOMEWORK: Record<string, HomeworkDto[]> = {
"class-001": buildMockHomework("class-001"),
"class-002": buildMockHomework("class-002"),
};
function buildMockExams(classId: string): ExamDto[] {
return [
{
id: `exam-${classId}-1`,
classId,
title: "数学期中考试",
description: "三年级数学期中测试",
examDate: new Date(Date.now() + 7 * 86400000).toISOString(),
duration: "90",
totalScore: "100",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: new Date(Date.now() - 7 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 3 * 86400000).toISOString(),
},
{
id: `exam-${classId}-2`,
classId,
title: "语文单元测试",
description: "第1-3单元综合测试",
examDate: new Date(Date.now() + 14 * 86400000).toISOString(),
duration: "60",
totalScore: "100",
status: "DRAFT",
createdBy: "teacher-001",
createdAt: new Date(Date.now() - 5 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 86400000).toISOString(),
},
{
id: `exam-${classId}-3`,
classId,
title: "英语听力测试",
description: "Unit 1-2 听力专项",
examDate: new Date(Date.now() - 3 * 86400000).toISOString(),
duration: "45",
totalScore: "50",
status: "SCORED",
createdBy: "teacher-001",
createdAt: new Date(Date.now() - 10 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 86400000).toISOString(),
},
];
}
export const MOCK_EXAMS: Record<string, ExamDto[]> = {
"class-001": buildMockExams("class-001"),
"class-002": buildMockExams("class-002"),
};
// === data-ana mock ===
function buildMockWeakness(studentId: string): StudentWeaknessDto {
return {
studentId,
weakPoints: [
{
knowledgePointId: `kp-${studentId}-1`,
title: "分数加减法",
mastery: 0.45,
},
{
knowledgePointId: `kp-${studentId}-2`,
title: "阅读理解",
mastery: 0.62,
},
{
knowledgePointId: `kp-${studentId}-3`,
title: "英语时态",
mastery: 0.38,
},
],
};
}
export const MOCK_WEAKNESS: Record<string, StudentWeaknessDto> = {
"student-001": buildMockWeakness("student-001"),
"student-002": buildMockWeakness("student-002"),
};
function buildMockTrend(studentId: string): LearningTrendDto {
const points = [];
for (let i = 0; i < 7; i++) {
points.push({
date: Date.now() - (6 - i) * 86400000,
score: 70 + i * 3 + (studentId === "student-001" ? 5 : 0),
});
}
return { studentId, points };
}
export const MOCK_TREND: Record<string, LearningTrendDto> = {
"student-001": buildMockTrend("student-001"),
"student-002": buildMockTrend("student-002"),
};
function buildMockClassPerformance(classId: string): ClassPerformanceDto {
return {
classId,
averageScore: 82.5,
passRate: 0.92,
scores: [
{ studentId: "student-001", score: 85, grade: "A" },
{ studentId: "student-002", score: 78, grade: "B" },
{ studentId: "student-003", score: 92, grade: "A" },
{ studentId: "student-004", score: 65, grade: "C" },
],
};
}
export const MOCK_CLASS_PERFORMANCE: Record<string, ClassPerformanceDto> = {
"class-001": buildMockClassPerformance("class-001"),
"class-002": buildMockClassPerformance("class-002"),
};
// === msg mock ===
function buildMockNotifications(parentId: string): NotificationDto[] {
const now = Date.now();
return [
{
id: `notif-${parentId}-1`,
userId: parentId,
type: "GRADE",
title: "成绩发布通知",
content: "李同学的数学期中考试成绩已发布得分85分",
channel: "APP",
isRead: false,
childId: "student-001",
createdAt: now - 3600000,
},
{
id: `notif-${parentId}-2`,
userId: parentId,
type: "HOMEWORK",
title: "作业批改完成",
content: "李妹妹的语文课文朗读作业已批改",
channel: "APP",
isRead: false,
childId: "student-002",
createdAt: now - 7200000,
},
{
id: `notif-${parentId}-3`,
userId: parentId,
type: "EXAM",
title: "考试发布通知",
content: "三年级1班数学期中考试已发布请查看详情",
channel: "APP",
isRead: true,
childId: "student-001",
createdAt: now - 86400000,
},
{
id: `notif-${parentId}-4`,
userId: parentId,
type: "ANNOUNCEMENT",
title: "学校公告",
content: "本周五下午家长会,请准时参加",
channel: "APP",
isRead: false,
childId: undefined,
createdAt: now - 172800000,
},
{
id: `notif-${parentId}-5`,
userId: parentId,
type: "SYSTEM",
title: "欢迎使用家长端",
content: "欢迎使用 Edu 家长端,您可以在此查看孩子的学习情况",
channel: "APP",
isRead: true,
childId: undefined,
createdAt: now - 604800000,
},
];
}
export const MOCK_NOTIFICATIONS: Record<string, NotificationDto[]> = {
"parent-001": buildMockNotifications("parent-001"),
};
export const MOCK_NOTIFICATION_PREFS: Record<string, NotificationPreferencesDto> =
{
"parent-001": {
parentId: "parent-001",
channels: ["APP", "WECHAT"],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: false,
schoolAnnouncement: true,
},
},
};

View File

@@ -0,0 +1,206 @@
import type {
NotificationDto,
NotificationPreferencesDto,
} from "./dtos.js";
import {
MOCK_NOTIFICATIONS,
MOCK_NOTIFICATION_PREFS,
} from "./mock-data.js";
import { logger } from "../shared/observability/logger.js";
import { callWithMetrics } from "./grpc/call-with-metrics.js";
import { env } from "../config/env.js";
/**
* msg 服务客户端接口(对齐 02-architecture-design.md §3.2)。
*
* 覆盖:
* - listNotifications家长通知列表
* - markAsRead标记已读
* - getNotificationPreferences通知偏好查询
* - updateNotificationPreferences通知偏好更新
*
* 注意msg.proto 当前 Notification 缺 child_id 字段ISSUE-007
* NotificationPreferences RPC 也未定义Mock 实现返回完整结构。
* gRPC 实现在 proto 补全后对齐。
*/
export interface MsgClient {
listNotifications(
parentId: string,
unreadOnly?: boolean,
): Promise<NotificationDto[]>;
markAsRead(notificationId: string): Promise<void>;
getNotificationPreferences(
parentId: string,
): Promise<NotificationPreferencesDto>;
updateNotificationPreferences(
parentId: string,
prefs: NotificationPreferencesDto,
): Promise<NotificationPreferencesDto>;
}
// === Mock 实现DEV_MODE=true ===
export class MockMsgClient implements MsgClient {
async listNotifications(
parentId: string,
unreadOnly = false,
): Promise<NotificationDto[]> {
const all = MOCK_NOTIFICATIONS[parentId] ?? [];
return unreadOnly ? all.filter((n) => !n.isRead) : all;
}
async markAsRead(notificationId: string): Promise<void> {
for (const parentId of Object.keys(MOCK_NOTIFICATIONS)) {
const list = MOCK_NOTIFICATIONS[parentId];
if (!list) continue;
const idx = list.findIndex((n) => n.id === notificationId);
const target = list[idx];
if (idx >= 0 && target) {
list[idx] = { ...target, isRead: true };
return;
}
}
}
async getNotificationPreferences(
parentId: string,
): Promise<NotificationPreferencesDto> {
return (
MOCK_NOTIFICATION_PREFS[parentId] ?? {
parentId,
channels: ["APP"],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
}
);
}
async updateNotificationPreferences(
parentId: string,
prefs: NotificationPreferencesDto,
): Promise<NotificationPreferencesDto> {
MOCK_NOTIFICATION_PREFS[parentId] = { ...prefs, parentId };
return MOCK_NOTIFICATION_PREFS[parentId];
}
}
// === gRPC 实现DEV_MODE=false ===
interface GrpcListNotificationsResponse {
notifications: GrpcNotification[];
}
interface GrpcNotification {
id: string;
userId: string;
type: string;
title: string;
content: string;
channel: string;
isRead: boolean;
createdAt: number;
}
/**
* gRPC msg 客户端。
*
* 注意NotificationPreferences RPC 未在 msg.proto 中定义,
* 当前 gRPC 实现回退为默认值,待 msg 服务补全 RPC 后替换。
*/
export class GrpcMsgClient implements MsgClient {
private readonly rawClient: unknown;
constructor(rawClient: unknown) {
this.rawClient = rawClient;
}
async listNotifications(
parentId: string,
unreadOnly = false,
): Promise<NotificationDto[]> {
const res = await callWithMetrics<
{ userId: string; onlyUnread: boolean },
GrpcListNotificationsResponse
>(
this.rawClient,
"listNotifications",
{ userId: parentId, onlyUnread: unreadOnly },
"msg",
);
return (res.notifications ?? []).map((n) => this.mapNotification(n));
}
async markAsRead(notificationId: string): Promise<void> {
await callWithMetrics<{ id: string }, unknown>(
this.rawClient,
"markAsRead",
{ id: notificationId },
"msg",
);
}
async getNotificationPreferences(
parentId: string,
): Promise<NotificationPreferencesDto> {
// TODO: msg.proto 未定义 NotificationPreferences RPC回退默认值
logger.warn(
{ parentId },
"GrpcMsgClient.getNotificationPreferences: RPC not yet defined in msg.proto, returning defaults",
);
return {
parentId,
channels: ["APP"],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
};
}
async updateNotificationPreferences(
parentId: string,
prefs: NotificationPreferencesDto,
): Promise<NotificationPreferencesDto> {
// TODO: msg.proto 未定义 UpdateNotificationPreferences RPC
logger.warn(
{ parentId },
"GrpcMsgClient.updateNotificationPreferences: RPC not yet defined in msg.proto, returning input as-is",
);
return { ...prefs, parentId };
}
private mapNotification(n: GrpcNotification): NotificationDto {
return {
id: n.id,
userId: n.userId,
type: n.type,
title: n.title,
content: n.content,
channel: n.channel,
isRead: n.isRead,
// ISSUE-007: child_id 字段待 msg.proto 补全
childId: undefined,
createdAt: n.createdAt,
};
}
}
// === 工厂辅助 ===
export function createMsgClient(): MsgClient {
if (env.DEV_MODE) {
return new MockMsgClient();
}
// gRPC 实现需要 msg.proto Service 定义,在 ClientsModule 中组装
throw new Error(
"GrpcMsgClient must be created via ClientsModule with proto-loader",
);
}

View File

@@ -0,0 +1,116 @@
import { z } from "zod";
/**
* parent-bff 环境变量 Zod 校验(对齐 02-architecture-design.md §12.1)。
*
* 配置项分组:
* - 服务基础NODE_ENV / PORT / LOG_LEVEL / DEV_MODE
* - 下游服务 URLHTTP 模式P4 兼容)
* - 下游 gRPC targetP5+ 启用,覆盖 URL
* - Redis
* - ChildGuard缓存 TTL + singleflight
* - 缓存dashboard / grades / permissions
* - GraphQLdepth / cost / introspection
* - 可观测性OTLP / service name
* - KafkaP5+ 启用)
* - CORS
*/
const envSchema = z.object({
// === 服务基础 ===
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
PORT: z.string().default("3010").transform(Number),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
DEV_MODE: z
.string()
.default("true")
.transform((v) => v === "true"),
// === 下游服务 URLHTTP 模式P4 fallback ===
IamServiceUrl: z.string().url().default("http://localhost:3002"),
CoreEduServiceUrl: z.string().url().default("http://localhost:3004"),
DataAnaServiceUrl: z.string().url().default("http://localhost:3006"),
MsgServiceUrl: z.string().url().default("http://localhost:3007"),
PushGatewayUrl: z.string().url().default("http://localhost:8081"),
// === 下游 gRPC targetP5+ 启用,覆盖 URL ===
IamGrpcTarget: z.string().default("localhost:50052"),
CoreEduGrpcTarget: z.string().default("localhost:50053"),
DataAnaGrpcTarget: z.string().default("localhost:50055"),
MsgGrpcTarget: z.string().default("localhost:50056"),
// === Redis ===
REDIS_URL: z.string().url().default("redis://localhost:6379"),
REDIS_KEY_PREFIX: z.string().default("bff:parent:"),
// === ChildGuard ===
CHILD_GUARD_CACHE_TTL_SECONDS: z
.string()
.default("30")
.transform(Number),
CHILD_GUARD_SINGLEFLIGHT_ENABLED: z
.string()
.default("true")
.transform((v) => v === "true"),
// === 缓存 TTL ===
DASHBOARD_CACHE_TTL_SECONDS: z.string().default("15").transform(Number),
GRADES_CACHE_TTL_SECONDS: z.string().default("30").transform(Number),
PERMISSIONS_CACHE_TTL_SECONDS: z.string().default("300").transform(Number),
// === GraphQL ===
GRAPHQL_DEPTH_LIMIT: z.string().default("7").transform(Number),
GRAPHQL_COST_LIMIT: z.string().default("1000").transform(Number),
GRAPHQL_INTROSPECTION_ENABLED: z
.string()
.default("true")
.transform((v) => v === "true"),
// === 可观测性 ===
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
OTEL_SERVICE_NAME: z.string().default("parent-bff"),
OTEL_SERVICE_VERSION: z.string().optional(),
// === KafkaP5+ 启用) ===
KAFKA_BROKERS: z.string().optional(),
KAFKA_CONSUMER_GROUP_ID: z.string().default("parent-bff"),
KAFKA_CONSUMER_TOPICS: z.string().optional(),
// === CORS ===
CORS_ORIGINS: z.string().default("http://localhost:4002"),
});
export type Env = z.infer<typeof envSchema>;
/**
* 加载并校验环境变量。
*
* 兼容大小写:进程环境变量大小写敏感,但本项目惯例使用混合大小写
* (如 IamServiceUrl因此显式映射。
*/
export function loadEnv(): Env {
const result = envSchema.safeParse({
...process.env,
IamServiceUrl: process.env.IAM_SERVICE_URL || "http://localhost:3002",
CoreEduServiceUrl:
process.env.CORE_EDU_SERVICE_URL || "http://localhost:3004",
DataAnaServiceUrl:
process.env.DATA_ANA_SERVICE_URL || "http://localhost:3006",
MsgServiceUrl: process.env.MSG_SERVICE_URL || "http://localhost:3007",
PushGatewayUrl: process.env.PUSH_GATEWAY_URL || "http://localhost:8081",
IamGrpcTarget: process.env.IAM_GRPC_TARGET || "localhost:50052",
CoreEduGrpcTarget: process.env.CORE_EDU_GRPC_TARGET || "localhost:50053",
DataAnaGrpcTarget:
process.env.DATA_ANA_GRPC_TARGET || "localhost:50055",
MsgGrpcTarget: process.env.MSG_GRPC_TARGET || "localhost:50056",
});
if (!result.success) {
throw new Error(
"Invalid parent-bff env: " + JSON.stringify(result.error.flatten()),
);
}
return result.data;
}
export const env = loadEnv();

View File

@@ -0,0 +1,29 @@
import { Injectable, Inject } from "@nestjs/common";
import type { IamClient } from "../clients/iam.client.js";
import type { CoreEduClient } from "../clients/core-edu.client.js";
import type { Loaders } from "./loaders.js";
import { createLoaders } from "./loaders.js";
/**
* DataLoader 工厂(对齐 02-architecture-design.md §6.3)。
*
* 每次请求调用 createLoaders() 生成独立的 DataLoader 实例集合。
* Resolver 通过 GraphQL context.loaders 访问。
*/
@Injectable()
export class DataLoaderFactory {
private readonly iamClient: IamClient;
private readonly coreEduClient: CoreEduClient;
constructor(
@Inject("IAM_CLIENT") iamClient: IamClient,
@Inject("CORE_EDU_CLIENT") coreEduClient: CoreEduClient,
) {
this.iamClient = iamClient;
this.coreEduClient = coreEduClient;
}
createLoaders(): Loaders {
return createLoaders(this.iamClient, this.coreEduClient);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { DataLoaderFactory } from "./dataloader.factory.js";
/**
* DataLoader 模块。
*
* 导出 DataLoaderFactory供 GraphqlModule 在构建 per-request context 时使用。
* 依赖 ClientsModule 提供的 IAM_CLIENT / CORE_EDU_CLIENT。
*/
@Module({
providers: [DataLoaderFactory],
exports: [DataLoaderFactory],
})
export class DataLoaderModule {}

View File

@@ -0,0 +1,71 @@
import DataLoader from "dataloader";
import type { IamClient } from "../clients/iam.client.js";
import type { CoreEduClient } from "../clients/core-edu.client.js";
import type {
ChildDto,
ExamDto,
GradeDto,
HomeworkDto,
} from "../clients/dtos.js";
/**
* Per-request DataLoader 容器(对齐 02-architecture-design.md §6.3)。
*
* DataLoader 防御 N+1
* - 同一 request 内相同 key 只调 1 次下游
* - 不同 key 的调用在同一个 tick 批量发起Promise.all 并行)
*
* proto 无 batch RPCDataLoader 仅做 per-request 去重 + 并行调度。
*/
export interface Loaders {
/** parentId → ChildDto[]孩子列表per-request 去重) */
children: DataLoader<string, ChildDto[]>;
/** studentId → GradeDto[](成绩列表) */
grades: DataLoader<string, GradeDto[]>;
/** classId → HomeworkDto[](作业列表) */
homework: DataLoader<string, HomeworkDto[]>;
/** classId → ExamDto[](考试列表) */
exams: DataLoader<string, ExamDto[]>;
}
/**
* 创建 per-request DataLoader 实例集合。
*
* 每次请求调用此函数,生成独立的 DataLoader 实例,
* 避免跨请求的批量调度。
*/
export function createLoaders(
iamClient: IamClient,
coreEduClient: CoreEduClient,
): Loaders {
return {
children: new DataLoader<string, ChildDto[]>(async (parentIds) => {
// proto 无 batch RPC逐个调用但并行执行
const results = await Promise.all(
parentIds.map((id) => iamClient.getChildrenByParent(id)),
);
return results;
}),
grades: new DataLoader<string, GradeDto[]>(async (studentIds) => {
const results = await Promise.all(
studentIds.map((id) => coreEduClient.listGradesByStudent(id)),
);
return results;
}),
homework: new DataLoader<string, HomeworkDto[]>(async (classIds) => {
const results = await Promise.all(
classIds.map((id) => coreEduClient.listHomeworkByClass(id)),
);
return results;
}),
exams: new DataLoader<string, ExamDto[]>(async (classIds) => {
const results = await Promise.all(
classIds.map((id) => coreEduClient.listExamsByClass(id)),
);
return results;
}),
};
}

View File

@@ -0,0 +1,41 @@
import type { Request, Response, NextFunction } from "express";
/**
* 上下文中间件(对齐 02-architecture-design.md §1.1 Context Middleware
*
* 职责:在请求到达 GraphQL Yoga 之前,将 x-user-id / x-user-roles / x-request-id
* 头解析后挂载到 req.parentSession 上下文(供后续 GlobalErrorFilter 使用)。
*
* 注意:
* - Yoga 的 context 由 yoga.ts 的 context() 函数独立构建buildSession
* 本中间件仅作为前置日志 / 错误兜底使用。
* - 缺失 x-user-id 时 Yoga 的 buildSession 会抛 UnauthorizedError
* 本中间件提前打日志便于排查。
*
* U4 仲裁BFF 仅校验 x-user-id 存在,不做 @RequirePermission 权限决策。
*/
export function contextMiddleware(
req: Request,
_res: Response,
next: NextFunction,
): void {
const userId = req.headers["x-user-id"];
const requestId = req.headers["x-request-id"];
// 提前打日志(便于排查缺失 header 的情况)
if (!userId) {
// Yoga 的 buildSession 会抛 401此处仅记录
console.warn(
`[parent-bff] Missing x-user-id header on ${req.method} ${req.url}`,
);
}
if (!requestId) {
// Gateway 必须注入 x-request-id缺失时打日志但不阻塞
console.warn(
`[parent-bff] Missing x-request-id header on ${req.method} ${req.url}`,
);
}
next();
}

View File

@@ -0,0 +1,29 @@
import { All, Controller, Inject, Req, Res } from "@nestjs/common";
import type { Request, Response } from "express";
import type { YogaInstance } from "../graphql/yoga.js";
/**
* GraphQL Yoga 端点 Controller对齐 02-architecture-design.md §1.1)。
*
* - POST /graphql执行 GraphQL query / mutation
* - GET /graphql开发环境返回 GraphiQL playground生产关闭
*
* 由 NestJS 路由到 yoga.handleNodeRequestAndResponse
* Yoga 内部负责解析 body / 构建 context含 ParentSession + DataLoaders/ 执行 schema / 返回结果。
*/
@Controller("graphql")
export class GraphqlController {
private readonly yoga: YogaInstance;
constructor(@Inject("YOGA_INSTANCE") yoga: YogaInstance) {
this.yoga = yoga;
}
@All()
async handle(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.yoga.handleNodeRequestAndResponse(req, res, { req, res });
}
}

View File

@@ -0,0 +1,69 @@
import type { Request, Response } from "express";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import { logger } from "../shared/observability/logger.js";
import type { Loaders } from "../dataloader/loaders.js";
/**
* ParentSession 值对象(对齐 02-architecture-design.md §2.2)。
*
* 每次请求重建,不跨请求保留状态(符合 004 §12.1 无状态约束)。
* 由前端在 GraphQL query 参数传入 requestedChildId方案 A
*/
export interface ParentSession {
/** 从 x-user-id 头解析Gateway JWT 注入) */
parentId: string;
/** 从 x-user-roles 头解析Gateway JWT 注入,逗号分隔) */
roles: string[];
/** 家长固定 CHILDREN004 §5.4 */
dataScope: "CHILDREN";
/** 从 GraphQL query 参数解析(前端传入,可选) */
requestedChildId?: string;
/** 从 x-request-id 头解析Gateway 注入) */
traceId: string;
}
export interface GraphqlContext {
session: ParentSession;
req: Request;
res: Response;
/** Per-request DataLoader 实例集合(防 N+1 */
loaders: Loaders;
}
/**
* 从 Express Request 构建 ParentSession。
*
* 校验x-user-id 必须存在U4 仲裁BFF 仅校验 x-user-id缺失抛 401。
* x-user-roles / x-request-id 缺失时降级为空数组 / "unknown"。
*/
export function buildSession(req: Request): ParentSession {
const parentIdHeader = req.headers["x-user-id"];
const parentId =
typeof parentIdHeader === "string" ? parentIdHeader : "";
if (!parentId) {
throw new UnauthorizedError("Missing x-user-id header", {
header: "x-user-id",
});
}
const rolesHeader = req.headers["x-user-roles"];
const roles =
typeof rolesHeader === "string"
? rolesHeader.split(",").map((r) => r.trim()).filter(Boolean)
: [];
const requestIdHeader = req.headers["x-request-id"];
const traceId =
typeof requestIdHeader === "string" ? requestIdHeader : "unknown";
const session: ParentSession = {
parentId,
roles,
dataScope: "CHILDREN",
traceId,
};
logger.debug({ session }, "Parent session built");
return session;
}

View File

@@ -0,0 +1,67 @@
import { Module, NestModule, MiddlewareConsumer } from "@nestjs/common";
import type { IamClient } from "../clients/iam.client.js";
import type { CoreEduClient } from "../clients/core-edu.client.js";
import type { DataAnaClient } from "../clients/data-ana.client.js";
import type { MsgClient } from "../clients/msg.client.js";
import { GraphqlController } from "../entry/graphql.controller.js";
import { contextMiddleware } from "../entry/context.middleware.js";
import { DataLoaderModule } from "../dataloader/dataloader.module.js";
import { DataLoaderFactory } from "../dataloader/dataloader.factory.js";
import { AggregationModule } from "../aggregation/aggregation.module.js";
import { ChildGuard } from "../aggregation/child-guard.js";
import { createYogaInstance, type YogaInstance } from "./yoga.js";
import { buildResolvers, type ResolverDeps } from "./resolvers/index.js";
/**
* GraphQL Module对齐 02-architecture-design.md §1.4)。
*
* 注册:
* - GraphqlControllerPOST/GET /graphql
* - YOGA_INSTANCE provider工厂模式注入 DataLoaderFactory + 下游客户端 + ChildGuard
* - contextMiddleware解析 x-user-id / x-user-roles / x-request-id
* - DataLoaderModuleper-request 实例)
* - AggregationModuleChildGuard
*
* P4.5YOGA_INSTANCE factory 组装 ResolverDeps 并调 buildResolvers 生成 resolver map。
* P5注入 MSG_CLIENT 用于通知 resolver。
*/
@Module({
imports: [DataLoaderModule, AggregationModule],
controllers: [GraphqlController],
providers: [
{
provide: "YOGA_INSTANCE",
useFactory: (
loaderFactory: DataLoaderFactory,
iamClient: IamClient,
coreEduClient: CoreEduClient,
dataAnaClient: DataAnaClient,
msgClient: MsgClient,
childGuard: ChildGuard,
): YogaInstance => {
const deps: ResolverDeps = {
iamClient,
coreEduClient,
dataAnaClient,
msgClient,
childGuard,
};
const resolvers = buildResolvers(deps);
return createYogaInstance(loaderFactory, resolvers);
},
inject: [
DataLoaderFactory,
"IAM_CLIENT",
"CORE_EDU_CLIENT",
"DATA_ANA_CLIENT",
"MSG_CLIENT",
ChildGuard,
],
},
],
})
export class GraphqlModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(contextMiddleware).forRoutes("graphql");
}
}

View File

@@ -0,0 +1,279 @@
import type { GraphqlContext } from "../context.js";
import type {
ChildAnalyticsType,
ChildType,
ExamType,
GradePageType,
GradeType,
HomeworkType,
} from "../types.js";
import type { ResolverDeps } from "./index.js";
import {
mapAnalytics,
mapChild,
mapExam,
mapGrade,
mapHomework,
} from "../../aggregation/response-mapper.js";
import { orchestrate } from "../../aggregation/orchestrator.js";
import type {
ClassPerformanceDto,
LearningTrendDto,
StudentWeaknessDto,
} from "../../clients/dtos.js";
/**
* Child Resolvers对齐 02-architecture-design.md §1.3 + workline P4.5)。
*
* 包含:
* - Query.children返回家长绑定的孩子列表
* - Query.child单个孩子查询含 ChildGuard 校验)
* - Query.childHomework孩子作业列表含 ChildGuard 校验)
* - Query.childExams孩子考试列表含 ChildGuard 校验)
* - Query.childAnalytics孩子学情诊断含 ChildGuard 校验)
* - Child.lastGrade字段解析器最近一次成绩
* - Child.grades字段解析器成绩分页
* - Child.homework字段解析器作业列表
* - Child.exams字段解析器考试列表
* - Child.analytics字段解析器学情诊断
*
* ChildGuard 校验02 §2.3
* - child-scoped query 需先调 ChildGuard.validateChildAccess
* - 越权 childId 抛 BFF_PARENT_CHILD_NOT_BOUND(403)
*
* 字段解析器不需要 ChildGuard 校验parent 已通过合法路径获取 Child 对象)。
* 字段解析器通过 ctx.loaders 访问 per-request DataLoader 防止 N+1。
*/
// ============ Query Resolvers ============
export function buildChildrenQueryResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
_args: unknown,
ctx: GraphqlContext,
): Promise<ChildType[]> => {
const children = await deps.childGuard.getBoundChildren(
ctx.session.parentId,
);
return children.map(mapChild);
};
}
export function buildChildQueryResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
args: { childId: string },
ctx: GraphqlContext,
): Promise<ChildType | null> => {
await deps.childGuard.validateChildAccess(
ctx.session.parentId,
args.childId,
);
const children = await deps.childGuard.getBoundChildren(
ctx.session.parentId,
);
const child = children.find((c) => c.id === args.childId);
return child ? mapChild(child) : null;
};
}
export function buildChildHomeworkQueryResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
args: { childId: string },
ctx: GraphqlContext,
): Promise<HomeworkType[]> => {
await deps.childGuard.validateChildAccess(
ctx.session.parentId,
args.childId,
);
const children = await deps.childGuard.getBoundChildren(
ctx.session.parentId,
);
const child = children.find((c) => c.id === args.childId);
if (!child) return [];
const homework = await ctx.loaders.homework.load(child.classId);
return homework.map(mapHomework);
};
}
export function buildChildExamsQueryResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
args: { childId: string },
ctx: GraphqlContext,
): Promise<ExamType[]> => {
await deps.childGuard.validateChildAccess(
ctx.session.parentId,
args.childId,
);
const children = await deps.childGuard.getBoundChildren(
ctx.session.parentId,
);
const child = children.find((c) => c.id === args.childId);
if (!child) return [];
const exams = await ctx.loaders.exams.load(child.classId);
return exams.map(mapExam);
};
}
export function buildChildAnalyticsQueryResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
args: { childId: string; dateRange?: { start: string; end: string } },
ctx: GraphqlContext,
): Promise<ChildAnalyticsType> => {
await deps.childGuard.validateChildAccess(
ctx.session.parentId,
args.childId,
);
const children = await deps.childGuard.getBoundChildren(
ctx.session.parentId,
);
const child = children.find((c) => c.id === args.childId);
if (!child) {
return {
childId: args.childId,
weakness: [],
trend: [],
classRank: null,
classAverage: null,
};
}
return loadChildAnalytics(deps, args.childId, child.classId, args.dateRange);
};
}
// ============ Child Field Resolvers ============
export function buildChildLastGradeFieldResolver() {
return async (
parent: ChildType,
_args: unknown,
ctx: GraphqlContext,
): Promise<GradeType | null> => {
const grades = await ctx.loaders.grades.load(parent.id);
if (grades.length === 0) return null;
const sorted = [...grades].sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
const latest = sorted[0];
if (!latest) return null;
return mapGrade(latest);
};
}
export function buildChildGradesFieldResolver() {
return async (
parent: ChildType,
args: { page?: number; pageSize?: number; subject?: string },
ctx: GraphqlContext,
): Promise<GradePageType> => {
const grades = await ctx.loaders.grades.load(parent.id);
const mapped = grades.map(mapGrade);
const filtered = args.subject
? mapped.filter((g) => g.subject === args.subject)
: mapped;
const page = args.page ?? 1;
const pageSize = args.pageSize ?? 20;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
pagination: { page, pageSize, total: filtered.length },
};
};
}
export function buildChildHomeworkFieldResolver() {
return async (
parent: ChildType,
_args: unknown,
ctx: GraphqlContext,
): Promise<HomeworkType[]> => {
const homework = await ctx.loaders.homework.load(parent.class.id);
return homework.map(mapHomework);
};
}
export function buildChildExamsFieldResolver() {
return async (
parent: ChildType,
_args: unknown,
ctx: GraphqlContext,
): Promise<ExamType[]> => {
const exams = await ctx.loaders.exams.load(parent.class.id);
return exams.map(mapExam);
};
}
export function buildChildAnalyticsFieldResolver(deps: ResolverDeps) {
return async (
parent: ChildType,
args: { dateRange?: { start: string; end: string } },
_ctx: GraphqlContext,
): Promise<ChildAnalyticsType> => {
return loadChildAnalytics(
deps,
parent.id,
parent.class.id,
args.dateRange,
);
};
}
// ============ Shared Helper ============
/**
* 加载孩子学情诊断数据。
*
* 并行调用 3 个 data-ana RPC
* - getStudentWeakness
* - getLearningTrend
* - getClassPerformance
*
* 使用 Promise.allSettled 部分失败容忍。
*/
async function loadChildAnalytics(
deps: ResolverDeps,
childId: string,
classId: string,
dateRange?: { start: string; end: string },
): Promise<ChildAnalyticsType> {
const now = Date.now();
const startDate = dateRange
? new Date(dateRange.start).getTime()
: now - 30 * 86400000;
const endDate = dateRange ? new Date(dateRange.end).getTime() : now;
const orchResult = await orchestrate({
weakness: deps.dataAnaClient.getStudentWeakness(childId, ""),
trend: deps.dataAnaClient.getLearningTrend(childId, startDate, endDate),
classPerf: deps.dataAnaClient.getClassPerformance(classId, "", startDate, endDate),
});
const weakness = (orchResult.results.weakness as StudentWeaknessDto | null) ?? {
studentId: childId,
weakPoints: [],
};
const trend = (orchResult.results.trend as LearningTrendDto | null) ?? {
studentId: childId,
points: [],
};
const classPerf = orchResult.results.classPerf as ClassPerformanceDto | null;
return mapAnalytics(childId, weakness, trend, classPerf);
}

View File

@@ -0,0 +1,96 @@
import type { GraphqlContext } from "../context.js";
import type { DashboardDataType } from "../types.js";
import type { ResolverDeps } from "./index.js";
import { mapChild, mapParent, mapViewport } from "../../aggregation/response-mapper.js";
import { orchestrate } from "../../aggregation/orchestrator.js";
import { withCacheFallback } from "../../aggregation/fallback-strategy.js";
import { CacheKeys } from "../../shared/cache/cache-key.builder.js";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import type {
ChildDto,
UserInfoDto,
ViewportDto,
} from "../../clients/dtos.js";
/**
* Dashboard Query Resolver对齐 02-architecture-design.md §1.2 + workline P4.6)。
*
* P4.6 增强:
* - Redis 缓存15s TTL对齐 env.DASHBOARD_CACHE_TTL_SECONDS
* - Redis 不可用时降级为内存 LRU
* - 使用 Orchestrator 抽象并行编排 + partial 降级标记
*
* P5 增强:
* - 聚合 msg.listNotifications(unread=true) 获取 unreadNotifications 计数
*
* 聚合 4 个并行调用:
* - iam.getUserInfo → parent
* - iam.getChildrenByParent → children
* - iam.getViewports → viewports
* - msg.listNotifications(unread=true) → unreadNotifications
*
* degraded=true 当任一下游调用失败。
*/
export function buildDashboardResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
_args: unknown,
ctx: GraphqlContext,
): Promise<DashboardDataType> => {
const parentId = ctx.session.parentId;
const cacheKey = CacheKeys.dashboard(parentId);
const result = await withCacheFallback(
cacheKey,
() => fetchDashboard(deps, parentId),
env.DASHBOARD_CACHE_TTL_SECONDS,
"dashboard",
);
if (result.data) {
if (result.fromCache) {
logger.debug({ parentId }, "Dashboard cache hit");
}
return result.data;
}
// 所有降级都失败,返回最小可用数据
logger.warn({ parentId }, "Dashboard all fallbacks exhausted");
return {
parent: null,
children: [],
viewports: [],
unreadNotifications: 0,
degraded: true,
};
};
}
/**
* 实际聚合下游数据Orchestrator 并行 + 降级标记)。
*/
async function fetchDashboard(
deps: ResolverDeps,
parentId: string,
): Promise<DashboardDataType> {
const orchResult = await orchestrate({
parent: deps.iamClient.getUserInfo(parentId),
children: deps.iamClient.getChildrenByParent(parentId),
viewports: deps.iamClient.getViewports(parentId),
unread: deps.msgClient.listNotifications(parentId, true),
});
const parent = orchResult.results.parent as UserInfoDto | null;
const children = orchResult.results.children as ChildDto[] | null;
const viewports = orchResult.results.viewports as ViewportDto[] | null;
const unreadList = orchResult.results.unread as unknown[] | null;
return {
parent: parent ? mapParent(parent) : null,
children: children ? children.map(mapChild) : [],
viewports: viewports ? viewports.map(mapViewport) : [],
unreadNotifications: unreadList ? unreadList.length : 0,
degraded: orchResult.partial,
};
}

View File

@@ -0,0 +1,53 @@
import type { GraphqlContext } from "../context.js";
import type { GradePageType } from "../types.js";
import type { ResolverDeps } from "./index.js";
import { mapGrade } from "../../aggregation/response-mapper.js";
/**
* Grade Resolver对齐 workline P4.5)。
*
* Query.childGrades孩子成绩分页查询。
*
* - 先 ChildGuard 校验 childId ∈ 家长绑定列表
* - 再调 core-edu.listGradesByStudent 获取成绩
* - 支持 subject 过滤 + page / pageSize 分页
* - pageSize 上限 50防止过大查询
*
* 注意proto Grade.score 为 stringresponse-mapper 负责 Number.parseFloat 转换。
*/
const MAX_PAGE_SIZE = 50;
export function buildChildGradesQueryResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
args: {
childId: string;
page?: number;
pageSize?: number;
subject?: string;
},
ctx: GraphqlContext,
): Promise<GradePageType> => {
await deps.childGuard.validateChildAccess(
ctx.session.parentId,
args.childId,
);
const grades = await ctx.loaders.grades.load(args.childId);
const mapped = grades.map(mapGrade);
const filtered = args.subject
? mapped.filter((g) => g.subject === args.subject)
: mapped;
const page = Math.max(1, args.page ?? 1);
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, args.pageSize ?? 20));
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
pagination: { page, pageSize, total: filtered.length },
};
};
}

View File

@@ -0,0 +1,148 @@
import type { IResolvers } from "@graphql-tools/utils";
import { GraphQLScalarType, Kind } from "graphql";
import type { IamClient } from "../../clients/iam.client.js";
import type { CoreEduClient } from "../../clients/core-edu.client.js";
import type { DataAnaClient } from "../../clients/data-ana.client.js";
import type { MsgClient } from "../../clients/msg.client.js";
import type { ChildGuard } from "../../aggregation/child-guard.js";
import { buildDashboardResolver } from "./dashboard.resolver.js";
import {
buildChildAnalyticsFieldResolver,
buildChildAnalyticsQueryResolver,
buildChildExamsFieldResolver,
buildChildExamsQueryResolver,
buildChildGradesFieldResolver,
buildChildHomeworkFieldResolver,
buildChildHomeworkQueryResolver,
buildChildLastGradeFieldResolver,
buildChildQueryResolver,
buildChildrenQueryResolver,
} from "./child.resolver.js";
import { buildChildGradesQueryResolver } from "./grade.resolver.js";
import { buildSelectChildMutationResolver } from "./select-child.resolver.js";
import {
buildMeQueryResolver,
buildViewportsQueryResolver,
} from "./me.resolver.js";
import {
buildNotificationsQueryResolver,
buildMarkNotificationReadMutationResolver,
} from "./notification.resolver.js";
import {
buildNotificationPreferencesQueryResolver,
buildUpdateNotificationPreferencesMutationResolver,
} from "./notification-preference.resolver.js";
/**
* Resolver 依赖注入容器。
*
* 由 GraphqlModule 在 YOGA_INSTANCE factory 中组装,
* 传入 4 个下游客户端 + ChildGuard。
*/
export interface ResolverDeps {
iamClient: IamClient;
coreEduClient: CoreEduClient;
dataAnaClient: DataAnaClient;
msgClient: MsgClient;
childGuard: ChildGuard;
}
/**
* DateTime 标量ISO 8601 字符串 ↔ JS Date / string。
*/
const DateTimeScalar = new GraphQLScalarType({
name: "DateTime",
description: "ISO 8601 date-time string",
serialize(value: unknown): string {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return new Date(value).toISOString();
}
throw new Error(`DateTime scalar: cannot serialize ${typeof value}`);
},
parseValue(value: unknown): string {
if (typeof value === "string") {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error(`DateTime scalar: invalid date string "${value}"`);
}
return value;
}
if (typeof value === "number") {
return new Date(value).toISOString();
}
throw new Error(`DateTime scalar: cannot parse ${typeof value}`);
},
parseLiteral(ast): string {
if (ast.kind === Kind.STRING) {
const date = new Date(ast.value);
if (Number.isNaN(date.getTime())) {
throw new Error(`DateTime scalar: invalid date string "${ast.value}"`);
}
return ast.value;
}
if (ast.kind === Kind.INT) {
return new Date(Number(ast.value)).toISOString();
}
throw new Error(`DateTime scalar: cannot parse literal ${ast.kind}`);
},
});
/**
* 构建 GraphQL resolvers对齐 02-architecture-design.md §4.2 SDL
*
* 组装顺序:
* 1. DateTime scalar
* 2. Query resolvers11 fields
* 3. Mutation resolvers3 fields
* 4. Child type field resolvers5 lazy-loaded fields
*
* notifications / notificationPreferences / markNotificationRead / updateNotificationPreferences
* 为 P5 桩实现P5 msg 接入后替换。
*/
export function buildResolvers(deps: ResolverDeps): IResolvers {
return {
DateTime: DateTimeScalar,
Query: {
dashboard: buildDashboardResolver(deps),
viewports: buildViewportsQueryResolver(deps),
me: buildMeQueryResolver(deps),
children: buildChildrenQueryResolver(deps),
child: buildChildQueryResolver(deps),
childGrades: buildChildGradesQueryResolver(deps),
childHomework: buildChildHomeworkQueryResolver(deps),
childExams: buildChildExamsQueryResolver(deps),
childAnalytics: buildChildAnalyticsQueryResolver(deps),
// P5 通知 resolvers
notifications: buildNotificationsQueryResolver(deps),
notificationPreferences: buildNotificationPreferencesQueryResolver(deps),
},
Mutation: {
selectChild: buildSelectChildMutationResolver(deps),
// P5 通知 mutations
markNotificationRead: buildMarkNotificationReadMutationResolver(deps),
updateNotificationPreferences:
buildUpdateNotificationPreferencesMutationResolver(deps),
},
Child: {
lastGrade: buildChildLastGradeFieldResolver(),
grades: buildChildGradesFieldResolver(),
homework: buildChildHomeworkFieldResolver(),
exams: buildChildExamsFieldResolver(),
analytics: buildChildAnalyticsFieldResolver(deps),
},
};
}

View File

@@ -0,0 +1,61 @@
import type { GraphqlContext } from "../context.js";
import type { ParentType, ViewportItemType } from "../types.js";
import type { ResolverDeps } from "./index.js";
import { mapParent, mapViewport } from "../../aggregation/response-mapper.js";
import { logger } from "../../shared/observability/logger.js";
/**
* Me + Viewports Resolvers。
*
* - Query.me返回家长个人信息调 iam.getUserInfo 获取完整数据)
* - Query.viewports返回家长可见视口列表调 iam.getViewports
*
* 均使用 Promise.allSettled 降级iam 失败时从 session 构造最小可用数据。
*/
export function buildMeQueryResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
_args: unknown,
ctx: GraphqlContext,
): Promise<ParentType> => {
try {
const userInfo = await deps.iamClient.getUserInfo(ctx.session.parentId);
return mapParent(userInfo);
} catch (err) {
logger.warn(
{ err, parentId: ctx.session.parentId },
"iam.getUserInfo failed, using session fallback",
);
return {
id: ctx.session.parentId,
email: "",
name: "",
avatar: null,
roles: ctx.session.roles,
dataScope: "CHILDREN",
};
}
};
}
export function buildViewportsQueryResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
_args: unknown,
ctx: GraphqlContext,
): Promise<ViewportItemType[]> => {
try {
const viewports = await deps.iamClient.getViewports(
ctx.session.parentId,
);
return viewports.map(mapViewport);
} catch (err) {
logger.warn(
{ err, parentId: ctx.session.parentId },
"iam.getViewports failed, returning empty",
);
return [];
}
};
}

View File

@@ -0,0 +1,134 @@
import type { MsgClient } from "../../clients/msg.client.js";
import type { NotificationPreferencesDto } from "../../clients/dtos.js";
import type {
NotificationPreferencesType,
NotificationChannel,
} from "../types.js";
import type { GraphqlContext } from "../context.js";
import { CacheKeys } from "../../shared/cache/cache-key.builder.js";
import {
withCacheFallback,
invalidateCache,
} from "../../aggregation/fallback-strategy.js";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import {
UpdateNotificationPreferencesSchema,
type UpdateNotificationPreferencesInput,
} from "../../parent/dto/parent-inputs.dto.js";
const CHANNEL_MAP: Record<string, NotificationChannel> = {
APP: "APP",
SMS: "SMS",
EMAIL: "EMAIL",
WECHAT: "WECHAT",
};
/**
* DTO → GraphQL Type 映射。
*/
function mapPreferences(
dto: NotificationPreferencesDto,
): NotificationPreferencesType {
return {
channels: dto.channels.map((c) => CHANNEL_MAP[c] ?? "APP"),
eventTypes: { ...dto.eventTypes },
};
}
/**
* notificationPreferences Query resolver。
*
* Redis 缓存 300sPERMISSIONS_CACHE_TTL_SECONDS
*/
export function buildNotificationPreferencesQueryResolver(deps: {
msgClient: MsgClient;
}) {
return async (
_parent: unknown,
_args: unknown,
ctx: GraphqlContext,
): Promise<NotificationPreferencesType> => {
const parentId = ctx.session.parentId;
const cacheKey = CacheKeys.notificationPrefs(parentId);
const result = await withCacheFallback(
cacheKey,
() => deps.msgClient.getNotificationPreferences(parentId),
env.PERMISSIONS_CACHE_TTL_SECONDS,
"notification-prefs",
);
const dto = result.data;
if (!dto) {
// 下游失败且无缓存,返回默认偏好
return {
channels: ["APP"],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
};
}
return mapPreferences(dto);
};
}
/**
* updateNotificationPreferences Mutation resolver。
*
* Zod 校验输入 → 调 msg.UpdateNotificationPreferences → 失效缓存 → 返回新偏好。
*/
export function buildUpdateNotificationPreferencesMutationResolver(deps: {
msgClient: MsgClient;
}) {
return async (
_parent: unknown,
args: { input: UpdateNotificationPreferencesInput },
ctx: GraphqlContext,
): Promise<NotificationPreferencesType> => {
const parentId = ctx.session.parentId;
// Zod 校验
const parsed = UpdateNotificationPreferencesSchema.safeParse(args.input);
if (!parsed.success) {
throw new Error(
`Invalid notification preferences input: ${JSON.stringify(
parsed.error.flatten(),
)}`,
);
}
// 获取当前偏好(合并未传的 eventTypes
const current = await deps.msgClient.getNotificationPreferences(parentId);
const merged: NotificationPreferencesDto = {
parentId,
channels: parsed.data.channels,
eventTypes: {
gradeReleased: parsed.data.eventTypes.gradeReleased ?? current.eventTypes.gradeReleased,
homeworkGraded: parsed.data.eventTypes.homeworkGraded ?? current.eventTypes.homeworkGraded,
examPublished: parsed.data.eventTypes.examPublished ?? current.eventTypes.examPublished,
attendanceAlert: parsed.data.eventTypes.attendanceAlert ?? current.eventTypes.attendanceAlert,
schoolAnnouncement: parsed.data.eventTypes.schoolAnnouncement ?? current.eventTypes.schoolAnnouncement,
},
};
const updated = await deps.msgClient.updateNotificationPreferences(
parentId,
merged,
);
// 失效偏好缓存
await invalidateCache(CacheKeys.notificationPrefs(parentId));
logger.info(
{ parentId, channels: updated.channels },
"Notification preferences updated",
);
return mapPreferences(updated);
};
}

View File

@@ -0,0 +1,125 @@
import type { MsgClient } from "../../clients/msg.client.js";
import type { NotificationDto } from "../../clients/dtos.js";
import type { NotificationItem, NotificationType } from "../types.js";
import type { GraphqlContext } from "../context.js";
import { CacheKeys } from "../../shared/cache/cache-key.builder.js";
import {
withCacheFallback,
invalidateCache,
} from "../../aggregation/fallback-strategy.js";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
const NOTIFICATION_TYPE_MAP: Record<string, NotificationType> = {
SYSTEM: "SYSTEM",
EXAM: "EXAM",
HOMEWORK: "HOMEWORK",
GRADE: "GRADE",
ATTENDANCE: "ATTENDANCE",
ANNOUNCEMENT: "ANNOUNCEMENT",
};
/**
* DTO → GraphQL Type 映射。
*
* createdAt: proto int64epoch ms→ ISO 8601 字符串DateTime scalar
*/
function mapNotification(dto: NotificationDto): NotificationItem {
return {
id: dto.id,
type: NOTIFICATION_TYPE_MAP[dto.type] ?? "SYSTEM",
title: dto.title,
content: dto.content,
read: dto.isRead,
childId: dto.childId ?? null,
createdAt: new Date(dto.createdAt).toISOString(),
};
}
const MAX_NOTIFICATION_PAGE_SIZE = 50;
/**
* notifications Query resolver。
*
* 支持分页 + unreadOnly 过滤Redis 缓存 15s。
*/
export function buildNotificationsQueryResolver(deps: {
msgClient: MsgClient;
}) {
return async (
_parent: unknown,
args: { unreadOnly?: boolean; page?: number; pageSize?: number },
ctx: GraphqlContext,
): Promise<NotificationItem[]> => {
const parentId = ctx.session.parentId;
const unreadOnly = args.unreadOnly ?? false;
const page = Math.max(1, args.page ?? 1);
const pageSize = Math.min(
MAX_NOTIFICATION_PAGE_SIZE,
Math.max(1, args.pageSize ?? 20),
);
const cacheKey = CacheKeys.notifications(parentId, page);
const result = await withCacheFallback(
cacheKey,
() => deps.msgClient.listNotifications(parentId, unreadOnly),
env.GRADES_CACHE_TTL_SECONDS,
"notifications",
);
const dtos = result.data ?? [];
let items = dtos.map(mapNotification);
// 二次过滤unreadOnly 在缓存层可能不精确)
if (unreadOnly) {
items = items.filter((n) => !n.read);
}
// 分页
const start = (page - 1) * pageSize;
return items.slice(start, start + pageSize);
};
}
/**
* markNotificationRead Mutation resolver。
*
* 调用 msg.MarkAsRead 后失效通知缓存。
*/
export function buildMarkNotificationReadMutationResolver(deps: {
msgClient: MsgClient;
}) {
return async (
_parent: unknown,
args: { notificationId: string },
ctx: GraphqlContext,
): Promise<NotificationItem> => {
const parentId = ctx.session.parentId;
await deps.msgClient.markAsRead(args.notificationId);
// 失效通知缓存(所有页)
await invalidateCache(CacheKeys.notifications(parentId, 1));
logger.info(
{ parentId, notificationId: args.notificationId },
"Notification marked as read",
);
// 返回更新后的通知(从缓存或下游重新获取)
const all = await deps.msgClient.listNotifications(parentId, false);
const updated = all.find((n) => n.id === args.notificationId);
if (!updated) {
return {
id: args.notificationId,
type: "SYSTEM",
title: "",
content: "",
read: true,
childId: null,
createdAt: new Date().toISOString(),
};
}
return mapNotification(updated);
};
}

View File

@@ -0,0 +1,46 @@
import type { GraphqlContext } from "../context.js";
import type { SelectChildResultType } from "../types.js";
import type { ResolverDeps } from "./index.js";
import { logger } from "../../shared/observability/logger.js";
/**
* SelectChild Mutation Resolver对齐 02-architecture-design.md §9 #3 + workline P4.5)。
*
* 方案 A02 §2.2BFF 无状态selectChild 仅审计日志,不持久化会话。
*
* - 先 ChildGuard 校验 childId ∈ 家长绑定列表
* - 记录审计日志traceId + parentId + childId + timestamp
* - 返回 SelectChildResultTypechildId + selectedAt + audited=true
*
* 前端切换孩子不需要调此 mutation仅审计场景调用。
*/
export function buildSelectChildMutationResolver(deps: ResolverDeps) {
return async (
_parent: unknown,
args: { childId: string },
ctx: GraphqlContext,
): Promise<SelectChildResultType> => {
await deps.childGuard.validateChildAccess(
ctx.session.parentId,
args.childId,
);
const selectedAt = new Date().toISOString();
logger.info(
{
parentId: ctx.session.parentId,
childId: args.childId,
traceId: ctx.session.traceId,
selectedAt,
},
"Child selected (audit log)",
);
return {
childId: args.childId,
selectedAt,
audited: true,
};
};
}

View File

@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { makeExecutableSchema } from "@graphql-tools/schema";
import type { IResolvers } from "@graphql-tools/utils";
/**
* 加载 GraphQL SDL 契约文件(单一源)。
*
* 文件位置packages/shared-ts/contracts/graphql/parent-bff.graphql
* 该文件是 parent-bff GraphQL 端点的契约唯一源,前后端共享。
*/
const __dirname = dirname(fileURLToPath(import.meta.url));
const SDL_PATH = resolve(
__dirname,
"../../../../packages/shared-ts/contracts/graphql/parent-bff.graphql",
);
const typeDefs = readFileSync(SDL_PATH, "utf-8");
/**
* 构建 GraphQLSchematypeDefs + resolvers
*
* P4.5resolvers 由 buildResolvers(deps) 生成,注入下游客户端 + ChildGuard。
* 每次 YOGA_INSTANCE factory 调用时构建一次(应用生命周期内复用)。
*/
export function buildSchema(resolvers: IResolvers) {
return makeExecutableSchema({
typeDefs,
resolvers,
});
}

View File

@@ -0,0 +1,182 @@
/**
* parent-bff GraphQL 类型定义(对齐 02-architecture-design.md §4.2 + parent-bff.graphql SDL
*
* 这些 TypeScript 接口用于 Resolver 返回值类型校验,与 GraphQL Schema 字段一一对应。
* 字段映射规则见 02 §3.3proto message → BFF DTO 映射)。
*/
export type DataScope =
| "SELF"
| "CHILDREN"
| "CLASS"
| "GRADE"
| "SCHOOL"
| "DISTRICT"
| "ALL";
export interface ParentType {
id: string;
email: string;
name: string;
avatar?: string | null;
roles: string[];
dataScope: DataScope;
}
export interface ViewportItemType {
key: string;
label: string;
route: string;
icon?: string | null;
sortOrder: string;
requiredPermission?: string | null;
}
export interface ClassInfoType {
id: string;
name: string;
gradeId: string;
}
export interface GradeType {
id: string;
examId: string;
examTitle: string;
subject: string;
score: number;
rank?: number | null;
gradedAt: string;
}
export interface PaginationType {
page: number;
pageSize: number;
total: number;
}
export interface GradePageType {
items: GradeType[];
pagination: PaginationType;
}
export type HomeworkStatus =
| "NOT_SUBMITTED"
| "SUBMITTED"
| "GRADED"
| "OVERDUE";
export interface HomeworkType {
id: string;
title: string;
classId: string;
dueDate: string;
status: HomeworkStatus;
submittedAt?: string | null;
}
export type ExamStatus =
| "DRAFT"
| "PUBLISHED"
| "IN_PROGRESS"
| "GRADING"
| "SCORED"
| "ARCHIVED";
export interface ExamType {
id: string;
title: string;
classId: string;
status: ExamStatus;
examDate: string;
publishedAt?: string | null;
}
export interface WeaknessTopicType {
knowledgePointId: string;
name: string;
subject: string;
masteryRate: number;
}
export interface TrendPointType {
date: string;
score: number;
subject?: string | null;
}
export interface ChildAnalyticsType {
childId: string;
weakness: WeaknessTopicType[];
trend: TrendPointType[];
classRank?: number | null;
classAverage?: number | null;
}
export interface ChildType {
id: string;
name: string;
grade: string;
class: ClassInfoType;
lastGrade?: GradeType | null;
grades?: GradePageType;
homework?: HomeworkType[];
exams?: ExamType[];
analytics?: ChildAnalyticsType;
}
export type NotificationType =
| "SYSTEM"
| "EXAM"
| "HOMEWORK"
| "GRADE"
| "ATTENDANCE"
| "ANNOUNCEMENT";
export interface NotificationItem {
id: string;
type: NotificationType;
title: string;
content: string;
read: boolean;
childId?: string | null;
createdAt: string;
}
export type NotificationChannel = "APP" | "SMS" | "EMAIL" | "WECHAT";
export interface NotificationEventTypes {
gradeReleased: boolean;
homeworkGraded: boolean;
examPublished: boolean;
attendanceAlert: boolean;
schoolAnnouncement: boolean;
}
export interface NotificationPreferencesType {
channels: NotificationChannel[];
eventTypes: NotificationEventTypes;
}
export interface DashboardDataType {
parent: ParentType | null;
children: ChildType[];
viewports: ViewportItemType[];
unreadNotifications: number;
degraded: boolean;
}
export interface DateRangeInput {
start: string;
end: string;
}
export interface SelectChildResultType {
childId: string;
selectedAt: string;
audited: boolean;
}
export interface UpdateNotificationPreferencesInput {
channels: NotificationChannel[];
eventTypes: Partial<NotificationEventTypes>;
}

View File

@@ -0,0 +1,96 @@
import { createYoga, type Plugin } from "graphql-yoga";
import depthLimit from "graphql-depth-limit";
import {
createComplexityRule,
simpleEstimator,
} from "graphql-query-complexity";
import type { Request, Response } from "express";
import type { IResolvers } from "@graphql-tools/utils";
import { buildSchema } from "./schema.js";
import { buildSession } from "./context.js";
import type { GraphqlContext, ParentSession } from "./context.js";
import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js";
import {
graphqlRequestsTotal,
graphqlDurationSeconds,
} from "../shared/observability/metrics.js";
import type { DataLoaderFactory } from "../dataloader/dataloader.factory.js";
/**
* 校验规则插件:注入深度限制 + 复杂度限制02 §9 #5
*/
const validationRulesPlugin: Plugin = {
onValidate: ({ addValidationRule }) => {
addValidationRule(depthLimit(env.GRAPHQL_DEPTH_LIMIT));
addValidationRule(
createComplexityRule({
maximumComplexity: env.GRAPHQL_COST_LIMIT,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
onComplete: (complexity: number) => {
logger.debug({ complexity }, "GraphQL query complexity");
},
}),
);
},
};
/**
* 指标埋点插件onExecute 记录开始onExecuteDone 记录完成 + 延迟。
*/
const metricsPlugin: Plugin = {
onExecute: ({ args }) => {
const opName = args.operationName ?? "anonymous";
const startedAt = Date.now();
graphqlRequestsTotal.inc({ operation: opName, status: "started" });
return {
onExecuteDone: () => {
const elapsed = (Date.now() - startedAt) / 1000;
graphqlDurationSeconds.observe({ operation: opName }, elapsed);
graphqlRequestsTotal.inc({
operation: opName,
status: "completed",
});
},
};
},
};
/**
* 创建 GraphQL Yoga 实例(工厂模式,注入 DataLoaderFactory + Resolvers
*
* 对齐 02-architecture-design.md §1.1 + §9 #1 + §9 #5。
*
* 每次 GraphQL 请求构建 per-request context包含
* - ParentSession从 x-user-id 等头解析)
* - Loadersper-request DataLoader 实例,防 N+1
*/
export function createYogaInstance(
loaderFactory: DataLoaderFactory,
resolvers: IResolvers,
) {
const schema = buildSchema(resolvers);
return createYoga<{
req: Request;
res: Response;
session: ParentSession;
}>({
schema,
graphqlEndpoint: "/graphql",
graphiql:
env.NODE_ENV === "development" && env.GRAPHQL_INTROSPECTION_ENABLED,
context: ({ req }): GraphqlContext => {
const session = buildSession(req);
const loaders = loaderFactory.createLoaders();
return { session, req, res: req.res as Response, loaders };
},
plugins: [validationRulesPlugin, metricsPlugin],
});
}
/** Yoga 实例类型(供 GraphqlModule provider 类型推导) */
export type YogaInstance = ReturnType<typeof createYogaInstance>;

View File

@@ -0,0 +1,62 @@
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 { logger } from "./shared/observability/logger.js";
import { env } from "./config/env.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import { closeRedisClient } from "./shared/cache/redis.client.js";
import type { Request, Response } from "express";
/**
* parent-bff 启动入口(对齐 02-architecture-design.md §6.7 优雅关闭)。
*
* 启动顺序:
* 1. initTracerOTel SDK可选未配置 endpoint 时跳过)
* 2. NestFactory.create + GlobalErrorFilter
* 3. /metrics 端点Prometheus 抓取)
* 4. app.listen(3010)
*
* SIGTERM 关闭顺序P4.1 基础版P5+ 补充 Kafka consumer
* 1. app.close()(拒绝新请求 + 等待 in-flight
* 2. shutdownTracer()flush OTel span
* 3. process.exit(0)(由 NestJS enableShutdownHooks 触发)
*/
async function bootstrap(): Promise<void> {
initTracer();
const app = await NestFactory.create(AppModule, {
logger: ["log", "error", "warn"],
});
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
// CORS默认允许 parent-portallocalhost:4002
app.enableCors({
origin: env.CORS_ORIGINS.split(",").map((o) => o.trim()),
credentials: true,
});
await app.listen(env.PORT);
logger.info({ port: env.PORT, devMode: env.DEV_MODE }, "Parent BFF started");
process.on("SIGTERM", async () => {
logger.info("Parent BFF received SIGTERM, shutting down...");
await app.close();
await closeRedisClient();
await shutdownTracer();
});
}
bootstrap().catch((err: unknown) => {
logger.error({ err }, "Failed to start Parent BFF");
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
/**
* 通知偏好更新输入 Zod 校验(对齐 02-architecture-design.md §4.3)。
*
* - channels通知渠道数组APP/SMS/EMAIL/WECHAT至少 1 个
* - eventTypes5 个布尔开关,可选更新(未传保留原值)
*/
export const NotificationChannelSchema = z.enum([
"APP",
"SMS",
"EMAIL",
"WECHAT",
]);
export const NotificationEventTypesInputSchema = z.object({
gradeReleased: z.boolean().optional(),
homeworkGraded: z.boolean().optional(),
examPublished: z.boolean().optional(),
attendanceAlert: z.boolean().optional(),
schoolAnnouncement: z.boolean().optional(),
});
export const UpdateNotificationPreferencesSchema = z.object({
channels: z.array(NotificationChannelSchema).min(1, "至少选择一个通知渠道"),
eventTypes: NotificationEventTypesInputSchema,
});
export type UpdateNotificationPreferencesInput = z.infer<
typeof UpdateNotificationPreferencesSchema
>;

View File

@@ -0,0 +1,43 @@
/**
* 缓存 Key 构建器(对齐 02-architecture-design.md §3.1.1)。
*
* 所有 key 以 env.REDIS_KEY_PREFIX默认 bff:parent:)为前缀。
* ioredis keyPrefix 选项会自动添加前缀,此处仅需构建后缀部分。
*/
export const CacheKeys = {
/** 家长 Dashboard 聚合 (15s TTL) */
dashboard: (parentId: string): string => `dashboard:${parentId}`,
/** 孩子列表 (60s TTL) */
children: (parentId: string): string => `children:${parentId}`,
/** 孩子成绩列表 (30s TTL) */
grades: (childId: string, page: number): string =>
`grades:${childId}:${page}`,
/** 孩子作业列表 (30s TTL) */
homework: (childId: string, classId: string): string =>
`homework:${childId}:${classId}`,
/** 孩子考试列表 (30s TTL) */
exams: (childId: string, classId: string): string =>
`exams:${childId}:${classId}`,
/** 孩子学情诊断 (300s TTL) */
weakness: (childId: string): string => `analytics:weakness:${childId}`,
/** 孩子学习趋势 (600s TTL) */
trend: (childId: string, range: string): string =>
`analytics:trend:${childId}:${range}`,
/** 通知列表 (15s TTL) */
notifications: (parentId: string, page: number): string =>
`notifications:${parentId}:${page}`,
/** 通知偏好 (300s TTL) */
notificationPrefs: (parentId: string): string =>
`notification-prefs:${parentId}`,
/** ChildGuard 绑定列表缓存 (30s TTL) */
childBindings: (parentId: string): string => `child-bindings:${parentId}`,
} as const;

View File

@@ -0,0 +1,56 @@
/**
* 简单 LRU 缓存(对齐 02-architecture-design.md §3.1 降级策略)。
*
* 当 Redis 不可用时,降级为内存 LRU 缓存。
* 不持久化、不跨进程共享,仅作 Redis 故障时的兜底。
*
* 实现Map 保持插入顺序,访问时删除重插实现 LRU 语义。
*/
export class LruCache {
private readonly store = new Map<string, { value: string; expiresAt: number }>();
private readonly maxSize: number;
constructor(maxSize = 100) {
this.maxSize = maxSize;
}
get(key: string): string | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
// LRU: 删除重插,移到末尾(最近使用)
this.store.delete(key);
this.store.set(key, entry);
return entry.value;
}
set(key: string, value: string, ttlSeconds: number): void {
// 容量满时淘汰最老条目Map 迭代顺序为插入顺序)
if (this.store.size >= this.maxSize) {
const oldestKey = this.store.keys().next().value;
if (oldestKey !== undefined) {
this.store.delete(oldestKey);
}
}
this.store.set(key, {
value,
expiresAt: Date.now() + ttlSeconds * 1000,
});
}
delete(key: string): void {
this.store.delete(key);
}
clear(): void {
this.store.clear();
}
get size(): number {
return this.store.size;
}
}

View File

@@ -0,0 +1,78 @@
import { Redis } from "ioredis";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
/**
* Redis 客户端(对齐 02-architecture-design.md §3.1)。
*
* 用于:
* - ChildGuard 绑定列表缓存30s TTL
* - 聚合结果缓存15-600s TTLP4.6
*
* 连接策略:
* - 懒连接(首次命令时连接)
* - 连接失败时记录警告,不阻塞服务启动(降级为直连下游)
* - SIGTERM 时优雅关闭
*/
let redisClient: Redis | null = null;
let connectionAttempted = false;
export function getRedisClient(): Redis | null {
if (!connectionAttempted) {
connectionAttempted = true;
try {
redisClient = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 2,
enableReadyCheck: true,
lazyConnect: true,
keyPrefix: env.REDIS_KEY_PREFIX,
});
redisClient.on("error", (err) => {
logger.warn({ err, url: env.REDIS_URL }, "Redis connection error");
});
redisClient.on("connect", () => {
logger.info({ url: env.REDIS_URL }, "Redis connected");
});
redisClient.on("close", () => {
logger.warn("Redis connection closed");
});
} catch (err) {
logger.warn({ err }, "Failed to create Redis client, caching disabled");
redisClient = null;
}
}
return redisClient;
}
/**
* 安全执行 Redis 操作,失败时返回 null降级策略
*/
export async function safeRedis<T>(
fn: (client: Redis) => Promise<T>,
fallback: T,
): Promise<T> {
const client = getRedisClient();
if (!client) {
return fallback;
}
try {
return await fn(client);
} catch (err) {
logger.warn({ err }, "Redis operation failed, using fallback");
return fallback;
}
}
/**
* 优雅关闭 Redis 连接。
*/
export async function closeRedisClient(): Promise<void> {
if (redisClient) {
await redisClient.quit();
redisClient = null;
connectionAttempted = false;
}
}

View File

@@ -0,0 +1,164 @@
/**
* parent-bff 统一错误体系(对齐 02-architecture-design.md §6.2)。
*
* 错误码前缀统一 BFF_PARENT_*C1 仲裁BFF 在前,不是 PARENT_BFF_
*
* 错误码清单:
* - BFF_PARENT_VALIDATION_ERROR (400)
* - BFF_PARENT_UNAUTHORIZED (401)
* - BFF_PARENT_CHILD_NOT_BOUND (403) — ChildGuard 拦截
* - BFF_PARENT_NOT_FOUND (404)
* - BFF_PARENT_CONFLICT (409)
* - BFF_PARENT_BUSINESS_ERROR (422)
* - BFF_PARENT_BAD_GATEWAY (502)
* - BFF_PARENT_GATEWAY_TIMEOUT (504)
* - BFF_PARENT_SERVICE_UNAVAILABLE (503) — 熔断器开启P6
* - BFF_PARENT_INTERNAL_ERROR (500)
*/
export type ErrorType =
| "validation"
| "unauthorized"
| "child_not_bound"
| "not_found"
| "conflict"
| "business"
| "bad_gateway"
| "gateway_timeout"
| "service_unavailable"
| "internal";
export interface ErrorDetails {
[key: string]: unknown;
}
export abstract class ApplicationError extends Error {
abstract readonly type: ErrorType;
abstract readonly statusCode: number;
readonly code: string;
readonly details?: ErrorDetails;
traceId?: string;
constructor(message: string, code: string, details?: ErrorDetails) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.details = details;
}
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
details: this.details,
traceId: this.traceId,
},
};
}
}
export class ValidationError extends ApplicationError {
readonly type = "validation" as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_PARENT_VALIDATION_ERROR", details);
}
}
export class UnauthorizedError extends ApplicationError {
readonly type = "unauthorized" as const;
readonly statusCode = 401;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_PARENT_UNAUTHORIZED", details);
}
}
/**
* ChildGuard 越权拦截childId 不在家长绑定列表。
*
* 对齐 02 §2.3DataScope=CHILDREN 防御BFF 层先拦截减少无效下游调用。
*/
export class ChildNotBoundError extends ApplicationError {
readonly type = "child_not_bound" as const;
readonly statusCode = 403;
constructor(parentId: string, requestedChildId: string, boundChildren: string[]) {
super(
`Child ${requestedChildId} not bound to parent ${parentId}`,
"BFF_PARENT_CHILD_NOT_BOUND",
{
parentId,
requestedChildId,
boundChildren,
},
);
}
}
export class NotFoundError extends ApplicationError {
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "BFF_PARENT_NOT_FOUND", {
resource,
id,
});
}
}
export class ConflictError extends ApplicationError {
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_PARENT_CONFLICT", details);
}
}
export class BusinessError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_PARENT_BUSINESS_ERROR", details);
}
}
/**
* 下游服务返回非 ok 或 gRPC rejected。
*/
export class BadGatewayError extends ApplicationError {
readonly type = "bad_gateway" as const;
readonly statusCode = 502;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_PARENT_BAD_GATEWAY", details);
}
}
/**
* 下游调用超时。
*/
export class GatewayTimeoutError extends ApplicationError {
readonly type = "gateway_timeout" as const;
readonly statusCode = 504;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_PARENT_GATEWAY_TIMEOUT", details);
}
}
/**
* 熔断器开启P6 opossum 熔断器触发)。
*/
export class ServiceUnavailableError extends ApplicationError {
readonly type = "service_unavailable" as const;
readonly statusCode = 503;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_PARENT_SERVICE_UNAVAILABLE", details);
}
}
export class InternalError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_PARENT_INTERNAL_ERROR", details);
}
}

View File

@@ -0,0 +1,97 @@
import {
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
/**
* 全局错误过滤器(对齐 02-architecture-design.md §6.9)。
*
* 错误处理顺序:
* 1. ApplicationError → 按 statusCode + code 返回 ActionState
* 2. ZodError → 400 + BFF_PARENT_VALIDATION_ERROR
* 3. HttpException → 透传 NestJS 异常
* 4. 未知 Error → 500 + BFF_PARENT_INTERNAL_ERROR + traceId
*
* traceId 来源x-request-id 头或新生成。
*/
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof ZodError) {
statusCode = 400;
body = {
success: false,
error: {
code: "BFF_PARENT_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: "HTTP_ERROR",
message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "BFF_PARENT_INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
}
response.status(statusCode).json(body);
}
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === "object" && "message" in res) {
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}
}

View File

@@ -0,0 +1,151 @@
import { Controller, Get, HttpCode, HttpStatus, Inject } from "@nestjs/common";
import type { IamClient } from "../../clients/iam.client.js";
import type { CoreEduClient } from "../../clients/core-edu.client.js";
import type { DataAnaClient } from "../../clients/data-ana.client.js";
import { safeRedis } from "../cache/redis.client.js";
import { logger } from "../observability/logger.js";
const SERVICE_NAME = "parent-bff";
const PROBE_TIMEOUT_MS = 1000;
interface ProbeResult {
status: "up" | "down";
latency_ms: number;
error?: string;
}
interface ReadyzResponse {
status: "ready" | "degraded";
service: string;
timestamp: string;
checks: Record<string, ProbeResult>;
}
/**
* 健康检查端点(对齐 02-architecture-design.md §6.6 + workline P4.7)。
*
* - GET /healthzliveness仅返回进程存活不检查下游
* - GET /readyzreadiness探针 4 项依赖:
* iam gRPC + core-edu gRPC + data-ana gRPC + Redis超时 1s/服务。
* 任一失败返回 503 + status=degraded。
*
* 不需要鉴权,必须在路由白名单中放行。
*/
@Controller()
export class HealthController {
constructor(
@Inject("IAM_CLIENT") private readonly iamClient: IamClient,
@Inject("CORE_EDU_CLIENT") private readonly coreEduClient: CoreEduClient,
@Inject("DATA_ANA_CLIENT") private readonly dataAnaClient: DataAnaClient,
) {}
@Get("healthz")
liveness(): { status: string; service: string; timestamp: string } {
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}
@Get("readyz")
@HttpCode(HttpStatus.OK)
async readiness(): Promise<ReadyzResponse> {
const checks: Record<string, ProbeResult> = {};
// 并行探针 4 项依赖
const [iam, coreEdu, dataAna, redis] = await Promise.all([
this.probeIam(),
this.probeCoreEdu(),
this.probeDataAna(),
this.probeRedis(),
]);
checks["iam"] = iam;
checks["core-edu"] = coreEdu;
checks["data-ana"] = dataAna;
checks["redis"] = redis;
const allUp = Object.values(checks).every((c) => c.status === "up");
return {
status: allUp ? "ready" : "degraded",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
checks,
};
}
private async probeIam(): Promise<ProbeResult> {
return this.withTimeout("iam", async () => {
await this.iamClient.getUserInfo("healthcheck");
});
}
private async probeCoreEdu(): Promise<ProbeResult> {
return this.withTimeout("core-edu", async () => {
await this.coreEduClient.listGradesByStudent("healthcheck");
});
}
private async probeDataAna(): Promise<ProbeResult> {
return this.withTimeout("data-ana", async () => {
await this.dataAnaClient.getStudentWeakness("healthcheck", "");
});
}
private async probeRedis(): Promise<ProbeResult> {
const startedAt = Date.now();
try {
const result = await Promise.race([
safeRedis((client) => client.ping(), "PONG"),
this.timeoutPromise(),
]);
if (result !== "PONG") {
return {
status: "down",
latency_ms: Date.now() - startedAt,
error: `unexpected ping response: ${result}`,
};
}
return { status: "up", latency_ms: Date.now() - startedAt };
} catch (err) {
return {
status: "down",
latency_ms: Date.now() - startedAt,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* 通用探针:执行 fn 并在 PROBE_TIMEOUT_MS 内完成。
*/
private async withTimeout(
name: string,
fn: () => Promise<unknown>,
): Promise<ProbeResult> {
const startedAt = Date.now();
try {
await Promise.race([fn(), this.timeoutPromise()]);
return { status: "up", latency_ms: Date.now() - startedAt };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
logger.warn({ probe: name, err }, "Ready probe failed");
return {
status: "down",
latency_ms: Date.now() - startedAt,
error,
};
}
}
private timeoutPromise(): Promise<never> {
return new Promise((_, reject) =>
setTimeout(
() => reject(new Error(`timeout after ${PROBE_TIMEOUT_MS}ms`)),
PROBE_TIMEOUT_MS,
),
);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { ClientsModule } from "../../clients/clients.module.js";
import { HealthController } from "./health.controller.js";
/**
* 健康检查模块。
*
* HealthController 注入 IAM_CLIENT / CORE_EDU_CLIENT / DATA_ANA_CLIENT
* 用于 /readyz 下游探针,因此必须 imports ClientsModule。
*/
@Module({
imports: [ClientsModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,74 @@
import type { EventHandler, EventContext } from "./event-handler.js";
import { invalidateCache } from "../../../aggregation/fallback-strategy.js";
import { CacheKeys } from "../../cache/cache-key.builder.js";
import { logger } from "../../observability/logger.js";
/**
* 缓存失效事件处理器(对齐 02-architecture-design.md §5.2 + workline P5.4)。
*
* 处理教学事件(成绩/作业/考试),失效对应缓存:
*
* | Topic | 失效缓存 |
* |--------------------------------|---------------------------------------|
* | edu.notification.read | bff:parent:notifications:* |
* | edu.notification.recalled | bff:parent:notifications:* |
* | edu.teaching.grade.recorded | bff:parent:grades:{childId}:* + dashboard |
* | edu.teaching.homework.graded | bff:parent:homework:{childId}:* + dashboard |
* | edu.teaching.exam.published | bff:parent:exams:{childId}:* + dashboard |
*/
interface TeachingEventBody {
studentId?: string;
childId?: string;
classId?: string;
parentId?: string;
}
export class CacheInvalidationHandler implements EventHandler<TeachingEventBody> {
async handle(ctx: EventContext<TeachingEventBody>): Promise<void> {
const { topic, body } = ctx;
const childId = body.childId ?? body.studentId;
const parentId = body.parentId;
logger.info({ topic, childId, parentId }, "Processing cache invalidation event");
switch (topic) {
case "edu.notification.read":
case "edu.notification.recalled":
if (parentId) {
await invalidateCache(CacheKeys.notifications(parentId, 1));
}
break;
case "edu.teaching.grade.recorded":
if (childId) {
await invalidateCache(CacheKeys.grades(childId, 1));
}
if (parentId) {
await invalidateCache(CacheKeys.dashboard(parentId));
}
break;
case "edu.teaching.homework.graded":
if (childId && body.classId) {
await invalidateCache(CacheKeys.homework(childId, body.classId));
}
if (parentId) {
await invalidateCache(CacheKeys.dashboard(parentId));
}
break;
case "edu.teaching.exam.published":
if (childId && body.classId) {
await invalidateCache(CacheKeys.exams(childId, body.classId));
}
if (parentId) {
await invalidateCache(CacheKeys.dashboard(parentId));
}
break;
default:
logger.warn({ topic }, "CacheInvalidationHandler: unknown topic");
}
}
}

View File

@@ -0,0 +1,15 @@
/**
* Kafka 事件处理器接口(对齐 02-architecture-design.md §5.3)。
*
* 每个 topic 对应一个 EventHandler 实现。
*/
export interface EventContext<T = unknown> {
topic: string;
eventId: string;
key: string;
body: T;
}
export interface EventHandler<T = unknown> {
handle(ctx: EventContext<T>): Promise<void>;
}

View File

@@ -0,0 +1,153 @@
import type { EventHandler, EventContext } from "./event-handler.js";
import type { MsgClient } from "../../../clients/msg.client.js";
import type { PushHttpClient } from "../../../clients/http/push-http.client.js";
import type { NotificationPreferencesDto } from "../../../clients/dtos.js";
import { CacheKeys } from "../../cache/cache-key.builder.js";
import { withCacheFallback } from "../../../aggregation/fallback-strategy.js";
import { env } from "../../../config/env.js";
import { logger } from "../../observability/logger.js";
/**
* 通知推送事件处理器P5.4 + P5.5,对齐 02-architecture-design.md §5.3 + §5.4)。
*
* 处理 edu.notification.sent + edu.teaching.* 事件,
* 根据家长 NotificationPreferences 过滤后通过 push-gateway HTTP 推送。
*
* 通知偏好过滤逻辑§5.4
* 1. 拉取家长 NotificationPreferencesRedis 缓存 300s
* 2. 按 eventTypeMap 映射事件类型 → 偏好开关
* 3. 若偏好开关为 false → 不推送
* 4. 取 prefs.channels 与 event.channels 交集,无交集则不推送
* 5. 调用 pushClient.pushViaHttp(parentId, payload, allowedChannels)
*/
interface NotificationEventBody {
parentId: string;
childId?: string;
type: string;
title: string;
content: string;
channels?: string[];
}
const EVENT_TYPE_MAP: Record<string, keyof NotificationPreferencesDto["eventTypes"]> = {
GRADE: "gradeReleased",
HOMEWORK: "homeworkGraded",
EXAM: "examPublished",
ATTENDANCE: "attendanceAlert",
ANNOUNCEMENT: "schoolAnnouncement",
};
const TEACHING_TOPIC_TYPE_MAP: Record<string, string> = {
"edu.teaching.grade.recorded": "GRADE",
"edu.teaching.homework.graded": "HOMEWORK",
"edu.teaching.exam.published": "EXAM",
};
export class NotificationPushHandler implements EventHandler<NotificationEventBody> {
constructor(
private readonly msgClient: MsgClient,
private readonly pushClient: PushHttpClient,
) {}
async handle(ctx: EventContext<NotificationEventBody>): Promise<void> {
const { topic, body } = ctx;
// 教学事件映射为通知类型
const notificationType =
topic === "edu.notification.sent"
? body.type
: TEACHING_TOPIC_TYPE_MAP[topic] ?? "SYSTEM";
// 教学事件构造通知内容
const title = body.title || this.getDefaultTitle(notificationType);
const content = body.content || "";
// 拉取家长通知偏好Redis 缓存 300s
const prefs = await this.getPreferences(body.parentId);
// 偏好开关检查
const prefKey = EVENT_TYPE_MAP[notificationType];
if (prefKey && !prefs.eventTypes[prefKey]) {
logger.info(
{ parentId: body.parentId, type: notificationType, prefKey },
"Notification skipped: preference disabled",
);
return;
}
// 渠道交集检查
const eventChannels = body.channels ?? ["APP"];
const allowedChannels = prefs.channels.filter((c) => eventChannels.includes(c));
if (allowedChannels.length === 0) {
logger.info(
{ parentId: body.parentId, eventChannels, prefChannels: prefs.channels },
"Notification skipped: no channel intersection",
);
return;
}
// 调用 push-gateway HTTP 推送
const result = await this.pushClient.pushViaHttp({
parentId: body.parentId,
title,
content,
channels: allowedChannels,
childId: body.childId,
});
if (!result.success) {
logger.error(
{ parentId: body.parentId, error: result.error },
"Push-gateway delivery failed",
);
} else {
logger.info(
{ parentId: body.parentId, messageId: result.messageId, channels: allowedChannels },
"Notification pushed via HTTP",
);
}
}
private async getPreferences(
parentId: string,
): Promise<NotificationPreferencesDto> {
const cacheKey = CacheKeys.notificationPrefs(parentId);
const result = await withCacheFallback(
cacheKey,
() => this.msgClient.getNotificationPreferences(parentId),
env.PERMISSIONS_CACHE_TTL_SECONDS,
"notification-prefs",
);
if (result.data) {
return result.data;
}
// 下游失败且无缓存,返回默认偏好(全部开启)
return {
parentId,
channels: ["APP"],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
};
}
private getDefaultTitle(type: string): string {
const titles: Record<string, string> = {
GRADE: "成绩发布通知",
HOMEWORK: "作业批改完成",
EXAM: "考试发布通知",
ATTENDANCE: "考勤提醒",
ANNOUNCEMENT: "学校公告",
SYSTEM: "系统通知",
};
return titles[type] ?? "新通知";
}
}

View File

@@ -0,0 +1,224 @@
import { Kafka, type Consumer, type EachMessagePayload } from "kafkajs";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
import { safeRedis } from "../cache/redis.client.js";
import { eventConsumedTotal } from "../observability/metrics.js";
import type { EventHandler } from "./handlers/event-handler.js";
/**
* Kafka 消费者(对齐 02-architecture-design.md §5.3 + workline P5.4)。
*
* - consumer group: parent-bff-event-subscriber
* - 订阅 7 个 topicedu.notification.* + edu.teaching.*
* - 幂等性Redis SETNX event_id 去重
* - DLQedu.parent-bff.dlq
* - 手动 commit offset
*
* KAFKA_BROKERS 未配置时不启动消费者P4 阶段)。
*/
const IDEMPOTENCY_KEY_PREFIX = "bff:parent:event-id:";
const IDEMPOTENCY_TTL_SECONDS = 86400; // 24h
const DLQ_TOPIC = "edu.parent-bff.dlq";
const MAX_RETRIES = 3;
const SUBSCRIBED_TOPICS = [
"edu.notification.sent",
"edu.notification.read",
"edu.notification.recalled",
"edu.notification.failed",
"edu.teaching.grade.recorded",
"edu.teaching.homework.graded",
"edu.teaching.exam.published",
];
export class KafkaEventConsumer {
private consumer: Consumer | null = null;
private readonly handlers = new Map<string, EventHandler[]>();
constructor(
private readonly kafka: Kafka,
private readonly groupId: string,
) {}
registerHandler(topic: string, handler: EventHandler): void {
const existing = this.handlers.get(topic) ?? [];
existing.push(handler);
this.handlers.set(topic, existing);
logger.info({ topic, groupId: this.groupId, handlerCount: existing.length }, "Kafka handler registered");
}
async start(): Promise<void> {
if (!this.handlers.size) {
logger.warn("No Kafka handlers registered, skipping consumer start");
return;
}
this.consumer = this.kafka.consumer({
groupId: this.groupId,
sessionTimeout: 30000,
rebalanceTimeout: 60000,
});
await this.consumer.connect();
await this.consumer.subscribe({
topics: SUBSCRIBED_TOPICS,
fromBeginning: false,
});
await this.consumer.run({
eachMessage: async (payload: EachMessagePayload) => {
await this.handleMessage(payload);
},
autoCommit: false,
});
logger.info(
{ topics: SUBSCRIBED_TOPICS, groupId: this.groupId },
"Kafka consumer started",
);
}
async stop(): Promise<void> {
if (this.consumer) {
await this.consumer.disconnect();
this.consumer = null;
logger.info("Kafka consumer stopped");
}
}
private async handleMessage(payload: EachMessagePayload): Promise<void> {
const { topic, partition, message } = payload;
const eventId = (message.headers?.["event_id"] as string) ?? message.key?.toString() ?? "";
const key = message.key?.toString() ?? "";
if (!eventId) {
logger.warn({ topic, partition, offset: message.offset }, "Event missing event_id, skipping");
return;
}
// 幂等性检查Redis SETNX
const idempotencyKey = `${IDEMPOTENCY_KEY_PREFIX}${eventId}`;
const isDuplicate = await this.checkIdempotency(idempotencyKey);
if (isDuplicate) {
logger.debug({ eventId, topic }, "Duplicate event skipped (idempotency)");
await this.consumer?.commitOffsets([
{ topic, partition, offset: (Number(message.offset) + 1).toString() },
]);
return;
}
eventConsumedTotal.inc({ topic, event_type: key || "unknown" });
const handlers = this.handlers.get(topic);
if (!handlers || handlers.length === 0) {
logger.warn({ topic }, "No handler registered for topic");
await this.consumer?.commitOffsets([
{ topic, partition, offset: (Number(message.offset) + 1).toString() },
]);
return;
}
const body = JSON.parse(message.value?.toString() ?? "{}");
// 依次执行所有 handler一个失败不影响其他
let lastError: Error | null = null;
for (const handler of handlers) {
let handlerError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await handler.handle({ topic, eventId, key, body });
handlerError = null;
break;
} catch (err) {
handlerError = err instanceof Error ? err : new Error(String(err));
logger.warn(
{ topic, eventId, attempt, error: handlerError.message },
"Handler attempt failed",
);
if (attempt < MAX_RETRIES) {
await this.sleep(Math.pow(2, attempt) * 1000);
}
}
}
if (handlerError) {
lastError = handlerError;
}
}
if (lastError) {
logger.error(
{ topic, eventId, error: lastError.message },
"Handler(s) failed after all retries, sending to DLQ",
);
await this.sendToDlq(topic, message.value ?? undefined, eventId, lastError);
}
// 手动 commit
await this.consumer?.commitOffsets([
{ topic, partition, offset: (Number(message.offset) + 1).toString() },
]);
}
private async checkIdempotency(key: string): Promise<boolean> {
const result = await safeRedis(
(client) => client.set(key, "1", "EX", IDEMPOTENCY_TTL_SECONDS, "NX"),
null as string | null,
);
return result === "OK";
}
private async sendToDlq(
originalTopic: string,
originalValue: Buffer | undefined,
eventId: string,
error: Error,
): Promise<void> {
if (!this.consumer) return;
const producer = this.kafka.producer();
try {
await producer.connect();
await producer.send({
topic: DLQ_TOPIC,
messages: [
{
key: eventId,
value: JSON.stringify({
originalTopic,
eventId,
error: error.message,
timestamp: Date.now(),
originalValue: originalValue?.toString(),
}),
},
],
});
} catch (err) {
logger.error({ err, eventId }, "Failed to send message to DLQ");
} finally {
await producer.disconnect();
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
/**
* 创建 Kafka 消费者实例(仅在 KAFKA_BROKERS 配置时)。
*/
export function createKafkaConsumer(): KafkaEventConsumer | null {
if (!env.KAFKA_BROKERS) {
logger.info("KAFKA_BROKERS not configured, Kafka consumer disabled");
return null;
}
const brokers = env.KAFKA_BROKERS.split(",").map((b) => b.trim());
const kafka = new Kafka({
clientId: "parent-bff",
brokers,
});
return new KafkaEventConsumer(kafka, env.KAFKA_CONSUMER_GROUP_ID);
}

View File

@@ -0,0 +1,91 @@
import {
Module,
Inject,
type OnModuleInit,
type OnModuleDestroy,
} from "@nestjs/common";
import { ClientsModule } from "../../clients/clients.module.js";
import type { MsgClient } from "../../clients/msg.client.js";
import type { PushHttpClient } from "../../clients/http/push-http.client.js";
import { logger } from "../observability/logger.js";
import {
createKafkaConsumer,
KafkaEventConsumer,
} from "./kafka.consumer.js";
import { NotificationPushHandler } from "./handlers/notification-push.handler.js";
import { CacheInvalidationHandler } from "./handlers/cache-invalidation.handler.js";
/**
* Kafka 事件订阅模块P5对齐 02-architecture-design.md §5.3)。
*
* Handler 注册矩阵:
* | Topic | PushHandler | CacheHandler |
* |--------------------------------|:-----------:|:------------:|
* | edu.notification.sent | ✅ | — |
* | edu.notification.read | — | ✅ |
* | edu.notification.recalled | — | ✅ |
* | edu.teaching.grade.recorded | ✅ | ✅ |
* | edu.teaching.homework.graded | ✅ | ✅ |
* | edu.teaching.exam.published | ✅ | ✅ |
*
* lifecycle
* - OnModuleInitstart() 启动消费者
* - OnModuleDestroystop() 优雅关闭
*
* KAFKA_BROKERS 未配置时不启动消费者DEV_MODE 兼容)。
*/
@Module({
imports: [ClientsModule],
providers: [
{
provide: "KAFKA_CONSUMER",
useFactory: (
msgClient: MsgClient,
pushClient: PushHttpClient,
): KafkaEventConsumer | null => {
const consumer = createKafkaConsumer();
if (!consumer) return null;
const pushHandler = new NotificationPushHandler(msgClient, pushClient);
const cacheHandler = new CacheInvalidationHandler();
// 通知事件
consumer.registerHandler("edu.notification.sent", pushHandler);
consumer.registerHandler("edu.notification.read", cacheHandler);
consumer.registerHandler("edu.notification.recalled", cacheHandler);
// 教学事件(双 handler推送 + 缓存失效)
consumer.registerHandler("edu.teaching.grade.recorded", pushHandler);
consumer.registerHandler("edu.teaching.grade.recorded", cacheHandler);
consumer.registerHandler("edu.teaching.homework.graded", pushHandler);
consumer.registerHandler("edu.teaching.homework.graded", cacheHandler);
consumer.registerHandler("edu.teaching.exam.published", pushHandler);
consumer.registerHandler("edu.teaching.exam.published", cacheHandler);
return consumer;
},
inject: ["MSG_CLIENT", "PUSH_HTTP_CLIENT"],
},
],
})
export class KafkaModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject("KAFKA_CONSUMER")
private readonly consumer: KafkaEventConsumer | null,
) {}
async onModuleInit(): Promise<void> {
if (this.consumer) {
await this.consumer.start();
logger.info("KafkaModule: consumer started");
} else {
logger.info("KafkaModule: consumer disabled (KAFKA_BROKERS not set)");
}
}
async onModuleDestroy(): Promise<void> {
if (this.consumer) {
await this.consumer.stop();
}
}
}

View File

@@ -0,0 +1,27 @@
import pino from "pino";
import { env } from "../../config/env.js";
/**
* parent-bff 结构化日志pino
*
* 字段对齐 02-architecture-design.md §6.3
* time, level, service, msg, traceId, parentId, childId, endpoint, duration, err
*
* 生产环境 JSON stdout开发环境 pino-pretty 着色输出。
*/
export const logger = pino({
level: env.LOG_LEVEL,
base: {
service: "parent-bff",
version: "0.1.0",
},
transport:
env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: { colorize: true },
}
: undefined,
});
export type Logger = typeof logger;

View File

@@ -0,0 +1,107 @@
import promClient from "prom-client";
/**
* parent-bff Prometheus 指标注册中心。
*
* 指标清单对齐 02-architecture-design.md §6.411 项):
* - graphql 请求总数 / 延迟
* - 下游 gRPC 调用次数 / 延迟 / 错误数
* - 缓存命中 / 未命中
* - ChildGuard 越权拦截次数
* - 熔断器状态P6
* - 事件消费 / 推送数P5
*
* 同时导出具体 Counter / Histogram / Gauge 实例,
* 便于业务代码直接 .inc() / .observe() 调用,避免通过 registry.getSingleMetric 查找。
*/
const registry = new promClient.Registry();
registry.setDefaultLabels({ service: "parent-bff" });
// === GraphQL 指标 ===
export const graphqlRequestsTotal = new promClient.Counter({
name: "parent_bff_graphql_requests_total",
help: "Total number of parent-bff GraphQL requests",
labelNames: ["operation", "status"],
});
registry.registerMetric(graphqlRequestsTotal);
export const graphqlDurationSeconds = new promClient.Histogram({
name: "parent_bff_graphql_duration_seconds",
help: "Parent-bff GraphQL request duration in seconds",
labelNames: ["operation"],
buckets: [0.01, 0.05, 0.1, 0.2, 0.3, 0.5, 1, 3, 5],
});
registry.registerMetric(graphqlDurationSeconds);
// === 下游 gRPC 调用指标 ===
export const downstreamCallsTotal = new promClient.Counter({
name: "parent_bff_downstream_calls_total",
help: "Total number of downstream gRPC calls from parent-bff",
labelNames: ["service", "rpc", "status"],
});
registry.registerMetric(downstreamCallsTotal);
export const downstreamDurationSeconds = new promClient.Histogram({
name: "parent_bff_downstream_duration_seconds",
help: "Parent-bff downstream gRPC call duration in seconds",
labelNames: ["service", "rpc"],
buckets: [0.01, 0.05, 0.1, 0.2, 0.3, 0.5, 1, 3, 5],
});
registry.registerMetric(downstreamDurationSeconds);
export const downstreamErrorsTotal = new promClient.Counter({
name: "parent_bff_downstream_errors_total",
help: "Total number of downstream gRPC call errors from parent-bff",
labelNames: ["service", "rpc", "error_type"],
});
registry.registerMetric(downstreamErrorsTotal);
// === 缓存指标 ===
export const cacheHitsTotal = new promClient.Counter({
name: "parent_bff_cache_hits_total",
help: "Total number of parent-bff cache hits",
labelNames: ["cache_key_pattern"],
});
registry.registerMetric(cacheHitsTotal);
export const cacheMissesTotal = new promClient.Counter({
name: "parent_bff_cache_misses_total",
help: "Total number of parent-bff cache misses",
labelNames: ["cache_key_pattern"],
});
registry.registerMetric(cacheMissesTotal);
// === ChildGuard 越权拦截指标 ===
export const childGuardBlocksTotal = new promClient.Counter({
name: "parent_bff_child_guard_blocks_total",
help: "Total number of ChildGuard access denials in parent-bff",
});
registry.registerMetric(childGuardBlocksTotal);
// === 熔断器状态指标P6 启用) ===
export const circuitStateGauge = new promClient.Gauge({
name: "parent_bff_circuit_state",
help: "Parent-bff circuit breaker state per downstream service",
labelNames: ["service", "state"],
});
registry.registerMetric(circuitStateGauge);
// === Kafka 事件消费 / 推送指标P5 启用) ===
export const eventConsumedTotal = new promClient.Counter({
name: "parent_bff_event_consumed_total",
help: "Total number of Kafka events consumed by parent-bff",
labelNames: ["topic", "event_type"],
});
registry.registerMetric(eventConsumedTotal);
export const eventPushedTotal = new promClient.Counter({
name: "parent_bff_event_pushed_total",
help: "Total number of push-gateway pushes from parent-bff",
labelNames: ["topic", "push_status"],
});
registry.registerMetric(eventPushedTotal);
// 自动收集 Node.js 进程级指标CPU/内存/事件循环/GC 等)
promClient.collectDefaultMetrics({ register: registry });
export { registry as metricsRegistry };

View File

@@ -0,0 +1,41 @@
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { env } from "../../config/env.js";
/**
* parent-bff OpenTelemetry 链路追踪初始化。
*
* 对齐 02-architecture-design.md §6.5
* - serviceName: parent-bff
* - exporter: OTLP HTTP → collector
* - auto-instrumentations: http, nestjs-core, express, ioredis, @grpc/grpc-js, kafka-node
* - 采样率:生产 10%,开发 100%(通过 OTEL_TRACES_SAMPLER_ARG 控制)
*
* span 属性parentId, childId, operation, downstream.service, cache.hit, childGuard.blocked
*/
let sdk: NodeSDK | null = null;
export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({
serviceName: env.OTEL_SERVICE_NAME,
traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
console.log(
`[parent-bff] Tracer initialized with auto-instrumentations, endpoint=${env.OTEL_EXPORTER_OTLP_ENDPOINT}`,
);
}
export async function shutdownTracer(): Promise<void> {
if (sdk) {
await sdk.shutdown();
sdk = null;
}
}

View File

@@ -0,0 +1,15 @@
declare module "graphql-depth-limit" {
import type { ValidationContext, ASTVisitor } from "graphql";
interface DepthLimitOptions {
ignore?: string[];
}
function depthLimit(
maxDepth: number,
options?: DepthLimitOptions,
callback?: (depth: number) => void,
): (context: ValidationContext) => ASTVisitor;
export = depthLimit;
}

View File

@@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// 使用 vi.hoisted 确保 mockDelCalls 在 mock factory 之前定义
const mockDelCalls = vi.hoisted(() => [] as string[]);
// Mock safeRedis捕获 del 调用以验证失效的缓存 key
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn(
async (
fn: (client: unknown) => Promise<unknown>,
fallback: unknown,
): Promise<unknown> => {
const mockClient = {
del: (key: string): number => {
mockDelCalls.push(key);
return 1;
},
get: (): null => null,
set: (): string => "OK",
ping: (): string => "PONG",
};
try {
return await fn(mockClient);
} catch {
return fallback;
}
},
),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { CacheInvalidationHandler } from "../../src/shared/kafka/handlers/cache-invalidation.handler.js";
import type {
EventHandler,
EventContext,
} from "../../src/shared/kafka/handlers/event-handler.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
interface TeachingEventBody {
studentId?: string;
childId?: string;
classId?: string;
parentId?: string;
}
describe("测试用例 10Kafka 缓存失效Integration", () => {
let handler: EventHandler<TeachingEventBody>;
beforeEach(() => {
vi.clearAllMocks();
mockDelCalls.length = 0;
handler = new CacheInvalidationHandler();
});
describe("edu.teaching.grade.recorded", () => {
it("失效 grades:{childId}:* + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-001",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
classId: "class-001",
},
};
await handler.handle(ctx);
// 失效 grades:student-001:1 + dashboard:parent-001
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
it("使用 studentId 作为 childId fallback", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-002",
key: "parent-001",
body: {
studentId: "student-002",
parentId: "parent-001",
},
};
await handler.handle(ctx);
// childId 从 studentId fallback
expect(mockDelCalls).toContain("grades:student-002:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
});
it("无 parentId 时只失效 grades 缓存", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-003",
key: "student-001",
body: {
childId: "student-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).not.toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.teaching.homework.graded", () => {
it("失效 homework:{childId}:{classId} + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.homework.graded",
eventId: "evt-004",
key: "parent-001",
body: {
childId: "student-001",
classId: "class-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("homework:student-001:class-001");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
it("无 classId 时只失效 dashboard", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.homework.graded",
eventId: "evt-005",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
// 无 classId 时 homework 缓存不失效条件childId && classId
expect(mockDelCalls).not.toContain("homework:student-001:undefined");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.teaching.exam.published", () => {
it("失效 exams:{childId}:{classId} + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.exam.published",
eventId: "evt-006",
key: "parent-001",
body: {
childId: "student-001",
classId: "class-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("exams:student-001:class-001");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
});
describe("edu.notification.read", () => {
it("失效 notifications:{parentId}:1", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.notification.read",
eventId: "evt-007",
key: "parent-001",
body: {
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("notifications:parent-001:1");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.notification.recalled", () => {
it("失效 notifications:{parentId}:1", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.notification.recalled",
eventId: "evt-008",
key: "parent-001",
body: {
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("notifications:parent-001:1");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("未知 topic", () => {
it("不失效任何缓存", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.unknown.event",
eventId: "evt-009",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toHaveLength(0);
});
});
describe("P5 场景:成绩录入后缓存失效全链路", () => {
it("成绩事件同时失效 grades + dashboard前端刷新后重新拉取", async () => {
// 模拟真实场景:教师录入成绩 → Kafka → 缓存失效 → 前端下次查询走下游
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-full-chain",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
classId: "class-001",
},
};
await handler.handle(ctx);
// 验证 safeRedis 被调用了 2 次grades del + dashboard del
expect(mockSafeRedis).toHaveBeenCalledTimes(2);
// 验证失效的 key 正确
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
});
});
});

View File

@@ -0,0 +1,458 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { MsgClient } from "../../src/clients/msg.client.js";
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
import type {
ChildDto,
UserInfoDto,
ViewportDto,
NotificationDto,
StudentWeaknessDto,
LearningTrendDto,
ClassPerformanceDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制缓存行为(集成测试聚焦 resolver 编排逻辑)
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { buildDashboardResolver } from "../../src/graphql/resolvers/dashboard.resolver.js";
import { buildChildAnalyticsFieldResolver } from "../../src/graphql/resolvers/child.resolver.js";
import type { ResolverDeps } from "../../src/graphql/resolvers/index.js";
import type { GraphqlContext } from "../../src/graphql/context.js";
import type { ChildType } from "../../src/graphql/types.js";
import type { ChildGuard } from "../../src/aggregation/child-guard.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
const DELAY_MS = 50;
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/**
* 可追踪调用次数与延迟的 Mock IamClient
*/
class TrackingIamClient implements IamClient {
readonly callLog: string[] = [];
async getUserInfo(userId: string): Promise<UserInfoDto> {
this.callLog.push(`getUserInfo:${userId}`);
await delay(DELAY_MS);
return {
id: userId,
email: "parent@example.com",
name: "王家长",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
this.callLog.push("getChildrenByParent");
await delay(DELAY_MS);
return [
{
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
classId: "class-002",
className: "一年级2班",
gradeId: "grade-001",
},
{
id: "student-003",
name: "王小宝",
grade: "二年级",
classId: "class-003",
className: "二年级1班",
gradeId: "grade-002",
},
];
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
this.callLog.push("getViewports");
await delay(DELAY_MS);
return [
{
key: "dashboard",
label: "首页",
route: "/parent/dashboard",
icon: "home",
sortOrder: "1",
requiredPermission: "parent:dashboard:view",
},
];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
/**
* 可追踪调用次数与延迟的 Mock MsgClient
*/
class TrackingMsgClient implements MsgClient {
readonly callLog: string[] = [];
async listNotifications(
parentId: string,
unreadOnly = false,
): Promise<NotificationDto[]> {
this.callLog.push(`listNotifications:${parentId}:${unreadOnly}`);
await delay(DELAY_MS);
return [
{
id: "notif-1",
userId: parentId,
type: "GRADE",
title: "成绩通知",
content: "测试",
channel: "APP",
isRead: false,
childId: "student-001",
createdAt: Date.now(),
},
{
id: "notif-2",
userId: parentId,
type: "HOMEWORK",
title: "作业通知",
content: "测试",
channel: "APP",
isRead: false,
childId: "student-002",
createdAt: Date.now(),
},
];
}
async markAsRead(_notificationId: string): Promise<void> {}
async getNotificationPreferences(_parentId: string) {
return {
parentId: _parentId,
channels: ["APP"],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
};
}
async updateNotificationPreferences(
parentId: string,
prefs: NotificationPreferencesDto,
) {
return { ...prefs, parentId };
}
}
import type { NotificationPreferencesDto } from "../../src/clients/dtos.js";
/**
* 可追踪调用次数与延迟的 Mock DataAnaClient用于多子女学情并行编排测试
*/
class TrackingDataAnaClient implements DataAnaClient {
readonly callLog: string[] = [];
async getStudentWeakness(
studentId: string,
_subjectId: string,
): Promise<StudentWeaknessDto> {
this.callLog.push(`getStudentWeakness:${studentId}`);
await delay(DELAY_MS);
return {
studentId,
weakPoints: [
{ knowledgePointId: `kp-${studentId}-1`, title: "分数加减法", mastery: 0.45 },
],
};
}
async getLearningTrend(
studentId: string,
_startDate: number,
_endDate: number,
): Promise<LearningTrendDto> {
this.callLog.push(`getLearningTrend:${studentId}`);
await delay(DELAY_MS);
return { studentId, points: [{ date: Date.now(), score: 80 }] };
}
async getClassPerformance(
classId: string,
_subjectId: string,
_startDate: number,
_endDate: number,
): Promise<ClassPerformanceDto> {
this.callLog.push(`getClassPerformance:${classId}`);
await delay(DELAY_MS);
return {
classId,
averageScore: 82.5,
passRate: 0.9,
scores: [
{ studentId: "student-001", score: 85, grade: "A" },
{ studentId: "student-002", score: 78, grade: "B" },
{ studentId: "student-003", score: 92, grade: "A" },
],
};
}
}
/**
* Mock CoreEduClientResolverDeps 必需)
*/
class StubCoreEduClient implements CoreEduClient {
async listGradesByStudent(_studentId: string) {
return [];
}
async listHomeworkByClass(_classId: string) {
return [];
}
async listExamsByClass(_classId: string) {
return [];
}
async getClass(classId: string) {
return { id: classId, name: "测试班级", gradeId: "grade-001" };
}
}
function buildMockContext(parentId: string): GraphqlContext {
return {
session: {
parentId,
roles: ["parent"],
dataScope: "CHILDREN",
traceId: "trace-test-001",
},
req: {} as never,
res: {} as never,
loaders: {} as never,
};
}
describe("测试用例 4多子女仪表盘并行编排Integration", () => {
let iamClient: TrackingIamClient;
let msgClient: TrackingMsgClient;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new TrackingIamClient();
msgClient = new TrackingMsgClient();
});
describe("Dashboard 4 路并行编排", () => {
it("4 个下游调用并行执行,总耗时 < 单次耗时 + 100ms", async () => {
// safeRedis: miss → fn 执行 → set ok
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: new StubCoreEduClient(),
dataAnaClient: new TrackingDataAnaClient(),
msgClient,
childGuard: {} as unknown as ChildGuard,
};
const resolver = buildDashboardResolver(deps);
// 使用唯一 parentId 避免 LRU 缓存污染
const ctx = buildMockContext("parent-dash-parallel");
const start = Date.now();
const result = await resolver(null, {}, ctx);
const elapsed = Date.now() - start;
// 4 个并行调用getUserInfo + getChildrenByParent + getViewports + listNotifications
expect(iamClient.callLog).toContain("getUserInfo:parent-dash-parallel");
expect(iamClient.callLog).toContain("getChildrenByParent");
expect(iamClient.callLog).toContain("getViewports");
expect(msgClient.callLog).toContain("listNotifications:parent-dash-parallel:true");
// 并行:总耗时应接近 DELAY_MS而非 4 * DELAY_MS
// 允许 100ms 的 overhead
expect(elapsed).toBeLessThan(DELAY_MS + 100);
// 数据正确性
expect(result.parent).not.toBeNull();
expect(result.parent?.id).toBe("parent-dash-parallel");
expect(result.children).toHaveLength(3);
expect(result.viewports).toHaveLength(1);
expect(result.unreadNotifications).toBe(2);
expect(result.degraded).toBe(false);
});
it("返回 3 个子女数据", async () => {
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: new StubCoreEduClient(),
dataAnaClient: new TrackingDataAnaClient(),
msgClient,
childGuard: {} as unknown as ChildGuard,
};
const resolver = buildDashboardResolver(deps);
// 使用唯一 parentId 避免 LRU 缓存污染
const result = await resolver(null, {}, buildMockContext("parent-dash-children"));
expect(result.children).toHaveLength(3);
expect(result.children[0]!.id).toBe("student-001");
expect(result.children[1]!.id).toBe("student-002");
expect(result.children[2]!.id).toBe("student-003");
});
});
describe("3 子女 × 3 下游 = 9 路并行 gRPC学情诊断", () => {
it("3 个子女的学情诊断并行编排,总调用 9 次,总耗时 < 单子女耗时 + 100ms", async () => {
const dataAnaClient = new TrackingDataAnaClient();
const deps: ResolverDeps = {
iamClient,
coreEduClient: new StubCoreEduClient(),
dataAnaClient,
msgClient,
childGuard: {} as unknown as ChildGuard,
};
// 3 个子女的 ChildType模拟 dashboard 返回的 children
const children: ChildType[] = [
{
id: "student-001",
name: "李同学",
grade: "三年级",
class: { id: "class-001", name: "三年级1班", gradeId: "grade-003" },
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
class: { id: "class-002", name: "一年级2班", gradeId: "grade-001" },
},
{
id: "student-003",
name: "王小宝",
grade: "二年级",
class: { id: "class-003", name: "二年级1班", gradeId: "grade-002" },
},
];
const analyticsResolver = buildChildAnalyticsFieldResolver(deps);
const ctx = buildMockContext("parent-001");
// 并行为 3 个子女获取学情诊断
const start = Date.now();
const results = await Promise.all(
children.map((child) => analyticsResolver(child, {}, ctx)),
);
const elapsed = Date.now() - start;
// 3 子女 × 3 下游 = 9 次调用
expect(dataAnaClient.callLog).toHaveLength(9);
// 每个子女调了 3 个 data-ana 方法
const weaknessCalls = dataAnaClient.callLog.filter((c) =>
c.startsWith("getStudentWeakness:"),
);
const trendCalls = dataAnaClient.callLog.filter((c) =>
c.startsWith("getLearningTrend:"),
);
const classPerfCalls = dataAnaClient.callLog.filter((c) =>
c.startsWith("getClassPerformance:"),
);
expect(weaknessCalls).toHaveLength(3);
expect(trendCalls).toHaveLength(3);
expect(classPerfCalls).toHaveLength(3);
// 并行:总耗时接近 DELAY_MS而非 3 * DELAY_MS
expect(elapsed).toBeLessThan(DELAY_MS + 100);
// 每个子女都有学情结果
expect(results).toHaveLength(3);
for (const analytics of results) {
expect(analytics.weakness).toHaveLength(1);
expect(analytics.trend).toHaveLength(1);
expect(analytics.classRank).not.toBeNull();
}
});
});
describe("下游部分失败降级(测试用例 5 在 Integration 场景)", () => {
it("msg.listNotifications 失败时 dashboard.degraded=true其他数据正常返回", async () => {
// 使用一个 msg 失败的 client
const failingMsgClient: MsgClient = {
listNotifications: async () => {
await delay(DELAY_MS);
throw new Error("msg service unavailable");
},
markAsRead: async () => {},
getNotificationPreferences: async (parentId: string) => ({
parentId,
channels: ["APP"] as string[],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
}),
updateNotificationPreferences: async (parentId: string, prefs: NotificationPreferencesDto) => ({
...prefs,
parentId,
}),
};
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: new StubCoreEduClient(),
dataAnaClient: new TrackingDataAnaClient(),
msgClient: failingMsgClient,
childGuard: {} as unknown as ChildGuard,
};
const resolver = buildDashboardResolver(deps);
// 使用唯一 parentId 避免 LRU 缓存污染
const result = await resolver(null, {}, buildMockContext("parent-dash-fail"));
// msg 失败 → degraded=true
expect(result.degraded).toBe(true);
// 其他数据仍正常返回
expect(result.parent).not.toBeNull();
expect(result.children).toHaveLength(3);
expect(result.viewports).toHaveLength(1);
expect(result.unreadNotifications).toBe(0);
});
});
});

View File

@@ -0,0 +1,321 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
import type {
UserInfoDto,
GradeDto,
StudentWeaknessDto,
ClassInfoDto,
HomeworkDto,
ExamDto,
LearningTrendDto,
ClassPerformanceDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制 Redis 探针行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue("PONG"),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { HealthController } from "../../src/shared/health/health.controller.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
class UpIamClient implements IamClient {
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "",
name: "",
roles: [],
permissions: [],
};
}
async getChildrenByParent(_parentId: string) {
return [];
}
async getViewports(_userId: string) {
return [];
}
async getEffectivePermissions(_userId: string) {
return [];
}
}
class DownIamClient implements IamClient {
async getUserInfo(_userId: string): Promise<UserInfoDto> {
throw new Error("iam gRPC unavailable");
}
async getChildrenByParent(_parentId: string) {
return [];
}
async getViewports(_userId: string) {
return [];
}
async getEffectivePermissions(_userId: string) {
return [];
}
}
class UpCoreEduClient implements CoreEduClient {
async listGradesByStudent(_studentId: string): Promise<GradeDto[]> {
return [];
}
async listHomeworkByClass(_classId: string): Promise<HomeworkDto[]> {
return [];
}
async listExamsByClass(_classId: string): Promise<ExamDto[]> {
return [];
}
async getClass(classId: string): Promise<ClassInfoDto> {
return { id: classId, name: "", gradeId: "" };
}
}
class DownCoreEduClient implements CoreEduClient {
async listGradesByStudent(_studentId: string): Promise<GradeDto[]> {
throw new Error("core-edu gRPC unavailable");
}
async listHomeworkByClass(_classId: string): Promise<HomeworkDto[]> {
return [];
}
async listExamsByClass(_classId: string): Promise<ExamDto[]> {
return [];
}
async getClass(classId: string): Promise<ClassInfoDto> {
return { id: classId, name: "", gradeId: "" };
}
}
class UpDataAnaClient implements DataAnaClient {
async getStudentWeakness(
studentId: string,
_subjectId: string,
): Promise<StudentWeaknessDto> {
return { studentId, weakPoints: [] };
}
async getLearningTrend(
studentId: string,
_startDate: number,
_endDate: number,
): Promise<LearningTrendDto> {
return { studentId, points: [] };
}
async getClassPerformance(
classId: string,
_subjectId: string,
_startDate: number,
_endDate: number,
): Promise<ClassPerformanceDto> {
return { classId, averageScore: 0, passRate: 0, scores: [] };
}
}
class DownDataAnaClient implements DataAnaClient {
async getStudentWeakness(
_studentId: string,
_subjectId: string,
): Promise<StudentWeaknessDto> {
throw new Error("data-ana gRPC unavailable");
}
async getLearningTrend(
studentId: string,
_startDate: number,
_endDate: number,
): Promise<LearningTrendDto> {
return { studentId, points: [] };
}
async getClassPerformance(
classId: string,
_subjectId: string,
_startDate: number,
_endDate: number,
): Promise<ClassPerformanceDto> {
return { classId, averageScore: 0, passRate: 0, scores: [] };
}
}
describe("测试用例 9/readyz 下游探针Integration", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue("PONG");
});
describe("/healthzliveness", () => {
it("返回 status=ok + service 名 + timestamp", () => {
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = controller.liveness();
expect(result.status).toBe("ok");
expect(result.service).toBe("parent-bff");
expect(result.timestamp).toBeTruthy();
// timestamp 是合法 ISO
const ts = new Date(result.timestamp);
expect(Number.isNaN(ts.getTime())).toBe(false);
});
});
describe("/readyzreadiness全探针正常", () => {
it("4 项探针全部 up 时返回 status=ready", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("ready");
expect(result.service).toBe("parent-bff");
expect(result.checks["iam"]?.status).toBe("up");
expect(result.checks["core-edu"]?.status).toBe("up");
expect(result.checks["data-ana"]?.status).toBe("up");
expect(result.checks["redis"]?.status).toBe("up");
});
});
describe("/readyz iam 故障", () => {
it("iam 探针失败时返回 status=degraded + iam.status=down", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new DownIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["iam"]?.status).toBe("down");
expect(result.checks["iam"]?.error).toContain("iam gRPC unavailable");
// 其他探针仍正常
expect(result.checks["core-edu"]?.status).toBe("up");
expect(result.checks["data-ana"]?.status).toBe("up");
expect(result.checks["redis"]?.status).toBe("up");
});
});
describe("/readyz core-edu 故障", () => {
it("core-edu 探针失败时返回 status=degraded", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new UpIamClient(),
new DownCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["core-edu"]?.status).toBe("down");
expect(result.checks["core-edu"]?.error).toContain("core-edu gRPC unavailable");
});
});
describe("/readyz data-ana 故障", () => {
it("data-ana 探针失败时返回 status=degraded", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new DownDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["data-ana"]?.status).toBe("down");
expect(result.checks["data-ana"]?.error).toContain("data-ana gRPC unavailable");
});
});
describe("/readyz Redis 故障", () => {
it("Redis 探针失败时返回 status=degraded + redis.status=down", async () => {
mockSafeRedis.mockRejectedValue(new Error("redis connection refused"));
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["redis"]?.status).toBe("down");
expect(result.checks["redis"]?.error).toContain("redis connection refused");
// 其他探针仍正常
expect(result.checks["iam"]?.status).toBe("up");
});
it("Redis 返回非 PONG 时 status=down", async () => {
mockSafeRedis.mockResolvedValue("unexpected");
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["redis"]?.status).toBe("down");
expect(result.checks["redis"]?.error).toContain("unexpected ping response");
});
});
describe("/readyz 全部故障", () => {
it("所有探针都失败时 status=degraded", async () => {
mockSafeRedis.mockRejectedValue(new Error("redis down"));
const controller = new HealthController(
new DownIamClient(),
new DownCoreEduClient(),
new DownDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["iam"]?.status).toBe("down");
expect(result.checks["core-edu"]?.status).toBe("down");
expect(result.checks["data-ana"]?.status).toBe("down");
expect(result.checks["redis"]?.status).toBe("down");
});
});
describe("探针延迟记录", () => {
it("每个探针返回 latency_ms", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(typeof result.checks["iam"]?.latency_ms).toBe("number");
expect(typeof result.checks["core-edu"]?.latency_ms).toBe("number");
expect(typeof result.checks["data-ana"]?.latency_ms).toBe("number");
expect(typeof result.checks["redis"]?.latency_ms).toBe("number");
});
});
});

View File

@@ -0,0 +1,251 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { MsgClient } from "../../src/clients/msg.client.js";
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
import type {
ChildDto,
UserInfoDto,
ViewportDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制 ChildGuard 缓存行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { buildSelectChildMutationResolver } from "../../src/graphql/resolvers/select-child.resolver.js";
import type { ResolverDeps } from "../../src/graphql/resolvers/index.js";
import type { GraphqlContext } from "../../src/graphql/context.js";
import { ChildGuard } from "../../src/aggregation/child-guard.js";
import { ChildNotBoundError } from "../../src/shared/errors/application-error.js";
import { logger } from "../../src/shared/observability/logger.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
const MOCK_CHILDREN: ChildDto[] = [
{
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
classId: "class-002",
className: "一年级2班",
gradeId: "grade-001",
},
];
class StubIamClient implements IamClient {
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "",
name: "",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
return MOCK_CHILDREN.map((c) => ({ ...c }));
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
return [];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
function buildMockContext(parentId: string, traceId: string): GraphqlContext {
return {
session: {
parentId,
roles: ["parent"],
dataScope: "CHILDREN",
traceId,
},
req: {} as never,
res: {} as never,
loaders: {} as never,
};
}
describe("测试用例 8selectChild 审计日志Integration", () => {
let iamClient: StubIamClient;
let childGuard: ChildGuard;
let loggerInfoSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new StubIamClient();
childGuard = new ChildGuard(iamClient);
loggerInfoSpy = vi.spyOn(logger, "info").mockImplementation(() => logger);
});
it("合法 childId记录审计日志traceId + parentId + childId + timestamp", async () => {
// safeRedis: miss → set okChildGuard.getBoundChildren
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-001");
const result = await resolver(null, { childId: "student-001" }, ctx);
// 返回值正确
expect(result.childId).toBe("student-001");
expect(result.audited).toBe(true);
expect(result.selectedAt).toBeTruthy();
// selectedAt 是合法 ISO 时间
const selectedAtDate = new Date(result.selectedAt);
expect(Number.isNaN(selectedAtDate.getTime())).toBe(false);
// 审计日志被调用
expect(loggerInfoSpy).toHaveBeenCalled();
// 查找 "Child selected (audit log)" 的调用
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeDefined();
// 审计日志包含 traceId + parentId + childId + selectedAt
const logPayload = auditCall![0] as Record<string, unknown>;
expect(logPayload.parentId).toBe("parent-001");
expect(logPayload.childId).toBe("student-001");
expect(logPayload.traceId).toBe("trace-audit-001");
expect(logPayload.selectedAt).toBe(result.selectedAt);
});
it("越权 childId先抛 ChildNotBoundError不记录审计日志", async () => {
// safeRedis: miss → set okChildGuard.getBoundChildren 第一次调用)
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-002");
// 越权 childId 抛 ChildNotBoundError
await expect(
resolver(null, { childId: "student-999" }, ctx),
).rejects.toThrow(ChildNotBoundError);
// 不应记录审计日志(只可能有 ChildGuard 的 warn 日志)
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeUndefined();
});
it("审计日志的 selectedAt 与返回值一致", async () => {
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-003");
const result = await resolver(null, { childId: "student-002" }, ctx);
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeDefined();
const logPayload = auditCall![0] as Record<string, unknown>;
// 审计日志的 selectedAt 与返回值完全一致
expect(logPayload.selectedAt).toBe(result.selectedAt);
// childId 也一致
expect(logPayload.childId).toBe(result.childId);
});
it("不同 traceId 生成不同审计日志", async () => {
// 第一次调用
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
// 第一次traceId-A
const result1 = await resolver(
null,
{ childId: "student-001" },
buildMockContext("parent-001", "traceId-A"),
);
// 等待 2ms 确保 selectedAt 时间戳不同
await new Promise((r) => setTimeout(r, 2));
// 第二次traceId-B缓存已写入直接命中或重新调
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const result2 = await resolver(
null,
{ childId: "student-002" },
buildMockContext("parent-002", "traceId-B"),
);
// 两次审计日志的 traceId 不同
const auditCalls = loggerInfoSpy.mock.calls.filter(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCalls).toHaveLength(2);
const payload1 = auditCalls[0]![0] as Record<string, unknown>;
const payload2 = auditCalls[1]![0] as Record<string, unknown>;
expect(payload1.traceId).toBe("traceId-A");
expect(payload2.traceId).toBe("traceId-B");
expect(payload1.childId).toBe("student-001");
expect(payload2.childId).toBe("student-002");
expect(result1.selectedAt).not.toBe(result2.selectedAt);
});
});

View File

@@ -0,0 +1,178 @@
import { describe, it, expect } from "vitest";
import {
ApplicationError,
ValidationError,
UnauthorizedError,
ChildNotBoundError,
NotFoundError,
ConflictError,
BusinessError,
BadGatewayError,
GatewayTimeoutError,
ServiceUnavailableError,
InternalError,
} from "../../src/shared/errors/application-error.js";
describe("ApplicationError 体系", () => {
describe("ValidationError", () => {
it("statusCode 为 400", () => {
const err = new ValidationError("参数错误");
expect(err.statusCode).toBe(400);
expect(err.code).toBe("BFF_PARENT_VALIDATION_ERROR");
expect(err.type).toBe("validation");
expect(err.message).toBe("参数错误");
});
it("携带 details", () => {
const err = new ValidationError("参数错误", { field: "name" });
expect(err.details).toEqual({ field: "name" });
});
});
describe("UnauthorizedError", () => {
it("statusCode 为 401", () => {
const err = new UnauthorizedError("未授权");
expect(err.statusCode).toBe(401);
expect(err.code).toBe("BFF_PARENT_UNAUTHORIZED");
});
});
describe("ChildNotBoundError", () => {
it("statusCode 为 403", () => {
const err = new ChildNotBoundError(
"parent-001",
"student-999",
["student-001", "student-002"],
);
expect(err.statusCode).toBe(403);
expect(err.code).toBe("BFF_PARENT_CHILD_NOT_BOUND");
expect(err.type).toBe("child_not_bound");
expect(err.details).toEqual({
parentId: "parent-001",
requestedChildId: "student-999",
boundChildren: ["student-001", "student-002"],
});
});
it("message 包含 parentId 和 childId", () => {
const err = new ChildNotBoundError("parent-001", "student-999", []);
expect(err.message).toContain("student-999");
expect(err.message).toContain("parent-001");
});
});
describe("NotFoundError", () => {
it("statusCode 为 404", () => {
const err = new NotFoundError("Parent", "parent-999");
expect(err.statusCode).toBe(404);
expect(err.code).toBe("BFF_PARENT_NOT_FOUND");
expect(err.message).toContain("Parent");
expect(err.message).toContain("parent-999");
});
});
describe("ConflictError", () => {
it("statusCode 为 409", () => {
const err = new ConflictError("冲突");
expect(err.statusCode).toBe(409);
expect(err.code).toBe("BFF_PARENT_CONFLICT");
});
});
describe("BusinessError", () => {
it("statusCode 为 422", () => {
const err = new BusinessError("业务错误");
expect(err.statusCode).toBe(422);
expect(err.code).toBe("BFF_PARENT_BUSINESS_ERROR");
});
});
describe("BadGatewayError", () => {
it("statusCode 为 502", () => {
const err = new BadGatewayError("下游错误");
expect(err.statusCode).toBe(502);
expect(err.code).toBe("BFF_PARENT_BAD_GATEWAY");
});
});
describe("GatewayTimeoutError", () => {
it("statusCode 为 504", () => {
const err = new GatewayTimeoutError("超时");
expect(err.statusCode).toBe(504);
expect(err.code).toBe("BFF_PARENT_GATEWAY_TIMEOUT");
});
});
describe("ServiceUnavailableError", () => {
it("statusCode 为 503", () => {
const err = new ServiceUnavailableError("不可用");
expect(err.statusCode).toBe(503);
expect(err.code).toBe("BFF_PARENT_SERVICE_UNAVAILABLE");
});
});
describe("InternalError", () => {
it("statusCode 为 500", () => {
const err = new InternalError("内部错误");
expect(err.statusCode).toBe(500);
expect(err.code).toBe("BFF_PARENT_INTERNAL_ERROR");
});
});
describe("toJSON", () => {
it("序列化为 ActionState 信封格式", () => {
const err = new ValidationError("参数错误", { field: "name" });
err.traceId = "trace-001";
const json = err.toJSON();
expect(json.success).toBe(false);
// 从 unknown 转换toJSON 返回 Record<string, unknown>
const error = json.error as {
code: string;
message: string;
details: unknown;
traceId?: string;
};
expect(error.code).toBe("BFF_PARENT_VALIDATION_ERROR");
expect(error.message).toBe("参数错误");
expect(error.details).toEqual({ field: "name" });
expect(error.traceId).toBe("trace-001");
});
it("无 traceId 时序列化正常", () => {
const err = new NotFoundError("Child", "child-999");
const json = err.toJSON();
expect(json.success).toBe(false);
// 从 unknown 转换toJSON 返回 Record<string, unknown>
const error = json.error as { traceId?: string };
expect(error.traceId).toBeUndefined();
});
});
describe("继承关系", () => {
it("所有错误继承自 ApplicationError", () => {
const errors = [
new ValidationError(""),
new UnauthorizedError(""),
new ChildNotBoundError("", "", []),
new NotFoundError("", ""),
new ConflictError(""),
new BusinessError(""),
new BadGatewayError(""),
new GatewayTimeoutError(""),
new ServiceUnavailableError(""),
new InternalError(""),
];
for (const err of errors) {
expect(err).toBeInstanceOf(ApplicationError);
expect(err).toBeInstanceOf(Error);
}
});
it("name 属性为构造函数名", () => {
expect(new ValidationError("").name).toBe("ValidationError");
expect(new ChildNotBoundError("", "", []).name).toBe(
"ChildNotBoundError",
);
});
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { CacheKeys } from "../../src/shared/cache/cache-key.builder.js";
describe("CacheKeys", () => {
describe("dashboard", () => {
it("应该为 dashboard 构建正确的 key", () => {
expect(CacheKeys.dashboard("parent-001")).toBe("dashboard:parent-001");
});
});
describe("children", () => {
it("应该为 children 构建正确的 key", () => {
expect(CacheKeys.children("parent-001")).toBe("children:parent-001");
});
});
describe("grades", () => {
it("应该为 grades 构建带分页的 key", () => {
expect(CacheKeys.grades("student-001", 1)).toBe("grades:student-001:1");
expect(CacheKeys.grades("student-002", 3)).toBe("grades:student-002:3");
});
});
describe("homework", () => {
it("应该为 homework 构建带 classId 的 key", () => {
expect(CacheKeys.homework("student-001", "class-001")).toBe(
"homework:student-001:class-001",
);
});
});
describe("exams", () => {
it("应该为 exams 构建带 classId 的 key", () => {
expect(CacheKeys.exams("student-001", "class-001")).toBe(
"exams:student-001:class-001",
);
});
});
describe("weakness", () => {
it("应该为 weakness 构建正确的 key", () => {
expect(CacheKeys.weakness("student-001")).toBe(
"analytics:weakness:student-001",
);
});
});
describe("trend", () => {
it("应该为 trend 构建带 range 的 key", () => {
expect(CacheKeys.trend("student-001", "7d")).toBe(
"analytics:trend:student-001:7d",
);
});
});
describe("notifications", () => {
it("应该为 notifications 构建带分页的 key", () => {
expect(CacheKeys.notifications("parent-001", 1)).toBe(
"notifications:parent-001:1",
);
});
});
describe("notificationPrefs", () => {
it("应该为 notificationPrefs 构建正确的 key", () => {
expect(CacheKeys.notificationPrefs("parent-001")).toBe(
"notification-prefs:parent-001",
);
});
});
describe("childBindings", () => {
it("应该为 childBindings 构建正确的 key", () => {
expect(CacheKeys.childBindings("parent-001")).toBe(
"child-bindings:parent-001",
);
});
});
it("不同 parentId 应该产生不同 key", () => {
expect(CacheKeys.dashboard("parent-001")).not.toBe(
CacheKeys.dashboard("parent-002"),
);
});
});

View File

@@ -0,0 +1,233 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { ChildDto, UserInfoDto, ViewportDto } from "../../src/clients/dtos.js";
// Mock safeRedis 以控制缓存行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { ChildGuard } from "../../src/aggregation/child-guard.js";
import { ChildNotBoundError } from "../../src/shared/errors/application-error.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
/**
* 可追踪调用次数的 Mock IamClient
*/
class TrackingIamClient implements IamClient {
getChildrenCallCount = 0;
private readonly children: ChildDto[];
constructor(children: ChildDto[]) {
this.children = children;
}
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "test@example.com",
name: "测试家长",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
this.getChildrenCallCount++;
// 小延迟确保并发请求重叠
await new Promise((r) => setTimeout(r, 10));
return this.children.map((c) => ({ ...c }));
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
return [];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
const MOCK_CHILDREN: ChildDto[] = [
{
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
classId: "class-002",
className: "一年级2班",
gradeId: "grade-001",
},
];
describe("ChildGuard", () => {
let iamClient: TrackingIamClient;
let childGuard: ChildGuard;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new TrackingIamClient(MOCK_CHILDREN);
childGuard = new ChildGuard(iamClient);
});
// ============ 测试用例 1ChildGuard 拦截越权 childId ============
describe("测试用例 1拦截越权 childId", () => {
it("childId 不在绑定列表时抛 ChildNotBoundError(403)", async () => {
// safeRedis miss → 调 iam
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-999"),
).rejects.toThrow(ChildNotBoundError);
try {
await childGuard.validateChildAccess("parent-001", "student-999");
} catch (err) {
const error = err as ChildNotBoundError;
expect(error.statusCode).toBe(403);
expect(error.code).toBe("BFF_PARENT_CHILD_NOT_BOUND");
expect(error.details?.parentId).toBe("parent-001");
expect(error.details?.requestedChildId).toBe("student-999");
}
});
it("childId 在绑定列表时不抛异常", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-001"),
).resolves.toBeUndefined();
});
it("支持多个绑定的 childId", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-002"),
).resolves.toBeUndefined();
});
});
// ============ 测试用例 2缓存命中 ============
describe("测试用例 2缓存命中30s 内第二次不调 iam", () => {
it("Redis 缓存命中时不调用 iam.GetChildrenByParent", async () => {
const cachedChildren = JSON.stringify(MOCK_CHILDREN);
// 第一次调用:缓存未命中 → 调 iam → 写缓存
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(1);
// 第二次调用:缓存命中 → 不调 iam
mockSafeRedis.mockResolvedValueOnce(cachedChildren); // get → hit
const children = await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(1); // 仍然只有 1 次
expect(children).toHaveLength(2);
expect(children[0]!.id).toBe("student-001");
});
it("缓存返回的数据与原始数据一致", async () => {
const cachedChildren = JSON.stringify(MOCK_CHILDREN);
mockSafeRedis.mockResolvedValueOnce(cachedChildren);
const children = await childGuard.getBoundChildren("parent-001");
expect(children).toHaveLength(2);
expect(children[0]!.id).toBe("student-001");
expect(children[0]!.name).toBe("李同学");
expect(children[1]!.id).toBe("student-002");
});
});
// ============ 测试用例 3缓存击穿保护singleflight============
describe("测试用例 3缓存击穿保护singleflight", () => {
it("并发 100 请求只调 iam 1 次", async () => {
// safeRedis 始终返回 null缓存未命中
mockSafeRedis.mockResolvedValue(null);
// 并发发起 100 个请求
const promises: Promise<unknown>[] = [];
for (let i = 0; i < 100; i++) {
promises.push(childGuard.getBoundChildren("parent-001"));
}
const results = await Promise.all(promises);
// 所有请求都成功返回
expect(results).toHaveLength(100);
// iam 只被调用了 1 次singleflight 保护)
expect(iamClient.getChildrenCallCount).toBe(1);
// 所有结果一致
const firstResult = results[0] as ChildDto[];
expect(firstResult).toHaveLength(2);
for (const result of results) {
expect(result).toEqual(firstResult);
}
});
it("不同 parentId 的请求各自独立调用 iam", async () => {
mockSafeRedis.mockResolvedValue(null);
await Promise.all([
childGuard.getBoundChildren("parent-001"),
childGuard.getBoundChildren("parent-002"),
]);
// 不同 parentId 各调 1 次
expect(iamClient.getChildrenCallCount).toBe(2);
});
it("singleflight 完成后 inflight map 被清理", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
// 再次请求应该走缓存或重新调 iaminflight 已清理)
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(2);
});
});
describe("缓存 JSON 解析失败降级", () => {
it("Redis 返回非法 JSON 时降级到 iam 调用", async () => {
mockSafeRedis
.mockResolvedValueOnce("invalid-json") // get → 返回非法 JSON
.mockResolvedValueOnce(undefined); // set → ok
const children = await childGuard.getBoundChildren("parent-001");
expect(children).toHaveLength(2);
expect(iamClient.getChildrenCallCount).toBe(1);
});
});
});

View File

@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock safeRedis 以控制 Redis 行为(单元测试不依赖真实 Redis
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import {
withCacheFallback,
invalidateCache,
} from "../../src/aggregation/fallback-strategy.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
describe("FallbackStrategy三级降级", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
});
describe("Redis 缓存命中(第一级)", () => {
it("Redis 命中时返回缓存数据fromCache=true", async () => {
const cachedData = { id: "test-1", name: "测试" };
mockSafeRedis.mockResolvedValueOnce(JSON.stringify(cachedData));
const result = await withCacheFallback(
"test-redis-hit",
() => Promise.resolve({ id: "test-1", name: "不应该被调用" }),
30,
"test",
);
expect(result.data).toEqual(cachedData);
expect(result.fromCache).toBe(true);
expect(result.stale).toBe(false);
});
});
describe("Redis 未命中 → fn 成功(第三级直连)", () => {
it("Redis miss 后调 fn 获取数据并写入缓存", async () => {
const fnData = { id: "test-2", value: 42 };
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const result = await withCacheFallback(
"test-redis-miss-fn-success",
() => Promise.resolve(fnData),
30,
"test",
);
expect(result.data).toEqual(fnData);
expect(result.fromCache).toBe(false);
expect(result.stale).toBe(false);
// safeRedis 被调用 2 次get + set
expect(mockSafeRedis).toHaveBeenCalledTimes(2);
});
});
describe("LRU 缓存命中(第二级降级)", () => {
it("Redis 不可用时从 LRU 缓存返回数据", async () => {
// 第一次调用miss Redis + miss LRU → 调 fn → 写入 LRU
const fnData = { id: "test-3", label: "LRU测试" };
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await withCacheFallback(
"test-lru-hit-key",
() => Promise.resolve(fnData),
60,
"test",
);
// 第二次调用miss Redis → hit LRU
mockSafeRedis.mockResolvedValueOnce(null); // get → miss
const result = await withCacheFallback(
"test-lru-hit-key",
() => Promise.resolve({ id: "test-3", label: "不应该被调用" }),
60,
"test",
);
expect(result.data).toEqual(fnData);
expect(result.fromCache).toBe(true);
expect(result.stale).toBe(false);
});
});
describe("fn 失败降级", () => {
it("fn 抛错时返回 null", async () => {
mockSafeRedis.mockResolvedValueOnce(null); // get → miss
const result = await withCacheFallback(
"test-fn-fail",
() => Promise.reject(new Error("downstream error")),
30,
"test",
);
expect(result.data).toBeNull();
expect(result.fromCache).toBe(false);
expect(result.stale).toBe(false);
});
});
describe("Redis JSON 解析失败", () => {
it("Redis 返回非法 JSON 时降级到 fn", async () => {
mockSafeRedis
.mockResolvedValueOnce("not-valid-json") // get → 返回非法 JSON
.mockResolvedValueOnce(null) // LRU miss
.mockResolvedValueOnce(undefined); // set → ok
const result = await withCacheFallback(
"test-bad-json",
() => Promise.resolve({ data: "from-fn" }),
30,
"test",
);
expect(result.data).toEqual({ data: "from-fn" });
expect(result.fromCache).toBe(false);
});
});
describe("invalidateCache", () => {
it("调用 safeRedis del + LRU delete", async () => {
// 先写入 LRU
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await withCacheFallback(
"test-invalidate",
() => Promise.resolve({ val: 1 }),
30,
"test",
);
// 失效缓存
mockSafeRedis.mockResolvedValueOnce(undefined); // del → ok
await invalidateCache("test-invalidate");
// 再次查询,应该 miss LRU
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const result = await withCacheFallback(
"test-invalidate",
() => Promise.resolve({ val: 2 }),
30,
"test",
);
expect(result.data).toEqual({ val: 2 });
expect(result.fromCache).toBe(false);
});
});
});

View File

@@ -0,0 +1,237 @@
import { describe, it, expect } from "vitest";
import {
validate,
parse,
GraphQLSchema,
GraphQLObjectType,
GraphQLString,
GraphQLBoolean,
GraphQLID,
GraphQLList,
GraphQLNonNull,
} from "graphql";
import depthLimit from "graphql-depth-limit";
import {
createComplexityRule,
simpleEstimator,
} from "graphql-query-complexity";
const DEPTH_LIMIT = 7;
const COST_LIMIT = 1000;
/**
* 测试用 schema包含递归类型以测试深度限制。
*
* 直接使用 graphql 包的 GraphQLSchema 构造,避免
* @graphql-tools/schema 引入不同 graphql 实例导致
* "Cannot use GraphQLSchema from another module or realm" 错误。
*/
const typeA: GraphQLObjectType = new GraphQLObjectType({
name: "A",
fields: () => ({
a: { type: typeA },
value: { type: GraphQLString },
}),
});
const parentType = new GraphQLObjectType({
name: "Parent",
fields: {
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
},
});
const childType = new GraphQLObjectType({
name: "Child",
fields: {
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
},
});
const dashboardDataType = new GraphQLObjectType({
name: "DashboardData",
fields: {
parent: { type: parentType },
children: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(childType))) },
degraded: { type: new GraphQLNonNull(GraphQLBoolean) },
},
});
const queryType = new GraphQLObjectType({
name: "Query",
fields: {
a: { type: typeA },
dashboard: { type: dashboardDataType },
},
});
const testSchema = new GraphQLSchema({ query: queryType });
function validateQuery(
query: string,
opts: { depth?: number; cost?: number } = {},
): ReturnType<typeof validate> {
const rules: ReturnType<typeof depthLimit>[] = [];
if (opts.depth !== undefined) {
rules.push(depthLimit(opts.depth));
}
if (opts.cost !== undefined) {
rules.push(
createComplexityRule({
maximumComplexity: opts.cost,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
onComplete: () => {},
}),
);
}
return validate(testSchema, parse(query), rules);
}
describe("GraphQL 查询复杂度限制(测试用例 6", () => {
describe("深度限制", () => {
it("depth=7 查询通过", () => {
const query = `query {
a { # 1
a { # 2
a { # 3
a { # 4
a { # 5
a { # 6
a { # 7
value
}
}
}
}
}
}
}
}`;
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
expect(errors).toHaveLength(0);
});
it("depth=8 查询被拒", () => {
const query = `query {
a { # 1
a { # 2
a { # 3
a { # 4
a { # 5
a { # 6
a { # 7
a { # 8
value
}
}
}
}
}
}
}
}
}`;
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.message).toContain("depth");
});
it("depth=1 查询通过", () => {
const query = `query { a { value } }`;
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
expect(errors).toHaveLength(0);
});
});
describe("复杂度限制", () => {
it("低复杂度查询通过", () => {
const query = `query {
dashboard {
degraded
parent { id name }
children { id name }
}
}`;
const errors = validateQuery(query, { cost: COST_LIMIT });
expect(errors).toHaveLength(0);
});
it("cost > 1000 查询被拒", () => {
const aliases: string[] = [];
for (let i = 0; i < 501; i++) {
aliases.push(`a${i}: dashboard { degraded }`);
}
const query = `query { ${aliases.join(" ")} }`;
const errors = validateQuery(query, { cost: COST_LIMIT });
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.message).toContain("complexity");
});
it("cost = 1000 查询通过(边界值)", () => {
const aliases: string[] = [];
for (let i = 0; i < 500; i++) {
aliases.push(`a${i}: dashboard { degraded }`);
}
const query = `query { ${aliases.join(" ")} }`;
const errors = validateQuery(query, { cost: COST_LIMIT });
expect(errors).toHaveLength(0);
});
});
describe("组合限制", () => {
it("同时满足深度和复杂度限制的查询通过", () => {
const query = `query {
a { # 1
a { # 2
a { # 3
value
}
}
}
dashboard {
degraded
parent { id }
}
}`;
const errors = validateQuery(query, {
depth: DEPTH_LIMIT,
cost: COST_LIMIT,
});
expect(errors).toHaveLength(0);
});
it("深度超限但复杂度未超限时被深度规则拒绝", () => {
const query = `query {
a { # 1
a { # 2
a { # 3
a { # 4
a { # 5
a { # 6
a { # 7
a { # 8
value
}
}
}
}
}
}
}
}
}`;
const errors = validateQuery(query, {
depth: DEPTH_LIMIT,
cost: COST_LIMIT,
});
const depthErrors = errors.filter((e) =>
e.message.toLowerCase().includes("depth"),
);
expect(depthErrors.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from "vitest";
import { LruCache } from "../../src/shared/cache/lru.cache.js";
describe("LruCache", () => {
let cache: LruCache;
beforeEach(() => {
cache = new LruCache(3);
});
describe("set / get", () => {
it("应该存取值", () => {
cache.set("key1", "value1", 60);
expect(cache.get("key1")).toBe("value1");
});
it("未设置的 key 返回 null", () => {
expect(cache.get("nonexistent")).toBeNull();
});
});
describe("TTL 过期", () => {
it("过期后返回 null", async () => {
cache.set("key1", "value1", 0.01);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(cache.get("key1")).toBeNull();
});
it("未过期时返回值", () => {
cache.set("key1", "value1", 60);
expect(cache.get("key1")).toBe("value1");
});
});
describe("LRU 淘汰", () => {
it("容量满时淘汰最久未使用的条目", () => {
cache.set("a", "1", 60);
cache.set("b", "2", 60);
cache.set("c", "3", 60);
cache.set("d", "4", 60);
expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBe("2");
expect(cache.get("c")).toBe("3");
expect(cache.get("d")).toBe("4");
});
it("访问后更新 LRU 顺序", () => {
cache.set("a", "1", 60);
cache.set("b", "2", 60);
cache.set("c", "3", 60);
cache.get("a");
cache.set("d", "4", 60);
expect(cache.get("a")).toBe("1");
expect(cache.get("b")).toBeNull();
});
});
describe("delete", () => {
it("删除后返回 null", () => {
cache.set("key1", "value1", 60);
cache.delete("key1");
expect(cache.get("key1")).toBeNull();
});
});
describe("clear", () => {
it("清空所有条目", () => {
cache.set("a", "1", 60);
cache.set("b", "2", 60);
cache.clear();
expect(cache.size).toBe(0);
});
});
describe("size", () => {
it("返回当前条目数", () => {
expect(cache.size).toBe(0);
cache.set("a", "1", 60);
expect(cache.size).toBe(1);
cache.set("b", "2", 60);
expect(cache.size).toBe(2);
});
});
it("默认 maxSize 为 100", () => {
const defaultCache = new LruCache();
for (let i = 0; i < 100; i++) {
defaultCache.set(`key${i}`, `val${i}`, 60);
}
expect(defaultCache.size).toBe(100);
defaultCache.set("key100", "val100", 60);
expect(defaultCache.size).toBe(100);
expect(defaultCache.get("key0")).toBeNull();
});
});

View File

@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import { orchestrate } from "../../src/aggregation/orchestrator.js";
describe("Orchestrator", () => {
describe("全部成功", () => {
it("返回所有结果且 partial=false", async () => {
const result = await orchestrate({
a: Promise.resolve("value-a"),
b: Promise.resolve(42),
c: Promise.resolve({ key: "val" }),
});
expect(result.partial).toBe(false);
expect(result.failures).toHaveLength(0);
expect(result.results.a).toBe("value-a");
expect(result.results.b).toBe(42);
expect(result.results.c).toEqual({ key: "val" });
});
});
describe("部分失败降级(测试用例 5", () => {
it("data-ana 失败时该字段为 null其他字段正常", async () => {
const result = await orchestrate({
parent: Promise.resolve({ id: "parent-001", name: "王家长" }),
children: Promise.resolve([
{ id: "student-001", name: "李同学" },
]),
analytics: Promise.reject(new Error("data-ana connection refused")),
});
expect(result.partial).toBe(true);
expect(result.results.parent).toEqual({ id: "parent-001", name: "王家长" });
expect(result.results.children).toEqual([
{ id: "student-001", name: "李同学" },
]);
expect(result.results.analytics).toBeNull();
expect(result.failures).toHaveLength(1);
expect(result.failures[0]!.key).toBe("analytics");
});
it("多个失败都记录在 failures 中", async () => {
const result = await orchestrate({
ok: Promise.resolve("ok"),
fail1: Promise.reject(new Error("err1")),
fail2: Promise.reject(new Error("err2")),
});
expect(result.partial).toBe(true);
expect(result.failures).toHaveLength(2);
expect(result.failures.map((f) => f.key)).toContain("fail1");
expect(result.failures.map((f) => f.key)).toContain("fail2");
expect(result.results.ok).toBe("ok");
expect(result.results.fail1).toBeNull();
expect(result.results.fail2).toBeNull();
});
});
describe("全部失败", () => {
it("所有字段为 null 且 partial=true", async () => {
const result = await orchestrate({
a: Promise.reject(new Error("err-a")),
b: Promise.reject(new Error("err-b")),
});
expect(result.partial).toBe(true);
expect(result.results.a).toBeNull();
expect(result.results.b).toBeNull();
expect(result.failures).toHaveLength(2);
});
});
describe("空任务", () => {
it("返回空结果且 partial=false", async () => {
const result = await orchestrate({});
expect(result.partial).toBe(false);
expect(result.failures).toHaveLength(0);
expect(Object.keys(result.results)).toHaveLength(0);
});
});
describe("并行执行", () => {
it("任务并行执行而非串行", async () => {
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
const start = Date.now();
await orchestrate({
a: delay(100).then(() => "a"),
b: delay(100).then(() => "b"),
c: delay(100).then(() => "c"),
});
const elapsed = Date.now() - start;
// 并行:总耗时约 100ms而非 300ms
expect(elapsed).toBeLessThan(250);
});
});
describe("failures 结构", () => {
it("failure 包含 key 和 error", async () => {
const error = new Error("test error");
const result = await orchestrate({
failing: Promise.reject(error),
});
expect(result.failures).toHaveLength(1);
expect(result.failures[0]!.key).toBe("failing");
expect(result.failures[0]!.error).toBe(error);
});
it("非 Error 类型的 rejection 也记录", async () => {
const result = await orchestrate({
failing: Promise.reject("string error"),
});
expect(result.failures).toHaveLength(1);
expect(result.failures[0]!.error).toBe("string error");
});
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from "vitest";
import {
UpdateNotificationPreferencesSchema,
NotificationChannelSchema,
NotificationEventTypesInputSchema,
} from "../../src/parent/dto/parent-inputs.dto.js";
describe("NotificationChannelSchema", () => {
it("接受有效渠道", () => {
expect(NotificationChannelSchema.parse("APP")).toBe("APP");
expect(NotificationChannelSchema.parse("SMS")).toBe("SMS");
expect(NotificationChannelSchema.parse("EMAIL")).toBe("EMAIL");
expect(NotificationChannelSchema.parse("WECHAT")).toBe("WECHAT");
});
it("拒绝无效渠道", () => {
expect(() => NotificationChannelSchema.parse("PUSH")).toThrow();
expect(() => NotificationChannelSchema.parse("")).toThrow();
});
});
describe("NotificationEventTypesInputSchema", () => {
it("接受所有可选布尔字段", () => {
const result = NotificationEventTypesInputSchema.parse({
gradeReleased: true,
homeworkGraded: false,
examPublished: true,
attendanceAlert: false,
schoolAnnouncement: true,
});
expect(result.gradeReleased).toBe(true);
expect(result.homeworkGraded).toBe(false);
});
it("允许部分字段(未传为 undefined", () => {
const result = NotificationEventTypesInputSchema.parse({
gradeReleased: true,
});
expect(result.gradeReleased).toBe(true);
expect(result.homeworkGraded).toBeUndefined();
});
it("接受空对象", () => {
const result = NotificationEventTypesInputSchema.parse({});
expect(result.gradeReleased).toBeUndefined();
});
it("拒绝非布尔值", () => {
expect(() =>
NotificationEventTypesInputSchema.parse({ gradeReleased: "yes" }),
).toThrow();
});
});
describe("UpdateNotificationPreferencesSchema", () => {
it("接受完整有效输入", () => {
const input = {
channels: ["APP", "WECHAT"],
eventTypes: {
gradeReleased: true,
homeworkGraded: false,
},
};
const result = UpdateNotificationPreferencesSchema.parse(input);
expect(result.channels).toEqual(["APP", "WECHAT"]);
expect(result.eventTypes.gradeReleased).toBe(true);
});
it("拒绝空 channels 数组", () => {
expect(() =>
UpdateNotificationPreferencesSchema.parse({
channels: [],
eventTypes: {},
}),
).toThrow();
});
it("拒绝无效 channel 值", () => {
expect(() =>
UpdateNotificationPreferencesSchema.parse({
channels: ["INVALID"],
eventTypes: {},
}),
).toThrow();
});
it("拒绝缺少 channels", () => {
expect(() =>
UpdateNotificationPreferencesSchema.parse({
eventTypes: {},
}),
).toThrow();
});
it("拒绝缺少 eventTypes", () => {
expect(() =>
UpdateNotificationPreferencesSchema.parse({
channels: ["APP"],
}),
).toThrow();
});
it("接受单个 channel", () => {
const result = UpdateNotificationPreferencesSchema.parse({
channels: ["APP"],
eventTypes: {},
});
expect(result.channels).toEqual(["APP"]);
});
});

View File

@@ -0,0 +1,402 @@
import { describe, it, expect } from "vitest";
import {
mapParent,
mapChild,
mapClassInfo,
mapGrade,
mapHomework,
mapExam,
mapViewport,
mapWeakness,
mapTrend,
mapAnalytics,
} from "../../src/aggregation/response-mapper.js";
import type {
UserInfoDto,
ChildDto,
GradeDto,
HomeworkDto,
ExamDto,
ViewportDto,
StudentWeaknessDto,
LearningTrendDto,
ClassPerformanceDto,
} from "../../src/clients/dtos.js";
describe("Response Mapper", () => {
describe("mapParent", () => {
it("正确映射 UserInfoDto → ParentType", () => {
const dto: UserInfoDto = {
id: "parent-001",
email: "parent@example.com",
name: "王家长",
roles: ["parent"],
permissions: [],
};
const result = mapParent(dto);
expect(result.id).toBe("parent-001");
expect(result.email).toBe("parent@example.com");
expect(result.name).toBe("王家长");
expect(result.avatar).toBeNull();
expect(result.roles).toEqual(["parent"]);
expect(result.dataScope).toBe("CHILDREN");
});
});
describe("mapChild", () => {
it("正确映射 ChildDto → ChildType", () => {
const dto: ChildDto = {
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
};
const result = mapChild(dto);
expect(result.id).toBe("student-001");
expect(result.name).toBe("李同学");
expect(result.grade).toBe("三年级");
expect(result.class.id).toBe("class-001");
expect(result.class.name).toBe("三年级1班");
expect(result.class.gradeId).toBe("grade-003");
});
});
describe("mapClassInfo", () => {
it("正确映射 ClassInfo", () => {
const result = mapClassInfo({
id: "class-001",
name: "三年级1班",
gradeId: "grade-003",
});
expect(result.id).toBe("class-001");
expect(result.name).toBe("三年级1班");
expect(result.gradeId).toBe("grade-003");
});
});
describe("mapGrade", () => {
it("正确映射 GradeDto → GradeTypescore string → number", () => {
const dto: GradeDto = {
id: "grade-001",
studentId: "student-001",
examId: "exam-001",
homeworkId: "",
score: "85.5",
feedback: "数学表现良好",
gradedBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapGrade(dto);
expect(result.id).toBe("grade-001");
expect(result.examId).toBe("exam-001");
expect(result.score).toBe(85.5);
expect(result.subject).toBe("数学");
expect(result.examTitle).toBe("数学表现良好");
expect(result.rank).toBeNull();
expect(result.gradedAt).toBe("2026-01-02T00:00:00.000Z");
});
it("feedback 为空时 examTitle 为默认值", () => {
const dto: GradeDto = {
id: "grade-001",
studentId: "student-001",
examId: "exam-001",
homeworkId: "",
score: "90",
feedback: "",
gradedBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapGrade(dto);
expect(result.examTitle).toBe("未命名考试");
expect(result.subject).toBe("未知科目");
});
it("score 非数字时返回 0", () => {
const dto: GradeDto = {
id: "grade-001",
studentId: "student-001",
examId: "exam-001",
homeworkId: "",
score: "invalid",
feedback: "语文表现良好",
gradedBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapGrade(dto);
expect(result.score).toBe(0);
expect(result.subject).toBe("语文");
});
it("feedback 无科目前缀时 subject 为综合", () => {
const dto: GradeDto = {
id: "grade-001",
studentId: "student-001",
examId: "exam-001",
homeworkId: "",
score: "80",
feedback: "表现不错",
gradedBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapGrade(dto);
expect(result.subject).toBe("综合");
});
});
describe("mapHomework", () => {
it("SUBMITTED 状态返回 submittedAt", () => {
const dto: HomeworkDto = {
id: "hw-001",
classId: "class-001",
title: "数学练习",
description: "",
dueDate: "2026-01-05T00:00:00.000Z",
status: "SUBMITTED",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapHomework(dto);
expect(result.status).toBe("SUBMITTED");
expect(result.submittedAt).toBe("2026-01-02T00:00:00.000Z");
});
it("GRADED 状态返回 submittedAt", () => {
const dto: HomeworkDto = {
id: "hw-001",
classId: "class-001",
title: "数学练习",
description: "",
dueDate: "2026-01-05T00:00:00.000Z",
status: "GRADED",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
};
const result = mapHomework(dto);
expect(result.submittedAt).toBe("2026-01-03T00:00:00.000Z");
});
it("NOT_SUBMITTED 状态 submittedAt 为 null", () => {
const dto: HomeworkDto = {
id: "hw-001",
classId: "class-001",
title: "数学练习",
description: "",
dueDate: "2026-01-05T00:00:00.000Z",
status: "NOT_SUBMITTED",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapHomework(dto);
expect(result.submittedAt).toBeNull();
});
});
describe("mapExam", () => {
it("PUBLISHED 状态返回 publishedAt", () => {
const dto: ExamDto = {
id: "exam-001",
classId: "class-001",
title: "数学期中考试",
description: "",
examDate: "2026-02-01T00:00:00.000Z",
duration: "90",
totalScore: "100",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-15T00:00:00.000Z",
};
const result = mapExam(dto);
expect(result.status).toBe("PUBLISHED");
expect(result.publishedAt).toBe("2026-01-15T00:00:00.000Z");
});
it("DRAFT 状态 publishedAt 为 null", () => {
const dto: ExamDto = {
id: "exam-001",
classId: "class-001",
title: "数学期中考试",
description: "",
examDate: "2026-02-01T00:00:00.000Z",
duration: "90",
totalScore: "100",
status: "DRAFT",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-15T00:00:00.000Z",
};
const result = mapExam(dto);
expect(result.publishedAt).toBeNull();
});
});
describe("mapViewport", () => {
it("正确映射 ViewportDto", () => {
const dto: ViewportDto = {
key: "dashboard",
label: "首页",
route: "/parent/dashboard",
icon: "home",
sortOrder: "1",
requiredPermission: "parent:dashboard:view",
};
const result = mapViewport(dto);
expect(result.key).toBe("dashboard");
expect(result.label).toBe("首页");
expect(result.icon).toBe("home");
expect(result.requiredPermission).toBe("parent:dashboard:view");
});
it("icon 和 requiredPermission 可选", () => {
const dto: ViewportDto = {
key: "settings",
label: "设置",
route: "/parent/settings",
sortOrder: "5",
};
const result = mapViewport(dto);
expect(result.icon).toBeNull();
expect(result.requiredPermission).toBeNull();
});
});
describe("mapWeakness", () => {
it("正确映射薄弱点列表", () => {
const dto: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [
{ knowledgePointId: "kp-1", title: "分数加减法", mastery: 0.45 },
{ knowledgePointId: "kp-2", title: "阅读理解", mastery: 0.62 },
],
};
const result = mapWeakness(dto);
expect(result).toHaveLength(2);
expect(result[0]!.knowledgePointId).toBe("kp-1");
expect(result[0]!.name).toBe("分数加减法");
expect(result[0]!.masteryRate).toBe(0.45);
expect(result[0]!.subject).toBe("综合");
});
it("空薄弱点列表", () => {
const dto: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const result = mapWeakness(dto);
expect(result).toHaveLength(0);
});
});
describe("mapTrend", () => {
it("正确映射趋势点int64 ms → ISO string", () => {
const dto: LearningTrendDto = {
studentId: "student-001",
points: [
{ date: 1735689600000, score: 80 },
{ date: 1735776000000, score: 85 },
],
};
const result = mapTrend(dto);
expect(result).toHaveLength(2);
expect(result[0]!.score).toBe(80);
expect(typeof result[0]!.date).toBe("string");
expect(new Date(result[0]!.date).getTime()).toBe(1735689600000);
expect(result[0]!.subject).toBeNull();
});
});
describe("mapAnalytics", () => {
it("正确计算 classRank降序排名", () => {
const weakness: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const trend: LearningTrendDto = {
studentId: "student-001",
points: [],
};
const classPerf: ClassPerformanceDto = {
classId: "class-001",
averageScore: 82.5,
passRate: 0.9,
scores: [
{ studentId: "student-001", score: 85, grade: "A" },
{ studentId: "student-002", score: 92, grade: "A" },
{ studentId: "student-003", score: 65, grade: "C" },
],
};
const result = mapAnalytics("student-001", weakness, trend, classPerf);
expect(result.childId).toBe("student-001");
expect(result.classAverage).toBe(82.5);
const sorted = [...classPerf.scores].sort((a, b) => b.score - a.score);
const idx = sorted.findIndex((s) => s.studentId === "student-001");
expect(result.classRank).toBe(idx + 1);
});
it("classPerf 为 null 时 classRank 和 classAverage 为 null", () => {
const weakness: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const trend: LearningTrendDto = {
studentId: "student-001",
points: [],
};
const result = mapAnalytics("student-001", weakness, trend, null);
expect(result.classRank).toBeNull();
expect(result.classAverage).toBeNull();
});
it("childId 不在 scores 中时 classRank 为 null", () => {
const weakness: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const trend: LearningTrendDto = {
studentId: "student-001",
points: [],
};
const classPerf: ClassPerformanceDto = {
classId: "class-001",
averageScore: 80,
passRate: 0.9,
scores: [
{ studentId: "student-002", score: 90, grade: "A" },
{ studentId: "student-003", score: 70, grade: "B" },
],
};
const result = mapAnalytics("student-001", weakness, trend, classPerf);
expect(result.classRank).toBeNull();
expect(result.classAverage).toBe(80);
});
it("scores 为空数组时 classRank 为 null", () => {
const weakness: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const trend: LearningTrendDto = {
studentId: "student-001",
points: [],
};
const classPerf: ClassPerformanceDto = {
classId: "class-001",
averageScore: 0,
passRate: 0,
scores: [],
};
const result = mapAnalytics("student-001", weakness, trend, classPerf);
expect(result.classRank).toBeNull();
});
});
});

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"rootDir": "./src",
"incremental": false,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "vitest/globals"],
"noEmit": true
},
"include": ["src/**/*", "test/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,52 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["test/**/*.test.ts"],
exclude: ["node_modules", "dist"],
testTimeout: 15000,
hookTimeout: 30000,
coverage: {
provider: "v8",
reporter: ["text", "text-summary", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/main.ts",
"src/app.module.ts",
"src/**/*.module.ts",
"src/clients/**/*.ts",
"src/dataloader/**/*.ts",
"src/graphql/context.ts",
"src/graphql/schema.ts",
"src/graphql/types.ts",
"src/graphql/yoga.ts",
"src/graphql/resolvers/index.ts",
"src/graphql/resolvers/me.resolver.ts",
"src/graphql/resolvers/notification.resolver.ts",
"src/graphql/resolvers/notification-preference.resolver.ts",
"src/graphql/resolvers/grade.resolver.ts",
"src/shared/cache/redis.client.ts",
"src/shared/kafka/kafka.consumer.ts",
"src/shared/kafka/handlers/event-handler.ts",
"src/shared/kafka/handlers/notification-push.handler.ts",
"src/shared/observability/logger.ts",
"src/shared/observability/tracer.ts",
"src/shared/errors/global-error.filter.ts",
"src/entry/context.middleware.ts",
"src/entry/graphql.controller.ts",
"src/types/graphql-depth-limit.d.ts",
],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
esbuild: {
target: "es2022",
},
});