feat: scaffold ai gateway identity and design
This commit is contained in:
@@ -5,4 +5,13 @@ go 1.23
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
ID string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
Roles []string `json:"role,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
SSOID string `json:"sso_id,omitempty"`
|
||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||
APIKeySecret string `json:"apiKeySecret,omitempty"`
|
||||
APIKeyName string `json:"apiKeyName,omitempty"`
|
||||
ID string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
Roles []string `json:"role,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
SSOID string `json:"sso_id,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserGroupID string `json:"userGroupId,omitempty"`
|
||||
UserGroupKey string `json:"userGroupKey,omitempty"`
|
||||
UserGroupKeys []string `json:"userGroupKeys,omitempty"`
|
||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||
APIKeySecret string `json:"apiKeySecret,omitempty"`
|
||||
APIKeyName string `json:"apiKeyName,omitempty"`
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
@@ -41,15 +48,15 @@ const userContextKey contextKey = "easyai-auth-user"
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
|
||||
type Authenticator struct {
|
||||
JWTSecret string
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
HTTPClient *http.Client
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator {
|
||||
return &Authenticator{
|
||||
JWTSecret: jwtSecret,
|
||||
JWTSecret: jwtSecret,
|
||||
ServerMainBaseURL: strings.TrimRight(serverMainBaseURL, "/"),
|
||||
ServerMainInternalToken: internalToken,
|
||||
HTTPClient: &http.Client{
|
||||
@@ -112,14 +119,24 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
|
||||
}
|
||||
|
||||
user := &User{
|
||||
ID: stringClaim(claims, "sub"),
|
||||
Username: stringClaim(claims, "username"),
|
||||
Roles: stringSliceClaim(claims, "role"),
|
||||
TenantID: stringClaim(claims, "tenantId"),
|
||||
SSOID: stringClaim(claims, "sso_id"),
|
||||
APIKeyID: stringClaim(claims, "apiKeyId"),
|
||||
APIKeySecret: stringClaim(claims, "apiKeySecret"),
|
||||
APIKeyName: stringClaim(claims, "apiKeyName"),
|
||||
ID: stringClaim(claims, "sub"),
|
||||
Username: stringClaim(claims, "username"),
|
||||
Roles: stringSliceClaim(claims, "role"),
|
||||
TenantID: stringClaim(claims, "tenantId"),
|
||||
GatewayTenantID: stringClaim(claims, "gatewayTenantId"),
|
||||
TenantKey: stringClaim(claims, "tenantKey"),
|
||||
SSOID: stringClaim(claims, "sso_id"),
|
||||
Source: stringClaim(claims, "source"),
|
||||
GatewayUserID: stringClaim(claims, "gatewayUserId"),
|
||||
UserGroupID: stringClaim(claims, "userGroupId"),
|
||||
UserGroupKey: stringClaim(claims, "userGroupKey"),
|
||||
UserGroupKeys: stringSliceClaim(claims, "userGroupKeys"),
|
||||
APIKeyID: stringClaim(claims, "apiKeyId"),
|
||||
APIKeySecret: stringClaim(claims, "apiKeySecret"),
|
||||
APIKeyName: stringClaim(claims, "apiKeyName"),
|
||||
}
|
||||
if user.Source == "" {
|
||||
user.Source = "gateway"
|
||||
}
|
||||
if user.ID == "" {
|
||||
return nil, ErrUnauthorized
|
||||
@@ -127,6 +144,30 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = time.Hour
|
||||
}
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Roles,
|
||||
"tenantId": user.TenantID,
|
||||
"gatewayTenantId": user.GatewayTenantID,
|
||||
"tenantKey": user.TenantKey,
|
||||
"source": user.Source,
|
||||
"gatewayUserId": user.GatewayUserID,
|
||||
"userGroupId": user.UserGroupID,
|
||||
"userGroupKey": user.UserGroupKey,
|
||||
"userGroupKeys": user.UserGroupKeys,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(a.JWTSecret))
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
|
||||
if a.ServerMainBaseURL == "" || a.ServerMainInternalToken == "" {
|
||||
return nil, ErrUnauthorized
|
||||
@@ -154,6 +195,9 @@ func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User,
|
||||
if user.ID == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if user.Source == "" {
|
||||
user.Source = "server-main"
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,19 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
CORSAllowedOrigin string
|
||||
LogLevel slog.Level
|
||||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
IdentityMode string
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
TaskProgressCallbackEnabled bool
|
||||
TaskProgressCallbackURL string
|
||||
TaskProgressCallbackTimeoutMS string
|
||||
TaskProgressCallbackMaxAttempts string
|
||||
CORSAllowedOrigin string
|
||||
LogLevel slog.Level
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -23,14 +28,21 @@ func Load() Config {
|
||||
AppEnv: env("APP_ENV", "development"),
|
||||
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
||||
DatabaseURL: gatewayDatabaseURL(),
|
||||
IdentityMode: env("IDENTITY_MODE", "hybrid"),
|
||||
JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"),
|
||||
ServerMainBaseURL: strings.TrimRight(
|
||||
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
|
||||
"/",
|
||||
),
|
||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178"),
|
||||
LogLevel: logLevel(env("LOG_LEVEL", "info")),
|
||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
|
||||
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
|
||||
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
|
||||
),
|
||||
TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"),
|
||||
TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"),
|
||||
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178"),
|
||||
LogLevel: logLevel(env("LOG_LEVEL", "info")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
@@ -12,9 +14,10 @@ import (
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"service": "easyai-ai-gateway",
|
||||
"env": s.cfg.AppEnv,
|
||||
"ok": true,
|
||||
"service": "easyai-ai-gateway",
|
||||
"env": s.cfg.AppEnv,
|
||||
"identityMode": s.cfg.IdentityMode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,6 +34,100 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.localIdentityEnabled() {
|
||||
writeError(w, http.StatusForbidden, "local registration is disabled")
|
||||
return
|
||||
}
|
||||
var input store.LocalRegisterInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
user, err := s.store.RegisterLocalUser(r.Context(), input)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrWeakPassword) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrInvalidInvitation) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.logger.Error("register local user failed", "error", err)
|
||||
writeError(w, http.StatusConflict, "user already exists or tenant is unavailable")
|
||||
return
|
||||
}
|
||||
s.writeAuthResponse(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.localIdentityEnabled() {
|
||||
writeError(w, http.StatusForbidden, "local login is disabled")
|
||||
return
|
||||
}
|
||||
var input store.LocalLoginInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
user, err := s.store.AuthenticateLocalUser(r.Context(), input)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid account or password")
|
||||
return
|
||||
}
|
||||
s.logger.Error("login local user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "login failed")
|
||||
return
|
||||
}
|
||||
s.writeAuthResponse(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) localIdentityEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(s.cfg.IdentityMode))
|
||||
return mode == "" || mode == "standalone" || mode == "hybrid"
|
||||
}
|
||||
|
||||
func (s *Server) writeAuthResponse(w http.ResponseWriter, status int, user store.GatewayUser) {
|
||||
authUser := authUserFromGatewayUser(user)
|
||||
const ttl = 24 * time.Hour
|
||||
token, err := s.auth.SignJWT(authUser, ttl)
|
||||
if err != nil {
|
||||
s.logger.Error("sign local jwt failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "token sign failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, status, map[string]any{
|
||||
"accessToken": token,
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": int(ttl.Seconds()),
|
||||
"user": authUser,
|
||||
})
|
||||
}
|
||||
|
||||
func authUserFromGatewayUser(user store.GatewayUser) *auth.User {
|
||||
roles := user.Roles
|
||||
if len(roles) == 0 {
|
||||
roles = []string{"user"}
|
||||
}
|
||||
tenantID := user.TenantID
|
||||
if tenantID == "" {
|
||||
tenantID = user.TenantKey
|
||||
}
|
||||
return &auth.User{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Roles: roles,
|
||||
TenantID: tenantID,
|
||||
GatewayTenantID: user.GatewayTenantID,
|
||||
TenantKey: user.TenantKey,
|
||||
Source: "gateway",
|
||||
GatewayUserID: user.ID,
|
||||
UserGroupID: user.DefaultUserGroupID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) listPlatforms(w http.ResponseWriter, r *http.Request) {
|
||||
platforms, err := s.store.ListPlatforms(r.Context())
|
||||
if err != nil {
|
||||
@@ -103,6 +200,36 @@ func (s *Server) listPricingRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) listTenants(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListTenants(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("list tenants failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list tenants failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("list users failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list users failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) listUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListUserGroups(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("list user groups failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list user groups failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
|
||||
@@ -29,13 +29,18 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
mux.HandleFunc("GET /healthz", server.health)
|
||||
mux.HandleFunc("GET /readyz", server.ready)
|
||||
|
||||
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
|
||||
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
|
||||
mux.Handle("GET /api/v1/me", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.me)))
|
||||
mux.Handle("GET /api/v1/catalog/providers", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listCatalogProviders)))
|
||||
mux.Handle("GET /api/v1/catalog/base-models", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listBaseModels)))
|
||||
mux.Handle("GET /api/v1/tenants", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listTenants)))
|
||||
mux.Handle("GET /api/v1/users", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUsers)))
|
||||
mux.Handle("GET /api/v1/user-groups", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUserGroups)))
|
||||
mux.Handle("GET /api/v1/pricing/rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
|
||||
mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing)))
|
||||
mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
|
||||
mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.createPlatform)))
|
||||
mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
|
||||
mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModels)))
|
||||
mux.Handle("GET /api/v1/runtime/rate-limit-windows", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
|
||||
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions")))
|
||||
|
||||
@@ -3,17 +3,27 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid account or password")
|
||||
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
@@ -126,6 +136,83 @@ type PricingRule struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type GatewayTenant struct {
|
||||
ID string `json:"id"`
|
||||
TenantKey string `json:"tenantKey"`
|
||||
Source string `json:"source"`
|
||||
ExternalTenantID string `json:"externalTenantId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultUserGroupID string `json:"defaultUserGroupId,omitempty"`
|
||||
PlanKey string `json:"planKey,omitempty"`
|
||||
BillingProfile map[string]any `json:"billingProfile,omitempty"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||
AuthPolicy map[string]any `json:"authPolicy,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
SyncedAt string `json:"syncedAt,omitempty"`
|
||||
SourceUpdatedAt string `json:"sourceUpdatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type LocalRegisterInput struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TenantKey string `json:"tenantKey"`
|
||||
TenantName string `json:"tenantName"`
|
||||
InvitationCode string `json:"invitationCode"`
|
||||
}
|
||||
|
||||
type LocalLoginInput struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type GatewayUser struct {
|
||||
ID string `json:"id"`
|
||||
UserKey string `json:"userKey"`
|
||||
Source string `json:"source"`
|
||||
ExternalUserID string `json:"externalUserId,omitempty"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
DefaultUserGroupID string `json:"defaultUserGroupId,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
AuthProfile map[string]any `json:"authProfile,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
SyncedAt string `json:"syncedAt,omitempty"`
|
||||
SourceUpdatedAt string `json:"sourceUpdatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserGroup struct {
|
||||
ID string `json:"id"`
|
||||
GroupKey string `json:"groupKey"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Priority int `json:"priority"`
|
||||
RechargeDiscountPolicy map[string]any `json:"rechargeDiscountPolicy,omitempty"`
|
||||
BillingDiscountPolicy map[string]any `json:"billingDiscountPolicy,omitempty"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||
QuotaPolicy map[string]any `json:"quotaPolicy,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type RateLimitWindow struct {
|
||||
ScopeType string `json:"scopeType"`
|
||||
ScopeKey string `json:"scopeKey"`
|
||||
@@ -145,18 +232,24 @@ type CreateTaskInput struct {
|
||||
}
|
||||
|
||||
type GatewayTask struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
UserID string `json:"userId"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Request map[string]any `json:"request,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Billings []any `json:"billings,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
UserID string `json:"userId"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserSource string `json:"userSource,omitempty"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
UserGroupID string `json:"userGroupId,omitempty"`
|
||||
UserGroupKey string `json:"userGroupKey,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Request map[string]any `json:"request,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Billings []any `json:"billings,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (s *Store) ListPlatforms(ctx context.Context) ([]Platform, error) {
|
||||
@@ -408,6 +501,383 @@ ORDER BY scope_type ASC, resource_type ASC, created_at DESC`)
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListTenants(ctx context.Context) ([]GatewayTenant, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, tenant_key, source, COALESCE(external_tenant_id, ''), name, COALESCE(description, ''),
|
||||
COALESCE(default_user_group_id::text, ''), COALESCE(plan_key, ''), billing_profile, rate_limit_policy,
|
||||
auth_policy, metadata, status, COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
|
||||
created_at, updated_at
|
||||
FROM gateway_tenants
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]GatewayTenant, 0)
|
||||
for rows.Next() {
|
||||
var item GatewayTenant
|
||||
var billingProfile []byte
|
||||
var rateLimitPolicy []byte
|
||||
var authPolicy []byte
|
||||
var metadata []byte
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.TenantKey,
|
||||
&item.Source,
|
||||
&item.ExternalTenantID,
|
||||
&item.Name,
|
||||
&item.Description,
|
||||
&item.DefaultUserGroupID,
|
||||
&item.PlanKey,
|
||||
&billingProfile,
|
||||
&rateLimitPolicy,
|
||||
&authPolicy,
|
||||
&metadata,
|
||||
&item.Status,
|
||||
&item.SyncedAt,
|
||||
&item.SourceUpdatedAt,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.BillingProfile = decodeObject(billingProfile)
|
||||
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||
item.AuthPolicy = decodeObject(authPolicy)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]GatewayUser, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, user_key, source, COALESCE(external_user_id, ''), username,
|
||||
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
|
||||
status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
|
||||
created_at, updated_at
|
||||
FROM gateway_users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]GatewayUser, 0)
|
||||
for rows.Next() {
|
||||
var item GatewayUser
|
||||
var roles []byte
|
||||
var authProfile []byte
|
||||
var metadata []byte
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserKey,
|
||||
&item.Source,
|
||||
&item.ExternalUserID,
|
||||
&item.Username,
|
||||
&item.DisplayName,
|
||||
&item.Email,
|
||||
&item.Phone,
|
||||
&item.AvatarURL,
|
||||
&item.GatewayTenantID,
|
||||
&item.TenantID,
|
||||
&item.TenantKey,
|
||||
&item.DefaultUserGroupID,
|
||||
&roles,
|
||||
&authProfile,
|
||||
&metadata,
|
||||
&item.Status,
|
||||
&item.LastLoginAt,
|
||||
&item.SyncedAt,
|
||||
&item.SourceUpdatedAt,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Roles = decodeStringArray(roles)
|
||||
item.AuthProfile = decodeObject(authProfile)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListUserGroups(ctx context.Context) ([]UserGroup, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, group_key, name, COALESCE(description, ''), source, priority,
|
||||
recharge_discount_policy, billing_discount_policy, rate_limit_policy, quota_policy, metadata,
|
||||
status, created_at, updated_at
|
||||
FROM gateway_user_groups
|
||||
ORDER BY priority ASC, group_key ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]UserGroup, 0)
|
||||
for rows.Next() {
|
||||
var item UserGroup
|
||||
var rechargeDiscountPolicy []byte
|
||||
var billingDiscountPolicy []byte
|
||||
var rateLimitPolicy []byte
|
||||
var quotaPolicy []byte
|
||||
var metadata []byte
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.GroupKey,
|
||||
&item.Name,
|
||||
&item.Description,
|
||||
&item.Source,
|
||||
&item.Priority,
|
||||
&rechargeDiscountPolicy,
|
||||
&billingDiscountPolicy,
|
||||
&rateLimitPolicy,
|
||||
"aPolicy,
|
||||
&metadata,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.RechargeDiscountPolicy = decodeObject(rechargeDiscountPolicy)
|
||||
item.BillingDiscountPolicy = decodeObject(billingDiscountPolicy)
|
||||
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||
item.QuotaPolicy = decodeObject(quotaPolicy)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) RegisterLocalUser(ctx context.Context, input LocalRegisterInput) (GatewayUser, error) {
|
||||
account := normalizeAccount(firstNonEmpty(input.Username, input.Email))
|
||||
if account == "" {
|
||||
return GatewayUser{}, errors.New("username or email is required")
|
||||
}
|
||||
if len(input.Password) < 8 {
|
||||
return GatewayUser{}, ErrWeakPassword
|
||||
}
|
||||
tenantKey := normalizeKey(input.TenantKey)
|
||||
if tenantKey == "" {
|
||||
tenantKey = "personal-" + normalizeKey(account)
|
||||
}
|
||||
tenantName := strings.TrimSpace(input.TenantName)
|
||||
if tenantName == "" {
|
||||
tenantName = tenantKey
|
||||
}
|
||||
displayName := strings.TrimSpace(input.DisplayName)
|
||||
username := strings.TrimSpace(input.Username)
|
||||
if username == "" {
|
||||
username = account
|
||||
}
|
||||
email := strings.TrimSpace(strings.ToLower(input.Email))
|
||||
invitationCode := strings.TrimSpace(input.InvitationCode)
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var tenantID string
|
||||
userGroupID := ""
|
||||
role := "user"
|
||||
invitationID := ""
|
||||
if invitationCode != "" {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT i.id::text,
|
||||
i.tenant_id::text,
|
||||
t.tenant_key,
|
||||
t.name,
|
||||
COALESCE(i.user_group_id::text, t.default_user_group_id::text, ''),
|
||||
COALESCE(NULLIF(i.role, ''), 'user')
|
||||
FROM gateway_tenant_invitations i
|
||||
JOIN gateway_tenants t ON t.id = i.tenant_id
|
||||
WHERE lower(i.invite_code) = lower($1)
|
||||
AND i.status = 'active'
|
||||
AND t.status = 'active'
|
||||
AND (i.expires_at IS NULL OR i.expires_at > now())
|
||||
AND (i.max_uses IS NULL OR i.used_count < i.max_uses)
|
||||
FOR UPDATE OF i`,
|
||||
invitationCode,
|
||||
).Scan(&invitationID, &tenantID, &tenantKey, &tenantName, &userGroupID, &role); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GatewayUser{}, ErrInvalidInvitation
|
||||
}
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
} else if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO gateway_tenants (tenant_key, source, external_tenant_id, name)
|
||||
VALUES ($1, 'gateway', $1, $2)
|
||||
ON CONFLICT (tenant_key) DO UPDATE SET updated_at=now()
|
||||
RETURNING id::text`,
|
||||
tenantKey, tenantName,
|
||||
).Scan(&tenantID); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
|
||||
rolesJSON, err := json.Marshal([]string{role})
|
||||
if err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
|
||||
var user GatewayUser
|
||||
var roles []byte
|
||||
var authProfile []byte
|
||||
var metadata []byte
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO gateway_users (
|
||||
user_key, source, external_user_id, username, display_name, email,
|
||||
password_hash, gateway_tenant_id, tenant_id, tenant_key, default_user_group_id, roles, status
|
||||
)
|
||||
VALUES ($1, 'gateway', $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6, $7::uuid, $8, $8, NULLIF($9, '')::uuid, $10::jsonb, 'active')
|
||||
RETURNING id::text, user_key, source, COALESCE(external_user_id, ''), username,
|
||||
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
|
||||
status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
|
||||
created_at, updated_at`,
|
||||
"gateway:"+account, account, username, displayName, email, string(passwordHash), tenantID, tenantKey, userGroupID, string(rolesJSON),
|
||||
).Scan(
|
||||
&user.ID,
|
||||
&user.UserKey,
|
||||
&user.Source,
|
||||
&user.ExternalUserID,
|
||||
&user.Username,
|
||||
&user.DisplayName,
|
||||
&user.Email,
|
||||
&user.Phone,
|
||||
&user.AvatarURL,
|
||||
&user.GatewayTenantID,
|
||||
&user.TenantID,
|
||||
&user.TenantKey,
|
||||
&user.DefaultUserGroupID,
|
||||
&roles,
|
||||
&authProfile,
|
||||
&metadata,
|
||||
&user.Status,
|
||||
&user.LastLoginAt,
|
||||
&user.SyncedAt,
|
||||
&user.SourceUpdatedAt,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
if invitationID != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tenant_invitations
|
||||
SET used_count = used_count + 1, updated_at = now()
|
||||
WHERE id = $1::uuid`, invitationID); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
}
|
||||
if userGroupID != "" {
|
||||
metadata, err := json.Marshal(map[string]any{
|
||||
"source": "registration",
|
||||
"invitationId": invitationID,
|
||||
})
|
||||
if err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO gateway_user_group_memberships (group_id, principal_type, principal_id, source, metadata)
|
||||
VALUES ($1::uuid, 'user', $2, 'gateway', $3::jsonb)
|
||||
ON CONFLICT (group_id, principal_type, principal_id)
|
||||
DO UPDATE SET status = 'active', updated_at = now()`,
|
||||
userGroupID, user.ID, string(metadata),
|
||||
); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
user.Roles = decodeStringArray(roles)
|
||||
user.AuthProfile = decodeObject(authProfile)
|
||||
user.Metadata = decodeObject(metadata)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) AuthenticateLocalUser(ctx context.Context, input LocalLoginInput) (GatewayUser, error) {
|
||||
account := normalizeAccount(input.Account)
|
||||
if account == "" || input.Password == "" {
|
||||
return GatewayUser{}, ErrInvalidCredentials
|
||||
}
|
||||
var user GatewayUser
|
||||
var passwordHash string
|
||||
var roles []byte
|
||||
var authProfile []byte
|
||||
var metadata []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, user_key, source, COALESCE(external_user_id, ''), username,
|
||||
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
|
||||
status, COALESCE(password_hash, ''), COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''),
|
||||
COALESCE(source_updated_at::text, ''), created_at, updated_at
|
||||
FROM gateway_users
|
||||
WHERE source='gateway'
|
||||
AND deleted_at IS NULL
|
||||
AND (external_user_id=$1 OR lower(username)=$1 OR lower(COALESCE(email, ''))=$1)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`, account,
|
||||
).Scan(
|
||||
&user.ID,
|
||||
&user.UserKey,
|
||||
&user.Source,
|
||||
&user.ExternalUserID,
|
||||
&user.Username,
|
||||
&user.DisplayName,
|
||||
&user.Email,
|
||||
&user.Phone,
|
||||
&user.AvatarURL,
|
||||
&user.GatewayTenantID,
|
||||
&user.TenantID,
|
||||
&user.TenantKey,
|
||||
&user.DefaultUserGroupID,
|
||||
&roles,
|
||||
&authProfile,
|
||||
&metadata,
|
||||
&user.Status,
|
||||
&passwordHash,
|
||||
&user.LastLoginAt,
|
||||
&user.SyncedAt,
|
||||
&user.SourceUpdatedAt,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if IsNotFound(err) {
|
||||
return GatewayUser{}, ErrInvalidCredentials
|
||||
}
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
if user.Status != "active" || passwordHash == "" {
|
||||
return GatewayUser{}, ErrInvalidCredentials
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil {
|
||||
return GatewayUser{}, ErrInvalidCredentials
|
||||
}
|
||||
user.Roles = decodeStringArray(roles)
|
||||
user.AuthProfile = decodeObject(authProfile)
|
||||
user.Metadata = decodeObject(metadata)
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE gateway_users SET last_login_at=now(), updated_at=now() WHERE id=$1`, user.ID)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRateLimitWindows(ctx context.Context) ([]RateLimitWindow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT scope_type, scope_key, metric, window_start, limit_value::float8, used_value::float8,
|
||||
@@ -448,11 +918,16 @@ func (s *Store) CreateTask(ctx context.Context, input CreateTaskInput, user *aut
|
||||
var resultBytes []byte
|
||||
var billingsBytes []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_tasks (kind, user_id, tenant_id, model, request, status)
|
||||
VALUES ($1, $2, NULLIF($3, ''), $4, $5, 'queued')
|
||||
RETURNING id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`,
|
||||
input.Kind, user.ID, user.TenantID, input.Model, requestBody,
|
||||
).Scan(&task.ID, &task.Kind, &task.UserID, &task.TenantID, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
|
||||
INSERT INTO gateway_tasks (
|
||||
kind, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
||||
api_key_id, user_group_id, user_group_key, model, request, status
|
||||
)
|
||||
VALUES ($1, $2, NULLIF($3, '')::uuid, COALESCE(NULLIF($4, ''), 'gateway'), NULLIF($5, '')::uuid, NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, '')::uuid, NULLIF($10, ''), $11, $12, 'queued')
|
||||
RETURNING id::text, kind, user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`,
|
||||
input.Kind, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.UserGroupID, user.UserGroupKey, input.Model, requestBody,
|
||||
).Scan(&task.ID, &task.Kind, &task.UserID, &task.GatewayUserID, &task.UserSource, &task.GatewayTenantID, &task.TenantID, &task.TenantKey, &task.UserGroupID, &task.UserGroupKey, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
|
||||
if err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
@@ -468,10 +943,12 @@ func (s *Store) GetTask(ctx context.Context, taskID string) (GatewayTask, error)
|
||||
var resultBytes []byte
|
||||
var billingsBytes []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at
|
||||
SELECT id::text, kind, user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at
|
||||
FROM gateway_tasks
|
||||
WHERE id=$1`, taskID,
|
||||
).Scan(&task.ID, &task.Kind, &task.UserID, &task.TenantID, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
|
||||
).Scan(&task.ID, &task.Kind, &task.UserID, &task.GatewayUserID, &task.UserSource, &task.GatewayTenantID, &task.TenantID, &task.TenantKey, &task.UserGroupID, &task.UserGroupKey, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
|
||||
if err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
@@ -506,3 +983,50 @@ func decodeArray(bytes []byte) []any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeStringArray(bytes []byte) []string {
|
||||
if len(bytes) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal(bytes, &out); err == nil {
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeAccount(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func normalizeKey(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
case r == '-' || r == '_' || r == '.' || unicode.IsSpace(r):
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
return "default"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ CREATE TABLE IF NOT EXISTS integration_platforms (
|
||||
auth_type text NOT NULL DEFAULT 'bearer',
|
||||
credentials jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
visibility_scope text NOT NULL DEFAULT 'global',
|
||||
tenant_id text,
|
||||
tenant_key text,
|
||||
default_pricing_mode text NOT NULL DEFAULT 'inherit_discount',
|
||||
default_discount_factor numeric NOT NULL DEFAULT 1,
|
||||
retry_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
@@ -72,6 +75,9 @@ CREATE INDEX IF NOT EXISTS idx_integration_platforms_status_priority
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_platforms_cooldown
|
||||
ON integration_platforms(cooldown_until);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_platforms_tenant_scope
|
||||
ON integration_platforms(visibility_scope, tenant_id, tenant_key, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pricing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scope_type text NOT NULL,
|
||||
@@ -94,6 +100,240 @@ CREATE INDEX IF NOT EXISTS idx_model_pricing_scope
|
||||
CREATE INDEX IF NOT EXISTS idx_model_pricing_effective
|
||||
ON model_pricing_rules(effective_from, effective_to);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_user_groups (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_key text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
source text NOT NULL DEFAULT 'gateway',
|
||||
priority integer NOT NULL DEFAULT 100,
|
||||
recharge_discount_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
billing_discount_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
quota_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_user_groups_status_priority
|
||||
ON gateway_user_groups(status, priority);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_tenants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_key text NOT NULL UNIQUE,
|
||||
source text NOT NULL DEFAULT 'gateway',
|
||||
external_tenant_id text,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
default_user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
|
||||
plan_key text,
|
||||
billing_profile jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
auth_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
synced_at timestamptz,
|
||||
source_updated_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
UNIQUE(source, external_tenant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_tenants_source_external
|
||||
ON gateway_tenants(source, external_tenant_id)
|
||||
WHERE external_tenant_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_tenants_status
|
||||
ON gateway_tenants(status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_key text NOT NULL UNIQUE,
|
||||
source text NOT NULL DEFAULT 'gateway',
|
||||
external_user_id text,
|
||||
username text NOT NULL,
|
||||
display_name text,
|
||||
email text,
|
||||
phone text,
|
||||
avatar_url text,
|
||||
password_hash text,
|
||||
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
|
||||
tenant_id text,
|
||||
tenant_key text,
|
||||
default_user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
|
||||
roles jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
auth_profile jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
last_login_at timestamptz,
|
||||
synced_at timestamptz,
|
||||
source_updated_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
UNIQUE(source, external_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_users_source_external
|
||||
ON gateway_users(source, external_user_id)
|
||||
WHERE external_user_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_users_status
|
||||
ON gateway_users(status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_users_tenant
|
||||
ON gateway_users(tenant_id, tenant_key, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_user_group_memberships (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id uuid NOT NULL REFERENCES gateway_user_groups(id) ON DELETE CASCADE,
|
||||
principal_type text NOT NULL,
|
||||
principal_id text NOT NULL,
|
||||
source text NOT NULL DEFAULT 'gateway',
|
||||
priority integer NOT NULL DEFAULT 100,
|
||||
effective_from timestamptz,
|
||||
effective_to timestamptz,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(group_id, principal_type, principal_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_group_membership_principal
|
||||
ON gateway_user_group_memberships(principal_type, principal_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_group_membership_effective
|
||||
ON gateway_user_group_memberships(effective_from, effective_to);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_tenant_invitations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES gateway_tenants(id) ON DELETE CASCADE,
|
||||
invite_code text NOT NULL UNIQUE,
|
||||
role text NOT NULL DEFAULT 'user',
|
||||
user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
|
||||
max_uses integer,
|
||||
used_count integer NOT NULL DEFAULT 0,
|
||||
expires_at timestamptz,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_invitations_tenant
|
||||
ON gateway_tenant_invitations(tenant_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_invitations_expiry
|
||||
ON gateway_tenant_invitations(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_api_keys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
|
||||
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE,
|
||||
tenant_id text,
|
||||
tenant_key text,
|
||||
user_id text,
|
||||
key_prefix text NOT NULL,
|
||||
key_hash text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
scopes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
|
||||
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
quota_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
expires_at timestamptz,
|
||||
last_used_at timestamptz,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_owner
|
||||
ON gateway_api_keys(gateway_user_id, status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_api_keys_prefix
|
||||
ON gateway_api_keys(key_prefix, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_wallet_accounts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
|
||||
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE,
|
||||
tenant_id text,
|
||||
tenant_key text,
|
||||
user_id text,
|
||||
currency text NOT NULL DEFAULT 'resource',
|
||||
balance numeric NOT NULL DEFAULT 0,
|
||||
frozen_balance numeric NOT NULL DEFAULT 0,
|
||||
total_recharged numeric NOT NULL DEFAULT 0,
|
||||
total_spent numeric NOT NULL DEFAULT 0,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(gateway_user_id, currency)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_wallet_accounts_tenant
|
||||
ON gateway_wallet_accounts(gateway_tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_wallet_transactions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES gateway_wallet_accounts(id) ON DELETE CASCADE,
|
||||
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
|
||||
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
|
||||
direction text NOT NULL,
|
||||
transaction_type text NOT NULL,
|
||||
amount numeric NOT NULL,
|
||||
balance_before numeric NOT NULL,
|
||||
balance_after numeric NOT NULL,
|
||||
idempotency_key text,
|
||||
reference_type text,
|
||||
reference_id text,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_wallet_transactions_account
|
||||
ON gateway_wallet_transactions(account_id, created_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_wallet_tx_idempotency
|
||||
ON gateway_wallet_transactions(account_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_recharge_orders (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
|
||||
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE,
|
||||
tenant_id text,
|
||||
tenant_key text,
|
||||
user_id text,
|
||||
amount numeric NOT NULL,
|
||||
bonus_amount numeric NOT NULL DEFAULT 0,
|
||||
payable_amount numeric NOT NULL,
|
||||
currency text NOT NULL DEFAULT 'resource',
|
||||
channel text NOT NULL DEFAULT 'manual',
|
||||
status text NOT NULL DEFAULT 'created',
|
||||
external_order_id text,
|
||||
idempotency_key text,
|
||||
paid_at timestamptz,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_recharge_orders_user
|
||||
ON gateway_recharge_orders(gateway_user_id, created_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_recharge_order_idempotency
|
||||
ON gateway_recharge_orders(gateway_user_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_models (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform_id uuid NOT NULL REFERENCES integration_platforms(id) ON DELETE CASCADE,
|
||||
@@ -135,8 +375,15 @@ CREATE TABLE IF NOT EXISTS gateway_tasks (
|
||||
kind text NOT NULL,
|
||||
run_mode text NOT NULL DEFAULT 'production',
|
||||
user_id text NOT NULL,
|
||||
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
|
||||
user_source text NOT NULL DEFAULT 'gateway',
|
||||
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
|
||||
tenant_id text,
|
||||
tenant_key text,
|
||||
api_key_id text,
|
||||
user_group_id uuid REFERENCES gateway_user_groups(id) ON DELETE SET NULL,
|
||||
user_group_key text,
|
||||
user_group_policy_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
model text NOT NULL,
|
||||
model_type text,
|
||||
request jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
@@ -226,6 +473,29 @@ CREATE TABLE IF NOT EXISTS gateway_task_events (
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_events_task_created
|
||||
ON gateway_task_events(task_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_task_callback_outbox (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id uuid NOT NULL REFERENCES gateway_tasks(id) ON DELETE CASCADE,
|
||||
event_id uuid REFERENCES gateway_task_events(id) ON DELETE SET NULL,
|
||||
seq bigint NOT NULL,
|
||||
callback_url text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_error text,
|
||||
delivered_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(task_id, seq, callback_url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_callback_outbox_pending
|
||||
ON gateway_task_callback_outbox(status, next_attempt_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_callback_outbox_task
|
||||
ON gateway_task_callback_outbox(task_id, seq);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runtime_client_states (
|
||||
client_id text PRIMARY KEY,
|
||||
platform_id uuid REFERENCES integration_platforms(id) ON DELETE SET NULL,
|
||||
|
||||
Reference in New Issue
Block a user