Files
hfb_sys/backend/internal/modules/distribution/announcement.go
T
yml2213 2a76d59a30 feat(distribution): 公告中心支持分站自定义
公告中心(前台公告列表/详情)此前完全没有站点概念,分站访客只能看到主站公告。
本次按「默认继承主站、站长可切仅本站」的口径支持分站自定义。

数据与口径:
- 新增 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 与跨站防护、公告来源开关校验与非法值容错
2026-10-01 10:02:31 +08:00

236 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}