新增 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。
90 lines
2.7 KiB
Go
90 lines
2.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
var errInvalidTaskIdempotencyKey = errors.New("invalid Idempotency-Key header")
|
|
|
|
func optionalTaskIdempotencyKey(r *http.Request) (string, bool, error) {
|
|
values := r.Header.Values("Idempotency-Key")
|
|
if len(values) == 0 {
|
|
return "", false, nil
|
|
}
|
|
if len(values) != 1 {
|
|
return "", false, errInvalidTaskIdempotencyKey
|
|
}
|
|
value := strings.TrimSpace(values[0])
|
|
if value == "" || len(value) > 255 || strings.Contains(value, ",") {
|
|
return "", false, errInvalidTaskIdempotencyKey
|
|
}
|
|
return value, true, nil
|
|
}
|
|
|
|
func taskIdempotencyKeyHash(key string) string {
|
|
digest := sha256.Sum256([]byte(key))
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func taskIdempotencyRequestHash(kind string, async bool, stream bool, body map[string]any) string {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"kind": kind, "async": async, "stream": stream, "request": body,
|
|
})
|
|
digest := sha256.Sum256(payload)
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func writeIdempotentTaskReplay(w http.ResponseWriter, task store.GatewayTask, compatible bool) {
|
|
w.Header().Set("Idempotent-Replayed", "true")
|
|
w.Header().Set("X-Gateway-Task-Id", task.ID)
|
|
if !compatible || task.AsyncMode || (task.Status != "succeeded" && task.Status != "failed" && task.Status != "cancelled") {
|
|
writeTaskAccepted(w, task)
|
|
return
|
|
}
|
|
if task.Status == "succeeded" {
|
|
writeJSON(w, http.StatusOK, task.Result)
|
|
return
|
|
}
|
|
status := storedTaskErrorStatus(task.ErrorCode)
|
|
message := strings.TrimSpace(task.ErrorMessage)
|
|
if message == "" {
|
|
message = strings.TrimSpace(task.Error)
|
|
}
|
|
if message == "" {
|
|
message = "task failed"
|
|
}
|
|
code := strings.TrimSpace(task.ErrorCode)
|
|
if code == "" {
|
|
code = "task_failed"
|
|
}
|
|
writeError(w, status, message, code)
|
|
}
|
|
|
|
func storedTaskErrorStatus(code string) int {
|
|
switch strings.TrimSpace(code) {
|
|
case "storage_write_failed", "storage_read_failed", "storage_config_invalid", "storage_auth_failed":
|
|
return http.StatusServiceUnavailable
|
|
case "binary_result_expired", "result_expired", "result_unavailable":
|
|
return http.StatusGone
|
|
case "pricing_unavailable", "response_chain_unavailable", "billing_hold":
|
|
return http.StatusServiceUnavailable
|
|
case "insufficient_balance":
|
|
return http.StatusPaymentRequired
|
|
case "bad_request", "invalid_parameter", "invalid_previous_response_id", "unsupported_operation":
|
|
return http.StatusBadRequest
|
|
case "no_model_candidate", "cloned_voice_not_found":
|
|
return http.StatusNotFound
|
|
case "rate_limit", "platform_cooling_down", "model_cooling_down":
|
|
return http.StatusTooManyRequests
|
|
default:
|
|
return http.StatusBadGateway
|
|
}
|
|
}
|