diff --git a/.env.example b/.env.example index 54744b8..053e244 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ APP_ENV=development HTTP_ADDR=:8088 -# Reuse the same PostgreSQL instance as Agent memory, but use an independent +# Reuse the same PostgreSQL 18 instance as Agent memory, but use an independent # database. When running from the host, use the externally reachable host/port. AI_GATEWAY_DATABASE_NAME=easyai_ai_gateway AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable @@ -17,9 +17,22 @@ AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@localhost:5432/easyai_ai_ # Keep this aligned with easyai-server-main CONFIG_JWT_SECRET in the first migration phase. CONFIG_JWT_SECRET=this is a very secret secret -# Used when the gateway delegates OpenAPI sk-* validation, file upload, and settlement callbacks. +# Identity mode: +# - standalone: Gateway owns users, groups, login/API keys, wallet, recharge, and local billing. +# - server-main: server-main owns users/API keys/billing; Gateway stores synced users/groups for policy execution. +# - hybrid: both sources are accepted and separated by gateway_users.source. +IDENTITY_MODE=hybrid + +# Used when the gateway delegates OpenAPI sk-* validation, user/group sync, file upload, and settlement callbacks. SERVER_MAIN_BASE_URL=http://localhost:3000 SERVER_MAIN_INTERNAL_TOKEN=change-me +# Gateway writes progress events locally, then calls this server-main endpoint. +# server-main receives the callback and pushes it through the existing WebSocket gateway. +TASK_PROGRESS_CALLBACK_ENABLED=true +TASK_PROGRESS_CALLBACK_URL=http://localhost:3000/internal/platform/task-progress-callbacks +TASK_PROGRESS_CALLBACK_TIMEOUT_MS=5000 +TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS=10 + CORS_ALLOWED_ORIGIN=http://localhost:5178 VITE_GATEWAY_API_BASE_URL=http://localhost:8088 diff --git a/README.md b/README.md index 798f860..f99b091 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ ## 技术选型 -- 后端:Go + PostgreSQL,复用 Agent memory 的 `easyai-pgvector`,保留 `server-main` 的 JWT / API Key 授权语义。 +- 后端:Go + PostgreSQL 18,复用 Agent memory 的 `easyai-pgvector`,支持本地用户、可选邀请码、API Key、余额/充值闭环,也支持复用 `server-main` 的 JWT / API Key 授权语义。 - 前端:React + TypeScript + TSX,UI 体系按 `shadcn-ui` / Radix / Tailwind 方向沉淀,先提供运维控制台骨架。 - Monorepo:Nx 负责任务编排,Go 使用 `go.work` 管理模块。 -- 集成:完成后由 `easyai-server-main` 通过内部 HTTP SDK 直连本服务,前端经网关访问本服务。 +- 集成:完成后由 `easyai-server-main` 通过内部 HTTP SDK 直连本服务;任务实时进度由 Gateway 回调 `server-main`,再通过原 WebSocket 网关推送给业务前端。 ## 目录 @@ -33,7 +33,8 @@ pnpm dev - API: `http://localhost:8088` - Web: `http://localhost:5178` -- PostgreSQL: 默认使用宿主机 `localhost:5432` 上的 `postgres` 容器,并使用独立库 `easyai_ai_gateway` +- PostgreSQL: 目标版本 18,默认使用宿主机 `localhost:5432` 上的 `easyai-pgvector` 实例,并使用独立库 `easyai_ai_gateway` +- 身份模式: 默认 `IDENTITY_MODE=hybrid`,可同时测试 Gateway 本地账号注册登录、可选邀请码和 `server-main` JWT / API Key 对接。 默认 EasyAI 部署里,`easyai-pgvector` 在容器网络内的连接串是: @@ -52,9 +53,9 @@ AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@localhost:5432/easyai_ai_ ## 迁移原则 1. 新服务先并行运行,不直接删除 `easyai-server-main` 内现有模块。 -2. 授权先复用 `server-main` 的 JWT secret、claim、角色权限模型。 -3. OpenAPI `sk-*` 校验、文件上传、扣费结算仍由 `server-main` 承担。 -4. 网关服务负责基准模型库、平台模型路由、TPM/RPM/并发限流、任务队列、三方平台执行、任务进度推送。 +2. 身份域支持 `standalone`、`server-main`、`hybrid` 三种模式;独立模式由 Gateway 维护租户、用户、用户组、本地 API Key、余额和充值订单,接入模式从 `server-main` 同步租户、用户和用户组。 +3. OpenAPI `sk-*` 校验、文件上传、扣费结算在接入模式下仍由 `server-main` 承担;独立模式走 Gateway 本地闭环。 +4. 网关服务负责基准模型库、平台模型路由、用户组调用折扣、TPM/RPM/并发限流、任务队列、三方平台执行、任务进度事件和回调 outbox。 5. 切流时优先让 `server-main` 的 `OpenaiService` 变成薄门面,内部调用本服务。 详细设计见 [docs/design.md](docs/design.md)。 diff --git a/apps/api/go.mod b/apps/api/go.mod index 8de3e49..6b7a1fa 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -5,4 +5,13 @@ go 1.23 require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx/v5 v5.7.2 + golang.org/x/crypto v0.31.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum new file mode 100644 index 0000000..5a44fea --- /dev/null +++ b/apps/api/go.sum @@ -0,0 +1,30 @@ +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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index 62f6dda..ff9bbde 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -24,14 +24,21 @@ const ( ) type User struct { - ID string `json:"sub"` - Username string `json:"username"` - Roles []string `json:"role,omitempty"` - TenantID string `json:"tenantId,omitempty"` - SSOID string `json:"sso_id,omitempty"` - APIKeyID string `json:"apiKeyId,omitempty"` - APIKeySecret string `json:"apiKeySecret,omitempty"` - APIKeyName string `json:"apiKeyName,omitempty"` + ID string `json:"sub"` + Username string `json:"username"` + Roles []string `json:"role,omitempty"` + TenantID string `json:"tenantId,omitempty"` + GatewayTenantID string `json:"gatewayTenantId,omitempty"` + TenantKey string `json:"tenantKey,omitempty"` + SSOID string `json:"sso_id,omitempty"` + Source string `json:"source,omitempty"` + GatewayUserID string `json:"gatewayUserId,omitempty"` + UserGroupID string `json:"userGroupId,omitempty"` + UserGroupKey string `json:"userGroupKey,omitempty"` + UserGroupKeys []string `json:"userGroupKeys,omitempty"` + APIKeyID string `json:"apiKeyId,omitempty"` + APIKeySecret string `json:"apiKeySecret,omitempty"` + APIKeyName string `json:"apiKeyName,omitempty"` } type contextKey string @@ -41,15 +48,15 @@ const userContextKey contextKey = "easyai-auth-user" var ErrUnauthorized = errors.New("unauthorized") type Authenticator struct { - JWTSecret string + JWTSecret string ServerMainBaseURL string ServerMainInternalToken string - HTTPClient *http.Client + HTTPClient *http.Client } func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator { return &Authenticator{ - JWTSecret: jwtSecret, + JWTSecret: jwtSecret, ServerMainBaseURL: strings.TrimRight(serverMainBaseURL, "/"), ServerMainInternalToken: internalToken, HTTPClient: &http.Client{ @@ -112,14 +119,24 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) { } user := &User{ - ID: stringClaim(claims, "sub"), - Username: stringClaim(claims, "username"), - Roles: stringSliceClaim(claims, "role"), - TenantID: stringClaim(claims, "tenantId"), - SSOID: stringClaim(claims, "sso_id"), - APIKeyID: stringClaim(claims, "apiKeyId"), - APIKeySecret: stringClaim(claims, "apiKeySecret"), - APIKeyName: stringClaim(claims, "apiKeyName"), + ID: stringClaim(claims, "sub"), + Username: stringClaim(claims, "username"), + Roles: stringSliceClaim(claims, "role"), + TenantID: stringClaim(claims, "tenantId"), + GatewayTenantID: stringClaim(claims, "gatewayTenantId"), + TenantKey: stringClaim(claims, "tenantKey"), + SSOID: stringClaim(claims, "sso_id"), + Source: stringClaim(claims, "source"), + GatewayUserID: stringClaim(claims, "gatewayUserId"), + UserGroupID: stringClaim(claims, "userGroupId"), + UserGroupKey: stringClaim(claims, "userGroupKey"), + UserGroupKeys: stringSliceClaim(claims, "userGroupKeys"), + APIKeyID: stringClaim(claims, "apiKeyId"), + APIKeySecret: stringClaim(claims, "apiKeySecret"), + APIKeyName: stringClaim(claims, "apiKeyName"), + } + if user.Source == "" { + user.Source = "gateway" } if user.ID == "" { return nil, ErrUnauthorized @@ -127,6 +144,30 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) { return user, nil } +func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) { + if ttl <= 0 { + ttl = time.Hour + } + now := time.Now() + claims := jwt.MapClaims{ + "sub": user.ID, + "username": user.Username, + "role": user.Roles, + "tenantId": user.TenantID, + "gatewayTenantId": user.GatewayTenantID, + "tenantKey": user.TenantKey, + "source": user.Source, + "gatewayUserId": user.GatewayUserID, + "userGroupId": user.UserGroupID, + "userGroupKey": user.UserGroupKey, + "userGroupKeys": user.UserGroupKeys, + "iat": now.Unix(), + "exp": now.Add(ttl).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(a.JWTSecret)) +} + func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) { if a.ServerMainBaseURL == "" || a.ServerMainInternalToken == "" { return nil, ErrUnauthorized @@ -154,6 +195,9 @@ func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, if user.ID == "" { return nil, ErrUnauthorized } + if user.Source == "" { + user.Source = "server-main" + } return &user, nil } diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 1bd7d7a..967c22b 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -8,14 +8,19 @@ import ( ) type Config struct { - AppEnv string - HTTPAddr string - DatabaseURL string - JWTSecret string - ServerMainBaseURL string - ServerMainInternalToken string - CORSAllowedOrigin string - LogLevel slog.Level + AppEnv string + HTTPAddr string + DatabaseURL string + IdentityMode string + JWTSecret string + ServerMainBaseURL string + ServerMainInternalToken string + TaskProgressCallbackEnabled bool + TaskProgressCallbackURL string + TaskProgressCallbackTimeoutMS string + TaskProgressCallbackMaxAttempts string + CORSAllowedOrigin string + LogLevel slog.Level } func Load() Config { @@ -23,14 +28,21 @@ func Load() Config { AppEnv: env("APP_ENV", "development"), HTTPAddr: env("HTTP_ADDR", ":8088"), DatabaseURL: gatewayDatabaseURL(), + IdentityMode: env("IDENTITY_MODE", "hybrid"), JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"), ServerMainBaseURL: strings.TrimRight( env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/", ), - ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), - CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178"), - LogLevel: logLevel(env("LOG_LEVEL", "info")), + ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), + TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", + TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL", + strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks", + ), + TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"), + TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"), + CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178"), + LogLevel: logLevel(env("LOG_LEVEL", "info")), } } diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 414b8dd..3993649 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -2,8 +2,10 @@ package httpapi import ( "encoding/json" + "errors" "fmt" "net/http" + "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" @@ -12,9 +14,10 @@ import ( func (s *Server) health(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "service": "easyai-ai-gateway", - "env": s.cfg.AppEnv, + "ok": true, + "service": "easyai-ai-gateway", + "env": s.cfg.AppEnv, + "identityMode": s.cfg.IdentityMode, }) } @@ -31,6 +34,100 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, user) } +func (s *Server) register(w http.ResponseWriter, r *http.Request) { + if !s.localIdentityEnabled() { + writeError(w, http.StatusForbidden, "local registration is disabled") + return + } + var input store.LocalRegisterInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + user, err := s.store.RegisterLocalUser(r.Context(), input) + if err != nil { + if errors.Is(err, store.ErrWeakPassword) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if errors.Is(err, store.ErrInvalidInvitation) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.logger.Error("register local user failed", "error", err) + writeError(w, http.StatusConflict, "user already exists or tenant is unavailable") + return + } + s.writeAuthResponse(w, http.StatusCreated, user) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + if !s.localIdentityEnabled() { + writeError(w, http.StatusForbidden, "local login is disabled") + return + } + var input store.LocalLoginInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + user, err := s.store.AuthenticateLocalUser(r.Context(), input) + if err != nil { + if errors.Is(err, store.ErrInvalidCredentials) { + writeError(w, http.StatusUnauthorized, "invalid account or password") + return + } + s.logger.Error("login local user failed", "error", err) + writeError(w, http.StatusInternalServerError, "login failed") + return + } + s.writeAuthResponse(w, http.StatusOK, user) +} + +func (s *Server) localIdentityEnabled() bool { + mode := strings.ToLower(strings.TrimSpace(s.cfg.IdentityMode)) + return mode == "" || mode == "standalone" || mode == "hybrid" +} + +func (s *Server) writeAuthResponse(w http.ResponseWriter, status int, user store.GatewayUser) { + authUser := authUserFromGatewayUser(user) + const ttl = 24 * time.Hour + token, err := s.auth.SignJWT(authUser, ttl) + if err != nil { + s.logger.Error("sign local jwt failed", "error", err) + writeError(w, http.StatusInternalServerError, "token sign failed") + return + } + writeJSON(w, status, map[string]any{ + "accessToken": token, + "tokenType": "Bearer", + "expiresIn": int(ttl.Seconds()), + "user": authUser, + }) +} + +func authUserFromGatewayUser(user store.GatewayUser) *auth.User { + roles := user.Roles + if len(roles) == 0 { + roles = []string{"user"} + } + tenantID := user.TenantID + if tenantID == "" { + tenantID = user.TenantKey + } + return &auth.User{ + ID: user.ID, + Username: user.Username, + Roles: roles, + TenantID: tenantID, + GatewayTenantID: user.GatewayTenantID, + TenantKey: user.TenantKey, + Source: "gateway", + GatewayUserID: user.ID, + UserGroupID: user.DefaultUserGroupID, + } +} + func (s *Server) listPlatforms(w http.ResponseWriter, r *http.Request) { platforms, err := s.store.ListPlatforms(r.Context()) if err != nil { @@ -103,6 +200,36 @@ func (s *Server) listPricingRules(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (s *Server) listTenants(w http.ResponseWriter, r *http.Request) { + items, err := s.store.ListTenants(r.Context()) + if err != nil { + s.logger.Error("list tenants failed", "error", err) + writeError(w, http.StatusInternalServerError, "list tenants failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { + items, err := s.store.ListUsers(r.Context()) + if err != nil { + s.logger.Error("list users failed", "error", err) + writeError(w, http.StatusInternalServerError, "list users failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) listUserGroups(w http.ResponseWriter, r *http.Request) { + items, err := s.store.ListUserGroups(r.Context()) + if err != nil { + s.logger.Error("list user groups failed", "error", err) + writeError(w, http.StatusInternalServerError, "list user groups failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) { var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index d103423..36dbc47 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -29,13 +29,18 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han mux.HandleFunc("GET /healthz", server.health) mux.HandleFunc("GET /readyz", server.ready) + mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register))) + mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login))) mux.Handle("GET /api/v1/me", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.me))) mux.Handle("GET /api/v1/catalog/providers", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listCatalogProviders))) mux.Handle("GET /api/v1/catalog/base-models", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listBaseModels))) + mux.Handle("GET /api/v1/tenants", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listTenants))) + mux.Handle("GET /api/v1/users", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUsers))) + mux.Handle("GET /api/v1/user-groups", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUserGroups))) mux.Handle("GET /api/v1/pricing/rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRules))) mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing))) mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPlatforms))) - mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.createPlatform))) + mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatform))) mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModels))) mux.Handle("GET /api/v1/runtime/rate-limit-windows", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows))) mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions"))) diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 28b644e..4b35d9e 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -3,17 +3,27 @@ package store import ( "context" "encoding/json" + "errors" + "strings" "time" + "unicode" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" ) type Store struct { pool *pgxpool.Pool } +var ( + ErrInvalidCredentials = errors.New("invalid account or password") + ErrInvalidInvitation = errors.New("invalid or expired invitation code") + ErrWeakPassword = errors.New("password must be at least 8 characters") +) + func Connect(ctx context.Context, databaseURL string) (*Store, error) { pool, err := pgxpool.New(ctx, databaseURL) if err != nil { @@ -126,6 +136,83 @@ type PricingRule struct { UpdatedAt time.Time `json:"updatedAt"` } +type GatewayTenant struct { + ID string `json:"id"` + TenantKey string `json:"tenantKey"` + Source string `json:"source"` + ExternalTenantID string `json:"externalTenantId,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DefaultUserGroupID string `json:"defaultUserGroupId,omitempty"` + PlanKey string `json:"planKey,omitempty"` + BillingProfile map[string]any `json:"billingProfile,omitempty"` + RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"` + AuthPolicy map[string]any `json:"authPolicy,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Status string `json:"status"` + SyncedAt string `json:"syncedAt,omitempty"` + SourceUpdatedAt string `json:"sourceUpdatedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type LocalRegisterInput struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"displayName"` + TenantKey string `json:"tenantKey"` + TenantName string `json:"tenantName"` + InvitationCode string `json:"invitationCode"` +} + +type LocalLoginInput struct { + Account string `json:"account"` + Password string `json:"password"` +} + +type GatewayUser struct { + ID string `json:"id"` + UserKey string `json:"userKey"` + Source string `json:"source"` + ExternalUserID string `json:"externalUserId,omitempty"` + Username string `json:"username"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + GatewayTenantID string `json:"gatewayTenantId,omitempty"` + TenantID string `json:"tenantId,omitempty"` + TenantKey string `json:"tenantKey,omitempty"` + DefaultUserGroupID string `json:"defaultUserGroupId,omitempty"` + Roles []string `json:"roles,omitempty"` + AuthProfile map[string]any `json:"authProfile,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Status string `json:"status"` + LastLoginAt string `json:"lastLoginAt,omitempty"` + SyncedAt string `json:"syncedAt,omitempty"` + SourceUpdatedAt string `json:"sourceUpdatedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserGroup struct { + ID string `json:"id"` + GroupKey string `json:"groupKey"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Source string `json:"source"` + Priority int `json:"priority"` + RechargeDiscountPolicy map[string]any `json:"rechargeDiscountPolicy,omitempty"` + BillingDiscountPolicy map[string]any `json:"billingDiscountPolicy,omitempty"` + RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"` + QuotaPolicy map[string]any `json:"quotaPolicy,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + type RateLimitWindow struct { ScopeType string `json:"scopeType"` ScopeKey string `json:"scopeKey"` @@ -145,18 +232,24 @@ type CreateTaskInput struct { } type GatewayTask struct { - ID string `json:"id"` - Kind string `json:"kind"` - UserID string `json:"userId"` - TenantID string `json:"tenantId,omitempty"` - Model string `json:"model"` - Request map[string]any `json:"request,omitempty"` - Status string `json:"status"` - Result map[string]any `json:"result,omitempty"` - Billings []any `json:"billings,omitempty"` - Error string `json:"error,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Kind string `json:"kind"` + UserID string `json:"userId"` + GatewayUserID string `json:"gatewayUserId,omitempty"` + UserSource string `json:"userSource,omitempty"` + GatewayTenantID string `json:"gatewayTenantId,omitempty"` + TenantID string `json:"tenantId,omitempty"` + TenantKey string `json:"tenantKey,omitempty"` + UserGroupID string `json:"userGroupId,omitempty"` + UserGroupKey string `json:"userGroupKey,omitempty"` + Model string `json:"model"` + Request map[string]any `json:"request,omitempty"` + Status string `json:"status"` + Result map[string]any `json:"result,omitempty"` + Billings []any `json:"billings,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (s *Store) ListPlatforms(ctx context.Context) ([]Platform, error) { @@ -408,6 +501,383 @@ ORDER BY scope_type ASC, resource_type ASC, created_at DESC`) return items, rows.Err() } +func (s *Store) ListTenants(ctx context.Context) ([]GatewayTenant, error) { + rows, err := s.pool.Query(ctx, ` +SELECT id::text, tenant_key, source, COALESCE(external_tenant_id, ''), name, COALESCE(description, ''), + COALESCE(default_user_group_id::text, ''), COALESCE(plan_key, ''), billing_profile, rate_limit_policy, + auth_policy, metadata, status, COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''), + created_at, updated_at +FROM gateway_tenants +WHERE deleted_at IS NULL +ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]GatewayTenant, 0) + for rows.Next() { + var item GatewayTenant + var billingProfile []byte + var rateLimitPolicy []byte + var authPolicy []byte + var metadata []byte + if err := rows.Scan( + &item.ID, + &item.TenantKey, + &item.Source, + &item.ExternalTenantID, + &item.Name, + &item.Description, + &item.DefaultUserGroupID, + &item.PlanKey, + &billingProfile, + &rateLimitPolicy, + &authPolicy, + &metadata, + &item.Status, + &item.SyncedAt, + &item.SourceUpdatedAt, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + return nil, err + } + item.BillingProfile = decodeObject(billingProfile) + item.RateLimitPolicy = decodeObject(rateLimitPolicy) + item.AuthPolicy = decodeObject(authPolicy) + item.Metadata = decodeObject(metadata) + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) ListUsers(ctx context.Context) ([]GatewayUser, error) { + rows, err := s.pool.Query(ctx, ` +SELECT id::text, user_key, source, COALESCE(external_user_id, ''), username, + COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''), + COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), + COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata, + status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''), + created_at, updated_at +FROM gateway_users +WHERE deleted_at IS NULL +ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]GatewayUser, 0) + for rows.Next() { + var item GatewayUser + var roles []byte + var authProfile []byte + var metadata []byte + if err := rows.Scan( + &item.ID, + &item.UserKey, + &item.Source, + &item.ExternalUserID, + &item.Username, + &item.DisplayName, + &item.Email, + &item.Phone, + &item.AvatarURL, + &item.GatewayTenantID, + &item.TenantID, + &item.TenantKey, + &item.DefaultUserGroupID, + &roles, + &authProfile, + &metadata, + &item.Status, + &item.LastLoginAt, + &item.SyncedAt, + &item.SourceUpdatedAt, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + return nil, err + } + item.Roles = decodeStringArray(roles) + item.AuthProfile = decodeObject(authProfile) + item.Metadata = decodeObject(metadata) + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) ListUserGroups(ctx context.Context) ([]UserGroup, error) { + rows, err := s.pool.Query(ctx, ` +SELECT id::text, group_key, name, COALESCE(description, ''), source, priority, + recharge_discount_policy, billing_discount_policy, rate_limit_policy, quota_policy, metadata, + status, created_at, updated_at +FROM gateway_user_groups +ORDER BY priority ASC, group_key ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]UserGroup, 0) + for rows.Next() { + var item UserGroup + var rechargeDiscountPolicy []byte + var billingDiscountPolicy []byte + var rateLimitPolicy []byte + var quotaPolicy []byte + var metadata []byte + if err := rows.Scan( + &item.ID, + &item.GroupKey, + &item.Name, + &item.Description, + &item.Source, + &item.Priority, + &rechargeDiscountPolicy, + &billingDiscountPolicy, + &rateLimitPolicy, + "aPolicy, + &metadata, + &item.Status, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + return nil, err + } + item.RechargeDiscountPolicy = decodeObject(rechargeDiscountPolicy) + item.BillingDiscountPolicy = decodeObject(billingDiscountPolicy) + item.RateLimitPolicy = decodeObject(rateLimitPolicy) + item.QuotaPolicy = decodeObject(quotaPolicy) + item.Metadata = decodeObject(metadata) + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) RegisterLocalUser(ctx context.Context, input LocalRegisterInput) (GatewayUser, error) { + account := normalizeAccount(firstNonEmpty(input.Username, input.Email)) + if account == "" { + return GatewayUser{}, errors.New("username or email is required") + } + if len(input.Password) < 8 { + return GatewayUser{}, ErrWeakPassword + } + tenantKey := normalizeKey(input.TenantKey) + if tenantKey == "" { + tenantKey = "personal-" + normalizeKey(account) + } + tenantName := strings.TrimSpace(input.TenantName) + if tenantName == "" { + tenantName = tenantKey + } + displayName := strings.TrimSpace(input.DisplayName) + username := strings.TrimSpace(input.Username) + if username == "" { + username = account + } + email := strings.TrimSpace(strings.ToLower(input.Email)) + invitationCode := strings.TrimSpace(input.InvitationCode) + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + return GatewayUser{}, err + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return GatewayUser{}, err + } + defer tx.Rollback(ctx) + + var tenantID string + userGroupID := "" + role := "user" + invitationID := "" + if invitationCode != "" { + if err := tx.QueryRow(ctx, ` +SELECT i.id::text, + i.tenant_id::text, + t.tenant_key, + t.name, + COALESCE(i.user_group_id::text, t.default_user_group_id::text, ''), + COALESCE(NULLIF(i.role, ''), 'user') +FROM gateway_tenant_invitations i +JOIN gateway_tenants t ON t.id = i.tenant_id +WHERE lower(i.invite_code) = lower($1) + AND i.status = 'active' + AND t.status = 'active' + AND (i.expires_at IS NULL OR i.expires_at > now()) + AND (i.max_uses IS NULL OR i.used_count < i.max_uses) +FOR UPDATE OF i`, + invitationCode, + ).Scan(&invitationID, &tenantID, &tenantKey, &tenantName, &userGroupID, &role); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return GatewayUser{}, ErrInvalidInvitation + } + return GatewayUser{}, err + } + } else if err := tx.QueryRow(ctx, ` + INSERT INTO gateway_tenants (tenant_key, source, external_tenant_id, name) + VALUES ($1, 'gateway', $1, $2) + ON CONFLICT (tenant_key) DO UPDATE SET updated_at=now() + RETURNING id::text`, + tenantKey, tenantName, + ).Scan(&tenantID); err != nil { + return GatewayUser{}, err + } + + rolesJSON, err := json.Marshal([]string{role}) + if err != nil { + return GatewayUser{}, err + } + + var user GatewayUser + var roles []byte + var authProfile []byte + var metadata []byte + if err := tx.QueryRow(ctx, ` + INSERT INTO gateway_users ( + user_key, source, external_user_id, username, display_name, email, + password_hash, gateway_tenant_id, tenant_id, tenant_key, default_user_group_id, roles, status + ) + VALUES ($1, 'gateway', $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6, $7::uuid, $8, $8, NULLIF($9, '')::uuid, $10::jsonb, 'active') + RETURNING id::text, user_key, source, COALESCE(external_user_id, ''), username, + COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''), + COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), + COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata, + status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''), + created_at, updated_at`, + "gateway:"+account, account, username, displayName, email, string(passwordHash), tenantID, tenantKey, userGroupID, string(rolesJSON), + ).Scan( + &user.ID, + &user.UserKey, + &user.Source, + &user.ExternalUserID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Phone, + &user.AvatarURL, + &user.GatewayTenantID, + &user.TenantID, + &user.TenantKey, + &user.DefaultUserGroupID, + &roles, + &authProfile, + &metadata, + &user.Status, + &user.LastLoginAt, + &user.SyncedAt, + &user.SourceUpdatedAt, + &user.CreatedAt, + &user.UpdatedAt, + ); err != nil { + return GatewayUser{}, err + } + if invitationID != "" { + if _, err := tx.Exec(ctx, ` +UPDATE gateway_tenant_invitations +SET used_count = used_count + 1, updated_at = now() +WHERE id = $1::uuid`, invitationID); err != nil { + return GatewayUser{}, err + } + } + if userGroupID != "" { + metadata, err := json.Marshal(map[string]any{ + "source": "registration", + "invitationId": invitationID, + }) + if err != nil { + return GatewayUser{}, err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO gateway_user_group_memberships (group_id, principal_type, principal_id, source, metadata) +VALUES ($1::uuid, 'user', $2, 'gateway', $3::jsonb) +ON CONFLICT (group_id, principal_type, principal_id) +DO UPDATE SET status = 'active', updated_at = now()`, + userGroupID, user.ID, string(metadata), + ); err != nil { + return GatewayUser{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return GatewayUser{}, err + } + user.Roles = decodeStringArray(roles) + user.AuthProfile = decodeObject(authProfile) + user.Metadata = decodeObject(metadata) + return user, nil +} + +func (s *Store) AuthenticateLocalUser(ctx context.Context, input LocalLoginInput) (GatewayUser, error) { + account := normalizeAccount(input.Account) + if account == "" || input.Password == "" { + return GatewayUser{}, ErrInvalidCredentials + } + var user GatewayUser + var passwordHash string + var roles []byte + var authProfile []byte + var metadata []byte + err := s.pool.QueryRow(ctx, ` +SELECT id::text, user_key, source, COALESCE(external_user_id, ''), username, + COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''), + COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), + COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata, + status, COALESCE(password_hash, ''), COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), + COALESCE(source_updated_at::text, ''), created_at, updated_at +FROM gateway_users +WHERE source='gateway' + AND deleted_at IS NULL + AND (external_user_id=$1 OR lower(username)=$1 OR lower(COALESCE(email, ''))=$1) +ORDER BY created_at ASC +LIMIT 1`, account, + ).Scan( + &user.ID, + &user.UserKey, + &user.Source, + &user.ExternalUserID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Phone, + &user.AvatarURL, + &user.GatewayTenantID, + &user.TenantID, + &user.TenantKey, + &user.DefaultUserGroupID, + &roles, + &authProfile, + &metadata, + &user.Status, + &passwordHash, + &user.LastLoginAt, + &user.SyncedAt, + &user.SourceUpdatedAt, + &user.CreatedAt, + &user.UpdatedAt, + ) + if err != nil { + if IsNotFound(err) { + return GatewayUser{}, ErrInvalidCredentials + } + return GatewayUser{}, err + } + if user.Status != "active" || passwordHash == "" { + return GatewayUser{}, ErrInvalidCredentials + } + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil { + return GatewayUser{}, ErrInvalidCredentials + } + user.Roles = decodeStringArray(roles) + user.AuthProfile = decodeObject(authProfile) + user.Metadata = decodeObject(metadata) + _, _ = s.pool.Exec(ctx, `UPDATE gateway_users SET last_login_at=now(), updated_at=now() WHERE id=$1`, user.ID) + return user, nil +} + func (s *Store) ListRateLimitWindows(ctx context.Context) ([]RateLimitWindow, error) { rows, err := s.pool.Query(ctx, ` SELECT scope_type, scope_key, metric, window_start, limit_value::float8, used_value::float8, @@ -448,11 +918,16 @@ func (s *Store) CreateTask(ctx context.Context, input CreateTaskInput, user *aut var resultBytes []byte var billingsBytes []byte err := s.pool.QueryRow(ctx, ` -INSERT INTO gateway_tasks (kind, user_id, tenant_id, model, request, status) -VALUES ($1, $2, NULLIF($3, ''), $4, $5, 'queued') -RETURNING id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`, - input.Kind, user.ID, user.TenantID, input.Model, requestBody, - ).Scan(&task.ID, &task.Kind, &task.UserID, &task.TenantID, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt) +INSERT INTO gateway_tasks ( + kind, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key, + api_key_id, user_group_id, user_group_key, model, request, status +) +VALUES ($1, $2, NULLIF($3, '')::uuid, COALESCE(NULLIF($4, ''), 'gateway'), NULLIF($5, '')::uuid, NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, '')::uuid, NULLIF($10, ''), $11, $12, 'queued') +RETURNING id::text, kind, user_id, COALESCE(gateway_user_id::text, ''), user_source, + COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), + COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`, + input.Kind, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, + ).Scan(&task.ID, &task.Kind, &task.UserID, &task.GatewayUserID, &task.UserSource, &task.GatewayTenantID, &task.TenantID, &task.TenantKey, &task.UserGroupID, &task.UserGroupKey, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt) if err != nil { return GatewayTask{}, err } @@ -468,10 +943,12 @@ func (s *Store) GetTask(ctx context.Context, taskID string) (GatewayTask, error) var resultBytes []byte var billingsBytes []byte err := s.pool.QueryRow(ctx, ` -SELECT id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at +SELECT id::text, kind, user_id, COALESCE(gateway_user_id::text, ''), user_source, + COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), + COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at FROM gateway_tasks WHERE id=$1`, taskID, - ).Scan(&task.ID, &task.Kind, &task.UserID, &task.TenantID, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt) + ).Scan(&task.ID, &task.Kind, &task.UserID, &task.GatewayUserID, &task.UserSource, &task.GatewayTenantID, &task.TenantID, &task.TenantKey, &task.UserGroupID, &task.UserGroupKey, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt) if err != nil { return GatewayTask{}, err } @@ -506,3 +983,50 @@ func decodeArray(bytes []byte) []any { } return out } + +func decodeStringArray(bytes []byte) []string { + if len(bytes) == 0 { + return nil + } + var out []string + if err := json.Unmarshal(bytes, &out); err == nil { + return out + } + return nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func normalizeAccount(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func normalizeKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastDash := false + for _, r := range value { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + b.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == '.' || unicode.IsSpace(r): + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "default" + } + return out +} diff --git a/apps/api/migrations/0001_init.sql b/apps/api/migrations/0001_init.sql index 5ca8e49..8d96768 100644 --- a/apps/api/migrations/0001_init.sql +++ b/apps/api/migrations/0001_init.sql @@ -49,6 +49,9 @@ CREATE TABLE IF NOT EXISTS integration_platforms ( auth_type text NOT NULL DEFAULT 'bearer', credentials jsonb NOT NULL DEFAULT '{}'::jsonb, config jsonb NOT NULL DEFAULT '{}'::jsonb, + visibility_scope text NOT NULL DEFAULT 'global', + tenant_id text, + tenant_key text, default_pricing_mode text NOT NULL DEFAULT 'inherit_discount', default_discount_factor numeric NOT NULL DEFAULT 1, retry_policy jsonb NOT NULL DEFAULT '{}'::jsonb, @@ -72,6 +75,9 @@ CREATE INDEX IF NOT EXISTS idx_integration_platforms_status_priority CREATE INDEX IF NOT EXISTS idx_integration_platforms_cooldown ON integration_platforms(cooldown_until); +CREATE INDEX IF NOT EXISTS idx_integration_platforms_tenant_scope + ON integration_platforms(visibility_scope, tenant_id, tenant_key, status); + CREATE TABLE IF NOT EXISTS model_pricing_rules ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), scope_type text NOT NULL, @@ -94,6 +100,240 @@ CREATE INDEX IF NOT EXISTS idx_model_pricing_scope CREATE INDEX IF NOT EXISTS idx_model_pricing_effective ON model_pricing_rules(effective_from, effective_to); +CREATE TABLE IF NOT EXISTS gateway_user_groups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + group_key text NOT NULL UNIQUE, + name text NOT NULL, + description text, + source text NOT NULL DEFAULT 'gateway', + priority integer NOT NULL DEFAULT 100, + recharge_discount_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + billing_discount_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + quota_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_gateway_user_groups_status_priority + ON gateway_user_groups(status, priority); + +CREATE TABLE IF NOT EXISTS gateway_tenants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_key text NOT NULL UNIQUE, + source text NOT NULL DEFAULT 'gateway', + external_tenant_id text, + name text NOT NULL, + description text, + default_user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL, + plan_key text, + billing_profile jsonb NOT NULL DEFAULT '{}'::jsonb, + rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + auth_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'active', + synced_at timestamptz, + source_updated_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + UNIQUE(source, external_tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_tenants_source_external + ON gateway_tenants(source, external_tenant_id) + WHERE external_tenant_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_gateway_tenants_status + ON gateway_tenants(status, created_at DESC); + +CREATE TABLE IF NOT EXISTS gateway_users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_key text NOT NULL UNIQUE, + source text NOT NULL DEFAULT 'gateway', + external_user_id text, + username text NOT NULL, + display_name text, + email text, + phone text, + avatar_url text, + password_hash text, + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, + tenant_id text, + tenant_key text, + default_user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL, + roles jsonb NOT NULL DEFAULT '[]'::jsonb, + auth_profile jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'active', + last_login_at timestamptz, + synced_at timestamptz, + source_updated_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + UNIQUE(source, external_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_users_source_external + ON gateway_users(source, external_user_id) + WHERE external_user_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_gateway_users_status + ON gateway_users(status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_gateway_users_tenant + ON gateway_users(tenant_id, tenant_key, status); + +CREATE TABLE IF NOT EXISTS gateway_user_group_memberships ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + group_id uuid NOT NULL REFERENCES gateway_user_groups(id) ON DELETE CASCADE, + principal_type text NOT NULL, + principal_id text NOT NULL, + source text NOT NULL DEFAULT 'gateway', + priority integer NOT NULL DEFAULT 100, + effective_from timestamptz, + effective_to timestamptz, + status text NOT NULL DEFAULT 'active', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(group_id, principal_type, principal_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_group_membership_principal + ON gateway_user_group_memberships(principal_type, principal_id, status); + +CREATE INDEX IF NOT EXISTS idx_user_group_membership_effective + ON gateway_user_group_memberships(effective_from, effective_to); + +CREATE TABLE IF NOT EXISTS gateway_tenant_invitations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES gateway_tenants(id) ON DELETE CASCADE, + invite_code text NOT NULL UNIQUE, + role text NOT NULL DEFAULT 'user', + user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL, + max_uses integer, + used_count integer NOT NULL DEFAULT 0, + expires_at timestamptz, + status text NOT NULL DEFAULT 'active', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_by uuid REFERENCES gateway_users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_gateway_invitations_tenant + ON gateway_tenant_invitations(tenant_id, status); + +CREATE INDEX IF NOT EXISTS idx_gateway_invitations_expiry + ON gateway_tenant_invitations(expires_at); + +CREATE TABLE IF NOT EXISTS gateway_api_keys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, + gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE, + tenant_id text, + tenant_key text, + user_id text, + key_prefix text NOT NULL, + key_hash text NOT NULL UNIQUE, + name text NOT NULL, + scopes jsonb NOT NULL DEFAULT '[]'::jsonb, + user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL, + rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + quota_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'active', + expires_at timestamptz, + last_used_at timestamptz, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_owner + ON gateway_api_keys(gateway_user_id, status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_prefix + ON gateway_api_keys(key_prefix, status); + +CREATE TABLE IF NOT EXISTS gateway_wallet_accounts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, + gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE, + tenant_id text, + tenant_key text, + user_id text, + currency text NOT NULL DEFAULT 'resource', + balance numeric NOT NULL DEFAULT 0, + frozen_balance numeric NOT NULL DEFAULT 0, + total_recharged numeric NOT NULL DEFAULT 0, + total_spent numeric NOT NULL DEFAULT 0, + status text NOT NULL DEFAULT 'active', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(gateway_user_id, currency) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_wallet_accounts_tenant + ON gateway_wallet_accounts(gateway_tenant_id, status); + +CREATE TABLE IF NOT EXISTS gateway_wallet_transactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES gateway_wallet_accounts(id) ON DELETE CASCADE, + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, + gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL, + direction text NOT NULL, + transaction_type text NOT NULL, + amount numeric NOT NULL, + balance_before numeric NOT NULL, + balance_after numeric NOT NULL, + idempotency_key text, + reference_type text, + reference_id text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_gateway_wallet_transactions_account + ON gateway_wallet_transactions(account_id, created_at DESC); + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_wallet_tx_idempotency + ON gateway_wallet_transactions(account_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; + +CREATE TABLE IF NOT EXISTS gateway_recharge_orders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, + gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE, + tenant_id text, + tenant_key text, + user_id text, + amount numeric NOT NULL, + bonus_amount numeric NOT NULL DEFAULT 0, + payable_amount numeric NOT NULL, + currency text NOT NULL DEFAULT 'resource', + channel text NOT NULL DEFAULT 'manual', + status text NOT NULL DEFAULT 'created', + external_order_id text, + idempotency_key text, + paid_at timestamptz, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_gateway_recharge_orders_user + ON gateway_recharge_orders(gateway_user_id, created_at DESC); + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_recharge_order_idempotency + ON gateway_recharge_orders(gateway_user_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; + CREATE TABLE IF NOT EXISTS platform_models ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), platform_id uuid NOT NULL REFERENCES integration_platforms(id) ON DELETE CASCADE, @@ -135,8 +375,15 @@ CREATE TABLE IF NOT EXISTS gateway_tasks ( kind text NOT NULL, run_mode text NOT NULL DEFAULT 'production', user_id text NOT NULL, + gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL, + user_source text NOT NULL DEFAULT 'gateway', + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, tenant_id text, + tenant_key text, api_key_id text, + user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL, + user_group_key text, + user_group_policy_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, model text NOT NULL, model_type text, request jsonb NOT NULL DEFAULT '{}'::jsonb, @@ -226,6 +473,29 @@ CREATE TABLE IF NOT EXISTS gateway_task_events ( CREATE INDEX IF NOT EXISTS idx_gateway_events_task_created ON gateway_task_events(task_id, created_at); +CREATE TABLE IF NOT EXISTS gateway_task_callback_outbox ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + task_id uuid NOT NULL REFERENCES gateway_tasks(id) ON DELETE CASCADE, + event_id uuid REFERENCES gateway_task_events(id) ON DELETE SET NULL, + seq bigint NOT NULL, + callback_url text NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL DEFAULT 'pending', + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text, + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(task_id, seq, callback_url) +); + +CREATE INDEX IF NOT EXISTS idx_task_callback_outbox_pending + ON gateway_task_callback_outbox(status, next_attempt_at); + +CREATE INDEX IF NOT EXISTS idx_task_callback_outbox_task + ON gateway_task_callback_outbox(task_id, seq); + CREATE TABLE IF NOT EXISTS runtime_client_states ( client_id text PRIMARY KEY, platform_id uuid REFERENCES integration_platforms(id) ON DELETE SET NULL, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6b400ff..404041f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,14 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type FormEvent } from 'react'; import type { BaseModelCatalogItem, CatalogProvider, + GatewayTenant, + GatewayUser, IntegrationPlatform, PlatformModel, PricingRule, RateLimitWindow, + UserGroup, } from '@easyai-ai-gateway/contracts'; import { getHealth, @@ -15,13 +18,88 @@ import { listPlatforms, listPricingRules, listRateLimitWindows, + listTenants, + listUserGroups, + listUsers, + loginLocalAccount, + registerLocalAccount, type HealthResponse, } from './api'; type LoadState = 'idle' | 'loading' | 'ready' | 'error'; +type AuthMode = 'login' | 'register' | 'external'; + +const primaryModules = [ + { + title: '首页', + path: '/', + description: '服务状态、推荐模型、最近任务、用量摘要和快捷入口。', + items: ['能力概览', '最近任务', '用量摘要'], + }, + { + title: '模型', + path: '/models', + description: '按能力、价格、限流和 provider 浏览模型,并进入在线试用。', + items: ['模型广场', '模型详情', '调用测试'], + }, + { + title: '用户工作台', + path: '/workspace', + description: '个人中心、身份来源、余额充值、API Key 管理和任务记录。', + items: ['个人总览', '身份来源', '余额充值', 'API Key', '任务记录'], + }, + { + title: '管理工作台', + path: '/admin', + description: '租户、用户、用户组、全局模型、平台、限流、重试、队列和回调 outbox。', + items: ['租户管理', '用户管理', '用户组策略', '全局模型', '队列限流'], + }, + { + title: 'API 文档', + path: '/docs', + description: '开放接口、鉴权、错误码、示例代码和在线调用测试。', + items: ['快速开始', '接口文档', '在线调试'], + }, +]; + +const workspacePages = [ + { title: '个人中心总览', path: '/workspace/overview', description: '账号、身份来源、租户、角色、用户组、余额、API Key 数、最近任务和用量摘要。' }, + { title: '余额与充值', path: '/workspace/billing', description: '余额、资源包、充值入口、用户组折扣、消费记录和订单状态。' }, + { title: 'API Key 管理', path: '/workspace/api-keys', description: '创建、禁用、重置、权限范围和最近调用记录。' }, + { title: '任务记录', path: '/workspace/tasks', description: 'Chat、生图、生视频任务列表、进度、结果和计费明细。' }, +]; + +const adminPages = [ + { title: '租户管理', path: '/admin/tenants', description: '本地租户、同步租户、租户策略、状态和用量隔离。' }, + { title: '用户管理', path: '/admin/users', description: '本地用户、同步用户、角色、状态、同步差异和用户组命中。' }, + { title: '用户组策略', path: '/admin/user-groups', description: '用户组成员、充值折扣、调用折扣、TPM/RPM/并发和队列优先级。' }, + { title: '全局模型配置', path: '/admin/models/global', description: '基准模型库、能力 schema、基准定价和默认限流模板。' }, + { title: '平台管理', path: '/admin/platforms', description: '平台 CRUD、凭证、默认折扣、平台模型、限流和重试策略。' }, + { title: '运行与队列', path: '/admin/runtime/queues', description: 'TPM/RPM 窗口、并发 lease、cooldown、任务恢复和队列积压。' }, + { title: '回调与结算', path: '/admin/callbacks', description: '任务进度 callback outbox、结算 outbox、失败重试和手动 replay。' }, +]; + +const apiDocPages = [ + { title: '鉴权与限流', path: '/docs/auth', description: '本地账号、JWT、OpenAPI Key、TPM/RPM/并发限制和错误码。' }, + { title: 'Chat / Responses', path: '/docs/api/chat', description: '对话、stream、结构化输出、取消请求和示例代码。' }, + { title: '图片 / 视频', path: '/docs/api/media', description: '生图、图像编辑、生视频、任务进度和结果取回。' }, + { title: '在线调用测试', path: '/docs/playground', description: '选择模型和 API Key,编辑参数,查看实时响应和 billings。' }, +]; export function App() { const [token, setToken] = useState(''); + const [externalToken, setExternalToken] = useState(''); + const [authMode, setAuthMode] = useState('login'); + const [loginForm, setLoginForm] = useState({ account: '', password: '' }); + const [registerForm, setRegisterForm] = useState({ + username: '', + email: '', + password: '', + displayName: '', + tenantKey: '', + tenantName: '', + invitationCode: '', + }); const [health, setHealth] = useState(null); const [platforms, setPlatforms] = useState([]); const [models, setModels] = useState([]); @@ -29,6 +107,9 @@ export function App() { const [baseModels, setBaseModels] = useState([]); const [pricingRules, setPricingRules] = useState([]); const [rateLimitWindows, setRateLimitWindows] = useState([]); + const [tenants, setTenants] = useState([]); + const [users, setUsers] = useState([]); + const [userGroups, setUserGroups] = useState([]); const [state, setState] = useState('idle'); const [error, setError] = useState(''); @@ -44,6 +125,9 @@ export function App() { const activeProviders = providers.filter((item) => item.status === 'active').length; const activeRateWindows = rateLimitWindows.filter((item) => item.resetAt >= new Date().toISOString()).length; return [ + { label: '租户', value: tenants.length, tone: 'cyan' }, + { label: '用户', value: users.length, tone: 'green' }, + { label: '用户组', value: userGroups.length, tone: 'blue' }, { label: '平台', value: platforms.length, tone: 'blue' }, { label: '启用平台', value: enabledPlatforms, tone: 'green' }, { label: '基准模型', value: baseModels.length, tone: 'violet' }, @@ -51,9 +135,9 @@ export function App() { { label: '定价规则', value: pricingRules.length, tone: 'cyan' }, { label: '限流窗口', value: activeRateWindows, tone: 'rose' }, ]; - }, [baseModels.length, models, platforms, pricingRules.length, providers, rateLimitWindows]); + }, [baseModels.length, models, platforms, pricingRules.length, providers, rateLimitWindows, tenants.length, userGroups.length, users.length]); - async function refresh() { + async function refresh(nextToken = token) { setState('loading'); setError(''); try { @@ -64,13 +148,19 @@ export function App() { baseModelResponse, pricingRuleResponse, rateLimitWindowResponse, + tenantResponse, + userResponse, + userGroupResponse, ] = await Promise.all([ - listPlatforms(token), - listModels(token), - listCatalogProviders(token), - listBaseModels(token), - listPricingRules(token), - listRateLimitWindows(token), + listPlatforms(nextToken), + listModels(nextToken), + listCatalogProviders(nextToken), + listBaseModels(nextToken), + listPricingRules(nextToken), + listRateLimitWindows(nextToken), + listTenants(nextToken), + listUsers(nextToken), + listUserGroups(nextToken), ]); setPlatforms(platformResponse.items); setModels(modelResponse.items); @@ -78,6 +168,9 @@ export function App() { setBaseModels(baseModelResponse.items); setPricingRules(pricingRuleResponse.items); setRateLimitWindows(rateLimitWindowResponse.items); + setTenants(tenantResponse.items); + setUsers(userResponse.items); + setUserGroups(userGroupResponse.items); setState('ready'); } catch (err) { setState('error'); @@ -85,6 +178,59 @@ export function App() { } } + async function submitLogin(event: FormEvent) { + event.preventDefault(); + setState('loading'); + setError(''); + try { + const response = await loginLocalAccount(loginForm); + setToken(response.accessToken); + await refresh(response.accessToken); + } catch (err) { + setState('error'); + setError(err instanceof Error ? err.message : '登录失败'); + } + } + + async function submitRegister(event: FormEvent) { + event.preventDefault(); + setState('loading'); + setError(''); + try { + const response = await registerLocalAccount(registerForm); + setToken(response.accessToken); + await refresh(response.accessToken); + } catch (err) { + setState('error'); + setError(err instanceof Error ? err.message : '注册失败'); + } + } + + async function submitExternalToken(event: FormEvent) { + event.preventDefault(); + const nextToken = externalToken.trim(); + if (!nextToken) { + setError('请填写 access token'); + return; + } + setToken(nextToken); + await refresh(nextToken); + } + + function signOut() { + setToken(''); + setState('idle'); + setPlatforms([]); + setModels([]); + setProviders([]); + setBaseModels([]); + setPricingRules([]); + setRateLimitWindows([]); + setTenants([]); + setUsers([]); + setUserGroups([]); + } + return (
@@ -92,30 +238,284 @@ export function App() {

EasyAI

AI Gateway Console

-
- - {health?.service ?? 'API 未连接'} +
+
+ + {health?.identityMode ? `${health.service} · ${health.identityMode}` : health?.service ?? 'API 未连接'} +
+ {token && ( + + )}
-
- - -
+ + )} {error &&
{error}
} +
+ ); +} + +function AuthPanel(props: { + authMode: AuthMode; + externalToken: string; + loginForm: { account: string; password: string }; + registerForm: { + username: string; + email: string; + password: string; + displayName: string; + tenantKey: string; + tenantName: string; + invitationCode: string; + }; + state: LoadState; + onAuthModeChange: (value: AuthMode) => void; + onExternalTokenChange: (value: string) => void; + onLoginChange: (value: { account: string; password: string }) => void; + onRegisterChange: (value: { + username: string; + email: string; + password: string; + displayName: string; + tenantKey: string; + tenantName: string; + invitationCode: string; + }) => void; + onSubmitExternalToken: (event: FormEvent) => void; + onSubmitLogin: (event: FormEvent) => void; + onSubmitRegister: (event: FormEvent) => void; +}) { + return ( +
+
+
+

Gateway Identity

+

登录 AI Gateway

+
+
+ {[ + ['login', '账号登录'], + ['register', '注册账号'], + ['external', '外部 Token'], + ].map(([value, label]) => ( + + ))} +
+ + {props.authMode === 'login' && ( +
+ + + +
+ )} + + {props.authMode === 'register' && ( +
+ + + + + + + + +
+ )} + + {props.authMode === 'external' && ( +
+ + +
+ )} +
+
+ ); +} + +function Dashboard(props: { + baseModels: BaseModelCatalogItem[]; + models: PlatformModel[]; + platforms: IntegrationPlatform[]; + rateLimitWindows: RateLimitWindow[]; + stats: Array<{ label: string; value: number; tone: string }>; +}) { + return ( + <> +
+
+
+

Navigation

+

前端页面结构

+
+ 5 个一级模块 +
+
+ {primaryModules.map((item) => ( +
+
+

{item.title}

+ {item.path} +
+

{item.description}

+
+ {item.items.map((tag) => ( + {tag} + ))} +
+
+ ))} +
+
+ +
+
+
+

Workspace

+

用户、管理与 API 文档

+
+ 设计分区 +
+
+ + + +
+
- {stats.map((item) => ( + {props.stats.map((item) => (
{item.label} {item.value} @@ -124,104 +524,80 @@ export function App() {
-
-
-

平台

- {platforms.length} -
-
-
- Provider - 名称 - 状态 - 优先级 -
- {platforms.map((item) => ( -
- {item.provider} - {item.name} - {item.status} - {item.priority} -
- ))} - {!platforms.length &&

暂无平台数据

} -
-
- -
-
-

模型

- {models.length} -
-
-
- 模型 - 类型 - 平台 - 启用 -
- {models.map((item) => ( -
- {item.modelName} - {item.modelType} - {item.provider ?? item.platformName} - {item.enabled ? '是' : '否'} -
- ))} - {!models.length &&

暂无模型数据

} -
-
+ [item.provider, item.name, item.status, String(item.priority)])} + title="平台" + /> + [item.modelName, item.modelType, item.provider ?? item.platformName ?? '-', item.enabled ? '是' : '否'])} + title="模型" + />
-
-
-

基准模型库

- {baseModels.length} -
-
-
- Provider - 模型 - 类型 - 版本 -
- {baseModels.map((item) => ( -
- {item.providerKey} - {item.canonicalModelKey} - {item.modelType} - {item.pricingVersion} -
- ))} - {!baseModels.length &&

暂无基准模型

} -
-
- -
-
-

TPM/RPM 窗口

- {rateLimitWindows.length} -
-
-
- Scope - 指标 - 使用 - 预占 -
- {rateLimitWindows.map((item) => ( -
- {item.scopeKey} - {item.metric} - {item.usedValue}/{item.limitValue} - {item.reservedValue} -
- ))} - {!rateLimitWindows.length &&

暂无限流窗口

} -
-
+ [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])} + title="基准模型库" + /> + [item.scopeKey, item.metric, `${item.usedValue}/${item.limitValue}`, String(item.reservedValue)])} + title="TPM/RPM 窗口" + />
- + + ); +} + +function DataPanel(props: { columns: string[]; empty: string; rows: string[][]; title: string }) { + return ( +
+
+

{props.title}

+ {props.rows.length} +
+
+
+ {props.columns.map((column) => ( + {column} + ))} +
+ {props.rows.map((row, index) => ( +
+ {row.map((cell, cellIndex) => ( + {cell} + ))} +
+ ))} + {!props.rows.length &&

{props.empty}

} +
+
+ ); +} + +function ModuleList(props: { + title: string; + items: Array<{ title: string; path: string; description: string }>; +}) { + return ( +
+

{props.title}

+ {props.items.map((item) => ( +
+
+ {item.title} +

{item.description}

+
+ {item.path} +
+ ))} +
); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index e4d661a..0851187 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,11 +1,15 @@ import type { + AuthResponse, BaseModelCatalogItem, CatalogProvider, + GatewayTenant, + GatewayUser, IntegrationPlatform, ListResponse, PlatformModel, PricingRule, RateLimitWindow, + UserGroup, } from '@easyai-ai-gateway/contracts'; const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088'; @@ -14,12 +18,37 @@ export interface HealthResponse { ok: boolean; service: string; env: string; + identityMode?: string; } export async function getHealth(): Promise { return request('/healthz', { auth: false }); } +export async function registerLocalAccount(input: { + username: string; + email?: string; + password: string; + displayName?: string; + tenantKey?: string; + tenantName?: string; + invitationCode?: string; +}): Promise { + return request('/api/v1/auth/register', { + auth: false, + body: input, + method: 'POST', + }); +} + +export async function loginLocalAccount(input: { account: string; password: string }): Promise { + return request('/api/v1/auth/login', { + auth: false, + body: input, + method: 'POST', + }); +} + export async function listPlatforms(token: string): Promise> { return request>('/api/v1/platforms', { token }); } @@ -40,17 +69,37 @@ export async function listPricingRules(token: string): Promise>('/api/v1/pricing/rules', { token }); } +export async function listTenants(token: string): Promise> { + return request>('/api/v1/tenants', { token }); +} + +export async function listUsers(token: string): Promise> { + return request>('/api/v1/users', { token }); +} + +export async function listUserGroups(token: string): Promise> { + return request>('/api/v1/user-groups', { token }); +} + export async function listRateLimitWindows(token: string): Promise> { return request>('/api/v1/runtime/rate-limit-windows', { token }); } -async function request(path: string, options: { token?: string; auth?: boolean } = {}): Promise { +async function request( + path: string, + options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {}, +): Promise { const headers: Record = {}; if (options.auth !== false && options.token) { headers.Authorization = `Bearer ${options.token}`; } + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } const response = await fetch(`${API_BASE}${path}`, { + method: options.method ?? 'GET', headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), }); if (!response.ok) { const body = await response.text(); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index ade662e..ba00e01 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -35,6 +35,12 @@ input { margin-bottom: 24px; } +.topbarActions { + display: flex; + align-items: center; + gap: 12px; +} + .eyebrow { margin: 0 0 4px; color: #667085; @@ -58,6 +64,11 @@ h2 { font-size: 18px; } +h3 { + margin: 0; + font-size: 15px; +} + .health { display: inline-flex; align-items: center; @@ -123,11 +134,98 @@ button { cursor: pointer; } +.ghostButton { + min-height: 36px; + padding: 0 14px; + border: 1px solid #cbd5e1; + background: #ffffff; + color: #2d3748; +} + button:disabled { cursor: not-allowed; opacity: 0.5; } +.authShell { + display: grid; + min-height: 620px; + align-items: center; +} + +.authPanel { + width: min(720px, 100%); + margin: 0 auto; + padding: 20px; + border: 1px solid #dde3ee; + border-radius: 8px; + background: #ffffff; +} + +.authHeader { + margin-bottom: 16px; +} + +.segmented { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 4px; + margin-bottom: 16px; + border: 1px solid #d8e0ec; + border-radius: 8px; + background: #f8fafc; +} + +.segmentButton { + min-height: 36px; + padding: 0 10px; + border-radius: 6px; + background: transparent; + color: #4a5568; +} + +.segmentButton[data-active="true"] { + background: #214e8a; + color: #ffffff; +} + +.authForm { + display: grid; + gap: 12px; +} + +.authForm.twoColumn { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.authForm label { + display: grid; + gap: 8px; + color: #4a5568; + font-size: 13px; + font-weight: 700; +} + +.authForm input { + width: 100%; + min-height: 42px; + padding: 0 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; + color: #172033; + outline: none; +} + +.authForm input:focus { + border-color: #2b6cb0; + box-shadow: 0 0 0 3px rgba(43, 108, 176, 0.14); +} + +.authForm button[type="submit"] { + grid-column: 1 / -1; +} + .notice { padding: 12px 14px; margin-bottom: 18px; @@ -138,9 +236,112 @@ button:disabled { font-size: 14px; } +.moduleBand { + padding: 18px; + margin-bottom: 18px; + border: 1px solid #dde3ee; + border-radius: 8px; + background: #ffffff; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.sectionHeader span { + color: #667085; + font-size: 13px; + font-weight: 700; +} + +.moduleGrid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.moduleCard { + min-height: 174px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid #e4eaf3; + border-radius: 8px; + background: #fbfcff; +} + +.moduleCardTop { + display: grid; + gap: 6px; +} + +.moduleCardTop span, +.moduleRow span { + color: #667085; + font-size: 12px; + font-weight: 700; +} + +.moduleCard p, +.moduleRow p { + color: #4a5568; + font-size: 13px; + line-height: 1.5; +} + +.moduleTags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: auto; +} + +.moduleTags span { + padding: 4px 7px; + border: 1px solid #d8e0ec; + border-radius: 999px; + background: #ffffff; + color: #3f4f67; + font-size: 12px; + font-weight: 700; +} + +.detailGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.moduleList { + display: grid; + gap: 10px; +} + +.moduleRow { + display: grid; + gap: 8px; + min-height: 112px; + padding: 12px; + border: 1px solid #e4eaf3; + border-radius: 8px; + background: #fbfcff; +} + +.moduleRow strong { + display: block; + margin-bottom: 4px; + color: #172033; + font-size: 14px; +} + .metrics { display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(126px, 1fr)); gap: 12px; margin-bottom: 18px; } @@ -259,7 +460,9 @@ button:disabled { @media (max-width: 860px) { .topbar, - .toolbar { + .toolbar, + .authForm.twoColumn, + .segmented { grid-template-columns: 1fr; } @@ -268,8 +471,15 @@ button:disabled { flex-direction: column; } + .topbarActions { + align-items: flex-start; + flex-direction: column; + } + .metrics, - .split { + .split, + .moduleGrid, + .detailGrid { grid-template-columns: 1fr; } @@ -280,7 +490,12 @@ button:disabled { } @media (min-width: 861px) and (max-width: 1180px) { - .metrics { + .metrics, + .moduleGrid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + .detailGrid { + grid-template-columns: 1fr; + } } diff --git a/docs/design.md b/docs/design.md index 93eca63..f6d359f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -4,10 +4,10 @@ 将现有 `easyai-server-main` / `easyai-integration-api` 中与 `integration-platform` 强相关的能力拆分为一个独立项目: -- 独立运行:后端 Go、复用 Agent memory 的 PostgreSQL(`easyai-pgvector`)、前端 React + TSX。 +- 独立运行:后端 Go、复用 Agent memory 的 PostgreSQL 18(`easyai-pgvector`)、前端 React + TSX。 - 前端 UI:使用 `shadcn-ui` + Tailwind CSS + Radix UI + `lucide-react`,保持控制台形态与 EasyAI 运维后台一致。 -- 独立部署:HTTP API、任务执行器、未来 WebSocket / SSE 推送均由本服务承载。 -- 授权复用:用户授权沿用 `easyai-server-main` 的 JWT claim、角色权限和 OpenAPI `sk-*` 校验方式。 +- 独立部署:HTTP API、任务执行器、任务事件持久化与进度回调均由本服务承载;接入 EasyAI 主站时,业务前端实时推送继续复用 `server-main` 现有 WebSocket 网关。 +- 身份双模式:支持 Gateway 本地账号注册登录,也支持沿用 `easyai-server-main` 的 JWT claim、角色权限和 OpenAPI `sk-*` 校验方式。 - 能力对接:作为 Chat、生图、生视频等模型能力网关,供 `easyai-server-main` 和前端直接调用。 - 渐进迁移:新服务稳定后,再让 `server-main` 的 `OpenaiService` / `IntegrationPlatformService` 切为远程调用门面。 @@ -26,16 +26,17 @@ | 测试模式 | 支持 simulation / dry-run,不向真实平台提交任务,仍完整走路由、队列、限流、重试、进度、结果归一流程 | | 计费预估 | 在任务创建前或前端配置面提供 estimated billing,且必须使用与真实路由一致的有效模型价格 | | 任务结果 | 保存任务请求、状态、结果摘要、billings,向主服务发送结算事件 | -| 推送 | 承接任务级 SSE / WebSocket / OpenAI stream 进度,提供类似原 RxJS Observable 的中间过程返回能力,不再经 `server-main` 转发 | +| 推送 | 承接任务级进度事件,提供类似原 RxJS Observable 的中间过程返回能力;业务实时进度通过配置回调地址回调 `server-main` 内部任务进度接口,再由主服务复用原 WebSocket 网关推给业务前端 | -### 2.2 保留在 server-main +### 2.2 接入 server-main 时仍保留在 server-main | 能力 | 原因 | | --- | --- | -| 用户、组织、租户、角色 | 仍是 EasyAI 主账号体系 | +| 用户、组织、租户、角色 | EasyAI 主站模式下仍是主账号体系;Gateway 保存同步副本和策略快照 | | 余额、资源包、扣费流水 | 需要复用现有账单锁、组织扣费、消费记录 | -| API Key 创建与撤销 | `sk-*` 生命周期属于主服务用户体系 | +| API Key 创建与撤销 | 接入模式下 `sk-*` 生命周期属于主服务用户体系;独立模式由 Gateway 本地 API Key 模块承接 | | 文件上传 | 复用 `server-main` 已开放的文件上传接口,OSS/COS/S3 密钥和上传实现继续只落在主服务 | +| 业务 WebSocket 网关 | 现有前端已经订阅主服务 WebSocket 通道,Gateway 通过任务进度回调接入,不直接替换业务推送链路 | | 对话、绘图历史、工作流历史 | 与产品域模型绑定,Gateway 只返回任务结果和结算载荷 | ## 3. 总体架构 @@ -58,6 +59,7 @@ flowchart LR AUTH[Auth Middleware] ROUTER[Model Router] QUEUE[Queue + Runtime] + CALLBACK[Task Progress Callback] VENDOR[Vendor Clients] ADMIN[React Admin Console] end @@ -70,12 +72,14 @@ flowchart LR subgraph servermain [server-main] USER[User / Org] APIKEY[API Key Verify] + WSGW[WebSocket Gateway] BILL[Billing + Ledger] FILES[Open File Upload] end FE --> HTTP --> API - FE --> PUSH + FE <-->|business progress ws| WSGW + ADMIN --> PUSH OPENAPI --> HTTP --> API MAIN -->|internal HTTP| API ADMIN --> API @@ -85,6 +89,7 @@ flowchart LR API --> ROUTER --> QUEUE --> VENDOR API --> PG QUEUE --> PG + QUEUE --> CALLBACK -->|POST task progress callback to server-main| WSGW QUEUE -->|settlement event| BILL API -->|POST /v1/files/upload| FILES ``` @@ -115,9 +120,15 @@ flowchart LR ## 5. 授权设计 -### 5.1 JWT 用户授权 +### 5.1 身份模式与 JWT 用户授权 -一期直接兼容 `server-main` 的 JWT: +Gateway 支持三种身份模式: + +- `standalone`:Gateway 本地账号注册登录,签发 Gateway JWT。 +- `server-main`:只接受 `server-main` JWT / OpenAPI `sk-*` 校验结果,用户与租户从主服务同步。 +- `hybrid`:同时接受 Gateway 本地账号和 `server-main` 身份,按 `source` 区分。 + +接入 `server-main` 时兼容主服务 JWT: - secret:读取 `CONFIG_JWT_SECRET`,默认值与现有 `jwtConstants.secret` 保持一致。 - token 有效期:由 `server-main` 签发控制,当前 access token 为 `600s`,refresh token 为 `7d`。 @@ -126,10 +137,36 @@ flowchart LR - `username` - `role` - `tenantId` + - `gatewayTenantId` + - `tenantKey` + - `source` + - `gatewayUserId` + - `userGroupId` / `userGroupKey` / `userGroupKeys` - `sso_id` - API Key 场景扩展:`apiKeyId`、`apiKeySecret`、`apiKeyName` -Gateway 只校验 access token,不签发 refresh token。刷新仍走 `server-main`。 +Gateway 本地登录首期只签发短期 access token,不做 refresh token;接入 `server-main` 的刷新仍走主服务。 + +### 5.1.1 本地账号注册登录 + +首期普通账号只做用户名/邮箱 + 密码: + +| Method | Path | Permission | 说明 | +| --- | --- | --- | --- | +| `POST` | `/api/v1/auth/register` | `public` | 注册 Gateway 本地账号,创建或加入租户,返回 access token | +| `POST` | `/api/v1/auth/login` | `public` | 本地账号登录,返回 access token | + +注册字段: + +- `username` / `email`:至少一个必填。 +- `password`:至少 8 位,落库为 bcrypt hash。 +- `tenantKey` / `tenantName`:可选;未传时创建个人租户。 + +安全边界: + +- 普通注册首期只给 `user` 角色。 +- 允许注册时填写已有 `tenantKey` 只是脚手架行为;正式上线前需要租户邀请、域名校验或管理员审批。 +- `server-main` 模式可关闭本地注册登录,只保留外部 token 入口。 ### 5.2 权限等级 @@ -218,6 +255,8 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 | `POST` | `/integration-platform/:id/copy` | `power` | 复制平台 | | `GET` | `/integration-platform/:id` | `power` | 平台详情 | +兼容表中的旧权限用于保持原接口语义;在新 Gateway 第一阶段,所有写入 provider / platform 凭证、启停平台、删除平台、复制平台的接口都要额外要求全局管理员,即 `manager/admin` 角色。 + ### 6.3 平台 API 配置兼容路由 | Method | Path | Permission | 说明 | @@ -238,18 +277,11 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 | `POST` | `/chat/completions` | 站内 Chat | | `POST` | `/chat/completions/cancel/:requestId` | 取消 Chat | | `POST` | `/chat/structured-output` | 结构化输出 | -| `POST` | `/responses` | Responses | -| `POST` | `/responses/cancel/:requestId` | 取消 Responses | | `POST` | `/images/generations` | 生图 | | `POST` | `/images/edits` | 图片编辑 | | `POST` | `/video/generations` | 生视频 | | `POST` | `/embeddings` | Embeddings | | `GET` | `/embeddings/models` | Embedding 模型 | -| `POST` | `/text2Model/generations` | 文生 3D / 模型 | -| `POST` | `/digital-human/create` | 数字人创建 | -| `POST` | `/digital-human/generations` | 数字人生成 | -| `POST` | `/music/generations` | 音乐生成 | -| `POST` | `/speech/generations` | 语音生成 | | `GET` | `/ai/result/:taskId` | 任务结果 | ### 6.5 OpenAPI 兼容路由 @@ -258,16 +290,9 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 | --- | --- | --- | | `POST` | `/v1/chat/completions` | OpenAI-compatible Chat | | `POST` | `/v1/chat/completions/cancel/:requestId` | 取消 Chat | -| `POST` | `/v1/responses` | Responses | -| `POST` | `/v1/responses/cancel/:requestId` | 取消 Responses | | `POST` | `/v1/images/generations` | 生图 | | `POST` | `/v1/images/edits` | 图片编辑 | | `POST` | `/v1/video/generations` | 生视频 | -| `POST` | `/v1/song/generations` | 音乐生成 | -| `POST` | `/v1/speech/generations` | 语音生成 | -| `POST` | `/v1/digital-human/create` | 数字人创建 | -| `POST` | `/v1/digital-human/generations` | 数字人生成 | -| `POST` | `/v1/ai/generations` | 通用 AI 任务 | | `GET` | `/v1/ai/result/:taskId` | 任务结果 | | `GET` | `/v1/ai/cancel/:taskId` | 取消任务 | | `POST` | `/v1/embeddings` | Embeddings | @@ -281,10 +306,30 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 | Method | Path | Permission | 说明 | | --- | --- | --- | --- | +| `POST` | `/api/v1/auth/register` | `public` | Gateway 本地账号注册,`invitationCode` 可选 | +| `POST` | `/api/v1/auth/login` | `public` | Gateway 本地账号登录 | | `GET` | `/api/v1/me` | `basic` | 网关当前身份调试 | | `GET` | `/api/v1/catalog/providers` | `power` | 基准 provider 列表 | | `GET` | `/api/v1/catalog/base-models` | `power` | 基准模型库列表 | | `GET` | `/api/v1/catalog/base-models/:id` | `power` | 基准模型详情 | +| `GET` | `/api/v1/tenants` | `power` | 网关租户列表,支持本地租户和 server-main 同步租户 | +| `GET` | `/api/v1/tenants/:id` | `power` | 租户详情、来源、策略和同步状态 | +| `POST` | `/api/v1/tenants/sync` | `power` | 从 `server-main` 拉取或接收租户同步 | +| `GET` | `/api/v1/tenant-invitations` | `power` | 本地租户邀请码列表 | +| `POST` | `/api/v1/tenant-invitations` | `power` | 创建本地注册邀请码 | +| `GET` | `/api/v1/users` | `power` | 网关用户列表,支持本地用户和 server-main 同步用户 | +| `GET` | `/api/v1/users/:id` | `power` | 用户详情、来源、角色、用户组和同步状态 | +| `POST` | `/api/v1/users/sync` | `power` | 从 `server-main` 拉取或接收用户同步 | +| `GET` | `/api/v1/user-groups` | `power` | 用户组策略列表 | +| `GET` | `/api/v1/user-groups/:id` | `power` | 用户组详情、成员与策略 | +| `POST` | `/api/v1/user-groups/:id/sync` | `power` | 同步用户组策略到 `server-main` | +| `GET` | `/api/v1/wallet/accounts` | `basic` | 独立模式本地余额账户 | +| `GET` | `/api/v1/wallet/transactions` | `basic` | 独立模式本地余额流水 | +| `GET` | `/api/v1/recharge/orders` | `basic` | 独立模式充值订单 | +| `POST` | `/api/v1/recharge/orders` | `basic` | 独立模式创建充值订单 | +| `GET` | `/api/v1/api-keys` | `basic` | 独立模式 API Key 列表 | +| `POST` | `/api/v1/api-keys` | `basic` | 独立模式创建 API Key | +| `PATCH` | `/api/v1/api-keys/:id/disable` | `basic` | 禁用本地 API Key | | `POST` | `/api/v1/pricing/estimate` | `basic` | 使用 effective pricing resolver 做价格预估 | | `GET` | `/api/v1/pricing/rules` | `power` | 定价规则列表 | | `GET` | `/api/v1/tasks/:taskId` | `basic` | Gateway 任务详情 | @@ -293,12 +338,17 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 | `GET` | `/api/v1/runtime/queues` | `power` | 队列与限流状态 | | `GET` | `/api/v1/runtime/rate-limit-windows` | `power` | TPM/RPM 当前窗口与并发 lease 状态 | | `POST` | `/api/v1/runtime/tasks/:taskId/replay` | `power` | 重放任务事件 | +| `POST` | `/api/v1/runtime/tasks/:taskId/callbacks/replay` | `power` | 重放任务进度回调 outbox | ### 6.7 内部接口 | Method | Path | 调用方 | 说明 | | --- | --- | --- | --- | | `POST` | `/internal/v1/settlements` | Gateway worker | 回调主服务结算失败时补偿 | +| `POST` | `${TASK_PROGRESS_CALLBACK_URL}` | Gateway worker | 任务进度回调到 `server-main` 内部任务进度接口,实际路径由配置决定 | +| `POST` | `/internal/platform/tenants/sync` | server-main | 同步租户增量、禁用状态和租户策略到 Gateway | +| `POST` | `/internal/platform/users/sync` | server-main | 同步用户增量、禁用状态、角色和用户组关系到 Gateway | +| `POST` | `/internal/platform/user-groups/sync` | server-main | 同步用户组、折扣和限流策略到 Gateway | | `POST` | `/internal/v1/task-callbacks` | server-main | 迁移期主服务回写历史或任务绑定 | ## 7. 数据模型 @@ -308,8 +358,46 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - 基准模型库保存 provider、canonical model、能力 schema、默认能力、基准价格、默认限流模板,是所有平台模型的 fallback。 - 创建平台时可以设置 `default_discount_factor`,平台模型默认按“基准价格 x 折扣系数”计算;没有任何平台侧配置时,能力和价格都 follow 基准模型。 - 平台模型可以覆盖能力、定价和限流。覆盖只作用于该平台模型,不能反向污染基准模型库。 +- 租户是隔离域:独立模式下 Gateway 管理 `gateway_tenants`;接入模式下同步 `server-main` 租户/组织,任务、用户、平台可见性、限流与计费策略都需要带租户上下文。 +- 用户是身份域:独立部署时 Gateway 自己维护 `gateway_users`、登录、API Key、余额、充值订单和钱包流水;接入 `server-main` 时 Gateway 保存用户同步副本和策略快照,认证、余额、充值、订单仍可由 `server-main` 作为事实源。 +- 用户组是策略作用域:不同用户组可以绑定充值折扣、模型计费折扣、TPM/RPM/并发、队列优先级、API Key 配额等策略。独立模式由 Gateway 完整执行;`server-main` 模式下充值和余额执行归主服务,Gateway 执行模型调用侧策略。 - estimated billing、真实任务 billings、控制台价格预览必须走同一个 effective pricing resolver。 +### 7.0 身份运行模式 + +Gateway 需要支持三种身份运行模式,默认配置为 `IDENTITY_MODE=hybrid`: + +| 模式 | 配置 | 用户事实源 | API Key / 登录 | 用户组策略 | 适用场景 | +| --- | --- | --- | --- | --- | --- | +| Standalone | `IDENTITY_MODE=standalone` | Gateway `gateway_users` | Gateway 本地用户、密码/SSO、API Key | Gateway 维护并执行充值折扣、调用折扣、限流和并发 | 独立商业化或单独部署 | +| Server-main gateway | `IDENTITY_MODE=server-main` | `server-main` | JWT 复用主服务 secret,`sk-*` 调用主服务校验 | `server-main` 与 Gateway 同步;主服务执行充值/余额,Gateway 执行调用折扣和限流 | EasyAI 主站拆分后的 AI 网关 | +| Hybrid | `IDENTITY_MODE=hybrid` | Gateway + `server-main` | 两种方式并存,按 `source` 区分 | 按 `source + group_key` 合并,冲突以更高优先级策略为准 | 迁移灰度或私有化集成 | + +本地注册规则: + +- 普通注册默认开放,`invitationCode` 可选;不填邀请码时创建或复用 `tenantKey` 对应的 Gateway 本地租户,未填 `tenantKey` 时生成个人租户。 +- 填写邀请码时必须命中 `gateway_tenant_invitations` 的有效记录,注册用户加入邀请码指定租户,并可绑定邀请码指定用户组。 +- 平台凭证、provider 凭证和全局模型配置暂时只允许全局管理员维护;租户侧先只做模型使用、用量查看和策略展示。 + +身份解析流程: + +1. 从 JWT / API Key / 内部调用 token 解析出外部身份。 +2. 根据 `source + external_user_id` 在 `gateway_users` 找到或创建同步副本。 +3. 按用户、租户、API Key、组织命中 `gateway_user_group_memberships`。 +4. 根据用户组优先级和策略合并规则得到 effective policy。 +5. 创建任务时把 `gateway_user_id`、`user_source`、`user_group_id`、`user_group_key`、`user_group_policy_snapshot` 写入 `gateway_tasks`,后续重试和结算不受同步变更影响。 + +### 7.0.1 多租户模型 + +多租户支持不能只停留在 claim 的 `tenantId` 字符串,Gateway 需要有自己的租户表和执行上下文: + +- `gateway_tenants` 保存租户事实或同步副本,使用 `source + external_tenant_id` 幂等同步。 +- `gateway_users.gateway_tenant_id` 关联 Gateway 租户,`tenant_id` / `tenant_key` 保留与 `server-main` 兼容的外部标识。 +- `gateway_tasks` 固化 `gateway_tenant_id`、`tenant_id`、`tenant_key`,确保任务恢复、重试、结算和审计不受后续租户变更影响。 +- `integration_platforms.visibility_scope` 区分 `global`、`tenant`、`private`,租户专属平台只能被对应租户路由到。 +- 限流、定价、用户组和 quota 都可以以租户作为 scope;同一请求会同时命中 global、tenant、user_group、user、api_key 等策略。 +- 控制台数据查询默认按当前用户租户过滤;`power/manager` 可跨租户查看和管理。 + ### 7.1 `integration_platforms` 保存平台实例与凭证: @@ -320,6 +408,8 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `auth_type` - `credentials`:加密后的凭证 JSON,后续应接入 KMS。 - `config`:限流、超时、重试、平台私有配置。 +- `visibility_scope`:`global`、`tenant`、`private`,用于多租户平台可见性。 +- `tenant_id` / `tenant_key`:租户专属平台的归属。 - `default_discount_factor`:平台默认折扣系数,基于基准模型价计算平台有效价。 - `priority` - `status` @@ -343,8 +433,12 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 保存任务请求与状态: - `kind`:`chat.completions`、`images.generations`、`videos.generations` -- `user_id` +- `user_id`:请求 claim 中的用户 ID,独立模式为 Gateway 用户 ID,接入模式为 `server-main` 用户 ID。 +- `gateway_user_id`:Gateway 本地用户表 ID,用于关联同步副本和审计。 +- `user_source`:`gateway`、`server-main`、`sync`。 - `tenant_id` +- `user_group_id` / `user_group_key` +- `user_group_policy_snapshot`:任务创建时解析出的用户组策略快照,用于审计和重试稳定性。 - `model` - `request` - `status`:`queued`、`running`、`succeeded`、`failed`、`cancelled` @@ -386,7 +480,7 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 ### 7.5 `gateway_task_events` -保存任务执行过程中的事件,用于 SSE / WebSocket 重放与服务重启后的进度恢复: +保存任务执行过程中的事件,用于控制台 SSE / WebSocket 重放、进度回调 outbox 投递与服务重启后的进度恢复: - `task_id` - `seq`:单任务内递增序号 @@ -399,6 +493,20 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 客户端断线重连时可通过 `Last-Event-ID` 或 `afterSeq` 回放事件。 +### 7.5.1 `gateway_task_callback_outbox` + +保存投递给 `server-main` 内部任务进度接口的回调。Gateway 产生事件后先写 `gateway_task_events`,再写 callback outbox,由独立 worker 按配置的 `TASK_PROGRESS_CALLBACK_URL` 投递,失败后可重试。 + +- `task_id` +- `event_id` / `seq`:与 `gateway_task_events` 对应,用于幂等与顺序控制。 +- `callback_url`:当前使用的回调地址快照,避免配置变化影响已排队事件。 +- `payload`:回调给 server-main 的标准事件载荷。 +- `status`:`pending`、`delivering`、`delivered`、`failed`、`skipped` +- `attempts` +- `next_attempt_at` +- `last_error` +- `delivered_at` + ### 7.6 `runtime_client_states` 保存平台客户端的运行时状态: @@ -522,7 +630,242 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `idx_model_pricing_scope(scope_type, scope_id, resource_type)` - `idx_model_pricing_effective(effective_from, effective_to)` -#### 7.9.4 `integration_platforms` +#### 7.9.4 `gateway_tenants` + +保存 Gateway 可识别的租户。独立模式下它是租户事实表;接入 `server-main` 时它是租户/组织同步副本。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | Gateway 租户 ID | +| `tenant_key` | `text` | unique, not null | Gateway 内稳定租户 key | +| `source` | `text` | not null | `gateway`、`server-main`、`sync` | +| `external_tenant_id` | `text` | nullable | 外部租户/组织 ID | +| `name` | `text` | not null | 租户名称 | +| `description` | `text` | nullable | 说明 | +| `default_user_group_id` | `uuid` | FK nullable | 默认用户组 | +| `plan_key` | `text` | nullable | 套餐或商业计划 key | +| `billing_profile` | `jsonb` | not null, default `{}` | 独立模式账务资料或接入模式同步摘要 | +| `rate_limit_policy` | `jsonb` | not null, default `{}` | 租户级 TPM/RPM/并发/队列策略 | +| `auth_policy` | `jsonb` | not null, default `{}` | 注册、邀请、SSO、域名限制等认证策略 | +| `metadata` | `jsonb` | not null, default `{}` | 同步、运营、展示扩展信息 | +| `status` | `text` | not null | `active`、`disabled`、`locked`、`deleted` | +| `synced_at` | `timestamptz` | nullable | 最近同步时间 | +| `source_updated_at` | `timestamptz` | nullable | 外部源更新时间 | +| `created_at` | `timestamptz` | not null | 创建时间 | +| `updated_at` | `timestamptz` | not null | 更新时间 | +| `deleted_at` | `timestamptz` | nullable | 软删除时间 | + +索引: + +- `uniq_gateway_tenants_tenant_key(tenant_key)` +- `uniq_gateway_tenants_source_external(source, external_tenant_id)` +- `idx_gateway_tenants_status(status, created_at)` + +#### 7.9.5 `gateway_users` + +保存 Gateway 可识别的用户。独立模式下它是用户事实表,并与本地 API Key、钱包、充值订单形成闭环;接入 `server-main` 时它是用户同步副本和策略执行缓存,不迁入主服务余额、订单、充值流水。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | Gateway 用户 ID | +| `user_key` | `text` | unique, not null | Gateway 内稳定用户 key | +| `source` | `text` | not null | `gateway`、`server-main`、`sync` | +| `external_user_id` | `text` | nullable | 外部用户 ID,`server-main` 模式为主服务用户 ID | +| `username` | `text` | not null | 登录名或同步用户名 | +| `display_name` | `text` | nullable | 展示名 | +| `email` | `text` | nullable | 邮箱 | +| `phone` | `text` | nullable | 手机号 | +| `avatar_url` | `text` | nullable | 头像 | +| `password_hash` | `text` | nullable | 仅独立模式使用,接入主服务时为空 | +| `gateway_tenant_id` | `uuid` | FK nullable | Gateway 租户 ID | +| `tenant_id` | `text` | nullable | 外部租户 ID 或兼容 claim | +| `tenant_key` | `text` | nullable | Gateway 租户 key | +| `default_user_group_id` | `uuid` | FK nullable | 默认用户组 | +| `roles` | `jsonb` | not null, default `[]` | `user`、`creator`、`operator`、`admin` 等角色 | +| `auth_profile` | `jsonb` | not null, default `{}` | SSO、MFA、API Key 策略等认证资料 | +| `metadata` | `jsonb` | not null, default `{}` | 同步、运营、展示扩展信息 | +| `status` | `text` | not null | `active`、`disabled`、`locked`、`deleted` | +| `last_login_at` | `timestamptz` | nullable | 最近登录时间 | +| `synced_at` | `timestamptz` | nullable | 最近同步时间 | +| `source_updated_at` | `timestamptz` | nullable | 外部源更新时间 | +| `created_at` | `timestamptz` | not null | 创建时间 | +| `updated_at` | `timestamptz` | not null | 更新时间 | +| `deleted_at` | `timestamptz` | nullable | 软删除时间 | + +索引: + +- `uniq_gateway_users_user_key(user_key)` +- `uniq_gateway_users_source_external(source, external_user_id)` +- `idx_gateway_users_status(status, created_at)` +- `idx_gateway_users_tenant(tenant_id, tenant_key, status)` + +同步规则: + +- `source='gateway'`:用户由 Gateway 创建和禁用,可独立使用;充值、余额、API Key 由 Gateway 本地模块承接。 +- `source='server-main'`:用户由主服务同步,Gateway 只保存执行模型调用所需字段;禁用、角色、用户组变更以主服务事件或同步任务为准。 +- 同一个外部用户以 `source + external_user_id` 幂等 upsert,避免用户名变更造成重复用户。 +- 用户被禁用后,新任务拒绝进入队列;已运行任务按任务策略快照继续或按管理员策略取消。 + +#### 7.9.6 `gateway_user_groups` + +保存用户组及其策略。用户组可以由 Gateway 管理,也可以从 `server-main` 同步;独立模式下 Gateway 执行全部策略,接入模式下充值和余额仍以 `server-main` 为事实源。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | 用户组 ID | +| `group_key` | `text` | unique, not null | 稳定用户组 key,如 `free`、`pro`、`enterprise` | +| `name` | `text` | not null | 展示名称 | +| `description` | `text` | nullable | 说明 | +| `source` | `text` | not null | `gateway`、`server-main`、`sync` | +| `priority` | `int` | not null, default `100` | 多组命中时优先级,越小越优先 | +| `recharge_discount_policy` | `jsonb` | not null, default `{}` | 充值折扣/赠送资源策略;独立模式由 Gateway 执行,接入模式由 `server-main` 执行 | +| `billing_discount_policy` | `jsonb` | not null, default `{}` | 模型调用计费折扣策略,用于 estimated billing 和 billings | +| `rate_limit_policy` | `jsonb` | not null, default `{}` | 用户组 TPM/RPM/并发/队列策略 | +| `quota_policy` | `jsonb` | not null, default `{}` | 日/月额度、API Key 额度、最大任务数 | +| `metadata` | `jsonb` | not null, default `{}` | 展示、运营、同步元数据 | +| `status` | `text` | not null | `active`、`disabled` | +| `created_at` | `timestamptz` | not null | 创建时间 | +| `updated_at` | `timestamptz` | not null | 更新时间 | + +示例: + +```json +{ + "recharge_discount_policy": { + "type": "tiered_bonus", + "tiers": [ + { "minAmount": 100, "bonusRatio": 0.05 }, + { "minAmount": 1000, "bonusRatio": 0.12 } + ] + }, + "billing_discount_policy": { + "defaultDiscountFactor": 0.9, + "modelTypeDiscounts": { "chat": 0.85, "image": 0.95 } + }, + "rate_limit_policy": { + "rules": [ + { "metric": "rpm", "limit": 1200, "windowSeconds": 60 }, + { "metric": "tpm_total", "limit": 300000, "windowSeconds": 60 }, + { "metric": "concurrent", "limit": 50, "leaseTtlSeconds": 900 } + ] + } +} +``` + +#### 7.9.7 `gateway_user_group_memberships` + +保存用户组成员关系。一个用户、租户或 API Key 可命中多个组,运行时按组优先级和策略合并规则解析有效策略。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | 成员关系 ID | +| `group_id` | `uuid` | FK | 用户组 ID | +| `principal_type` | `text` | not null | `user`、`tenant`、`api_key`、`organization` | +| `principal_id` | `text` | not null | 对应 Gateway 用户 ID、`server-main` 外部用户 ID、租户 ID、API Key ID 或组织 ID | +| `source` | `text` | not null | `gateway`、`server-main`、`sync` | +| `priority` | `int` | not null, default `100` | 关系级优先级 | +| `effective_from` | `timestamptz` | nullable | 生效开始 | +| `effective_to` | `timestamptz` | nullable | 生效结束 | +| `status` | `text` | not null | `active`、`disabled` | +| `metadata` | `jsonb` | not null, default `{}` | 同步和运营元数据 | +| `created_at` | `timestamptz` | not null | 创建时间 | +| `updated_at` | `timestamptz` | not null | 更新时间 | + +索引: + +- `uniq_user_group_membership(group_id, principal_type, principal_id)` +- `idx_user_group_membership_principal(principal_type, principal_id, status)` +- `idx_user_group_membership_effective(effective_from, effective_to)` + +成员解析规则: + +- 独立模式优先使用 `principal_type='user' + gateway_users.id`。 +- 接入 `server-main` 时可使用 `principal_type='user' + external_user_id`,并通过 `gateway_users.source='server-main'` 反查同步副本。 +- 多个组同时命中时,先按 `gateway_user_group_memberships.priority`,再按 `gateway_user_groups.priority` 合并策略。 + +#### 7.9.8 `gateway_tenant_invitations` + +本地注册邀请码。邀请码不是普通注册的必填项,但填写后必须可校验,并决定用户加入哪个租户和用户组。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | 邀请 ID | +| `tenant_id` | `uuid` | FK | 加入的 Gateway 租户 | +| `invite_code` | `text` | unique, not null | 注册邀请码 | +| `role` | `text` | not null, default `user` | 注册后角色,默认普通用户 | +| `user_group_id` | `uuid` | FK nullable | 注册后默认用户组 | +| `max_uses` | `int` | nullable | 最大使用次数 | +| `used_count` | `int` | not null | 已使用次数 | +| `expires_at` | `timestamptz` | nullable | 过期时间 | +| `status` | `text` | not null | `active`、`disabled` | +| `metadata` | `jsonb` | not null | 渠道、备注、审批信息 | + +#### 7.9.9 `gateway_api_keys` + +独立模式本地 API Key 表。`server-main` 接入模式下,`sk-*` 仍调用主服务校验;Gateway 只记录执行快照和策略。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | API Key ID | +| `gateway_tenant_id` / `gateway_user_id` | `uuid` | FK | 所属租户与用户 | +| `key_prefix` | `text` | not null | 展示和快速定位前缀 | +| `key_hash` | `text` | unique, not null | secret hash,不保存明文 | +| `name` | `text` | not null | 用户命名 | +| `scopes` | `jsonb` | not null | 可调用能力范围 | +| `user_group_id` | `uuid` | nullable | API Key 级策略组 | +| `rate_limit_policy` / `quota_policy` | `jsonb` | not null | Key 级限流和额度覆盖 | +| `status` | `text` | not null | `active`、`disabled`、`revoked` | +| `expires_at` / `last_used_at` | `timestamptz` | nullable | 过期与最近使用 | + +#### 7.9.10 `gateway_wallet_accounts` + +独立模式本地钱包账户。Phase 1 先支持资源余额闭环,后续可扩展人民币、美元或企业额度。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | 钱包账户 ID | +| `gateway_tenant_id` / `gateway_user_id` | `uuid` | FK | 所属租户与用户 | +| `currency` | `text` | not null | `resource`、`credit`、`cny`、`usd` | +| `balance` | `numeric` | not null | 可用余额 | +| `frozen_balance` | `numeric` | not null | 冻结余额 | +| `total_recharged` | `numeric` | not null | 累计充值 | +| `total_spent` | `numeric` | not null | 累计消费 | +| `status` | `text` | not null | `active`、`frozen`、`disabled` | + +#### 7.9.11 `gateway_wallet_transactions` + +独立模式钱包流水。任务扣费、充值到账、退款、人工调整都必须写流水,并使用 `idempotency_key` 防重。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | 流水 ID | +| `account_id` | `uuid` | FK | 钱包账户 | +| `direction` | `text` | not null | `credit`、`debit`、`freeze`、`unfreeze` | +| `transaction_type` | `text` | not null | `recharge`、`billing`、`refund`、`adjustment` | +| `amount` | `numeric` | not null | 金额 | +| `balance_before` / `balance_after` | `numeric` | not null | 变动前后余额 | +| `idempotency_key` | `text` | nullable | 幂等 key | +| `reference_type` / `reference_id` | `text` | nullable | 关联任务、订单或人工工单 | + +#### 7.9.12 `gateway_recharge_orders` + +独立模式充值订单。第一阶段先闭合订单、折扣、入账和流水;支付渠道可以从手工确认开始,再接真实支付。 + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | 充值订单 ID | +| `gateway_tenant_id` / `gateway_user_id` | `uuid` | FK | 所属租户与用户 | +| `amount` | `numeric` | not null | 入账基础额度 | +| `bonus_amount` | `numeric` | not null | 用户组充值折扣或赠送额度 | +| `payable_amount` | `numeric` | not null | 实际应付 | +| `currency` | `text` | not null | 币种或资源类型 | +| `channel` | `text` | not null | `manual`、`wechat`、`alipay`、`stripe` | +| `status` | `text` | not null | `created`、`pending`、`paid`、`closed`、`failed` | +| `external_order_id` | `text` | nullable | 第三方支付订单 | +| `idempotency_key` | `text` | nullable | 创建订单幂等 key | +| `paid_at` | `timestamptz` | nullable | 支付完成时间 | + +#### 7.9.13 `integration_platforms` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | @@ -534,6 +877,9 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 | `auth_type` | `text` | not null | `bearer`、`api_key`、`basic`、`custom` | | `credentials` | `jsonb` | not null, default `{}` | 加密后的凭证 JSON | | `config` | `jsonb` | not null, default `{}` | 超时、重试、限流、平台私有配置 | +| `visibility_scope` | `text` | not null, default `global` | `global`、`tenant`、`private` | +| `tenant_id` | `text` | nullable | 租户外部 ID 或兼容 claim | +| `tenant_key` | `text` | nullable | Gateway 租户 key | | `default_pricing_mode` | `text` | not null, default `inherit_discount` | 平台默认价格模式:`inherit`、`inherit_discount`、`custom` | | `default_discount_factor` | `numeric` | not null, default `1` | 平台默认折扣系数,创建平台时可统一基于基准模型打折 | | `retry_policy` | `jsonb` | not null, default `{}` | 平台级重试策略 | @@ -552,8 +898,9 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `idx_integration_platforms_provider_status(provider, status)` - `idx_integration_platforms_status_priority(status, priority, dynamic_priority)` - `idx_integration_platforms_cooldown(cooldown_until)` +- `idx_integration_platforms_tenant_scope(visibility_scope, tenant_id, tenant_key, status)` -#### 7.9.5 `platform_models` +#### 7.9.14 `platform_models` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | @@ -585,7 +932,7 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `idx_platform_models_alias(model_alias)` - `idx_platform_models_capabilities` 使用 `GIN(capabilities)` -#### 7.9.6 `gateway_tasks` +#### 7.9.15 `gateway_tasks` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | @@ -593,9 +940,16 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 | `external_task_id` | `text` | nullable | 与 `server-main` / 前端兼容的外部任务 ID | | `kind` | `text` | not null | `chat.completions`、`images.generations` 等 | | `run_mode` | `text` | not null | `production`、`simulation` | -| `user_id` | `text` | not null | 用户 ID | -| `tenant_id` | `text` | nullable | 租户 ID | +| `user_id` | `text` | not null | 请求 claim 中的用户 ID | +| `gateway_user_id` | `uuid` | nullable | Gateway 用户表 ID | +| `user_source` | `text` | not null, default `gateway` | `gateway`、`server-main`、`sync` | +| `gateway_tenant_id` | `uuid` | nullable | Gateway 租户 ID | +| `tenant_id` | `text` | nullable | 外部租户 ID 或兼容 claim | +| `tenant_key` | `text` | nullable | Gateway 租户 key | | `api_key_id` | `text` | nullable | OpenAPI Key ID | +| `user_group_id` | `uuid` | nullable | 命中的用户组 ID | +| `user_group_key` | `text` | nullable | 命中的用户组 key | +| `user_group_policy_snapshot` | `jsonb` | not null, default `{}` | 用户组策略快照 | | `model` | `text` | not null | 请求模型 | | `model_type` | `text` | nullable | 模型类型 | | `request` | `jsonb` | not null | 原始请求快照 | @@ -630,7 +984,7 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `idx_gateway_tasks_external(external_task_id)` - `uniq_gateway_tasks_idempotency(user_id, idempotency_key)` where `idempotency_key is not null` -#### 7.9.7 `gateway_task_attempts` +#### 7.9.16 `gateway_task_attempts` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | @@ -658,7 +1012,7 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `idx_gateway_attempts_task(task_id)` - `idx_gateway_attempts_client(client_id, started_at desc)` -#### 7.9.8 `gateway_task_events` +#### 7.9.17 `gateway_task_events` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | @@ -679,7 +1033,31 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `uniq_gateway_events_seq(task_id, seq)` - `idx_gateway_events_task_created(task_id, created_at)` -#### 7.9.9 `runtime_client_states` +#### 7.9.18 `gateway_task_callback_outbox` + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `id` | `uuid` | PK | 回调 outbox ID | +| `task_id` | `uuid` | FK | 任务 ID | +| `event_id` | `uuid` | FK nullable | 对应 `gateway_task_events.id` | +| `seq` | `bigint` | not null | 单任务事件序号 | +| `callback_url` | `text` | not null | 投递地址快照,来自 `TASK_PROGRESS_CALLBACK_URL` | +| `payload` | `jsonb` | not null | 回调载荷 | +| `status` | `text` | not null | `pending`、`delivering`、`delivered`、`failed`、`skipped` | +| `attempts` | `int` | not null, default `0` | 已投递次数 | +| `next_attempt_at` | `timestamptz` | not null | 下次投递时间 | +| `last_error` | `text` | nullable | 最近错误 | +| `delivered_at` | `timestamptz` | nullable | 投递成功时间 | +| `created_at` | `timestamptz` | not null | 创建时间 | +| `updated_at` | `timestamptz` | not null | 更新时间 | + +索引: + +- `uniq_task_callback_seq(task_id, seq, callback_url)` +- `idx_task_callback_outbox_pending(status, next_attempt_at)` +- `idx_task_callback_outbox_task(task_id, seq)` + +#### 7.9.19 `runtime_client_states` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | @@ -701,7 +1079,7 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `idx_runtime_client_queue(queue_key, cooldown_until)` - `idx_runtime_client_platform(platform_id)` -#### 7.9.10 `gateway_upload_assets` +#### 7.9.20 `gateway_upload_assets` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | @@ -722,7 +1100,7 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `idx_gateway_upload_task(task_id)` - `idx_gateway_upload_file(server_main_file_id)` -#### 7.9.11 `gateway_retry_policies` +#### 7.9.21 `gateway_retry_policies` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | @@ -738,12 +1116,12 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `uniq_retry_policy_scope(scope_type, scope_key)` -#### 7.9.12 `gateway_rate_limit_policies` +#### 7.9.22 `gateway_rate_limit_policies` | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | | `id` | `uuid` | PK | 策略 ID | -| `scope_type` | `text` | not null | `global`、`provider`、`platform`、`client`、`base_model`、`platform_model`、`method`、`tenant`、`user`、`api_key` | +| `scope_type` | `text` | not null | `global`、`provider`、`platform`、`client`、`base_model`、`platform_model`、`method`、`tenant`、`user_group`、`user`、`api_key` | | `scope_key` | `text` | not null | 作用域 key | | `policy` | `jsonb` | not null | TPM、RPM、并发、队列长度、冷却策略 | | `created_at` | `timestamptz` | not null | 创建时间 | @@ -766,7 +1144,7 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 } ``` -#### 7.9.13 `gateway_rate_limit_counters` +#### 7.9.23 `gateway_rate_limit_counters` 保存一分钟窗口内的 TPM/RPM 使用量。Redis 可做加速,但 PG counter 是重启恢复和审计的事实源。 @@ -786,7 +1164,7 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - `pk_rate_limit_counter(scope_type, scope_key, metric, window_start)` -#### 7.9.14 `gateway_concurrency_leases` +#### 7.9.24 `gateway_concurrency_leases` 保存并发请求租约,服务异常重启后可释放过期并发。 @@ -830,7 +1208,8 @@ AI Gateway 的外部接口必须以“兼容现有 EasyAI 路由、请求 DTO、 - 基准模型级:`base_model_id` / `canonical_model_key` - 平台模型级:`platform_model_id` / `model_name` / `model_type` - 方法级:`method_name`,例如 `runAIApp$` -- 租户/用户级:`tenant_id` / `user_id` +- 租户/用户级:`gateway_tenant_id` / `tenant_id` / `tenant_key` / `user_id` +- 用户组级:`user_group_id` / `user_group_key` - OpenAPI Key 级:`api_key_id` 同一请求可能命中多个策略,默认以最严格策略为准;如后续需要不同优先级,可在 policy 中增加 `mergeStrategy`。 @@ -936,10 +1315,13 @@ RPM 和并发的边界: 1. 平台模型 `capability_override` / `billing_config_override`。 2. 平台模型 `discount_factor`。 3. 平台 `default_discount_factor`。 -4. 基准模型 `capabilities` / `base_billing_config`。 +4. 用户组 `billing_discount_policy`,用于用户组级模型调用折扣。 +5. 基准模型 `capabilities` / `base_billing_config`。 如果没有配置平台模型价格,必须 follow 基准模型;不能出现空价格导致预估扣费为 0 的隐式行为。 +接入 `server-main` 时,充值折扣不在 Gateway 内直接变更余额。Gateway 只保存 `recharge_discount_policy` 并与主服务同步;充值订单、资源包发放、余额流水仍由 `server-main` 作为事实源执行。独立模式下,充值、余额、订单和钱包流水由 Gateway 本地账务模块闭环。 + ### 9.3 各能力计价模型 文本类: @@ -1026,6 +1408,7 @@ type ModelClient interface { 具体 client 只负责供应商协议: - OpenAI-compatible:同步/stream chat、responses、image generation。 +- Gemini:Chat、多模态图片输入、生图、图像编辑优先进入第一阶段迁移。 - RunningHub:模板类 app 提交、轮询、结果提取,队列 key 使用 `${provider}-${methodName}`。 - Jimeng / Vidu / Kling / Hunyuan Video:视频任务提交与轮询。 - Suno / Speech / Digital Human:各自的提交和结果归一。 @@ -1117,17 +1500,56 @@ type ProgressPublisher interface { ### 12.2 推送通道 -- SSE:`GET /api/v1/tasks/:taskId/events`,支持 `Last-Event-ID` 回放。 -- WebSocket:用于高频任务进度、队列监控、控制台实时状态。 +- 业务实时进度主通道:Gateway 按 `TASK_PROGRESS_CALLBACK_URL` 回调 `server-main`,由 `server-main` 复用原 WebSocket 网关推送给业务前端。 +- 控制台 SSE:`GET /api/v1/tasks/:taskId/events`,支持 `Last-Event-ID` 回放,用于 Gateway 控制台、调试和故障诊断。 +- Gateway WebSocket:用于网关控制台高频队列监控、运行态监控;不替代业务前端现有 WebSocket 通道。 - OpenAI stream:`/chat/completions`、`/v1/chat/completions`、`/responses` 需要保持原 stream chunk 格式。 -- server-main bridge:迁移期如仍需老前端通道,可由 `server-main` 订阅 Gateway 事件后转发,但目标态应由网关直推。 + +任务进度回调示例: + +```http +POST ${TASK_PROGRESS_CALLBACK_URL} +Authorization: Bearer ${SERVER_MAIN_INTERNAL_TOKEN} +Content-Type: application/json +Idempotency-Key: ${taskId}:${seq} +X-EasyAI-Event-Type: task.progress +``` + +```json +{ + "eventId": "uuid", + "taskId": "uuid", + "externalTaskId": "server-main-task-id", + "userId": "user-id", + "tenantId": "tenant-id", + "apiKeyId": "optional", + "kind": "images.generations", + "model": "gpt-image-1", + "seq": 12, + "event": "progress", + "status": "running", + "phase": "polling", + "progress": 0.42, + "message": "Generating video frames", + "payload": {}, + "createdAt": "2026-05-09T12:00:00Z" +} +``` + +`server-main` 收到后不重新执行任务,只做三件事: + +1. 按 `Idempotency-Key` 或 `taskId + seq` 幂等落库/去重。 +2. 根据 `externalTaskId`、`taskId`、`userId`、`tenantId` 找到原业务会话或任务频道。 +3. 通过原 WebSocket 网关推送给业务前端,保持前端订阅协议不大改。 ### 12.3 进度持久化要求 -- 所有对用户可见的状态变化都必须先写 `gateway_task_events`,再广播。 +- 所有对用户可见的状态变化都必须先写 `gateway_task_events`,再写 `gateway_task_callback_outbox`,最后广播/投递。 - 客户端断线后能从 `seq` 或 `Last-Event-ID` 补齐。 - 任务恢复后必须先重放最近状态,再继续发布新进度。 - 完成态事件必须包含足够的结果摘要,前端无需额外轮询才能更新卡片状态。 +- 回调投递失败不能阻塞任务执行,但必须进入 outbox 重试,并在控制台显示滞留状态。 +- 同一任务的回调按 `seq` 保序投递;允许不同任务并发投递。 ## 13. 测试模式与全链路模拟 @@ -1277,18 +1699,43 @@ sequenceDiagram MAIN->>MAIN: create history / task id MAIN->>GW: POST /api/v1/images/generations GW->>V: submit task + GW-->>MAIN: POST task progress callback + MAIN-->>FE: WebSocket task progress V-->>GW: result + GW-->>MAIN: POST task completed callback + MAIN-->>FE: WebSocket task completed GW-->>MAIN: settlement event with billings MAIN->>MAIN: billing + history update GW-->>MAIN: task result MAIN-->>FE: response ``` -### 14.2 中期:前端直接打 Gateway +### 14.2 实时任务进度回调 + +AI Gateway 不直接替换原业务前端的 WebSocket 订阅方式。Gateway 在配置中指定任务进度回调地址: + +```env +TASK_PROGRESS_CALLBACK_URL=http://easyai-server-main:3000/internal/platform/task-progress-callbacks +TASK_PROGRESS_CALLBACK_ENABLED=true +TASK_PROGRESS_CALLBACK_TIMEOUT_MS=5000 +TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS=10 +``` + +运行时要求: + +- `BaseClient` / runtime 每产生一个进度事件,先写 `gateway_task_events`,再写 `gateway_task_callback_outbox`。 +- callback worker 使用 `SERVER_MAIN_INTERNAL_TOKEN` 调用 `TASK_PROGRESS_CALLBACK_URL`。 +- `server-main` 收到事件后进入主服务内部推送流程,再由原 WebSocket 网关推送给业务前端。 +- Gateway 控制台仍可通过 SSE 读 `gateway_task_events`,用于运维诊断,不影响业务前端推送通道。 +- 如果 `server-main` 短暂不可用,Gateway 按 outbox 重试;超过最大次数后标记 `failed`,控制台可手动 replay。 + +### 14.3 中期:前端直接打 Gateway 前端配置 `VITE_GATEWAY_API_BASE_URL`,生成类请求直接走网关路由到 AI Gateway。`server-main` 只处理登录、刷新、余额、历史、开放文件上传、扣费。 -### 14.3 结算事件 +即使生成请求改为前端直连 Gateway,业务实时进度默认仍回调 `server-main` 内部任务进度接口,除非某个新业务明确迁移到 Gateway 自身的事件通道。 + +### 14.4 结算事件 Gateway 任务成功后向 `server-main` 发送结算事件: @@ -1369,100 +1816,155 @@ type ServerMainUploadClient interface { - 如果上传失败,任务按 provider retry 策略判断是否可重试;上传失败本身也要写 attempt/event。 - Gateway 不保存长期文件密钥,不暴露 OSS 配置页面。 -## 16. 前端控制台 +## 16. 前端页面设计 -前端控制台使用 React + TypeScript + TSX + `shadcn-ui`。首期包含: +前端使用 React + TypeScript + TSX + `shadcn-ui`,定位不是单纯后台控制台,而是“AI 能力门户 + 用户工作台 + 管理工作台 + API 文档中心”。页面根据 `IDENTITY_MODE` 切换身份来源:独立模式走 Gateway 本地登录 / API Key;接入 `server-main` 时走主服务 JWT / OpenAPI Key 授权。普通用户只能进入模型、用户工作台和 API 文档,管理员额外进入管理工作台。 -- 健康检查。 -- 基准 provider / 基准模型库。 -- 基准定价与平台折扣预览。 -- 平台列表。 -- 模型能力概览。 -- 任务入口占位。 -- 队列与 TPM/RPM/并发限流状态。 -- 客户端运行状态与 cooldown。 -- 重试策略配置。 -- 上传记录与主服务上传接口调用状态。 -- 后续补:凭证编辑、模型计费配置、任务详情、平台探活。 +### 16.1 一级导航 -控制台走同一套 JWT,管理页面需要 `power` 权限。 +| 一级模块 | 路由 | 默认权限 | 定位 | +| --- | --- | --- | --- | +| 登录 | `/login`、`/register` | `public` | Gateway 本地账号登录注册、外部 token 入口 | +| 首页 | `/` | `public/basic` | 服务入口、模型能力概览、用量摘要、最近任务、快捷入口 | +| 模型 | `/models` | `public/basic` | 可用模型浏览、能力筛选、价格/限流展示、模型调用入口 | +| 用户工作台 | `/workspace` | `basic` | 个人中心、余额充值、API Key、任务记录 | +| 管理工作台 | `/admin` | `power/manager` | 用户与用户组、全局模型配置、平台管理、队列限流、结算与系统设置 | +| API 文档 | `/docs` | `public/basic` | 开放接口文档、鉴权说明、示例代码、在线调用测试 | -### 16.1 页面路由模块 +### 16.2 登录与注册 + +首期登录页先提供普通账号能力和可选邀请码注册,后续再接 SSO、验证码、MFA: | 页面 | 路由 | 权限 | 说明 | | --- | --- | --- | --- | -| 总览 | `/` 或 `/dashboard` | `power` | 服务健康、任务吞吐、成功率、队列积压、异常平台 | -| 基准 Provider | `/catalog/providers` | `power` | provider 列表、能力 schema、默认限流模板、启停状态 | -| 基准模型库 | `/catalog/base-models` | `power` | 全量模型、provider、模型类型、能力、基准价格、默认限流 | -| 基准模型详情 | `/catalog/base-models/:id` | `power` | 能力 schema、基准定价、价格版本、平台引用情况 | -| 定价规则 | `/pricing/rules` | `power` | 文本/图像/视频等资源价格、动态权重、版本和生效时间 | -| 平台管理 | `/platforms` | `power` | 平台列表、启停、优先级、凭证状态、复制平台、异常重置 | -| 平台详情 | `/platforms/:id` | `power` | 基础配置、凭证、默认折扣、模型、限流、重试、运行状态 | -| 模型管理 | `/models` | `power` | 平台模型列表、基准模型映射、类型、能力、有效计费、启用状态 | -| 模型详情 | `/models/:id` | `power` | 基准继承、能力覆盖、计费规则、权限过滤、测试请求 | -| API 配置 | `/platform-apis` | `power` | 兼容原 `integration/platform-api` 的 CRUD 与执行测试 | -| 任务列表 | `/tasks` | `basic` | 任务查询、状态过滤、用户/模型/平台筛选 | -| 任务详情 | `/tasks/:id` | `basic` | 请求、attempt、进度事件、结果、计费、上传资产 | -| 队列监控 | `/runtime/queues` | `power` | queue key、等待数、运行数、租约、恢复状态 | -| 客户端运行态 | `/runtime/clients` | `power` | running、waiting、limiter ratio、cooldown、last error | -| 重试策略 | `/policies/retry` | `power` | global/provider/platform/model/method 重试策略 | -| 限流策略 | `/policies/rate-limit` | `power` | TPM、RPM、并发、队列长度、冷却策略 | -| 测试模式 | `/simulation` | `power` | simulation profile、失败注入、慢任务、dry-run billing | -| 上传记录 | `/uploads` | `power` | 主服务上传记录映射、任务关联、失败重试 | -| 结算 outbox | `/settlements` | `manager` | 结算事件、重试、跳过、幂等 key | -| 系统设置 | `/settings` | `manager` | server-main 地址、内部令牌、运行模式、安全开关 | +| 登录 | `/login` | `public` | 用户名/邮箱 + 密码登录 Gateway 本地账号 | +| 注册 | `/register` | `public` | 创建 Gateway 本地账号;可填写租户 key / 租户名称 / 邀请码,邀请码不是必填 | +| 外部 Token 入口 | `/login?mode=external` | `public` | 粘贴 `server-main` access token,用于接入模式和开发调试 | -### 16.2 关键页面设计 +登录成功后统一获得 Gateway 可校验的 access token;本地账号 token 中包含 `source=gateway`、`gatewayUserId`、`gatewayTenantId`、`tenantKey`。外部 token 保留 `source=server-main` 或主服务返回的 claim。 -**基准模型库** +### 16.3 首页 -- 表格列:provider、模型名、类型、状态、能力标签、基准输入价、基准输出价、图像/视频基准价、默认 TPM/RPM/并发。 -- 详情页 tab:基础信息、能力 schema、基准定价、默认限流、价格版本、引用平台模型。 -- 支持导入/同步原项目基准配置,变更价格时创建新 `pricing_version`。 +首页不是营销页,首屏应直接提供可操作入口和运行状态。 -**定价规则** +| 页面 | 路由 | 权限 | 说明 | +| --- | --- | --- | --- | +| 首页总览 | `/` | `public/basic` | 推荐模型、最近任务、余额摘要、API Key 状态、服务公告 | +| 能力概览 | `/capabilities` | `public/basic` | Chat、生图、生视频、音频、Embedding 等能力入口 | +| 服务状态 | `/status` | `public/basic` | 网关健康、模型可用性、平台异常公告、限流提示 | -- 文本规则按输入/输出 token 分开配置,单位为 `1k_tokens`。 -- 图像规则提供分辨率、质量、数量、模式权重表。 -- 视频规则提供时长单位、分辨率、有无音频、参考视频、指定声音等权重表。 -- 提供“基准价 x 平台折扣 x 模型覆盖”的实时预览,避免运营保存前看不到最终价。 +首页组件: -**平台管理** +- `HeroStatusBand`:显示 Gateway 健康、可用模型数、今日任务数、当前余额。 +- `QuickStartActions`:Chat 调用、生图调用、生视频调用、创建 API Key、查看 API 文档。 +- `ModelCapabilityGrid`:按文本、图像、视频、音频、Embedding 展示可用能力。 +- `RecentTasks`:最近任务状态、进度、失败原因和重试入口。 +- `UsageSnapshot`:本月调用量、token、图片/视频任务、费用趋势。 -- 表格列:平台名、provider、状态、优先级、动态优先级、启用模型数、running/waiting、cooldown、最近错误、更新时间。 -- 操作:启用/禁用、复制、重置异常、清队列、进入详情。 -- 创建/编辑平台时可选择基准 provider,配置默认折扣系数。 -- 详情页 tab:基础信息、凭证、模型、基准模型映射、限流、重试、运行态、快照。 +### 16.4 模型 -**模型管理** +模型页面面向普通用户和管理员,但展示内容按权限裁剪。 -- 支持按 `model_type`、provider、平台、启用状态、能力标签筛选。 -- 能力配置用 JSON editor + 表单化快捷项结合:是否 stream、多模态输入、参考图/视频/音频、尺寸、时长、上下文窗口。 -- 模型详情展示“follow 基准模型 / 按折扣继承 / 自定义覆盖”三态,保存后写入 effective capabilities 和 effective billing config。 -- 计费配置保留原 `estimatedBilling` 语义,前端提供即时 dry-run 预估。 +| 页面 | 路由 | 权限 | 说明 | +| --- | --- | --- | --- | +| 模型广场 | `/models` | `public/basic` | 按能力、provider、价格、上下文、多模态筛选模型 | +| 模型详情 | `/models/:modelKey` | `public/basic` | 能力、价格、限流、参数 schema、示例请求、在线试用 | +| 模型价格 | `/models/:modelKey/pricing` | `basic` | 文本输入/输出、图像分辨率/质量、视频时长/分辨率等价格 | +| 模型调用测试 | `/models/:modelKey/playground` | `basic` | 选择 API Key 或 JWT,在线测试 Chat/生图/生视频 | -**任务详情** +模型页能力: -- 顶部状态条展示:状态、run mode、模型、平台、客户端、attempt 次数、耗时、是否模拟。 -- `Attempts` 表展示每次客户端尝试、错误码、是否可重试、失败切换决策。 -- `Events` 时间线展示 queue/routing/submit/poll/upload/completed 等进度,可重放。 -- `Request/Result` 使用 JSON viewer。 -- `Uploads` 展示调用主服务上传接口后的 file id、URL、content-type、size。 +- 筛选:模型类型、provider、是否支持 stream、多模态、参考图/视频/音频、上下文窗口、价格区间。 +- 展示:有效模型价、TPM/RPM/并发限制、是否支持测试模式、常见错误码。 +- 管理员视角:展示平台模型来源、基准模型映射、平台折扣、能力覆盖和实际候选平台。 -**队列监控** +### 16.5 用户工作台 -- 按 queue key 展示 waiting、running、leased timeout、failed retryable、next run。 -- 展示当前窗口的 TPM/RPM 使用量、预占量、并发 lease、重置时间。 -- 提供只读诊断为主;危险操作如清队列、重放任务需要二次确认 dialog。 +用户工作台关注“我能用什么、用了多少、怎么调用、历史任务在哪里”。 -**测试模式** +| 页面 | 路由 | 权限 | 说明 | +| --- | --- | --- | --- | +| 个人中心总览 | `/workspace/overview` | `basic` | 账号信息、身份来源、角色、用户组、余额、API Key 数、最近任务、用量摘要 | +| 余额与充值 | `/workspace/billing` | `basic` | 余额、资源包、充值入口、消费记录、用户组充值折扣、发票/订单状态 | +| API Key 管理 | `/workspace/api-keys` | `basic` | API Key 列表、创建、禁用、重置、权限范围、最近调用 | +| 任务记录 | `/workspace/tasks` | `basic` | Chat/生图/生视频等任务列表,按状态、模型、时间筛选 | +| 任务详情 | `/workspace/tasks/:id` | `basic` | 请求、进度事件、结果、计费、上传资产、失败原因 | -- Profile 编辑器:成功、慢任务、可重试失败、不可重试失败、未知提交态。 -- 一键发起模拟 Chat / 生图 / 生视频任务。 -- 展示完整进度流、attempt 切换和 dry-run billing。 -- 页面显著标识 simulation,避免与真实生产任务混淆。 +边界: -### 16.3 前端模块目录 +- 独立模式下,用户资料、API Key、余额、充值订单和钱包流水由 Gateway 本地模块闭环;接入 `server-main` 时,余额、充值、订单、API Key 生命周期仍归 `server-main`,Gateway 前端可以通过 `server-main` API 或兼容代理展示。 +- 用户组影响充值折扣、调用折扣和并发/限流;工作台需要展示当前用户命中的用户组、折扣说明、TPM/RPM/并发限制。 +- 任务记录以 Gateway 任务为执行事实源,但业务历史最终仍可由 `server-main` 汇总。 +- API Key 创建时必须显示一次性 secret,后续只展示 key 名称、前缀、权限和最近使用时间。 + +### 16.6 管理工作台 + +管理工作台只对 `power/manager` 开放,负责全局模型、平台、限流、重试、运行态和系统集成。当前阶段平台凭证和 provider 凭证仅允许全局管理员配置,租户管理员暂不拥有自助配置入口。 + +| 页面 | 路由 | 权限 | 说明 | +| --- | --- | --- | --- | +| 管理总览 | `/admin` | `power` | 服务健康、任务吞吐、成功率、队列积压、异常平台、回调 outbox | +| 租户管理 | `/admin/tenants` | `power` | 本地租户、同步租户、租户策略、状态和用量隔离 | +| 用户管理 | `/admin/users` | `power` | 本地用户、同步用户、角色、状态、同步差异和用户组命中 | +| 用户组管理 | `/admin/user-groups` | `power` | 用户组、成员关系、充值折扣、调用折扣、并发/限流策略 | +| 全局模型配置 | `/admin/models/global` | `power` | 基准模型库、能力 schema、基准定价、默认限流模板 | +| 基准 Provider | `/admin/catalog/providers` | `power` | provider 列表、协议类型、能力 schema、启停状态 | +| 基准模型详情 | `/admin/catalog/base-models/:id` | `power` | 能力、价格版本、默认 TPM/RPM/并发、引用平台模型 | +| 定价规则 | `/admin/pricing/rules` | `power` | 文本/图像/视频/音频价格、动态权重、版本生效时间 | +| 平台管理 | `/admin/platforms` | `power` | 平台 CRUD、启停、优先级、凭证状态、复制、异常重置 | +| 平台详情 | `/admin/platforms/:id` | `power` | 基础信息、凭证、模型、折扣、限流、重试、运行态、快照 | +| 平台模型 | `/admin/platform-models` | `power` | 平台模型列表、基准映射、能力覆盖、价格覆盖、权限过滤 | +| API 配置 | `/admin/platform-apis` | `power` | 兼容原 `integration/platform-api` 的 CRUD 与执行测试 | +| 队列监控 | `/admin/runtime/queues` | `power` | queue key、等待数、运行数、租约、恢复状态 | +| 客户端运行态 | `/admin/runtime/clients` | `power` | running、waiting、limiter ratio、cooldown、last error | +| 限流策略 | `/admin/policies/rate-limit` | `power` | TPM、RPM、并发、队列长度、冷却策略 | +| 重试策略 | `/admin/policies/retry` | `power` | global/provider/platform/model/method 重试策略 | +| 任务审计 | `/admin/tasks` | `power` | 全量任务、attempt、失败切换、用户/模型/平台筛选 | +| 上传记录 | `/admin/uploads` | `power` | 主服务上传记录映射、任务关联、失败重试 | +| 结算 outbox | `/admin/settlements` | `manager` | 结算事件、重试、跳过、幂等 key | +| 回调 outbox | `/admin/callbacks` | `power` | 任务进度回调状态、失败原因、手动 replay | +| 测试模式 | `/admin/simulation` | `power` | simulation profile、失败注入、慢任务、dry-run billing | +| 系统设置 | `/admin/settings` | `manager` | server-main 地址、内部令牌、运行模式、安全开关 | + +关键管理页面: + +- 全局模型配置:维护 `model_catalog_providers`、`base_model_catalog`、`model_pricing_rules`,支持导入旧项目配置。 +- 租户管理:维护 `gateway_tenants`,支持本地租户 CRUD、从 `server-main` 增量同步、禁用状态同步、租户策略和用量隔离审计。 +- 用户管理:维护 `gateway_users`,支持本地用户 CRUD、从 `server-main` 增量同步、禁用状态同步、角色同步和同步差异审计。 +- 用户组管理:维护 `gateway_user_groups`、`gateway_user_group_memberships`,支持从 `server-main` 同步成员关系;独立模式执行全部策略,接入模式下充值折扣由 `server-main` 执行,Gateway 执行调用折扣和并发/限流。 +- 平台管理:创建平台时选择基准 provider,配置默认折扣系数;平台模型可 follow 基准、按折扣继承或自定义覆盖。 +- 队列与限流:展示 TPM/RPM 窗口、预占值、并发 lease、cooldown、next run。 +- 回调 outbox:展示 Gateway 到进度回调目标的投递结果,支持按 task replay。 + +### 16.7 API 文档与在线调用测试 + +API 文档面向开发者,需要能完成从鉴权到真实调用的闭环。 + +| 页面 | 路由 | 权限 | 说明 | +| --- | --- | --- | --- | +| 文档首页 | `/docs` | `public/basic` | 快速开始、鉴权方式、模型列表、错误码、限流说明 | +| 鉴权 | `/docs/auth` | `public/basic` | JWT、OpenAPI `sk-*`、Header、权限范围、API Key 安全 | +| Chat | `/docs/api/chat` | `public/basic` | `/v1/chat/completions`、stream、工具调用、示例代码 | +| Responses | `/docs/api/responses` | `public/basic` | `/v1/responses` 请求、取消、结构化输出 | +| 图片 | `/docs/api/images` | `public/basic` | `/v1/images/generations`、`/v1/images/edits`、文件输入 | +| 视频 | `/docs/api/videos` | `public/basic` | `/v1/video/generations`、进度查询、结果取回 | +| 音频/语音 | `/docs/api/audio` | `public/basic` | speech、music、digital human 等接口 | +| Embeddings | `/docs/api/embeddings` | `public/basic` | embedding 模型、批量输入、维度说明 | +| 文件上传 | `/docs/api/files` | `public/basic` | 说明仍调用 `server-main` `/v1/files/upload` | +| 任务结果 | `/docs/api/tasks` | `public/basic` | `/v1/ai/result/:taskId`、取消、状态、回调语义 | +| 错误码 | `/docs/errors` | `public/basic` | 兼容错误结构、retryable 分类、限流错误 | +| 在线调试 | `/docs/playground` | `basic` | 选择模型、API Key、请求模板,执行在线调用测试 | + +在线调试器: + +- 左侧选择接口类型:Chat、Responses、生图、图像编辑、生视频、Embedding、语音。 +- 中间为参数表单 + JSON editor 双模式,表单字段来自模型能力 schema。 +- 右侧展示 curl、JavaScript、Python 示例代码和实时响应。 +- 支持 stream 结果面板、任务进度时间线、结果预览、usage / billings。 +- 支持测试模式开关;开启后标记 `simulation`,不触达真实 provider、不真实扣费。 +- 调试前展示当前 API Key 的权限、余额、TPM/RPM/并发限制。 + +### 16.8 前端模块目录 ```text apps/web/src/ @@ -1476,19 +1978,36 @@ apps/web/src/ json-viewer/ status-badge/ features/ - dashboard/ - catalog/ - pricing/ - platforms/ + auth/ + login/ + register/ + home/ models/ - platform-apis/ - tasks/ - runtime/ - policies/ - simulation/ - uploads/ - settlements/ - settings/ + workspace/ + overview/ + billing/ + api-keys/ + tasks/ + admin/ + dashboard/ + catalog/ + tenants/ + users/ + user-groups/ + pricing/ + platforms/ + platform-apis/ + runtime/ + policies/ + tasks/ + callbacks/ + uploads/ + settlements/ + simulation/ + settings/ + docs/ + api-reference/ + playground/ lib/ api-client.ts auth.ts @@ -1498,12 +2017,13 @@ apps/web/src/ dto.ts # 或从 packages/contracts 引入 ``` -### 16.4 前端数据流 +### 16.9 前端数据流 - 使用 `@tanstack/react-query` 管理列表、详情、轮询和 mutation。 - 表格使用 `@tanstack/react-table`,配合 shadcn `Table`。 - 表单使用 `react-hook-form` + `zod`,校验策略与后端 DTO 对齐。 -- SSE / WebSocket 事件统一封装为 `useTaskEvents(taskId)`。 +- 用户工作台在独立模式下调用 Gateway 本地身份/账务模块;接入 `server-main` 时余额、充值、API Key 生命周期优先调用 `server-main` API,Gateway 展示执行侧任务、模型能力、用户同步状态和用户组执行策略。 +- Gateway 控制台事件统一封装为 `useTaskEvents(taskId)`;业务前端实时进度仍由原 WebSocket 网关推送。 - 所有 destructive action 使用 shadcn `Dialog` 二次确认。 - 页面级权限由路由 loader 或 wrapper 判断,不在每个按钮里重复散落判断。 @@ -1513,46 +2033,54 @@ apps/web/src/ - 建立 monorepo、Go API、React 控制台、PG migration。 - 前端接入 `shadcn-ui`,建立基础组件目录和主题 token。 -- PG 复用 Agent memory 的 `easyai-pgvector` 实例,但使用独立数据库 `easyai_ai_gateway`,避免与 `easyai_memory` 的记忆表混库。 +- PG 目标版本为 PostgreSQL 18;复用 Agent memory 的 `easyai-pgvector` 实例,但使用独立数据库 `easyai_ai_gateway`,避免与 `easyai_memory` 的记忆表混库。 - 容器网络内默认连接串为 `postgresql://easyai:easyai2025@easyai-pgvector:5432/easyai_ai_gateway?schema=public`;宿主机直跑时必须改成宿主机可访问的 host/port,例如 `localhost` 或实际映射端口。 - 如果只提供 `MEMORY_DATABASE_URL`,Go 侧只借用其中的 host/user/password,并按 `AI_GATEWAY_DATABASE_NAME` 替换数据库名;同时会把 Prisma 风格的 `schema=public` 转成 PostgreSQL `search_path` 参数。 -- 建立 JWT / API Key 授权中间件骨架。 +- 建立 JWT / API Key 授权中间件骨架和 `standalone` / `server-main` / `hybrid` 身份模式配置,默认 `hybrid`。 - 固化 API、事件、数据库设计。 - 建立 simulation / dry-run 模式配置与安全开关,默认禁止生产环境普通用户随意开启。 -- 建立基准 provider、基准模型库、价格规则和 TPM/RPM/并发限流表结构。 +- 建立基准 provider、基准模型库、价格规则、用户、用户组策略、邀请码、本地 API Key、钱包、充值订单和 TPM/RPM/并发限流表结构。 -### Phase 1:平台管理迁移 +### Phase 1:模型库 + 首批生成能力 -- 导入原项目模型能力、基准计费配置、provider 默认配置,形成 `base_model_catalog`。 -- 迁移 `integration-platform` CRUD、模型配置、权限过滤。 +第一阶段只做可落地的核心闭环:模型库、大模型对话、生图、图像编辑。Client 只迁移 OpenAI 和 Gemini 两个,视频和其他 provider 后移。 + +- 导入 OpenAI、Gemini 的基准 provider、基准模型、能力 schema、默认限流模板,形成首批 `base_model_catalog`。 +- 建立全局模型配置:模型类型、上下文、多模态能力、图片输入/输出能力、stream 支持、价格规则。 - 平台创建支持选择基准模型、设置默认折扣系数;平台模型支持 follow 基准模型、折扣继承和自定义覆盖。 -- 从 Mongo/Mongoose schema 映射到 PostgreSQL。 -- 提供与旧接口兼容的 DTO。 +- 建立 `gateway_users` 同步副本和 `gateway_user_groups` 策略解析,支持本地用户与 `server-main` 用户在同一套策略链路里命中不同折扣和限流。 +- 建立本地账号闭环:普通注册可选邀请码,独立模式支持本地 API Key、余额、充值订单和钱包流水。 +- 迁移大模型对话路由:`/chat/completions`、`/v1/chat/completions`,并为 `/responses` / `/v1/responses` 保留兼容契约。 +- 迁移生图和图像编辑路由:`/images/generations`、`/v1/images/generations`、`/images/edits`、`/v1/images/edits`。 +- 建立 OpenAI Chat / Image Client 与 Gemini Chat / Image Client 的 Base Client 实现和 contract test。 +- 支持文本输入/输出 token 计费,图像按分辨率、质量、数量、生成/编辑模式计费。 +- 实现调用 `server-main` 开放上传接口的 `ServerMainUploadClient`,覆盖参考图、mask、provider 临时 URL 转存、base64 小文件上传。 +- 实现 SimulationClient,支持 Chat、生图、图像编辑的成功、失败、慢任务、限流、未知提交态等 profile。 -### Phase 2:模型路由与客户端迁移 +### Phase 2:路由、队列与稳定性补强 -- 迁移 `IntegrationModelFactory` 行为。 +- 迁移 `IntegrationModelFactory.assignClientsByModelName` 行为。 - 建立 Base Client 生命周期:构建参数、提交任务、轮询结果、归一化结果、估算计费、取消任务。 - 实现客户端失败切换策略:上一个客户端失败后按配置切到下一个候选客户端。 -- 迁移 OpenAI-compatible client、RunningHub、Jimeng、Vidu 等高频 provider。 -- 为每个 provider 增加 contract test 和 snapshot。 - -### Phase 3:生成任务迁移 - -- Chat、生图、生视频优先。 -- 迁移参数预处理链,尤其多模态能力过滤。 - 补队列持久化、TPM/RPM/并发限流、重启恢复、重试、超时、任务事件。 -- 实现 SSE / WebSocket / OpenAI stream 进度流,替代原 RxJS Observable 的对外效果。 -- 实现调用 `server-main` 开放上传接口的 `ServerMainUploadClient`,覆盖远程 URL 转存、provider 临时 URL 转存、base64 小文件上传。 -- 实现 SimulationClient,支持成功、失败、慢任务、限流、未知提交态等 profile,验证全链路但不触达真实平台。 +- 实现任务进度 callback outbox,将 Gateway 进度回调给实时推送通道;同时保留 Gateway 控制台 SSE / WebSocket 和 OpenAI stream。 +- 为 OpenAI、Gemini 增加 retry classification test、billing snapshot、progress event snapshot。 -### Phase 4:server-main 切薄门面 +### Phase 3:server-main 切薄门面与灰度 -- `OpenaiService` 内部切到 Gateway HTTP SDK。 +- `OpenaiService` 内部先将 Chat、生图、图像编辑切到 Gateway HTTP SDK。 - 结算事件接入 `server-main` 扣费链路。 - 前端逐步改 `Gateway API Base URL`。 +- 开启 shadow / dry-run,比对旧实现和 Gateway 的候选模型、预估扣费、参数预处理结果。 -### Phase 5:清理旧实现 +### Phase 4:视频与更多 provider + +- 在 Phase 1 稳定后再迁移生视频、音频、Embedding、音乐、数字人等能力。 +- 迁移 RunningHub、Jimeng、Vidu、Kling、Hunyuan Video、Suno 等 provider。 +- 对 app-style provider 补 `assignClientsByProviderMethod` 和 `provider + methodName` 队列 key。 +- 每个 provider 增加 contract test、retry classification test、billing snapshot。 + +### Phase 5:server-main 清理旧实现 - 删除或冻结 `server-main` 中重复的 runtime client。 - 保留必要 BFF、历史、账单、文件上传能力。 @@ -1567,18 +2095,38 @@ apps/web/src/ - OpenAPI `sk-*` 能委托 `server-main` 校验并获得用户 claim。 - 原 `integration-platform`、站内生成、`/v1/*` 核心路由可用,DTO 与响应结构兼容。 - 基准 provider、基准模型库、基准价格和默认限流模板可在控制台维护。 +- 用户、用户组、成员关系、充值折扣、调用折扣、TPM/RPM/并发策略可在管理工作台维护;独立模式可本地闭环,接入模式下充值执行仍以 `server-main` 为事实源。 +- 接入 `server-main` 时,用户、角色、禁用状态和用户组成员关系支持增量同步;任务保存用户和用户组策略快照。 - 平台创建可设置默认折扣系数;平台模型未配置时 follow 基准模型,配置折扣时按基准价折扣计算,自定义覆盖时覆盖价格和能力。 - 文本定价支持输入/输出 token 分开计费;图像定价支持分辨率和质量权重;视频定价支持时长、分辨率、有无音频等权重。 - estimated billing 与真实任务 billings 使用同一个 effective pricing resolver,不能出现预估和真实路由价格来源不一致。 - 队列任务在服务异常重启后可恢复:已提交供应商的任务继续 poll,未提交任务重新排队。 -- 限流策略覆盖 TPM、RPM、并发,并支持平台、客户端、基准模型、平台模型、方法、租户、用户、API Key 维度。 +- 限流策略覆盖 TPM、RPM、并发,并支持平台、客户端、基准模型、平台模型、方法、租户、用户组、用户、API Key 维度。 - TPM/RPM 一分钟窗口和并发 lease 在服务重启后可恢复或释放,不产生永久占用。 - 多客户端候选下,一个客户端可重试失败后按策略切换下一个客户端;禁用重试时必须直接失败。 - Base Client 架构覆盖构建参数、提交任务、取回结果、归一化结果、计费估算、取消任务。 - 任务中间进度可通过 SSE / WebSocket / stream 返回,并支持断线重放。 +- 业务前端实时进度通过 `TASK_PROGRESS_CALLBACK_URL` 回调到 `server-main` 内部任务进度接口,再由主服务复用原 WebSocket 网关推送;callback outbox 支持失败重试和手动 replay。 - 文件上传统一调用 `server-main` 开放上传接口,Gateway 不维护自己的 OSS 配置。 - 测试模式下不会向真实平台提交任务、不会真实扣费、不会调用主服务生产上传接口,但会完整经过路由、队列、限流、重试、进度和结果归一。 - 测试模式支持 profile 注入可重试错误,能验证客户端失败切换;禁用重试时能验证直接失败。 -- Chat / 生图 / 生视频至少各完成一个 provider 的端到端任务。 +- Phase 1 至少完成 Chat / 生图 / 图像编辑各一个 provider 的端到端任务;生视频放到 Phase 4 后续迁移。 - 结算事件在 `server-main` 幂等扣费。 - 同名模型跨平台权限过滤与旧逻辑一致。 + +## 19. 本轮设计复核结论与已确认决策 + +本轮复核后已修正: + +- 身份边界:不再写成“用户/租户只能留在 `server-main`”,改为 `standalone`、`server-main`、`hybrid` 三种模式。 +- 多租户:补 `gateway_tenants`,并把用户、任务、平台可见性、限流和策略解析都接入租户上下文。 +- 登录注册:补 Gateway 本地账号注册登录接口和前端登录页规划。 +- 进度回调:统一表述为回调 `server-main` 内部任务进度接口,不直接回调 ws-gateway。 +- Phase 1 范围:继续限定为模型库、Chat、生图、图像编辑、OpenAI 和 Gemini;视频迁移放到后续阶段。 + +已确认决策: + +1. 默认身份模式就是 `hybrid`,用于同时支持本地账号和 `server-main` token / API Key。 +2. 普通注册支持填写邀请码,但邀请码不是必填项;填写后按邀请码加入指定租户和用户组。 +3. 先做本地闭环:独立模式下本地 API Key、余额、充值订单和钱包流水由 Gateway 承接。 +4. 当前阶段仅允许全局管理员配置 provider / platform 凭证和全局模型,不开放租户管理员自助配置凭证。 diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 529671c..ecb4301 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -1,48 +1,103 @@ # integration-platform 迁移实施计划 -## 第 1 周:基础设施 +## Phase 0:脚手架与基础契约 -- 在 Agent memory 的 `easyai-pgvector` 实例上建立独立数据库 `easyai_ai_gateway` 和 AI Gateway 表,不直接使用 `easyai_memory` 记忆库。正式 EasyAI compose 默认账号为 `easyai` / `easyai2025`。 -- 完成 JWT / API Key 授权验证。 -- 完成基准 provider、基准模型库、平台与模型管理 API。 -- 完成基准定价、平台默认折扣、平台模型覆盖的 schema。 -- React 控制台接入平台、基准模型、TPM/RPM 限流窗口列表。 +- 在 Agent memory 的 PostgreSQL 18 `easyai-pgvector` 实例上建立独立数据库 `easyai_ai_gateway` 和 AI Gateway 表,不直接使用 `easyai_memory` 记忆库。正式 EasyAI compose 默认账号为 `easyai` / `easyai2025`。 +- 完成 Go API、React 前端、Nx/go.work/pnpm monorepo、基础 migration。 +- 完成本地账号注册登录、可选邀请码、JWT / API Key 授权验证骨架,并将默认身份模式设为 `hybrid`。 +- 固化兼容路由、任务事件、队列、定价、限流、回调 outbox 的基础设计。 +- React 前端先具备登录页、首页、模型、用户工作台、管理工作台、API 文档的页面骨架。 -## 第 2 周:路由行为复刻 +## Phase 1:模型库 + 首批生成能力 + +第一阶段只做可落地的核心闭环:**模型库、大模型对话、文本生图、图像编辑**。Client 只迁移 **OpenAI** 和 **Gemini** 两个,暂不迁移视频和其他 provider。 + +### 1.1 模型库 + +- 建立 `model_catalog_providers`、`base_model_catalog`、`model_pricing_rules` 的首批数据。 +- 建立 `gateway_tenants`、`gateway_users`、`gateway_user_groups`、`gateway_tenant_invitations` 和用户组策略,支持独立租户/用户、可选邀请码注册、`server-main` 同步租户/用户、不同用户组的充值折扣、调用折扣和并发/限流策略。 +- 建立独立模式本地闭环表:`gateway_api_keys`、`gateway_wallet_accounts`、`gateway_wallet_transactions`、`gateway_recharge_orders`。 +- 导入 OpenAI、Gemini 的基准 provider、基准模型、能力 schema、默认限流模板。 +- 支持全局模型配置:模型类型、上下文、多模态能力、图片输入/输出能力、stream 支持、价格规则。 +- 支持平台模型 follow 基准模型、平台折扣、模型级自定义价格和能力覆盖。 +- 管理工作台可查看和编辑基准模型、平台模型、定价规则、默认限流。 + +### 1.2 大模型对话 + +- 迁移兼容路由: + - `/chat/completions` + - `/v1/chat/completions` + - `/responses` / `/v1/responses` 可先保留契约,按 OpenAI/Gemini Chat 能力逐步补齐。 +- 支持同步响应和 stream 响应。 +- 支持文本、多模态图片输入的参数归一与能力校验。 +- 支持 TPM/RPM/并发限流、失败切换、任务事件、usage / billings 回填。 +- 先完成 OpenAI Chat Client 和 Gemini Chat Client 的 contract test。 + +### 1.3 生图与图像编辑 + +- 迁移兼容路由: + - `/images/generations` + - `/v1/images/generations` + - `/images/edits` + - `/v1/images/edits` +- 支持 prompt、参考图、mask / edit 输入、尺寸、质量、数量等参数归一。 +- 文件输入与结果转存统一调用 `server-main` `/v1/files/upload`。 +- 支持图像价格预估:分辨率、质量、数量、生成/编辑模式权重。 +- 先完成 OpenAI Image Client 和 Gemini Image Client 的 contract test。 + +### 1.4 第一阶段验收 + +- 模型库中能维护 OpenAI、Gemini 的基准模型、能力和价格。 +- 管理员能配置平台、平台模型、折扣、限流和重试策略。 +- 管理员能配置租户、用户、用户组、成员关系、充值折扣、调用折扣、TPM/RPM/并发策略。 +- 普通用户能在模型页看到可用 Chat / 生图 / 图像编辑模型。 +- API 文档能在线测试 Chat、生图、图像编辑。 +- OpenAI 与 Gemini 的 Chat、生图、图像编辑至少各跑通一个端到端用例。 +- estimated billing 与真实 billings 使用同一个 effective pricing resolver。 +- 测试模式可模拟 Chat、生图、图像编辑的成功、可重试失败、不可重试失败。 +- 任务进度写入 `gateway_task_events` 和 callback outbox,失败可重试和 replay。 + +## Phase 2:路由、队列与稳定性补强 - 从旧代码抽取以下行为测试: - 同名模型平台权限过滤。 - `assignClientsByModelName` 候选排序。 - - `assignClientsByProviderMethod` provider-level 负载均衡。 - estimated billing 使用真实候选集。 - 建立 TPM/RPM/并发限流 fixtures,覆盖预占、释放、失败切换重新计数。 - Go 侧实现 router,并用 fixtures 对齐旧行为。 +- 补齐持久化队列、任务租约、heartbeat、重启恢复、attempt 审计。 +- 补齐租户、用户和用户组策略解析:`source + externalTenantId` 租户同步、`source + externalUserId` 用户同步、多组命中、优先级、策略合并、任务策略快照。 +- 补齐 callback outbox、settlement outbox 的重试、死信和手动 replay。 -## 第 3 周:核心 provider +## Phase 3:server-main 薄门面与灰度 -- 先迁 OpenAI-compatible / Universal。 -- 再迁生图、生视频主 provider。 -- 每个 provider 建 contract test。 +- `server-main` 的 Chat、生图、图像编辑入口内部切到 Gateway HTTP SDK。 +- 开启 shadow / dry-run,比对旧实现和 Gateway 的候选模型、预估扣费、参数预处理结果。 +- 前端逐步增加 `VITE_GATEWAY_API_BASE_URL`,灰度切流核心接口。 +- 观察任务成功率、平均排队时间、限流命中、扣费一致性、回调 outbox 滞留。 -## 第 4 周:任务链路 +## Phase 4:视频与更多 provider -- 实现队列、任务状态、SSE 进度。 -- 实现 TPM/RPM 一分钟窗口计数和并发 lease 恢复。 -- 打通 Chat、生图、生视频端到端。 -- 生成结算事件,接入 server-main 幂等扣费。 +- 在 Phase 1 稳定后再迁移生视频、音频、Embedding、音乐、数字人等能力。 +- 迁移 RunningHub、Jimeng、Vidu、Kling、Hunyuan Video、Suno 等 provider。 +- 对 app-style provider 补 `assignClientsByProviderMethod` 和 `provider + methodName` 队列 key。 +- 每个 provider 增加 contract test、retry classification test、billing snapshot。 -## 第 5 周:切流 +## Phase 5:清理旧实现 -- server-main `OpenaiService` 加 Gateway client。 -- 开启 shadow / dry-run 比对。 -- 前端增加 `VITE_GATEWAY_API_BASE_URL`。 -- 灰度切流,观察任务成功率、平均排队、扣费一致性。 +- 删除或冻结 `server-main` 中重复的 runtime client。 +- 保留必要 BFF、用户历史、账单、文件上传能力。 +- 将旧 `integration-platform` 配置迁移脚本和回滚脚本固化。 ## 风险控制 +- 第一阶段不做视频和大量 provider,避免迁移面过宽。 - 不做 first-match 回退,所有候选选择都要有行为测试。 -- API Key 不在 Gateway 落库。 +- 接入 `server-main` 模式下 API Key 不在 Gateway 落库;独立模式的本地 API Key、余额、充值订单和钱包流水在 Gateway 闭环。 - OSS 密钥不进入 Gateway;文件统一调用 server-main 开放上传接口。 +- 租户、用户和用户组可由 Gateway 管理或从 server-main 同步;接入模式下充值执行、余额流水仍以 server-main 为事实源。 +- 平台凭证和 provider 凭证当前阶段只允许全局管理员配置,不开放租户管理员自助维护。 +- 业务前端实时进度仍走现有 WebSocket 网关;Gateway 只负责事件与回调 outbox。 - 平台模型没有自定义价格时必须 follow 基准模型,不能隐式按 0 计费。 - estimated billing 与真实结算必须使用同一个 effective pricing resolver。 - 结算事件必须幂等和可重试。 diff --git a/docs/server-main-integration.md b/docs/server-main-integration.md index 2acc3f8..b7b840f 100644 --- a/docs/server-main-integration.md +++ b/docs/server-main-integration.md @@ -19,7 +19,14 @@ Content-Type: application/json "sub": "user-id", "username": "demo", "role": ["user"], - "tenantId": null, + "tenantId": "tenant-id", + "gatewayTenantId": "optional-gateway-tenant-id", + "tenantKey": "team-a", + "source": "server-main", + "gatewayUserId": "optional-gateway-user-id", + "userGroupId": "optional-primary-group-id", + "userGroupKey": "pro", + "userGroupKeys": ["pro", "image-plus"], "apiKeyId": "key-id", "apiKeySecret": "sk-...", "apiKeyName": "production-key" @@ -38,7 +45,180 @@ file=@result.png AI Gateway 不维护独立 OSS 配置,也不向 `server-main` 申请预签名。需要上传本地中间产物、provider 临时 URL 转存、base64 解码结果时,统一组装 multipart 请求调用主服务开放上传接口,并记录主服务返回的 file id / URL / object key。 -### 1.3 结算事件 +### 1.3 租户同步 + +AI Gateway 在独立模式下自己维护租户;接入 `server-main` 时保存主服务租户/组织同步副本,用于任务隔离、平台可见性、租户级限流和审计。 + +建议新增同步接口: + +```http +POST /internal/platform/tenants/sync +Authorization: Bearer ${SERVER_MAIN_INTERNAL_TOKEN} +Content-Type: application/json +Idempotency-Key: tenant:${source}:${externalTenantId}:${version} +``` + +请求体: + +```json +{ + "source": "server-main", + "externalTenantId": "tenant-id", + "tenantKey": "team-a", + "name": "Team A", + "status": "active", + "planKey": "pro", + "rateLimitPolicy": { + "rules": [ + { "metric": "rpm", "limit": 500, "windowSeconds": 60 }, + { "metric": "concurrent", "limit": 20, "leaseTtlSeconds": 900 } + ] + }, + "sourceUpdatedAt": "2026-05-09T12:00:00Z" +} +``` + +要求: + +- Gateway 使用 `source + externalTenantId` 幂等 upsert 到 `gateway_tenants`。 +- 租户禁用后,新任务拒绝入队;已运行任务按任务策略快照继续或由管理员取消。 +- 用户同步必须带可映射的 `tenantId` / `tenantKey`,使任务、用户、用户组、限流和平台可见性都能落到同一租户上下文。 + +### 1.4 用户同步 + +AI Gateway 需要在独立模式下自己维护用户,在接入 `server-main` 时保存主服务用户的同步副本。同步副本只用于模型调用策略、审计、任务归属和用户组解析,不承接主服务余额、订单、充值流水。 + +建议新增同步接口: + +```http +POST /internal/platform/users/sync +Authorization: Bearer ${SERVER_MAIN_INTERNAL_TOKEN} +Content-Type: application/json +Idempotency-Key: user:${source}:${externalUserId}:${version} +``` + +请求体: + +```json +{ + "source": "server-main", + "externalUserId": "user-id", + "username": "demo", + "displayName": "Demo User", + "email": "demo@example.com", + "tenantId": "tenant-id", + "tenantKey": "team-a", + "roles": ["user"], + "status": "active", + "sourceUpdatedAt": "2026-05-09T12:00:00Z", + "userGroupKeys": ["pro", "image-plus"] +} +``` + +要求: + +- Gateway 使用 `source + externalUserId` 幂等 upsert 到 `gateway_users`。 +- `status=disabled/locked/deleted` 后,Gateway 应拒绝创建新任务;已运行任务按任务策略快照继续或由管理员取消。 +- 用户角色以主服务返回为准,但 Gateway 可以叠加本地管理角色,二者需要在 `auth_profile` 或 `metadata` 里可审计。 +- 用户组关系可以随用户同步一起带,也可以通过用户组同步接口单独维护,最终都落到 `gateway_user_group_memberships`。 + +### 1.5 用户组与折扣策略同步 + +用户组是跨服务策略:Gateway 需要按用户组执行模型调用折扣、TPM/RPM/并发、队列优先级;`server-main` 需要按用户组执行充值折扣、资源包赠送、余额流水。两边必须保持同一个 `groupKey`。 + +建议新增同步接口: + +```http +POST /internal/platform/user-groups/sync +Authorization: Bearer ${SERVER_MAIN_INTERNAL_TOKEN} +Content-Type: application/json +Idempotency-Key: ${groupKey}:${version} +``` + +请求体: + +```json +{ + "groupKey": "enterprise", + "name": "企业组", + "rechargeDiscountPolicy": { + "type": "tiered_bonus", + "tiers": [{ "minAmount": 1000, "bonusRatio": 0.12 }] + }, + "billingDiscountPolicy": { + "defaultDiscountFactor": 0.9 + }, + "rateLimitPolicy": { + "rules": [ + { "metric": "rpm", "limit": 1200, "windowSeconds": 60 }, + { "metric": "concurrent", "limit": 50, "leaseTtlSeconds": 900 } + ] + }, + "memberships": [ + { "principalType": "user", "principalId": "user-id" }, + { "principalType": "tenant", "principalId": "tenant-id" } + ] +} +``` + +要求: + +- `server-main` 是充值、余额和订单事实源,负责执行 `rechargeDiscountPolicy`。 +- Gateway 是模型执行事实源,负责执行 `billingDiscountPolicy`、`rateLimitPolicy`、队列和并发策略。 +- 用户登录 / API Key 校验返回 claim 时,建议带上命中的 `userGroupKey` / `userGroupId`;Gateway 也可以根据同步缓存二次解析。 + +### 1.6 任务进度回调到 server-main + +AI Gateway 不直接替换原业务前端 WebSocket 通道。Gateway 配置任务进度回调地址,所有任务中间状态先写入 Gateway 本地事件表和 callback outbox,再回调给 `server-main`,由 `server-main` 内部推送流程复用原 WebSocket 网关推送给业务前端。 + +```http +POST /internal/platform/task-progress-callbacks +Authorization: Bearer ${SERVER_MAIN_INTERNAL_TOKEN} +Content-Type: application/json +Idempotency-Key: ${taskId}:${seq} +X-EasyAI-Event-Type: task.progress +``` + +请求体: + +```json +{ + "eventId": "uuid", + "taskId": "gateway-task-id", + "externalTaskId": "server-main-task-id", + "userId": "user-id", + "tenantId": "tenant-id", + "apiKeyId": "optional", + "kind": "images.generations", + "model": "gpt-image-1", + "seq": 12, + "event": "progress", + "status": "running", + "phase": "polling", + "progress": 0.42, + "message": "Generating video frames", + "payload": {}, + "createdAt": "2026-05-09T12:00:00Z" +} +``` + +`server-main` 处理要求: + +- 使用 `Idempotency-Key` 或 `taskId + seq` 幂等去重。 +- 根据 `externalTaskId` / `taskId` / `userId` / `tenantId` 定位原业务频道。 +- 复用现有 WebSocket 网关事件格式推给前端,尽量不改业务前端订阅协议。 +- 只负责推送与必要状态同步,不重新执行任务、不重新计算计费。 + +Gateway 侧配置: + +```env +TASK_PROGRESS_CALLBACK_ENABLED=true +TASK_PROGRESS_CALLBACK_URL=http://easyai-server-main:3000/internal/platform/task-progress-callbacks +TASK_PROGRESS_CALLBACK_TIMEOUT_MS=5000 +TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS=10 +``` + +### 1.7 结算事件 ```http POST /internal/platform/settlements @@ -66,6 +246,7 @@ Idempotency-Key: ${eventId} AI_GATEWAY_ENABLED=true AI_GATEWAY_BASE_URL=http://easyai-ai-gateway:8088 AI_GATEWAY_INTERNAL_TOKEN=change-me +AI_GATEWAY_TASK_PROGRESS_CALLBACK_ENABLED=true ``` ## 3. 迁移期双写与比对 @@ -81,7 +262,7 @@ AI_GATEWAY_INTERNAL_TOKEN=change-me - `refresh_token` 签发和刷新。 - 用户余额查询。 -- 用户 API Key 的创建、撤销、列表。 -- 账单锁、扣费流水。 +- `server-main` 用户 API Key 的创建、撤销、列表。Gateway 独立模式会维护自己的本地 API Key。 +- `server-main` 账单锁、扣费流水。Gateway 独立模式会维护自己的钱包账户、充值订单和钱包流水。 - OSS/COS/S3 上传配置和实际文件落库。 - 对话与绘图历史最终落库。 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 956c854..793f0b6 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,12 +5,57 @@ export interface AuthUser { username: string; role?: string[]; tenantId?: string | null; + gatewayTenantId?: string; + tenantKey?: string; sso_id?: string; + source?: 'gateway' | 'server-main' | 'sync' | string; + gatewayUserId?: string; + userGroupId?: string; + userGroupKey?: string; + userGroupKeys?: string[]; apiKeyId?: string; apiKeySecret?: string; apiKeyName?: string; } +export interface AuthResponse { + accessToken: string; + tokenType: 'Bearer'; + expiresIn: number; + user: AuthUser; +} + +export interface LocalRegisterRequest { + username: string; + email?: string; + password: string; + displayName?: string; + tenantKey?: string; + tenantName?: string; + invitationCode?: string; +} + +export interface LocalLoginRequest { + account: string; + password: string; +} + +export interface GatewayInvitation { + id: string; + tenantId: string; + inviteCode: string; + role: 'user' | 'creator' | 'operator' | 'admin' | string; + userGroupId?: string; + maxUses?: number; + usedCount: number; + expiresAt?: string; + status: 'active' | 'disabled' | 'expired' | string; + metadata?: Record; + createdBy?: string; + createdAt: string; + updatedAt: string; +} + export interface IntegrationPlatform { id: string; provider: string; @@ -89,6 +134,156 @@ export interface PricingRule { updatedAt: string; } +export interface GatewayUser { + id: string; + userKey: string; + source: 'gateway' | 'server-main' | 'sync' | string; + externalUserId?: string; + username: string; + displayName?: string; + email?: string; + phone?: string; + avatarUrl?: string; + gatewayTenantId?: string; + tenantId?: string; + tenantKey?: string; + defaultUserGroupId?: string; + roles?: string[]; + authProfile?: Record; + metadata?: Record; + status: 'active' | 'disabled' | 'locked' | 'deleted' | string; + lastLoginAt?: string; + syncedAt?: string; + sourceUpdatedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface GatewayTenant { + id: string; + tenantKey: string; + source: 'gateway' | 'server-main' | 'sync' | string; + externalTenantId?: string; + name: string; + description?: string; + defaultUserGroupId?: string; + planKey?: string; + billingProfile?: Record; + rateLimitPolicy?: RateLimitPolicy; + authPolicy?: Record; + metadata?: Record; + status: 'active' | 'disabled' | 'locked' | 'deleted' | string; + syncedAt?: string; + sourceUpdatedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface UserGroup { + id: string; + groupKey: string; + name: string; + description?: string; + source: 'gateway' | 'server-main' | 'sync' | string; + priority: number; + rechargeDiscountPolicy?: Record; + billingDiscountPolicy?: Record; + rateLimitPolicy?: RateLimitPolicy; + quotaPolicy?: Record; + status: 'active' | 'disabled' | string; + createdAt: string; + updatedAt: string; +} + +export interface UserGroupMembership { + id: string; + groupId: string; + principalType: 'user' | 'tenant' | 'api_key' | 'organization' | string; + principalId: string; + source: 'gateway' | 'server-main' | 'sync' | string; + priority: number; + effectiveFrom?: string; + effectiveTo?: string; + status: 'active' | 'disabled' | string; + createdAt: string; + updatedAt: string; +} + +export interface GatewayApiKey { + id: string; + gatewayTenantId?: string; + gatewayUserId: string; + tenantId?: string; + tenantKey?: string; + userId?: string; + keyPrefix: string; + name: string; + scopes?: string[]; + userGroupId?: string; + rateLimitPolicy?: RateLimitPolicy; + quotaPolicy?: Record; + status: 'active' | 'disabled' | 'revoked' | string; + expiresAt?: string; + lastUsedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface GatewayWalletAccount { + id: string; + gatewayTenantId?: string; + gatewayUserId: string; + tenantId?: string; + tenantKey?: string; + userId?: string; + currency: 'resource' | 'credit' | 'cny' | 'usd' | string; + balance: number; + frozenBalance: number; + totalRecharged: number; + totalSpent: number; + status: 'active' | 'frozen' | 'disabled' | string; + createdAt: string; + updatedAt: string; +} + +export interface GatewayWalletTransaction { + id: string; + accountId: string; + gatewayTenantId?: string; + gatewayUserId?: string; + direction: 'credit' | 'debit' | 'freeze' | 'unfreeze' | string; + transactionType: 'recharge' | 'billing' | 'refund' | 'adjustment' | string; + amount: number; + balanceBefore: number; + balanceAfter: number; + idempotencyKey?: string; + referenceType?: string; + referenceId?: string; + metadata?: Record; + createdAt: string; +} + +export interface GatewayRechargeOrder { + id: string; + gatewayTenantId?: string; + gatewayUserId: string; + tenantId?: string; + tenantKey?: string; + userId?: string; + amount: number; + bonusAmount: number; + payableAmount: number; + currency: 'resource' | 'credit' | 'cny' | 'usd' | string; + channel: 'manual' | 'wechat' | 'alipay' | 'stripe' | 'paypal' | string; + status: 'created' | 'pending' | 'paid' | 'closed' | 'failed' | string; + externalOrderId?: string; + idempotencyKey?: string; + paidAt?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + export interface RateLimitRule { metric: RateLimitMetric; limit: number; @@ -147,7 +342,14 @@ export interface GatewayTask { id: string; kind: string; userId: string; + gatewayUserId?: string; + userSource?: 'gateway' | 'server-main' | 'sync' | string; + gatewayTenantId?: string; tenantId?: string; + tenantKey?: string; + userGroupId?: string; + userGroupKey?: string; + userGroupPolicySnapshot?: Record; model: string; request?: Record; status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string; diff --git a/scripts/create-database.sh b/scripts/create-database.sh index 3a33ba6..6dffcec 100755 --- a/scripts/create-database.sh +++ b/scripts/create-database.sh @@ -5,6 +5,16 @@ CONTAINER="${AI_GATEWAY_PG_CONTAINER:-easyai-pgvector}" PGUSER="${AI_GATEWAY_PG_USER:-easyai}" DB_NAME="${AI_GATEWAY_DATABASE_NAME:-easyai_ai_gateway}" +version_num="$( + docker exec "$CONTAINER" \ + psql -U "$PGUSER" -d postgres -tAc "SHOW server_version_num" \ + | tr -d '[:space:]' +)" + +if [[ "${version_num:-0}" -lt 180000 ]]; then + echo "[ai-gateway] warning: PostgreSQL 18 is expected, current server_version_num=${version_num}" +fi + exists="$( docker exec "$CONTAINER" \ psql -U "$PGUSER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" \