feat: 公告系统 — Phase 2 完成

- Model/Repo/Service/Controller 全栈实现
- CRUD + ListActive(生效时间范围过滤)
- 管理后台独立页面 /admin/announcements(列表+弹窗CRUD)
- 前台首页 Hero 下方公告栏(info/success/warning/error 四色)
- Moderator+ 权限可管理公告
This commit is contained in:
2026-06-22 00:00:21 +08:00
parent 2d1dd5d401
commit fc57ed17d4
15 changed files with 837 additions and 8 deletions

View File

@ -0,0 +1,102 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"github.com/gin-gonic/gin"
)
// AnnouncementController 公告管理控制器
type AnnouncementController struct {
svc announcementUseCase
}
type announcementUseCase interface {
Create(a *model.Announcement) error
Update(a *model.Announcement) error
Delete(id uint) error
List(offset, limit int) ([]model.Announcement, int64, error)
ListActive() ([]model.Announcement, error)
}
// NewAnnouncementController 构造函数
func NewAnnouncementController(svc announcementUseCase) *AnnouncementController {
return &AnnouncementController{svc: svc}
}
// Page 公告管理页面
func (ac *AnnouncementController) Page(c *gin.Context) {
c.HTML(http.StatusOK, "admin/announcements/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "公告管理",
"ExtraCSS": "/admin/static/css/announcements.css",
"ExtraJS": "/admin/static/js/announcements.js",
"Types": service.AnnouncementTypes,
}))
}
// List 公告列表 API
func (ac *AnnouncementController) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
offset := (page - 1) * pageSize
list, total, err := ac.svc.List(offset, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, gin.H{"list": list, "total": total})
}
// Create 创建公告 API
func (ac *AnnouncementController) Create(c *gin.Context) {
var req model.Announcement
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
if err := ac.svc.Create(&req); err != nil {
common.Error(c, http.StatusInternalServerError, "创建失败")
return
}
common.OkMessage(c, "公告已创建")
}
// Update 更新公告 API
func (ac *AnnouncementController) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的 ID")
return
}
var req model.Announcement
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
req.ID = uint(id)
if err := ac.svc.Update(&req); err != nil {
common.Error(c, http.StatusInternalServerError, "更新失败")
return
}
common.OkMessage(c, "公告已更新")
}
// Delete 删除公告 API
func (ac *AnnouncementController) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的 ID")
return
}
if err := ac.svc.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
common.OkMessage(c, "公告已删除")
}

View File

