feat(storage): 统一二进制对象存储与公开错误

新增 Aliyun OSS 与 S3 协议、通道内重试和按优先级跨通道切换,保留 server-main 兼容与环境 OSS 内存通道。

将请求及结果中的 Base64、Data URI、Buffer、multipart 和内联二进制统一对象化,生产路径不再写入本机静态目录,历史本地资源仅保留只读兼容。

引入 PublicErrorV1 并统一 API、异步查询、兼容协议和失败回调的安全错误输出,同时补充迁移、管理端、指标、OpenAPI 与本地模拟验收。

验证:go test ./... -count=1;go vet ./...;pnpm lint;pnpm test;pnpm build;pnpm openapi;tests/ci/migrations-test.sh。
This commit is contained in:
2026-08-04 08:14:39 +08:00
parent d129bcccbd
commit 0f0998cbcf
55 changed files with 3649 additions and 1008 deletions
+23 -1
View File
@@ -11,6 +11,7 @@ import (
"unicode/utf8"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
"github.com/jackc/pgx/v5"
)
@@ -544,12 +545,14 @@ WHERE id = $1::uuid
}
func (s *Store) FailQueuedTask(ctx context.Context, taskID string, code string, message string) (GatewayTask, error) {
publicErrorJSON := encodePublicErrorSnapshot(code, message, 0, true, "", taskID)
tag, err := s.pool.Exec(ctx, `
UPDATE gateway_tasks
SET status = 'failed',
error = NULL,
error_code = NULLIF($2, ''),
error_message = NULLIF($3, ''),
public_error = $4::jsonb,
billing_status = CASE
WHEN run_mode NOT IN ('production', 'acceptance', 'acceptance_canary') OR gateway_user_id IS NULL THEN 'not_required'
ELSE 'released'
@@ -563,7 +566,7 @@ SET status = 'failed',
finished_at = now(),
updated_at = now()
WHERE id = $1::uuid
AND status = 'queued'`, taskID, strings.TrimSpace(code), truncateUTF8Bytes(message, 2048))
AND status = 'queued'`, taskID, strings.TrimSpace(code), truncateUTF8Bytes(message, 2048), string(publicErrorJSON))
if err != nil {
return GatewayTask{}, err
}
@@ -686,6 +689,7 @@ SET status = 'queued',
error = NULL,
error_code = NULL,
error_message = NULL,
public_error = NULL,
updated_at = now()
WHERE id = $1::uuid
AND status IN ('queued', 'running')
@@ -722,6 +726,7 @@ SET status = 'queued',
error = NULL,
error_code = NULL,
error_message = NULL,
public_error = NULL,
updated_at = now()
WHERE task.id = $1::uuid
AND task.status IN ('queued', 'running')
@@ -1371,6 +1376,7 @@ SELECT a.id::text, a.task_id::text, a.attempt_no,
a.request_snapshot, COALESCE(a.response_snapshot, '{}'::jsonb),
COALESCE(a.response_started_at::text, ''), COALESCE(a.response_finished_at::text, ''),
COALESCE(a.response_duration_ms, 0), COALESCE(a.error_code, ''), COALESCE(a.error_message, ''),
COALESCE(a.public_error, '{}'::jsonb),
COALESCE(a.pricing_snapshot, '{}'::jsonb), COALESCE(a.request_fingerprint, ''),
a.upstream_submission_status, COALESCE(a.upstream_submission_updated_at::text, ''),
a.started_at, COALESCE(a.finished_at::text, '')
@@ -1403,6 +1409,7 @@ func scanTaskAttempt(scanner taskScanner) (TaskAttempt, error) {
var requestBytes []byte
var responseBytes []byte
var pricingSnapshotBytes []byte
var publicErrorBytes []byte
if err := scanner.Scan(
&item.ID,
&item.TaskID,
@@ -1430,6 +1437,7 @@ func scanTaskAttempt(scanner taskScanner) (TaskAttempt, error) {
&item.ResponseDurationMS,
&item.ErrorCode,
&item.ErrorMessage,
&publicErrorBytes,
&pricingSnapshotBytes,
&item.RequestFingerprint,
&item.UpstreamSubmissionStatus,
@@ -1444,6 +1452,12 @@ func scanTaskAttempt(scanner taskScanner) (TaskAttempt, error) {
item.RequestSnapshot = decodeObject(requestBytes)
item.ResponseSnapshot = decodeObject(responseBytes)
item.PricingSnapshot = decodeObject(pricingSnapshotBytes)
if len(publicErrorBytes) > 0 {
var snapshot publicerror.Error
if json.Unmarshal(publicErrorBytes, &snapshot) == nil && snapshot.Code != "" {
item.PublicError = &snapshot
}
}
enrichTaskAttemptFromMetrics(&item)
return item, nil
}
@@ -1534,6 +1548,7 @@ func (s *Store) FinishTaskAttempt(ctx context.Context, input FinishTaskAttemptIn
}
usageJSON, _ := json.Marshal(sanitizeJSONForStorage(minimalTaskAttemptUsage(input.Usage)))
metricsJSON, _ := json.Marshal(sanitizeJSONForStorage(minimalTaskAttemptMetrics(input.Metrics)))
publicErrorJSON := encodePublicErrorSnapshot(input.ErrorCode, input.ErrorMessage, statusCode, input.Retryable, input.RequestID, "")
_, err := s.pool.Exec(ctx, `
UPDATE gateway_task_attempts
SET status = $2::text,
@@ -1550,6 +1565,7 @@ SET status = $2::text,
response_duration_ms = $8,
error_code = NULLIF($9::text, ''),
error_message = NULLIF(left($10::text, 2048), ''),
public_error = CASE WHEN $2::text = 'failed' THEN $14::jsonb ELSE NULL END,
upstream_submission_status = COALESCE(NULLIF($13::text, ''), upstream_submission_status),
upstream_submission_updated_at = CASE
WHEN NULLIF($13::text, '') IS NULL THEN upstream_submission_updated_at
@@ -1570,6 +1586,7 @@ WHERE id = $1::uuid`,
usageJSON,
metricsJSON,
input.UpstreamSubmissionStatus,
string(publicErrorJSON),
)
return err
}
@@ -1651,6 +1668,7 @@ SET status = 'succeeded',
response_duration_ms = $5,
error_code = NULL,
error_message = NULL,
public_error = NULL,
finished_at = now()
WHERE id = $1::uuid`,
input.AttemptID, input.RequestID,
@@ -1687,6 +1705,7 @@ SET status = 'succeeded',
error = NULL,
error_code = NULL,
error_message = NULL,
public_error = NULL,
locked_by = NULL,
locked_at = NULL,
heartbeat_at = NULL,
@@ -2049,6 +2068,7 @@ func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureIn
metricsJSON, _ := json.Marshal(sanitizeJSONForStorage(emptyObjectIfNil(input.Metrics)))
resultJSON, _ := json.Marshal(minimalTaskResult(nil))
message := truncateUTF8Bytes(input.Message, 2048)
publicErrorJSON := encodePublicErrorSnapshot(input.Code, message, 0, true, input.RequestID, input.TaskID)
finalizedAdmission := false
err := s.beginTransaction(ctx, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
@@ -2057,6 +2077,7 @@ func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureIn
error = NULL,
error_code = NULLIF($3::text, ''),
error_message = NULLIF($2::text, ''),
public_error = $11::jsonb,
request_id = NULLIF($4::text, ''),
metrics = $5::jsonb,
response_started_at = $6::timestamptz,
@@ -2090,6 +2111,7 @@ WHERE id = $1::uuid
input.ResponseDurationMS,
string(resultJSON),
input.ExecutionToken,
string(publicErrorJSON),
)
if err != nil {
return err