21 Commits

Author SHA1 Message Date
52b61be822 删除误上传的.env 2026-01-21 22:07:24 +08:00
17a2792ac4 初步完成举报功能 2026-01-21 22:04:12 +08:00
68d7318285 初步完成举报功能 2026-01-21 21:34:11 +08:00
432b875ba4 皮肤部分拿apifox测过了 2026-01-20 11:50:24 +08:00
116612ffec 修改了皮肤收藏部分 2026-01-13 18:34:21 +08:00
lafay
3e8b7d150d chore: Refactor Dockerfile and build workflow for improved efficiency
All checks were successful
Build / build (push) Successful in 4m4s
Build / build-docker (push) Successful in 1m16s
- Removed the build stage from the Dockerfile, simplifying the image creation process.
- Updated the Dockerfile to directly copy the pre-built binary instead of using a multi-stage build.
- Modified the build workflow to eliminate unnecessary build arguments, streamlining the configuration.
2026-01-10 05:21:45 +08:00
lafay
fd5a0e8405 chore: Update Docker image tags in build workflow
Some checks failed
Build / build (push) Successful in 3m57s
Build / build-docker (push) Has been cancelled
- Changed Docker image tags in the build workflow to reflect the new repository owner.
- Updated the image references to use 'carrotskin' instead of the previous owner for consistency.
2026-01-10 05:12:51 +08:00
lafay
573c10ed1d chore: Remove Swagger documentation generation from build workflow
Some checks failed
Build / build (push) Successful in 4m0s
Build / build-docker (push) Failing after 41s
- Eliminated the Swagger documentation generation step from the build process.
- Updated the main server file to remove the Swagger documentation import, streamlining the codebase.
2026-01-10 05:00:09 +08:00
lafay
3b8d8bd7a7 chore: Add Swagger documentation generation to build workflow
Some checks failed
Build / build (push) Failing after 55s
Build / build-docker (push) Has been skipped
- Included a step to generate Swagger documentation during the build process.
- This addition enhances API documentation and ensures it is up-to-date with the codebase.
2026-01-10 04:57:56 +08:00
lafay
6338592d27 chore: Remove Go proxy setup from build workflow
Some checks failed
Build / build (push) Failing after 3m8s
Build / build-docker (push) Has been skipped
- Eliminated the Go proxy configuration from the build workflow to streamline the setup process.
- This change simplifies the environment setup for dependency management.
2026-01-10 04:47:14 +08:00
lafay
ef460ec891 chore: Disable caching in Go setup for build workflow
Some checks failed
Build / build (push) Failing after 1m31s
Build / build-docker (push) Has been skipped
- Updated the build workflow to disable caching for the Go setup action.
- This change aims to ensure a clean build environment for better consistency.
2026-01-10 04:42:22 +08:00
lafay
62d9432a2d chore: Add Go proxy setup to build workflow
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
- Configured Go proxy settings for improved dependency management.
- Disabled Go checksum database for local development environments.
2026-01-10 04:39:11 +08:00
lafay
e1d79ed445 chore: Update build workflow to include 'dev' branch
Some checks failed
Build / build (push) Failing after 21m16s
Build / build-docker (push) Has been skipped
- Added 'dev' branch to the push and pull_request triggers in the build workflow configuration.
- Ensured that CI/CD processes are aligned for both master and dev branches.
2026-01-10 04:01:20 +08:00
lafay
c5d7e317a4 refactor: Streamline user information retrieval and validation
- Refactored the user information retrieval process to improve efficiency.
- Enhanced validation logic for input parameters in the user handler.
- Updated UserService interface to support new retrieval methods.
- Improved error handling for user status checks before responding.
2026-01-10 03:58:22 +08:00
lafay
06539dc086 feat: Add public user information retrieval endpoint
- Introduced a new endpoint to fetch public user information without authentication.
- Implemented UserToPublicUserInfo function to format user data for the response.
- Updated UserService interface and user service implementation to support fetching users by username.
- Enhanced user handler to validate input parameters and check user status before responding.
2026-01-10 03:52:35 +08:00
lafay
22142db782 fix: Improve texture upload handling and caching logic
- Simplified caching logic by removing unnecessary nil check before setting cache.
- Enhanced error handling in texture upload process to return the original texture object if fetching the uploader information fails or returns nil.
2026-01-10 03:23:26 +08:00
lafay
2c9c6ecfc0 Merge branch 'dev' of https://code.littlelan.cn/CarrotSkin/backend into dev 2026-01-10 03:17:39 +08:00
lafay
c5db489d72 refactor: Enhance texture handling and configuration
- Removed Swagger documentation import from the main server file.
- Updated TextureInfo struct to include UploaderUsername for better texture metadata.
- Modified texture repository methods to preload Uploader information when fetching textures by hash.
- Improved texture service to handle cases where Uploader information is missing, ensuring proper caching and retrieval.
- Added Redis configuration options in the environment variable setup for better flexibility.
2026-01-10 03:15:27 +08:00
d952ddd4ea 增加环境变量是否成功读取的检查,并在控制台中打印日志 2026-01-10 01:58:46 +08:00
e761ff5be5 移除预签名url相关实现 2026-01-04 16:08:23 +08:00
9e83ae16af 更新readme 2025-12-26 21:32:32 +08:00
26 changed files with 1684 additions and 747 deletions

View File

@@ -0,0 +1,73 @@
name: Build
on:
push:
branches:
- master
- dev
pull_request:
branches:
- master
- dev
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: false
- name: Download dependencies
run: go mod download
- name: Build
env:
GOOS: linux
GOARCH: amd64
CGO_ENABLED: 0
run: go build -v -o mcauth-linux-amd64 ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: mcauth-linux-amd64
path: mcauth-linux-amd64
build-docker:
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: code.littlelan.cn
username: ${{ secrets.GIT_USERNAME }}
password: ${{ secrets.GIT_TOKEN }}
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: mcauth-linux-amd64
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
code.littlelan.cn/carrotskin/mcauth:latest
code.littlelan.cn/carrotskin/mcauth:${{ github.sha }}
platforms: linux/amd64

2
.gitignore vendored
View File

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

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# 运行阶段
FROM alpine:latest
# 安装必要的运行时依赖
RUN apk add --no-cache ca-certificates tzdata wget
# 创建非 root 用户
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
# 设置工作目录
WORKDIR /app
# 复制已经编译好的二进制文件
ARG BINARY_NAME=mcauth-linux-amd64
COPY ${BINARY_NAME} /app/server
# 复制配置文件(如果需要)
COPY configs/ /app/configs/
# 设置权限
RUN chown -R appuser:appuser /app
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# 启动应用
ENTRYPOINT ["/app/server"]

646
README.md
View File

