diff --git a/.gitignore b/.gitignore index 0fc5dfc93..a53f08137 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ assets/prototypes/.*.drawio.bkp camera_language_guide.md .superdesign .playwright-cli/ +/project-memory/ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1b18b401b..8ad1bb81d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -113,6 +113,7 @@ func run(ctx context.Context) error { registerSystemStatusRoutes(api, status) handler.RegisterOAuthCallbackRoutes(r, svc) handler.RegisterAuthRoutes(api, svc) + handler.RegisterAppearanceRoutes(api, svc) handler.RegisterFeatureAvailabilityRoutes(api, svc) handler.RegisterResponseInterceptionRoutes(api, svc) handler.RegisterAdminRoutes(api, svc) diff --git a/backend/internal/handler/appearance.go b/backend/internal/handler/appearance.go new file mode 100644 index 000000000..e2a218c3d --- /dev/null +++ b/backend/internal/handler/appearance.go @@ -0,0 +1,136 @@ +package handler + +import ( + "io" + "net/http" + "time" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterAppearanceRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/public/appearance", func(c *gin.Context) { + setting, err := svc.Appearance() + if err != nil { + failService(c, err) + return + } + c.Header("Cache-Control", "no-store") + ok(c, gin.H{"appearance": setting}) + }) + + r.GET("/public/appearance/assets/:slot", func(c *gin.Context) { + stream, err := svc.OpenAppearanceAsset(c.Param("slot"), c.GetHeader("Range")) + if err != nil { + failService(c, err) + return + } + defer stream.Body.Close() + resource := stream.Resource + mimeType := resource.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + if c.Query("v") != "" { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + } else { + c.Header("Cache-Control", "public, no-cache") + } + c.Header("Accept-Ranges", "bytes") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + if stream.ContentRange != "" { + c.Header("Content-Range", stream.ContentRange) + } + if seeker, available := stream.Body.(io.ReadSeeker); available { + c.Header("Content-Type", mimeType) + http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker) + return + } + c.DataFromReader(stream.StatusCode, stream.ContentLength, mimeType, stream.Body, nil) + }) + + r.GET("/admin/settings/appearance", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.AdminAppearance(actor) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + + r.PATCH("/admin/settings/appearance", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + current, err := svc.AdminAppearance(actor) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) + req := current.AppearanceSetting + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + setting, err := svc.UpdateAppearance(actor, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + + r.DELETE("/admin/settings/appearance", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.ResetAppearance(actor) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + + r.POST("/admin/settings/appearance/assets/:slot", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + policy, available := loadRuntimePolicy(c, svc) + if !available || !enforceRateLimit(c, "admin-appearance-upload:"+actor.ID, policy.Request.ResourceUploadPerMinute, time.Minute) { + return + } + maxBytes, err := service.AppearanceAssetMaxBytes(c.Param("slot")) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20)) + file, err := c.FormFile("file") + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + resource, err := svc.UploadAppearanceAsset(actor, c.Param("slot"), file) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"resource": resource}) + }) +} diff --git a/backend/internal/handler/appearance_routes_test.go b/backend/internal/handler/appearance_routes_test.go new file mode 100644 index 000000000..fccfebff6 --- /dev/null +++ b/backend/internal/handler/appearance_routes_test.go @@ -0,0 +1,34 @@ +package handler + +import ( + "testing" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestAppearanceRoutesAreRegistered(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterAppearanceRoutes(router.Group("/api"), &service.Service{}) + wanted := map[string]bool{ + "GET /api/public/appearance": false, + "GET /api/public/appearance/assets/:slot": false, + "GET /api/admin/settings/appearance": false, + "PATCH /api/admin/settings/appearance": false, + "DELETE /api/admin/settings/appearance": false, + "POST /api/admin/settings/appearance/assets/:slot": false, + } + for _, route := range router.Routes() { + key := route.Method + " " + route.Path + if _, exists := wanted[key]; exists { + wanted[key] = true + } + } + for route, found := range wanted { + if !found { + t.Errorf("route %s is not registered", route) + } + } +} diff --git a/backend/internal/service/admin_storage_delete.go b/backend/internal/service/admin_storage_delete.go index b113d426d..7c0ff1397 100644 --- a/backend/internal/service/admin_storage_delete.go +++ b/backend/internal/service/admin_storage_delete.go @@ -83,6 +83,15 @@ func (s *Service) DeleteAdminResources(actor *model.User, req AdminResourceDelet blocked.References = appendUniqueAdminResourceReference(blocked.References, AdminResourceReferenceView{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) blockedByID[reference.ResourceID] = blocked } + for resourceID, references := range s.appearanceResourceReferences(resourceIDs) { + for _, reference := range references { + blocked := blockedByID[resourceID] + blocked.ID = resourceID + blocked.Reason = "资源仍被业务数据引用" + blocked.References = appendUniqueAdminResourceReference(blocked.References, reference) + blockedByID[resourceID] = blocked + } + } deletable := make([]model.Resource, 0, len(resources)) deletableIDs := make([]string, 0, len(resources)) diff --git a/backend/internal/service/admin_storage_delete_test.go b/backend/internal/service/admin_storage_delete_test.go index b66832aaa..081efa610 100644 --- a/backend/internal/service/admin_storage_delete_test.go +++ b/backend/internal/service/admin_storage_delete_test.go @@ -69,6 +69,26 @@ func TestDeleteAdminResourcesBlocksAnnouncementImage(t *testing.T) { assertModelCount(t, db, &model.Resource{}, "id = ?", 1, resource.ID) } +func TestDeleteAdminResourcesBlocksDarkAppearanceLogo(t *testing.T) { + svc, db, _, admin := newAdminStorageDeleteTestService(t) + resource := model.Resource{ID: "appearance-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "appearance/logo.png", MimeType: "image/png"} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + if _, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, DarkLogoResourceID: resource.ID, LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID}); err != nil { + t.Fatal(err) + } + + result, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{resource.ID}}) + if err != nil { + t.Fatal(err) + } + if len(result.Deleted) != 0 || len(result.Blocked) != 1 || len(result.Blocked[0].References) != 1 || result.Blocked[0].References[0].Kind != "外观" { + t.Fatalf("result = %#v", result) + } + assertModelCount(t, db, &model.Resource{}, "id = ?", 1, resource.ID) +} + func TestDeleteAdminResourcesKeepsSharedPhysicalObject(t *testing.T) { svc, db, _, admin := newAdminStorageDeleteTestService(t) resources := []model.Resource{ diff --git a/backend/internal/service/appearance.go b/backend/internal/service/appearance.go new file mode 100644 index 000000000..26e4c5ff2 --- /dev/null +++ b/backend/internal/service/appearance.go @@ -0,0 +1,561 @@ +package service + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +const appearanceSettingKey = "appearance" + +const ( + AppearanceAssetLogo = "logo" + AppearanceAssetDarkLogo = "logo-dark" + AppearanceAssetVideo = "video" + AppearanceAssetPoster = "poster" +) + +const ( + appearanceSchemaVersion = 3 + 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从文字走向银幕。" +) + +type AppearanceSetting struct { + 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"` + SkinID string `json:"skinId"` +} + +type PublicAppearanceSetting struct { + 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"` + SkinID string `json:"skinId"` + 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{ + SchemaVersion: appearanceSchemaVersion, + BrandName: defaultAppearanceBrandName, + BrandSlug: defaultAppearanceBrandSlug, + AuthHeroTitle: defaultAppearanceHeroTitle, + LogoFrameEnabled: true, + SkinID: defaultAppearanceSkinID, + } +} + +func AppearanceAssetMaxBytes(slot string) (int64, error) { + switch strings.TrimSpace(slot) { + case AppearanceAssetLogo, AppearanceAssetDarkLogo: + return appearanceLogoMaxBytes, nil + case AppearanceAssetPoster: + return appearancePosterMaxBytes, nil + case AppearanceAssetVideo: + return appearanceVideoMaxBytes, nil + default: + return 0, BadAuthRequest("外观资源类型无效") + } +} + +func (s *Service) Appearance() (*PublicAppearanceSetting, error) { + setting, value, err := s.readAppearance() + if err != nil { + return nil, err + } + 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 + } + 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 + 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 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 + } + 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 { + kind = "video" + } + 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) 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: "登录页视频封面"}, + } + 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() + 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 + } + return setting, value, nil +} + +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 value.SkinID != defaultAppearanceSkinID { + return BadAuthRequest("当前版本仅支持经典皮肤") + } + 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 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 && resource.Kind != "video" { + return BadAuthRequest("登录页品牌视频必须是视频资源") + } + if slot != AppearanceAssetVideo && 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 || 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 { + 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 "品牌视频" + 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{ + 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, + SkinID: value.SkinID, + 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 appearanceAssetURL(slot string, revision string) string { + return "/api/public/appearance/assets/" + url.PathEscape(slot) + "?v=" + url.QueryEscape(revision) +} diff --git a/backend/internal/service/appearance_test.go b/backend/internal/service/appearance_test.go new file mode 100644 index 000000000..5e64c60de --- /dev/null +++ b/backend/internal/service/appearance_test.go @@ -0,0 +1,299 @@ +package service + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "os" + "path/filepath" + "strings" + "testing" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) { + svc, _, _, _ := newAppearanceTestService(t) + + appearance, err := svc.Appearance() + 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 { + t.Fatalf("Appearance() = %#v", appearance) + } + if appearance.LogoConfigured || appearance.DarkLogoConfigured || appearance.AuthVideoConfigured || appearance.AuthVideoPosterConfigured || appearance.Revision != "builtin" { + t.Fatalf("Appearance() configured state = %#v", appearance) + } +} + +func TestAppearanceBackfillsVersionThreeFieldsForExistingSetting(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 { + t.Fatal(err) + } + + appearance, err := svc.Appearance() + 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 { + t.Fatalf("legacy appearance = %#v", appearance) + } +} + +func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T) { + svc, db, _, 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"}, + {ID: "brand-video", UserID: admin.ID, Kind: "video", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/video.mp4", MimeType: "video/mp4"}, + {ID: "brand-poster", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/poster.jpg", MimeType: "image/jpeg"}, + } + if err := db.Create(&resources).Error; err != nil { + t.Fatal(err) + } + + updated, err := svc.UpdateAppearance(admin, AppearanceSetting{ + BrandName: "HIMA Studio", + BrandSlug: "hima-studio", + AuthHeroTitle: "把灵感,\n变成可见的故事。", + AuthHeroDescription: "从画布开始,持续推进你的创作。", + LogoResourceID: "brand-logo", + DarkLogoResourceID: "brand-logo-dark", + LogoFrameEnabled: false, + AuthVideoResourceID: "brand-video", + AuthVideoPosterResourceID: "brand-poster", + SkinID: defaultAppearanceSkinID, + }) + 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 { + t.Fatalf("UpdateAppearance() = %#v", updated) + } + for _, assetURL := range []string{updated.Public.LogoURL, updated.Public.DarkLogoURL, updated.Public.AuthVideoURL, updated.Public.AuthVideoPosterURL} { + if !strings.HasPrefix(assetURL, "/api/public/appearance/assets/") || !strings.Contains(assetURL, "?v=") { + t.Fatalf("versioned asset URL = %q", assetURL) + } + } + encodedPublic, err := json.Marshal(updated.Public) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encodedPublic), "brand-logo") || strings.Contains(string(encodedPublic), "brand-logo-dark") || strings.Contains(string(encodedPublic), "brand-video") || strings.Contains(string(encodedPublic), "brand-poster") { + t.Fatalf("public appearance leaked resource IDs: %s", encodedPublic) + } + var auditCount int64 + if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "appearance.update").Count(&auditCount).Error; err != nil { + t.Fatal(err) + } + if auditCount != 1 { + t.Fatalf("audit count = %d, want 1", auditCount) + } +} + +func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) { + svc, db, _, 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"}, + } + if err := db.Create(&resources).Error; err != nil { + t.Fatal(err) + } + + lightOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: "only-light", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID}) + if err != nil { + t.Fatal(err) + } + if lightOnly.Public.LogoURL != lightOnly.Public.DarkLogoURL || !lightOnly.Public.LogoConfigured || lightOnly.Public.DarkLogoConfigured { + t.Fatalf("light-only appearance = %#v", lightOnly.Public) + } + + darkOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, DarkLogoResourceID: "only-dark", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID}) + if err != nil { + t.Fatal(err) + } + if darkOnly.Public.LogoURL != darkOnly.Public.DarkLogoURL || !darkOnly.Public.LogoConfigured || !darkOnly.Public.DarkLogoConfigured { + t.Fatalf("dark-only appearance = %#v", darkOnly.Public) + } +} + +func TestResetAppearanceRestoresBuiltInBrandWithoutDeletingResources(t *testing.T) { + svc, db, _, admin := newAppearanceTestService(t) + resource := model.Resource{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + if _, err := svc.UpdateAppearance(admin, AppearanceSetting{ + BrandName: "HIMA Studio", + BrandSlug: "hima-studio", + AuthHeroTitle: "把灵感变成故事", + LogoResourceID: resource.ID, + SkinID: defaultAppearanceSkinID, + }); err != nil { + t.Fatal(err) + } + + reset, err := svc.ResetAppearance(admin) + if err != nil { + t.Fatal(err) + } + if reset.Configured || reset.BrandName != defaultAppearanceBrandName || reset.BrandSlug != defaultAppearanceBrandSlug || reset.AuthHeroTitle != defaultAppearanceHeroTitle || reset.LogoResourceID != "" || reset.DarkLogoResourceID != "" || !reset.LogoFrameEnabled || reset.Public.Revision != "builtin" || reset.Public.LogoConfigured || reset.Public.DarkLogoConfigured { + t.Fatalf("ResetAppearance() = %#v", reset) + } + var resourceCount int64 + if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil { + t.Fatal(err) + } + if resourceCount != 1 { + t.Fatalf("resource count = %d, want 1", resourceCount) + } + var auditCount int64 + if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "appearance.reset").Count(&auditCount).Error; err != nil { + t.Fatal(err) + } + if auditCount != 1 { + t.Fatalf("reset audit count = %d, want 1", auditCount) + } +} + +func TestUpdateAppearanceRejectsAnotherUsersNewResource(t *testing.T) { + svc, db, _, admin := newAppearanceTestService(t) + resource := model.Resource{ID: "foreign-logo", UserID: "another-admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + + _, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: resource.ID, SkinID: defaultAppearanceSkinID}) + if err == nil || !strings.Contains(err.Error(), "当前管理员上传") { + t.Fatalf("UpdateAppearance() error = %v", err) + } +} + +func TestOpenAppearanceAssetOnlyServesConfiguredSlot(t *testing.T) { + svc, db, dataDir, admin := newAppearanceTestService(t) + resource := model.Resource{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + 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("brand-bytes"), 0o640); err != nil { + t.Fatal(err) + } + if _, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: resource.ID, SkinID: defaultAppearanceSkinID}); err != nil { + t.Fatal(err) + } + + stream, err := svc.OpenAppearanceAsset(AppearanceAssetLogo, "") + if err != nil { + t.Fatal(err) + } + stream.Body.Close() + if stream.Resource.ID != resource.ID { + t.Fatalf("served resource = %q", stream.Resource.ID) + } + if _, err := svc.OpenAppearanceAsset(AppearanceAssetVideo, ""); err == nil { + t.Fatal("unconfigured video slot unexpectedly served a resource") + } +} + +func TestUpdateAppearanceValidatesLoginCopy(t *testing.T) { + svc, _, _, admin := newAppearanceTestService(t) + + _, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: "", SkinID: defaultAppearanceSkinID}) + if err == nil || !strings.Contains(err.Error(), "主标题") { + t.Fatalf("empty title error = %v", err) + } + _, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, AuthHeroDescription: strings.Repeat("文", 161), SkinID: defaultAppearanceSkinID}) + if err == nil || !strings.Contains(err.Error(), "160") { + t.Fatalf("long description error = %v", err) + } + _, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: "标题\t注入", SkinID: defaultAppearanceSkinID}) + if err == nil || !strings.Contains(err.Error(), "控制字符") { + t.Fatalf("control character error = %v", err) + } + _, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "HIMA Studio", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: defaultAppearanceSkinID}) + if err == nil || !strings.Contains(err.Error(), "英文品牌标识") { + t.Fatalf("invalid slug error = %v", err) + } +} + +func TestValidateAppearanceUploadSniffsBytesInsteadOfDeclaredMIME(t *testing.T) { + pngHeader := multipartFileHeader(t, "logo.png", "text/plain", append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...)) + if mimeType, err := validateAppearanceUpload(AppearanceAssetLogo, pngHeader); err != nil || mimeType != "image/png" { + t.Fatalf("validateAppearanceUpload(png) = %q, %v", mimeType, err) + } + darkPNGHeader := multipartFileHeader(t, "logo-dark.png", "text/plain", append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...)) + if mimeType, err := validateAppearanceUpload(AppearanceAssetDarkLogo, darkPNGHeader); err != nil || mimeType != "image/png" { + t.Fatalf("validateAppearanceUpload(dark png) = %q, %v", mimeType, err) + } + fakeImage := multipartFileHeader(t, "fake.png", "image/png", []byte("not an image")) + if _, err := validateAppearanceUpload(AppearanceAssetLogo, fakeImage); err == nil { + t.Fatal("declared image MIME accepted non-image bytes") + } + mp4Bytes := append([]byte{0x00, 0x00, 0x00, 0x14}, []byte("ftypisom\x00\x00\x00\x00isom")...) + mp4Header := multipartFileHeader(t, "hero.mp4", "application/octet-stream", mp4Bytes) + if mimeType, err := validateAppearanceUpload(AppearanceAssetVideo, mp4Header); err != nil || mimeType != "video/mp4" { + t.Fatalf("validateAppearanceUpload(mp4) = %q, %v", mimeType, err) + } + fakeMP4 := multipartFileHeader(t, "fake.mp4", "video/mp4", append([]byte{0x00, 0x00, 0x10, 0x00}, []byte("ftypisom")...)) + if _, err := validateAppearanceUpload(AppearanceAssetVideo, fakeMP4); err == nil { + t.Fatal("invalid MP4 box size accepted") + } +} + +func multipartFileHeader(t *testing.T, name string, contentType string, data []byte) *multipart.FileHeader { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", `form-data; name="file"; filename="`+name+`"`) + header.Set("Content-Type", contentType) + part, err := writer.CreatePart(header) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + if err := req.ParseMultipartForm(int64(body.Len()) + 1024); err != nil { + t.Fatal(err) + } + return req.MultipartForm.File["file"][0] +} + +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{}) + if err != nil { + t.Fatal(err) + } + if err := database.MigrateSchema(db); err != nil { + t.Fatal(err) + } + admin := &model.User{ID: "appearance-admin", Username: "appearance-admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + if err := db.Create(admin).Error; err != nil { + t.Fatal(err) + } + dataDir := t.TempDir() + return New(repository.New(db), dataDir), db, dataDir, admin +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index fedc483b0..9d5dca8dd 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -19,6 +19,12 @@ description: 影策后端核心持久化表与状态边界 版本 3 会将 `wardrobe`、`weapon`、`accessory` 迁移为 `prop`,将 `style` 迁移为 `material`,并把原先归入 `other` 的图片、视频、音频和 3D 模型迁移为 `material`。迁移只合并重复的待确认候选:保留最早记录,其余标记为 `ignored`;已经确认的资产不会被自动删除或归并。 +## 平台外观配置 + +平台外观复用既有 `system_settings` 表,不新增结构迁移。键 `appearance` 的 JSON 值使用版本化合同;版本 3 保存品牌名称、英文品牌标识、登录页主标题与可选说明文案、浅色与可选深色 Logo 资源 ID、Logo 外框开关、登录页视频资源 ID、可选封面资源 ID 和预留皮肤 ID。只配置一套 Logo 时,公开投影会在浅色和深色主题复用同一资源;旧版本缺少深色 Logo 与外框字段时分别回退到单 Logo 和启用外框,不改变既有视觉。资源元数据和文件仍分别由 `resources` 表与当前资源存储承担。恢复默认外观时删除这条设置记录并回到内置值,不级联删除已经上传的资源。 + +公开接口只投影品牌名、英文品牌标识、登录页公开文案、深浅主题使用的稳定 slot 资源 URL、Logo 外框状态、配置状态和修订标识,不返回内部资源 ID、对象路径或存储凭据。管理员保存新配置前,服务层会校验文本长度与安全字符、资源为就绪状态、类型与 slot 匹配,且新绑定资源由当前管理员上传;两套 Logo、视频和封面中任何正在使用的资源都会进入管理员删除保护。品牌变更同时写入 `admin_audit_events`。 + ## 账号认证与密码找回 | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index dd594bc6f..d0494624b 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -5,6 +5,21 @@ description: 影策当前已实现的核心工作台能力与使用入口 # 功能清单 +## 平台外观管理 + +管理员可以在“系统配置 → 外观管理”统一设置平台品牌名称、英文品牌标识、浅色与深色 Logo、Logo 外框、登录页主标题、可选说明文案、品牌视频和可选视频封面。Logo 可以只上传任意一套并自动复用,也可以分别为浅色与深色界面上传;工作台和管理后台导航中的圆角矩形外框可以通过统一开关单独禁用,禁用后 Logo 会等比放大到原外框的可用区域,但导航占位不变。品牌名称和对应主题 Logo 会应用到登录页、工作台导航、管理后台、浏览器标题以及创作与素材库的主要品牌文案;登录、注册和找回密码共享同一套左侧品牌文案与影片。 + +前端入口在加载 React 应用之前先读取 `GET /api/public/appearance`。初始 HTML 不包含内置品牌名、Logo 或默认 favicon;只有公开配置成功或明确回退到内置外观后才渲染应用,避免自定义品牌访问时先闪现“影策”。自定义视频未设置封面时保持纯色背景直至视频首帧就绪,不复用内置视频封面。 + +浅色 Logo、深色 Logo、视频和封面通过后台专用上传接口进入现有资源存储,并由稳定的公开 slot URL 读取;公开响应不暴露资源 ID。正在使用的外观资源会阻止管理员从资源管理页直接删除。英文品牌标识可作为对象存储路径前缀建议,但不会自动覆盖已经保存的前缀或改写历史资源。“恢复影策默认”会删除外观配置记录并立即恢复全部内置品牌值和默认 Logo 外框,同时保留已经上传的素材文件。配置保留 `schemaVersion` 和 `skinId`,当前只开放 `classic`,后续皮肤库可在同一边界增加颜色、字体、圆角和组件密度包。 + +### 相关实现 + +- `backend/internal/service/appearance.go`:外观配置校验、公开投影、资源绑定与媒体类型校验。 +- `backend/internal/handler/appearance.go`:公开读取、公开资源流和管理员读写/上传接口。 +- `web/src/services/appearance-bootstrap.ts`:应用渲染前的公开外观解析与安全回退。 +- `web/src/pages/admin/settings/appearance-settings-page.tsx`:外观管理、草稿预览与原子保存流程。 + ## 邮箱找回密码 登录页在密码字段标题行提供“忘记密码?”入口。已配置账户邮件服务时,本地密码账号可以通过绑定邮箱获取 6 位验证码,并在 10 分钟内设置新密码;发送接口对存在和不存在的邮箱返回相同结果,避免公开账号状态。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 040b34fd5..6443190cb 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,21 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 平台外观管理 + +管理后台已新增“系统配置 → 外观管理”,并让公开品牌配置在应用渲染前完成解析;以下真实浏览器与媒体场景需要持续确认: + +- [x] 后端专项与全量测试通过,覆盖内置默认值、管理员权限、资源类型/归属、公开 URL 不泄漏资源 ID、媒体字节嗅探、删除保护与路由注册。 +- [x] 前端专项测试、后台回归测试、TypeScript 检查和生产构建通过,覆盖初始 HTML 品牌中立、自定义视频不回退内置封面、不安全 URL 回退、单 Logo 深浅主题复用、双 Logo 主题选择、外框关闭与登录场景固定使用深色 Logo。 +- [ ] 使用管理员账号依次保存品牌名、英文品牌标识、登录页标题/说明、浅色 PNG/WebP Logo、可选深色 Logo、外框开关、MP4/WebM 视频与封面,确认工作台和登录页立即更新,刷新后保持一致。 +- [ ] 分别只上传浅色 Logo、只上传深色 Logo、同时上传两套 Logo,并切换浅色/深色主题,确认单图自动复用、双图按主题选择;开启“禁用 Logo 后面的圆角矩形外框”后确认工作台和管理后台导航只显示等比放大到原外框可用区域的 Logo,布局不跳动。 +- [ ] 在“对象存储”中点击“使用品牌标识”,确认只更新当前路径前缀草稿;未点击或未保存时保留服务端已有前缀,历史资源读取不受影响。 +- [ ] 保存一套自定义品牌后点击“恢复影策默认”,确认品牌名称、英文标识、Logo、登录页文案和媒体立即恢复内置值,自定义素材文件仍保留在存储资源中。 +- [ ] 清空浏览器缓存并以慢速网络打开 `/login`、`/register` 和 `/forgot-password`,确认加载阶段不出现内置品牌名、Logo、favicon 或视频封面闪烁。 +- [ ] 更换品牌视频但不上传封面,确认首帧出现前只显示中性深色背景;再配置封面,确认只显示当前自定义封面。 +- [ ] 在存储资源页尝试删除当前浅色 Logo、深色 Logo、视频和封面,确认显示“外观”引用并保留文件;解除绑定后可正常删除。 +- [ ] 将公开配置接口模拟为超时或 5xx,确认 4 秒内结束等待并完整回退内置外观,应用仍可进入。 + ## 画布导入同步、素材回收站与历史 - [x] 画布压缩包导入会先校验媒体完整性,并统一通过远端同步入口上传资源;失败时保持脏状态和自动重试,不再把浏览器本地 storage key 误报为已同步。 diff --git a/docs/design/admin-ui-change-log.md b/docs/design/admin-ui-change-log.md index ce0f87b0e..181a66586 100644 --- a/docs/design/admin-ui-change-log.md +++ b/docs/design/admin-ui-change-log.md @@ -1140,3 +1140,15 @@ - 验证结果:八个目标源码/样式/记录文件通过定向 Prettier;全量 `npx tsc --noEmit` 为 0 错误;Vite 8.1.5 生产构建成功转换 12,695 个模块并在约 4.63 秒完成,仅保留项目既有的大分块提示。使用现有已登录 Chrome 会话访问当前源码开发服务;账号真实项目数为 0,未创建或修改测试项目。五个详情 URL 均以不存在项目做只读错误态检查,稳定显示“项目不可用”和返回操作;亮色与暗色分别覆盖 1280×800、1440×900、1920×1080 三档真实 CSS 视口,文档和 main 横向溢出均为 0,错误表面宽 760px,亮色为 `rgb(255,255,255)`、暗色为 `rgb(26,29,32)`。有数据态、归档态、章节编辑态和五页业务动作因真实账号无项目且禁止写业务数据,以完整 JSX/CSS 结构检查、历史状态机核对、全量类型和生产构建作为验证边界;没有伪造接口或业务记录。控制台仅出现批次 132 已记录的 Ant `App cssVar` 与 `Modal maskClosable` 既有警告,没有本批运行错误。 - 潜在影响:项目详情首屏增加约 76~86px 当前视图身份层,章节编辑区可用高度相应减少但仍由单一 flex 区域内部滚动;设置和资产页新增一级表面与细边界,1920px 下内容不再无限拉伸。顶部项目状态与当前 view 更醒目,页面 h1→h2→说明顺序稳定。接口请求、业务状态、路由、数据、权限和移动端均不变。 - 逐项回滚:修改前八个原件、SHA-256、校验文件和恢复步骤位于 `.local/ui-change-backups/batch-133/MANIFEST.md` 与 `MANIFEST.sha256`。回滚前先保留后续修改,再按原相对路径逐项恢复并运行 `shasum -a 256 -c MANIFEST.sha256`;完整回滚最后恢复本记录。不需要初始化、提交或依赖 Git。 + +## 批次 134:外观管理 Logo 外框开关与无框尺寸统一 + +- 日期时间:2026-09-02 17:04:34 +0800 +- 目的:修复“禁用 Logo 后面的圆角矩形外框”复选框与后台现有布尔设置视觉不一致的问题,并让关闭外框后的品牌 Logo 等比放大到原外框可用区域;保持原导航占位、深浅 Logo 选择、品牌加载时序、接口和保存方式不变。 +- 修改前状况:外观管理单独使用 Ant Checkbox,而登录注册、功能开放等后台布尔设置已经统一使用主题化 Ant Switch;页面只给 Checkbox 外层文字加样式,控件自身状态与其他模块不一致。关闭外框仅清除容器背景、边框、圆角和阴影,实际 Logo 仍保持 `size-5`,因此在 32~38px 导航容器内显得偏小;44px 预览容器也仍固定显示 28px Logo。 +- 涉及文件:`web/src/pages/admin/settings/appearance-settings-page.tsx`、`web/src/styles/admin-ui.css`、`web/src/styles/globals.css`、`web/test/appearance-bootstrap.test.ts`、`docs/content/docs/overview/features.mdx`、`docs/content/docs/progress/pending-test.mdx`、`docs/design/admin-ui-change-log.md`。未修改后端、外观 schema、API、数据库、上传与重置逻辑、导航容器尺寸或操作人现有品牌配置。 +- 具体改动:外框选项复用后台已有 Ant Switch 和集中主题 token,采用“说明文案 + 当前状态文字 + 右侧开关”的现有设置行方向,开启表示禁用外框,`checked` 与 `logoFrameEnabled` 继续按反向关系转换;补充 `aria-label` 和说明关联。关闭外框时,共享 `BrandLogoFrame` 的直接图片或 SVG 子元素宽高提升到容器 `100%`,继续由 `object-fit: contain` 等比适配,因此工作台 32px、管理后台 32~38px 及折叠态均复用各自原容器,不发生布局跳动;深浅模式预览同步由 28px 放大到完整 44px 可用区域。窄屏选项行改为上下排列,状态与开关仍保持两端对齐。 +- 修改前基线:`appearance-settings-page.tsx` 为 `a9e133fb76e046b8775f284279f054e78d86fda8f1bfa08f73bb54a9c9c82447`,`admin-ui.css` 为 `c0fa61947300287f059ae3b0f987d685c4ab9b52f620923ee8f8713d0dbdcd88`,`globals.css` 为 `7c6cdba67750029ee959055a8d70e013c0cadfd05aaaf7dd398b42ea6f83ac48`,`appearance-bootstrap.test.ts` 为 `23f02b574541bd09c31785bf72d80d9968f9d14e70a7cfc6ecf643dcc7866ff9`,两份功能文档分别为 `7d7b0b46252a9c7a9787d99e7b6018b14ef8df189f7a0332f66f00804d89f537` 与 `4d58b9eb3db6110c30e12a6e41d371c6680fd51655bd11525c679b7cf03fbf10`;原件保存在 `.local/ui-change-backups/batch-134/`。 +- 验证结果:目标文件定向 Prettier 与 `git diff --check` 通过;固定 Bun 1.3.13、提交的 `bun.lock` 和干净依赖环境运行外观、后台、对象存储与找回密码四组专项测试,23 项全部通过、194 个断言无失败,TypeScript `tsc --noEmit` 为 0 错误。生产 Docker 多阶段构建转换 13,499 个模块并成功生成镜像,只保留项目既有的大分块提示。独立前端烟测返回 200,确认初始 HTML 不含内置品牌与 `/logo.svg`,新尺寸选择器已进入打包 CSS;真实管理员页面的深浅主题截图仍按 `docs/content/docs/progress/pending-test.mdx` 保留为人工验证项,不伪造登录态或业务配置。 +- 潜在影响:关闭外框后,宽或高较小、透明留白较多的 Logo 会在原容器范围内明显变大,但仍保持自身比例且不会溢出;如素材文件本身带有透明边距,视觉内容不会强制裁切到满边。开关仍属于外观页面草稿,需要点击原有“保存修改”才写入服务端;没有改成即时保存。 +- 逐项回滚:先使用 `.local/ui-change-backups/batch-134/` 中对应原件逐项恢复本批六个源码/文档文件,再恢复本台账并执行其 `MANIFEST.sha256` 校验;恢复前如文件已包含后续批次,先复制当前版本。本批不依赖 Git 历史做本地回滚。 diff --git a/web/index.html b/web/index.html index 2cdf28263..7b82110f9 100644 --- a/web/index.html +++ b/web/index.html @@ -3,9 +3,41 @@ - - - 影策 + + 正在加载 +