diff --git a/backend/internal/model/announcement.go b/backend/internal/model/announcement.go new file mode 100644 index 0000000..de99cdc --- /dev/null +++ b/backend/internal/model/announcement.go @@ -0,0 +1,23 @@ +package model + +import "time" + +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"` +} + +func (Announcement) TableName() string { + return "announcements" +} diff --git a/backend/internal/modules/announcement/dto.go b/backend/internal/modules/announcement/dto.go new file mode 100644 index 0000000..3a8cfff --- /dev/null +++ b/backend/internal/modules/announcement/dto.go @@ -0,0 +1,50 @@ +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"` +} + +type AnnouncementListQuery struct { + Category string + Status string + Page int + PageSize int +} + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +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"` +} + +type UpdateAnnouncementRequest 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"` +} diff --git a/backend/internal/modules/announcement/handler.go b/backend/internal/modules/announcement/handler.go new file mode 100644 index 0000000..1320d36 --- /dev/null +++ b/backend/internal/modules/announcement/handler.go @@ -0,0 +1,225 @@ +package announcement + +import ( + "errors" + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// List 获取公告列表(前台用户) +func (h *Handler) List(c *gin.Context) { + query := AnnouncementListQuery{ + Category: c.Query("category"), + Status: "published", + } + query.Page, query.PageSize = parsePagination(c) + + result, err := h.service.List(query) + if err != nil { + response.InternalServerError(c, "获取公告列表失败") + return + } + + response.OK(c, result) +} + +// GetByID 获取公告详情 +func (h *Handler) GetByID(c *gin.Context) { + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "公告ID不正确") + return + } + + announcement, err := h.service.GetByID(id) + if err != nil { + if errors.Is(err, ErrNotFound) { + response.NotFound(c, "公告不存在") + return + } + response.InternalServerError(c, "获取公告失败") + return + } + + response.OK(c, announcement) +} + +// AdminList 管理员获取公告列表 +func (h *Handler) AdminList(c *gin.Context) { + query := AnnouncementListQuery{ + Category: c.Query("category"), + Status: c.Query("status"), + } + query.Page, query.PageSize = parsePagination(c) + + result, err := h.service.AdminList(query) + if err != nil { + response.InternalServerError(c, "获取公告列表失败") + return + } + + response.OK(c, result) +} + +// AdminGetByID 管理员获取公告详情 +func (h *Handler) AdminGetByID(c *gin.Context) { + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "公告ID不正确") + return + } + + announcement, err := h.service.AdminGetByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + response.NotFound(c, "公告不存在") + return + } + response.InternalServerError(c, "获取公告失败") + return + } + + response.OK(c, announcement) +} + +// Create 创建公告 +func (h *Handler) Create(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "未授权") + return + } + + var req CreateAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求参数不正确") + return + } + + announcement, err := h.service.Create(req, adminID) + if err != nil { + response.InternalServerError(c, "创建公告失败") + return + } + + response.OK(c, announcement) +} + +// Update 更新公告 +func (h *Handler) Update(c *gin.Context) { + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "公告ID不正确") + return + } + + var req UpdateAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求参数不正确") + return + } + + announcement, err := h.service.Update(id, req) + if err != nil { + response.InternalServerError(c, "更新公告失败") + return + } + + response.OK(c, announcement) +} + +// Publish 发布公告 +func (h *Handler) Publish(c *gin.Context) { + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "公告ID不正确") + return + } + + if err := h.service.Publish(id); err != nil { + response.InternalServerError(c, "发布公告失败") + return + } + + response.OK(c, gin.H{"message": "发布成功"}) +} + +// Archive 归档公告 +func (h *Handler) Archive(c *gin.Context) { + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "公告ID不正确") + return + } + + if err := h.service.Archive(id); err != nil { + response.InternalServerError(c, "归档公告失败") + return + } + + response.OK(c, gin.H{"message": "归档成功"}) +} + +// Delete 删除公告 +func (h *Handler) Delete(c *gin.Context) { + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "公告ID不正确") + return + } + + if err := h.service.Delete(id); err != nil { + response.InternalServerError(c, "删除公告失败") + return + } + + response.OK(c, gin.H{"message": "删除成功"}) +} + +// 工具函数 + +func parseID(c *gin.Context) (uint64, error) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + return 0, errors.New("无效的ID") + } + return id, nil +} + +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} diff --git a/backend/internal/modules/announcement/repository.go b/backend/internal/modules/announcement/repository.go new file mode 100644 index 0000000..50e7eab --- /dev/null +++ b/backend/internal/modules/announcement/repository.go @@ -0,0 +1,200 @@ +package announcement + +import ( + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// List 获取公告列表(前台用户) +func (r *Repository) List(query AnnouncementListQuery) (*PaginatedResult, error) { + var total int64 + tx := r.db.Model(&model.Announcement{}).Where("status = ?", "published") + + if query.Category != "" { + tx = tx.Where("category = ?", query.Category) + } + + if err := tx.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, published_at DESC"). + Offset(offset). + Limit(query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + + items := make([]AnnouncementDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toAnnouncementDTO(row)) + } + + return &PaginatedResult{ + Items: items, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +// GetByID 获取公告详情 +func (r *Repository) GetByID(id uint64) (*AnnouncementDTO, error) { + var announcement model.Announcement + if err := r.db.Where("id = ? AND status = ?", id, "published").First(&announcement).Error; err != nil { + return nil, err + } + + // 增加查看次数 + r.db.Model(&model.Announcement{}).Where("id = ?", id).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1)) + + dto := toAnnouncementDTO(announcement) + return &dto, nil +} + +// AdminList 管理员获取公告列表 +func (r *Repository) AdminList(query AnnouncementListQuery) (*PaginatedResult, error) { + var total int64 + tx := r.db.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 { + return nil, err + } + + offset := (query.Page - 1) * query.PageSize + var rows []model.Announcement + if err := tx.Order("is_pinned DESC, priority DESC, id DESC"). + Offset(offset). + Limit(query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + + items := make([]AnnouncementDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toAnnouncementDTO(row)) + } + + return &PaginatedResult{ + Items: items, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +// AdminGetByID 管理员获取公告详情 +func (r *Repository) AdminGetByID(id uint64) (*AnnouncementDTO, error) { + var announcement model.Announcement + if err := r.db.Where("id = ?", id).First(&announcement).Error; err != nil { + return nil, err + } + + dto := toAnnouncementDTO(announcement) + return &dto, nil +} + +// Create 创建公告 +func (r *Repository) Create(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, + } + + if err := r.db.Create(&announcement).Error; err != nil { + return nil, err + } + + dto := toAnnouncementDTO(announcement) + return &dto, nil +} + +// Update 更新公告 +func (r *Repository) Update(id uint64, req UpdateAnnouncementRequest) (*AnnouncementDTO, error) { + updates := make(map[string]interface{}) + + if req.Title != "" { + updates["title"] = req.Title + } + if req.Content != "" { + updates["content"] = req.Content + } + if req.Category != "" { + updates["category"] = req.Category + } + updates["priority"] = req.Priority + updates["is_pinned"] = req.IsPinned + updates["is_important"] = req.IsImportant + + if err := r.db.Model(&model.Announcement{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return nil, err + } + + return r.AdminGetByID(id) +} + +// Publish 发布公告 +func (r *Repository) Publish(id uint64) error { + now := time.Now() + return r.db.Model(&model.Announcement{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "status": "published", + "published_at": now, + }).Error +} + +// Archive 归档公告 +func (r *Repository) Archive(id uint64) error { + return r.db.Model(&model.Announcement{}). + Where("id = ?", id). + Update("status", "archived").Error +} + +// Delete 删除公告 +func (r *Repository) Delete(id uint64) error { + return r.db.Where("id = ?", id).Delete(&model.Announcement{}).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, + } +} diff --git a/backend/internal/modules/announcement/service.go b/backend/internal/modules/announcement/service.go new file mode 100644 index 0000000..2df557d --- /dev/null +++ b/backend/internal/modules/announcement/service.go @@ -0,0 +1,100 @@ +package announcement + +import ( + "errors" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +var ( + ErrNotFound = errors.New("公告不存在") + ErrInvalidRequest = errors.New("请求参数不正确") + ErrUnauthorized = errors.New("未授权") +) + +// List 获取公告列表(前台用户) +func (s *Service) List(query AnnouncementListQuery) (*PaginatedResult, error) { + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + + return s.repo.List(query) +} + +// GetByID 获取公告详情 +func (s *Service) GetByID(id uint64) (*AnnouncementDTO, error) { + dto, err := s.repo.GetByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return dto, nil +} + +// AdminList 管理员获取公告列表 +func (s *Service) AdminList(query AnnouncementListQuery) (*PaginatedResult, error) { + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + + return s.repo.AdminList(query) +} + +// AdminGetByID 管理员获取公告详情 +func (s *Service) AdminGetByID(id uint64) (*AnnouncementDTO, error) { + dto, err := s.repo.AdminGetByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return dto, nil +} + +// Create 创建公告 +func (s *Service) Create(req CreateAnnouncementRequest, createdBy uint64) (*AnnouncementDTO, error) { + return s.repo.Create(req, createdBy) +} + +// Update 更新公告 +func (s *Service) Update(id uint64, req UpdateAnnouncementRequest) (*AnnouncementDTO, error) { + return s.repo.Update(id, req) +} + +// Publish 发布公告 +func (s *Service) Publish(id uint64) error { + return s.repo.Publish(id) +} + +// Archive 归档公告 +func (s *Service) Archive(id uint64) error { + return s.repo.Archive(id) +} + +// Delete 删除公告 +func (s *Service) Delete(id uint64) error { + return s.repo.Delete(id) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2e977b0..f322ada 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -13,6 +13,7 @@ import ( "hfb_sys/backend/internal/modules/adminmgr" "hfb_sys/backend/internal/modules/adminrole" "hfb_sys/backend/internal/modules/adminuser" + "hfb_sys/backend/internal/modules/announcement" "hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/chathub" @@ -183,6 +184,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { fileHandler := filemodule.NewHandler(fileService, fileStorage) listingService := listing.NewService(listingRepo, systemConfigRepo) listingHandler := listing.NewHandler(listingService, fileStorage) + var announcementRepo *announcement.Repository + if deps.DB != nil { + announcementRepo = announcement.NewRepository(deps.DB) + } + announcementService := announcement.NewService(announcementRepo) + announcementHandler := announcement.NewHandler(announcementService) requireAuth := middleware.Auth(jwtManager) requireAdmin := middleware.AdminAuth(jwtManager) requireRealname := middleware.RequireRealname(userRepo) @@ -307,6 +314,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { realnameRoutes.GET("/status", realnameHandler.Status) } + announcementRoutes := api.Group("/announcements") + { + announcementRoutes.GET("", announcementHandler.List) + announcementRoutes.GET("/:id", announcementHandler.GetByID) + } + adminAuthRoutes := api.Group("/admin/auth") { adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha) @@ -381,6 +394,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete) adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles) adminRoutes.PUT("/admin-users/:id/password", adminMgrHandler.ChangePassword) + + // 公告管理 + adminRoutes.GET("/announcements", requirePerm("announcement:view"), announcementHandler.AdminList) + adminRoutes.GET("/announcements/:id", requirePerm("announcement:view"), announcementHandler.AdminGetByID) + adminRoutes.POST("/announcements", requirePerm("announcement:manage"), announcementHandler.Create) + adminRoutes.PUT("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Update) + adminRoutes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), announcementHandler.Publish) + adminRoutes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), announcementHandler.Archive) + adminRoutes.DELETE("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Delete) } } diff --git a/backend/migrations/000005_add_announcements.sql b/backend/migrations/000005_add_announcements.sql new file mode 100644 index 0000000..7cceb18 --- /dev/null +++ b/backend/migrations/000005_add_announcements.sql @@ -0,0 +1,22 @@ +-- ============================================ +-- 公告系统表 +-- ============================================ + +-- 公告表 +CREATE TABLE IF NOT EXISTS announcements ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(255) NOT NULL COMMENT '公告标题', + content TEXT NOT NULL COMMENT '公告内容(支持富文本/Markdown)', + category VARCHAR(32) NOT NULL DEFAULT 'notice' COMMENT '分类: notice(通知), tutorial(教程), rule(规则), faq(常见问题)', + priority INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越大越靠前', + is_pinned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否置顶', + is_important TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否重要(重要的会有特殊标记)', + view_count INT NOT NULL DEFAULT 0 COMMENT '查看次数', + status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '状态: draft(草稿), published(已发布), archived(已归档)', + published_at DATETIME NULL COMMENT '发布时间', + created_by BIGINT UNSIGNED NULL COMMENT '创建者ID(管理员)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_announcements_status (status, is_pinned, priority, published_at), + KEY idx_announcements_category (category, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公告表'; diff --git a/backend/migrations/000006_insert_sample_announcements.sql b/backend/migrations/000006_insert_sample_announcements.sql new file mode 100644 index 0000000..b11e56d --- /dev/null +++ b/backend/migrations/000006_insert_sample_announcements.sql @@ -0,0 +1,98 @@ +-- 插入示例公告数据 + +INSERT INTO announcements (title, content, category, priority, is_pinned, is_important, status, published_at) VALUES +('号主须知(重要!)', '## 平台担保交易,拒绝私下转账! + +平台提供交易担保机制,用户支付的押金与租金全部冻结在平台账户中,租号结束后根据账号状态自动结算给号主或退款给租客。 + +**警告:** +- 账号被非本人,请看到此消息后主动下架账号! +- 不要随意更改账号密码或绑定信息 +- 请勿诱导买家进行私下交易 +- 违规将会被冻结账户并承担相应损失 + +有任何问题请联系客服处理。', 'notice', 100, 1, 1, 'published', NOW()), + +('租客须知(重要!)', '## 账号使用规范 + +1. **禁止修改账号信息** - 不得修改账号密码、绑定手机、实名信息等 +2. **禁止充值** - 租用期间请勿对账号进行任何形式的充值 +3. **禁止违规操作** - 不得使用外挂、辅助工具,不得进行恶意行为 +4. **按时归还** - 租用到期后请及时归还账号,逾期将扣除押金 + +**温馨提示:** +- 交接账号后请第一时间登录验证 +- 发现账号异常请立即联系客服 +- 使用过程中遇到问题随时咨询客服 + +祝您游戏愉快!', 'notice', 99, 1, 1, 'published', NOW()), + +('人脸验证教程', '## 如何完成实名认证 + +为了保障交易安全,平台要求所有用户完成实名认证后才能发布商品和下单。 + +### 认证步骤: + +1. 进入"个人资料"页面 +2. 点击"实名认证"按钮 +3. 按照提示填写真实姓名和身份证号 +4. 进行人脸识别验证 +5. 等待系统审核(通常1-3分钟) + +### 注意事项: + +- 请确保光线充足,面部清晰 +- 摘下眼镜、帽子等遮挡物 +- 一个身份证只能认证一个账号 +- 认证信息仅用于交易安全,平台严格保密 + +认证失败请联系客服协助处理。', 'tutorial', 90, 0, 0, 'published', NOW()), + +('除纯市外其他高级物资默认从收费表', '## 出租开始须必开启备锁! + +号主在出租账号前,务必确保账号已开启"账号保护"功能(即备锁)。 + +### 为什么要开启备锁? + +- 防止租客恶意修改账号信息 +- 保护账号安全,降低被盗风险 +- 发生纠纷时便于平台介入处理 +- 租用结束后方便号主快速收回账号 + +### 如何开启? + +具体开启方式请参考游戏官方设置,不同平台操作略有差异。如不清楚如何设置,请咨询客服。 + +**重要提醒:未开启备锁导致的账号安全问题,平台不承担责任!**', 'rule', 80, 0, 0, 'published', NOW()), + +('出租完成后如何结算?', '## 租金结算流程 + +租用期满后,系统会根据账号状态自动进行结算: + +### 正常归还 +- 租客按时归还账号 +- 号主确认账号无异常 +- 系统自动将租金结算到号主钱包 +- 押金退还给租客 + +### 异常情况 +- 账号信息被修改 → 押金赔付给号主 +- 账号被找回 → 租金退还给租客 +- 产生纠纷 → 平台客服介入仲裁 + +### 提现说明 +目前提现功能暂未开放,敬请期待后续更新。', 'faq', 70, 0, 0, 'published', NOW()), + +('如何联系客服?', '## 客服联系方式 + +遇到问题可以通过以下方式联系客服: + +1. **在线客服** - 点击页面右上角"客服"按钮,即可在线咨询 +2. **订单页面** - 每个订单详情页都有专属客服入口 +3. **消息中心** - 查看历史咨询记录 + +### 客服工作时间 +- 工作日:09:00 - 22:00 +- 节假日:10:00 - 20:00 + +非工作时间留言,客服上线后会第一时间回复。', 'faq', 60, 0, 0, 'published', NOW()); diff --git a/backend/pkg/response/response.go b/backend/pkg/response/response.go index 3292c2e..1112427 100644 --- a/backend/pkg/response/response.go +++ b/backend/pkg/response/response.go @@ -46,3 +46,11 @@ func Unauthorized(c *gin.Context, message string) { func ServiceUnavailable(c *gin.Context, message string) { Error(c, http.StatusServiceUnavailable, "service_unavailable", message) } + +func NotFound(c *gin.Context, message string) { + Error(c, http.StatusNotFound, "not_found", message) +} + +func InternalServerError(c *gin.Context, message string) { + Error(c, http.StatusInternalServerError, "internal_server_error", message) +} diff --git a/docs/announcement-feature.md b/docs/announcement-feature.md new file mode 100644 index 0000000..96adfcc --- /dev/null +++ b/docs/announcement-feature.md @@ -0,0 +1,101 @@ +# 公告系统功能说明 + +## 功能概述 + +在钱包旁边新增了公告中心功能,用户可以查看平台通知、使用教程、规则说明和常见问题解答。 + +## 主要特性 + +### 前台功能(用户端) +- **公告列表页** (`/announcements`) + - 支持按分类筛选(全部、通知公告、使用教程、规则说明、常见问题) + - 显示置顶和重要标记 + - 分页浏览 + - 点击查看详情 + +- **公告详情页** (`/announcements/:id`) + - 查看完整公告内容 + - 支持富文本显示 + - 自动统计浏览次数 + +### 后台功能(管理端) +- 公告管理 CRUD(创建、编辑、删除) +- 发布/归档公告 +- 设置优先级、置顶、重要标记 +- 按状态和分类筛选 + +## 数据库表结构 + +```sql +CREATE TABLE announcements ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + category VARCHAR(32) NOT NULL DEFAULT 'notice', + priority INT NOT NULL DEFAULT 0, + is_pinned TINYINT(1) NOT NULL DEFAULT 0, + is_important TINYINT(1) NOT NULL DEFAULT 0, + view_count INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + published_at DATETIME NULL, + created_by BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); +``` + +## API 端点 + +### 用户端 +- `GET /api/announcements` - 获取公告列表 +- `GET /api/announcements/:id` - 获取公告详情 + +### 管理端 +- `GET /api/admin/announcements` - 管理员获取公告列表 +- `GET /api/admin/announcements/:id` - 管理员获取公告详情 +- `POST /api/admin/announcements` - 创建公告 +- `PUT /api/admin/announcements/:id` - 更新公告 +- `POST /api/admin/announcements/:id/publish` - 发布公告 +- `POST /api/admin/announcements/:id/archive` - 归档公告 +- `DELETE /api/admin/announcements/:id` - 删除公告 + +## 权限要求 + +管理端需要以下权限: +- `announcement:view` - 查看公告 +- `announcement:manage` - 管理公告(创建、编辑、发布、归档、删除) + +## 前端路由 + +- `/announcements` - 公告列表 +- `/announcements/:id` - 公告详情 + +## 导航入口 + +在顶部导航栏"钱包"旁边添加了"公告"入口,用户可以方便地访问公告中心。 + +## 数据迁移 + +1. 运行 `000005_add_announcements.sql` 创建表结构 +2. 运行 `000006_insert_sample_announcements.sql` 插入示例数据 + +## 技术实现 + +### 后端 +- 使用 Go + Gin 框架 +- GORM 作为 ORM +- 遵循模块化设计(handler -> service -> repository) + +### 前端 +- Vue 3 + TypeScript +- Element Plus UI 组件库 +- 响应式设计,支持移动端 + +## 未来扩展 + +可以考虑添加: +- 公告搜索功能 +- 用户收藏公告 +- 评论功能 +- 富文本编辑器(管理端) +- 公告推送通知 diff --git a/frontend/src/features/announcement/api/announcements.ts b/frontend/src/features/announcement/api/announcements.ts new file mode 100644 index 0000000..3435271 --- /dev/null +++ b/frontend/src/features/announcement/api/announcements.ts @@ -0,0 +1,42 @@ +import { apiRequest } from '@/utils/apiRequest' + +export interface Announcement { + 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 AnnouncementListParams { + category?: string + page?: number + page_size?: number +} + +export interface PaginatedResult { + items: T[] + total: number + page: number + page_size: number +} + +export async function fetchAnnouncements(params: AnnouncementListParams = {}): Promise> { + return apiRequest>('/api/announcements', { + method: 'GET', + params, + }) +} + +export async function fetchAnnouncementDetail(id: number): Promise { + return apiRequest(`/api/announcements/${id}`, { + method: 'GET', + }) +} diff --git a/frontend/src/features/announcement/index.ts b/frontend/src/features/announcement/index.ts new file mode 100644 index 0000000..ed1a53f --- /dev/null +++ b/frontend/src/features/announcement/index.ts @@ -0,0 +1 @@ +export * from './api/announcements' diff --git a/frontend/src/features/announcement/views/AnnouncementDetailView.vue b/frontend/src/features/announcement/views/AnnouncementDetailView.vue new file mode 100644 index 0000000..4139cae --- /dev/null +++ b/frontend/src/features/announcement/views/AnnouncementDetailView.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/frontend/src/features/announcement/views/AnnouncementsView.vue b/frontend/src/features/announcement/views/AnnouncementsView.vue new file mode 100644 index 0000000..32d6bd6 --- /dev/null +++ b/frontend/src/features/announcement/views/AnnouncementsView.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 039e263..83ca78b 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -12,6 +12,7 @@ import { Finished, House, InfoFilled, + Megaphone, Search, Service, Shop, @@ -33,6 +34,7 @@ const navItems = [ { label: "我的订单", to: "/orders", icon: Tickets }, { label: "消息", to: "/messages", icon: ChatDotRound }, { label: "钱包", to: "/wallet", icon: Wallet }, + { label: "公告", to: "/announcements", icon: Megaphone }, ]; const buyerServiceItems = [ { label: "待支付", icon: Coin, to: { path: "/orders", query: { tab: "pending_payment" } } }, diff --git a/frontend/src/router/accountRoutes.ts b/frontend/src/router/accountRoutes.ts index 33b97ad..cb5ad26 100644 --- a/frontend/src/router/accountRoutes.ts +++ b/frontend/src/router/accountRoutes.ts @@ -66,4 +66,14 @@ export const accountRoutes: RouteRecordRaw[] = [ component: () => import('@/features/chats/views/ChatView.vue'), meta: { requiresAuth: true }, }, + { + path: '/announcements', + name: 'announcements', + component: () => import('@/features/announcement/views/AnnouncementsView.vue'), + }, + { + path: '/announcements/:id', + name: 'announcement-detail', + component: () => import('@/features/announcement/views/AnnouncementDetailView.vue'), + }, ]