@@ -1,564 +1,160 @@
# CarrotSkin Backend
一个功能完善的Minecraft皮肤站后端系统,采用单体架构设计基于Go语言和Gin框架开发
一个功能完善的 Minecraft 皮肤站后端,基于 Go + Gin 构建,覆盖用户认证、材质管理、角色档案、审计日志等核心能力,并提供完整的 Swagger 文档与容器友好的环境变量配置
## ✨ 核心功能
## ✨ 功能亮点
- **用户认证系统** - 注册、登录、JWT认证、积分系统
- **邮箱验证系统** - 注册验证、找回密码、更换邮箱基于Redis的验证码
- **材质管理系统** - 皮肤/披风上传、搜索、收藏、下载统计
- **角色档案系统** - Minecraft角色创建、管理、RSA密钥生成
- **文件存储** - MinIO/RustFS对象存储集成、预签名URL上传
- **缓存系统** - Redis缓存、验证码存储、频率限制
- **权限管理** - Casbin RBAC权限控制
- **数据审计** - 登录日志、操作审计、下载记录
- **账号体系**:注册 / 登录 / JWT 鉴权 / Yggdrasil 密码同步 / 用户积分
- **邮箱验证码**:验证码发送频率控制、邮箱绑定与变更
- **材质中心**皮肤/披风上传、搜索、收藏、下载统计、Hash 去重
- **角色档案**Minecraft Profile 管理、RSA 密钥生成、活跃档案切换
- **存储与上传**RustFS/MinIO 预签名 URL,减轻服务器带宽压力
- **任务与日志**:登录日志、操作审计、材质下载记录、定时任务
- **权限体系**Casbin RBAC,支持细粒度路线授
- **配置管理**100% 依赖环境变量,`SERVER_SWAGGER_ENABLED` 控制 Swagger
- **可观测性**Zap 结构化日志、统一 API 响应模型
## 项目结构
## 🛠 技术栈
| 类型 | 选型 |
| --- | --- |
| 语言 / 运行时 | Go 1.24+ |
| Web 框架 | Gin |
| ORM | GORM (PostgreSQL 驱动) |
| 数据库 | PostgreSQL 15+ |
| 缓存 / 消息 | Redis 6+ |
| 对象存储 | RustFS / MinIOS3 兼容) |
| 权限控制 | Casbin |
| 配置 | Viper + `.env` |
| API 文档 | swaggo / Swagger UI |
| 日志 | Uber Zap |
## 📁 目录结构
```
backend/
├── cmd/ # 应用程序入口
│ └── server/ # 主服务器入口
└── main.go # 服务初始化、路由注册
├── internal/ # 私有应用代码
│ ├── handler/ # HTTP处理器函数式
│ ├── routes.go # 路由注册
│ ├── auth_handler.go
│ ├── user_handler.go
│ └── ...
│ ├── service/ # 业务逻辑服务(函数式
├── common.go # 公共声明jsoniter等
├── user_service.go
└── ...
│ ├── repository/ # 数据访问层(函数式)
├── user_repository.go
│ │ └── ...
│ ├── model/ # 数据模型GORM
│ ├── middleware/ # 中间件
│ └── types/ # 类型定义
├── pkg/ # 公共库代码
│ ├── auth/ # 认证授权
│ │ └── manager.go # JWT服务管理器
│ ├── config/ # 配置管理
│ │ └── manager.go # 配置管理器
│ ├── database/ # 数据库连接
│ │ ├── manager.go # 数据库管理器AutoMigrate
│ │ └── postgres.go # PostgreSQL连接
│ ├── email/ # 邮件服务
│ │ └── manager.go # 邮件服务管理器
│ ├── logger/ # 日志系统
│ │ └── manager.go # 日志管理器
│ ├── redis/ # Redis客户端
│ │ └── manager.go # Redis管理器
│ ├── storage/ # 文件存储(RustFS/MinIO)
│ │ └── manager.go # 存储管理器
│ ├── utils/ # 工具函数
│ └── validator/ # 数据验证
├── docs/ # API定义和文档Swagger
├── configs/ # 配置文件
│ └── casbin/ # Casbin权限配置
├── go.mod # Go模块依赖
├── go.sum # Go模块校验
├── start.sh # Linux/Mac启动脚本
├── .env # 环境变量配置
└── README.md # 项目说明
├── cmd/server/ # 应用入口main.go
├── internal/
├── handler/ # HTTP Handler 与 Swagger 注解
│ ├── service/ # 业务逻辑
│ ├── repository/ # 数据访问
│ ├── model/ # GORM 数据模型
│ ├── types/ # 请求/响应 DTO
│ ├── middleware/ # Gin 中间件
└── task/ # 定时任务与后台作业
├── pkg/ # 可复用组件config、database、auth、logger、redis、storage 等
├── docs/ # swagger 生成产物docs.go / swagger.json / swagger.yaml
├── start.sh # 启动脚本(自动 swag init
├── docker-compose.yml # 本地容器编排
├── .env.example # 环境变量示例
└── go.mod # Go Module 定义
```
## 技术栈
## ✅ 前置要求
- **语言**: Go 1.23+
- **框架**: Gin Web Framework
- **数据库**: PostgreSQL 15+ (GORM ORM)
- **缓存**: Redis 6.0+
- **存储**: RustFS/MinIO (S3兼容对象存储)
- **权限**: Casbin RBAC
- **日志**: Zap (结构化日志)
- **配置**: 环境变量 (.env) + Viper
- **JSON**: jsoniter (高性能JSON序列化)
- **文档**: Swagger/OpenAPI 3.0
- Go 1.24+
- PostgreSQL 15+
- Redis 6+
- RustFS / MinIO或其他兼容 S3 的对象存储,用于皮肤与头像)
## 快速开始
## 🚀 快速开始
### 环境要求
- Go 1.21或更高版本
- PostgreSQL 15或更高版本
- Redis 6.0或更高版本
- RustFS 或其他 S3 兼容对象存储服务
### 安装和运行
1. **克隆项目**
```bash
git clone <repository-url>
cd CarrotSkin/backend
```
1. **克隆仓库**
```bash
git clone <repo>
cd backend
```
2. **安装依赖**
```bash
go mod download
```
```bash
go mod download
```
3. **配置环境**
```bash
# 复制环境变量文件
cp .env.example .env
# 编辑 .env 文件配置数据库、RustFS等服务连接信息
```
**注意**:项目完全依赖 `.env` 文件进行配置,不再使用 YAML 配置文件,便于 Docker 容器化部署。
3. **配置环境变量**
```bash
cp .env .env
# 根据实际环境填写数据库、Redis、对象存储、邮件等信息
```
4. **初始化数据库**
```bash
# 创建数据库
createdb carrotskin
# 或者使用PostgreSQL客户端
psql -h localhost -U postgres -c "CREATE DATABASE carrotskin;"
```
```bash
createdb carrotskin
# 或 psql -c "CREATE DATABASE carrotskin;"
```
> 应用启动时会执行 `AutoMigrate`,自动创建 / 更新表结构。
> 💡 **提示**: 项目使用 GORM 的 `AutoMigrate` 功能自动创建和更新数据库表结构无需手动执行SQL脚本。首次启动时会自动创建所有表。
5. **启动服务**
- **推荐**`./start.sh`(自动 `swag init`,随后 `go run cmd/server/main.go`
- **手动启动**
```bash
swag init -g cmd/server/main.go -o docs
go run cmd/server/main.go
```
5. **运行服务**
6. **访问接口**
- API Root: `http://localhost:8080`
- Swagger: `http://localhost:8080/swagger/index.html`(需 `SERVER_SWAGGER_ENABLED=true`
方式一:使用启动脚本(推荐)
```bash
# Linux/Mac
chmod +x start.sh
./start.sh
## ⚙️ 关键环境变量
# Windows
start.bat
```
| 变量 | 说明 | 示例 |
| --- | --- | --- |
| `SERVER_PORT` | 服务监听端口 | `8080` |
| `SERVER_MODE` | Gin 模式debug/release | `debug` |
| `SERVER_SWAGGER_ENABLED` | 是否暴露 Swagger UI | `true` |
| `DATABASE_HOST` / `DATABASE_PORT` | PostgreSQL 地址 | `localhost` / `5432` |
| `DATABASE_USERNAME` / `DATABASE_PASSWORD` | 数据库凭据 | `postgres` |
| `DATABASE_NAME` | 数据库名称 | `carrotskin` |
| `REDIS_HOST` / `REDIS_PORT` | Redis 地址 | `localhost` / `6379` |
| `REDIS_PASSWORD` | Redis 密码(无可为空) | `` |
| `RUSTFS_ENDPOINT` | RustFS/MinIO 访问地址 | `127.0.0.1:9000` |
| `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` | 对象存储凭据 | `minioadmin` |
| `RUSTFS_BUCKET_TEXTURES` / `RUSTFS_BUCKET_AVATARS` | 存储桶名称 | `carrotskin-textures` |
| `JWT_SECRET` | JWT 签名密钥 | `change-me` |
| `EMAIL_ENABLED` | 是否开启邮件服务 | `true` |
| `EMAIL_SMTP_HOST` / `EMAIL_SMTP_PORT` | SMTP 配置 | `smtp.example.com` / `587` |
方式二:直接运行
```bash
# 设置环境变量(或使用.env文件
export DATABASE_HOST=localhost
export DATABASE_PORT=5432
# ... 其他环境变量
更多变量请参考 `.env.example` 与 `.env.docker.example`。
# 运行服务
go run cmd/server/main.go
```
> 💡 **提示**:
> - 启动脚本会自动加载 `.env` 文件中的环境变量
> - 首次启动时会自动执行数据库迁移AutoMigrate
> - 如果对象存储未配置,服务仍可启动(相关功能不可用)
服务启动后:
- **服务地址**: http://localhost:8080
- **Swagger文档**: http://localhost:8080/swagger/index.html
- **健康检查**: http://localhost:8080/health
## API接口
### 认证相关
- `POST /api/v1/auth/register` - 用户注册(需邮箱验证码)
- `POST /api/v1/auth/login` - 用户登录(支持用户名/邮箱)
- `POST /api/v1/auth/send-code` - 发送验证码(注册/重置密码/更换邮箱)
- `POST /api/v1/auth/reset-password` - 重置密码(需验证码)
### 用户相关(需认证)
- `GET /api/v1/user/profile` - 获取用户信息
- `PUT /api/v1/user/profile` - 更新用户信息(头像、密码)
- `POST /api/v1/user/avatar/upload-url` - 生成头像上传URL
- `PUT /api/v1/user/avatar` - 更新头像
- `POST /api/v1/user/change-email` - 更换邮箱(需验证码)
### 材质管理
公开接口:
- `GET /api/v1/texture` - 搜索材质
- `GET /api/v1/texture/:id` - 获取材质详情
认证接口:
- `POST /api/v1/texture/upload-url` - 生成材质上传URL
- `POST /api/v1/texture` - 创建材质记录
- `PUT /api/v1/texture/:id` - 更新材质
- `DELETE /api/v1/texture/:id` - 删除材质
- `POST /api/v1/texture/:id/favorite` - 切换收藏状态
- `GET /api/v1/texture/my` - 我的材质列表
- `GET /api/v1/texture/favorites` - 我的收藏列表
### 角色档案
公开接口:
- `GET /api/v1/profile/:uuid` - 获取档案详情
认证接口:
- `POST /api/v1/profile` - 创建角色档案UUID由后端生成
- `GET /api/v1/profile` - 我的档案列表
- `PUT /api/v1/profile/:uuid` - 更新档案
- `DELETE /api/v1/profile/:uuid` - 删除档案
- `POST /api/v1/profile/:uuid/activate` - 设置活跃档案
### 系统配置
- `GET /api/v1/system/config` - 获取系统配置
## 配置管理
### 环境变量配置
项目**完全依赖环境变量**进行配置,不使用 YAML 配置文件,便于容器化部署:
1. **配置来源**: 环境变量 或 `.env` 文件
2. **环境变量格式**: 使用下划线分隔,全大写,如 `DATABASE_HOST`
3. **容器部署**: 直接在容器运行时设置环境变量即可
**主要环境变量**:
```bash
# 数据库配置
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=your_password
DATABASE_NAME=carrotskin
# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password
REDIS_DATABASE=0
REDIS_POOL_SIZE=10
# RustFS对象存储配置 (S3兼容)
RUSTFS_ENDPOINT=127.0.0.1:9000
RUSTFS_ACCESS_KEY=your_access_key
RUSTFS_SECRET_KEY=your_secret_key
RUSTFS_USE_SSL=false
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
# JWT配置
JWT_SECRET=your-jwt-secret-key
JWT_EXPIRE_HOURS=168
# 邮件配置
EMAIL_ENABLED=true
EMAIL_SMTP_HOST=smtp.example.com
EMAIL_SMTP_PORT=587
EMAIL_USERNAME=noreply@example.com
EMAIL_PASSWORD=your_email_password
EMAIL_FROM_NAME=CarrotSkin
```
**动态配置(存储在数据库中)**:
- 积分系统配置(注册奖励、签到积分等)
- 用户限制配置(最大材质数、最大角色数等)
- 网站设置(站点名称、公告、维护模式等)
完整的环境变量列表请参考 `.env.example` 文件。
### 数据库自动迁移
项目使用 GORM 的 `AutoMigrate` 功能自动管理数据库表结构:
- **首次启动**: 自动创建所有表结构
- **模型更新**: 自动添加新字段、索引等
- **类型转换**: 自动处理字段类型变更如枚举类型转为varchar
- **外键管理**: 自动管理外键关系
**注意事项**:
- 生产环境建议先备份数据库再执行迁移
- 某些复杂变更(如删除字段)可能需要手动处理
- 枚举类型在PostgreSQL中存储为varchar避免类型兼容问题
## 架构设计
### 面向过程的函数式架构
项目采用**面向过程的函数式架构**,摒弃不必要的面向对象抽象,使用独立函数和单例管理器模式,代码更简洁、可维护性更强:
```
┌─────────────────────────────────────┐
│ Handler 层 (函数) │ ← 路由处理、参数验证、响应格式化
├─────────────────────────────────────┤
│ Service 层 (函数) │ ← 业务逻辑、权限检查、数据验证
├─────────────────────────────────────┤
│ Repository 层 (函数) │ ← 数据库操作、关联查询
├─────────────────────────────────────┤
│ Manager 层 (单例模式) │ ← 核心依赖管理(线程安全)
│ - database.MustGetDB() │
│ - logger.MustGetLogger() │
│ - auth.MustGetJWTService() │
│ - redis.MustGetClient() │
│ - email.MustGetService() │
│ - storage.MustGetClient() │
│ - config.MustGetConfig() │
├──────────────┬──────────────────────┤
│ PostgreSQL │ Redis │ RustFS │ ← 数据存储层
└──────────────┴──────────────────────┘
```
### 架构特点
1. **函数式设计**: 所有业务逻辑以独立函数形式实现,无结构体方法,降低耦合度
2. **管理器模式**: 使用 `sync.Once` 实现线程安全的单例管理器,统一管理核心依赖
3. **按需获取**: 通过管理器函数按需获取依赖,避免链式传递,代码更清晰
4. **自动迁移**: 使用 GORM AutoMigrate 自动管理数据库表结构
5. **高性能**: 使用 jsoniter 替代标准库 json提升序列化性能
### 核心模块
1. **认证模块** (`internal/handler/auth_handler.go`)
- JWT令牌生成和验证通过 `auth.MustGetJWTService()` 获取)
- bcrypt密码加密
- 邮箱验证码注册
- 密码重置功能
- 登录日志记录(支持用户名/邮箱登录)
2. **用户模块** (`internal/handler/user_handler.go`)
- 用户信息管理
- 头像上传预签名URL通过 `storage.MustGetClient()` 获取)
- 密码修改(需原密码验证)
- 邮箱更换(需验证码)
- 积分系统
3. **邮箱验证模块** (`internal/service/verification_service.go`)
- 验证码生成6位数字
- 验证码存储Redis10分钟有效期通过 `redis.MustGetClient()` 获取)
- 发送频率限制1分钟
- 邮件发送HTML格式通过 `email.MustGetService()` 获取)
4. **材质模块** (`internal/handler/texture_handler.go`)
- 材质上传预签名URL
- 材质搜索和收藏
- Hash去重
- 下载统计
5. **档案模块** (`internal/handler/profile_handler.go`)
- Minecraft角色管理
- RSA密钥生成RSA-2048
- 活跃状态管理
- 档案数量限制
6. **管理器模块** (`pkg/*/manager.go`)
- 数据库管理器:`database.MustGetDB()` - 线程安全的数据库连接
- 日志管理器:`logger.MustGetLogger()` - 结构化日志实例
- JWT管理器`auth.MustGetJWTService()` - JWT服务实例
- Redis管理器`redis.MustGetClient()` - Redis客户端
- 邮件管理器:`email.MustGetService()` - 邮件服务
- 存储管理器:`storage.MustGetClient()` - 对象存储客户端
- 配置管理器:`config.MustGetConfig()` - 应用配置
### 技术特性
- **架构优势**:
- 面向过程的函数式设计,代码简洁清晰
- 单例管理器模式,线程安全的依赖管理
- 按需获取依赖,避免链式传递
- 自动数据库迁移AutoMigrate
- **安全性**:
- bcrypt密码加密、JWT令牌认证
- 邮箱验证码(注册/重置密码/更换邮箱)
- Casbin RBAC权限控制
- 频率限制(防暴力破解)
- **性能**:
- jsoniter 高性能JSON序列化替代标准库
- PostgreSQL索引优化
- Redis缓存验证码、会话等
- 预签名URL减轻服务器压力
- 连接池管理
- **可靠性**:
- 事务保证数据一致性
- 完整的错误处理和日志记录
- 优雅关闭和资源清理
- 对象存储连接失败时服务仍可启动
- **可扩展**:
- 清晰的函数式架构
- 管理器模式统一管理依赖
- 环境变量配置(便于容器化)
- **审计**:
- 登录日志(成功/失败)
- 操作审计
- 下载记录
## 开发指南
### 代码结构
- `cmd/server/` - 应用入口,初始化服务
- `internal/handler/` - HTTP请求处理
- `internal/service/` - 业务逻辑实现
- `internal/repository/` - 数据库操作
- `internal/model/` - 数据模型定义
- `internal/types/` - 请求/响应类型定义
- `internal/middleware/` - 中间件JWT、CORS、日志等
- `pkg/` - 可复用的公共库
### 开发规范
1. **代码风格**: 遵循Go官方代码规范使用 `gofmt` 格式化
2. **架构模式**: 使用函数式设计,避免不必要的结构体和方法
3. **依赖管理**: 通过管理器函数获取依赖(如 `database.MustGetDB()`),避免链式传递
4. **错误处理**: 使用统一的错误响应格式 (`model.NewErrorResponse`)
5. **日志记录**: 使用 Zap 结构化日志,通过 `logger.MustGetLogger()` 获取实例
6. **JSON序列化**: 使用 jsoniter 替代标准库 json提升性能
7. **RESTful API**: 遵循 REST 设计原则合理使用HTTP方法
### 添加新功能
1.`internal/model/` 定义数据模型GORM会自动迁移
2.`internal/repository/` 实现数据访问函数(使用 `database.MustGetDB()` 获取数据库)
3.`internal/service/` 实现业务逻辑函数(按需使用管理器获取依赖)
4.`internal/handler/` 实现HTTP处理函数使用管理器获取logger、jwtService等
5.`internal/handler/routes.go` 注册路由
**示例**:
```go
// Repository层
func FindUserByID(id uint) (*model.User, error) {
db := database.MustGetDB()
var user model.User
err := db.First(&user, id).Error
return &user, err
}
// Service层
func GetUserProfile(userID uint) (*model.User, error) {
logger := logger.MustGetLogger()
user, err := repository.FindUserByID(userID)
if err != nil {
logger.Error("获取用户失败", zap.Error(err))
return nil, err
}
return user, nil
}
// Handler层
func GetUserProfile(c *gin.Context) {
logger := logger.MustGetLogger()
jwtService := auth.MustGetJWTService()
// ... 处理逻辑
}
```
## 部署
### 本地开发
## 🧪 常用命令
```bash
# 安装依赖
go mod download
# 运行单元测试
go test ./...
# 配置环境变量(创建.env文件或直接export
cp .env.example .env
# 编辑 .env 文件
# 重新生成 swagger
swag init -g cmd/server/main.go -o docs
# 启动服务
# 方式1: 使用启动脚本
./start.sh # Linux/Mac
start.bat # Windows
# 方式2: 直接运行
go run cmd/server/main.go
# 代码格式化 / 静态检查
gofmt -w .
golangci-lint run (若已安装)
```
**首次启动**:
- 会自动执行数据库迁移AutoMigrate创建所有表结构
- 如果对象存储未配置,会记录警告但服务仍可启动
- 检查日志确认所有服务初始化成功
## 🧱 架构说明
### 生产部署
- **分层设计**Handler -> Service -> Repository -> Model层次清晰、职责单一。
- **依赖管理器**`pkg/*/manager.go` 使用 `sync.Once` 实现线程安全单例DB / Redis / Logger / Storage / Email / Auth / Config
- **Swagger 注解**:所有 Handler、模型、DTO 均补齐 `@Summary` / `@Description` / `@Success`,可直接生成 OpenAPI 文档。
- **配置优先级**`.env` -> 系统环境变量,所有配置均可通过容器注入。
- **自动任务**`internal/task` 承载后台作业,可按需扩展。
```bash
# 构建二进制文件
go build -o carrotskin-server cmd/server/main.go
## 📝 Swagger 说明
# 运行服务
./carrotskin-server
```
- `start.sh` 会在启动前执行 `swag init -g cmd/server/main.go -o docs`
- 若手动运行,需要保证 `docs/` 下的 `docs.go`、`swagger.json`、`swagger.yaml` 与代码同步
- 通过 `SERVER_SWAGGER_ENABLED=false` 可在生产环境关闭 Swagger UI 暴露
### Docker部署
## 🤝 贡献指南
```bash
# 构建镜像
docker build -t carrotskin-backend:latest .
1. Fork & Clone
2. 创建特性分支:`git checkout -b feature/xxx`
3. 编写代码并补全测试 / Swagger 注释
4. 提交时附上变更说明
# 启动服务
docker-compose up -d
```
## 📄 许可证
## 故障排查
该项目未附带开源许可证,默认保留所有权利。若需对外使用,请先与作者确认协议。
### 常见问题
---
1. **数据库连接失败**
- 检查 `.env` 中的数据库配置(`DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_NAME`
- 确认PostgreSQL服务已启动
- 验证数据库用户权限
- 确认数据库已创建:`createdb carrotskin``psql -c "CREATE DATABASE carrotskin;"`
- 检查数据库迁移日志,确认表结构创建成功
2. **Redis连接失败**
- 检查Redis服务是否运行`redis-cli ping`
- 验证 `.env` 中的Redis配置
- 确认Redis密码是否正确
- 检查防火墙规则
3. **RustFS/MinIO连接失败**
- 检查存储服务是否运行
- 验证访问密钥是否正确(`RUSTFS_ACCESS_KEY`, `RUSTFS_SECRET_KEY`
- 确认存储桶是否已创建(`RUSTFS_BUCKET_TEXTURES`, `RUSTFS_BUCKET_AVATARS`
- 检查网络连接和端口(`RUSTFS_ENDPOINT`
- **注意**: 如果对象存储连接失败,服务仍可启动,但上传功能不可用
4. **邮件发送失败**
- 检查 `EMAIL_ENABLED=true`
- 验证SMTP服务器地址和端口
- 确认邮箱用户名和密码正确
- 检查邮件服务商是否需要开启SMTP
- 查看日志获取详细错误信息
5. **验证码相关问题**
- 验证码过期10分钟有效期
- 发送过于频繁1分钟限制
- Redis存储失败检查Redis连接
- 邮件未收到(检查垃圾邮件)
6. **JWT验证失败**
- 检查 `JWT_SECRET` 是否配置
- 验证令牌是否过期默认168小时
- 确认请求头中包含 `Authorization: Bearer <token>`
- Token格式是否正确
### 调试技巧
1. **查看日志**
```bash
# 实时查看日志
tail -f logs/app.log
# 搜索错误日志
grep "ERROR" logs/app.log
```
2. **测试Redis连接**
```bash
redis-cli -h localhost -p 6379 -a your_password
> PING
> KEYS *
```
3. **测试数据库连接**
```bash
psql -h localhost -U postgres -d carrotskin
\dt # 查看所有表
```
4. **测试邮件配置**
- 使用Swagger文档测试 `/api/v1/auth/send-code` 接口
- 检查邮件服务商是否限制发送频率
### 开发调试
启用详细日志:
```bash
# 在 .env 中设置
LOG_LEVEL=debug
SERVER_MODE=debug
```
如需了解业务细节或 API 调用示例,请参考 `docs/swagger.yaml` 或运行服务后访问 Swagger UI。祝编码愉快🍀

View File

@@ -17,6 +17,7 @@ import (
"os/signal"
"syscall"
"time"
_ "time/tzdata"
"carrotskin/internal/container"
"carrotskin/internal/handler"
@@ -32,8 +33,6 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
_ "carrotskin/docs" // Swagger docs
)
func main() {

View File

@@ -32,6 +32,7 @@ type Container struct {
TextureRepo repository.TextureRepository
ClientRepo repository.ClientRepository
YggdrasilRepo repository.YggdrasilRepository
ReportRepo repository.ReportRepository
// Service层
UserService service.UserService
@@ -43,6 +44,7 @@ type Container struct {
SecurityService service.SecurityService
CaptchaService service.CaptchaService
SignatureService *service.SignatureService
ReportService service.ReportService
}
// NewContainer 创建依赖容器
@@ -86,6 +88,7 @@ func NewContainer(
c.TextureRepo = repository.NewTextureRepository(db)
c.ClientRepo = repository.NewClientRepository(db)
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
c.ReportRepo = repository.NewReportRepository(db)
// 初始化SignatureService作为依赖注入避免在容器中创建并立即调用
// 将SignatureService添加到容器中供其他服务使用
@@ -95,6 +98,7 @@ func NewContainer(
c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger)
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
c.ReportService = service.NewReportService(c.ReportRepo, c.UserRepo, logger)
// 获取Yggdrasil私钥并创建JWT服务TokenService需要
// 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT

View File

@@ -62,6 +62,19 @@ func UserToUserInfo(user *model.User) *types.UserInfo {
}
}
// UserToPublicUserInfo 将 User 模型转换为 PublicUserInfo 响应
func UserToPublicUserInfo(user *model.User) *types.PublicUserInfo {
return &types.PublicUserInfo{
ID: user.ID,
Username: user.Username,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
CreatedAt: user.CreatedAt,
}
}
// ProfileToProfileInfo 将 Profile 模型转换为 ProfileInfo 响应
func ProfileToProfileInfo(profile *model.Profile) *types.ProfileInfo {
return &types.ProfileInfo{
@@ -87,9 +100,15 @@ func ProfilesToProfileInfos(profiles []*model.Profile) []*types.ProfileInfo {
// TextureToTextureInfo 将 Texture 模型转换为 TextureInfo 响应
func TextureToTextureInfo(texture *model.Texture) *types.TextureInfo {
uploaderUsername := ""
if texture.Uploader != nil {
uploaderUsername = texture.Uploader.Username
}
return &types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
UploaderUsername: uploaderUsername,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),

View File

@@ -0,0 +1,495 @@
package handler
import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// ReportHandler 举报处理器
type ReportHandler struct {
container *container.Container
logger *zap.Logger
}
// NewReportHandler 创建ReportHandler实例
func NewReportHandler(c *container.Container) *ReportHandler {
return &ReportHandler{
container: c,
logger: c.Logger,
}
}
// CreateReportRequest 创建举报请求
type CreateReportRequest struct {
TargetType string `json:"target_type" binding:"required"` // "texture" 或 "user"
TargetID int64 `json:"target_id" binding:"required"`
Reason string `json:"reason" binding:"required"`
}
// CreateReport 创建举报
// @Summary 创建举报
// @Description 用户举报皮肤或其他用户
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body CreateReportRequest true "举报信息"
// @Success 200 {object} model.Response{data=model.Report} "创建成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Router /api/v1/report [post]
func (h *ReportHandler) CreateReport(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req CreateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 转换目标类型
var targetType model.ReportType
switch req.TargetType {
case "texture":
targetType = model.ReportTypeTexture
case "user":
targetType = model.ReportTypeUser
default:
RespondBadRequest(c, "无效的举报类型", nil)
return
}
report, err := h.container.ReportService.CreateReport(c.Request.Context(), userID, targetType, req.TargetID, req.Reason)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, report)
}
// GetByID 获取举报详情
// @Summary 获取举报详情
// @Description 获取指定ID的举报详细信息
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "举报ID"
// @Success 200 {object} model.Response{data=model.Report} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "举报不存在"
// @Router /api/v1/report/{id} [get]
func (h *ReportHandler) GetByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的举报ID", err)
return
}
report, err := h.container.ReportService.GetByID(c.Request.Context(), id)
if err != nil {
RespondNotFound(c, err.Error())
return
}
RespondSuccess(c, report)
}
// GetByReporterID 获取举报人的举报记录
// @Summary 获取举报人的举报记录
// @Description 获取指定用户的举报记录列表
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param reporter_id path int true "举报人ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Router /api/v1/report/reporter/{reporter_id} [get]
func (h *ReportHandler) GetByReporterID(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
reporterID, err := strconv.ParseInt(c.Param("reporter_id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的举报人ID", err)
return
}
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
reports, total, err := h.container.ReportService.GetByReporterID(c.Request.Context(), reporterID, userID, page, pageSize)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"list": reports,
"total": total,
"page": page,
"per_page": pageSize,
})
}
// GetByTarget 获取目标对象的举报记录
// @Summary 获取目标对象的举报记录
// @Description 获取指定目标对象的举报记录列表(仅管理员)
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param target_type path string true "目标类型 (texture/user)"
// @Param target_id path int true "目标ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/target/{target_type}/{target_id} [get]
func (h *ReportHandler) GetByTarget(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
targetTypeStr := c.Param("target_type")
targetID, err := strconv.ParseInt(c.Param("target_id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的目标ID", err)
return
}
var targetType model.ReportType
switch targetTypeStr {
case "texture":
targetType = model.ReportTypeTexture
case "user":
targetType = model.ReportTypeUser
default:
RespondBadRequest(c, "无效的目标类型", nil)
return
}
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
reports, total, err := h.container.ReportService.GetByTarget(c.Request.Context(), targetType, targetID, userID, page, pageSize)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"list": reports,
"total": total,
"page": page,
"per_page": pageSize,
})
}
// GetByStatus 根据状态查询举报记录
// @Summary 根据状态查询举报记录
// @Description 根据状态查询举报记录列表(仅管理员)
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param status path string true "状态 (pending/approved/rejected)"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Router /api/v1/report/status/{status} [get]
func (h *ReportHandler) GetByStatus(c *gin.Context) {
statusStr := c.Param("status")
var status model.ReportStatus
switch statusStr {
case "pending":
status = model.ReportStatusPending
case "approved":
status = model.ReportStatusApproved
case "rejected":
status = model.ReportStatusRejected
default:
RespondBadRequest(c, "无效的状态", nil)
return
}
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
reports, total, err := h.container.ReportService.GetByStatus(c.Request.Context(), status, page, pageSize)
if err != nil {
RespondServerError(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"list": reports,
"total": total,
"page": page,
"per_page": pageSize,
})
}
// Search 搜索举报记录
// @Summary 搜索举报记录
// @Description 搜索举报记录(仅管理员)
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param keyword query int false "关键词举报人ID或目标ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/search [get]
func (h *ReportHandler) Search(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
keywordStr := c.Query("keyword")
keyword, err := strconv.ParseInt(keywordStr, 10, 64)
if err != nil {
RespondBadRequest(c, "无效的关键词", err)
return
}
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
reports, total, err := h.container.ReportService.Search(c.Request.Context(), keyword, userID, page, pageSize)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"list": reports,
"total": total,
"page": page,
"per_page": pageSize,
})
}
// ReviewRequest 处理举报请求
type ReviewRequest struct {
Status string `json:"status" binding:"required"` // "approved" 或 "rejected"
ReviewNote string `json:"review_note"`
}
// Review 处理举报记录
// @Summary 处理举报记录
// @Description 管理员处理举报记录
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "举报ID"
// @Param request body ReviewRequest true "处理信息"
// @Success 200 {object} model.Response{data=model.Report} "处理成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/{id}/review [put]
func (h *ReportHandler) Review(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的举报ID", err)
return
}
var req ReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 转换状态
var status model.ReportStatus
switch req.Status {
case "approved":
status = model.ReportStatusApproved
case "rejected":
status = model.ReportStatusRejected
default:
RespondBadRequest(c, "无效的状态", nil)
return
}
report, err := h.container.ReportService.Review(c.Request.Context(), id, userID, status, req.ReviewNote)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, report)
}
// BatchReviewRequest 批量处理举报请求
type BatchReviewRequest struct {
IDs []int64 `json:"ids" binding:"required"`
Status string `json:"status" binding:"required"` // "approved" 或 "rejected"
ReviewNote string `json:"review_note"`
}
// BatchReview 批量处理举报记录
// @Summary 批量处理举报记录
// @Description 管理员批量处理举报记录
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body BatchReviewRequest true "处理信息"
// @Success 200 {object} model.Response{data=map[string]interface{}} "处理成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/batch-review [put]
func (h *ReportHandler) BatchReview(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req BatchReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 转换状态
var status model.ReportStatus
switch req.Status {
case "approved":
status = model.ReportStatusApproved
case "rejected":
status = model.ReportStatusRejected
default:
RespondBadRequest(c, "无效的状态", nil)
return
}
affected, err := h.container.ReportService.BatchReview(c.Request.Context(), req.IDs, userID, status, req.ReviewNote)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"affected": affected,
})
}
// Delete 删除举报记录
// @Summary 删除举报记录
// @Description 删除指定的举报记录
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "举报ID"
// @Success 200 {object} model.Response "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/{id} [delete]
func (h *ReportHandler) Delete(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的举报ID", err)
return
}
if err := h.container.ReportService.Delete(c.Request.Context(), id, userID); err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, nil)
}
// BatchDeleteRequest 批量删除请求
type BatchDeleteRequest struct {
IDs []int64 `json:"ids" binding:"required"`
}
// BatchDelete 批量删除举报记录
// @Summary 批量删除举报记录
// @Description 管理员批量删除举报记录
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body BatchDeleteRequest true "删除信息"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/batch-delete [delete]
func (h *ReportHandler) BatchDelete(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req BatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
affected, err := h.container.ReportService.BatchDelete(c.Request.Context(), req.IDs, userID)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"affected": affected,
})
}
// GetStats 获取举报统计信息
// @Summary 获取举报统计信息
// @Description 获取举报统计信息(仅管理员)
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Router /api/v1/report/stats [get]
func (h *ReportHandler) GetStats(c *gin.Context) {
stats, err := h.container.ReportService.GetStats(c.Request.Context())
if err != nil {
RespondServerError(c, err.Error(), err)
return
}
RespondSuccess(c, stats)
}

