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 与跨站防护、公告来源开关校验与非法值容错
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user