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

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

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

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

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

View File

@@ -12,7 +12,7 @@ class Settings(BaseSettings):
openai_base_url: str = "https://api.openai.com/v1"
anthropic_api_key: str = ""
# 开发模式true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错
dev_mode: str = "false"
dev_mode: bool = False
# 可观测性
otel_endpoint: str = "http://localhost:4318"
log_level: str = "info"
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
@property
def is_dev(self) -> bool:
"""是否处于开发模式."""
return self.dev_mode.lower() == "true"
return self.dev_mode
@property
def llm_available(self) -> bool:

View File

@@ -41,7 +41,7 @@ def init_tracer() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期."""
init_tracer()
logger.info(
@@ -89,6 +89,12 @@ class ChatResponse(BaseModel):
degraded: bool = False
class QuestionRequest(BaseModel):
"""题目生成请求."""
prompt: str
def _extract_content(result: dict[str, Any] | None) -> tuple[str, str, dict[str, Any]]:
"""从 OpenAI 响应中抽取 (content, model, usage)。"""
if result is None:
@@ -171,7 +177,7 @@ async def chat_stream(req: ChatRequest) -> StreamingResponse:
@router.post("/generate/question")
async def generate_question(prompt: str) -> dict[str, Any]:
async def generate_question(req: QuestionRequest) -> dict[str, Any]:
"""生成题目(无 API key 时降级返回骨架)."""
with tracer.start_as_current_span("generate_question"):
messages = [
@@ -180,7 +186,7 @@ async def generate_question(prompt: str) -> dict[str, Any]:
"content": "You are an educational question generator. "
"Generate a clear, concise question based on the user's prompt.",
},
{"role": "user", "content": prompt},
{"role": "user", "content": req.prompt},
]
result = await chat_completion(
messages=messages,
@@ -190,7 +196,7 @@ async def generate_question(prompt: str) -> dict[str, Any]:
base_url=settings.openai_base_url,
)
if result is None:
logger.warning("generate_question_degraded", prompt=prompt[:100])
logger.warning("generate_question_degraded", prompt=req.prompt[:100])
return {
"success": True,
"data": {"question": "[degraded] question generation skeleton"},

View File

@@ -6,6 +6,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/sony/gobreaker/v2 v2.1.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0
go.opentelemetry.io/otel v1.44.0
@@ -14,6 +15,7 @@ require (
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
@@ -40,7 +42,11 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/redis/go-redis/v9 v9.7.0 // indirect
@@ -52,6 +58,7 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect

View File

@@ -2,6 +2,8 @@ github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZp
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -74,8 +76,16 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -85,10 +95,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
@@ -97,6 +117,8 @@ github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo=
github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sony/gobreaker/v2 v2.1.0 h1:av2BnjtRmVPWBvy5gSFPytm1J8BmN5AGhq875FfGKDM=
github.com/sony/gobreaker/v2 v2.1.0/go.mod h1:dO3Q/nCzxZj6ICjH6J/gM0r4oAwBMVLY8YAQf+NTtUg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -148,6 +170,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
@@ -171,6 +195,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,10 +1,12 @@
package config
import (
"log"
"os"
"strconv"
)
// Config 持有 api-gateway 运行时配置
type Config struct {
Port string
JWTSecret string
@@ -23,10 +25,26 @@ type Config struct {
DevMode bool
}
// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须配置 JWT_SECRET
const devJWTSecret = "p1-dev-secret-change-in-production"
// Load 从环境变量加载配置。
// 非 DevMode 下若 JWT_SECRET 未配置则 fatal 退出DevMode 下使用默认密钥并打印 warning。
func Load() *Config {
devMode := getEnvBool("DEV_MODE", false)
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" {
if devMode {
log.Println("warning: JWT_SECRET not set, using dev default (DEV_MODE=true)")
jwtSecret = devJWTSecret
} else {
log.Fatal("JWT_SECRET must be set in non-dev mode")
}
}
return &Config{
Port: getEnv("API_GATEWAY_PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "p1-dev-secret-change-in-production"),
JWTSecret: jwtSecret,
JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"),
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
@@ -39,10 +57,11 @@ func Load() *Config {
AiServiceURL: getEnv("AI_SERVICE_URL", "http://localhost:3008"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: getEnvBool("DEV_MODE", false),
DevMode: devMode,
}
}
// getEnv 读取环境变量,缺失时返回 fallback
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
@@ -50,15 +69,7 @@ func getEnv(key, fallback string) string {
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return fallback
}
// getEnvBool 读取环境变量并解析为 bool缺失或解析失败时返回 fallback
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {

View File

@@ -7,7 +7,6 @@ import (
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
@@ -73,7 +72,7 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return
}
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
@@ -107,7 +106,7 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
if sub, ok := claims["sub"].(string); ok {
c.Request.Header.Set("x-user-id", sub)
}
if roles, ok := claims["roles"].([]interface{}); ok {
if roles, ok := claims["roles"].([]any); ok {
roleStrs := make([]string, 0, len(roles))
for _, r := range roles {
if s, ok := r.(string); ok {
@@ -120,22 +119,3 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
c.Next()
}
}
// RequestIDMiddleware 注入请求 ID 用于全链路追踪
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateUUID()
}
c.Set("request_id", requestID)
c.Writer.Header().Set("X-Request-ID", requestID)
c.Next()
}
}
// generateUUID 生成带 req- 前缀的唯一请求 ID
// 使用 uuid.New() 基于 RFC 4122 v4 随机 UUID避免 time.Now() 产生的冲突与可预测性
func generateUUID() string {
return "req-" + uuid.New().String()
}

View File

@@ -1,6 +1,7 @@
package middleware
import (
"log"
"net/http"
"os"
"strconv"
@@ -12,22 +13,26 @@ import (
// corsMaxAge 预检缓存时长12 小时
const corsMaxAge = 12 * 60 * 60
// devCORSOrigins 是未配置 CORS_ORIGINS 时的开发环境默认白名单
const devCORSOrigins = "http://localhost:3000,http://localhost:3001"
// CORS 返回跨域资源共享中间件。
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔,默认 *)。
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔
// 未配置时使用开发环境白名单localhost:3000/3001并打印 warning。
// 允许方法GET POST PUT DELETE OPTIONS PATCH
// 允许头Authorization Content-Type X-Request-Id X-Trace-Id
// 暴露头X-Request-Id X-Trace-Id
func CORS() gin.HandlerFunc {
allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS"))
if len(allowed) == 0 {
log.Println("warning: CORS_ORIGINS not set, using dev default whitelist")
allowed = parseCORSOrigins(devCORSOrigins)
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
allowOrigin := ""
if len(allowed) == 0 {
// 未配置则默认允许所有来源
allowOrigin = "*"
} else if allowed[origin] {
if allowed[origin] {
allowOrigin = origin
}
@@ -39,9 +44,7 @@ func CORS() gin.HandlerFunc {
h.Set("Access-Control-Expose-Headers", "X-Request-Id, X-Trace-Id")
h.Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge))
// 非通配来源需标注 Vary便于缓存正确区分
if allowOrigin != "*" {
h.Add("Vary", "Origin")
}
h.Add("Vary", "Origin")
}
// 预检请求直接返回 204
@@ -53,7 +56,7 @@ func CORS() gin.HandlerFunc {
}
}
// parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map(表示通配 *
// parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map
func parseCORSOrigins(raw string) map[string]bool {
allowed := map[string]bool{}
if raw == "" {

View File

@@ -15,6 +15,7 @@ import (
"github.com/edu-cloud/api-gateway/internal/observability"
"github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
@@ -51,6 +52,8 @@ func main() {
// 健康检查路由(无需鉴权,在 Auth 之前)
r.GET("/healthz", health.Healthz)
r.GET("/readyz", health.Readyz)
// Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// API v1 组:熔断 + 鉴权 + 反向代理
api := r.Group("/api/v1")

View File

@@ -1,8 +1,15 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ClassesModule } from "./classes/classes.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
imports: [ClassesModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
})
export class AppModule {}

View File

@@ -7,11 +7,15 @@ import {
Post,
Put,
Req,
} from '@nestjs/common';
import { ClassesService } from './classes.service.js';
import { createClassSchema, updateClassSchema } from './classes.dto.js';
import type { AuthenticatedRequest } from '../middleware/auth.middleware.js';
import type { Class } from './classes.schema.js';
} from "@nestjs/common";
import { ClassesService } from "./classes.service.js";
import { createClassSchema, updateClassSchema } from "./classes.dto.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import type { Class } from "./classes.schema.js";
interface ClassResponse {
id: string;
@@ -28,11 +32,12 @@ interface SuccessResponse<T> {
data: T;
}
@Controller('classes')
@Controller("classes")
export class ClassesController {
constructor(private readonly service: ClassesService) {}
@Post()
@RequirePermission(Permissions.CLASSES_CREATE)
async create(@Body() body: unknown): Promise<SuccessResponse<ClassResponse>> {
const dto = createClassSchema.parse(body);
const result = await this.service.create(dto);
@@ -40,21 +45,32 @@ export class ClassesController {
}
@Get()
async list(@Req() req: AuthenticatedRequest): Promise<SuccessResponse<ClassResponse[]>> {
const gradeId = req.query.gradeId as string | undefined;
@RequirePermission(Permissions.CLASSES_READ)
async list(
@Req() req: AuthenticatedRequest,
): Promise<SuccessResponse<ClassResponse[]>> {
const gradeIdRaw = req.query.gradeId;
const gradeId = typeof gradeIdRaw === "string" ? gradeIdRaw : undefined;
const result = await this.service.list(gradeId);
return { success: true as const, data: result.map((c) => this.toResponse(c)) };
return {
success: true as const,
data: result.map((c) => this.toResponse(c)),
};
}
@Get(':id')
async getById(@Param('id') id: string): Promise<SuccessResponse<ClassResponse>> {
@Get(":id")
@RequirePermission(Permissions.CLASSES_READ)
async getById(
@Param("id") id: string,
): Promise<SuccessResponse<ClassResponse>> {
const result = await this.service.getById(id);
return { success: true as const, data: this.toResponse(result) };
}
@Put(':id')
@Put(":id")
@RequirePermission(Permissions.CLASSES_UPDATE)
async update(
@Param('id') id: string,
@Param("id") id: string,
@Body() body: unknown,
): Promise<SuccessResponse<ClassResponse>> {
const dto = updateClassSchema.parse(body);
@@ -62,8 +78,9 @@ export class ClassesController {
return { success: true as const, data: this.toResponse(result) };
}
@Delete(':id')
async delete(@Param('id') id: string): Promise<{ success: true }> {
@Delete(":id")
@RequirePermission(Permissions.CLASSES_DELETE)
async delete(@Param("id") id: string): Promise<{ success: true }> {
await this.service.delete(id);
return { success: true as const };
}

View File

@@ -1,14 +1,18 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../config/database.js';
import { classes, type Class, type NewClass } from './classes.schema.js';
import { eq } from "drizzle-orm";
import { getDb } from "../config/database.js";
import { classes, type Class, type NewClass } from "./classes.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js";
export class ClassesRepository {
async create(data: NewClass): Promise<Class> {
const db = getDb();
await db.insert(classes).values(data);
const [result] = await db.select().from(classes).where(eq(classes.id, data.id));
const [result] = await db
.select()
.from(classes)
.where(eq(classes.id, data.id));
if (!result) {
throw new Error('Failed to read created class');
throw new DatabaseError("Failed to read created class");
}
return result;
}
@@ -27,7 +31,10 @@ export class ClassesRepository {
return db.select().from(classes);
}
async update(id: string, data: Partial<NewClass>): Promise<Class | undefined> {
async update(
id: string,
data: Partial<NewClass>,
): Promise<Class | undefined> {
const db = getDb();
await db.update(classes).set(data).where(eq(classes.id, id));
const [result] = await db.select().from(classes).where(eq(classes.id, id));

View File

@@ -1,10 +1,11 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from './env.js';
import { drizzle } from "drizzle-orm/mysql2";
import type { MySql2Database } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null;
export function getDb() {
export function getDb(): MySql2Database {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,

View File

@@ -6,6 +6,7 @@ import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -19,7 +20,7 @@ async function bootstrap(): Promise<void> {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});

View File

@@ -1,5 +1,9 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
@@ -10,15 +14,18 @@ export interface AuthenticatedRequest extends Request {
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
// 从 Gateway 注入的头部读取用户信息
const userId = req.headers['x-user-id'] as string | undefined;
const rolesHeader = req.headers['x-user-roles'] as string | undefined;
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException('Missing x-user-id header');
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(',') : [];
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -1,50 +1,71 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import { PermissionDeniedError } from '../shared/errors/application-error.js';
import type { AuthenticatedRequest } from './auth.middleware.js';
import {
Injectable,
CanActivate,
ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export type Permission =
| 'CLASS_CREATE'
| 'CLASS_READ'
| 'CLASS_UPDATE'
| 'CLASS_DELETE';
"CLASSES_CREATE" | "CLASSES_READ" | "CLASSES_UPDATE" | "CLASSES_DELETE";
export const Permissions = {
CLASS_CREATE: 'CLASS_CREATE' as const,
CLASS_READ: 'CLASS_READ' as const,
CLASS_UPDATE: 'CLASS_UPDATE' as const,
CLASS_DELETE: 'CLASS_DELETE' as const,
CLASSES_CREATE: "CLASSES_CREATE" as const,
CLASSES_READ: "CLASSES_READ" as const,
CLASSES_UPDATE: "CLASSES_UPDATE" as const,
CLASSES_DELETE: "CLASSES_DELETE" as const,
};
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE],
teacher: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE],
student: [Permissions.CLASS_READ],
parent: [Permissions.CLASS_READ],
admin: [
Permissions.CLASSES_CREATE,
Permissions.CLASSES_READ,
Permissions.CLASSES_UPDATE,
Permissions.CLASSES_DELETE,
],
teacher: [
Permissions.CLASSES_CREATE,
Permissions.CLASSES_READ,
Permissions.CLASSES_UPDATE,
],
student: [Permissions.CLASSES_READ],
parent: [Permissions.CLASSES_READ],
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {}
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && perms.includes(this.requiredPermission)) {
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
throw new PermissionDeniedError(this.requiredPermission);
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}
// 工厂函数用于装饰器修复import Reflector 已移至文件顶部)
export function createPermissionGuardFactory(_reflector: Reflector) {
return {
create: (permission: Permission) => new PermissionGuard(permission),
};
}

View File

@@ -1,7 +1,13 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { ZodError } from 'zod';
import { ApplicationError } from './application-error.js';
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";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
@@ -12,7 +18,9 @@ export class GlobalErrorFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'CLASSES_VALIDATION_ERROR',
message: 'Validation failed',
code: "CLASSES_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'HTTP_ERROR',
code: "HTTP_ERROR",
message,
traceId,
},
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body);
}
private extractHttpMessage(res: string | object, exception: HttpException): string {
if (typeof res === 'string') {
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === 'object' && 'message' in res) {
if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}

View File

@@ -1,9 +1,12 @@
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { Redis } from 'ioredis';
import type { Producer } from 'kafkajs';
import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
const SERVICE_NAME = 'classes';
const SERVICE_NAME = "classes";
/**
* 优雅停机服务。
@@ -12,52 +15,31 @@ const SERVICE_NAME = 'classes';
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
*
* 关闭顺序Kafka producer → Redis → DataSource
* 先停外部消息生产避免新事件,再关缓存,最后关 DB
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
*
* 依赖注入 token 约定(需与各服务 provider 注册一致):
* - DataSource由 `TypeOrmModule.forRoot()` 提供。
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
* Classes 服务仅使用 Drizzle ORMMySQL无 Kafka / Redis 依赖
* 关闭时仅需关闭数据库连接池
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name);
constructor(
private readonly dataSource: DataSource,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
) {}
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
);
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
await this.safeDisconnect('redis', () => this.redis.quit());
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
try {
await closeDb();
this.logger.log("database connection closed");
} catch (error) {
this.logger.error(
`database close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
}
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
this.logger.log(`${name} closed`);
} catch (error) {
this.logger.error(
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}

View File

@@ -31,6 +31,7 @@
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"

View File

@@ -1,9 +1,12 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { TextbooksModule } from "./textbooks/textbooks.module.js";
import { ChaptersModule } from "./chapters/chapters.module.js";
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
import { QuestionsModule } from "./questions/questions.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
imports: [
@@ -13,5 +16,9 @@ import { HealthModule } from "./shared/health/health.module.js";
QuestionsModule,
HealthModule,
],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
})
export class AppModule {}

View File

@@ -13,12 +13,17 @@ import {
type UpdateChapterInput,
} from "./chapters.service.js";
import type { Chapter } from "./chapters.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("chapters")
export class ChaptersController {
constructor(private readonly service: ChaptersService) {}
@Post()
@RequirePermission(Permissions.CONTENT_CHAPTER_CREATE)
async create(
@Body() body: CreateChapterInput,
): Promise<{ success: true; data: { id: string } }> {
@@ -27,6 +32,7 @@ export class ChaptersController {
}
@Get("textbook/:textbookId")
@RequirePermission(Permissions.CONTENT_CHAPTER_READ)
async listByTextbook(
@Param("textbookId") textbookId: string,
): Promise<{ success: true; data: Chapter[] }> {
@@ -35,6 +41,7 @@ export class ChaptersController {
}
@Get(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_READ)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: Chapter }> {
@@ -43,6 +50,7 @@ export class ChaptersController {
}
@Put(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateChapterInput,
@@ -52,6 +60,7 @@ export class ChaptersController {
}
@Delete(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_DELETE)
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {

View File

@@ -14,12 +14,17 @@ import {
type PrerequisiteNode,
} from "./knowledge-points.service.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("knowledge-points")
export class KnowledgePointsController {
constructor(private readonly service: KnowledgePointsService) {}
@Post()
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE)
async create(
@Body() body: CreateKnowledgePointInput,
): Promise<{ success: true; data: { id: string } }> {
@@ -28,6 +33,7 @@ export class KnowledgePointsController {
}
@Get("chapter/:chapterId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async listByChapter(
@Param("chapterId") chapterId: string,
): Promise<{ success: true; data: KnowledgePoint[] }> {
@@ -36,6 +42,7 @@ export class KnowledgePointsController {
}
@Get(":id/prerequisites")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async getPrerequisites(
@Param("id") id: string,
): Promise<{ success: true; data: PrerequisiteNode[] }> {
@@ -44,6 +51,7 @@ export class KnowledgePointsController {
}
@Get(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: KnowledgePoint }> {
@@ -52,6 +60,7 @@ export class KnowledgePointsController {
}
@Post(":id/prerequisites/:prerequisiteId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async addPrerequisite(
@Param("id") id: string,
@Param("prerequisiteId") prerequisiteId: string,
@@ -61,6 +70,7 @@ export class KnowledgePointsController {
}
@Put(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateKnowledgePointInput,
@@ -70,6 +80,7 @@ export class KnowledgePointsController {
}
@Delete(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_DELETE)
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {

View File

@@ -8,6 +8,7 @@ import { closeDb } from "./config/database.js";
import { closeNeo4j } from "./config/neo4j.js";
import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -20,8 +21,7 @@ async function bootstrap(): Promise<void> {
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});

View File

@@ -0,0 +1,30 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
userRoles?: string[];
}
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -0,0 +1,105 @@
import {
Injectable,
CanActivate,
ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = {
CONTENT_TEXTBOOK_CREATE: "CONTENT_TEXTBOOK_CREATE" as const,
CONTENT_TEXTBOOK_READ: "CONTENT_TEXTBOOK_READ" as const,
CONTENT_TEXTBOOK_UPDATE: "CONTENT_TEXTBOOK_UPDATE" as const,
CONTENT_TEXTBOOK_DELETE: "CONTENT_TEXTBOOK_DELETE" as const,
CONTENT_CHAPTER_CREATE: "CONTENT_CHAPTER_CREATE" as const,
CONTENT_CHAPTER_READ: "CONTENT_CHAPTER_READ" as const,
CONTENT_CHAPTER_UPDATE: "CONTENT_CHAPTER_UPDATE" as const,
CONTENT_CHAPTER_DELETE: "CONTENT_CHAPTER_DELETE" as const,
CONTENT_QUESTION_CREATE: "CONTENT_QUESTION_CREATE" as const,
CONTENT_QUESTION_READ: "CONTENT_QUESTION_READ" as const,
CONTENT_QUESTION_UPDATE: "CONTENT_QUESTION_UPDATE" as const,
CONTENT_QUESTION_DELETE: "CONTENT_QUESTION_DELETE" as const,
CONTENT_KNOWLEDGE_POINT_CREATE: "CONTENT_KNOWLEDGE_POINT_CREATE" as const,
CONTENT_KNOWLEDGE_POINT_READ: "CONTENT_KNOWLEDGE_POINT_READ" as const,
CONTENT_KNOWLEDGE_POINT_UPDATE: "CONTENT_KNOWLEDGE_POINT_UPDATE" as const,
CONTENT_KNOWLEDGE_POINT_DELETE: "CONTENT_KNOWLEDGE_POINT_DELETE" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.CONTENT_TEXTBOOK_CREATE,
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_TEXTBOOK_UPDATE,
Permissions.CONTENT_TEXTBOOK_DELETE,
Permissions.CONTENT_CHAPTER_CREATE,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_CHAPTER_UPDATE,
Permissions.CONTENT_CHAPTER_DELETE,
Permissions.CONTENT_QUESTION_CREATE,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_QUESTION_UPDATE,
Permissions.CONTENT_QUESTION_DELETE,
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
Permissions.CONTENT_KNOWLEDGE_POINT_DELETE,
],
teacher: [
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_CHAPTER_CREATE,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_CHAPTER_UPDATE,
Permissions.CONTENT_QUESTION_CREATE,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_QUESTION_UPDATE,
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
],
student: [
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
],
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}

View File

@@ -13,12 +13,17 @@ import {
type UpdateQuestionInput,
} from "./questions.service.js";
import type { Question } from "./questions.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("questions")
export class QuestionsController {
constructor(private readonly service: QuestionsService) {}
@Post()
@RequirePermission(Permissions.CONTENT_QUESTION_CREATE)
async create(
@Body() body: CreateQuestionInput,
): Promise<{ success: true; data: { id: string } }> {
@@ -27,6 +32,7 @@ export class QuestionsController {
}
@Get("knowledge-point/:knowledgePointId")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async listByKnowledgePoint(
@Param("knowledgePointId") knowledgePointId: string,
): Promise<{ success: true; data: Question[] }> {
@@ -35,6 +41,7 @@ export class QuestionsController {
}
@Get(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: Question }> {
@@ -43,6 +50,7 @@ export class QuestionsController {
}
@Put(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateQuestionInput,
@@ -52,6 +60,7 @@ export class QuestionsController {
}
@Delete(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_DELETE)
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {

View File

@@ -5,6 +5,7 @@ import {
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@@ -14,13 +15,12 @@ export class GlobalErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例,
// 但 content 服务未引入 @types/express此处按 core-edu 模式不显式标注类型。
const response = ctx.getResponse();
const request = ctx.getRequest();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceIdHeader = request.headers["x-request-id"];
const traceId =
(request.headers["x-request-id"] as string | undefined) ?? "unknown";
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
@@ -30,7 +30,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof ZodError) {
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
statusCode = 400;
body = {
success: false,
@@ -79,7 +78,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
return res;
}
if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}

View File

@@ -1,13 +1,22 @@
import { Injectable, Logger } from "@nestjs/common";
import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
import { closeNeo4j } from "../../config/neo4j.js";
const SERVICE_NAME = "content";
@Injectable()
export class LifecycleService {
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,

View File

@@ -13,12 +13,17 @@ import {
type UpdateTextbookInput,
} from "./textbooks.service.js";
import type { Textbook } from "./textbooks.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("textbooks")
export class TextbooksController {
constructor(private readonly service: TextbooksService) {}
@Post()
@RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE)
async create(
@Body() body: CreateTextbookInput,
): Promise<{ success: true; data: { id: string } }> {
@@ -27,12 +32,14 @@ export class TextbooksController {
}
@Get()
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
async list(): Promise<{ success: true; data: Textbook[] }> {
const data = await this.service.list();
return { success: true, data };
}
@Get(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: Textbook }> {
@@ -41,6 +48,7 @@ export class TextbooksController {
}
@Put(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateTextbookInput,
@@ -50,6 +58,7 @@ export class TextbooksController {
}
@Delete(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_DELETE)
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {

View File

@@ -1,10 +1,17 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ExamsModule } from "./exams/exams.module.js";
import { HomeworkModule } from "./homework/homework.module.js";
import { GradesModule } from "./grades/grades.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
})
export class AppModule {}

View File

@@ -8,23 +8,32 @@ import {
Put,
Req,
} from "@nestjs/common";
import type { Request } from "express";
import {
ExamsService,
type CreateExamInput,
type UpdateExamInput,
} from "./exams.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("exams")
export class ExamsController {
constructor(private readonly examsService: ExamsService) {}
@Post()
@RequirePermission(Permissions.EXAM_CREATE)
async create(
@Body() body: CreateExamInput,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.examsService.createExam({
...body,
createdBy: userId,
@@ -33,6 +42,7 @@ export class ExamsController {
}
@Get(":id")
@RequirePermission(Permissions.EXAM_READ)
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<ExamsService["getExam"]>>;
@@ -42,6 +52,7 @@ export class ExamsController {
}
@Get("class/:classId")
@RequirePermission(Permissions.EXAM_READ)
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>;
@@ -51,6 +62,7 @@ export class ExamsController {
}
@Put(":id")
@RequirePermission(Permissions.EXAM_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateExamInput,
@@ -60,6 +72,7 @@ export class ExamsController {
}
@Delete(":id")
@RequirePermission(Permissions.EXAM_DELETE)
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {

View File

@@ -1,17 +1,26 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { GradesService, type RecordGradeInput } from "./grades.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("grades")
export class GradesController {
constructor(private readonly gradesService: GradesService) {}
@Post()
@RequirePermission(Permissions.GRADE_CREATE)
async record(
@Body() body: RecordGradeInput,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.gradesService.recordGrade({
...body,
gradedBy: userId,
@@ -20,6 +29,7 @@ export class GradesController {
}
@Get(":id")
@RequirePermission(Permissions.GRADE_READ)
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["getGrade"]>>;
@@ -29,6 +39,7 @@ export class GradesController {
}
@Get("student/:studentId")
@RequirePermission(Permissions.GRADE_READ)
async listByStudent(@Param("studentId") studentId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByStudent"]>>;
@@ -38,6 +49,7 @@ export class GradesController {
}
@Get("exam/:examId")
@RequirePermission(Permissions.GRADE_READ)
async listByExam(@Param("examId") examId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByExam"]>>;
@@ -47,6 +59,7 @@ export class GradesController {
}
@Get("homework/:homeworkId")
@RequirePermission(Permissions.GRADE_READ)
async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByHomework"]>>;

View File

@@ -1,20 +1,29 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import {
HomeworkService,
type AssignHomeworkInput,
} from "./homework.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("homework")
export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {}
@Post()
@RequirePermission(Permissions.HOMEWORK_CREATE)
async assign(
@Body() body: AssignHomeworkInput,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.homeworkService.assignHomework({
...body,
createdBy: userId,
@@ -23,6 +32,7 @@ export class HomeworkController {
}
@Get(":id")
@RequirePermission(Permissions.HOMEWORK_READ)
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<HomeworkService["getHomework"]>>;
@@ -32,6 +42,7 @@ export class HomeworkController {
}
@Get("class/:classId")
@RequirePermission(Permissions.HOMEWORK_READ)
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<HomeworkService["listByClass"]>>;
@@ -41,6 +52,7 @@ export class HomeworkController {
}
@Post(":id/submit")
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
async submit(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {

View File

@@ -7,6 +7,7 @@ import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js";
import { registry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -16,8 +17,7 @@ async function bootstrap(): Promise<void> {
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", registry.contentType);
res.end(await registry.metrics());
});

View File

@@ -2,39 +2,29 @@ import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import type { Request, Response, NextFunction } from 'express';
export interface AuthenticatedUser {
id: string;
role: string;
permissions: string[];
}
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
userId?: string;
userRoles?: string[];
}
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userId = req.headers['x-user-id'] as string | undefined;
const role = req.headers['x-user-role'] as string | undefined;
const permissionsHeader = req.headers['x-user-permissions'] as
| string
| undefined;
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId || !role) {
throw new UnauthorizedException(
'Missing authentication headers (x-user-id, x-user-role)',
);
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
req.user = {
id: userId,
role,
permissions: permissionsHeader ? permissionsHeader.split(',') : [],
};
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -1,48 +1,92 @@
import {
Injectable,
CanActivate,
ExecutionContext,
Injectable,
ForbiddenException,
} from '@nestjs/common';
import type { AuthenticatedRequest } from './auth.middleware.js';
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = {
EXAM_CREATE: 'exam:create',
EXAM_READ: 'exam:read',
EXAM_UPDATE: 'exam:update',
EXAM_DELETE: 'exam:delete',
HOMEWORK_CREATE: 'homework:create',
HOMEWORK_READ: 'homework:read',
HOMEWORK_UPDATE: 'homework:update',
HOMEWORK_DELETE: 'homework:delete',
HOMEWORK_GRADE: 'homework:grade',
HOMEWORK_SUBMIT: 'homework:submit',
GRADE_CREATE: 'grade:create',
GRADE_READ: 'grade:read',
GRADE_UPDATE: 'grade:update',
GRADE_DELETE: 'grade:delete',
CLASS_MANAGE: 'class:manage',
CLASS_READ: 'class:read',
CLASS_TRANSFER: 'class:transfer',
EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const,
EXAM_READ: "CORE_EDU_EXAM_READ" as const,
EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const,
EXAM_DELETE: "CORE_EDU_EXAM_DELETE" as const,
HOMEWORK_CREATE: "CORE_EDU_HOMEWORK_CREATE" as const,
HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const,
HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const,
HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT" as const,
GRADE_CREATE: "CORE_EDU_GRADE_CREATE" as const,
GRADE_READ: "CORE_EDU_GRADE_READ" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_UPDATE,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_CREATE,
Permissions.GRADE_READ,
],
teacher: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_UPDATE,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_CREATE,
Permissions.GRADE_READ,
],
student: [
Permissions.EXAM_READ,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_READ,
],
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {}
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
if (!user) {
throw new ForbiddenException('User not authenticated');
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
if (!user.permissions.includes(this.requiredPermission)) {
throw new ForbiddenException(
`Missing permission: ${this.requiredPermission}`,
);
}
return true;
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}

View File

@@ -1,25 +1,45 @@
export enum CoreEduErrorCode {
VALIDATION_ERROR = 'CORE_EDU_VALIDATION_ERROR',
NOT_FOUND = 'CORE_EDU_NOT_FOUND',
UNAUTHORIZED = 'CORE_EDU_UNAUTHORIZED',
FORBIDDEN = 'CORE_EDU_FORBIDDEN',
CONFLICT = 'CORE_EDU_CONFLICT',
INTERNAL_ERROR = 'CORE_EDU_INTERNAL_ERROR',
EXAM_NOT_FOUND = 'CORE_EDU_EXAM_NOT_FOUND',
HOMEWORK_NOT_FOUND = 'CORE_EDU_HOMEWORK_NOT_FOUND',
GRADE_NOT_FOUND = 'CORE_EDU_GRADE_NOT_FOUND',
OUTBOX_PUBLISH_FAILED = 'CORE_EDU_OUTBOX_PUBLISH_FAILED',
VALIDATION_ERROR = "CORE_EDU_VALIDATION_ERROR",
NOT_FOUND = "CORE_EDU_NOT_FOUND",
UNAUTHORIZED = "CORE_EDU_UNAUTHORIZED",
FORBIDDEN = "CORE_EDU_FORBIDDEN",
CONFLICT = "CORE_EDU_CONFLICT",
INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR",
EXAM_NOT_FOUND = "CORE_EDU_EXAM_NOT_FOUND",
HOMEWORK_NOT_FOUND = "CORE_EDU_HOMEWORK_NOT_FOUND",
GRADE_NOT_FOUND = "CORE_EDU_GRADE_NOT_FOUND",
OUTBOX_PUBLISH_FAILED = "CORE_EDU_OUTBOX_PUBLISH_FAILED",
}
export class ApplicationError extends Error {
readonly code: CoreEduErrorCode;
readonly statusCode: number;
readonly details?: unknown;
traceId?: string;
constructor(
public readonly code: CoreEduErrorCode,
code: CoreEduErrorCode,
message: string,
public readonly statusCode: number = 500,
public readonly details?: unknown,
statusCode: number = 500,
details?: unknown,
) {
super(message);
this.name = 'ApplicationError';
this.name = this.constructor.name;
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
details: this.details,
traceId: this.traceId,
},
};
}
}
@@ -36,17 +56,25 @@ export class NotFoundError extends ApplicationError {
}
export class UnauthorizedError extends ApplicationError {
constructor(message: string = 'Unauthorized') {
constructor(message: string = "Unauthorized") {
super(CoreEduErrorCode.UNAUTHORIZED, message, 401);
}
}
export class ForbiddenError extends ApplicationError {
constructor(message: string = 'Forbidden') {
constructor(message: string = "Forbidden") {
super(CoreEduErrorCode.FORBIDDEN, message, 403);
}
}
export class PermissionDeniedError extends ApplicationError {
constructor(permission: string) {
super(CoreEduErrorCode.FORBIDDEN, `Permission denied: ${permission}`, 403, {
permission,
});
}
}
export class ConflictError extends ApplicationError {
constructor(message: string, details?: unknown) {
super(CoreEduErrorCode.CONFLICT, message, 409, details);
@@ -54,7 +82,7 @@ export class ConflictError extends ApplicationError {
}
export class InternalError extends ApplicationError {
constructor(message: string = 'Internal server error', details?: unknown) {
constructor(message: string = "Internal server error", details?: unknown) {
super(CoreEduErrorCode.INTERNAL_ERROR, message, 500, details);
}
}

View File

@@ -1,63 +1,95 @@
import {
ExceptionFilter,
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { ZodError } from 'zod';
import { ApplicationError, CoreEduErrorCode } from './application-error.js';
import { logger } from '../observability/logger.js';
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let code = CoreEduErrorCode.INTERNAL_ERROR;
let message = 'Internal server error';
let details: unknown;
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;
code = exception.code;
message = exception.message;
details = exception.details;
body = exception.toJSON();
} else if (exception instanceof ZodError) {
statusCode = HttpStatus.BAD_REQUEST;
code = CoreEduErrorCode.VALIDATION_ERROR;
message = 'Validation failed';
details = exception.flatten().fieldErrors;
statusCode = 400;
body = {
success: false,
error: {
code: "CORE_EDU_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const resp = exception.getResponse();
message =
typeof resp === 'string'
? resp
: (resp as { message?: string }).message ?? exception.message;
} else if (exception instanceof Error) {
message = exception.message;
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: "HTTP_ERROR",
message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
}
logger.error(
this.logger.error(
{
err: exception,
path: request.url,
method: request.method,
code,
},
`Request failed: ${message}`,
`Request failed: ${request.method} ${request.url}`,
);
response.status(statusCode).json({
code,
message,
details,
timestamp: new Date().toISOString(),
path: request.url,
});
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

@@ -1,12 +1,21 @@
import { Injectable, Logger } from "@nestjs/common";
import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
const SERVICE_NAME = "core-edu";
@Injectable()
export class LifecycleService {
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,

View File

@@ -5,6 +5,7 @@
保证服务在 ClickHouse 不可用时仍可启动并响应骨架数据。
"""
import asyncio
from datetime import datetime
from typing import Any
@@ -68,7 +69,7 @@ async def close_client() -> None:
global _client, _client_initialized
if _client is not None:
try:
_client.close()
await asyncio.to_thread(_client.close)
except Exception as exc: # noqa: BLE001
logger.warning("clickhouse_client_close_failed", error=str(exc))
finally:
@@ -86,7 +87,8 @@ async def query_dashboard(student_id: str) -> dict | None:
return None
try:
rows = client.query(
result = await asyncio.to_thread(
client.query,
"SELECT student_id, class_id, exam_id, subject_id, score, "
"rank_in_class, knowledge_point_id, mastery_level, error_count, "
"last_updated "
@@ -95,7 +97,8 @@ async def query_dashboard(student_id: str) -> dict | None:
"ORDER BY last_updated DESC "
"LIMIT 50",
parameters={"sid": student_id},
).result_rows
)
rows = result.result_rows
except Exception as exc: # noqa: BLE001
logger.warning("query_dashboard_failed_degraded", error=str(exc), student_id=student_id)
return None
@@ -131,7 +134,8 @@ async def query_class_performance(class_id: str) -> dict | None:
try:
# 平均分、参考人数、及格率(>=60
agg_rows = client.query(
result = await asyncio.to_thread(
client.query,
"SELECT "
" count() AS total_students, "
" avg(score) AS average_score, "
@@ -139,7 +143,8 @@ async def query_class_performance(class_id: str) -> dict | None:
"FROM student_dashboard_view "
"WHERE class_id = {cid:String}",
parameters={"cid": class_id},
).result_rows
)
agg_rows = result.result_rows
except Exception as exc: # noqa: BLE001
logger.warning(
"query_class_performance_failed_degraded",
@@ -175,7 +180,8 @@ async def query_student_errors(student_id: str) -> list[dict] | None:
return None
try:
rows = client.query(
result = await asyncio.to_thread(
client.query,
"SELECT student_id, question_id, knowledge_point_id, error_count, "
"last_error_time, content "
"FROM student_errors "
@@ -183,7 +189,8 @@ async def query_student_errors(student_id: str) -> list[dict] | None:
"ORDER BY last_error_time DESC "
"LIMIT 100",
parameters={"sid": student_id},
).result_rows
)
rows = result.result_rows
except Exception as exc: # noqa: BLE001
logger.warning(
"query_student_errors_failed_degraded",
@@ -212,7 +219,7 @@ async def ping() -> bool:
if client is None:
return False
try:
client.query("SELECT 1")
await asyncio.to_thread(client.query, "SELECT 1")
return True
except Exception as exc: # noqa: BLE001
logger.warning("clickhouse_ping_failed", error=str(exc))
@@ -241,7 +248,8 @@ async def upsert_student_dashboard(
return False
try:
client.insert(
await asyncio.to_thread(
client.insert,
"student_dashboard_view",
[
[
@@ -305,7 +313,8 @@ async def upsert_student_error(
return False
try:
client.insert(
await asyncio.to_thread(
client.insert,
"student_errors",
[
[

View File

@@ -23,8 +23,8 @@ class Settings(BaseSettings):
# 可观测性
otel_endpoint: str = "http://localhost:4318"
log_level: str = "info"
# 开发模式开关"true"/"false"
dev_mode: str = "false"
# 开发模式开关
dev_mode: bool = False
# Kafka brokersCDC 消费;留空则不启动消费者)
# 主机访问用 localhost:9092容器内访问用 kafka:29092
kafka_brokers: str = ""

View File

@@ -9,11 +9,12 @@
import asyncio
import contextlib
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import structlog
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
@@ -89,7 +90,7 @@ def init_tracer() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期.
1. 初始化 loggerstructlog
@@ -133,6 +134,9 @@ FastAPIInstrumentor.instrument_app(app)
# Prometheus 指标
app.mount("/metrics", make_asgi_app())
# 业务路由
router = APIRouter()
@app.get("/healthz")
async def healthz() -> dict:
@@ -189,7 +193,7 @@ async def readyz() -> dict:
}
@app.get("/analytics/class/{class_id}/performance")
@router.get("/analytics/class/{class_id}/performance")
async def class_performance(class_id: str) -> dict:
"""班级成绩分析.
@@ -215,7 +219,7 @@ async def class_performance(class_id: str) -> dict:
return {"success": True, "data": {**result, "degraded": False}}
@app.get("/analytics/student/{student_id}/weakness")
@router.get("/analytics/student/{student_id}/weakness")
async def student_weakness(student_id: str) -> dict:
"""学生薄弱知识点分析.
@@ -259,7 +263,7 @@ async def student_weakness(student_id: str) -> dict:
}
@app.get("/analytics/student/{student_id}/errorbook")
@router.get("/analytics/student/{student_id}/errorbook")
async def student_errorbook(student_id: str) -> dict:
"""学生错题本.
@@ -290,3 +294,6 @@ async def student_errorbook(student_id: str) -> dict:
"degraded": False,
},
}
app.include_router(router)

View File

@@ -0,0 +1,241 @@
# 模块理解确认书 — iam
> AIai02TS / 身份认证)
> 阶段:阶段 1 交付物
> 日期2026-07-09
> 关联:[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[AI 分配方案](../../../docs/architecture/ai-allocation.md)、[pending-features P2](../../../docs/architecture/roadmap/pending-features.md)
---
## 1. 我在架构中的位置
- **层级**L5 业务微服务层004 §3.1 六层架构)
- **业务领域****D1 身份认证领域**004 §1.1b),独立限界上下文,不归属其他领域
- **上游(谁调用我)**
- `api-gateway`Go/GinHTTP 反向代理 `/api/v1/iam/*``http://iam:3002/iam/*`(已注册,见 [api-gateway/main.go](../../api-gateway/main.go) 第 76-83 行)
- `teacher-bff`NestJSHTTP 调用 `/iam/me``/iam/viewports` 聚合教师身份与视口(见 [teacher-bff/teacher.service.ts](../../teacher-bff/src/teacher/teacher.service.ts) 第 30/78 行)
- 未来:`student-bff``parent-bff`(同样走 HTTP 聚合)
- **下游(我调用谁)**
- `MySQL`(独占库 `iam_db`,连接串 `DATABASE_URL`
- `Redis`P2 待启用:权限缓存 + refresh token 黑名单env 已留 `REDIS_URL` 占位)
- `Kafka`P2 待启用:发布 `edu.identity.user.*` 事件,由 core-edu/msg 消费)
- **通信方式**
- 入口:**HTTP/REST**(当前实现,端口 3002
- 出口MySQLmysql2 连接池 + Drizzle ORM、Redis待接、Kafka待接
- 演进P3 起对外暴露 gRPC`iam.proto` 已定义 `IamService`REST 与 gRPC 并存过渡期)
- **不持有跨服务状态**:会话/token 黑名单放 Redis不落本地内存
## 2. 我的限界上下文
### 2.1 我负责的聚合 / 实体
| 聚合根 | 实体 / 值对象 | 当前表 | 职责 |
| ----------------------------------- | --------------------- | ---------------------- | ------------------------------------ |
| **User**(用户) | UserStatus、DataScope | `iam_users` | 注册、登录、密码校验、用户信息查询 |
| **Role**(角色) | — | `iam_roles` | 三层角色模型(系统/组织/临时)的载体 |
| **Permission**(权限点) | resource + action | `iam_permissions` | 权限点常量集中表 |
| **RolePermission**(角色-权限映射) | — | `iam_role_permissions` | 多对多,权限并集来源 |
| **UserRole**(用户-角色绑定) | — | `iam_user_roles` | 用户拥有的多层角色 |
| **RoleViewport**(角色-视口配置) | componentConfig(JSON) | `iam_role_viewports` | 4 层视口模型的 L1 导航 + L3 组件配置 |
| **RefreshToken**(刷新令牌) | tokenHash + revokedAt | `iam_refresh_tokens` | refresh token 持久化 + 轮换/撤销 |
### 2.2 业务领域归属
- **D1 身份认证领域**004 §1.1biam 独占此领域
- 上游依赖:无(身份认证是整个系统的权限中枢,不依赖其他业务领域)
- 下游被依赖core-edu消费 `UserRegistered` 初始化默认班级关联、msg消费 `UserRegistered` 发欢迎通知)
### 2.3 我不负责什么(边界外)
- ❌ 班级/学科/年级数据 → `core-edu`D2 教学组织)
- ❌ 考试/作业/成绩 → `core-edu`D3 教学核心)
- ❌ 教材/知识点/题库 → `content`D4 内容资源)
- ❌ 站内信/通知投递 → `msg`D5 沟通通知)
- ❌ 学情分析/掌握度计算 → `data-ana`D6 智能洞察)
- ❌ WebSocket 长连接管理 → `push-gateway`
- ❌ JWT 公钥校验 → `api-gateway`iam 只签发,不校验)
- ❌ 前端权限 Hook → `packages/ui-components/hooks/use-permission.ts`(前端通过 BFF 拉取,不直连 iam
## 3. 我与外部的契约
### 3.1 我消费的 proto message从 shared-proto
- 当前**不消费**任何外部 protoiam 是身份认证源头,不反向依赖业务服务)
- 未来 P3 转 gRPC 时,消费 `google/protobuf/timestamp.proto`(时间字段标准化)
### 3.2 我暴露的契约
#### 当前 REST API已实现见 [iam.controller.ts](../src/iam/iam.controller.ts)、[rbac.controller.ts](../src/iam/rbac.controller.ts)
| Method | Path | 权限 | 说明 |
| ------ | ---------------------------- | ----------------- | ------------------------------------- |
| POST | `/iam/register` | 公开 | 注册(自动分配 teacher 角色) |
| POST | `/iam/login` | 公开 | 登录,返回 accessToken + refreshToken |
| POST | `/iam/refresh` | 公开 | 刷新令牌 |
| GET | `/iam/me` | `IAM_USER_READ` | 当前用户信息(读 `x-user-id` 头) |
| GET | `/iam/viewports` | `IAM_USER_READ` | 当前用户视口L1 导航,按权限过滤) |
| GET | `/iam/permissions/effective` | `IAM_USER_READ` | 当前用户有效权限(多角色去重) |
| GET | `/iam/roles` | `IAM_ROLE_MANAGE` | 所有角色列表(管理端) |
| GET | `/iam/permissions` | `IAM_ROLE_MANAGE` | 所有权限点列表(管理端) |
#### 健康检查([health.controller.ts](../src/shared/health/health.controller.ts)
| Method | Path | 鉴权 | 用途 |
| ------ | ---------- | ---- | ---------------------------------------------------------------- |
| GET | `/healthz` | 无 | liveness进程存活 |
| GET | `/readyz` | 无 | readiness校验 DB `SELECT 1`,失败 503 |
| GET | `/metrics` | 无 | Prometheus 指标抓取([main.ts](../src/main.ts) 第 23-26 行注册) |
#### Proto 契约([iam.proto](../../../packages/shared-proto/proto/iam.proto)
```protobuf
package next_edu_cloud.iam.v1;
service IamService {
rpc Register(RegisterRequest) returns (AuthResponse);
rpc Login(LoginRequest) returns (AuthResponse);
rpc RefreshToken(RefreshTokenRequest) returns (TokenPair);
rpc GetUserInfo(GetUserInfoRequest) returns (UserInfo);
}
```
> **缺口**proto 仅定义 4 个 RPCREST 已实现的 `viewports`、`permissions/effective`、`roles`、`permissions` 未在 proto 中体现。P2 阶段 2 设计文档需补齐 `GetViewports`、`GetEffectivePermissions`、`ListRoles`、`ListPermissions`、`Logout`、`GetPublicKey`RS256 公钥暴露端点)等 RPC。
#### 我发布的领域事件P2 待实现 Outbox
| 事件 | 触发时机 | Topic遵循 004 §7.2 | 消费者 |
| ------------------------------ | ------------- | -------------------------------- | ------------------------------------------------- |
| `UserRegistered` | 注册成功 | `edu.identity.user.created` | core-edu初始化默认班级关联、msg发欢迎通知 |
| `UserUpdated` | 用户信息变更 | `edu.identity.user.updated` | core-edu、msg |
| `UserDeleted` / `UserDisabled` | 用户注销/禁用 | `edu.identity.user.deleted` | core-edu、msg清理关联 |
| `UserRoleChanged` | 角色绑定变更 | `edu.identity.user.role_changed` | 自身缓存失效、msg审计 |
> **Topic 命名遵循 004 §7.2**`edu.identity.user.created` / `edu.identity.user.updated`。
#### 我消费的事件
- 当前:**无**
- 未来不主动消费业务事件iam 是权限中枢,不订阅其他领域事件)
### 3.3 错误码前缀
- **前缀**`IAM_`(见 [application-error.ts](../src/shared/errors/application-error.ts)
- 已定义错误码:
| 错误码 | HTTP | 触发条件 |
| ----------------------- | ---- | ---------------------------- |
| `IAM_VALIDATION_ERROR` | 400 | Zod 校验失败 / 字段非法 |
| `IAM_UNAUTHORIZED` | 401 | 未登录、密码错误、token 失效 |
| `IAM_PERMISSION_DENIED` | 403 | 缺少所需权限点 |
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限不存在 |
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复 |
| `IAM_BUSINESS_ERROR` | 422 | 业务规则违反(如账号禁用) |
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
> **全局错误格式**`{ success: false, error: { code, message, details?, traceId } }`,由 [GlobalErrorFilter](../src/shared/errors/global-error.filter.ts) 统一兜底traceId 从 `x-request-id` 头注入。
## 4. 我的技术栈
| 维度 | 选型 | 版本 | 备注 |
| -------- | --------------------- | -------------- | ------------------------------------------------------------------------------- |
| 语言 | TypeScriptESM | 5.6+ | `tsconfig.json``NodeNext` + `incremental: false`(避免 nest watch 不 emit |
| 框架 | NestJS | 10.4+ | 装饰器 + DI`@nestjs/platform-express` |
| ORM | Drizzle ORMmysql2 | 0.31+ | `getDb()` 单例池,无 typeorm DataSource 依赖 |
| 数据库 | MySQL 8 | — | 独占库 `iam_db`,连接池 connectionLimit=10 |
| 缓存 | RedisP2 待接) | 7 | 权限缓存 TTL 5min + refresh token 黑名单 |
| 密码哈希 | bcrypt | 5.1+ | cost ≥ 12已对齐 project_rules §4 |
| JWT | jsonwebtoken | 9.0+ | **当前 HS256P2 必须切 RS256** |
| 日志 | pino | 9.4+ | 结构化 JSON`service: iam` base 字段 |
| 指标 | prom-client | 15.1+ | `iam_requests_total` + `iam_request_duration_seconds` |
| 链路 | OpenTelemetry SDK | 0.53+ | auto-instrumentations + OTLP HTTP exporter |
| 输入校验 | Zod | 3.23+ | Controller 层 `schema.parse(body)` |
| 测试 | Vitest | 2.1+ | **当前缺失P2 必须补齐** |
| 容器 | Dockerfile 多阶段 | node:20-alpine | builder + runtimeEXPOSE 3002 |
## 5. 我的阶段归属
- **阶段****P2 身份**M4-M6
- **当前阶段目标**pending-features §P2
1. ✅ 已实现骨架:注册/登录/刷新/me/viewports/effective permissions/roles/permissions 列表
2.**RS256 非对称签名**(当前仍是 HS256env.JWT_SECRET 单密钥)
3.**refresh_token 轮换 + Redis 黑名单失效**(当前只存 hash未实现撤销检查
4.**权限缓存**`getEffectivePermissions` 结果 Redis 缓存 TTL 5min角色变更主动失效
5.**Outbox 事件发布**`UserRegistered` / `UserUpdated` / `UserRoleChanged`
6.**2FA**pending-features §P2 提及但优先级低)
7.**补全数据表**`parent_student_relations``class_subject_teachers`pending-features §P2 schema 清单)
8.**gRPC 化**iam.proto 已定义 4 RPC但服务尚未实现 gRPC server
9.**测试覆盖**classes 有 `test/unit/classes.service.test.ts`iam 无任何测试)
10.**RBAC CRUD 完整化**(当前只有读,缺角色/权限/视口的增删改)
- **依赖的上游阶段产出**
- P1 ✅ api-gateway已注册 `/iam/*` 路由,已透传 `x-user-id`/`x-user-roles` 头)
- P1 ✅ classes 黄金模板(横切关注点模板:错误处理/可观测/健康检查/优雅关闭)
- P1 ✅ shared-protoiam.proto 已定义基础 4 RPC
- **P2 退出标准**pending-features §P2 + 004 §14.2
- 教师登录 → 获取 JWTRS256→ 访问 teacher-portal → 侧边栏按 `viewports.L1` 渲染 → 看到空白 Dashboard
- 打 tag `v0.2.0-p2`
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
> 参照 [classes 黄金模板](../../classes/src/) 全部源码与 [known-issues §2.2 classes](../../../docs/troubleshooting/known-issues.md) 经验。
| 对齐项 | classes 状态 | iam 当前状态 | iam 待补齐 |
| --------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| **权限装饰器 `@RequirePermission`** | ✅ 每个 Handler 都有 | ⚠️ Handler 有装饰器,但 `PermissionGuard` 用本地硬编码 `ROLE_PERMISSIONS` mapadmin/teacher未走 DB 查询 | P2 改为 DB 驱动Guard 调用 `IamService.getEffectivePermissions(userId)`(从 Redis 缓存读取) |
| **错误码前缀统一** | ✅ `CLASSES_*` | ✅ `IAM_*` | 无 |
| **loggerpino** | ✅ `shared/observability/logger.ts` | ✅ 同模板 | 无 |
| **metricsprom-client** | ✅ `*_requests_total` + `*_request_duration_seconds` | ✅ `iam_requests_total` + `iam_request_duration_seconds` | 无 |
| **tracerOTel** | ✅ auto-instrumentations + OTLP | ✅ 同模板 | 无 |
| **`/healthz` + `/readyz`** | ✅ Drizzle `SELECT 1` | ✅ 同模板 | 无 |
| **优雅关闭SIGTERM** | ✅ `LifecycleService` 关闭 DB 池 | ✅ 同模板 | P2 接入 Redis/Kafka 后需补关闭顺序HTTP → Kafka → Redis → DB |
| **测试覆盖率 ≥ 80%** | ✅ `test/unit/classes.service.test.ts` | ❌ **完全缺失** | P2 必须补齐:`test/unit/iam.service.test.ts``test/unit/iam.repository.test.ts``test/unit/permission.guard.test.ts` |
| **Dockerfile 多阶段构建** | ✅ builder + runtime | ✅ 同模板 | 无 |
| **Zod 输入验证** | ✅ Controller 层 `schema.parse(body)` | ✅ 同模板 | 无 |
| **GlobalErrorFilter** | ✅ 注册到 `main.ts` | ✅ 同模板 | 无 |
| **ESM `.js` 后缀** | ✅ 相对 import 带 `.js` | ✅ 同模板 | 无 |
| **`tsconfig.json` incremental: false** | ✅ | ✅ | 无 |
| **AppModule 显式 imports HealthModule** | ✅ | ✅ | 无 |
| **Drizzle `getDb()` 单例** | ✅ | ✅ | 无 |
| **Controller 读 `x-user-id` 头** | ✅ | ✅ | 无 |
### 6.1 当前 iam 与 classes 黄金模板的差异点(需在阶段 2 设计文档中明确处理)
1. **PermissionGuard 数据源**classes 用本地 `ROLE_PERMISSIONS` map够用因为 classes 权限点固定iam **必须改为 DB 驱动**——因为 iam 自身就是权限中枢,权限点/RBAC 是动态的,硬编码会导致角色变更不生效。
2. **AuthMiddleware 未注册**[app.module.ts](../src/app.module.ts) 只注册了 `PermissionGuard` 作为 `APP_GUARD``AuthMiddleware` 未在 `AppModule.configure()` 中消费。Controller 直接从 `req.headers['x-user-id']` 读取,这是 known-issues §2.3 记录的决策。P2 设计文档需明确:要么注册 AuthMiddleware要么继续走 header 直读(当前选择后者,与 Gateway 透传策略一致)。
3. **JWT 签名算法**classes 不签发 JWT只校验iam 是**唯一签发方**,必须切 RS256 并暴露公钥端点 `/iam/.well-known/jwks.json``/iam/public-key`
4. **Outbox 模式**classes 无 OutboxP3 才引入iam P2 需要引入 Outbox 发布用户事件——需要 coord 在 shared-ts 中提供 Outbox 工具或 iam 自建(参照 core-edu P3 设计)。
## 7. 服务审计表ai02 自检)
> 依照 ai-allocation §10 模板,对当前 iam 已实现代码进行审计。
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ---- | ---------- | ---------- | ------ | ------- | ------ | -------- | ------- | -------- | ---------- | ---------- |
| iam | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 0% ❌ | ✅ |
**说明**
- 权限装饰器 ⚠️:装饰器已挂载到每个 Handler但 Guard 用本地硬编码 `ROLE_PERMISSIONS`,未走 DB与 iam 作为权限中枢的职责矛盾)
- 测试覆盖率 0%[services/iam/](../) 下无 `test/` 目录Vitest 配置缺失classes 有 `vitest.config.ts` + `test/unit/`
---
## 8. 待 coord 交叉审查的关键决策点
以下决策需在阶段 2 设计文档中明确,并在 coord 交叉审查时确认:
1. **RS256 密钥管理**P2 用本地文件 `/opt/edu/keys/iam-private.pem` + `/opt/edu/keys/iam-public.pem`,还是引入 VaultP6 才落地)?建议 P2 用本地文件 + 环境变量 `IAM_PRIVATE_KEY_PATH`P6 迁 Vault。
2. **公钥暴露端点**:用 `GET /iam/.well-known/jwks.json`JWK Set 标准,支持密钥轮换)还是 `GET /iam/public-key`(简单 PEM建议前者为未来密钥轮换留余地。
3. **权限缓存失效策略**:角色变更时主动 `DEL iam:perms:{userId}`,还是发 `UserRoleChanged` 事件让消费者自行失效?建议两者都做:本地服务内主动 DEL同步事件供其他服务感知。
4. **Outbox 实现位置**iam 自建 `iam_outbox` 表 + relay worker还是复用 shared-ts 提供的通用 Outbox 工具?**需 coord 确认 shared-ts 是否在 P2 提供 Outbox 工具**。若未提供iam 自建轻量 Outbox参照 core-edu P3 设计,但要先于 core-edu 落地)。
5. **gRPC 与 REST 并存策略**P2 是否同步实现 gRPC server还是 P2 仅 RESTP3 再补 gRPC建议 P2 仅 REST保证退出标准达成gRPC server 在 P3 随 core-edu 一起引入(此时 shared-ts 也有 gRPC 工具沉淀)。
6. **DataScope 枚举对齐**:当前 schema 用 `self/class/grade/school/district/all`004 §5.3 用 `L0-SELF` ~ `L5-ALL`。建议保持 schema 字符串枚举DB 友好),在 API/proto 层用 `L0`~`L5` 数值枚举映射,业务代码用语义常量。
7. **parent_student_relations 表归属**pending-features §P2 schema 清单把此表放在 iam但 004 §5.4 提到家长场景域由 parent-bff 聚合。需确认:此表是 iam 管理(家长-学生关系是身份关系),还是 core-edu 管理(教学组织关系)?**建议归 iam**(身份关系优先于教学组织)。
8. **`class_subject_teachers` 表归属**:同上,此表横跨 iam教师身份与 core-edu班级/学科。pending-features §P2 放 iam但 core-edu 的 classes 模块也需要。**建议归 core-edu**(班级-学科-教师是教学组织数据iam 只存 `userId` 与角色,不存具体任教关系。
---
**AI Agent**: ai02 (iam-module)
**Branch**: main单仓库并行模式见 ai-allocation §9.1
**Coordinator**: coord-ai

View File

@@ -0,0 +1,718 @@
# 模块架构设计文档 — iam
> AIai02TS / 身份认证)
> 阶段:阶段 2 交付物
> 日期2026-07-09
> 关联:[阶段 1 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[pending-features P2](../../../docs/architecture/roadmap/pending-features.md)
> 状态:待 coord 交叉审查
---
## 1. 模块内部分层图
### 1.1 调用链总览
```mermaid
flowchart TB
subgraph Client["客户端 / Gateway / BFF"]
Req[HTTP 请求<br/>带 x-user-id / x-user-roles 头]
end
subgraph NestJS["iam 服务NestJS"]
direction TB
MW[AuthMiddleware<br/>❌ 当前未注册P2 仍走 header 直读]
Guard[PermissionGuard<br/>APP_GUARD 全局守卫]
Filter[GlobalErrorFilter<br/>全局异常过滤器]
subgraph Controllers["Controller 层"]
IamCtl[IamController<br/>/iam/register, login, refresh, me]
RbacCtl[RbacController<br/>/iam/viewports, permissions, roles]
UserCtl[UserController<br/>P2 新增: 用户 CRUD]
RoleCtl[RoleController<br/>P2 新增: 角色 CRUD]
ViewportCtl[ViewportController<br/>P2 新增: 视口 CRUD]
JwksCtl[JwksController<br/>P2 新增: RS256 公钥暴露]
end
subgraph Services["Application Service 层"]
IamSvc[IamService<br/>认证编排]
RbacSvc[RbacService<br/>RBAC 编排]
UserSvc[UserService<br/>用户领域编排]
CacheSvc[PermissionCacheService<br/>Redis 权限缓存]
end
subgraph Domain["Domain 层P2 轻量)"]
UserEntity[UserEntity<br/>聚合根]
RoleEntity[RoleEntity<br/>聚合根]
end
subgraph Repo["Repository 层"]
IamRepo[IamRepository<br/>Drizzle 查询]
RbacRepo[RbacRepository<br/>Drizzle 查询]
end
subgraph Outbox["Outbox 模块"]
OutboxTbl[(iam_outbox 表)]
Relay[OutboxRelayWorker<br/>后台轮询 + Kafka 投递]
end
subgraph Infra["基础设施"]
Db[(MySQL<br/>iam_db)]
Redis[(Redis<br/>权限缓存 + token 黑名单)]
Kafka[(Kafka<br/>edu.identity.user.* topic)]
end
end
Req --> MW
MW --> Guard
Guard --> Controllers
Controllers --> Services
Services --> Domain
Services --> Repo
Services --> CacheSvc
Repo --> Db
CacheSvc --> Redis
Services --> OutboxTbl
OutboxTbl --> Relay
Relay --> Kafka
Controllers -.异常.-> Filter
```
### 1.2 中间件 / Guard / Filter 拦截顺序
```
请求进入
→ AuthMiddlewareP2 仍不注册Controller 直读 header
→ PermissionGuardAPP_GUARDDEV_MODE 旁路 + DB 驱动权限校验)
→ Controller HandlerZod 校验 body
→ Application Service业务编排
→ RepositoryDrizzle 查询)
→ 异常抛出
→ GlobalErrorFilter统一兜底注入 traceId
→ 响应返回
```
### 1.3 目录结构P2 目标态)
```
services/iam/src/
├─ iam/ # 限界上下文:认证
│ ├─ iam.controller.ts # 认证端点register/login/refresh/me/logout
│ ├─ iam.service.ts # 认证编排
│ ├─ iam.repository.ts # 用户/refresh_token 查询
│ ├─ iam.schema.ts # users / refresh_tokens 表
│ ├─ iam.dto.ts # Zod schema
│ └─ domain/
│ └─ user.entity.ts # UserEntity 聚合根P2 新增)
├─ rbac/ # 限界上下文RBACP2 从 iam/ 拆出)
│ ├─ rbac.controller.ts # 角色/权限/视口查询端点
│ ├─ role.controller.ts # 角色 CRUDP2 新增)
│ ├─ permission.controller.ts # 权限点 CRUDP2 新增)
│ ├─ viewport.controller.ts # 视口配置 CRUDP2 新增)
│ ├─ rbac.service.ts # RBAC 编排
│ ├─ rbac.repository.ts # 角色/权限/视口查询
│ ├─ rbac.schema.ts # roles / permissions / role_permissions / role_viewports 表
│ └─ domain/
│ └─ role.entity.ts # RoleEntity 聚合根P2 新增)
├─ jwks/ # 限界上下文JWT 公钥暴露P2 新增)
│ ├─ jwks.controller.ts # GET /iam/.well-known/jwks.json
│ ├─ jwks.service.ts # 密钥加载 + JWK Set 生成
│ └─ jwks.repository.ts # 密钥元数据持久化(可选)
├─ cache/ # 限界上下文Redis 缓存P2 新增)
│ ├─ permission-cache.service.ts # getEffectivePermissions 缓存
│ └─ token-blacklist.service.ts # refresh token 黑名单
├─ outbox/ # Outbox 模式P2 新增)
│ ├─ outbox.schema.ts # iam_outbox 表
│ ├─ outbox.publisher.ts # 写入 outbox事务内
│ └─ outbox.relay-worker.ts # 后台轮询 + Kafka 投递
├─ config/
│ ├─ database.ts # Drizzle 池(已有)
│ ├─ redis.ts # Redis 客户端P2 新增)
│ ├─ jwt.ts # RS256 密钥加载P2 新增)
│ └─ env.ts # 环境变量P2 扩展)
├─ middleware/
│ ├─ auth.middleware.ts # 保留P2 仍不注册)
│ └─ permission.guard.ts # 改造DB 驱动 + 缓存
├─ shared/
│ ├─ errors/ # 已有
│ ├─ health/ # 已有
│ ├─ lifecycle/ # 改造:关闭顺序 HTTP→Kafka→Redis→DB
│ └─ observability/ # 已有
├─ app.module.ts # 改造imports 新增 OutboxModule、CacheModule、JwksModule
└─ main.ts # 改造:启动 OutboxRelayWorker
```
## 2. 领域模型
### 2.1 聚合根与实体
```mermaid
classDiagram
class UserEntity {
-id: string
-email: string
-passwordHash: string
-name: string
-status: UserStatus
-dataScope: DataScope
-createdAt: Date
-updatedAt: Date
+create(props) UserEntity$
+rename(name) UserRenamedEvent
+disable() UserDisabledEvent
+changeDataScope(scope) UserDataScopeChangedEvent
+verifyPassword(plain) bool
}
class RoleEntity {
-id: string
-name: string
-description: string?
-roleType: RoleType
+create(props) RoleEntity$
+rename(name) RoleRenamedEvent
}
class Permission {
+id: string
+name: string
+resource: string
+action: string
}
class RoleViewport {
+id: string
+roleId: string
+viewportKey: string
+label: string
+route: string
+sortOrder: string
+requiredPermission: string?
+componentConfig: string?
}
class RefreshToken {
+id: string
+userId: string
+tokenHash: string
+expiresAt: Date
+revokedAt: Date?
+isRevoked() bool
+isExpired() bool
}
UserEntity "1" --> "many" RefreshToken : 拥有
RoleEntity "1" --> "many" Permission : 通过 role_permissions
RoleEntity "1" --> "many" RoleViewport : 配置
```
### 2.2 值对象(枚举)
```typescript
enum UserStatus {
ACTIVE = "active",
DISABLED = "disabled",
PENDING = "pending", // P2 新增:注册后待激活
}
enum DataScope {
SELF = "self", // L0
CLASS = "class", // L1
GRADE = "grade", // L2
SCHOOL = "school", // L3
DISTRICT = "district", // L4
ALL = "all", // L5
}
enum RoleType {
// P2 新增:三层角色模型
SYSTEM = "system", // 系统预设admin/teacher/student/parent
ORGANIZATION = "organization", // 组织分配(年级组长/班主任/学科组长)
TEMPORARY = "temporary", // 临时授权(代课教师)
}
```
### 2.3 聚合间通信
- **同服务内**`IamService` 直接调用 `RbacService``PermissionCacheService`NestJS DI
- **跨服务**:通过 Kafka 事件Outbox 发布),不直接调用其他服务
## 3. 数据模型
### 3.1 表清单P2 目标态)
#### 3.1.1 已有表(保留)
| 表名 | 用途 | 主键 | 唯一索引 |
| ---------------------- | -------------------- | ----------------------------- | ----------------------- |
| `iam_users` | 用户主表 | `id` (char36) | `email` |
| `iam_roles` | 角色表 | `id` | `name` |
| `iam_user_roles` | 用户-角色绑定 | `(userId, roleId)` 复合 | — |
| `iam_permissions` | 权限点表 | `id` | `name` |
| `iam_role_permissions` | 角色-权限映射 | `(roleId, permissionId)` 复合 | — |
| `iam_refresh_tokens` | refresh token 持久化 | `id` | — |
| `iam_role_viewports` | 角色-视口配置 | `id` | `(roleId, viewportKey)` |
#### 3.1.2 P2 新增表
| 表名 | 用途 | 主键 | 唯一索引 |
| ------------------------------ | ------------------------------- | ---- | ----------------------- |
| `iam_outbox` | Outbox 事件表(事务内写入) | `id` | — |
| `iam_parent_student_relations` | 家长-学生关系表 | `id` | `(parentId, studentId)` |
| `iam_user_sessions` | 用户会话记录(审计 + 强制下线) | `id` | `userId + deviceHash` |
> **注**`class_subject_teachers` 表归属 core-edu见 §8.1 决策点 8不在 iam。
#### 3.1.3 P2 表结构定义
```typescript
// iam_outboxOutbox 事件表
export const iamOutbox = mysqlTable(
"iam_outbox",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
aggregateId: char("aggregate_id", { length: 36 }).notNull(),
aggregateType: varchar("aggregate_type", { length: 50 }).notNull(), // 'User' | 'Role'
eventType: varchar("event_type", { length: 100 }).notNull(), // 'UserRegistered' | ...
payload: text("payload").notNull(), // JSON 序列化
topic: varchar("topic", { length: 100 }).notNull(), // 'edu.identity.user.created'
status: mysqlEnum("status", ["pending", "published", "failed"])
.notNull()
.default("pending"),
retryCount: int("retry_count").notNull().default(0),
occurredAt: timestamp("occurred_at").notNull().defaultNow(),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
statusIdx: index("idx_outbox_status").on(table.status), // relay 轮询用
aggregateIdx: index("idx_outbox_aggregate").on(table.aggregateId),
}),
);
// iam_parent_student_relations家长-学生关系
export const parentStudentRelations = mysqlTable(
"iam_parent_student_relations",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
parentId: char("parent_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
relation: varchar("relation", { length: 20 }).notNull(), // 'father' | 'mother' | 'guardian'
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
parentStudentUniq: uniqueIndex("uniq_parent_student").on(
table.parentId,
table.studentId,
),
studentIdx: index("idx_student").on(table.studentId),
}),
);
// iam_roles 表扩展:新增 role_type 字段
// 在现有 iam_roles 表 ALTER ADD:
// role_type ENUM('system','organization','temporary') NOT NULL DEFAULT 'system'
// level INT NOT NULL DEFAULT 0 -- 三层优先级system=0(最高) / organization=1 / temporary=2
```
### 3.2 索引策略
| 表 | 索引 | 用途 |
| ------------------------------ | -------------------------------------------------- | -------------------------------- |
| `iam_users` | PK(`id`)、UNIQUE(`email`) | 主键查询、登录查询 |
| `iam_user_roles` | INDEX(`userId`)、INDEX(`roleId`) | 按用户查角色、按角色查用户 |
| `iam_role_permissions` | INDEX(`roleId`)、INDEX(`permissionId`) | 按角色查权限 |
| `iam_refresh_tokens` | INDEX(`userId`)、INDEX(`tokenHash`) | 按用户查 token、按 hash 校验 |
| `iam_role_viewports` | INDEX(`roleId`) | 按角色查视口 |
| `iam_outbox` | INDEX(`status`)、INDEX(`aggregateId`) | relay 轮询 pending、按聚合查事件 |
| `iam_parent_student_relations` | UNIQUE(`parentId`,`studentId`)、INDEX(`studentId`) | 防重、按学生查家长 |
### 3.3 读写分离策略
- **写路径**:所有 Command 走 MySQL 主库iam 独占库,无读写分离)
- **读路径**P2 暂不引入 ClickHouse 读模型iam 读多写少但数据量小MySQL 足够)
- **缓存层**`getEffectivePermissions` / `getUserViewports` 结果走 Redis 缓存TTL 5min
## 4. API 设计
### 4.1 REST API 完整清单P2 目标态)
| Method | Path | 权限 | 请求体 / 参数 | 响应 | 说明 |
| ------ | ------------------------------------------ | ----------------- | ---------------------------------- | ----------------- | -------------------------------------- |
| POST | `/iam/register` | 公开 | `{email, password, name}` | `{user, tokens}` | 注册 + 自动分配 teacher 角色 |
| POST | `/iam/login` | 公开 | `{email, password}` | `{user, tokens}` | 登录 |
| POST | `/iam/refresh` | 公开 | `{refreshToken}` | `{tokens}` | 刷新令牌(轮换 + 旧 token 加入黑名单) |
| POST | `/iam/logout` | `IAM_USER_READ` | `{refreshToken}` | `{success}` | 登出refresh token 加黑名单) |
| GET | `/iam/me` | `IAM_USER_READ` | — | `{user}` | 当前用户信息 |
| GET | `/iam/viewports` | `IAM_USER_READ` | — | `{viewports[]}` | 当前用户视口L1 导航) |
| GET | `/iam/permissions/effective` | `IAM_USER_READ` | — | `{permissions[]}` | 当前用户有效权限 |
| GET | `/iam/.well-known/jwks.json` | 公开 | — | `{keys[]}` | RS256 公钥 JWK SetGateway 拉取) |
| GET | `/iam/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 角色列表 |
| POST | `/iam/roles` | `IAM_ROLE_MANAGE` | `{name, description, roleType}` | `{role}` | 创建角色 |
| PUT | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | `{name?, description?}` | `{role}` | 更新角色 |
| DELETE | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除角色(系统角色禁止删) |
| GET | `/iam/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 权限点列表 |
| GET | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 用户角色列表 |
| POST | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | `{roleId}` | `{success}` | 给用户分配角色 |
| DELETE | `/iam/users/:id/roles/:roleId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除用户角色(触发缓存失效) |
| GET | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 角色权限列表 |
| POST | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | `{permissionId}` | `{success}` | 给角色授予权限 |
| DELETE | `/iam/roles/:id/permissions/:permissionId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除角色权限(触发缓存失效) |
| GET | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | — | `{viewports[]}` | 角色视口列表 |
| POST | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | `{viewportKey, label, route, ...}` | `{viewport}` | 创建视口配置 |
| PUT | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | `{label?, route?, ...}` | `{viewport}` | 更新视口配置 |
| DELETE | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除视口配置 |
| GET | `/iam/users/:id/parents` | `IAM_USER_READ` | — | `{parents[]}` | 学生家长列表(家长-学生关系) |
| POST | `/iam/users/:studentId/parents` | `IAM_ROLE_MANAGE` | `{parentId, relation}` | `{success}` | 绑定家长-学生关系 |
### 4.2 请求/响应结构示例
```typescript
// 注册响应
interface RegisterResponse {
success: true;
data: {
user: {
id: string;
email: string;
name: string;
roles: string[]; // ['teacher']
permissions: string[]; // ['IAM_USER_READ', 'CLASSES_READ', ...]
dataScope: "self" | "class" | "grade" | "school" | "district" | "all";
};
tokens: {
accessToken: string; // RS256 签名15min
refreshToken: string; // RS256 签名7day
expiresIn: 900; // 秒
};
};
}
// JWK Set 响应RS256 公钥暴露)
interface JwkSet {
keys: Array<{
kty: "RSA";
use: "sig";
alg: "RS256";
kid: string; // 密钥 ID支持轮换
n: string; // modulus base64url
e: string; // exponent base64url
}>;
}
```
### 4.3 JWT PayloadRS256 签发)
```typescript
interface JwtPayload {
sub: string; // userId
email: string;
roles: string[]; // ['teacher', 'grade_leader']
dataScope: DataScope; // 'class' | 'grade' | ...
type: "access" | "refresh";
iat: number; // 签发时间
exp: number; // 过期时间
iss: "next-edu-cloud"; // 签发者
aud: "next-edu-cloud"; // 受众
jti: string; // JWT ID用于黑名单
}
```
## 5. 事件设计
### 5.1 我发布的领域事件
| 事件 | 触发时机 | Topic | 消费者动作 |
| ----------------- | ------------------------------ | -------------------------------- | -------------------------------------------------- |
| `UserRegistered` | 注册成功 | `edu.identity.user.created` | core-edu 初始化默认班级关联msg 发欢迎通知 |
| `UserUpdated` | 用户信息变更name/dataScope | `edu.identity.user.updated` | core-edu 同步用户快照msg 通知 |
| `UserDisabled` | 用户禁用/注销 | `edu.identity.user.deleted` | core-edu 解除关联msg 通知push-gateway 强制下线 |
| `UserRoleChanged` | 用户角色绑定变更 | `edu.identity.user.role_changed` | 自身 Redis 缓存失效msg 审计日志 |
| `RoleCreated` | 角色创建 | `edu.identity.role.created` | msg 审计(仅管理端关注) |
| `RoleUpdated` | 角色权限变更 | `edu.identity.role.updated` | 所有该角色用户的缓存失效msg 审计 |
### 5.2 事件 Schema建议 coord 在 shared-proto 中统一定义)
```protobuf
// 建议在 packages/shared-proto/proto/events.proto 新增:
message UserEvent {
string event_id = 1; // UUID幂等去重
string aggregate_id = 2; // userId
string event_type = 3; // 'UserRegistered' | 'UserUpdated' | ...
int64 occurred_at = 4; // 发生时间戳ms
string user_id = 5;
string email = 6;
string name = 7;
repeated string roles = 8;
string data_scope = 9;
string action = 10; // 'created' | 'updated' | 'disabled' | 'role_changed'
map<string, string> metadata = 11; // trace_id 等
}
message RoleEvent {
string event_id = 1;
string aggregate_id = 2; // roleId
string event_type = 3;
int64 occurred_at = 4;
string role_id = 5;
string role_name = 6;
string action = 7; // 'created' | 'updated' | 'deleted'
map<string, string> metadata = 8;
}
```
> **需 coord 在 shared-proto/events.proto 中统一定义**iam 只负责填充字段并写入 outbox。
### 5.3 我消费的事件
- **当前**:无
- **未来**不主动消费业务事件iam 是权限中枢,单向发布)
### 5.4 Outbox 实现策略
```mermaid
sequenceDiagram
participant Ctl as Controller
participant Svc as IamService
participant DB as MySQL
participant Outbox as iam_outbox 表
participant Relay as OutboxRelayWorker
participant Kafka as Kafka
Ctl->>Svc: register(dto)
Svc->>DB: BEGIN TX
Svc->>DB: INSERT iam_users
Svc->>DB: INSERT iam_user_roles
Svc->>Outbox: INSERT event (status=pending)
Svc->>DB: COMMIT TX
Svc-->>Ctl: {user, tokens}
loop 每 100ms 轮询
Relay->>Outbox: SELECT * WHERE status='pending' LIMIT 100
Outbox-->>Relay: events[]
Relay->>Kafka: produce(topic, payload)
Kafka-->>Relay: ack
Relay->>Outbox: UPDATE status='published', published_at=NOW()
end
```
**Relay Worker 实现**
- 独立 `@Injectable()` 服务,`OnModuleInit` 启动轮询
- 每 100ms 查询 `status='pending'` 的事件,批量投递 Kafka
- 投递失败重试 3 次,超过后标记 `status='failed'`,记录日志
- Kafka 未启动时不阻塞主服务try/catch + 日志警告)
## 6. 横切关注点对齐清单
### 6.1 权限装饰器(所有端点及对应权限常量)
| 端点 | 权限常量 |
| ----------------------------------------------- | ----------------- |
| POST /iam/register | 公开(无装饰器) |
| POST /iam/login | 公开 |
| POST /iam/refresh | 公开 |
| GET /iam/.well-known/jwks.json | 公开 |
| POST /iam/logout | `IAM_USER_READ` |
| GET /iam/me | `IAM_USER_READ` |
| GET /iam/viewports | `IAM_USER_READ` |
| GET /iam/permissions/effective | `IAM_USER_READ` |
| GET /iam/users/:id/parents | `IAM_USER_READ` |
| GET /iam/roles | `IAM_ROLE_MANAGE` |
| POST /iam/roles | `IAM_ROLE_MANAGE` |
| PUT /iam/roles/:id | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id | `IAM_ROLE_MANAGE` |
| GET /iam/permissions | `IAM_ROLE_MANAGE` |
| GET /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
| POST /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
| DELETE /iam/users/:id/roles/:roleId | `IAM_ROLE_MANAGE` |
| GET /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
| POST /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id/permissions/:permissionId | `IAM_ROLE_MANAGE` |
| GET /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
| POST /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
| PUT /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
| POST /iam/users/:studentId/parents | `IAM_ROLE_MANAGE` |
**权限常量清单**P2 完整化):
```typescript
export const Permissions = {
// 用户管理
IAM_USER_CREATE: "IAM_USER_CREATE",
IAM_USER_READ: "IAM_USER_READ",
IAM_USER_UPDATE: "IAM_USER_UPDATE",
IAM_USER_DELETE: "IAM_USER_DELETE",
// 角色管理
IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE",
// 视口管理
IAM_VIEWPORT_MANAGE: "IAM_VIEWPORT_MANAGE",
// 家长-学生关系管理
IAM_RELATION_MANAGE: "IAM_RELATION_MANAGE",
} as const;
```
### 6.2 错误码清单(带前缀)
| 错误码 | HTTP | 触发条件 |
| ----------------------- | ---- | ---------------------------------------------------- |
| `IAM_VALIDATION_ERROR` | 400 | Zod 校验失败 |
| `IAM_UNAUTHORIZED` | 401 | 未登录、密码错误、token 失效、refresh token 在黑名单 |
| `IAM_PERMISSION_DENIED` | 403 | 缺少所需权限点 |
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限/视口不存在 |
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复、家长-学生关系已存在 |
| `IAM_BUSINESS_ERROR` | 422 | 账号禁用、系统角色禁止删除、refresh token 已撤销 |
| `IAM_RATE_LIMITED` | 429 | 登录失败次数过多P2 可选,限流在 Gateway |
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
| `IAM_OUTBOX_ERROR` | 500 | Outbox 写入或投递失败 |
### 6.3 Logger 初始化位置与配置
- **位置**`src/shared/observability/logger.ts`(已有)
- **配置**pino`base: { service: 'iam', version: '0.1.0' }``level: env.LOG_LEVEL`
- **P2 新增**:日志中注入 `traceId`(从 `x-request-id` 头读取OTel auto-instrumentation 已覆盖)
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| ------------------------------------- | --------- | ------------------------ | ------------------------------ |
| `iam_requests_total` | Counter | method, endpoint, status | 请求总数(已有) |
| `iam_request_duration_seconds` | Histogram | method, endpoint | 请求延迟(已有) |
| `iam_login_attempts_total` | Counter | result(success/failure) | 登录尝试次数P2 新增) |
| `iam_login_duration_seconds` | Histogram | — | 登录耗时P2 新增) |
| `iam_jwt_issued_total` | Counter | type(access/refresh) | JWT 签发次数P2 新增) |
| `iam_permission_cache_hits_total` | Counter | — | 权限缓存命中P2 新增) |
| `iam_permission_cache_misses_total` | Counter | — | 权限缓存未命中P2 新增) |
| `iam_outbox_pending` | Gauge | — | Outbox 待投递事件数P2 新增) |
| `iam_outbox_publish_duration_seconds` | Histogram | — | Outbox 投递耗时P2 新增) |
### 6.5 Tracer 初始化位置
- **位置**`src/shared/observability/tracer.ts`(已有)
- **配置**NodeSDK + OTLP HTTP exporter`serviceName: 'iam'`
- **P2 保持**auto-instrumentations 覆盖 HTTP/Express/Drizzlemysql2
### 6.6 /healthz 检查逻辑
- **端点**`GET /healthz`
- **逻辑**:仅返回进程存活,不检查依赖
- **响应**`{ status: 'ok', service: 'iam', timestamp: ISO }`
### 6.7 /readyz 检查逻辑P2 改造)
- **端点**`GET /readyz`
- **逻辑**P2 新增 Redis + Kafka 检查):
```typescript
async readiness() {
const checks = await Promise.allSettled([
this.checkDb(), // db.execute(sql`SELECT 1`)
this.checkRedis(), // redis.ping()
this.checkKafka(), // kafka.admin().listTopics()(轻量探活)
]);
const allOk = checks.every(r => r.status === 'fulfilled');
if (!allOk) throw 503;
return { status: 'ok', service: 'iam', timestamp, checks };
}
```
- **失败**HTTP 503响应体含失败项详情
### 6.8 优雅关闭顺序P2 改造)
```typescript
async onApplicationShutdown(signal?: string) {
// 1. 停止接收新请求NestJS 自动)
// 2. 停止 OutboxRelayWorker停止轮询
await this.relayWorker.stop();
// 3. 关闭 Kafka producer
await this.kafkaProducer.disconnect();
// 4. 关闭 Redis 连接
await this.redisClient.quit();
// 5. 关闭 MySQL 连接池
await closeDb();
// 6. 关闭 Tracer
await shutdownTracer();
}
```
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ----------- | ----- | ------------------------------------------- | -------------------------------- |
| 被调用 | api-gateway | HTTP | `POST /iam/register` 等 | Gateway 反向代理 `/api/v1/iam/*` |
| 被调用 | teacher-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | BFF 聚合用户身份与视口 |
| 被调用 | student-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | 同上P3 |
| 被调用 | parent-bff | HTTP | `GET /iam/me`、`GET /iam/users/:id/parents` | 同上 + 家长-学生关系P4 |
| 被调用 | api-gateway | HTTP | `GET /iam/.well-known/jwks.json` | Gateway 拉取 RS256 公钥校验 JWT |
| 发布 | — | Kafka | `edu.identity.user.created` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.updated` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.deleted` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.role_changed` | 自身缓存失效 / msg 审计 |
| 发布 | — | Kafka | `edu.identity.role.created` | msg 审计 |
| 发布 | — | Kafka | `edu.identity.role.updated` | 自身缓存失效 / msg 审计 |
| 消费 | — | — | — | iam 不消费外部事件 |
### 7.1 端口分配
| 服务 | 端口 | 备注 |
| -------- | ----- | ------------------------------------------------------------ |
| iam HTTP | 3002 | 已有REST 入口 |
| iam gRPC | 50052 | P3 引入(与 core-edu 50053 等区分,需 coord 全局端口表确认) |
### 7.2 Topic 命名(遵循 004 §7.2
- `edu.identity.user.created`
- `edu.identity.user.updated`
- `edu.identity.user.deleted`
- `edu.identity.user.role_changed`
- `edu.identity.role.created`
- `edu.identity.role.updated`
> **需 coord 在全局 Topic 表中登记**,避免与 core-edu 的 `edu.identity.user.created` 冲突004 §7.2 已记录 iam 为生产者)。
## 8. 风险与假设
### 8.1 架构决策点(需 coord 仲裁)
| # | 决策点 | 我的建议 | 风险 |
| --- | ------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 1 | RS256 密钥管理 | P2 本地文件 `IAM_PRIVATE_KEY_PATH` / `IAM_PUBLIC_KEY_PATH`P6 迁 Vault | 密钥文件权限管理;容器挂载 |
| 2 | 公钥暴露端点 | `GET /iam/.well-known/jwks.json`JWK Set 标准) | Gateway 需实现 JWK 解析 |
| 3 | 权限缓存失效 | 角色变更时本地主动 `DEL iam:perms:{userId}` + 发事件 | 缓存与 DB 短暂不一致(< 5min TTL |
| 4 | Outbox 实现 | iam 自建轻量 OutboxP2 先于 core-edu 落地) | 与 core-edu P3 Outbox 模式需对齐(建议 coord 在 shared-ts 提供通用工具) |
| 5 | gRPC 引入时机 | P2 仅 RESTP3 随 core-edu 引入 gRPC server | teacher-bff P2 仍走 HTTPP3 改 gRPC 需协调 ai03 |
| 6 | DataScope 枚举 | schema 用字符串枚举proto 用数值枚举 L0-L5 映射 | 跨层映射需文档化 |
| 7 | `parent_student_relations` 归属 | 归 iam身份关系优先 | core-edu 查询家长需走 iam API |
| 8 | `class_subject_teachers` 归属 | 归 core-edu教学组织数据 | iam 只存 userId + 角色,不存任教关系 |
| 9 | PermissionGuard 改造 | DB 驱动 + Redis 缓存,废弃本地 ROLE_PERMISSIONS map | 性能:每次请求多一次缓存查询 |
| 10 | AuthMiddleware 注册 | P2 仍不注册Controller 直读 header与 Gateway 透传策略一致) | 多 Controller 重复读 header 代码 |
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| -------------------- | ---------------------------------- | -------------------------------------------------------- |
| Redis 不可用 | 权限校验回退到 DB 查询,性能下降 | `getEffectivePermissions` catch 后走 DB不抛错 |
| Kafka 不可用 | Outbox 事件积压,下游服务感知延迟 | Relay Worker 重试 + `status='failed'` 记录,不阻塞主流程 |
| RS256 密钥泄露 | 任何人可伪造 JWT | 密钥文件权限 600 + 容器 Secret 挂载 + 定期轮换P6 |
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 事件驱动失效 + 短 TTL |
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天)或归档到 ClickHouse |
### 8.3 假设
- 假设 coord 在 shared-proto/events.proto 中统一定义 `UserEvent` / `RoleEvent` messageiam 只填充字段)
- 假设 api-gateway 实现 JWK Set 拉取与 RS256 公钥校验ai01 负责)
- 假设 teacher-bff 仍走 HTTP 调用 iamai03 负责P3 改 gRPC 时协调)
- 假设 shared-ts 在 P2 不提供通用 Outbox 工具iam 自建P3 core-edu 落地后回写到 shared-ts
### 8.4 未决问题(需 coord 回复)
1. shared-ts 是否在 P2 提供通用 Outbox 工具?还是 iam 自建?
2. events.proto 中 `UserEvent` / `RoleEvent` 由 coord 统一定义,还是 iam 提交 PR
3. iam gRPC 端口 50052 是否与全局端口表冲突?
4. `class_subject_teachers` 表归属确认(我建议归 core-edupending-features §P2 写在 iam
---
**AI Agent**: ai02 (iam-module)
**Branch**: main单仓库并行模式见 ai-allocation §9.1
**Coordinator**: coord-ai

View File

@@ -1,8 +1,15 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { IamModule } from "./iam/iam.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
imports: [IamModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
})
export class AppModule {}

View File

@@ -1,10 +1,11 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from './env.js';
import { drizzle } from "drizzle-orm/mysql2";
import type { MySql2Database } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null;
export function getDb() {
export function getDb(): MySql2Database {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,

View File

@@ -1,37 +1,53 @@
import { Body, Controller, Get, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { IamService } from "./iam.service.js";
import type { TokenPair, UserInfo } from "./iam.service.js";
import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("iam")
export class IamController {
constructor(private readonly service: IamService) {}
// 公开端点:注册,不设权限校验
@Post("register")
async register(@Body() body: unknown) {
async register(
@Body() body: unknown,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = registerSchema.parse(body);
const result = await this.service.register(dto);
return { success: true as const, data: result };
}
// 公开端点:登录,不设权限校验
@Post("login")
async login(@Body() body: unknown) {
async login(
@Body() body: unknown,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = loginSchema.parse(body);
const result = await this.service.login(dto);
return { success: true as const, data: result };
}
// 公开端点:刷新令牌,不设权限校验
@Post("refresh")
async refresh(@Body() body: unknown) {
async refresh(
@Body() body: unknown,
): Promise<{ success: true; data: TokenPair }> {
const dto = refreshTokenSchema.parse(body);
const tokens = await this.service.refresh(dto.refreshToken);
return { success: true as const, data: tokens };
}
@Get("me")
async me(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
@RequirePermission(Permissions.IAM_USER_READ)
async me(@Req() req: Request): Promise<{ success: true; data: UserInfo }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}

View File

@@ -10,6 +10,7 @@ import {
roleViewports,
} from "./iam.schema.js";
import type { User, Role, Permission, RoleViewport } from "./iam.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js";
export class IamRepository {
async createUser(data: {
@@ -22,7 +23,7 @@ export class IamRepository {
await db.insert(users).values(data);
const [result] = await db.select().from(users).where(eq(users.id, data.id));
if (!result) {
throw new Error("Failed to create user");
throw new DatabaseError("Failed to create user");
}
return result;
}

View File

@@ -92,7 +92,11 @@ export class IamService {
async refresh(refreshToken: string): Promise<TokenPair> {
let payload: jwt.JwtPayload;
try {
payload = jwt.verify(refreshToken, env.JWT_SECRET) as jwt.JwtPayload;
const decoded = jwt.verify(refreshToken, env.JWT_SECRET);
if (typeof decoded === "string") {
throw new UnauthorizedError("Invalid refresh token");
}
payload = decoded;
} catch {
throw new UnauthorizedError("Invalid refresh token");
}
@@ -101,9 +105,14 @@ export class IamService {
throw new UnauthorizedError("Invalid token type");
}
const user = await this.repository.findUserById(payload.sub as string);
const sub = typeof payload.sub === "string" ? payload.sub : undefined;
if (!sub) {
throw new UnauthorizedError("Invalid token subject");
}
const user = await this.repository.findUserById(sub);
if (!user) {
throw new NotFoundError("User", payload.sub as string);
throw new NotFoundError("User", sub);
}
return this.issueTokens(user).then((r) => r.tokens);

View File

@@ -1,7 +1,13 @@
import { Controller, Get, Req } from "@nestjs/common";
import type { Request } from "express";
import { IamService } from "./iam.service.js";
import type { ViewportItem } from "./iam.service.js";
import type { Role, Permission } from "./iam.schema.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
// RBAC 管理端点:角色/权限/视口查询
@Controller("iam")
@@ -10,8 +16,12 @@ export class RbacController {
// 获取当前用户的视口配置L1 导航)
@Get("viewports")
async viewports(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
@RequirePermission(Permissions.IAM_USER_READ)
async viewports(
@Req() req: Request,
): Promise<{ success: true; data: ViewportItem[] }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
@@ -21,8 +31,12 @@ export class RbacController {
// 获取当前用户的有效权限
@Get("permissions/effective")
async effectivePermissions(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
@RequirePermission(Permissions.IAM_USER_READ)
async effectivePermissions(
@Req() req: Request,
): Promise<{ success: true; data: { permissions: string[] } }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
@@ -32,14 +46,16 @@ export class RbacController {
// 列出所有角色(管理端用)
@Get("roles")
async roles() {
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
async roles(): Promise<{ success: true; data: Role[] }> {
const data = await this.service.getAllRoles();
return { success: true as const, data };
}
// 列出所有权限点(管理端用)
@Get("permissions")
async permissions() {
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
async permissions(): Promise<{ success: true; data: Permission[] }> {
const data = await this.service.getAllPermissions();
return { success: true as const, data };
}

View File

@@ -6,6 +6,7 @@ import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -19,7 +20,7 @@ async function bootstrap(): Promise<void> {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});

View File

@@ -1,5 +1,9 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
@@ -10,15 +14,18 @@ export interface AuthenticatedRequest extends Request {
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
// 从 Gateway 注入的头部读取用户信息
const userId = req.headers['x-user-id'] as string | undefined;
const rolesHeader = req.headers['x-user-roles'] as string | undefined;
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException('Missing x-user-id header');
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(',') : [];
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -1,23 +1,32 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import { PermissionDeniedError } from '../shared/errors/application-error.js';
import type { AuthenticatedRequest } from './auth.middleware.js';
import {
Injectable,
CanActivate,
ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export type Permission =
| 'IAM_USER_CREATE'
| 'IAM_USER_READ'
| 'IAM_USER_UPDATE'
| 'IAM_USER_DELETE'
| 'IAM_ROLE_MANAGE';
| "IAM_USER_CREATE"
| "IAM_USER_READ"
| "IAM_USER_UPDATE"
| "IAM_USER_DELETE"
| "IAM_ROLE_MANAGE";
export const Permissions = {
IAM_USER_CREATE: 'IAM_USER_CREATE' as const,
IAM_USER_READ: 'IAM_USER_READ' as const,
IAM_USER_UPDATE: 'IAM_USER_UPDATE' as const,
IAM_USER_DELETE: 'IAM_USER_DELETE' as const,
IAM_ROLE_MANAGE: 'IAM_ROLE_MANAGE' as const,
IAM_USER_CREATE: "IAM_USER_CREATE" as const,
IAM_USER_READ: "IAM_USER_READ" as const,
IAM_USER_UPDATE: "IAM_USER_UPDATE" as const,
IAM_USER_DELETE: "IAM_USER_DELETE" as const,
IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE" as const,
};
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.IAM_USER_CREATE,
@@ -31,26 +40,32 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {}
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && perms.includes(this.requiredPermission)) {
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
throw new PermissionDeniedError(this.requiredPermission);
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}
// 工厂函数,用于装饰器
export function createPermissionGuardFactory(_reflector: Reflector) {
return {
create: (permission: Permission) => new PermissionGuard(permission),
};
}

View File

@@ -1,7 +1,13 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { ZodError } from 'zod';
import { ApplicationError } from './application-error.js';
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";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
@@ -12,7 +18,9 @@ export class GlobalErrorFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'IAM_VALIDATION_ERROR',
message: 'Validation failed',
code: "IAM_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'HTTP_ERROR',
code: "HTTP_ERROR",
message,
traceId,
},
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body);
}
private extractHttpMessage(res: string | object, exception: HttpException): string {
if (typeof res === 'string') {
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === 'object' && 'message' in res) {
if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}

View File

@@ -32,6 +32,7 @@
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.6.0",

View File

@@ -1,10 +1,15 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { NotificationsModule } from "./notifications/notifications.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
imports: [NotificationsModule, HealthModule],
providers: [LifecycleService],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
})
export class AppModule {}

View File

@@ -1,35 +1,24 @@
import { Client } from "@elastic/elasticsearch";
import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
/**
* Elasticsearch 客户端。
*
* 降级模式:当 ES_URL 未设置或连接失败时esClient 为 null
* 所有 ES 操作通过 safeIndex / safeSearch 自动跳过,服务仍可启动。
*/
export const esClient: Client | null = env.ES_URL
? new Client({ node: env.ES_URL })
: null;
/**
* 探活 ES 连接。失败仅记录日志,不抛错(降级模式)。
*/
export async function checkEsConnection(): Promise<void> {
if (!esClient) {
console.log("Elasticsearch disabled (ES_URL not set)");
logger.info("Elasticsearch disabled (ES_URL not set)");
return;
}
try {
await esClient.ping();
console.log("Elasticsearch connected");
logger.info("Elasticsearch connected");
} catch (err) {
console.error("Elasticsearch connection failed:", err);
logger.error({ err }, "Elasticsearch connection failed");
}
}
/**
* 关闭 ES 客户端连接。
*/
export async function closeEs(): Promise<void> {
if (esClient) {
await esClient.close();
@@ -51,23 +40,20 @@ export interface SearchResult {
hits: SearchHit[];
}
/**
* 安全索引文档。client 为 null 或失败时跳过(降级模式),返回是否成功。
*/
export async function safeIndex(params: IndexParams): Promise<boolean> {
if (!esClient) return false;
try {
await esClient.index(params);
return true;
} catch (err) {
console.error("Elasticsearch index failed:", err);
logger.error(
{ err, index: params.index, id: params.id },
"Elasticsearch index failed",
);
return false;
}
}
/**
* 安全搜索。client 为 null 或失败时返回空数组(降级模式)。
*/
export async function safeSearch(
index: string,
query: Record<string, unknown>,
@@ -75,13 +61,25 @@ export async function safeSearch(
if (!esClient) return { hits: [] };
try {
const result = await esClient.search({ index, query });
const hits = (result.hits.hits as unknown[]).map((raw) => {
const hit = raw as { _id: string; _source: Record<string, unknown> };
return { _id: hit._id, _source: hit._source };
});
const hits: SearchHit[] = [];
for (const raw of result.hits.hits) {
const source = raw._source;
if (
raw._id &&
source &&
typeof source === "object" &&
!Array.isArray(source)
) {
// source 已收窄为 object但 object 无索引签名,需断言为 Record<string, unknown>
hits.push({
_id: raw._id,
_source: source as Record<string, unknown>,
});
}
}
return { hits };
} catch (err) {
console.error("Elasticsearch search failed:", err);
logger.error({ err, index }, "Elasticsearch search failed");
return { hits: [] };
}
}

View File

@@ -8,6 +8,7 @@ import { logger } from "./shared/observability/logger.js";
import { checkEsConnection, closeEs } from "./config/elasticsearch.js";
import { closeDb } from "./config/database.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
initTracer();
@@ -19,13 +20,11 @@ async function bootstrap(): Promise<void> {
logger: ["log", "error", "warn"],
});
// 无全局路由前缀Gateway 会去掉 /api/v1再转发至本服务。
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});

View File

@@ -0,0 +1,30 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
userRoles?: string[];
}
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -0,0 +1,66 @@
import {
Injectable,
CanActivate,
ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = {
MSG_NOTIFICATION_SEND: "MSG_NOTIFICATION_SEND" as const,
MSG_NOTIFICATION_READ: "MSG_NOTIFICATION_READ" as const,
MSG_NOTIFICATION_MANAGE: "MSG_NOTIFICATION_MANAGE" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.MSG_NOTIFICATION_SEND,
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_NOTIFICATION_MANAGE,
],
teacher: [
Permissions.MSG_NOTIFICATION_SEND,
Permissions.MSG_NOTIFICATION_READ,
],
student: [Permissions.MSG_NOTIFICATION_READ],
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}

View File

@@ -2,8 +2,6 @@ import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
Param,
Post,
Put,
@@ -11,42 +9,42 @@ import {
Req,
} from "@nestjs/common";
import { NotificationsService } from "./notifications.service.js";
import type { SendNotificationDto } from "./notifications.service.js";
interface AuthedRequest {
headers: Record<string, string | string[] | undefined>;
}
/**
* 从请求头读取用户身份。
* Gateway 在通过鉴权后会注入 x-user-id如未注入开发模式或匿名请求返回 null。
*/
function getUserIdFromRequest(req: AuthedRequest): string | null {
const raw = req.headers["x-user-id"];
if (typeof raw === "string" && raw.length > 0) return raw;
return null;
}
import {
sendNotificationSchema,
sendNotificationBatchSchema,
} from "./notifications.dto.js";
import type { SendNotificationDto } from "./notifications.dto.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
@Controller("notifications")
export class NotificationsController {
constructor(private readonly service: NotificationsService) {}
@Post()
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async send(@Body() body: unknown): Promise<{ success: true; data: unknown }> {
const result = await this.service.send(body as SendNotificationDto);
const dto: SendNotificationDto = sendNotificationSchema.parse(body);
const result = await this.service.send(dto);
return { success: true, data: result };
}
@Post("batch")
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async createBatch(
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dtos = (body as SendNotificationDto[]) ?? [];
const dtos: SendNotificationDto[] = sendNotificationBatchSchema.parse(body);
const result = await this.service.createBatch(dtos);
return { success: true, data: result };
}
@Get("user/:userId")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async listByUser(
@Param("userId") userId: string,
@Query("unread") unread: string,
@@ -56,6 +54,7 @@ export class NotificationsController {
}
@Get("user/:userId/page")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async listByUserPaginated(
@Param("userId") userId: string,
@Query("page") page: string,
@@ -72,29 +71,22 @@ export class NotificationsController {
}
@Put(":id/read")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async markAsRead(@Param("id") id: string): Promise<{ success: true }> {
await this.service.markAsRead(id);
return { success: true };
}
@Get("search")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async search(
@Req() req: AuthedRequest,
@Req() req: AuthenticatedRequest,
@Query("q") q: string,
@Query("userId") userIdParam: string,
): Promise<{ success: true; data: unknown }> {
const userId = getUserIdFromRequest(req) ?? userIdParam;
const userId = req.userId ?? userIdParam;
if (!userId) {
throw new HttpException(
{
success: false,
error: {
code: "MSG_PERMISSION_DENIED",
message: "Missing user identity",
},
},
HttpStatus.FORBIDDEN,
);
throw new PermissionDeniedError("MSG_NOTIFICATION_READ");
}
const result = await this.service.search(userId, q);
return { success: true, data: result };

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
export const sendNotificationSchema = z.object({
userId: z.string().min(1),
type: z.string().min(1).max(50),
title: z.string().min(1).max(200),
content: z.string().min(1),
channel: z.string().max(20).optional(),
metadata: z.record(z.unknown()).optional(),
});
export const sendNotificationBatchSchema = z.array(sendNotificationSchema);
export type SendNotificationDto = z.infer<typeof sendNotificationSchema>;

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { eq, and, desc, sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { db } from "../config/database.js";
import {
notifications,
@@ -10,19 +10,11 @@ import type {
Notification,
NotificationPreference,
} from "./notifications.schema.js";
import type { SendNotificationDto } from "./notifications.dto.js";
import { safeIndex, safeSearch } from "../config/elasticsearch.js";
import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js";
export interface SendNotificationDto {
userId: string;
type: string;
title: string;
content: string;
channel?: string;
metadata?: Record<string, unknown>;
}
export interface SendResult {
id: string;
skipped: boolean;
@@ -39,7 +31,7 @@ export interface PaginatedResult {
@Injectable()
export class NotificationsService {
async send(dto: SendNotificationDto): Promise<SendResult> {
const id = uuidv4();
const id = randomUUID();
const channel = dto.channel ?? "in_app";
const [pref] = await db

View File

@@ -5,6 +5,7 @@ import {
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@@ -14,13 +15,12 @@ export class GlobalErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例,
// 但 msg 服务未引入 @types/express此处按 core-edu / content 模式不显式标注类型。
const response = ctx.getResponse();
const request = ctx.getRequest();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceIdHeader = request.headers["x-request-id"];
const traceId =
(request.headers["x-request-id"] as string | undefined) ?? "unknown";
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
@@ -30,7 +30,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof ZodError) {
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
statusCode = 400;
body = {
success: false,
@@ -79,7 +78,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
return res;
}
if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}

View File

@@ -0,0 +1,346 @@
# 模块理解确认书 — parent-bff
> AI 标识ai04
> 阶段:阶段 1全局理解
> 日期2026-07-09
> 状态:待 coord 审核
> 关联文档:[ai-allocation §6 模板](../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)、[pending-features P4](../../docs/architecture/roadmap/pending-features.md)
---
## 1. 我在架构中的位置
| 维度 | 内容 |
| -------------- | ---------------------------------------------------------------------------- |
| 层级 | **L4 BFF 聚合层**004 §3.1 六层架构) |
| 上游调用方 | api-gatewayGo Gin反向代理 `/api/v1/parent/*` → parent-bff:3010 |
| 下游被调用方 | iam、core-edu按 004 §4 服务依赖图parent-bff 依赖最少) |
| 通信方式(入) | HTTP RESTapi-gateway → parent-bff当前阶段设计意图为 gRPC004 §4.1 |
| 通信方式(出) | HTTP fetch当前阶段对齐 teacher-bff 模式);设计意图为 gRPC004 §4.1 |
| 微前端对接 | parent-portalai07 负责P4 阶段)通过 api-gateway 调用 parent-bff |
| 推送通道 | push-gatewayP5 阶段WebSocket/SSE 推送孩子成绩发布、作业批改等通知) |
**架构定位**004 §1.1a + §5.4
- 按"使用场景域"分 BFFparent-bff 服务于**家长场景域**,复用角色:家长
- 不按角色分 BFF新角色复用现有 BFF 通过视口差异化
- DataScope = **CHILDREN自定义级**家长只能看自己绑定孩子的数据004 §5.3 的 L0 SELF 变体dataScope 枚举值含 `children`,见 004 §5.4 iam 服务职责)
**004 §4 服务依赖图明确**
```
PBFF --> IAM
PBFF --> CoreEdu
```
parent-bff 仅依赖 iam + core-edu**不直接依赖 content / data-ana / msg / ai**。这是设计意图:家长场景的核心是"查看孩子的教学数据",教学数据由 core-edu 提供。但 ai-allocation.md §5 ai04 设计重点提到"家长通知偏好配置",意味着 parent-bff 可能需要调用 msg 服务。**此差异需 coord 仲裁**(见 §7.2)。
---
## 2. 我的限界上下文
### 2.1 我负责什么
parent-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB对齐 teacher-bff 模式)。职责:
1. **多子女账户切换**家长账号可绑定多个孩子parent-bff 维护"当前选中孩子"上下文
2. **聚合**:并行调用 iam查家长信息 + 孩子列表)+ core-edu查孩子的教学数据
3. **裁剪**:将下游返回的领域数据裁剪为家长端所需的最小字段集
4. **协议转换**:对外暴露场景化 HTTP/GraphQL 端点,对内调用下游 REST/gRPC
5. **缓存**:聚合结果 Redis 短缓存 5-30s004 §6.2 BFF 混合读策略)
6. **通知偏好**:家长可配置通知偏好(哪些事件推送、哪些不推送),存于 msg 服务或 iam 服务
### 2.2 我的聚合场景(家长视角)
| 场景 | 聚合的下游服务 | 用途 |
| ------------------ | --------------------------------------------------------------------------------- | ---------------------------------- |
| 家长首页 Dashboard | iam `/iam/me` + iam `/iam/children`(缺失) + core-edu `/grades/student/:childId` | 个人信息 + 孩子列表 + 孩子近期成绩 |
| 切换当前孩子 | iam `/iam/children`(缺失) + core-edu `/homework/class/:classId` | 切换上下文后加载孩子数据 |
| 孩子的考试成绩 | core-edu `/exams/class/:classId` + `/grades/student/:childId` | 孩子所在班级考试 + 孩子成绩 |
| 孩子的作业情况 | core-edu `/homework/class/:classId` | 孩子作业列表 + 提交状态 |
| 孩子的成绩趋势 | data-ana `/analytics/student/:childId/trend`004 未列入依赖,待仲裁) | 历史成绩曲线 |
| 孩子的学情诊断 | data-ana `/analytics/student/:childId/weakness`(待仲裁) | 薄弱知识点 |
| 班级学情对比 | data-ana `/analytics/class/:classId/performance`(待仲裁) | 孩子相对班级的位置 |
| 消息中心 | msg `/notifications`004 未列入依赖,待仲裁) | 家长通知列表 |
| 通知偏好配置 | msg 或 iam缺失 | 配置哪些事件推送 |
| 孩子的出勤 | core-edu `/attendance/student/:childId`(缺失) | 出勤记录 |
### 2.3 我不负责什么(明确边界外)
| 不负责项 | 归属服务 | 说明 |
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
| 业务数据持久化 | core-edu / iam | BFF 不写 DB |
| 权限校验 | 下游业务服务 + iam | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 让下游校验 |
| 用户认证 | iam + api-gateway | JWT 校验在 GatewayBFF 只读 `x-user-id` 头 |
| 学生-家长关系维护 | iam 或 core-edu缺失 | parent-bff 只读取关系,不维护 |
| 领域事件发布 | core-edu | BFF 不发布事件,仅可选订阅事件用于实时推送 |
| 数据范围过滤 | 下游业务服务 Repository 层 | BFF 透传 userId + childId下游按 DataScope=CHILDREN 过滤 |
| 考试批改 | core-edu | 家长不能批改,只能查看孩子成绩 |
| 孩子的作业提交 | core-edu学生端职责 | 家长不能代孩子提交作业 |
---
## 3. 我与外部的契约
### 3.1 我消费的 proto message / 下游接口
> ⚠️ **重要差距**:当前阶段 BFF→Service 走 HTTP fetch对齐 teacher-bff 现状proto 仅作"契约文档"。gRPC 落地需 coord 在 buf.gen.yaml 补 gRPC 插件。
> ⚠️ **核心依赖缺失**iam 没有"家长-学生关联查询"接口parent-bff 无法实现核心场景。
| 下游服务 | proto service设计意图 | 当前 REST 端点(实际可用) | 用途 |
| ------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------- |
| iam | `IamService.GetUserInfo` | `GET /iam/me` | 获取家长个人信息 |
| iam | `IamService.GetViewports`proto 缺失) | `GET /iam/viewports` | 获取家长端导航视口 |
| iam | `IamService.GetEffectivePermissions`proto 缺失) | `GET /iam/permissions/effective` | 获取有效权限列表 |
| iam | **`GetChildrenByParent`proto + REST 双缺失)** | **无** | 查询家长绑定的孩子列表(核心缺失) |
| iam | **`GetParentsByStudent`proto + REST 双缺失)** | **无** | 反向查询(可选) |
| classescore-edu | `ClassService.GetClass` | `GET /classes/:id` | 查孩子所在班级信息 |
| core-edu | `ExamService.GetExam` / `ListExamsByClass` | `GET /exams/class/:classId` | 查孩子班级考试 |
| core-edu | `HomeworkService.ListHomeworkByClass` | `GET /homework/class/:classId` | 查孩子作业 |
| core-edu | `GradeService.GetGrade` / `ListGradesByStudent` / `ListGradesByExam` | `GET /grades/student/:childId` / `GET /grades/exam/:examId` | 查孩子成绩 |
| core-edu | **`AttendanceService`proto + REST 双缺失)** | **无** | 查孩子出勤(全局缺失) |
| data-ana | `AnalyticsService.GetClassPerformance` / `GetStudentWeakness` / `GetLearningTrend` | **REST 未实现** | 学情分析004 未列入依赖,待仲裁) |
| msg | `NotificationService.ListNotifications` / `MarkAsRead` | `GET /notifications`msg 服务 P5 才落地) | 家长通知004 未列入依赖,待仲裁) |
| msg 或 iam | **通知偏好配置proto + REST 双缺失)** | **无** | 配置推送偏好 |
### 3.2 我暴露的 API 端点parent-bff 对外)
> 路由前缀:`/parent`(对齐 teacher-bff 用 `/teacher` 的命名规律)
> 网关路径:`/api/v1/parent/*` → api-gateway 剥离 `/api/v1` 后代理到 parent-bff:3010
| method | path | 聚合下游 | 权限(透传给下游校验) | 说明 |
| ------ | ---------------------------------------------- | ------------------ | ------------------------ | --------------------------------------- |
| GET | `/parent/dashboard` | iam + core-edu | PARENT_DASHBOARD_READ | 家长首页(含孩子列表 + 近期成绩) |
| GET | `/parent/children` | iam | PARENT_CHILDREN_READ | 我的孩子列表 |
| POST | `/parent/children/:childId/select` | BFF 内部状态) | PARENT_CHILDREN_READ | 切换当前选中孩子sessionId 或 cookie |
| GET | `/parent/children/:childId/exams` | core-edu | PARENT_EXAM_READ | 孩子的考试列表 |
| GET | `/parent/children/:childId/homework` | core-edu | PARENT_HOMEWORK_READ | 孩子的作业列表 |
| GET | `/parent/children/:childId/grades` | core-edu | PARENT_GRADE_READ | 孩子的成绩列表 |
| GET | `/parent/children/:childId/analytics/trend` | data-ana待仲裁 | PARENT_ANALYTICS_READ | 孩子成绩趋势 |
| GET | `/parent/children/:childId/analytics/weakness` | data-ana待仲裁 | PARENT_ANALYTICS_READ | 孩子薄弱知识点 |
| GET | `/parent/notifications` | msg待仲裁 | PARENT_NOTIFICATION_READ | 家长通知列表 |
| POST | `/parent/notifications/:id/read` | msg待仲裁 | PARENT_NOTIFICATION_READ | 标记已读 |
| GET | `/parent/notification-preferences` | msg 或 iam缺失 | PARENT_PREFERENCE_READ | 通知偏好配置 |
| PUT | `/parent/notification-preferences` | msg 或 iam缺失 | PARENT_PREFERENCE_UPDATE | 更新通知偏好 |
### 3.3 错误码前缀
| 前缀 | 用途 | 示例 |
| ------------- | ---------------------- | --------------------------------------------------------------------------------- |
| `PARENT_BFF_` | parent-bff 自身错误 | `PARENT_BFF_UNAUTHORIZED``PARENT_BFF_BAD_GATEWAY``PARENT_BFF_CHILD_NOT_BOUND` |
| 下游错误透传 | 下游服务错误码原样返回 | `IAM_USER_NOT_FOUND``CORE_EDU_GRADE_NOT_FOUND` |
错误类清单(对齐 teacher-bff application-error.ts + 新增家长特有错误):
- `UnauthorizedError(401)` — 缺失 `x-user-id`
- `BadGatewayError(502)` — 下游服务返回非 ok 或 fetch rejected
- `ValidationError(400)` — 入参校验失败BFF 层 Zod 校验)
- `BusinessError(422)` — 业务校验失败(如家长查询的 childId 不在自己绑定列表内)
- `NotFoundError(404)` — 资源不存在
- `InternalError(500)` — 未捕获异常
**家长特有错误**
- `PARENT_BFF_CHILD_NOT_BOUND(403)` — 家长查询的 childId 未在自己绑定的孩子列表内DataScope=CHILDREN 越权)
### 3.4 我订阅的 Kafka 事件(可选,用于实时推送)
| Topic | 事件 | 消费动作 |
| --------------------- | ----------------- | ------------------------------ |
| `edu.grade.events` | `grade.recorded` | 推送给家长(孩子成绩发布) |
| `edu.homework.events` | `homework.graded` | 推送给家长(孩子作业批改完成) |
| `edu.exam.events` | `exam.published` | 推送给家长(考试提醒) |
> ⚠️ Kafka 订阅在 P5 阶段 push-gateway 落地后才有意义P4 阶段 parent-bff 可不消费事件,仅做同步聚合。
> ⚠️ 家长通知偏好配置生效后BFF 在订阅事件时应过滤:若家长关闭了"成绩推送"偏好,则不推送该类事件。
---
## 4. 我的技术栈
| 维度 | 选型 | 依据 |
| ------------ | ------------------------------------ | ----------------------------------------------------------------------------- |
| 语言 | TypeScript 5.5+ | 004 §2.1 |
| 框架 | NestJS 10 | 004 §2.1,对齐 teacher-bff 模板 |
| ORM | **无**BFF 不访问 DB | 对齐 teacher-bff无 repository/schema/dto |
| 缓存 | Redis 7短缓存 5-30s | 004 §6.2 BFF 混合读策略 |
| 会话状态 | Redis当前选中孩子 childId | 多子女账户切换,不持久化在 BFF 内存 |
| 可观测性日志 | pino | 对齐 classes/teacher-bff |
| 可观测性指标 | prom-client`/metrics` 端点) | 对齐 teacher-bff main.ts |
| 可观测性链路 | OpenTelemetry SDK + OTLP exporter | 对齐 teacher-bff tracer.ts |
| API 风格 | **HTTP REST**(当前阶段) | 对齐 teacher-bff 现状;与 student-bff 一致,需 coord 仲裁是否统一升级 GraphQL |
| 输入校验 | Zod | 对齐 classes/teacher-bff |
| 错误处理 | GlobalErrorFilter + ApplicationError | 对齐 classes/teacher-bff |
| ESM 模式 | NodeNext + `.js` 后缀 import | 对齐 teacher-bff tsconfig |
| 测试框架 | Jest待定对齐 classes | 黄金模板要求测试覆盖率 ≥ 80% |
### 4.1 关于 GraphQL 的设计决策(待 coord 仲裁)
与 student-bff §4.1 相同的矛盾ai04 建议 P4 阶段 parent-bff **先对齐 teacher-bff 现状REST + fetch + Promise.allSettled**,与 student-bff 保持一致。此决策需 coord 仲裁。
### 4.2 关于多子女账户切换的设计
**问题**:家长账号绑定多个孩子,前端需要知道"当前选中的孩子"。
**方案对比**
| 方案 | 实现 | 优点 | 缺点 |
| -------------- | --------------------------------------------------------- | ---------------- | --------------------------------------------------- |
| A. 前端管理 | 前端在 URL query 或 localStorage 存 childId每次请求带上 | BFF 无状态,简单 | 切换后页面状态丢失风险 |
| B. BFF Session | Redis 存 `parent:userId:currentChildId`BFF 读取 | 切换全局生效 | BFF 引入会话状态,违反"无状态服务"约束004 §12.1 |
| C. JWT Claims | iam 在 JWT 中注入 `currentChildId` claim | 与认证一体化 | 切换孩子需重签 JWT成本高 |
**ai04 建议****方案 A**(前端管理 childIdBFF 无状态),符合 004 §12.1"无状态服务"约束。`POST /parent/children/:childId/select` 端点可选,仅用于记录切换日志(审计),不持久化会话。
---
## 5. 我的阶段归属
| 维度 | 内容 |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 阶段 | **P4 内容分析阶段**M11-M13 |
| 退出标准pending-features P4 | 教师查看知识图谱前置依赖Neo4j 秒级返回)→ 学生查看学情诊断ClickHouse 宽表 5s 内返回)→ CDC 链路延迟 < 5s |
| parent-bff 在 P4 的最小交付 | 家长查看孩子成绩 + 学情诊断(双轨读:实时查 core-edu 主库 + 聚合查 data-ana ClickHouse 宽表) |
| 依赖上游阶段产出 | P1api-gateway + classes 黄金模板 + shared-proto、P2iam 认证 + teacher-bff BFF 模板、P3core-edu 考试/作业/成绩 + student-bff BFF 模式验证) |
| P4 同阶段依赖 | contentP4 落地、data-anaP4 落地,学情诊断 API |
| P5 阶段扩展 | msg 通知 + push-gateway 推送 + 通知偏好配置 |
| 前置阻塞 | iam 必须先补"家长-学生关联查询"接口(见 §7.1 |
### 5.1 P4 阶段最小可行集合MVP
parent-bff 在 P4 阶段优先级:
| 优先级 | 端点 | P4 必需 | 说明 |
| ------ | -------------------------------------------------- | ------- | ------------------------------------------- |
| P0 | `/parent/children` GET | ✅ | 家长核心场景:查孩子列表(依赖 iam 补接口) |
| P0 | `/parent/children/:childId/grades` GET | ✅ | 查孩子成绩 |
| P0 | `/parent/dashboard` GET | ✅ | 家长首页 |
| P1 | `/parent/children/:childId/exams` GET | ✅ | 孩子考试 |
| P1 | `/parent/children/:childId/homework` GET | ✅ | 孩子作业 |
| P1 | `/parent/children/:childId/analytics/trend` GET | ✅ | 学情趋势(依赖 data-ana P4 落地) |
| P1 | `/parent/children/:childId/analytics/weakness` GET | ✅ | 薄弱知识点 |
| P2 | `/parent/notifications` GET | ⚠️ 可选 | P5 msg 服务落地后才有意义 |
| P2 | `/parent/notification-preferences` GET/PUT | ⚠️ 可选 | P5 落地 |
### 5.2 与 student-bff 的协同
parent-bff 与 student-bff 同属 ai04 负责,技术栈完全一致(同语言、同框架、同 BFF 模式)。两者共享:
- **teacher-bff 克隆模板**shared/ 目录结构、main.ts、env.ts、health.controller.ts 等基础代码
- **聚合模式**Promise.allSettled 并行调用 + BadGatewayError 错误处理
- **可观测性**pino + prom-client + OTel 三支柱
- **错误码前缀**`PARENT_BFF_` / `STUDENT_BFF_` 仅前缀不同
**差异点**
- parent-bff 多了"多子女账户切换"上下文
- parent-bff 多了"DataScope=CHILDREN 越权校验"(家长查询的 childId 必须在自己绑定列表内)
- parent-bff 不直接依赖 content / msg / ai按 004 §4但实际场景可能需要待 coord 仲裁)
---
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
> 对照 ai-allocation.md §6 模板第 6 节 + §10 审计模板
| 对齐项 | classes 黄金模板 | parent-bff 计划 | 备注 |
| ------------------------------- | ---------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
| 权限装饰器 `@RequirePermission` | ✅ 全部 Controller 方法 | ⚠️ **不对齐** | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 给下游校验 |
| 错误码前缀统一 | ✅ `CLASSES_` | ✅ `PARENT_BFF_` | 对齐 teacher-bff 的 `TEACHER_BFF_` 模式 |
| loggerpino | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `parent-bff` |
| metricsprom-client | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `parent_bff_` |
| tracerOpenTelemetry | ✅ OTLP exporter + auto-instrumentations | ✅ 复制 teacher-bff tracer.ts | serviceName 改 `parent-bff` |
| `/healthz` 健康检查 | ✅ liveness | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| `/readyz` 健康检查 | ✅ Drizzle `SELECT 1` | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| 优雅关闭SIGTERM | ✅ LifecycleService 关闭 DB 连接池 | ✅ main.ts 注册 SIGTERM → `app.close()` + `shutdownTracer()` | BFF 无 DB 连接 |
| 测试覆盖率 ≥ 80% | ✅ Jest | ⚠️ **待补** | BFF 测试重点是 Service 层聚合逻辑 + DataScope 越权校验 |
| Dockerfile 多阶段构建 | ✅ builder + runtime | ✅ 复制 teacher-bff Dockerfile | EXPOSE 改 3010 |
| Zod 输入验证 | ✅ Controller 层 `schema.parse(body)` | ✅ Controller 层校验 | 通知偏好更新 body 需 Zod 校验 |
| GlobalErrorFilter | ✅ `@Catch()` 全局过滤器 | ✅ 复制 teacher-bff | 注册到 main.ts |
| ESM `.js` 后缀 import | ✅ tsconfig NodeNext | ✅ 复制 teacher-bff tsconfig | 所有相对 import 带 `.js` |
| `import type` 纯类型导入 | ✅ | ✅ | 对齐 classes 规范 |
| 环境变量 Zod 校验 | ✅ `config/env.ts` | ✅ 复制 teacher-bff env.ts | 下游 URL 配置项扩展 |
### 6.1 与 teacher-bff 模板的差异点(克隆时必须改)
| 文件 | teacher-bff 现值 | parent-bff 应改为 |
| ------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
| `package.json` name | `@edu/teacher-bff` | `@edu/parent-bff` |
| `src/config/env.ts` `PORT` default | `"3003"` | `"3010"` |
| `src/config/env.ts` 下游 URL | IamServiceUrl / ClassesServiceUrl / CoreEduServiceUrl | + DataAnaServiceUrl / MsgServiceUrl按仲裁结果 |
| `src/teacher/` 目录名 | `teacher/` | `parent/` |
| `@Controller("teacher")` | `"teacher"` | `"parent"` |
| `health.controller.ts` `SERVICE_NAME` | `"teacher-bff"` | `"parent-bff"` |
| `application-error.ts` 错误码前缀 | `TEACHER_BFF_` | `PARENT_BFF_` |
| `application-error.ts` 新增错误类 | — | `ChildNotBoundError(403)`DataScope=CHILDREN 越权) |
| `metrics.ts` 指标名前缀 | `teacher_bff_` | `parent_bff_` |
| `tracer.ts` serviceName | `"teacher-bff"` | `"parent-bff"` |
| `logger.ts` service | `"teacher-bff"` | `"parent-bff"` |
| `main.ts` 启动日志 | `"Teacher BFF started"` | `"Parent BFF started"` |
| `Dockerfile` `EXPOSE` | `3003` | `3010` |
---
## 7. 风险与依赖(待 coord 仲裁)
### 7.1 上游依赖缺口(核心阻塞)
| 风险 | 影响 | 缓解措施 |
| ------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **iam 缺失"家长-学生关联查询"接口**proto + REST + schema 三缺失) | **P0 阻塞**parent-bff 核心场景无法实现 | 推动 coord 协调 ai02 在 iam 补1) `iam_student_guardians`2) Repository 查询方法3) `GET /iam/children` REST 端点4) proto `GetChildrenByParent` RPC |
| pending-features P2 提到 `parent_student_relations` 表,但 iam.schema.ts 未实现 | 表缺失 | 同上,推动 ai02 补表 |
| data-ana 服务未实现查询 APIanalytics.proto 3 个 method 无 REST 端点) | P4 学情诊断端点无法实现 | 推动 ai06 在 P4 实现 data-ana 查询 API |
| content.proto 缺 Chapter/Question 域 | parent-bff 不直接依赖 content影响较小 | 推动 coord 在 shared-proto 补全P4 content 服务落地前) |
| iam.proto 缺 Viewport/EffectivePermissions | 家长端导航视口 proto 契约不全 | 当前走 REST `/iam/viewports`proto 补全后切换 |
| 出勤attendance全局缺失 | 家长无法查孩子出勤 | 推动 coord 在 core_edu.proto 补 Attendance 域P4 或后续) |
| msg 通知偏好配置接口缺失 | 家长通知偏好无法配置 | 推动 ai05 在 msg 服务补接口P5 阶段) |
### 7.2 设计决策待仲裁
| 决策点 | 选项 | ai04 建议 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| BFF API 风格 | A. REST对齐 teacher-bff 现状)<br/>B. GraphQL对齐 004 §11.3 设计意图) | **A**(与 student-bff 保持一致) |
| BFF 是否做权限校验 | A. 不校验(对齐 teacher-bff透传 x-user-id<br/>B. 加 `@RequirePermission` 装饰器 | **A**BFF 是聚合层,权限由下游服务校验) |
| **DataScope=CHILDREN 越权校验位置** | A. BFF 校验(家长查询的 childId 必须在绑定列表内)<br/>B. 下游服务校验core-edu 接收 childId 时校验) | **A**BFF 层校验更早失败,减少下游调用;但需 iam 提供"查询家长绑定孩子列表"接口) |
| 多子女账户切换方案 | A. 前端管理 childIdBFF 无状态)<br/>B. BFF SessionRedis 存当前 childId<br/>C. JWT Claims | **A**(符合 004 §12.1 无状态约束) |
| **004 §4 依赖图与实际场景的偏差** | 004 列 parent-bff 仅依赖 iam + core-edu但家长通知、学情诊断需要 msg + data-ana | **建议扩展依赖**parent-bff → iam + core-edu + data-ana + msg与 student-bff 对齐),需 coord 仲裁并更新 004 §4 |
| Kafka 事件订阅 | A. P4 不订阅(仅同步聚合)<br/>B. P4 订阅事件推送 | **A**push-gateway P5 才落地) |
| 通知偏好存储位置 | A. msg 服务(通知域内)<br/>B. iam 服务(用户偏好域内) | **A**(通知偏好与通知发送强相关,归 msg 服务) |
| 端口分配 | 3010 | 对齐 full-stack-runbook 端口矩阵3001-3009 已用/将用) |
### 7.3 跨模块协作需求(需提交 coord 协调)
| 需求 | 涉及 AI | 协调内容 |
| ---------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------- |
| **iam 新增家长-学生关联接口**P0 阻塞) | ai02 | 补 `iam_student_guardians` 表 + Repository + `GET /iam/children` 端点 + proto `GetChildrenByParent` RPC |
| api-gateway 新增 `/parent` 路由 | ai01 | 在 main.go + config.go 新增 `ParentBffURL` 字段 + 路由块 |
| docker-compose.deploy.yml 新增 parent-bff 服务定义 | coordinfra | 端口 3010加入 edu-net + edu-shared 网络 |
| full-stack-runbook 端口矩阵更新 | coorddocs | 追加 3010 行 |
| 004 架构图状态更新 + 依赖图仲裁 | coorddocs | parent-bff 状态从"📐 需设计"改为"✅ 已实现";仲裁是否扩展 parent-bff 依赖到 data-ana + msg |
| shared-proto 补全 iam.protoViewport/EffectivePermissions/StudentParentRelation | coord | 推动 ai02 补 proto |
| shared-proto 补全 core_edu.protoAttendance 域) | coord | 推动 ai03 补 proto |
| data-ana 实现 analytics.proto 查询 API | ai06 | P4 阶段落地 |
| buf.gen.yaml 补 gRPC 插件 | coord | 决定是否在 P4 升级到 gRPC 通信 |
---
## 8. 阶段 1 自检结论
| 检查项 | 状态 |
| ------------------------------------ | ------------------------------- |
| 已读必读文档清单ai-allocation §4 | ✅ |
| 已运行 arch:scan 更新 arch.db | ✅ |
| 已查 arch:query modules / stats | ✅ |
| 已读 classes 黄金模板源码 | ✅ |
| 已读 teacher-bff BFF 模板源码 | ✅ |
| 已读 iam 认证服务源码 | ✅ |
| 已读 shared-proto 全部 .proto | ✅ |
| 已识别 proto 契约缺口 | ✅(见 §7.1 |
| 已识别端口/路由预留情况 | ✅3010 可用,路由未预留) |
| 已识别设计决策待仲裁项 | ✅(见 §7.2 |
| 已识别跨模块协作需求 | ✅(见 §7.3 |
| 已识别 P0 阻塞项 | ✅iam 家长-学生关联接口缺失) |
**ai04 阶段 1 交付完成,请 coord 审核。审核通过后进入阶段 2模块架构设计文档**
**特别提示 coord**parent-bff 存在 P0 阻塞项——iam 缺失"家长-学生关联查询"接口。建议 coord 优先协调 ai02 在 P3 阶段parent-bff P4 落地前)补全此接口,否则 P4 阶段 parent-bff 无法启动实施。

View File

@@ -6,6 +6,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3
github.com/prometheus/client_golang v1.23.2
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
@@ -13,6 +14,7 @@ require (
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
@@ -36,7 +38,11 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -47,6 +53,7 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect

View File

@@ -1,3 +1,5 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
@@ -51,8 +53,16 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF2
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -62,14 +72,26 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -115,6 +137,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
@@ -136,6 +160,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,24 +1,44 @@
package config
import "os"
import (
"log"
"os"
)
// Config 持有 push-gateway 运行时配置
type Config struct {
Port string
JWTSecret string
DevMode bool
RedisURL string
OTLPEndpoint string
Port string
JWTSecret string
DevMode bool
RedisURL string
OTLPEndpoint string
InternalAPIKey string
}
// Load 从环境变量加载配置并提供默认值
// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须配置 JWT_SECRET
const devJWTSecret = "p1-dev-secret-change-in-production"
// Load 从环境变量加载配置。
// 非 DevMode 下若 JWT_SECRET 未配置则 fatal 退出DevMode 下使用默认密钥并打印 warning。
func Load() *Config {
devMode := getEnv("DEV_MODE", "false") == "true"
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" {
if devMode {
log.Println("warning: JWT_SECRET not set, using dev default (DEV_MODE=true)")
jwtSecret = devJWTSecret
} else {
log.Fatal("JWT_SECRET must be set in non-dev mode")
}
}
return &Config{
Port: getEnv("PUSH_GATEWAY_PORT", "8081"),
JWTSecret: getEnv("JWT_SECRET", "p1-dev-secret-change-in-production"),
DevMode: getEnv("DEV_MODE", "false") == "true",
RedisURL: getEnv("REDIS_URL", ""),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
Port: getEnv("PUSH_GATEWAY_PORT", "8081"),
JWTSecret: jwtSecret,
DevMode: devMode,
RedisURL: getEnv("REDIS_URL", ""),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
InternalAPIKey: getEnv("INTERNAL_API_KEY", ""),
}
}

View File

@@ -40,6 +40,17 @@ func NewHub() *Hub {
}
}
// ClientCount 返回当前在线连接总数(供 /readyz 探针使用)
func (h *Hub) ClientCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
count := 0
for _, conns := range h.clients {
count += len(conns)
}
return count
}
// Register 注册一个用户连接,返回 Connection 供调用方持有
func (h *Hub) Register(userID string, conn *websocket.Conn) *Connection {
c := &Connection{

View File

@@ -3,6 +3,7 @@ package ws
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
@@ -25,16 +26,20 @@ var upgrader = websocket.Upgrader{
},
}
// internalAPIKeyHeader 是内部 API 鉴权请求头名称
const internalAPIKeyHeader = "X-Internal-Key"
// Handler 处理 WebSocket 升级与内部推送 API
type Handler struct {
hub *hub.Hub
jwtSecret string
devMode bool
hub *hub.Hub
jwtSecret string
devMode bool
internalAPIKey string
}
// NewHandler 创建 Handler 实例
func NewHandler(h *hub.Hub, jwtSecret string, devMode bool) *Handler {
return &Handler{hub: h, jwtSecret: jwtSecret, devMode: devMode}
func NewHandler(h *hub.Hub, jwtSecret string, devMode bool, internalAPIKey string) *Handler {
return &Handler{hub: h, jwtSecret: jwtSecret, devMode: devMode, internalAPIKey: internalAPIKey}
}
// HandleWebSocket 升级 HTTP 为 WebSocket保持长连接并处理心跳
@@ -47,6 +52,7 @@ func (h *Handler) HandleWebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[ws] upgrade failed, user=%s: %v", userID, err)
return
}
defer conn.Close()
@@ -58,6 +64,7 @@ func (h *Handler) HandleWebSocket(c *gin.Context) {
go func() {
for msg := range connPtr.Outgoing() {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Printf("[ws] write failed, user=%s: %v", userID, err)
return
}
}
@@ -67,6 +74,9 @@ func (h *Handler) HandleWebSocket(c *gin.Context) {
for {
_, msg, err := conn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Printf("[ws] read failed, user=%s: %v", userID, err)
}
break
}
if strings.ToLower(string(msg)) == "ping" {
@@ -77,6 +87,14 @@ func (h *Handler) HandleWebSocket(c *gin.Context) {
// PushHandler 接收来自 Msg 服务的定向推送请求
func (h *Handler) PushHandler(c *gin.Context) {
if !h.checkInternalAPIKey(c) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{"code": "UNAUTHORIZED", "message": "invalid or missing internal api key"},
})
return
}
var req struct {
UserID string `json:"userId"`
Event string `json:"event"`
@@ -103,6 +121,14 @@ func (h *Handler) PushHandler(c *gin.Context) {
// BroadcastHandler 接收来自 Msg 服务的广播请求
func (h *Handler) BroadcastHandler(c *gin.Context) {
if !h.checkInternalAPIKey(c) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{"code": "UNAUTHORIZED", "message": "invalid or missing internal api key"},
})
return
}
var req struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
@@ -122,6 +148,19 @@ func (h *Handler) BroadcastHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// checkInternalAPIKey 校验内部 API 请求头 X-Internal-Key。
// DevMode 下跳过校验;非 DevMode 下要求 INTERNAL_API_KEY 已配置且与请求头匹配。
func (h *Handler) checkInternalAPIKey(c *gin.Context) bool {
if h.devMode {
return true
}
if h.internalAPIKey == "" {
log.Println("[ws] internal api key not configured, rejecting request")
return false
}
return c.GetHeader(internalAPIKeyHeader) == h.internalAPIKey
}
// authenticate 校验 WebSocket 连接的 JWT 或 dev token
func (h *Handler) authenticate(c *gin.Context) (string, error) {
tokenStr := c.Query("token")

View File

@@ -14,6 +14,7 @@ import (
"github.com/edu-cloud/push-gateway/internal/observability"
"github.com/edu-cloud/push-gateway/internal/ws"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
@@ -26,7 +27,7 @@ func main() {
gin.SetMode(gin.ReleaseMode)
h := hub.NewHub()
wsHandler := ws.NewHandler(h, cfg.JWTSecret, cfg.DevMode)
wsHandler := ws.NewHandler(h, cfg.JWTSecret, cfg.DevMode, cfg.InternalAPIKey)
r := gin.New()
r.Use(gin.Recovery())
@@ -37,6 +38,18 @@ func main() {
c.JSON(200, gin.H{"status": "ok", "service": "push-gateway"})
})
// 就绪探针:检查 WebSocket hub 状态
r.GET("/readyz", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "push-gateway",
"connections": h.ClientCount(),
})
})
// Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// WebSocket 升级端点
r.GET("/ws", wsHandler.HandleWebSocket)

View File

@@ -0,0 +1,297 @@
# 模块理解确认书 — student-bff
> AI 标识ai04
> 阶段:阶段 1全局理解
> 日期2026-07-09
> 状态:待 coord 审核
> 关联文档:[ai-allocation §6 模板](../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)、[pending-features P3](../../docs/architecture/roadmap/pending-features.md)
---
## 1. 我在架构中的位置
| 维度 | 内容 |
| -------------- | ----------------------------------------------------------------------------- |
| 层级 | **L4 BFF 聚合层**004 §3.1 六层架构) |
| 上游调用方 | api-gatewayGo Gin反向代理 `/api/v1/student/*` → student-bff:3009 |
| 下游被调用方 | iam、core-edu、content、data-ana按 004 §4 服务依赖图) |
| 通信方式(入) | HTTP RESTapi-gateway → student-bff当前阶段设计意图为 gRPC004 §4.1 |
| 通信方式(出) | HTTP fetch当前阶段对齐 teacher-bff 模式);设计意图为 gRPC004 §4.1 |
| 微前端对接 | student-portalai07 负责P3 阶段)通过 api-gateway 调用 student-bff |
| 推送通道 | push-gatewayP5 阶段WebSocket/SSE 推送考试通知、成绩发布等) |
**架构定位**004 §1.1a + §5.4
- 按"使用场景域"分 BFFstudent-bff 服务于**学习场景域**,复用角色:学生
- 不按角色分 BFF新角色复用现有 BFF 通过视口差异化
- DataScope = **SELFL0**学生只能看自己的数据004 §5.3
---
## 2. 我的限界上下文
### 2.1 我负责什么
student-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB对齐 teacher-bff 模式)。职责:
1. **聚合**:并行调用多个下游业务服务,组装学生视角的复合数据
2. **裁剪**:将下游返回的领域数据裁剪为学生端所需的最小字段集
3. **协议转换**:对外暴露场景化 HTTP/GraphQL 端点,对内调用下游 REST/gRPC
4. **缓存**:聚合结果 Redis 短缓存 5-30s004 §6.2 BFF 混合读策略)
### 2.2 我的聚合场景(学生视角)
| 场景 | 聚合的下游服务 | 用途 |
| ------------------ | --------------------------------------------------------------------------- | ------------------------------ |
| 学生首页 Dashboard | iam `/iam/me` + core-edu `/homework/class/:classId` + msg `/notifications` | 个人信息 + 待办作业 + 未读消息 |
| 即将到来的考试 | core-edu `/exams/class/:classId` | 考试日程提醒 |
| 我的作业列表 | core-edu `/homework/class/:classId` | 查看待完成作业 |
| 提交作业 | core-edu `/homework/:id/submit` | 学生提交作业答案 |
| 我的成绩 | core-edu `/grades/student/:studentId` | 查询历史成绩 |
| 消息中心 | msg `/notifications` + `/notifications/:id/read` | 通知列表 + 已读 |
| 教材浏览 | content `/textbooks` + `/chapters` | 按章节学习 |
| 题库练习 | content `/questions` | 按知识点刷题 |
| 学情诊断 | data-ana `/analytics/student/:id/weakness` + `/analytics/student/:id/trend` | 自我掌握度分析 |
| AI 答疑 | ai `/ai/chat` + `/ai/stream-chat`SSE 流式) | 智能答疑辅助 |
| 个性化学习路径 | content `/knowledge-points/:id/learning-path` | 基于学情推荐学习路径 |
### 2.3 我不负责什么(明确边界外)
| 不负责项 | 归属服务 | 说明 |
| -------------- | ------------------------------ | ----------------------------------------------------------------- |
| 业务数据持久化 | core-edu / content / msg / iam | BFF 不写 DB |
| 权限校验 | 下游业务服务 + iam | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 让下游校验 |
| 用户认证 | iam + api-gateway | JWT 校验在 GatewayBFF 只读 `x-user-id` 头 |
| 领域事件发布 | core-edu / content | BFF 不发布事件,仅可选订阅事件用于实时推送 |
| 数据范围过滤 | 下游业务服务 Repository 层 | BFF 透传 userId下游按 DataScope=SELF 过滤 |
| 班级管理 | core-educlasses 模块) | 学生只读自己所在班级 |
| 考试批改 | core-edu | 学生不能批改,只能查看成绩 |
---
## 3. 我与外部的契约
### 3.1 我消费的 proto message / 下游接口
> ⚠️ **重要差距**:当前阶段 BFF→Service 走 HTTP fetch对齐 teacher-bff 现状proto 仅作"契约文档"。gRPC 落地需 coord 在 buf.gen.yaml 补 gRPC 插件。
| 下游服务 | proto service设计意图 | 当前 REST 端点(实际可用) | 用途 |
| ------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------ |
| iam | `IamService.GetUserInfo` | `GET /iam/me` | 获取学生个人信息 + roles + dataScope |
| iam | `IamService.GetViewports`proto 缺失) | `GET /iam/viewports` | 获取学生端导航视口 |
| iam | `IamService.GetEffectivePermissions`proto 缺失) | `GET /iam/permissions/effective` | 获取有效权限列表 |
| classescore-edu | `ClassService.GetClass` / `ListClasses` | `GET /classes` / `GET /classes/:id` | 查自己所在班级 |
| core-edu | `ExamService.GetExam` / `ListExamsByClass` | `GET /exams/class/:classId` | 查班级考试 |
| core-edu | `HomeworkService.GetHomework` / `ListHomeworkByClass` / `SubmitHomework` | `GET /homework/class/:classId` / `POST /homework/:id/submit` | 查作业 + 提交 |
| core-edu | `GradeService.GetGrade` / `ListGradesByStudent` | `GET /grades/student/:studentId` | 查自己成绩 |
| content | `TextbookService.GetTextbook` / `ListTextbooks` | `GET /textbooks` | 查教材 |
| content | `ChapterService`proto 缺失) | `GET /chapters` / `GET /chapters/:id` | 查章节 |
| content | `QuestionService`proto 缺失) | `GET /questions` | 查题库 |
| content | `KnowledgeGraphService.GetLearningPath` | `GET /knowledge-points/:id/learning-path` | 学习路径 |
| msg | `NotificationService.ListNotifications` / `MarkAsRead` / `SearchNotifications` | `GET /notifications` / `POST /notifications/:id/read` | 消息中心 |
| data-ana | `AnalyticsService.GetStudentWeakness` / `GetLearningTrend` | **REST 未实现** | 学情分析 |
| ai | `AiService.Chat` / `StreamChat` / `GenerateQuestion` | **REST 未实现** | AI 答疑 |
### 3.2 我暴露的 API 端点student-bff 对外)
> 路由前缀:`/student`(对齐 teacher-bff 用 `/teacher` 的命名规律BFF 用角色单数无 `-bff` 后缀)
> 网关路径:`/api/v1/student/*` → api-gateway 剥离 `/api/v1` 后代理到 student-bff:3009
| method | path | 聚合下游 | 权限(透传给下游校验) | 说明 |
| ------ | --------------------------------- | -------------------- | ------------------------- | -------------------- |
| GET | `/student/dashboard` | iam + core-edu + msg | STUDENT_DASHBOARD_READ | 学生首页聚合 |
| GET | `/student/exams` | core-edu | STUDENT_EXAM_READ | 即将到来的考试 |
| GET | `/student/homework` | core-edu | STUDENT_HOMEWORK_READ | 我的作业列表 |
| POST | `/student/homework/:id/submit` | core-edu | STUDENT_HOMEWORK_SUBMIT | 提交作业 |
| GET | `/student/grades` | core-edu | STUDENT_GRADE_READ | 我的成绩 |
| GET | `/student/notifications` | msg | STUDENT_NOTIFICATION_READ | 消息列表 |
| POST | `/student/notifications/:id/read` | msg | STUDENT_NOTIFICATION_READ | 标记已读 |
| GET | `/student/textbooks` | content | STUDENT_CONTENT_READ | 教材列表 |
| GET | `/student/chapters/:textbookId` | content | STUDENT_CONTENT_READ | 章节树 |
| GET | `/student/questions` | content | STUDENT_CONTENT_READ | 题库(按知识点过滤) |
| GET | `/student/analytics/weakness` | data-ana | STUDENT_ANALYTICS_READ | 学情诊断 |
| GET | `/student/analytics/trend` | data-ana | STUDENT_ANALYTICS_READ | 学习趋势 |
| POST | `/student/ai/chat` | ai | STUDENT_AI_CHAT | AI 答疑(同步) |
| POST | `/student/ai/stream-chat` | ai | STUDENT_AI_CHAT | AI 答疑SSE 流式) |
### 3.3 错误码前缀
| 前缀 | 用途 | 示例 |
| -------------- | ---------------------- | ----------------------------------------------------- |
| `STUDENT_BFF_` | student-bff 自身错误 | `STUDENT_BFF_UNAUTHORIZED``STUDENT_BFF_BAD_GATEWAY` |
| 下游错误透传 | 下游服务错误码原样返回 | `CLASSES_NOT_FOUND``IAM_USER_NOT_FOUND` |
错误类清单(对齐 teacher-bff application-error.ts
- `UnauthorizedError(401)` — 缺失 `x-user-id`
- `BadGatewayError(502)` — 下游服务返回非 ok 或 fetch rejected
- `ValidationError(400)` — 入参校验失败BFF 层 Zod 校验)
- `InternalError(500)` — 未捕获异常
### 3.4 我订阅的 Kafka 事件(可选,用于实时推送)
| Topic | 事件 | 消费动作 |
| --------------------- | --------------------------------------- | -------------------------- |
| `edu.homework.events` | `homework.assigned` / `homework.graded` | 推送给学生push-gateway |
| `edu.exam.events` | `exam.published` / `exam.updated` | 考试提醒推送 |
| `edu.grade.events` | `grade.recorded` | 成绩发布推送 |
> ⚠️ Kafka 订阅在 P5 阶段 push-gateway 落地后才有意义P3 阶段 student-bff 可不消费事件,仅做同步聚合。
---
## 4. 我的技术栈
| 维度 | 选型 | 依据 |
| ------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| 语言 | TypeScript 5.5+ | 004 §2.1 |
| 框架 | NestJS 10 | 004 §2.1,对齐 teacher-bff 模板 |
| ORM | **无**BFF 不访问 DB | 对齐 teacher-bff无 repository/schema/dto |
| 缓存 | Redis 7短缓存 5-30s | 004 §6.2 BFF 混合读策略 |
| 可观测性日志 | pino | 对齐 classes/teacher-bff |
| 可观测性指标 | prom-client`/metrics` 端点) | 对齐 teacher-bff main.ts |
| 可观测性链路 | OpenTelemetry SDK + OTLP exporter | 对齐 teacher-bff tracer.ts |
| API 风格 | **HTTP REST**(当前阶段) | 对齐 teacher-bff 现状pending-features P2 设计意图为 GraphQL但 teacher-bff 实际未落地 GraphQL需 coord 仲裁是否在 student-bff 引入 |
| 输入校验 | Zod | 对齐 classes/teacher-bff |
| 错误处理 | GlobalErrorFilter + ApplicationError | 对齐 classes/teacher-bff |
| ESM 模式 | NodeNext + `.js` 后缀 import | 对齐 teacher-bff tsconfig |
| 测试框架 | Jest待定对齐 classes | 黄金模板要求测试覆盖率 ≥ 80% |
### 4.1 关于 GraphQL 的设计决策(待 coord 仲裁)
**现状矛盾**
- 004 §11.3 BFF 聚合模式图示为 GraphQL Resolver + DataLoader + Redis 缓存
- pending-features P2 明确"Teacher BFFTS/GraphQL"用 GraphQL Yoga + DataLoader
- **实际**teacher-bff 当前是纯 REST + fetch无 GraphQL、无 DataLoader
- ai-allocation.md §5 ai04 设计重点提到"DataLoader 复用 teacher-bff 模式"
**ai04 倾向方案**P3 阶段 student-bff **先对齐 teacher-bff 现状REST + fetch + Promise.allSettled**,避免技术栈分裂;若 coord 决策统一升级到 GraphQL则在 P3 后期或 P4 阶段同步升级 teacher-bff + student-bff + parent-bff 三端。此决策需 coord 仲裁。
---
## 5. 我的阶段归属
| 维度 | 内容 |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| 阶段 | **P3 核心教学阶段**M7-M10 |
| 退出标准pending-features P3 | 教师创建考试 → 发布 → 学生作答提交 → 教师批改 → 事件发到 Kafka → 成绩统计更新 → 全链路可观测 |
| student-bff 在 P3 的最小交付 | 学生作答作业页面所需 API`/student/homework` 列表 + `/student/homework/:id/submit` 提交 + `/student/grades` 成绩查看 |
| 依赖上游阶段产出 | P1api-gateway 路由骨架 + classes 黄金模板 + shared-proto、P2iam 认证 + teacher-bff BFF 模板 + teacher-portal 微前端骨架) |
| P3 同阶段依赖 | core-edu考试/作业/成绩域 CRUD + Outbox 事件) |
| P4 阶段扩展 | 学情诊断查询(双轨读:实时查 core-edu 主库 + 聚合查 data-ana ClickHouse 宽表) |
| P5 阶段扩展 | AI 答疑流式响应 + Kafka 事件订阅推送 |
### 5.1 P3 阶段最小可行集合MVP
student-bff 在 P3 阶段不一定要实现全部 14 个端点,优先级:
| 优先级 | 端点 | P3 必需 | 说明 |
| ------ | ----------------------------------------------------------------- | ------- | ------------------------- |
| P0 | `/student/homework` GET | ✅ | 学生作答作业页面核心 |
| P0 | `/student/homework/:id/submit` POST | ✅ | 学生作答提交 |
| P0 | `/student/grades` GET | ✅ | 成绩查看 |
| P0 | `/student/dashboard` GET | ✅ | 学生首页 |
| P1 | `/student/exams` GET | ✅ | 考试日程 |
| P1 | `/student/notifications` GET | ⚠️ 可选 | P5 msg 服务落地后才有意义 |
| P2 | `/student/textbooks` / `/student/chapters` / `/student/questions` | ❌ P4 | content 服务 P4 才落地 |
| P2 | `/student/analytics/*` | ❌ P4 | data-ana 学情诊断 P4 |
| P2 | `/student/ai/*` | ❌ P5 | ai 服务 P5 |
---
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
> 对照 ai-allocation.md §6 模板第 6 节 + §10 审计模板
| 对齐项 | classes 黄金模板 | student-bff 计划 | 备注 |
| ------------------------------- | ---------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
| 权限装饰器 `@RequirePermission` | ✅ 全部 Controller 方法 | ⚠️ **不对齐** | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 给下游校验 |
| 错误码前缀统一 | ✅ `CLASSES_` | ✅ `STUDENT_BFF_` | 对齐 teacher-bff 的 `TEACHER_BFF_` 模式 |
| loggerpino | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `student-bff` |
| metricsprom-client | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `student_bff_` |
| tracerOpenTelemetry | ✅ OTLP exporter + auto-instrumentations | ✅ 复制 teacher-bff tracer.ts | serviceName 改 `student-bff` |
| `/healthz` 健康检查 | ✅ liveness | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| `/readyz` 健康检查 | ✅ Drizzle `SELECT 1` | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok可选检查下游服务可达性 |
| 优雅关闭SIGTERM | ✅ LifecycleService 关闭 DB 连接池 | ✅ main.ts 注册 SIGTERM → `app.close()` + `shutdownTracer()` | BFF 无 DB 连接,仅需关闭 HTTP server + tracer |
| 测试覆盖率 ≥ 80% | ✅ Jest | ⚠️ **待补** | BFF 测试重点是 Service 层聚合逻辑 mock 下游 fetch |
| Dockerfile 多阶段构建 | ✅ builder + runtime | ✅ 复制 teacher-bff Dockerfile | EXPOSE 改 3009 |
| Zod 输入验证 | ✅ Controller 层 `schema.parse(body)` | ✅ Controller 层校验 | 提交作业 body 需 Zod 校验 |
| GlobalErrorFilter | ✅ `@Catch()` 全局过滤器 | ✅ 复制 teacher-bff | 注册到 main.ts |
| ESM `.js` 后缀 import | ✅ tsconfig NodeNext | ✅ 复制 teacher-bff tsconfig | 所有相对 import 带 `.js` |
| `import type` 纯类型导入 | ✅ | ✅ | 对齐 classes 规范 |
| 环境变量 Zod 校验 | ✅ `config/env.ts` | ✅ 复制 teacher-bff env.ts | 下游 URL 配置项扩展 |
### 6.1 与 teacher-bff 模板的差异点(克隆时必须改)
| 文件 | teacher-bff 现值 | student-bff 应改为 |
| ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `package.json` name | `@edu/teacher-bff` | `@edu/student-bff` |
| `src/config/env.ts` `PORT` default | `"3003"` | `"3009"` |
| `src/config/env.ts` 下游 URL | IamServiceUrl / ClassesServiceUrl / CoreEduServiceUrl | + ContentServiceUrl / DataAnaServiceUrl / MsgServiceUrl / AiServiceUrl按聚合需求 |
| `src/teacher/` 目录名 | `teacher/` | `student/` |
| `@Controller("teacher")` | `"teacher"` | `"student"` |
| `health.controller.ts` `SERVICE_NAME` | `"teacher-bff"` | `"student-bff"` |
| `application-error.ts` 错误码前缀 | `TEACHER_BFF_` | `STUDENT_BFF_` |
| `metrics.ts` 指标名前缀 | `teacher_bff_` | `student_bff_` |
| `tracer.ts` serviceName | `"teacher-bff"` | `"student-bff"` |
| `logger.ts` service | `"teacher-bff"` | `"student-bff"` |
| `main.ts` 启动日志 | `"Teacher BFF started"` | `"Student BFF started"` |
| `Dockerfile` `EXPOSE` | `3003` | `3009` |
---
## 7. 风险与依赖(待 coord 仲裁)
### 7.1 上游依赖缺口
| 风险 | 影响 | 缓解措施 |
| -------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------- |
| data-ana 服务未实现查询 APIanalytics.proto 3 个 method 无 REST 端点) | P4 学情诊断端点无法实现 | P3 阶段先不实现 `/student/analytics/*`,等 ai06 在 P4 实现 data-ana 查询 API 后再补 |
| ai 服务未实现 REST/gRPC 端点 | P5 AI 答疑端点无法实现 | P3/P4 阶段先不实现 `/student/ai/*`,等 ai06 在 P5 实现 ai 服务后再补 |
| content.proto 缺 Chapter/Question 域 | P4 教材/题库端点 proto 契约不全 | 推动 coord 在 shared-proto 补全 content.proto |
| iam.proto 缺 Viewport/EffectivePermissions | 学生端导航视口 proto 契约不全 | 当前走 REST `/iam/viewports`proto 补全后切换 |
| 出勤attendance全局缺失 | 学生端无法查出勤 | 推动 coord 在 core_edu.proto 补 Attendance 域P3 后期或 P4 |
| 学生-家长关联表缺失pending-features P2 提到 `parent_student_relations` | 影响 parent-bff不影响 student-bff | 报告给 coord由 ai02 在 iam 或 ai03 在 core-edu 补表 |
### 7.2 设计决策待仲裁
| 决策点 | 选项 | ai04 建议 |
| ------------------ | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| BFF API 风格 | A. REST对齐 teacher-bff 现状)<br/>B. GraphQL对齐 004 §11.3 设计意图 + pending-features P2 | **A**P3 阶段先 REST避免技术栈分裂后续统一升级 |
| BFF 是否做权限校验 | A. 不校验(对齐 teacher-bff透传 x-user-id<br/>B. 加 `@RequirePermission` 装饰器 | **A**BFF 是聚合层,权限由下游服务校验) |
| `/readyz` 检查逻辑 | A. 直接返回 ok对齐 teacher-bff<br/>B. 检查下游服务可达性 | **A**P3 阶段,下游可达性由 Prometheus 监控) |
| Kafka 事件订阅 | A. P3 不订阅(仅同步聚合)<br/>B. P3 订阅事件推送 | **A**push-gateway P5 才落地P3 无推送通道) |
| 端口分配 | 3009 | 对齐 full-stack-runbook 端口矩阵3001-3008 已用) |
### 7.3 跨模块协作需求(需提交 coord 协调)
| 需求 | 涉及 AI | 协调内容 |
| ------------------------------------------------------------ | -------------- | --------------------------------------------------------- |
| api-gateway 新增 `/student` 路由 | ai01 | 在 main.go + config.go 新增 `StudentBffURL` 字段 + 路由块 |
| docker-compose.deploy.yml 新增 student-bff 服务定义 | coordinfra | 端口 3009加入 edu-net + edu-shared 网络 |
| full-stack-runbook 端口矩阵更新 | coorddocs | 追加 3009 行 |
| 004 架构图状态更新 | coorddocs | student-bff 状态从"📐 需设计"改为"✅ 已实现" |
| shared-proto 补全 content.protoChapter/Question | coord | P4 阶段 content 服务落地前补全 |
| shared-proto 补全 iam.protoViewport/EffectivePermissions | coord | 推动 ai02 补 proto |
| buf.gen.yaml 补 gRPC 插件 | coord | 决定是否在 P3 升级到 gRPC 通信 |
---
## 8. 阶段 1 自检结论
| 检查项 | 状态 |
| ------------------------------------ | --------------------------- |
| 已读必读文档清单ai-allocation §4 | ✅ |
| 已运行 arch:scan 更新 arch.db | ✅ |
| 已查 arch:query modules / stats | ✅ |
| 已读 classes 黄金模板源码 | ✅ |
| 已读 teacher-bff BFF 模板源码 | ✅ |
| 已读 iam 认证服务源码 | ✅ |
| 已读 shared-proto 全部 .proto | ✅ |
| 已识别 proto 契约缺口 | ✅(见 §7.1 |
| 已识别端口/路由预留情况 | ✅3009 可用,路由未预留) |
| 已识别设计决策待仲裁项 | ✅(见 §7.2 |
| 已识别跨模块协作需求 | ✅(见 §7.3 |
**ai04 阶段 1 交付完成,请 coord 审核。审核通过后进入阶段 2模块架构设计文档**

View File

@@ -0,0 +1,176 @@
# 服务审计表 — ai04
> AI 标识ai04
> 阶段:阶段 1自检
> 日期2026-07-09
> 审计对象student-bffP3 待实现、parent-bffP4 待实现)
> 对照基准classes 黄金模板(已实现)+ teacher-bff BFF 模板(已实现)
> 模板来源:[ai-allocation.md §10](../../docs/architecture/ai-allocation.md)
---
## 1. 审计状态说明
| 标记 | 含义 |
| ---- | --------------------------------------------------- |
| ✅ | 已对齐黄金模板(实施时复制 + 改名即可) |
| ⚠️ | 有差异但已识别缓解方案(需 coord 仲裁或实施时调整) |
| ❌ | 未对齐,需补齐 |
| 🆕 | 全新服务,尚未实现,审计结果为"设计目标" |
由于 student-bff 和 parent-bff 均为**全新服务(未实现)**,本审计表为**设计目标审计**,列出实施时需对齐的各项。
---
## 2. ai04 服务审计表
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ---------------------------- | ---------- | ----------------- | ------- | -------------- | ------- | -------- | ------- | ---------- | ------------ | ---------- |
| **student-bff**P3 待实现) | ⚠️ 不对齐 | ✅ `STUDENT_BFF_` | ✅ pino | ✅ prom-client | ✅ OTel | ✅ | ✅ | ✅ SIGTERM | 🆕 目标 ≥80% | ✅ 多阶段 |
| **parent-bff**P4 待实现) | ⚠️ 不对齐 | ✅ `PARENT_BFF_` | ✅ pino | ✅ prom-client | ✅ OTel | ✅ | ✅ | ✅ SIGTERM | 🆕 目标 ≥80% | ✅ 多阶段 |
---
## 3. 审计项详解
### 3.1 权限装饰器 `@RequirePermission` — ⚠️ 不对齐
**classes 黄金模板**:全部 Controller 方法用 `@RequirePermission(Permissions.XXX)` 装饰,全局 `PermissionGuard` 校验。
**ai04 服务决策****不对齐**。理由:
- BFF 是纯聚合层,权限由下游业务服务校验(对齐 teacher-bff 现状)
- BFF 透传 `x-user-id` 头给下游,下游 Repository 按 DataScope 过滤
- parent-bff 在 BFF 层做"DataScope=CHILDREN 越权校验"(家长查询的 childId 必须在绑定列表内),但用普通 Service 层校验,不用装饰器
**风险**:若 coord 要求 BFF 也加权限装饰器(双重校验),需在 student-bff/parent-bff 引入 `middleware/permission.guard.ts`(复制 classes 实现)。
### 3.2 错误码前缀 — ✅ 对齐
| 服务 | 前缀 | 示例 |
| ----------- | -------------- | --------------------------------------------------------------------------------- |
| student-bff | `STUDENT_BFF_` | `STUDENT_BFF_UNAUTHORIZED``STUDENT_BFF_BAD_GATEWAY` |
| parent-bff | `PARENT_BFF_` | `PARENT_BFF_UNAUTHORIZED``PARENT_BFF_BAD_GATEWAY``PARENT_BFF_CHILD_NOT_BOUND` |
对齐 teacher-bff 的 `TEACHER_BFF_` 模式。
### 3.3 logger — ✅ 对齐
复制 teacher-bff `shared/observability/logger.ts`,仅改 `service` 字段:
- student-bff: `service: 'student-bff'`
- parent-bff: `service: 'parent-bff'`
### 3.4 metrics — ✅ 对齐
复制 teacher-bff `shared/observability/metrics.ts` + `main.ts``/metrics` 端点注册,仅改指标名前缀:
- student-bff: `student_bff_requests_total``student_bff_request_duration_seconds`
- parent-bff: `parent_bff_requests_total``parent_bff_request_duration_seconds`
### 3.5 tracer — ✅ 对齐
复制 teacher-bff `shared/observability/tracer.ts`,仅改 `serviceName`
- student-bff: `serviceName: 'student-bff'`
- parent-bff: `serviceName: 'parent-bff'`
### 3.6 /healthz — ✅ 对齐
复制 teacher-bff `shared/health/health.controller.ts`,仅改 `SERVICE_NAME`
- student-bff: `service: 'student-bff'`
- parent-bff: `service: 'parent-bff'`
### 3.7 /readyz — ✅ 对齐
BFF 不查 DB`/readyz` 直接返回 ok对齐 teacher-bff
**可选增强**P3 后期检查下游服务可达性HEAD 请求 iam/core-edu 的 `/healthz`),但 P3 阶段建议先简单返回 ok下游可达性由 Prometheus 监控。
### 3.8 优雅关闭 — ✅ 对齐
复制 teacher-bff `main.ts` 中 SIGTERM 处理:
```ts
process.on("SIGTERM", async () => {
await app.close();
await shutdownTracer();
});
```
BFF 无 DB 连接需关闭(无 LifecycleService仅需关闭 HTTP server + tracer。
### 3.9 测试覆盖率 — 🆕 目标 ≥ 80%
**测试重点**
- Service 层聚合逻辑mock 下游 fetch验证 Promise.allSettled 容错)
- Controller 层入参校验Zod schema
- 错误处理BadGatewayError、UnauthorizedError、ValidationError
- parent-bff 特有DataScope=CHILDREN 越权校验
**测试框架**Jest对齐 classes待确认 classes 是否已配置 Jest
### 3.10 Dockerfile — ✅ 对齐
复制 teacher-bff `Dockerfile`多阶段构建builder + runtime仅改 `EXPOSE`
- student-bff: `EXPOSE 3009`
- parent-bff: `EXPOSE 3010`
---
## 4. 与 teacher-bff 模板对比(克隆基线)
teacher-bff 是已实现的最小 BFF 模板student-bff/parent-bff 应 1:1 克隆后改造。
### 4.1 完全复制的部分(无需改动)
| 文件/目录 | 说明 |
| -------------------------------------- | ----------------------------------- |
| `shared/errors/global-error.filter.ts` | 全局错误过滤器(仅错误码前缀不同) |
| `shared/health/health.module.ts` | 健康检查模块 |
| `shared/observability/logger.ts` | pino logger仅 service 名不同) |
| `shared/observability/metrics.ts` | prom-client仅指标名前缀不同 |
| `shared/observability/tracer.ts` | OTel tracer仅 serviceName 不同) |
| `app.module.ts` | 根模块(仅 imports 的业务模块不同) |
| `main.ts` | 启动流程(仅端口和日志不同) |
| `tsconfig.json` | ESM 配置 |
| `nest-cli.json` | NestJS CLI 配置 |
### 4.2 需要改造的部分
| 文件 | 改造点 |
| --------------------------------------- | ---------------------------------------------------------- |
| `package.json` | name 字段、dependencies按下游服务需求扩展 |
| `src/config/env.ts` | PORT default、下游服务 URL 配置项 |
| `src/<context>/` 目录名 | teacher → student / parent |
| `src/<context>/<context>.controller.ts` | @Controller 装饰器前缀、端点路由 |
| `src/<context>/<context>.service.ts` | 聚合逻辑(按场景域不同) |
| `src/<context>/<context>.module.ts` | 模块注册 |
| `shared/errors/application-error.ts` | 错误码前缀、新增错误类parent-bff 加 ChildNotBoundError |
| `shared/health/health.controller.ts` | SERVICE_NAME |
| `Dockerfile` | EXPOSE 端口 |
### 4.3 teacher-bff 模板的可改进点ai04 实施时可优化)
| 改进点 | teacher-bff 现状 | ai04 建议改进 |
| ------------ | ----------------------------- | ------------------------------------------------------------ |
| 聚合响应类型 | 大量用 `unknown` | 用 Zod schema 推导强类型响应 |
| 下游调用封装 | 原生 `fetch()` 散落在 Service | 抽取 `DownstreamClient` 工具类带超时、重试、traceId 透传) |
| 错误信封 | 下游错误直接吞掉返回 null | 下游错误结构化记录service/endpoint/status透传 traceId |
| 缓存 | 无缓存 | 引入 NestJS CacheInterceptor + Redis5-30s 短缓存) |
> ⚠️ 这些改进点需 coord 仲裁是否回写到 teacher-bff避免技术栈分裂还是仅用于 student-bff/parent-bff。
---
## 5. 审计结论
| 服务 | 总体对齐度 | 阻塞项 | 备注 |
| ----------- | ------------ | -------------------------------------- | ---------------------------------------------------------------- |
| student-bff | 🟢 高90% | 无 P0 阻塞 | P3 阶段可直接克隆 teacher-bff 实施data-ana/ai 端点延后到 P4/P5 |
| parent-bff | 🟡 中70% | **P0 阻塞iam 家长-学生关联接口缺失** | 需 coord 协调 ai02 在 P3 阶段补全 iam 接口,否则 P4 无法启动实施 |
**ai04 阶段 1 审计完成。两份理解确认书 + 本审计表已交付,请 coord 审核。**

View File

@@ -0,0 +1,77 @@
# ai04 工作经验日志
> AI 标识ai04student-bff + parent-bff
> 阶段:阶段 1全局理解
> 日期2026-07-09
> 待 coord 合并到 `docs/troubleshooting/known-issues.md` "工作经验日志"区
---
## 2026-07-09 阶段 1 上下文加载
**模块**student-bffP3 待实现、parent-bffP4 待实现)
**做了什么**
1. 运行 `pnpm run arch:scan` 更新 arch.db10 TS / 2 Go / 2 Python / 138 proto 契约12 模块 342 符号)
2. 通过 3 个 search subagent 并行探索classes/teacher-bff/iam 三个 TS NestJS 模板服务结构、shared-proto 全部 8 个 .proto 契约、student-bff/parent-bff 现状与端口/路由预留
3. 阅读 README、MIGRATION_GUIDE、004 架构影响地图、pending-features 路线图
4. 产出两份模块理解确认书 + 一份服务审计表
**学到什么**
1. **teacher-bff 实际实现与设计意图存在差距**
- 004 §11.3 设计意图为 GraphQL + DataLoaderpending-features P2 明确"Teacher BFFTS/GraphQL"
- 实际 teacher-bff 是纯 REST + 原生 fetch + Promise.allSettled无 GraphQL、无 DataLoader
- ai04 建议 student-bff/parent-bff 先对齐 teacher-bff 现状REST避免技术栈分裂待 coord 仲裁是否统一升级
2. **shared-proto 契约与实际 REST 实现存在显著缺口**
- proto 包名用 `next_edu_cloud.*` 而非项目规则要求的 `edu.*`8 个文件全不合规)
- buf.gen.yaml 只配置了 protocolbuffers 序列化插件,**无 gRPC 插件**proto 目前仅作"契约文档"
- iam REST 已暴露 viewports/permissions/effective/roles 端点,但 proto 完全缺失
- content REST 已实现 chapters/questions CRUD但 proto 完全缺失
- data-ana/ai 服务的 proto 已定义但 REST 端点未实现
3. **parent-bff 存在 P0 阻塞项**
- iam 缺失"家长-学生关联查询"接口proto + REST + schema 三缺失)
- pending-features P2 提到 `parent_student_relations` 表,但 iam.schema.ts 未实现
- 需 coord 协调 ai02 在 P3 阶段补全,否则 P4 parent-bff 无法启动
4. **004 §4 服务依赖图与实际场景存在偏差**
- 004 列 parent-bff 仅依赖 iam + core-edu
- 但家长通知、学情诊断场景需要 msg + data-ana
- 需 coord 仲裁是否扩展 parent-bff 依赖
5. **端口/路由预留情况**
- student-bff=3009、parent-bff=3010 可用3001-3008 已用)
- api-gateway 未预留 `/student``/parent` 路由(需 ai01 协调)
- pnpm-workspace.yaml glob `services/*` 自动覆盖新目录
- commitlint scope-enum + CODEOWNERS 已预留 student-bff/parent-bff
6. **BFF 克隆模板策略**
- teacher-bff 是最小 BFF 模板shared/ 目录结构与 classes 完全一致
- BFF 特征:无 DB、无 repository/schema/dto、无 PermissionGuard、无 LifecycleService
- student-bff/parent-bff 可 1:1 克隆 teacher-bff 后改造11 处需改名)
**下次注意**
1. 阶段 2 设计文档需等 coord 审核阶段 1 后再产出
2. 实施前需推动 coord 协调 ai02 补全 iam 家长-学生关联接口P0 阻塞)
3. 实施前需推动 coord 协调 ai01 在 api-gateway 预留 `/student``/parent` 路由
4. proto 包名合规性(`next_edu_cloud.*``edu.*`)需 coord 仲裁是否迁移
5. buf.gen.yaml 是否补 gRPC 插件需 coord 决策
---
## 待 coord 合并到 known-issues.md "场景→技术"映射
| 场景 | 技术/规则 |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| BFF 服务克隆 teacher-bff 模板 | 复制 shared/ + 改 11 处命名package.json/env.ts/Controller/SERVICE_NAME/错误码前缀/指标名前缀/tracer serviceName/logger service/Dockerfile EXPOSE |
| BFF 不做权限校验 | 透传 `x-user-id` 给下游服务校验(对齐 teacher-bffBFF 无 PermissionGuard |
| BFF 聚合用 Promise.allSettled | Dashboard 类聚合容错(部分失败返回 null单依赖查询用 throw BadGatewayError 快速失败 |
| BFF 无 DB 依赖 | /readyz 直接返回 ok无 LifecycleService无 closeDb() |
| 新服务端口分配 | NestJS 服务按 3001-3008 顺序分配student-bff=3009、parent-bff=3010 |
| api-gateway 路由前缀命名 | BFF 用角色单数无 `-bff` 后缀teacher-bff → `/teacher`student-bff → `/student` |
| proto 包名不合规 | 当前用 `next_edu_cloud.*`,规则要求 `edu.*`8 个文件全不合规,待 coord 仲裁迁移 |
| buf.gen.yaml 缺 gRPC 插件 | 当前只配置 protocolbuffers 序列化proto 仅作"契约文档"gRPC 通信未落地 |

View File

@@ -30,6 +30,7 @@
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"eslint": "^9.10.0",
"pino-pretty": "^11.2.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}

View File

@@ -1,9 +1,8 @@
import { Module } from "@nestjs/common";
import { TeacherModule } from "./teacher/teacher.module.js";
import { HealthController } from "./health.controller.js";
import { HealthModule } from "./shared/health/health.module.js";
@Module({
imports: [TeacherModule],
controllers: [HealthController],
imports: [TeacherModule, HealthModule],
})
export class AppModule {}

View File

@@ -1,26 +1,31 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { env } from "./config/env.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 type { Request, Response } from "express";
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, res) => {
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
await app.listen(env.PORT);
console.log(`Teacher BFF started on port ${env.PORT}`);
logger.info({ port: env.PORT }, "Teacher BFF started");
process.on("SIGTERM", async () => {
await app.close();
@@ -29,6 +34,6 @@ async function bootstrap(): Promise<void> {
}
bootstrap().catch((err: unknown) => {
console.error("Failed to start Teacher BFF", err);
logger.error({ err }, "Failed to start Teacher BFF");
process.exit(1);
});

View File

@@ -0,0 +1,118 @@
export type ErrorType =
| "validation"
| "not_found"
| "permission_denied"
| "unauthorized"
| "conflict"
| "business"
| "database"
| "bad_gateway"
| "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, "TEACHER_BFF_VALIDATION_ERROR", details);
}
}
export class NotFoundError extends ApplicationError {
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "TEACHER_BFF_NOT_FOUND", {
resource,
id,
});
}
}
export class PermissionDeniedError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(permission: string) {
super(`Permission denied: ${permission}`, "TEACHER_BFF_PERMISSION_DENIED", {
permission,
});
}
}
export class UnauthorizedError extends ApplicationError {
readonly type = "unauthorized" as const;
readonly statusCode = 401;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_UNAUTHORIZED", details);
}
}
export class ConflictError extends ApplicationError {
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_CONFLICT", details);
}
}
export class BusinessError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_BUSINESS_ERROR", details);
}
}
export class DatabaseError extends ApplicationError {
readonly type = "database" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_DATABASE_ERROR", details);
}
}
export class BadGatewayError extends ApplicationError {
readonly type = "bad_gateway" as const;
readonly statusCode = 502;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_BAD_GATEWAY", details);
}
}
export class InternalError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_INTERNAL_ERROR", details);
}
}

View File

@@ -0,0 +1,87 @@
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";
@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: "TEACHER_BFF_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: "HTTP_ERROR",
message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
}
response.status(statusCode).json(body);
}
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}
}

View File

@@ -1,10 +1,12 @@
import { Controller, Get } from "@nestjs/common";
const SERVICE_NAME = "teacher-bff";
/**
*
*
* - GET /healthzliveness
* - GET /readyzreadiness ok
* - GET /healthzliveness
* - GET /readyzreadinessBFF 访 DB ok
*
*
*/
@@ -14,7 +16,7 @@ export class HealthController {
liveness(): { status: string; service: string; timestamp: string } {
return {
status: "ok",
service: "teacher-bff",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}
@@ -23,7 +25,7 @@ export class HealthController {
readiness(): { status: string; service: string; timestamp: string } {
return {
status: "ok",
service: "teacher-bff",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,19 @@
import pino from "pino";
import { env } from "../../config/env.js";
export const logger = pino({
level: env.LOG_LEVEL,
base: {
service: "teacher-bff",
version: "0.1.0",
},
transport:
env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: { colorize: true },
}
: undefined,
});
export type Logger = typeof logger;

View File

@@ -1,55 +1,70 @@
import {
Controller,
Get,
Param,
Req,
UnauthorizedException,
} from "@nestjs/common";
import { Controller, Get, Param, Req } from "@nestjs/common";
import type { Request } from "express";
import { TeacherService } from "./teacher.service.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
interface SuccessResponse<T> {
success: true;
data: T;
}
@Controller("teacher")
export class TeacherController {
constructor(private readonly service: TeacherService) {}
@Get("dashboard")
async dashboard(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
async dashboard(@Req() req: Request): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.getDashboard(userId);
return { success: true as const, data };
}
@Get("viewports")
async viewports(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
async viewports(@Req() req: Request): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.getViewports(userId);
return { success: true as const, data };
}
// 聚合班级下的考试列表core-edu
@Get("classes/:classId/exams")
async listExamsByClass(@Param("classId") classId: string) {
const data = await this.service.listExamsByClass(classId);
async listExamsByClass(
@Req() req: Request,
@Param("classId") classId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listExamsByClass(userId, classId);
return { success: true as const, data };
}
// 聚合班级下的作业列表core-edu
@Get("classes/:classId/homework")
async listHomeworkByClass(@Param("classId") classId: string) {
const data = await this.service.listHomeworkByClass(classId);
async listHomeworkByClass(
@Req() req: Request,
@Param("classId") classId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listHomeworkByClass(userId, classId);
return { success: true as const, data };
}
// 聚合考试下的成绩列表core-edu
@Get("exams/:examId/grades")
async listGradesByExam(@Param("examId") examId: string) {
const data = await this.service.listGradesByExam(examId);
async listGradesByExam(
@Req() req: Request,
@Param("examId") examId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listGradesByExam(userId, examId);
return { success: true as const, data };
}
private extractUserId(req: Request): string {
const header = req.headers["x-user-id"];
const userId = typeof header === "string" ? header : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
return userId;
}
}

View File

@@ -1,5 +1,7 @@
import { Injectable } from "@nestjs/common";
import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js";
import { BadGatewayError } from "../shared/errors/application-error.js";
export interface ViewportItem {
key: string;
@@ -10,13 +12,20 @@ export interface ViewportItem {
requiredPermission: string | null;
}
interface DownstreamEnvelope<T> {
success: boolean;
data?: T;
}
interface DashboardData {
user: unknown;
classes: unknown;
}
@Injectable()
export class TeacherService {
// 聚合 IAM + classes 服务的数据
async getDashboard(userId: string): Promise<{
user: unknown;
classes: unknown;
}> {
async getDashboard(userId: string): Promise<DashboardData> {
const [iamRes, classesRes] = await Promise.allSettled([
fetch(`${env.IamServiceUrl}/iam/me`, {
headers: { "x-user-id": userId },
@@ -26,13 +35,42 @@ export class TeacherService {
}),
]);
return {
user: iamRes.status === "fulfilled" ? await iamRes.value.json() : null,
classes:
classesRes.status === "fulfilled"
? await classesRes.value.json()
: null,
};
let user: unknown = null;
let classes: unknown = null;
if (iamRes.status === "fulfilled") {
if (iamRes.value.ok) {
user = await iamRes.value.json();
} else {
logger.warn(
{ status: iamRes.value.status, url: iamRes.value.url },
"Downstream IAM service call failed",
);
}
} else {
logger.warn(
{ err: iamRes.reason, service: "iam" },
"Downstream IAM service call rejected",
);
}
if (classesRes.status === "fulfilled") {
if (classesRes.value.ok) {
classes = await classesRes.value.json();
} else {
logger.warn(
{ status: classesRes.value.status, url: classesRes.value.url },
"Downstream classes service call failed",
);
}
} else {
logger.warn(
{ err: classesRes.reason, service: "classes" },
"Downstream classes service call rejected",
);
}
return { user, classes };
}
// 聚合 IAM 视口配置L1 导航)
@@ -41,51 +79,80 @@ export class TeacherService {
headers: { "x-user-id": userId },
});
if (!res.ok) {
return [];
logger.warn(
{ status: res.status, url: res.url },
"Downstream IAM service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "iam",
endpoint: "viewports",
status: res.status,
});
}
const json = (await res.json()) as {
success: boolean;
data?: ViewportItem[];
};
const json = (await res.json()) as DownstreamEnvelope<ViewportItem[]>;
return json.data ?? [];
}
// 聚合班级下的考试列表core-edu
async listExamsByClass(classId: string): Promise<unknown> {
async listExamsByClass(userId: string, classId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/exams/class/${encodeURIComponent(classId)}`,
{ headers: { "x-user-id": "bff" } },
{ headers: { "x-user-id": userId } },
);
if (!res.ok) {
return [];
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "exams-by-class",
status: res.status,
});
}
const json = (await res.json()) as { success: boolean; data?: unknown };
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
}
// 聚合班级下的作业列表core-edu
async listHomeworkByClass(classId: string): Promise<unknown> {
async listHomeworkByClass(userId: string, classId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/homework/class/${encodeURIComponent(classId)}`,
{ headers: { "x-user-id": "bff" } },
{ headers: { "x-user-id": userId } },
);
if (!res.ok) {
return [];
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "homework-by-class",
status: res.status,
});
}
const json = (await res.json()) as { success: boolean; data?: unknown };
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
}
// 聚合考试下的成绩列表core-edu
async listGradesByExam(examId: string): Promise<unknown> {
async listGradesByExam(userId: string, examId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/grades/exam/${encodeURIComponent(examId)}`,
{ headers: { "x-user-id": "bff" } },
{ headers: { "x-user-id": userId } },
);
if (!res.ok) {
return [];
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "grades-by-exam",
status: res.status,
});
}
const json = (await res.json()) as { success: boolean; data?: unknown };
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
}
}