chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

5
gateway/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
gateway.exe
*.md
apisix
docker-compose.yml

17
gateway/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
ARG BASE_REGISTRY=docker.m.daocloud.io/library
FROM ${BASE_REGISTRY}/golang:1.21-bookworm AS build
WORKDIR /src
ENV GOPROXY=https://goproxy.cn,direct
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/gateway .
FROM ${BASE_REGISTRY}/debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/gateway /app/gateway
COPY etc/gateway.docker.yaml /app/etc/gateway.yaml
EXPOSE 8180
CMD ["/app/gateway", "-f", "/app/etc/gateway.yaml"]

43
gateway/README.md Normal file
View File

@@ -0,0 +1,43 @@
# 网关
统一入口:**默认用 Go 网关(本机 :8180**Docker 就绪后可切 APISIX:9080
## 路由
| 对外路径 | 上游 |
|----------|------|
| `/api/v1/*` | platform `:8888` |
| `/ai/*` | AI `:8001`(去掉 `/ai` 前缀) |
| `/gateway/health` | 网关健康检查 |
JWT 仍由中台校验网关负责反代、CORS、限流、`X-Request-Id` / `X-Gateway`
## 1Go 网关(推荐本地)
```powershell
# 先启动 platform + ai-service
cd gateway
go mod tidy
go run . -f etc/gateway.yaml
```
探测:
```powershell
curl http://127.0.0.1:8180/gateway/health
curl -X POST http://127.0.0.1:8180/api/v1/auth/login -H "Content-Type: application/json" -d "{\"username\":\"demo\",\"password\":\"demo123\"}"
```
前端 `vite` 已代理到 `8180`
## 2APISIXDocker
先启动 Docker Desktop
```powershell
cd gateway
docker compose up -d
```
入口:`http://127.0.0.1:9080`
路由见 `apisix/apisix.yaml``host.docker.internal` 访问宿主机服务)。

View File

