feat(material): add material handling and routing functionality
Some checks failed
Build Backend / build (push) Failing after 12m1s
Build Backend / build-docker (push) Has been skipped

- Introduced MaterialHandler for managing learning materials and subjects.
- Updated router to include routes for public and admin material management.
- Enhanced database initialization with material subject and file models.
- Implemented seeding logic for material subjects and files.
- Updated wire generation to include MaterialHandler and related services.
This commit is contained in:
lafay
2026-03-25 20:44:12 +08:00
parent 28a45caad3
commit 0ef8e5a7e1
10 changed files with 1122 additions and 22 deletions

View File

@@ -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,

View File

@@ -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})
}

View File

@@ -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 {

113
internal/model/material.go Normal file
View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}

View File

@@ -28,6 +28,7 @@ var HandlerSet = wire.NewSet(
handler.NewAdminDashboardHandler,
handler.NewAdminLogHandler,
handler.NewQRCodeHandler,
handler.NewMaterialHandler,
// 需要特殊处理的 Handler
ProvideUserHandler,

View File

@@ -23,6 +23,8 @@ var RepositorySet = wire.NewSet(
repository.NewVoteRepository,
repository.NewChannelRepository,
repository.NewScheduleRepository,
repository.NewMaterialSubjectRepository,
repository.NewMaterialFileRepository,
ProvideUserActivityRepository,
// 日志相关仓储

View File

@@ -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)