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
18 changed files with 897 additions and 125 deletions

View File

@@ -1,3 +1,6 @@
# CarrotSkin 环境配置文件示例
# 复制此文件为 .env 并修改相应的配置值
# =============================================================================
# 站点配置
# =============================================================================
@@ -31,10 +34,10 @@ SERVER_SWAGGER_ENABLED=true
# 数据库配置
# =============================================================================
DATABASE_DRIVER=postgres
DATABASE_HOST=120.27.110.94
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=user_wc2MbZ
DATABASE_PASSWORD=password_65b5aN
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=your_password_here
DATABASE_NAME=carrotskin
DATABASE_SSL_MODE=disable
DATABASE_TIMEZONE=Asia/Shanghai
@@ -46,19 +49,19 @@ DATABASE_CONN_MAX_IDLE_TIME=10m
# =============================================================================
# Redis配置
# =============================================================================
REDIS_HOST=120.27.110.94
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=redis_ZXjbN5
REDIS_PASSWORD=
REDIS_DATABASE=0
REDIS_POOL_SIZE=10
# =============================================================================
# RustFS对象存储配置 (S3兼容)
# =============================================================================
RUSTFS_ENDPOINT=120.27.110.94:9000
RUSTFS_PUBLIC_URL=http://120.27.110.94:9000
RUSTFS_ACCESS_KEY=ftbulyR6rj0AZ4n5ID7g
RUSTFS_SECRET_KEY=P8q3VZ1wfMEdGJayu4sxh7NRSAB2H0tkFeTQlXLW
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
@@ -75,18 +78,6 @@ JWT_EXPIRE_HOURS=168
LOG_LEVEL=info
LOG_FORMAT=json
LOG_OUTPUT=logs/app.log
# 保留的旧配置项
LOG_MAX_SIZE=100
LOG_MAX_BACKUPS=3
LOG_MAX_AGE=28
LOG_COMPRESS=true
# =============================================================================
# 文件上传配置 (保留的旧配置项)
# =============================================================================
UPLOAD_MAX_SIZE=10485760
UPLOAD_TEXTURE_MAX_SIZE=2097152
UPLOAD_AVATAR_MAX_SIZE=1048576
# =============================================================================
# 安全配置
@@ -94,17 +85,15 @@ UPLOAD_AVATAR_MAX_SIZE=1048576
# CORS 允许的来源,多个用逗号分隔
SECURITY_ALLOWED_ORIGINS=*
# 允许的头像/材质URL域名多个用逗号分隔
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1,120.27.110.94
# 保留的旧配置项
MAX_LOGIN_ATTEMPTS=5
LOGIN_LOCK_DURATION=30m
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
# =============================================================================
# 邮件配置
# 腾讯企业邮箱SSL配置示例smtp.exmail.qq.com, 端口465
# =============================================================================
EMAIL_ENABLED=true
EMAIL_SMTP_HOST=smtp.exmail.qq.com
EMAIL_SMTP_PORT=465
EMAIL_USERNAME=system@qczlit.cn
EMAIL_PASSWORD=545mkewZwMzEWUjD
EMAIL_FROM_NAME=CarrotSkin
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

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

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

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

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)

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 {
var count int64
err := tx.Model(&model.UserTextureFavorite{}).
Where("user_id = ? AND texture_id = ?", userID, textureID).
Count(&count).Error
if err != nil {
return err
}
func (r *textureRepository) IsFavorited(ctx context.Context, userID, textureID int64) (bool, error) {
var count int64
// 使用 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
return count > 0, 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
}
func (r *textureRepository) AddFavorite(ctx context.Context, userID, textureID int64) error {
favorite := &model.UserTextureFavorite{
UserID: userID,
TextureID: textureID,
}
return r.db.WithContext(ctx).Create(favorite).Error
}
favorite := &model.UserTextureFavorite{
UserID: userID,
TextureID: textureID,
}
if err := tx.Create(favorite).Error; err != nil {
return err
}
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

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

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