fix(media): 对齐 EasyAI 媒体响应与转存策略
统一媒体异步提交和轮询响应,扩展 /ai/result 到当前用户的 Gateway 媒体任务,并保持既有 Gateway 字段兼容。\n\n启用转存但无可用渠道时回退到 24 小时本地静态资源;关闭转存时保留图片、音频等上游 Base64 字段。同步更新文件上传兼容结构、OpenAPI、管理端说明和回归测试。\n\n验证:cd apps/api && env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1;gofmt -l 无输出;pnpm nx run web:typecheck。
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestEasyAITaskAcceptedResponseKeepsGatewayFieldsAndAddsLegacyFields(t *testing.T) {
|
||||
task := store.GatewayTask{
|
||||
ID: "task-accepted-1",
|
||||
Kind: "speech.generations",
|
||||
Status: "queued",
|
||||
AsyncMode: true,
|
||||
}
|
||||
|
||||
got := easyAITaskAcceptedResponse(task)
|
||||
if got["status"] != "submitted" || got["task_id"] != task.ID || got["taskId"] != task.ID {
|
||||
t.Fatalf("unexpected EasyAI submission identity: %+v", got)
|
||||
}
|
||||
if _, ok := got["task"].(store.GatewayTask); !ok {
|
||||
t.Fatalf("Gateway task envelope was removed: %+v", got)
|
||||
}
|
||||
next, _ := got["next"].(map[string]string)
|
||||
if next["detail"] != "/api/v1/tasks/"+task.ID ||
|
||||
next["events"] != "/api/v1/tasks/"+task.ID+"/events" ||
|
||||
next["result"] != "/api/v1/ai/result/"+task.ID {
|
||||
t.Fatalf("unexpected next links: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultStatus(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "queued", want: "submitted"},
|
||||
{input: "pending", want: "submitted"},
|
||||
{input: "running", want: "process"},
|
||||
{input: "processing", want: "process"},
|
||||
{input: "succeeded", want: "success"},
|
||||
{input: "completed", want: "success"},
|
||||
{input: "failed", want: "failed"},
|
||||
{input: "cancelled", want: "failed"},
|
||||
} {
|
||||
t.Run(item.input, func(t *testing.T) {
|
||||
if got := easyAITaskResultStatus(item.input); got != item.want {
|
||||
t.Fatalf("easyAITaskResultStatus(%q)=%q, want %q", item.input, got, item.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseNormalizesMediaOutputs(t *testing.T) {
|
||||
createdAt := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
task store.GatewayTask
|
||||
wantType string
|
||||
wantURL string
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "image alias",
|
||||
task: store.GatewayTask{
|
||||
ID: "image-1", Kind: "images.generations", Status: "succeeded", CreatedAt: createdAt,
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"image_url": "https://example.com/output.png",
|
||||
}}},
|
||||
},
|
||||
wantType: "image", wantURL: "https://example.com/output.png", wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "video raw content",
|
||||
task: store.GatewayTask{
|
||||
ID: "video-1", Kind: "videos.generations", Status: "succeeded", CreatedAt: createdAt,
|
||||
Result: map[string]any{"raw": map[string]any{"content": map[string]any{
|
||||
"video_url": "https://example.com/output.mp4",
|
||||
"duration": 5,
|
||||
}}},
|
||||
},
|
||||
wantType: "video", wantURL: "https://example.com/output.mp4", wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "audio alias",
|
||||
task: store.GatewayTask{
|
||||
ID: "audio-1", Kind: "speech.generations", Status: "succeeded", CreatedAt: createdAt,
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"audio_url": "https://example.com/output.wav",
|
||||
}}},
|
||||
},
|
||||
wantType: "audio", wantURL: "https://example.com/output.wav", wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "voice clone without media",
|
||||
task: store.GatewayTask{
|
||||
ID: "voice-1", Kind: "voice.clone", Status: "succeeded", CreatedAt: createdAt,
|
||||
Result: map[string]any{"voice_id": "voice-test-1", "cloned_voice": map[string]any{"voiceId": "voice-test-1"}},
|
||||
},
|
||||
wantCount: 0,
|
||||
},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
got := easyAITaskResultResponse(item.task)
|
||||
if got["status"] != "success" || got["task_id"] != item.task.ID || got["created"] != createdAt.UnixMilli() {
|
||||
t.Fatalf("unexpected task result identity: %+v", got)
|
||||
}
|
||||
data, _ := got["data"].([]any)
|
||||
if len(data) != item.wantCount {
|
||||
t.Fatalf("unexpected output count: got=%d response=%+v", len(data), got)
|
||||
}
|
||||
outputContent, _ := got["output_content"].([]any)
|
||||
if len(outputContent) != item.wantCount {
|
||||
t.Fatalf("output_content is not synchronized: %+v", got)
|
||||
}
|
||||
if item.wantCount == 0 {
|
||||
if got["voice_id"] != "voice-test-1" {
|
||||
t.Fatalf("voice clone fields were lost: %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["type"] != item.wantType || output["url"] != item.wantURL {
|
||||
t.Fatalf("unexpected normalized output: %+v", output)
|
||||
}
|
||||
if _, exposed := output["b64_json"]; exposed {
|
||||
t.Fatalf("base64 output was exposed: %+v", output)
|
||||
}
|
||||
urls, _ := got["output"].([]string)
|
||||
if len(urls) != 1 || urls[0] != item.wantURL {
|
||||
t.Fatalf("unexpected output URL list: %+v", got["output"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponsePreservesBase64ImageOutput(t *testing.T) {
|
||||
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
|
||||
task := store.GatewayTask{
|
||||
ID: "image-base64-1", Kind: "images.generations", Status: "succeeded",
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"b64_json": base64Payload,
|
||||
"mime_type": "image/png",
|
||||
}}},
|
||||
}
|
||||
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("unexpected base64 output count: %+v", got)
|
||||
}
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["type"] != "image" || output["b64_json"] != base64Payload || output["mime_type"] != "image/png" {
|
||||
t.Fatalf("base64 image output was not preserved: %+v", output)
|
||||
}
|
||||
if _, exposedAsURL := output["url"]; exposedAsURL {
|
||||
t.Fatalf("base64 image must not be exposed as a URL: %+v", output)
|
||||
}
|
||||
outputContent, _ := got["output_content"].([]any)
|
||||
content, _ := outputContent[0].(map[string]any)
|
||||
if content["b64_json"] != base64Payload {
|
||||
t.Fatalf("output_content lost base64 image: %+v", outputContent)
|
||||
}
|
||||
urls, _ := got["output"].([]string)
|
||||
if len(urls) != 0 {
|
||||
t.Fatalf("base64-only output must not create URL aliases: %+v", urls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseMovesImageDataURLToB64JSON(t *testing.T) {
|
||||
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
|
||||
task := store.GatewayTask{
|
||||
ID: "image-data-url-1", Kind: "images.generations", Status: "succeeded",
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"url": "data:image/png;base64," + base64Payload,
|
||||
}}},
|
||||
}
|
||||
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["b64_json"] != base64Payload || output["mime_type"] != "image/png" || output["type"] != "image" {
|
||||
t.Fatalf("image data URL was not normalized: %+v", output)
|
||||
}
|
||||
if _, exists := output["url"]; exists {
|
||||
t.Fatalf("image data URL should move out of the URL field: %+v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
|
||||
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
|
||||
task := store.GatewayTask{
|
||||
ID: "audio-data-url-1", Kind: "speech.generations", Status: "succeeded",
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"audio_url": "data:audio/mpeg;base64," + base64Payload,
|
||||
}}},
|
||||
}
|
||||
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["content"] != "data:audio/mpeg;base64,"+base64Payload ||
|
||||
output["mime_type"] != "audio/mpeg" ||
|
||||
output["type"] != "audio" {
|
||||
t.Fatalf("audio data URL was not normalized to inline content: %+v", output)
|
||||
}
|
||||
if _, exists := output["audio_url"]; exists {
|
||||
t.Fatalf("audio data URL should move out of the URL field: %+v", output)
|
||||
}
|
||||
urls, _ := got["output"].([]string)
|
||||
if len(urls) != 0 {
|
||||
t.Fatalf("base64-only audio must not create URL aliases: %+v", urls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseMapsVectorFormats(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
format string
|
||||
wantType string
|
||||
}{
|
||||
{format: "svg", wantType: "image"},
|
||||
{format: "png", wantType: "image"},
|
||||
{format: "pdf", wantType: "file"},
|
||||
{format: "eps", wantType: "file"},
|
||||
} {
|
||||
t.Run(item.format, func(t *testing.T) {
|
||||
task := store.GatewayTask{
|
||||
ID: "vector-" + item.format, Kind: "images.vectorize", Status: "succeeded",
|
||||
Request: map[string]any{"format": item.format},
|
||||
Result: map[string]any{"data": []any{map[string]any{"url": "https://example.com/output." + item.format}}},
|
||||
}
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["type"] != item.wantType {
|
||||
t.Fatalf("format %s mapped to %v, want %s", item.format, output["type"], item.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseClearsFailedOutputs(t *testing.T) {
|
||||
task := store.GatewayTask{
|
||||
ID: "failed-1", Kind: "videos.upscales", Status: "cancelled",
|
||||
ErrorCode: "cancelled", ErrorMessage: "任务已取消",
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"url": "https://example.com/partial.mp4",
|
||||
}}},
|
||||
}
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
outputContent, _ := got["output_content"].([]any)
|
||||
if got["status"] != "failed" || got["code"] != "cancelled" || got["message"] != "任务已取消" ||
|
||||
len(data) != 0 || len(outputContent) != 0 {
|
||||
t.Fatalf("unexpected failed response: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAISynchronousVectorAndFileUploadResponsesAreAdditive(t *testing.T) {
|
||||
vectorTask := store.GatewayTask{ID: "vector-sync-1", Kind: "images.vectorize"}
|
||||
vector := easyAISynchronousTaskResponse(vectorTask, map[string]any{
|
||||
"status": "success",
|
||||
"data": []any{map[string]any{"url": "https://example.com/output.svg"}},
|
||||
})
|
||||
if vector["status"] != "success" || vector["taskId"] != vectorTask.ID || vector["task_id"] != vectorTask.ID {
|
||||
t.Fatalf("unexpected vector response: %+v", vector)
|
||||
}
|
||||
|
||||
upload := easyAIFileUploadResponse(map[string]any{
|
||||
"id": "file-1", "url": "https://example.com/upload.png", "filename": "upload.png",
|
||||
})
|
||||
data, _ := upload["data"].(map[string]any)
|
||||
if upload["status"] != "success" || upload["url"] != data["url"] || upload["id"] != data["id"] {
|
||||
t.Fatalf("unexpected upload response: %+v", upload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAIAsyncMediaRequestRequiresExplicitHeader(t *testing.T) {
|
||||
syncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
if easyAIAsyncMediaRequest("images.generations", syncRequest) {
|
||||
t.Fatal("image request without X-Async must stay synchronous")
|
||||
}
|
||||
asyncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
asyncRequest.Header.Set("X-Async", "true")
|
||||
if !easyAIAsyncMediaRequest("images.generations", asyncRequest) {
|
||||
t.Fatal("image request with X-Async must enable EasyAI async compatibility")
|
||||
}
|
||||
if easyAIAsyncMediaRequest("chat.completions", asyncRequest) {
|
||||
t.Fatal("chat completions must not enter EasyAI media async compatibility")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEasyAIAsyncErrorKeepsNestedGatewayError(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
writeEasyAIAsyncError(recorder, http.StatusBadRequest, "invalid request", map[string]any{"param": "model"}, "invalid_parameter")
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorPayload, _ := got["error"].(map[string]any)
|
||||
data, _ := got["data"].([]any)
|
||||
if got["status"] != "failed" || got["code"] != "invalid_parameter" ||
|
||||
errorPayload["message"] != "invalid request" || len(data) != 0 {
|
||||
t.Fatalf("unexpected async error response: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user