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:
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -80,6 +81,7 @@ func TestRequestAssetUsesPrivateSignedURLEvenWhenPublicBaseURLExists(t *testing.
|
||||
defer server.Close()
|
||||
channel := testObjectStorageChannel("s3", server.URL, "s3-private")
|
||||
channel.Config["publicBaseUrl"] = "https://cdn.example"
|
||||
channel.Config["accessScope"] = "public"
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -101,6 +103,112 @@ func TestRequestAssetUsesPrivateSignedURLEvenWhenPublicBaseURLExists(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateSignedURLUsesConfiguredTTL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
channel := testObjectStorageChannel("s3", server.URL, "s3-private-ttl")
|
||||
channel.Config["signedUrlExpiresSeconds"] = 3600
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter.now = func() time.Time { return time.Date(2026, time.August, 4, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
result, err := adapter.put(t.Context(), FileUploadPayload{
|
||||
Bytes: []byte("private input"), ContentType: "image/png", FileName: "input.png", Scene: store.FileStorageSceneRequestAsset,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(stringFromAny(result["url"]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := parsed.Query().Get("X-Amz-Expires"); got != "3600" {
|
||||
t.Fatalf("X-Amz-Expires=%q, want 3600", got)
|
||||
}
|
||||
if got := stringFromAny(result["urlExpiresAt"]); got != "2026-08-04T01:00:00Z" {
|
||||
t.Fatalf("urlExpiresAt=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryObjectEnsuresS3LifecycleAndAddsTagging(t *testing.T) {
|
||||
var methods []string
|
||||
var lifecyclePayload string
|
||||
var tagging string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, lifecycle := r.URL.Query()["lifecycle"]; lifecycle {
|
||||
methods = append(methods, r.Method+" lifecycle")
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
lifecyclePayload = string(body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
methods = append(methods, r.Method+" object")
|
||||
tagging = r.Header.Get("x-amz-tagging")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := testObjectStorageChannel("s3", server.URL, "s3-expiring")
|
||||
channel.Config["temporaryFileExpirePolicy"] = "1m"
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter.now = func() time.Time { return time.Date(2026, time.August, 4, 0, 0, 0, 0, time.UTC) }
|
||||
result, err := adapter.put(t.Context(), FileUploadPayload{
|
||||
Bytes: []byte("temporary image"), ContentType: "image/png", Scene: store.FileStorageSceneImageResult,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(methods, ","); got != "GET lifecycle,PUT lifecycle,PUT object" {
|
||||
t.Fatalf("methods=%s", got)
|
||||
}
|
||||
for _, fragment := range []string{"DeleteTempFiles-1d", "DeleteTempFiles-1m", "DeleteTempFiles-3m", "DeleteTempFiles-6m", "<Value>1m</Value>"} {
|
||||
if !strings.Contains(lifecyclePayload, fragment) {
|
||||
t.Fatalf("lifecycle payload missing %q: %s", fragment, lifecyclePayload)
|
||||
}
|
||||
}
|
||||
values, err := url.ParseQuery(tagging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values.Get("TempFile") != "true" || values.Get("ExpiresIn") != "1m" || values.Get("ExpiresAt") != "1788393600" {
|
||||
t.Fatalf("unexpected tagging: %q", tagging)
|
||||
}
|
||||
if got := stringFromAny(result["objectExpiresAt"]); got != "2026-09-03T00:00:00Z" {
|
||||
t.Fatalf("objectExpiresAt=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeObjectStorageLifecyclePreservesUnrelatedRules(t *testing.T) {
|
||||
current := []byte(`<?xml version="1.0"?><LifecycleConfiguration><Rule><ID>KeepArchive</ID><Prefix>archive/</Prefix><Status>Enabled</Status><Expiration><Days>365</Days></Expiration></Rule></LifecycleConfiguration>`)
|
||||
merged, changed, err := mergeObjectStorageLifecycle("aliyun_oss", current)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("lifecycle merge unexpectedly reported no change")
|
||||
}
|
||||
value := string(merged)
|
||||
if !strings.Contains(value, "<ID>KeepArchive</ID>") || !strings.Contains(value, "<Prefix>archive/</Prefix>") {
|
||||
t.Fatalf("unrelated lifecycle rule was not preserved: %s", value)
|
||||
}
|
||||
for _, definition := range objectStorageLifecycleDefinitions {
|
||||
if !strings.Contains(value, "<ID>"+definition.ID+"</ID>") {
|
||||
t.Fatalf("managed lifecycle rule %s missing", definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunOSSUsesBucketVirtualHostForRegionalEndpoint(t *testing.T) {
|
||||
channel := testObjectStorageChannel("aliyun_oss", "https://oss-cn-hangzhou.aliyuncs.com", "oss-primary")
|
||||
channel.Config["forcePathStyle"] = false
|
||||
@@ -138,6 +246,37 @@ func TestAliyunOSSRequestUsesSignatureV1(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunOSSSignatureIncludesLifecycleTaggingHeader(t *testing.T) {
|
||||
channel := testObjectStorageChannel("aliyun_oss", "https://oss-cn-hangzhou.aliyuncs.com", "oss-tagged")
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPut, "https://bucket.oss-cn-hangzhou.aliyuncs.com/media/image_result/object.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "image/png")
|
||||
request.Header.Set("x-oss-tagging", "ExpiresAt=1788393600&ExpiresIn=1m&TempFile=true")
|
||||
now := time.Date(2026, time.August, 4, 1, 2, 3, 0, time.UTC)
|
||||
adapter.signOSSRequest(request, now)
|
||||
stringToSign := "PUT\n\nimage/png\nTue, 04 Aug 2026 01:02:03 GMT\n" +
|
||||
"x-oss-tagging:ExpiresAt=1788393600&ExpiresIn=1m&TempFile=true\n" +
|
||||
"/bucket/media/image_result/object.png"
|
||||
want := "OSS access-id:" + hmacSHA1Base64([]byte("access-secret"), stringToSign)
|
||||
if got := request.Header.Get("Authorization"); got != want {
|
||||
t.Fatalf("authorization=%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryRetentionDoesNotApplyToPersistentUploadScene(t *testing.T) {
|
||||
config := map[string]any{"temporaryFileExpirePolicy": "1d"}
|
||||
retention := objectStorageRetentionFor(config, store.FileStorageSceneUpload, time.Now())
|
||||
if retention.Enabled || retention.Policy != objectStorageExpirationNever {
|
||||
t.Fatalf("persistent upload unexpectedly expires: %+v", retention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectStorageRetriesCurrentChannelThenFailsOver(t *testing.T) {
|
||||
var primaryCalls atomic.Int64
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user