View File

@@ -21,6 +21,7 @@ type Handlers struct {
Yggdrasil *YggdrasilHandler
CustomSkin *CustomSkinHandler
Admin *AdminHandler
Report *ReportHandler
}
// NewHandlers 创建所有Handler实例
@@ -34,6 +35,7 @@ func NewHandlers(c *container.Container) *Handlers {
Yggdrasil: NewYggdrasilHandler(c),
CustomSkin: NewCustomSkinHandler(c),
Admin: NewAdminHandler(c),
Report: NewReportHandler(c),
}
}
@@ -77,6 +79,9 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 管理员路由(需要管理员权限)
registerAdminRoutes(v1, c, h.Admin)
// 举报路由
registerReportRoutes(v1, h.Report, c.JWT)
}
}
@@ -93,6 +98,10 @@ func registerAuthRoutes(v1 *gin.RouterGroup, h *AuthHandler) {
// registerUserRoutes 注册用户路由
func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JWTService) {
// 公开用户信息路由(无需认证)
v1.GET("/users/public", h.GetPublicInfo)
// 需要认证的用户路由
userGroup := v1.Group("/user")
userGroup.Use(middleware.AuthMiddleware(jwtService))
{
@@ -232,3 +241,28 @@ func registerCustomSkinRoutes(v1 *gin.RouterGroup, h *CustomSkinHandler) {
csl.GET("/textures/:hash", h.GetTexture)
}
}
// registerReportRoutes 注册举报路由
func registerReportRoutes(v1 *gin.RouterGroup, h *ReportHandler, jwtService *auth.JWTService) {
reportGroup := v1.Group("/report")
{
// 公开路由(无需认证)
reportGroup.GET("/stats", h.GetStats)
// 需要认证的路由
reportAuth := reportGroup.Group("")
reportAuth.Use(middleware.AuthMiddleware(jwtService))
{
reportAuth.POST("", h.CreateReport)
reportAuth.GET("/:id", h.GetByID)
reportAuth.GET("/reporter_id", h.GetByReporterID)
reportAuth.GET("/target", h.GetByTarget)
reportAuth.GET("/status", h.GetByStatus)
reportAuth.GET("/search", h.Search)
reportAuth.PUT("/:id/review", h.Review)
reportAuth.POST("/batch-review", h.BatchReview)
reportAuth.DELETE("/:id", h.Delete)
reportAuth.POST("/batch-delete", h.BatchDelete)
}
}
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/types"
@@ -315,3 +316,55 @@ func (h *UserHandler) ResetYggdrasilPassword(c *gin.Context) {
h.logger.Info("Yggdrasil密码重置成功", zap.Int64("userId", userID))
RespondSuccess(c, gin.H{"password": newPassword})
}
// GetPublicInfo 获取用户公开信息
// @Summary 获取用户公开信息
// @Description 根据用户名或用户ID获取用户的公开信息不包含敏感信息如邮箱
// @Tags user
// @Accept json
// @Produce json
// @Param username query string false "用户名"
// @Param id query int false "用户ID"
// @Success 200 {object} model.Response{data=types.PublicUserInfo} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Router /api/v1/users/public [get]
func (h *UserHandler) GetPublicInfo(c *gin.Context) {
username := c.Query("username")
idStr := c.Query("id")
// 至少需要提供一个参数
if username == "" && idStr == "" {
RespondBadRequest(c, "必须提供用户名或用户ID", nil)
return
}
var user *model.User
var err error
// 优先使用用户名查询
if username != "" {
user, err = h.container.UserService.GetByUsername(c.Request.Context(), username)
} else {
// 使用用户ID查询
id := parseIntWithDefault(idStr, 0)
if id == 0 {
RespondBadRequest(c, "无效的用户ID", nil)
return
}
user, err = h.container.UserService.GetByID(c.Request.Context(), int64(id))
}
if err != nil || user == nil {
RespondNotFound(c, "用户不存在")
return
}
// 检查用户状态
if user.Status != 1 {
RespondNotFound(c, "用户不可用")
return
}
RespondSuccess(c, UserToPublicUserInfo(user))
}

49
internal/model/report.go Normal file
View File

@@ -0,0 +1,49 @@
package model
import (
"time"
)
// ReportType 举报类型
// @Description 举报类型枚举TEXTURE(皮肤)或USER(用户)
type ReportType string
const (
ReportTypeTexture ReportType = "TEXTURE"
ReportTypeUser ReportType = "USER"
)
// ReportStatus 举报状态
// @Description 举报状态枚举PENDING(待处理)、APPROVED(已通过)、REJECTED(已驳回)
type ReportStatus string
const (
ReportStatusPending ReportStatus = "PENDING"
ReportStatusApproved ReportStatus = "APPROVED"
ReportStatusRejected ReportStatus = "REJECTED"
)
// Report 举报模型
// @Description 用户举报记录模型,用于举报皮肤或用户
type Report struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ReporterID int64 `gorm:"column:reporter_id;not null;index:idx_reports_reporter_created,priority:1" json:"reporter_id"` // 举报人ID
TargetType ReportType `gorm:"column:target_type;type:varchar(50);not null;index:idx_reports_target_status,priority:1" json:"target_type"` // TEXTURE 或 USER
TargetID int64 `gorm:"column:target_id;not null;index:idx_reports_target_status,priority:2" json:"target_id"` // 被举报对象ID皮肤ID或用户ID
Reason string `gorm:"column:reason;type:text;not null" json:"reason"` // 举报原因
Status ReportStatus `gorm:"column:status;type:varchar(50);not null;default:'PENDING';index:idx_reports_status_created,priority:1;index:idx_reports_target_status,priority:3" json:"status"` // PENDING, APPROVED, REJECTED
ReviewerID *int64 `gorm:"column:reviewer_id;type:bigint" json:"reviewer_id,omitempty"` // 处理人ID管理员
ReviewNote string `gorm:"column:review_note;type:text" json:"review_note,omitempty"` // 处理备注
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamp" json:"reviewed_at,omitempty"` // 处理时间
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_reports_reporter_created,priority:2,sort:desc;index:idx_reports_status_created,priority:2,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
// 关联
Reporter *User `gorm:"foreignKey:ReporterID;constraint:OnDelete:CASCADE" json:"reporter,omitempty"`
Reviewer *User `gorm:"foreignKey:ReviewerID;constraint:OnDelete:SET NULL" json:"reviewer,omitempty"`
}
// TableName 指定表名
func (Report) TableName() string {
return "reports"
}

View File

@@ -56,17 +56,12 @@ 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
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
ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, 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)
@@ -84,3 +79,21 @@ type ClientRepository interface {
DeleteByClientToken(ctx context.Context, clientToken string) error
DeleteByUserID(ctx context.Context, userID int64) error
}
// ReportRepository 举报仓储接口
type ReportRepository interface {
Create(ctx context.Context, report *model.Report) error
FindByID(ctx context.Context, id int64) (*model.Report, error)
FindByReporterID(ctx context.Context, reporterID int64, page, pageSize int) ([]*model.Report, int64, error)
FindByTarget(ctx context.Context, targetType model.ReportType, targetID int64, page, pageSize int) ([]*model.Report, int64, error)
FindByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error)
Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Report, int64, error)
Update(ctx context.Context, report *model.Report) error
UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error
Review(ctx context.Context, id int64, status model.ReportStatus, reviewerID int64, reviewNote string) error
BatchReview(ctx context.Context, ids []int64, status model.ReportStatus, reviewerID int64, reviewNote string) (int64, error)
Delete(ctx context.Context, id int64) error
BatchDelete(ctx context.Context, ids []int64) (int64, error)
CountByStatus(ctx context.Context, status model.ReportStatus) (int64, error)
CheckDuplicate(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64) (bool, error)
}

