From bdfccf51d1a81d3e90c8c6ff2714d5edc86f45ab Mon Sep 17 00:00:00 2001 From: Ray <133741134+likeraylee@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:28:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(appearance):=20=E6=9C=AC=E5=9C=B0=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E7=AB=99=E7=82=B9=20Logo=20=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=99=BB=E5=BD=95=E8=A7=86=E9=A2=91=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=92=AD=E6=94=BE=20(#435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/appearance.go | 70 +++++++++-- backend/internal/service/appearance_test.go | 114 +++++++++++++++++- backend/internal/service/resource.go | 33 +++-- .../service/resource_oss_fallback_test.go | 4 +- backend/internal/service/resource_test.go | 4 +- backend/internal/service/task_render.go | 2 +- web/src/components/brand/brand-logo.tsx | 20 ++- .../settings/appearance-settings-page.tsx | 17 ++- web/src/pages/auth/auth-scene.tsx | 26 +++- web/src/services/api/appearance.ts | 3 + web/src/stores/use-appearance-store.ts | 6 +- web/src/styles/admin-ui.css | 54 +++++++++ web/test/appearance-bootstrap.test.ts | 13 ++ 13 files changed, 328 insertions(+), 38 deletions(-) diff --git a/backend/internal/service/appearance.go b/backend/internal/service/appearance.go index a2a0db5db..ced7049a3 100644 --- a/backend/internal/service/appearance.go +++ b/backend/internal/service/appearance.go @@ -8,6 +8,8 @@ import ( "mime/multipart" "net/http" "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -29,7 +31,7 @@ const ( ) const ( - appearanceSchemaVersion = 6 + appearanceSchemaVersion = 7 appearanceLogoMaxBytes int64 = 5 << 20 appearancePosterMaxBytes int64 = 10 << 20 appearanceVideoMaxBytes int64 = 256 << 20 @@ -56,6 +58,7 @@ type AppearanceSetting struct { LogoFrameEnabled bool `json:"logoFrameEnabled"` AuthVideoResourceID string `json:"authVideoResourceId"` AuthVideoPosterResourceID string `json:"authVideoPosterResourceId"` + AuthVideoAutoplay bool `json:"authVideoAutoplay"` SkinID string `json:"skinId"` SkinThemes []AppearanceSkinTheme `json:"skinThemes"` SEOTitle string `json:"seoTitle"` @@ -77,6 +80,7 @@ type PublicAppearanceSetting struct { LogoFrameEnabled bool `json:"logoFrameEnabled"` AuthVideoURL string `json:"authVideoUrl"` AuthVideoPosterURL string `json:"authVideoPosterUrl"` + AuthVideoAutoplay bool `json:"authVideoAutoplay"` SkinID string `json:"skinId"` ActiveSkin AppearanceSkinTheme `json:"activeSkin"` SEOTitle string `json:"seoTitle"` @@ -105,13 +109,14 @@ type AdminAppearanceSetting struct { func defaultAppearanceSetting() AppearanceSetting { return AppearanceSetting{ - SchemaVersion: appearanceSchemaVersion, - BrandName: defaultAppearanceBrandName, - BrandSlug: defaultAppearanceBrandSlug, - AuthHeroTitle: defaultAppearanceHeroTitle, - LogoFrameEnabled: true, - SkinID: defaultAppearanceSkinID, - SkinThemes: defaultAppearanceSkinThemes(), + SchemaVersion: appearanceSchemaVersion, + BrandName: defaultAppearanceBrandName, + BrandSlug: defaultAppearanceBrandSlug, + AuthHeroTitle: defaultAppearanceHeroTitle, + AuthVideoAutoplay: true, + LogoFrameEnabled: true, + SkinID: defaultAppearanceSkinID, + SkinThemes: defaultAppearanceSkinThemes(), } } @@ -133,6 +138,7 @@ func (s *Service) Appearance() (*PublicAppearanceSetting, error) { if err != nil { return nil, err } + value = s.resolveAvailableAppearanceAssets(value) return publicAppearanceSetting(setting, value), nil } @@ -144,6 +150,7 @@ func (s *Service) AdminAppearance(actor *model.User) (*AdminAppearanceSetting, e if err != nil { return nil, err } + value = s.resolveAvailableAppearanceAssets(value) result := &AdminAppearanceSetting{ AppearanceSetting: value, Public: *publicAppearanceSetting(setting, value), @@ -257,7 +264,15 @@ func (s *Service) UploadAppearanceAsset(actor *model.User, slot string, header * if slot == AppearanceAssetVideo { kind = "video" } - resource, err := s.UploadResource(actor.ID, header, kind, 0, 0, 0) + var resource *model.Resource + // Logos are tiny, installation-owned assets. Keep them in the server's + // persistent resource directory so their availability and cost do not + // depend on the administrator's currently selected object storage. + if slot == AppearanceAssetLogo || slot == AppearanceAssetDarkLogo { + resource, err = s.uploadLocalResource(actor.ID, header, kind, 0, 0, 0) + } else { + resource, err = s.UploadResource(actor.ID, header, kind, 0, 0, 0) + } if err != nil { return nil, err } @@ -364,6 +379,42 @@ func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, err return setting, value, nil } +// resolveAvailableAppearanceAssets prevents stale resource references from +// being advertised to anonymous clients or echoed back into the admin editor. +// Remote objects are not probed on every appearance request; the frontend has +// its own load-error fallback for objects that disappear outside the system. +func (s *Service) resolveAvailableAppearanceAssets(value AppearanceSetting) AppearanceSetting { + for _, slot := range []string{AppearanceAssetLogo, AppearanceAssetDarkLogo, AppearanceAssetVideo, AppearanceAssetPoster} { + resourceID := appearanceResourceID(value, slot) + if resourceID == "" || s.appearanceAssetAvailable(slot, resourceID) { + continue + } + switch slot { + case AppearanceAssetLogo: + value.LogoResourceID = "" + case AppearanceAssetDarkLogo: + value.DarkLogoResourceID = "" + case AppearanceAssetVideo: + value.AuthVideoResourceID = "" + case AppearanceAssetPoster: + value.AuthVideoPosterResourceID = "" + } + } + return value +} + +func (s *Service) appearanceAssetAvailable(slot string, resourceID string) bool { + resource, err := s.repo.Resource(resourceID) + if err != nil || validateAppearanceResourceType(slot, resource) != nil { + return false + } + if resource.Provider != "local" { + return true + } + info, err := os.Stat(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey))) + return err == nil && !info.IsDir() +} + func validateAppearanceSetting(value AppearanceSetting) error { if value.BrandName == "" || utf8.RuneCountInString(value.BrandName) > 40 { return BadAuthRequest("品牌名称必须为 1 到 40 个字符") @@ -571,6 +622,7 @@ func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetti LogoFrameEnabled: value.LogoFrameEnabled, AuthVideoURL: defaultAppearanceVideoURL, AuthVideoPosterURL: defaultAppearancePosterURL, + AuthVideoAutoplay: value.AuthVideoAutoplay, SkinID: value.SkinID, ActiveSkin: activeAppearanceSkin(value.SkinThemes, value.SkinID), SEOTitle: effectiveAppearanceSEOTitle(value), diff --git a/backend/internal/service/appearance_test.go b/backend/internal/service/appearance_test.go index 9454358db..900dde917 100644 --- a/backend/internal/service/appearance_test.go +++ b/backend/internal/service/appearance_test.go @@ -29,7 +29,7 @@ func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) { if err != nil { t.Fatal(err) } - if appearance.Configured || appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != defaultAppearanceBrandName || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.AuthVideoURL != defaultAppearanceVideoURL || appearance.AuthVideoPosterURL != defaultAppearancePosterURL || appearance.SkinID != defaultAppearanceSkinID || appearance.SEOTitle != defaultAppearanceBrandName || !strings.Contains(appearance.SEODescription, defaultAppearanceBrandName) || !strings.Contains(appearance.FooterCopyright, defaultAppearanceBrandName) || appearance.ICPFilingEnabled { + if appearance.Configured || appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != defaultAppearanceBrandName || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.AuthVideoURL != defaultAppearanceVideoURL || appearance.AuthVideoPosterURL != defaultAppearancePosterURL || !appearance.AuthVideoAutoplay || appearance.SkinID != defaultAppearanceSkinID || appearance.SEOTitle != defaultAppearanceBrandName || !strings.Contains(appearance.SEODescription, defaultAppearanceBrandName) || !strings.Contains(appearance.FooterCopyright, defaultAppearanceBrandName) || appearance.ICPFilingEnabled { t.Fatalf("Appearance() = %#v", appearance) } if appearance.LogoConfigured || appearance.DarkLogoConfigured || appearance.AuthVideoConfigured || appearance.AuthVideoPosterConfigured || appearance.Revision != "builtin" { @@ -96,7 +96,7 @@ func appearanceColorContrastRatio(t *testing.T, foreground, background string) f return (lighter + 0.05) / (darker + 0.05) } -func TestAppearanceBackfillsVersionSixFieldsForExistingSetting(t *testing.T) { +func TestAppearanceBackfillsVersionSevenFieldsForExistingSetting(t *testing.T) { svc, db, _, _ := newAppearanceTestService(t) legacy := model.SystemSetting{Key: appearanceSettingKey, ValueJSON: `{"schemaVersion":1,"brandName":"旧品牌","skinId":"classic"}`} if err := db.Create(&legacy).Error; err != nil { @@ -107,13 +107,13 @@ func TestAppearanceBackfillsVersionSixFieldsForExistingSetting(t *testing.T) { if err != nil { t.Fatal(err) } - if appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != "旧品牌" || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.SEOTitle != "旧品牌" || !strings.Contains(appearance.SEODescription, "旧品牌") || !strings.Contains(appearance.FooterCopyright, "旧品牌") || appearance.ActiveSkin.ID != "classic" { + if appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != "旧品牌" || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || !appearance.AuthVideoAutoplay || appearance.SEOTitle != "旧品牌" || !strings.Contains(appearance.SEODescription, "旧品牌") || !strings.Contains(appearance.FooterCopyright, "旧品牌") || appearance.ActiveSkin.ID != "classic" { t.Fatalf("legacy appearance = %#v", appearance) } } func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T) { - svc, db, _, admin := newAppearanceTestService(t) + svc, db, dataDir, admin := newAppearanceTestService(t) resources := []model.Resource{ {ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"}, {ID: "brand-logo-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo-dark.png", MimeType: "image/png"}, @@ -123,6 +123,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T if err := db.Create(&resources).Error; err != nil { t.Fatal(err) } + writeAppearanceResourceFixtures(t, dataDir, resources) updated, err := svc.UpdateAppearance(admin, AppearanceSetting{ BrandName: "HIMA Studio", @@ -134,6 +135,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T LogoFrameEnabled: false, AuthVideoResourceID: "brand-video", AuthVideoPosterResourceID: "brand-poster", + AuthVideoAutoplay: false, SkinID: "studio-indigo", SEOTitle: "HIMA Studio - AI 影视工作台", SEODescription: "面向 Agent、图片、视频、画布与短剧生产的一体化 AI 创作工作台。", @@ -145,7 +147,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T if err != nil { t.Fatal(err) } - if !updated.Configured || updated.BrandName != "HIMA Studio" || updated.BrandSlug != "hima-studio" || updated.Public.BrandSlug != "hima-studio" || updated.AuthHeroTitle != "把灵感,\n变成可见的故事。" || updated.Public.AuthHeroDescription != "从画布开始,持续推进你的创作。" || !updated.Public.LogoConfigured || !updated.Public.DarkLogoConfigured || updated.Public.LogoFrameEnabled || !updated.Public.AuthVideoConfigured || !updated.Public.AuthVideoPosterConfigured || updated.Public.SkinID != "studio-indigo" || updated.Public.SEOTitle != "HIMA Studio - AI 影视工作台" || updated.Public.SEOKeywords != "AI 影视,短剧,画布" || !updated.Public.ICPFilingEnabled || updated.Public.ICPFilingNumber != "蜀ICP备2026000000号-1" { + if !updated.Configured || updated.BrandName != "HIMA Studio" || updated.BrandSlug != "hima-studio" || updated.Public.BrandSlug != "hima-studio" || updated.AuthHeroTitle != "把灵感,\n变成可见的故事。" || updated.Public.AuthHeroDescription != "从画布开始,持续推进你的创作。" || !updated.Public.LogoConfigured || !updated.Public.DarkLogoConfigured || updated.Public.LogoFrameEnabled || !updated.Public.AuthVideoConfigured || !updated.Public.AuthVideoPosterConfigured || updated.Public.AuthVideoAutoplay || updated.Public.SkinID != "studio-indigo" || updated.Public.SEOTitle != "HIMA Studio - AI 影视工作台" || updated.Public.SEOKeywords != "AI 影视,短剧,画布" || !updated.Public.ICPFilingEnabled || updated.Public.ICPFilingNumber != "蜀ICP备2026000000号-1" { t.Fatalf("UpdateAppearance() = %#v", updated) } for _, assetURL := range []string{updated.Public.LogoURL, updated.Public.DarkLogoURL, updated.Public.AuthVideoURL, updated.Public.AuthVideoPosterURL} { @@ -173,7 +175,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T } func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) { - svc, db, _, admin := newAppearanceTestService(t) + svc, db, dataDir, admin := newAppearanceTestService(t) resources := []model.Resource{ {ID: "only-light", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/light.png", MimeType: "image/png"}, {ID: "only-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/dark.png", MimeType: "image/png"}, @@ -181,6 +183,7 @@ func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) { if err := db.Create(&resources).Error; err != nil { t.Fatal(err) } + writeAppearanceResourceFixtures(t, dataDir, resources) lightOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: "only-light", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID}) if err != nil { @@ -281,6 +284,89 @@ func TestOpenAppearanceAssetOnlyServesConfiguredSlot(t *testing.T) { } } +func TestAppearanceFallsBackWhenConfiguredLogoResourceIsMissing(t *testing.T) { + svc, db, _, admin := newAppearanceTestService(t) + value := defaultAppearanceSetting() + value.BrandName = "HIMA Studio" + value.BrandSlug = "hima-studio" + value.LogoResourceID = "missing-logo" + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil { + t.Fatal(err) + } + + appearance, err := svc.Appearance() + if err != nil { + t.Fatal(err) + } + if appearance.LogoConfigured || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL { + t.Fatalf("missing logo public appearance = %#v", appearance) + } + adminAppearance, err := svc.AdminAppearance(admin) + if err != nil { + t.Fatal(err) + } + if adminAppearance.LogoResourceID != "" || adminAppearance.Public.LogoConfigured { + t.Fatalf("missing logo admin appearance = %#v", adminAppearance) + } +} + +func TestAppearanceFallsBackWhenLocalLogoFileIsMissing(t *testing.T) { + svc, db, _, _ := newAppearanceTestService(t) + resource := model.Resource{ID: "missing-local-logo", UserID: "appearance-admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/missing.png", MimeType: "image/png"} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + value := defaultAppearanceSetting() + value.LogoResourceID = resource.ID + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil { + t.Fatal(err) + } + + appearance, err := svc.Appearance() + if err != nil { + t.Fatal(err) + } + if appearance.LogoConfigured || appearance.LogoURL != defaultAppearanceLogoURL { + t.Fatalf("missing local logo public appearance = %#v", appearance) + } +} + +func TestUploadAppearanceLogoAlwaysUsesServerLocalStorage(t *testing.T) { + svc, db, dataDir, admin := newAppearanceTestService(t) + requests := make(chan struct{}, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests <- struct{}{} + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + seedOSSEnabled(t, db, admin.ID, server.URL) + pngBytes := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...) + + for _, slot := range []string{AppearanceAssetLogo, AppearanceAssetDarkLogo} { + resource, err := svc.UploadAppearanceAsset(admin, slot, multipartFileHeader(t, slot+".png", "image/png", pngBytes)) + if err != nil { + t.Fatalf("UploadAppearanceAsset(%s): %v", slot, err) + } + if resource.Provider != "local" || resource.Endpoint != "" || resource.Bucket != "" || resource.StorageSettingID != "" { + t.Fatalf("UploadAppearanceAsset(%s) storage = %#v", slot, resource) + } + if _, err := os.Stat(filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey))); err != nil { + t.Fatalf("UploadAppearanceAsset(%s) local file: %v", slot, err) + } + } + if len(requests) != 0 { + t.Fatalf("logo upload unexpectedly contacted object storage %d time(s)", len(requests)) + } +} + func TestUpdateAppearanceValidatesLoginCopy(t *testing.T) { svc, _, _, admin := newAppearanceTestService(t) @@ -415,6 +501,22 @@ func multipartFileHeader(t *testing.T, name string, contentType string, data []b return req.MultipartForm.File["file"][0] } +func writeAppearanceResourceFixtures(t *testing.T, dataDir string, resources []model.Resource) { + t.Helper() + for _, resource := range resources { + if resource.Provider != "local" { + continue + } + filePath := filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey)) + if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filePath, []byte(resource.ID), 0o640); err != nil { + t.Fatal(err) + } + } +} + func newAppearanceTestService(t *testing.T) (*Service, *gorm.DB, string, *model.User) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index 4678da70f..6f9bb7235 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -269,6 +269,17 @@ func validatePublicResourceBaseURL(raw string) (*url.URL, error) { } func (s *Service) UploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { + return s.uploadResource(userID, header, kind, width, height, durationMs, false, uploadIdentity...) +} + +// uploadLocalResource keeps small system-owned assets on the server even when +// the uploader or platform has enabled object storage. The resource still uses +// the normal database record and persistent data directory lifecycle. +func (s *Service) uploadLocalResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { + return s.uploadResource(userID, header, kind, width, height, durationMs, true, uploadIdentity...) +} + +func (s *Service) uploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, forceLocal bool, uploadIdentity ...string) (*model.Resource, error) { if header == nil { return nil, BadAuthRequest("请选择要上传的文件") } @@ -298,7 +309,7 @@ func (s *Service) UploadResource(userID string, header *multipart.FileHeader, ki if err != nil { return nil, err } - resource, stored, err := s.storeResource(userID, kind, header.Filename, mimeType, header.Size, width, height, durationMs, file, uploadKey) + resource, stored, err := s.storeResource(userID, kind, header.Filename, mimeType, header.Size, width, height, durationMs, file, uploadKey, forceLocal) if err != nil { s.releaseUserUploadQuota(userID, day, header.Size) } else if stored { @@ -334,7 +345,7 @@ func (s *Service) UploadResourceFile(userID string, fileName string, size int64, if err != nil { return nil, err } - resource, stored, err := s.storeResource(userID, kind, fileName, mimeType, size, width, height, durationMs, file, uploadKey) + resource, stored, err := s.storeResource(userID, kind, fileName, mimeType, size, width, height, durationMs, file, uploadKey, false) if err != nil { s.releaseUserUploadQuota(userID, day, size) } else if stored { @@ -397,7 +408,7 @@ func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, w if err != nil { return nil, err } - resource, stored, err := s.storeResource(userID, kind, payload.fileName, payload.mimeType, size, width, height, durationMs, bytes.NewReader(payload.data), uploadKey) + resource, stored, err := s.storeResource(userID, kind, payload.fileName, payload.mimeType, size, width, height, durationMs, bytes.NewReader(payload.data), uploadKey, false) if err != nil { s.releaseUserUploadQuota(userID, day, size) } else if stored { @@ -492,7 +503,7 @@ func (s *Service) openResourceRange(userID string, resource *model.Resource, ran return &ResourceStream{Resource: resource, Body: stream.body, StatusCode: stream.statusCode, ContentLength: stream.contentLength, ContentRange: stream.contentRange, AcceptRanges: stream.acceptRanges}, nil } -func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string) (*model.Resource, bool, error) { +func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool) (*model.Resource, bool, error) { if existing, err := s.resourceForUploadKey(userID, uploadKey); err != nil { return nil, false, err } else if existing != nil { @@ -503,9 +514,15 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim } now := time.Now() kind = normalizeResourceKind(kind, mimeType) - setting, storageSettingID, useOSS, err := s.activeResourceOSSSetting(userID) - if err != nil { - return nil, false, err + var setting ossSettingValue + var storageSettingID string + var useOSS bool + var err error + if !forceLocal { + setting, storageSettingID, useOSS, err = s.activeResourceOSSSetting(userID) + if err != nil { + return nil, false, err + } } provider := "local" objectKey := localObjectKey(userID, kind, fileName, mimeType, now) @@ -752,7 +769,7 @@ func (s *Service) persistGeneratedMediaValueMode(userID string, value interface{ return nil, err } } - resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil) + resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil, false) if err != nil { if enforceQuota { s.releaseUserUploadQuota(userID, quotaDay, int64(len(data))) diff --git a/backend/internal/service/resource_oss_fallback_test.go b/backend/internal/service/resource_oss_fallback_test.go index dae0364f8..461d255bb 100644 --- a/backend/internal/service/resource_oss_fallback_test.go +++ b/backend/internal/service/resource_oss_fallback_test.go @@ -40,7 +40,7 @@ func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) { resource, created, err := service.storeResource( "user-1", "video", "intro.mp4", "video/mp4", 1024, - 1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, + 1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, false, ) if err != nil { t.Fatalf("storeResource: %v", err) @@ -74,7 +74,7 @@ func TestStoreResourceLocalPathUnaffectedByOSS(t *testing.T) { resource, created, err := service.storeResource( "user-2", "video", "local.mp4", "video/mp4", 512, - 1280, 720, 0, bytes.NewReader([]byte("local-bytes")), nil, + 1280, 720, 0, bytes.NewReader([]byte("local-bytes")), nil, false, ) if err != nil { t.Fatalf("storeResource: %v", err) diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go index 937cb177f..529fb4421 100644 --- a/backend/internal/service/resource_test.go +++ b/backend/internal/service/resource_test.go @@ -925,14 +925,14 @@ func newResourceTestService(t *testing.T) *Service { func TestStoreResourceReusesReadyUploadIdentity(t *testing.T) { svc := newResourceTestService(t) uploadKey := normalizedResourceUploadKey([]string{"image:user-1:logical-upload"}) - first, stored, err := svc.storeResource("user-1", "image", "first.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey) + first, stored, err := svc.storeResource("user-1", "image", "first.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey, false) if err != nil { t.Fatal(err) } if !stored { t.Fatal("first upload was not stored") } - second, stored, err := svc.storeResource("user-1", "image", "second.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey) + second, stored, err := svc.storeResource("user-1", "image", "second.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey, false) if err != nil { t.Fatal(err) } diff --git a/backend/internal/service/task_render.go b/backend/internal/service/task_render.go index 9c497abb9..f02defab6 100644 --- a/backend/internal/service/task_render.go +++ b/backend/internal/service/task_render.go @@ -88,7 +88,7 @@ func (w *taskWorkerCoordinator) processTimelineRender(task *model.Task, ctx cont } durationMs := planDurationMs(plan) fileName := fmt.Sprintf("timeline-render-%s.mp4", time.Now().Format("20060102-150405")) - resource, _, err := s.storeResource(task.UserID, "media", fileName, "video/mp4", stat.Size(), renderWidth, renderHeight, durationMs, file, nil) + resource, _, err := s.storeResource(task.UserID, "media", fileName, "video/mp4", stat.Size(), renderWidth, renderHeight, durationMs, file, nil, false) if err != nil || resource == nil { return w.failTimelineTask(task, "渲染失败", "保存渲染产物失败") } diff --git a/web/src/components/brand/brand-logo.tsx b/web/src/components/brand/brand-logo.tsx index c88cee3e6..b21d48b7d 100644 --- a/web/src/components/brand/brand-logo.tsx +++ b/web/src/components/brand/brand-logo.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, ReactNode } from "react"; +import { useState, type CSSProperties, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { appearanceLogoURL, useAppearanceStore } from "@/stores/use-appearance-store"; @@ -14,8 +14,24 @@ type BrandLogoProps = { export function BrandLogo({ className, fallback, alt = "", theme = "auto" }: BrandLogoProps) { const appearance = useAppearanceStore((state) => state.appearance); const currentTheme = useThemeStore((state) => state.theme); + const source = appearanceLogoURL(appearance, theme === "auto" ? currentTheme : theme); + const [failedSource, setFailedSource] = useState(null); if (!appearance.logoConfigured) return <>{fallback}; - return {alt}; + // A configured custom logo must never fall through to the built-in brand + // when its file becomes unavailable. Keep its footprint neutral instead. + if (failedSource === source) return