Files
ttstd-infinite-canvas/backend/internal/app/provider_http_client.go
dxiaom 9df34abd6b feat(admin): 渠道管理 - 模型供应商支持渠道级 SOCKS5/HTTP 代理
- ModelChannel 新增 proxy_url 字段,v48 迁移自动加列
- outbound 新增 OutboundHTTPClientWithProxy,socks5/socks5h/http/https 均支持;
  目标主机名不做本地解析,直接交给代理服务器远端解析,本地开启 TUN/VPN 时仍可用
- 系统代理、Provider 出站、管理员模型测试路径均使用渠道级代理
- 渠道管理表单新增"代理地址"输入框,留空回退全局代理
2026-10-10 00:59:37 +08:00

694 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
// Provider 出站 HTTP、multipart 与媒体字节读取。
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"strconv"
"strings"
"time"
"yingce/backend/internal/model"
"yingce/backend/internal/platform"
)
func postGeminiJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("序列化上游请求失败:%w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, geminiVeoURL(config.BaseURL, path), bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("x-goog-api-key", config.APIKey)
req.Header.Set("Content-Type", "application/json")
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func deleteGeminiCachedContent(ctx context.Context, config providerConfig, resourceName string) error {
if err := validateGeminiCachedContentName(resourceName); err != nil {
return err
}
suffix := strings.TrimPrefix(strings.TrimSpace(resourceName), "cachedContents/")
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, geminiVeoURL(config.BaseURL, "/cachedContents/"+suffix), nil)
if err != nil {
return err
}
req.Header.Set("x-goog-api-key", config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
_, _, err = doBinary(req)
if err == nil {
return nil
}
var httpErr providerHTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
return nil
}
return err
}
func getGeminiJSON(ctx context.Context, config providerConfig, path string, target interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, geminiVeoURL(config.BaseURL, path), nil)
if err != nil {
return err
}
req.Header.Set("x-goog-api-key", config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func getGeminiBinary(ctx context.Context, config providerConfig, rawURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("x-goog-api-key", config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
return doBinary(req)
}
func geminiVeoURL(baseURL string, path string) string {
return apiURLWithDefaultPrefix(baseURL, path, "/v1beta")
}
func postStreamingBinary(ctx context.Context, config providerConfig, path string, body interface{}, onChunk func(string, []byte)) ([]byte, string, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, "", fmt.Errorf("序列化上游请求失败:%w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
if err != nil {
return nil, "", err
}
applyProviderAuth(req, config)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
ApplyOutboundHeaders(req, config.Headers)
return doBinaryWithConsumer(req, onChunk)
}
func postJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("序列化上游请求失败:%w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
if err != nil {
return err
}
applyProviderAuth(req, config)
req.Header.Set("Content-Type", "application/json")
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func applyProviderAuth(req *http.Request, config providerConfig) {
if config.APIFormat == "claude" {
req.Header.Set("x-api-key", config.APIKey)
req.Header.Set("anthropic-version", "2023-06-01")
return
}
if config.APIFormat == "gemini" {
req.Header.Set("x-goog-api-key", config.APIKey)
return
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
}
func postForm(ctx context.Context, config providerConfig, path string, contentType string, body io.Reader, target interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
req.Header.Set("Content-Type", contentType)
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func getJSON(ctx context.Context, config providerConfig, path string, target interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL(config.BaseURL, path), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
return doJSON(req, target)
}
func postBinary(ctx context.Context, config providerConfig, path string, body interface{}) ([]byte, string, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, "", fmt.Errorf("序列化上游请求失败:%w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
req.Header.Set("Content-Type", "application/json")
ApplyOutboundHeaders(req, config.Headers)
return doBinary(req)
}
func getBinary(ctx context.Context, config providerConfig, path string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL(config.BaseURL, path), nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
return doBinary(req)
}
func getExternalBinary(ctx context.Context, rawURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
return doBinary(req)
}
func getProviderExternalBinary(ctx context.Context, config providerConfig, rawURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
if sameProviderOrigin(config.BaseURL, rawURL) {
applyProviderAuth(req, config)
ApplyOutboundHeaders(req, config.Headers)
}
return doBinary(req)
}
func sameProviderOrigin(baseURL string, rawURL string) bool {
base, baseErr := url.Parse(strings.TrimSpace(baseURL))
target, targetErr := url.Parse(strings.TrimSpace(rawURL))
if baseErr != nil || targetErr != nil || base.Scheme == "" || base.Host == "" || target.Scheme == "" || target.Host == "" {
return false
}
return strings.EqualFold(base.Scheme, target.Scheme) && strings.EqualFold(base.Host, target.Host)
}
func doJSON(req *http.Request, target interface{}) error {
data, mimeType, err := doBinary(req)
if err != nil {
return err
}
if !strings.Contains(mimeType, "json") && !json.Valid(data) {
return providerResponseDecodeError{Err: fmt.Errorf("接口返回非 JSON 内容:%s", mimeType)}
}
if err := json.Unmarshal(data, target); err != nil {
return providerResponseDecodeError{Err: err}
}
if payload, ok := target.(*imageResponse); ok {
if payload.Error != nil && payload.Error.Message != "" {
return errors.New(providerPayloadErrorMessage(payload.Error.Message))
}
if payload.Code != nil && *payload.Code != 0 {
return errors.New(providerPayloadErrorMessage(payload.Msg))
}
}
if payload, ok := target.(*map[string]interface{}); ok {
if _, rawMessage, failed := providerPayloadBusinessFailure(*payload); failed {
return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)}
}
if errValue, ok := (*payload)["error"].(map[string]interface{}); ok && stringField(errValue, "message") != "" {
rawMessage := stringField(errValue, "message")
return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)}
}
}
return nil
}
func doBinary(req *http.Request) ([]byte, string, error) {
return doBinaryWithConsumer(req, nil)
}
// doBinaryWithConsumer 是 Provider 出站响应的统一安全边界。JSON、SSE 和媒体下载最终都在这里执行
// 渠道并发/熔断、SSRF、超时、响应大小、HTTP 状态和审计检查;onChunk 仅观察已读取的流片段,
// 不会绕过完整响应的大小上限或错误判定。
func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]byte, string, error) {
startedAt := time.Now()
requestTimeout := providerHTTPTimeout
if deadline, ok := req.Context().Deadline(); ok {
if remaining := time.Until(deadline); remaining > 0 {
requestTimeout = remaining
}
}
var release func()
var coordinator *platform.Coordinator
var runtimeService *Service
requestKind := providerRequestKindFromContext(req.Context())
cacheManagementRequest := requestKind == "cache-create" || requestKind == "cache-delete"
responseLimit := maxProviderResponseBytes
channelID := ""
channelProxyURL := ""
if metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok && metadata.Service != nil {
runtimeService = metadata.Service
coordinator = metadata.Service.coordinator
channelID = metadata.ChannelID
channelProxyURL = metadata.ProxyURL
policy, err := metadata.Service.RuntimePolicy()
if err != nil {
err = fmt.Errorf("读取生成资源限制失败:%w", err)
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
responseLimit = megabytes(policy.Resource.GeneratedFileMB)
if !cacheManagementRequest {
open, err := coordinator.CircuitOpen(req.Context(), channelID)
if err != nil {
err = fmt.Errorf("读取渠道熔断状态失败:%w", err)
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
if open {
err = providerCircuitOpenError{}
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
slotID := channelID
if slotID == "" {
slotID = "custom:" + strings.ToLower(req.URL.Host)
}
var concurrencyLimit int
release, concurrencyLimit, err = metadata.Service.AcquireChannelSlot(req.Context(), channelID, slotID, requestTimeout+time.Minute)
metadata.ConcurrencyLimit = concurrencyLimit
req = req.WithContext(context.WithValue(req.Context(), providerAnalyticsKey{}, metadata))
if err != nil {
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
}
if release != nil {
defer release()
}
}
if _, err := ValidateOutboundURL(req.URL.String()); err != nil {
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
ApplyDefaultOutboundHeaders(req)
client := OutboundHTTPClientWithProxy(requestTimeout, channelProxyURL)
resp, err := client.Do(req)
if err != nil {
err = providerConnectionError(err)
if runtimeService != nil && !cacheManagementRequest {
_ = runtimeService.RecordChannelResult(req.Context(), channelID, !errors.Is(err, context.Canceled))
}
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
defer resp.Body.Close()
if resp.ContentLength > responseLimit {
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
return nil, "", err
}
mimeType := resp.Header.Get("Content-Type")
var buffered bytes.Buffer
reader := io.LimitReader(resp.Body, responseLimit+1)
chunk := make([]byte, 32<<10)
for {
readCount, readErr := reader.Read(chunk)
if readCount > 0 {
if int64(buffered.Len()+readCount) > responseLimit {
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
recordProviderRequest(req, startedAt, resp.StatusCode, buffered.Bytes(), err)
return nil, "", err
}
_, _ = buffered.Write(chunk[:readCount])
if onChunk != nil {
onChunk(mimeType, chunk[:readCount])
}
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
readErr = providerConnectionError(readErr)
recordProviderRequest(req, startedAt, resp.StatusCode, buffered.Bytes(), readErr)
return nil, "", readErr
}
}
data := buffered.Bytes()
if int64(len(data)) > responseLimit {
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
return nil, "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if runtimeService != nil && !cacheManagementRequest {
_ = runtimeService.RecordChannelResult(req.Context(), channelID, resp.StatusCode >= 500)
}
httpErr := providerHTTPError{StatusCode: resp.StatusCode, Status: resp.Status, Body: string(data), RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())}
recordProviderRequest(req, startedAt, resp.StatusCode, data, httpErr)
return nil, "", httpErr
}
recordProviderRequest(req, startedAt, resp.StatusCode, data, nil)
if runtimeService != nil && !cacheManagementRequest {
_ = runtimeService.RecordChannelResult(req.Context(), channelID, false)
}
return data, mimeType, nil
}
func providerRequestKindFromContext(ctx context.Context) string {
metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
return strings.TrimSpace(metadata.RequestKind)
}
func providerConnectionError(err error) error {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return fmt.Errorf("模型服务连接提前关闭,未收到完整结果;请先核对中转站任务和扣费记录,再决定是否重试:%w", err)
}
return err
}
func parseRetryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil && at.After(now) {
return at.Sub(now)
}
return 0
}
func providerPollingDeadline(ctx context.Context) time.Time {
if deadline, ok := ctx.Deadline(); ok {
return deadline
}
return time.Now().Add(videoPollTimeout)
}
func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode int, responseBody []byte, requestErr error) {
metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil {
return
}
status := model.ApiCallStatusSucceeded
errorCode := ""
errorText := ""
if requestErr != nil || statusCode < 200 || statusCode >= 300 {
status = model.ApiCallStatusFailed
errorCode, errorText = providerRequestErrorDetails(requestErr)
} else if businessCode, businessMessage, failed := providerResponseBusinessFailure(responseBody); failed {
status = model.ApiCallStatusFailed
errorCode = businessCode
errorText = businessMessage
}
requestKind := providerRequestKind(req.Method, req.URL.Path)
if metadata.RequestKind != "" {
requestKind = metadata.RequestKind
}
if status == model.ApiCallStatusSucceeded && (requestKind == "create" || requestKind == "poll") && (metadata.Capability == "image" || metadata.Capability == "video") {
metadata.Service.syncProviderTaskProgress(metadata.TaskID, responseBody)
}
apiFormat := "openai"
if req.Header.Get("x-goog-api-key") != "" {
apiFormat = "gemini"
}
callLog := model.ApiCallLog{
UserID: metadata.UserID, TraceID: metadata.TraceID, RequestID: metadata.RequestID, ChannelID: metadata.ChannelID, TaskID: metadata.TaskID, BillingOrderID: metadata.BillingOrderID,
Source: "backend-task", Capability: metadata.Capability, Operation: metadata.Operation,
RequestKind: requestKind, Billable: providerRequestIsBillable(req.Method, requestKind),
APIFormat: apiFormat, Method: req.Method, Path: req.URL.Path, Model: metadata.Model,
Status: status, StatusCode: statusCode, DurationMs: time.Since(startedAt).Milliseconds(),
ErrorCode: errorCode, Error: errorText, ConcurrencyLimit: metadata.ConcurrencyLimit, UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path,
ProviderRequestID: metadata.ProviderRequestID, RequestContentType: req.Header.Get("Content-Type"), RequestBody: requestPayloadForLog(req), ResponseBody: SanitizeAPICallPayload(responseBody, ""),
}
channelSlotFailure := false
if code, message := ChannelSlotFailureDetails(requestErr); code != "" {
channelSlotFailure = true
callLog.ErrorCode = code
callLog.Error = message
}
if requestKind == "create" && metadata.Capability == "video" {
callLog.VideoSeconds = metadata.VideoSeconds
if callLog.VideoSeconds <= 0 {
if strings.Contains(strings.ToLower(metadata.Model), "seedance") || strings.Contains(req.URL.Path, "/contents/generations/tasks") {
callLog.VideoSeconds = 5
} else {
callLog.VideoSeconds = 6
}
}
}
metadata.Service.EnrichAPICallLog(&callLog, responseBody)
callLog.Error = withUserVisibleLogError(requestErr, callLog.Error)
if err := metadata.Service.LogAPICall(callLog); err != nil {
if !channelSlotFailure && metadata.Billing != nil {
if uncertainErr := metadata.Billing.MarkBillingUncertain(metadata.BillingOrderID, "上游调用日志写入失败,费用状态待核对"); uncertainErr != nil {
// 这里无法把日志落库错误返回给已完成的 HTTP 请求,只能把计费边界失败写入进程日志,交给待核对审计继续处理。
log.Printf("provider billing uncertainty update failed: task_id=%s billing_order_id=%s error=%v", metadata.TaskID, metadata.BillingOrderID, uncertainErr)
}
}
}
}
func providerRequestIsBillable(method, requestKind string) bool {
if method != http.MethodPost {
return false
}
switch requestKind {
case "cancel", "cache-create", "cache-delete":
return false
default:
return true
}
}
func providerRequestErrorDetails(err error) (string, string) {
if err == nil {
return "", ""
}
if errors.Is(err, context.Canceled) {
return "request_cancelled", "任务取消,中断上游请求"
}
if errors.Is(err, context.DeadlineExceeded) {
return "upstream_timeout", "等待上游响应超时"
}
return "", safeProviderLogError(err)
}
// withUserVisibleLogError 把画布上实际展示的失败原因写进请求日志,同时保留原始诊断。
// 语音合成权限/音色不匹配和网络异常都会被前端改写成固定中文;只存上游原文时,后台按用户看到的文案检索不到。
func withUserVisibleLogError(requestErr error, stored string) string {
stored = strings.TrimSpace(stored)
visible := userVisibleFailureLabel(requestErr, stored)
if visible == "" || strings.Contains(stored, visible) {
return truncateRunes(stored, 2_000)
}
if stored == "" {
return visible
}
return truncateRunes(visible+" "+stored, 2_000)
}
func userVisibleFailureLabel(requestErr error, stored string) string {
texts := make([]string, 0, 3)
if requestErr != nil {
texts = append(texts, taskFailureMessage(requestErr), requestErr.Error())
}
if stored != "" {
texts = append(texts, stored)
}
network := false
for _, text := range texts {
if speechResourceDeniedUserMessage(text) != "" {
return volcengineSpeechResourceDeniedMessage
}
if userFacingTaskError(text) == taskErrorNetworkMessage {
network = true
}
}
if network {
return taskErrorNetworkMessage
}
return ""
}
func safeProviderLogError(err error) string {
var httpErr providerHTTPError
if errors.As(err, &httpErr) {
return fmt.Sprintf("上游 HTTP %d", httpErr.StatusCode)
}
return truncateRunes(err.Error(), 500)
}
func providerRequestKind(method string, path string) string {
if method == http.MethodGet {
if strings.HasSuffix(strings.TrimRight(path, "/"), "/content") || strings.Contains(path, "/download") {
return "download"
}
return "poll"
}
if strings.Contains(path, "repair") {
return "repair"
}
return "create"
}
func apiURL(baseURL string, path string) string {
return apiURLWithDefaultPrefix(baseURL, path, "/v1")
}
// ChannelAPIURL 是 Provider 请求拼接 URL 的唯一公共入口。
// 渠道地址可配置为 host、host/、host/v1 或 host/v1/;调用方应传协议路径,避免重复硬编码版本前缀。
func ChannelAPIURL(baseURL string, path string) string {
return apiURL(baseURL, path)
}
// ChannelAPIURLForProtocol 把协议默认版本收敛在传输边界:Gemini 默认 v1beta,
// OpenAI 兼容协议默认 v1;baseURL 或 path 中显式出现的版本始终优先。
func ChannelAPIURLForProtocol(baseURL string, path string, interfaceType model.ChannelInterfaceType) string {
if interfaceType == model.ChannelInterfaceAgnesVideo && strings.HasPrefix(strings.TrimSpace(path), "/agnesapi") {
base, err := url.Parse(strings.TrimSpace(baseURL))
requestPath, pathErr := url.Parse(strings.TrimSpace(path))
if err == nil && pathErr == nil && base.Scheme != "" && base.Host != "" && strings.HasPrefix(requestPath.Path, "/") {
base.Path = requestPath.Path
base.RawPath = requestPath.RawPath
base.RawQuery = requestPath.RawQuery
base.Fragment = ""
return base.String()
}
}
defaultPrefix := "/v1"
if interfaceType == model.ChannelInterfaceGeminiVeo || interfaceType == model.ChannelInterfaceGeminiImage {
defaultPrefix = "/v1beta"
}
return apiURLWithDefaultPrefix(baseURL, path, defaultPrefix)
}
var channelAPIPrefixes = []string{"/api/plan/v3", "/api/v3", "/api/v1", "/v1beta", "/v1", "/v2", "/v3"}
func apiURLWithDefaultPrefix(baseURL string, path string, defaultPrefix string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
requestPath := strings.TrimSpace(path)
if requestPath == "" {
return base
}
if !strings.HasPrefix(requestPath, "/") {
requestPath = "/" + requestPath
}
requestPrefix := requestAPIPathPrefix(requestPath)
basePrefix := baseAPIPathPrefix(base)
if requestPrefix != "" {
if basePrefix == requestPrefix {
return base + strings.TrimPrefix(requestPath, requestPrefix)
}
// 请求路径显式版本优先于 baseURL 残留版本,例如 base=/v1、path=/v2/... 时必须切到 /v2。
return strings.TrimSuffix(base, basePrefix) + requestPath
}
if basePrefix != "" {
return base + requestPath
}
return base + defaultPrefix + requestPath
}
func requestAPIPathPrefix(value string) string {
lower := strings.ToLower(value)
for _, prefix := range channelAPIPrefixes {
if lower == prefix || strings.HasPrefix(lower, prefix+"/") || strings.HasPrefix(lower, prefix+"?") || strings.HasPrefix(lower, prefix+"#") {
return prefix
}
}
return ""
}
func baseAPIPathPrefix(value string) string {
lower := strings.ToLower(strings.TrimRight(value, "/"))
for _, prefix := range channelAPIPrefixes {
if lower == prefix || strings.HasSuffix(lower, prefix) {
return prefix
}
}
return ""
}
func writeField(writer *multipart.Writer, key string, value string) {
_ = writer.WriteField(key, value)
}
func writeMediaPart(writer *multipart.Writer, field string, media providerMedia) error {
raw, mimeType, err := mediaBytes(media)
if err != nil {
return err
}
filename := providerMediaFilename(media, mimeType)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": field, "filename": filename}))
header.Set("Content-Type", mimeType)
part, err := writer.CreatePart(header)
if err != nil {
return err
}
_, err = part.Write(raw)
return err
}
func providerMediaFilename(media providerMedia, mimeType string) string {
base := strings.TrimSpace(media.ID)
if base == "" {
base = "reference"
}
var builder strings.Builder
for _, char := range base {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' {
builder.WriteRune(char)
if builder.Len() >= 64 {
break
}
}
}
base = builder.String()
if base == "" {
base = "reference"
}
extensions, _ := mime.ExtensionsByType(strings.TrimSpace(strings.Split(mimeType, ";")[0]))
extension := ".bin"
if len(extensions) > 0 {
extension = extensions[0]
}
return "reference-" + base + extension
}
func mediaBytes(media providerMedia) ([]byte, string, error) {
value := media.DataURL
if value == "" {
value = media.URL
}
if !strings.HasPrefix(value, "data:") {
return nil, "", errors.New("后端任务队列需要 data URL 形式的本地参考素材")
}
header, encoded, ok := strings.Cut(value, ",")
if !ok {
return nil, "", errors.New("data URL 格式错误")
}
mimeType := strings.TrimPrefix(strings.Split(strings.TrimPrefix(header, "data:"), ";")[0], " ")
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, "", err
}
return raw, normalizedMediaMimeType(defaultString(mimeType, media.Type), raw), nil
}