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:
2026-07-23 22:35:41 +08:00
parent 8b7d3e9c9a
commit 76a7702925
15 changed files with 1562 additions and 163 deletions
+489
View File
@@ -0,0 +1,489 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func easyAIAsyncMediaRequest(kind string, r *http.Request) bool {
if r == nil || !asyncRequest(r) {
return false
}
switch kind {
case "images.generations",
"images.edits",
"images.vectorize",
"videos.generations",
"videos.upscales",
"song.generations",
"music.generations",
"speech.generations",
"voice.clone":
return true
default:
return false
}
}
func easyAITaskAcceptedResponse(task store.GatewayTask) map[string]any {
resultPath := "/api/v1/ai/result/" + task.ID
return map[string]any{
"status": "submitted",
"task_id": task.ID,
"taskId": task.ID,
"query_url": resultPath,
"task": task,
"next": map[string]string{
"events": "/api/v1/tasks/" + task.ID + "/events",
"detail": "/api/v1/tasks/" + task.ID,
"result": resultPath,
},
}
}
func easyAITaskAcceptedResponseWithExtensions(task store.GatewayTask, extensions map[string]any) map[string]any {
response := cloneEasyAIMap(extensions)
for key, value := range easyAITaskAcceptedResponse(task) {
response[key] = value
}
return response
}
func writeEasyAIAsyncError(w http.ResponseWriter, status int, message string, details map[string]any, codes ...string) {
code := ""
if len(codes) > 0 {
code = strings.TrimSpace(codes[0])
}
errorPayload := map[string]any{
"message": message,
"status": status,
}
if code != "" {
errorPayload["code"] = code
}
for key, value := range details {
errorPayload[key] = value
}
response := map[string]any{
"status": "failed",
"message": message,
"data": []any{},
"error": errorPayload,
}
if code != "" {
response["code"] = code
}
writeJSON(w, status, response)
}
func easyAISynchronousTaskResponse(task store.GatewayTask, output map[string]any) map[string]any {
if task.Kind != "images.vectorize" {
return output
}
response := cloneEasyAIMap(output)
response["taskId"] = task.ID
response["task_id"] = task.ID
response["query_url"] = "/api/v1/ai/result/" + task.ID
return response
}
func easyAIFileUploadResponse(upload map[string]any) map[string]any {
response := cloneEasyAIMap(upload)
response["status"] = "success"
response["data"] = cloneEasyAIMap(upload)
return response
}
func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
sourceResult := cloneEasyAIMap(task.Result)
cleanResult := cloneEasyAIMap(sourceResult)
delete(cleanResult, "raw")
delete(cleanResult, "raw_data")
normalizeEasyAIInlineMediaFields(cleanResult)
data := easyAITaskResultData(task, sourceResult)
status := easyAITaskResultStatus(task.Status)
if status == "failed" {
data = []any{}
}
cleanResult["data"] = data
cleanResult["output_content"] = data
response := cloneEasyAIMap(cleanResult)
response["status"] = status
response["task_id"] = task.ID
response["taskId"] = task.ID
response["query_url"] = "/api/v1/ai/result/" + task.ID
response["created"] = task.CreatedAt.UnixMilli()
response["data"] = data
response["output_content"] = data
response["output"] = easyAIOutputURLs(data)
response["result"] = cleanResult
upstreamTaskID := firstNonEmpty(
easyAIString(cleanResult["upstream_task_id"]),
task.RemoteTaskID,
)
if upstreamTaskID != "" {
response["upstream_task_id"] = upstreamTaskID
}
if len(task.Usage) > 0 && response["usage"] == nil {
response["usage"] = task.Usage
}
cancelState := runner.DescribeTaskCancellation(task)
response["cancellable"] = cancelState.Cancellable
response["submitted"] = cancelState.Submitted
message := firstNonEmpty(
easyAIString(cleanResult["message"]),
task.ErrorMessage,
task.Error,
task.Message,
)
if message == "" {
switch status {
case "submitted":
message = "任务已提交,请稍后查看结果"
case "process":
message = "任务处理中"
case "failed":
message = "任务执行失败"
}
}
if message != "" {
response["message"] = message
}
if code := firstNonEmpty(task.ErrorCode, easyAIString(cleanResult["code"])); code != "" {
response["code"] = code
}
return response
}
func easyAITaskResultStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "succeeded", "success", "completed":
return "success"
case "failed", "cancelled", "canceled", "rejected":
return "failed"
case "running", "processing", "in_progress":
return "process"
default:
return "submitted"
}
}
func easyAITaskResultData(task store.GatewayTask, result map[string]any) []any {
for _, source := range easyAIResultSources(result) {
if items := easyAIOutputArray(source); len(items) > 0 {
return normalizeEasyAIOutputItems(task, items)
}
}
if item := easyAIOutputMap(result); len(item) > 0 {
normalizeEasyAIInlineMediaFields(item)
if easyAIOutputURL(item) != "" ||
easyAIOutputHasInlineMedia(item) ||
(strings.EqualFold(easyAIString(item["type"]), "text") && (item["text"] != nil || item["content"] != nil)) {
return normalizeEasyAIOutputItems(task, []any{item})
}
}
return []any{}
}
func easyAIResultSources(result map[string]any) []any {
sources := []any{
result["data"],
result["output_content"],
easyAIStructuredOutput(result["content"]),
result["images"],
result["videos"],
result["audios"],
result["files"],
result["output"],
}
if raw, ok := result["raw"].(map[string]any); ok {
sources = append(sources,
raw["data"],
raw["output_content"],
easyAIStructuredOutput(raw["content"]),
raw["images"],
raw["videos"],
raw["audios"],
raw["files"],
raw["output"],
)
}
return sources
}
func easyAIStructuredOutput(value any) any {
switch value.(type) {
case []any, []string, map[string]any:
return value
default:
return nil
}
}
func easyAIOutputArray(value any) []any {
switch typed := value.(type) {
case []any:
return typed
case []string:
items := make([]any, 0, len(typed))
for _, item := range typed {
items = append(items, item)
}
return items
case map[string]any, string:
return []any{typed}
default:
return nil
}
}
func normalizeEasyAIOutputItems(task store.GatewayTask, items []any) []any {
normalized := make([]any, 0, len(items))
for _, item := range items {
output := easyAIOutputMap(item)
if len(output) == 0 {
continue
}
delete(output, "raw_data")
normalizeEasyAIInlineMediaFields(output)
mediaURL := easyAIOutputURL(output)
if mediaURL != "" {
output["url"] = mediaURL
}
if strings.TrimSpace(easyAIString(output["type"])) == "" {
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
output["type"] = outputType
}
}
normalized = append(normalized, output)
}
return normalized
}
func easyAIOutputMap(value any) map[string]any {
switch typed := value.(type) {
case map[string]any:
return cloneEasyAIMap(typed)
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return nil
}
if strings.HasPrefix(strings.ToLower(trimmed), "data:") {
return map[string]any{"url": trimmed}
}
if strings.HasPrefix(trimmed, "/") ||
strings.HasPrefix(strings.ToLower(trimmed), "http://") ||
strings.HasPrefix(strings.ToLower(trimmed), "https://") {
return map[string]any{"url": trimmed}
}
return map[string]any{"type": "text", "content": trimmed}
default:
return nil
}
}
func easyAIOutputURL(item map[string]any) string {
for _, key := range []string{
"url",
"generated_url",
"generatedUrl",
"output_url",
"outputUrl",
"download_url",
"downloadUrl",
"image_url",
"video_url",
"audio_url",
} {
if value := easyAINestedURL(item[key]); value != "" {
return value
}
}
return ""
}
func easyAINestedURL(value any) string {
var candidate string
switch typed := value.(type) {
case string:
candidate = strings.TrimSpace(typed)
case map[string]any:
candidate = strings.TrimSpace(easyAIString(typed["url"]))
default:
return ""
}
if strings.HasPrefix(strings.ToLower(candidate), "data:") {
return ""
}
return candidate
}
func normalizeEasyAIInlineMediaFields(item map[string]any) {
for _, key := range []string{
"url",
"generated_url",
"generatedUrl",
"output_url",
"outputUrl",
"download_url",
"downloadUrl",
"image_url",
"video_url",
"audio_url",
} {
value := easyAINestedURLAllowData(item[key])
contentType, payload, ok := easyAIBase64DataURL(value)
if !ok {
continue
}
if strings.HasPrefix(contentType, "image/") {
if easyAIString(item["b64_json"]) == "" {
item["b64_json"] = payload
}
} else if easyAIString(item["content"]) == "" {
item["content"] = value
}
if easyAIString(item["mime_type"]) == "" {
item["mime_type"] = contentType
}
delete(item, key)
}
}
func easyAIBase64DataURL(value string) (string, string, bool) {
value = strings.TrimSpace(value)
if !strings.HasPrefix(strings.ToLower(value), "data:") {
return "", "", false
}
meta, payload, ok := strings.Cut(value, ",")
if !ok || strings.TrimSpace(payload) == "" {
return "", "", false
}
parts := strings.Split(strings.TrimSpace(meta[5:]), ";")
if len(parts) < 2 || !strings.EqualFold(strings.TrimSpace(parts[len(parts)-1]), "base64") {
return "", "", false
}
contentType := strings.ToLower(strings.TrimSpace(parts[0]))
if !strings.HasPrefix(contentType, "image/") &&
!strings.HasPrefix(contentType, "audio/") &&
!strings.HasPrefix(contentType, "video/") &&
!strings.HasPrefix(contentType, "application/") {
return "", "", false
}
return contentType, strings.TrimSpace(payload), true
}
func easyAIOutputHasInlineMedia(item map[string]any) bool {
if easyAIString(item["b64_json"]) != "" {
return true
}
content := strings.ToLower(easyAIString(item["content"]))
return strings.HasPrefix(content, "data:") && strings.Contains(content, ";base64,")
}
func easyAINestedURLAllowData(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
return strings.TrimSpace(easyAIString(typed["url"]))
default:
return ""
}
}
func easyAIOutputType(task store.GatewayTask, item map[string]any, mediaURL string) string {
kind := strings.ToLower(strings.TrimSpace(task.Kind))
switch {
case kind == "images.vectorize":
format := strings.ToLower(firstNonEmpty(
easyAIString(item["format"]),
easyAIString(task.Request["format"]),
))
if format == "svg" || format == "png" {
return "image"
}
if format != "" {
return "file"
}
case strings.HasPrefix(kind, "images."):
return "image"
case strings.HasPrefix(kind, "videos.") || kind == "video.upscale":
return "video"
case kind == "song.generations" || kind == "music.generations" || kind == "speech.generations":
return "audio"
}
lowerURL := strings.ToLower(mediaURL)
if index := strings.IndexAny(lowerURL, "?#"); index >= 0 {
lowerURL = lowerURL[:index]
}
switch {
case strings.HasSuffix(lowerURL, ".svg"),
strings.HasSuffix(lowerURL, ".png"),
strings.HasSuffix(lowerURL, ".jpg"),
strings.HasSuffix(lowerURL, ".jpeg"),
strings.HasSuffix(lowerURL, ".webp"),
strings.HasSuffix(lowerURL, ".gif"):
return "image"
case strings.HasSuffix(lowerURL, ".mp4"),
strings.HasSuffix(lowerURL, ".mov"),
strings.HasSuffix(lowerURL, ".webm"):
return "video"
case strings.HasSuffix(lowerURL, ".mp3"),
strings.HasSuffix(lowerURL, ".wav"),
strings.HasSuffix(lowerURL, ".m4a"),
strings.HasSuffix(lowerURL, ".aac"),
strings.HasSuffix(lowerURL, ".flac"):
return "audio"
case mediaURL != "":
return "file"
case item["text"] != nil || item["content"] != nil:
return "text"
default:
return ""
}
}
func easyAIOutputURLs(data []any) []string {
output := make([]string, 0, len(data))
for _, item := range data {
if typed, ok := item.(map[string]any); ok {
if mediaURL := easyAIOutputURL(typed); mediaURL != "" {
output = append(output, mediaURL)
}
}
}
return output
}
func easyAIString(value any) string {
typed, _ := value.(string)
return strings.TrimSpace(typed)
}
func cloneEasyAIMap(source map[string]any) map[string]any {
if len(source) == 0 {
return map[string]any{}
}
raw, err := json.Marshal(source)
if err != nil {
return map[string]any{}
}
var result map[string]any
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]any{}
}
return result
}