View File

@@ -0,0 +1,225 @@
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,7 +98,6 @@ 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)
}
@@ -150,22 +149,20 @@ func TestTextureRepository_Basic(t *testing.T) {
t.Fatalf("FindByHashAndUploaderID mismatch")
}
_ = textureRepo.IncrementFavoriteCount(ctx, tex.ID)
_ = textureRepo.DecrementFavoriteCount(ctx, tex.ID)
_, _ = 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.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)
@@ -187,7 +184,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.AddFavorite(ctx, u.ID, tex.ID+1)
_, _ = textureRepo.ToggleFavorite(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)
}
@@ -206,7 +203,6 @@ 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

@@ -29,13 +29,13 @@ func (r *textureRepository) FindByID(ctx context.Context, id int64) (*model.Text
func (r *textureRepository) FindByHash(ctx context.Context, hash string) (*model.Texture, error) {
var texture model.Texture
err := r.db.WithContext(ctx).Where("hash = ?", hash).First(&texture).Error
err := r.db.WithContext(ctx).Preload("Uploader").Where("hash = ?", hash).First(&texture).Error
return handleNotFoundResult(&texture, err)
}
func (r *textureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
var texture model.Texture
err := r.db.WithContext(ctx).Where("hash = ? AND uploader_id = ?", hash, uploaderID).First(&texture).Error
err := r.db.WithContext(ctx).Preload("Uploader").Where("hash = ? AND uploader_id = ?", hash, uploaderID).First(&texture).Error
return handleNotFoundResult(&texture, err)
}
@@ -138,42 +138,52 @@ 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) IsFavorited(ctx context.Context, userID, textureID int64) (bool, 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
// 使用 Select("1") 优化,只查询是否存在,不需要查询所有字段
err := r.db.WithContext(ctx).Model(&model.UserTextureFavorite{}).
Select("1").
err := tx.Model(&model.UserTextureFavorite{}).
Where("user_id = ? AND texture_id = ?", userID, textureID).
Limit(1).
Count(&count).Error
return count > 0, err
}
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
}
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
}
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
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) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {

View File

@@ -19,6 +19,7 @@ type UserService interface {
// 用户查询
GetByID(ctx context.Context, id int64) (*model.User, error)
GetByEmail(ctx context.Context, email string) (*model.User, error)
GetByUsername(ctx context.Context, username string) (*model.User, error)
// 用户更新
UpdateInfo(ctx context.Context, user *model.User) error
@@ -136,6 +137,30 @@ type SecurityService interface {
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
}
// ReportService 举报服务接口
type ReportService interface {
// 创建举报
CreateReport(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64, reason string) (*model.Report, error)
// 查询举报
GetByID(ctx context.Context, id int64) (*model.Report, error)
GetByReporterID(ctx context.Context, reporterID, userID int64, page, pageSize int) ([]*model.Report, int64, error)
GetByTarget(ctx context.Context, targetType model.ReportType, targetID, userID int64, page, pageSize int) ([]*model.Report, int64, error)
GetByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error)
Search(ctx context.Context, keyword, userID int64, page, pageSize int) ([]*model.Report, int64, error)
// 处理举报
Review(ctx context.Context, reportID, reviewerID int64, status model.ReportStatus, reviewNote string) (*model.Report, error)
BatchReview(ctx context.Context, ids []int64, reviewerID int64, status model.ReportStatus, reviewNote string) (int64, error)
// 删除举报
Delete(ctx context.Context, reportID, userID int64) error
BatchDelete(ctx context.Context, ids []int64, userID int64) (int64, error)
// 统计
GetStats(ctx context.Context) (map[string]int64, error)
}
// Services 服务集合
type Services struct {
User UserService
@@ -146,6 +171,7 @@ type Services struct {
Captcha CaptchaService
Yggdrasil YggdrasilService
Security SecurityService
Report ReportService
}
// ServiceDeps 服务依赖

View File

@@ -391,37 +391,24 @@ 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) 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 {
func (m *MockTextureRepository) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
if m.favorites[userID] == nil {
m.favorites[userID] = make(map[int64]bool)
}
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)
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--
}
return nil
}
return !isFavorited, nil
}
func (m *MockTextureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
@@ -474,7 +461,6 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
return deleted, nil
}
// ============================================================================
// Service Mocks
// ============================================================================

