feat: scaffold ai gateway identity and design
This commit is contained in:
parent
6323e70e49
commit
5b20f017eb
17
.env.example
17
.env.example
@ -1,7 +1,7 @@
|
|||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
HTTP_ADDR=:8088
|
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.
|
# database. When running from the host, use the externally reachable host/port.
|
||||||
AI_GATEWAY_DATABASE_NAME=easyai_ai_gateway
|
AI_GATEWAY_DATABASE_NAME=easyai_ai_gateway
|
||||||
AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable
|
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.
|
# Keep this aligned with easyai-server-main CONFIG_JWT_SECRET in the first migration phase.
|
||||||
CONFIG_JWT_SECRET=this is a very secret secret
|
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_BASE_URL=http://localhost:3000
|
||||||
SERVER_MAIN_INTERNAL_TOKEN=change-me
|
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
|
CORS_ALLOWED_ORIGIN=http://localhost:5178
|
||||||
VITE_GATEWAY_API_BASE_URL=http://localhost:8088
|
VITE_GATEWAY_API_BASE_URL=http://localhost:8088
|
||||||
|
|||||||
13
README.md
13
README.md
@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
## 技术选型
|
## 技术选型
|
||||||
|
|
||||||
- 后端:Go + PostgreSQL,复用 Agent memory 的 `easyai-pgvector`,保留 `server-main` 的 JWT / API Key 授权语义。
|
- 后端:Go + PostgreSQL 18,复用 Agent memory 的 `easyai-pgvector`,支持本地用户、可选邀请码、API Key、余额/充值闭环,也支持复用 `server-main` 的 JWT / API Key 授权语义。
|
||||||
- 前端:React + TypeScript + TSX,UI 体系按 `shadcn-ui` / Radix / Tailwind 方向沉淀,先提供运维控制台骨架。
|
- 前端:React + TypeScript + TSX,UI 体系按 `shadcn-ui` / Radix / Tailwind 方向沉淀,先提供运维控制台骨架。
|
||||||
- Monorepo:Nx 负责任务编排,Go 使用 `go.work` 管理模块。
|
- Monorepo:Nx 负责任务编排,Go 使用 `go.work` 管理模块。
|
||||||
- 集成:完成后由 `easyai-server-main` 通过内部 HTTP SDK 直连本服务,前端经网关访问本服务。
|
- 集成:完成后由 `easyai-server-main` 通过内部 HTTP SDK 直连本服务;任务实时进度由 Gateway 回调 `server-main`,再通过原 WebSocket 网关推送给业务前端。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
@ -33,7 +33,8 @@ pnpm dev
|
|||||||
|
|
||||||
- API: `http://localhost:8088`
|
- API: `http://localhost:8088`
|
||||||
- Web: `http://localhost:5178`
|
- 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` 在容器网络内的连接串是:
|
默认 EasyAI 部署里,`easyai-pgvector` 在容器网络内的连接串是:
|
||||||
|
|
||||||
@ -52,9 +53,9 @@ AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@localhost:5432/easyai_ai_
|
|||||||
## 迁移原则
|
## 迁移原则
|
||||||
|
|
||||||
1. 新服务先并行运行,不直接删除 `easyai-server-main` 内现有模块。
|
1. 新服务先并行运行,不直接删除 `easyai-server-main` 内现有模块。
|
||||||
2. 授权先复用 `server-main` 的 JWT secret、claim、角色权限模型。
|
2. 身份域支持 `standalone`、`server-main`、`hybrid` 三种模式;独立模式由 Gateway 维护租户、用户、用户组、本地 API Key、余额和充值订单,接入模式从 `server-main` 同步租户、用户和用户组。
|
||||||
3. OpenAPI `sk-*` 校验、文件上传、扣费结算仍由 `server-main` 承担。
|
3. OpenAPI `sk-*` 校验、文件上传、扣费结算在接入模式下仍由 `server-main` 承担;独立模式走 Gateway 本地闭环。
|
||||||
4. 网关服务负责基准模型库、平台模型路由、TPM/RPM/并发限流、任务队列、三方平台执行、任务进度推送。
|
4. 网关服务负责基准模型库、平台模型路由、用户组调用折扣、TPM/RPM/并发限流、任务队列、三方平台执行、任务进度事件和回调 outbox。
|
||||||
5. 切流时优先让 `server-main` 的 `OpenaiService` 变成薄门面,内部调用本服务。
|
5. 切流时优先让 `server-main` 的 `OpenaiService` 变成薄门面,内部调用本服务。
|
||||||
|
|
||||||
详细设计见 [docs/design.md](docs/design.md)。
|
详细设计见 [docs/design.md](docs/design.md)。
|
||||||
|
|||||||
@ -5,4 +5,13 @@ go 1.23
|
|||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/jackc/pgx/v5 v5.7.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
30
apps/api/go.sum
Normal 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=
|
||||||
@ -24,14 +24,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"sub"`
|
ID string `json:"sub"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Roles []string `json:"role,omitempty"`
|
Roles []string `json:"role,omitempty"`
|
||||||
TenantID string `json:"tenantId,omitempty"`
|
TenantID string `json:"tenantId,omitempty"`
|
||||||
SSOID string `json:"sso_id,omitempty"`
|
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
TenantKey string `json:"tenantKey,omitempty"`
|
||||||
APIKeySecret string `json:"apiKeySecret,omitempty"`
|
SSOID string `json:"sso_id,omitempty"`
|
||||||
APIKeyName string `json:"apiKeyName,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
|
type contextKey string
|
||||||
@ -41,15 +48,15 @@ const userContextKey contextKey = "easyai-auth-user"
|
|||||||
var ErrUnauthorized = errors.New("unauthorized")
|
var ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
|
||||||
type Authenticator struct {
|
type Authenticator struct {
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
ServerMainBaseURL string
|
ServerMainBaseURL string
|
||||||
ServerMainInternalToken string
|
ServerMainInternalToken string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator {
|
func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator {
|
||||||
return &Authenticator{
|
return &Authenticator{
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
ServerMainBaseURL: strings.TrimRight(serverMainBaseURL, "/"),
|
ServerMainBaseURL: strings.TrimRight(serverMainBaseURL, "/"),
|
||||||
ServerMainInternalToken: internalToken,
|
ServerMainInternalToken: internalToken,
|
||||||
HTTPClient: &http.Client{
|
HTTPClient: &http.Client{
|
||||||
@ -112,14 +119,24 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := &User{
|
user := &User{
|
||||||
ID: stringClaim(claims, "sub"),
|
ID: stringClaim(claims, "sub"),
|
||||||
Username: stringClaim(claims, "username"),
|
Username: stringClaim(claims, "username"),
|
||||||
Roles: stringSliceClaim(claims, "role"),
|
Roles: stringSliceClaim(claims, "role"),
|
||||||
TenantID: stringClaim(claims, "tenantId"),
|
TenantID: stringClaim(claims, "tenantId"),
|
||||||
SSOID: stringClaim(claims, "sso_id"),
|
GatewayTenantID: stringClaim(claims, "gatewayTenantId"),
|
||||||
APIKeyID: stringClaim(claims, "apiKeyId"),
|
TenantKey: stringClaim(claims, "tenantKey"),
|
||||||
APIKeySecret: stringClaim(claims, "apiKeySecret"),
|
SSOID: stringClaim(claims, "sso_id"),
|
||||||
APIKeyName: stringClaim(claims, "apiKeyName"),
|
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 == "" {
|
if user.ID == "" {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
@ -127,6 +144,30 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
|
|||||||
return user, nil
|
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) {
|
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
|
||||||
if a.ServerMainBaseURL == "" || a.ServerMainInternalToken == "" {
|
if a.ServerMainBaseURL == "" || a.ServerMainInternalToken == "" {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
@ -154,6 +195,9 @@ func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User,
|
|||||||
if user.ID == "" {
|
if user.ID == "" {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
if user.Source == "" {
|
||||||
|
user.Source = "server-main"
|
||||||
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,14 +8,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppEnv string
|
AppEnv string
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
JWTSecret string
|
IdentityMode string
|
||||||
ServerMainBaseURL string
|
JWTSecret string
|
||||||
ServerMainInternalToken string
|
ServerMainBaseURL string
|
||||||
CORSAllowedOrigin string
|
ServerMainInternalToken string
|
||||||
LogLevel slog.Level
|
TaskProgressCallbackEnabled bool
|
||||||
|
TaskProgressCallbackURL string
|
||||||
|
TaskProgressCallbackTimeoutMS string
|
||||||
|
TaskProgressCallbackMaxAttempts string
|
||||||
|
CORSAllowedOrigin string
|
||||||
|
LogLevel slog.Level
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@ -23,14 +28,21 @@ func Load() Config {
|
|||||||
AppEnv: env("APP_ENV", "development"),
|
AppEnv: env("APP_ENV", "development"),
|
||||||
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
||||||
DatabaseURL: gatewayDatabaseURL(),
|
DatabaseURL: gatewayDatabaseURL(),
|
||||||
|
IdentityMode: env("IDENTITY_MODE", "hybrid"),
|
||||||
JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"),
|
JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"),
|
||||||
ServerMainBaseURL: strings.TrimRight(
|
ServerMainBaseURL: strings.TrimRight(
|
||||||
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
|
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
|
||||||
"/",
|
"/",
|
||||||
),
|
),
|
||||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||||
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178"),
|
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
|
||||||
LogLevel: logLevel(env("LOG_LEVEL", "info")),
|
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")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,10 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
"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) {
|
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"service": "easyai-ai-gateway",
|
"service": "easyai-ai-gateway",
|
||||||
"env": s.cfg.AppEnv,
|
"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)
|
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) {
|
func (s *Server) listPlatforms(w http.ResponseWriter, r *http.Request) {
|
||||||
platforms, err := s.store.ListPlatforms(r.Context())
|
platforms, err := s.store.ListPlatforms(r.Context())
|
||||||
if err != nil {
|
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})
|
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) {
|
func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
|||||||
@ -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 /healthz", server.health)
|
||||||
mux.HandleFunc("GET /readyz", server.ready)
|
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/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/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/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("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("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("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/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("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")))
|
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions")))
|
||||||
|
|||||||
@ -3,17 +3,27 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
pool *pgxpool.Pool
|
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) {
|
func Connect(ctx context.Context, databaseURL string) (*Store, error) {
|
||||||
pool, err := pgxpool.New(ctx, databaseURL)
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -126,6 +136,83 @@ type PricingRule struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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 {
|
type RateLimitWindow struct {
|
||||||
ScopeType string `json:"scopeType"`
|
ScopeType string `json:"scopeType"`
|
||||||
ScopeKey string `json:"scopeKey"`
|
ScopeKey string `json:"scopeKey"`
|
||||||
@ -145,18 +232,24 @@ type CreateTaskInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GatewayTask struct {
|
type GatewayTask struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
TenantID string `json:"tenantId,omitempty"`
|
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||||
Model string `json:"model"`
|
UserSource string `json:"userSource,omitempty"`
|
||||||
Request map[string]any `json:"request,omitempty"`
|
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||||
Status string `json:"status"`
|
TenantID string `json:"tenantId,omitempty"`
|
||||||
Result map[string]any `json:"result,omitempty"`
|
TenantKey string `json:"tenantKey,omitempty"`
|
||||||
Billings []any `json:"billings,omitempty"`
|
UserGroupID string `json:"userGroupId,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
UserGroupKey string `json:"userGroupKey,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Model string `json:"model"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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) {
|
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()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListTenants(ctx context.Context) ([]GatewayTenant, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id::text, tenant_key, source, COALESCE(external_tenant_id, ''), name, COALESCE(description, ''),
|
||||||
|
COALESCE(default_user_group_id::text, ''), COALESCE(plan_key, ''), billing_profile, rate_limit_policy,
|
||||||
|
auth_policy, metadata, status, COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
|
||||||
|
created_at, updated_at
|
||||||
|
FROM gateway_tenants
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]GatewayTenant, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item GatewayTenant
|
||||||
|
var billingProfile []byte
|
||||||
|
var rateLimitPolicy []byte
|
||||||
|
var authPolicy []byte
|
||||||
|
var metadata []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.TenantKey,
|
||||||
|
&item.Source,
|
||||||
|
&item.ExternalTenantID,
|
||||||
|
&item.Name,
|
||||||
|
&item.Description,
|
||||||
|
&item.DefaultUserGroupID,
|
||||||
|
&item.PlanKey,
|
||||||
|
&billingProfile,
|
||||||
|
&rateLimitPolicy,
|
||||||
|
&authPolicy,
|
||||||
|
&metadata,
|
||||||
|
&item.Status,
|
||||||
|
&item.SyncedAt,
|
||||||
|
&item.SourceUpdatedAt,
|
||||||
|
&item.CreatedAt,
|
||||||
|
&item.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.BillingProfile = decodeObject(billingProfile)
|
||||||
|
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||||
|
item.AuthPolicy = decodeObject(authPolicy)
|
||||||
|
item.Metadata = decodeObject(metadata)
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListUsers(ctx context.Context) ([]GatewayUser, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id::text, user_key, source, COALESCE(external_user_id, ''), username,
|
||||||
|
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||||
|
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||||
|
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
|
||||||
|
status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
|
||||||
|
created_at, updated_at
|
||||||
|
FROM gateway_users
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]GatewayUser, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item GatewayUser
|
||||||
|
var roles []byte
|
||||||
|
var authProfile []byte
|
||||||
|
var metadata []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.UserKey,
|
||||||
|
&item.Source,
|
||||||
|
&item.ExternalUserID,
|
||||||
|
&item.Username,
|
||||||
|
&item.DisplayName,
|
||||||
|
&item.Email,
|
||||||
|
&item.Phone,
|
||||||
|
&item.AvatarURL,
|
||||||
|
&item.GatewayTenantID,
|
||||||
|
&item.TenantID,
|
||||||
|
&item.TenantKey,
|
||||||
|
&item.DefaultUserGroupID,
|
||||||
|
&roles,
|
||||||
|
&authProfile,
|
||||||
|
&metadata,
|
||||||
|
&item.Status,
|
||||||
|
&item.LastLoginAt,
|
||||||
|
&item.SyncedAt,
|
||||||
|
&item.SourceUpdatedAt,
|
||||||
|
&item.CreatedAt,
|
||||||
|
&item.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Roles = decodeStringArray(roles)
|
||||||
|
item.AuthProfile = decodeObject(authProfile)
|
||||||
|
item.Metadata = decodeObject(metadata)
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListUserGroups(ctx context.Context) ([]UserGroup, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id::text, group_key, name, COALESCE(description, ''), source, priority,
|
||||||
|
recharge_discount_policy, billing_discount_policy, rate_limit_policy, quota_policy, metadata,
|
||||||
|
status, created_at, updated_at
|
||||||
|
FROM gateway_user_groups
|
||||||
|
ORDER BY priority ASC, group_key ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]UserGroup, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item UserGroup
|
||||||
|
var rechargeDiscountPolicy []byte
|
||||||
|
var billingDiscountPolicy []byte
|
||||||
|
var rateLimitPolicy []byte
|
||||||
|
var quotaPolicy []byte
|
||||||
|
var metadata []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.GroupKey,
|
||||||
|
&item.Name,
|
||||||
|
&item.Description,
|
||||||
|
&item.Source,
|
||||||
|
&item.Priority,
|
||||||
|
&rechargeDiscountPolicy,
|
||||||
|
&billingDiscountPolicy,
|
||||||
|
&rateLimitPolicy,
|
||||||
|
"aPolicy,
|
||||||
|
&metadata,
|
||||||
|
&item.Status,
|
||||||
|
&item.CreatedAt,
|
||||||
|
&item.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.RechargeDiscountPolicy = decodeObject(rechargeDiscountPolicy)
|
||||||
|
item.BillingDiscountPolicy = decodeObject(billingDiscountPolicy)
|
||||||
|
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||||
|
item.QuotaPolicy = decodeObject(quotaPolicy)
|
||||||
|
item.Metadata = decodeObject(metadata)
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RegisterLocalUser(ctx context.Context, input LocalRegisterInput) (GatewayUser, error) {
|
||||||
|
account := normalizeAccount(firstNonEmpty(input.Username, input.Email))
|
||||||
|
if account == "" {
|
||||||
|
return GatewayUser{}, errors.New("username or email is required")
|
||||||
|
}
|
||||||
|
if len(input.Password) < 8 {
|
||||||
|
return GatewayUser{}, ErrWeakPassword
|
||||||
|
}
|
||||||
|
tenantKey := normalizeKey(input.TenantKey)
|
||||||
|
if tenantKey == "" {
|
||||||
|
tenantKey = "personal-" + normalizeKey(account)
|
||||||
|
}
|
||||||
|
tenantName := strings.TrimSpace(input.TenantName)
|
||||||
|
if tenantName == "" {
|
||||||
|
tenantName = tenantKey
|
||||||
|
}
|
||||||
|
displayName := strings.TrimSpace(input.DisplayName)
|
||||||
|
username := strings.TrimSpace(input.Username)
|
||||||
|
if username == "" {
|
||||||
|
username = account
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(strings.ToLower(input.Email))
|
||||||
|
invitationCode := strings.TrimSpace(input.InvitationCode)
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var tenantID string
|
||||||
|
userGroupID := ""
|
||||||
|
role := "user"
|
||||||
|
invitationID := ""
|
||||||
|
if invitationCode != "" {
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT i.id::text,
|
||||||
|
i.tenant_id::text,
|
||||||
|
t.tenant_key,
|
||||||
|
t.name,
|
||||||
|
COALESCE(i.user_group_id::text, t.default_user_group_id::text, ''),
|
||||||
|
COALESCE(NULLIF(i.role, ''), 'user')
|
||||||
|
FROM gateway_tenant_invitations i
|
||||||
|
JOIN gateway_tenants t ON t.id = i.tenant_id
|
||||||
|
WHERE lower(i.invite_code) = lower($1)
|
||||||
|
AND i.status = 'active'
|
||||||
|
AND t.status = 'active'
|
||||||
|
AND (i.expires_at IS NULL OR i.expires_at > now())
|
||||||
|
AND (i.max_uses IS NULL OR i.used_count < i.max_uses)
|
||||||
|
FOR UPDATE OF i`,
|
||||||
|
invitationCode,
|
||||||
|
).Scan(&invitationID, &tenantID, &tenantKey, &tenantName, &userGroupID, &role); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return GatewayUser{}, ErrInvalidInvitation
|
||||||
|
}
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
} else if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO gateway_tenants (tenant_key, source, external_tenant_id, name)
|
||||||
|
VALUES ($1, 'gateway', $1, $2)
|
||||||
|
ON CONFLICT (tenant_key) DO UPDATE SET updated_at=now()
|
||||||
|
RETURNING id::text`,
|
||||||
|
tenantKey, tenantName,
|
||||||
|
).Scan(&tenantID); err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rolesJSON, err := json.Marshal([]string{role})
|
||||||
|
if err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user GatewayUser
|
||||||
|
var roles []byte
|
||||||
|
var authProfile []byte
|
||||||
|
var metadata []byte
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO gateway_users (
|
||||||
|
user_key, source, external_user_id, username, display_name, email,
|
||||||
|
password_hash, gateway_tenant_id, tenant_id, tenant_key, default_user_group_id, roles, status
|
||||||
|
)
|
||||||
|
VALUES ($1, 'gateway', $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6, $7::uuid, $8, $8, NULLIF($9, '')::uuid, $10::jsonb, 'active')
|
||||||
|
RETURNING id::text, user_key, source, COALESCE(external_user_id, ''), username,
|
||||||
|
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||||
|
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||||
|
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
|
||||||
|
status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
|
||||||
|
created_at, updated_at`,
|
||||||
|
"gateway:"+account, account, username, displayName, email, string(passwordHash), tenantID, tenantKey, userGroupID, string(rolesJSON),
|
||||||
|
).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.UserKey,
|
||||||
|
&user.Source,
|
||||||
|
&user.ExternalUserID,
|
||||||
|
&user.Username,
|
||||||
|
&user.DisplayName,
|
||||||
|
&user.Email,
|
||||||
|
&user.Phone,
|
||||||
|
&user.AvatarURL,
|
||||||
|
&user.GatewayTenantID,
|
||||||
|
&user.TenantID,
|
||||||
|
&user.TenantKey,
|
||||||
|
&user.DefaultUserGroupID,
|
||||||
|
&roles,
|
||||||
|
&authProfile,
|
||||||
|
&metadata,
|
||||||
|
&user.Status,
|
||||||
|
&user.LastLoginAt,
|
||||||
|
&user.SyncedAt,
|
||||||
|
&user.SourceUpdatedAt,
|
||||||
|
&user.CreatedAt,
|
||||||
|
&user.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
if invitationID != "" {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE gateway_tenant_invitations
|
||||||
|
SET used_count = used_count + 1, updated_at = now()
|
||||||
|
WHERE id = $1::uuid`, invitationID); err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userGroupID != "" {
|
||||||
|
metadata, err := json.Marshal(map[string]any{
|
||||||
|
"source": "registration",
|
||||||
|
"invitationId": invitationID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO gateway_user_group_memberships (group_id, principal_type, principal_id, source, metadata)
|
||||||
|
VALUES ($1::uuid, 'user', $2, 'gateway', $3::jsonb)
|
||||||
|
ON CONFLICT (group_id, principal_type, principal_id)
|
||||||
|
DO UPDATE SET status = 'active', updated_at = now()`,
|
||||||
|
userGroupID, user.ID, string(metadata),
|
||||||
|
); err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
user.Roles = decodeStringArray(roles)
|
||||||
|
user.AuthProfile = decodeObject(authProfile)
|
||||||
|
user.Metadata = decodeObject(metadata)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AuthenticateLocalUser(ctx context.Context, input LocalLoginInput) (GatewayUser, error) {
|
||||||
|
account := normalizeAccount(input.Account)
|
||||||
|
if account == "" || input.Password == "" {
|
||||||
|
return GatewayUser{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
var user GatewayUser
|
||||||
|
var passwordHash string
|
||||||
|
var roles []byte
|
||||||
|
var authProfile []byte
|
||||||
|
var metadata []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id::text, user_key, source, COALESCE(external_user_id, ''), username,
|
||||||
|
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||||
|
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||||
|
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
|
||||||
|
status, COALESCE(password_hash, ''), COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''),
|
||||||
|
COALESCE(source_updated_at::text, ''), created_at, updated_at
|
||||||
|
FROM gateway_users
|
||||||
|
WHERE source='gateway'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND (external_user_id=$1 OR lower(username)=$1 OR lower(COALESCE(email, ''))=$1)
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1`, account,
|
||||||
|
).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.UserKey,
|
||||||
|
&user.Source,
|
||||||
|
&user.ExternalUserID,
|
||||||
|
&user.Username,
|
||||||
|
&user.DisplayName,
|
||||||
|
&user.Email,
|
||||||
|
&user.Phone,
|
||||||
|
&user.AvatarURL,
|
||||||
|
&user.GatewayTenantID,
|
||||||
|
&user.TenantID,
|
||||||
|
&user.TenantKey,
|
||||||
|
&user.DefaultUserGroupID,
|
||||||
|
&roles,
|
||||||
|
&authProfile,
|
||||||
|
&metadata,
|
||||||
|
&user.Status,
|
||||||
|
&passwordHash,
|
||||||
|
&user.LastLoginAt,
|
||||||
|
&user.SyncedAt,
|
||||||
|
&user.SourceUpdatedAt,
|
||||||
|
&user.CreatedAt,
|
||||||
|
&user.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return GatewayUser{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return GatewayUser{}, err
|
||||||
|
}
|
||||||
|
if user.Status != "active" || passwordHash == "" {
|
||||||
|
return GatewayUser{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil {
|
||||||
|
return GatewayUser{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
user.Roles = decodeStringArray(roles)
|
||||||
|
user.AuthProfile = decodeObject(authProfile)
|
||||||
|
user.Metadata = decodeObject(metadata)
|
||||||
|
_, _ = s.pool.Exec(ctx, `UPDATE gateway_users SET last_login_at=now(), updated_at=now() WHERE id=$1`, user.ID)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListRateLimitWindows(ctx context.Context) ([]RateLimitWindow, error) {
|
func (s *Store) ListRateLimitWindows(ctx context.Context) ([]RateLimitWindow, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT scope_type, scope_key, metric, window_start, limit_value::float8, used_value::float8,
|
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 resultBytes []byte
|
||||||
var billingsBytes []byte
|
var billingsBytes []byte
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO gateway_tasks (kind, user_id, tenant_id, model, request, status)
|
INSERT INTO gateway_tasks (
|
||||||
VALUES ($1, $2, NULLIF($3, ''), $4, $5, 'queued')
|
kind, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
||||||
RETURNING id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`,
|
api_key_id, user_group_id, user_group_key, model, request, status
|
||||||
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)
|
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 {
|
if err != nil {
|
||||||
return GatewayTask{}, err
|
return GatewayTask{}, err
|
||||||
}
|
}
|
||||||
@ -468,10 +943,12 @@ func (s *Store) GetTask(ctx context.Context, taskID string) (GatewayTask, error)
|
|||||||
var resultBytes []byte
|
var resultBytes []byte
|
||||||
var billingsBytes []byte
|
var billingsBytes []byte
|
||||||
err := s.pool.QueryRow(ctx, `
|
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
|
FROM gateway_tasks
|
||||||
WHERE id=$1`, taskID,
|
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 {
|
if err != nil {
|
||||||
return GatewayTask{}, err
|
return GatewayTask{}, err
|
||||||
}
|
}
|
||||||
@ -506,3 +983,50 @@ func decodeArray(bytes []byte) []any {
|
|||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -49,6 +49,9 @@ CREATE TABLE IF NOT EXISTS integration_platforms (
|
|||||||
auth_type text NOT NULL DEFAULT 'bearer',
|
auth_type text NOT NULL DEFAULT 'bearer',
|
||||||
credentials jsonb NOT NULL DEFAULT '{}'::jsonb,
|
credentials jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
config 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_pricing_mode text NOT NULL DEFAULT 'inherit_discount',
|
||||||
default_discount_factor numeric NOT NULL DEFAULT 1,
|
default_discount_factor numeric NOT NULL DEFAULT 1,
|
||||||
retry_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
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
|
CREATE INDEX IF NOT EXISTS idx_integration_platforms_cooldown
|
||||||
ON integration_platforms(cooldown_until);
|
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 (
|
CREATE TABLE IF NOT EXISTS model_pricing_rules (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
scope_type text NOT NULL,
|
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
|
CREATE INDEX IF NOT EXISTS idx_model_pricing_effective
|
||||||
ON model_pricing_rules(effective_from, effective_to);
|
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 (
|
CREATE TABLE IF NOT EXISTS platform_models (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
platform_id uuid NOT NULL REFERENCES integration_platforms(id) ON DELETE CASCADE,
|
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,
|
kind text NOT NULL,
|
||||||
run_mode text NOT NULL DEFAULT 'production',
|
run_mode text NOT NULL DEFAULT 'production',
|
||||||
user_id text NOT NULL,
|
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_id text,
|
||||||
|
tenant_key text,
|
||||||
api_key_id 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 text NOT NULL,
|
||||||
model_type text,
|
model_type text,
|
||||||
request jsonb NOT NULL DEFAULT '{}'::jsonb,
|
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
|
CREATE INDEX IF NOT EXISTS idx_gateway_events_task_created
|
||||||
ON gateway_task_events(task_id, created_at);
|
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 (
|
CREATE TABLE IF NOT EXISTS runtime_client_states (
|
||||||
client_id text PRIMARY KEY,
|
client_id text PRIMARY KEY,
|
||||||
platform_id uuid REFERENCES integration_platforms(id) ON DELETE SET NULL,
|
platform_id uuid REFERENCES integration_platforms(id) ON DELETE SET NULL,
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
import type {
|
import type {
|
||||||
BaseModelCatalogItem,
|
BaseModelCatalogItem,
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
|
GatewayTenant,
|
||||||
|
GatewayUser,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
PlatformModel,
|
PlatformModel,
|
||||||
PricingRule,
|
PricingRule,
|
||||||
RateLimitWindow,
|
RateLimitWindow,
|
||||||
|
UserGroup,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
import {
|
import {
|
||||||
getHealth,
|
getHealth,
|
||||||
@ -15,13 +18,88 @@ import {
|
|||||||
listPlatforms,
|
listPlatforms,
|
||||||
listPricingRules,
|
listPricingRules,
|
||||||
listRateLimitWindows,
|
listRateLimitWindows,
|
||||||
|
listTenants,
|
||||||
|
listUserGroups,
|
||||||
|
listUsers,
|
||||||
|
loginLocalAccount,
|
||||||
|
registerLocalAccount,
|
||||||
type HealthResponse,
|
type HealthResponse,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
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() {
|
export function App() {
|
||||||
const [token, setToken] = useState('');
|
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 [health, setHealth] = useState<HealthResponse | null>(null);
|
||||||
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
||||||
const [models, setModels] = useState<PlatformModel[]>([]);
|
const [models, setModels] = useState<PlatformModel[]>([]);
|
||||||
@ -29,6 +107,9 @@ export function App() {
|
|||||||
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
||||||
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
|
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
|
||||||
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
|
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 [state, setState] = useState<LoadState>('idle');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@ -44,6 +125,9 @@ export function App() {
|
|||||||
const activeProviders = providers.filter((item) => item.status === 'active').length;
|
const activeProviders = providers.filter((item) => item.status === 'active').length;
|
||||||
const activeRateWindows = rateLimitWindows.filter((item) => item.resetAt >= new Date().toISOString()).length;
|
const activeRateWindows = rateLimitWindows.filter((item) => item.resetAt >= new Date().toISOString()).length;
|
||||||
return [
|
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: platforms.length, tone: 'blue' },
|
||||||
{ label: '启用平台', value: enabledPlatforms, tone: 'green' },
|
{ label: '启用平台', value: enabledPlatforms, tone: 'green' },
|
||||||
{ label: '基准模型', value: baseModels.length, tone: 'violet' },
|
{ label: '基准模型', value: baseModels.length, tone: 'violet' },
|
||||||
@ -51,9 +135,9 @@ export function App() {
|
|||||||
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
|
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
|
||||||
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
|
{ 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');
|
setState('loading');
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
@ -64,13 +148,19 @@ export function App() {
|
|||||||
baseModelResponse,
|
baseModelResponse,
|
||||||
pricingRuleResponse,
|
pricingRuleResponse,
|
||||||
rateLimitWindowResponse,
|
rateLimitWindowResponse,
|
||||||
|
tenantResponse,
|
||||||
|
userResponse,
|
||||||
|
userGroupResponse,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
listPlatforms(token),
|
listPlatforms(nextToken),
|
||||||
listModels(token),
|
listModels(nextToken),
|
||||||
listCatalogProviders(token),
|
listCatalogProviders(nextToken),
|
||||||
listBaseModels(token),
|
listBaseModels(nextToken),
|
||||||
listPricingRules(token),
|
listPricingRules(nextToken),
|
||||||
listRateLimitWindows(token),
|
listRateLimitWindows(nextToken),
|
||||||
|
listTenants(nextToken),
|
||||||
|
listUsers(nextToken),
|
||||||
|
listUserGroups(nextToken),
|
||||||
]);
|
]);
|
||||||
setPlatforms(platformResponse.items);
|
setPlatforms(platformResponse.items);
|
||||||
setModels(modelResponse.items);
|
setModels(modelResponse.items);
|
||||||
@ -78,6 +168,9 @@ export function App() {
|
|||||||
setBaseModels(baseModelResponse.items);
|
setBaseModels(baseModelResponse.items);
|
||||||
setPricingRules(pricingRuleResponse.items);
|
setPricingRules(pricingRuleResponse.items);
|
||||||
setRateLimitWindows(rateLimitWindowResponse.items);
|
setRateLimitWindows(rateLimitWindowResponse.items);
|
||||||
|
setTenants(tenantResponse.items);
|
||||||
|
setUsers(userResponse.items);
|
||||||
|
setUserGroups(userGroupResponse.items);
|
||||||
setState('ready');
|
setState('ready');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState('error');
|
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 (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
@ -92,30 +238,284 @@ export function App() {
|
|||||||
<p className="eyebrow">EasyAI</p>
|
<p className="eyebrow">EasyAI</p>
|
||||||
<h1>AI Gateway Console</h1>
|
<h1>AI Gateway Console</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="health" data-ok={health?.ok === true}>
|
<div className="topbarActions">
|
||||||
<span />
|
<div className="health" data-ok={health?.ok === true}>
|
||||||
{health?.service ?? 'API 未连接'}
|
<span />
|
||||||
|
{health?.identityMode ? `${health.service} · ${health.identityMode}` : health?.service ?? 'API 未连接'}
|
||||||
|
</div>
|
||||||
|
{token && (
|
||||||
|
<button type="button" className="ghostButton" onClick={signOut}>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="toolbar" aria-label="授权与刷新">
|
{!token ? (
|
||||||
<label className="tokenField">
|
<AuthPanel
|
||||||
<span>Server Main JWT</span>
|
authMode={authMode}
|
||||||
<input
|
externalToken={externalToken}
|
||||||
value={token}
|
loginForm={loginForm}
|
||||||
onChange={(event) => setToken(event.target.value)}
|
registerForm={registerForm}
|
||||||
placeholder="粘贴 server-main access_token"
|
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>}
|
{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="概览">
|
<section className="metrics" aria-label="概览">
|
||||||
{stats.map((item) => (
|
{props.stats.map((item) => (
|
||||||
<div className="metric" data-tone={item.tone} key={item.label}>
|
<div className="metric" data-tone={item.tone} key={item.label}>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<strong>{item.value}</strong>
|
<strong>{item.value}</strong>
|
||||||
@ -124,104 +524,80 @@ export function App() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="split">
|
<section className="split">
|
||||||
<div className="panel">
|
<DataPanel
|
||||||
<div className="panelHeader">
|
columns={['Provider', '名称', '状态', '优先级']}
|
||||||
<h2>平台</h2>
|
empty="暂无平台数据"
|
||||||
<span>{platforms.length}</span>
|
rows={props.platforms.map((item) => [item.provider, item.name, item.status, String(item.priority)])}
|
||||||
</div>
|
title="平台"
|
||||||
<div className="table" role="table">
|
/>
|
||||||
<div className="row head" role="row">
|
<DataPanel
|
||||||
<span>Provider</span>
|
columns={['模型', '类型', '平台', '启用']}
|
||||||
<span>名称</span>
|
empty="暂无模型数据"
|
||||||
<span>状态</span>
|
rows={props.models.map((item) => [item.modelName, item.modelType, item.provider ?? item.platformName ?? '-', item.enabled ? '是' : '否'])}
|
||||||
<span>优先级</span>
|
title="模型"
|
||||||
</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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="split secondary">
|
<section className="split secondary">
|
||||||
<div className="panel">
|
<DataPanel
|
||||||
<div className="panelHeader">
|
columns={['Provider', '模型', '类型', '版本']}
|
||||||
<h2>基准模型库</h2>
|
empty="暂无基准模型"
|
||||||
<span>{baseModels.length}</span>
|
rows={props.baseModels.map((item) => [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])}
|
||||||
</div>
|
title="基准模型库"
|
||||||
<div className="table catalogTable" role="table">
|
/>
|
||||||
<div className="row head" role="row">
|
<DataPanel
|
||||||
<span>Provider</span>
|
columns={['Scope', '指标', '使用', '预占']}
|
||||||
<span>模型</span>
|
empty="暂无限流窗口"
|
||||||
<span>类型</span>
|
rows={props.rateLimitWindows.map((item) => [item.scopeKey, item.metric, `${item.usedValue}/${item.limitValue}`, String(item.reservedValue)])}
|
||||||
<span>版本</span>
|
title="TPM/RPM 窗口"
|
||||||
</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>
|
|
||||||
</section>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AuthResponse,
|
||||||
BaseModelCatalogItem,
|
BaseModelCatalogItem,
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
|
GatewayTenant,
|
||||||
|
GatewayUser,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
PlatformModel,
|
PlatformModel,
|
||||||
PricingRule,
|
PricingRule,
|
||||||
RateLimitWindow,
|
RateLimitWindow,
|
||||||
|
UserGroup,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
|
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
|
||||||
@ -14,12 +18,37 @@ export interface HealthResponse {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
service: string;
|
service: string;
|
||||||
env: string;
|
env: string;
|
||||||
|
identityMode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHealth(): Promise<HealthResponse> {
|
export async function getHealth(): Promise<HealthResponse> {
|
||||||
return request<HealthResponse>('/healthz', { auth: false });
|
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>> {
|
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
|
||||||
return request<ListResponse<IntegrationPlatform>>('/api/v1/platforms', { token });
|
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 });
|
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>> {
|
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
|
||||||
return request<ListResponse<RateLimitWindow>>('/api/v1/runtime/rate-limit-windows', { token });
|
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> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (options.auth !== false && options.token) {
|
if (options.auth !== false && options.token) {
|
||||||
headers.Authorization = `Bearer ${options.token}`;
|
headers.Authorization = `Bearer ${options.token}`;
|
||||||
}
|
}
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
headers,
|
headers,
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
|
|||||||
@ -35,6 +35,12 @@ input {
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbarActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
color: #667085;
|
color: #667085;
|
||||||
@ -58,6 +64,11 @@ h2 {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.health {
|
.health {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -123,11 +134,98 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghostButton {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
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 {
|
.notice {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
@ -138,9 +236,112 @@ button:disabled {
|
|||||||
font-size: 14px;
|
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 {
|
.metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(126px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
@ -259,7 +460,9 @@ button:disabled {
|
|||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.topbar,
|
.topbar,
|
||||||
.toolbar {
|
.toolbar,
|
||||||
|
.authForm.twoColumn,
|
||||||
|
.segmented {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,8 +471,15 @@ button:disabled {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbarActions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.metrics,
|
.metrics,
|
||||||
.split {
|
.split,
|
||||||
|
.moduleGrid,
|
||||||
|
.detailGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,7 +490,12 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 861px) and (max-width: 1180px) {
|
@media (min-width: 861px) and (max-width: 1180px) {
|
||||||
.metrics {
|
.metrics,
|
||||||
|
.moduleGrid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detailGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
866
docs/design.md
866
docs/design.md
File diff suppressed because it is too large
Load Diff
@ -1,48 +1,103 @@
|
|||||||
# integration-platform 迁移实施计划
|
# integration-platform 迁移实施计划
|
||||||
|
|
||||||
## 第 1 周:基础设施
|
## Phase 0:脚手架与基础契约
|
||||||
|
|
||||||
- 在 Agent memory 的 `easyai-pgvector` 实例上建立独立数据库 `easyai_ai_gateway` 和 AI Gateway 表,不直接使用 `easyai_memory` 记忆库。正式 EasyAI compose 默认账号为 `easyai` / `easyai2025`。
|
- 在 Agent memory 的 PostgreSQL 18 `easyai-pgvector` 实例上建立独立数据库 `easyai_ai_gateway` 和 AI Gateway 表,不直接使用 `easyai_memory` 记忆库。正式 EasyAI compose 默认账号为 `easyai` / `easyai2025`。
|
||||||
- 完成 JWT / API Key 授权验证。
|
- 完成 Go API、React 前端、Nx/go.work/pnpm monorepo、基础 migration。
|
||||||
- 完成基准 provider、基准模型库、平台与模型管理 API。
|
- 完成本地账号注册登录、可选邀请码、JWT / API Key 授权验证骨架,并将默认身份模式设为 `hybrid`。
|
||||||
- 完成基准定价、平台默认折扣、平台模型覆盖的 schema。
|
- 固化兼容路由、任务事件、队列、定价、限流、回调 outbox 的基础设计。
|
||||||
- React 控制台接入平台、基准模型、TPM/RPM 限流窗口列表。
|
- 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` 候选排序。
|
- `assignClientsByModelName` 候选排序。
|
||||||
- `assignClientsByProviderMethod` provider-level 负载均衡。
|
|
||||||
- estimated billing 使用真实候选集。
|
- estimated billing 使用真实候选集。
|
||||||
- 建立 TPM/RPM/并发限流 fixtures,覆盖预占、释放、失败切换重新计数。
|
- 建立 TPM/RPM/并发限流 fixtures,覆盖预占、释放、失败切换重新计数。
|
||||||
- Go 侧实现 router,并用 fixtures 对齐旧行为。
|
- Go 侧实现 router,并用 fixtures 对齐旧行为。
|
||||||
|
- 补齐持久化队列、任务租约、heartbeat、重启恢复、attempt 审计。
|
||||||
|
- 补齐租户、用户和用户组策略解析:`source + externalTenantId` 租户同步、`source + externalUserId` 用户同步、多组命中、优先级、策略合并、任务策略快照。
|
||||||
|
- 补齐 callback outbox、settlement outbox 的重试、死信和手动 replay。
|
||||||
|
|
||||||
## 第 3 周:核心 provider
|
## Phase 3:server-main 薄门面与灰度
|
||||||
|
|
||||||
- 先迁 OpenAI-compatible / Universal。
|
- `server-main` 的 Chat、生图、图像编辑入口内部切到 Gateway HTTP SDK。
|
||||||
- 再迁生图、生视频主 provider。
|
- 开启 shadow / dry-run,比对旧实现和 Gateway 的候选模型、预估扣费、参数预处理结果。
|
||||||
- 每个 provider 建 contract test。
|
- 前端逐步增加 `VITE_GATEWAY_API_BASE_URL`,灰度切流核心接口。
|
||||||
|
- 观察任务成功率、平均排队时间、限流命中、扣费一致性、回调 outbox 滞留。
|
||||||
|
|
||||||
## 第 4 周:任务链路
|
## Phase 4:视频与更多 provider
|
||||||
|
|
||||||
- 实现队列、任务状态、SSE 进度。
|
- 在 Phase 1 稳定后再迁移生视频、音频、Embedding、音乐、数字人等能力。
|
||||||
- 实现 TPM/RPM 一分钟窗口计数和并发 lease 恢复。
|
- 迁移 RunningHub、Jimeng、Vidu、Kling、Hunyuan Video、Suno 等 provider。
|
||||||
- 打通 Chat、生图、生视频端到端。
|
- 对 app-style provider 补 `assignClientsByProviderMethod` 和 `provider + methodName` 队列 key。
|
||||||
- 生成结算事件,接入 server-main 幂等扣费。
|
- 每个 provider 增加 contract test、retry classification test、billing snapshot。
|
||||||
|
|
||||||
## 第 5 周:切流
|
## Phase 5:清理旧实现
|
||||||
|
|
||||||
- server-main `OpenaiService` 加 Gateway client。
|
- 删除或冻结 `server-main` 中重复的 runtime client。
|
||||||
- 开启 shadow / dry-run 比对。
|
- 保留必要 BFF、用户历史、账单、文件上传能力。
|
||||||
- 前端增加 `VITE_GATEWAY_API_BASE_URL`。
|
- 将旧 `integration-platform` 配置迁移脚本和回滚脚本固化。
|
||||||
- 灰度切流,观察任务成功率、平均排队、扣费一致性。
|
|
||||||
|
|
||||||
## 风险控制
|
## 风险控制
|
||||||
|
|
||||||
|
- 第一阶段不做视频和大量 provider,避免迁移面过宽。
|
||||||
- 不做 first-match 回退,所有候选选择都要有行为测试。
|
- 不做 first-match 回退,所有候选选择都要有行为测试。
|
||||||
- API Key 不在 Gateway 落库。
|
- 接入 `server-main` 模式下 API Key 不在 Gateway 落库;独立模式的本地 API Key、余额、充值订单和钱包流水在 Gateway 闭环。
|
||||||
- OSS 密钥不进入 Gateway;文件统一调用 server-main 开放上传接口。
|
- OSS 密钥不进入 Gateway;文件统一调用 server-main 开放上传接口。
|
||||||
|
- 租户、用户和用户组可由 Gateway 管理或从 server-main 同步;接入模式下充值执行、余额流水仍以 server-main 为事实源。
|
||||||
|
- 平台凭证和 provider 凭证当前阶段只允许全局管理员配置,不开放租户管理员自助维护。
|
||||||
|
- 业务前端实时进度仍走现有 WebSocket 网关;Gateway 只负责事件与回调 outbox。
|
||||||
- 平台模型没有自定义价格时必须 follow 基准模型,不能隐式按 0 计费。
|
- 平台模型没有自定义价格时必须 follow 基准模型,不能隐式按 0 计费。
|
||||||
- estimated billing 与真实结算必须使用同一个 effective pricing resolver。
|
- estimated billing 与真实结算必须使用同一个 effective pricing resolver。
|
||||||
- 结算事件必须幂等和可重试。
|
- 结算事件必须幂等和可重试。
|
||||||
|
|||||||
@ -19,7 +19,14 @@ Content-Type: application/json
|
|||||||
"sub": "user-id",
|
"sub": "user-id",
|
||||||
"username": "demo",
|
"username": "demo",
|
||||||
"role": ["user"],
|
"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",
|
"apiKeyId": "key-id",
|
||||||
"apiKeySecret": "sk-...",
|
"apiKeySecret": "sk-...",
|
||||||
"apiKeyName": "production-key"
|
"apiKeyName": "production-key"
|
||||||
@ -38,7 +45,180 @@ file=@result.png
|
|||||||
|
|
||||||
AI Gateway 不维护独立 OSS 配置,也不向 `server-main` 申请预签名。需要上传本地中间产物、provider 临时 URL 转存、base64 解码结果时,统一组装 multipart 请求调用主服务开放上传接口,并记录主服务返回的 file id / URL / object key。
|
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
|
```http
|
||||||
POST /internal/platform/settlements
|
POST /internal/platform/settlements
|
||||||
@ -66,6 +246,7 @@ Idempotency-Key: ${eventId}
|
|||||||
AI_GATEWAY_ENABLED=true
|
AI_GATEWAY_ENABLED=true
|
||||||
AI_GATEWAY_BASE_URL=http://easyai-ai-gateway:8088
|
AI_GATEWAY_BASE_URL=http://easyai-ai-gateway:8088
|
||||||
AI_GATEWAY_INTERNAL_TOKEN=change-me
|
AI_GATEWAY_INTERNAL_TOKEN=change-me
|
||||||
|
AI_GATEWAY_TASK_PROGRESS_CALLBACK_ENABLED=true
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 迁移期双写与比对
|
## 3. 迁移期双写与比对
|
||||||
@ -81,7 +262,7 @@ AI_GATEWAY_INTERNAL_TOKEN=change-me
|
|||||||
|
|
||||||
- `refresh_token` 签发和刷新。
|
- `refresh_token` 签发和刷新。
|
||||||
- 用户余额查询。
|
- 用户余额查询。
|
||||||
- 用户 API Key 的创建、撤销、列表。
|
- `server-main` 用户 API Key 的创建、撤销、列表。Gateway 独立模式会维护自己的本地 API Key。
|
||||||
- 账单锁、扣费流水。
|
- `server-main` 账单锁、扣费流水。Gateway 独立模式会维护自己的钱包账户、充值订单和钱包流水。
|
||||||
- OSS/COS/S3 上传配置和实际文件落库。
|
- OSS/COS/S3 上传配置和实际文件落库。
|
||||||
- 对话与绘图历史最终落库。
|
- 对话与绘图历史最终落库。
|
||||||
|
|||||||
@ -5,12 +5,57 @@ export interface AuthUser {
|
|||||||
username: string;
|
username: string;
|
||||||
role?: string[];
|
role?: string[];
|
||||||
tenantId?: string | null;
|
tenantId?: string | null;
|
||||||
|
gatewayTenantId?: string;
|
||||||
|
tenantKey?: string;
|
||||||
sso_id?: string;
|
sso_id?: string;
|
||||||
|
source?: 'gateway' | 'server-main' | 'sync' | string;
|
||||||
|
gatewayUserId?: string;
|
||||||
|
userGroupId?: string;
|
||||||
|
userGroupKey?: string;
|
||||||
|
userGroupKeys?: string[];
|
||||||
apiKeyId?: string;
|
apiKeyId?: string;
|
||||||
apiKeySecret?: string;
|
apiKeySecret?: string;
|
||||||
apiKeyName?: 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 {
|
export interface IntegrationPlatform {
|
||||||
id: string;
|
id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
@ -89,6 +134,156 @@ export interface PricingRule {
|
|||||||
updatedAt: string;
|
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 {
|
export interface RateLimitRule {
|
||||||
metric: RateLimitMetric;
|
metric: RateLimitMetric;
|
||||||
limit: number;
|
limit: number;
|
||||||
@ -147,7 +342,14 @@ export interface GatewayTask {
|
|||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
gatewayUserId?: string;
|
||||||
|
userSource?: 'gateway' | 'server-main' | 'sync' | string;
|
||||||
|
gatewayTenantId?: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
|
tenantKey?: string;
|
||||||
|
userGroupId?: string;
|
||||||
|
userGroupKey?: string;
|
||||||
|
userGroupPolicySnapshot?: Record<string, unknown>;
|
||||||
model: string;
|
model: string;
|
||||||
request?: Record<string, unknown>;
|
request?: Record<string, unknown>;
|
||||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string;
|
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string;
|
||||||
|
|||||||
@ -5,6 +5,16 @@ CONTAINER="${AI_GATEWAY_PG_CONTAINER:-easyai-pgvector}"
|
|||||||
PGUSER="${AI_GATEWAY_PG_USER:-easyai}"
|
PGUSER="${AI_GATEWAY_PG_USER:-easyai}"
|
||||||
DB_NAME="${AI_GATEWAY_DATABASE_NAME:-easyai_ai_gateway}"
|
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="$(
|
exists="$(
|
||||||
docker exec "$CONTAINER" \
|
docker exec "$CONTAINER" \
|
||||||
psql -U "$PGUSER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" \
|
psql -U "$PGUSER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" \
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user