diff --git a/backend/database/migrate.go b/backend/database/migrate.go index 7bff6b8..4bceaab 100644 --- a/backend/database/migrate.go +++ b/backend/database/migrate.go @@ -32,6 +32,9 @@ func Migrate(db *gorm.DB) error { &models.Attachment{}, &models.Label{}, &models.TaskLabel{}, + + // 活动日志 + &models.ActivityLog{}, ) if err != nil { diff --git a/backend/dto/ActivityLogResponse.go b/backend/dto/ActivityLogResponse.go new file mode 100644 index 0000000..2f83785 --- /dev/null +++ b/backend/dto/ActivityLogResponse.go @@ -0,0 +1,30 @@ +package dto + +import "time" + +// ActivityLogResponse 活动日志响应 +type ActivityLogResponse struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + ActionType string `json:"action_type"` + EntityType string `json:"entity_type"` + EntityID uint `json:"entity_id"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +// ActivityLogListResponse 活动日志列表响应 +type ActivityLogListResponse struct { + Data []ActivityLogResponse `json:"data"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// PaginationQuery 分页查询参数 +type PaginationQuery struct { + Page int `form:"page" binding:"omitempty,min=1"` + PageSize int `form:"limit" binding:"omitempty,min=1,max=100"` +} diff --git a/backend/handlers/activity/board_activities.go b/backend/handlers/activity/board_activities.go new file mode 100644 index 0000000..b34d8f2 --- /dev/null +++ b/backend/handlers/activity/board_activities.go @@ -0,0 +1,115 @@ +package activity + +import ( + "math" + "net/http" + "progress-wall-backend/dto" + "progress-wall-backend/models" + "progress-wall-backend/repository" + "progress-wall-backend/services" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// BoardActivitiesHandler 看板活动日志处理器adctiv +type BoardActivitiesHandler struct { + activityService *services.ActivityLogService +} + +// NewBoardActivitiesHandler 创建看板活动日志处理器 +func NewBoardActivitiesHandler(db *gorm.DB) *BoardActivitiesHandler { + activityRepo := repository.NewActivityLogRepository(db) + activityService := services.NewActivityLogService(activityRepo, db) + + return &BoardActivitiesHandler{ + activityService: activityService, + } +} + +// GetBoardActivities 获取看板活动日志 +// GET /api/boards/:board_id/activities +func (h *BoardActivitiesHandler) GetBoardActivities(c *gin.Context) { + // 获取当前登录用户ID + userID := c.GetUint("user_id") + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + + // 获取看板ID + boardIDStr := c.Param("board_id") + boardID, err := strconv.ParseUint(boardIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的看板ID"}) + return + } + + // 验证用户是否有权访问该看板 + if err := h.activityService.VerifyBoardAccess(userID, uint(boardID)); err != nil { + switch err { + case services.ErrBoardNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "看板不存在"}) + case services.ErrAccessDenied: + c.JSON(http.StatusForbidden, gin.H{"error": "没有权限访问该看板"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "验证权限失败"}) + } + return + } + + // 获取分页参数 + var query dto.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分页参数"}) + return + } + + // 设置默认值 + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + + // 获取活动日志 + logs, total, err := h.activityService.GetBoardActivities(uint(boardID), query.Page, query.PageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取活动日志失败"}) + return + } + + // 转换为响应格式 + activities := make([]dto.ActivityLogResponse, len(logs)) + for i, log := range logs { + activities[i] = convertToActivityLogResponse(log) + } + + // 计算总页数 + totalPages := int(math.Ceil(float64(total) / float64(query.PageSize))) + + // 返回响应 + c.JSON(http.StatusOK, dto.ActivityLogListResponse{ + Data: activities, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + TotalPages: totalPages, + }) +} + +// convertToActivityLogResponse 转换模型为响应DTO +func convertToActivityLogResponse(log models.ActivityLog) dto.ActivityLogResponse { + return dto.ActivityLogResponse{ + ID: log.ID, + UserID: log.UserID, + Username: log.Username, + ActionType: log.ActionType, + EntityType: log.EntityType, + EntityID: log.EntityID, + Description: log.Description, + CreatedAt: log.CreatedAt, + } +} diff --git a/backend/handlers/activity/task_activities.go b/backend/handlers/activity/task_activities.go new file mode 100644 index 0000000..b6fa1f1 --- /dev/null +++ b/backend/handlers/activity/task_activities.go @@ -0,0 +1,100 @@ +package activity + +import ( + "math" + "net/http" + "progress-wall-backend/dto" + "progress-wall-backend/repository" + "progress-wall-backend/services" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// TaskActivitiesHandler 任务活动日志处理器 +type TaskActivitiesHandler struct { + activityService *services.ActivityLogService +} + +// NewTaskActivitiesHandler 创建任务活动日志处理器 +func NewTaskActivitiesHandler(db *gorm.DB) *TaskActivitiesHandler { + activityRepo := repository.NewActivityLogRepository(db) + activityService := services.NewActivityLogService(activityRepo, db) + + return &TaskActivitiesHandler{ + activityService: activityService, + } +} + +// GetTaskActivities 获取任务活动日志 +// GET /api/tasks/:task_id/activities +func (h *TaskActivitiesHandler) GetTaskActivities(c *gin.Context) { + // 获取当前登录用户ID + userID := c.GetUint("user_id") + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + + // 获取任务ID + taskIDStr := c.Param("task_id") + taskID, err := strconv.ParseUint(taskIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的任务ID"}) + return + } + + // 验证用户是否有权访问该任务 + if err := h.activityService.VerifyTaskAccess(userID, uint(taskID)); err != nil { + switch err { + case services.ErrTaskNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"}) + case services.ErrAccessDenied: + c.JSON(http.StatusForbidden, gin.H{"error": "没有权限访问该任务"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "验证权限失败"}) + } + return + } + + // 获取分页参数 + var query dto.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分页参数"}) + return + } + + // 设置默认值 + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + + // 获取活动日志 + logs, total, err := h.activityService.GetTaskActivities(uint(taskID), query.Page, query.PageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取活动日志失败"}) + return + } + + // 转换为响应格式 + activities := make([]dto.ActivityLogResponse, len(logs)) + for i, log := range logs { + activities[i] = convertToActivityLogResponse(log) + } + + // 计算总页数 + totalPages := int(math.Ceil(float64(total) / float64(query.PageSize))) + + // 返回响应 + c.JSON(http.StatusOK, dto.ActivityLogListResponse{ + Data: activities, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + TotalPages: totalPages, + }) +} diff --git a/backend/models/activity_log.go b/backend/models/activity_log.go new file mode 100644 index 0000000..39e659c --- /dev/null +++ b/backend/models/activity_log.go @@ -0,0 +1,62 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// ActivityLog 活动日志表 +type ActivityLog struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + UserID uint `json:"user_id" gorm:"not null;index;comment:'执行操作的用户ID'"` + Username string `json:"username" gorm:"size:50;not null;comment:'执行操作的用户名(冗余字段,优化查询)'"` + ActionType string `json:"action_type" gorm:"size:50;not null;index;comment:'操作类型:create/update/delete/move/comment等'"` + EntityType string `json:"entity_type" gorm:"size:50;not null;index;comment:'实体类型:board/task/comment/attachment等'"` + EntityID uint `json:"entity_id" gorm:"not null;index;comment:'实体ID'"` + BoardID *uint `json:"board_id" gorm:"index;comment:'关联的看板ID(用于看板级别查询)'"` + TaskID *uint `json:"task_id" gorm:"index;comment:'关联的任务ID(用于任务级别查询)'"` + ProjectID *uint `json:"project_id" gorm:"index;comment:'关联的项目ID'"` + Description string `json:"description" gorm:"type:text;not null;comment:'操作描述文本'"` + Metadata string `json:"metadata" gorm:"type:json;comment:'额外的元数据(JSON格式)'"` + IPAddress string `json:"ip_address" gorm:"size:45;comment:'操作者IP地址'"` + UserAgent string `json:"user_agent" gorm:"size:255;comment:'用户代理字符串'"` + CreatedAt time.Time `json:"created_at" gorm:"index"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + // 关联关系 + User User `json:"user,omitempty" gorm:"foreignKey:UserID"` + Board *Board `json:"board,omitempty" gorm:"foreignKey:BoardID"` + Task *Task `json:"task,omitempty" gorm:"foreignKey:TaskID"` + Project *Project `json:"project,omitempty" gorm:"foreignKey:ProjectID"` +} + +// TableName 指定表名 +func (ActivityLog) TableName() string { + return "activity_logs" +} + +// ActivityActionType 定义常用的操作类型 +const ( + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionMove = "move" + ActionComment = "comment" + ActionAttach = "attach" + ActionAssign = "assign" + ActionArchive = "archive" + ActionRestore = "restore" +) + +// ActivityEntityType 定义常用的实体类型 +const ( + EntityBoard = "board" + EntityColumn = "column" + EntityTask = "task" + EntityComment = "comment" + EntityAttachment = "attachment" + EntityLabel = "label" + EntityProject = "project" +) diff --git a/backend/repository/ActivityLogRepository.go b/backend/repository/ActivityLogRepository.go new file mode 100644 index 0000000..091a8b0 --- /dev/null +++ b/backend/repository/ActivityLogRepository.go @@ -0,0 +1,105 @@ +package repository + +import ( + "progress-wall-backend/models" + + "gorm.io/gorm" +) + +// ActivityLogRepository 活动日志仓储接口 +type ActivityLogRepository interface { + // Create 创建活动日志 + Create(log *models.ActivityLog) error + + // GetByBoardID 根据看板ID获取活动日志(支持分页) + GetByBoardID(boardID uint, offset, limit int) ([]models.ActivityLog, int64, error) + + // GetByTaskID 根据任务ID获取活动日志(支持分页) + GetByTaskID(taskID uint, offset, limit int) ([]models.ActivityLog, int64, error) + + // GetByProjectID 根据项目ID获取活动日志(支持分页) + GetByProjectID(projectID uint, offset, limit int) ([]models.ActivityLog, int64, error) +} + +type activityLogRepository struct { + db *gorm.DB +} + +// NewActivityLogRepository 创建活动日志仓储实例 +func NewActivityLogRepository(db *gorm.DB) ActivityLogRepository { + return &activityLogRepository{db: db} +} + +// Create 创建活动日志 +func (r *activityLogRepository) Create(log *models.ActivityLog) error { + return r.db.Create(log).Error +} + +// GetByBoardID 根据看板ID获取活动日志(支持分页) +func (r *activityLogRepository) GetByBoardID(boardID uint, offset, limit int) ([]models.ActivityLog, int64, error) { + var logs []models.ActivityLog + var total int64 + + // 查询总数 + if err := r.db.Model(&models.ActivityLog{}). + Where("board_id = ?", boardID). + Count(&total).Error; err != nil { + return nil, 0, err + } + + // 查询分页数据,预加载用户信息 + err := r.db.Where("board_id = ?", boardID). + Preload("User"). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&logs).Error + + return logs, total, err +} + +// GetByTaskID 根据任务ID获取活动日志(支持分页) +func (r *activityLogRepository) GetByTaskID(taskID uint, offset, limit int) ([]models.ActivityLog, int64, error) { + var logs []models.ActivityLog + var total int64 + + // 查询总数 + if err := r.db.Model(&models.ActivityLog{}). + Where("task_id = ?", taskID). + Count(&total).Error; err != nil { + return nil, 0, err + } + + // 查询分页数据,预加载用户信息 + err := r.db.Where("task_id = ?", taskID). + Preload("User"). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&logs).Error + + return logs, total, err +} + +// GetByProjectID 根据项目ID获取活动日志(支持分页) +func (r *activityLogRepository) GetByProjectID(projectID uint, offset, limit int) ([]models.ActivityLog, int64, error) { + var logs []models.ActivityLog + var total int64 + + // 查询总数 + if err := r.db.Model(&models.ActivityLog{}). + Where("project_id = ?", projectID). + Count(&total).Error; err != nil { + return nil, 0, err + } + + // 查询分页数据,预加载用户信息 + err := r.db.Where("project_id = ?", projectID). + Preload("User"). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&logs).Error + + return logs, total, err +} diff --git a/backend/routes/routes.go b/backend/routes/routes.go index fdd9939..126e05b 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -2,6 +2,7 @@ package routes import ( "progress-wall-backend/config" + "progress-wall-backend/handlers/activity" "progress-wall-backend/handlers/auth" "progress-wall-backend/handlers/user" "progress-wall-backend/middleware" @@ -33,6 +34,8 @@ func SetupRoutes(db *gorm.DB, cfg *config.Config) *gin.Engine { loginHandler := auth.NewLoginHandler(db, cfg) registerHandler := auth.NewRegisterHandler(db, cfg) profileHandler := user.NewProfileHandler(db) + boardActivitiesHandler := activity.NewBoardActivitiesHandler(db) + taskActivitiesHandler := activity.NewTaskActivitiesHandler(db) // 公开路由(不需要认证) api := r.Group("/api") @@ -48,7 +51,14 @@ func SetupRoutes(db *gorm.DB, cfg *config.Config) *gin.Engine { protected := api.Group("") protected.Use(middleware.AuthMiddleware(cfg)) { + // 用户相关 protected.GET("/user/profile", profileHandler.GetProfile) + + // 看板活动日志 + protected.GET("/boards/:board_id/activities", boardActivitiesHandler.GetBoardActivities) + + // 任务活动日志 + protected.GET("/tasks/:task_id/activities", taskActivitiesHandler.GetTaskActivities) } return r diff --git a/backend/services/activity_log_service.go b/backend/services/activity_log_service.go new file mode 100644 index 0000000..f1f7fec --- /dev/null +++ b/backend/services/activity_log_service.go @@ -0,0 +1,113 @@ +package services + +import ( + "errors" + "progress-wall-backend/models" + "progress-wall-backend/repository" + + "gorm.io/gorm" +) + +// ActivityLogService 活动日志服务 +type ActivityLogService struct { + repo repository.ActivityLogRepository + db *gorm.DB +} + +// NewActivityLogService 创建活动日志服务实例 +func NewActivityLogService( + repo repository.ActivityLogRepository, + db *gorm.DB, +) *ActivityLogService { + return &ActivityLogService{ + repo: repo, + db: db, + } +} + +// CreateLog 创建活动日志 +func (s *ActivityLogService) CreateLog(log *models.ActivityLog) error { + return s.repo.Create(log) +} + +// GetBoardActivities 获取看板活动日志 +func (s *ActivityLogService) GetBoardActivities(boardID uint, page, pageSize int) ([]models.ActivityLog, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 // 默认每页20条 + } + + offset := (page - 1) * pageSize + return s.repo.GetByBoardID(boardID, offset, pageSize) +} + +// GetTaskActivities 获取任务活动日志 +func (s *ActivityLogService) GetTaskActivities(taskID uint, page, pageSize int) ([]models.ActivityLog, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 // 默认每页20条 + } + + offset := (page - 1) * pageSize + return s.repo.GetByTaskID(taskID, offset, pageSize) +} + +// VerifyBoardAccess 验证用户是否有权访问看板 +func (s *ActivityLogService) VerifyBoardAccess(userID, boardID uint) error { + // 首先检查看板是否存在 + var board models.Board + if err := s.db.First(&board, boardID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrBoardNotFound + } + return err + } + + // 检查用户是否是看板所有者或项目成员 + var count int64 + err := s.db.Model(&models.ProjectMember{}). + Where("project_id = ? AND user_id = ?", board.ProjectID, userID). + Count(&count).Error + + if err != nil { + return err + } + + if count == 0 && board.OwnerID != userID { + return ErrAccessDenied + } + + return nil +} + +// VerifyTaskAccess 验证用户是否有权访问任务 +func (s *ActivityLogService) VerifyTaskAccess(userID, taskID uint) error { + // 首先检查任务是否存在 + var task models.Task + if err := s.db.First(&task, taskID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrTaskNotFound + } + return err + } + + // 检查用户是否是项目成员 + var count int64 + err := s.db.Model(&models.ProjectMember{}). + Where("project_id = ? AND user_id = ?", task.ProjectID, userID). + Count(&count).Error + + if err != nil { + return err + } + + if count == 0 && task.CreatorID != userID { + return ErrAccessDenied + } + + return nil +} diff --git a/backend/services/errors.go b/backend/services/errors.go index efbe94c..d215aee 100644 --- a/backend/services/errors.go +++ b/backend/services/errors.go @@ -11,4 +11,7 @@ var ( ErrUpdateLoginTime = errors.New("更新登录时间失败") ErrUserExists = errors.New("用户名或邮箱已存在") ErrInvalidPassword = errors.New("密码格式不正确") + ErrBoardNotFound = errors.New("看板不存在") + ErrTaskNotFound = errors.New("任务不存在") + ErrAccessDenied = errors.New("没有访问权限") )