package app import ( "encoding/binary" "encoding/json" "errors" "fmt" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" "unicode" "unicode/utf8" "yingce/backend/internal/assets" "yingce/backend/internal/model" "gorm.io/gorm" ) const appearanceSettingKey = "appearance" const ( AppearanceAssetLogo = "logo" AppearanceAssetDarkLogo = "logo-dark" AppearanceAssetVideo = "video" AppearanceAssetPoster = "poster" AppearanceAssetUpdateImage = "update-image" AppearanceAssetUpdateVideo = "update-video" ) const ( appearanceSchemaVersion = 10 appearanceLogoMaxBytes int64 = 5 << 20 appearancePosterMaxBytes int64 = 10 << 20 appearanceVideoMaxBytes int64 = 256 << 20 ) const ( defaultAppearanceBrandName = "影策" defaultAppearanceBrandSlug = "open-ai-canvas" defaultAppearanceSkinID = "classic" defaultAppearanceLogoURL = "/logo.svg" defaultAppearanceVideoURL = "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4" defaultAppearancePosterURL = "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg" defaultAppearanceHeroTitle = "让一个故事,\n从文字走向银幕。" defaultRedeemPurchaseURL = "" // 默认不配置购买链接,前端为空时不展示入口 ) type AppearanceSetting struct { Updates UpdateAnnouncement `json:"updates"` Canvas CanvasAppearance `json:"canvas"` SchemaVersion int `json:"schemaVersion"` BrandName string `json:"brandName"` BrandSlug string `json:"brandSlug"` AuthHeroTitle string `json:"authHeroTitle"` AuthHeroDescription string `json:"authHeroDescription"` LogoResourceID string `json:"logoResourceId"` DarkLogoResourceID string `json:"darkLogoResourceId"` 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"` SEODescription string `json:"seoDescription"` SEOKeywords string `json:"seoKeywords"` FooterCopyright string `json:"footerCopyright"` ICPFilingEnabled bool `json:"icpFilingEnabled"` ICPFilingNumber string `json:"icpFilingNumber"` RedeemPurchaseURL string `json:"redeemPurchaseUrl"` } type PublicAppearanceSetting struct { Updates UpdateAnnouncement `json:"updates"` Canvas CanvasAppearance `json:"canvas"` SchemaVersion int `json:"schemaVersion"` BrandName string `json:"brandName"` BrandSlug string `json:"brandSlug"` AuthHeroTitle string `json:"authHeroTitle"` AuthHeroDescription string `json:"authHeroDescription"` LogoURL string `json:"logoUrl"` DarkLogoURL string `json:"darkLogoUrl"` 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"` SEODescription string `json:"seoDescription"` SEOKeywords string `json:"seoKeywords"` FooterCopyright string `json:"footerCopyright"` ICPFilingEnabled bool `json:"icpFilingEnabled"` ICPFilingNumber string `json:"icpFilingNumber"` RedeemPurchaseURL string `json:"redeemPurchaseUrl"` LogoConfigured bool `json:"logoConfigured"` DarkLogoConfigured bool `json:"darkLogoConfigured"` AuthVideoConfigured bool `json:"authVideoConfigured"` AuthVideoPosterConfigured bool `json:"authVideoPosterConfigured"` Configured bool `json:"configured"` Revision string `json:"revision"` UpdatedAt time.Time `json:"updatedAt,omitempty"` } type AdminAppearanceSetting struct { AppearanceSetting Public PublicAppearanceSetting `json:"public"` Configured bool `json:"configured"` UpdatedBy string `json:"updatedBy,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` UpdatedAt time.Time `json:"updatedAt,omitempty"` } func defaultAppearanceSetting() AppearanceSetting { return AppearanceSetting{ Canvas: defaultCanvasAppearance(), SchemaVersion: appearanceSchemaVersion, BrandName: defaultAppearanceBrandName, BrandSlug: defaultAppearanceBrandSlug, AuthHeroTitle: defaultAppearanceHeroTitle, AuthVideoAutoplay: true, LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID, SkinThemes: defaultAppearanceSkinThemes(), RedeemPurchaseURL: defaultRedeemPurchaseURL, } } func AppearanceAssetMaxBytes(slot string) (int64, error) { switch strings.TrimSpace(slot) { case AppearanceAssetLogo, AppearanceAssetDarkLogo: return appearanceLogoMaxBytes, nil case AppearanceAssetPoster, AppearanceAssetUpdateImage: return appearancePosterMaxBytes, nil case AppearanceAssetVideo, AppearanceAssetUpdateVideo: return appearanceVideoMaxBytes, nil default: return 0, BadAuthRequest("外观资源类型无效") } } func (s *Service) Appearance() (*PublicAppearanceSetting, error) { setting, value, err := s.readAppearance() if err != nil { return nil, err } value = s.resolveAvailableAppearanceAssets(value) return publicAppearanceSetting(setting, value), nil } func (s *Service) AdminAppearance(actor *model.User) (*AdminAppearanceSetting, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } setting, value, err := s.readAppearance() if err != nil { return nil, err } value = s.resolveAvailableAppearanceAssets(value) result := &AdminAppearanceSetting{ AppearanceSetting: value, Public: *publicAppearanceSetting(setting, value), Configured: setting != nil, } if setting != nil { result.UpdatedBy = setting.UpdatedBy result.CreatedAt = setting.CreatedAt result.UpdatedAt = setting.UpdatedAt } return result, nil } func (s *Service) UpdateAppearance(actor *model.User, value AppearanceSetting) (*AdminAppearanceSetting, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } value.SchemaVersion = appearanceSchemaVersion canvas, canvasErr := normalizeCanvasAppearance(value.Canvas) if canvasErr != nil { return nil, canvasErr } value.Canvas = canvas updates, updatesErr := normalizeUpdateAnnouncement(value.Updates) if updatesErr != nil { return nil, updatesErr } value.Updates = updates value.BrandName = strings.TrimSpace(value.BrandName) value.BrandSlug = strings.ToLower(strings.TrimSpace(value.BrandSlug)) value.AuthHeroTitle = normalizeAppearanceCopy(value.AuthHeroTitle) value.AuthHeroDescription = normalizeAppearanceCopy(value.AuthHeroDescription) value.LogoResourceID = strings.TrimSpace(value.LogoResourceID) value.DarkLogoResourceID = strings.TrimSpace(value.DarkLogoResourceID) value.AuthVideoResourceID = strings.TrimSpace(value.AuthVideoResourceID) value.AuthVideoPosterResourceID = strings.TrimSpace(value.AuthVideoPosterResourceID) value.SkinID = strings.TrimSpace(value.SkinID) if len(value.SkinThemes) == 0 { value.SkinThemes = defaultAppearanceSkinThemes() } value.SkinThemes = normalizeAppearanceSkinThemes(value.SkinThemes) value.SEOTitle = normalizeAppearanceSingleLine(value.SEOTitle) value.SEODescription = normalizeAppearanceCopy(value.SEODescription) value.SEOKeywords = normalizeAppearanceSingleLine(value.SEOKeywords) value.FooterCopyright = normalizeAppearanceSingleLine(value.FooterCopyright) value.ICPFilingNumber = normalizeAppearanceSingleLine(value.ICPFilingNumber) value.RedeemPurchaseURL = strings.TrimSpace(value.RedeemPurchaseURL) if err := validateAppearanceSetting(value); err != nil { return nil, err } s.storageMu.Lock() defer s.storageMu.Unlock() current, before, err := s.readAppearance() if err != nil { return nil, err } if value.Canvas.Live2DResourceID != "" { entry, err := s.validateLive2DResource(actor, value.Canvas.Live2DResourceID, before.Canvas.Live2DResourceID) if err != nil { return nil, err } value.Canvas.Live2DEntry = entry } if err := s.validateUpdateAnnouncementResources(actor, value.Updates, before.Updates); err != nil { return nil, err } for _, candidate := range []struct { slot string resourceID string currentID string }{ {slot: AppearanceAssetLogo, resourceID: value.LogoResourceID, currentID: before.LogoResourceID}, {slot: AppearanceAssetDarkLogo, resourceID: value.DarkLogoResourceID, currentID: before.DarkLogoResourceID}, {slot: AppearanceAssetVideo, resourceID: value.AuthVideoResourceID, currentID: before.AuthVideoResourceID}, {slot: AppearanceAssetPoster, resourceID: value.AuthVideoPosterResourceID, currentID: before.AuthVideoPosterResourceID}, } { if err := s.validateAppearanceResource(actor, candidate.slot, candidate.resourceID, candidate.currentID); err != nil { return nil, err } } encoded, err := json.Marshal(value) if err != nil { return nil, err } setting := model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID} if current != nil { setting.CreatedAt = current.CreatedAt } if err := s.repo.SaveSystemSetting(&setting); err != nil { return nil, err } if err := s.appendAdminAudit(actor, "appearance.update", "system_setting", appearanceSettingKey, "更新外观配置", map[string]any{"before": before, "after": value}); err != nil { return nil, err } return s.AdminAppearance(actor) } func (s *Service) ResetAppearance(actor *model.User) (*AdminAppearanceSetting, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } s.storageMu.Lock() defer s.storageMu.Unlock() _, before, err := s.readAppearance() if err != nil { return nil, err } if err := s.repo.DeleteSystemSetting(appearanceSettingKey); err != nil { return nil, err } after := defaultAppearanceSetting() if err := s.appendAdminAudit(actor, "appearance.reset", "system_setting", appearanceSettingKey, "恢复影策默认品牌标识", map[string]any{"before": before, "after": after}); err != nil { return nil, err } return s.AdminAppearance(actor) } func (s *Service) UploadAppearanceAsset(actor *model.User, slot string, header *multipart.FileHeader) (*model.Resource, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } mimeType, err := validateAppearanceUpload(slot, header) if err != nil { return nil, err } // UploadResource otherwise trusts a non-empty declared MIME. Replace it with // the sniffed value so the persisted resource contract matches the bytes. header.Header.Set("Content-Type", mimeType) kind := "image" if slot == AppearanceAssetVideo || slot == AppearanceAssetUpdateVideo { kind = "video" } 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 } resource.PublicURL = "" return resource, nil } func (s *Service) OpenAppearanceAsset(slot string, rangeHeader string) (*ResourceStream, error) { _, value, err := s.readAppearance() if err != nil { return nil, err } resourceID := appearanceResourceID(value, slot) if resourceID == "" { return nil, NotFound("未配置该外观资源") } resource, err := s.repo.Resource(resourceID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, NotFound("外观资源不存在") } return nil, err } if err := validateAppearanceResourceType(slot, resource); err != nil { return nil, err } return s.openResourceRange(resource.UserID, resource, rangeHeader) } func (s *Service) PrepareAppearanceAssetDelivery(slot string, options ResourceAccessOptions, rangeHeader string) (*ResourceDelivery, error) { _, value, err := s.readAppearance() if err != nil { return nil, err } resourceID := appearanceResourceID(value, slot) if resourceID == "" { return nil, NotFound("未配置该外观资源") } resource, err := s.repo.Resource(resourceID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, NotFound("外观资源不存在") } return nil, err } if err := validateAppearanceResourceType(slot, resource); err != nil { return nil, err } if options.Purpose == "" { options.Purpose = assets.PurposeDisplay } return s.prepareResourceDelivery(resource.UserID, resource, options, rangeHeader) } func (s *Service) appearanceResourceReferences(resourceIDs []string) map[string][]AdminResourceReferenceView { result := make(map[string][]AdminResourceReferenceView) _, value, err := s.readAppearance() if err != nil { // Invalid appearance JSON must fail closed for deletion. The caller turns // this sentinel into a visible blocked reference instead of deleting files. for _, resourceID := range resourceIDs { result[resourceID] = []AdminResourceReferenceView{{Kind: "外观", ID: appearanceSettingKey, Title: "外观配置无法读取"}} } return result } candidates := []struct { resourceID string title string }{ {resourceID: value.LogoResourceID, title: "浅色模式品牌 Logo"}, {resourceID: value.DarkLogoResourceID, title: "深色模式品牌 Logo"}, {resourceID: value.AuthVideoResourceID, title: "登录页品牌视频"}, {resourceID: value.AuthVideoPosterResourceID, title: "登录页视频封面"}, {resourceID: value.Canvas.Live2DResourceID, title: "画布 Agent Live2D 形象"}, } for _, release := range value.Updates.Releases { for _, block := range release.Blocks { if block.ResourceID != "" { candidates = append(candidates, struct { resourceID string title string }{block.ResourceID, "更新公告 " + release.Version}) } } } wanted := make(map[string]struct{}, len(resourceIDs)) for _, resourceID := range resourceIDs { wanted[resourceID] = struct{}{} } for _, candidate := range candidates { if candidate.resourceID == "" { continue } if _, exists := wanted[candidate.resourceID]; exists { result[candidate.resourceID] = appendUniqueAdminResourceReference(result[candidate.resourceID], AdminResourceReferenceView{Kind: "外观", ID: appearanceSettingKey, Title: candidate.title}) } } return result } func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, error) { setting, err := s.repo.SystemSetting(appearanceSettingKey) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, defaultAppearanceSetting(), nil } if err != nil { return nil, AppearanceSetting{}, err } value := defaultAppearanceSetting() // Decode stored themes without reusing built-in slice entries: omitted // fields must be migrated according to each stored theme's identity. value.SkinThemes = nil if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil { return nil, AppearanceSetting{}, errors.New("外观配置格式无效") } value.SchemaVersion = appearanceSchemaVersion value.BrandName = strings.TrimSpace(value.BrandName) if value.BrandName == "" { value.BrandName = defaultAppearanceBrandName } value.BrandSlug = strings.ToLower(strings.TrimSpace(value.BrandSlug)) if value.BrandSlug == "" { value.BrandSlug = defaultAppearanceBrandSlug } value.AuthHeroTitle = normalizeAppearanceCopy(value.AuthHeroTitle) if value.AuthHeroTitle == "" { value.AuthHeroTitle = defaultAppearanceHeroTitle } value.AuthHeroDescription = normalizeAppearanceCopy(value.AuthHeroDescription) value.SkinID = strings.TrimSpace(value.SkinID) if value.SkinID == "" { value.SkinID = defaultAppearanceSkinID } if len(value.SkinThemes) == 0 { value.SkinThemes = defaultAppearanceSkinThemes() } value.SkinThemes = normalizeAppearanceSkinThemes(value.SkinThemes) value.SEOTitle = normalizeAppearanceSingleLine(value.SEOTitle) value.SEODescription = normalizeAppearanceCopy(value.SEODescription) value.SEOKeywords = normalizeAppearanceSingleLine(value.SEOKeywords) value.FooterCopyright = normalizeAppearanceSingleLine(value.FooterCopyright) value.ICPFilingNumber = normalizeAppearanceSingleLine(value.ICPFilingNumber) value.RedeemPurchaseURL = strings.TrimSpace(value.RedeemPurchaseURL) 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 个字符") } for _, char := range value.BrandName { if unicode.IsControl(char) { return BadAuthRequest("品牌名称不能包含控制字符") } } if !validAppearanceBrandSlug(value.BrandSlug) { return BadAuthRequest("英文品牌标识须为 1 到 48 位小写字母、数字或连字符,且不能以连字符开头或结尾") } if err := validateAppearanceCopy(value.AuthHeroTitle, "登录页主标题", 80, true); err != nil { return err } if err := validateAppearanceCopy(value.AuthHeroDescription, "登录页说明文案", 160, false); err != nil { return err } if err := validateAppearanceSkinThemes(value.SkinThemes, value.SkinID); err != nil { return err } for _, field := range []struct { value string label string max int }{ {value.SEOTitle, "SEO 标题", 70}, {value.SEODescription, "SEO 描述", 200}, {value.SEOKeywords, "SEO 关键词", 300}, {value.FooterCopyright, "版权信息", 160}, {value.ICPFilingNumber, "备案号", 64}, } { if err := validateAppearanceCopy(field.value, field.label, field.max, false); err != nil { return err } } if value.ICPFilingEnabled && value.ICPFilingNumber == "" { return BadAuthRequest("显示备案号前请先填写备案号") } if value.RedeemPurchaseURL != "" { parsed, err := url.Parse(value.RedeemPurchaseURL) if err != nil || parsed.Scheme != "https" || parsed.Host == "" { return BadAuthRequest("兑换码购买链接必须是有效的 HTTPS 地址") } } for _, resourceID := range []string{value.LogoResourceID, value.DarkLogoResourceID, value.AuthVideoResourceID, value.AuthVideoPosterResourceID} { if len(resourceID) > 80 { return BadAuthRequest("外观资源 ID 无效") } } return nil } func normalizeAppearanceCopy(value string) string { return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")) } func normalizeAppearanceSingleLine(value string) string { return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", " "), "\r", " ")) } func validAppearanceBrandSlug(value string) bool { if len(value) == 0 || len(value) > 48 || value[0] == '-' || value[len(value)-1] == '-' { return false } for _, char := range value { if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { return false } } return true } func validateAppearanceCopy(value string, label string, maxRunes int, required bool) error { if required && value == "" { return BadAuthRequest(label + "不能为空") } if utf8.RuneCountInString(value) > maxRunes { return BadAuthRequest(fmt.Sprintf("%s不能超过 %d 个字符", label, maxRunes)) } for _, char := range value { if unicode.IsControl(char) && char != '\n' { return BadAuthRequest(label + "不能包含控制字符") } } return nil } func (s *Service) validateAppearanceResource(actor *model.User, slot string, resourceID string, currentID string) error { if resourceID == "" { return nil } resource, err := s.repo.Resource(resourceID) if err != nil { return BadAuthRequest("选择的外观资源不存在") } if resourceID != currentID && resource.UserID != actor.ID { return Forbidden("只能使用当前管理员上传的外观资源") } return validateAppearanceResourceType(slot, resource) } func validateAppearanceResourceType(slot string, resource *model.Resource) error { if resource == nil || resource.Status != model.ResourceStatusReady { return BadAuthRequest("外观资源尚未上传完成") } mimeType := strings.ToLower(strings.TrimSpace(strings.Split(resource.MimeType, ";")[0])) allowed := appearanceAllowedMIMETypes(slot) if _, exists := allowed[mimeType]; !exists { return BadAuthRequest("外观资源文件类型不受支持") } if (slot == AppearanceAssetVideo || slot == AppearanceAssetUpdateVideo) && resource.Kind != "video" { return BadAuthRequest("登录页品牌视频必须是视频资源") } if slot != AppearanceAssetVideo && slot != AppearanceAssetUpdateVideo && resource.Kind != "image" { return BadAuthRequest("Logo 和视频封面必须是图片资源") } return nil } func validateAppearanceUpload(slot string, header *multipart.FileHeader) (string, error) { maxBytes, err := AppearanceAssetMaxBytes(slot) if err != nil { return "", err } if header == nil || header.Size <= 0 || header.Size > maxBytes { return "", BadAuthRequest(fmt.Sprintf("%s大小必须在 %dMB 以内", appearanceAssetLabel(slot), maxBytes>>20)) } file, err := header.Open() if err != nil { return "", err } defer file.Close() buffer := make([]byte, 512) read, readErr := file.Read(buffer) if readErr != nil && read == 0 { return "", BadAuthRequest("外观资源内容无法读取") } mimeType := detectAppearanceMIME(slot, buffer[:read], header.Size) if _, exists := appearanceAllowedMIMETypes(slot)[mimeType]; !exists { return "", BadAuthRequest(appearanceAssetLabel(slot) + "文件类型不受支持") } return mimeType, nil } func detectAppearanceMIME(slot string, data []byte, fileSize int64) string { mimeType := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) if (slot != AppearanceAssetVideo && slot != AppearanceAssetUpdateVideo) || mimeType == "video/mp4" || len(data) < 12 { return mimeType } // Go's generic sniffer only recognises a subset of MP4 compatible brands. // Accept a structurally valid ISO Base Media File Format `ftyp` first box, // which covers common `isom`, `iso2`, `avc1`, `M4V ` and similar MP4 files. boxSize := int64(binary.BigEndian.Uint32(data[:4])) if string(data[4:8]) == "ftyp" && boxSize >= 12 && boxSize%4 == 0 && boxSize <= fileSize { return "video/mp4" } return mimeType } func appearanceAllowedMIMETypes(slot string) map[string]struct{} { if slot == AppearanceAssetVideo || slot == AppearanceAssetUpdateVideo { return map[string]struct{}{"video/mp4": {}, "video/webm": {}} } return map[string]struct{}{"image/png": {}, "image/jpeg": {}, "image/webp": {}} } func appearanceAssetLabel(slot string) string { switch slot { case AppearanceAssetLogo: return "浅色模式品牌 Logo" case AppearanceAssetDarkLogo: return "深色模式品牌 Logo" case AppearanceAssetPoster: return "视频封面" case AppearanceAssetVideo: return "品牌视频" case AppearanceAssetUpdateImage: return "公告图片" case AppearanceAssetUpdateVideo: return "公告视频" default: return "外观资源" } } func appearanceResourceID(value AppearanceSetting, slot string) string { switch slot { case AppearanceAssetLogo: return value.LogoResourceID case AppearanceAssetDarkLogo: return value.DarkLogoResourceID case AppearanceAssetVideo: return value.AuthVideoResourceID case AppearanceAssetPoster: return value.AuthVideoPosterResourceID default: return "" } } func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetting) *PublicAppearanceSetting { revision := "builtin" if setting != nil { revision = strconv.FormatInt(setting.UpdatedAt.UTC().UnixNano(), 36) } result := &PublicAppearanceSetting{ Updates: publicUpdateAnnouncement(value.Updates, revision), Canvas: value.Canvas, SchemaVersion: appearanceSchemaVersion, BrandName: value.BrandName, BrandSlug: value.BrandSlug, AuthHeroTitle: value.AuthHeroTitle, AuthHeroDescription: value.AuthHeroDescription, LogoURL: defaultAppearanceLogoURL, DarkLogoURL: defaultAppearanceLogoURL, LogoFrameEnabled: value.LogoFrameEnabled, AuthVideoURL: defaultAppearanceVideoURL, AuthVideoPosterURL: defaultAppearancePosterURL, AuthVideoAutoplay: value.AuthVideoAutoplay, SkinID: value.SkinID, ActiveSkin: activeAppearanceSkin(value.SkinThemes, value.SkinID), SEOTitle: effectiveAppearanceSEOTitle(value), SEODescription: effectiveAppearanceSEODescription(value), SEOKeywords: value.SEOKeywords, FooterCopyright: effectiveAppearanceCopyright(value), ICPFilingEnabled: value.ICPFilingEnabled && value.ICPFilingNumber != "", ICPFilingNumber: value.ICPFilingNumber, RedeemPurchaseURL: safeAppearancePurchaseURL(value.RedeemPurchaseURL), Configured: setting != nil, Revision: revision, } if setting != nil { result.UpdatedAt = setting.UpdatedAt } if value.LogoResourceID != "" || value.DarkLogoResourceID != "" { result.LogoConfigured = true if value.LogoResourceID != "" { result.LogoURL = appearanceAssetURL(AppearanceAssetLogo, revision) } else { result.LogoURL = appearanceAssetURL(AppearanceAssetDarkLogo, revision) } if value.DarkLogoResourceID != "" { result.DarkLogoConfigured = true result.DarkLogoURL = appearanceAssetURL(AppearanceAssetDarkLogo, revision) } else { result.DarkLogoURL = result.LogoURL } if value.LogoResourceID == "" { result.LogoURL = result.DarkLogoURL } } if value.AuthVideoResourceID != "" { result.AuthVideoConfigured = true result.AuthVideoURL = appearanceAssetURL(AppearanceAssetVideo, revision) if value.AuthVideoPosterResourceID == "" { // A custom video must not briefly reveal the built-in poster while its // first frame loads. An optional custom poster may be configured below. result.AuthVideoPosterURL = "" } } if value.AuthVideoPosterResourceID != "" { result.AuthVideoPosterConfigured = true result.AuthVideoPosterURL = appearanceAssetURL(AppearanceAssetPoster, revision) } return result } func safeAppearancePurchaseURL(value string) string { candidate := strings.TrimSpace(value) if candidate == "" { return "" } parsed, err := url.Parse(candidate) if err != nil || parsed.Scheme != "https" || parsed.Host == "" { return "" } return parsed.String() } func effectiveAppearanceSEOTitle(value AppearanceSetting) string { if value.SEOTitle != "" { return value.SEOTitle } return value.BrandName } func effectiveAppearanceSEODescription(value AppearanceSetting) string { if value.SEODescription != "" { return value.SEODescription } return value.BrandName + ",面向 AI 影视与短剧创作的工作台。" } func effectiveAppearanceCopyright(value AppearanceSetting) string { if value.FooterCopyright != "" { return value.FooterCopyright } return fmt.Sprintf("© %d %s. All rights reserved.", time.Now().Year(), value.BrandName) } func (s *Service) appearanceBrandName() string { brandName, _ := s.appearanceIdentity() return brandName } func (s *Service) appearanceIdentity() (string, string) { _, value, err := s.readAppearance() if err != nil || strings.TrimSpace(value.BrandName) == "" { return defaultAppearanceBrandName, defaultAppearanceBrandSlug } return value.BrandName, value.BrandSlug } func appearanceAssetURL(slot string, revision string) string { return "/api/public/appearance/assets/" + url.PathEscape(slot) + "?v=" + url.QueryEscape(revision) }