View File

@@ -0,0 +1,335 @@
package service
import (
"context"
"errors"
"strconv"
"time"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"go.uber.org/zap"
)
// reportService ReportService的实现
type reportService struct {
reportRepo repository.ReportRepository
userRepo repository.UserRepository
logger *zap.Logger
}
// NewReportService 创建ReportService实例
func NewReportService(
reportRepo repository.ReportRepository,
userRepo repository.UserRepository,
logger *zap.Logger,
) ReportService {
return &reportService{
reportRepo: reportRepo,
userRepo: userRepo,
logger: logger,
}
}
// CreateReport 创建举报
func (s *reportService) CreateReport(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64, reason string) (*model.Report, error) {
// 验证举报人存在
reporter, err := s.userRepo.FindByID(ctx, reporterID)
if err != nil {
s.logger.Error("举报人不存在", zap.Int64("reporter_id", reporterID), zap.Error(err))
return nil, apperrors.ErrUserNotFound
}
if reporter == nil {
return nil, apperrors.ErrUserNotFound
}
// 验证举报原因
if reason == "" {
return nil, errors.New("举报原因不能为空")
}
if len(reason) > 500 {
return nil, errors.New("举报原因不能超过500字符")
}
// 验证目标类型
if targetType != model.ReportTypeTexture && targetType != model.ReportTypeUser {
return nil, errors.New("无效的举报类型")
}
// 检查是否重复举报
isDuplicate, err := s.reportRepo.CheckDuplicate(ctx, reporterID, targetType, targetID)
if err != nil {
s.logger.Error("检查重复举报失败", zap.Error(err))
return nil, err
}
if isDuplicate {
return nil, errors.New("您已经举报过该对象,请勿重复举报")
}
// 创建举报记录
report := &model.Report{
ReporterID: reporterID,
TargetType: targetType,
TargetID: targetID,
Reason: reason,
Status: model.ReportStatusPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.reportRepo.Create(ctx, report); err != nil {
s.logger.Error("创建举报失败", zap.Error(err))
return nil, err
}
s.logger.Info("创建举报成功", zap.Int64("report_id", report.ID), zap.Int64("reporter_id", reporterID))
return report, nil
}
// GetByID 根据ID查询举报
func (s *reportService) GetByID(ctx context.Context, id int64) (*model.Report, error) {
report, err := s.reportRepo.FindByID(ctx, id)
if err != nil {
s.logger.Error("查询举报失败", zap.Int64("report_id", id), zap.Error(err))
return nil, err
}
return report, nil
}
// GetByReporterID 根据举报人ID查询举报记录
func (s *reportService) GetByReporterID(ctx context.Context, reporterID, userID int64, page, pageSize int) ([]*model.Report, int64, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, 0, err
}
if user == nil {
return nil, 0, apperrors.ErrUserNotFound
}
// 只有本人或管理员可以查看自己的举报记录
if reporterID != userID && !(user.Role == "admin") {
return nil, 0, errors.New("无权查看其他用户的举报记录")
}
reports, total, err := s.reportRepo.FindByReporterID(ctx, reporterID, page, pageSize)
if err != nil {
s.logger.Error("查询举报记录失败", zap.Error(err))
return nil, 0, err
}
return reports, total, nil
}
// GetByTarget 根据目标对象查询举报记录
func (s *reportService) GetByTarget(ctx context.Context, targetType model.ReportType, targetID, userID int64, page, pageSize int) ([]*model.Report, int64, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, 0, err
}
if user == nil {
return nil, 0, apperrors.ErrUserNotFound
}
// 只有管理员可以查看目标对象的举报记录
if !(user.Role == "admin") {
return nil, 0, errors.New("无权查看举报记录")
}
reports, total, err := s.reportRepo.FindByTarget(ctx, targetType, targetID, page, pageSize)
if err != nil {
s.logger.Error("查询举报记录失败", zap.Error(err))
return nil, 0, err
}
return reports, total, nil
}
// GetByStatus 根据状态查询举报记录
func (s *reportService) GetByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error) {
reports, total, err := s.reportRepo.FindByStatus(ctx, status, page, pageSize)
if err != nil {
s.logger.Error("查询举报记录失败", zap.Error(err))
return nil, 0, err
}
return reports, total, nil
}
// Search 搜索举报记录
func (s *reportService) Search(ctx context.Context, keyword, userID int64, page, pageSize int) ([]*model.Report, int64, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, 0, err
}
if user == nil {
return nil, 0, apperrors.ErrUserNotFound
}
// 只有管理员可以搜索举报记录
if !(user.Role == "admin") {
return nil, 0, errors.New("无权搜索举报记录")
}
reports, total, err := s.reportRepo.Search(ctx, strconv.FormatInt(keyword, 10), page, pageSize)
if err != nil {
s.logger.Error("搜索举报记录失败", zap.Error(err))
return nil, 0, err
}
return reports, total, nil
}
// Review 处理举报记录
func (s *reportService) Review(ctx context.Context, reportID, reviewerID int64, status model.ReportStatus, reviewNote string) (*model.Report, error) {
// 验证处理人存在且是管理员
reviewer, err := s.userRepo.FindByID(ctx, reviewerID)
if err != nil {
s.logger.Error("处理人不存在", zap.Int64("reviewer_id", reviewerID), zap.Error(err))
return nil, apperrors.ErrUserNotFound
}
if reviewer == nil || !(reviewer.Role == "admin") {
return nil, errors.New("只有管理员可以处理举报")
}
// 验证状态
if status != model.ReportStatusApproved && status != model.ReportStatusRejected {
return nil, errors.New("无效的举报处理状态")
}
// 处理举报
if err := s.reportRepo.Review(ctx, reportID, status, reviewerID, reviewNote); err != nil {
s.logger.Error("处理举报失败", zap.Int64("report_id", reportID), zap.Error(err))
return nil, err
}
// 返回更新后的举报记录
report, err := s.reportRepo.FindByID(ctx, reportID)
if err != nil {
s.logger.Error("查询举报记录失败", zap.Int64("report_id", reportID), zap.Error(err))
return nil, err
}
s.logger.Info("处理举报成功", zap.Int64("report_id", reportID), zap.Int64("reviewer_id", reviewerID), zap.String("status", string(status)))
return report, nil
}
// BatchReview 批量处理举报记录
func (s *reportService) BatchReview(ctx context.Context, ids []int64, reviewerID int64, status model.ReportStatus, reviewNote string) (int64, error) {
// 验证处理人存在且是管理员
reviewer, err := s.userRepo.FindByID(ctx, reviewerID)
if err != nil {
s.logger.Error("处理人不存在", zap.Int64("reviewer_id", reviewerID), zap.Error(err))
return 0, apperrors.ErrUserNotFound
}
if reviewer == nil || !(reviewer.Role == "admin") {
return 0, errors.New("只有管理员可以处理举报")
}
// 验证状态
if status != model.ReportStatusApproved && status != model.ReportStatusRejected {
return 0, errors.New("无效的举报处理状态")
}
// 批量处理举报
affected, err := s.reportRepo.BatchReview(ctx, ids, status, reviewerID, reviewNote)
if err != nil {
s.logger.Error("批量处理举报失败", zap.Error(err))
return 0, err
}
s.logger.Info("批量处理举报成功", zap.Int("count", int(affected)), zap.Int64("reviewer_id", reviewerID), zap.String("status", string(status)))
return affected, nil
}
// Delete 删除举报记录
func (s *reportService) Delete(ctx context.Context, reportID, userID int64) error {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return apperrors.ErrUserNotFound
}
// 查询举报记录
report, err := s.reportRepo.FindByID(ctx, reportID)
if err != nil {
return err
}
if report == nil {
return errors.New("举报记录不存在")
}
// 只有举报人、管理员或处理人可以删除举报记录
if report.ReporterID != userID && !(user.Role == "admin") && (report.ReviewerID == nil || *report.ReviewerID != userID) {
return errors.New("无权删除此举报记录")
}
if err := s.reportRepo.Delete(ctx, reportID); err != nil {
s.logger.Error("删除举报记录失败", zap.Int64("report_id", reportID), zap.Error(err))
return err
}
s.logger.Info("删除举报记录成功", zap.Int64("report_id", reportID))
return nil
}
// BatchDelete 批量删除举报记录
func (s *reportService) BatchDelete(ctx context.Context, ids []int64, userID int64) (int64, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return 0, err
}
if user == nil {
return 0, apperrors.ErrUserNotFound
}
// 只有管理员可以批量删除
if !(user.Role == "admin") {
return 0, errors.New("无权批量删除举报记录")
}
affected, err := s.reportRepo.BatchDelete(ctx, ids)
if err != nil {
s.logger.Error("批量删除举报记录失败", zap.Error(err))
return 0, err
}
s.logger.Info("批量删除举报记录成功", zap.Int("count", int(affected)))
return affected, nil
}
// GetStats 获取举报统计信息
func (s *reportService) GetStats(ctx context.Context) (map[string]int64, error) {
stats := make(map[string]int64)
// 统计各状态的举报数量
pendingCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusPending)
if err != nil {
return nil, err
}
stats["pending"] = pendingCount
approvedCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusApproved)
if err != nil {
return nil, err
}
stats["approved"] = approvedCount
rejectedCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusRejected)
if err != nil {
return nil, err
}
stats["rejected"] = rejectedCount
stats["total"] = pendingCount + approvedCount + rejectedCount
return stats, nil
}

