feat(storage): 完善对象存储配置与过期策略
补齐 OSS/S3 的 Endpoint、Region、Bucket、CDN、对象前缀和签名有效期配置,并为生成结果与请求素材自动维护分级生命周期规则。普通上传继续保持永久,私有资源按配置生成限时签名 URL,管理端连接测试覆盖生命周期、上传、读取和删除。\n\n新增可重复的真实 OSS 验收脚本,凭据仅从本地环境读取,接口响应继续保持脱敏。\n\n验证:Go 全量测试、迁移安全检查、pnpm lint、pnpm test、pnpm build、本地阿里云 OSS 真实上传下载删除验收。
This commit is contained in:
@@ -47,11 +47,13 @@ type objectStorageAdapter struct {
|
||||
}
|
||||
|
||||
type FileStorageChannelTestResult struct {
|
||||
Provider string `json:"provider"`
|
||||
PutSucceeded bool `json:"putSucceeded"`
|
||||
HeadSucceeded bool `json:"headSucceeded"`
|
||||
DeleteSucceeded bool `json:"deleteSucceeded"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Provider string `json:"provider"`
|
||||
PutSucceeded bool `json:"putSucceeded"`
|
||||
HeadSucceeded bool `json:"headSucceeded"`
|
||||
DeleteSucceeded bool `json:"deleteSucceeded"`
|
||||
LifecycleReady bool `json:"lifecycleReady"`
|
||||
ExpirationPolicy string `json:"expirationPolicy"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
// TestFileStorageChannel performs an isolated write, metadata read and cleanup
|
||||
@@ -59,16 +61,29 @@ type FileStorageChannelTestResult struct {
|
||||
// content-addressed business object and no object key or credential is exposed.
|
||||
func (s *Service) TestFileStorageChannel(ctx context.Context, channel store.FileStorageChannel) (FileStorageChannelTestResult, error) {
|
||||
startedAt := time.Now()
|
||||
result := FileStorageChannelTestResult{Provider: strings.ToLower(strings.TrimSpace(channel.Provider))}
|
||||
result := FileStorageChannelTestResult{
|
||||
Provider: strings.ToLower(strings.TrimSpace(channel.Provider)),
|
||||
ExpirationPolicy: objectStorageExpirationPolicy(channel.Config),
|
||||
}
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.ExpirationPolicy != objectStorageExpirationNever {
|
||||
if err := adapter.ensureLifecycle(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.LifecycleReady = true
|
||||
}
|
||||
probeScene := store.FileStorageSceneUpload
|
||||
if result.LifecycleReady {
|
||||
probeScene = store.FileStorageSceneImageResult
|
||||
}
|
||||
payload := FileUploadPayload{
|
||||
Bytes: []byte("easyai-storage-probe:" + uuid.NewString()),
|
||||
ContentType: "application/octet-stream",
|
||||
FileName: "probe.bin",
|
||||
Scene: store.FileStorageSceneUpload,
|
||||
Scene: probeScene,
|
||||
Source: "admin-connection-test",
|
||||
}
|
||||
upload, err := adapter.put(ctx, payload)
|
||||
@@ -112,6 +127,12 @@ func newObjectStorageAdapter(channel store.FileStorageChannel) (*objectStorageAd
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) put(ctx context.Context, payload FileUploadPayload) (map[string]any, error) {
|
||||
retention := objectStorageRetentionFor(a.channel.Config, payload.Scene, a.now().UTC())
|
||||
if retention.Enabled {
|
||||
if err := a.ensureLifecycle(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
objectKey := a.objectKey(payload)
|
||||
requestURL, err := a.objectURL(objectKey)
|
||||
if err != nil {
|
||||
@@ -126,6 +147,14 @@ func (a *objectStorageAdapter) put(ctx context.Context, payload FileUploadPayloa
|
||||
return nil, storageClientError("storage_config_invalid", err.Error(), 0, false)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
if retention.Enabled {
|
||||
tagging := retention.taggingHeader()
|
||||
if strings.EqualFold(a.channel.Provider, "s3") {
|
||||
req.Header.Set("x-amz-tagging", tagging)
|
||||
} else {
|
||||
req.Header.Set("x-oss-tagging", tagging)
|
||||
}
|
||||
}
|
||||
if err := a.sign(req, sha256Hex(payload.Bytes), a.now().UTC()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,11 +177,12 @@ func (a *objectStorageAdapter) put(ctx context.Context, payload FileUploadPayloa
|
||||
}
|
||||
urlExpiresAt := ""
|
||||
if publicURL == "" {
|
||||
publicURL, err = a.presignGet(objectKey, objectStorageSignedURLTTL)
|
||||
signedURLTTL := objectStorageSignedURLTTLForConfig(a.channel.Config)
|
||||
publicURL, err = a.presignGet(objectKey, signedURLTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urlExpiresAt = a.now().UTC().Add(objectStorageSignedURLTTL).Format(time.RFC3339)
|
||||
urlExpiresAt = a.now().UTC().Add(signedURLTTL).Format(time.RFC3339)
|
||||
}
|
||||
digest := sha256.Sum256(payload.Bytes)
|
||||
result := map[string]any{
|
||||
@@ -173,6 +203,10 @@ func (a *objectStorageAdapter) put(ctx context.Context, payload FileUploadPayloa
|
||||
if urlExpiresAt != "" {
|
||||
result["urlExpiresAt"] = urlExpiresAt
|
||||
}
|
||||
if retention.Enabled {
|
||||
result["objectExpiresAt"] = retention.ExpiresAt.Format(time.RFC3339)
|
||||
result["expirationPolicy"] = retention.Policy
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -301,10 +335,7 @@ func (a *objectStorageAdapter) objectURL(objectKey string) (string, error) {
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) publicURL(objectKey string) string {
|
||||
baseURL := strings.TrimRight(objectStorageConfigString(a.channel.Config, "publicBaseUrl"), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimRight(objectStorageConfigString(a.channel.Config, "publicBaseURL"), "/")
|
||||
}
|
||||
baseURL := strings.TrimRight(objectStoragePublicBaseURL(a.channel.Config), "/")
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -322,19 +353,45 @@ func (a *objectStorageAdapter) sign(req *http.Request, payloadHash string, now t
|
||||
|
||||
func (a *objectStorageAdapter) signOSSRequest(req *http.Request, now time.Time) {
|
||||
date := now.UTC().Format(http.TimeFormat)
|
||||
contentMD5 := req.Header.Get("Content-MD5")
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
canonicalHeaders := ""
|
||||
if token := strings.TrimSpace(a.channel.SessionToken); token != "" {
|
||||
req.Header.Set("x-oss-security-token", token)
|
||||
canonicalHeaders = "x-oss-security-token:" + token + "\n"
|
||||
}
|
||||
canonicalHeaders := canonicalOSSHeaders(req)
|
||||
canonicalResource := "/" + objectStorageConfigString(a.channel.Config, "bucket") + req.URL.EscapedPath()
|
||||
stringToSign := req.Method + "\n\n" + contentType + "\n" + date + "\n" + canonicalHeaders + canonicalResource
|
||||
if _, ok := req.URL.Query()["lifecycle"]; ok {
|
||||
canonicalResource += "?lifecycle"
|
||||
}
|
||||
stringToSign := req.Method + "\n" + contentMD5 + "\n" + contentType + "\n" + date + "\n" + canonicalHeaders + canonicalResource
|
||||
signature := hmacSHA1Base64([]byte(a.channel.AccessKeySecret), stringToSign)
|
||||
req.Header.Set("Authorization", "OSS "+a.channel.AccessKeyID+":"+signature)
|
||||
req.Header.Set("Date", date)
|
||||
}
|
||||
|
||||
func canonicalOSSHeaders(req *http.Request) string {
|
||||
headers := map[string]string{}
|
||||
for key, values := range req.Header {
|
||||
lower := strings.ToLower(strings.TrimSpace(key))
|
||||
if strings.HasPrefix(lower, "x-oss-") {
|
||||
headers[lower] = strings.Join(values, ",")
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(headers))
|
||||
for key := range headers {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var canonical strings.Builder
|
||||
for _, key := range keys {
|
||||
canonical.WriteString(key)
|
||||
canonical.WriteByte(':')
|
||||
canonical.WriteString(strings.Join(strings.Fields(headers[key]), " "))
|
||||
canonical.WriteByte('\n')
|
||||
}
|
||||
return canonical.String()
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) presignOSSGet(requestURL string, objectKey string, ttl time.Duration, now time.Time) (string, error) {
|
||||
parsed, err := url.Parse(requestURL)
|
||||
if err != nil {
|
||||
@@ -487,18 +544,27 @@ func objectStorageConfigBool(config map[string]any, key string) bool {
|
||||
}
|
||||
|
||||
func objectStorageAccessScope(channel store.FileStorageChannel, scene string) string {
|
||||
if scope := strings.ToLower(objectStorageConfigString(channel.Config, "accessScope")); scope == "public" || scope == "private" {
|
||||
return scope
|
||||
}
|
||||
if strings.TrimSpace(scene) == store.FileStorageSceneRequestAsset {
|
||||
return "private"
|
||||
}
|
||||
if strings.TrimSpace(objectStorageConfigString(channel.Config, "publicBaseUrl")) != "" || strings.TrimSpace(objectStorageConfigString(channel.Config, "publicBaseURL")) != "" {
|
||||
if scope := strings.ToLower(objectStorageConfigString(channel.Config, "accessScope")); scope == "public" || scope == "private" {
|
||||
return scope
|
||||
}
|
||||
if objectStoragePublicBaseURL(channel.Config) != "" {
|
||||
return "public"
|
||||
}
|
||||
return "private"
|
||||
}
|
||||
|
||||
func objectStoragePublicBaseURL(config map[string]any) string {
|
||||
return firstNonEmptyString(
|
||||
objectStorageConfigString(config, "publicBaseUrl"),
|
||||
objectStorageConfigString(config, "publicBaseURL"),
|
||||
objectStorageConfigString(config, "cdnDomain"),
|
||||
objectStorageConfigString(config, "publicDomain"),
|
||||
)
|
||||
}
|
||||
|
||||
func escapeObjectKey(objectKey string) string {
|
||||
parts := strings.Split(strings.TrimLeft(objectKey, "/"), "/")
|
||||
for index, part := range parts {
|
||||
|
||||
Reference in New Issue
Block a user