@ -0,0 +1,17 @@
package model
import "time"
// Announcement 公告
type Announcement struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Type string `gorm:"type:varchar(20);default:info" json:"type"` // info / warning / success / error
IsActive bool `gorm:"default:true" json:"is_active"`
StartAt *time.Time `json:"start_at,omitempty"`
EndAt *time.Time `json:"end_at,omitempty"`
Sort int `gorm:"default:0" json:"sort"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,73 @@
package repository
import (
"time"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"gorm.io/gorm"
)
// AnnouncementRepo 公告数据访问
type AnnouncementRepo struct {
db *gorm.DB
}
// NewAnnouncementRepo 构造函数
func NewAnnouncementRepo(db *gorm.DB) *AnnouncementRepo {
return &AnnouncementRepo{db: db}
}
// WithTx 基于事务创建新实例
func (r *AnnouncementRepo) WithTx(tx *gorm.DB) service.AnnouncementStore {
return &AnnouncementRepo{db: tx}
}
// Create 创建公告
func (r *AnnouncementRepo) Create(a *model.Announcement) error {
return r.db.Create(a).Error
}
// Update 更新公告
func (r *AnnouncementRepo) Update(a *model.Announcement) error {
return r.db.Save(a).Error
}
// Delete 删除公告
func (r *AnnouncementRepo) Delete(id uint) error {
return r.db.Delete(&model.Announcement{}, id).Error
}
// FindByID 按 ID 查询
func (r *AnnouncementRepo) FindByID(id uint) (*model.Announcement, error) {
var a model.Announcement
err := r.db.First(&a, id).Error
if err != nil {
return nil, err
}
return &a, nil
}
// List 分页查询全部公告(管理后台用)
func (r *AnnouncementRepo) List(offset, limit int) ([]model.Announcement, int64, error) {
var total int64
if err := r.db.Model(&model.Announcement{}).Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.Announcement
err := r.db.Order("sort ASC, created_at DESC").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}
// ListActive 查询当前生效中的公告(前台用,按 sort 排序)
func (r *AnnouncementRepo) ListActive() ([]model.Announcement, error) {
now := time.Now()
var list []model.Announcement
err := r.db.Where("is_active = true").
Where("start_at IS NULL OR start_at <= ?", now).
Where("end_at IS NULL OR end_at >= ?", now).
Order("sort ASC, created_at DESC").
Find(&list).Error
return list, err
}

View File

@ -32,6 +32,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
adminPages.GET("/announcements", d.announcementCtrl.Page)
}
// --- 管理后台 API ---
@ -99,4 +100,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminCommentAPI.GET("", d.adminCommentCtrl.ListComments)
adminCommentAPI.DELETE("/:id", d.adminCommentCtrl.Delete)
}
// --- 公告管理 APImoderator+ ---
announcementAPI := r.Group("/api/admin/announcements")
announcementAPI.Use(d.authMdw.Required())
announcementAPI.Use(middleware.RequireMinRole(model.RoleModerator))
announcementAPI.Use(d.authMdw.BannedWriteGuard())
announcementAPI.Use(middleware.CSRF(cfg))
{
announcementAPI.GET("", d.announcementCtrl.List)
announcementAPI.POST("", d.announcementCtrl.Create)
announcementAPI.PUT("/:id", d.announcementCtrl.Update)
announcementAPI.DELETE("/:id", d.announcementCtrl.Delete)
}
}

View File

@ -43,8 +43,10 @@ type dependencies struct {
followCtrl *controller.FollowController
favoriteCtrl *controller.FavoriteController
favoriteService *service.FavoriteService
trendsService *service.TrendsService
homeCache *cache.HomePageCache
trendsService *service.TrendsService
homeCache *cache.HomePageCache
announcementService *service.AnnouncementService
announcementCtrl *adminCtrl.AnnouncementController
}
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (

View File

@ -103,6 +103,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
// 公告系统
announcementRepo := repository.NewAnnouncementRepo(db)
announcementService := service.NewAnnouncementService(announcementRepo)
announcementCtrl := adminCtrl.NewAnnouncementController(announcementService)
// 首页内存缓存30s TTL文章变更时失效
homeCache := cache.NewHomePageCache(30 * time.Second)
postService.WithHomePageInvalidator(func() { homeCache.Invalidate() })
@ -126,7 +131,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
followCtrl: followCtrl,
favoriteCtrl: favoriteCtrl,
favoriteService: favoriteService,
trendsService: trendsService,
homeCache: homeCache,
trendsService: trendsService,
homeCache: homeCache,
announcementService: announcementService,
announcementCtrl: announcementCtrl,
}
}

View File

@ -33,11 +33,13 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
d.homeCache.Set(posts, total)
}
}
announcements, _ := d.announcementService.ListActive()
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
"Title": "首页",
"ExtraCSS": "/static/css/home.css",
"Posts": posts,
"PostCount": total,
"Title": "首页",
"ExtraCSS": "/static/css/home.css",
"Posts": posts,
"PostCount": total,
"Announcements": announcements,
}))
})

View File

@ -0,0 +1,59 @@
package service
import "metazone.cc/mce/internal/model"
// AnnouncementTypes 公告类型及其显示名称和颜色
var AnnouncementTypes = map[string]struct {
Label string
Color string
}{
"info": {"信息", "#3b82f6"},
"success": {"成功", "#22c55e"},
"warning": {"警告", "#f59e0b"},
"error": {"错误", "#ef4444"},
}
// AnnouncementStore 公告仓库接口ISP
type AnnouncementStore interface {
Create(a *model.Announcement) error
Update(a *model.Announcement) error
Delete(id uint) error
FindByID(id uint) (*model.Announcement, error)
List(offset, limit int) ([]model.Announcement, int64, error)
ListActive() ([]model.Announcement, error)
}
// AnnouncementService 公告业务逻辑
type AnnouncementService struct {
repo AnnouncementStore
}
// NewAnnouncementService 构造函数
func NewAnnouncementService(repo AnnouncementStore) *AnnouncementService {
return &AnnouncementService{repo: repo}
}
// Create 创建公告
func (s *AnnouncementService) Create(a *model.Announcement) error {
return s.repo.Create(a)
}
// Update 更新公告
func (s *AnnouncementService) Update(a *model.Announcement) error {
return s.repo.Update(a)
}
// Delete 删除公告
func (s *AnnouncementService) Delete(id uint) error {
return s.repo.Delete(id)
}
// List 分页列表
func (s *AnnouncementService) List(offset, limit int) ([]model.Announcement, int64, error) {
return s.repo.List(offset, limit)
}
// ListActive 生效中的公告
func (s *AnnouncementService) ListActive() ([]model.Announcement, error) {
return s.repo.ListActive()
}