diff --git a/cmd/server/wire.go b/cmd/server/wire.go index a13ed5e..d2054bd 100644 --- a/cmd/server/wire.go +++ b/cmd/server/wire.go @@ -46,6 +46,7 @@ func ProvideRouter( adminDashboardHandler *handler.AdminDashboardHandler, adminLogHandler *handler.AdminLogHandler, qrcodeHandler *handler.QRCodeHandler, + materialHandler *handler.MaterialHandler, logService *service.LogService, activityService service.UserActivityService, casbinService service.CasbinService, @@ -73,6 +74,7 @@ func ProvideRouter( adminDashboardHandler, adminLogHandler, qrcodeHandler, + materialHandler, logService, activityService, casbinService, diff --git a/internal/handler/material_handler.go b/internal/handler/material_handler.go new file mode 100644 index 0000000..97d1dea --- /dev/null +++ b/internal/handler/material_handler.go @@ -0,0 +1,490 @@ +package handler + +import ( + "strconv" + + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/repository" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// MaterialHandler 学习资料处理器 +type MaterialHandler struct { + materialService service.MaterialService +} + +// NewMaterialHandler 创建学习资料处理器 +func NewMaterialHandler(materialService service.MaterialService) *MaterialHandler { + return &MaterialHandler{materialService: materialService} +} + +// ===================================================== +// 响应结构体 +// ===================================================== + +type subjectResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Color string `json:"color"` + Description string `json:"description,omitempty"` + SortOrder int `json:"sort_order"` + IsActive bool `json:"is_active"` + MaterialCount int `json:"material_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type materialResponse struct { + ID string `json:"id"` + SubjectID string `json:"subject_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + FileType string `json:"file_type"` + FileSize int64 `json:"file_size"` + FileURL string `json:"file_url"` + FileName string `json:"file_name"` + DownloadCount int `json:"download_count"` + AuthorID string `json:"author_id,omitempty"` + Tags []string `json:"tags,omitempty"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Subject *subjectResponse `json:"subject,omitempty"` +} + +// ===================================================== +// 转换函数 +// ===================================================== + +func toSubjectResponse(s *model.MaterialSubject, materialCount int64) *subjectResponse { + if s == nil { + return nil + } + return &subjectResponse{ + ID: s.ID, + Name: s.Name, + Icon: s.Icon, + Color: s.Color, + Description: s.Description, + SortOrder: s.SortOrder, + IsActive: s.IsActive, + MaterialCount: int(materialCount), + CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} + +func toMaterialResponse(f *model.MaterialFile) *materialResponse { + if f == nil { + return nil + } + var subjectResp *subjectResponse + if f.Subject != nil { + subjectResp = toSubjectResponse(f.Subject, 0) + } + return &materialResponse{ + ID: f.ID, + SubjectID: f.SubjectID, + Title: f.Title, + Description: f.Description, + FileType: string(f.FileType), + FileSize: f.FileSize, + FileURL: f.FileURL, + FileName: f.FileName, + DownloadCount: f.DownloadCount, + AuthorID: f.AuthorID, + Tags: f.Tags, + Status: string(f.Status), + CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: f.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + Subject: subjectResp, + } +} + +// ===================================================== +// 公开API - 学科相关 +// ===================================================== + +// ListSubjects 公开学科列表(仅启用) +func (h *MaterialHandler) ListSubjects(c *gin.Context) { + items, err := h.materialService.ListActiveSubjects() + if err != nil { + response.InternalServerError(c, "failed to get subjects") + return + } + result := make([]*subjectResponse, 0, len(items)) + for _, item := range items { + _, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID) + result = append(result, toSubjectResponse(item, count)) + } + response.Success(c, gin.H{"list": result}) +} + +// GetSubject 获取学科详情 +func (h *MaterialHandler) GetSubject(c *gin.Context) { + id := c.Param("id") + item, count, err := h.materialService.GetSubjectByIDWithMaterialCount(id) + if err != nil { + response.NotFound(c, "subject not found") + return + } + response.Success(c, toSubjectResponse(item, count)) +} + +// ===================================================== +// 公开API - 资料相关 +// ===================================================== + +// ListMaterials 资料列表 +func (h *MaterialHandler) ListMaterials(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + subjectID := c.Query("subject_id") + fileType := c.Query("file_type") + keyword := c.Query("keyword") + + params := repository.MaterialFileQueryParams{ + SubjectID: subjectID, + FileType: fileType, + Keyword: keyword, + Status: string(model.MaterialStatusActive), + Page: page, + PageSize: pageSize, + SortBy: "created_at", + SortOrder: "DESC", + } + + items, total, err := h.materialService.ListMaterials(params) + if err != nil { + response.InternalServerError(c, "failed to get materials") + return + } + + result := make([]*materialResponse, 0, len(items)) + for _, item := range items { + result = append(result, toMaterialResponse(item)) + } + + response.Success(c, gin.H{ + "list": result, + "total": total, + "page": page, + "page_size": pageSize, + "has_more": int64(page*pageSize) < total, + }) +} + +// GetMaterial 获取资料详情 +func (h *MaterialHandler) GetMaterial(c *gin.Context) { + id := c.Param("id") + item, err := h.materialService.GetMaterialByIDWithSubject(id) + if err != nil { + response.NotFound(c, "material not found") + return + } + response.Success(c, toMaterialResponse(item)) +} + +// SearchMaterials 搜索资料 +func (h *MaterialHandler) SearchMaterials(c *gin.Context) { + keyword := c.Query("keyword") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + items, total, err := h.materialService.SearchMaterials(keyword, page, pageSize) + if err != nil { + response.InternalServerError(c, "failed to search materials") + return + } + + result := make([]*materialResponse, 0, len(items)) + for _, item := range items { + result = append(result, toMaterialResponse(item)) + } + + response.Success(c, gin.H{ + "list": result, + "total": total, + "page": page, + "page_size": pageSize, + "has_more": int64(page*pageSize) < total, + }) +} + +// DownloadMaterial 下载资料(增加下载次数) +func (h *MaterialHandler) DownloadMaterial(c *gin.Context) { + id := c.Param("id") + item, err := h.materialService.GetMaterialByID(id) + if err != nil { + response.NotFound(c, "material not found") + return + } + // 增加下载次数 + _ = h.materialService.IncrementDownloadCount(id) + // 返回下载链接 + response.Success(c, gin.H{ + "id": item.ID, + "file_url": item.FileURL, + "file_name": item.FileName, + }) +} + +// ===================================================== +// 管理API - 学科相关 +// ===================================================== + +// AdminListSubjects 管理端学科列表(全部) +func (h *MaterialHandler) AdminListSubjects(c *gin.Context) { + items, err := h.materialService.ListAllSubjects() + if err != nil { + response.InternalServerError(c, "failed to get subjects") + return + } + result := make([]*subjectResponse, 0, len(items)) + for _, item := range items { + _, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID) + result = append(result, toSubjectResponse(item, count)) + } + response.Success(c, gin.H{"list": result}) +} + +type adminSubjectUpsertRequest struct { + Name string `json:"name" binding:"required,max=100"` + Icon string `json:"icon" binding:"max=50"` + Color string `json:"color" binding:"max=20"` + Description string `json:"description" binding:"max=255"` + SortOrder int `json:"sort_order"` + IsActive *bool `json:"is_active"` +} + +// AdminCreateSubject 创建学科 +func (h *MaterialHandler) AdminCreateSubject(c *gin.Context) { + var req adminSubjectUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + item, err := h.materialService.CreateSubject(req.Name, req.Icon, req.Color, req.Description, req.SortOrder, isActive) + if err != nil { + response.InternalServerError(c, "failed to create subject") + return + } + response.Success(c, toSubjectResponse(item, 0)) +} + +// AdminUpdateSubject 更新学科 +func (h *MaterialHandler) AdminUpdateSubject(c *gin.Context) { + id := c.Param("id") + var req adminSubjectUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + item, err := h.materialService.UpdateSubject(id, req.Name, req.Icon, req.Color, req.Description, req.SortOrder, isActive) + if err != nil { + response.InternalServerError(c, "failed to update subject") + return + } + _, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID) + response.Success(c, toSubjectResponse(item, count)) +} + +// AdminDeleteSubject 删除学科 +func (h *MaterialHandler) AdminDeleteSubject(c *gin.Context) { + id := c.Param("id") + if err := h.materialService.DeleteSubject(id); err != nil { + if err == service.ErrSubjectHasMaterials { + response.BadRequest(c, "学科下存在资料,无法删除") + return + } + response.InternalServerError(c, "failed to delete subject") + return + } + response.Success(c, gin.H{"success": true}) +} + +// ===================================================== +// 管理API - 资料相关 +// ===================================================== + +// AdminListMaterials 管理端资料列表 +func (h *MaterialHandler) AdminListMaterials(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + subjectID := c.Query("subject_id") + fileType := c.Query("file_type") + status := c.Query("status") + keyword := c.Query("keyword") + + params := repository.MaterialFileQueryParams{ + SubjectID: subjectID, + FileType: fileType, + Status: status, + Keyword: keyword, + Page: page, + PageSize: pageSize, + SortBy: "created_at", + SortOrder: "DESC", + } + + items, total, err := h.materialService.ListMaterials(params) + if err != nil { + response.InternalServerError(c, "failed to get materials") + return + } + + result := make([]*materialResponse, 0, len(items)) + for _, item := range items { + result = append(result, toMaterialResponse(item)) + } + + response.Success(c, gin.H{ + "list": result, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} + +type adminMaterialCreateRequest struct { + SubjectID string `json:"subject_id" binding:"required"` + Title string `json:"title" binding:"required,max=200"` + Description string `json:"description"` + FileType string `json:"file_type" binding:"required,oneof=pdf word ppt"` + FileSize int64 `json:"file_size"` + FileURL string `json:"file_url" binding:"required"` + FileName string `json:"file_name"` + Tags []string `json:"tags"` +} + +type adminMaterialUpdateRequest struct { + SubjectID string `json:"subject_id"` + Title string `json:"title" binding:"max=200"` + Description string `json:"description"` + FileType string `json:"file_type" binding:"omitempty,oneof=pdf word ppt"` + FileSize int64 `json:"file_size"` + FileURL string `json:"file_url"` + FileName string `json:"file_name"` + Tags []string `json:"tags"` + Status string `json:"status" binding:"omitempty,oneof=active inactive"` +} + +// AdminGetMaterial 管理端获取资料详情 +func (h *MaterialHandler) AdminGetMaterial(c *gin.Context) { + id := c.Param("id") + item, err := h.materialService.GetMaterialByIDWithSubject(id) + if err != nil { + response.NotFound(c, "material not found") + return + } + response.Success(c, toMaterialResponse(item)) +} + +// AdminCreateMaterial 创建资料 +func (h *MaterialHandler) AdminCreateMaterial(c *gin.Context) { + var req adminMaterialCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // 获取当前用户ID作为作者 + userID, exists := c.Get("user_id") + var authorID string + if exists { + authorID = userID.(string) + } + + file := &model.MaterialFile{ + SubjectID: req.SubjectID, + Title: req.Title, + Description: req.Description, + FileType: model.MaterialFileType(req.FileType), + FileSize: req.FileSize, + FileURL: req.FileURL, + FileName: req.FileName, + Tags: req.Tags, + Status: model.MaterialStatusActive, + AuthorID: authorID, + } + + if err := h.materialService.CreateMaterial(file); err != nil { + response.InternalServerError(c, "failed to create material") + return + } + response.Success(c, toMaterialResponse(file)) +} + +// AdminUpdateMaterial 更新资料 +func (h *MaterialHandler) AdminUpdateMaterial(c *gin.Context) { + id := c.Param("id") + var req adminMaterialUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + file, err := h.materialService.GetMaterialByID(id) + if err != nil { + response.NotFound(c, "material not found") + return + } + + // 更新字段 + if req.SubjectID != "" { + file.SubjectID = req.SubjectID + } + if req.Title != "" { + file.Title = req.Title + } + if req.Description != "" { + file.Description = req.Description + } + if req.FileType != "" { + file.FileType = model.MaterialFileType(req.FileType) + } + if req.FileSize > 0 { + file.FileSize = req.FileSize + } + if req.FileURL != "" { + file.FileURL = req.FileURL + } + if req.FileName != "" { + file.FileName = req.FileName + } + if req.Tags != nil { + file.Tags = req.Tags + } + if req.Status != "" { + file.Status = model.MaterialStatus(req.Status) + } + + if err := h.materialService.UpdateMaterial(file); err != nil { + response.InternalServerError(c, "failed to update material") + return + } + response.Success(c, toMaterialResponse(file)) +} + +// AdminDeleteMaterial 删除资料 +func (h *MaterialHandler) AdminDeleteMaterial(c *gin.Context) { + id := c.Param("id") + if err := h.materialService.DeleteMaterial(id); err != nil { + response.InternalServerError(c, "failed to delete material") + return + } + response.Success(c, gin.H{"success": true}) +} diff --git a/internal/model/init.go b/internal/model/init.go index fabe39d..a18e740 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -159,6 +159,10 @@ func autoMigrate(db *gorm.DB) error { &Role{}, &UserRole{}, &CasbinRule{}, + + // 学习资料相关 + &MaterialSubject{}, + &MaterialFile{}, ) if err != nil { return err @@ -184,6 +188,11 @@ func autoMigrate(db *gorm.DB) error { return fmt.Errorf("failed to seed channels: %w", err) } + // 初始化学习资料学科种子数据 + if err := seedMaterialSubjects(db); err != nil { + return fmt.Errorf("failed to seed material subjects: %w", err) + } + return nil } @@ -321,8 +330,18 @@ func seedPermissions(db *gorm.DB) error { return nil } -// seedChannels 初始化频道种子数据(按 slug 幂等) +// seedChannels 初始化频道种子数据(仅在表为空时) func seedChannels(db *gorm.DB) error { + // 检查是否已有频道数据 + var count int64 + if err := db.Model(&Channel{}).Count(&count).Error; err != nil { + return err + } + if count > 0 { + zap.L().Info("Channels already exist, skipping seed", zap.Int64("count", count)) + return nil + } + defaultChannels := []Channel{ {Name: "跳蚤市场", Slug: "market", Description: "二手交易与闲置发布", SortOrder: 10, IsActive: true}, {Name: "课程交流", Slug: "courses", Description: "课程资料、选课与学习讨论", SortOrder: 20, IsActive: true}, @@ -331,33 +350,45 @@ func seedChannels(db *gorm.DB) error { {Name: "保研出国", Slug: "graduate-abroad", Description: "保研、留学申请与经验分享", SortOrder: 50, IsActive: true}, } - for _, item := range defaultChannels { - var existing Channel - err := db.Where("slug = ?", item.Slug).First(&existing).Error - if err == nil { - updates := map[string]any{ - "name": item.Name, - "description": item.Description, - "sort_order": item.SortOrder, - "is_active": item.IsActive, - } - if uerr := db.Model(&existing).Updates(updates).Error; uerr != nil { - return uerr - } - continue - } - if err != nil && err != gorm.ErrRecordNotFound { - return err - } - if cerr := db.Create(&item).Error; cerr != nil { - return cerr - } + if err := db.Create(&defaultChannels).Error; err != nil { + return err } zap.L().Info("Seeded channels successfully", zap.Int("count", len(defaultChannels))) return nil } +// seedMaterialSubjects 初始化学习资料学科种子数据(仅在表为空时) +func seedMaterialSubjects(db *gorm.DB) error { + // 检查是否已有数据 + var count int64 + if err := db.Model(&MaterialSubject{}).Count(&count).Error; err != nil { + return err + } + if count > 0 { + zap.L().Info("Material subjects already exist, skipping seed", zap.Int64("count", count)) + return nil + } + + defaultSubjects := []MaterialSubject{ + {Name: "数学", Icon: "calculator-variant", Color: "#FF6B6B", Description: "高等数学、线性代数、概率论等", SortOrder: 10, IsActive: true}, + {Name: "英语", Icon: "translate", Color: "#4ECDC4", Description: "四六级、考研英语、托福雅思等", SortOrder: 20, IsActive: true}, + {Name: "物理", Icon: "atom", Color: "#45B7D1", Description: "大学物理、电磁学、力学等", SortOrder: 30, IsActive: true}, + {Name: "化学", Icon: "flask", Color: "#96CEB4", Description: "有机化学、无机化学、分析化学等", SortOrder: 40, IsActive: true}, + {Name: "计算机", Icon: "laptop", Color: "#FFEAA7", Description: "数据结构、算法、操作系统等", SortOrder: 50, IsActive: true}, + {Name: "经济管理", Icon: "chart-line", Color: "#DDA0DD", Description: "微观经济学、宏观经济学、管理学等", SortOrder: 60, IsActive: true}, + {Name: "法学", Icon: "scale-balance", Color: "#F39C12", Description: "民法、刑法、宪法等", SortOrder: 70, IsActive: true}, + {Name: "语文文学", Icon: "book-open-page-variant", Color: "#3498DB", Description: "古代文学、现代文学、写作等", SortOrder: 80, IsActive: true}, + } + + if err := db.Create(&defaultSubjects).Error; err != nil { + return err + } + + zap.L().Info("Seeded material subjects successfully", zap.Int("count", len(defaultSubjects))) + return nil +} + // CloseDB 关闭数据库连接 func CloseDB(db *gorm.DB) { if sqlDB, err := db.DB(); err == nil { diff --git a/internal/model/material.go b/internal/model/material.go new file mode 100644 index 0000000..a6ede43 --- /dev/null +++ b/internal/model/material.go @@ -0,0 +1,113 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// MaterialFileType 文件类型 +type MaterialFileType string + +const ( + MaterialFileTypePDF MaterialFileType = "pdf" + MaterialFileTypeWord MaterialFileType = "word" + MaterialFileTypePPT MaterialFileType = "ppt" +) + +// MaterialStatus 资料状态 +type MaterialStatus string + +const ( + MaterialStatusActive MaterialStatus = "active" + MaterialStatusInactive MaterialStatus = "inactive" +) + +// MaterialSubject 学科分类 +type MaterialSubject struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + Name string `json:"name" gorm:"type:varchar(100);not null"` + Icon string `json:"icon" gorm:"type:varchar(50)"` // MaterialCommunityIcons name + Color string `json:"color" gorm:"type:varchar(20)"` // 主题色 + Description string `json:"description" gorm:"type:varchar(255)"` // 描述 + SortOrder int `json:"sort_order" gorm:"default:0;index"` // 排序权重 + IsActive bool `json:"is_active" gorm:"default:true;index"` // 是否启用 + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + + // 关联 + Materials []MaterialFile `json:"materials,omitempty" gorm:"foreignKey:SubjectID"` +} + +// BeforeCreate 创建前生成UUID +func (s *MaterialSubject) BeforeCreate(tx *gorm.DB) error { + if s.ID == "" { + s.ID = uuid.New().String() + } + return nil +} + +func (MaterialSubject) TableName() string { + return "material_subjects" +} + +// StringArray 字符串数组类型(用于tags) +type StringArray []string + +// Value 实现 driver.Valuer 接口 +func (a StringArray) Value() (driver.Value, error) { + if a == nil { + return nil, nil + } + return json.Marshal(a) +} + +// Scan 实现 sql.Scanner 接口 +func (a *StringArray) Scan(value interface{}) error { + if value == nil { + *a = nil + return nil + } + bytes, ok := value.([]byte) + if !ok { + return nil + } + return json.Unmarshal(bytes, a) +} + +// MaterialFile 文件资料 +type MaterialFile struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + SubjectID string `json:"subject_id" gorm:"type:varchar(36);index;not null"` + Title string `json:"title" gorm:"type:varchar(200);not null"` + Description string `json:"description" gorm:"type:text"` + FileType MaterialFileType `json:"file_type" gorm:"type:varchar(20);not null;index"` + FileSize int64 `json:"file_size" gorm:"default:0"` // 文件大小(字节) + FileURL string `json:"file_url" gorm:"type:text;not null"` // 文件URL + FileName string `json:"file_name" gorm:"type:varchar(255)"` // 原始文件名 + DownloadCount int `json:"download_count" gorm:"default:0"` // 下载次数 + AuthorID string `json:"author_id" gorm:"type:varchar(36);index"` // 上传者ID + Tags StringArray `json:"tags" gorm:"type:json"` // 标签 + Status MaterialStatus `json:"status" gorm:"type:varchar(20);default:active;index"` // 状态 + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + + // 关联 + Subject *MaterialSubject `json:"subject,omitempty" gorm:"foreignKey:SubjectID"` + Author *User `json:"author,omitempty" gorm:"foreignKey:AuthorID"` +} + +// BeforeCreate 创建前生成UUID +func (f *MaterialFile) BeforeCreate(tx *gorm.DB) error { + if f.ID == "" { + f.ID = uuid.New().String() + } + return nil +} + +func (MaterialFile) TableName() string { + return "material_files" +} diff --git a/internal/repository/material_repo.go b/internal/repository/material_repo.go new file mode 100644 index 0000000..77e8f68 --- /dev/null +++ b/internal/repository/material_repo.go @@ -0,0 +1,247 @@ +package repository + +import ( + "carrot_bbs/internal/model" + + "gorm.io/gorm" +) + +// MaterialSubjectRepository 学科分类仓储 +type MaterialSubjectRepository struct { + db *gorm.DB +} + +func NewMaterialSubjectRepository(db *gorm.DB) *MaterialSubjectRepository { + return &MaterialSubjectRepository{db: db} +} + +// ListActive 获取所有启用的学科分类 +func (r *MaterialSubjectRepository) ListActive() ([]*model.MaterialSubject, error) { + var subjects []*model.MaterialSubject + err := r.db. + Where("is_active = ?", true). + Order("sort_order ASC, created_at ASC"). + Find(&subjects).Error + return subjects, err +} + +// ListAll 获取所有学科分类(管理端用) +func (r *MaterialSubjectRepository) ListAll() ([]*model.MaterialSubject, error) { + var subjects []*model.MaterialSubject + err := r.db. + Order("sort_order ASC, created_at ASC"). + Find(&subjects).Error + return subjects, err +} + +// GetByID 根据ID获取学科分类 +func (r *MaterialSubjectRepository) GetByID(id string) (*model.MaterialSubject, error) { + var subject model.MaterialSubject + if err := r.db.First(&subject, "id = ?", id).Error; err != nil { + return nil, err + } + return &subject, nil +} + +// Create 创建学科分类 +func (r *MaterialSubjectRepository) Create(subject *model.MaterialSubject) error { + return r.db.Create(subject).Error +} + +// Update 更新学科分类 +func (r *MaterialSubjectRepository) Update(subject *model.MaterialSubject) error { + return r.db.Save(subject).Error +} + +// Delete 删除学科分类 +func (r *MaterialSubjectRepository) Delete(id string) error { + return r.db.Delete(&model.MaterialSubject{}, "id = ?", id).Error +} + +// CountMaterials 统计学科下的资料数量 +func (r *MaterialSubjectRepository) CountMaterials(subjectID string) (int64, error) { + var count int64 + err := r.db.Model(&model.MaterialFile{}).Where("subject_id = ?", subjectID).Count(&count).Error + return count, err +} + +// MaterialFileRepository 文件资料仓储 +type MaterialFileRepository struct { + db *gorm.DB +} + +func NewMaterialFileRepository(db *gorm.DB) *MaterialFileRepository { + return &MaterialFileRepository{db: db} +} + +// ListQueryParams 查询参数 +type MaterialFileQueryParams struct { + SubjectID string + FileType string + Status string + Keyword string + Page int + PageSize int + SortBy string + SortOrder string +} + +// List 获取资料列表(支持筛选和分页) +func (r *MaterialFileRepository) List(params MaterialFileQueryParams) ([]*model.MaterialFile, int64, error) { + var files []*model.MaterialFile + var total int64 + + query := r.db.Model(&model.MaterialFile{}) + + // 筛选条件 + if params.SubjectID != "" { + query = query.Where("subject_id = ?", params.SubjectID) + } + if params.FileType != "" { + query = query.Where("file_type = ?", params.FileType) + } + if params.Status != "" { + query = query.Where("status = ?", params.Status) + } + if params.Keyword != "" { + keyword := "%" + params.Keyword + "%" + query = query.Where("title LIKE ? OR description LIKE ?", keyword, keyword) + } + + // 统计总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 排序 + sortBy := params.SortBy + if sortBy == "" { + sortBy = "created_at" + } + sortOrder := params.SortOrder + if sortOrder == "" { + sortOrder = "DESC" + } + orderClause := sortBy + " " + sortOrder + + // 分页 + page := params.Page + if page < 1 { + page = 1 + } + pageSize := params.PageSize + if pageSize < 1 { + pageSize = 20 + } + offset := (page - 1) * pageSize + + // 查询 + if err := query.Order(orderClause).Offset(offset).Limit(pageSize).Find(&files).Error; err != nil { + return nil, 0, err + } + + return files, total, nil +} + +// GetByID 根据ID获取资料详情 +func (r *MaterialFileRepository) GetByID(id string) (*model.MaterialFile, error) { + var file model.MaterialFile + if err := r.db.First(&file, "id = ?", id).Error; err != nil { + return nil, err + } + return &file, nil +} + +// GetByIDWithSubject 根据ID获取资料详情(包含学科信息) +func (r *MaterialFileRepository) GetByIDWithSubject(id string) (*model.MaterialFile, error) { + var file model.MaterialFile + if err := r.db.Preload("Subject").First(&file, "id = ?", id).Error; err != nil { + return nil, err + } + return &file, nil +} + +// Create 创建资料 +func (r *MaterialFileRepository) Create(file *model.MaterialFile) error { + return r.db.Create(file).Error +} + +// Update 更新资料 +func (r *MaterialFileRepository) Update(file *model.MaterialFile) error { + return r.db.Save(file).Error +} + +// Delete 删除资料 +func (r *MaterialFileRepository) Delete(id string) error { + return r.db.Delete(&model.MaterialFile{}, "id = ?", id).Error +} + +// IncrementDownloadCount 增加下载次数 +func (r *MaterialFileRepository) IncrementDownloadCount(id string) error { + return r.db.Model(&model.MaterialFile{}).Where("id = ?", id). + UpdateColumn("download_count", gorm.Expr("download_count + ?", 1)).Error +} + +// GetBySubjectID 根据学科ID获取资料列表 +func (r *MaterialFileRepository) GetBySubjectID(subjectID string, page, pageSize int) ([]*model.MaterialFile, int64, error) { + var files []*model.MaterialFile + var total int64 + + query := r.db.Model(&model.MaterialFile{}).Where("subject_id = ? AND status = ?", subjectID, model.MaterialStatusActive) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&files).Error; err != nil { + return nil, 0, err + } + + return files, total, nil +} + +// Search 搜索资料 +func (r *MaterialFileRepository) Search(keyword string, page, pageSize int) ([]*model.MaterialFile, int64, error) { + var files []*model.MaterialFile + var total int64 + + query := r.db.Model(&model.MaterialFile{}).Where("status = ?", model.MaterialStatusActive) + if keyword != "" { + kw := "%" + keyword + "%" + query = query.Where("title LIKE ? OR description LIKE ?", kw, kw) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&files).Error; err != nil { + return nil, 0, err + } + + return files, total, nil +} + +// GetHotMaterials 获取热门资料(按下载次数排序) +func (r *MaterialFileRepository) GetHotMaterials(limit int) ([]*model.MaterialFile, error) { + var files []*model.MaterialFile + err := r.db. + Where("status = ?", model.MaterialStatusActive). + Order("download_count DESC"). + Limit(limit). + Find(&files).Error + return files, err +} + +// GetLatestMaterials 获取最新资料 +func (r *MaterialFileRepository) GetLatestMaterials(limit int) ([]*model.MaterialFile, error) { + var files []*model.MaterialFile + err := r.db. + Where("status = ?", model.MaterialStatusActive). + Order("created_at DESC"). + Limit(limit). + Find(&files).Error + return files, err +} \ No newline at end of file diff --git a/internal/router/router.go b/internal/router/router.go index 6fe80a7..d659d54 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -33,6 +33,7 @@ type Router struct { adminDashboardHandler *handler.AdminDashboardHandler adminLogHandler *handler.AdminLogHandler qrcodeHandler *handler.QRCodeHandler + materialHandler *handler.MaterialHandler logService *service.LogService jwtService *service.JWTService casbinService service.CasbinService @@ -62,6 +63,7 @@ func New( adminDashboardHandler *handler.AdminDashboardHandler, adminLogHandler *handler.AdminLogHandler, qrcodeHandler *handler.QRCodeHandler, + materialHandler *handler.MaterialHandler, logService *service.LogService, activityService service.UserActivityService, casbinService service.CasbinService, @@ -94,6 +96,7 @@ func New( adminDashboardHandler: adminDashboardHandler, adminLogHandler: adminLogHandler, qrcodeHandler: qrcodeHandler, + materialHandler: materialHandler, logService: logService, jwtService: jwtService, casbinService: casbinService, @@ -262,6 +265,21 @@ func (r *Router) setupRoutes() { } } + // 学习资料路由(公开) + if r.materialHandler != nil { + materials := v1.Group("/materials") + { + // 学科相关 + materials.GET("/subjects", r.materialHandler.ListSubjects) + materials.GET("/subjects/:id", r.materialHandler.GetSubject) + // 资料相关 + materials.GET("", r.materialHandler.ListMaterials) + materials.GET("/search", r.materialHandler.SearchMaterials) + materials.GET("/:id", r.materialHandler.GetMaterial) + materials.GET("/:id/download", r.materialHandler.DownloadMaterial) + } + } + // 投票选项路由 voteOptions := v1.Group("/vote-options") voteOptions.Use(authMiddleware) @@ -508,6 +526,21 @@ func (r *Router) setupRoutes() { logs.GET("/export", r.adminLogHandler.ExportLogs) } } + + // 学习资料管理 + if r.materialHandler != nil { + // 学科管理 + admin.GET("/materials/subjects", r.materialHandler.AdminListSubjects) + admin.POST("/materials/subjects", r.materialHandler.AdminCreateSubject) + admin.PUT("/materials/subjects/:id", r.materialHandler.AdminUpdateSubject) + admin.DELETE("/materials/subjects/:id", r.materialHandler.AdminDeleteSubject) + // 资料管理 + admin.GET("/materials", r.materialHandler.AdminListMaterials) + admin.GET("/materials/:id", r.materialHandler.AdminGetMaterial) + admin.POST("/materials", r.materialHandler.AdminCreateMaterial) + admin.PUT("/materials/:id", r.materialHandler.AdminUpdateMaterial) + admin.DELETE("/materials/:id", r.materialHandler.AdminDeleteMaterial) + } } } } diff --git a/internal/service/material_service.go b/internal/service/material_service.go new file mode 100644 index 0000000..57d7d11 --- /dev/null +++ b/internal/service/material_service.go @@ -0,0 +1,172 @@ +package service + +import ( + "errors" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" +) + +// ErrSubjectHasMaterials 学科下存在资料,无法删除 +var ErrSubjectHasMaterials = errors.New("学科下存在资料,无法删除") + +// MaterialService 学习资料服务接口 +type MaterialService interface { + // 学科相关 + ListActiveSubjects() ([]*model.MaterialSubject, error) + ListAllSubjects() ([]*model.MaterialSubject, error) + GetSubjectByID(id string) (*model.MaterialSubject, error) + GetSubjectByIDWithMaterialCount(id string) (*model.MaterialSubject, int64, error) + CreateSubject(name, icon, color, description string, sortOrder int, isActive bool) (*model.MaterialSubject, error) + UpdateSubject(id, name, icon, color, description string, sortOrder int, isActive bool) (*model.MaterialSubject, error) + DeleteSubject(id string) error + + // 资料相关 + ListMaterials(params repository.MaterialFileQueryParams) ([]*model.MaterialFile, int64, error) + GetMaterialByID(id string) (*model.MaterialFile, error) + GetMaterialByIDWithSubject(id string) (*model.MaterialFile, error) + GetMaterialsBySubject(subjectID string, page, pageSize int) ([]*model.MaterialFile, int64, error) + SearchMaterials(keyword string, page, pageSize int) ([]*model.MaterialFile, int64, error) + GetHotMaterials(limit int) ([]*model.MaterialFile, error) + GetLatestMaterials(limit int) ([]*model.MaterialFile, error) + CreateMaterial(file *model.MaterialFile) error + UpdateMaterial(file *model.MaterialFile) error + DeleteMaterial(id string) error + IncrementDownloadCount(id string) error +} + +// materialServiceImpl 学习资料服务实现 +type materialServiceImpl struct { + subjectRepo *repository.MaterialSubjectRepository + fileRepo *repository.MaterialFileRepository +} + +// NewMaterialService 创建学习资料服务 +func NewMaterialService( + subjectRepo *repository.MaterialSubjectRepository, + fileRepo *repository.MaterialFileRepository, +) MaterialService { + return &materialServiceImpl{ + subjectRepo: subjectRepo, + fileRepo: fileRepo, + } +} + +// ===================================================== +// 学科相关方法 +// ===================================================== + +func (s *materialServiceImpl) ListActiveSubjects() ([]*model.MaterialSubject, error) { + return s.subjectRepo.ListActive() +} + +func (s *materialServiceImpl) ListAllSubjects() ([]*model.MaterialSubject, error) { + return s.subjectRepo.ListAll() +} + +func (s *materialServiceImpl) GetSubjectByID(id string) (*model.MaterialSubject, error) { + return s.subjectRepo.GetByID(id) +} + +func (s *materialServiceImpl) GetSubjectByIDWithMaterialCount(id string) (*model.MaterialSubject, int64, error) { + subject, err := s.subjectRepo.GetByID(id) + if err != nil { + return nil, 0, err + } + count, err := s.subjectRepo.CountMaterials(id) + if err != nil { + return subject, 0, nil + } + return subject, count, nil +} + +func (s *materialServiceImpl) CreateSubject(name, icon, color, description string, sortOrder int, isActive bool) (*model.MaterialSubject, error) { + subject := &model.MaterialSubject{ + Name: name, + Icon: icon, + Color: color, + Description: description, + SortOrder: sortOrder, + IsActive: isActive, + } + if err := s.subjectRepo.Create(subject); err != nil { + return nil, err + } + return subject, nil +} + +func (s *materialServiceImpl) UpdateSubject(id, name, icon, color, description string, sortOrder int, isActive bool) (*model.MaterialSubject, error) { + subject, err := s.subjectRepo.GetByID(id) + if err != nil { + return nil, err + } + subject.Name = name + subject.Icon = icon + subject.Color = color + subject.Description = description + subject.SortOrder = sortOrder + subject.IsActive = isActive + if err := s.subjectRepo.Update(subject); err != nil { + return nil, err + } + return subject, nil +} + +func (s *materialServiceImpl) DeleteSubject(id string) error { + // 检查学科下是否有资料 + count, err := s.subjectRepo.CountMaterials(id) + if err != nil { + return err + } + if count > 0 { + return ErrSubjectHasMaterials + } + return s.subjectRepo.Delete(id) +} + +// ===================================================== +// 资料相关方法 +// ===================================================== + +func (s *materialServiceImpl) ListMaterials(params repository.MaterialFileQueryParams) ([]*model.MaterialFile, int64, error) { + return s.fileRepo.List(params) +} + +func (s *materialServiceImpl) GetMaterialByID(id string) (*model.MaterialFile, error) { + return s.fileRepo.GetByID(id) +} + +func (s *materialServiceImpl) GetMaterialByIDWithSubject(id string) (*model.MaterialFile, error) { + return s.fileRepo.GetByIDWithSubject(id) +} + +func (s *materialServiceImpl) GetMaterialsBySubject(subjectID string, page, pageSize int) ([]*model.MaterialFile, int64, error) { + return s.fileRepo.GetBySubjectID(subjectID, page, pageSize) +} + +func (s *materialServiceImpl) SearchMaterials(keyword string, page, pageSize int) ([]*model.MaterialFile, int64, error) { + return s.fileRepo.Search(keyword, page, pageSize) +} + +func (s *materialServiceImpl) GetHotMaterials(limit int) ([]*model.MaterialFile, error) { + return s.fileRepo.GetHotMaterials(limit) +} + +func (s *materialServiceImpl) GetLatestMaterials(limit int) ([]*model.MaterialFile, error) { + return s.fileRepo.GetLatestMaterials(limit) +} + +func (s *materialServiceImpl) CreateMaterial(file *model.MaterialFile) error { + return s.fileRepo.Create(file) +} + +func (s *materialServiceImpl) UpdateMaterial(file *model.MaterialFile) error { + return s.fileRepo.Update(file) +} + +func (s *materialServiceImpl) DeleteMaterial(id string) error { + return s.fileRepo.Delete(id) +} + +func (s *materialServiceImpl) IncrementDownloadCount(id string) error { + return s.fileRepo.IncrementDownloadCount(id) +} \ No newline at end of file diff --git a/internal/wire/handler.go b/internal/wire/handler.go index 2ea8680..df36028 100644 --- a/internal/wire/handler.go +++ b/internal/wire/handler.go @@ -28,6 +28,7 @@ var HandlerSet = wire.NewSet( handler.NewAdminDashboardHandler, handler.NewAdminLogHandler, handler.NewQRCodeHandler, + handler.NewMaterialHandler, // 需要特殊处理的 Handler ProvideUserHandler, diff --git a/internal/wire/repository.go b/internal/wire/repository.go index 9683368..a8d6b92 100644 --- a/internal/wire/repository.go +++ b/internal/wire/repository.go @@ -23,6 +23,8 @@ var RepositorySet = wire.NewSet( repository.NewVoteRepository, repository.NewChannelRepository, repository.NewScheduleRepository, + repository.NewMaterialSubjectRepository, + repository.NewMaterialFileRepository, ProvideUserActivityRepository, // 日志相关仓储 diff --git a/internal/wire/service.go b/internal/wire/service.go index 26760ca..d2e37be 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -54,6 +54,7 @@ var ServiceSet = wire.NewSet( ProvideAdminDashboardService, ProvideQRCodeLoginService, ProvideHotRankWorker, + ProvideMaterialService, // 日志服务 ProvideAsyncLogManager, @@ -369,6 +370,14 @@ func ProvideQRCodeLoginService( return service.NewQRCodeLoginService(redisClient, sseHub, jwtService, userService, activityService, logService) } +// ProvideMaterialService 提供学习资料服务 +func ProvideMaterialService( + subjectRepo *repository.MaterialSubjectRepository, + fileRepo *repository.MaterialFileRepository, +) service.MaterialService { + return service.NewMaterialService(subjectRepo, fileRepo) +} + // parseDuration 解析时长字符串 func parseDuration(s string) (time.Duration, error) { return time.ParseDuration(s)