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
+37 -18
View File
@@ -1039,15 +1039,14 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/reranks [post]
// @Router /api/v1/videos/generations [post]
// @Router /api/v1/song/generations [post]
// @Router /api/v1/music/generations [post]
// @Router /api/v1/speech/generations [post]
// @Router /api/v1/voice_clone [post]
func (s *Server) createTask(kind string, compatible bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetProtocol := targetProtocolForTaskRequest(kind, r)
writeTaskError := func(status int, message string, details map[string]any, codes ...string) {
if easyAIAsyncMediaRequest(kind, r) {
writeEasyAIAsyncError(w, status, message, details, codes...)
return
}
if targetProtocol == "" {
writeErrorWithDetails(w, status, message, details, codes...)
return
@@ -1093,9 +1092,10 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
return
}
responsePlan := planTaskResponse(kind, compatible, body, r)
if targetProtocol != "" {
if targetProtocol != "" && !easyAIAsyncMediaRequest(kind, r) {
// OpenAI compatibility endpoints are synchronous APIs. Gateway async
// task envelopes belong only to native Gateway routes.
// task envelopes belong only to native Gateway routes, except for the
// explicit EasyAI media X-Async extension.
responsePlan.asyncMode = false
}
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
@@ -1150,7 +1150,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeTaskError(http.StatusConflict, "streaming idempotent replay is not supported", nil, "idempotency_stream_replay_unsupported")
return
}
if targetProtocol != "" {
if targetProtocol != "" && !responsePlan.asyncMode {
if task.Status == "succeeded" {
writeJSON(w, http.StatusOK, task.Result)
return
@@ -1274,7 +1274,7 @@ func openAIEmbeddingsDoc() {}
// @Security BearerAuth
// @Param input body TaskRequest true "OpenAI Images 请求"
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Success 200 {object} CompatibleResponse
// @Success 200 {object} OpenAIImagesResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
@@ -1284,6 +1284,32 @@ func openAIEmbeddingsDoc() {}
// @Router /api/v1/images/edits [post]
func openAIImagesDoc() {}
// easyAIMediaTasksDoc godoc
// @Summary 创建 EasyAI 媒体任务
// @Description 默认同步返回 EasyAI GeneratedResponse;设置 X-Async=true 时返回同时兼容 Gateway 与 server-main EasyAIClient 的异步提交结构。
// @Tags media
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param Idempotency-Key header string false "可选请求幂等键;同一用户范围内唯一"
// @Param input body TaskRequest true "媒体任务请求,字段随任务类型变化"
// @Success 200 {object} EasyAIGeneratedResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/videos/generations [post]
// @Router /api/v1/song/generations [post]
// @Router /api/v1/music/generations [post]
// @Router /api/v1/speech/generations [post]
// @Router /api/v1/voice_clone [post]
func easyAIMediaTasksDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
base := context.WithoutCancel(r.Context())
if s.ctx == nil {
@@ -1418,7 +1444,7 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
writeWireResponse(w, result.Wire)
return
}
writeJSON(w, http.StatusOK, result.Output)
writeJSON(w, http.StatusOK, easyAISynchronousTaskResponse(result.Task, result.Output))
}
func streamIncludeUsage(body map[string]any) bool {
@@ -1461,14 +1487,7 @@ func streamCompatibleKind(kind string) bool {
}
func writeTaskAccepted(w http.ResponseWriter, task store.GatewayTask) {
writeJSON(w, http.StatusAccepted, map[string]any{
"taskId": task.ID,
"task": task,
"next": map[string]string{
"events": fmt.Sprintf("/api/v1/tasks/%s/events", task.ID),
"detail": fmt.Sprintf("/api/v1/tasks/%s", task.ID),
},
})
writeJSON(w, http.StatusAccepted, easyAITaskAcceptedResponse(task))
}
func apiKeyScopeAllowed(user *auth.User, kind string) bool {