diff --git a/docs/migrations/002_announcements.sql b/docs/migrations/002_announcements.sql new file mode 100644 index 0000000..52fcfed --- /dev/null +++ b/docs/migrations/002_announcements.sql @@ -0,0 +1,13 @@ +-- 公告表 +CREATE TABLE IF NOT EXISTS announcements ( + id SERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + type VARCHAR(20) DEFAULT 'info', + is_active BOOLEAN DEFAULT true, + start_at TIMESTAMP, + end_at TIMESTAMP, + sort INT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); diff --git a/internal/controller/admin/announcement_controller.go b/internal/controller/admin/announcement_controller.go new file mode 100644 index 0000000..d526278 --- /dev/null +++ b/internal/controller/admin/announcement_controller.go @@ -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, "公告已删除") +} diff --git a/internal/model/announcement.go b/internal/model/announcement.go new file mode 100644 index 0000000..5a6a114 --- /dev/null +++ b/internal/model/announcement.go @@ -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"` +} diff --git a/internal/repository/announcement_repo.go b/internal/repository/announcement_repo.go new file mode 100644 index 0000000..674cc60 --- /dev/null +++ b/internal/repository/announcement_repo.go @@ -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 +} diff --git a/internal/router/admin.go b/internal/router/admin.go index 647b090..c5c093a 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -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) } + + // --- 公告管理 API(moderator+) --- + 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) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 43a51bd..c067533 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -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) ( diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 0141408..73f5287 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -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, } } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index fc0a7fa..aaf7079 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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, })) }) diff --git a/internal/service/announcement_service.go b/internal/service/announcement_service.go new file mode 100644 index 0000000..981e9e3 --- /dev/null +++ b/internal/service/announcement_service.go @@ -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() +} diff --git a/templates/MetaLab-2026/html/home/index.html b/templates/MetaLab-2026/html/home/index.html index a248344..05826a9 100644 --- a/templates/MetaLab-2026/html/home/index.html +++ b/templates/MetaLab-2026/html/home/index.html @@ -17,6 +17,18 @@ +{{if .Announcements}} + +
+{{end}} +管理前台首页公告栏展示内容
+| ID | +标题 | +类型 | +状态 | +生效时间 | +排序 | +创建时间 | +操作 | +
|---|---|---|---|---|---|---|---|
| 加载中... | |||||||