From 2a76d59a30b7ebc0e968bc4bb17f61ebe212ece5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 10:02:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E6=94=AF=E6=8C=81=E5=88=86=E7=AB=99=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公告中心(前台公告列表/详情)此前完全没有站点概念,分站访客只能看到主站公告。 本次按「默认继承主站、站长可切仅本站」的口径支持分站自定义。 数据与口径: - 新增 announcements.distribution_site_id(0=主站),前台按站点隔离 - 新增 distribution_sites.announcement_mode:inherit(默认,主站+本站)/ own_only(仅本站) 后端: - 公告模块新增 ResolvePublicSiteScope:主站 [0]、inherit [0, siteID]、own_only [siteID], 站点编码无效/已停用回落主站;列表与详情都按该范围过滤,详情不再允许跨站按 ID 读取 - 管理端公告列表支持按站点筛选(site_id:不传=全部,0=主站,>0=分站)并带出站点名 - 分销模块新增本站公告 CRUD(distribution/announcement.go):强制 distribution_site_id=本站, 先过 ensureSiteAccess,跨站更新/删除/上下架一律 ErrAnnouncementNotFound - UpdateSiteBranding 支持 announcement_mode 切换(非法值报错,读侧非法历史值回落 inherit) - 新增权限 distribution:announcement_view|manage 并授予 super_admin/ops/finance, 站长开通时继承 ops 角色即自动获得 - 路由:站长侧 /api/distribution/announcements*,平台侧 /api/admin/distribution/announcements* 前端: - 分销后台新增「公告中心」页:来源开关(继承主站+本站 / 仅本站)、本站公告列表与 新建/编辑/发布/下架/归档/恢复/删除,空态按来源模式给出引导 - 平台后台公告页新增「站点」列与筛选,新建公告可选择归属站点(主站或指定分站代发) - 前台 PC/移动无需改动:请求已带 X-Distribution-Site-Code,后端按站点隔离,页面已有空态 测试: - 公告模块:主站/继承/仅本站三种可见范围、跨站详情拦截、无效站点回落、管理端站点筛选 - 分销模块:本站公告 CRUD 与跨站防护、公告来源开关校验与非法值容错 --- backend/internal/model/announcement.go | 36 +- backend/internal/model/distribution.go | 4 +- backend/internal/modules/announcement/dto.go | 44 +- .../internal/modules/announcement/handler.go | 32 +- .../modules/announcement/repository.go | 143 ++++-- .../modules/announcement/repository_test.go | 176 +++++++ .../internal/modules/announcement/service.go | 11 +- .../modules/distribution/announcement.go | 235 +++++++++ .../modules/distribution/announcement_test.go | 142 ++++++ backend/internal/modules/distribution/dto.go | 47 +- .../internal/modules/distribution/handler.go | 125 +++++ .../modules/distribution/repository.go | 11 + .../internal/modules/distribution/service.go | 35 ++ .../modules/distribution/site_crud_test.go | 2 +- backend/internal/router/router.go | 15 + .../000077_announcement_distribution_site.sql | 37 ++ distribution-admin/src/AppShell.tsx | 4 + distribution-admin/src/api.ts | 89 ++++ .../src/pages/Announcements.tsx | 455 ++++++++++++++++++ docs/分销网管理后台设计方案.md | 18 + .../features/admin/api/adminAnnouncements.ts | 4 + .../admin/components/AnnouncementDialog.vue | 45 ++ .../admin/views/AdminAnnouncementsView.vue | 50 +- .../announcement/api/announcements.ts | 4 + 24 files changed, 1676 insertions(+), 88 deletions(-) create mode 100644 backend/internal/modules/announcement/repository_test.go create mode 100644 backend/internal/modules/distribution/announcement.go create mode 100644 backend/internal/modules/distribution/announcement_test.go create mode 100644 backend/migrations/000077_announcement_distribution_site.sql create mode 100644 distribution-admin/src/pages/Announcements.tsx diff --git a/backend/internal/model/announcement.go b/backend/internal/model/announcement.go index de99cdc..e470dbd 100644 --- a/backend/internal/model/announcement.go +++ b/backend/internal/model/announcement.go @@ -2,20 +2,30 @@ package model import "time" +// 公告中心来源模式(distribution_sites.announcement_mode): +// - inherit:分站展示主站公告 + 本站公告(默认) +// - own_only:分站只展示本站公告 +const ( + AnnouncementModeInherit = "inherit" + AnnouncementModeOwnOnly = "own_only" +) + type Announcement struct { - ID uint64 `gorm:"primaryKey" json:"id"` - Title string `gorm:"size:255;not null" json:"title"` - Content string `gorm:"type:text;not null" json:"content"` - Category string `gorm:"size:32;not null;default:'notice'" json:"category"` - Priority int `gorm:"not null;default:0" json:"priority"` - IsPinned bool `gorm:"not null;default:false" json:"is_pinned"` - IsImportant bool `gorm:"not null;default:false" json:"is_important"` - ViewCount int `gorm:"not null;default:0" json:"view_count"` - Status string `gorm:"size:32;not null;default:'draft'" json:"status"` - PublishedAt *time.Time `json:"published_at"` - CreatedBy *uint64 `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + // DistributionSiteID 公告归属站点:0=主站,>0=对应分站(分站公告中心按站点隔离)。 + DistributionSiteID uint64 `gorm:"not null;default:0;index" json:"distribution_site_id"` + Title string `gorm:"size:255;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Category string `gorm:"size:32;not null;default:'notice'" json:"category"` + Priority int `gorm:"not null;default:0" json:"priority"` + IsPinned bool `gorm:"not null;default:false" json:"is_pinned"` + IsImportant bool `gorm:"not null;default:false" json:"is_important"` + ViewCount int `gorm:"not null;default:0" json:"view_count"` + Status string `gorm:"size:32;not null;default:'draft'" json:"status"` + PublishedAt *time.Time `json:"published_at"` + CreatedBy *uint64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Announcement) TableName() string { diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index 6593b0c..88149b4 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -18,7 +18,9 @@ type DistributionSite struct { AWRecycleJSON string `gorm:"type:text" json:"aw_recycle_json"` CooperationFeedbackJSON string `gorm:"type:text" json:"cooperation_feedback_json"` // StorefrontTextJSON 首页文案(tagline/hero_badge/hero_desc/card_sub),空字段继承前台默认。 - StorefrontTextJSON string `gorm:"type:text" json:"storefront_text_json"` + StorefrontTextJSON string `gorm:"type:text" json:"storefront_text_json"` + // AnnouncementMode 公告中心来源:inherit=主站公告+本站公告(默认),own_only=仅本站公告。 + AnnouncementMode string `gorm:"size:16;not null;default:'inherit'" json:"announcement_mode"` DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"` SMSConfigJSON string `gorm:"type:text" json:"-"` diff --git a/backend/internal/modules/announcement/dto.go b/backend/internal/modules/announcement/dto.go index 3a8cfff..f7b4531 100644 --- a/backend/internal/modules/announcement/dto.go +++ b/backend/internal/modules/announcement/dto.go @@ -3,18 +3,20 @@ package announcement import "time" type AnnouncementDTO struct { - ID uint64 `json:"id"` - Title string `json:"title"` - Content string `json:"content"` - Category string `json:"category"` - Priority int `json:"priority"` - IsPinned bool `json:"is_pinned"` - IsImportant bool `json:"is_important"` - ViewCount int `json:"view_count"` - Status string `json:"status"` - PublishedAt *time.Time `json:"published_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + DistributionSiteID uint64 `json:"distribution_site_id"` + DistributionSiteName string `json:"distribution_site_name,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + Category string `json:"category"` + Priority int `json:"priority"` + IsPinned bool `json:"is_pinned"` + IsImportant bool `json:"is_important"` + ViewCount int `json:"view_count"` + Status string `json:"status"` + PublishedAt *time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type AnnouncementListQuery struct { @@ -22,6 +24,10 @@ type AnnouncementListQuery struct { Status string Page int PageSize int + // AllowedSiteIDs 前台可见范围:主站=[0];分站 inherit=[0, siteID];分站 own_only=[siteID]。 + AllowedSiteIDs []uint64 + // AdminSiteID 管理端按站点筛选:nil=全部,0=仅主站,>0=指定分站。 + AdminSiteID *uint64 } type PaginatedResult struct { @@ -32,12 +38,14 @@ type PaginatedResult struct { } type CreateAnnouncementRequest struct { - Title string `json:"title" binding:"required,max=255"` - Content string `json:"content" binding:"required"` - Category string `json:"category" binding:"required,oneof=notice tutorial rule faq"` - Priority int `json:"priority"` - IsPinned bool `json:"is_pinned"` - IsImportant bool `json:"is_important"` + // DistributionSiteID 归属站点,0=主站(平台后台可指定分站代发)。 + DistributionSiteID uint64 `json:"distribution_site_id"` + Title string `json:"title" binding:"required,max=255"` + Content string `json:"content" binding:"required"` + Category string `json:"category" binding:"required,oneof=notice tutorial rule faq"` + Priority int `json:"priority"` + IsPinned bool `json:"is_pinned"` + IsImportant bool `json:"is_important"` } type UpdateAnnouncementRequest struct { diff --git a/backend/internal/modules/announcement/handler.go b/backend/internal/modules/announcement/handler.go index f349c44..d3223f1 100644 --- a/backend/internal/modules/announcement/handler.go +++ b/backend/internal/modules/announcement/handler.go @@ -3,8 +3,10 @@ package announcement import ( "errors" "strconv" + "strings" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -19,7 +21,7 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// List 获取公告列表(前台用户) +// List 获取公告列表(前台用户,按当前站点隔离) func (h *Handler) List(c *gin.Context) { query := AnnouncementListQuery{ Category: c.Query("category"), @@ -27,6 +29,14 @@ func (h *Handler) List(c *gin.Context) { } query.Page, query.PageSize = parsePagination(c) + siteIDs, err := h.service.ResolvePublicSiteScope(c.Request.Context(), sitecode.FromRequest(c)) + if err != nil { + response.RecordError(c, err) + response.InternalServerError(c, "获取公告列表失败") + return + } + query.AllowedSiteIDs = siteIDs + result, err := h.service.List(c.Request.Context(), query) if err != nil { response.RecordError(c, err) @@ -37,7 +47,7 @@ func (h *Handler) List(c *gin.Context) { response.OK(c, result) } -// GetByID 获取公告详情 +// GetByID 获取公告详情(限当前站点可见范围) func (h *Handler) GetByID(c *gin.Context) { id, err := parseID(c) if err != nil { @@ -45,7 +55,13 @@ func (h *Handler) GetByID(c *gin.Context) { return } - announcement, err := h.service.GetByID(c.Request.Context(), id) + siteIDs, err := h.service.ResolvePublicSiteScope(c.Request.Context(), sitecode.FromRequest(c)) + if err != nil { + response.InternalServerError(c, "获取公告失败") + return + } + + announcement, err := h.service.GetByID(c.Request.Context(), id, siteIDs) if err != nil { if errors.Is(err, ErrNotFound) { response.NotFound(c, "公告不存在") @@ -58,13 +74,21 @@ func (h *Handler) GetByID(c *gin.Context) { response.OK(c, announcement) } -// AdminList 管理员获取公告列表 +// AdminList 管理员获取公告列表(site_id 可选:不传=全部,0=主站,>0=指定分站) func (h *Handler) AdminList(c *gin.Context) { query := AnnouncementListQuery{ Category: c.Query("category"), Status: c.Query("status"), } query.Page, query.PageSize = parsePagination(c) + if raw, ok := c.GetQuery("site_id"); ok { + siteID, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64) + if err != nil { + response.BadRequest(c, "站点ID不正确") + return + } + query.AdminSiteID = &siteID + } result, err := h.service.AdminList(c.Request.Context(), query) if err != nil { diff --git a/backend/internal/modules/announcement/repository.go b/backend/internal/modules/announcement/repository.go index 75a11c5..52c618b 100644 --- a/backend/internal/modules/announcement/repository.go +++ b/backend/internal/modules/announcement/repository.go @@ -2,6 +2,8 @@ package announcement import ( "context" + "errors" + "strings" "time" "hfb_sys/backend/internal/model" @@ -17,11 +19,41 @@ func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } +// ResolvePublicSiteScope 解析前台请求应看到的公告归属站点集合。 +// +// - 主站(无站点编码):只看主站公告 [0] +// - 分站 inherit:主站公告 + 本站公告 [0, siteID] +// - 分站 own_only:仅本站公告 [siteID] +// - 站点编码无效/已停用:回落主站,与前台站点探测失败的兜底一致 +func (r *Repository) ResolvePublicSiteScope(ctx context.Context, siteCode string) ([]uint64, error) { + code := strings.TrimSpace(siteCode) + if code == "" { + return []uint64{0}, nil + } + var site model.DistributionSite + err := r.db.WithContext(ctx).Select("id", "announcement_mode"). + Where("site_code = ? AND status = ?", code, "active").First(&site).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return []uint64{0}, nil + } + return nil, err + } + if strings.TrimSpace(site.AnnouncementMode) == model.AnnouncementModeOwnOnly { + return []uint64{site.ID}, nil + } + return []uint64{0, site.ID}, nil +} + // List 获取公告列表(前台用户) func (r *Repository) List(ctx context.Context, query AnnouncementListQuery) (*PaginatedResult, error) { var total int64 tx := r.db.WithContext(ctx).Model(&model.Announcement{}).Where("status = ?", "published") + if len(query.AllowedSiteIDs) > 0 { + tx = tx.Where("distribution_site_id IN ?", query.AllowedSiteIDs) + } + if query.Category != "" { tx = tx.Where("category = ?", query.Category) } @@ -52,11 +84,15 @@ func (r *Repository) List(ctx context.Context, query AnnouncementListQuery) (*Pa }, nil } -// GetByID 获取公告详情 -func (r *Repository) GetByID(ctx context.Context, id uint64) (*AnnouncementDTO, error) { +// GetByID 获取公告详情(限前台可见的站点范围,避免跨站按 ID 读取) +func (r *Repository) GetByID(ctx context.Context, id uint64, allowedSiteIDs []uint64) (*AnnouncementDTO, error) { var announcement model.Announcement db := r.db.WithContext(ctx) - if err := db.Where("id = ? AND status = ?", id, "published").First(&announcement).Error; err != nil { + query := db.Where("id = ? AND status = ?", id, "published") + if len(allowedSiteIDs) > 0 { + query = query.Where("distribution_site_id IN ?", allowedSiteIDs) + } + if err := query.First(&announcement).Error; err != nil { return nil, err } @@ -67,34 +103,26 @@ func (r *Repository) GetByID(ctx context.Context, id uint64) (*AnnouncementDTO, return &dto, nil } -// AdminList 管理员获取公告列表 +// AdminList 管理员获取公告列表(可按站点筛选,含草稿/归档) func (r *Repository) AdminList(ctx context.Context, query AnnouncementListQuery) (*PaginatedResult, error) { + base := r.adminListBase(ctx, query) + var total int64 - tx := r.db.WithContext(ctx).Model(&model.Announcement{}) - - if query.Status != "" { - tx = tx.Where("status = ?", query.Status) - } - if query.Category != "" { - tx = tx.Where("category = ?", query.Category) - } - - if err := tx.Count(&total).Error; err != nil { + if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { return nil, err } offset := (query.Page - 1) * query.PageSize - var rows []model.Announcement - if err := tx.Order("is_pinned DESC, priority DESC, id DESC"). + var items []AnnouncementDTO + if err := base.Session(&gorm.Session{}). + Order("a.is_pinned DESC, a.priority DESC, a.id DESC"). Offset(offset). Limit(query.PageSize). - Find(&rows).Error; err != nil { + Scan(&items).Error; err != nil { return nil, err } - - items := make([]AnnouncementDTO, 0, len(rows)) - for _, row := range rows { - items = append(items, toAnnouncementDTO(row)) + if items == nil { + items = []AnnouncementDTO{} } return &PaginatedResult{ @@ -105,28 +133,50 @@ func (r *Repository) AdminList(ctx context.Context, query AnnouncementListQuery) }, nil } -// AdminGetByID 管理员获取公告详情 +// adminListBase 管理端公告列表基础查询:带站点名,支持状态/分类/站点筛选。 +func (r *Repository) adminListBase(ctx context.Context, query AnnouncementListQuery) *gorm.DB { + base := r.db.WithContext(ctx).Table("announcements AS a"). + Select("a.id, a.distribution_site_id, COALESCE(s.site_name, '') AS distribution_site_name, a.title, a.content, a.category, a.priority, a.is_pinned, a.is_important, a.view_count, a.status, a.published_at, a.created_at, a.updated_at"). + Joins("LEFT JOIN distribution_sites AS s ON s.id = a.distribution_site_id") + + if query.Status != "" { + base = base.Where("a.status = ?", query.Status) + } + if query.Category != "" { + base = base.Where("a.category = ?", query.Category) + } + if query.AdminSiteID != nil { + base = base.Where("a.distribution_site_id = ?", *query.AdminSiteID) + } + return base +} + +// AdminGetByID 管理员获取公告详情(带站点名) func (r *Repository) AdminGetByID(ctx context.Context, id uint64) (*AnnouncementDTO, error) { - var announcement model.Announcement - if err := r.db.WithContext(ctx).Where("id = ?", id).First(&announcement).Error; err != nil { + var dto AnnouncementDTO + if err := r.adminListBase(ctx, AnnouncementListQuery{}). + Where("a.id = ?", id). + Scan(&dto).Error; err != nil { return nil, err } - - dto := toAnnouncementDTO(announcement) + if dto.ID == 0 { + return nil, gorm.ErrRecordNotFound + } return &dto, nil } // Create 创建公告 func (r *Repository) Create(ctx context.Context, req CreateAnnouncementRequest, createdBy uint64) (*AnnouncementDTO, error) { announcement := model.Announcement{ - Title: req.Title, - Content: req.Content, - Category: req.Category, - Priority: req.Priority, - IsPinned: req.IsPinned, - IsImportant: req.IsImportant, - Status: "draft", - CreatedBy: &createdBy, + DistributionSiteID: req.DistributionSiteID, + Title: req.Title, + Content: req.Content, + Category: req.Category, + Priority: req.Priority, + IsPinned: req.IsPinned, + IsImportant: req.IsImportant, + Status: "draft", + CreatedBy: &createdBy, } if err := r.db.WithContext(ctx).Create(&announcement).Error; err != nil { @@ -197,17 +247,18 @@ func (r *Repository) Delete(ctx context.Context, id uint64) error { func toAnnouncementDTO(a model.Announcement) AnnouncementDTO { return AnnouncementDTO{ - ID: a.ID, - Title: a.Title, - Content: a.Content, - Category: a.Category, - Priority: a.Priority, - IsPinned: a.IsPinned, - IsImportant: a.IsImportant, - ViewCount: a.ViewCount, - Status: a.Status, - PublishedAt: a.PublishedAt, - CreatedAt: a.CreatedAt, - UpdatedAt: a.UpdatedAt, + ID: a.ID, + DistributionSiteID: a.DistributionSiteID, + Title: a.Title, + Content: a.Content, + Category: a.Category, + Priority: a.Priority, + IsPinned: a.IsPinned, + IsImportant: a.IsImportant, + ViewCount: a.ViewCount, + Status: a.Status, + PublishedAt: a.PublishedAt, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, } } diff --git a/backend/internal/modules/announcement/repository_test.go b/backend/internal/modules/announcement/repository_test.go new file mode 100644 index 0000000..8c3edea --- /dev/null +++ b/backend/internal/modules/announcement/repository_test.go @@ -0,0 +1,176 @@ +package announcement + +import ( + "context" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" +) + +// TestPublicScopeAndSiteIsolation 覆盖公告中心的分站可见范围: +// 主站只看主站、分站继承=主站+本站、分站仅本站=只有本站,详情也不能跨站读取。 +func TestPublicScopeAndSiteIsolation(t *testing.T) { + db := database.NewTestDB() + repo := NewRepository(db) + ctx := context.Background() + + mainAnnouncement := model.Announcement{Title: "主站公告", Content: "主站内容", Category: "notice", Status: "published"} + if err := db.Create(&mainAnnouncement).Error; err != nil { + t.Fatal(err) + } + + inheritSite := model.DistributionSite{ + SiteCode: "ann_inherit", SiteName: "继承站", Domain: "ann-inherit.example.com", + Status: "active", AnnouncementMode: model.AnnouncementModeInherit, + } + ownOnlySite := model.DistributionSite{ + SiteCode: "ann_own_only", SiteName: "独立站", Domain: "ann-own.example.com", + Status: "active", AnnouncementMode: model.AnnouncementModeOwnOnly, + } + if err := db.Create(&inheritSite).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&ownOnlySite).Error; err != nil { + t.Fatal(err) + } + + inheritOwn := model.Announcement{ + DistributionSiteID: inheritSite.ID, Title: "继承站公告", Content: "本站内容", + Category: "notice", Status: "published", + } + ownOnlyOwn := model.Announcement{ + DistributionSiteID: ownOnlySite.ID, Title: "独立站公告", Content: "本站内容", + Category: "notice", Status: "published", + } + if err := db.Create(&inheritOwn).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&ownOnlyOwn).Error; err != nil { + t.Fatal(err) + } + + // 主站:仅 [0] + mainScope, err := repo.ResolvePublicSiteScope(ctx, "") + if err != nil { + t.Fatal(err) + } + if len(mainScope) != 1 || mainScope[0] != 0 { + t.Fatalf("main scope = %v, want [0]", mainScope) + } + mainList, err := repo.List(ctx, AnnouncementListQuery{AllowedSiteIDs: mainScope, Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if mainList.Total != 1 { + t.Fatalf("主站应只看到 1 条主站公告, got %d", mainList.Total) + } + + // 分站 inherit:[0, siteID] → 主站公告 + 本站公告 + inheritScope, err := repo.ResolvePublicSiteScope(ctx, inheritSite.SiteCode) + if err != nil { + t.Fatal(err) + } + if len(inheritScope) != 2 { + t.Fatalf("inherit scope = %v, want [0, siteID]", inheritScope) + } + inheritList, err := repo.List(ctx, AnnouncementListQuery{AllowedSiteIDs: inheritScope, Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if inheritList.Total != 2 { + t.Fatalf("继承站应看到主站+本站共 2 条, got %d", inheritList.Total) + } + + // 分站 own_only:只有本站公告(不含主站公告) + ownScope, err := repo.ResolvePublicSiteScope(ctx, ownOnlySite.SiteCode) + if err != nil { + t.Fatal(err) + } + if len(ownScope) != 1 || ownScope[0] != ownOnlySite.ID { + t.Fatalf("own_only scope = %v, want [siteID]", ownScope) + } + ownList, err := repo.List(ctx, AnnouncementListQuery{AllowedSiteIDs: ownScope, Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if ownList.Total != 1 { + t.Fatalf("仅本站站点应只看到本站 1 条, got %d", ownList.Total) + } + + // 无效站点编码回落主站,避免把主站公告错误地当成某站的 + fallbackScope, err := repo.ResolvePublicSiteScope(ctx, "not_exists_site") + if err != nil { + t.Fatal(err) + } + if len(fallbackScope) != 1 || fallbackScope[0] != 0 { + t.Fatalf("未知站点应回落主站, got %v", fallbackScope) + } + + // 详情跨站隔离:主站访客不能按 ID 读分站公告 + if _, err := repo.GetByID(ctx, inheritOwn.ID, []uint64{0}); err == nil { + t.Fatalf("主站不应能读取分站公告详情") + } + if _, err := repo.GetByID(ctx, inheritOwn.ID, inheritScope); err != nil { + t.Fatalf("继承站应能读取本站公告详情: %v", err) + } + if _, err := repo.GetByID(ctx, ownOnlyOwn.ID, inheritScope); err == nil { + t.Fatalf("继承站不应能读取其它分站公告详情") + } +} + +// TestAdminListFiltersBySite 管理端可按站点筛选,并带出站点名。 +func TestAdminListFiltersBySite(t *testing.T) { + db := database.NewTestDB() + repo := NewRepository(db) + ctx := context.Background() + + site := model.DistributionSite{ + SiteCode: "ann_admin", SiteName: "筛选站", Domain: "ann-admin.example.com", + Status: "active", AnnouncementMode: model.AnnouncementModeInherit, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Announcement{Title: "主站公告", Content: "x", Category: "notice", Status: "draft"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Announcement{ + DistributionSiteID: site.ID, Title: "分站公告", Content: "y", Category: "notice", Status: "draft", + }).Error; err != nil { + t.Fatal(err) + } + + all, err := repo.AdminList(ctx, AnnouncementListQuery{Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if all.Total != 2 { + t.Fatalf("不传站点应返回全部 2 条, got %d", all.Total) + } + + mainOnly := uint64(0) + mainList, err := repo.AdminList(ctx, AnnouncementListQuery{AdminSiteID: &mainOnly, Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if mainList.Total != 1 { + t.Fatalf("site_id=0 应只返回主站公告, got %d", mainList.Total) + } + + siteID := site.ID + siteList, err := repo.AdminList(ctx, AnnouncementListQuery{AdminSiteID: &siteID, Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if siteList.Total != 1 { + t.Fatalf("按站点筛选应只返回该站公告, got %d", siteList.Total) + } + items, ok := siteList.Items.([]AnnouncementDTO) + if !ok || len(items) != 1 { + t.Fatalf("unexpected items: %#v", siteList.Items) + } + if items[0].DistributionSiteID != site.ID || items[0].DistributionSiteName != "筛选站" { + t.Fatalf("站点信息未透出: %+v", items[0]) + } +} diff --git a/backend/internal/modules/announcement/service.go b/backend/internal/modules/announcement/service.go index 2b381f1..0d5e97a 100644 --- a/backend/internal/modules/announcement/service.go +++ b/backend/internal/modules/announcement/service.go @@ -21,6 +21,11 @@ var ( ErrUnauthorized = errors.New("未授权") ) +// ResolvePublicSiteScope 解析前台请求应看到的公告归属站点集合(站点编码由请求头解析)。 +func (s *Service) ResolvePublicSiteScope(ctx context.Context, siteCode string) ([]uint64, error) { + return s.repo.ResolvePublicSiteScope(ctx, siteCode) +} + // List 获取公告列表(前台用户) func (s *Service) List(ctx context.Context, query AnnouncementListQuery) (*PaginatedResult, error) { if query.Page < 1 { @@ -36,9 +41,9 @@ func (s *Service) List(ctx context.Context, query AnnouncementListQuery) (*Pagin return s.repo.List(ctx, query) } -// GetByID 获取公告详情 -func (s *Service) GetByID(ctx context.Context, id uint64) (*AnnouncementDTO, error) { - dto, err := s.repo.GetByID(ctx, id) +// GetByID 获取公告详情(限前台可见的站点范围) +func (s *Service) GetByID(ctx context.Context, id uint64, allowedSiteIDs []uint64) (*AnnouncementDTO, error) { + dto, err := s.repo.GetByID(ctx, id, allowedSiteIDs) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound diff --git a/backend/internal/modules/distribution/announcement.go b/backend/internal/modules/distribution/announcement.go new file mode 100644 index 0000000..86c1644 --- /dev/null +++ b/backend/internal/modules/distribution/announcement.go @@ -0,0 +1,235 @@ +package distribution + +import ( + "context" + "errors" + "strings" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +// 分站公告中心:站长在分销后台自助维护本站公告。 +// +// 站点隔离口径:所有写操作与列表查询都强制 distribution_site_id = siteID, +// 且先通过 ensureSiteAccess 校验当前管理员对该站点的访问权,避免跨站读写。 +// 前台展示范围由站点 announcement_mode 决定(见 announcement 模块 ResolvePublicSiteScope)。 + +// normalizeAnnouncementMode 校验公告中心来源模式,空值按默认 inherit 处理。 +func normalizeAnnouncementMode(value string) (string, error) { + switch strings.TrimSpace(value) { + case "", model.AnnouncementModeInherit: + return model.AnnouncementModeInherit, nil + case model.AnnouncementModeOwnOnly: + return model.AnnouncementModeOwnOnly, nil + default: + return "", ErrInvalidAnnouncementMode + } +} + +// ListSiteAnnouncements 分页查询本站公告(含草稿/归档,供站长管理)。 +func (r *Repository) ListSiteAnnouncements(ctx context.Context, adminID, siteID uint64, status string, page, pageSize int) (*SiteAnnouncementPage, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + base := r.db.WithContext(ctx).Model(&model.Announcement{}).Where("distribution_site_id = ?", siteID) + if s := strings.TrimSpace(status); s != "" { + base = base.Where("status = ?", s) + } + + var total int64 + if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.Announcement + if err := base.Session(&gorm.Session{}). + Order("is_pinned DESC, priority DESC, id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&rows).Error; err != nil { + return nil, err + } + + items := make([]SiteAnnouncementDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toSiteAnnouncementDTO(row)) + } + return &SiteAnnouncementPage{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +// CreateSiteAnnouncement 在指定站点下新建公告(草稿)。 +func (r *Repository) CreateSiteAnnouncement(ctx context.Context, adminID, siteID uint64, req CreateSiteAnnouncementRequest) (*SiteAnnouncementDTO, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + createdBy := adminID + row := model.Announcement{ + DistributionSiteID: siteID, + Title: strings.TrimSpace(req.Title), + Content: req.Content, + Category: strings.TrimSpace(req.Category), + Priority: req.Priority, + IsPinned: req.IsPinned, + IsImportant: req.IsImportant, + Status: "draft", + CreatedBy: &createdBy, + } + if row.Category == "" { + row.Category = "notice" + } + if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { + return nil, err + } + dto := toSiteAnnouncementDTO(row) + return &dto, nil +} + +// UpdateSiteAnnouncement 更新本站公告内容。 +func (r *Repository) UpdateSiteAnnouncement(ctx context.Context, adminID, siteID, id uint64, req UpdateSiteAnnouncementRequest) (*SiteAnnouncementDTO, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + row, err := r.findSiteAnnouncement(ctx, siteID, id) + if err != nil { + return nil, err + } + + updates := map[string]any{ + "priority": req.Priority, + "is_pinned": req.IsPinned, + "is_important": req.IsImportant, + } + if title := strings.TrimSpace(req.Title); title != "" { + updates["title"] = title + } + if req.Content != "" { + updates["content"] = req.Content + } + if category := strings.TrimSpace(req.Category); category != "" { + updates["category"] = category + } + if err := r.db.WithContext(ctx).Model(&model.Announcement{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil { + return nil, err + } + updated, err := r.findSiteAnnouncement(ctx, siteID, id) + if err != nil { + return nil, err + } + dto := toSiteAnnouncementDTO(*updated) + return &dto, nil +} + +// UpdateSiteAnnouncementStatus 站点公告上下架:publish=发布,archive=归档,unarchive=取消归档。 +func (r *Repository) UpdateSiteAnnouncementStatus(ctx context.Context, adminID, siteID, id uint64, action string) (*SiteAnnouncementDTO, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + row, err := r.findSiteAnnouncement(ctx, siteID, id) + if err != nil { + return nil, err + } + + now := time.Now() + updates := map[string]any{} + switch strings.TrimSpace(action) { + case "publish": + updates["status"] = "published" + updates["published_at"] = now + case "archive": + updates["status"] = "archived" + case "unarchive": + updates["status"] = "published" + updates["published_at"] = gorm.Expr("COALESCE(published_at, ?)", now) + default: + return nil, ErrInvalidListingStatus + } + if err := r.db.WithContext(ctx).Model(&model.Announcement{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil { + return nil, err + } + updated, err := r.findSiteAnnouncement(ctx, siteID, id) + if err != nil { + return nil, err + } + dto := toSiteAnnouncementDTO(*updated) + return &dto, nil +} + +// DeleteSiteAnnouncement 删除本站公告。 +func (r *Repository) DeleteSiteAnnouncement(ctx context.Context, adminID, siteID, id uint64) error { + if siteID == 0 { + return ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return err + } + row, err := r.findSiteAnnouncement(ctx, siteID, id) + if err != nil { + return err + } + return r.db.WithContext(ctx).Where("id = ?", row.ID).Delete(&model.Announcement{}).Error +} + +// findSiteAnnouncement 按站点归属读取公告,跨站 ID 一律视为不存在。 +func (r *Repository) findSiteAnnouncement(ctx context.Context, siteID, id uint64) (*model.Announcement, error) { + var row model.Announcement + err := r.db.WithContext(ctx).Where("id = ? AND distribution_site_id = ?", id, siteID).First(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAnnouncementNotFound + } + return nil, err + } + return &row, nil +} + +func toSiteAnnouncementDTO(a model.Announcement) SiteAnnouncementDTO { + return SiteAnnouncementDTO{ + ID: a.ID, + DistributionSiteID: a.DistributionSiteID, + Title: a.Title, + Content: a.Content, + Category: a.Category, + Priority: a.Priority, + IsPinned: a.IsPinned, + IsImportant: a.IsImportant, + ViewCount: a.ViewCount, + Status: a.Status, + PublishedAt: a.PublishedAt, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + } +} + +// normalizeAnnouncementModeOrInherit 读侧容错:非法/历史空值一律按默认继承口径返回。 +func normalizeAnnouncementModeOrInherit(value string) string { + mode, err := normalizeAnnouncementMode(value) + if err != nil { + return model.AnnouncementModeInherit + } + return mode +} diff --git a/backend/internal/modules/distribution/announcement_test.go b/backend/internal/modules/distribution/announcement_test.go new file mode 100644 index 0000000..fbbe532 --- /dev/null +++ b/backend/internal/modules/distribution/announcement_test.go @@ -0,0 +1,142 @@ +package distribution + +import ( + "context" + "errors" + "testing" + + "hfb_sys/backend/internal/model" +) + +// TestSiteAnnouncementCRUDIsSiteScoped 验证站长自助公告只能读写本站数据: +// 列表/更新/删除/上下架都强制 distribution_site_id = 本站,跨站 ID 视为不存在。 +func TestSiteAnnouncementCRUDIsSiteScoped(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + ctx := context.Background() + + siteA, err := repo.CreateSite(ctx, CreateSiteRequest{ + SiteCode: "ann_site_a", SiteName: "公告站A", Domain: "ann-a.hfb.local", + }) + if err != nil { + t.Fatal(err) + } + siteB, err := repo.CreateSite(ctx, CreateSiteRequest{ + SiteCode: "ann_site_b", SiteName: "公告站B", Domain: "ann-b.hfb.local", + }) + if err != nil { + t.Fatal(err) + } + + created, err := repo.CreateSiteAnnouncement(ctx, 0, siteA.ID, CreateSiteAnnouncementRequest{ + Title: "本站公告", Content: "内容", Category: "notice", + }) + if err != nil { + t.Fatalf("CreateSiteAnnouncement failed: %v", err) + } + if created.DistributionSiteID != siteA.ID || created.Status != "draft" { + t.Fatalf("unexpected created announcement: %+v", created) + } + + // 列表按站点隔离 + pageA, err := repo.ListSiteAnnouncements(ctx, 0, siteA.ID, "", 1, 20) + if err != nil { + t.Fatal(err) + } + if pageA.Total != 1 { + t.Fatalf("A 站应有 1 条公告, got %d", pageA.Total) + } + pageB, err := repo.ListSiteAnnouncements(ctx, 0, siteB.ID, "", 1, 20) + if err != nil { + t.Fatal(err) + } + if pageB.Total != 0 { + t.Fatalf("B 站不应看到 A 站公告, got %d", pageB.Total) + } + + // 跨站更新/删除/上下架一律拒绝 + if _, err := repo.UpdateSiteAnnouncement(ctx, 0, siteB.ID, created.ID, UpdateSiteAnnouncementRequest{Title: "改"}); !errors.Is(err, ErrAnnouncementNotFound) { + t.Fatalf("跨站更新应返回 ErrAnnouncementNotFound, got %v", err) + } + if err := repo.DeleteSiteAnnouncement(ctx, 0, siteB.ID, created.ID); !errors.Is(err, ErrAnnouncementNotFound) { + t.Fatalf("跨站删除应返回 ErrAnnouncementNotFound, got %v", err) + } + if _, err := repo.UpdateSiteAnnouncementStatus(ctx, 0, siteB.ID, created.ID, "publish"); !errors.Is(err, ErrAnnouncementNotFound) { + t.Fatalf("跨站发布应返回 ErrAnnouncementNotFound, got %v", err) + } + + // 本站更新与发布 + updated, err := repo.UpdateSiteAnnouncement(ctx, 0, siteA.ID, created.ID, UpdateSiteAnnouncementRequest{ + Title: "本站公告(改)", Content: "新内容", Category: "rule", Priority: 5, IsPinned: true, + }) + if err != nil { + t.Fatalf("UpdateSiteAnnouncement failed: %v", err) + } + if updated.Title != "本站公告(改)" || updated.Category != "rule" || !updated.IsPinned || updated.Priority != 5 { + t.Fatalf("unexpected updated announcement: %+v", updated) + } + published, err := repo.UpdateSiteAnnouncementStatus(ctx, 0, siteA.ID, created.ID, "publish") + if err != nil { + t.Fatalf("publish failed: %v", err) + } + if published.Status != "published" || published.PublishedAt == nil { + t.Fatalf("发布后状态/时间不正确: %+v", published) + } + archived, err := repo.UpdateSiteAnnouncementStatus(ctx, 0, siteA.ID, created.ID, "archive") + if err != nil { + t.Fatalf("archive failed: %v", err) + } + if archived.Status != "archived" { + t.Fatalf("归档后状态不正确: %+v", archived) + } + if _, err := repo.ListSiteAnnouncements(ctx, 0, siteA.ID, "archived", 1, 20); err != nil { + t.Fatalf("按状态查询失败: %v", err) + } + if err := repo.DeleteSiteAnnouncement(ctx, 0, siteA.ID, created.ID); err != nil { + t.Fatalf("本站删除失败: %v", err) + } +} + +// TestUpdateSiteBrandingAnnouncementMode 验证公告来源开关可保存并校验非法值。 +func TestUpdateSiteBrandingAnnouncementMode(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + ctx := context.Background() + + site, err := repo.CreateSite(ctx, CreateSiteRequest{ + SiteCode: "ann_mode_site", SiteName: "模式站", Domain: "ann-mode.hfb.local", + }) + if err != nil { + t.Fatal(err) + } + if site.AnnouncementMode != model.AnnouncementModeInherit { + t.Fatalf("默认公告来源应为 inherit, got %q", site.AnnouncementMode) + } + + ownOnly := model.AnnouncementModeOwnOnly + updated, err := repo.UpdateSiteBranding(ctx, 0, site.ID, UpdateSiteBrandingRequest{AnnouncementMode: &ownOnly}) + if err != nil { + t.Fatalf("切换公告来源失败: %v", err) + } + if updated.AnnouncementMode != model.AnnouncementModeOwnOnly { + t.Fatalf("announcement_mode = %q, want own_only", updated.AnnouncementMode) + } + + invalid := "whatever" + if _, err := repo.UpdateSiteBranding(ctx, 0, site.ID, UpdateSiteBrandingRequest{AnnouncementMode: &invalid}); !errors.Is(err, ErrInvalidAnnouncementMode) { + t.Fatalf("非法来源应返回 ErrInvalidAnnouncementMode, got %v", err) + } + + // 读侧容错:库里历史非法值按 inherit 返回 + if err := db.Model(&model.DistributionSite{}).Where("id = ?", site.ID). + Update("announcement_mode", "legacy_value").Error; err != nil { + t.Fatal(err) + } + reloaded, err := repo.FindSite(ctx, 0, site.ID) + if err != nil { + t.Fatal(err) + } + if reloaded.AnnouncementMode != model.AnnouncementModeInherit { + t.Fatalf("非法历史值应回落 inherit, got %q", reloaded.AnnouncementMode) + } +} diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 87756af..ab7c6ab 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -17,6 +17,7 @@ type SiteDTO struct { AWRecycleJSON string `json:"aw_recycle_json"` CooperationFeedbackJSON string `json:"cooperation_feedback_json"` StorefrontTextJSON string `json:"storefront_text_json"` + AnnouncementMode string `json:"announcement_mode"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` ListingQrcodeURL string `json:"listing_qrcode_url"` ReceiveRatioDelta float64 `json:"receive_ratio_delta"` @@ -431,7 +432,51 @@ type UpdateSiteBrandingRequest struct { AWRecycleJSON *string `json:"aw_recycle_json"` CooperationFeedbackJSON *string `json:"cooperation_feedback_json"` StorefrontTextJSON *string `json:"storefront_text_json"` - ListingQrcodeURL *string `json:"listing_qrcode_url"` + // AnnouncementMode 公告中心来源:inherit(主站+本站)/ own_only(仅本站)。 + AnnouncementMode *string `json:"announcement_mode"` + ListingQrcodeURL *string `json:"listing_qrcode_url"` +} + +// SiteAnnouncementDTO 分站公告(站长自助维护)。 +type SiteAnnouncementDTO struct { + ID uint64 `json:"id"` + DistributionSiteID uint64 `json:"distribution_site_id"` + Title string `json:"title"` + Content string `json:"content"` + Category string `json:"category"` + Priority int `json:"priority"` + IsPinned bool `json:"is_pinned"` + IsImportant bool `json:"is_important"` + ViewCount int `json:"view_count"` + Status string `json:"status"` + PublishedAt *time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SiteAnnouncementPage struct { + Items []SiteAnnouncementDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type CreateSiteAnnouncementRequest struct { + Title string `json:"title" binding:"required,max=255"` + Content string `json:"content" binding:"required"` + Category string `json:"category" binding:"omitempty,oneof=notice tutorial rule faq"` + Priority int `json:"priority"` + IsPinned bool `json:"is_pinned"` + IsImportant bool `json:"is_important"` +} + +type UpdateSiteAnnouncementRequest struct { + Title string `json:"title" binding:"max=255"` + Content string `json:"content"` + Category string `json:"category" binding:"omitempty,oneof=notice tutorial rule faq"` + Priority int `json:"priority"` + IsPinned bool `json:"is_pinned"` + IsImportant bool `json:"is_important"` } // 自主加价 DTO:增加该站「1 元兑换哈夫币」比例,不是人民币。 diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 54fd1b2..cba137b 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -642,6 +642,131 @@ func (h *Handler) resolveSiteID(c *gin.Context) (uint64, error) { return h.service.ScopeSiteID(c.Request.Context(), adminID, requested) } +// 分站公告中心(站长自助维护本站公告)。站点由 ?site_id= 解析并校验访问权。 +func (h *Handler) ListSiteAnnouncements(c *gin.Context) { + siteID, adminID, ok := h.resolveAnnouncementSite(c) + if !ok { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + resp, err := h.service.ListSiteAnnouncements(c.Request.Context(), adminID, siteID, c.Query("status"), page, pageSize) + if err != nil { + writeError(c, err) + return + } + response.OK(c, resp) +} + +func (h *Handler) CreateSiteAnnouncement(c *gin.Context) { + siteID, adminID, ok := h.resolveAnnouncementSite(c) + if !ok { + return + } + var req CreateSiteAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "公告参数格式不正确") + return + } + dto, err := h.service.CreateSiteAnnouncement(c.Request.Context(), adminID, siteID, req) + if err != nil { + writeError(c, err) + return + } + response.Created(c, dto) +} + +func (h *Handler) UpdateSiteAnnouncement(c *gin.Context) { + siteID, adminID, ok := h.resolveAnnouncementSite(c) + if !ok { + return + } + id, ok := parseSiteAnnouncementID(c) + if !ok { + return + } + var req UpdateSiteAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "公告参数格式不正确") + return + } + dto, err := h.service.UpdateSiteAnnouncement(c.Request.Context(), adminID, siteID, id, req) + if err != nil { + writeError(c, err) + return + } + response.OK(c, dto) +} + +func (h *Handler) PublishSiteAnnouncement(c *gin.Context) { + h.updateSiteAnnouncementStatus(c, "publish") +} +func (h *Handler) ArchiveSiteAnnouncement(c *gin.Context) { + h.updateSiteAnnouncementStatus(c, "archive") +} +func (h *Handler) UnarchiveSiteAnnouncement(c *gin.Context) { + h.updateSiteAnnouncementStatus(c, "unarchive") +} + +func (h *Handler) updateSiteAnnouncementStatus(c *gin.Context, action string) { + siteID, adminID, ok := h.resolveAnnouncementSite(c) + if !ok { + return + } + id, ok := parseSiteAnnouncementID(c) + if !ok { + return + } + dto, err := h.service.UpdateSiteAnnouncementStatus(c.Request.Context(), adminID, siteID, id, action) + if err != nil { + writeError(c, err) + return + } + response.OK(c, dto) +} + +func (h *Handler) DeleteSiteAnnouncement(c *gin.Context) { + siteID, adminID, ok := h.resolveAnnouncementSite(c) + if !ok { + return + } + id, ok := parseSiteAnnouncementID(c) + if !ok { + return + } + if err := h.service.DeleteSiteAnnouncement(c.Request.Context(), adminID, siteID, id); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"deleted": true}) +} + +// resolveAnnouncementSite 解析公告所属站点:公告路由的 :id 是公告 ID, +// 因此站点只能来自 ?site_id=(不能复用会优先读 :id 的 resolveSiteID)。 +func (h *Handler) resolveAnnouncementSite(c *gin.Context) (uint64, uint64, bool) { + adminID, _ := currentAdminID(c) + requested := uint64(parseQueryUint(c, "site_id")) + siteID, err := h.service.ScopeSiteID(c.Request.Context(), adminID, requested) + if err != nil { + writeError(c, err) + return 0, 0, false + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return 0, 0, false + } + return siteID, adminID, true +} + +func parseSiteAnnouncementID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "公告ID不正确") + return 0, false + } + return id, true +} + func (h *Handler) UpdateSiteBranding(c *gin.Context) { siteID, err := h.resolveSiteID(c) if err != nil { diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index c97ffc2..eb869c5 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -52,6 +52,8 @@ var ( ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365") ErrBatchNotCancellable = errors.New("distribution settlement batch cannot be cancelled") ErrSettlementBatchStale = errors.New("distribution settlement batch stale") + ErrAnnouncementNotFound = errors.New("distribution site announcement not found") + ErrInvalidAnnouncementMode = errors.New("invalid announcement mode") ) var settlementReadyOrderStatuses = []string{"completed", "closed"} @@ -888,6 +890,7 @@ func siteDTO(row model.DistributionSite) SiteDTO { AWRecycleJSON: row.AWRecycleJSON, CooperationFeedbackJSON: row.CooperationFeedbackJSON, StorefrontTextJSON: row.StorefrontTextJSON, + AnnouncementMode: normalizeAnnouncementModeOrInherit(row.AnnouncementMode), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } @@ -2750,6 +2753,14 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin site.StorefrontTextJSON = strings.TrimSpace(*req.StorefrontTextJSON) updates["storefront_text_json"] = site.StorefrontTextJSON } + if req.AnnouncementMode != nil { + mode, err := normalizeAnnouncementMode(*req.AnnouncementMode) + if err != nil { + return nil, err + } + site.AnnouncementMode = mode + updates["announcement_mode"] = mode + } if req.ListingQrcodeURL != nil { site.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) updates["listing_qrcode_url"] = site.ListingQrcodeURL diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 5183881..220bb67 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -313,6 +313,41 @@ func (s *Service) BatchUpdateAccountDisplay(ctx context.Context, adminID, siteID return s.repo.BatchUpdateAccountDisplay(ctx, adminID, siteID, req) } +func (s *Service) ListSiteAnnouncements(ctx context.Context, adminID, siteID uint64, status string, page, pageSize int) (*SiteAnnouncementPage, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.ListSiteAnnouncements(ctx, adminID, siteID, status, page, pageSize) +} + +func (s *Service) CreateSiteAnnouncement(ctx context.Context, adminID, siteID uint64, req CreateSiteAnnouncementRequest) (*SiteAnnouncementDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.CreateSiteAnnouncement(ctx, adminID, siteID, req) +} + +func (s *Service) UpdateSiteAnnouncement(ctx context.Context, adminID, siteID, id uint64, req UpdateSiteAnnouncementRequest) (*SiteAnnouncementDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.UpdateSiteAnnouncement(ctx, adminID, siteID, id, req) +} + +func (s *Service) UpdateSiteAnnouncementStatus(ctx context.Context, adminID, siteID, id uint64, action string) (*SiteAnnouncementDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.UpdateSiteAnnouncementStatus(ctx, adminID, siteID, id, action) +} + +func (s *Service) DeleteSiteAnnouncement(ctx context.Context, adminID, siteID, id uint64) error { + if err := s.available(); err != nil { + return err + } + return s.repo.DeleteSiteAnnouncement(ctx, adminID, siteID, id) +} + func (s *Service) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) { if err := s.available(); err != nil { return nil, err diff --git a/backend/internal/modules/distribution/site_crud_test.go b/backend/internal/modules/distribution/site_crud_test.go index 8eedba3..4bc9a8e 100644 --- a/backend/internal/modules/distribution/site_crud_test.go +++ b/backend/internal/modules/distribution/site_crud_test.go @@ -3,8 +3,8 @@ package distribution import ( "testing" - "hfb_sys/backend/internal/model" "golang.org/x/crypto/bcrypt" + "hfb_sys/backend/internal/model" ) func TestSiteCRUD_UpdateSiteAndDomainUniqueness(t *testing.T) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f6d5e2c..2d87ea1 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -641,6 +641,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { distributionProtectedRoutes.GET("/sites/:id/analytics", distributionHandler.GetDashboardAnalytics) distributionProtectedRoutes.GET("/received-listings", distributionHandler.ListReceivedListings) distributionProtectedRoutes.PUT("/received-listings/:id/status", distributionHandler.UpdateReceivedListingStatus) + // 分站公告中心:站长自助维护本站公告(站点由 ?site_id= 解析并校验成员权限) + distributionProtectedRoutes.GET("/announcements", distributionHandler.ListSiteAnnouncements) + distributionProtectedRoutes.POST("/announcements", distributionHandler.CreateSiteAnnouncement) + distributionProtectedRoutes.PUT("/announcements/:id", distributionHandler.UpdateSiteAnnouncement) + distributionProtectedRoutes.POST("/announcements/:id/publish", distributionHandler.PublishSiteAnnouncement) + distributionProtectedRoutes.POST("/announcements/:id/archive", distributionHandler.ArchiveSiteAnnouncement) + distributionProtectedRoutes.POST("/announcements/:id/unarchive", distributionHandler.UnarchiveSiteAnnouncement) + distributionProtectedRoutes.DELETE("/announcements/:id", distributionHandler.DeleteSiteAnnouncement) distributionProtectedRoutes.PUT("/accounts/:id/markup", distributionHandler.UpdateAccountMarkup) distributionProtectedRoutes.POST("/accounts/batch-markup", distributionHandler.BatchUpdateAccountMarkup) distributionProtectedRoutes.POST("/accounts/batch-display", distributionHandler.BatchUpdateAccountDisplay) @@ -674,6 +682,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/distribution/sites/:id/analytics", requirePerm("distribution:site_view"), distributionHandler.GetDashboardAnalytics) adminRoutes.GET("/distribution/received-listings", requirePerm("distribution:account_view"), distributionHandler.ListReceivedListings) adminRoutes.PUT("/distribution/received-listings/:id/status", requirePerm("distribution:account_display"), distributionHandler.UpdateReceivedListingStatus) + adminRoutes.GET("/distribution/announcements", requirePerm("distribution:announcement_view"), distributionHandler.ListSiteAnnouncements) + adminRoutes.POST("/distribution/announcements", requirePerm("distribution:announcement_manage"), distributionHandler.CreateSiteAnnouncement) + adminRoutes.PUT("/distribution/announcements/:id", requirePerm("distribution:announcement_manage"), distributionHandler.UpdateSiteAnnouncement) + adminRoutes.POST("/distribution/announcements/:id/publish", requirePerm("distribution:announcement_manage"), distributionHandler.PublishSiteAnnouncement) + adminRoutes.POST("/distribution/announcements/:id/archive", requirePerm("distribution:announcement_manage"), distributionHandler.ArchiveSiteAnnouncement) + adminRoutes.POST("/distribution/announcements/:id/unarchive", requirePerm("distribution:announcement_manage"), distributionHandler.UnarchiveSiteAnnouncement) + adminRoutes.DELETE("/distribution/announcements/:id", requirePerm("distribution:announcement_manage"), distributionHandler.DeleteSiteAnnouncement) adminRoutes.GET("/distribution/rules", requirePerm("distribution:rule_view"), distributionHandler.ListRules) adminRoutes.POST("/distribution/rules", requirePerm("distribution:rule_manage"), distributionHandler.CreateRule) adminRoutes.PUT("/distribution/rules/:id/status", requirePerm("distribution:rule_manage"), distributionHandler.UpdateRuleStatus) diff --git a/backend/migrations/000077_announcement_distribution_site.sql b/backend/migrations/000077_announcement_distribution_site.sql new file mode 100644 index 0000000..15ce695 --- /dev/null +++ b/backend/migrations/000077_announcement_distribution_site.sql @@ -0,0 +1,37 @@ +-- +goose Up + +-- 公告中心支持分站自定义: +-- 1) announcements.distribution_site_id 标记公告归属站点(0 = 主站),前台按站点隔离; +-- 2) distribution_sites.announcement_mode 决定分站公告中心来源: +-- inherit(默认)= 主站公告 + 本站公告;own_only = 仅本站公告。 +ALTER TABLE announcements + ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '归属站点,0=主站' AFTER id, + ADD KEY idx_announcements_site (distribution_site_id, status, is_pinned, priority, published_at); + +ALTER TABLE distribution_sites + ADD COLUMN announcement_mode VARCHAR(16) NOT NULL DEFAULT 'inherit' COMMENT '公告中心来源:inherit=继承主站+本站,own_only=仅本站' AFTER storefront_text_json; + +-- 站长(ops 角色)自助维护本站公告所需权限;超管与财务同样授予,便于代管。 +INSERT INTO permissions (code, name, resource, action) VALUES +('distribution:announcement_view', '查看分站公告', 'distribution', 'announcement_view'), +('distribution:announcement_manage', '管理分站公告', 'distribution', 'announcement_manage') +ON DUPLICATE KEY UPDATE name = VALUES(name), resource = VALUES(resource), action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code IN ('super_admin', 'ops', 'finance') AND p.code LIKE 'distribution:announcement%'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code LIKE 'distribution:announcement%'; + +DELETE FROM permissions WHERE code LIKE 'distribution:announcement%'; + +ALTER TABLE distribution_sites + DROP COLUMN announcement_mode; + +ALTER TABLE announcements + DROP KEY idx_announcements_site, + DROP COLUMN distribution_site_id; diff --git a/distribution-admin/src/AppShell.tsx b/distribution-admin/src/AppShell.tsx index e35463f..9c09b30 100644 --- a/distribution-admin/src/AppShell.tsx +++ b/distribution-admin/src/AppShell.tsx @@ -12,6 +12,7 @@ import { OrderedListOutlined, SettingOutlined, ShopOutlined, + SoundOutlined, SkinOutlined, UserOutlined, WalletOutlined, @@ -26,6 +27,7 @@ import Accounts from './pages/Accounts' import Settlements from './pages/Settlements' import Orders from './pages/Orders' import Wallet from './pages/Wallet' +import Announcements from './pages/Announcements' import SiteSettings from './pages/SiteSettings' import Login from './pages/Login' import Storefront from './pages/Storefront' @@ -44,6 +46,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () { key: '/sold', icon: , label: 售出账号订单 }, { key: '/receive', icon: , label: 分销收号管理 }, { key: '/accounts', icon: , label: 商品货架与加价 }, + { key: '/announcements', icon: , label: 公告中心 }, { key: '/wallet', icon: , label: 资金与提现 }, { key: '/settlements', @@ -153,6 +156,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () } /> } /> } /> + } /> } /> } /> } /> diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 7e82651..bc9286d 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -130,6 +130,7 @@ export interface Site { aw_recycle_json?: string cooperation_feedback_json?: string storefront_text_json?: string + announcement_mode?: string listing_qrcode_url?: string receive_ratio_delta?: number receive_cost_delta?: number @@ -653,9 +654,97 @@ export interface SiteBrandingPayload { aw_recycle_json?: string cooperation_feedback_json?: string storefront_text_json?: string + /** 公告中心来源:inherit=继承主站+本站,own_only=仅本站 */ + announcement_mode?: string listing_qrcode_url?: string } +// ---------------- 分站公告中心(站长自助维护本站公告) ---------------- + +export interface SiteAnnouncement { + id: number + distribution_site_id: number + title: string + content: string + category: string + priority: number + is_pinned: boolean + is_important: boolean + view_count: number + status: string + published_at: string | null + created_at: string + updated_at: string +} + +export interface SiteAnnouncementPage { + items: SiteAnnouncement[] + total: number + page: number + page_size: number +} + +export interface SiteAnnouncementPayload { + title: string + content: string + category: string + priority: number + is_pinned: boolean + is_important: boolean +} + +export async function listSiteAnnouncements( + siteID: number, + params: { status?: string; page?: number; page_size?: number } = {}, +): Promise { + const resp = await api.get>('/admin/distribution/announcements', { + params: { site_id: siteID, ...params }, + }) + return extract(resp) +} + +export async function createSiteAnnouncement( + siteID: number, + payload: SiteAnnouncementPayload, +): Promise { + const resp = await api.post>( + '/admin/distribution/announcements', + payload, + { params: { site_id: siteID } }, + ) + return extract(resp) +} + +export async function updateSiteAnnouncement( + siteID: number, + id: number, + payload: SiteAnnouncementPayload, +): Promise { + const resp = await api.put>( + `/admin/distribution/announcements/${id}`, + payload, + { params: { site_id: siteID } }, + ) + return extract(resp) +} + +export async function updateSiteAnnouncementStatus( + siteID: number, + id: number, + action: 'publish' | 'archive' | 'unarchive', +): Promise { + const resp = await api.post>( + `/admin/distribution/announcements/${id}/${action}`, + {}, + { params: { site_id: siteID } }, + ) + return extract(resp) +} + +export async function deleteSiteAnnouncement(siteID: number, id: number): Promise { + await api.delete(`/admin/distribution/announcements/${id}`, { params: { site_id: siteID } }) +} + export async function uploadSiteImage(file: File, scene: string): Promise { const form = new FormData() form.append('file', file) diff --git a/distribution-admin/src/pages/Announcements.tsx b/distribution-admin/src/pages/Announcements.tsx new file mode 100644 index 0000000..cc8ffa8 --- /dev/null +++ b/distribution-admin/src/pages/Announcements.tsx @@ -0,0 +1,455 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Alert, + Button, + Card, + Checkbox, + Empty, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Radio, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd' +import { + BellOutlined, + DeleteOutlined, + EditOutlined, + PlusOutlined, + PushpinOutlined, + ReloadOutlined, + WarningOutlined, +} from '@ant-design/icons' +import { + createSiteAnnouncement, + deleteSiteAnnouncement, + updateSiteAnnouncement, + updateSiteAnnouncementStatus, + updateSiteBranding, + listSiteAnnouncements, + listSites, + getMe, + type Site, + type SiteAnnouncement, + type SiteAnnouncementPayload, +} from '../api' +import { useMember } from '../MemberContext' + +const CATEGORY_OPTIONS = [ + { value: 'notice', label: '通知公告' }, + { value: 'tutorial', label: '使用教程' }, + { value: 'rule', label: '规则说明' }, + { value: 'faq', label: '常见问题' }, +] + +const STATUS_OPTIONS = [ + { value: '', label: '全部状态' }, + { value: 'draft', label: '草稿' }, + { value: 'published', label: '已发布' }, + { value: 'archived', label: '已归档' }, +] + +const STATUS_META: Record = { + draft: { label: '草稿', color: 'default' }, + published: { label: '已发布', color: 'green' }, + archived: { label: '已归档', color: 'orange' }, +} + +const EMPTY_FORM: SiteAnnouncementPayload = { + title: '', + content: '', + category: 'notice', + priority: 0, + is_pinned: false, + is_important: false, +} + +function formatTime(value?: string | null) { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const pad = (n: number) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +/** + * 分站公告中心:站长自助维护本站公告。 + * + * 展示来源由本站 announcement_mode 控制: + * inherit(默认)= 主站公告 + 本站公告;own_only = 仅本站公告。 + */ +export default function Announcements() { + const member = useMember() + const [siteID, setSiteID] = useState(member?.site_id) + const [sites, setSites] = useState([]) + const [announcementMode, setAnnouncementMode] = useState<'inherit' | 'own_only'>('inherit') + const [modeSaving, setModeSaving] = useState(false) + + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [status, setStatus] = useState('') + const [loading, setLoading] = useState(false) + + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [saving, setSaving] = useState(false) + const [form] = Form.useForm() + + const load = useCallback( + async (targetSiteID?: number, nextPage = page, nextPageSize = pageSize, nextStatus = status) => { + if (!targetSiteID) return + setLoading(true) + try { + const result = await listSiteAnnouncements(targetSiteID, { + status: nextStatus || undefined, + page: nextPage, + page_size: nextPageSize, + }) + setItems(result.items) + setTotal(result.total) + } catch { + message.error('公告加载失败') + } finally { + setLoading(false) + } + }, + [page, pageSize, status], + ) + + useEffect(() => { + async function init() { + let currentMember = member + if (!currentMember) { + try { + currentMember = await getMe() + } catch { + // ignore + } + } + const activeSiteID = currentMember?.site_id + if (!activeSiteID) return + setSiteID(activeSiteID) + try { + const rows = await listSites() + setSites(rows) + const current = rows.find(row => row.id === activeSiteID) + setAnnouncementMode(current?.announcement_mode === 'own_only' ? 'own_only' : 'inherit') + } catch { + // 站点信息读取失败时保留默认口径 + } + void load(activeSiteID, 1, pageSize, '') + } + void init() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [member]) + + async function handleModeChange(next: 'inherit' | 'own_only') { + if (!siteID || next === announcementMode) return + setModeSaving(true) + try { + await updateSiteBranding(siteID, { announcement_mode: next }) + setAnnouncementMode(next) + message.success(next === 'own_only' ? '已切换为「仅本站公告」' : '已切换为「继承主站 + 本站公告」') + } catch { + message.error('公告来源切换失败') + } finally { + setModeSaving(false) + } + } + + function openCreate() { + setEditing(null) + form.setFieldsValue({ ...EMPTY_FORM }) + setModalOpen(true) + } + + function openEdit(row: SiteAnnouncement) { + setEditing(row) + form.setFieldsValue({ + title: row.title, + content: row.content, + category: row.category, + priority: row.priority, + is_pinned: row.is_pinned, + is_important: row.is_important, + }) + setModalOpen(true) + } + + async function handleSubmit() { + if (!siteID) return + const values = await form.validateFields() + setSaving(true) + try { + if (editing) { + await updateSiteAnnouncement(siteID, editing.id, values) + message.success('公告已保存') + } else { + await createSiteAnnouncement(siteID, values) + message.success('公告已创建为草稿,发布后才会在前台展示') + } + setModalOpen(false) + void load(siteID) + } catch { + message.error('公告保存失败') + } finally { + setSaving(false) + } + } + + async function handleStatusAction(row: SiteAnnouncement, action: 'publish' | 'archive' | 'unarchive') { + if (!siteID) return + try { + await updateSiteAnnouncementStatus(siteID, row.id, action) + message.success(action === 'publish' ? '公告已发布' : action === 'archive' ? '公告已归档' : '公告已取消归档') + void load(siteID) + } catch { + message.error('操作失败') + } + } + + async function handleDelete(row: SiteAnnouncement) { + if (!siteID) return + try { + await deleteSiteAnnouncement(siteID, row.id) + message.success('公告已删除') + void load(siteID) + } catch { + message.error('删除失败') + } + } + + return ( + + + + + + + 公告中心 + + + + 维护本站公告(列表 / 详情页展示)。公告发布后立即在本站前台生效。 + + + 公告来源 + void handleModeChange(e.target.value)} + disabled={modeSaving} + > + 继承主站 + 本站公告 + 仅本站公告 + + + 「继承」会同时展示主站公告与本站公告;「仅本站」只展示本站公告,主站公告不再下发(本站未发布公告时公告中心为空)。 + + + + + + + + + + + + +