View File

@@ -55,6 +55,22 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
if texture.Status == -1 {
return nil, errors.New("材质已删除")
}
// 如果缓存中没有 Uploader 信息,重新查询数据库
if texture.Uploader == nil {
texture2, err := s.textureRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
if texture2 == nil {
return nil, ErrTextureNotFound
}
if texture2.Status == -1 {
return nil, errors.New("材质已删除")
}
// 更新缓存
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
return texture2, nil
}
return &texture, nil
}
@@ -71,9 +87,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
}
// 存入缓存(异步)
if texture2 != nil {
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
}
return texture2, nil
}
@@ -205,39 +219,22 @@ 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 {
if texture == nil || texture.Status != 1 || !texture.IsPublic {
return false, ErrTextureNotFound
}
isFavorited, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
isAdded, err := s.textureRepo.ToggleFavorite(ctx, userID, textureID)
if err != nil {
return false, err
}
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
}
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.UserFavoritesPattern(userID))
// 未收藏 -> 添加收藏
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
return isAdded, nil
}
func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
@@ -365,7 +362,17 @@ func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, na
// 清除用户的 texture 列表缓存(所有分页)
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
// 重新查询以预加载 Uploader 关联
textureWithUploader, err := s.textureRepo.FindByID(ctx, texture.ID)
if err != nil {
// 如果查询失败,返回原始创建的 texture 对象(虽然可能没有 Uploader 信息)
return texture, nil
}
if textureWithUploader == nil {
// 如果查询返回 nil极端情况如数据库复制延迟返回原始创建的 texture 对象
return texture, nil
}
return textureWithUploader, nil
}
// parseTextureTypeInternal 解析材质类型

