fix(appearance): 本地保存站点 Logo 并支持登录视频自动播放 (#435)

This commit is contained in:
Ray
2026-09-07 22:28:32 +08:00
committed by GitHub
parent 21b83eca2e
commit 487b1b4b29
13 changed files with 328 additions and 38 deletions

View File

@@ -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),

View File

@@ -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{})

View File

@@ -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)))

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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, "渲染失败", "保存渲染产物失败")
}

View File

@@ -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<string | null>(null);
if (!appearance.logoConfigured) return <>{fallback}</>;
return <img src={appearanceLogoURL(appearance, theme === "auto" ? currentTheme : theme)} alt={alt} className={cn("block object-contain", className)} draggable={false} />;
// 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 <span className={cn("block", className)} aria-hidden="true" />;
return (
<img
src={source}
alt={alt}
className={cn("block object-contain", className)}
draggable={false}
onError={(event) => {
event.currentTarget.style.visibility = "hidden";
setFailedSource(source);
}}
/>
);
}
export function BrandLogoFrame({ className, logoClassName, fallback, alt = "", theme = "auto" }: BrandLogoProps & { logoClassName?: string }) {

View File

@@ -31,6 +31,7 @@ export default function AppearanceSettingsPage() {
const [brandSlug, setBrandSlug] = useState("");
const [authHeroTitle, setAuthHeroTitle] = useState("");
const [authHeroDescription, setAuthHeroDescription] = useState("");
const [authVideoAutoplay, setAuthVideoAutoplay] = useState(true);
const [logoFrameEnabled, setLogoFrameEnabled] = useState(true);
const [skinId, setSkinId] = useState("classic");
const [skinThemes, setSkinThemes] = useState<SkinDefinition[]>([DEFAULT_CLASSIC_SKIN]);
@@ -61,6 +62,7 @@ export default function AppearanceSettingsPage() {
brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug ||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
authVideoAutoplay !== setting?.authVideoAutoplay ||
logoFrameEnabled !== setting?.logoFrameEnabled ||
skinId !== setting?.skinId ||
JSON.stringify(skinThemes) !== JSON.stringify(setting?.skinThemes) ||
@@ -80,6 +82,7 @@ export default function AppearanceSettingsPage() {
setBrandSlug(value.brandSlug);
setAuthHeroTitle(value.authHeroTitle);
setAuthHeroDescription(value.authHeroDescription);
setAuthVideoAutoplay(value.authVideoAutoplay);
setLogoFrameEnabled(value.logoFrameEnabled);
const themes = value.skinThemes.map((theme) => normalizeSkinDefinition(theme));
setSkinThemes(themes.length ? themes : [cloneSkinDefinition(DEFAULT_CLASSIC_SKIN)]);
@@ -189,6 +192,7 @@ export default function AppearanceSettingsPage() {
setBrandSlug(setting.brandSlug);
setAuthHeroTitle(setting.authHeroTitle);
setAuthHeroDescription(setting.authHeroDescription);
setAuthVideoAutoplay(setting.authVideoAutoplay);
setLogoFrameEnabled(setting.logoFrameEnabled);
setSkinId(setting.skinId);
setSkinThemes(setting.skinThemes.map((theme) => cloneSkinDefinition(theme)));
@@ -324,6 +328,7 @@ export default function AppearanceSettingsPage() {
logoFrameEnabled,
authVideoResourceId: ids.video,
authVideoPosterResourceId: ids.poster,
authVideoAutoplay,
skinId,
skinThemes,
seoTitle: nextSeoTitle,
@@ -547,6 +552,16 @@ export default function AppearanceSettingsPage() {
</Form.Item>
</Form>
<div className="admin-appearance-media-list">
<div className="admin-appearance-video-autoplay-option">
<span className="admin-appearance-video-autoplay-copy">
<strong>登录页视频自动播放</strong>
<small>默认开启并静音循环播放;访客启用“减少动态效果”时仍尊重其系统偏好。</small>
</span>
<span className="admin-appearance-video-autoplay-control">
<span>{authVideoAutoplay ? "已开启" : "已关闭"}</span>
<Switch checked={authVideoAutoplay} disabled={saving || refreshing || restoring} aria-label="登录页视频自动播放" onChange={setAuthVideoAutoplay} />
</span>
</div>
<AssetPicker
slot="video"
title="品牌视频"
@@ -580,7 +595,7 @@ export default function AppearanceSettingsPage() {
<AdminStatusBadge label={dirty ? "未保存" : "线上版本"} tone={dirty ? "warning" : "success"} />
</div>
<div className="admin-appearance-preview-stage">
<video key={previews.video} src={previews.video} poster={previews.poster || undefined} muted loop playsInline autoPlay preload="metadata" />
<video key={`${previews.video}-${authVideoAutoplay}`} src={previews.video} poster={previews.poster || undefined} muted loop playsInline autoPlay={authVideoAutoplay} preload="metadata" />
<span className="admin-appearance-preview-shade" />
<span className="admin-appearance-preview-brand">
<img src={previews.logoDark} alt="" />

View File

@@ -1,7 +1,7 @@
import { motion, useReducedMotion } from "motion/react";
import { ConfigProvider, Tabs } from "antd";
import { ArrowLeft, Play } from "lucide-react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router";
import { BrandLogo } from "@/components/brand/brand-logo";
@@ -51,17 +51,33 @@ export function AuthScene() {
const location = useLocation();
const navigate = useNavigate();
const reducedMotion = useReducedMotion();
const [videoActive, setVideoActive] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const [manualVideoActive, setManualVideoActive] = useState(false);
const [videoPlaying, setVideoPlaying] = useState(false);
const [failedPosterURL, setFailedPosterURL] = useState("");
const recovery = location.pathname === "/forgot-password";
const activeTab = location.pathname === "/register" ? "register" : "login";
const copy = recovery ? authCopy.recovery : activeTab === "register" ? authCopy.register : authCopy.login;
const automaticVideoActive = appearance.authVideoAutoplay && !reducedMotion;
const videoActive = Boolean(appearance.authVideoUrl && (automaticVideoActive || manualVideoActive));
useEffect(() => {
setManualVideoActive(false);
setVideoPlaying(false);
}, [appearance.authVideoUrl, appearance.authVideoAutoplay]);
const playVideo = () => {
setManualVideoActive(true);
requestAnimationFrame(() => {
void videoRef.current?.play().catch(() => setVideoPlaying(false));
});
};
return (
<main className="auth-scene h-dvh min-h-0 overflow-y-auto text-white lg:overflow-hidden">
<div className="grid min-h-full lg:h-full lg:grid-cols-[minmax(0,1.32fr)_minmax(520px,1fr)]">
<section className="relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label={`${appearance.brandName}品牌影片`}>
{videoActive && appearance.authVideoUrl ? <video className="absolute inset-0 size-full object-cover" src={appearance.authVideoUrl} poster={appearance.authVideoPosterUrl || undefined} autoPlay={!reducedMotion} muted loop playsInline preload="metadata" /> : appearance.authVideoPosterUrl && failedPosterURL !== appearance.authVideoPosterUrl ? <img className="absolute inset-0 size-full object-cover" src={appearance.authVideoPosterUrl} alt="" decoding="async" onError={() => setFailedPosterURL(appearance.authVideoPosterUrl)} /> : null}
{videoActive && appearance.authVideoUrl ? <video ref={videoRef} className="absolute inset-0 size-full object-cover" src={appearance.authVideoUrl} poster={appearance.authVideoPosterUrl || undefined} autoPlay muted loop playsInline preload="metadata" onPlay={() => setVideoPlaying(true)} onPause={() => setVideoPlaying(false)} /> : appearance.authVideoPosterUrl && failedPosterURL !== appearance.authVideoPosterUrl ? <img className="absolute inset-0 size-full object-cover" src={appearance.authVideoPosterUrl} alt="" decoding="async" onError={() => setFailedPosterURL(appearance.authVideoPosterUrl)} /> : null}
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgba(4,5,8,.58),transparent_42%,rgba(4,5,8,.74))]" />
<div aria-hidden className="auth-scene-video-blend absolute inset-y-0 right-0 hidden w-[clamp(120px,14vw,240px)] lg:block" />
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-5 sm:p-7 lg:p-9">
@@ -69,9 +85,9 @@ export function AuthScene() {
<BrandLogo theme="dark" className="size-7" alt="" fallback={<span className="size-7 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />} />
{appearance.brandName}
</Link>
<button type="button" className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[var(--fs-label)] text-white/76 backdrop-blur-xl transition hover:bg-black/35 disabled:cursor-default" onClick={() => setVideoActive(true)} disabled={videoActive || !appearance.authVideoUrl} aria-pressed={videoActive}>
<button type="button" className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[var(--fs-label)] text-white/76 backdrop-blur-xl transition hover:bg-black/35 disabled:cursor-default" onClick={playVideo} disabled={videoPlaying || !appearance.authVideoUrl} aria-pressed={videoPlaying}>
<Play className="size-3 fill-current" />
{videoActive ? "创作正在发生" : "播放品牌影片"}
{videoPlaying ? "创作正在发生" : "播放品牌影片"}
</button>
</div>
<motion.div

View File

@@ -12,6 +12,7 @@ export type PublicAppearance = {
logoFrameEnabled: boolean;
authVideoUrl: string;
authVideoPosterUrl: string;
authVideoAutoplay: boolean;
skinId: string;
activeSkin: SkinDefinition;
seoTitle: string;
@@ -40,6 +41,7 @@ export type AdminAppearance = {
logoFrameEnabled: boolean;
authVideoResourceId: string;
authVideoPosterResourceId: string;
authVideoAutoplay: boolean;
skinId: string;
skinThemes: SkinDefinition[];
seoTitle: string;
@@ -87,6 +89,7 @@ export async function updateAdminAppearance(
| "logoFrameEnabled"
| "authVideoResourceId"
| "authVideoPosterResourceId"
| "authVideoAutoplay"
| "skinId"
| "skinThemes"
| "seoTitle"

View File

@@ -4,7 +4,7 @@ import type { PublicAppearance } from "@/services/api/appearance";
import { applySkinTheme, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition } from "@/lib/skin-themes";
export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
schemaVersion: 6,
schemaVersion: 7,
brandName: "影策",
brandSlug: "open-ai-canvas",
authHeroTitle: "让一个故事,\n从文字走向银幕。",
@@ -14,6 +14,7 @@ export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
logoFrameEnabled: true,
authVideoUrl: "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4",
authVideoPosterUrl: "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg",
authVideoAutoplay: true,
skinId: "classic",
activeSkin: DEFAULT_CLASSIC_SKIN,
seoTitle: "影策",
@@ -59,7 +60,7 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
return {
...DEFAULT_PUBLIC_APPEARANCE,
...value,
schemaVersion: 6,
schemaVersion: 7,
brandName: resolvedBrandName,
brandSlug,
authHeroTitle,
@@ -69,6 +70,7 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
logoFrameEnabled: value?.logoFrameEnabled !== false,
authVideoUrl: safeAppearanceURL(value?.authVideoUrl, DEFAULT_PUBLIC_APPEARANCE.authVideoUrl),
authVideoPosterUrl: safeAppearanceURL(value?.authVideoPosterUrl, customVideo ? "" : DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl),
authVideoAutoplay: value?.authVideoAutoplay !== false,
skinId: normalizeSkinDefinition(value?.activeSkin).id,
activeSkin: normalizeSkinDefinition(value?.activeSkin),
seoTitle,

View File

@@ -248,6 +248,50 @@
margin-top: 4px;
}
.admin-appearance-video-autoplay-option {
display: flex;
min-height: 72px;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 1px solid var(--admin-card-border);
border-radius: var(--r-md);
background: var(--admin-layer-2);
padding: 12px 14px;
}
.admin-appearance-video-autoplay-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.admin-appearance-video-autoplay-copy strong {
color: var(--admin-text-primary);
font-size: 12px;
font-weight: 650;
}
.admin-appearance-video-autoplay-copy small,
.admin-appearance-video-autoplay-control > span {
color: var(--admin-text-muted);
font-size: 11px;
line-height: 1.5;
}
.admin-appearance-video-autoplay-control {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.admin-appearance-video-autoplay-control > span {
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.admin-appearance-asset-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
@@ -1222,6 +1266,16 @@
justify-content: space-between;
}
.admin-appearance-video-autoplay-option {
align-items: flex-start;
flex-direction: column;
}
.admin-appearance-video-autoplay-control {
width: 100%;
justify-content: space-between;
}
.admin-appearance-asset-actions {
grid-column: 1 / -1;
justify-content: flex-start;

View File

@@ -28,6 +28,12 @@ test("a custom login video never falls back to the built-in poster", () => {
expect(appearance.authHeroTitle).toBe("把灵感,\n变成可见的故事。");
expect(appearance.authHeroDescription).toBe("从同一个创作空间持续推进。");
expect(appearance.authVideoPosterUrl).toBe("");
expect(appearance.authVideoAutoplay).toBe(true);
});
test("login video autoplay defaults on and can be disabled explicitly", () => {
expect(normalizePublicAppearance({}).authVideoAutoplay).toBe(true);
expect(normalizePublicAppearance({ authVideoAutoplay: false }).authVideoAutoplay).toBe(false);
});
test("appearance URLs reject executable and insecure remote schemes", () => {
@@ -64,6 +70,7 @@ test("auth scene consumes resolved appearance instead of hardcoded media constan
const source = await Bun.file(new URL("../src/pages/auth/auth-scene.tsx", import.meta.url)).text();
expect(source).toContain("appearance.authVideoUrl");
expect(source).toContain("appearance.authVideoAutoplay");
expect(source).toContain("appearance.authVideoPosterUrl || undefined");
expect(source).toContain("appearance.brandName");
expect(source).toContain("appearance.authHeroTitle");
@@ -91,8 +98,14 @@ test("appearance management exposes light and dark logo uploads plus the frame s
expect(pageSource).toContain("setLogoFrameEnabled(!checked)");
expect(pageSource).not.toContain("<Checkbox");
expect(pageSource).toContain("深浅模式 Logo 预览");
expect(pageSource).toContain("登录页视频自动播放");
expect(pageSource).toContain("authVideoAutoplay");
expect(brandSource).toContain("useThemeStore");
expect(brandSource).toContain("data-logo-frame-enabled");
expect(brandSource).toContain("failedSource === source");
expect(brandSource).toContain('aria-hidden="true"');
expect(brandSource).toContain('style.visibility = "hidden"');
expect(brandSource).toContain("setFailedSource(source)");
expect(adminStyles).toContain(".admin-appearance-logo-preview-mark.is-unframed img");
expect(globalStyles).toContain('.brand-logo-frame[data-logo-frame-enabled="false"] > :is(img, svg)');
});