feat(p1): complete P1 foundation stage
- 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:
15
services/api-gateway/Dockerfile
Normal file
15
services/api-gateway/Dockerfile
Normal 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"]
|
||||
19
services/api-gateway/README.md
Normal file
19
services/api-gateway/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# API Gateway
|
||||
|
||||
Go (Gin) 实现的 API 网关,所有外部请求的统一入口。
|
||||
|
||||
## 职责
|
||||
- 路由转发:/api/v1/classes/* → classes 服务
|
||||
- JWT 鉴权:P1 HS256,P2 RS256
|
||||
- 请求 ID 注入:全链路追踪
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
cd services/api-gateway
|
||||
go mod tidy
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## 健康检查
|
||||
GET /healthz
|
||||
14
services/api-gateway/go.mod
Normal file
14
services/api-gateway/go.mod
Normal 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
|
||||
)
|
||||
44
services/api-gateway/internal/config/config.go
Normal file
44
services/api-gateway/internal/config/config.go
Normal 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
|
||||
}
|
||||
112
services/api-gateway/internal/middleware/auth.go
Normal file
112
services/api-gateway/internal/middleware/auth.go
Normal 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 用 HS256,P2 改 RS256(IAM 签发)
|
||||
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()
|
||||
}
|
||||
35
services/api-gateway/internal/proxy/proxy.go
Normal file
35
services/api-gateway/internal/proxy/proxy.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
services/api-gateway/internal/routing/routing.go
Normal file
42
services/api-gateway/internal/routing/routing.go
Normal 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",
|
||||
})
|
||||
}
|
||||
47
services/api-gateway/main.go
Normal file
47
services/api-gateway/main.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user