chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
5
gateway/.dockerignore
Normal file
5
gateway/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
gateway.exe
|
||||
*.md
|
||||
apisix
|
||||
docker-compose.yml
|
||||
17
gateway/Dockerfile
Normal file
17
gateway/Dockerfile
Normal 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
43
gateway/README.md
Normal 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`。
|
||||
|
||||
## 1)Go 网关(推荐本地)
|
||||
|
||||
```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`。
|
||||
|
||||
## 2)APISIX(Docker)
|
||||
|
||||
先启动 Docker Desktop,再:
|
||||
|
||||
```powershell
|
||||
cd gateway
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
入口:`http://127.0.0.1:9080`
|
||||
路由见 `apisix/apisix.yaml`(`host.docker.internal` 访问宿主机服务)。
|
||||
38
gateway/apisix/apisix.yaml
Normal file
38
gateway/apisix/apisix.yaml
Normal 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
|
||||
14
gateway/apisix/config.yaml
Normal file
14
gateway/apisix/config.yaml
Normal 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
|
||||
28
gateway/docker-compose.yml
Normal file
28
gateway/docker-compose.yml
Normal 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:
|
||||
12
gateway/etc/gateway.docker.yaml
Normal file
12
gateway/etc/gateway.docker.yaml
Normal 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
12
gateway/etc/gateway.yaml
Normal 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
81
gateway/gateway.go
Normal 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
22
gateway/go.mod
Normal 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
68
gateway/go.sum
Normal 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=
|
||||
16
gateway/internal/config/config.go
Normal file
16
gateway/internal/config/config.go
Normal 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"`
|
||||
// 网关预检 JWT;AI 生成可匿名
|
||||
JWTEnable bool `json:",default=true"`
|
||||
}
|
||||
102
gateway/internal/jwtmw/jwt.go
Normal file
102
gateway/internal/jwtmw/jwt.go
Normal 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:])
|
||||
}
|
||||
186
gateway/internal/proxy/director.go
Normal file
186
gateway/internal/proxy/director.go
Normal 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}
|
||||
}
|
||||
76
gateway/internal/ratelimit/limiter.go
Normal file
76
gateway/internal/ratelimit/limiter.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user