View File

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

View File

@@ -199,6 +199,14 @@ func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User
}, s.cache.Policy.UserEmailTTL)
}
func (s *userService) GetByUsername(ctx context.Context, username string) (*model.User, error) {
// 使用 Cached 装饰器自动处理缓存
cacheKey := s.cacheKeys.UserByUsername(username)
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
return s.userRepo.FindByUsername(ctx, username)
}, s.cache.Policy.UserTTL)
}
func (s *userService) UpdateInfo(ctx context.Context, user *model.User) error {
err := s.userRepo.Update(ctx, user)
if err != nil {

View File

@@ -110,6 +110,18 @@ type UserInfo struct {
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
}
// PublicUserInfo 用户公开信息
// @Description 用户公开信息(不包含敏感信息如邮箱)
type PublicUserInfo struct {
ID int64 `json:"id" example:"1"`
Username string `json:"username" example:"testuser"`
Avatar string `json:"avatar" example:"https://example.com/avatar.png"`
Points int `json:"points" example:"100"`
Role string `json:"role" example:"user"`
Status int16 `json:"status" example:"1"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
}
// TextureType 材质类型
type TextureType string
@@ -123,6 +135,7 @@ const (
type TextureInfo struct {
ID int64 `json:"id" example:"1"`
UploaderID int64 `json:"uploader_id" example:"1"`
UploaderUsername string `json:"uploader_username" example:"testuser"`
Name string `json:"name" example:"My Skin"`
Description string `json:"description,omitempty" example:"A cool skin"`
Type TextureType `json:"type" example:"SKIN"`

View File

@@ -131,13 +131,16 @@ type SecurityConfig struct {
// Load 加载配置 - 完全从环境变量加载不依赖YAML文件
func Load() (*Config, error) {
// 加载.env文件如果存在
_ = godotenv.Load(".env")
if err := godotenv.Load(".env"); err != nil {
fmt.Printf("[Config] 注意: 未加载 .env 文件 (原因: %v)\n", err)
} else {
fmt.Println("[Config] 成功加载 .env 文件")
}
// 设置默认值
setDefaults()
// 设置环境变量前缀
viper.SetEnvPrefix("CARROTSKIN")
// 自动读取环境变量(不设置前缀,因为 BindEnv 已经明确指定了变量名)
viper.AutomaticEnv()
// 手动设置环境变量映射
@@ -152,6 +155,20 @@ func Load() (*Config, error) {
// 从环境变量中覆盖配置
overrideFromEnv(&config)
// 打印关键配置加载状态
fmt.Println("==================================================")
fmt.Println(" CarrotSkin Configuration Check ")
fmt.Println("==================================================")
fmt.Printf("Server Port: %s\n", config.Server.Port)
fmt.Printf("Database Host: %s\n", config.Database.Host)
fmt.Printf("Redis Host: %s\n", config.Redis.Host)
fmt.Printf("Environment: %s\n", config.Environment)
if config.Database.Host == "localhost" && os.Getenv("DATABASE_HOST") != "" && os.Getenv("DATABASE_HOST") != "localhost" {
fmt.Printf("[Warning] Database Host is 'localhost' but env DATABASE_HOST is set to '%s'. Viper binding might have failed.\n", os.Getenv("DATABASE_HOST"))
}
fmt.Println("==================================================")
return &config, nil
}
@@ -302,6 +319,7 @@ func setupEnvMappings() {
// overrideFromEnv 从环境变量中覆盖配置
func overrideFromEnv(config *Config) {
// 处理RustFS存储桶配置
if texturesBucket := os.Getenv("RUSTFS_BUCKET_TEXTURES"); texturesBucket != "" {
if config.RustFS.Buckets == nil {
@@ -342,6 +360,24 @@ func overrideFromEnv(config *Config) {
}
}
// 处理Redis基本配置
if host := os.Getenv("REDIS_HOST"); host != "" {
config.Redis.Host = host
}
if port := os.Getenv("REDIS_PORT"); port != "" {
if val, err := strconv.Atoi(port); err == nil {
config.Redis.Port = val
}
}
if password := os.Getenv("REDIS_PASSWORD"); password != "" {
config.Redis.Password = password
}
if database := os.Getenv("REDIS_DATABASE"); database != "" {
if val, err := strconv.Atoi(database); err == nil {
config.Redis.Database = val
}
}
// 处理Redis连接池配置
if poolSize := os.Getenv("REDIS_POOL_SIZE"); poolSize != "" {
if val, err := strconv.Atoi(poolSize); err == nil {

View File

@@ -369,6 +369,11 @@ 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

@@ -79,57 +79,6 @@ func (s *StorageClient) GetBucket(name string) (string, error) {
return bucket, nil
}
// GeneratePresignedURL 生成预签名上传URL (PUT方法)
func (s *StorageClient) GeneratePresignedURL(ctx context.Context, bucketName, objectName string, expires time.Duration) (string, error) {
url, err := s.client.PresignedPutObject(ctx, bucketName, objectName, expires)
if err != nil {
return "", fmt.Errorf("生成预签名URL失败: %w", err)
}
return url.String(), nil
}
// PresignedPostPolicyResult 预签名POST策略结果
type PresignedPostPolicyResult struct {
PostURL string // POST的URL
FormData map[string]string // 表单数据
FileURL string // 文件的最终访问URL
}
// GeneratePresignedPostURL 生成预签名POST URL (支持表单上传)
// 注意使用时必须确保file字段是表单的最后一个字段
func (s *StorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*PresignedPostPolicyResult, error) {
// 创建上传策略
policy := minio.NewPostPolicy()
// 设置策略的基本信息
policy.SetBucket(bucketName)
policy.SetKey(objectName)
policy.SetExpires(time.Now().UTC().Add(expires))
// 设置文件大小限制
if err := policy.SetContentLengthRange(minSize, maxSize); err != nil {
return nil, fmt.Errorf("设置文件大小限制失败: %w", err)
}
// 使用MinIO客户端和策略生成预签名的POST URL和表单数据
postURL, formData, err := s.client.PresignedPostPolicy(ctx, policy)
if err != nil {
return nil, fmt.Errorf("生成预签名POST URL失败: %w", err)
}
// 移除form_data中多余的bucket字段MinIO Go SDK可能会添加这个字段但会导致签名错误
// 注意在Go中直接delete不存在的key是安全的
delete(formData, "bucket")
// 使用配置的公开访问URL构造文件的永久访问URL
fileURL := s.BuildFileURL(bucketName, objectName)
return &PresignedPostPolicyResult{
PostURL: postURL.String(),
FormData: formData,
FileURL: fileURL,
}, nil
}
// BuildFileURL 构建文件的公开访问URL
func (s *StorageClient) BuildFileURL(bucketName, objectName string) string {

View File

@@ -1,9 +1,7 @@
package storage
import (
"context"
"testing"
"time"
"carrotskin/pkg/config"
@@ -41,31 +39,3 @@ func TestNewStorage_SkipConnectWhenNoCreds(t *testing.T) {
t.Fatalf("NewStorage should not error when creds empty: %v", err)
}
}
func TestPresignedHelpers_WithNilClient(t *testing.T) {
s := &StorageClient{
client: (*minio.Client)(nil),
buckets: map[string]string{"textures": "tex-bkt"},
publicURL: "http://localhost:9000",
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// 预期会panicnil client用recover捕获
func() {
defer func() {
if r := recover(); r == nil {
t.Fatalf("GeneratePresignedURL expected panic with nil client")
}
}()
_, _ = s.GeneratePresignedURL(ctx, "tex-bkt", "obj", time.Minute)
}()
func() {
defer func() {
if r := recover(); r == nil {
t.Fatalf("GeneratePresignedPostURL expected panic with nil client")
}
}()
_, _ = s.GeneratePresignedPostURL(ctx, "tex-bkt", "obj", 0, 10, time.Minute)
}()
}