3 Commits

Author SHA1 Message Date
WuYuuuub
1ba0e6b2f0 添加了后台管理接口,包括用户、角色、材质管理,管理员可进行删除、查找、设定状态操作,详情在README
Some checks failed
Build / build (pull_request) Successful in 4m14s
Build / build-docker (pull_request) Failing after 3m11s
2026-01-23 00:32:27 +08:00
WuYuuuub
9219e8c6ea 添加了更严格的邮箱格式检查
Some checks failed
Build / build (pull_request) Successful in 4m53s
Build / build-docker (pull_request) Failing after 2m0s
2026-01-14 15:39:38 +08:00
WuYuuuub
133c46c086 添加 HTML 转义,防止邮件内容中的 HTML 注入攻击
All checks were successful
Build / build (pull_request) Successful in 5m5s
Build / build-docker (pull_request) Successful in 3m25s
2026-01-13 18:41:35 +08:00
23 changed files with 877 additions and 1275 deletions

2
.gitignore vendored
View File

@@ -60,7 +60,7 @@ configs/config.yaml
.env.production
# Keep example files
!.env
!.env.example
# Database files
*.db

136
README.md
View File

@@ -72,7 +72,7 @@ backend/
3. **配置环境变量**
```bash
cp .env .env
cp .env.example .env
# 根据实际环境填写数据库、Redis、对象存储、邮件等信息
```
@@ -144,6 +144,140 @@ golangci-lint run (若已安装)
- 若手动运行,需要保证 `docs/` 下的 `docs.go`、`swagger.json`、`swagger.yaml` 与代码同步
- 通过 `SERVER_SWAGGER_ENABLED=false` 可在生产环境关闭 Swagger UI 暴露
## 🔐 管理后台 API
管理后台接口均需要管理员权限(`role=admin`),所有接口路径前缀为 `/api/v1/admin`。
### 📊 统计信息
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/stats` | GET | 获取系统统计数据(用户数、材质数、下载量等) |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"total_users": 100,
"active_users": 80,
"banned_users": 5,
"admin_users": 3,
"total_textures": 500,
"public_textures": 300,
"pending_textures": 10,
"total_downloads": 1000,
"total_favorites": 500
}
}
```
### 👥 角色管理
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/roles` | GET | 获取所有可用角色列表 |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"roles": [
{
"name": "user",
"display_name": "普通用户",
"description": "拥有基本用户权限"
},
{
"name": "admin",
"display_name": "管理员",
"description": "拥有所有管理权限"
}
]
}
}
```
### 👤 用户管理
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/users` | GET | 获取用户列表(分页) |
| `/users/search` | GET | 搜索用户(支持关键词、角色、状态筛选、排序) |
| `/users/{id}` | GET | 获取用户详情 |
| `/users/{id}` | DELETE | 删除用户(软删除) |
| `/users/role` | PUT | 设置单个用户角色 |
| `/users/status` | PUT | 设置单个用户状态(封禁/解封) |
| `/users/batch-role` | PUT | 批量设置用户角色 |
| `/users/batch-delete` | DELETE | 批量删除用户 |
**搜索用户请求参数:**
- `keyword` (string): 搜索关键词(用户名或邮箱)
- `role` (string): 角色筛选
- `status` (int): 状态筛选1=正常0=禁用,-1=删除)
- `sort_by` (string): 排序字段
- `sort_desc` (bool): 是否降序
- `page` (int): 页码
- `page_size` (int): 每页数量
**设置用户状态请求示例:**
```json
{
"user_id": 123,
"status": 0
}
```
- `status`: 1=正常0=禁用,-1=删除
**批量设置角色请求示例:**
```json
{
"user_ids": [1, 2, 3],
"role": "admin"
}
```
### 🎨 材质管理
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/textures` | GET | 获取材质列表(分页) |
| `/textures/search` | GET | 搜索材质(支持关键词、类型、状态、上传者筛选、排序) |
| `/textures/{id}` | PUT | 更新材质信息(名称、描述、公开状态、审核状态) |
| `/textures/{id}` | DELETE | 删除材质 |
| `/textures/batch-delete` | DELETE | 批量删除材质 |
**搜索材质请求参数:**
- `keyword` (string): 搜索关键词
- `type` (string): 材质类型SKIN/CAPE
- `status` (int): 状态筛选
- `uploader_id` (int): 上传者ID筛选
- `sort_by` (string): 排序字段
- `sort_desc` (bool): 是否降序
- `page` (int): 页码
- `page_size` (int): 每页数量
**更新材质请求示例:**
```json
{
"name": "新皮肤名称",
"description": "新描述",
"is_public": true,
"status": 1
}
```
### 🔒 安全特性
1. **权限保护**:所有管理接口都需要管理员权限(`role=admin`
2. **安全限制**:管理员不能修改/删除自己的角色和状态
3. **批量操作**:支持批量设置角色和批量删除
4. **操作日志**:所有管理操作都会记录日志
5. **封禁功能**:通过 `/users/status` 接口可以封禁/解封用户
## 🤝 贡献指南
1. Fork & Clone

View File

@@ -17,7 +17,6 @@ import (
"os/signal"
"syscall"
"time"
_ "time/tzdata"
"carrotskin/internal/container"
"carrotskin/internal/handler"

2
go.mod
View File

@@ -112,7 +112,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/swag v1.16.6
github.com/swaggo/swag v1.16.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect

View File

@@ -32,7 +32,6 @@ type Container struct {
TextureRepo repository.TextureRepository
ClientRepo repository.ClientRepository
YggdrasilRepo repository.YggdrasilRepository
ReportRepo repository.ReportRepository
// Service层
UserService service.UserService
@@ -44,7 +43,6 @@ type Container struct {
SecurityService service.SecurityService
CaptchaService service.CaptchaService
SignatureService *service.SignatureService
ReportService service.ReportService
}
// NewContainer 创建依赖容器
@@ -88,7 +86,6 @@ func NewContainer(
c.TextureRepo = repository.NewTextureRepository(db)
c.ClientRepo = repository.NewClientRepository(db)
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
c.ReportRepo = repository.NewReportRepository(db)
// 初始化SignatureService作为依赖注入避免在容器中创建并立即调用
// 将SignatureService添加到容器中供其他服务使用
@@ -98,7 +95,6 @@ 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

View File

@@ -6,6 +6,7 @@ import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"carrotskin/internal/types"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -365,18 +366,527 @@ func (h *AdminHandler) GetTextureList(c *gin.Context) {
}))
}
// GetPermissions 获取权限列表
// @Summary 获取权限列表
// @Description 管理员获取所有Casbin权限规则
// SearchUsers 搜索用户
// @Summary 搜索用户
// @Description 管理员根据条件搜索用户
// @Tags Admin
// @Produce json
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Param keyword query string false "搜索关键词(用户名或邮箱)"
// @Param role query string false "角色筛选"
// @Param status query int false "状态筛选"
// @Param sort_by query string false "排序字段"
// @Param sort_desc query bool false "是否降序"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} model.Response{data=map[string]interface{}} "搜索成功"
// @Security BearerAuth
// @Router /api/v1/admin/permissions [get]
func (h *AdminHandler) GetPermissions(c *gin.Context) {
// 获取所有权限规则
policies, _ := h.container.Casbin.GetEnforcer().GetPolicy()
// @Router /api/v1/admin/users/search [get]
func (h *AdminHandler) SearchUsers(c *gin.Context) {
var req types.AdminUserSearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 || req.PageSize > 100 {
req.PageSize = 20
}
db := h.container.DB.Model(&model.User{})
// 关键词搜索
if req.Keyword != "" {
db = db.Where("username LIKE ? OR email LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%")
}
// 角色筛选
if req.Role != "" {
db = db.Where("role = ?", req.Role)
}
// 状态筛选
if req.Status != nil {
db = db.Where("status = ?", *req.Status)
}
// 排序
sortBy := "id"
if req.SortBy != "" {
sortBy = req.SortBy
}
order := sortBy
if req.SortDesc {
order += " DESC"
}
db = db.Order(order)
// 分页
var total int64
db.Count(&total)
var users []model.User
db.Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&users)
// 构建响应
userList := make([]gin.H, len(users))
for i, u := range users {
userList[i] = gin.H{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"avatar": u.Avatar,
"role": u.Role,
"status": u.Status,
"points": u.Points,
"last_login_at": u.LastLoginAt,
"created_at": u.CreatedAt,
}
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"policies": policies,
"users": userList,
"total": total,
"page": req.Page,
"page_size": req.PageSize,
}))
}
// DeleteUser 删除用户
// @Summary 删除用户
// @Description 管理员删除用户(软删除)
// @Tags Admin
// @Produce json
// @Param id path int true "用户ID"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 400 {object} model.ErrorResponse "不能删除自己"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Security BearerAuth
// @Router /api/v1/admin/users/{id} [delete]
func (h *AdminHandler) DeleteUser(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的用户ID", err)
return
}
operatorID, _ := c.Get("user_id")
// 不能删除自己
if userID == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能删除自己",
nil,
))
return
}
// 检查用户是否存在
user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
nil,
))
return
}
// 软删除
if err := h.container.DB.Delete(user).Error; err != nil {
RespondServerError(c, "删除用户失败", err)
return
}
h.container.Logger.Info("管理员删除用户",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("deleted_user_id", userID),
zap.String("username", user.Username),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "用户删除成功",
"user_id": userID,
}))
}
// BatchSetUserRole 批量设置用户角色
// @Summary 批量设置用户角色
// @Description 管理员批量设置多个用户的角色
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body map[string]interface{} true "批量设置请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "设置成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Security BearerAuth
// @Router /api/v1/admin/users/batch-role [put]
func (h *AdminHandler) BatchSetUserRole(c *gin.Context) {
var req struct {
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
Role string `json:"role" binding:"required,oneof=user admin"`
}
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
operatorID, _ := c.Get("user_id")
// 检查是否包含自己
for _, uid := range req.UserIDs {
if uid == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能修改自己的角色",
nil,
))
return
}
}
// 批量更新
if err := h.container.DB.Model(&model.User{}).
Where("id IN ?", req.UserIDs).
Update("role", req.Role).Error; err != nil {
RespondServerError(c, "批量更新角色失败", err)
return
}
h.container.Logger.Info("管理员批量设置用户角色",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int("count", len(req.UserIDs)),
zap.String("role", req.Role),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "批量设置角色成功",
"count": len(req.UserIDs),
"role": req.Role,
}))
}
// BatchDeleteUsers 批量删除用户
// @Summary 批量删除用户
// @Description 管理员批量删除多个用户
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body map[string]interface{} true "批量删除请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Security BearerAuth
// @Router /api/v1/admin/users/batch-delete [delete]
func (h *AdminHandler) BatchDeleteUsers(c *gin.Context) {
var req struct {
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
operatorID, _ := c.Get("user_id")
// 检查是否包含自己
for _, uid := range req.UserIDs {
if uid == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能删除自己",
nil,
))
return
}
}
// 批量软删除
if err := h.container.DB.Where("id IN ?", req.UserIDs).Delete(&model.User{}).Error; err != nil {
RespondServerError(c, "批量删除用户失败", err)
return
}
h.container.Logger.Info("管理员批量删除用户",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int("count", len(req.UserIDs)),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "批量删除用户成功",
"count": len(req.UserIDs),
}))
}
// GetRoles 获取角色列表
// @Summary 获取角色列表
// @Description 管理员获取所有可用角色
// @Tags Admin
// @Produce json
// @Success 200 {object} model.Response{data=types.AdminRoleListResponse} "获取成功"
// @Security BearerAuth
// @Router /api/v1/admin/roles [get]
func (h *AdminHandler) GetRoles(c *gin.Context) {
roles := []types.RoleInfo{
{
Name: "user",
DisplayName: "普通用户",
Description: "拥有基本用户权限",
},
{
Name: "admin",
DisplayName: "管理员",
Description: "拥有所有管理权限",
},
}
c.JSON(http.StatusOK, model.NewSuccessResponse(types.AdminRoleListResponse{
Roles: roles,
}))
}
// SearchTextures 搜索材质
// @Summary 搜索材质
// @Description 管理员根据条件搜索材质
// @Tags Admin
// @Produce json
// @Param keyword query string false "搜索关键词"
// @Param type query string false "材质类型"
// @Param status query int false "状态筛选"
// @Param uploader_id query int false "上传者ID"
// @Param sort_by query string false "排序字段"
// @Param sort_desc query bool false "是否降序"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} model.Response{data=map[string]interface{}} "搜索成功"
// @Security BearerAuth
// @Router /api/v1/admin/textures/search [get]
func (h *AdminHandler) SearchTextures(c *gin.Context) {
var req types.AdminTextureSearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 || req.PageSize > 100 {
req.PageSize = 20
}
db := h.container.DB.Model(&model.Texture{})
// 关键词搜索
if req.Keyword != "" {
db = db.Where("name LIKE ?", "%"+req.Keyword+"%")
}
// 类型筛选
if req.Type != "" {
db = db.Where("type = ?", req.Type)
}
// 状态筛选
if req.Status != nil {
db = db.Where("status = ?", *req.Status)
}
// 上传者筛选
if req.UploaderID != nil {
db = db.Where("uploader_id = ?", *req.UploaderID)
}
// 排序
sortBy := "id"
if req.SortBy != "" {
sortBy = req.SortBy
}
order := sortBy
if req.SortDesc {
order += " DESC"
}
db = db.Order(order)
// 分页
var total int64
db.Count(&total)
var textures []model.Texture
db.Preload("Uploader").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&textures)
// 构建响应
textureList := make([]gin.H, len(textures))
for i, t := range textures {
uploaderName := ""
if t.Uploader != nil {
uploaderName = t.Uploader.Username
}
textureList[i] = gin.H{
"id": t.ID,
"name": t.Name,
"type": t.Type,
"hash": t.Hash,
"uploader_id": t.UploaderID,
"uploader_name": uploaderName,
"is_public": t.IsPublic,
"download_count": t.DownloadCount,
"favorite_count": t.FavoriteCount,
"status": t.Status,
"created_at": t.CreatedAt,
}
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"textures": textureList,
"total": total,
"page": req.Page,
"page_size": req.PageSize,
}))
}
// UpdateTexture 更新材质
// @Summary 更新材质
// @Description 管理员更新材质信息
// @Tags Admin
// @Accept json
// @Produce json
// @Param id path int true "材质ID"
// @Param request body types.AdminTextureUpdateRequest true "更新材质请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "更新成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "材质不存在"
// @Security BearerAuth
// @Router /api/v1/admin/textures/{id} [put]
func (h *AdminHandler) UpdateTexture(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
var req types.AdminTextureUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 检查材质是否存在
var texture model.Texture
if err := h.container.DB.First(&texture, textureID).Error; err != nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"材质不存在",
nil,
))
return
}
// 构建更新字段
updates := make(map[string]interface{})
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.IsPublic != nil {
updates["is_public"] = *req.IsPublic
}
if req.Status != nil {
updates["status"] = *req.Status
}
// 执行更新
if len(updates) > 0 {
if err := h.container.DB.Model(&texture).Updates(updates).Error; err != nil {
RespondServerError(c, "更新材质失败", err)
return
}
}
operatorID, _ := c.Get("user_id")
h.container.Logger.Info("管理员更新材质",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("texture_id", textureID),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "材质更新成功",
"texture_id": textureID,
}))
}
// BatchDeleteTextures 批量删除材质
// @Summary 批量删除材质
// @Description 管理员批量删除多个材质
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body map[string]interface{} true "批量删除请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Security BearerAuth
// @Router /api/v1/admin/textures/batch-delete [delete]
func (h *AdminHandler) BatchDeleteTextures(c *gin.Context) {
var req struct {
TextureIDs []int64 `json:"texture_ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
operatorID, _ := c.Get("user_id")
// 批量删除
if err := h.container.DB.Where("id IN ?", req.TextureIDs).Delete(&model.Texture{}).Error; err != nil {
RespondServerError(c, "批量删除材质失败", err)
return
}
h.container.Logger.Info("管理员批量删除材质",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int("count", len(req.TextureIDs)),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "批量删除材质成功",
"count": len(req.TextureIDs),
}))
}
// GetStats 获取统计信息
// @Summary 获取统计信息
// @Description 管理员获取系统统计数据
// @Tags Admin
// @Produce json
// @Success 200 {object} model.Response{data=types.AdminStatsResponse} "获取成功"
// @Security BearerAuth
// @Router /api/v1/admin/stats [get]
func (h *AdminHandler) GetStats(c *gin.Context) {
var stats types.AdminStatsResponse
// 用户统计
h.container.DB.Model(&model.User{}).Count(&stats.TotalUsers)
h.container.DB.Model(&model.User{}).Where("status = ?", 1).Count(&stats.ActiveUsers)
h.container.DB.Model(&model.User{}).Where("status = ?", 0).Count(&stats.BannedUsers)
h.container.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&stats.AdminUsers)
// 材质统计
h.container.DB.Model(&model.Texture{}).Count(&stats.TotalTextures)
h.container.DB.Model(&model.Texture{}).Where("is_public = ?", true).Count(&stats.PublicTextures)
h.container.DB.Model(&model.Texture{}).Where("status = ?", 0).Count(&stats.PendingTextures)
// 下载和收藏统计
h.container.DB.Model(&model.Texture{}).Select("COALESCE(SUM(download_count), 0)").Scan(&stats.TotalDownloads)
h.container.DB.Model(&model.Texture{}).Select("COALESCE(SUM(favorite_count), 0)").Scan(&stats.TotalFavorites)
c.JSON(http.StatusOK, model.NewSuccessResponse(stats))
}

View File

@@ -117,6 +117,16 @@ func (h *AuthHandler) SendVerificationCode(c *gin.Context) {
return
}
// 验证邮箱格式
if !isValidEmail(req.Email) {
h.logger.Warn("发送验证码失败:邮箱格式错误",
zap.String("email", req.Email),
)
RespondBadRequest(c, "邮箱格式错误", nil)
return
}
// 调用服务发送验证码
if err := h.container.VerificationService.SendCode(c.Request.Context(), req.Email, req.Type); err != nil {
h.logger.Error("发送验证码失败",
zap.String("email", req.Email),

View File

@@ -5,6 +5,7 @@ import (
"carrotskin/internal/model"
"carrotskin/internal/types"
"net/http"
"regexp"
"strconv"
"github.com/gin-gonic/gin"
@@ -227,3 +228,14 @@ func RespondWithError(c *gin.Context, err error) {
// 默认返回500错误
RespondServerError(c, err.Error(), err)
}
// isValidEmail 验证邮箱格式
func isValidEmail(email string) bool {
if email == "" {
return false
}
// 更严格的邮箱格式验证
emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
matched, _ := regexp.MatchString(emailRegex, email)
return matched
}

View File

@@ -1,495 +0,0 @@
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)
}

View File

@@ -21,7 +21,6 @@ type Handlers struct {
Yggdrasil *YggdrasilHandler
CustomSkin *CustomSkinHandler
Admin *AdminHandler
Report *ReportHandler
}
// NewHandlers 创建所有Handler实例
@@ -35,7 +34,6 @@ func NewHandlers(c *container.Container) *Handlers {
Yggdrasil: NewYggdrasilHandler(c),
CustomSkin: NewCustomSkinHandler(c),
Admin: NewAdminHandler(c),
Report: NewReportHandler(c),
}
}
@@ -79,9 +77,6 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 管理员路由(需要管理员权限)
registerAdminRoutes(v1, c, h.Admin)
// 举报路由
registerReportRoutes(v1, h.Report, c.JWT)
}
}
@@ -208,18 +203,28 @@ func registerAdminRoutes(v1 *gin.RouterGroup, c *container.Container, h *AdminHa
admin.Use(middleware.RequireAdmin())
{
// 统计信息
admin.GET("/stats", h.GetStats)
// 角色管理
admin.GET("/roles", h.GetRoles)
// 用户管理
admin.GET("/users", h.GetUserList)
admin.GET("/users/search", h.SearchUsers)
admin.GET("/users/:id", h.GetUserDetail)
admin.DELETE("/users/:id", h.DeleteUser)
admin.PUT("/users/role", h.SetUserRole)
admin.PUT("/users/status", h.SetUserStatus)
admin.PUT("/users/batch-role", h.BatchSetUserRole)
admin.DELETE("/users/batch-delete", h.BatchDeleteUsers)
// 材质管理(审核)
admin.GET("/textures", h.GetTextureList)
admin.GET("/textures/search", h.SearchTextures)
admin.PUT("/textures/:id", h.UpdateTexture)
admin.DELETE("/textures/:id", h.DeleteTexture)
// 权限管理
admin.GET("/permissions", h.GetPermissions)
admin.DELETE("/textures/batch-delete", h.BatchDeleteTextures)
}
}
@@ -241,28 +246,3 @@ 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)
}
}
}

View File

@@ -1,49 +0,0 @@
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"
}

View File

@@ -56,12 +56,17 @@ type TextureRepository interface {
Delete(ctx context.Context, id int64) error
BatchDelete(ctx context.Context, ids []int64) (int64, error) // 批量删除
IncrementDownloadCount(ctx context.Context, id int64) error
IncrementFavoriteCount(ctx context.Context, id int64) error
DecrementFavoriteCount(ctx context.Context, id int64) error
CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error
ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error)
IsFavorited(ctx context.Context, userID, textureID int64) (bool, error)
AddFavorite(ctx context.Context, userID, textureID int64) error
RemoveFavorite(ctx context.Context, userID, textureID int64) error
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error)
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
}
// YggdrasilRepository Yggdrasil仓储接口
type YggdrasilRepository interface {
GetPasswordByID(ctx context.Context, id int64) (string, error)
@@ -79,21 +84,3 @@ 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)
}

View File

@@ -1,225 +0,0 @@
package repository
import (
"carrotskin/internal/model"
"context"
"errors"
"time"
"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 := time.Now()
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 {
now := time.Now()
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,
"reviewed_at": &now,
})
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
}

View File

@@ -98,6 +98,7 @@ func TestProfileRepository_Basic(t *testing.T) {
t.Fatalf("CountByUserID mismatch: %d err=%v", count, err)
}
if err := profileRepo.UpdateLastUsedAt(ctx, "p-uuid"); err != nil {
t.Fatalf("UpdateLastUsedAt err: %v", err)
}
@@ -149,20 +150,22 @@ func TestTextureRepository_Basic(t *testing.T) {
t.Fatalf("FindByHashAndUploaderID mismatch")
}
_, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID)
favList, _, _ := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10)
if len(favList) == 0 {
t.Fatalf("GetUserFavorites expected at least 1 favorite")
}
_, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID)
favList, _, _ = textureRepo.GetUserFavorites(ctx, u.ID, 1, 10)
if len(favList) != 0 {
t.Fatalf("GetUserFavorites expected 0 favorites after toggle off")
}
_ = textureRepo.IncrementFavoriteCount(ctx, tex.ID)
_ = textureRepo.DecrementFavoriteCount(ctx, tex.ID)
_ = textureRepo.IncrementDownloadCount(ctx, tex.ID)
_ = textureRepo.CreateDownloadLog(ctx, &model.TextureDownloadLog{TextureID: tex.ID, UserID: &u.ID, IPAddress: "127.0.0.1"})
// 收藏
_ = textureRepo.AddFavorite(ctx, u.ID, tex.ID)
if fav, err := textureRepo.IsFavorited(ctx, u.ID, tex.ID); err == nil {
if !fav {
t.Fatalf("IsFavorited expected true")
}
} else {
t.Skipf("IsFavorited not supported by sqlite: %v", err)
}
_ = textureRepo.RemoveFavorite(ctx, u.ID, tex.ID)
// 批量更新与删除
if affected, err := textureRepo.BatchUpdate(ctx, []int64{tex.ID}, map[string]interface{}{"name": "tex-new"}); err != nil || affected != 1 {
t.Fatalf("BatchUpdate mismatch, affected=%d err=%v", affected, err)
@@ -184,7 +187,7 @@ func TestTextureRepository_Basic(t *testing.T) {
if list, total, err := textureRepo.Search(ctx, "search", model.TextureTypeCape, true, 1, 10); err != nil || total == 0 || len(list) == 0 {
t.Fatalf("Search mismatch, total=%d len=%d err=%v", total, len(list), err)
}
_, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID+1)
_ = textureRepo.AddFavorite(ctx, u.ID, tex.ID+1)
if favList, total, err := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10); err != nil || total == 0 || len(favList) == 0 {
t.Fatalf("GetUserFavorites mismatch, total=%d len=%d err=%v", total, len(favList), err)
}
@@ -203,6 +206,7 @@ func TestTextureRepository_Basic(t *testing.T) {
_ = textureRepo.Delete(ctx, tex.ID)
}
func TestClientRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
repo := NewClientRepository(db)

View File

@@ -138,52 +138,42 @@ func (r *textureRepository) IncrementDownloadCount(ctx context.Context, id int64
UpdateColumn("download_count", gorm.Expr("download_count + ?", 1)).Error
}
func (r *textureRepository) IncrementFavoriteCount(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("favorite_count", gorm.Expr("favorite_count + ?", 1)).Error
}
func (r *textureRepository) DecrementFavoriteCount(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("favorite_count", gorm.Expr("favorite_count - ?", 1)).Error
}
func (r *textureRepository) CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
func (r *textureRepository) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
var isAdded bool
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
func (r *textureRepository) IsFavorited(ctx context.Context, userID, textureID int64) (bool, error) {
var count int64
err := tx.Model(&model.UserTextureFavorite{}).
// 使用 Select("1") 优化,只查询是否存在,不需要查询所有字段
err := r.db.WithContext(ctx).Model(&model.UserTextureFavorite{}).
Select("1").
Where("user_id = ? AND texture_id = ?", userID, textureID).
Limit(1).
Count(&count).Error
if err != nil {
return err
}
if count > 0 {
result := tx.Where("user_id = ? AND texture_id = ?", userID, textureID).
Delete(&model.UserTextureFavorite{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
if err := tx.Model(&model.Texture{}).Where("id = ?", textureID).
UpdateColumn("favorite_count", gorm.Expr("GREATEST(favorite_count - 1, 0)")).Error; err != nil {
return err
}
}
isAdded = false
return nil
return count > 0, err
}
func (r *textureRepository) AddFavorite(ctx context.Context, userID, textureID int64) error {
favorite := &model.UserTextureFavorite{
UserID: userID,
TextureID: textureID,
}
if err := tx.Create(favorite).Error; err != nil {
return err
return r.db.WithContext(ctx).Create(favorite).Error
}
if err := tx.Model(&model.Texture{}).Where("id = ?", textureID).
UpdateColumn("favorite_count", gorm.Expr("favorite_count + 1")).Error; err != nil {
return err
}
isAdded = true
return nil
})
return isAdded, err
func (r *textureRepository) RemoveFavorite(ctx context.Context, userID, textureID int64) error {
return r.db.WithContext(ctx).Where("user_id = ? AND texture_id = ?", userID, textureID).
Delete(&model.UserTextureFavorite{}).Error
}
func (r *textureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {

View File

@@ -137,30 +137,6 @@ 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
@@ -171,7 +147,6 @@ type Services struct {
Captcha CaptchaService
Yggdrasil YggdrasilService
Security SecurityService
Report ReportService
}
// ServiceDeps 服务依赖

View File

@@ -391,24 +391,37 @@ func (m *MockTextureRepository) IncrementFavoriteCount(ctx context.Context, id i
return nil
}
func (m *MockTextureRepository) DecrementFavoriteCount(ctx context.Context, id int64) error {
if texture, ok := m.textures[id]; ok && texture.FavoriteCount > 0 {
texture.FavoriteCount--
}
return nil
}
func (m *MockTextureRepository) CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error {
return nil
}
func (m *MockTextureRepository) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
func (m *MockTextureRepository) IsFavorited(ctx context.Context, userID, textureID int64) (bool, error) {
if userFavs, ok := m.favorites[userID]; ok {
return userFavs[textureID], nil
}
return false, nil
}
func (m *MockTextureRepository) AddFavorite(ctx context.Context, userID, textureID int64) error {
if m.favorites[userID] == nil {
m.favorites[userID] = make(map[int64]bool)
}
isFavorited := m.favorites[userID][textureID]
m.favorites[userID][textureID] = !isFavorited
if texture, ok := m.textures[textureID]; ok {
if !isFavorited {
texture.FavoriteCount++
} else if texture.FavoriteCount > 0 {
texture.FavoriteCount--
m.favorites[userID][textureID] = true
return nil
}
func (m *MockTextureRepository) RemoveFavorite(ctx context.Context, userID, textureID int64) error {
if userFavs, ok := m.favorites[userID]; ok {
delete(userFavs, textureID)
}
return !isFavorited, nil
return nil
}
func (m *MockTextureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
@@ -461,6 +474,7 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
return deleted, nil
}
// ============================================================================
// Service Mocks
// ============================================================================

View File

@@ -1,335 +0,0 @@
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
}

View File

@@ -219,22 +219,39 @@ func (s *textureService) Delete(ctx context.Context, textureID, uploaderID int64
}
func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
// 确保材质存在
texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil {
return false, err
}
if texture == nil || texture.Status != 1 || !texture.IsPublic {
if texture == nil {
return false, ErrTextureNotFound
}
isAdded, err := s.textureRepo.ToggleFavorite(ctx, userID, textureID)
isFavorited, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
if err != nil {
return false, err
}
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.UserFavoritesPattern(userID))
if isFavorited {
// 已收藏 -> 取消收藏
if err := s.textureRepo.RemoveFavorite(ctx, userID, textureID); err != nil {
return false, err
}
if err := s.textureRepo.DecrementFavoriteCount(ctx, textureID); err != nil {
return false, err
}
return false, nil
}
return isAdded, nil
// 未收藏 -> 添加收藏
if err := s.textureRepo.AddFavorite(ctx, userID, textureID); err != nil {
return false, err
}
if err := s.textureRepo.IncrementFavoriteCount(ctx, textureID); err != nil {
return false, err
}
return true, nil
}
func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {

View File

@@ -3,7 +3,6 @@ package service
import (
"carrotskin/internal/model"
"context"
"strings"
"testing"
"go.uber.org/zap"
@@ -565,7 +564,7 @@ func TestTextureServiceImpl_Create(t *testing.T) {
ctx := context.Background()
// UploadTexture需要文件数据这里创建一个简单的测试数据
fileData := []byte(strings.Repeat("x", 512))
fileData := []byte("fake png data for testing")
texture, err := textureService.UploadTexture(
ctx,
tt.uploaderID,
@@ -761,7 +760,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
UploaderID: 1,
Name: "T",
})
_, _ = textureRepo.ToggleFavorite(context.Background(), 1, i)
_ = textureRepo.AddFavorite(context.Background(), 1, i)
}
cacheManager := NewMockCacheManager()

View File

@@ -206,3 +206,84 @@ type SystemConfigResponse struct {
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
}
// AdminUserSearchRequest 管理员用户搜索请求
// @Description 管理员搜索用户请求参数
type AdminUserSearchRequest struct {
PaginationRequest
Keyword string `json:"keyword" form:"keyword" example:"testuser"`
Role string `json:"role" form:"role" binding:"omitempty,oneof=user admin"`
Status *int16 `json:"status" form:"status" binding:"omitempty,oneof=1 0 -1"`
SortBy string `json:"sort_by" form:"sort_by" binding:"omitempty,oneof=id username email points created_at"`
SortDesc bool `json:"sort_desc" form:"sort_desc"`
}
// AdminUserCreateRequest 管理员创建用户请求
// @Description 管理员创建用户请求参数
type AdminUserCreateRequest struct {
Username string `json:"username" binding:"required,min=3,max=50" example:"newuser"`
Email string `json:"email" binding:"required,email" example:"user@example.com"`
Password string `json:"password" binding:"required,min=6,max=128" example:"password123"`
Role string `json:"role" binding:"required,oneof=user admin" example:"user"`
Points int `json:"points" binding:"omitempty,min=0" example:"0"`
}
// AdminUserUpdateRequest 管理员更新用户请求
// @Description 管理员更新用户请求参数
type AdminUserUpdateRequest struct {
Username *string `json:"username" binding:"omitempty,min=3,max=50" example:"newusername"`
Email *string `json:"email" binding:"omitempty,email" example:"newemail@example.com"`
Password *string `json:"password" binding:"omitempty,min=6,max=128" example:"newpassword"`
Role *string `json:"role" binding:"omitempty,oneof=user admin" example:"admin"`
Points *int `json:"points" binding:"omitempty,min=0" example:"100"`
Status *int16 `json:"status" binding:"omitempty,oneof=1 0 -1"`
}
// AdminTextureSearchRequest 管理员材质搜索请求
// @Description 管理员搜索材质请求参数
type AdminTextureSearchRequest struct {
PaginationRequest
Keyword string `json:"keyword" form:"keyword" example:"skin"`
Type TextureType `json:"type" form:"type" binding:"omitempty,oneof=SKIN CAPE"`
Status *int16 `json:"status" form:"status" binding:"omitempty,oneof=1 0 -1"`
UploaderID *int64 `json:"uploader_id" form:"uploader_id" example:"1"`
SortBy string `json:"sort_by" form:"sort_by" binding:"omitempty,oneof=id name download_count favorite_count created_at"`
SortDesc bool `json:"sort_desc" form:"sort_desc"`
}
// AdminTextureUpdateRequest 管理员更新材质请求
// @Description 管理员更新材质请求参数
type AdminTextureUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,min=1,max=100" example:"New Skin Name"`
Description *string `json:"description" binding:"omitempty,max=500" example:"New description"`
IsPublic *bool `json:"is_public" example:"true"`
Status *int16 `json:"status" binding:"omitempty,oneof=1 0 -1"`
}
// AdminRoleListResponse 角色列表响应
// @Description 角色列表响应数据
type AdminRoleListResponse struct {
Roles []RoleInfo `json:"roles"`
}
// RoleInfo 角色信息
// @Description 角色详细信息
type RoleInfo struct {
Name string `json:"name" example:"admin"`
DisplayName string `json:"display_name" example:"管理员"`
Description string `json:"description" example:"拥有所有管理权限"`
}
// AdminStatsResponse 管理员统计信息响应
// @Description 管理员统计信息
type AdminStatsResponse struct {
TotalUsers int64 `json:"total_users" example:"100"`
ActiveUsers int64 `json:"active_users" example:"80"`
BannedUsers int64 `json:"banned_users" example:"5"`
AdminUsers int64 `json:"admin_users" example:"3"`
TotalTextures int64 `json:"total_textures" example:"500"`
PublicTextures int64 `json:"public_textures" example:"300"`
PendingTextures int64 `json:"pending_textures" example:"10"`
TotalDownloads int64 `json:"total_downloads" example:"1000"`
TotalFavorites int64 `json:"total_favorites" example:"500"`
}

View File

@@ -369,11 +369,6 @@ func (b *CacheKeyBuilder) ProfilePattern(userID int64) string {
return fmt.Sprintf("%sprofile:*:%d*", b.prefix, userID)
}
// UserFavoritesPattern 用户收藏相关的所有缓存键模式
func (b *CacheKeyBuilder) UserFavoritesPattern(userID int64) string {
return fmt.Sprintf("%sfavorites:*:%d*", b.prefix, userID)
}
// Exists 检查缓存键是否存在
func (cm *CacheManager) Exists(ctx context.Context, key string) (bool, error) {
if !cm.config.Enabled || cm.redis == nil {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/smtp"
"net/textproto"
"html"
"carrotskin/pkg/config"
@@ -70,8 +71,6 @@ func (s *Service) send(to []string, subject, body string) error {
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, s.cfg.SMTPPort)
// 判断端口决定发送方式
// 465端口使用SSL/TLS隐式TLS
// 587端口使用STARTTLS显式TLS
var err error
if s.cfg.SMTPPort == 465 {
// 使用SSL/TLS连接适用于465端口
@@ -132,6 +131,10 @@ func (s *Service) getBody(code, purpose string) string {
message = "您的验证码为:"
}
// 转义 HTML 特殊字符
escapedMessage := html.EscapeString(message)
escapedCode := html.EscapeString(code)
return fmt.Sprintf(`
<!DOCTYPE html>
<html>
@@ -158,5 +161,5 @@ func (s *Service) getBody(code, purpose string) string {
</div>
</body>
</html>
`, message, code)
`, escapedMessage, escapedCode)
}