diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 28fc9e5e6..a47702145 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -158,6 +158,55 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) { } } +func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-legacy-v6-order?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&schemaMigration{}); err != nil { + t.Fatal(err) + } + for _, item := range schemaMigrations[:5] { + if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now().UTC()}).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Create(&schemaMigration{Version: 6, Name: "asset_library_folders", Checksum: assetLibraryFoldersChecksum, AppliedAt: time.Now().UTC()}).Error; err != nil { + t.Fatal(err) + } + + if err := MigrateSchema(db); err != nil { + t.Fatalf("repair legacy migration order: %v", err) + } + if !db.Migrator().HasColumn(&model.Resource{}, "playback_status") || !db.Migrator().HasColumn(&model.Resource{}, "playback_object_key") || !db.Migrator().HasColumn(&model.Resource{}, "playback_error") { + t.Fatal("legacy database did not receive resource playback columns") + } + status, err := ReadSchemaStatus(db) + if err != nil { + t.Fatal(err) + } + if !status.Ready || status.Current != CurrentSchemaVersion { + t.Fatalf("unexpected repaired schema status: %#v", status) + } + var applied schemaMigration + if err := db.First(&applied, "version = ?", 6).Error; err != nil { + t.Fatal(err) + } + if applied.Name != "asset_library_folders" || applied.Checksum != assetLibraryFoldersChecksum { + t.Fatalf("historical migration 6 must be preserved: %#v", applied) + } + var playback schemaMigration + if err := db.First(&playback, "version = ?", 7).Error; err != nil { + t.Fatal(err) + } + if playback.Name != "resource_playback_variant" || playback.Checksum != resourcePlaybackChecksum { + t.Fatalf("migration 7 must supply playback schema: %#v", playback) + } +} + func TestMigrateSchemaRollsBackFailedMigration(t *testing.T) { db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-rollback?mode=memory&cache=shared"}) if err != nil { diff --git a/backend/internal/service/announcement.go b/backend/internal/service/announcement.go index eb74ddbce..f90a89821 100644 --- a/backend/internal/service/announcement.go +++ b/backend/internal/service/announcement.go @@ -459,8 +459,8 @@ func validAnnouncementLevel(level model.AnnouncementLevel) bool { func normalizeAnnouncementInput(req CreateAnnouncementRequest) (string, string, model.AnnouncementLevel, error) { title := strings.TrimSpace(req.Title) content := strings.TrimSpace(req.Content) - if title == "" || content == "" { - return "", "", "", BadAuthRequest("请填写公告标题和正文") + if title == "" { + return "", "", "", BadAuthRequest("请填写公告标题") } if utf8.RuneCountInString(title) > 120 { return "", "", "", BadAuthRequest("公告标题不能超过 120 个字符") diff --git a/backend/internal/service/announcement_test.go b/backend/internal/service/announcement_test.go index bf76bca51..79dab8000 100644 --- a/backend/internal/service/announcement_test.go +++ b/backend/internal/service/announcement_test.go @@ -262,6 +262,19 @@ func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) { } } +func TestAnnouncementPublishAllowsEmptyContent(t *testing.T) { + svc, _ := newAnnouncementImageTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + + announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "仅标题公告", Content: " ", Level: model.AnnouncementLevelInfo}) + if err != nil { + t.Fatal(err) + } + if announcement.Title != "仅标题公告" || announcement.Content != "" { + t.Fatalf("announcement = %+v", announcement) + } +} + func newAnnouncementImageTestService(t *testing.T) (*Service, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index f37b09bc3..74fa9829f 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -129,6 +129,9 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) { if email == "" { return nil, BadAuthRequest("请输入邮箱") } + if err := s.validateRegistrationEmailDomain(email); err != nil { + return nil, err + } verifiedCode, err = s.VerifyRegistrationEmailCode(email, req.EmailCode) if err != nil { return nil, err diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go index d7afd2b3b..a831f28c1 100644 --- a/backend/internal/service/email.go +++ b/backend/internal/service/email.go @@ -28,41 +28,56 @@ const emailSettingKey = "email" const registrationEmailPurpose = "registration" const registrationCodeTTL = 10 * time.Minute +var defaultRegistrationEmailDomains = []string{ + "gmail.com", + "163.com", + "126.com", + "qq.com", + "outlook.com", + "hotmail.com", + "icloud.com", + "yahoo.com", + "foxmail.com", +} + type EmailSettingRequest struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - Encryption string `json:"encryption"` - FromEmail string `json:"fromEmail"` - FromName string `json:"fromName"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Encryption string `json:"encryption"` + FromEmail string `json:"fromEmail"` + FromName string `json:"fromName"` + RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` } type PublicEmailSetting struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Encryption string `json:"encryption"` - FromEmail string `json:"fromEmail"` - FromName string `json:"fromName"` - FromNameInherited bool `json:"fromNameInherited"` - HasPassword bool `json:"hasPassword"` - UpdatedBy string `json:"updatedBy"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Encryption string `json:"encryption"` + FromEmail string `json:"fromEmail"` + FromName string `json:"fromName"` + FromNameInherited bool `json:"fromNameInherited"` + HasPassword bool `json:"hasPassword"` + RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` + UpdatedBy string `json:"updatedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type emailSettingValue struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - Encryption string `json:"encryption"` - FromEmail string `json:"fromEmail"` - FromName string `json:"fromName"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Encryption string `json:"encryption"` + FromEmail string `json:"fromEmail"` + FromName string `json:"fromName"` + RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` } func (s *Service) AdminEmailSetting(actor *model.User) (*PublicEmailSetting, error) { @@ -84,7 +99,15 @@ func (s *Service) UpdateEmailSetting(actor *model.User, req EmailSettingRequest) if err != nil { return nil, err } - next := normalizeEmailSetting(emailSettingValue{Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password, Encryption: req.Encryption, FromEmail: req.FromEmail, FromName: req.FromName}) + allowedDomains := req.RegistrationAllowedDomains + if allowedDomains == nil { + allowedDomains = current.RegistrationAllowedDomains + } + next := normalizeEmailSetting(emailSettingValue{ + Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password, + Encryption: req.Encryption, FromEmail: req.FromEmail, FromName: req.FromName, + RegistrationAllowedDomains: allowedDomains, + }) if next.Password == "" { next.Password = current.Password } @@ -146,6 +169,9 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error { if err != nil { return err } + if err := validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains); err != nil { + return err + } if !setting.Enabled { return Forbidden("平台尚未启用注册邮件,请联系管理员") } @@ -249,6 +275,9 @@ func (s *Service) readEmailSetting() (*model.SystemSetting, emailSettingValue, e } func validateEmailSetting(value emailSettingValue) error { + if err := validateRegistrationEmailDomains(value.RegistrationAllowedDomains); err != nil { + return err + } if !value.Enabled { return nil } @@ -273,6 +302,10 @@ func normalizeEmailSetting(value emailSettingValue) emailSettingValue { value.Password = strings.TrimSpace(value.Password) value.FromEmail = normalizeEmail(value.FromEmail) value.FromName = strings.TrimSpace(value.FromName) + if value.RegistrationAllowedDomains == nil { + value.RegistrationAllowedDomains = append([]string(nil), defaultRegistrationEmailDomains...) + } + value.RegistrationAllowedDomains = normalizeEmailDomains(value.RegistrationAllowedDomains) if value.Port == 0 { value.Port = 587 } @@ -287,7 +320,11 @@ func normalizeEmailSetting(value emailSettingValue) emailSettingValue { func (s *Service) publicEmailSetting(setting *model.SystemSetting, value emailSettingValue) *PublicEmailSetting { inherited := value.FromName == "" || value.FromName == defaultAppearanceBrandName value = resolveEmailSender(value, s.appearanceBrandName()) - result := &PublicEmailSetting{Enabled: value.Enabled, Host: value.Host, Port: value.Port, Username: value.Username, Encryption: value.Encryption, FromEmail: value.FromEmail, FromName: value.FromName, FromNameInherited: inherited, HasPassword: value.Password != ""} + result := &PublicEmailSetting{ + Enabled: value.Enabled, Host: value.Host, Port: value.Port, Username: value.Username, Encryption: value.Encryption, + FromEmail: value.FromEmail, FromName: value.FromName, FromNameInherited: inherited, HasPassword: value.Password != "", + RegistrationAllowedDomains: value.RegistrationAllowedDomains, + } if setting != nil { result.UpdatedBy = setting.UpdatedBy result.CreatedAt = setting.CreatedAt @@ -296,6 +333,75 @@ func (s *Service) publicEmailSetting(setting *model.SystemSetting, value emailSe return result } +func normalizeEmailDomains(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, raw := range values { + value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "@"))) + value = strings.TrimSuffix(value, ".") + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func validateRegistrationEmailDomains(allowed []string) error { + if len(allowed) > 100 { + return BadAuthRequest("电子邮件域名白名单最多填写 100 项") + } + for _, domain := range allowed { + if !validEmailDomain(domain) { + return BadAuthRequest("电子邮件域名格式不正确:" + domain) + } + } + return nil +} + +func validEmailDomain(value string) bool { + if len(value) > 253 || !strings.Contains(value, ".") || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") { + return false + } + for _, label := range strings.Split(value, ".") { + if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return false + } + for _, char := range label { + if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { + return false + } + } + } + return true +} + +func (s *Service) validateRegistrationEmailDomain(email string) error { + _, setting, err := s.readEmailSetting() + if err != nil { + return err + } + return validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains) +} + +func validateRegistrationEmailDomain(email string, allowedDomains []string) error { + parts := strings.Split(email, "@") + if len(parts) != 2 { + return BadAuthRequest("邮箱格式不正确") + } + domain := strings.ToLower(parts[1]) + if len(allowedDomains) == 0 { + return nil + } + for _, allowed := range allowedDomains { + if domain == allowed { + return nil + } + } + return BadAuthRequest("该邮箱域名不在管理员设置的白名单内") +} + func resolveEmailSender(value emailSettingValue, brandName string) emailSettingValue { if value.FromName == "" || value.FromName == defaultAppearanceBrandName { value.FromName = brandName diff --git a/backend/internal/service/email_domain_test.go b/backend/internal/service/email_domain_test.go new file mode 100644 index 000000000..f22819489 --- /dev/null +++ b/backend/internal/service/email_domain_test.go @@ -0,0 +1,134 @@ +package service + +import ( + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestRegistrationDomainPolicyChecksSendingAndRegistration(t *testing.T) { + svc, db := newPasswordResetTestService(t) + admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + if err := db.Create(admin).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`}).Error; err != nil { + t.Fatal(err) + } + var deliveredCode string + sends := 0 + svc.mailSender = func(_ emailSettingValue, _, _ string, body string) error { + sends++ + deliveredCode = codeFromEmailBody(body) + return nil + } + if err := svc.SendRegistrationEmailCode("member@blocked.example"); err == nil || !strings.Contains(err.Error(), "白名单") { + t.Fatalf("blocked domain must not receive registration code: %v", err) + } + var codes int64 + if err := db.Model(&model.EmailVerificationCode{}).Count(&codes).Error; err != nil { + t.Fatal(err) + } + if sends != 0 || codes != 0 { + t.Fatalf("blocked send created side effects: sends=%d codes=%d", sends, codes) + } + if err := svc.SendRegistrationEmailCode("member@example.com"); err != nil { + t.Fatal(err) + } + if deliveredCode == "" || sends != 1 { + t.Fatal("allowed domain did not receive a code") + } + // A code issued before an administrator tightens the policy must not bypass it. + if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{"gmail.com"}}); err != nil { + t.Fatal(err) + } + if _, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", Password: "strong-password", EmailCode: deliveredCode}); err == nil || !strings.Contains(err.Error(), "白名单") { + t.Fatalf("registration must recheck current domain policy: %v", err) + } + count, err := svc.repo.UserCount() + if err != nil || count != 1 { + t.Fatalf("blocked registration created a user: count=%d err=%v", count, err) + } +} + +func TestEmailDomainSettingsRoundTripAndValidation(t *testing.T) { + svc, _ := newPasswordResetTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + setting, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{" @GMAIL.COM. ", "gmail.com"}}) + if err != nil || setting == nil || len(setting.RegistrationAllowedDomains) != 1 || setting.RegistrationAllowedDomains[0] != "gmail.com" { + t.Fatalf("normalization failed: setting=%+v err=%v", setting, err) + } + if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{"bad_domain"}}); err == nil { + t.Fatal("invalid whitelist accepted while SMTP is disabled") + } + setting, err = svc.AdminEmailSetting(admin) + if err != nil || setting == nil || len(setting.RegistrationAllowedDomains) != 1 || setting.RegistrationAllowedDomains[0] != "gmail.com" { + t.Fatalf("failed update changed the saved whitelist: setting=%+v err=%v", setting, err) + } + if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{}}); err != nil { + t.Fatal(err) + } + setting, err = svc.AdminEmailSetting(admin) + if err != nil || setting == nil || setting.RegistrationAllowedDomains == nil || len(setting.RegistrationAllowedDomains) != 0 { + t.Fatalf("explicit empty whitelist must survive persistence: setting=%+v err=%v", setting, err) + } + if err := svc.validateRegistrationEmailDomain("member@custom.example"); err != nil { + t.Fatalf("empty saved whitelist should allow custom domains: %v", err) + } +} + +func TestNormalizeEmailSettingDefaultsToMainstreamRegistrationDomains(t *testing.T) { + setting := normalizeEmailSetting(emailSettingValue{}) + for _, domain := range []string{"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com", "icloud.com", "yahoo.com", "foxmail.com"} { + if !containsEmailDomain(setting.RegistrationAllowedDomains, domain) { + t.Fatalf("default allowed domains = %#v, missing %q", setting.RegistrationAllowedDomains, domain) + } + } + + setting = normalizeEmailSetting(emailSettingValue{RegistrationAllowedDomains: []string{}}) + if len(setting.RegistrationAllowedDomains) != 0 { + t.Fatalf("explicit empty allowed domains = %#v, want unrestricted", setting.RegistrationAllowedDomains) + } +} + +func TestValidateRegistrationEmailDomainUsesExactWhitelistMatch(t *testing.T) { + allowed := []string{"gmail.com", "example.org"} + for _, email := range []string{"member@gmail.com", "member@example.org"} { + if err := validateRegistrationEmailDomain(email, allowed); err != nil { + t.Fatalf("%s should be allowed: %v", email, err) + } + } + for _, test := range []struct { + email string + want string + }{ + {email: "member@fakegmail.com", want: "不在管理员设置的白名单"}, + {email: "member@blocked.example", want: "不在管理员设置的白名单"}, + } { + if err := validateRegistrationEmailDomain(test.email, allowed); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validateRegistrationEmailDomain(%q) error = %v", test.email, err) + } + } +} + +func TestValidateRegistrationEmailDomainsRejectsInvalidDomain(t *testing.T) { + if err := validateRegistrationEmailDomains([]string{"invalid_domain"}); err == nil { + t.Fatal("invalid domain should be rejected") + } +} + +func TestValidateRegistrationEmailDomainAllowsAnyDomainWhenWhitelistIsEmpty(t *testing.T) { + if err := validateRegistrationEmailDomain("member@example.org", nil); err != nil { + t.Fatalf("empty whitelist should not restrict registration: %v", err) + } +} + +func containsEmailDomain(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/backend/internal/service/password_reset_test.go b/backend/internal/service/password_reset_test.go index f8db217bd..2ae4b5789 100644 --- a/backend/internal/service/password_reset_test.go +++ b/backend/internal/service/password_reset_test.go @@ -21,7 +21,8 @@ func TestPasswordResetChangesPasswordConsumesCodeAndRevokesSessions(t *testing.T if err != nil { t.Fatal(err) } - user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: oldHash} + // Existing users can reset passwords even outside the registration whitelist. + user := model.User{ID: "user-1", Username: "creator", Email: "creator@external.example", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: oldHash} if err := db.Create(&user).Error; err != nil { t.Fatal(err) } @@ -255,7 +256,7 @@ func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) { if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil { t.Fatal(err) } - settingJSON, err := json.Marshal(emailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策"}) + settingJSON, err := json.Marshal(emailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策", RegistrationAllowedDomains: []string{"example.com"}}) if err != nil { t.Fatal(err) } diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 0112a5479..68feb432e 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -39,6 +39,8 @@ description: 影策后端核心持久化表与状态边界 密码重置在单个数据库事务中条件消费 `password_reset` 验证码、更新 `users.password_hash` 并删除 `auth_sessions`。任一步失败都会回滚,不能出现密码已修改但旧会话仍有效,或验证码已消耗但密码未更新的部分状态。 +邮件系统设置还保存电子邮件域名白名单。新配置默认填入 Gmail、163、126、QQ、Outlook、Hotmail、iCloud、Yahoo 和 Foxmail;管理员可按行编辑完整域名。白名单为空时不限制邮箱域名,非空时只有精确匹配的域名可注册。该规则只作用于新用户注册和注册验证码,不限制 SMTP 发件邮箱,也不影响已有账号找回密码。 + ## 技能包与版本 | 表 | 用途 | 关键边界 | @@ -144,7 +146,7 @@ description: 影策后端核心持久化表与状态边界 | `announcement_image_drafts` | 标记尚未发布的公告配图资源 | 发布/更新事务消费草稿;取消编辑显式回收,超过 24 小时的异常遗留草稿由后台清理 | | `user_announcement_reads` | 保存用户已读状态 | 同一用户和公告唯一;公告重新发布时清除旧已读记录 | -活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。 +活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。公告只要求标题,正文和配图均可为空。用户首次进入工作区时会自动看到未读公告,可以只关闭本次或选择今日不再提醒;主动打开公告中心后才会写入服务端已读状态。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。 ## 资源存储 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 54097ace6..7b869367a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,15 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 邮箱域名白名单与公告自动提醒(PR #417) + +- 新增注册邮箱域名白名单:默认常见服务商、空列表不限制、精确域名匹配;发送注册验证码及提交注册都会校验,已有账号找回密码不受限制。保存失败不覆盖既有配置。 +- 公告允许仅标题发布;工作区自动提示未读公告,支持关闭本次及今日不再提醒。保留弹窗按需加载;主动打开后即使已读同步失败,关闭也不会立刻重新弹出。 +- 合并沿用主分支历史迁移方案,不重复执行迁移或重写版本 6 的历史记录;未修改用户数据库,历史升级仍需备份及 PostgreSQL 实际环境验收。 +- 已通过前端构建及本次相关后端数据库、handler、service 专项测试;公告提醒前端回归使用静态契约检查,不等同于浏览器交互验收。 +- 基线复测确认既有失败:后端渠道私网策略 2 项、视频恢复策略 2 项,以及前端工作区加载骨架断言 1 项;本次不修改这些无关逻辑。 +- 待人工验证:邮箱设置保存/清空/刷新及错误展示、仅标题公告发布与重新发布、明暗主题、窄屏弹窗、跨日提醒、不同账号的今日免打扰隔离,以及网络失败后的手动关闭与重试。 + ## v1.2.6.rc1 候选版本发布 - 发布版本与镜像标签分别为 `v1.2.6.rc1` 和 `1.2.6.rc1`;GitHub Release 应标记为预发布,不能被当作正式版本推荐。 diff --git a/web/package.json b/web/package.json index dc6d58da4..0265cb2fc 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts", "dev": "vite --host 0.0.0.0 --port 3000", "build": "bun run build:bridge && tsc --noEmit && vite build", - "pretest": "bun test test/admin-ui-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts", + "pretest": "bun test test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts", "typecheck": "tsc --noEmit", "test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts", "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-pricing.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", diff --git a/web/src/components/layout/system-announcement-center.tsx b/web/src/components/layout/system-announcement-center.tsx index 40008da61..d8ac370c9 100644 --- a/web/src/components/layout/system-announcement-center.tsx +++ b/web/src/components/layout/system-announcement-center.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Tag } from "antd"; import { Bell } from "lucide-react"; -import { lazy, Suspense, useState, type CSSProperties } from "react"; +import { lazy, Suspense, useEffect, useState, type CSSProperties } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { aceternityMotion } from "@/lib/aceternity-motion"; @@ -11,6 +11,7 @@ const AnnouncementTimelineModal = lazy(() => import("@/components/ui/aceternity/ const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000; const ANNOUNCEMENT_CACHE_TTL_MS = 60_000; +const ANNOUNCEMENT_DISMISS_TODAY_PREFIX = "yingce.announcements.dismiss-today"; type AnnouncementFeed = Awaited>; @@ -21,12 +22,15 @@ type SystemAnnouncementCenterProps = { showLabel?: boolean; labelClassName?: string; staticMotion?: boolean; + autoOpen?: boolean; }; -export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) { +export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false, autoOpen = false }: SystemAnnouncementCenterProps) { const reducedMotion = useReducedMotion(); const queryClient = useQueryClient(); const [open, setOpen] = useState(false); + const [automaticPrompt, setAutomaticPrompt] = useState(false); + const [dismissedFingerprint, setDismissedFingerprint] = useState(""); const queryKey = ["system-announcements", userId] as const; const feedQuery = useQuery({ queryKey, @@ -41,7 +45,16 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0); const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : ""; + useEffect(() => { + if (!autoOpen || open || !feedQuery.isSuccess || unreadCount <= 0 || announcements.length === 0) return; + const fingerprint = announcementFeedFingerprint(announcements); + if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId)) return; + setAutomaticPrompt(true); + setOpen(true); + }, [announcements, autoOpen, dismissedFingerprint, feedQuery.isSuccess, open, unreadCount, userId]); + const openAnnouncements = async () => { + setAutomaticPrompt(false); setOpen(true); const feed = (await feedQuery.refetch()).data; if (!feed?.unreadCount) return; @@ -55,6 +68,14 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = } }; + const dismissAutomaticPrompt = (duration: "once" | "today") => { + const fingerprint = announcementFeedFingerprint(announcements); + if (fingerprint) setDismissedFingerprint(fingerprint); + if (duration === "today") rememberAnnouncementDismissalToday(userId); + setAutomaticPrompt(false); + setOpen(false); + }; + return ( <> {open ? ( - setOpen(false)} onRetry={() => void feedQuery.refetch()} /> + dismissAutomaticPrompt("once")} + onDismissOnce={() => dismissAutomaticPrompt("once")} + onDismissToday={() => dismissAutomaticPrompt("today")} + onRetry={() => void feedQuery.refetch()} + /> ) : null} ); } + +function announcementFeedFingerprint(announcements: AnnouncementFeed["announcements"]) { + return announcements + .map((announcement) => `${announcement.id}:${announcement.publishedAt}`) + .sort() + .join("|"); +} + +function announcementAutoPromptSuppressed(userId: string) { + try { + return localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`) === localDateKey(); + } catch { + return false; + } +} + +function rememberAnnouncementDismissalToday(userId: string) { + try { + localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, localDateKey()); + } catch { + // 浏览器禁用存储时仍允许关闭弹窗,本次组件生命周期内不会重复打开。 + } +} + +function localDateKey() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; +} diff --git a/web/src/components/layout/workspace-top-bar.tsx b/web/src/components/layout/workspace-top-bar.tsx index 870188e1f..4c56ccd2f 100644 --- a/web/src/components/layout/workspace-top-bar.tsx +++ b/web/src/components/layout/workspace-top-bar.tsx @@ -47,7 +47,7 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
- {user ? : null} + {user ? : null}
diff --git a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx index b7b830f16..82e7c1700 100644 --- a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx +++ b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Image as AntImage, Modal } from "antd"; +import { Button, Image as AntImage, Modal } from "antd"; import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react"; import { aceternityMotion } from "@/lib/aceternity-motion"; @@ -11,7 +11,10 @@ type AnnouncementTimelineModalProps = { announcements: SystemAnnouncement[]; loading?: boolean; error?: string; + automaticPrompt?: boolean; onClose: () => void; + onDismissOnce?: () => void; + onDismissToday?: () => void; onRetry?: () => void; }; @@ -22,7 +25,7 @@ const levelMeta: Record + + + + ) : null} onCancel={onClose} title={
@@ -111,7 +119,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou {announcement.pinned ? 置顶 : null}
{announcement.imageUrl ? : null} - + {announcement.content ? : null} diff --git a/web/src/pages/admin/components/admin-announcements-panel.tsx b/web/src/pages/admin/components/admin-announcements-panel.tsx index 4f9173137..308703bc7 100644 --- a/web/src/pages/admin/components/admin-announcements-panel.tsx +++ b/web/src/pages/admin/components/admin-announcements-panel.tsx @@ -810,13 +810,10 @@ function AnnouncementEditor({ - + @@ -835,7 +832,7 @@ function AnnouncementEditor({

{watchedTitle?.trim() || "公告标题将在这里显示"}

{imagePreviewUrl ? 公告配图预览 : null} - {watchedContent?.trim() ? :

公告正文将在这里显示。

} + {watchedContent?.trim() ? :

此公告仅展示标题。

} @@ -889,7 +886,7 @@ function AnnouncementEditor({
公告正文
- + {pending.content ? : "无正文"}
diff --git a/web/src/pages/admin/components/email-settings-panel.tsx b/web/src/pages/admin/components/email-settings-panel.tsx index 27f1194a4..1f3d892a7 100644 --- a/web/src/pages/admin/components/email-settings-panel.tsx +++ b/web/src/pages/admin/components/email-settings-panel.tsx @@ -8,7 +8,13 @@ import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from import { useAppearanceStore } from "@/stores/use-appearance-store"; import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui"; -type EmailFormValues = Pick; +type EmailFormValues = Pick & { + registrationAllowedDomains: string; +}; + +type NormalizedEmailFormValues = Omit & { + registrationAllowedDomains: string[]; +}; export default function EmailSettingsPanel() { const { message, modal } = App.useApp(); @@ -289,8 +295,25 @@ export default function EmailSettingsPanel() { - {draftEnabled ? ( -
+
{ + const values = form.getFieldsValue(true); + setDraftEnabled(Boolean(values.enabled)); + setDirty(hasEmailChanges(values, setting)); + setSaveError(""); + }} + > + + + {draftEnabled ? ( +
} > - { - const values = form.getFieldsValue(true); - setDraftEnabled(Boolean(values.enabled)); - setDirty(hasEmailChanges(values, setting)); - setSaveError(""); - }} - > - -
} title="服务器连接" description="填写 SMTP 主机、端口和传输加密;STARTTLS 通常使用 587,直接 TLS 通常使用 465。" />
@@ -402,10 +409,53 @@ export default function EmailSettingsPanel() {
-
+ +
+ ) : null} + +
+
- ) : null} + ); } @@ -432,10 +482,11 @@ function toEmailFormValues(setting: EmailSetting): EmailFormValues { encryption: setting.encryption, fromEmail: setting.fromEmail, fromName: setting.fromNameInherited ? "" : setting.fromName, + registrationAllowedDomains: setting.registrationAllowedDomains.join("\n"), }; } -function normalizeEmailFormValues(values: Partial): EmailFormValues { +function normalizeEmailFormValues(values: Partial): NormalizedEmailFormValues { return { enabled: Boolean(values.enabled), host: values.host?.trim() || "", @@ -445,6 +496,7 @@ function normalizeEmailFormValues(values: Partial): EmailFormVa encryption: values.encryption === "tls" || values.encryption === "none" ? values.encryption : "starttls", fromEmail: values.fromEmail?.trim().toLowerCase() || "", fromName: values.fromName?.trim() || "", + registrationAllowedDomains: normalizeDomains(values.registrationAllowedDomains), }; } @@ -453,7 +505,8 @@ function hasEmailChanges(values: Partial, setting: EmailSetting const draft = normalizeEmailFormValues(values); const saved = normalizeEmailFormValues(toEmailFormValues(setting)); if (draft.password) return true; - return (Object.keys(saved) as Array).some((key) => key !== "password" && draft[key] !== saved[key]); + const scalarFields: Array = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"]; + return scalarFields.some((key) => draft[key] !== saved[key]) || !arraysEqual(draft.registrationAllowedDomains, saved.registrationAllowedDomains); } function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | null) { @@ -466,11 +519,11 @@ function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | nul return ""; } -function emailResponseMatches(setting: EmailSetting, expected: EmailFormValues) { +function emailResponseMatches(setting: EmailSetting, expected: NormalizedEmailFormValues) { const actual = normalizeEmailFormValues(toEmailFormValues(setting)); const fields: Array = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"]; if (expected.password && !setting.hasPassword) return false; - return fields.every((key) => actual[key] === expected[key]); + return fields.every((key) => actual[key] === expected[key]) && arraysEqual(actual.registrationAllowedDomains, expected.registrationAllowedDomains); } function isEmailSetting(value: unknown): value is EmailSetting { @@ -485,10 +538,36 @@ function isEmailSetting(value: unknown): value is EmailSetting { typeof setting.fromEmail === "string" && typeof setting.fromName === "string" && typeof setting.fromNameInherited === "boolean" && - typeof setting.hasPassword === "boolean" + typeof setting.hasPassword === "boolean" && + Array.isArray(setting.registrationAllowedDomains) && + setting.registrationAllowedDomains.every((value) => typeof value === "string") ); } +function normalizeDomains(value?: string) { + return Array.from( + new Set( + (value || "") + .split(/[\s,,;;]+/) + .map((domain) => domain.trim().toLowerCase().replace(/^@/, "").replace(/\.$/, "")) + .filter(Boolean), + ), + ); +} + +function arraysEqual(left: string[], right: string[]) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function validateDomainList(value?: string) { + const invalid = invalidDomain(value); + return invalid ? Promise.reject(new Error(`邮箱域名格式不正确:“${invalid}”`)) : Promise.resolve(); +} + +function invalidDomain(value?: string) { + return normalizeDomains(value).find((domain) => !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(domain)); +} + function isValidEmail(value: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); } diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index d52c46c9e..fd92a75f3 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -147,6 +147,7 @@ export type EmailSetting = { fromName: string; fromNameInherited: boolean; hasPassword: boolean; + registrationAllowedDomains: string[]; updatedBy?: string; createdAt?: string; updatedAt?: string; diff --git a/web/src/styles/admin-ui.css b/web/src/styles/admin-ui.css index ee939dd67..e24744f88 100644 --- a/web/src/styles/admin-ui.css +++ b/web/src/styles/admin-ui.css @@ -3337,6 +3337,12 @@ padding-bottom: 24px; } +.admin-email-form-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + .admin-email-settings .admin-settings-section { border: 1px solid var(--admin-card-border); } diff --git a/web/test/announcement-prompt-regressions.test.ts b/web/test/announcement-prompt-regressions.test.ts new file mode 100644 index 000000000..8943d9279 --- /dev/null +++ b/web/test/announcement-prompt-regressions.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; + +test("announcement prompts preserve lazy loading and dismiss even when read synchronization fails", async () => { + const source = await Bun.file(new URL("../src/components/layout/system-announcement-center.tsx", import.meta.url)).text(); + expect(source).toContain('lazy(() => import("@/components/ui/aceternity/announcement-timeline-modal")'); + expect(source).toContain("{open ? ("); + expect(source).toContain(""); + expect(source).toContain("automaticPrompt={automaticPrompt}"); + expect(source).toContain('onClose={() => dismissAutomaticPrompt("once")}'); + expect(source).toContain('onDismissToday={() => dismissAutomaticPrompt("today")}'); + expect(source).toContain("setDismissedFingerprint(fingerprint)"); + expect(source).toContain("fingerprint === dismissedFingerprint"); + expect(source).toContain("`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`"); +});