fix(media): 统一图片结果 URL 化并限制同步 Base64

将上游 URL 直接持久化,内联媒体经对象存储后仅保留 URL 与内部定位元数据;异步轮询、任务详情和幂等重放统一使用零对象读取的 URL 投影,并增加 64KiB 响应门禁。

OpenAI 图片接口接受 url 与 b64_json,同步 Base64 限制为 20MiB 和每 Pod 2 并发;新增历史结果迁移清零门禁、结果指标和 API GOMEMLIMIT。

验证:API go test ./...、go vet、聚焦 race、pnpm openapi、pnpm lint/test/build、迁移安全检查与 docker compose config 均通过。
This commit is contained in:
2026-08-05 18:15:06 +08:00
parent f9b945e4aa
commit b13392ef50
22 changed files with 1367 additions and 176 deletions
+58 -40
View File
@@ -1,7 +1,6 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
@@ -109,32 +108,31 @@ func easyAIFileUploadResponse(upload map[string]any) map[string]any {
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
output := easyAIOutputURLs(data)
compatResult := map[string]any{
"data": data,
"output": output,
"output_content": data,
}
response := map[string]any{
"status": status,
"task_id": task.ID,
"taskId": task.ID,
"query_url": "/api/v1/ai/result/" + task.ID,
"created": task.CreatedAt.UnixMilli(),
"data": data,
"output": output,
"output_content": data,
"result": compatResult,
}
upstreamTaskID := firstNonEmpty(
easyAIString(cleanResult["upstream_task_id"]),
easyAIString(sourceResult["upstream_task_id"]),
task.RemoteTaskID,
)
if upstreamTaskID != "" {
@@ -144,12 +142,14 @@ func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
response["usage"] = task.Usage
}
cancelState := runner.DescribeTaskCancellation(task)
response["cancellable"] = cancelState.Cancellable
response["submitted"] = cancelState.Submitted
if status != "success" {
cancelState := runner.DescribeTaskCancellation(task)
response["cancellable"] = cancelState.Cancellable
response["submitted"] = cancelState.Submitted
}
message := firstNonEmpty(
easyAIString(cleanResult["message"]),
easyAIString(sourceResult["message"]),
task.ErrorMessage,
task.Error,
task.Message,
@@ -164,10 +164,10 @@ func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
message = "任务执行失败"
}
}
if message != "" {
if message != "" && status != "success" {
response["message"] = message
}
if code := firstNonEmpty(task.ErrorCode, easyAIString(cleanResult["code"])); code != "" || status == "failed" {
if code := firstNonEmpty(task.ErrorCode, easyAIString(sourceResult["code"])); code != "" || status == "failed" {
standard := publicTaskError(task)
if code != "" && task.ErrorCode == "" {
standard = publicerror.WithIDs(publicerror.FromFields(code, message, 0, false), task.RequestID, task.ID)
@@ -268,19 +268,24 @@ func normalizeEasyAIOutputItems(task store.GatewayTask, items []any) []any {
if len(output) == 0 {
continue
}
delete(output, "raw_data")
normalizeEasyAIInlineMediaFields(output)
mediaURL := easyAIOutputURL(output)
if mediaURL != "" {
output["url"] = mediaURL
if mediaURL == "" {
continue
}
if strings.TrimSpace(easyAIString(output["type"])) == "" {
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
output["type"] = outputType
lightweight := map[string]any{"url": mediaURL}
for _, key := range []string{"type", "mime_type", "width", "height", "duration", "format", "seed", "revised_prompt"} {
if value, exists := output[key]; exists && value != nil {
lightweight[key] = value
}
}
normalized = append(normalized, output)
if strings.TrimSpace(easyAIString(lightweight["type"])) == "" {
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
lightweight["type"] = outputType
}
}
normalized = append(normalized, lightweight)
}
return normalized
}
@@ -492,13 +497,26 @@ 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{}
result := make(map[string]any, len(source))
for key, value := range source {
result[key] = cloneEasyAIValue(value)
}
return result
}
func cloneEasyAIValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneEasyAIMap(typed)
case []any:
next := make([]any, len(typed))
for index, item := range typed {
next[index] = cloneEasyAIValue(item)
}
return next
case []string:
return append([]string(nil), typed...)
default:
return value
}
}