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:
@@ -39,6 +39,9 @@ type FileStorageChannel struct {
|
||||
Provider string `json:"provider"`
|
||||
UploadURL string `json:"uploadUrl,omitempty"`
|
||||
APIKey string `json:"-"`
|
||||
AccessKeyID string `json:"-"`
|
||||
AccessKeySecret string `json:"-"`
|
||||
SessionToken string `json:"-"`
|
||||
CredentialsPreview map[string]any `json:"credentialsPreview,omitempty"`
|
||||
Scenes []string `json:"scenes,omitempty"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
@@ -53,16 +56,21 @@ type FileStorageChannel struct {
|
||||
}
|
||||
|
||||
type FileStorageChannelInput struct {
|
||||
ChannelKey string `json:"channelKey"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
APIKey *string `json:"apiKey"`
|
||||
Scenes []string `json:"scenes"`
|
||||
Config map[string]any `json:"config"`
|
||||
RetryPolicy map[string]any `json:"retryPolicy"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
ChannelKey string `json:"channelKey"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
APIKey *string `json:"apiKey"`
|
||||
AccessKey *string `json:"accessKey"`
|
||||
AccessKeyID *string `json:"accessKeyId"`
|
||||
AccessKeySecret *string `json:"accessKeySecret"`
|
||||
SecretKey *string `json:"secretKey"`
|
||||
SessionToken *string `json:"sessionToken"`
|
||||
Scenes []string `json:"scenes"`
|
||||
Config map[string]any `json:"config"`
|
||||
RetryPolicy map[string]any `json:"retryPolicy"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type FileStorageSettings struct {
|
||||
@@ -156,11 +164,18 @@ WHERE id = $1::uuid
|
||||
AND deleted_at IS NULL`, id))
|
||||
}
|
||||
|
||||
func (s *Store) GetFileStorageChannelByKey(ctx context.Context, channelKey string) (FileStorageChannel, error) {
|
||||
return scanFileStorageChannel(s.pool.QueryRow(ctx, `
|
||||
SELECT `+fileStorageChannelColumns+`
|
||||
FROM file_storage_channels
|
||||
WHERE channel_key = $1`, strings.TrimSpace(channelKey)))
|
||||
}
|
||||
|
||||
func (s *Store) CreateFileStorageChannel(ctx context.Context, input FileStorageChannelInput) (FileStorageChannel, error) {
|
||||
input = normalizeFileStorageChannelInput(input)
|
||||
credentials, _ := json.Marshal(credentialsFromFileStorageInput(input))
|
||||
config, _ := json.Marshal(configFromFileStorageInput(input))
|
||||
retryPolicy, _ := json.Marshal(defaultFileStorageRetryPolicyIfEmpty(input.RetryPolicy))
|
||||
retryPolicy, _ := json.Marshal(defaultFileStorageRetryPolicyIfEmpty(input.RetryPolicy, input.Provider))
|
||||
|
||||
return scanFileStorageChannel(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO file_storage_channels (
|
||||
@@ -182,10 +197,10 @@ RETURNING `+fileStorageChannelColumns,
|
||||
|
||||
func (s *Store) UpdateFileStorageChannel(ctx context.Context, id string, input FileStorageChannelInput) (FileStorageChannel, error) {
|
||||
input = normalizeFileStorageChannelInput(input)
|
||||
replaceCredentials := input.APIKey != nil
|
||||
replaceCredentials := fileStorageInputReplacesCredentials(input)
|
||||
credentials, _ := json.Marshal(credentialsFromFileStorageInput(input))
|
||||
config, _ := json.Marshal(configFromFileStorageInput(input))
|
||||
retryPolicy, _ := json.Marshal(defaultFileStorageRetryPolicyIfEmpty(input.RetryPolicy))
|
||||
retryPolicy, _ := json.Marshal(defaultFileStorageRetryPolicyIfEmpty(input.RetryPolicy, input.Provider))
|
||||
|
||||
return scanFileStorageChannel(s.pool.QueryRow(ctx, `
|
||||
UPDATE file_storage_channels
|
||||
@@ -193,7 +208,7 @@ SET channel_key = $2,
|
||||
name = $3,
|
||||
provider = $4,
|
||||
upload_url = NULLIF($5, ''),
|
||||
credentials = CASE WHEN $6::boolean THEN $7 ELSE credentials END,
|
||||
credentials = CASE WHEN $6::boolean THEN credentials || $7::jsonb ELSE credentials END,
|
||||
config = $8,
|
||||
retry_policy = $9,
|
||||
priority = $10,
|
||||
@@ -307,6 +322,9 @@ func scanFileStorageChannel(scanner fileStorageChannelScanner) (FileStorageChann
|
||||
}
|
||||
credentialObject := decodeObject(credentials)
|
||||
item.APIKey = stringFromObject(credentialObject, "apiKey")
|
||||
item.AccessKeyID = stringFromObject(credentialObject, "accessKeyId")
|
||||
item.AccessKeySecret = stringFromObject(credentialObject, "accessKeySecret")
|
||||
item.SessionToken = stringFromObject(credentialObject, "sessionToken")
|
||||
item.CredentialsPreview = maskCredentialsPreview(credentials)
|
||||
configObject := decodeObject(config)
|
||||
item.Scenes = fileStorageScenesFromConfig(configObject)
|
||||
@@ -324,6 +342,17 @@ func normalizeFileStorageChannelInput(input FileStorageChannelInput) FileStorage
|
||||
apiKey := strings.TrimSpace(*input.APIKey)
|
||||
input.APIKey = &apiKey
|
||||
}
|
||||
if input.AccessKeyID == nil && input.AccessKey != nil {
|
||||
input.AccessKeyID = input.AccessKey
|
||||
}
|
||||
if input.AccessKeySecret == nil && input.SecretKey != nil {
|
||||
input.AccessKeySecret = input.SecretKey
|
||||
}
|
||||
for _, value := range []*string{input.AccessKeyID, input.AccessKeySecret, input.SessionToken} {
|
||||
if value != nil {
|
||||
*value = strings.TrimSpace(*value)
|
||||
}
|
||||
}
|
||||
input.Scenes = normalizeFileStorageScenes(input.Scenes)
|
||||
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
|
||||
if input.Provider == "" {
|
||||
@@ -342,11 +371,24 @@ func normalizeFileStorageChannelInput(input FileStorageChannelInput) FileStorage
|
||||
}
|
||||
|
||||
func credentialsFromFileStorageInput(input FileStorageChannelInput) map[string]any {
|
||||
apiKey := fileStorageInputAPIKey(input)
|
||||
if apiKey == "" {
|
||||
return map[string]any{}
|
||||
credentials := map[string]any{}
|
||||
if input.APIKey != nil {
|
||||
credentials["apiKey"] = fileStorageInputAPIKey(input)
|
||||
}
|
||||
return map[string]any{"apiKey": apiKey}
|
||||
if input.AccessKeyID != nil {
|
||||
credentials["accessKeyId"] = strings.TrimSpace(*input.AccessKeyID)
|
||||
}
|
||||
if input.AccessKeySecret != nil {
|
||||
credentials["accessKeySecret"] = strings.TrimSpace(*input.AccessKeySecret)
|
||||
}
|
||||
if input.SessionToken != nil {
|
||||
credentials["sessionToken"] = strings.TrimSpace(*input.SessionToken)
|
||||
}
|
||||
return credentials
|
||||
}
|
||||
|
||||
func fileStorageInputReplacesCredentials(input FileStorageChannelInput) bool {
|
||||
return input.APIKey != nil || input.AccessKeyID != nil || input.AccessKeySecret != nil || input.AccessKey != nil || input.SecretKey != nil || input.SessionToken != nil
|
||||
}
|
||||
|
||||
func fileStorageInputAPIKey(input FileStorageChannelInput) string {
|
||||
@@ -549,7 +591,10 @@ func NormalizeFileStorageResultUploadPolicy(policy string) string {
|
||||
case "upload_all", "all", "always", "all_upload":
|
||||
return FileStorageResultUploadPolicyUploadAll
|
||||
case "upload_none", "none", "never", "disabled", "no_upload", "skip", "skip_all":
|
||||
return FileStorageResultUploadPolicyUploadNone
|
||||
// Local result persistence is no longer a supported write path. Preserve
|
||||
// compatibility with historical settings by normalizing them to the safe
|
||||
// object-storage policy.
|
||||
return FileStorageResultUploadPolicyDefault
|
||||
default:
|
||||
return FileStorageResultUploadPolicyDefault
|
||||
}
|
||||
@@ -601,10 +646,22 @@ func defaultFileStorageScenes() []string {
|
||||
return []string{FileStorageSceneUpload, FileStorageSceneImageResult, FileStorageSceneRequestAsset}
|
||||
}
|
||||
|
||||
func defaultFileStorageRetryPolicyIfEmpty(policy map[string]any) map[string]any {
|
||||
func defaultFileStorageRetryPolicyIfEmpty(policy map[string]any, providers ...string) map[string]any {
|
||||
if len(policy) > 0 {
|
||||
return policy
|
||||
}
|
||||
provider := ""
|
||||
if len(providers) > 0 {
|
||||
provider = strings.ToLower(strings.TrimSpace(providers[0]))
|
||||
}
|
||||
if provider == "aliyun_oss" || provider == "s3" {
|
||||
return map[string]any{
|
||||
"enabled": true,
|
||||
"maxRetries": 2,
|
||||
"backoffSeconds": []any{0.25, 1.0},
|
||||
"strategy": "exponential",
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"enabled": true,
|
||||
"maxRetries": 3,
|
||||
|
||||
Reference in New Issue
Block a user