Files
easyai-ai-gateway/apps/api/internal/store/response_chains.go
T
wangbo 4f163ea6d7 perf(storage): 拦截任务二进制并本地暂存结果
原因:任务标准结果中的 Base64、Data URI 和 Buffer 会进入 PostgreSQL JSON,导致 TOAST 与备份体积快速增长。

影响:新增统一 JSON 持久化关口;upload_none 将二进制原子写入本地结果目录,数据库仅保存带 SHA-256 的有界占位符;任务详情、同步响应、异步查询和兼容协议按需校验恢复。补充 24 小时清理、容量上限、历史小批量治理命令及管理端说明。

风险:本地结果超过 TTL、丢失或损坏时分别返回明确的 410/500;空间不足时返回 503 且不重试上游。未自动执行历史治理。

验证:三种真实图片模型同步/异步与幂等重放通过;Go vet/全量测试、前端 111 测试、lint/typecheck/build、OpenAPI、迁移安全、govulncheck、依赖审计、手工发布测试及 Linux amd64 构建通过。
2026-07-24 21:13:09 +08:00

184 lines
6.8 KiB
Go

package store
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
var ErrInvalidPreviousResponseID = errors.New("invalid previous response id")
var ErrResponseChainTooDeep = errors.New("response chain exceeds the maximum depth")
const maxResponseChainHistory = 100
type ResponseChain struct {
PublicResponseID string
ParentResponseID string
GatewayTaskID string
RequestedModel string
GatewayUserID string
UserID string
UserSource string
GatewayTenantID string
TenantID string
TenantKey string
APIKeyID string
PlatformID string
PlatformModelID string
ClientID string
UpstreamProtocol string
UpstreamEndpoint string
UpstreamResponseID string
RequestSnapshot map[string]any
ResponseSnapshot map[string]any
InternalSnapshot map[string]any
CreatedAt time.Time
ExpiresAt time.Time
}
type CreateResponseChainInput struct {
ResponseChain
}
func (s *Store) CreateResponseChain(ctx context.Context, input CreateResponseChainInput) error {
requestJSON, err := json.Marshal(sanitizeJSONForStorage(input.RequestSnapshot))
if err != nil {
return err
}
responseJSON, err := json.Marshal(sanitizeJSONForStorage(input.ResponseSnapshot))
if err != nil {
return err
}
internalJSON, err := json.Marshal(sanitizeJSONForStorage(input.InternalSnapshot))
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO gateway_response_chains (
public_response_id, parent_response_id, gateway_task_id,
gateway_user_id, user_id, user_source, gateway_tenant_id, tenant_id, tenant_key, api_key_id,
platform_id, platform_model_id, client_id, upstream_protocol, upstream_endpoint, upstream_response_id,
request_snapshot, response_snapshot, internal_snapshot, expires_at
) VALUES (
$1, NULLIF($2, ''), $3::uuid,
NULLIF($4, '')::uuid, $5, $6, NULLIF($7, '')::uuid, NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''),
$11::uuid, $12::uuid, $13, $14, $15, NULLIF($16, ''),
$17::jsonb, $18::jsonb, $19::jsonb, $20
)
ON CONFLICT (public_response_id) DO NOTHING`,
input.PublicResponseID, input.ParentResponseID, input.GatewayTaskID,
input.GatewayUserID, input.UserID, input.UserSource, input.GatewayTenantID, input.TenantID, input.TenantKey, input.APIKeyID,
input.PlatformID, input.PlatformModelID, input.ClientID, input.UpstreamProtocol, input.UpstreamEndpoint, input.UpstreamResponseID,
requestJSON, responseJSON, internalJSON, input.ExpiresAt,
)
return err
}
func (s *Store) GetResponseChain(ctx context.Context, publicResponseID string, owner GatewayTask) (ResponseChain, error) {
chain, err := s.scanResponseChain(s.pool.QueryRow(ctx, `
SELECT chain.public_response_id, COALESCE(chain.parent_response_id, ''), chain.gateway_task_id::text,
task.requested_model,
COALESCE(chain.gateway_user_id::text, ''), chain.user_id, chain.user_source,
COALESCE(chain.gateway_tenant_id::text, ''), COALESCE(chain.tenant_id, ''), COALESCE(chain.tenant_key, ''), COALESCE(chain.api_key_id, ''),
chain.platform_id::text, chain.platform_model_id::text, chain.client_id, chain.upstream_protocol, chain.upstream_endpoint,
COALESCE(chain.upstream_response_id, ''), chain.request_snapshot, chain.response_snapshot, chain.internal_snapshot,
chain.created_at, chain.expires_at
FROM gateway_response_chains chain
JOIN gateway_tasks task ON task.id = chain.gateway_task_id
WHERE chain.public_response_id = $1`, strings.TrimSpace(publicResponseID)))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ResponseChain{}, ErrInvalidPreviousResponseID
}
return ResponseChain{}, err
}
if chain.ExpiresAt.Before(time.Now()) || !sameResponseChainOwner(chain, owner) {
return ResponseChain{}, ErrInvalidPreviousResponseID
}
return chain, nil
}
func (s *Store) ListResponseChainHistory(ctx context.Context, current ResponseChain) ([]ResponseChain, error) {
rows, err := s.pool.Query(ctx, `
WITH RECURSIVE history AS (
SELECT chain.*, 0 AS depth
FROM gateway_response_chains chain
WHERE chain.public_response_id = $1
UNION ALL
SELECT parent.*, history.depth + 1
FROM gateway_response_chains parent
JOIN history ON parent.public_response_id = history.parent_response_id
WHERE history.depth < $2
)
SELECT history.public_response_id, COALESCE(history.parent_response_id, ''), history.gateway_task_id::text,
task.requested_model,
COALESCE(history.gateway_user_id::text, ''), history.user_id, history.user_source,
COALESCE(history.gateway_tenant_id::text, ''), COALESCE(history.tenant_id, ''), COALESCE(history.tenant_key, ''), COALESCE(history.api_key_id, ''),
history.platform_id::text, history.platform_model_id::text, history.client_id, history.upstream_protocol, history.upstream_endpoint,
COALESCE(history.upstream_response_id, ''), history.request_snapshot, history.response_snapshot, history.internal_snapshot,
history.created_at, history.expires_at
FROM history
JOIN gateway_tasks task ON task.id = history.gateway_task_id
ORDER BY depth DESC`, current.PublicResponseID, maxResponseChainHistory)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ResponseChain, 0)
for rows.Next() {
item, scanErr := s.scanResponseChain(rows)
if scanErr != nil {
return nil, scanErr
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) > maxResponseChainHistory {
return nil, ErrResponseChainTooDeep
}
return out, nil
}
type responseChainScanner interface {
Scan(dest ...any) error
}
func (s *Store) scanResponseChain(scanner responseChainScanner) (ResponseChain, error) {
var item ResponseChain
var requestJSON, responseJSON, internalJSON []byte
err := scanner.Scan(
&item.PublicResponseID, &item.ParentResponseID, &item.GatewayTaskID, &item.RequestedModel,
&item.GatewayUserID, &item.UserID, &item.UserSource,
&item.GatewayTenantID, &item.TenantID, &item.TenantKey, &item.APIKeyID,
&item.PlatformID, &item.PlatformModelID, &item.ClientID,
&item.UpstreamProtocol, &item.UpstreamEndpoint, &item.UpstreamResponseID,
&requestJSON, &responseJSON, &internalJSON, &item.CreatedAt, &item.ExpiresAt,
)
if err != nil {
return ResponseChain{}, err
}
item.RequestSnapshot = decodeObject(requestJSON)
item.ResponseSnapshot = decodeObject(responseJSON)
item.InternalSnapshot = decodeObject(internalJSON)
return item, nil
}
func sameResponseChainOwner(chain ResponseChain, task GatewayTask) bool {
if chain.GatewayUserID != "" || task.GatewayUserID != "" {
if chain.GatewayUserID == "" || chain.GatewayUserID != task.GatewayUserID {
return false
}
} else if chain.UserID != task.UserID || chain.UserSource != task.UserSource {
return false
}
return chain.GatewayTenantID == task.GatewayTenantID &&
chain.TenantID == task.TenantID &&
chain.TenantKey == task.TenantKey
}