feat(acceptance): 增加生产同构媒体压力验收模式
引入动态流量门禁、隔离验收身份与协议级 Gemini/Volces 模拟器,覆盖双站点 API、Worker、PostgreSQL、River、账务、回调和媒体物化链路。 新增 P24/P28/P32 容量阶梯、Worker 强杀恢复、真实小流量 canary、CAS 放量和失败保持 validation 的生产编排;Worker 执行槽、连接池、媒体并发和双站点副本数改为环境配置。 验证:Go 全量测试、真实 PostgreSQL 迁移集成测试、迁移安全检查、OpenAPI 生成、ShellCheck、Kustomize、gofmt 和 git diff --check。
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const SystemSettingGatewayTrafficMode = "gateway_traffic_mode"
|
||||
|
||||
var (
|
||||
acceptanceReleaseSHAPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
acceptanceDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProductionTrafficPaused = errors.New("production traffic is paused for validation")
|
||||
ErrAcceptanceNotAuthorized = errors.New("acceptance request is not authorized")
|
||||
ErrAcceptanceRunNotActive = errors.New("acceptance run is not active")
|
||||
ErrAcceptanceStateConflict = errors.New("acceptance state changed")
|
||||
ErrAcceptancePromotionGates = errors.New("acceptance promotion gates have not passed")
|
||||
)
|
||||
|
||||
type GatewayTrafficMode struct {
|
||||
Mode string `json:"mode"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
Revision int64 `json:"revision"`
|
||||
ReleaseSHA string `json:"releaseSha,omitempty"`
|
||||
APIImageDigest string `json:"apiImageDigest,omitempty"`
|
||||
WorkerImageDigest string `json:"workerImageDigest,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AcceptanceRun struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ReleaseSHA string `json:"releaseSha"`
|
||||
APIImageDigest string `json:"apiImageDigest"`
|
||||
WorkerImageDigest string `json:"workerImageDigest"`
|
||||
APIKeyID string `json:"apiKeyId"`
|
||||
UserID string `json:"userId"`
|
||||
EmulatorBaseURL string `json:"emulatorBaseUrl"`
|
||||
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||
CapacityProfile string `json:"capacityProfile"`
|
||||
Config map[string]any `json:"config"`
|
||||
Report map[string]any `json:"report"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
PromotedAt string `json:"promotedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CreateAcceptanceRunInput struct {
|
||||
ReleaseSHA string `json:"releaseSha"`
|
||||
APIImageDigest string `json:"apiImageDigest"`
|
||||
WorkerImageDigest string `json:"workerImageDigest"`
|
||||
APIKeyID string `json:"apiKeyId"`
|
||||
UserID string `json:"userId"`
|
||||
Token string `json:"token"`
|
||||
EmulatorBaseURL string `json:"emulatorBaseUrl"`
|
||||
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||
CapacityProfile string `json:"capacityProfile"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type FinishAcceptanceRunInput struct {
|
||||
RunID string `json:"-"`
|
||||
Passed bool `json:"passed"`
|
||||
Report map[string]any `json:"report"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
}
|
||||
|
||||
type PromoteAcceptanceRunInput struct {
|
||||
RunID string `json:"-"`
|
||||
Revision int64 `json:"revision"`
|
||||
ReleaseSHA string `json:"releaseSha"`
|
||||
APIImageDigest string `json:"apiImageDigest"`
|
||||
WorkerImageDigest string `json:"workerImageDigest"`
|
||||
}
|
||||
|
||||
func DefaultGatewayTrafficMode() GatewayTrafficMode {
|
||||
return GatewayTrafficMode{Mode: "live"}
|
||||
}
|
||||
|
||||
func (s *Store) GetGatewayTrafficMode(ctx context.Context) (GatewayTrafficMode, error) {
|
||||
var value []byte
|
||||
var updatedAt time.Time
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT value, updated_at
|
||||
FROM system_settings
|
||||
WHERE setting_key = $1`, SystemSettingGatewayTrafficMode).Scan(&value, &updatedAt)
|
||||
if err != nil {
|
||||
if IsNotFound(err) || IsUndefinedDatabaseObject(err) {
|
||||
return DefaultGatewayTrafficMode(), nil
|
||||
}
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
var mode GatewayTrafficMode
|
||||
if err := json.Unmarshal(value, &mode); err != nil {
|
||||
return GatewayTrafficMode{}, fmt.Errorf("decode gateway traffic mode: %w", err)
|
||||
}
|
||||
mode.Mode = normalizeTrafficMode(mode.Mode)
|
||||
mode.UpdatedAt = updatedAt
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAcceptanceRun(ctx context.Context, input CreateAcceptanceRunInput) (AcceptanceRun, error) {
|
||||
input.ReleaseSHA = strings.TrimSpace(input.ReleaseSHA)
|
||||
input.APIImageDigest = strings.TrimSpace(input.APIImageDigest)
|
||||
input.WorkerImageDigest = strings.TrimSpace(input.WorkerImageDigest)
|
||||
input.APIKeyID = strings.TrimSpace(input.APIKeyID)
|
||||
input.UserID = strings.TrimSpace(input.UserID)
|
||||
input.Token = strings.TrimSpace(input.Token)
|
||||
input.EmulatorBaseURL = strings.TrimRight(strings.TrimSpace(input.EmulatorBaseURL), "/")
|
||||
input.CallbackURL = strings.TrimSpace(input.CallbackURL)
|
||||
input.CapacityProfile = strings.ToUpper(strings.TrimSpace(input.CapacityProfile))
|
||||
if input.CapacityProfile == "" {
|
||||
input.CapacityProfile = "P24"
|
||||
}
|
||||
if input.ReleaseSHA == "" || input.APIImageDigest == "" || input.WorkerImageDigest == "" ||
|
||||
input.APIKeyID == "" || input.UserID == "" || input.Token == "" || input.EmulatorBaseURL == "" ||
|
||||
input.CallbackURL == "" {
|
||||
return AcceptanceRun{}, errors.New("release SHA, image digests, API key, user, token, emulator URL, and callback URL are required")
|
||||
}
|
||||
if !acceptanceReleaseSHAPattern.MatchString(input.ReleaseSHA) ||
|
||||
!acceptanceDigestPattern.MatchString(input.APIImageDigest) ||
|
||||
!acceptanceDigestPattern.MatchString(input.WorkerImageDigest) {
|
||||
return AcceptanceRun{}, errors.New("release SHA and image digests must use full immutable identifiers")
|
||||
}
|
||||
if len(input.Token) < 32 {
|
||||
return AcceptanceRun{}, errors.New("acceptance token must contain at least 32 characters")
|
||||
}
|
||||
if !validAcceptanceURL(input.EmulatorBaseURL) || !validAcceptanceURL(input.CallbackURL) {
|
||||
return AcceptanceRun{}, errors.New("emulator and callback URLs must be absolute HTTP URLs without user information")
|
||||
}
|
||||
switch input.CapacityProfile {
|
||||
case "P24", "P28", "P32":
|
||||
default:
|
||||
return AcceptanceRun{}, errors.New("capacity profile must be P24, P28, or P32")
|
||||
}
|
||||
config, _ := json.Marshal(sanitizeAcceptanceMetadata(sanitizeJSONForStorage(input.Config)))
|
||||
tokenHash := acceptanceTokenSHA256(input.Token)
|
||||
return scanAcceptanceRun(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_acceptance_runs (
|
||||
release_sha, api_image_digest, worker_image_digest, api_key_id, user_id,
|
||||
token_sha256, emulator_base_url, callback_url, capacity_profile, config
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), $9, $10::jsonb)
|
||||
RETURNING `+acceptanceRunColumns,
|
||||
input.ReleaseSHA, input.APIImageDigest, input.WorkerImageDigest, input.APIKeyID, input.UserID,
|
||||
tokenHash, input.EmulatorBaseURL, input.CallbackURL, input.CapacityProfile, config,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) GetAcceptanceRun(ctx context.Context, runID string) (AcceptanceRun, error) {
|
||||
return scanAcceptanceRun(s.pool.QueryRow(ctx, `
|
||||
SELECT `+acceptanceRunColumns+`
|
||||
FROM gateway_acceptance_runs
|
||||
WHERE id = $1::uuid`, strings.TrimSpace(runID)))
|
||||
}
|
||||
|
||||
func (s *Store) ActivateAcceptanceRun(ctx context.Context, runID string) (GatewayTrafficMode, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
defer rollbackTransaction(tx)
|
||||
|
||||
var currentValue []byte
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT value
|
||||
FROM system_settings
|
||||
WHERE setting_key = $1
|
||||
FOR UPDATE`, SystemSettingGatewayTrafficMode).Scan(¤tValue); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
var current GatewayTrafficMode
|
||||
if err := json.Unmarshal(currentValue, ¤t); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if normalizeTrafficMode(current.Mode) != "live" {
|
||||
return GatewayTrafficMode{}, ErrAcceptanceStateConflict
|
||||
}
|
||||
run, err := scanAcceptanceRun(tx.QueryRow(ctx, `
|
||||
SELECT `+acceptanceRunColumns+`
|
||||
FROM gateway_acceptance_runs
|
||||
WHERE id = $1::uuid
|
||||
FOR UPDATE`, strings.TrimSpace(runID)))
|
||||
if err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if run.Status != "pending" && run.Status != "failed" {
|
||||
return GatewayTrafficMode{}, ErrAcceptanceStateConflict
|
||||
}
|
||||
next := GatewayTrafficMode{
|
||||
Mode: "validation",
|
||||
RunID: run.ID,
|
||||
Revision: current.Revision + 1,
|
||||
ReleaseSHA: run.ReleaseSHA,
|
||||
APIImageDigest: run.APIImageDigest,
|
||||
WorkerImageDigest: run.WorkerImageDigest,
|
||||
}
|
||||
value, _ := json.Marshal(next)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE system_settings
|
||||
SET value = $2::jsonb, updated_at = now()
|
||||
WHERE setting_key = $1`, SystemSettingGatewayTrafficMode, value); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_acceptance_runs
|
||||
SET status = 'running', started_at = COALESCE(started_at, now()),
|
||||
finished_at = NULL, failure_reason = NULL, updated_at = now()
|
||||
WHERE id = $1::uuid`, run.ID); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
next.UpdatedAt = time.Now()
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (s *Store) FinishAcceptanceRun(ctx context.Context, input FinishAcceptanceRunInput) (AcceptanceRun, error) {
|
||||
status := "failed"
|
||||
if input.Passed {
|
||||
status = "passed"
|
||||
}
|
||||
report, _ := json.Marshal(sanitizeAcceptanceMetadata(sanitizeJSONForStorage(input.Report)))
|
||||
return scanAcceptanceRun(s.pool.QueryRow(ctx, `
|
||||
UPDATE gateway_acceptance_runs
|
||||
SET status = $2, report = $3::jsonb, failure_reason = NULLIF($4, ''),
|
||||
finished_at = now(), updated_at = now()
|
||||
WHERE id = $1::uuid AND status = 'running'
|
||||
RETURNING `+acceptanceRunColumns,
|
||||
strings.TrimSpace(input.RunID), status, report, strings.TrimSpace(input.FailureReason),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) RetryAcceptanceRun(ctx context.Context, runID string) (AcceptanceRun, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return AcceptanceRun{}, err
|
||||
}
|
||||
defer rollbackTransaction(tx)
|
||||
var value []byte
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT value
|
||||
FROM system_settings
|
||||
WHERE setting_key = $1
|
||||
FOR UPDATE`, SystemSettingGatewayTrafficMode).Scan(&value); err != nil {
|
||||
return AcceptanceRun{}, err
|
||||
}
|
||||
var mode GatewayTrafficMode
|
||||
if err := json.Unmarshal(value, &mode); err != nil {
|
||||
return AcceptanceRun{}, err
|
||||
}
|
||||
if normalizeTrafficMode(mode.Mode) != "validation" || mode.RunID != strings.TrimSpace(runID) {
|
||||
return AcceptanceRun{}, ErrAcceptanceStateConflict
|
||||
}
|
||||
run, err := scanAcceptanceRun(tx.QueryRow(ctx, `
|
||||
UPDATE gateway_acceptance_runs
|
||||
SET status = 'running', report = '{}'::jsonb, failure_reason = NULL,
|
||||
finished_at = NULL, updated_at = now()
|
||||
WHERE id = $1::uuid AND status = 'failed'
|
||||
RETURNING `+acceptanceRunColumns, strings.TrimSpace(runID)))
|
||||
if err != nil {
|
||||
return AcceptanceRun{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return AcceptanceRun{}, err
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (s *Store) PromoteAcceptanceRun(ctx context.Context, input PromoteAcceptanceRunInput) (GatewayTrafficMode, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
defer rollbackTransaction(tx)
|
||||
var value []byte
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT value
|
||||
FROM system_settings
|
||||
WHERE setting_key = $1
|
||||
FOR UPDATE`, SystemSettingGatewayTrafficMode).Scan(&value); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
var current GatewayTrafficMode
|
||||
if err := json.Unmarshal(value, ¤t); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if normalizeTrafficMode(current.Mode) != "validation" ||
|
||||
current.RunID != strings.TrimSpace(input.RunID) ||
|
||||
current.Revision != input.Revision ||
|
||||
current.ReleaseSHA != strings.TrimSpace(input.ReleaseSHA) ||
|
||||
current.APIImageDigest != strings.TrimSpace(input.APIImageDigest) ||
|
||||
current.WorkerImageDigest != strings.TrimSpace(input.WorkerImageDigest) {
|
||||
return GatewayTrafficMode{}, ErrAcceptanceStateConflict
|
||||
}
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT status
|
||||
FROM gateway_acceptance_runs
|
||||
WHERE id = $1::uuid
|
||||
FOR UPDATE`, current.RunID).Scan(&status); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if status != "passed" {
|
||||
return GatewayTrafficMode{}, ErrAcceptancePromotionGates
|
||||
}
|
||||
next := GatewayTrafficMode{Mode: "live", Revision: current.Revision + 1}
|
||||
nextValue, _ := json.Marshal(next)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE system_settings
|
||||
SET value = $2::jsonb, updated_at = now()
|
||||
WHERE setting_key = $1`, SystemSettingGatewayTrafficMode, nextValue); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_acceptance_runs
|
||||
SET status = 'promoted', promoted_at = now(), updated_at = now()
|
||||
WHERE id = $1::uuid`, current.RunID); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
next.UpdatedAt = time.Now()
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (s *Store) AbortAcceptanceRun(ctx context.Context, input PromoteAcceptanceRunInput) (GatewayTrafficMode, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
defer rollbackTransaction(tx)
|
||||
var value []byte
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT value
|
||||
FROM system_settings
|
||||
WHERE setting_key = $1
|
||||
FOR UPDATE`, SystemSettingGatewayTrafficMode).Scan(&value); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
var current GatewayTrafficMode
|
||||
if err := json.Unmarshal(value, ¤t); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if normalizeTrafficMode(current.Mode) != "validation" ||
|
||||
current.RunID != strings.TrimSpace(input.RunID) ||
|
||||
current.Revision != input.Revision ||
|
||||
current.ReleaseSHA != strings.TrimSpace(input.ReleaseSHA) ||
|
||||
current.APIImageDigest != strings.TrimSpace(input.APIImageDigest) ||
|
||||
current.WorkerImageDigest != strings.TrimSpace(input.WorkerImageDigest) {
|
||||
return GatewayTrafficMode{}, ErrAcceptanceStateConflict
|
||||
}
|
||||
next := GatewayTrafficMode{Mode: "live", Revision: current.Revision + 1}
|
||||
nextValue, _ := json.Marshal(next)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE system_settings
|
||||
SET value = $2::jsonb, updated_at = now()
|
||||
WHERE setting_key = $1`, SystemSettingGatewayTrafficMode, nextValue); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_acceptance_runs
|
||||
SET status = 'aborted', finished_at = COALESCE(finished_at, now()), updated_at = now()
|
||||
WHERE id = $1::uuid AND status IN ('running', 'failed', 'passed')`, current.RunID); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return GatewayTrafficMode{}, err
|
||||
}
|
||||
next.UpdatedAt = time.Now()
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (s *Store) AuthorizeAcceptanceTask(ctx context.Context, runID string, token string, user *auth.User) (string, error) {
|
||||
mode, err := s.GetGatewayTrafficMode(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
runID = strings.TrimSpace(runID)
|
||||
token = strings.TrimSpace(token)
|
||||
if mode.Mode == "live" {
|
||||
if runID != "" || token != "" {
|
||||
return "", ErrAcceptanceRunNotActive
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if runID == "" || token == "" || runID != mode.RunID {
|
||||
return "", ErrProductionTrafficPaused
|
||||
}
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
return "", ErrAcceptanceNotAuthorized
|
||||
}
|
||||
var activeRunID, apiKeyID, userID, tokenHash, status string
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, api_key_id, user_id, token_sha256, status
|
||||
FROM gateway_acceptance_runs
|
||||
WHERE id = $1::uuid`, runID).Scan(&activeRunID, &apiKeyID, &userID, &tokenHash, &status)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrAcceptanceNotAuthorized
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if status != "running" || apiKeyID != strings.TrimSpace(user.APIKeyID) || userID != strings.TrimSpace(user.ID) ||
|
||||
subtle.ConstantTimeCompare([]byte(tokenHash), []byte(acceptanceTokenSHA256(token))) != 1 {
|
||||
return "", ErrAcceptanceNotAuthorized
|
||||
}
|
||||
return activeRunID, nil
|
||||
}
|
||||
|
||||
func (s *Store) AcceptanceCandidateOverride(ctx context.Context, runID string) (string, string, error) {
|
||||
var baseURL, status string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT emulator_base_url, status
|
||||
FROM gateway_acceptance_runs
|
||||
WHERE id = $1::uuid`, strings.TrimSpace(runID)).Scan(&baseURL, &status)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if status != "running" && status != "passed" {
|
||||
return "", "", ErrAcceptanceRunNotActive
|
||||
}
|
||||
return strings.TrimRight(strings.TrimSpace(baseURL), "/"), "acceptance-protocol-emulator", nil
|
||||
}
|
||||
|
||||
func (s *Store) AcceptanceCallbackURL(ctx context.Context, taskID string) (string, bool, error) {
|
||||
var callbackURL string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(run.callback_url, '')
|
||||
FROM gateway_tasks task
|
||||
JOIN gateway_acceptance_runs run ON run.id = task.acceptance_run_id
|
||||
WHERE task.id = $1::uuid`, strings.TrimSpace(taskID)).Scan(&callbackURL)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return strings.TrimSpace(callbackURL), true, nil
|
||||
}
|
||||
|
||||
func normalizeTrafficMode(value string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "validation") {
|
||||
return "validation"
|
||||
}
|
||||
return "live"
|
||||
}
|
||||
|
||||
func acceptanceTokenSHA256(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func validAcceptanceURL(raw string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.User != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
return parsed.Scheme == "http" || parsed.Scheme == "https"
|
||||
}
|
||||
|
||||
func sanitizeAcceptanceMetadata(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
next := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "").Replace(key))
|
||||
if strings.Contains(normalized, "password") ||
|
||||
strings.Contains(normalized, "secret") ||
|
||||
strings.Contains(normalized, "credential") ||
|
||||
strings.Contains(normalized, "connectionstring") ||
|
||||
strings.Contains(normalized, "databaseurl") ||
|
||||
(strings.Contains(normalized, "token") && !strings.HasSuffix(normalized, "count")) {
|
||||
next[key] = "[REDACTED]"
|
||||
continue
|
||||
}
|
||||
next[key] = sanitizeAcceptanceMetadata(item)
|
||||
}
|
||||
return next
|
||||
case []any:
|
||||
next := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
next[index] = sanitizeAcceptanceMetadata(item)
|
||||
}
|
||||
return next
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const acceptanceRunColumns = `
|
||||
id::text, status, release_sha, api_image_digest, worker_image_digest,
|
||||
api_key_id, user_id, emulator_base_url, COALESCE(callback_url, ''),
|
||||
capacity_profile, config, report, COALESCE(failure_reason, ''),
|
||||
COALESCE(started_at::text, ''), COALESCE(finished_at::text, ''),
|
||||
COALESCE(promoted_at::text, ''), created_at, updated_at`
|
||||
|
||||
func scanAcceptanceRun(scanner taskScanner) (AcceptanceRun, error) {
|
||||
var run AcceptanceRun
|
||||
var config, report []byte
|
||||
err := scanner.Scan(
|
||||
&run.ID, &run.Status, &run.ReleaseSHA, &run.APIImageDigest, &run.WorkerImageDigest,
|
||||
&run.APIKeyID, &run.UserID, &run.EmulatorBaseURL, &run.CallbackURL,
|
||||
&run.CapacityProfile, &config, &report, &run.FailureReason,
|
||||
&run.StartedAt, &run.FinishedAt, &run.PromotedAt, &run.CreatedAt, &run.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return AcceptanceRun{}, err
|
||||
}
|
||||
run.Config = decodeObject(config)
|
||||
run.Report = decodeObject(report)
|
||||
return run, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
func TestAcceptanceTrafficGateAndCASPromotion(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run acceptance traffic integration test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
applyOIDCJITTestMigrations(t, ctx, databaseURL)
|
||||
db, err := Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
resetTrafficMode := func() {
|
||||
_, _ = db.pool.Exec(context.Background(), `
|
||||
UPDATE system_settings
|
||||
SET value = '{"mode":"live","revision":0}'::jsonb, updated_at = now()
|
||||
WHERE setting_key = $1`, SystemSettingGatewayTrafficMode)
|
||||
}
|
||||
resetTrafficMode()
|
||||
t.Cleanup(resetTrafficMode)
|
||||
|
||||
token := "acceptance-token-" + time.Now().Format("150405.000000000")
|
||||
run, err := db.CreateAcceptanceRun(ctx, CreateAcceptanceRunInput{
|
||||
ReleaseSHA: strings.Repeat("a", 40),
|
||||
APIImageDigest: "sha256:" + strings.Repeat("b", 64),
|
||||
WorkerImageDigest: "sha256:" + strings.Repeat("c", 64),
|
||||
APIKeyID: "acceptance-api-key",
|
||||
UserID: "acceptance-user",
|
||||
Token: token,
|
||||
EmulatorBaseURL: "http://acceptance-emulator:8090",
|
||||
CallbackURL: "http://acceptance-emulator:8090/callbacks",
|
||||
CapacityProfile: "P24",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create acceptance run: %v", err)
|
||||
}
|
||||
encoded, _ := json.Marshal(run)
|
||||
if strings.Contains(string(encoded), token) {
|
||||
t.Fatal("acceptance run response exposed the raw token")
|
||||
}
|
||||
mode, err := db.ActivateAcceptanceRun(ctx, run.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("activate acceptance run: %v", err)
|
||||
}
|
||||
if mode.Mode != "validation" || mode.RunID != run.ID || mode.Revision != 1 {
|
||||
t.Fatalf("unexpected validation mode: %+v", mode)
|
||||
}
|
||||
user := &auth.User{ID: "acceptance-user", APIKeyID: "acceptance-api-key"}
|
||||
if _, err := db.AuthorizeAcceptanceTask(ctx, "", "", user); !errors.Is(err, ErrProductionTrafficPaused) {
|
||||
t.Fatalf("production request error=%v, want traffic paused", err)
|
||||
}
|
||||
if _, err := db.AuthorizeAcceptanceTask(ctx, run.ID, "wrong-token", user); !errors.Is(err, ErrAcceptanceNotAuthorized) {
|
||||
t.Fatalf("wrong acceptance token error=%v", err)
|
||||
}
|
||||
if authorizedRunID, err := db.AuthorizeAcceptanceTask(ctx, run.ID, token, user); err != nil || authorizedRunID != run.ID {
|
||||
t.Fatalf("authorize acceptance task run=%q err=%v", authorizedRunID, err)
|
||||
}
|
||||
if baseURL, credential, err := db.AcceptanceCandidateOverride(ctx, run.ID); err != nil ||
|
||||
baseURL != "http://acceptance-emulator:8090" || credential == "" || credential == token {
|
||||
t.Fatalf("candidate override base=%q credential=%q err=%v", baseURL, credential, err)
|
||||
}
|
||||
if _, err := db.FinishAcceptanceRun(ctx, FinishAcceptanceRunInput{
|
||||
RunID: run.ID, Passed: true, Report: map[string]any{"queueFinal": 0},
|
||||
}); err != nil {
|
||||
t.Fatalf("finish acceptance run: %v", err)
|
||||
}
|
||||
promotion := PromoteAcceptanceRunInput{
|
||||
RunID: run.ID, Revision: mode.Revision,
|
||||
ReleaseSHA: mode.ReleaseSHA, APIImageDigest: mode.APIImageDigest, WorkerImageDigest: mode.WorkerImageDigest,
|
||||
}
|
||||
stale := promotion
|
||||
stale.Revision++
|
||||
if _, err := db.PromoteAcceptanceRun(ctx, stale); !errors.Is(err, ErrAcceptanceStateConflict) {
|
||||
t.Fatalf("stale promotion error=%v, want state conflict", err)
|
||||
}
|
||||
live, err := db.PromoteAcceptanceRun(ctx, promotion)
|
||||
if err != nil {
|
||||
t.Fatalf("promote acceptance run: %v", err)
|
||||
}
|
||||
if live.Mode != "live" || live.Revision != 2 {
|
||||
t.Fatalf("unexpected live mode: %+v", live)
|
||||
}
|
||||
|
||||
failedRun, err := db.CreateAcceptanceRun(ctx, CreateAcceptanceRunInput{
|
||||
ReleaseSHA: strings.Repeat("d", 40),
|
||||
APIImageDigest: "sha256:" + strings.Repeat("e", 64),
|
||||
WorkerImageDigest: "sha256:" + strings.Repeat("f", 64),
|
||||
APIKeyID: "acceptance-api-key",
|
||||
UserID: "acceptance-user",
|
||||
Token: token + "-retry",
|
||||
EmulatorBaseURL: "http://acceptance-emulator:8090",
|
||||
CallbackURL: "http://acceptance-emulator:8090/callbacks",
|
||||
CapacityProfile: "P24",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create retry acceptance run: %v", err)
|
||||
}
|
||||
failedMode, err := db.ActivateAcceptanceRun(ctx, failedRun.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("activate retry acceptance run: %v", err)
|
||||
}
|
||||
if _, err := db.FinishAcceptanceRun(ctx, FinishAcceptanceRunInput{
|
||||
RunID: failedRun.ID, Passed: false, FailureReason: "load failed",
|
||||
}); err != nil {
|
||||
t.Fatalf("fail acceptance run: %v", err)
|
||||
}
|
||||
if retried, err := db.RetryAcceptanceRun(ctx, failedRun.ID); err != nil || retried.Status != "running" {
|
||||
t.Fatalf("retry acceptance run=%+v err=%v", retried, err)
|
||||
}
|
||||
aborted, err := db.AbortAcceptanceRun(ctx, PromoteAcceptanceRunInput{
|
||||
RunID: failedRun.ID, Revision: failedMode.Revision,
|
||||
ReleaseSHA: failedMode.ReleaseSHA, APIImageDigest: failedMode.APIImageDigest,
|
||||
WorkerImageDigest: failedMode.WorkerImageDigest,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("abort acceptance run: %v", err)
|
||||
}
|
||||
if aborted.Mode != "live" || aborted.Revision != failedMode.Revision+1 {
|
||||
t.Fatalf("unexpected aborted mode: %+v", aborted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAcceptanceMetadataRedactsSecrets(t *testing.T) {
|
||||
value := sanitizeAcceptanceMetadata(map[string]any{
|
||||
"token": "raw-token",
|
||||
"nested": map[string]any{
|
||||
"databaseUrl": "postgresql://example.invalid/database",
|
||||
"taskCount": 1200,
|
||||
},
|
||||
})
|
||||
next := value.(map[string]any)
|
||||
if next["token"] != "[REDACTED]" {
|
||||
t.Fatalf("token=%v", next["token"])
|
||||
}
|
||||
nested := next["nested"].(map[string]any)
|
||||
if nested["databaseUrl"] != "[REDACTED]" || nested["taskCount"] != 1200 {
|
||||
t.Fatalf("nested=%v", nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceURLsMustBeAbsoluteAndCredentialFree(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"http://acceptance-emulator.easyai.svc.cluster.local:8090",
|
||||
"https://acceptance.example.invalid/callbacks",
|
||||
} {
|
||||
if !validAcceptanceURL(raw) {
|
||||
t.Fatalf("valid URL rejected: %s", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"relative/path", "ftp://example.invalid/file", "https://user:pass@example.invalid"} {
|
||||
if validAcceptanceURL(raw) {
|
||||
t.Fatalf("invalid URL accepted: %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,6 +545,7 @@ type CreateTaskInput struct {
|
||||
Model string `json:"model"`
|
||||
ExternalTaskID string `json:"externalTaskId,omitempty"`
|
||||
RunMode string `json:"runMode"`
|
||||
AcceptanceRunID string `json:"acceptanceRunId,omitempty"`
|
||||
Async bool `json:"async"`
|
||||
Request map[string]any `json:"request"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
@@ -564,6 +565,7 @@ type GatewayTask struct {
|
||||
ExternalTaskID string `json:"externalTaskId,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
RunMode string `json:"runMode"`
|
||||
AcceptanceRunID string `json:"acceptanceRunId,omitempty"`
|
||||
UserID string `json:"userId"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserSource string `json:"userSource,omitempty"`
|
||||
@@ -629,7 +631,8 @@ type GatewayTask struct {
|
||||
}
|
||||
|
||||
const gatewayTaskColumns = `
|
||||
id::text, COALESCE(external_task_id, ''), kind, run_mode, user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
||||
id::text, COALESCE(external_task_id, ''), kind, run_mode, COALESCE(acceptance_run_id::text, ''),
|
||||
user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||
COALESCE(api_key_id, ''), COALESCE(api_key_name, ''), COALESCE(api_key_prefix, ''),
|
||||
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model,
|
||||
@@ -2103,15 +2106,19 @@ func (s *Store) CreateTaskIdempotent(ctx context.Context, input CreateTaskInput,
|
||||
|
||||
task, err := scanGatewayTask(tx.QueryRow(ctx, `
|
||||
INSERT INTO gateway_tasks (
|
||||
external_task_id, kind, run_mode, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
||||
external_task_id, kind, run_mode, acceptance_run_id, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
||||
api_key_id, api_key_name, api_key_prefix, user_group_id, user_group_key,
|
||||
model, requested_model, request, async_mode, status, result, billings, conversation_id, new_message_count,
|
||||
idempotency_key_hash, idempotency_request_hash, finished_at
|
||||
)
|
||||
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5, '')::uuid, COALESCE(NULLIF($6, ''), 'gateway'), NULLIF($7, '')::uuid, NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, ''), NULLIF($13, '')::uuid, NULLIF($14, ''), $15, $15, $16, $17, $18, $19::jsonb, $20::jsonb, NULLIF($21, '')::uuid, $22, NULLIF($23, ''), NULLIF($24, ''), NULL)
|
||||
VALUES (NULLIF($1, ''), $2, $3, NULLIF($4, '')::uuid, $5, NULLIF($6, '')::uuid, COALESCE(NULLIF($7, ''), 'gateway'), NULLIF($8, '')::uuid, NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, ''), NULLIF($13, ''), NULLIF($14, '')::uuid, NULLIF($15, ''), $16, $16, $17, $18, $19, $20::jsonb, $21::jsonb, NULLIF($22, '')::uuid, $23, NULLIF($24, ''), NULLIF($25, ''), NULL)
|
||||
ON CONFLICT (user_id, idempotency_key_hash) WHERE idempotency_key_hash IS NOT NULL DO NOTHING
|
||||
RETURNING `+gatewayTaskColumns,
|
||||
strings.TrimSpace(input.ExternalTaskID), input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, input.Async, status, resultBody, billingsBody, input.ConversationID, input.NewMessageCount, strings.TrimSpace(input.IdempotencyKeyHash), strings.TrimSpace(input.IdempotencyRequestHash),
|
||||
strings.TrimSpace(input.ExternalTaskID), input.Kind, runMode, strings.TrimSpace(input.AcceptanceRunID),
|
||||
user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey,
|
||||
user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey,
|
||||
input.Model, requestBody, input.Async, status, resultBody, billingsBody, input.ConversationID,
|
||||
input.NewMessageCount, strings.TrimSpace(input.IdempotencyKeyHash), strings.TrimSpace(input.IdempotencyRequestHash),
|
||||
))
|
||||
replayed := false
|
||||
if errors.Is(err, pgx.ErrNoRows) && strings.TrimSpace(input.IdempotencyKeyHash) != "" {
|
||||
@@ -2204,6 +2211,7 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
|
||||
&task.ExternalTaskID,
|
||||
&task.Kind,
|
||||
&task.RunMode,
|
||||
&task.AcceptanceRunID,
|
||||
&task.UserID,
|
||||
&task.GatewayUserID,
|
||||
&task.UserSource,
|
||||
@@ -2461,6 +2469,12 @@ func normalizeRunMode(input string, request map[string]any) string {
|
||||
if mode == "simulation" {
|
||||
return "simulation"
|
||||
}
|
||||
if mode == "acceptance" {
|
||||
return "acceptance"
|
||||
}
|
||||
if mode == "acceptance_canary" {
|
||||
return "acceptance_canary"
|
||||
}
|
||||
return "production"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package store
|
||||
|
||||
type PostgresPoolMetricsSnapshot struct {
|
||||
MaxConnections int32
|
||||
TotalConnections int32
|
||||
AcquiredConnections int32
|
||||
IdleConnections int32
|
||||
EmptyAcquireCount int64
|
||||
CanceledAcquireCount int64
|
||||
}
|
||||
|
||||
func (s *Store) PostgresPoolMetrics() PostgresPoolMetricsSnapshot {
|
||||
if s == nil || s.pool == nil {
|
||||
return PostgresPoolMetricsSnapshot{}
|
||||
}
|
||||
statistics := s.pool.Stat()
|
||||
return PostgresPoolMetricsSnapshot{
|
||||
MaxConnections: statistics.MaxConns(),
|
||||
TotalConnections: statistics.TotalConns(),
|
||||
AcquiredConnections: statistics.AcquiredConns(),
|
||||
IdleConnections: statistics.IdleConns(),
|
||||
EmptyAcquireCount: statistics.EmptyAcquireCount(),
|
||||
CanceledAcquireCount: statistics.CanceledAcquireCount(),
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,7 @@ func (s *Store) ClaimTaskExecution(ctx context.Context, taskID string, execution
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT status = 'queued' AND next_run_at <= now(),
|
||||
status = 'running' AND (execution_lease_expires_at IS NULL OR execution_lease_expires_at <= now()),
|
||||
run_mode = 'production',
|
||||
run_mode IN ('production', 'acceptance', 'acceptance_canary'),
|
||||
gateway_user_id IS NOT NULL
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
@@ -431,7 +431,7 @@ SELECT status = 'queued'
|
||||
OR execution_lease_expires_at IS NULL
|
||||
OR execution_lease_expires_at <= now()
|
||||
),
|
||||
run_mode = 'production',
|
||||
run_mode IN ('production', 'acceptance', 'acceptance_canary'),
|
||||
gateway_user_id IS NOT NULL
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
@@ -513,7 +513,7 @@ SET status = 'failed',
|
||||
error_code = NULLIF($2, ''),
|
||||
error_message = NULLIF($3, ''),
|
||||
billing_status = CASE
|
||||
WHEN run_mode <> 'production' OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN run_mode NOT IN ('production', 'acceptance', 'acceptance_canary') OR gateway_user_id IS NULL THEN 'not_required'
|
||||
ELSE 'released'
|
||||
END,
|
||||
billing_updated_at = now(),
|
||||
@@ -910,7 +910,7 @@ SET status = 'cancelled',
|
||||
error_code = 'client_disconnected',
|
||||
error_message = NULLIF($3::text, ''),
|
||||
billing_status = CASE
|
||||
WHEN run_mode <> 'production' OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN run_mode NOT IN ('production', 'acceptance', 'acceptance_canary') OR gateway_user_id IS NULL THEN 'not_required'
|
||||
ELSE 'released'
|
||||
END,
|
||||
billing_updated_at = now(),
|
||||
@@ -991,7 +991,7 @@ SET status = 'cancelled',
|
||||
error_code = 'task_cancelled',
|
||||
error_message = NULLIF($2, ''),
|
||||
billing_status = CASE
|
||||
WHEN run_mode <> 'production' OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN run_mode NOT IN ('production', 'acceptance', 'acceptance_canary') OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN reservation_amount > 0 THEN 'pending'
|
||||
ELSE 'released'
|
||||
END,
|
||||
@@ -1027,7 +1027,7 @@ SELECT id, 'task.billing.release', 'release', reservation_amount, billing_curren
|
||||
pricing_snapshot, $2::jsonb, 'pending', now()
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND run_mode IN ('production', 'acceptance', 'acceptance_canary')
|
||||
AND gateway_user_id IS NOT NULL
|
||||
AND reservation_amount > 0
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`, taskID, string(payloadJSON))
|
||||
@@ -1569,7 +1569,7 @@ SET status = 'succeeded',
|
||||
final_charge_amount = $9::numeric,
|
||||
billing_version = 'effective-pricing-v2',
|
||||
billing_status = CASE
|
||||
WHEN run_mode = 'production' AND gateway_user_id IS NOT NULL THEN 'pending'
|
||||
WHEN run_mode IN ('production', 'acceptance', 'acceptance_canary') AND gateway_user_id IS NOT NULL THEN 'pending'
|
||||
ELSE 'not_required'
|
||||
END,
|
||||
billing_currency = $10,
|
||||
@@ -1624,7 +1624,7 @@ INSERT INTO settlement_outbox (
|
||||
SELECT id, 'task.billing.settle', 'settle', $2::numeric, $3, $4::jsonb, $5::jsonb, 'pending', now()
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND run_mode IN ('production', 'acceptance', 'acceptance_canary')
|
||||
AND gateway_user_id IS NOT NULL
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`,
|
||||
input.TaskID, finalChargeAmount, currency, string(pricingSnapshotJSON), string(payloadJSON))
|
||||
@@ -1725,7 +1725,7 @@ SELECT id, 'task.billing.review', 'release', reservation_amount, billing_currenc
|
||||
pricing_snapshot, $2::jsonb, 'manual_review', now(), $3
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND run_mode IN ('production', 'acceptance', 'acceptance_canary')
|
||||
AND gateway_user_id IS NOT NULL
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`, input.TaskID, string(payloadJSON), input.Code)
|
||||
return err
|
||||
@@ -1902,7 +1902,7 @@ func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureIn
|
||||
response_duration_ms = $8,
|
||||
result = $9::jsonb,
|
||||
billing_status = CASE
|
||||
WHEN run_mode <> 'production' OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN run_mode NOT IN ('production', 'acceptance', 'acceptance_canary') OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN reservation_amount > 0 THEN 'pending'
|
||||
ELSE 'released'
|
||||
END,
|
||||
@@ -1944,7 +1944,7 @@ SELECT id, 'task.billing.release', 'release', reservation_amount, billing_curren
|
||||
pricing_snapshot, $2::jsonb, 'pending', now()
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND run_mode IN ('production', 'acceptance', 'acceptance_canary')
|
||||
AND gateway_user_id IS NOT NULL
|
||||
AND reservation_amount > 0
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`, input.TaskID, string(payloadJSON))
|
||||
|
||||
Reference in New Issue
Block a user