feat(banner): 首页常驻滚动通知与管理配置及类型样式支持

This commit is contained in:
✨await committed 2026-09-17 23:17:09 +08:00
1 parent 772c551989
commit a3b6761ba9
34 files changed
+3345 -10

No files matched your search

+391
View File
@@ -275,6 +275,397 @@ func TestAnnouncementPublishAllowsEmptyContent(t *testing.T) {
}
}
func TestBannerAnnouncementLifecycle(t *testing.T) {
svc, _ := newAnnouncementImageTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
Title: "常驻公告1",
Status: "active",
})
if err != nil {
t.Fatal(err)
}
if banner.Title != "常驻公告1" || banner.Status != "active" {
t.Fatalf("banner = %+v", banner)
}
activeBanners, err := svc.ActiveBannerAnnouncements()
if err != nil {
t.Fatal(err)
}
if len(activeBanners) != 1 || activeBanners[0].ID != banner.ID {
t.Fatalf("activeBanners = %+v", activeBanners)
}
updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
Title: "更新的常驻公告",
Link: "/projects",
Status: "disabled",
})
if err != nil {
t.Fatal(err)
}
if updated.Title != "更新的常驻公告" || updated.Link != "/projects" || updated.Status != "disabled" {
t.Fatalf("updated = %+v", updated)
}
activeAfterDisable, _ := svc.ActiveBannerAnnouncements()
if len(activeAfterDisable) != 0 {
t.Fatalf("expected 0 active banners, got %d", len(activeAfterDisable))
}
}
func TestBannerAnnouncementAcceptsFutureStartAndLinkKinds(t *testing.T) {
svc, _ := newAnnouncementImageTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
future := time.Now().Add(24 * time.Hour)
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
Title: "预约明天上线",
Link: "https://example.com/news/1",
Status: "active",
StartsAt: &future,
})
if err != nil {
t.Fatalf("future start should be accepted: %v", err)
}
if banner.Link != "https://example.com/news/1" || banner.StartsAt == nil {
t.Fatalf("banner = %+v", banner)
}
// 未来开始时间未到,不应出现在 active 列表
active, err := svc.ActiveBannerAnnouncements()
if err != nil {
t.Fatal(err)
}
if len(active) != 0 {
t.Fatalf("future banner should not be active yet, got %d", len(active))
}
// 内链合法
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "内链", Link: "/projects", Status: "active"}); err != nil {
t.Fatalf("internal link should be accepted: %v", err)
}
// 非法链接拒绝
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "坏链接", Link: "javascript:alert(1)", Status: "active"}); err == nil {
t.Fatal("javascript: link should be rejected")
}
// 结束早于开始拒绝
start := time.Now().Add(-time.Hour)
end := time.Now().Add(-2 * time.Hour)
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "倒挂时间", Status: "active", StartsAt: &start, EndsAt: &end}); err == nil {
t.Fatal("endsAt before startsAt should be rejected")
}
}
func TestBannerAnnouncementValidityWindowFiltering(t *testing.T) {
svc, _ := newAnnouncementImageTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
now := time.Now()
past := now.Add(-48 * time.Hour)
future := now.Add(48 * time.Hour)
// 已结束:start 在过去、end 已过 → 不展示
middle := past.Add(24 * time.Hour)
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "已过期", Status: "active", StartsAt: &past, EndsAt: &middle}); err != nil {
t.Fatal(err)
}
// 生效中:start 已过、end 在未来 → 展示
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "生效中", Status: "active", StartsAt: &past, EndsAt: &future}); err != nil {
t.Fatal(err)
}
// 未开始:start 在未来 → 不展示
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "未开始", Status: "active", StartsAt: &future}); err != nil {
t.Fatal(err)
}
// 禁用:即使时间窗口内也不展示
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "被禁用", Status: "disabled"}); err != nil {
t.Fatal(err)
}
active, err := svc.ActiveBannerAnnouncements()
if err != nil {
t.Fatal(err)
}
if len(active) != 1 || active[0].Title != "生效中" {
t.Fatalf("active = %+v, want only 生效中", active)
}
}
func TestBannerAnnouncementTitleRuns(t *testing.T) {
svc, _ := newAnnouncementImageTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
size := 16
weight := 700
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
TitleRuns: []model.BannerTitleRun{
{Text: "限时", FontSize: &size, FontWeight: &weight, Color: "#ff0000"},
{Text: "活动", FontSize: &size, FontWeight: &weight, Color: "#FF0000"},
{Text: "已上线", FontFamily: "mono"},
},
Status: "active",
})
if err != nil {
t.Fatal(err)
}
// 相邻同样式分段合并,纯文本标题由分段拼接得出,供列表与检索使用。
if banner.Title != "限时活动已上线" {
t.Fatalf("title = %q, want 限时活动已上线", banner.Title)
}
if len(banner.TitleRuns) != 2 {
t.Fatalf("titleRuns = %+v, want 2 merged runs", banner.TitleRuns)
}
if banner.TitleRuns[0].Text != "限时活动" || banner.TitleRuns[0].Color != "#FF0000" {
t.Fatalf("first run = %+v", banner.TitleRuns[0])
}
// 读回时样式分段必须完整保留(列存储 + 显式编解码的回归点)。
stored, err := svc.repo.BannerAnnouncement(banner.ID)
if err != nil {
t.Fatal(err)
}
if len(stored.TitleRuns) != 2 || stored.TitleRuns[0].FontSize == nil || *stored.TitleRuns[0].FontSize != 16 {
t.Fatalf("stored = %+v", stored.TitleRuns)
}
active, err := svc.ActiveBannerAnnouncements()
if err != nil {
t.Fatal(err)
}
if len(active) != 1 || len(active[0].TitleRuns) != 2 {
t.Fatalf("active = %+v", active)
}
// 更新走显式 map,样式分段同样不能被丢掉。
updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
TitleRuns: []model.BannerTitleRun{{Text: "更新后的标题", Color: "#00FF00"}},
Status: "active",
})
if err != nil {
t.Fatal(err)
}
if updated.Title != "更新后的标题" || len(updated.TitleRuns) != 1 || updated.TitleRuns[0].Color != "#00FF00" {
t.Fatalf("updated = %+v", updated)
}
// 只传纯文本时清空样式分段。
plain, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{Title: "纯文本标题", Status: "active"})
if err != nil {
t.Fatal(err)
}
if len(plain.TitleRuns) != 0 || plain.Title != "纯文本标题" {
t.Fatalf("plain = %+v", plain)
}
}
func TestBannerAnnouncementRejectsInvalidTitleRuns(t *testing.T) {
svc, _ := newAnnouncementImageTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
size := func(value int) *int { return &value }
weight := func(value int) *int { return &value }
cases := []struct {
name string
req CreateBannerAnnouncementRequest
}{
{name: "字号过小", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(9)}}, Status: "active"}},
{name: "字号过大", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(21)}}, Status: "active"}},
{name: "字重非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontWeight: weight(6000)}}, Status: "active"}},
{name: "字体非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontFamily: "comic-sans"}}, Status: "active"}},
{name: "颜色非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", Color: "red"}}, Status: "active"}},
{name: "颜色注入", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", Color: "#fff;background:url(x)"}}, Status: "active"}},
{name: "分段文本为空", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "", FontSize: size(14)}}, Status: "active"}},
}
for _, testCase := range cases {
if _, err := svc.CreateBannerAnnouncement(admin, testCase.req); err == nil {
t.Fatalf("%s: expected rejection", testCase.name)
}
}
long := ""
for range 121 {
long += "字"
}
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: long}}, Status: "active"}); err == nil {
t.Fatal("over-long run text should be rejected")
}
// 边界值可接受:10 与 20、四种字重、三种字体。
for _, value := range []int{10, 20} {
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(value)}}, Status: "active"}); err != nil {
t.Fatalf("font size %d should be accepted: %v", value, err)
}
}
for _, value := range []int{400, 500, 600, 700} {
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontWeight: weight(value)}}, Status: "active"}); err != nil {
t.Fatalf("font weight %d should be accepted: %v", value, err)
}
}
for _, value := range []string{"sans", "serif", "mono"} {
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontFamily: value}}, Status: "active"}); err != nil {
t.Fatalf("font family %q should be accepted: %v", value, err)
}
}
}
func TestBannerAnnouncementNoticeType(t *testing.T) {
svc, _ := newAnnouncementImageTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
// 未传类型时补成默认值,行数据始终是可直接渲染的具体取值。
def, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "默认外观", Status: "active"})
if err != nil {
t.Fatal(err)
}
if def.NoticeType != bannerNoticeDefaultType {
t.Fatalf("default noticeType = %q", def.NoticeType)
}
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
Title: "限时活动通知",
NoticeType: "activity",
Status: "active",
})
if err != nil {
t.Fatal(err)
}
if banner.NoticeType != "activity" {
t.Fatalf("banner = %+v", banner)
}
// 读回路径(仓储 + 公开接口)都要保留类型。
stored, err := svc.repo.BannerAnnouncement(banner.ID)
if err != nil {
t.Fatal(err)
}
if stored.NoticeType != "activity" {
t.Fatalf("stored = %+v", stored)
}
active, err := svc.ActiveBannerAnnouncements()
if err != nil {
t.Fatal(err)
}
found := false
for _, item := range active {
if item.ID == banner.ID && item.NoticeType == "activity" {
found = true
}
}
if !found {
t.Fatalf("active = %+v, want activity", active)
}
// 更新走显式字段表,漏字段会被静默清空的回归点。
updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
Title: "改成警告",
NoticeType: "warning",
Status: "active",
})
if err != nil {
t.Fatal(err)
}
if updated.NoticeType != "warning" {
t.Fatalf("updated = %+v", updated)
}
reloaded, err := svc.repo.BannerAnnouncement(banner.ID)
if err != nil {
t.Fatal(err)
}
if reloaded.NoticeType != "warning" {
t.Fatalf("reloaded = %+v", reloaded)
}
// 边界:全部合法取值都必须被接受,否则后台选了却被静默丢弃。
for value := range bannerNoticeTypes {
created, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "类型 " + value, NoticeType: value, Status: "active"})
if err != nil {
t.Fatalf("notice type %q should be accepted: %v", value, err)
}
if created.NoticeType != value {
t.Fatalf("notice type %q normalized to %q", value, created.NoticeType)
}
}
// 大小写与空格归一化。
normalized, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "归一化", NoticeType: " ACTIVITY ", Status: "active"})
if err != nil {
t.Fatal(err)
}
if normalized.NoticeType != "activity" {
t.Fatalf("normalized = %+v", normalized)
}
}
func TestBannerAnnouncementRejectsUnknownNoticeType(t *testing.T) {
svc, _ := newAnnouncementImageTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
cases := []struct {
name string
req CreateBannerAnnouncementRequest
}{
{name: "类型不在白名单", req: CreateBannerAnnouncementRequest{Title: "标题", NoticeType: "danger", Status: "active"}},
{name: "类型注入", req: CreateBannerAnnouncementRequest{Title: "标题", NoticeType: "notice;--x:1", Status: "active"}},
}
for _, testCase := range cases {
if _, err := svc.CreateBannerAnnouncement(admin, testCase.req); err == nil {
t.Fatalf("%s: expected rejection", testCase.name)
}
}
// 更新路径同样受白名单约束,不能绕过校验改坏已存在的行。
created, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "已有通知", Status: "active"})
if err != nil {
t.Fatal(err)
}
if _, err := svc.UpdateBannerAnnouncement(admin, created.ID, UpdateBannerAnnouncementRequest{Title: "改坏", NoticeType: "rainbow", Status: "active"}); err == nil {
t.Fatal("update with unknown notice type should be rejected")
}
}
// emoji 图标素材就是普通文本分段:不需要独立字段或白名单,但必须能完整往返、并入纯文本标题参与检索。
func TestBannerAnnouncementTitleRunsWithEmoji(t *testing.T) {
svc, _ := newAnnouncementImageTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
TitleRuns: []model.BannerTitleRun{
{Text: "💰"},
{Text: "双 11 预售开启,全场"},
{Text: "5 折", Color: "#FFE58F", FontWeight: func() *int { v := 700; return &v }()},
{Text: "🚀"},
},
NoticeType: "activity",
Status: "active",
})
if err != nil {
t.Fatal(err)
}
if banner.Title != "💰双 11 预售开启,全场5 折🚀" {
t.Fatalf("title = %q", banner.Title)
}
if len(banner.TitleRuns) != 3 {
t.Fatalf("titleRuns = %+v, want 3(开头 emoji 与正文同为默认样式应合并)", banner.TitleRuns)
}
stored, err := svc.repo.BannerAnnouncement(banner.ID)
if err != nil {
t.Fatal(err)
}
if len(stored.TitleRuns) != 3 {
t.Fatalf("stored = %+v", stored.TitleRuns)
}
active, err := svc.ActiveBannerAnnouncements()
if err != nil {
t.Fatal(err)
}
if len(active) != 1 || active[0].Title != banner.Title {
t.Fatalf("active = %+v", active)
}
}
func newAnnouncementImageTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})