@@ -0,0 +1,38 @@
routes:
- id: platform-api
uri: /api/v1/*
name: platform-api
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
upstream:
type: roundrobin
nodes:
"host.docker.internal:8888": 1
plugins:
cors:
allow_origins: "**"
allow_methods: "**"
allow_headers: "**"
limit-req:
rate: 50
burst: 30
key_type: var
key: remote_addr
rejected_code: 429
- id: ai-service
uri: /ai/*
name: ai-service
methods: ["GET", "POST", "OPTIONS"]
upstream:
type: roundrobin
nodes:
"host.docker.internal:8001": 1
plugins:
cors:
allow_origins: "**"
allow_methods: "**"
allow_headers: "**"
proxy-rewrite:
regex_uri: ["^/ai/(.*)", "/$1"]
#END

View File

@@ -0,0 +1,14 @@
apisix:
node_listen: 9080
enable_ipv6: false
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
plugin_attr:
prometheus:
export_addr:
ip: 0.0.0.0
port: 9091

View File

@@ -0,0 +1,28 @@
# APISIX 声明式网关Docker 启动后使用)
# 前置:启动 Docker Desktop并保证宿主机 platform:8888 / ai:8001 已运行
services:
etcd:
image: bitnami/etcd:3.5
environment:
ALLOW_NONE_AUTHENTICATION: "yes"
ETCD_ADVERTISE_CLIENT_URLS: http://etcd:2379
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
volumes:
- etcd_data:/bitnami/etcd
apisix:
image: apache/apisix:3.11.0-debian
depends_on:
- etcd
ports:
- "9080:9080" # HTTP 入口
- "9180:9180" # Admin API
volumes:
- ./apisix/config.yaml:/usr/local/apisix/conf/config.yaml:ro
- ./apisix/apisix.yaml:/usr/local/apisix/conf/apisix.yaml:ro
environment:
TZ: Asia/Shanghai
volumes:
etcd_data:

View File

@@ -0,0 +1,12 @@
Name: gateway
Host: 0.0.0.0
Port: 8180
PlatformURL: "http://platform:8888"
AIURL: "http://ai:8001"
RateLimitPerMin: 6000
ProxyTimeoutSec: 60
CorsEnable: true
JWTEnable: true
JWTSecret: "dev-only-change-me"

12
gateway/etc/gateway.yaml Normal file
View File

@@ -0,0 +1,12 @@
Name: gateway
Host: 0.0.0.0
Port: 8180
PlatformURL: "http://127.0.0.1:8888"
AIURL: "http://127.0.0.1:8001"
RateLimitPerMin: 6000
ProxyTimeoutSec: 60
CorsEnable: true
JWTEnable: true
JWTSecret: "dev-only-change-me"

81
gateway/gateway.go Normal file
View File

@@ -0,0 +1,81 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"aijianzhan/gateway/internal/config"
"aijianzhan/gateway/internal/jwtmw"
"aijianzhan/gateway/internal/proxy"
"aijianzhan/gateway/internal/ratelimit"
"github.com/zeromicro/go-zero/core/conf"
)
var configFile = flag.String("f", "etc/gateway.yaml", "config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
dir, err := proxy.New(c.PlatformURL, c.AIURL, c.ProxyTimeoutSec)
if err != nil {
log.Fatal(err)
}
limiter := ratelimit.New(c.RateLimitPerMin)
h := limiter.Middleware(dir.Handler())
if c.JWTEnable {
secret := c.JWTSecret
if secret == "" {
secret = "dev-only-change-me"
}
public := []string{
"/gateway/health",
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/token",
"/api/v1/auth/agent/register",
"/api/v1/meta/",
"/api/v1/public/",
"/ai/",
"/ai",
}
h = jwtmw.Middleware(secret, public)(h)
}
mux := http.NewServeMux()
mux.HandleFunc("/gateway/health", dir.Health)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if c.CorsEnable {
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*"
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id, X-Tenant-Id, X-User-Id, X-Role")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Credentials", "true")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
if r.URL.Path == "/gateway/health" {
dir.Health(w, r)
return
}
h(w, r)
})
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
limitDesc := fmt.Sprintf("%d/min (loopback exempt)", c.RateLimitPerMin)
if c.RateLimitPerMin <= 0 {
limitDesc = "disabled"
}
fmt.Printf("gateway listening %s jwt=%v rate=%s → platform=%s ai=%s\n", addr, c.JWTEnable, limitDesc, c.PlatformURL, c.AIURL)
log.Fatal(http.ListenAndServe(addr, mux))
}

22
gateway/go.mod Normal file
View File

@@ -0,0 +1,22 @@
module aijianzhan/gateway
go 1.21
require (
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/zeromicro/go-zero v1.6.6
)
require (
github.com/fatih/color v1.17.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
go.opentelemetry.io/otel v1.19.0 // indirect
go.opentelemetry.io/otel/trace v1.19.0 // indirect
go.uber.org/automaxprocs v1.5.3 // indirect
golang.org/x/sys v0.21.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

68
gateway/go.sum Normal file
View File

@@ -0,0 +1,68 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/zeromicro/go-zero v1.6.6 h1:nZTVYObklHiBdYJ/nPoAZ8kGVAplWSDjT7DGE7ur0uk=
github.com/zeromicro/go-zero v1.6.6/go.mod h1:olKf1/hELbSmuIgLgJeoeNVp3tCbLqj6UmO7ATSta4A=
go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs=
go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY=
go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE=
go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8=
go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o=
go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A=
go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg=
go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo=
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
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=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=

View File

@@ -0,0 +1,16 @@
package config
type Config struct {
Name string `json:",optional"`
Host string `json:",default=0.0.0.0"`
Port int `json:",default=8180"`
PlatformURL string `json:",default=http://127.0.0.1:8888"`
AIURL string `json:",default=http://127.0.0.1:8001"`
RateLimitPerMin int `json:",default=240"`
ProxyTimeoutSec int `json:",default=60"`
CorsEnable bool `json:",default=true"`
// 与中台 Auth.AccessSecret 保持一致
JWTSecret string `json:",optional"`
// 网关预检 JWTAI 生成可匿名
JWTEnable bool `json:",default=true"`
}

View File

@@ -0,0 +1,102 @@
package jwtmw
import (
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
)
type Claims struct {
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// Middleware 网关侧预检 JWT公开路径放行。中台仍会再次校验。
func Middleware(secret string, publicPrefixes []string) func(http.HandlerFunc) http.HandlerFunc {
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}))
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
for _, p := range publicPrefixes {
if path == p || strings.HasPrefix(path, p) {
next(w, r)
return
}
}
if r.Method == http.MethodOptions {
next(w, r)
return
}
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(strings.ToLower(auth), "bearer ") {
writeErr(w, http.StatusUnauthorized, "gateway: missing bearer token")
return
}
raw := strings.TrimSpace(auth[len("Bearer "):])
if raw == "" || strings.Count(raw, ".") != 2 {
writeErr(w, http.StatusUnauthorized, "gateway: malformed token")
return
}
token, err := parser.ParseWithClaims(raw, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte(secret), nil
})
if err != nil || token == nil || !token.Valid {
writeErr(w, http.StatusUnauthorized, "gateway: invalid token ("+shortJWTErr(err)+"),请重新登录")
return
}
next(w, r)
}
}
}
func shortJWTErr(err error) string {
if err == nil {
return "rejected"
}
msg := err.Error()
switch {
case strings.Contains(msg, "expired"):
return "expired"
case strings.Contains(msg, "signature"):
return "bad signature"
case strings.Contains(msg, "malformed"):
return "malformed"
case strings.Contains(msg, "used before"):
return "not yet valid"
default:
if len(msg) > 80 {
return msg[:80]
}
return msg
}
}
func writeErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"code":` + itoa(code) + `,"message":"` + escapeJSON(msg) + `"}`))
}
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", " ")
return s
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}

View File

@@ -0,0 +1,186 @@
package proxy
import (
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
)
type Director struct {
Platform *url.URL
AI *url.URL
Timeout time.Duration
}
func New(platformURL, aiURL string, timeoutSec int) (*Director, error) {
p, err := url.Parse(platformURL)
if err != nil {
return nil, err
}
a, err := url.Parse(aiURL)
if err != nil {
return nil, err
}
if timeoutSec <= 0 {
timeoutSec = 60
}
return &Director{Platform: p, AI: a, Timeout: time.Duration(timeoutSec) * time.Second}, nil
}
func (d *Director) Handler() http.HandlerFunc {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return func(w http.ResponseWriter, r *http.Request) {
target, path := d.route(r.URL.Path)
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = transport
proxy.FlushInterval = 100 * time.Millisecond
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadGateway)
_, _ = rw.Write([]byte(`{"code":502,"message":"upstream unavailable: ` + escape(err.Error()) + `"}`))
}
proxy.ModifyResponse = func(resp *http.Response) error {
// 网关已统一加 CORS去掉上游重复头避免浏览器报 Failed to fetch
for _, h := range []string{
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Methods",
"Access-Control-Expose-Headers",
"Access-Control-Max-Age",
} {
resp.Header.Del(h)
}
resp.Header.Set("X-Gateway", "aijianzhan-gateway")
return nil
}
// 重写路径
r.URL.Path = path
r.Host = target.Host
r.Header.Set("X-Forwarded-Host", r.Header.Get("Host"))
r.Header.Set("X-Forwarded-Proto", "http")
if r.Header.Get("X-Request-Id") == "" {
r.Header.Set("X-Request-Id", newRequestID())
}
// 超时上下文
ctx := r.Context()
proxy.ServeHTTP(w, r.WithContext(ctx))
}
}
func (d *Director) route(path string) (*url.URL, string) {
// AI/ai/* → 上游去掉 /ai 前缀
if strings.HasPrefix(path, "/ai/") || path == "/ai" {
next := strings.TrimPrefix(path, "/ai")
if next == "" {
next = "/"
}
return d.AI, next
}
// 生成蓝图:统一 /api/v1/apps/generate兼容旧冒号路径避免重复实现
if path == "/api/v1/apps/generate" || strings.HasPrefix(path, "/api/v1/apps/generate?") {
return d.AI, path
}
if strings.HasPrefix(path, "/api/v1/apps:generate") || strings.Contains(path, "/apps%3Agenerate") {
return d.AI, "/api/v1/apps/generate"
}
// 其余 /api 走中台
return d.Platform, path
}
func escape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
if len(s) > 200 {
s = s[:200]
}
return s
}
func newRequestID() string {
return strings.ReplaceAll(time.Now().UTC().Format("20060102T150405.000000000"), ".", "")
}
// Health 聚合探测
func (d *Director) Health(w http.ResponseWriter, _ *http.Request) {
type st struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
}
client := &http.Client{Timeout: 2 * time.Second}
out := []st{
probe(client, "platform", d.Platform.String()+"/api/v1/auth/login"),
probe(client, "ai", d.AI.String()+"/health"),
}
allOK := true
for _, s := range out {
if !s.OK {
allOK = false
break
}
}
code := http.StatusOK
if !allOK {
code = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = io.WriteString(w, `{"gateway":"ok","upstreams":[`)
for i, s := range out {
if i > 0 {
_, _ = io.WriteString(w, ",")
}
ok := "false"
if s.OK {
ok = "true"
}
_, _ = io.WriteString(w, `{"name":"`+s.Name+`","ok":`+ok+`}`)
}
_, _ = io.WriteString(w, `]}`)
}
func probe(client *http.Client, name, rawURL string) struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
} {
// login 用 OPTIONS/GET 可能 405用短超时 HEAD/GET health 风格
req, _ := http.NewRequest(http.MethodGet, rawURL, nil)
if name == "platform" {
// 未登录会 401/405 都说明服务活着;连接失败才算挂
req, _ = http.NewRequest(http.MethodPost, rawURL, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
}{Name: name, OK: false, Detail: err.Error()}
}
defer resp.Body.Close()
return struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
}{Name: name, OK: true}
}

View File

@@ -0,0 +1,76 @@
package ratelimit
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
type Limiter struct {
mu sync.Mutex
visitors map[string]*visitor
rate int // 0 = disabled
window time.Duration
}
type visitor struct {
count int
reset time.Time
}
// New creates a per-IP limiter. ratePerMinute <= 0 disables limiting.
func New(ratePerMinute int) *Limiter {
return &Limiter{visitors: map[string]*visitor{}, rate: ratePerMinute, window: time.Minute}
}
func (l *Limiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if l.rate <= 0 {
next(w, r)
return
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil || ip == "" {
ip = r.RemoteAddr
}
// 本机开发Vite 代理与所有本机请求共用 127.0.0.1,不做限流
if isLoopback(ip) {
next(w, r)
return
}
if !l.allow(ip) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":429,"message":"gateway rate limit exceeded"}`))
return
}
next(w, r)
}
}
func isLoopback(ip string) bool {
ip = strings.Trim(ip, "[]")
if ip == "127.0.0.1" || ip == "::1" || ip == "localhost" {
return true
}
parsed := net.ParseIP(ip)
return parsed != nil && parsed.IsLoopback()
}
func (l *Limiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
v, ok := l.visitors[key]
if !ok || now.After(v.reset) {
l.visitors[key] = &visitor{count: 1, reset: now.Add(l.window)}
return true
}
if v.count >= l.rate {
return false
}
v.count++
return true
}