feat(storage): 统一二进制对象存储与公开错误

新增 Aliyun OSS 与 S3 协议、通道内重试和按优先级跨通道切换,保留 server-main 兼容与环境 OSS 内存通道。

将请求及结果中的 Base64、Data URI、Buffer、multipart 和内联二进制统一对象化,生产路径不再写入本机静态目录,历史本地资源仅保留只读兼容。

引入 PublicErrorV1 并统一 API、异步查询、兼容协议和失败回调的安全错误输出,同时补充迁移、管理端、指标、OpenAPI 与本地模拟验收。

验证:go test ./... -count=1;go vet ./...;pnpm lint;pnpm test;pnpm build;pnpm openapi;tests/ci/migrations-test.sh。
This commit is contained in:
2026-08-04 08:14:39 +08:00
parent d129bcccbd
commit 0f0998cbcf
55 changed files with 3649 additions and 1008 deletions
+79 -6
View File
@@ -148,10 +148,14 @@ func (s *Service) hydrateProviderRequestAssetRef(ctx context.Context, ref map[st
}
return base64.StdEncoding.EncodeToString(payload), nil
}
if strings.TrimSpace(asset.URL) == "" {
assetURL, err := s.requestAssetAccessURL(ctx, asset)
if err != nil {
return nil, err
}
if strings.TrimSpace(assetURL) == "" {
return nil, requestAssetExpiredError(asset)
}
return asset.URL, nil
return assetURL, nil
}
func (s *Service) hydrateProviderRequestAssetString(ctx context.Context, value string, path []string, candidate store.RuntimeModelCandidate) (any, error) {
@@ -224,10 +228,14 @@ func (s *Service) resolveRequestAsset(ctx context.Context, ref map[string]any) (
sha := stringFromAny(ref["sha256"])
contentType := stringFromAny(ref["contentType"])
asset := store.RequestAsset{
SHA256: sha,
ContentType: contentType,
URL: stringFromAny(ref["url"]),
StorageProvider: stringFromAny(ref["storageProvider"]),
SHA256: sha,
ContentType: contentType,
URL: stringFromAny(ref["url"]),
StorageProvider: stringFromAny(ref["storageProvider"]),
StorageChannelID: stringFromAny(ref["storageChannelId"]),
StorageChannelKey: stringFromAny(ref["storageChannelKey"]),
ObjectKey: stringFromAny(ref["objectKey"]),
AccessScope: stringFromAny(ref["accessScope"]),
}
if size := floatFromAny(ref["size"]); size > 0 {
asset.ByteSize = int64(size)
@@ -254,6 +262,17 @@ func (s *Service) readRequestAssetBytes(ctx context.Context, asset store.Request
if requestAssetIsExpired(asset, time.Now()) {
return nil, requestAssetExpiredError(asset)
}
if strings.TrimSpace(asset.ObjectKey) != "" {
channel, err := s.fileStorageChannelForAsset(ctx, asset)
if err != nil {
return nil, err
}
adapter, err := newObjectStorageAdapter(channel)
if err != nil {
return nil, err
}
return readObjectStorageWithRetries(ctx, adapter, asset.ObjectKey)
}
if strings.TrimSpace(asset.LocalPath) != "" {
payload, err := os.ReadFile(asset.LocalPath)
if err != nil {
@@ -290,6 +309,60 @@ func (s *Service) readRequestAssetBytes(ctx context.Context, asset store.Request
return nil, requestAssetExpiredError(asset)
}
func readObjectStorageWithRetries(ctx context.Context, adapter *objectStorageAdapter, objectKey string) ([]byte, error) {
maxRetries, delays := uploadRetrySchedule(adapter.channel.RetryPolicy, adapter.channel.Provider)
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
payload, err := adapter.get(ctx, objectKey)
if err == nil {
return payload, nil
}
lastErr = err
if attempt >= maxRetries || !clients.IsRetryable(err) {
break
}
if err := sleepWithContext(ctx, retryDelayForAttempt(attempt, delays)); err != nil {
return nil, err
}
}
return nil, lastErr
}
func (s *Service) requestAssetAccessURL(ctx context.Context, asset store.RequestAsset) (string, error) {
if strings.TrimSpace(asset.ObjectKey) == "" {
return asset.URL, nil
}
channel, err := s.fileStorageChannelForAsset(ctx, asset)
if err != nil {
return "", err
}
adapter, err := newObjectStorageAdapter(channel)
if err != nil {
return "", err
}
if strings.EqualFold(strings.TrimSpace(asset.AccessScope), "public") {
if value := adapter.publicURL(asset.ObjectKey); value != "" {
return value, nil
}
}
return adapter.presignGet(asset.ObjectKey, objectStorageSignedURLTTL)
}
func (s *Service) fileStorageChannelForAsset(ctx context.Context, asset store.RequestAsset) (store.FileStorageChannel, error) {
channelKey := strings.TrimSpace(asset.StorageChannelKey)
if channelKey == "environment-direct-oss" && s.directOSS != nil {
return s.directOSS.fileStorageChannel(), nil
}
if s.store == nil || channelKey == "" {
return store.FileStorageChannel{}, &clients.ClientError{Code: "storage_read_failed", Message: "object storage channel is unavailable", Retryable: true}
}
channel, err := s.store.GetFileStorageChannelByKey(ctx, channelKey)
if err != nil {
return store.FileStorageChannel{}, &clients.ClientError{Code: "storage_read_failed", Message: "object storage channel is unavailable", Retryable: true}
}
return channel, nil
}
func (s *Service) localPathFromRequestAssetURL(value string) string {
raw := strings.TrimSpace(value)
if raw == "" {