feat: scaffold ai gateway identity and design

This commit is contained in:
wangbo 2026-05-09 16:01:32 +08:00
parent 6323e70e49
commit 5b20f017eb
18 changed files with 3043 additions and 372 deletions

View File

@ -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

View File

@ -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 + TSXUI 体系按 `shadcn-ui` / Radix / Tailwind 方向沉淀,先提供运维控制台骨架。
- MonorepoNx 负责任务编排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)。

View File

@ -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
)

30
apps/api/go.sum Normal file
View File

@ -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=

View File

@ -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
}

View File

@ -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")),
}
}

View File

@ -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 {

View File

@ -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")))

View File

@ -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,
&quotaPolicy,
&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
}

View File

@ -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,

View File

@ -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<AuthMode>('login');
const [loginForm, setLoginForm] = useState({ account: '', password: '' });
const [registerForm, setRegisterForm] = useState({
username: '',
email: '',
password: '',
displayName: '',
tenantKey: '',
tenantName: '',
invitationCode: '',
});
const [health, setHealth] = useState<HealthResponse | null>(null);
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
const [models, setModels] = useState<PlatformModel[]>([]);
@ -29,6 +107,9 @@ export function App() {
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
const [tenants, setTenants] = useState<GatewayTenant[]>([]);
const [users, setUsers] = useState<GatewayUser[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const [state, setState] = useState<LoadState>('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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main className="page">
<header className="topbar">
@ -92,30 +238,284 @@ export function App() {
<p className="eyebrow">EasyAI</p>
<h1>AI Gateway Console</h1>
</div>
<div className="health" data-ok={health?.ok === true}>
<span />
{health?.service ?? 'API 未连接'}
<div className="topbarActions">
<div className="health" data-ok={health?.ok === true}>
<span />
{health?.identityMode ? `${health.service} · ${health.identityMode}` : health?.service ?? 'API 未连接'}
</div>
{token && (
<button type="button" className="ghostButton" onClick={signOut}>
退
</button>
)}
</div>
</header>
<section className="toolbar" aria-label="授权与刷新">
<label className="tokenField">
<span>Server Main JWT</span>
<input
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="粘贴 server-main access_token"
{!token ? (
<AuthPanel
authMode={authMode}
externalToken={externalToken}
loginForm={loginForm}
registerForm={registerForm}
state={state}
onAuthModeChange={setAuthMode}
onExternalTokenChange={setExternalToken}
onLoginChange={setLoginForm}
onRegisterChange={setRegisterForm}
onSubmitExternalToken={submitExternalToken}
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
/>
) : (
<>
<section className="toolbar" aria-label="授权与刷新">
<label className="tokenField">
<span>Access Token</span>
<input value={token} onChange={(event) => setToken(event.target.value)} />
</label>
<button type="button" onClick={() => refresh()} disabled={!token || state === 'loading'}>
{state === 'loading' ? '加载中' : '刷新'}
</button>
</section>
<Dashboard
baseModels={baseModels}
models={models}
platforms={platforms}
rateLimitWindows={rateLimitWindows}
stats={stats}
/>
</label>
<button type="button" onClick={refresh} disabled={!token || state === 'loading'}>
{state === 'loading' ? '加载中' : '刷新'}
</button>
</section>
</>
)}
{error && <div className="notice">{error}</div>}
</main>
);
}
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<HTMLFormElement>) => void;
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<section className="authShell" aria-label="登录">
<div className="authPanel">
<div className="authHeader">
<p className="eyebrow">Gateway Identity</p>
<h2> AI Gateway</h2>
</div>
<div className="segmented" role="tablist">
{[
['login', '账号登录'],
['register', '注册账号'],
['external', '外部 Token'],
].map(([value, label]) => (
<button
type="button"
className="segmentButton"
data-active={props.authMode === value}
key={value}
onClick={() => props.onAuthModeChange(value as AuthMode)}
>
{label}
</button>
))}
</div>
{props.authMode === 'login' && (
<form className="authForm" onSubmit={props.onSubmitLogin}>
<label>
<span></span>
<input
autoComplete="username"
value={props.loginForm.account}
onChange={(event) => props.onLoginChange({ ...props.loginForm, account: event.target.value })}
placeholder="用户名或邮箱"
/>
</label>
<label>
<span></span>
<input
autoComplete="current-password"
type="password"
value={props.loginForm.password}
onChange={(event) => props.onLoginChange({ ...props.loginForm, password: event.target.value })}
placeholder="至少 8 位"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '登录中' : '登录'}
</button>
</form>
)}
{props.authMode === 'register' && (
<form className="authForm twoColumn" onSubmit={props.onSubmitRegister}>
<label>
<span></span>
<input
autoComplete="username"
value={props.registerForm.username}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, username: event.target.value })}
placeholder="demo"
/>
</label>
<label>
<span></span>
<input
autoComplete="email"
type="email"
value={props.registerForm.email}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, email: event.target.value })}
placeholder="demo@example.com"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.displayName}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, displayName: event.target.value })}
placeholder="Demo User"
/>
</label>
<label>
<span></span>
<input
autoComplete="new-password"
type="password"
value={props.registerForm.password}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, password: event.target.value })}
placeholder="至少 8 位"
/>
</label>
<label>
<span> Key</span>
<input
value={props.registerForm.tenantKey}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, tenantKey: event.target.value })}
placeholder="team-a"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.tenantName}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, tenantName: event.target.value })}
placeholder="Team A"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.invitationCode}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, invitationCode: event.target.value })}
placeholder="可选"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '注册中' : '注册并登录'}
</button>
</form>
)}
{props.authMode === 'external' && (
<form className="authForm" onSubmit={props.onSubmitExternalToken}>
<label>
<span>Access Token</span>
<input
value={props.externalToken}
onChange={(event) => props.onExternalTokenChange(event.target.value)}
placeholder="粘贴 server-main access_token"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '验证中' : '进入控制台'}
</button>
</form>
)}
</div>
</section>
);
}
function Dashboard(props: {
baseModels: BaseModelCatalogItem[];
models: PlatformModel[];
platforms: IntegrationPlatform[];
rateLimitWindows: RateLimitWindow[];
stats: Array<{ label: string; value: number; tone: string }>;
}) {
return (
<>
<section className="moduleBand" aria-label="一级页面">
<div className="sectionHeader">
<div>
<p className="eyebrow">Navigation</p>
<h2></h2>
</div>
<span>5 </span>
</div>
<div className="moduleGrid">
{primaryModules.map((item) => (
<article className="moduleCard" key={item.path}>
<div className="moduleCardTop">
<h3>{item.title}</h3>
<span>{item.path}</span>
</div>
<p>{item.description}</p>
<div className="moduleTags">
{item.items.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</article>
))}
</div>
</section>
<section className="moduleBand" aria-label="工作台与文档">
<div className="sectionHeader">
<div>
<p className="eyebrow">Workspace</p>
<h2> API </h2>
</div>
<span></span>
</div>
<div className="detailGrid">
<ModuleList title="用户工作台" items={workspacePages} />
<ModuleList title="管理工作台" items={adminPages} />
<ModuleList title="API 文档" items={apiDocPages} />
</div>
</section>
<section className="metrics" aria-label="概览">
{stats.map((item) => (
{props.stats.map((item) => (
<div className="metric" data-tone={item.tone} key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
@ -124,104 +524,80 @@ export function App() {
</section>
<section className="split">
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{platforms.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
<span>Provider</span>
<span></span>
<span></span>
<span></span>
</div>
{platforms.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.provider}</span>
<span>{item.name}</span>
<span>{item.status}</span>
<span>{item.priority}</span>
</div>
))}
{!platforms.length && <p className="empty"></p>}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{models.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{models.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.modelName}</span>
<span>{item.modelType}</span>
<span>{item.provider ?? item.platformName}</span>
<span>{item.enabled ? '是' : '否'}</span>
</div>
))}
{!models.length && <p className="empty"></p>}
</div>
</div>
<DataPanel
columns={['Provider', '名称', '状态', '优先级']}
empty="暂无平台数据"
rows={props.platforms.map((item) => [item.provider, item.name, item.status, String(item.priority)])}
title="平台"
/>
<DataPanel
columns={['模型', '类型', '平台', '启用']}
empty="暂无模型数据"
rows={props.models.map((item) => [item.modelName, item.modelType, item.provider ?? item.platformName ?? '-', item.enabled ? '是' : '否'])}
title="模型"
/>
</section>
<section className="split secondary">
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{baseModels.length}</span>
</div>
<div className="table catalogTable" role="table">
<div className="row head" role="row">
<span>Provider</span>
<span></span>
<span></span>
<span></span>
</div>
{baseModels.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.providerKey}</span>
<span>{item.canonicalModelKey}</span>
<span>{item.modelType}</span>
<span>{item.pricingVersion}</span>
</div>
))}
{!baseModels.length && <p className="empty"></p>}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2>TPM/RPM </h2>
<span>{rateLimitWindows.length}</span>
</div>
<div className="table rateTable" role="table">
<div className="row head" role="row">
<span>Scope</span>
<span></span>
<span>使</span>
<span></span>
</div>
{rateLimitWindows.map((item) => (
<div className="row" role="row" key={`${item.scopeType}:${item.scopeKey}:${item.metric}:${item.windowStart}`}>
<span>{item.scopeKey}</span>
<span>{item.metric}</span>
<span>{item.usedValue}/{item.limitValue}</span>
<span>{item.reservedValue}</span>
</div>
))}
{!rateLimitWindows.length && <p className="empty"></p>}
</div>
</div>
<DataPanel
columns={['Provider', '模型', '类型', '版本']}
empty="暂无基准模型"
rows={props.baseModels.map((item) => [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])}
title="基准模型库"
/>
<DataPanel
columns={['Scope', '指标', '使用', '预占']}
empty="暂无限流窗口"
rows={props.rateLimitWindows.map((item) => [item.scopeKey, item.metric, `${item.usedValue}/${item.limitValue}`, String(item.reservedValue)])}
title="TPM/RPM 窗口"
/>
</section>
</main>
</>
);
}
function DataPanel(props: { columns: string[]; empty: string; rows: string[][]; title: string }) {
return (
<div className="panel">
<div className="panelHeader">
<h2>{props.title}</h2>
<span>{props.rows.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
{props.columns.map((column) => (
<span key={column}>{column}</span>
))}
</div>
{props.rows.map((row, index) => (
<div className="row" role="row" key={`${props.title}-${index}`}>
{row.map((cell, cellIndex) => (
<span key={`${props.title}-${index}-${cellIndex}`}>{cell}</span>
))}
</div>
))}
{!props.rows.length && <p className="empty">{props.empty}</p>}
</div>
</div>
);
}
function ModuleList(props: {
title: string;
items: Array<{ title: string; path: string; description: string }>;
}) {
return (
<div className="moduleList">
<h3>{props.title}</h3>
{props.items.map((item) => (
<div className="moduleRow" key={item.path}>
<div>
<strong>{item.title}</strong>
<p>{item.description}</p>
</div>
<span>{item.path}</span>
</div>
))}
</div>
);
}

View File

@ -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<HealthResponse> {
return request<HealthResponse>('/healthz', { auth: false });
}
export async function registerLocalAccount(input: {
username: string;
email?: string;
password: string;
displayName?: string;
tenantKey?: string;
tenantName?: string;
invitationCode?: string;
}): Promise<AuthResponse> {
return request<AuthResponse>('/api/v1/auth/register', {
auth: false,
body: input,
method: 'POST',
});
}
export async function loginLocalAccount(input: { account: string; password: string }): Promise<AuthResponse> {
return request<AuthResponse>('/api/v1/auth/login', {
auth: false,
body: input,
method: 'POST',
});
}
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
return request<ListResponse<IntegrationPlatform>>('/api/v1/platforms', { token });
}
@ -40,17 +69,37 @@ export async function listPricingRules(token: string): Promise<ListResponse<Pric
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
}
export async function listTenants(token: string): Promise<ListResponse<GatewayTenant>> {
return request<ListResponse<GatewayTenant>>('/api/v1/tenants', { token });
}
export async function listUsers(token: string): Promise<ListResponse<GatewayUser>> {
return request<ListResponse<GatewayUser>>('/api/v1/users', { token });
}
export async function listUserGroups(token: string): Promise<ListResponse<UserGroup>> {
return request<ListResponse<UserGroup>>('/api/v1/user-groups', { token });
}
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
return request<ListResponse<RateLimitWindow>>('/api/v1/runtime/rate-limit-windows', { token });
}
async function request<T>(path: string, options: { token?: string; auth?: boolean } = {}): Promise<T> {
async function request<T>(
path: string,
options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {},
): Promise<T> {
const headers: Record<string, string> = {};
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();

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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 3server-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。
- 结算事件必须幂等和可重试。

View File

@ -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 上传配置和实际文件落库。
- 对话与绘图历史最终落库。

View File

@ -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<string, unknown>;
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<string, unknown>;
metadata?: Record<string, unknown>;
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<string, unknown>;
rateLimitPolicy?: RateLimitPolicy;
authPolicy?: Record<string, unknown>;
metadata?: Record<string, unknown>;
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<string, unknown>;
billingDiscountPolicy?: Record<string, unknown>;
rateLimitPolicy?: RateLimitPolicy;
quotaPolicy?: Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
model: string;
request?: Record<string, unknown>;
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string;

View File

@ -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}'" \