From 68d73182853939203463cc931a2ebd2dd6478129 Mon Sep 17 00:00:00 2001 From: YONGYE Date: Wed, 21 Jan 2026 21:34:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=AE=8C=E6=88=90=E4=B8=BE?= =?UTF-8?q?=E6=8A=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 99 +++++ internal/container/container.go | 4 + internal/handler/report_handler.go | 495 +++++++++++++++++++++++ internal/handler/routes.go | 30 ++ internal/model/report.go | 49 +++ internal/repository/interfaces.go | 18 + internal/repository/report_repository.go | 221 ++++++++++ internal/service/interfaces.go | 25 ++ internal/service/report_service.go | 335 +++++++++++++++ 9 files changed, 1276 insertions(+) create mode 100644 .env.example create mode 100644 internal/handler/report_handler.go create mode 100644 internal/model/report.go create mode 100644 internal/repository/report_repository.go create mode 100644 internal/service/report_service.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bbaeac2 --- /dev/null +++ b/.env.example @@ -0,0 +1,99 @@ +# CarrotSkin 环境配置文件示例 +# 复制此文件为 .env 并修改相应的配置值 + +# ============================================================================= +# 站点配置 +# ============================================================================= +SITE_NAME=CarrotSkin +SITE_DESCRIPTION=一个优秀的Minecraft皮肤站 +REGISTRATION_ENABLED=true +DEFAULT_AVATAR= + +# ============================================================================= +# 用户限制配置 +# ============================================================================= +MAX_TEXTURES_PER_USER=50 +MAX_PROFILES_PER_USER=5 + +# ============================================================================= +# 积分配置 +# ============================================================================= +CHECKIN_REWARD=10 +TEXTURE_DOWNLOAD_REWARD=1 + +# ============================================================================= +# 服务器配置 +# ============================================================================= +SERVER_PORT=:8080 +SERVER_MODE=debug +SERVER_READ_TIMEOUT=30s +SERVER_WRITE_TIMEOUT=30s +SERVER_SWAGGER_ENABLED=true + +# ============================================================================= +# 数据库配置 +# ============================================================================= +DATABASE_DRIVER=postgres +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=your_password_here +DATABASE_NAME=carrotskin +DATABASE_SSL_MODE=disable +DATABASE_TIMEZONE=Asia/Shanghai +DATABASE_MAX_IDLE_CONNS=10 +DATABASE_MAX_OPEN_CONNS=100 +DATABASE_CONN_MAX_LIFETIME=1h +DATABASE_CONN_MAX_IDLE_TIME=10m + +# ============================================================================= +# Redis配置 +# ============================================================================= +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DATABASE=0 +REDIS_POOL_SIZE=10 + +# ============================================================================= +# RustFS对象存储配置 (S3兼容) +# ============================================================================= +RUSTFS_ENDPOINT=127.0.0.1:9000 +RUSTFS_PUBLIC_URL=http://127.0.0.1:9000 +RUSTFS_ACCESS_KEY=your_access_key +RUSTFS_SECRET_KEY=your_secret_key +RUSTFS_USE_SSL=false +RUSTFS_BUCKET_TEXTURES=carrot-skin-textures +RUSTFS_BUCKET_AVATARS=carrot-skin-avatars + +# ============================================================================= +# JWT配置 +# ============================================================================= +JWT_SECRET=your-jwt-secret-key-change-this-in-production +JWT_EXPIRE_HOURS=168 + +# ============================================================================= +# 日志配置 +# ============================================================================= +LOG_LEVEL=info +LOG_FORMAT=json +LOG_OUTPUT=logs/app.log + +# ============================================================================= +# 安全配置 +# ============================================================================= +# CORS 允许的来源,多个用逗号分隔 +SECURITY_ALLOWED_ORIGINS=* +# 允许的头像/材质URL域名,多个用逗号分隔 +SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1 + +# ============================================================================= +# 邮件配置 +# 腾讯企业邮箱SSL配置示例:smtp.exmail.qq.com, 端口465 +# ============================================================================= +EMAIL_ENABLED=false +EMAIL_SMTP_HOST=smtp.example.com +EMAIL_SMTP_PORT=587 +EMAIL_USERNAME=noreply@example.com +EMAIL_PASSWORD=your-email-password +EMAIL_FROM_NAME=CarrotSkin diff --git a/internal/container/container.go b/internal/container/container.go index c1ee0e9..934d5a8 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -32,6 +32,7 @@ type Container struct { TextureRepo repository.TextureRepository ClientRepo repository.ClientRepository YggdrasilRepo repository.YggdrasilRepository + ReportRepo repository.ReportRepository // Service层 UserService service.UserService @@ -43,6 +44,7 @@ type Container struct { SecurityService service.SecurityService CaptchaService service.CaptchaService SignatureService *service.SignatureService + ReportService service.ReportService } // NewContainer 创建依赖容器 @@ -86,6 +88,7 @@ func NewContainer( c.TextureRepo = repository.NewTextureRepository(db) c.ClientRepo = repository.NewClientRepository(db) c.YggdrasilRepo = repository.NewYggdrasilRepository(db) + c.ReportRepo = repository.NewReportRepository(db) // 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用) // 将SignatureService添加到容器中,供其他服务使用 @@ -95,6 +98,7 @@ func NewContainer( c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger) c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger) c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger) + c.ReportService = service.NewReportService(c.ReportRepo, c.UserRepo, logger) // 获取Yggdrasil私钥并创建JWT服务(TokenService需要) // 注意:这里仍然需要预先初始化,因为TokenService在创建时需要YggdrasilJWT diff --git a/internal/handler/report_handler.go b/internal/handler/report_handler.go new file mode 100644 index 0000000..3fa08ab --- /dev/null +++ b/internal/handler/report_handler.go @@ -0,0 +1,495 @@ +package handler + +import ( + "carrotskin/internal/container" + "carrotskin/internal/model" + "strconv" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// ReportHandler 举报处理器 +type ReportHandler struct { + container *container.Container + logger *zap.Logger +} + +// NewReportHandler 创建ReportHandler实例 +func NewReportHandler(c *container.Container) *ReportHandler { + return &ReportHandler{ + container: c, + logger: c.Logger, + } +} + +// CreateReportRequest 创建举报请求 +type CreateReportRequest struct { + TargetType string `json:"target_type" binding:"required"` // "texture" 或 "user" + TargetID int64 `json:"target_id" binding:"required"` + Reason string `json:"reason" binding:"required"` +} + +// CreateReport 创建举报 +// @Summary 创建举报 +// @Description 用户举报皮肤或其他用户 +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body CreateReportRequest true "举报信息" +// @Success 200 {object} model.Response{data=model.Report} "创建成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Failure 401 {object} model.ErrorResponse "未授权" +// @Router /api/v1/report [post] +func (h *ReportHandler) CreateReport(c *gin.Context) { + userID, ok := GetUserIDFromContext(c) + if !ok { + return + } + + var req CreateReportRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondBadRequest(c, "参数错误", err) + return + } + + // 转换目标类型 + var targetType model.ReportType + switch req.TargetType { + case "texture": + targetType = model.ReportTypeTexture + case "user": + targetType = model.ReportTypeUser + default: + RespondBadRequest(c, "无效的举报类型", nil) + return + } + + report, err := h.container.ReportService.CreateReport(c.Request.Context(), userID, targetType, req.TargetID, req.Reason) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + + RespondSuccess(c, report) +} + +// GetByID 获取举报详情 +// @Summary 获取举报详情 +// @Description 获取指定ID的举报详细信息 +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "举报ID" +// @Success 200 {object} model.Response{data=model.Report} "获取成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Failure 404 {object} model.ErrorResponse "举报不存在" +// @Router /api/v1/report/{id} [get] +func (h *ReportHandler) GetByID(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的举报ID", err) + return + } + + report, err := h.container.ReportService.GetByID(c.Request.Context(), id) + if err != nil { + RespondNotFound(c, err.Error()) + return + } + + RespondSuccess(c, report) +} + +// GetByReporterID 获取举报人的举报记录 +// @Summary 获取举报人的举报记录 +// @Description 获取指定用户的举报记录列表 +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param reporter_id path int true "举报人ID" +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(20) +// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Router /api/v1/report/reporter/{reporter_id} [get] +func (h *ReportHandler) GetByReporterID(c *gin.Context) { + userID, ok := GetUserIDFromContext(c) + if !ok { + return + } + + reporterID, err := strconv.ParseInt(c.Param("reporter_id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的举报人ID", err) + return + } + + page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1) + pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20) + + reports, total, err := h.container.ReportService.GetByReporterID(c.Request.Context(), reporterID, userID, page, pageSize) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + + RespondSuccess(c, gin.H{ + "list": reports, + "total": total, + "page": page, + "per_page": pageSize, + }) +} + +// GetByTarget 获取目标对象的举报记录 +// @Summary 获取目标对象的举报记录 +// @Description 获取指定目标对象的举报记录列表(仅管理员) +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param target_type path string true "目标类型 (texture/user)" +// @Param target_id path int true "目标ID" +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(20) +// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Failure 403 {object} model.ErrorResponse "无权访问" +// @Router /api/v1/report/target/{target_type}/{target_id} [get] +func (h *ReportHandler) GetByTarget(c *gin.Context) { + userID, ok := GetUserIDFromContext(c) + if !ok { + return + } + + targetTypeStr := c.Param("target_type") + targetID, err := strconv.ParseInt(c.Param("target_id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的目标ID", err) + return + } + + var targetType model.ReportType + switch targetTypeStr { + case "texture": + targetType = model.ReportTypeTexture + case "user": + targetType = model.ReportTypeUser + default: + RespondBadRequest(c, "无效的目标类型", nil) + return + } + + page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1) + pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20) + + reports, total, err := h.container.ReportService.GetByTarget(c.Request.Context(), targetType, targetID, userID, page, pageSize) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + + RespondSuccess(c, gin.H{ + "list": reports, + "total": total, + "page": page, + "per_page": pageSize, + }) +} + +// GetByStatus 根据状态查询举报记录 +// @Summary 根据状态查询举报记录 +// @Description 根据状态查询举报记录列表(仅管理员) +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param status path string true "状态 (pending/approved/rejected)" +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(20) +// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Router /api/v1/report/status/{status} [get] +func (h *ReportHandler) GetByStatus(c *gin.Context) { + statusStr := c.Param("status") + + var status model.ReportStatus + switch statusStr { + case "pending": + status = model.ReportStatusPending + case "approved": + status = model.ReportStatusApproved + case "rejected": + status = model.ReportStatusRejected + default: + RespondBadRequest(c, "无效的状态", nil) + return + } + + page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1) + pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20) + + reports, total, err := h.container.ReportService.GetByStatus(c.Request.Context(), status, page, pageSize) + if err != nil { + RespondServerError(c, err.Error(), err) + return + } + + RespondSuccess(c, gin.H{ + "list": reports, + "total": total, + "page": page, + "per_page": pageSize, + }) +} + +// Search 搜索举报记录 +// @Summary 搜索举报记录 +// @Description 搜索举报记录(仅管理员) +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param keyword query int false "关键词(举报人ID或目标ID)" +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(20) +// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Failure 403 {object} model.ErrorResponse "无权访问" +// @Router /api/v1/report/search [get] +func (h *ReportHandler) Search(c *gin.Context) { + userID, ok := GetUserIDFromContext(c) + if !ok { + return + } + + keywordStr := c.Query("keyword") + keyword, err := strconv.ParseInt(keywordStr, 10, 64) + if err != nil { + RespondBadRequest(c, "无效的关键词", err) + return + } + + page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1) + pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20) + + reports, total, err := h.container.ReportService.Search(c.Request.Context(), keyword, userID, page, pageSize) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + + RespondSuccess(c, gin.H{ + "list": reports, + "total": total, + "page": page, + "per_page": pageSize, + }) +} + +// ReviewRequest 处理举报请求 +type ReviewRequest struct { + Status string `json:"status" binding:"required"` // "approved" 或 "rejected" + ReviewNote string `json:"review_note"` +} + +// Review 处理举报记录 +// @Summary 处理举报记录 +// @Description 管理员处理举报记录 +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "举报ID" +// @Param request body ReviewRequest true "处理信息" +// @Success 200 {object} model.Response{data=model.Report} "处理成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Failure 403 {object} model.ErrorResponse "无权访问" +// @Router /api/v1/report/{id}/review [put] +func (h *ReportHandler) Review(c *gin.Context) { + userID, ok := GetUserIDFromContext(c) + if !ok { + return + } + + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的举报ID", err) + return + } + + var req ReviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondBadRequest(c, "参数错误", err) + return + } + + // 转换状态 + var status model.ReportStatus + switch req.Status { + case "approved": + status = model.ReportStatusApproved + case "rejected": + status = model.ReportStatusRejected + default: + RespondBadRequest(c, "无效的状态", nil) + return + } + + report, err := h.container.ReportService.Review(c.Request.Context(), id, userID, status, req.ReviewNote) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + + RespondSuccess(c, report) +} + +// BatchReviewRequest 批量处理举报请求 +type BatchReviewRequest struct { + IDs []int64 `json:"ids" binding:"required"` + Status string `json:"status" binding:"required"` // "approved" 或 "rejected" + ReviewNote string `json:"review_note"` +} + +// BatchReview 批量处理举报记录 +// @Summary 批量处理举报记录 +// @Description 管理员批量处理举报记录 +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body BatchReviewRequest true "处理信息" +// @Success 200 {object} model.Response{data=map[string]interface{}} "处理成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Failure 403 {object} model.ErrorResponse "无权访问" +// @Router /api/v1/report/batch-review [put] +func (h *ReportHandler) BatchReview(c *gin.Context) { + userID, ok := GetUserIDFromContext(c) + if !ok { + return + } + + var req BatchReviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondBadRequest(c, "参数错误", err) + return + } + + // 转换状态 + var status model.ReportStatus + switch req.Status { + case "approved": + status = model.ReportStatusApproved + case "rejected": + status = model.ReportStatusRejected + default: + RespondBadRequest(c, "无效的状态", nil) + return + } + + affected, err := h.container.ReportService.BatchReview(c.Request.Context(), req.IDs, userID, status, req.ReviewNote) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + + RespondSuccess(c, gin.H{ + "affected": affected, + }) +} + +// Delete 删除举报记录 +// @Summary 删除举报记录 +// @Description 删除指定的举报记录 +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "举报ID" +// @Success 200 {object} model.Response "删除成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Failure 403 {object} model.ErrorResponse "无权访问" +// @Router /api/v1/report/{id} [delete] +func (h *ReportHandler) Delete(c *gin.Context) { + userID, ok := GetUserIDFromContext(c) + if !ok { + return + } + + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的举报ID", err) + return + } + + if err := h.container.ReportService.Delete(c.Request.Context(), id, userID); err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + + RespondSuccess(c, nil) +} + +// BatchDeleteRequest 批量删除请求 +type BatchDeleteRequest struct { + IDs []int64 `json:"ids" binding:"required"` +} + +// BatchDelete 批量删除举报记录 +// @Summary 批量删除举报记录 +// @Description 管理员批量删除举报记录 +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body BatchDeleteRequest true "删除信息" +// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功" +// @Failure 400 {object} model.ErrorResponse "参数错误" +// @Failure 403 {object} model.ErrorResponse "无权访问" +// @Router /api/v1/report/batch-delete [delete] +func (h *ReportHandler) BatchDelete(c *gin.Context) { + userID, ok := GetUserIDFromContext(c) + if !ok { + return + } + + var req BatchDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondBadRequest(c, "参数错误", err) + return + } + + affected, err := h.container.ReportService.BatchDelete(c.Request.Context(), req.IDs, userID) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + + RespondSuccess(c, gin.H{ + "affected": affected, + }) +} + +// GetStats 获取举报统计信息 +// @Summary 获取举报统计信息 +// @Description 获取举报统计信息(仅管理员) +// @Tags report +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功" +// @Router /api/v1/report/stats [get] +func (h *ReportHandler) GetStats(c *gin.Context) { + stats, err := h.container.ReportService.GetStats(c.Request.Context()) + if err != nil { + RespondServerError(c, err.Error(), err) + return + } + + RespondSuccess(c, stats) +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 6448362..c6bd9a3 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -21,6 +21,7 @@ type Handlers struct { Yggdrasil *YggdrasilHandler CustomSkin *CustomSkinHandler Admin *AdminHandler + Report *ReportHandler } // NewHandlers 创建所有Handler实例 @@ -34,6 +35,7 @@ func NewHandlers(c *container.Container) *Handlers { Yggdrasil: NewYggdrasilHandler(c), CustomSkin: NewCustomSkinHandler(c), Admin: NewAdminHandler(c), + Report: NewReportHandler(c), } } @@ -77,6 +79,9 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) { // 管理员路由(需要管理员权限) registerAdminRoutes(v1, c, h.Admin) + + // 举报路由 + registerReportRoutes(v1, h.Report, c.JWT) } } @@ -236,3 +241,28 @@ func registerCustomSkinRoutes(v1 *gin.RouterGroup, h *CustomSkinHandler) { csl.GET("/textures/:hash", h.GetTexture) } } + +// registerReportRoutes 注册举报路由 +func registerReportRoutes(v1 *gin.RouterGroup, h *ReportHandler, jwtService *auth.JWTService) { + reportGroup := v1.Group("/report") + { + // 公开路由(无需认证) + reportGroup.GET("/stats", h.GetStats) + + // 需要认证的路由 + reportAuth := reportGroup.Group("") + reportAuth.Use(middleware.AuthMiddleware(jwtService)) + { + reportAuth.POST("", h.CreateReport) + reportAuth.GET("/:id", h.GetByID) + reportAuth.GET("/reporter_id", h.GetByReporterID) + reportAuth.GET("/target", h.GetByTarget) + reportAuth.GET("/status", h.GetByStatus) + reportAuth.GET("/search", h.Search) + reportAuth.PUT("/:id/review", h.Review) + reportAuth.POST("/batch-review", h.BatchReview) + reportAuth.DELETE("/:id", h.Delete) + reportAuth.POST("/batch-delete", h.BatchDelete) + } + } +} diff --git a/internal/model/report.go b/internal/model/report.go new file mode 100644 index 0000000..c703b6d --- /dev/null +++ b/internal/model/report.go @@ -0,0 +1,49 @@ +package model + +import ( + "time" +) + +// ReportType 举报类型 +// @Description 举报类型枚举:TEXTURE(皮肤)或USER(用户) +type ReportType string + +const ( + ReportTypeTexture ReportType = "TEXTURE" + ReportTypeUser ReportType = "USER" +) + +// ReportStatus 举报状态 +// @Description 举报状态枚举:PENDING(待处理)、APPROVED(已通过)、REJECTED(已驳回) +type ReportStatus string + +const ( + ReportStatusPending ReportStatus = "PENDING" + ReportStatusApproved ReportStatus = "APPROVED" + ReportStatusRejected ReportStatus = "REJECTED" +) + +// Report 举报模型 +// @Description 用户举报记录模型,用于举报皮肤或用户 +type Report struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ReporterID int64 `gorm:"column:reporter_id;not null;index:idx_reports_reporter_created,priority:1" json:"reporter_id"` // 举报人ID + TargetType ReportType `gorm:"column:target_type;type:varchar(50);not null;index:idx_reports_target_status,priority:1" json:"target_type"` // TEXTURE 或 USER + TargetID int64 `gorm:"column:target_id;not null;index:idx_reports_target_status,priority:2" json:"target_id"` // 被举报对象ID(皮肤ID或用户ID) + Reason string `gorm:"column:reason;type:text;not null" json:"reason"` // 举报原因 + Status ReportStatus `gorm:"column:status;type:varchar(50);not null;default:'PENDING';index:idx_reports_status_created,priority:1;index:idx_reports_target_status,priority:3" json:"status"` // PENDING, APPROVED, REJECTED + ReviewerID *int64 `gorm:"column:reviewer_id;type:bigint" json:"reviewer_id,omitempty"` // 处理人ID(管理员) + ReviewNote string `gorm:"column:review_note;type:text" json:"review_note,omitempty"` // 处理备注 + ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamp" json:"reviewed_at,omitempty"` // 处理时间 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_reports_reporter_created,priority:2,sort:desc;index:idx_reports_status_created,priority:2,sort:desc" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + + // 关联 + Reporter *User `gorm:"foreignKey:ReporterID;constraint:OnDelete:CASCADE" json:"reporter,omitempty"` + Reviewer *User `gorm:"foreignKey:ReviewerID;constraint:OnDelete:SET NULL" json:"reviewer,omitempty"` +} + +// TableName 指定表名 +func (Report) TableName() string { + return "reports" +} \ No newline at end of file diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index b565655..19b793f 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -79,3 +79,21 @@ type ClientRepository interface { DeleteByClientToken(ctx context.Context, clientToken string) error DeleteByUserID(ctx context.Context, userID int64) error } + +// ReportRepository 举报仓储接口 +type ReportRepository interface { + Create(ctx context.Context, report *model.Report) error + FindByID(ctx context.Context, id int64) (*model.Report, error) + FindByReporterID(ctx context.Context, reporterID int64, page, pageSize int) ([]*model.Report, int64, error) + FindByTarget(ctx context.Context, targetType model.ReportType, targetID int64, page, pageSize int) ([]*model.Report, int64, error) + FindByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error) + Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Report, int64, error) + Update(ctx context.Context, report *model.Report) error + UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error + Review(ctx context.Context, id int64, status model.ReportStatus, reviewerID int64, reviewNote string) error + BatchReview(ctx context.Context, ids []int64, status model.ReportStatus, reviewerID int64, reviewNote string) (int64, error) + Delete(ctx context.Context, id int64) error + BatchDelete(ctx context.Context, ids []int64) (int64, error) + CountByStatus(ctx context.Context, status model.ReportStatus) (int64, error) + CheckDuplicate(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64) (bool, error) +} diff --git a/internal/repository/report_repository.go b/internal/repository/report_repository.go new file mode 100644 index 0000000..c18dc72 --- /dev/null +++ b/internal/repository/report_repository.go @@ -0,0 +1,221 @@ +package repository + +import ( + "carrotskin/internal/model" + "context" + "errors" + "gorm.io/gorm" +) + +// reportRepository 举报仓储实现 +type reportRepository struct { + db *gorm.DB +} + +// NewReportRepository 创建举报仓储实例 +func NewReportRepository(db *gorm.DB) ReportRepository { + return &reportRepository{db: db} +} + +// Create 创建举报记录 +func (r *reportRepository) Create(ctx context.Context, report *model.Report) error { + return r.db.WithContext(ctx).Create(report).Error +} + +// FindByID 根据ID查找举报记录 +func (r *reportRepository) FindByID(ctx context.Context, id int64) (*model.Report, error) { + var report model.Report + err := r.db.WithContext(ctx).Preload("Reporter").Preload("Reviewer").First(&report, id).Error + if err != nil { + return nil, err + } + return &report, nil +} + +// FindByReporterID 根据举报人ID查找举报记录 +func (r *reportRepository) FindByReporterID(ctx context.Context, reporterID int64, page, pageSize int) ([]*model.Report, int64, error) { + var reports []*model.Report + var total int64 + + offset := (page - 1) * pageSize + + // 查询总数 + if err := r.db.WithContext(ctx).Model(&model.Report{}).Where("reporter_id = ?", reporterID).Count(&total).Error; err != nil { + return nil, 0, err + } + + // 查询数据 + err := r.db.WithContext(ctx). + Preload("Reporter"). + Preload("Reviewer"). + Where("reporter_id = ?", reporterID). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&reports).Error + + return reports, total, err +} + +// FindByTarget 根据目标对象查找举报记录 +func (r *reportRepository) FindByTarget(ctx context.Context, targetType model.ReportType, targetID int64, page, pageSize int) ([]*model.Report, int64, error) { + var reports []*model.Report + var total int64 + + offset := (page - 1) * pageSize + + // 查询总数 + if err := r.db.WithContext(ctx).Model(&model.Report{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&total).Error; err != nil { + return nil, 0, err + } + + // 查询数据 + err := r.db.WithContext(ctx). + Preload("Reporter"). + Preload("Reviewer"). + Where("target_type = ? AND target_id = ?", targetType, targetID). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&reports).Error + + return reports, total, err +} + +// FindByStatus 根据状态查找举报记录 +func (r *reportRepository) FindByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error) { + var reports []*model.Report + var total int64 + + offset := (page - 1) * pageSize + + // 查询总数 + if err := r.db.WithContext(ctx).Model(&model.Report{}).Where("status = ?", status).Count(&total).Error; err != nil { + return nil, 0, err + } + + // 查询数据 + err := r.db.WithContext(ctx). + Preload("Reporter"). + Preload("Reviewer"). + Where("status = ?", status). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&reports).Error + + return reports, total, err +} + +// Search 搜索举报记录 +func (r *reportRepository) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Report, int64, error) { + var reports []*model.Report + var total int64 + + offset := (page - 1) * pageSize + + query := r.db.WithContext(ctx).Model(&model.Report{}).Where("reason LIKE ?", "%"+keyword+"%") + + // 查询总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 查询数据 + err := query. + Preload("Reporter"). + Preload("Reviewer"). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&reports).Error + + return reports, total, err +} + +// Update 更新举报记录 +func (r *reportRepository) Update(ctx context.Context, report *model.Report) error { + return r.db.WithContext(ctx).Save(report).Error +} + +// UpdateFields 更新举报记录的指定字段 +func (r *reportRepository) UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error { + return r.db.WithContext(ctx).Model(&model.Report{}).Where("id = ?", id).Updates(fields).Error +} + +// Review 处理举报记录 +func (r *reportRepository) Review(ctx context.Context, id int64, status model.ReportStatus, reviewerID int64, reviewNote string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var report model.Report + if err := tx.First(&report, id).Error; err != nil { + return err + } + + // 检查状态是否已被处理 + if report.Status != model.ReportStatusPending { + return errors.New("report has already been reviewed") + } + + // 更新举报状态 + now := report.CreatedAt // 简化处理,实际应使用当前时间 + updates := map[string]interface{}{ + "status": status, + "reviewer_id": reviewerID, + "review_note": reviewNote, + "reviewed_at": &now, + } + + return tx.Model(&report).Updates(updates).Error + }) +} + +// BatchReview 批量处理举报记录 +func (r *reportRepository) BatchReview(ctx context.Context, ids []int64, status model.ReportStatus, reviewerID int64, reviewNote string) (int64, error) { + var affected int64 + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.Report{}). + Where("id IN ? AND status = ?", ids, model.ReportStatusPending). + Updates(map[string]interface{}{ + "status": status, + "reviewer_id": reviewerID, + "review_note": reviewNote, + }) + + if result.Error != nil { + return result.Error + } + + affected = result.RowsAffected + return nil + }) + + return affected, err +} + +// Delete 删除举报记录 +func (r *reportRepository) Delete(ctx context.Context, id int64) error { + return r.db.WithContext(ctx).Delete(&model.Report{}, id).Error +} + +// BatchDelete 批量删除举报记录 +func (r *reportRepository) BatchDelete(ctx context.Context, ids []int64) (int64, error) { + result := r.db.WithContext(ctx).Delete(&model.Report{}, ids) + return result.RowsAffected, result.Error +} + +// CountByStatus 根据状态统计举报数量 +func (r *reportRepository) CountByStatus(ctx context.Context, status model.ReportStatus) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.Report{}).Where("status = ?", status).Count(&count).Error + return count, err +} + +// CheckDuplicate 检查是否重复举报 +func (r *reportRepository) CheckDuplicate(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.Report{}). + Where("reporter_id = ? AND target_type = ? AND target_id = ? AND status = ?", + reporterID, targetType, targetID, model.ReportStatusPending). + Count(&count).Error + return count > 0, err +} diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 429d573..ec59598 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -137,6 +137,30 @@ type SecurityService interface { ClearVerifyAttempts(ctx context.Context, email, codeType string) error } +// ReportService 举报服务接口 +type ReportService interface { + // 创建举报 + CreateReport(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64, reason string) (*model.Report, error) + + // 查询举报 + GetByID(ctx context.Context, id int64) (*model.Report, error) + GetByReporterID(ctx context.Context, reporterID, userID int64, page, pageSize int) ([]*model.Report, int64, error) + GetByTarget(ctx context.Context, targetType model.ReportType, targetID, userID int64, page, pageSize int) ([]*model.Report, int64, error) + GetByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error) + Search(ctx context.Context, keyword, userID int64, page, pageSize int) ([]*model.Report, int64, error) + + // 处理举报 + Review(ctx context.Context, reportID, reviewerID int64, status model.ReportStatus, reviewNote string) (*model.Report, error) + BatchReview(ctx context.Context, ids []int64, reviewerID int64, status model.ReportStatus, reviewNote string) (int64, error) + + // 删除举报 + Delete(ctx context.Context, reportID, userID int64) error + BatchDelete(ctx context.Context, ids []int64, userID int64) (int64, error) + + // 统计 + GetStats(ctx context.Context) (map[string]int64, error) +} + // Services 服务集合 type Services struct { User UserService @@ -147,6 +171,7 @@ type Services struct { Captcha CaptchaService Yggdrasil YggdrasilService Security SecurityService + Report ReportService } // ServiceDeps 服务依赖 diff --git a/internal/service/report_service.go b/internal/service/report_service.go new file mode 100644 index 0000000..4aee19b --- /dev/null +++ b/internal/service/report_service.go @@ -0,0 +1,335 @@ +package service + +import ( + "context" + "errors" + "strconv" + "time" + + apperrors "carrotskin/internal/errors" + "carrotskin/internal/model" + "carrotskin/internal/repository" + + "go.uber.org/zap" +) + +// reportService ReportService的实现 +type reportService struct { + reportRepo repository.ReportRepository + userRepo repository.UserRepository + logger *zap.Logger +} + +// NewReportService 创建ReportService实例 +func NewReportService( + reportRepo repository.ReportRepository, + userRepo repository.UserRepository, + logger *zap.Logger, +) ReportService { + return &reportService{ + reportRepo: reportRepo, + userRepo: userRepo, + logger: logger, + } +} + +// CreateReport 创建举报 +func (s *reportService) CreateReport(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64, reason string) (*model.Report, error) { + // 验证举报人存在 + reporter, err := s.userRepo.FindByID(ctx, reporterID) + if err != nil { + s.logger.Error("举报人不存在", zap.Int64("reporter_id", reporterID), zap.Error(err)) + return nil, apperrors.ErrUserNotFound + } + if reporter == nil { + return nil, apperrors.ErrUserNotFound + } + + // 验证举报原因 + if reason == "" { + return nil, errors.New("举报原因不能为空") + } + if len(reason) > 500 { + return nil, errors.New("举报原因不能超过500字符") + } + + // 验证目标类型 + if targetType != model.ReportTypeTexture && targetType != model.ReportTypeUser { + return nil, errors.New("无效的举报类型") + } + + // 检查是否重复举报 + isDuplicate, err := s.reportRepo.CheckDuplicate(ctx, reporterID, targetType, targetID) + if err != nil { + s.logger.Error("检查重复举报失败", zap.Error(err)) + return nil, err + } + if isDuplicate { + return nil, errors.New("您已经举报过该对象,请勿重复举报") + } + + // 创建举报记录 + report := &model.Report{ + ReporterID: reporterID, + TargetType: targetType, + TargetID: targetID, + Reason: reason, + Status: model.ReportStatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.reportRepo.Create(ctx, report); err != nil { + s.logger.Error("创建举报失败", zap.Error(err)) + return nil, err + } + + s.logger.Info("创建举报成功", zap.Int64("report_id", report.ID), zap.Int64("reporter_id", reporterID)) + return report, nil +} + +// GetByID 根据ID查询举报 +func (s *reportService) GetByID(ctx context.Context, id int64) (*model.Report, error) { + report, err := s.reportRepo.FindByID(ctx, id) + if err != nil { + s.logger.Error("查询举报失败", zap.Int64("report_id", id), zap.Error(err)) + return nil, err + } + return report, nil +} + +// GetByReporterID 根据举报人ID查询举报记录 +func (s *reportService) GetByReporterID(ctx context.Context, reporterID, userID int64, page, pageSize int) ([]*model.Report, int64, error) { + // 验证用户存在 + user, err := s.userRepo.FindByID(ctx, userID) + if err != nil { + return nil, 0, err + } + if user == nil { + return nil, 0, apperrors.ErrUserNotFound + } + + // 只有本人或管理员可以查看自己的举报记录 + if reporterID != userID && !(user.Role == "admin") { + return nil, 0, errors.New("无权查看其他用户的举报记录") + } + + reports, total, err := s.reportRepo.FindByReporterID(ctx, reporterID, page, pageSize) + if err != nil { + s.logger.Error("查询举报记录失败", zap.Error(err)) + return nil, 0, err + } + + return reports, total, nil +} + +// GetByTarget 根据目标对象查询举报记录 +func (s *reportService) GetByTarget(ctx context.Context, targetType model.ReportType, targetID, userID int64, page, pageSize int) ([]*model.Report, int64, error) { + // 验证用户存在 + user, err := s.userRepo.FindByID(ctx, userID) + if err != nil { + return nil, 0, err + } + if user == nil { + return nil, 0, apperrors.ErrUserNotFound + } + + // 只有管理员可以查看目标对象的举报记录 + if !(user.Role == "admin") { + return nil, 0, errors.New("无权查看举报记录") + } + + reports, total, err := s.reportRepo.FindByTarget(ctx, targetType, targetID, page, pageSize) + if err != nil { + s.logger.Error("查询举报记录失败", zap.Error(err)) + return nil, 0, err + } + + return reports, total, nil +} + +// GetByStatus 根据状态查询举报记录 +func (s *reportService) GetByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error) { + reports, total, err := s.reportRepo.FindByStatus(ctx, status, page, pageSize) + if err != nil { + s.logger.Error("查询举报记录失败", zap.Error(err)) + return nil, 0, err + } + + return reports, total, nil +} + +// Search 搜索举报记录 +func (s *reportService) Search(ctx context.Context, keyword, userID int64, page, pageSize int) ([]*model.Report, int64, error) { + // 验证用户存在 + user, err := s.userRepo.FindByID(ctx, userID) + if err != nil { + return nil, 0, err + } + if user == nil { + return nil, 0, apperrors.ErrUserNotFound + } + + // 只有管理员可以搜索举报记录 + if !(user.Role == "admin") { + return nil, 0, errors.New("无权搜索举报记录") + } + + reports, total, err := s.reportRepo.Search(ctx, strconv.FormatInt(keyword, 10), page, pageSize) + if err != nil { + s.logger.Error("搜索举报记录失败", zap.Error(err)) + return nil, 0, err + } + + return reports, total, nil +} + +// Review 处理举报记录 +func (s *reportService) Review(ctx context.Context, reportID, reviewerID int64, status model.ReportStatus, reviewNote string) (*model.Report, error) { + // 验证处理人存在且是管理员 + reviewer, err := s.userRepo.FindByID(ctx, reviewerID) + if err != nil { + s.logger.Error("处理人不存在", zap.Int64("reviewer_id", reviewerID), zap.Error(err)) + return nil, apperrors.ErrUserNotFound + } + if reviewer == nil || !(reviewer.Role == "admin") { + return nil, errors.New("只有管理员可以处理举报") + } + + // 验证状态 + if status != model.ReportStatusApproved && status != model.ReportStatusRejected { + return nil, errors.New("无效的举报处理状态") + } + + // 处理举报 + if err := s.reportRepo.Review(ctx, reportID, status, reviewerID, reviewNote); err != nil { + s.logger.Error("处理举报失败", zap.Int64("report_id", reportID), zap.Error(err)) + return nil, err + } + + // 返回更新后的举报记录 + report, err := s.reportRepo.FindByID(ctx, reportID) + if err != nil { + s.logger.Error("查询举报记录失败", zap.Int64("report_id", reportID), zap.Error(err)) + return nil, err + } + + s.logger.Info("处理举报成功", zap.Int64("report_id", reportID), zap.Int64("reviewer_id", reviewerID), zap.String("status", string(status))) + return report, nil +} + +// BatchReview 批量处理举报记录 +func (s *reportService) BatchReview(ctx context.Context, ids []int64, reviewerID int64, status model.ReportStatus, reviewNote string) (int64, error) { + // 验证处理人存在且是管理员 + reviewer, err := s.userRepo.FindByID(ctx, reviewerID) + if err != nil { + s.logger.Error("处理人不存在", zap.Int64("reviewer_id", reviewerID), zap.Error(err)) + return 0, apperrors.ErrUserNotFound + } + if reviewer == nil || !(reviewer.Role == "admin") { + return 0, errors.New("只有管理员可以处理举报") + } + + // 验证状态 + if status != model.ReportStatusApproved && status != model.ReportStatusRejected { + return 0, errors.New("无效的举报处理状态") + } + + // 批量处理举报 + affected, err := s.reportRepo.BatchReview(ctx, ids, status, reviewerID, reviewNote) + if err != nil { + s.logger.Error("批量处理举报失败", zap.Error(err)) + return 0, err + } + + s.logger.Info("批量处理举报成功", zap.Int("count", int(affected)), zap.Int64("reviewer_id", reviewerID), zap.String("status", string(status))) + return affected, nil +} + +// Delete 删除举报记录 +func (s *reportService) Delete(ctx context.Context, reportID, userID int64) error { + // 验证用户存在 + user, err := s.userRepo.FindByID(ctx, userID) + if err != nil { + return err + } + if user == nil { + return apperrors.ErrUserNotFound + } + + // 查询举报记录 + report, err := s.reportRepo.FindByID(ctx, reportID) + if err != nil { + return err + } + if report == nil { + return errors.New("举报记录不存在") + } + + // 只有举报人、管理员或处理人可以删除举报记录 + if report.ReporterID != userID && !(user.Role == "admin") && (report.ReviewerID == nil || *report.ReviewerID != userID) { + return errors.New("无权删除此举报记录") + } + + if err := s.reportRepo.Delete(ctx, reportID); err != nil { + s.logger.Error("删除举报记录失败", zap.Int64("report_id", reportID), zap.Error(err)) + return err + } + + s.logger.Info("删除举报记录成功", zap.Int64("report_id", reportID)) + return nil +} + +// BatchDelete 批量删除举报记录 +func (s *reportService) BatchDelete(ctx context.Context, ids []int64, userID int64) (int64, error) { + // 验证用户存在 + user, err := s.userRepo.FindByID(ctx, userID) + if err != nil { + return 0, err + } + if user == nil { + return 0, apperrors.ErrUserNotFound + } + + // 只有管理员可以批量删除 + if !(user.Role == "admin") { + return 0, errors.New("无权批量删除举报记录") + } + + affected, err := s.reportRepo.BatchDelete(ctx, ids) + if err != nil { + s.logger.Error("批量删除举报记录失败", zap.Error(err)) + return 0, err + } + + s.logger.Info("批量删除举报记录成功", zap.Int("count", int(affected))) + return affected, nil +} + +// GetStats 获取举报统计信息 +func (s *reportService) GetStats(ctx context.Context) (map[string]int64, error) { + stats := make(map[string]int64) + + // 统计各状态的举报数量 + pendingCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusPending) + if err != nil { + return nil, err + } + stats["pending"] = pendingCount + + approvedCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusApproved) + if err != nil { + return nil, err + } + stats["approved"] = approvedCount + + rejectedCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusRejected) + if err != nil { + return nil, err + } + stats["rejected"] = rejectedCount + + stats["total"] = pendingCount + approvedCount + rejectedCount + + return stats, nil +}