Compare commits
19 Commits
85a9463913
...
email
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ba0e6b2f0 | ||
|
|
9219e8c6ea | ||
|
|
133c46c086 | ||
|
|
3e8b7d150d | ||
|
|
fd5a0e8405 | ||
|
|
573c10ed1d | ||
|
|
3b8d8bd7a7 | ||
|
|
6338592d27 | ||
|
|
ef460ec891 | ||
|
|
62d9432a2d | ||
|
|
e1d79ed445 | ||
|
|
c5d7e317a4 | ||
|
|
06539dc086 | ||
|
|
22142db782 | ||
|
|
2c9c6ecfc0 | ||
|
|
c5db489d72 | ||
| d952ddd4ea | |||
| e761ff5be5 | |||
| 9e83ae16af |
73
.gitea/workflows/build.yml
Normal file
73
.gitea/workflows/build.yml
Normal 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
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal 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"]
|
||||
754
README.md
754
README.md
@@ -1,564 +1,294 @@
|
||||
# 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 / MinIO(S3 兼容) |
|
||||
| 权限控制 | 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.example .env
|
||||
# 根据实际环境填写数据库、Redis、对象存储、邮件等信息
|
||||
```
|
||||
|
||||
4. **初始化数据库**
|
||||
```bash
|
||||
createdb carrotskin
|
||||
# 或 psql -c "CREATE DATABASE carrotskin;"
|
||||
```
|
||||
> 应用启动时会执行 `AutoMigrate`,自动创建 / 更新表结构。
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
6. **访问接口**
|
||||
- API Root: `http://localhost:8080`
|
||||
- Swagger: `http://localhost:8080/swagger/index.html`(需 `SERVER_SWAGGER_ENABLED=true`)
|
||||
|
||||
## ⚙️ 关键环境变量
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| `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` |
|
||||
|
||||
更多变量请参考 `.env.example` 与 `.env.docker.example`。
|
||||
|
||||
## 🧪 常用命令
|
||||
|
||||
```bash
|
||||
# 创建数据库
|
||||
createdb carrotskin
|
||||
# 或者使用PostgreSQL客户端
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE carrotskin;"
|
||||
# 运行单元测试
|
||||
go test ./...
|
||||
|
||||
# 重新生成 swagger
|
||||
swag init -g cmd/server/main.go -o docs
|
||||
|
||||
# 代码格式化 / 静态检查
|
||||
gofmt -w .
|
||||
golangci-lint run (若已安装)
|
||||
```
|
||||
|
||||
> 💡 **提示**: 项目使用 GORM 的 `AutoMigrate` 功能自动创建和更新数据库表结构,无需手动执行SQL脚本。首次启动时会自动创建所有表。
|
||||
## 🧱 架构说明
|
||||
|
||||
5. **运行服务**
|
||||
- **分层设计**: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
|
||||
# Linux/Mac
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
## 📝 Swagger 说明
|
||||
|
||||
# Windows
|
||||
start.bat
|
||||
```
|
||||
- `start.sh` 会在启动前执行 `swag init -g cmd/server/main.go -o docs`
|
||||
- 若手动运行,需要保证 `docs/` 下的 `docs.go`、`swagger.json`、`swagger.yaml` 与代码同步
|
||||
- 通过 `SERVER_SWAGGER_ENABLED=false` 可在生产环境关闭 Swagger UI 暴露
|
||||
|
||||
方式二:直接运行
|
||||
```bash
|
||||
# 设置环境变量(或使用.env文件)
|
||||
export DATABASE_HOST=localhost
|
||||
export DATABASE_PORT=5432
|
||||
# ... 其他环境变量
|
||||
## 🔐 管理后台 API
|
||||
|
||||
# 运行服务
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
管理后台接口均需要管理员权限(`role=admin`),所有接口路径前缀为 `/api/v1/admin`。
|
||||
|
||||
> 💡 **提示**:
|
||||
> - 启动脚本会自动加载 `.env` 文件中的环境变量
|
||||
> - 首次启动时会自动执行数据库迁移(AutoMigrate)
|
||||
> - 如果对象存储未配置,服务仍可启动(相关功能不可用)
|
||||
### 📊 统计信息
|
||||
|
||||
服务启动后:
|
||||
- **服务地址**: http://localhost:8080
|
||||
- **Swagger文档**: http://localhost:8080/swagger/index.html
|
||||
- **健康检查**: http://localhost:8080/health
|
||||
| 接口 | 方法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/stats` | GET | 获取系统统计数据(用户数、材质数、下载量等) |
|
||||
|
||||
## 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位数字)
|
||||
- 验证码存储(Redis,10分钟有效期,通过 `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()
|
||||
// ... 处理逻辑
|
||||
**响应示例:**
|
||||
```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 | 获取所有可用角色列表 |
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
go mod download
|
||||
|
||||
# 配置环境变量(创建.env文件或直接export)
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件
|
||||
|
||||
# 启动服务
|
||||
# 方式1: 使用启动脚本
|
||||
./start.sh # Linux/Mac
|
||||
start.bat # Windows
|
||||
|
||||
# 方式2: 直接运行
|
||||
go run cmd/server/main.go
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"roles": [
|
||||
{
|
||||
"name": "user",
|
||||
"display_name": "普通用户",
|
||||
"description": "拥有基本用户权限"
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"display_name": "管理员",
|
||||
"description": "拥有所有管理权限"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**首次启动**:
|
||||
- 会自动执行数据库迁移(AutoMigrate),创建所有表结构
|
||||
- 如果对象存储未配置,会记录警告但服务仍可启动
|
||||
- 检查日志确认所有服务初始化成功
|
||||
### 👤 用户管理
|
||||
|
||||
### 生产部署
|
||||
| 接口 | 方法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/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 | 批量删除用户 |
|
||||
|
||||
```bash
|
||||
# 构建二进制文件
|
||||
go build -o carrotskin-server cmd/server/main.go
|
||||
**搜索用户请求参数:**
|
||||
- `keyword` (string): 搜索关键词(用户名或邮箱)
|
||||
- `role` (string): 角色筛选
|
||||
- `status` (int): 状态筛选(1=正常,0=禁用,-1=删除)
|
||||
- `sort_by` (string): 排序字段
|
||||
- `sort_desc` (bool): 是否降序
|
||||
- `page` (int): 页码
|
||||
- `page_size` (int): 每页数量
|
||||
|
||||
# 运行服务
|
||||
./carrotskin-server
|
||||
**设置用户状态请求示例:**
|
||||
```json
|
||||
{
|
||||
"user_id": 123,
|
||||
"status": 0
|
||||
}
|
||||
```
|
||||
- `status`: 1=正常,0=禁用,-1=删除
|
||||
|
||||
**批量设置角色请求示例:**
|
||||
```json
|
||||
{
|
||||
"user_ids": [1, 2, 3],
|
||||
"role": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
### Docker部署
|
||||
### 🎨 材质管理
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t carrotskin-backend:latest .
|
||||
| 接口 | 方法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/textures` | GET | 获取材质列表(分页) |
|
||||
| `/textures/search` | GET | 搜索材质(支持关键词、类型、状态、上传者筛选、排序) |
|
||||
| `/textures/{id}` | PUT | 更新材质信息(名称、描述、公开状态、审核状态) |
|
||||
| `/textures/{id}` | DELETE | 删除材质 |
|
||||
| `/textures/batch-delete` | DELETE | 批量删除材质 |
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
**搜索材质请求参数:**
|
||||
- `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. **数据库连接失败**
|
||||
- 检查 `.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密码是否正确
|
||||
- 检查防火墙规则
|
||||
1. Fork & Clone
|
||||
2. 创建特性分支:`git checkout -b feature/xxx`
|
||||
3. 编写代码并补全测试 / Swagger 注释
|
||||
4. 提交时附上变更说明
|
||||
|
||||
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。祝编码愉快!🍀
|
||||
|
||||
@@ -32,8 +32,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
_ "carrotskin/docs" // Swagger docs
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/types"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -62,6 +63,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,22 +101,28 @@ 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,
|
||||
Name: texture.Name,
|
||||
Description: texture.Description,
|
||||
Type: types.TextureType(texture.Type),
|
||||
URL: texture.URL,
|
||||
Hash: texture.Hash,
|
||||
Size: texture.Size,
|
||||
IsPublic: texture.IsPublic,
|
||||
DownloadCount: texture.DownloadCount,
|
||||
FavoriteCount: texture.FavoriteCount,
|
||||
IsSlim: texture.IsSlim,
|
||||
Status: texture.Status,
|
||||
CreatedAt: texture.CreatedAt,
|
||||
UpdatedAt: texture.UpdatedAt,
|
||||
ID: texture.ID,
|
||||
UploaderID: texture.UploaderID,
|
||||
UploaderUsername: uploaderUsername,
|
||||
Name: texture.Name,
|
||||
Description: texture.Description,
|
||||
Type: types.TextureType(texture.Type),
|
||||
URL: texture.URL,
|
||||
Hash: texture.Hash,
|
||||
Size: texture.Size,
|
||||
IsPublic: texture.IsPublic,
|
||||
DownloadCount: texture.DownloadCount,
|
||||
FavoriteCount: texture.FavoriteCount,
|
||||
IsSlim: texture.IsSlim,
|
||||
Status: texture.Status,
|
||||
CreatedAt: texture.CreatedAt,
|
||||
UpdatedAt: texture.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,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
|
||||
}
|
||||
|
||||
@@ -93,6 +93,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))
|
||||
{
|
||||
@@ -199,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
|
||||
|
||||
return texture2, nil
|
||||
}
|
||||
@@ -365,7 +379,17 @@ func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, na
|
||||
// 清除用户的 texture 列表缓存(所有分页)
|
||||
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
|
||||
|
||||
return texture, nil
|
||||
// 重新查询以预加载 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 解析材质类型
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,21 +133,22 @@ const (
|
||||
// TextureInfo 材质信息
|
||||
// @Description 材质详细信息
|
||||
type TextureInfo struct {
|
||||
ID int64 `json:"id" example:"1"`
|
||||
UploaderID int64 `json:"uploader_id" example:"1"`
|
||||
Name string `json:"name" example:"My Skin"`
|
||||
Description string `json:"description,omitempty" example:"A cool skin"`
|
||||
Type TextureType `json:"type" example:"SKIN"`
|
||||
URL string `json:"url" example:"https://rustfs.example.com/textures/xxx.png"`
|
||||
Hash string `json:"hash" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
|
||||
Size int `json:"size" example:"2048"`
|
||||
IsPublic bool `json:"is_public" example:"true"`
|
||||
DownloadCount int `json:"download_count" example:"100"`
|
||||
FavoriteCount int `json:"favorite_count" example:"50"`
|
||||
IsSlim bool `json:"is_slim" example:"false"`
|
||||
Status int16 `json:"status" example:"1"`
|
||||
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
|
||||
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"`
|
||||
URL string `json:"url" example:"https://rustfs.example.com/textures/xxx.png"`
|
||||
Hash string `json:"hash" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
|
||||
Size int `json:"size" example:"2048"`
|
||||
IsPublic bool `json:"is_public" example:"true"`
|
||||
DownloadCount int `json:"download_count" example:"100"`
|
||||
FavoriteCount int `json:"favorite_count" example:"50"`
|
||||
IsSlim bool `json:"is_slim" example:"false"`
|
||||
Status int16 `json:"status" example:"1"`
|
||||
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
|
||||
}
|
||||
|
||||
// ProfileInfo 角色信息
|
||||
@@ -193,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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
// 预期会panic(nil 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)
|
||||
}()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user