feat(gateway): 补齐桌面端高级媒体直连接口
ci / verify (pull_request) Successful in 15m34s

新增图片矢量化、视频超分、每日用量、计价与任务隔离能力,并通过环境变量解析平台凭据。

已通过 Go 全量门禁、迁移检查、镜像构建以及 Vectorizer 五格式和 Topaz 3 秒视频真实 DEV 验收。
This commit is contained in:
2026-07-22 14:02:53 +08:00
parent cbebfd7baa
commit 3056cf8fca
37 changed files with 3336 additions and 29 deletions
+80 -3
View File
@@ -8,10 +8,12 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"net/url"
@@ -85,6 +87,11 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
if err != nil {
return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true}
}
// Topaz download URLs are short-lived. Persist them before the task can be
// marked succeeded even when the global policy normally keeps URL media.
if taskKind == "videos.upscales" {
policy.UploadURLMedia = true
}
decisions := make([]generatedAssetDecision, len(data))
needsUpload := false
changed := false
@@ -723,7 +730,8 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
allowLocal := strings.HasPrefix(strings.TrimSpace(asset.URL), "/")
resp, err := generatedAssetHTTPClient(allowLocal).Do(req)
if err != nil {
return nil, "", &clients.ClientError{Code: "upload_source_fetch_failed", Message: err.Error(), Retryable: true}
}
@@ -752,6 +760,47 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
return payload, strings.TrimSpace(strings.Split(contentType, ";")[0]), nil
}
func generatedAssetHTTPClient(allowLocal bool) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return nil, errors.New("generated media DNS resolution failed")
}
for _, address := range addresses {
if generatedAssetBlockedAddress(address.IP) && !(allowLocal && address.IP.IsLoopback()) {
return nil, errors.New("generated media resolved to a blocked network")
}
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
var attempts []error
for _, resolved := range addresses {
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return connection, nil
}
attempts = append(attempts, dialErr)
}
return nil, errors.Join(attempts...)
}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Minute,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func generatedAssetBlockedAddress(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
func (s *Service) generatedAssetFetchURL(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
@@ -1304,6 +1353,9 @@ func mediaContentTypeFromItem(item map[string]any) string {
func mediaKindForAsset(taskKind string, item map[string]any, sourceKey string, contentType string) string {
contentType = strings.ToLower(strings.TrimSpace(contentType))
if generatedContentTypeIsDocument(contentType) {
return "file"
}
if strings.HasPrefix(contentType, "image/") {
return "image"
}
@@ -1314,6 +1366,9 @@ func mediaKindForAsset(taskKind string, item map[string]any, sourceKey string, c
return "audio"
}
itemType := strings.ToLower(strings.TrimSpace(stringFromAny(item["type"])))
if itemType == "file" || strings.Contains(itemType, "document") {
return "file"
}
if strings.Contains(itemType, "video") {
return "video"
}
@@ -1346,6 +1401,8 @@ func defaultContentTypeForGeneratedAsset(kind string) string {
return "video/mp4"
case "audio":
return "audio/mpeg"
case "file":
return "application/octet-stream"
default:
return "image/png"
}
@@ -1354,10 +1411,10 @@ func defaultContentTypeForGeneratedAsset(kind string) string {
func resolvedGeneratedAssetContentType(declared string, kind string, payload []byte) string {
declared = normalizeGeneratedContentType(declared)
detected := detectGeneratedAssetContentType(payload)
if generatedContentTypeIsMedia(detected) {
if generatedContentTypeIsMedia(detected) || generatedContentTypeIsDocument(detected) {
return detected
}
if generatedContentTypeIsMedia(declared) {
if generatedContentTypeIsMedia(declared) || generatedContentTypeIsDocument(declared) {
return declared
}
return defaultContentTypeForGeneratedAsset(kind)
@@ -1380,6 +1437,15 @@ func generatedContentTypeIsMedia(contentType string) bool {
strings.HasPrefix(contentType, "audio/")
}
func generatedContentTypeIsDocument(contentType string) bool {
switch normalizeGeneratedContentType(contentType) {
case "application/pdf", "application/postscript", "application/eps", "application/dxf", "image/vnd.dxf":
return true
default:
return false
}
}
func generatedAssetKindFromContentType(fallback string, contentType string) string {
contentType = normalizeGeneratedContentType(contentType)
if strings.HasPrefix(contentType, "image/") {
@@ -1391,6 +1457,9 @@ func generatedAssetKindFromContentType(fallback string, contentType string) stri
if strings.HasPrefix(contentType, "audio/") {
return "audio"
}
if generatedContentTypeIsDocument(contentType) {
return "file"
}
fallback = strings.ToLower(strings.TrimSpace(fallback))
if fallback != "" {
return fallback
@@ -1434,6 +1503,14 @@ func randomHexSuffix(byteCount int) string {
func fileExtensionForContentType(contentType string, kind string) string {
normalized := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
switch normalized {
case "application/pdf":
return ".pdf"
case "application/postscript", "application/eps":
return ".eps"
case "application/dxf", "image/vnd.dxf":
return ".dxf"
}
switch normalized {
case "image/jpeg", "image/jpg":
return ".jpg"
case "image/webp":