feat(banner): 首页常驻滚动通知与管理配置及类型样式支持
This commit is contained in:
1 parent
772c551989
commit
a3b6761ba9
34 files changed
+3345
-10
No files matched your search
@@ -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{})
|
||||
|
||||
Reference in new issue
Block a user