fix(*): 资源与技能 - 精简列表载荷并禁止对象存储静默降级

This commit is contained in:
ddcat666
2026-09-24 23:57:13 +08:00
parent 5528dbc3e4
commit d1acc8f6f3
16 changed files with 271 additions and 99 deletions

View File

@@ -58,7 +58,7 @@ func (s *Service) UserAssetsPage(userID string, page int, pageSize int, filter U
}
rawAssets := make([]json.RawMessage, 0, len(assets))
for _, asset := range assets {
if payload := clientAssetPayload(asset); len(payload) > 0 {
if payload := clientAssetListPayload(asset); len(payload) > 0 {
rawAssets = append(rawAssets, payload)
}
}

View File

@@ -15,7 +15,6 @@ import (
_ "image/png"
"infinite-canvas/backend/internal/kernel"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
@@ -432,10 +431,9 @@ func writeLocalResourceObject(filePath string, body io.Reader) error {
return closeErr
}
// A cloud write is successful only after the configured origin accepts it.
// storeResourceObject writes to the configured origin and degrades to local storage
// when the external origin is unavailable. The resource binding is rewritten before
// the caller persists the ready state, so later reads follow the actual object location.
// storeResourceObject writes to the configured origin. When object storage is
// enabled, a failed origin write must fail the resource instead of silently
// changing its storage location to local disk.
func (s *Service) storeResourceObject(resource *model.Resource, fileName string, body io.Reader) (string, error) {
if resource == nil {
return "", errors.New("资源不存在")
@@ -444,35 +442,14 @@ func (s *Service) storeResourceObject(resource *model.Resource, fileName string,
return "", writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)), body)
}
setting, settingErr := s.ossSettingForResource(resource.UserID, resource)
var etag string
var putErr error
if settingErr == nil {
etag, putErr = putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body)
if settingErr != nil {
return "", settingErr
}
if putErr == nil && settingErr == nil {
return etag, nil
etag, err := putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body)
if err != nil {
return "", err
}
fallbackErr := putErr
if fallbackErr == nil {
fallbackErr = settingErr
}
if seeker, ok := body.(io.Seeker); ok {
if _, seekErr := seeker.Seek(0, io.SeekStart); seekErr != nil {
return "", errors.Join(fallbackErr, fmt.Errorf("降级本地存储时重置读取位置失败:%w", seekErr))
}
}
localKey := localObjectKey(resource.UserID, resource.Kind, fileName, resource.MimeType, time.Now())
resource.Provider = "local"
resource.ObjectKey = localKey
resource.Endpoint = ""
resource.Bucket = ""
resource.StorageSettingID = ""
resource.ETag = ""
if localErr := writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(localKey)), body); localErr != nil {
return "", errors.Join(fallbackErr, fmt.Errorf("降级本地存储失败:%w", localErr))
}
log.Printf("object storage upload degraded to local storage: resource=%s error=%v", resource.ID, fallbackErr)
return "", nil
return etag, nil
}
func (s *Service) retryStoredResource(userID string, resource *model.Resource, kind string, mimeType string, size int64, body io.Reader) (*model.Resource, error) {

View File

@@ -31,10 +31,9 @@ func newResourceFallbackTestService(t *testing.T) (*Service, *gorm.DB) {
return &Service{repo: repository.New(db), dataDir: t.TempDir()}, db
}
// TestStoreResourceDegradesToLocalWhenOSSUnavailable 覆盖对象存储不可用时的降级:
// 配置了 OSS(enabled=1、endpoint 不可达)时上传应回退本地存储成功,
// 而不是把上传标记为失败——本地媒体导入不应因外部存储故障整体失败。
func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) {
// TestStoreResourceFailsWhenOSSUnavailable ensures an enabled object-storage
// configuration does not silently change the resource to local storage.
func TestStoreResourceFailsWhenOSSUnavailable(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
service, db := newResourceFallbackTestService(t)
seedOSSEnabled(t, db, "user-1", "http://127.0.0.1:1")
@@ -43,28 +42,28 @@ func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) {
"user-1", "video", "intro.mp4", "video/mp4", 1024,
1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, false,
)
if err != nil {
t.Fatalf("storeResource: %v", err)
if err == nil {
t.Fatal("storeResource() error = nil, want OSS upload error")
}
if !created {
t.Fatal("storeResource returned created=false, want true")
t.Fatal("storeResource returned created=false, want true for persisted failed resource")
}
if resource.Status != model.ResourceStatusReady {
t.Fatalf("resource.Status = %q, want %q", resource.Status, model.ResourceStatusReady)
if resource != nil {
t.Fatalf("storeResource returned resource = %#v, want nil on failed upload", resource)
}
if resource.Provider != "local" {
t.Fatalf("resource.Provider = %q, want local (degraded from OSS)", resource.Provider)
var stored model.Resource
if err := db.First(&stored).Error; err != nil {
t.Fatalf("failed resource was not persisted: %v", err)
}
if resource.Endpoint != "" || resource.Bucket != "" || resource.StorageSettingID != "" {
t.Fatalf("resource storage binding not cleared after degrade: endpoint=%q bucket=%q setting=%q",
resource.Endpoint, resource.Bucket, resource.StorageSettingID)
if stored.Status != model.ResourceStatusFailed {
t.Fatalf("resource.Status = %q, want %q", stored.Status, model.ResourceStatusFailed)
}
payload, err := os.ReadFile(filepath.Join(service.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)))
if err != nil {
t.Fatalf("local object not written: %v", err)
if stored.Provider != "aliyun" || stored.Endpoint != "http://127.0.0.1:1" || stored.Bucket != "test-bucket" || stored.StorageSettingID == "" {
t.Fatalf("resource OSS binding changed after failed upload: provider=%q endpoint=%q bucket=%q setting=%q", stored.Provider, stored.Endpoint, stored.Bucket, stored.StorageSettingID)
}
if string(payload) != "fake-mp4-bytes" {
t.Fatalf("local object content = %q, want fake-mp4-bytes", payload)
if _, err := os.Stat(filepath.Join(service.dataDir, "resources")); !os.IsNotExist(err) {
t.Fatalf("local resources directory exists after failed OSS upload: err=%v", err)
}
}
@@ -98,6 +97,7 @@ func TestStoreResourceLocalPathUnaffectedByOSS(t *testing.T) {
func seedOSSEnabled(t *testing.T, db *gorm.DB, userID string, endpoint string) {
t.Helper()
setting := model.UserOSSSetting{
ID: newID(),
UserID: userID,
Enabled: true,
ValueJSON: `{"provider":"aliyun","endpoint":"` + endpoint + `","bucket":"test-bucket","accessKeyId":"ak-test","accessKeySecret":"sk-plaintext","region":"cn-shenzhen"}`,

View File

@@ -13,6 +13,7 @@ type (
SkillShowcaseMedia = skills.SkillShowcaseMedia
SkillEffectiveUser = skills.SkillEffectiveUser
SkillItem = skills.SkillItem
AddedSkillReference = skills.AddedSkillReference
SkillCategory = skills.SkillCategory
SkillListRequest = skills.SkillListRequest
SkillList = skills.SkillList
@@ -41,7 +42,7 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
return s.skillDomain().Skills(userID, req)
}
func (s *Service) AddedSkills(userID string) ([]SkillItem, error) {
func (s *Service) AddedSkills(userID string) ([]AddedSkillReference, error) {
return s.skillDomain().AddedSkills(userID)
}