chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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