fix(worker): 将 Gemini 同步请求交由异步队列执行

将非流式 Gemini generateContent 改为 River Worker 执行,API 通过批量状态查询等待完成,避免高并发同步请求耗尽 API 数据库连接池。\n\n生成图片在持久化时保存带哈希的资产引用,响应恢复时下载并校验后重建 Base64,数据库不保存媒体原文。请求体物化增加前置内存门禁,并扩展双 API/Worker PostgreSQL 压力测试。\n\n验证:go test ./... -count=1;go vet ./...;pnpm openapi;迁移安全检查;64 与 256 请求双角色异步 Gemini 压力测试。
This commit is contained in:
2026-07-31 05:49:45 +08:00
parent 352e23e099
commit 4af86b22ee
12 changed files with 516 additions and 48 deletions
@@ -17,6 +17,7 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
@@ -405,6 +406,17 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
}
switch typed := value.(type) {
case map[string]any:
if ref, ok := generatedResultAssetReference(typed); ok {
payload, contentType, err := s.readGeneratedResultAsset(ctx, ref)
if err != nil {
return nil, false, err
}
encoded := base64.StdEncoding.EncodeToString(payload)
if generatedResultAssetUsesDataURI(typed) {
return "data:" + contentType + ";base64," + encoded, true, nil
}
return encoded, true, nil
}
next := make(map[string]any, len(typed))
changed := false
for key, childValue := range typed {
@@ -453,6 +465,62 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
}
}
func generatedResultAssetReference(value map[string]any) (store.RequestAsset, bool) {
ref, ok := value["assetRef"].(map[string]any)
if !ok {
return store.RequestAsset{}, false
}
storage, _ := value["assetStorage"].(map[string]any)
if stringFromAny(storage["scene"]) != store.FileStorageSceneImageResult {
return store.RequestAsset{}, false
}
asset := store.RequestAsset{
SHA256: strings.ToLower(strings.TrimSpace(stringFromAny(ref["sha256"]))),
ContentType: firstNonEmptyString(stringFromAny(ref["contentType"]), stringFromAny(storage["contentType"])),
URL: firstNonEmptyString(stringFromAny(ref["url"]), stringFromAny(value["url"])),
StorageProvider: stringFromAny(ref["storageProvider"]),
}
if size := floatFromAny(ref["size"]); size > 0 {
asset.ByteSize = int64(size)
}
if expiresAt := stringFromAny(ref["expiresAt"]); expiresAt != "" {
if parsed, err := time.Parse(time.RFC3339, expiresAt); err == nil {
asset.ExpiresAt = &parsed
}
}
if asset.URL == "" || asset.SHA256 == "" || asset.ByteSize <= 0 {
return store.RequestAsset{}, false
}
return asset, true
}
func generatedResultAssetUsesDataURI(value map[string]any) bool {
storage, _ := value["assetStorage"].(map[string]any)
source := normalizeLocalBinaryKey(stringFromAny(storage["source"]))
return source == "datauri"
}
func (s *Service) readGeneratedResultAsset(ctx context.Context, asset store.RequestAsset) ([]byte, string, error) {
payload, err := s.readRequestAssetBytes(ctx, asset)
if err != nil {
return nil, "", err
}
digest := sha256.Sum256(payload)
if int64(len(payload)) != asset.ByteSize || hex.EncodeToString(digest[:]) != asset.SHA256 {
return nil, "", &clients.ClientError{
Code: "binary_result_corrupted",
Message: "stored result asset failed size or hash verification",
StatusCode: 500,
Retryable: false,
}
}
contentType := strings.TrimSpace(asset.ContentType)
if contentType == "" {
contentType = "application/octet-stream"
}
return payload, contentType, nil
}
func (s *Service) readLocalBinaryResult(taskID string, descriptor localBinaryDescriptor) ([]byte, error) {
path := filepath.Join(s.localBinaryResultRoot(), safeLocalBinaryTaskDir(taskID), descriptor.SHA256+".bin")
info, err := os.Stat(path)