feat(p1): complete P1 foundation stage
Some checks failed
CI Go / test (push) Has been cancelled
CI Proto / lint (push) Has been cancelled
CI Python / test (push) Has been cancelled
CI TypeScript / test (push) Has been cancelled

- monorepo: pnpm workspace + go.work + pyproject.toml + commitlint/husky
- infra: docker-compose (minimal + full profiles) + init-sql + prometheus
- arch-scan: multi-language scanner skeleton (TS/Go/Python/Proto)
- shared-proto: buf v2 + classes.proto (ClassService CRUD contract)
- api-gateway: Go/Gin + JWT HS256 auth + reverse proxy + request ID
- classes: NestJS golden template (error system + observability + middleware + CRUD + tests)
- teacher-portal: Next.js + paper-feel UI design system
- CI/CD: 4 workflows (go/ts/py/proto)
- docs: migration guide + project_rules + coding-standards + git-workflow + ui-design-system + 004 + 9 module READMEs + known-issues + spec/plan migration + roadmap
This commit is contained in:
SpecialX
2026-07-07 23:39:37 +08:00
commit 2ba4250165
100 changed files with 15242 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o api-gateway .
# Runtime stage
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/api-gateway .
EXPOSE 8080
CMD ["./api-gateway"]

View File

@@ -0,0 +1,19 @@
# API Gateway
Go (Gin) 实现的 API 网关,所有外部请求的统一入口。
## 职责
- 路由转发:/api/v1/classes/* → classes 服务
- JWT 鉴权P1 HS256P2 RS256
- 请求 ID 注入:全链路追踪
## 开发
```bash
cd services/api-gateway
go mod tidy
go run main.go
```
## 健康检查
GET /healthz

View File

@@ -0,0 +1,14 @@
module github.com/edu-cloud/api-gateway
go 1.22
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0
go.opentelemetry.io/otel v1.27.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0
go.opentelemetry.io/otel/sdk v1.27.0
go.uber.org/zap v1.27.0
)

View File

@@ -0,0 +1,44 @@
package config
import (
"os"
"strconv"
)
type Config struct {
Port string
JWTSecret string
JWTIssuer string
JWTAudience string
ClassesServiceURL string
OTLPEndpoint string
LogLevel string
}
func Load() *Config {
return &Config{
Port: getEnv("API_GATEWAY_PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "p1-dev-secret-change-in-production"),
JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"),
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
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
}

View File

@@ -0,0 +1,112 @@
package middleware
import (
"net/http"
"strings"
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AuthMiddleware 验证 JWT 并注入用户信息到请求头
// P1 用 HS256P2 改 RS256IAM 签发)
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// 健康检查跳过鉴权
if c.Request.URL.Path == "/healthz" {
c.Next()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "missing authorization header",
},
})
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if tokenStr == authHeader {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "invalid authorization scheme, expected Bearer",
},
})
return
}
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_TOKEN",
"message": "token validation failed",
},
})
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_CLAIMS",
"message": "invalid token claims",
},
})
return
}
// 注入用户信息到下游请求头
if sub, ok := claims["sub"].(string); ok {
c.Request.Header.Set("x-user-id", sub)
}
if roles, ok := claims["roles"].([]interface{}); ok {
roleStrs := make([]string, 0, len(roles))
for _, r := range roles {
if s, ok := r.(string); ok {
roleStrs = append(roleStrs, s)
}
}
c.Request.Header.Set("x-user-roles", strings.Join(roleStrs, ","))
}
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

@@ -0,0 +1,35 @@
package proxy
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
// NewProxy 创建反向代理
func NewProxy(targetURL string) (*httputil.ReverseProxy, error) {
target, err := url.Parse(targetURL)
if err != nil {
return nil, err
}
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// 去除 /api/v1 前缀
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api/v1")
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api")
req.Host = target.Host
}
return proxy, nil
}
// ProxyHandler 返回 Gin 处理函数
func ProxyHandler(proxy *httputil.ReverseProxy) gin.HandlerFunc {
return func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
}
}

View File

@@ -0,0 +1,42 @@
package routing
import (
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/edu-cloud/api-gateway/internal/middleware"
"github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/gin-gonic/gin"
)
// Setup 配置路由
func Setup(cfg *config.Config) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// 中间件
r.Use(middleware.RequestIDMiddleware())
r.Use(gin.Recovery())
// 健康检查(无需鉴权)
r.GET("/healthz", healthz)
// API v1 组(需要鉴权)
api := r.Group("/api/v1")
api.Use(middleware.AuthMiddleware(cfg))
{
// classes 服务路由
classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL)
if err != nil {
panic("failed to create classes proxy: " + err.Error())
}
api.Any("/classes/*path", proxy.ProxyHandler(classesProxy))
}
return r
}
func healthz(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "api-gateway",
})
}

View File

@@ -0,0 +1,47 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/edu-cloud/api-gateway/internal/routing"
)
func main() {
cfg := config.Load()
r := routing.Setup(cfg)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
// 优雅关闭
go func() {
log.Printf("API Gateway listening on :%s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down API Gateway...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("API Gateway exited")
}