首次提交:初始化项目代码

This commit is contained in:
sunct
2026-07-31 15:24:14 +08:00
commit 8d3c97bd01
73 changed files with 11720 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.idea
.vscode
deployments/k8s
*.md
Dockerfile*
deploy-all.sh
+18
View File
@@ -0,0 +1,18 @@
# 本地开发环境变量(覆盖 K8s 默认值)
# 使用前先把 docker-compose.infra.yml 跑起来: docker compose -f docker-compose.infra.yml up -d
# Auth Service
AUTH_PORT=8081
JWT_SECRET=cloudnest-secret-key
# 本地连接地址(docker-compose 暴露到宿主机)
MYSQL_DSN=root:sun900120@tcp(localhost:3306)/cloudnest?charset=utf8mb4&parseTime=True&loc=Local
REDIS_ADDR=localhost:6379
# File Service
FILE_PORT=8082
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=cloudnest-files
MINIO_USE_SSL=false
+19
View File
@@ -0,0 +1,19 @@
# Go
*.exe
*.dll
*.so
*.dylib
*.test
*.out
vendor/
# IDE
.idea/
.vscode/
# 构建产物
bin/
dist/
# 部署脚本无执行权限提醒
*.sh.bak
Binary file not shown.
File diff suppressed because it is too large Load Diff
+432
View File
@@ -0,0 +1,432 @@
# CloudNest
CloudNest 是一个基于 Clean Architecture(整洁架构)的云存储后端服务,提供用户认证和文件管理功能。
## 技术栈
- **语言**: Go 1.25+
- **框架**: Gin 1.10
- **数据库**: MySQL 8.0 + GORM
- **缓存**: Redis 7
- **对象存储**: MinIO
- **认证**: JWT (github.com/golang-jwt/jwt/v5)
- **依赖注入**: Uber Dig
- **配置管理**: ViperYAML + 环境变量)
- **日志**: Go 标准库 log/slogJSON 格式)
## 架构设计
项目采用 **Clean Architecture(整洁架构)**,遵循依赖倒置原则(DIP),确保核心业务逻辑独立于框架、UI 和外部依赖。
```
┌─────────────────────────────────────────────────────────────┐
│ Interface Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Handler │ │ Routes │ │ Middleware │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
└─────────┼────────────────┼─────────────────────┼─────────────┘
│ │ │
┌─────────▼────────────────▼─────────────────────▼─────────────┐
│ Application Layer │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ AuthService │ FileService │ │
│ │ - Register() │ - Upload() │ │
│ │ - Login() │ - List() │ │
│ │ │ - PresignDownload() │ │
│ │ │ - Delete() │ │
│ └──────────────┬────────────────┴───────────────┬─────────┘ │
└─────────────────┼────────────────────────────────┼─────────────┘
│ │
┌─────────────────▼────────────────────────────────▼─────────────┐
│ Domain Layer │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Entity │ │ Repository │ │
│ │ - User │ │ - Interface │ │
│ │ - FileMeta │ │ - (DIP) │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │
┌─────────────────▼────────────────────────────────▼─────────────┐
│ Infrastructure Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ MySQL │ │ Redis │ │ MinIO │ │ Repository │ │
│ └──────────┘ └──────────┘ └──────────┘ │ Implementation│ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 目录结构
```
cloudnest/
├── cmd/ # 启动入口 / Entry points
│ ├── auth-service/ # 认证服务
│ │ ├── main.go # 服务入口(含端口检测、优雅关闭)
│ │ └── embed.go # 静态文件嵌入
│ └── file-service/ # 文件服务
│ └── main.go # 服务入口(含端口检测、优雅关闭)
├── configs/ # 配置文件 / Configuration files
│ ├── config.yaml # 基础默认配置
│ ├── config.dev.yaml # 开发环境配置
│ ├── config.prod.yaml # 生产环境配置
│ └── config.test.yaml # 测试环境配置
├── deployments/ # 部署配置 / Deployment configs
│ ├── docker/ # Dockerfile
│ └── k8s/ # Kubernetes 配置
├── internal/
│ ├── application/ # 应用层 / Application Layer
│ │ ├── auth/ # 认证应用服务
│ │ │ ├── dto.go # 请求/响应 DTO
│ │ │ └── service.go # 业务逻辑
│ │ └── file/ # 文件应用服务
│ │ ├── dto.go
│ │ └── service.go
│ ├── config/ # 配置管理 / Configuration
│ │ └── config.go # YAML + 环境变量加载
│ ├── di/ # 依赖注入 / Dependency Injection
│ │ └── container.go # Uber Dig 容器配置
│ ├── domain/ # 领域层 / Domain Layer
│ │ ├── auth/ # 认证领域
│ │ │ ├── entity/ # 实体定义
│ │ │ └── repository/ # 仓储接口(DIP
│ │ └── file/ # 文件领域
│ │ ├── entity/
│ │ └── repository/
│ ├── infrastructure/ # 基础设施层 / Infrastructure
│ │ ├── database/ # 数据库连接(MySQL、Redis
│ │ ├── repository/ # 仓储实现(GORM、MinIO
│ │ └── storage/ # 对象存储(MinIO 客户端)
│ ├── interface/ # 接口层 / Interface Layer
│ │ └── http/
│ │ ├── handler/ # HTTP Handler
│ │ └── routes/ # 路由定义
│ ├── middleware/ # 中间件 / Middleware
│ │ ├── cors.go # 跨域处理
│ │ ├── error_handler.go # 错误恢复与处理
│ │ └── jwt.go # JWT 认证
│ └── pkg/ # 工具包 / Utilities
│ ├── crypto/ # 密码加密(bcrypt
│ ├── errors/ # 应用错误类型
│ ├── jwt/ # JWT 生成与解析
│ ├── logger/ # 结构化日志(log/slog
│ ├── response/ # 统一响应格式
│ └── validator/ # 参数校验
├── docker-compose.infra.yml # 基础设施 Docker Compose
├── start-local.ps1 # Windows PowerShell 启动脚本
├── start-local.bat # Windows CMD 启动脚本
├── deploy-all.sh # Kubernetes 一键部署脚本
├── go.mod
└── go.sum
```
## 环境要求
- **Go**: 1.25+
- **MySQL**: 8.0+
- **Redis**: 7.0+
- **MinIO**: Latest
- **Docker**: 20.10+(用于本地基础设施)
## 配置管理
项目使用 **分层配置加载** 机制,支持 YAML 文件 + 环境变量覆盖:
### 加载顺序(后面的覆盖前面的)
1. `configs/config.yaml` —— 基础默认值
2. `configs/config.{env}.yaml` —— 环境特定配置
3. **环境变量** —— 最高优先级
### 环境切换
通过 `CLOUDNEST_ENV` 环境变量选择配置环境:
```bash
# 开发环境(默认)
export CLOUDNEST_ENV=dev
# 测试环境
export CLOUDNEST_ENV=test
# 生产环境
export CLOUDNEST_ENV=prod
```
### 配置文件说明
| 文件 | 用途 | 注意 |
|------|------|------|
| `config.yaml` | 所有环境的默认配置 | 不要在此存放敏感信息 |
| `config.dev.yaml` | 本地开发环境 | 使用 localhost 连接 |
| `config.test.yaml` | 自动化测试环境 | 使用独立的测试数据库 |
| `config.prod.yaml` | 生产环境 | 敏感信息通过环境变量注入 |
### 环境变量映射
以下环境变量会自动映射到配置项(优先级最高):
```bash
# 应用 / Application
APP_NAME=cloudnest
APP_LOG_LEVEL=debug
# 服务器 / Server
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
# 数据库 / Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=yourpassword
DB_NAME=cloudnest
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# MinIO
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=cloudnest-files
# JWT
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=720
```
## 快速开始
### 1. 安装依赖
```bash
go mod tidy
```
### 2. 启动基础设施
使用 Docker Compose 启动 MySQL、Redis 和 MinIO
```bash
docker compose -f docker-compose.infra.yml up -d
```
### 3. 启动服务(Windows
**PowerShell 方式(推荐):**
```powershell
# 使用 dev 配置启动(默认)
.\start-local.ps1
# 使用 test 配置启动
$env:CLOUDNEST_ENV="test"; .\start-local.ps1
```
**CMD 方式:**
```cmd
start-local.bat
```
### 4. 手动启动服务(跨平台)
```bash
# 设置环境并启动认证服务
export CLOUDNEST_ENV=dev
go run ./cmd/auth-service
# 另开终端启动文件服务
export CLOUDNEST_ENV=dev
go run ./cmd/file-service
```
### 5. 访问服务
| 服务 | 地址 | 说明 |
|------|------|------|
| **Auth Service** | `http://localhost:8081` | 认证服务,处理注册/登录 |
| **File Service** | `http://localhost:8082` | 文件服务,处理上传/下载 |
| **MinIO Console** | `http://localhost:9001` | 对象存储管理界面 (minioadmin / minioadmin) |
| **健康检查** | `GET /healthz` | 服务健康状态 |
> **注意**: 开发模式下如果端口被占用,服务会自动检测并切换到下一个可用端口。生产环境使用配置文件中指定的固定端口。
## API 文档
### 认证接口
#### 注册
```bash
POST /api/v1/auth/register
Content-Type: application/json
{
"username": "string (3-50 chars)",
"password": "string (min 6 chars)"
}
```
**响应:**
```json
{
"code": 0,
"message": "注册成功"
}
```
#### 登录
```bash
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "string",
"password": "string"
}
```
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"token": "jwt-token"
}
}
```
### 文件接口
所有文件接口需要在 Header 中携带 JWT Token
```bash
Authorization: Bearer <jwt-token>
```
#### 上传文件
```bash
POST /api/v1/files/upload
Content-Type: multipart/form-data
file: <file>
```
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"message": "上传成功",
"file": "username/filename",
"size": 1024
}
}
```
#### 列出文件
```bash
GET /api/v1/files
```
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"files": ["username/file1.txt", "username/file2.jpg"]
}
}
```
#### 获取下载链接
```bash
GET /api/v1/files/download/:name
```
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"download_url": "https://minio.example.com/..."
}
}
```
#### 删除文件
```bash
DELETE /api/v1/files/:name
```
**响应:**
```json
{
"code": 0,
"message": "删除成功"
}
```
## 项目特性
- **分层架构**: 清晰的 Clean Architecture 层次划分,高内聚低耦合
- **依赖注入**: 使用 Uber Dig 实现依赖管理,便于测试和替换实现
- **配置管理**: YAML 文件 + 环境变量分层覆盖,支持多环境切换
- **端口自动检测**: 开发模式下自动检测并切换被占用的端口
- **优雅关闭**: 支持 SIGINT/SIGTERM 信号处理,确保请求完成后再关闭
- **错误处理**: 统一的错误响应格式,支持错误码和 HTTP 状态码
- **日志记录**: JSON 格式结构化日志,支持 Debug/Info/Warn/Error/Fatal 级别
- **参数校验**: 使用 go-playground/validator 进行请求参数校验
- **CORS 支持**: 跨域请求处理中间件
- **JWT 认证**: 基于 Token 的无状态认证机制
## 开发规范
- **包命名**: 小写,使用单数形式(如 `entity``repository`
- **文件命名**: 使用 snake_case(如 `auth_handler.go`
- **接口命名**: 以 `Repository` 结尾(如 `UserRepository`
- **服务命名**: 以 `Service` 结尾(如 `AuthService`
- **Handler 命名**: 以 `Handler` 结尾(如 `AuthHandler`
- **注释规范**: 中英文双语注释,包含功能说明、参数、返回值和注意事项
## 部署
### Docker
```bash
docker build -t cloudnest/auth-service -f deployments/docker/auth-service.Dockerfile .
docker build -t cloudnest/file-service -f deployments/docker/file-service.Dockerfile .
```
### Kubernetes
```bash
# 一键部署
CLOUDNEST_ENV=prod ./deploy-all.sh
# 或手动部署
kubectl apply -f deployments/k8s/
```
## License
MIT
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+20
View File
@@ -0,0 +1,20 @@
package main
import (
"embed"
"net/http"
"github.com/gin-gonic/gin"
)
//go:embed web
var webAssets embed.FS
func serveHTML(c *gin.Context, name string) {
content, err := webAssets.ReadFile(name)
if err != nil {
c.String(http.StatusNotFound, "not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"net/http"
"cloudnest/internal/bootstrap"
"cloudnest/internal/config"
"cloudnest/internal/di"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
func main() {
// Step 1: Initialize logger FIRST to prevent nil pointer panics
// 第一步:先初始化日志记录器以防止空指针 panic
logger.Init("info")
// Step 2: Load configuration
// 第二步:加载配置
cfg := config.Load()
// Step 3: Re-initialize logger with the configured log level
// 第三步:使用配置的日志级别重新初始化日志记录器
logger.Init(cfg.App.LogLevel)
// Step 4: Initialize services
// 第四步:初始化服务
c := di.New()
if err := c.InitializeServices(); err != nil {
logger.Fatal("failed to initialize services", "error", err)
}
// Step 5: Build the HTTP engine (auth service only registers auth routes)
// 第五步:构建 HTTP 引擎(认证服务只注册认证路由)
engine, err := c.BuildAuthEngine()
if err != nil {
logger.Fatal("failed to build engine", "error", err)
}
setupStaticFiles(engine)
// Step 6: Create server with lifecycle management
// 第六步:创建带生命周期管理的服务器
srv := bootstrap.NewServer(cfg, engine)
if err := srv.Run(); err != nil {
if err == bootstrap.ErrReloadRequested {
logger.Info("exiting for reload, supervisor should restart")
} else {
logger.Error("server exited with error", "error", err)
}
}
}
func setupStaticFiles(engine *gin.Engine) {
engine.GET("/", func(c *gin.Context) { serveHTML(c, "web/index.html") })
engine.GET("/login", func(c *gin.Context) { serveHTML(c, "web/login.html") })
engine.GET("/register", func(c *gin.Context) { serveHTML(c, "web/register.html") })
engine.NoRoute(func(c *gin.Context) {
if c.Request.Method == http.MethodGet {
serveHTML(c, "web/index.html")
} else {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "not found"})
}
})
}
File diff suppressed because it is too large Load Diff
+376
View File
@@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CloudNest - 登录</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; overflow: hidden; }
/* 登录页面容器 / Login page container */
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* 背景装饰圆形 / Background decoration circles */
.bg-decoration {
position: absolute;
width: 600px;
height: 600px;
border-radius: 50%;
background: rgba(255,255,255,0.05);
top: -200px;
right: -100px;
}
.bg-decoration-2 {
position: absolute;
width: 400px;
height: 400px;
border-radius: 50%;
background: rgba(255,255,255,0.03);
bottom: -150px;
left: -50px;
}
/* 浮动图标动画 / Floating icon animation */
.floating-icon {
position: absolute;
font-size: 48px;
color: rgba(255,255,255,0.08);
animation: float 6s ease-in-out infinite;
}
.floating-icon:nth-child(3) { top: 20%; left: 10%; animation-delay: 0s; }
.floating-icon:nth-child(4) { top: 60%; right: 15%; animation-delay: 2s; }
.floating-icon:nth-child(5) { bottom: 25%; left: 20%; animation-delay: 4s; }
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-20px) rotate(10deg); }
}
/* 登录卡片 / Login card */
.login-card {
background: white;
border-radius: 20px;
padding: 50px 60px;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
width: 100%;
max-width: 450px;
position: relative;
z-index: 10;
}
/* Logo区域 / Logo section */
.logo-section {
text-align: center;
margin-bottom: 35px;
}
.logo {
width: 70px;
height: 70px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: white;
margin-bottom: 15px;
box-shadow: 0 10px 25px rgba(37,99,235,0.3);
}
.logo-title {
font-size: 28px;
font-weight: 700;
color: #1e293b;
letter-spacing: 2px;
}
.logo-subtitle {
font-size: 14px;
color: #64748b;
margin-top: 5px;
}
/* 表单项 / Form items */
.form-item {
margin-bottom: 25px;
}
.el-input__wrapper {
border-radius: 12px;
padding: 0 15px;
height: 48px;
transition: all 0.3s ease;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px rgba(37,99,235,0.2);
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 2px rgba(37,99,235,0.4);
border-color: #2563eb;
}
/* 登录按钮 / Login button */
.login-btn {
width: 100%;
height: 50px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
border: none;
transition: all 0.3s ease;
margin-top: 10px;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(37,99,235,0.4);
}
.login-btn:active {
transform: translateY(0);
}
/* 注册链接 / Register link */
.register-link {
text-align: center;
margin-top: 25px;
font-size: 14px;
color: #64748b;
}
.register-link a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
.register-link a:hover {
color: #1d4ed8;
text-decoration: underline;
}
/* 验证码区域 / Captcha area */
.captcha-row {
display: flex;
align-items: center;
gap: 12px;
}
.captcha-row .el-input {
flex: 1;
}
.captcha-img {
width: 120px;
height: 48px;
border-radius: 10px;
cursor: pointer;
border: 1px solid #e2e8f0;
background: #f8fafc;
object-fit: contain;
}
.el-message {
top: 20px;
}
</style>
</head>
<body>
<div id="app">
<div class="login-container">
<div class="bg-decoration"></div>
<div class="bg-decoration-2"></div>
<i class="fa-solid fa-cloud floating-icon"></i>
<i class="fa-solid fa-database floating-icon"></i>
<i class="fa-solid fa-folder-open floating-icon"></i>
<div class="login-card">
<div class="logo-section">
<div class="logo">
<i class="fa-solid fa-cloud"></i>
</div>
<div class="logo-title">CloudNest</div>
<div class="logo-subtitle">轻量级个人云存储平台</div>
</div>
<el-form :model="loginForm" :rules="rules" ref="loginFormRef" class="form-container">
<el-form-item prop="username" class="form-item">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
size="large"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password" class="form-item">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<div class="captcha-row">
<el-form-item prop="captcha_answer" class="form-item captcha-input-item">
<el-input
v-model="loginForm.captcha_answer"
placeholder="请输入图形验证码"
size="large"
prefix-icon="Picture"
/>
</el-form-item>
<img
:src="captchaImage"
class="captcha-img"
@click="refreshCaptcha"
title="点击刷新验证码"
:alt="captchaImage ? '验证码' : '加载中...'"
/>
</div>
<el-form-item>
<el-button
type="primary"
class="login-btn"
@click="handleLogin"
:loading="loading"
>
<i class="fa-solid fa-right-to-bracket"></i> 登 录
</el-button>
</el-form-item>
</el-form>
<div class="register-link">
还没有账号?<a href="/register">立即注册</a>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
createApp({
setup() {
const loginFormRef = ref(null);
const loading = ref(false);
const captchaImage = ref('');
const captchaLoading = ref(false);
const captchaError = ref('');
// API基础路径配置(本地测试使用 localhost)
// API base path configuration (use localhost for local testing)
// 认证服务端口: 8081 / Auth service port: 8081
const AUTH_API_BASE = 'http://localhost:8081/api/v1/auth';
const CAPTCHA_API_BASE = 'http://localhost:8081/api/v1';
const loginForm = reactive({
username: '',
password: '',
captcha_id: '',
captcha_answer: ''
});
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度为3-50个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少为6个字符', trigger: 'blur' }
],
captcha_answer: [
{ required: true, message: '请输入图形验证码', trigger: 'blur' }
]
};
// 加载图形验证码 / Load captcha
const loadCaptcha = async () => {
captchaLoading.value = true;
captchaError.value = '';
try {
console.log('[Captcha] 开始加载验证码...');
const response = await axios.get(`${CAPTCHA_API_BASE}/captcha`);
console.log('[Captcha] 响应:', response.data);
if (response.data && response.data.code === 0 && response.data.data) {
loginForm.captcha_id = response.data.data.captcha_id || '';
captchaImage.value = response.data.data.image_url || '';
console.log('[Captcha] 设置图片URL:', captchaImage.value);
} else {
captchaError.value = '验证码加载失败';
console.error('[Captcha] 响应格式不正确:', response.data);
}
} catch (error) {
captchaError.value = '验证码加载失败,请刷新页面重试';
console.error('[Captcha] 加载验证码失败:', error);
} finally {
captchaLoading.value = false;
}
};
// 刷新图形验证码 / Refresh captcha
const refreshCaptcha = () => {
loadCaptcha();
};
// 处理登录 / Handle login
// API路由: POST /api/v1/auth/login
const handleLogin = async () => {
const valid = await loginFormRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const response = await axios.post(`${AUTH_API_BASE}/login`, {
username: loginForm.username,
password: loginForm.password,
captcha_id: loginForm.captcha_id,
captcha_answer: loginForm.captcha_answer
});
if (response.data.code === 0 && response.data.data?.token) {
localStorage.setItem('cloudnest_token', response.data.data.token);
localStorage.setItem('cloudnest_username', loginForm.username);
ElementPlus.ElMessage.success('登录成功');
setTimeout(() => {
window.location.href = '/';
}, 500);
} else {
ElementPlus.ElMessage.error(response.data.message || '登录失败');
refreshCaptcha();
}
} catch (error) {
const message = error.response?.data?.message || '登录失败,请检查网络连接';
ElementPlus.ElMessage.error(message);
refreshCaptcha();
} finally {
loading.value = false;
}
};
onMounted(() => {
loadCaptcha();
});
return {
loginFormRef,
loading,
loginForm,
rules,
captchaImage,
handleLogin,
refreshCaptcha
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>
+503
View File
@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CloudNest - 注册</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; overflow: hidden; }
/* 注册页面容器 / Register page container */
.register-container {
min-height: 100vh;
background: linear-gradient(135deg, #0f766e 0%, #14b8a6 50%, #2dd4bf 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* 背景装饰圆形 / Background decoration circles */
.bg-decoration {
position: absolute;
width: 600px;
height: 600px;
border-radius: 50%;
background: rgba(255,255,255,0.05);
top: -200px;
left: -150px;
}
.bg-decoration-2 {
position: absolute;
width: 450px;
height: 450px;
border-radius: 50%;
background: rgba(255,255,255,0.03);
bottom: -180px;
right: -80px;
}
/* 浮动图标动画 / Floating icon animation */
.floating-icon {
position: absolute;
font-size: 52px;
color: rgba(255,255,255,0.08);
animation: float 6s ease-in-out infinite;
}
.floating-icon:nth-child(3) { top: 15%; right: 12%; animation-delay: 0s; }
.floating-icon:nth-child(4) { top: 55%; left: 8%; animation-delay: 2s; }
.floating-icon:nth-child(5) { bottom: 20%; right: 18%; animation-delay: 4s; }
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-25px) rotate(10deg); }
}
/* 注册卡片 / Register card */
.register-card {
background: white;
border-radius: 20px;
padding: 50px 60px;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
width: 100%;
max-width: 450px;
position: relative;
z-index: 10;
}
/* Logo区域 / Logo section */
.logo-section {
text-align: center;
margin-bottom: 35px;
}
.logo {
width: 70px;
height: 70px;
background: linear-gradient(135deg, #0f766e, #14b8a6);
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: white;
margin-bottom: 15px;
box-shadow: 0 10px 25px rgba(15,118,110,0.3);
}
.logo-title {
font-size: 28px;
font-weight: 700;
color: #1e293b;
letter-spacing: 2px;
}
.logo-subtitle {
font-size: 14px;
color: #64748b;
margin-top: 5px;
}
/* 表单项 / Form items */
.form-item {
margin-bottom: 22px;
}
.el-input__wrapper {
border-radius: 12px;
padding: 0 15px;
height: 48px;
transition: all 0.3s ease;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px rgba(15,118,110,0.2);
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 2px rgba(15,118,110,0.4);
border-color: #0f766e;
}
/* 注册按钮 / Register button */
.register-btn {
width: 100%;
height: 50px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%);
border: none;
transition: all 0.3s ease;
margin-top: 10px;
}
.register-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(15,118,110,0.4);
}
.register-btn:active {
transform: translateY(0);
}
/* 登录链接 / Login link */
.login-link {
text-align: center;
margin-top: 25px;
font-size: 14px;
color: #64748b;
}
.login-link a {
color: #0f766e;
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
.login-link a:hover {
color: #0d9488;
text-decoration: underline;
}
/* 验证码区域 / Captcha area */
.captcha-row {
display: flex;
align-items: center;
gap: 12px;
}
.captcha-row .el-input {
flex: 1;
}
.captcha-img {
width: 120px;
height: 48px;
border-radius: 10px;
cursor: pointer;
border: 1px solid #e2e8f0;
background: #f8fafc;
object-fit: contain;
}
.email-code-row {
display: flex;
align-items: center;
gap: 12px;
}
.email-code-row .el-input {
flex: 1;
}
.email-code-btn {
width: 120px;
height: 48px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
}
.el-message {
top: 20px;
}
</style>
</head>
<body>
<div id="app">
<div class="register-container">
<div class="bg-decoration"></div>
<div class="bg-decoration-2"></div>
<i class="fa-solid fa-user-plus floating-icon"></i>
<i class="fa-solid fa-shield-halved floating-icon"></i>
<i class="fa-solid fa-key floating-icon"></i>
<div class="register-card">
<div class="logo-section">
<div class="logo">
<i class="fa-solid fa-user-plus"></i>
</div>
<div class="logo-title">CloudNest</div>
<div class="logo-subtitle">创建您的个人云存储账号</div>
</div>
<el-form :model="registerForm" :rules="rules" ref="registerFormRef" class="form-container">
<el-form-item prop="username" class="form-item">
<el-input
v-model="registerForm.username"
placeholder="请输入用户名(3-50个字符)"
size="large"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="nickname" class="form-item">
<el-input
v-model="registerForm.nickname"
placeholder="请输入昵称"
size="large"
prefix-icon="UserFilled"
/>
</el-form-item>
<el-form-item prop="password" class="form-item">
<el-input
v-model="registerForm.password"
type="password"
placeholder="请输入密码(至少6个字符)"
size="large"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item prop="confirmPassword" class="form-item">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="请确认密码"
size="large"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item prop="email" class="form-item">
<el-input
v-model="registerForm.email"
placeholder="请输入邮箱(可选)"
size="large"
prefix-icon="Message"
/>
</el-form-item>
<div class="captcha-row">
<el-form-item prop="captcha_answer" class="form-item captcha-input-item">
<el-input
v-model="registerForm.captcha_answer"
placeholder="请输入图形验证码"
size="large"
prefix-icon="Picture"
/>
</el-form-item>
<img
:src="captchaImage"
class="captcha-img"
@click="refreshCaptcha"
title="点击刷新验证码"
:alt="captchaImage ? '验证码' : '加载中...'"
/>
</div>
<el-form-item prop="email_code" class="form-item" v-if="registerForm.email">
<div class="email-code-row">
<el-input
v-model="registerForm.email_code"
placeholder="请输入邮箱验证码"
size="large"
prefix-icon="MessageBox"
/>
<el-button
type="primary"
class="email-code-btn"
@click="sendEmailCode"
:disabled="emailCountdown > 0 || !registerForm.email || !registerForm.captcha_answer"
:loading="emailCodeLoading"
>
{{ emailCountdown > 0 ? emailCountdown + 's' : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="register-btn"
@click="handleRegister"
:loading="loading"
>
<i class="fa-solid fa-user-plus"></i> 注 册
</el-button>
</el-form-item>
</el-form>
<div class="login-link">
已有账号?<a href="/login">立即登录</a>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
createApp({
setup() {
const registerFormRef = ref(null);
const loading = ref(false);
const captchaImage = ref('');
const emailCountdown = ref(0);
const emailCountdownTimer = ref(null);
const emailCodeLoading = ref(false);
// API基础路径配置(本地测试使用 localhost)
// API base path configuration (use localhost for local testing)
// 认证服务端口: 8081 / Auth service port: 8081
const AUTH_API_BASE = 'http://localhost:8081/api/v1/auth';
const CAPTCHA_API_BASE = 'http://localhost:8081/api/v1';
const registerForm = reactive({
username: '',
nickname: '',
password: '',
confirmPassword: '',
email: '',
captcha_id: '',
captcha_answer: '',
email_code: ''
});
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度为3-50个字符', trigger: 'blur' }
],
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 1, max: 50, message: '昵称长度为1-50个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少为6个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
},
trigger: 'blur'
}
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
captcha_answer: [
{ required: true, message: '请输入图形验证码', trigger: 'blur' }
],
email_code: [
{ required: true, message: '请输入邮箱验证码', trigger: 'blur' }
]
};
// 加载图形验证码 / Load captcha
const loadCaptcha = async () => {
try {
const response = await axios.get(`${CAPTCHA_API_BASE}/captcha`);
if (response.data.code === 0 && response.data.data) {
registerForm.captcha_id = response.data.data.captcha_id || '';
captchaImage.value = `${CAPTCHA_API_BASE}/captcha/${registerForm.captcha_id}/image`;
}
} catch (error) {
console.error('加载验证码失败', error);
}
};
// 刷新图形验证码 / Refresh captcha
const refreshCaptcha = () => {
loadCaptcha();
};
// 发送邮箱验证码 / Send email code
const sendEmailCode = async () => {
if (!registerForm.email) {
ElementPlus.ElMessage.warning('请先输入邮箱');
return;
}
if (!registerForm.captcha_answer) {
ElementPlus.ElMessage.warning('请先输入图形验证码');
return;
}
emailCodeLoading.value = true;
try {
const response = await axios.post(`${AUTH_API_BASE}/email-code`, {
email: registerForm.email,
captcha_id: registerForm.captcha_id,
captcha_answer: registerForm.captcha_answer
});
if (response.data.code === 0) {
ElementPlus.ElMessage.success('邮箱验证码已发送');
emailCountdown.value = 60;
emailCountdownTimer.value = setInterval(() => {
emailCountdown.value--;
if (emailCountdown.value <= 0) {
clearInterval(emailCountdownTimer.value);
emailCountdownTimer.value = null;
}
}, 1000);
} else {
ElementPlus.ElMessage.error(response.data.message || '发送失败');
refreshCaptcha();
}
} catch (error) {
const message = error.response?.data?.message || '发送失败,请检查网络连接';
ElementPlus.ElMessage.error(message);
refreshCaptcha();
} finally {
emailCodeLoading.value = false;
}
};
// 处理注册 / Handle register
// API路由: POST /api/v1/auth/register
const handleRegister = async () => {
const valid = await registerFormRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const payload = {
username: registerForm.username,
password: registerForm.password,
nickname: registerForm.nickname,
email: registerForm.email,
captcha_id: registerForm.captcha_id,
captcha_answer: registerForm.captcha_answer,
email_code: registerForm.email_code
};
// 如果邮箱为空,不发送邮箱验证码
if (!registerForm.email) {
delete payload.email;
delete payload.email_code;
}
const response = await axios.post(`${AUTH_API_BASE}/register`, payload);
if (response.data.code === 0) {
ElementPlus.ElMessage.success(response.data.message || '注册成功');
setTimeout(() => {
window.location.href = '/login';
}, 1500);
} else {
ElementPlus.ElMessage.error(response.data.message || '注册失败');
refreshCaptcha();
}
} catch (error) {
const message = error.response?.data?.message || '注册失败,请检查网络连接';
ElementPlus.ElMessage.error(message);
refreshCaptcha();
} finally {
loading.value = false;
}
};
onMounted(() => {
loadCaptcha();
});
return {
registerFormRef,
loading,
registerForm,
rules,
captchaImage,
emailCountdown,
emailCodeLoading,
handleRegister,
refreshCaptcha,
sendEmailCode
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"cloudnest/internal/bootstrap"
"cloudnest/internal/config"
"cloudnest/internal/di"
"cloudnest/internal/pkg/logger"
)
func main() {
// Step 1: Initialize logger FIRST to prevent nil pointer panics
// 第一步:先初始化日志记录器以防止空指针 panic
// Using a default level until we can read config
// 使用默认级别直到读取配置
logger.Init("info")
// Step 2: Load configuration
// 第二步:加载配置
cfg := config.Load()
// Step 3: Re-initialize logger with the configured log level
// 第三步:使用配置的日志级别重新初始化日志记录器
logger.Init(cfg.App.LogLevel)
// Step 4: Initialize services
// 第四步:初始化服务
c := di.New()
if err := c.InitializeServices(); err != nil {
logger.Fatal("failed to initialize services", "error", err)
}
// Step 5: Build the HTTP engine (file service only registers file routes)
// 第五步:构建 HTTP 引擎(文件服务只注册文件路由)
engine, err := c.BuildFileEngine()
if err != nil {
logger.Fatal("failed to build engine", "error", err)
}
// Step 6: Create server with lifecycle management
// 第六步:创建带生命周期管理的服务器
srv := bootstrap.NewServer(cfg, engine)
if err := srv.Run(); err != nil {
if err == bootstrap.ErrReloadRequested {
logger.Info("exiting for reload, supervisor should restart")
} else {
logger.Error("server exited with error", "error", err)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
# CloudNest 开发环境配置
# CloudNest Development Environment Configuration
# 自动加载数据库、缓存、存储的本地连接地址
# Automatically loads local connection addresses for database, cache, and storage
app:
env: development
log_level: debug
file_service_url: http://localhost:8082
server:
host: 0.0.0.0
# Auth Service 默认端口 / Auth Service default port
# File Service 请通过 SERVER_PORT=8082 环境变量覆盖 / Override File Service via SERVER_PORT=8082 env var
port: 8081
database:
host: localhost
port: 3306
user: root
password: sun900120
dbname: cloudnest
cache:
host: localhost
port: 6379
storage:
endpoint: localhost:9000
access_key_id: minioadmin
secret_access_key: minioadmin
bucket_name: cloudnest-files
+44
View File
@@ -0,0 +1,44 @@
# CloudNest 生产环境配置
# CloudNest Production Environment Configuration
# ⚠️ 生产环境敏感信息请通过环境变量注入,不要直接写在此文件
# ⚠️ Production sensitive info should be injected via environment variables, DO NOT hardcode here
app:
env: production
log_level: info
server:
host: 0.0.0.0
port: 8080
graceful_shutdown_timeout: 60
database:
# 生产环境请使用环境变量覆盖密码 / Use env var to override password in production
host: ${DB_HOST}
port: ${DB_PORT:3306}
user: ${DB_USER}
password: ${DB_PASSWORD}
dbname: ${DB_NAME:cloudnest}
max_idle_conns: 20
max_open_conns: 200
conn_max_lifetime: 7200
cache:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
db: ${REDIS_DB:0}
pool_size: 50
min_idle_conns: 10
storage:
endpoint: ${MINIO_ENDPOINT}
access_key_id: ${MINIO_ACCESS_KEY}
secret_access_key: ${MINIO_SECRET_KEY}
bucket_name: ${MINIO_BUCKET:cloudnest-files}
use_ssl: ${MINIO_USE_SSL:true}
max_file_size: 500
jwt:
secret: ${JWT_SECRET}
expires_in: 168
+34
View File
@@ -0,0 +1,34 @@
# CloudNest 测试环境配置
# CloudNest Test Environment Configuration
# 使用内存数据库或测试专用实例
# Uses in-memory database or test-specific instances
app:
env: test
log_level: warn
server:
host: 127.0.0.1
port: 18080
database:
host: localhost
port: 3306
user: root
password: sun900120
dbname: cloudnest_test
cache:
host: localhost
port: 6379
db: 1
storage:
endpoint: localhost:9000
access_key_id: minioadmin
secret_access_key: minioadmin
bucket_name: cloudnest-test-files
jwt:
secret: test-secret-key
expires_in: 1
+71
View File
@@ -0,0 +1,71 @@
# CloudNest 基础配置文件
# CloudNest Base Configuration File
# 此文件包含所有环境的默认配置,可被环境特定配置覆盖
# This file contains default config for all environments, can be overridden by env-specific configs
app:
name: cloudnest
version: 1.0.0
env: development
log_level: info
server:
host: 0.0.0.0
port: 8080
# graceful_shutdown_timeout: 30s
# 优雅关闭超时时间(秒)
graceful_shutdown_timeout: 30
database:
# MySQL DSN 格式: user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
# MySQL DSN format: user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
driver: mysql
host: localhost
port: 3306
user: root
password: ""
dbname: cloudnest
charset: utf8mb4
parse_time: true
loc: Local
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 3600
cache:
driver: redis
host: localhost
port: 6379
password: ""
db: 0
# Redis 连接池配置 / Redis connection pool settings
pool_size: 10
min_idle_conns: 5
storage:
driver: minio
endpoint: localhost:9000
access_key_id: minioadmin
secret_access_key: minioadmin
bucket_name: cloudnest-files
use_ssl: false
# 预签名 URL 有效期(小时)/ Presigned URL expiration (hours)
presign_duration: 24
# 最大上传文件大小(MB/ Max upload file size (MB)
max_file_size: 100
# 回收站文件过期天数 / Recycle bin file expiration days
recycle_bin_expire_days: 7
jwt:
secret: cloudnest-secret-key
# Token 过期时间(小时)/ Token expiration time (hours)
expires_in: 720
# 签发者 / Issuer
issuer: cloudnest
email:
smtp_host: "smtp.qq.com"
smtp_port: 587
smtp_username: ""
smtp_password: ""
from_name: "CloudNest"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# CloudNest 一键部署脚本
# CloudNest One-Click Deployment Script
# 用法 / Usage: CLOUDNEST_ENV=prod ./deploy-all.sh
set -euo pipefail
ENV=${CLOUDNEST_ENV:-prod}
echo "=== CloudNest 一键部署脚本 ==="
echo "环境 / Environment: $ENV"
# 0. 创建命名空间 / Create namespace
kubectl create namespace cloudnest 2>/dev/null || true
# 1. 基础设施 / Infrastructure
echo "[1/3] 部署 MySQL / Redis / MinIO..."
kubectl apply -f deployments/k8s/00-namespace.yaml
kubectl apply -f deployments/k8s/mysql-deployment.yaml
kubectl apply -f deployments/k8s/redis-deployment.yaml
kubectl apply -f deployments/k8s/minio-deployment.yaml
echo "等待基础设施就绪 / Waiting for infrastructure..."
kubectl wait --for=condition=ready pod -l app=mysql -n cloudnest --timeout=180s
kubectl wait --for=condition=ready pod -l app=redis -n cloudnest --timeout=60s
kubectl wait --for=condition=ready pod -l app=minio -n cloudnest --timeout=60s
# 2. 微服务 / Microservices
echo "[2/3] 部署 Auth / File 微服务..."
kubectl apply -f deployments/k8s/auth-deployment.yaml
kubectl apply -f deployments/k8s/file-deployment.yaml
kubectl apply -f deployments/k8s/ingress.yaml
kubectl wait --for=condition=ready pod -l app=auth-service -n cloudnest --timeout=120s
kubectl wait --for=condition=ready pod -l app=file-service -n cloudnest --timeout=120s
# 3. 状态总览 / Status overview
echo "[3/3] 部署完成,资源总览 / Deployment complete, resource overview:"
kubectl get all,ingressroute,middleware -n cloudnest
echo ""
echo "下一步 / Next steps:"
echo " 1. 在域名服务商添加 A 记录: api / minio -> 你的服务器公网 IP"
echo " Add A records at your DNS provider: api / minio -> your server public IP"
echo " 2. 按部署指南启用 Traefik Let's Encrypt 自动证书"
echo " Enable Traefik Let's Encrypt auto-certificate per deployment guide"
echo " 3. curl -X POST https://api.yourdomain.com/api/v1/auth/register -d '{\"username\":\"admin\",\"password\":\"123456\"}' -H 'Content-Type: application/json'"
+45
View File
@@ -0,0 +1,45 @@
server {
listen 80;
server_name cloudnest.example.com;
# 前端静态文件 / Frontend static files
location / {
# Auth Service 提供前端页面
# Auth Service serves frontend pages
proxy_pass http://localhost:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 认证 API / Auth API
location /api/v1/auth/ {
proxy_pass http://localhost:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 文件服务 API / File service API
location /api/v1/files/ {
proxy_pass http://localhost:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 健康检查 / Health check
location /healthz {
proxy_pass http://localhost:8081;
access_log off;
}
# SSL/TLS 配置(生产环境)/ SSL/TLS configuration (production)
# ssl_certificate /etc/nginx/ssl/cloudnest.crt;
# ssl_certificate_key /etc/nginx/ssl/cloudnest.key;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
}
@@ -0,0 +1,20 @@
# 多阶段构建,减小最终镜像体积
FROM golang:1.22-alpine AS builder
WORKDIR /src
# 先复制 go.mod / go.sum 并下载依赖,利用 Docker 缓存
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
# 静态二进制,方便在 alpine 中运行
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/auth-service ./cmd/auth-service
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata && adduser -D -u 10001 app
WORKDIR /app
COPY --from=builder /out/auth-service /app/auth-service
USER app
EXPOSE 8081
ENTRYPOINT ["/app/auth-service"]
@@ -0,0 +1,17 @@
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/file-service ./cmd/file-service
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata && adduser -D -u 10002 app
WORKDIR /app
COPY --from=builder /out/file-service /app/file-service
USER app
EXPOSE 8082
ENTRYPOINT ["/app/file-service"]
+11
View File
@@ -0,0 +1,11 @@
# 部署指南:在能访问外网的 Linux 服务器上执行
# 1) 替换 yourdomain.com / yourname / your-email@example.com
# 2) docker login && docker build -f deployments/docker/auth-service.Dockerfile -t yourname/auth-service:v1 .
# 3) docker build -f deployments/docker/file-service.Dockerfile -t yourname/file-service:v1 .
# 4) docker push yourname/auth-service:v1 && docker push yourname/file-service:v1
# 5) kubectl apply -f deployments/k8s/
---
apiVersion: v1
kind: Namespace
metadata:
name: cloudnest
+61
View File
@@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-service
namespace: cloudnest
labels:
app: auth-service
spec:
replicas: 1
selector:
matchLabels:
app: auth-service
template:
metadata:
labels:
app: auth-service
spec:
containers:
- name: auth-service
# 替换 yourname 为你的 Docker Hub 用户名
# 若在服务器本地构建,把 imagePullPolicy 改为 Never
image: yourname/auth-service:v1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8081
name: http
env:
- name: AUTH_PORT
value: "8081"
- name: JWT_SECRET
value: "cloudnest-secret-key"
- name: MYSQL_DSN
value: "root:cloudnest123@tcp(mysql:3306)/cloudnest?charset=utf8mb4&parseTime=True&loc=Local"
- name: REDIS_ADDR
value: "redis:6379"
resources:
limits:
memory: "128Mi"
cpu: "300m"
requests:
memory: "64Mi"
cpu: "50m"
readinessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: auth-service
namespace: cloudnest
spec:
selector:
app: auth-service
ports:
- port: 8081
targetPort: 8081
name: http
+66
View File
@@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: file-service
namespace: cloudnest
labels:
app: file-service
spec:
replicas: 1
selector:
matchLabels:
app: file-service
template:
metadata:
labels:
app: file-service
spec:
containers:
- name: file-service
# 替换 yourname 为你的 Docker Hub 用户名
image: yourname/file-service:v1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8082
name: http
env:
- name: FILE_PORT
value: "8082"
- name: JWT_SECRET
value: "cloudnest-secret-key"
- name: MINIO_ENDPOINT
value: "minio:9000"
- name: MINIO_ACCESS_KEY
value: "minioadmin"
- name: MINIO_SECRET_KEY
value: "minioadmin"
- name: MINIO_BUCKET
value: "cloudnest-files"
- name: MINIO_USE_SSL
value: "false"
resources:
limits:
memory: "128Mi"
cpu: "300m"
requests:
memory: "64Mi"
cpu: "50m"
readinessProbe:
httpGet:
path: /healthz
port: 8082
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: file-service
namespace: cloudnest
spec:
selector:
app: file-service
ports:
- port: 8082
targetPort: 8082
name: http
+56
View File
@@ -0,0 +1,56 @@
# CloudNest Ingress 路由 + Let's Encrypt 自动证书
# 1) 将所有 yourdomain.com 替换成你的真实域名
# 2) 提前按部署指南 5.5 启用 certResolver: letsencrypt
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: strip-prefix
namespace: cloudnest
spec:
stripPrefix:
prefixes:
- /auth
- /files
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: cloudnest-ingress
namespace: cloudnest
spec:
entryPoints:
- websecure
routes:
- match: Host(`api.yourdomain.com`) && PathPrefix(`/auth`)
kind: Rule
middlewares:
- name: strip-prefix
services:
- name: auth-service
port: 8081
- match: Host(`api.yourdomain.com`) && PathPrefix(`/files`)
kind: Rule
middlewares:
- name: strip-prefix
services:
- name: file-service
port: 8082
tls:
certResolver: letsencrypt
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: minio-console
namespace: cloudnest
spec:
entryPoints:
- websecure
routes:
- match: Host(`minio.yourdomain.com`)
kind: Rule
services:
- name: minio
port: 9001
tls:
certResolver: letsencrypt
+72
View File
@@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
namespace: cloudnest
labels:
app: minio
spec:
replicas: 1
selector:
matchLabels:
app: minio
template:
metadata:
labels:
app: minio
spec:
containers:
- name: minio
image: minio/minio:latest
args:
- server
- /data
- --console-address
- ":9001"
env:
- name: MINIO_ROOT_USER
value: "minioadmin"
- name: MINIO_ROOT_PASSWORD
value: "minioadmin"
ports:
- containerPort: 9000
name: api
- containerPort: 9001
name: console
resources:
limits:
memory: "256Mi"
cpu: "500m"
requests:
memory: "128Mi"
cpu: "100m"
readinessProbe:
httpGet:
path: /minio/health/live
port: 9000
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: minio-data
mountPath: /data
volumes:
- name: minio-data
hostPath:
path: /data/minio
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: minio
namespace: cloudnest
spec:
selector:
app: minio
ports:
- name: api
port: 9000
targetPort: 9000
- name: console
port: 9001
targetPort: 9001
+66
View File
@@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
namespace: cloudnest
labels:
app: mysql
spec:
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0
env:
- name: MYSQL_ROOT_PASSWORD
value: "cloudnest123"
- name: MYSQL_DATABASE
value: "cloudnest"
ports:
- containerPort: 3306
name: mysql
resources:
limits:
memory: "256Mi"
cpu: "500m"
requests:
memory: "128Mi"
cpu: "100m"
readinessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-pcloudnest123"]
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-pcloudnest123"]
initialDelaySeconds: 30
periodSeconds: 20
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
volumes:
- name: mysql-data
hostPath:
path: /data/mysql
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: cloudnest
spec:
selector:
app: mysql
ports:
- port: 3306
targetPort: 3306
name: mysql
+43
View File
@@ -0,0 +1,43 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: cloudnest
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
name: redis
resources:
limits:
memory: "64Mi"
cpu: "200m"
requests:
memory: "32Mi"
cpu: "50m"
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: cloudnest
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
name: redis
+100
View File
@@ -0,0 +1,100 @@
# CloudNest 基础设施 Docker Compose
# CloudNest Infrastructure Docker Compose
# 包含 MySQL / Redis / MinIO 三个基础设施服务
# Contains MySQL / Redis / MinIO infrastructure services
# 启动方式 / Startup:
# docker compose -f docker-compose.infra.yml up -d
# 停止方式 / Stop:
# docker compose -f docker-compose.infra.yml down
# 查看日志 / View logs:
# docker compose -f docker-compose.infra.yml logs -f
# 环境变量说明 / Environment variables:
# DB_HOST - MySQL 主机(默认:容器内主机名 mysql)
# DB_PORT - MySQL 端口(默认:3306
# DB_USER - MySQL 用户(默认:root
# DB_PASSWORD - MySQL 密码(默认:sun900120
# DB_NAME - 数据库名(默认:cloudnest
# REDIS_PORT - Redis 端口(默认:6379
# MINIO_PORT - MinIO API 端口(默认:9000
# MINIO_CONSOLE_PORT - MinIO 控制台端口(默认:9001)
# MINIO_ACCESS_KEY - MinIO 访问密钥(默认:minioadmin
# MINIO_SECRET_KEY - MinIO 秘密密钥(默认:minioadmin
version: "3.8"
services:
mysql:
image: mysql:8.0
container_name: cloudnest-mysql
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-sun900120}
MYSQL_DATABASE: ${DB_NAME:-cloudnest}
ports:
- "${DB_PORT:-3306}:3306"
volumes:
- mysql-data:/var/lib/mysql
# 可选:挂载初始化脚本目录 / Optional: mount init scripts
# - ./scripts/mysql/init:/docker-entrypoint-initdb.d
networks:
- cloudnest-network
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${DB_PASSWORD:-sun900120}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
command: --default-authentication-plugin=caching_sha2_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
redis:
image: redis:7-alpine
container_name: cloudnest-redis
ports:
- "${REDIS_PORT:-6379}:6379"
networks:
- cloudnest-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
# 可选:挂载 Redis 配置文件 / Optional: mount redis config
# volumes:
# - ./configs/redis.conf:/usr/local/etc/redis/redis.conf
# command: redis-server /usr/local/etc/redis/redis.conf
minio:
image: minio/minio:latest
container_name: cloudnest-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
ports:
- "${MINIO_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio-data:/data
networks:
- cloudnest-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
mysql-data:
name: cloudnest-mysql-data
minio-data:
name: cloudnest-minio-data
networks:
cloudnest-network:
name: cloudnest-network
driver: bridge
+61
View File
@@ -0,0 +1,61 @@
module cloudnest
go 1.20
require (
github.com/dchest/captcha v1.1.0
github.com/gin-gonic/gin v1.8.2
github.com/go-playground/validator/v10 v10.15.0
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/minio/minio-go/v7 v7.0.60
github.com/spf13/viper v1.16.0
go.uber.org/dig v1.17.0
golang.org/x/crypto v0.14.0
gorm.io/driver/mysql v1.5.0
gorm.io/gorm v1.25.6
)
require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.9.11 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sirupsen/logrus v1.9.2 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+573
View File
@@ -0,0 +1,573 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.8.2 h1:UzKToD9/PoFj/V4rvlKqTRKnQYyz8Sc1MJlv4JHPtvY=
github.com/gin-gonic/gin v1.8.2/go.mod h1:qw5AYuDrzRTnhvusDsrov+fDIxp9Dleuu12h8nfB398=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjAR7VgKoNB6ryXfw=
github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk=
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.60 h1:iHkrmWyHFs/eZiWc2F/5jAHtNBAFy+HjdhMX6FkkPWc=
github.com/minio/minio-go/v7 v7.0.60/go.mod h1:NUDy4A4oXPq1l2yK6LTSvCEzAMeIcoz9lcj5dbzSrRE=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.0 h1:6hSAT5QcyIaty0jfnff0z0CLDjyRgZ8mlMHLqSt7uXM=
gorm.io/driver/mysql v1.5.0/go.mod h1:FFla/fJuCvyTi7rJQd27qlNX2v3L6deTR1GgTjSOLPo=
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.6 h1:V92+vVda1wEISSOMtodHVRcUIOPYa2tgQtyF+DfFx+A=
gorm.io/gorm v1.25.6/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+95
View File
@@ -0,0 +1,95 @@
package auth
// RegisterRequest represents the request body for user registration.
// RegisterRequest 表示用户注册的请求体。
//
// Fields:
// - Username: The desired username (3-50 characters).
// - Password: The desired password (minimum 6 characters).
//
// Validation:
// - Username is required and must be 3-50 characters.
// - Password is required and must be at least 6 characters.
//
// 字段:
// - Username: 期望的用户名(3-50 个字符)。
// - Password: 期望的密码(最少 6 个字符)。
//
// 验证规则:
// - Username 是必填项,必须是 3-50 个字符。
// - Password 是必填项,必须至少 6 个字符。
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=6"`
Email string `json:"email"`
Nickname string `json:"nickname"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaAnswer string `json:"captcha_answer" binding:"required"`
EmailCode string `json:"email_code"` // 邮箱注册时必填
}
// LoginRequest represents the request body for user login.
// LoginRequest 表示用户登录的请求体。
//
// Fields:
// - Username: The user's username.
// - Password: The user's password.
//
// Validation:
// - Both fields are required.
//
// 字段:
// - Username: 用户的用户名。
// - Password: 用户的密码。
//
// 验证规则:
// - 两个字段都是必填项。
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaAnswer string `json:"captcha_answer" binding:"required"`
}
// TokenResponse represents the response body for successful login.
// TokenResponse 表示成功登录的响应体。
//
// Fields:
// - Token: The JWT access token.
//
// 字段:
// - Token: JWT 访问令牌。
type TokenResponse struct {
Token string `json:"token"`
}
// UpdateProfileRequest represents the request body for updating user profile.
// UpdateProfileRequest 表示更新用户个人信息的请求体。
//
// Fields:
// - Email: The user's email address.
// - Phone: The user's phone number.
//
// 字段:
// - Email: 用户的邮箱地址。
// - Phone: 用户的手机号码。
type UpdateProfileRequest struct {
Email string `json:"email"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
}
// ChangePasswordRequest represents the request body for changing password.
// ChangePasswordRequest 表示修改密码的请求体。
//
// Fields:
// - CurrentPassword: The user's current password.
// - NewPassword: The new password.
//
// 字段:
// - CurrentPassword: 用户当前的密码。
// - NewPassword: 新密码。
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=6"`
}
+159
View File
@@ -0,0 +1,159 @@
package auth
import (
"fmt"
"math/rand"
"strings"
"time"
"cloudnest/internal/domain/auth/entity"
"cloudnest/internal/domain/auth/repository"
"cloudnest/internal/pkg/crypto"
"cloudnest/internal/pkg/errors"
"cloudnest/internal/pkg/jwt"
)
type EmailVerifier interface {
VerifyCode(email, code string) bool
}
type AuthService struct {
userRepo repository.UserRepository
emailService EmailVerifier
jwtSecret string
jwtExpires int
}
func NewAuthService(userRepo repository.UserRepository, emailService EmailVerifier, jwtSecret string, jwtExpires int) *AuthService {
return &AuthService{
userRepo: userRepo,
emailService: emailService,
jwtSecret: jwtSecret,
jwtExpires: jwtExpires,
}
}
func generateUserCode() string {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
r := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, 8)
for i := range b {
b[i] = charset[r.Intn(len(charset))]
}
return string(b)
}
func (s *AuthService) Register(username, password, email, nickname, emailCode string) error {
existing, err := s.userRepo.FindByUsername(username)
if err == nil && existing != nil {
return errors.Conflict("用户名已存在")
}
if email != "" {
existing, err := s.userRepo.FindByEmail(email)
if err == nil && existing != nil {
return errors.Conflict("邮箱已被注册")
}
if emailCode == "" {
return errors.BadRequest("邮箱验证码不能为空")
}
if !s.emailService.VerifyCode(email, emailCode) {
return errors.BadRequest("邮箱验证码错误")
}
}
hashed, err := crypto.HashPassword(password)
if err != nil {
return errors.InternalError("密码加密失败", err)
}
userCode := generateUserCode()
for {
existing, err := s.userRepo.FindByUserCode(userCode)
if err != nil || existing == nil {
break
}
userCode = generateUserCode()
}
user := entity.NewUser(username, hashed, userCode, nickname)
if email != "" {
user.Email = email
}
return s.userRepo.Create(user)
}
func (s *AuthService) Login(loginName, password string) (string, *entity.User, error) {
var user *entity.User
var err error
if strings.Contains(loginName, "@") {
user, err = s.userRepo.FindByEmail(loginName)
} else {
user, err = s.userRepo.FindByUsername(loginName)
}
if err != nil {
return "", nil, err
}
if !crypto.CheckPasswordHash(password, user.Password) {
return "", nil, errors.Unauthorized("用户名或密码错误")
}
expiresIn := time.Duration(s.jwtExpires) * time.Hour
token, err := jwt.GenerateToken(user.ID, user.Username, user.UserCode, s.jwtSecret, expiresIn)
return token, user, err
}
func (s *AuthService) CheckStorageLimit(userCode string, fileSize int64) error {
user, err := s.userRepo.FindByUserCode(userCode)
if err != nil {
return err
}
if user.StorageUsed+fileSize > user.StorageLimit {
return errors.BadRequest("存储空间不足,当前已使用 " + formatSize(user.StorageUsed) + ",限制 " + formatSize(user.StorageLimit))
}
return nil
}
func (s *AuthService) AddStorageUsed(userCode string, fileSize int64) error {
return s.userRepo.UpdateStorageUsed(userCode, fileSize)
}
func (s *AuthService) SubtractStorageUsed(userCode string, fileSize int64) error {
return s.userRepo.UpdateStorageUsed(userCode, -fileSize)
}
func (s *AuthService) UpdateProfile(userCode, email, phone, nickname string) error {
return s.userRepo.UpdateProfile(userCode, email, phone, nickname)
}
func (s *AuthService) ChangePassword(userCode, currentPassword, newPassword string) error {
user, err := s.userRepo.FindByUserCode(userCode)
if err != nil {
return err
}
if !crypto.CheckPasswordHash(currentPassword, user.Password) {
return errors.Unauthorized("当前密码不正确")
}
hashed, err := crypto.HashPassword(newPassword)
if err != nil {
return errors.InternalError("密码加密失败", err)
}
return s.userRepo.UpdatePassword(userCode, hashed)
}
func (s *AuthService) GetUserInfo(userCode string) (*entity.User, error) {
return s.userRepo.FindByUserCode(userCode)
}
func formatSize(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
} else if bytes < 1024*1024 {
return fmt.Sprintf("%.2f KB", float64(bytes)/1024)
} else if bytes < 1024*1024*1024 {
return fmt.Sprintf("%.2f MB", float64(bytes)/(1024*1024))
}
return fmt.Sprintf("%.2f GB", float64(bytes)/(1024*1024*1024))
}
+40
View File
@@ -0,0 +1,40 @@
package captcha
import (
"bytes"
"cloudnest/internal/infrastructure/database"
"github.com/dchest/captcha"
)
type CaptchaService struct {
redis *database.RedisClient
}
func NewCaptchaService(redis *database.RedisClient) *CaptchaService {
captcha.SetCustomStore(captcha.NewMemoryStore(100, 5*60*1e9))
return &CaptchaService{redis: redis}
}
func (s *CaptchaService) Generate() (captchaID string, imageBytes []byte, err error) {
captchaID = captcha.New()
var buf bytes.Buffer
if err = captcha.WriteImage(&buf, captchaID, 240, 80); err != nil {
return "", nil, err
}
return captchaID, buf.Bytes(), nil
}
func (s *CaptchaService) GetImage(captchaID string) ([]byte, error) {
var buf bytes.Buffer
if err := captcha.WriteImage(&buf, captchaID, 240, 80); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (s *CaptchaService) Verify(captchaID, answer string) bool {
return captcha.VerifyString(captchaID, answer)
}
+144
View File
@@ -0,0 +1,144 @@
package email
import (
"context"
"crypto/tls"
"fmt"
"math/rand"
"net"
"net/smtp"
"time"
"cloudnest/internal/config"
"cloudnest/internal/infrastructure/database"
"cloudnest/internal/pkg/logger"
)
// EmailService provides email sending and verification code management.
// EmailService 提供邮件发送和验证码管理功能。
type EmailService struct {
cfg *config.EmailConfig
redis *database.RedisClient
}
// NewEmailService creates a new EmailService.
// NewEmailService 创建一个新的 EmailService。
func NewEmailService(cfg *config.EmailConfig, redis *database.RedisClient) *EmailService {
return &EmailService{cfg: cfg, redis: redis}
}
// SendVerifyCode generates a 6-digit verification code, sends it via email, and stores it in Redis.
// SendVerifyCode 生成6位数字验证码,通过邮件发送,并存入 Redis。
//
// Returns:
// - code: The generated verification code.
// - err: Any error that occurred.
func (s *EmailService) SendVerifyCode(toEmail string) (code string, err error) {
if s.cfg.SMTPUsername == "" {
return "", fmt.Errorf("邮件服务未配置")
}
code = generateCode(6)
subject := "CloudNest 邮箱验证码"
body := fmt.Sprintf("您的验证码是:%s,有效期10分钟,请勿泄露给他人。", code)
if err = s.sendMail(toEmail, subject, body); err != nil {
logger.Error("failed to send email", "error", err, "to", toEmail)
return "", err
}
ctx := context.Background()
key := fmt.Sprintf("email_code:%s", toEmail)
if err = s.redis.Client.Set(ctx, key, code, 10*time.Minute).Err(); err != nil {
return "", err
}
return code, nil
}
// VerifyCode checks the email verification code and deletes it from Redis on success.
// VerifyCode 检查邮箱验证码,成功时从 Redis 中删除。
func (s *EmailService) VerifyCode(email, code string) bool {
ctx := context.Background()
key := fmt.Sprintf("email_code:%s", email)
val, err := s.redis.Client.Get(ctx, key).Result()
if err != nil {
return false
}
if val != code {
return false
}
s.redis.Client.Del(ctx, key)
return true
}
func generateCode(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = byte(rand.Intn(10)) + '0'
}
return string(b)
}
func (s *EmailService) sendMail(to, subject, body string) error {
from := s.cfg.SMTPUsername
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, s.cfg.SMTPPort)
msg := []byte(fmt.Sprintf("To: %s\r\nFrom: %s <%s>\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
to, s.cfg.FromName, from, subject, body))
if s.cfg.SMTPPort == 587 {
return sendMailSTARTTLS(addr, s.cfg.SMTPHost, from, s.cfg.SMTPPassword, []string{to}, msg)
}
auth := smtp.PlainAuth("", from, s.cfg.SMTPPassword, s.cfg.SMTPHost)
return smtp.SendMail(addr, auth, from, []string{to}, msg)
}
func sendMailSTARTTLS(addr, host, from, password string, to []string, msg []byte) error {
conn, err := net.Dial("tcp", addr)
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Close()
tlsConfig := &tls.Config{ServerName: host}
if err := client.StartTLS(tlsConfig); err != nil {
return err
}
auth := smtp.PlainAuth("", from, password, host)
if err := client.Auth(auth); err != nil {
return err
}
if err := client.Mail(from); err != nil {
return err
}
for _, addr := range to {
if err := client.Rcpt(addr); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(msg)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
return client.Quit()
}
+152
View File
@@ -0,0 +1,152 @@
package file
// UploadResponse represents the response body for successful file upload.
// UploadResponse 表示成功上传文件的响应体。
//
// Fields:
// - Message: A success message.
// - File: The storage path of the uploaded file.
// - Size: The file size in bytes.
//
// 字段:
// - Message: 成功消息。
// - File: 上传文件的存储路径。
// - Size: 文件大小(字节)。
type UploadResponse struct {
Message string `json:"message"`
File string `json:"file"`
Size int64 `json:"size"`
}
// FileItem represents a single file in the listing.
// FileItem 表示列表中的单个文件。
//
// Fields:
// - Key: The storage key/path of the file.
// - Size: The file size in bytes.
// - LastModified: The timestamp when the file was last modified.
//
// 字段:
// - Key: 文件的存储键/路径。
// - Size: 文件大小(字节)。
// - LastModified: 文件最后修改的时间戳。
type FileItem struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified string `json:"last_modified"`
}
// ListResponse represents the response body for file listing.
// ListResponse 表示文件列表的响应体。
//
// Fields:
// - Files: A list of file items with metadata.
//
// 字段:
// - Files: 包含元数据的文件项列表。
type ListResponse struct {
Files []FileItem `json:"files"`
}
// DeleteResponse represents the response body for successful file deletion.
// DeleteResponse 表示成功删除文件的响应体。
//
// Fields:
// - Message: A success message.
//
// 字段:
// - Message: 成功消息。
type DeleteResponse struct {
Message string `json:"message"`
}
// FavoriteResponse represents the response body for favorite operations.
// FavoriteResponse 表示收藏操作的响应体。
//
// Fields:
// - Message: A success message.
// - FileKey: The file key that was favorited/unfavorited.
//
// 字段:
// - Message: 成功消息。
// - FileKey: 被收藏/取消收藏的文件键。
type FavoriteResponse struct {
Message string `json:"message"`
FileKey string `json:"file_key"`
}
// FavoriteListResponse represents the response body for favorite list.
// FavoriteListResponse 表示收藏列表的响应体。
//
// Fields:
// - Favorites: A list of favorite file information.
//
// 字段:
// - Favorites: 收藏文件信息列表。
type FavoriteListResponse struct {
Favorites []FavoriteItem `json:"favorites"`
}
// FavoriteItem represents a single favorite file.
// FavoriteItem 表示单个收藏文件。
//
// Fields:
// - FileKey: The storage key of the file.
// - FileName: The original filename.
// - CreatedAt: Timestamp when the file was favorited.
//
// 字段:
// - FileKey: 文件的存储键。
// - FileName: 原始文件名。
// - CreatedAt: 文件被收藏的时间戳。
type FavoriteItem struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
Size int64 `json:"size"`
CreatedAt string `json:"created_at"`
}
// RecycleBinResponse represents the response body for recycle bin list.
// RecycleBinResponse 表示回收站列表的响应体。
//
// Fields:
// - Files: A list of deleted file information.
//
// 字段:
// - Files: 已删除文件信息列表。
type RecycleBinResponse struct {
Files []DeletedFileItem `json:"files"`
}
// DeletedFileItem represents a single deleted file.
// DeletedFileItem 表示单个已删除文件。
//
// Fields:
// - FileKey: The storage key of the file.
// - FileName: The original filename.
// - Size: The file size in bytes.
// - DeletedAt: Timestamp when the file was deleted.
//
// 字段:
// - FileKey: 文件的存储键。
// - FileName: 原始文件名。
// - Size: 文件大小(字节)。
// - DeletedAt: 文件被删除的时间戳。
type DeletedFileItem struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
Size int64 `json:"size"`
DeletedAt string `json:"deleted_at"`
}
// PreviewResponse represents the response body for file preview info.
// PreviewResponse 表示文件预览信息的响应体。
//
// Fields:
// - FileType: The type of file (image, text, pdf, other).
//
// 字段:
// - FileType: 文件类型(image, text, pdf, other)。
type PreviewResponse struct {
FileType string `json:"file_type"`
}
+627
View File
@@ -0,0 +1,627 @@
package file
import (
"cloudnest/internal/pkg/errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"cloudnest/internal/domain/file/entity"
"cloudnest/internal/domain/file/repository"
infrastructureRepo "cloudnest/internal/infrastructure/repository"
)
// FileService provides file management operations.
// FileService 提供文件管理操作。
//
// This service implements the application use cases for file upload, download, list, and delete.
// It organizes files by username, creating user-specific directories in the object storage.
//
// 此服务实现了文件上传、下载、列表和删除的应用用例。
// 它按用户名组织文件,在对象存储中创建用户特定的目录。
type FileService struct {
fileRepo repository.FileRepository
fileMetaRepo *infrastructureRepo.FileMetaRepository
bucket string
}
// NewFileService creates a new FileService instance.
// NewFileService 创建一个新的 FileService 实例。
//
// Parameters:
// - fileRepo: The file repository for storage operations.
// - bucket: The default bucket name for file storage.
//
// Returns:
// - *FileService: A pointer to the new FileService instance.
//
// 参数:
// - fileRepo: 文件存储操作的仓储。
// - bucket: 文件存储的默认桶名称。
//
// 返回值:
// - *FileService: 新创建的 FileService 实例指针。
func NewFileService(fileRepo repository.FileRepository, fileMetaRepo *infrastructureRepo.FileMetaRepository, bucket string) *FileService {
return &FileService{
fileRepo: fileRepo,
fileMetaRepo: fileMetaRepo,
bucket: bucket,
}
}
// userObject generates the storage key for a user's file.
// userObject 为用户文件生成存储键。
//
// Parameters:
// - username: The user's username.
// - name: The filename.
//
// Returns:
// - string: The storage key in format "username/filename".
//
// 参数:
// - username: 用户的用户名。
// - name: 文件名。
//
// 返回值:
// - string: 存储键,格式为 "username/filename"。
func userObject(userCode, name string) string {
return fmt.Sprintf("%s/%s", userCode, name)
}
// Upload uploads a file to the user's directory.
// Upload 将文件上传到用户的目录。
//
// Parameters:
// - username: The owner of the file.
// - filename: The original filename.
// - reader: The file content reader.
// - size: The file size in bytes.
// - contentType: The MIME type of the file.
//
// Returns:
// - error: An error if the upload fails.
//
// 参数:
// - username: 文件的所有者。
// - filename: 原始文件名。
// - reader: 文件内容读取器。
// - size: 文件大小(字节)。
// - contentType: 文件的 MIME 类型。
//
// 返回值:
// - error: 如果上传失败则返回错误。
func (s *FileService) Upload(userCode, filename string, reader io.Reader, size int64, contentType string) error {
return s.fileRepo.Upload(s.bucket, userObject(userCode, filename), reader, size, contentType)
}
// List retrieves all files for a user.
// List 获取用户的所有文件。
//
// Parameters:
// - username: The user's username.
//
// Returns:
// - []string: A list of file keys belonging to the user.
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
//
// 返回值:
// - []string: 用户所属的文件键列表。
// - error: 如果操作失败则返回错误。
func (s *FileService) List(userCode string) ([]repository.FileInfo, error) {
fileInfos, err := s.fileRepo.List(s.bucket, userCode+"/")
if err != nil {
return nil, err
}
deletedKeys, err := s.fileMetaRepo.GetDeletedFileKeys(userCode)
if err != nil {
return nil, err
}
deletedSet := make(map[string]bool)
for _, key := range deletedKeys {
deletedSet[key] = true
}
var filtered []repository.FileInfo
for _, info := range fileInfos {
if !deletedSet[info.Key] {
filtered = append(filtered, info)
}
}
return filtered, nil
}
// PresignDownload generates a pre-signed URL for file download.
// PresignDownload 生成用于文件下载的预签名 URL。
//
// Parameters:
// - username: The file owner's username.
// - filename: The name of the file to download.
//
// Returns:
// - string: The pre-signed download URL.
// - error: An error if the operation fails.
//
// 参数:
// - username: 文件所有者的用户名。
// - filename: 要下载的文件名。
//
// 返回值:
// - string: 预签名下载 URL。
// - error: 如果操作失败则返回错误。
func (s *FileService) PresignDownload(userCode, filename string) (string, error) {
return s.fileRepo.PresignDownload(s.bucket, userObject(userCode, filename))
}
// GetFileStream retrieves a file as a readable stream for proxying to the client.
// GetFileStream 获取文件的可读流,用于代理给客户端。
//
// This method allows the Go backend to stream file content directly to the HTTP response,
// avoiding CORS issues with MinIO and keeping MinIO internal to the server.
//
// Parameters:
// - username: The file owner's username.
// - filename: The name of the file to stream.
//
// Returns:
// - io.ReadCloser: A readable stream of the file content. Caller must close it.
// - int64: The file size in bytes.
// - string: The Content-Type of the file.
// - error: An error if the operation fails.
//
// 此方法允许 Go 后端将文件内容直接流式传输到 HTTP 响应,
// 避免 MinIO 的 CORS 问题,并保持 MinIO 对服务器内部可见。
//
// 参数:
// - username: 文件所有者的用户名。
// - filename: 要流式传输的文件名。
//
// 返回值:
// - io.ReadCloser: 文件内容的可读流。调用方必须关闭它。
// - int64: 文件大小(字节)。
// - string: 文件的 Content-Type。
// - error: 如果操作失败则返回错误。
func (s *FileService) GetFileStream(userCode, filename string) (io.ReadCloser, int64, string, error) {
return s.fileRepo.GetObject(s.bucket, userObject(userCode, filename))
}
// Delete removes a file from the user's directory.
// Delete 从用户的目录中删除文件。
//
// Parameters:
// - username: The file owner's username.
// - filename: The name of the file to delete.
//
// Returns:
// - error: An error if the deletion fails.
//
// 参数:
// - username: 文件所有者的用户名。
// - filename: 要删除的文件名。
//
// 返回值:
// - error: 如果删除失败则返回错误。
func (s *FileService) Delete(userCode, filename string) error {
return s.fileRepo.Delete(s.bucket, userObject(userCode, filename))
}
// SoftDelete moves a file to the recycle bin instead of permanent deletion.
// SoftDelete 将文件移动到回收站而不是永久删除。
//
// Parameters:
// - userID: The user's ID.
// - username: The user's username.
// - filename: The name of the file to delete.
// - size: The file size in bytes.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - userID: 用户的 ID。
// - username: 用户的用户名。
// - filename: 要删除的文件名。
// - size: 文件大小(字节)。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (s *FileService) SoftDelete(userID uint, userCode, filename string, _ int64) error {
fileKey := userObject(userCode, filename)
size, _, err := s.fileRepo.GetObjectInfo(s.bucket, fileKey)
if err != nil {
return errors.InternalError("获取文件信息失败", err)
}
s.fileMetaRepo.RemoveFavorite(userCode, fileKey)
deletedFile := entity.NewDeletedFile(userID, userCode, fileKey, filename, size)
return s.fileMetaRepo.AddToRecycleBin(deletedFile)
}
// Favorite adds a file to user's favorites.
// Favorite 将文件添加到用户的收藏夹。
//
// Parameters:
// - userID: The user's ID.
// - username: The user's username.
// - filename: The name of the file to favorite.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - userID: 用户的 ID。
// - username: 用户的用户名。
// - filename: 要收藏的文件名。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (s *FileService) Favorite(userID uint, userCode, filename string) error {
fileKey := userObject(userCode, filename)
size, _, err := s.fileRepo.GetObjectInfo(s.bucket, fileKey)
if err != nil {
return errors.InternalError("获取文件信息失败", err)
}
favorite := entity.NewFavorite(userID, userCode, fileKey, size)
return s.fileMetaRepo.AddFavorite(favorite)
}
// Unfavorite removes a file from user's favorites.
// Unfavorite 从用户的收藏夹中移除文件。
//
// Parameters:
// - username: The user's username.
// - filename: The name of the file to unfavorite.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
// - filename: 要取消收藏的文件名。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (s *FileService) Unfavorite(userCode, filename string) error {
fileKey := userObject(userCode, filename)
return s.fileMetaRepo.RemoveFavorite(userCode, fileKey)
}
// GetFavorites retrieves all favorites for a user.
// GetFavorites 获取用户的所有收藏。
//
// Parameters:
// - username: The user's username.
//
// Returns:
// - []entity.Favorite: A list of Favorite entities.
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
//
// 返回值:
// - []entity.Favorite: Favorite 实体列表。
// - error: 如果操作失败则返回错误。
func (s *FileService) GetFavorites(userCode string) ([]entity.Favorite, error) {
favorites, err := s.fileMetaRepo.GetFavorites(userCode)
if err != nil {
return nil, err
}
deletedKeys, err := s.fileMetaRepo.GetDeletedFileKeys(userCode)
if err != nil {
return nil, err
}
deletedSet := make(map[string]bool)
for _, key := range deletedKeys {
deletedSet[key] = true
}
var filtered []entity.Favorite
for _, f := range favorites {
if !deletedSet[f.FileKey] {
filtered = append(filtered, f)
}
}
return filtered, nil
}
// GetRecycleBin retrieves all deleted files for a user.
// GetRecycleBin 获取用户的所有已删除文件。
//
// Parameters:
// - username: The user's username.
//
// Returns:
// - []entity.DeletedFile: A list of DeletedFile entities.
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
//
// 返回值:
// - []entity.DeletedFile: DeletedFile 实体列表。
// - error: 如果操作失败则返回错误。
func (s *FileService) GetRecycleBin(userCode string) ([]entity.DeletedFile, error) {
deletedFiles, err := s.fileMetaRepo.GetRecycleBin(userCode)
if err != nil {
return nil, err
}
for i := range deletedFiles {
if deletedFiles[i].Size == 0 {
size, _, err := s.fileRepo.GetObjectInfo(s.bucket, deletedFiles[i].FileKey)
if err == nil {
deletedFiles[i].Size = size
}
}
}
return deletedFiles, nil
}
// RestoreFromRecycleBin restores a file from the recycle bin.
// RestoreFromRecycleBin 从回收站恢复文件。
//
// Parameters:
// - username: The user's username.
// - filename: The name of the file to restore.
//
// Returns:
// - *entity.DeletedFile: The restored DeletedFile entity.
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
// - filename: 要恢复的文件名。
//
// 返回值:
// - *entity.DeletedFile: 恢复的 DeletedFile 实体。
// - error: 如果操作失败则返回错误。
func (s *FileService) RestoreFromRecycleBin(userCode, filename string) (*entity.DeletedFile, error) {
fileKey := userObject(userCode, filename)
return s.fileMetaRepo.RestoreFromRecycleBin(userCode, fileKey)
}
// DeleteFromRecycleBin permanently deletes a file from the recycle bin.
// DeleteFromRecycleBin 从回收站永久删除文件。
//
// Parameters:
// - username: The user's username.
// - filename: The name of the file to permanently delete.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
// - filename: 要永久删除的文件名。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (s *FileService) DeleteFromRecycleBin(userCode, filename string) error {
fileKey := userObject(userCode, filename)
if err := s.fileMetaRepo.DeleteFromRecycleBin(userCode, fileKey); err != nil {
return err
}
return s.fileRepo.Delete(s.bucket, fileKey)
}
// PresignPreview generates a pre-signed URL for file preview.
// PresignPreview 生成用于文件预览的预签名 URL。
//
// Parameters:
// - username: The file owner's username.
// - filename: The name of the file to preview.
//
// Returns:
// - string: The pre-signed preview URL.
// - error: An error if the operation fails.
//
// 参数:
// - username: 文件所有者的用户名。
// - filename: 要预览的文件名。
//
// 返回值:
// - string: 预签名预览 URL。
// - error: 如果操作失败则返回错误。
func (s *FileService) PresignPreview(userCode, filename string) (string, error) {
return s.fileRepo.PresignDownload(s.bucket, userObject(userCode, filename))
}
// GetFileExtension returns the file extension without the dot.
// GetFileExtension 返回文件扩展名(不含点)。
//
// Parameters:
// - filename: The filename.
//
// Returns:
// - string: The file extension in lowercase.
//
// 参数:
// - filename: 文件名。
//
// 返回值:
// - string: 小写的文件扩展名。
func (s *FileService) GetFileExtension(filename string) string {
idx := strings.LastIndex(filename, ".")
if idx == -1 {
return ""
}
return strings.ToLower(filename[idx+1:])
}
// IsImageFile checks if the file is an image based on its extension.
// IsImageFile 根据文件扩展名检查是否为图片文件。
//
// Parameters:
// - filename: The filename.
//
// Returns:
// - bool: True if the file is an image.
//
// 参数:
// - filename: 文件名。
//
// 返回值:
// - bool: 如果是图片文件则返回 true。
func (s *FileService) IsImageFile(filename string) bool {
ext := s.GetFileExtension(filename)
imageExts := map[string]bool{
"jpg": true,
"jpeg": true,
"png": true,
"gif": true,
"bmp": true,
"webp": true,
"svg": true,
}
return imageExts[ext]
}
// IsTextFile checks if the file is a text file based on its extension.
// IsTextFile 根据文件扩展名检查是否为文本文件。
//
// Parameters:
// - filename: The filename.
//
// Returns:
// - bool: True if the file is a text file.
//
// 参数:
// - filename: 文件名。
//
// 返回值:
// - bool: 如果是文本文件则返回 true。
func (s *FileService) IsTextFile(filename string) bool {
ext := s.GetFileExtension(filename)
textExts := map[string]bool{
"txt": true,
"md": true,
"json": true,
"xml": true,
"html": true,
"css": true,
"js": true,
"go": true,
"py": true,
"java": true,
"cpp": true,
"c": true,
}
return textExts[ext]
}
// IsPDFFile checks if the file is a PDF file based on its extension.
// IsPDFFile 根据文件扩展名检查是否为 PDF 文件。
//
// Parameters:
// - filename: The filename.
//
// Returns:
// - bool: True if the file is a PDF.
//
// 参数:
// - filename: 文件名。
//
// 返回值:
// - bool: 如果是 PDF 文件则返回 true。
func (s *FileService) IsPDFFile(filename string) bool {
ext := s.GetFileExtension(filename)
return ext == "pdf"
}
func (s *FileService) GetChunkDir() string {
return filepath.Join(os.TempDir(), "cloudnest_chunks")
}
func (s *FileService) SaveChunk(username, hash string, chunkIndex int, data []byte) error {
chunkDir := filepath.Join(s.GetChunkDir(), username, hash)
if err := os.MkdirAll(chunkDir, 0755); err != nil {
return errors.InternalError("创建分片目录失败", err)
}
chunkPath := filepath.Join(chunkDir, strconv.Itoa(chunkIndex))
if err := os.WriteFile(chunkPath, data, 0644); err != nil {
return errors.InternalError("保存分片失败", err)
}
return nil
}
func (s *FileService) GetUploadedChunks(username, hash string) ([]int, error) {
chunkDir := filepath.Join(s.GetChunkDir(), username, hash)
entries, err := os.ReadDir(chunkDir)
if err != nil {
if os.IsNotExist(err) {
return []int{}, nil
}
return nil, errors.InternalError("读取分片目录失败", err)
}
var chunks []int
for _, entry := range entries {
if !entry.IsDir() {
idx, err := strconv.Atoi(entry.Name())
if err == nil {
chunks = append(chunks, idx)
}
}
}
return chunks, nil
}
func (s *FileService) MergeChunks(username, hash, filename string, chunkCount int, fileSize int64) error {
chunkDir := filepath.Join(s.GetChunkDir(), username, hash)
destPath := filepath.Join(chunkDir, "merged_"+filename)
destFile, err := os.Create(destPath)
if err != nil {
return errors.InternalError("创建合并文件失败", err)
}
defer destFile.Close()
for i := 0; i < chunkCount; i++ {
chunkPath := filepath.Join(chunkDir, strconv.Itoa(i))
chunkData, err := os.ReadFile(chunkPath)
if err != nil {
return errors.InternalError("读取分片失败", err)
}
if _, err := destFile.Write(chunkData); err != nil {
return errors.InternalError("写入合并文件失败", err)
}
}
file, err := os.Open(destPath)
if err != nil {
return errors.InternalError("打开合并文件失败", err)
}
defer file.Close()
contentType := "application/octet-stream"
if err := s.Upload(username, filename, file, fileSize, contentType); err != nil {
return err
}
os.RemoveAll(chunkDir)
return nil
}
+250
View File
@@ -0,0 +1,250 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"cloudnest/internal/config"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
// Server encapsulates HTTP server lifecycle management with graceful start/restart/shutdown.
// Server 封装了 HTTP 服务器的生命周期管理,支持优雅启动/重启/关闭。
//
// Features:
// 1. Graceful startup with port auto-detection in dev mode
// 2. Graceful shutdown on SIGINT/SIGTERM signals
// 3. Hot reload on SIGHUP signal (Linux/Mac only) - re-reads config
// 4. Waits for in-flight requests to complete before shutdown
//
// 特性:
// 1. 开发模式下优雅启动,自动检测可用端口
// 2. 收到 SIGINT/SIGTERM 信号时优雅关闭
// 3. 收到 SIGHUP 信号时热重载(仅 Linux/Mac),重新读取配置
// 4. 关闭前等待正在进行的请求完成
type Server struct {
cfg *config.Config
engine *gin.Engine
srv *http.Server
mu sync.RWMutex
reloadChan chan os.Signal
shutdownOnce sync.Once
}
// NewServer creates a new Server instance with the given config and engine.
// NewServer 使用给定的配置和引擎创建新的 Server 实例。
//
// Parameters:
// - cfg: The application configuration.
// - engine: The Gin engine to serve.
//
// Returns:
// - *Server: A new Server instance.
//
// 参数:
// - cfg: 应用程序配置。
// - engine: 要服务的 Gin 引擎。
//
// 返回值:
// - *Server: 新的 Server 实例。
func NewServer(cfg *config.Config, engine *gin.Engine) *Server {
return &Server{
cfg: cfg,
engine: engine,
reloadChan: make(chan os.Signal, 1),
}
}
// Run starts the server and blocks until a shutdown signal is received.
// Run 启动服务器并阻塞,直到收到关闭信号。
//
// Lifecycle:
// 1. Auto-detect available port (dev mode only)
// 2. Start listening in a goroutine
// 3. Register signal handlers (SIGINT, SIGTERM for shutdown; SIGHUP for reload on Unix)
// 4. Block until shutdown signal
// 5. Gracefully shut down with timeout
//
// 生命周期:
// 1. 自动检测可用端口(仅开发模式)
// 2. 在 goroutine 中开始监听
// 3. 注册信号处理器(SIGINT、SIGTERM 用于关闭;SIGHUP 用于 Unix 上的重载)
// 4. 阻塞直到收到关闭信号
// 5. 使用超时优雅关闭
//
// Returns:
// - error: An error if startup or shutdown fails.
//
// 返回值:
// - error: 如果启动或关闭失败则返回错误。
func (s *Server) Run() error {
port := s.resolvePort()
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, port)
s.mu.Lock()
s.srv = &http.Server{
Addr: addr,
Handler: s.engine,
}
s.mu.Unlock()
// Register signal handlers / 注册信号处理器
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Register SIGHUP for hot reload (Unix only)
// 注册 SIGHUP 用于热重载(仅 Unix
if isUnix() {
signal.Notify(s.reloadChan, syscall.SIGHUP)
}
// Start server in a goroutine / 在 goroutine 中启动服务器
serverErr := make(chan error, 1)
go func() {
logger.Info("server listening", "address", addr, "env", s.cfg.App.Env)
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
close(serverErr)
}()
// Wait for signal / 等待信号
select {
case sig := <-sigChan:
logger.Info("shutdown signal received", "signal", sig.String())
return s.Shutdown()
case sig := <-s.reloadChan:
logger.Info("reload signal received, restarting", "signal", sig.String())
if err := s.Shutdown(); err != nil {
return err
}
// After shutdown, the caller should restart the process
// 关闭后,调用方应重启进程
return ErrReloadRequested
case err := <-serverErr:
if err != nil {
logger.Error("server failed", "error", err)
return err
}
return nil
}
}
// Shutdown gracefully shuts down the server with a timeout.
// Shutdown 使用超时优雅关闭服务器。
//
// Parameters:
// - timeout: The maximum time to wait for in-flight requests to complete.
//
// Returns:
// - error: An error if shutdown fails.
//
// Note:
// - Safe to call multiple times (subsequent calls are no-ops).
//
// 参数:
// - timeout: 等待正在进行的请求完成的最长时间。
//
// 返回值:
// - error: 如果关闭失败则返回错误。
//
// 注意:
// - 可以安全地多次调用(后续调用是空操作)。
func (s *Server) Shutdown() error {
var shutdownErr error
s.shutdownOnce.Do(func() {
s.mu.RLock()
srv := s.srv
s.mu.RUnlock()
if srv == nil {
return
}
timeout := time.Duration(s.cfg.Server.GracefulShutdownTimeout) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
logger.Info("graceful shutdown started", "timeout_seconds", timeout)
if err := srv.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed, forcing close", "error", err)
if closeErr := srv.Close(); closeErr != nil {
shutdownErr = fmt.Errorf("forced close failed: %w (original: %v)", closeErr, err)
return
}
shutdownErr = err
}
logger.Info("server exited gracefully")
})
return shutdownErr
}
// resolvePort finds an available port starting from the configured port.
// resolvePort 从配置的端口开始查找可用端口。
//
// In dev mode: tries configured port, then port+1, port+2, ..., up to +9.
// In prod mode: uses the configured port directly (no auto-switch).
//
// 开发模式:尝试配置端口,然后 port+1, port+2, ..., 最多 +9。
// 生产模式:直接使用配置的端口(不自动切换)。
//
// Returns:
// - int: The actual port to use.
//
// 返回值:
// - int: 实际使用的端口。
func (s *Server) resolvePort() int {
port := s.cfg.Server.Port
if !s.cfg.IsDev() {
return port
}
availablePort, err := config.FindAvailablePort(port, 10)
if err != nil {
logger.Fatal("no available port found", "preferred", port, "error", err)
}
if availablePort != port {
logger.Info("port auto-switched", "from", port, "to", availablePort)
}
return availablePort
}
// Addr returns the actual server address (host:port) after resolvePort has been called.
// Addr 返回调用 resolvePort 后的实际服务器地址(host:port)。
//
// Returns:
// - string: The server address, or empty string if server hasn't started.
//
// 返回值:
// - string: 服务器地址,如果服务器尚未启动则返回空字符串。
func (s *Server) Addr() string {
s.mu.RLock()
defer s.mu.RUnlock()
if s.srv == nil {
return ""
}
return s.srv.Addr
}
// isUnix returns true if the current OS is Unix-like (Linux, macOS, BSD, etc.).
// isUnix 如果当前操作系统是类 Unix 系统(Linux、macOS、BSD 等)则返回 true。
func isUnix() bool {
return os.PathSeparator == '/'
}
// ErrReloadRequested is returned by Run() when a SIGHUP signal requests a reload.
// ErrReloadRequested 当 SIGHUP 信号请求重载时由 Run() 返回。
//
// The caller should typically exit with a non-zero code so the process supervisor
// (systemd, Docker, k8s) can restart the application with the new config.
//
// 调用方通常应以非零退出码退出,以便进程管理器(systemd、Docker、k8s)可以使用新配置重启应用程序。
var ErrReloadRequested = errors.New("reload requested")
+454
View File
@@ -0,0 +1,454 @@
package config
import (
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"cloudnest/internal/pkg/logger"
"github.com/spf13/viper"
)
// Config holds all application configuration.
// Config 包含所有应用程序配置。
//
// Configuration loading order (later overrides earlier):
// 1. config.yaml (default values)
// 2. config.{env}.yaml (environment-specific values)
// 3. Environment variables (highest priority, auto-mapped)
//
// 配置加载顺序(后面的覆盖前面的):
// 1. config.yaml(默认值)
// 2. config.{env}.yaml(环境特定值)
// 3. 环境变量(最高优先级,自动映射)
type Config struct {
App AppConfig `mapstructure:"app"`
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Cache CacheConfig `mapstructure:"cache"`
Storage StorageConfig `mapstructure:"storage"`
JWT JWTConfig `mapstructure:"jwt"`
Email EmailConfig `mapstructure:"email"`
}
// AppConfig holds application-level configuration.
// AppConfig 包含应用程序级别的配置。
type AppConfig struct {
Name string `mapstructure:"name"`
Version string `mapstructure:"version"`
Env string `mapstructure:"env"`
LogLevel string `mapstructure:"log_level"`
FileServiceURL string `mapstructure:"file_service_url"`
}
// ServerConfig holds HTTP server configuration.
// ServerConfig 包含 HTTP 服务器配置。
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
}
// DatabaseConfig holds database connection configuration.
// DatabaseConfig 包含数据库连接配置。
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
Charset string `mapstructure:"charset"`
ParseTime bool `mapstructure:"parse_time"`
Loc string `mapstructure:"loc"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
}
// CacheConfig holds Redis cache configuration.
// CacheConfig 包含 Redis 缓存配置。
type CacheConfig struct {
Driver string `mapstructure:"driver"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConns int `mapstructure:"min_idle_conns"`
}
// StorageConfig holds MinIO object storage configuration.
// StorageConfig 包含 MinIO 对象存储配置。
type StorageConfig struct {
Driver string `mapstructure:"driver"`
Endpoint string `mapstructure:"endpoint"`
AccessKeyID string `mapstructure:"access_key_id"`
SecretAccessKey string `mapstructure:"secret_access_key"`
BucketName string `mapstructure:"bucket_name"`
UseSSL bool `mapstructure:"use_ssl"`
PresignDuration int `mapstructure:"presign_duration"`
MaxFileSize int `mapstructure:"max_file_size"`
RecycleBinExpireDays int `mapstructure:"recycle_bin_expire_days"`
}
// JWTConfig holds JWT authentication configuration.
// JWTConfig 包含 JWT 认证配置。
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpiresIn int `mapstructure:"expires_in"`
Issuer string `mapstructure:"issuer"`
}
// EmailConfig holds SMTP email configuration.
// EmailConfig 包含 SMTP 邮件配置。
type EmailConfig struct {
SMTPHost string `mapstructure:"smtp_host"`
SMTPPort int `mapstructure:"smtp_port"`
SMTPUsername string `mapstructure:"smtp_username"`
SMTPPassword string `mapstructure:"smtp_password"`
FromName string `mapstructure:"from_name"`
}
// DSN builds MySQL DSN string from config fields.
// DSN 从配置字段构建 MySQL DSN 字符串。
//
// Returns:
// - string: The formatted DSN string.
//
// 返回值:
// - string: 格式化后的 DSN 字符串。
func (c *DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=%t&loc=%s",
c.User,
c.Password,
c.Host,
c.Port,
c.DBName,
c.Charset,
c.ParseTime,
c.Loc,
)
}
// Addr builds Redis connection address.
// Addr 构建 Redis 连接地址。
//
// Returns:
// - string: The formatted "host:port" address.
//
// 返回值:
// - string: 格式化的 "host:port" 地址。
func (c *CacheConfig) Addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
// PresignDuration builds time.Duration from hours config.
// PresignDuration 从小时配置构建 time.Duration。
//
// Returns:
// - time.Duration: The presign duration.
//
// 返回值:
// - time.Duration: 预签名有效期。
func (c *StorageConfig) PresignDurationTime() time.Duration {
return time.Duration(c.PresignDuration) * time.Hour
}
// ExpiresIn builds time.Duration from hours config.
// ExpiresIn 从小时配置构建 time.Duration。
//
// Returns:
// - time.Duration: The JWT expiration duration.
//
// 返回值:
// - time.Duration: JWT 过期时间。
func (c *JWTConfig) ExpiresInDuration() time.Duration {
return time.Duration(c.ExpiresIn) * time.Hour
}
// getEnv returns the current environment name from CLOUDNEST_ENV or defaults to "dev".
// getEnv 从 CLOUDNEST_ENV 获取当前环境名称,默认为 "dev"。
func getEnv() string {
env := strings.ToLower(os.Getenv("CLOUDNEST_ENV"))
if env == "" {
env = "dev"
}
return env
}
// getConfigDir returns the absolute path to the configs directory.
// getConfigDir 返回 configs 目录的绝对路径。
//
// Note:
// - Tries to find configs/ relative to the executable or current working directory.
// - Falls back to current working directory if not found.
//
// 注意:
// - 尝试从可执行文件或当前工作目录相对位置找到 configs/。
// - 如果找不到则回退到当前工作目录。
func getConfigDir() string {
// Try to get the directory of the current file
// 尝试获取当前文件的目录
_, filename, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Join(filepath.Dir(filename), "..", "..", "configs")
if _, err := os.Stat(dir); err == nil {
return dir
}
}
// Try executable directory / 尝试可执行文件目录
ex, err := os.Executable()
if err == nil {
dir := filepath.Join(filepath.Dir(ex), "configs")
if _, err := os.Stat(dir); err == nil {
return dir
}
}
// Try current working directory / 尝试当前工作目录
cwd, err := os.Getwd()
if err == nil {
dir := filepath.Join(cwd, "configs")
if _, err := os.Stat(dir); err == nil {
return dir
}
}
return "configs"
}
// Load reads and parses configuration from YAML files and environment variables.
// Load 从 YAML 文件和环境变量中读取并解析配置。
//
// Loading order (later overrides earlier):
// 1. configs/config.yaml (base defaults)
// 2. configs/config.{env}.yaml (env-specific overrides)
// 3. Environment variables (highest priority)
//
// Environment selection via CLOUDNEST_ENV:
// - "dev" -> configs/config.dev.yaml
// - "test" -> configs/config.test.yaml
// - "prod" -> configs/config.prod.yaml
// - default -> configs/config.dev.yaml
//
// Environment variables are automatically mapped with the following prefixes:
// - APP_ -> app
// - SERVER_ -> server
// - DB_ -> database
// - REDIS_ -> cache
// - MINIO_ -> storage
// - JWT_ -> jwt
// - EMAIL_ -> email
//
// Returns:
// - *Config: A pointer to the parsed configuration struct.
//
// 配置加载顺序(后面的覆盖前面的):
// 1. configs/config.yaml(基础默认值)
// 2. configs/config.{env}.yaml(环境特定覆盖)
// 3. 环境变量(最高优先级)
//
// 通过 CLOUDNEST_ENV 选择环境:
// - "dev" -> configs/config.dev.yaml
// - "test" -> configs/config.test.yaml
// - "prod" -> configs/config.prod.yaml
// - 默认 -> configs/config.dev.yaml
//
// 环境变量自动映射的前缀:
// - APP_ -> app
// - SERVER_ -> server
// - DB_ -> database
// - REDIS_ -> cache
// - MINIO_ -> storage
// - JWT_ -> jwt
// - EMAIL_ -> email
//
// 返回值:
// - *Config: 解析后的配置结构体指针。
func Load() *Config {
env := getEnv()
configDir := getConfigDir()
v := viper.New()
v.SetConfigType("yaml")
// 1. Load base config / 加载基础配置
baseConfig := filepath.Join(configDir, "config.yaml")
v.SetConfigFile(baseConfig)
if err := v.ReadInConfig(); err != nil {
logger.Warn("failed to read base config, using defaults", "error", err, "path", baseConfig)
} else {
logger.Info("base config loaded", "path", baseConfig)
}
// 2. Load environment-specific config / 加载环境特定配置
envConfig := filepath.Join(configDir, fmt.Sprintf("config.%s.yaml", env))
if _, err := os.Stat(envConfig); err == nil {
envViper := viper.New()
envViper.SetConfigFile(envConfig)
if err := envViper.ReadInConfig(); err == nil {
// Merge environment config into base config
// 将环境配置合并到基础配置中
for _, key := range envViper.AllKeys() {
v.Set(key, envViper.Get(key))
}
logger.Info("env config loaded", "env", env, "path", envConfig)
} else {
logger.Warn("failed to read env config", "env", env, "error", err)
}
} else {
logger.Info("env config not found, using base config", "env", env, "path", envConfig)
}
// 3. Enable environment variable overrides / 启用环境变量覆盖
v.AutomaticEnv()
v.SetEnvPrefix("")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// Map environment variables to nested config keys
// 将环境变量映射到嵌套配置键
envMappings := map[string]string{
"APP_NAME": "app.name",
"APP_VERSION": "app.version",
"APP_ENV": "app.env",
"APP_LOG_LEVEL": "app.log_level",
"APP_FILE_SERVICE_URL": "app.file_service_url",
"SERVER_HOST": "server.host",
"SERVER_PORT": "server.port",
"SERVER_GRACEFUL_SHUTDOWN_TIMEOUT": "server.graceful_shutdown_timeout",
"DB_DRIVER": "database.driver",
"DB_HOST": "database.host",
"DB_PORT": "database.port",
"DB_USER": "database.user",
"DB_PASSWORD": "database.password",
"DB_NAME": "database.dbname",
"DB_CHARSET": "database.charset",
"DB_PARSE_TIME": "database.parse_time",
"DB_LOC": "database.loc",
"DB_MAX_IDLE_CONNS": "database.max_idle_conns",
"DB_MAX_OPEN_CONNS": "database.max_open_conns",
"DB_CONN_MAX_LIFETIME": "database.conn_max_lifetime",
"REDIS_HOST": "cache.host",
"REDIS_PORT": "cache.port",
"REDIS_PASSWORD": "cache.password",
"REDIS_DB": "cache.db",
"REDIS_POOL_SIZE": "cache.pool_size",
"REDIS_MIN_IDLE_CONNS": "cache.min_idle_conns",
"MINIO_ENDPOINT": "storage.endpoint",
"MINIO_ACCESS_KEY": "storage.access_key_id",
"MINIO_SECRET_KEY": "storage.secret_access_key",
"MINIO_BUCKET": "storage.bucket_name",
"MINIO_USE_SSL": "storage.use_ssl",
"MINIO_PRESIGN_DURATION": "storage.presign_duration",
"MINIO_MAX_FILE_SIZE": "storage.max_file_size",
"MINIO_RECYCLE_BIN_EXPIRE_DAYS": "storage.recycle_bin_expire_days",
"JWT_SECRET": "jwt.secret",
"JWT_EXPIRES_IN": "jwt.expires_in",
"JWT_ISSUER": "jwt.issuer",
"EMAIL_SMTP_HOST": "email.smtp_host",
"EMAIL_SMTP_PORT": "email.smtp_port",
"EMAIL_SMTP_USERNAME": "email.smtp_username",
"EMAIL_SMTP_PASSWORD": "email.smtp_password",
"EMAIL_FROM_NAME": "email.from_name",
}
for envKey, configKey := range envMappings {
if val := os.Getenv(envKey); val != "" {
// Try to parse as int/bool first, fallback to string
// 先尝试解析为 int/bool,否则回退为 string
if intVal, err := strconv.Atoi(val); err == nil {
v.Set(configKey, intVal)
} else if boolVal, err := strconv.ParseBool(val); err == nil {
v.Set(configKey, boolVal)
} else {
v.Set(configKey, val)
}
}
}
// 4. Unmarshal into struct / 反序列化到结构体
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
logger.Fatal("failed to unmarshal config", "error", err)
}
logger.Info("config loaded",
"app", fmt.Sprintf("%s:%s", cfg.App.Name, cfg.App.Version),
"env", cfg.App.Env,
"config_dir", configDir,
)
return &cfg
}
// IsDev returns true if running in development environment.
// IsDev 如果运行在开发环境则返回 true。
func (c *Config) IsDev() bool {
return strings.ToLower(c.App.Env) == "development" || strings.ToLower(c.App.Env) == "dev"
}
// IsProd returns true if running in production environment.
// IsProd 如果运行在生产环境则返回 true。
func (c *Config) IsProd() bool {
return strings.ToLower(c.App.Env) == "production" || strings.ToLower(c.App.Env) == "prod"
}
// IsTest returns true if running in test environment.
// IsTest 如果运行在测试环境则返回 true。
func (c *Config) IsTest() bool {
return strings.ToLower(c.App.Env) == "test"
}
// FindAvailablePort checks if the configured port is available, returns next available if occupied.
// FindAvailablePort 检查配置的端口是否可用,如果被占用则返回下一个可用端口。
//
// Parameters:
// - preferredPort: The preferred port to use.
// - maxAttempts: Maximum number of ports to try.
//
// Returns:
// - int: An available port number.
// - error: An error if no port is available after max attempts.
//
// Note:
// - Only searches in development mode. In production, it returns the configured port directly.
// - Search range: preferredPort to preferredPort + maxAttempts - 1.
//
// 参数:
// - preferredPort: 首选端口。
// - maxAttempts: 最大尝试次数。
//
// 返回值:
// - int: 可用端口号。
// - error: 如果超过最大尝试次数仍未找到可用端口则返回错误。
//
// 注意:
// - 仅在开发模式下搜索。生产模式下直接返回配置端口。
// - 搜索范围: preferredPort 到 preferredPort + maxAttempts - 1。
func FindAvailablePort(preferredPort int, maxAttempts int) (int, error) {
for i := 0; i < maxAttempts; i++ {
port := preferredPort + i
addr := fmt.Sprintf(":%d", port)
listener, err := net.Listen("tcp", addr)
if err != nil {
logger.Warn("port occupied, trying next", "port", port, "error", err)
continue
}
listener.Close()
return port, nil
}
return 0, fmt.Errorf("no available port found in range %d-%d", preferredPort, preferredPort+maxAttempts-1)
}
+209
View File
@@ -0,0 +1,209 @@
package di
import (
"net/http"
"time"
"cloudnest/internal/application/auth"
"cloudnest/internal/application/captcha"
"cloudnest/internal/application/email"
"cloudnest/internal/application/file"
"cloudnest/internal/config"
"cloudnest/internal/domain/auth/repository"
fileRepo "cloudnest/internal/domain/file/repository"
"cloudnest/internal/infrastructure/database"
infrastructureRepo "cloudnest/internal/infrastructure/repository"
"cloudnest/internal/infrastructure/storage"
"cloudnest/internal/interface/http/handler"
"cloudnest/internal/interface/http/routes"
"cloudnest/internal/middleware"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
"go.uber.org/dig"
)
type Container struct {
*dig.Container
}
func New() *Container {
c := dig.New()
c.Provide(func() *config.Config {
return config.Load()
})
c.Provide(func(cfg *config.Config) (*database.MySQLClient, error) {
return database.NewMySQLClient(cfg.Database.DSN())
})
c.Provide(func(cfg *config.Config) (*database.RedisClient, error) {
return database.NewRedisClient(cfg.Cache.Addr(), cfg.Cache.Password, cfg.Cache.DB)
})
c.Provide(func(cfg *config.Config) (*storage.MinioClient, error) {
return storage.NewMinioClient(
cfg.Storage.Endpoint,
cfg.Storage.AccessKeyID,
cfg.Storage.SecretAccessKey,
cfg.Storage.UseSSL,
)
})
c.Provide(func(mysql *database.MySQLClient) repository.UserRepository {
return infrastructureRepo.NewUserRepository(mysql)
})
c.Provide(func(minio *storage.MinioClient) fileRepo.FileRepository {
return infrastructureRepo.NewFileRepository(minio)
})
c.Provide(func(mysql *database.MySQLClient) *infrastructureRepo.FileMetaRepository {
return infrastructureRepo.NewFileMetaRepository(mysql)
})
c.Provide(func(userRepo repository.UserRepository, emailService *email.EmailService, cfg *config.Config) *auth.AuthService {
return auth.NewAuthService(userRepo, emailService, cfg.JWT.Secret, cfg.JWT.ExpiresIn)
})
c.Provide(func(fRepo fileRepo.FileRepository, fMetaRepo *infrastructureRepo.FileMetaRepository, cfg *config.Config) *file.FileService {
return file.NewFileService(fRepo, fMetaRepo, cfg.Storage.BucketName)
})
c.Provide(func(redis *database.RedisClient) *captcha.CaptchaService {
return captcha.NewCaptchaService(redis)
})
c.Provide(func(cfg *config.Config, redis *database.RedisClient) *email.EmailService {
return email.NewEmailService(&cfg.Email, redis)
})
c.Provide(func(service *auth.AuthService, captchaService *captcha.CaptchaService, emailService *email.EmailService) *handler.AuthHandler {
return handler.NewAuthHandler(service, captchaService, emailService)
})
c.Provide(func(emailService *email.EmailService, captchaService *captcha.CaptchaService) *handler.EmailHandler {
return handler.NewEmailHandler(emailService, captchaService)
})
c.Provide(func(service *file.FileService, authService *auth.AuthService) *handler.FileHandler {
return handler.NewFileHandler(service, authService)
})
c.Provide(func(service *captcha.CaptchaService) *handler.CaptchaHandler {
return handler.NewCaptchaHandler(service)
})
return &Container{c}
}
// BuildEngine builds the Gin engine with routes based on service type.
// BuildEngine 根据服务类型构建 Gin 引擎并注册路由。
//
// Parameters:
// - serviceType: The type of service (auth or file).
//
// Returns:
// - *gin.Engine: The configured Gin engine.
// - error: Any error that occurred during setup.
//
// 参数:
// - serviceType: 服务类型 (auth 或 file)。
//
// 返回值:
// - *gin.Engine: 配置好的 Gin 引擎。
// - error: 设置过程中发生的任何错误。
func (c *Container) BuildEngine(serviceType routes.ServiceType) (*gin.Engine, error) {
var engine *gin.Engine
err := c.Invoke(func(cfg *config.Config, authHandler *handler.AuthHandler, fileHandler *handler.FileHandler, captchaHandler *handler.CaptchaHandler, emailHandler *handler.EmailHandler) {
engine = gin.New()
engine.RedirectTrailingSlash = false
engine.RedirectFixedPath = false
engine.Use(gin.Recovery())
engine.Use(middleware.CORS())
engine.Use(middleware.ErrorHandler())
engine.GET("/healthz", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
})
routes.SetupRoutes(engine, serviceType, authHandler, fileHandler, captchaHandler, emailHandler, cfg.JWT.Secret)
logger.Info("engine built successfully")
})
return engine, err
}
// BuildAuthEngine builds the Gin engine for auth service.
// BuildAuthEngine 为认证服务构建 Gin 引擎。
//
// This method only registers authentication routes.
//
// 此方法只注册认证路由。
func (c *Container) BuildAuthEngine() (*gin.Engine, error) {
return c.BuildEngine(routes.ServiceTypeAuth)
}
// BuildFileEngine builds the Gin engine for file service.
// BuildFileEngine 为文件服务构建 Gin 引擎。
//
// This method only registers file management routes.
//
// 此方法只注册文件管理路由。
func (c *Container) BuildFileEngine() (*gin.Engine, error) {
return c.BuildEngine(routes.ServiceTypeFile)
}
func (c *Container) InitializeServices() error {
return c.Invoke(func(mysql *database.MySQLClient, redis *database.RedisClient, minio *storage.MinioClient, cfg *config.Config) error {
if err := mysql.Migrate(); err != nil {
return err
}
if err := minio.EnsureBucket(cfg.Storage.BucketName); err != nil {
return err
}
logger.Info("all services initialized")
go startRecycleBinCleaner(cfg, mysql, minio)
return nil
})
}
func startRecycleBinCleaner(cfg *config.Config, mysql *database.MySQLClient, minio *storage.MinioClient) {
expireDays := cfg.Storage.RecycleBinExpireDays
if expireDays <= 0 {
expireDays = 7
}
fMetaRepo := infrastructureRepo.NewFileMetaRepository(mysql)
fRepo := infrastructureRepo.NewFileRepository(minio)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
logger.Info("starting recycle bin cleanup", "expire_days", expireDays)
expiredFiles, err := fMetaRepo.CleanExpiredRecycleBin(expireDays)
if err != nil {
logger.Error("failed to clean recycle bin", "error", err)
continue
}
for _, df := range expiredFiles {
if err := fRepo.Delete(df.UserCode, df.FileKey); err != nil {
logger.Error("failed to delete file from storage", "file_key", df.FileKey, "error", err)
}
}
logger.Info("recycle bin cleanup completed", "files_cleaned", len(expiredFiles))
<-ticker.C
}
}
+44
View File
@@ -0,0 +1,44 @@
package entity
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `json:"id" gorm:"primaryKey;comment:用户ID"`
UserCode string `json:"user_code" gorm:"uniqueIndex;size:8;comment:用户码(8位随机字母数字)"`
Username string `json:"username" gorm:"uniqueIndex;size:50;comment:用户名"`
Password string `json:"-" gorm:"size:255;comment:密码(加密存储)"`
Email string `json:"email" gorm:"size:100;comment:邮箱"`
Phone string `json:"phone" gorm:"size:20;comment:手机号"`
Nickname string `json:"nickname" gorm:"size:50;comment:昵称"`
StorageUsed int64 `json:"storage_used" gorm:"default:0;comment:已使用存储空间(字节)"`
StorageLimit int64 `json:"storage_limit" gorm:"default:52428800;comment:存储空间限制(字节),默认50MB"`
CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"comment:删除时间"`
}
func (User) TableName() string {
return "users"
}
func (User) TableComment() string {
return "用户表"
}
func NewUser(username, password, userCode, nickname string) *User {
if nickname == "" {
nickname = username
}
return &User{
Username: username,
Password: password,
UserCode: userCode,
Nickname: nickname,
StorageUsed: 0,
StorageLimit: 52428800,
}
}
@@ -0,0 +1,21 @@
package repository
import "cloudnest/internal/domain/auth/entity"
// UserRepository defines the interface for user data access operations.
// UserRepository 定义了用户数据访问操作的接口。
//
// This interface follows the Dependency Inversion Principle (DIP),
// where the application layer depends on this abstraction rather than concrete implementations.
//
// 此接口遵循依赖倒置原则(DIP),应用层依赖于这个抽象而非具体实现。
type UserRepository interface {
FindByUsername(username string) (*entity.User, error)
FindByUserCode(userCode string) (*entity.User, error)
FindByEmail(email string) (*entity.User, error)
Create(user *entity.User) error
FindByID(id uint) (*entity.User, error)
UpdateStorageUsed(userCode string, size int64) error
UpdateProfile(userCode, email, phone, nickname string) error
UpdatePassword(userCode, newPassword string) error
}
@@ -0,0 +1,36 @@
package entity
import (
"time"
"gorm.io/gorm"
)
type DeletedFile struct {
ID uint `json:"id" gorm:"primaryKey;comment:删除记录ID"`
UserID uint `json:"user_id" gorm:"comment:用户ID"`
UserCode string `json:"user_code" gorm:"index;size:8;comment:用户码"`
FileKey string `json:"file_key" gorm:"size:500;comment:文件存储键"`
FileName string `json:"file_name" gorm:"size:255;comment:文件名"`
Size int64 `json:"size" gorm:"comment:文件大小(字节)"`
DeletedAt time.Time `json:"deleted_at" gorm:"comment:删除时间"`
RemovedAt gorm.DeletedAt `json:"-" gorm:"comment:物理删除时间"`
}
func (DeletedFile) TableName() string {
return "deleted_files"
}
func (DeletedFile) TableComment() string {
return "回收站文件表"
}
func NewDeletedFile(userID uint, userCode, fileKey, fileName string, size int64) *DeletedFile {
return &DeletedFile{
UserID: userID,
UserCode: userCode,
FileKey: fileKey,
FileName: fileName,
Size: size,
}
}
+34
View File
@@ -0,0 +1,34 @@
package entity
import (
"time"
"gorm.io/gorm"
)
type Favorite struct {
ID uint `json:"id" gorm:"primaryKey;comment:收藏ID"`
UserID uint `json:"user_id" gorm:"comment:用户ID"`
UserCode string `json:"user_code" gorm:"index;size:8;comment:用户码"`
FileKey string `json:"file_key" gorm:"size:500;comment:文件存储键"`
Size int64 `json:"size" gorm:"default:0;comment:文件大小(字节)"`
CreatedAt time.Time `json:"created_at" gorm:"comment:收藏时间"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"comment:删除时间"`
}
func (Favorite) TableName() string {
return "favorites"
}
func (Favorite) TableComment() string {
return "用户收藏表"
}
func NewFavorite(userID uint, userCode, fileKey string, size int64) *Favorite {
return &Favorite{
UserID: userID,
UserCode: userCode,
FileKey: fileKey,
Size: size,
}
}
+34
View File
@@ -0,0 +1,34 @@
package entity
// FileMeta contains metadata about a file stored in the object storage.
// FileMeta 包含存储在对象存储中的文件元数据。
//
// Fields:
// - Key: The unique key/path of the file in the storage.
// - Size: The size of the file in bytes.
//
// 字段:
// - Key: 文件在存储中的唯一键/路径。
// - Size: 文件大小(字节)。
type FileMeta struct {
Key string `json:"key"`
Size int64 `json:"size"`
}
// UploadRequest contains the necessary information to upload a file.
// UploadRequest 包含上传文件所需的必要信息。
//
// Fields:
// - Filename: The original filename.
// - Content: The file content as bytes.
// - ContentType: The MIME type of the file.
//
// 字段:
// - Filename: 原始文件名。
// - Content: 文件内容(字节)。
// - ContentType: 文件的 MIME 类型。
type UploadRequest struct {
Filename string
Content []byte
ContentType string
}
@@ -0,0 +1,144 @@
package repository
import "io"
// FileInfo contains metadata for a file.
// FileInfo 包含文件的元数据。
//
// Fields:
// - Key: The storage key/path of the file.
// - Size: The file size in bytes.
// - LastModified: The timestamp when the file was last modified.
//
// 字段:
// - Key: 文件的存储键/路径。
// - Size: 文件大小(字节)。
// - LastModified: 文件最后修改的时间戳。
type FileInfo struct {
Key string
Size int64
LastModified string
}
// FileRepository defines the interface for file storage operations.
// FileRepository 定义了文件存储操作的接口。
//
// This interface follows the Dependency Inversion Principle (DIP),
// where the application layer depends on this abstraction rather than concrete implementations.
//
// 此接口遵循依赖倒置原则(DIP),应用层依赖于这个抽象而非具体实现。
type FileRepository interface {
// Upload uploads a file to the object storage.
// Upload 将文件上传到对象存储。
//
// Parameters:
// - bucket: The target bucket name.
// - objectName: The unique name/path for the object.
// - reader: The file content reader.
// - size: The size of the file in bytes.
// - contentType: The MIME type of the file.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 目标桶名称。
// - objectName: 对象的唯一名称/路径。
// - reader: 文件内容读取器。
// - size: 文件大小(字节)。
// - contentType: 文件的 MIME 类型。
//
// 返回值:
// - error: 如果操作失败则返回错误。
Upload(bucket, objectName string, reader io.Reader, size int64, contentType string) error
// List retrieves a list of files under the specified prefix with metadata.
// List 获取指定前缀下的文件列表及其元数据。
//
// Parameters:
// - bucket: The bucket name.
// - prefix: The prefix/path to filter files.
//
// Returns:
// - []FileInfo: A list of file information including key, size, and last modified time.
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - prefix: 用于过滤文件的前缀/路径。
//
// 返回值:
// - []FileInfo: 文件信息列表,包含键、大小和最后修改时间。
// - error: 如果操作失败则返回错误。
List(bucket, prefix string) ([]FileInfo, error)
// PresignDownload generates a pre-signed URL for downloading a file.
// PresignDownload 生成用于下载文件的预签名 URL。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object.
//
// Returns:
// - string: The pre-signed download URL.
// - error: An error if the operation fails.
//
// Note:
// - The URL is time-limited and doesn't require authentication.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 对象的名称/路径。
//
// 返回值:
// - string: 预签名下载 URL。
// - error: 如果操作失败则返回错误。
//
// 注意:
// - URL 有时间限制且不需要认证。
PresignDownload(bucket, objectName string) (string, error)
// Delete removes a file from the object storage.
// Delete 从对象存储中删除文件。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object to delete.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 要删除的对象的名称/路径。
//
// 返回值:
// - error: 如果操作失败则返回错误。
Delete(bucket, objectName string) error
// GetObject retrieves a file as a readable stream from the object storage.
// GetObject 从对象存储中获取文件的可读流。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object.
//
// Returns:
// - io.ReadCloser: A readable stream of the file content. Caller must close it.
// - int64: The file size in bytes.
// - string: The Content-Type of the file.
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 对象的名称/路径。
//
// 返回值:
// - io.ReadCloser: 文件内容的可读流。调用方必须关闭它。
// - int64: 文件大小(字节)。
// - string: 文件的 Content-Type。
// - error: 如果操作失败则返回错误。
GetObject(bucket, objectName string) (io.ReadCloser, int64, string, error)
GetObjectInfo(bucket, objectName string) (int64, string, error)
}
+92
View File
@@ -0,0 +1,92 @@
package database
import (
"cloudnest/internal/domain/auth/entity"
fileentity "cloudnest/internal/domain/file/entity"
"cloudnest/internal/pkg/logger"
"gorm.io/driver/mysql"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// MySQLClient provides MySQL database operations.
// MySQLClient 提供 MySQL 数据库操作。
//
// This struct wraps the GORM database connection and provides methods
// for database initialization and migration.
//
// 此结构体封装了 GORM 数据库连接,并提供了数据库初始化和迁移的方法。
type MySQLClient struct {
DB *gorm.DB
}
// NewMySQLClient creates a new MySQL client.
// NewMySQLClient 创建一个新的 MySQL 客户端。
//
// Parameters:
// - dsn: The MySQL data source name in format "user:password@tcp(host:port)/database".
//
// Returns:
// - *MySQLClient: A pointer to the new MySQLClient instance.
// - error: An error if the connection fails.
//
// Note:
// - The connection is established immediately.
// - GORM logging is enabled at Info level.
//
// 参数:
// - dsn: MySQL 数据源名称,格式为 "user:password@tcp(host:port)/database"。
//
// 返回值:
// - *MySQLClient: 新创建的 MySQLClient 实例指针。
// - error: 如果连接失败则返回错误。
//
// 注意:
// - 连接会立即建立。
// - GORM 日志级别为 Info。
func NewMySQLClient(dsn string) (*MySQLClient, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Info),
})
if err != nil {
return nil, err
}
logger.Info("mysql connected")
return &MySQLClient{DB: db}, nil
}
// Migrate runs database migrations for all registered models.
// Migrate 对所有注册的模型运行数据库迁移。
//
// Returns:
// - error: An error if migration fails.
//
// Note:
// - This method should be called once during application startup.
// - It automatically creates or updates database tables based on model definitions.
//
// 返回值:
// - error: 如果迁移失败则返回错误。
//
// 注意:
// - 此方法应在应用程序启动时调用一次。
// - 它会根据模型定义自动创建或更新数据库表。
func (c *MySQLClient) Migrate() error {
if err := c.DB.AutoMigrate(&entity.User{}, &fileentity.Favorite{}, &fileentity.DeletedFile{}); err != nil {
return err
}
c.setTableComment("users", "用户表")
c.setTableComment("favorites", "用户收藏表")
c.setTableComment("deleted_files", "回收站文件表")
return nil
}
func (c *MySQLClient) setTableComment(tableName, comment string) {
sql := "ALTER TABLE `" + tableName + "` COMMENT = '" + comment + "'"
if err := c.DB.Exec(sql).Error; err != nil {
logger.Error("failed to set table comment", "table", tableName, "error", err)
}
}
+58
View File
@@ -0,0 +1,58 @@
package database
import (
"cloudnest/internal/pkg/logger"
"github.com/go-redis/redis/v8"
)
// RedisClient provides Redis cache operations.
// RedisClient 提供 Redis 缓存操作。
//
// This struct wraps the go-redis client and provides methods for cache operations.
//
// 此结构体封装了 go-redis 客户端,并提供了缓存操作的方法。
type RedisClient struct {
Client *redis.Client
}
// NewRedisClient creates a new Redis client.
// NewRedisClient 创建一个新的 Redis 客户端。
//
// Parameters:
// - addr: The Redis server address (host:port).
// - password: The Redis password (empty string for no password).
// - db: The Redis database number (0-15).
//
// Returns:
// - *RedisClient: A pointer to the new RedisClient instance.
// - error: An error if the connection fails.
//
// Note:
// - A PING command is sent to verify the connection.
//
// 参数:
// - addr: Redis 服务器地址(host:port)。
// - password: Redis 密码(无密码时为空字符串)。
// - db: Redis 数据库编号(0-15)。
//
// 返回值:
// - *RedisClient: 新创建的 RedisClient 实例指针。
// - error: 如果连接失败则返回错误。
//
// 注意:
// - 会发送 PING 命令来验证连接。
func NewRedisClient(addr, password string, db int) (*RedisClient, error) {
client := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
_, err := client.Ping(client.Context()).Result()
if err != nil {
return nil, err
}
logger.Info("redis connected")
return &RedisClient{Client: client}, nil
}
@@ -0,0 +1,241 @@
package repository
import (
"time"
"cloudnest/internal/domain/file/entity"
"cloudnest/internal/infrastructure/database"
"cloudnest/internal/pkg/errors"
)
// FileMetaRepository provides database operations for file metadata.
// FileMetaRepository 提供文件元数据的数据库操作。
//
// This repository handles operations related to file favorites and recycle bin,
// storing data in MySQL database.
//
// 此仓储处理文件收藏和回收站相关操作,数据存储在 MySQL 数据库中。
type FileMetaRepository struct {
db *database.MySQLClient
}
// NewFileMetaRepository creates a new FileMetaRepository.
// NewFileMetaRepository 创建一个新的 FileMetaRepository。
//
// Parameters:
// - db: The MySQL client for database operations.
//
// Returns:
// - *FileMetaRepository: A pointer to the new FileMetaRepository instance.
//
// 参数:
// - db: 用于数据库操作的 MySQL 客户端。
//
// 返回值:
// - *FileMetaRepository: 新创建的 FileMetaRepository 实例指针。
func NewFileMetaRepository(db *database.MySQLClient) *FileMetaRepository {
return &FileMetaRepository{db: db}
}
// AddFavorite adds a file to user's favorites.
// AddFavorite 将文件添加到用户的收藏夹。
//
// Parameters:
// - favorite: The Favorite entity to save.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - favorite: 要保存的 Favorite 实体。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) AddFavorite(favorite *entity.Favorite) error {
var exists entity.Favorite
if err := r.db.DB.Where("user_code = ? AND file_key = ?", favorite.UserCode, favorite.FileKey).First(&exists).Error; err == nil {
return errors.BadRequest("文件已在收藏夹中")
}
if err := r.db.DB.Create(favorite).Error; err != nil {
return errors.InternalError("添加收藏失败", err)
}
return nil
}
// RemoveFavorite removes a file from user's favorites.
// RemoveFavorite 从用户的收藏夹中移除文件。
//
// Parameters:
// - userCode: The user's userCode.
// - fileKey: The file storage key.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
// - fileKey: 文件的存储键。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) RemoveFavorite(userCode, fileKey string) error {
result := r.db.DB.Where("user_code = ? AND file_key = ?", userCode, fileKey).Delete(&entity.Favorite{})
if result.Error != nil {
return errors.InternalError("取消收藏失败", result.Error)
}
if result.RowsAffected == 0 {
return errors.NotFound("收藏记录不存在")
}
return nil
}
// GetFavorites retrieves all favorites for a user.
// GetFavorites 获取用户的所有收藏。
//
// Parameters:
// - userCode: The user's userCode.
//
// Returns:
// - []entity.Favorite: A list of Favorite entities.
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
//
// 返回值:
// - []entity.Favorite: Favorite 实体列表。
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) GetFavorites(userCode string) ([]entity.Favorite, error) {
var favorites []entity.Favorite
if err := r.db.DB.Where("user_code = ?", userCode).Order("created_at DESC").Find(&favorites).Error; err != nil {
return nil, errors.InternalError("获取收藏列表失败", err)
}
return favorites, nil
}
// AddToRecycleBin adds a file to the recycle bin.
// AddToRecycleBin 将文件添加到回收站。
//
// Parameters:
// - deletedFile: The DeletedFile entity to save.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - deletedFile: 要保存的 DeletedFile 实体。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) AddToRecycleBin(deletedFile *entity.DeletedFile) error {
if err := r.db.DB.Create(deletedFile).Error; err != nil {
return errors.InternalError("添加到回收站失败", err)
}
return nil
}
// RestoreFromRecycleBin restores a file from the recycle bin.
// RestoreFromRecycleBin 从回收站恢复文件。
//
// Parameters:
// - userCode: The user's userCode.
// - fileKey: The file storage key.
//
// Returns:
// - *entity.DeletedFile: The restored DeletedFile entity.
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
// - fileKey: 文件的存储键。
//
// 返回值:
// - *entity.DeletedFile: 恢复的 DeletedFile 实体。
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) RestoreFromRecycleBin(userCode, fileKey string) (*entity.DeletedFile, error) {
var deletedFile entity.DeletedFile
if err := r.db.DB.Where("user_code = ? AND file_key = ?", userCode, fileKey).First(&deletedFile).Error; err != nil {
return nil, errors.NotFound("回收站中不存在该文件")
}
if err := r.db.DB.Delete(&deletedFile).Error; err != nil {
return nil, errors.InternalError("恢复文件失败", err)
}
return &deletedFile, nil
}
// DeleteFromRecycleBin permanently deletes a file from the recycle bin.
// DeleteFromRecycleBin 从回收站永久删除文件。
//
// Parameters:
// - userCode: The user's userCode.
// - fileKey: The file storage key.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
// - fileKey: 文件的存储键。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) DeleteFromRecycleBin(userCode, fileKey string) error {
result := r.db.DB.Where("user_code = ? AND file_key = ?", userCode, fileKey).Delete(&entity.DeletedFile{})
if result.Error != nil {
return errors.InternalError("永久删除失败", result.Error)
}
if result.RowsAffected == 0 {
return errors.NotFound("回收站中不存在该文件")
}
return nil
}
// GetRecycleBin retrieves all deleted files for a user.
// GetRecycleBin 获取用户的所有已删除文件。
//
// Parameters:
// - userCode: The user's userCode.
//
// Returns:
// - []entity.DeletedFile: A list of DeletedFile entities.
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
//
// 返回值:
// - []entity.DeletedFile: DeletedFile 实体列表。
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) GetRecycleBin(userCode string) ([]entity.DeletedFile, error) {
var deletedFiles []entity.DeletedFile
if err := r.db.DB.Where("user_code = ?", userCode).Order("deleted_at DESC").Find(&deletedFiles).Error; err != nil {
return nil, errors.InternalError("获取回收站列表失败", err)
}
return deletedFiles, nil
}
func (r *FileMetaRepository) GetDeletedFileKeys(userCode string) ([]string, error) {
var keys []string
if err := r.db.DB.Model(&entity.DeletedFile{}).Where("user_code = ?", userCode).Pluck("file_key", &keys).Error; err != nil {
return nil, errors.InternalError("获取已删除文件键失败", err)
}
return keys, nil
}
func (r *FileMetaRepository) CleanExpiredRecycleBin(expireDays int) ([]entity.DeletedFile, error) {
var expiredFiles []entity.DeletedFile
if err := r.db.DB.Where("deleted_at < ?", time.Now().AddDate(0, 0, -expireDays)).Find(&expiredFiles).Error; err != nil {
return nil, errors.InternalError("查询过期文件失败", err)
}
if len(expiredFiles) == 0 {
return []entity.DeletedFile{}, nil
}
result := r.db.DB.Where("deleted_at < ?", time.Now().AddDate(0, 0, -expireDays)).Delete(&entity.DeletedFile{})
if result.Error != nil {
return nil, errors.InternalError("清理过期文件失败", result.Error)
}
return expiredFiles, nil
}
@@ -0,0 +1,216 @@
package repository
import (
"context"
"io"
"cloudnest/internal/domain/file/repository"
"cloudnest/internal/infrastructure/storage"
"cloudnest/internal/pkg/errors"
"github.com/minio/minio-go/v7"
)
// fileRepository is the MinIO implementation of FileRepository.
// fileRepository 是 FileRepository 的 MinIO 实现。
//
// This struct implements the FileRepository interface using MinIO for object storage operations.
//
// 此结构体使用 MinIO 实现了 FileRepository 接口,用于对象存储操作。
type fileRepository struct {
minio *storage.MinioClient
}
// NewFileRepository creates a new file repository.
// NewFileRepository 创建一个新的文件仓储。
//
// Parameters:
// - minio: The MinIO client for storage operations.
//
// Returns:
// - repository.FileRepository: A FileRepository implementation.
//
// 参数:
// - minio: 用于存储操作的 MinIO 客户端。
//
// 返回值:
// - repository.FileRepository: FileRepository 的实现。
func NewFileRepository(minio *storage.MinioClient) repository.FileRepository {
return &fileRepository{minio: minio}
}
// Upload uploads a file to the object storage.
// Upload 将文件上传到对象存储。
//
// Parameters:
// - bucket: The target bucket name.
// - objectName: The unique name/path for the object.
// - reader: The file content reader.
// - size: The size of the file in bytes.
// - contentType: The MIME type of the file.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 目标桶名称。
// - objectName: 对象的唯一名称/路径。
// - reader: 文件内容读取器。
// - size: 文件大小(字节)。
// - contentType: 文件的 MIME 类型。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *fileRepository) Upload(bucket, objectName string, reader io.Reader, size int64, contentType string) error {
_, err := r.minio.Client.PutObject(context.Background(), bucket, objectName, reader, size, minio.PutObjectOptions{ContentType: contentType})
if err != nil {
return errors.InternalError("上传文件失败", err)
}
return nil
}
// List retrieves a list of files under the specified prefix with metadata.
// List 获取指定前缀下的文件列表及其元数据。
//
// Parameters:
// - bucket: The bucket name.
// - prefix: The prefix/path to filter files.
//
// Returns:
// - []repository.FileInfo: A list of file information.
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - prefix: 用于过滤文件的前缀/路径。
//
// 返回值:
// - []repository.FileInfo: 文件信息列表。
// - error: 如果操作失败则返回错误。
func (r *fileRepository) List(bucket, prefix string) ([]repository.FileInfo, error) {
var files []repository.FileInfo
for obj := range r.minio.Client.ListObjects(context.Background(), bucket, minio.ListObjectsOptions{Prefix: prefix}) {
if obj.Err != nil {
return nil, errors.InternalError("获取文件列表失败", obj.Err)
}
files = append(files, repository.FileInfo{
Key: obj.Key,
Size: obj.Size,
LastModified: obj.LastModified.Format("2006-01-02 15:04:05"),
})
}
return files, nil
}
// PresignDownload generates a pre-signed URL for downloading a file.
// PresignDownload 生成用于下载文件的预签名 URL。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object.
//
// Returns:
// - string: The pre-signed download URL.
// - error: An error if the operation fails.
//
// Note:
// - The URL expires after storage.PresignDuration (default 24 hours).
// - Returns errors.NotFoundError if the file doesn't exist.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 对象的名称/路径。
//
// 返回值:
// - string: 预签名下载 URL。
// - error: 如果操作失败则返回错误。
//
// 注意:
// - URL 在 storage.PresignDuration(默认 24 小时)后过期。
// - 如果文件不存在,返回 errors.NotFoundError。
func (r *fileRepository) PresignDownload(bucket, objectName string) (string, error) {
url, err := r.minio.Client.PresignedGetObject(context.Background(), bucket, objectName, storage.PresignDuration, nil)
if err != nil {
return "", errors.NotFound("文件不存在")
}
return url.String(), nil
}
// Delete removes a file from the object storage.
// Delete 从对象存储中删除文件。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object to delete.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 要删除的对象的名称/路径。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *fileRepository) Delete(bucket, objectName string) error {
err := r.minio.Client.RemoveObject(context.Background(), bucket, objectName, minio.RemoveObjectOptions{})
if err != nil {
return errors.InternalError("删除文件失败", err)
}
return nil
}
// GetObject retrieves a file as a readable stream from the object storage.
// GetObject 从对象存储中获取文件的可读流。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object.
//
// Returns:
// - io.ReadCloser: A readable stream of the file content. Caller must close it.
// - int64: The file size in bytes.
// - string: The Content-Type of the file.
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 对象的名称/路径。
//
// 返回值:
// - io.ReadCloser: 文件内容的可读流。调用方必须关闭它。
// - int64: 文件大小(字节)。
// - string: 文件的 Content-Type。
// - error: 如果操作失败则返回错误。
func (r *fileRepository) GetObject(bucket, objectName string) (io.ReadCloser, int64, string, error) {
obj, err := r.minio.Client.GetObject(context.Background(), bucket, objectName, minio.GetObjectOptions{})
if err != nil {
return nil, 0, "", errors.NotFound("文件不存在")
}
info, err := obj.Stat()
if err != nil {
obj.Close()
return nil, 0, "", errors.NotFound("文件不存在")
}
contentType := info.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
return obj, info.Size, contentType, nil
}
func (r *fileRepository) GetObjectInfo(bucket, objectName string) (int64, string, error) {
info, err := r.minio.Client.StatObject(context.Background(), bucket, objectName, minio.StatObjectOptions{})
if err != nil {
return 0, "", errors.NotFound("文件不存在")
}
contentType := info.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
return info.Size, contentType, nil
}
@@ -0,0 +1,105 @@
package repository
import (
"cloudnest/internal/domain/auth/entity"
"cloudnest/internal/domain/auth/repository"
"cloudnest/internal/infrastructure/database"
"cloudnest/internal/pkg/errors"
"gorm.io/gorm"
"strings"
)
type userRepository struct {
db *database.MySQLClient
}
func NewUserRepository(db *database.MySQLClient) repository.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindByUsername(username string) (*entity.User, error) {
var user entity.User
err := r.db.DB.Where("username = ?", username).First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.NotFound("用户不存在")
}
return nil, errors.InternalError("查询用户失败", err)
}
return &user, nil
}
func (r *userRepository) FindByUserCode(userCode string) (*entity.User, error) {
var user entity.User
err := r.db.DB.Where("user_code = ?", userCode).First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.NotFound("用户不存在")
}
return nil, errors.InternalError("查询用户失败", err)
}
return &user, nil
}
func (r *userRepository) FindByEmail(email string) (*entity.User, error) {
var user entity.User
err := r.db.DB.Where("email = ?", email).First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.NotFound("用户不存在")
}
return nil, errors.InternalError("查询用户失败", err)
}
return &user, nil
}
func (r *userRepository) Create(user *entity.User) error {
err := r.db.DB.Create(user).Error
if err != nil {
if strings.Contains(err.Error(), "Duplicate entry") || strings.Contains(err.Error(), "unique constraint") {
return errors.Conflict("用户名或用户码已存在")
}
return errors.InternalError("创建用户失败", err)
}
return nil
}
func (r *userRepository) FindByID(id uint) (*entity.User, error) {
var user entity.User
err := r.db.DB.First(&user, id).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.NotFound("用户不存在")
}
return nil, errors.InternalError("查询用户失败", err)
}
return &user, nil
}
func (r *userRepository) UpdateStorageUsed(userCode string, size int64) error {
err := r.db.DB.Model(&entity.User{}).Where("user_code = ?", userCode).Update("storage_used", gorm.Expr("storage_used + ?", size)).Error
if err != nil {
return errors.InternalError("更新存储空间失败", err)
}
return nil
}
func (r *userRepository) UpdateProfile(userCode, email, phone, nickname string) error {
err := r.db.DB.Model(&entity.User{}).Where("user_code = ?", userCode).Updates(map[string]interface{}{
"email": email,
"phone": phone,
"nickname": nickname,
}).Error
if err != nil {
return errors.InternalError("更新个人信息失败", err)
}
return nil
}
func (r *userRepository) UpdatePassword(userCode, newPassword string) error {
err := r.db.DB.Model(&entity.User{}).Where("user_code = ?", userCode).Update("password", newPassword).Error
if err != nil {
return errors.InternalError("更新密码失败", err)
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package storage
import (
"context"
"time"
"cloudnest/internal/pkg/logger"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// PresignDuration is the default duration for pre-signed URLs.
// PresignDuration 是预签名 URL 的默认有效期。
var PresignDuration = 24 * time.Hour
// MinioClient provides MinIO object storage operations.
// MinioClient 提供 MinIO 对象存储操作。
//
// This struct wraps the MinIO client and provides methods for storage operations.
//
// 此结构体封装了 MinIO 客户端,并提供了存储操作的方法。
type MinioClient struct {
Client *minio.Client
}
// NewMinioClient creates a new MinIO client.
// NewMinioClient 创建一个新的 MinIO 客户端。
//
// Parameters:
// - endpoint: The MinIO server endpoint (host:port).
// - accessKey: The MinIO access key.
// - secretKey: The MinIO secret key.
// - useSSL: Whether to use SSL/TLS for connections.
//
// Returns:
// - *MinioClient: A pointer to the new MinioClient instance.
// - error: An error if the connection fails.
//
// 参数:
// - endpoint: MinIO 服务器端点(host:port)。
// - accessKey: MinIO 访问密钥。
// - secretKey: MinIO 秘密密钥。
// - useSSL: 是否使用 SSL/TLS 连接。
//
// 返回值:
// - *MinioClient: 新创建的 MinioClient 实例指针。
// - error: 如果连接失败则返回错误。
func NewMinioClient(endpoint, accessKey, secretKey string, useSSL bool) (*MinioClient, error) {
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: useSSL,
})
if err != nil {
return nil, err
}
logger.Info("minio connected")
return &MinioClient{Client: client}, nil
}
// EnsureBucket creates the bucket if it doesn't exist.
// EnsureBucket 如果桶不存在则创建桶。
//
// Parameters:
// - bucketName: The name of the bucket to ensure.
//
// Returns:
// - error: An error if the operation fails.
//
// Note:
// - This method is idempotent - it can be called safely even if the bucket exists.
//
// 参数:
// - bucketName: 要确保存在的桶名称。
//
// 返回值:
// - error: 如果操作失败则返回错误。
//
// 注意:
// - 此方法是幂等的 - 即使桶已存在也可以安全调用。
func (c *MinioClient) EnsureBucket(bucketName string) error {
exists, err := c.Client.BucketExists(context.Background(), bucketName)
if err != nil {
return err
}
if !exists {
if err := c.Client.MakeBucket(context.Background(), bucketName, minio.MakeBucketOptions{}); err != nil {
return err
}
logger.Info("bucket created", "bucket", bucketName)
}
return nil
}
@@ -0,0 +1,219 @@
package handler
import (
"cloudnest/internal/application/auth"
"cloudnest/internal/application/captcha"
"cloudnest/internal/application/email"
"cloudnest/internal/pkg/errors"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// AuthHandler handles HTTP requests for authentication operations.
// AuthHandler 处理认证操作的 HTTP 请求。
//
// This struct acts as the controller layer for authentication endpoints,
// converting HTTP requests to service calls and formatting responses.
//
// 此结构体作为认证端点的控制器层,将 HTTP 请求转换为服务调用并格式化响应。
type AuthHandler struct {
service *auth.AuthService
captchaService *captcha.CaptchaService
emailService *email.EmailService
}
// NewAuthHandler creates a new AuthHandler.
// NewAuthHandler 创建一个新的 AuthHandler。
//
// Parameters:
// - service: The authentication service to use.
// - captchaService: The captcha service to use.
// - emailService: The email service to use.
//
// Returns:
// - *AuthHandler: A pointer to the new AuthHandler instance.
//
// 参数:
// - service: 要使用的认证服务。
// - captchaService: 要使用的验证码服务。
// - emailService: 要使用的邮件服务。
//
// 返回值:
// - *AuthHandler: 新创建的 AuthHandler 实例指针。
func NewAuthHandler(service *auth.AuthService, captchaService *captcha.CaptchaService, emailService *email.EmailService) *AuthHandler {
return &AuthHandler{
service: service,
captchaService: captchaService,
emailService: emailService,
}
}
// Register handles user registration requests.
// Register 处理用户注册请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/auth/register
// - Body: {"username": "string", "password": "string"}
//
// Response:
// - Success: 200 OK {"code": 0, "message": "注册成功"}
// - Conflict: 409 {"code": 409, "message": "用户名已存在"}
// - Bad Request: 400 {"code": 400, "message": "参数错误"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/auth/register
// - 请求体: {"username": "string", "password": "string"}
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "注册成功"}
// - 冲突: 409 {"code": 409, "message": "用户名已存在"}
// - 请求错误: 400 {"code": 400, "message": "参数错误"}
func (h *AuthHandler) Register(c *gin.Context) {
var req auth.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if !h.captchaService.Verify(req.CaptchaID, req.CaptchaAnswer) {
response.BadRequest(c, "图形验证码错误")
return
}
if err := h.service.Register(req.Username, req.Password, req.Email, req.Nickname, req.EmailCode); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "注册成功")
}
// Login handles user login requests.
// Login 处理用户登录请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/auth/login
// - Body: {"username": "string", "password": "string"}
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"token": "string"}}
// - Unauthorized: 401 {"code": 401, "message": "用户名或密码错误"}
// - Bad Request: 400 {"code": 400, "message": "参数错误"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/auth/login
// - 请求体: {"username": "string", "password": "string"}
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"token": "string"}}
// - 未授权: 401 {"code": 401, "message": "用户名或密码错误"}
// - 请求错误: 400 {"code": 400, "message": "参数错误"}
func (h *AuthHandler) Login(c *gin.Context) {
var req auth.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if !h.captchaService.Verify(req.CaptchaID, req.CaptchaAnswer) {
response.BadRequest(c, "图形验证码错误")
return
}
token, _, err := h.service.Login(req.Username, req.Password)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.Success(c, auth.TokenResponse{Token: token})
}
func (h *AuthHandler) Profile(c *gin.Context) {
userCode := c.GetString("user_code")
if userCode == "" {
response.Unauthorized(c, "用户码不能为空")
return
}
user, err := h.service.GetUserInfo(userCode)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.Success(c, map[string]interface{}{
"username": user.Username,
"nickname": user.Nickname,
"email": user.Email,
"phone": user.Phone,
"storage_used": user.StorageUsed,
"storage_limit": user.StorageLimit,
})
}
func (h *AuthHandler) UpdateProfile(c *gin.Context) {
userCode := c.GetString("user_code")
if userCode == "" {
response.Unauthorized(c, "用户码不能为空")
return
}
var req auth.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if err := h.service.UpdateProfile(userCode, req.Email, req.Phone, req.Nickname); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "更新成功")
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
userCode := c.GetString("user_code")
if userCode == "" {
response.Unauthorized(c, "用户码不能为空")
return
}
var req auth.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if err := h.service.ChangePassword(userCode, req.CurrentPassword, req.NewPassword); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "密码修改成功")
}
@@ -0,0 +1,63 @@
package handler
import (
"net/http"
"cloudnest/internal/application/captcha"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// CaptchaHandler handles HTTP requests for captcha operations.
// CaptchaHandler 处理验证码操作的 HTTP 请求。
type CaptchaHandler struct {
service *captcha.CaptchaService
}
// NewCaptchaHandler creates a new CaptchaHandler.
// NewCaptchaHandler 创建一个新的 CaptchaHandler。
func NewCaptchaHandler(service *captcha.CaptchaService) *CaptchaHandler {
return &CaptchaHandler{service: service}
}
// GenerateCaptcha handles GET /api/v1/captcha.
// GenerateCaptcha 处理 GET /api/v1/captcha。
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"captcha_id": "string", "image_url": "string"}}
func (h *CaptchaHandler) GenerateCaptcha(c *gin.Context) {
captchaID, imageBytes, err := h.service.Generate()
if err != nil {
response.InternalError(c, "验证码生成失败")
return
}
imageUrl := "/api/v1/captcha/" + captchaID + "/image"
// Store image bytes in context for potential direct use, but we return URL here
_ = imageBytes
response.Success(c, gin.H{
"captcha_id": captchaID,
"image_url": imageUrl,
})
}
// GetCaptchaImage handles GET /api/v1/captcha/:id/image.
// GetCaptchaImage 处理 GET /api/v1/captcha/:id/image。
//
// Returns the captcha PNG image directly.
func (h *CaptchaHandler) GetCaptchaImage(c *gin.Context) {
captchaID := c.Param("id")
if captchaID == "" {
c.Status(http.StatusNotFound)
return
}
imageBytes, err := h.service.GetImage(captchaID)
if err != nil {
c.Status(http.StatusNotFound)
return
}
c.Data(http.StatusOK, "image/png", imageBytes)
}
@@ -0,0 +1,62 @@
package handler
import (
"cloudnest/internal/application/captcha"
"cloudnest/internal/application/email"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// EmailHandler handles HTTP requests for email verification code operations.
// EmailHandler 处理邮箱验证码操作的 HTTP 请求。
type EmailHandler struct {
emailService *email.EmailService
captchaService *captcha.CaptchaService
}
// NewEmailHandler creates a new EmailHandler.
// NewEmailHandler 创建一个新的 EmailHandler。
func NewEmailHandler(emailService *email.EmailService, captchaService *captcha.CaptchaService) *EmailHandler {
return &EmailHandler{
emailService: emailService,
captchaService: captchaService,
}
}
// SendEmailCodeRequest represents the request body for sending email verification code.
// SendEmailCodeRequest 表示发送邮箱验证码的请求体。
type SendEmailCodeRequest struct {
Email string `json:"email" binding:"required,email"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaAnswer string `json:"captcha_answer" binding:"required"`
}
// SendEmailCode handles POST /api/v1/auth/email-code.
// SendEmailCode 处理 POST /api/v1/auth/email-code。
//
// It first verifies the captcha, then sends the email verification code.
// 先验证图形验证码,通过后才发送邮箱验证码。
func (h *EmailHandler) SendEmailCode(c *gin.Context) {
var req SendEmailCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if !h.captchaService.Verify(req.CaptchaID, req.CaptchaAnswer) {
response.Error(c, 400, "图形验证码错误")
return
}
_, err := h.emailService.SendVerifyCode(req.Email)
if err != nil {
if err.Error() == "邮件服务未配置" {
response.Error(c, 503, "邮件服务未配置")
return
}
response.InternalError(c, "邮件发送失败")
return
}
response.SuccessMsg(c, "验证码已发送")
}
@@ -0,0 +1,713 @@
package handler
import (
"io"
"net/url"
"strconv"
"strings"
"cloudnest/internal/application/auth"
"cloudnest/internal/application/file"
"cloudnest/internal/pkg/errors"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
type FileHandler struct {
service *file.FileService
authService *auth.AuthService
}
func NewFileHandler(service *file.FileService, authService *auth.AuthService) *FileHandler {
return &FileHandler{service: service, authService: authService}
}
// Upload handles file upload requests.
// Upload 处理文件上传请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/upload
// - Content-Type: multipart/form-data
// - Body: file=<file>
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"message": "上传成功", "file": "string", "size": int}}
// - Bad Request: 400 {"code": 400, "message": "文件上传失败"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/files/upload
// - 内容类型: multipart/form-data
// - 请求体: file=<file>
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"message": "上传成功", "file": "string", "size": int}}
// - 请求错误: 400 {"code": 400, "message": "文件上传失败"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Upload(c *gin.Context) {
f, header, err := c.Request.FormFile("file")
if err != nil {
response.BadRequest(c, "文件上传失败")
return
}
defer f.Close()
userCode := c.GetString("user_code")
if err := h.authService.CheckStorageLimit(userCode, header.Size); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
if err := h.service.Upload(userCode, header.Filename, f, header.Size, header.Header.Get("Content-Type")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
if err := h.authService.AddStorageUsed(userCode, header.Size); err != nil {
response.InternalError(c, "更新存储空间失败")
return
}
response.Success(c, file.UploadResponse{
Message: "上传成功",
File: userCode + "/" + header.Filename,
Size: header.Size,
})
}
// List handles file listing requests.
// List 处理文件列表请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"files": ["string"]}}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"files": ["string"]}}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) List(c *gin.Context) {
userCode := c.GetString("user_code")
fileInfos, err := h.service.List(userCode)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
items := make([]file.FileItem, len(fileInfos))
for i, info := range fileInfos {
items[i] = file.FileItem{
Key: info.Key,
Size: info.Size,
LastModified: info.LastModified,
}
}
response.Success(c, file.ListResponse{Files: items})
}
// Download handles file download requests by streaming the file through the backend.
// Download 处理文件下载请求,通过后端流式传输文件。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/download/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK (file content streamed with Content-Disposition header)
// - Not Found: 404 {"code": 404, "message": "文件不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/download/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK(文件内容流式传输,带 Content-Disposition 头部)
// - 未找到: 404 {"code": 404, "message": "文件不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Download(c *gin.Context) {
userCode := c.GetString("user_code")
filename := c.Param("name")
reader, size, contentType, err := h.service.GetFileStream(userCode, filename)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
defer reader.Close()
// Set headers for file download / 设置文件下载头部
encodedName := url.PathEscape(filename)
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"; filename*=UTF-8''"+encodedName)
c.Header("Content-Type", contentType)
c.Header("Content-Length", strconv.FormatInt(size, 10))
// Stream the file content to the response / 将文件内容流式传输到响应
c.Status(200)
io.Copy(c.Writer, reader)
}
// Delete handles file deletion requests.
// Delete 处理文件删除请求。
//
// Request:
// - Method: DELETE
// - Path: /api/v1/files/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "删除成功"}
// - Not Found: 404 {"code": 404, "message": "文件不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: DELETE
// - 路径: /api/v1/files/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "删除成功"}
// - 未找到: 404 {"code": 404, "message": "文件不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Delete(c *gin.Context) {
userCode := c.GetString("user_code")
if err := h.service.Delete(userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "删除成功")
}
// SoftDelete handles soft delete requests, moving file to recycle bin.
// SoftDelete 处理软删除请求,将文件移动到回收站。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/soft-delete/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "已移到回收站"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/files/soft-delete/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "已移到回收站"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) SoftDelete(c *gin.Context) {
userCode := c.GetString("user_code")
userID := c.GetUint("user_id")
filename := c.Param("name")
var req struct {
Size int64 `json:"size"`
}
c.ShouldBindJSON(&req)
if err := h.service.SoftDelete(userID, userCode, filename, req.Size); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "已移到回收站")
}
// Favorite handles file favorite requests.
// Favorite 处理文件收藏请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/favorite/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "收藏成功"}
// - Bad Request: 400 {"code": 400, "message": "文件已在收藏夹中"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/files/favorite/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "收藏成功"}
// - 请求错误: 400 {"code": 400, "message": "文件已在收藏夹中"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Favorite(c *gin.Context) {
userCode := c.GetString("user_code")
userID := c.GetUint("user_id")
if err := h.service.Favorite(userID, userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "收藏成功")
}
// Unfavorite handles file unfavorite requests.
// Unfavorite 处理文件取消收藏请求。
//
// Request:
// - Method: DELETE
// - Path: /api/v1/files/favorite/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "取消收藏成功"}
// - Not Found: 404 {"code": 404, "message": "收藏记录不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: DELETE
// - 路径: /api/v1/files/favorite/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "取消收藏成功"}
// - 未找到: 404 {"code": 404, "message": "收藏记录不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Unfavorite(c *gin.Context) {
userCode := c.GetString("user_code")
if err := h.service.Unfavorite(userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "取消收藏成功")
}
// ListFavorites handles favorite listing requests.
// ListFavorites 处理收藏列表请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/favorites
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"favorites": [...]}}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/favorites
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"favorites": [...]}}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) ListFavorites(c *gin.Context) {
userCode := c.GetString("user_code")
favorites, err := h.service.GetFavorites(userCode)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
items := make([]file.FavoriteItem, len(favorites))
for i, f := range favorites {
items[i] = file.FavoriteItem{
FileKey: f.FileKey,
FileName: strings.TrimPrefix(f.FileKey, userCode+"/"),
Size: f.Size,
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
response.Success(c, file.FavoriteListResponse{Favorites: items})
}
// RecycleBin handles recycle bin listing requests.
// RecycleBin 处理回收站列表请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/recycle-bin
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"files": [...]}}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/recycle-bin
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"files": [...]}}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) RecycleBin(c *gin.Context) {
userCode := c.GetString("user_code")
deletedFiles, err := h.service.GetRecycleBin(userCode)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
items := make([]file.DeletedFileItem, len(deletedFiles))
for i, df := range deletedFiles {
items[i] = file.DeletedFileItem{
FileKey: df.FileKey,
FileName: df.FileName,
Size: df.Size,
DeletedAt: df.DeletedAt.Format("2006-01-02 15:04:05"),
}
}
response.Success(c, file.RecycleBinResponse{Files: items})
}
// Restore handles file restore requests from recycle bin.
// Restore 处理从回收站恢复文件的请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/restore/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "恢复成功"}
// - Not Found: 404 {"code": 404, "message": "回收站中不存在该文件"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/files/restore/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "恢复成功"}
// - 未找到: 404 {"code": 404, "message": "回收站中不存在该文件"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Restore(c *gin.Context) {
userCode := c.GetString("user_code")
if _, err := h.service.RestoreFromRecycleBin(userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "恢复成功")
}
// DeleteFromRecycleBin handles permanent deletion requests from recycle bin.
// DeleteFromRecycleBin 处理从回收站永久删除文件的请求。
//
// Request:
// - Method: DELETE
// - Path: /api/v1/files/recycle-bin/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "永久删除成功"}
// - Not Found: 404 {"code": 404, "message": "回收站中不存在该文件"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: DELETE
// - 路径: /api/v1/files/recycle-bin/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "永久删除成功"}
// - 未找到: 404 {"code": 404, "message": "回收站中不存在该文件"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) DeleteFromRecycleBin(c *gin.Context) {
userCode := c.GetString("user_code")
if err := h.service.DeleteFromRecycleBin(userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "永久删除成功")
}
// Preview handles file preview requests.
// Preview 处理文件预览请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/preview/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"file_type": "string"}}
// - Not Found: 404 {"code": 404, "message": "文件不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/preview/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"file_type": "string"}}
// - 未找到: 404 {"code": 404, "message": "文件不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Preview(c *gin.Context) {
filename := c.Param("name")
fileType := "other"
if h.service.IsImageFile(filename) {
fileType = "image"
} else if h.service.IsTextFile(filename) {
fileType = "text"
} else if h.service.IsPDFFile(filename) {
fileType = "pdf"
}
response.Success(c, file.PreviewResponse{FileType: fileType})
}
// Content handles file content requests by streaming the file inline through the backend.
// Content 处理文件内容请求,通过后端内联流式传输文件。
//
// This endpoint is used for file preview (images, PDFs, text) by streaming
// the file content directly from MinIO through the Go backend, avoiding
// CORS issues and keeping MinIO internal.
//
// Request:
// - Method: GET
// - Path: /api/v1/files/content/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK (file content streamed inline)
// - Not Found: 404 {"code": 404, "message": "文件不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 此端点用于文件预览(图片、PDF、文本),通过 Go 后端直接从 MinIO
// 流式传输文件内容,避免 CORS 问题并保持 MinIO 内部可见。
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/content/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK(文件内容内联流式传输)
// - 未找到: 404 {"code": 404, "message": "文件不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Content(c *gin.Context) {
userCode := c.GetString("user_code")
filename := c.Param("name")
reader, size, contentType, err := h.service.GetFileStream(userCode, filename)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
defer reader.Close()
// Set headers for inline preview / 设置内联预览头部
c.Header("Content-Disposition", "inline")
c.Header("Content-Type", contentType)
c.Header("Content-Length", strconv.FormatInt(size, 10))
// Stream the file content to the response / 将文件内容流式传输到响应
c.Status(200)
io.Copy(c.Writer, reader)
}
// CheckUpload handles upload check requests for resumable uploads.
// CheckUpload 处理断点续传的上传检查请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/upload/check
// - Query: hash=<file_hash>&filename=<filename>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"uploaded_chunks": [0, 1, 2]}}
func (h *FileHandler) CheckUpload(c *gin.Context) {
userCode := c.GetString("user_code")
hash := c.Query("hash")
if hash == "" {
response.BadRequest(c, "缺少文件哈希")
return
}
chunks, err := h.service.GetUploadedChunks(userCode, hash)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, map[string]interface{}{"uploaded_chunks": chunks})
}
// UploadChunk handles chunk upload requests for resumable uploads.
// UploadChunk 处理断点续传的分片上传请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/upload/chunk
// - Content-Type: multipart/form-data
// - Body: file=<chunk_data>&hash=<file_hash>&chunk_index=<index>&chunk_count=<count>&filename=<filename>&file_size=<size>
func (h *FileHandler) UploadChunk(c *gin.Context) {
userCode := c.GetString("user_code")
f, _, err := c.Request.FormFile("file")
if err != nil {
response.BadRequest(c, "分片上传失败")
return
}
defer f.Close()
hash := c.PostForm("hash")
chunkIndexStr := c.PostForm("chunk_index")
fileSizeStr := c.PostForm("file_size")
if hash == "" || chunkIndexStr == "" || fileSizeStr == "" {
response.BadRequest(c, "缺少必要参数")
return
}
chunkIndex, err := strconv.Atoi(chunkIndexStr)
if err != nil {
response.BadRequest(c, "分片索引无效")
return
}
fileSize, err := strconv.ParseInt(fileSizeStr, 10, 64)
if err != nil {
response.BadRequest(c, "文件大小无效")
return
}
if err := h.authService.CheckStorageLimit(userCode, fileSize); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
data, err := io.ReadAll(f)
if err != nil {
response.BadRequest(c, "读取分片数据失败")
return
}
if err := h.service.SaveChunk(userCode, hash, chunkIndex, data); err != nil {
response.InternalError(c, err.Error())
return
}
response.SuccessMsg(c, "分片上传成功")
}
// CompleteUpload handles upload completion requests for resumable uploads.
// CompleteUpload 处理断点续传的上传完成请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/upload/complete
// - Body: {"hash": "string", "filename": "string", "chunk_count": int, "file_size": int}
func (h *FileHandler) CompleteUpload(c *gin.Context) {
userCode := c.GetString("user_code")
var req struct {
Hash string `json:"hash"`
Filename string `json:"filename"`
ChunkCount int `json:"chunk_count"`
FileSize int64 `json:"file_size"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if req.Hash == "" || req.Filename == "" || req.ChunkCount <= 0 {
response.BadRequest(c, "缺少必要参数")
return
}
if err := h.service.MergeChunks(userCode, req.Hash, req.Filename, req.ChunkCount, req.FileSize); err != nil {
response.InternalError(c, err.Error())
return
}
if err := h.authService.AddStorageUsed(userCode, req.FileSize); err != nil {
response.InternalError(c, "更新存储空间失败")
return
}
response.SuccessMsg(c, "上传完成")
}
@@ -0,0 +1,47 @@
package routes
import (
"cloudnest/internal/interface/http/handler"
"cloudnest/internal/middleware"
"github.com/gin-gonic/gin"
)
// RegisterAuthRoutes registers authentication-related routes.
// RegisterAuthRoutes 注册认证相关的路由。
//
// Parameters:
// - r: The router group to register routes on.
// - h: The authentication handler.
// - emailHandler: The email verification handler.
//
// Routes:
// - POST /register: Register a new user.
// - POST /login: Authenticate and get JWT token.
// - POST /email-code: Send email verification code.
// - GET /profile: Get user profile (requires JWT).
// - PUT /profile: Update user profile (requires JWT).
// - PUT /password: Change password (requires JWT).
//
// 参数:
// - r: 要注册路由的路由器组。
// - h: 认证处理器。
// - emailHandler: 邮箱验证码处理器。
//
// 路由:
// - POST /register: 注册新用户。
// - POST /login: 认证并获取 JWT 令牌。
// - POST /email-code: 发送邮箱验证码。
// - GET /profile: 获取用户个人信息(需要 JWT)。
// - PUT /profile: 更新用户个人信息(需要 JWT)。
// - PUT /password: 修改密码(需要 JWT)。
func RegisterAuthRoutes(r *gin.RouterGroup, h *handler.AuthHandler, emailHandler *handler.EmailHandler, jwtSecret string) {
r.POST("/register", h.Register)
r.POST("/login", h.Login)
r.POST("/email-code", emailHandler.SendEmailCode)
protected := r.Group("")
protected.Use(middleware.JWT(jwtSecret))
protected.GET("/profile", h.Profile)
protected.PUT("/profile", h.UpdateProfile)
protected.PUT("/password", h.ChangePassword)
}
@@ -0,0 +1,17 @@
package routes
import (
"cloudnest/internal/interface/http/handler"
"github.com/gin-gonic/gin"
)
// RegisterCaptchaRoutes registers captcha-related routes.
// RegisterCaptchaRoutes 注册验证码相关的路由。
//
// Routes:
// - GET /captcha: Generate a new captcha (returns captcha_id and image_url).
// - GET /captcha/:id/image: Get captcha image by ID.
func RegisterCaptchaRoutes(r *gin.RouterGroup, h *handler.CaptchaHandler) {
r.GET("/captcha", h.GenerateCaptcha)
r.GET("/captcha/:id/image", h.GetCaptchaImage)
}
@@ -0,0 +1,53 @@
package routes
import (
"cloudnest/internal/interface/http/handler"
"github.com/gin-gonic/gin"
)
// RegisterFileRoutes registers file management routes.
// RegisterFileRoutes 注册文件管理路由。
//
// Parameters:
// - r: The router group to register routes on.
// - h: The file handler.
//
// Routes:
// - POST /upload: Upload a file.
// - GET /: List all files for the authenticated user.
// - GET /download/:name: Get a pre-signed download URL.
// - DELETE /:name: Delete a file.
//
// Note:
// - These routes require JWT authentication.
//
// 参数:
// - r: 要注册路由的路由器组。
// - h: 文件处理器。
//
// 路由:
// - POST /upload: 上传文件。
// - GET /: 获取认证用户的所有文件列表。
// - GET /download/:name: 获取预签名下载 URL。
// - DELETE /:name: 删除文件。
//
// 注意:
// - 这些路由需要 JWT 认证。
func RegisterFileRoutes(r *gin.RouterGroup, h *handler.FileHandler) {
r.POST("/upload", h.Upload)
r.GET("/upload/check", h.CheckUpload)
r.POST("/upload/chunk", h.UploadChunk)
r.POST("/upload/complete", h.CompleteUpload)
r.GET("/", h.List)
r.GET("/download/:name", h.Download)
r.GET("/content/:name", h.Content)
r.DELETE("/:name", h.Delete)
r.POST("/soft-delete/:name", h.SoftDelete)
r.POST("/favorite/:name", h.Favorite)
r.DELETE("/favorite/:name", h.Unfavorite)
r.GET("/favorites", h.ListFavorites)
r.GET("/recycle-bin", h.RecycleBin)
r.POST("/restore/:name", h.Restore)
r.DELETE("/recycle-bin/:name", h.DeleteFromRecycleBin)
r.GET("/preview/:name", h.Preview)
}
+102
View File
@@ -0,0 +1,102 @@
package routes
import (
"cloudnest/internal/interface/http/handler"
"cloudnest/internal/middleware"
"github.com/gin-gonic/gin"
)
// ServiceType defines the type of service that is running.
// ServiceType 定义运行的服务类型。
type ServiceType string
const (
// ServiceTypeAuth is the authentication service.
// ServiceTypeAuth 是认证服务。
ServiceTypeAuth ServiceType = "auth"
// ServiceTypeFile is the file management service.
// ServiceTypeFile 是文件管理服务。
ServiceTypeFile ServiceType = "file"
// ServiceTypeAll is for testing purposes, registers all routes.
// ServiceTypeAll 用于测试目的,注册所有路由。
ServiceTypeAll ServiceType = "all"
)
// SetupRoutes registers API routes based on the service type.
// SetupRoutes 根据服务类型注册 API 路由。
//
// Parameters:
// - engine: The Gin engine instance.
// - serviceType: The type of service (auth, file, or all).
// - authHandler: The authentication handler (required for auth service).
// - fileHandler: The file handler (required for file service).
// - jwtSecret: The secret key for JWT token validation.
//
// Note:
// - Authentication routes are public and don't require JWT.
// - File routes require JWT authentication via middleware.JWT.
// - Each service should only register its own routes to avoid conflicts.
//
// 参数:
// - engine: Gin 引擎实例。
// - serviceType: 服务类型 (auth, file, 或 all)。
// - authHandler: 认证处理器(认证服务必需)。
// - fileHandler: 文件处理器(文件服务必需)。
// - jwtSecret: JWT 令牌验证的密钥。
//
// 注意:
// - 认证路由是公开的,不需要 JWT。
// - 文件路由需要通过 middleware.JWT 进行 JWT 认证。
// - 每个服务应该只注册自己的路由以避免冲突。
func SetupRoutes(engine *gin.Engine, serviceType ServiceType, authHandler *handler.AuthHandler, fileHandler *handler.FileHandler, captchaHandler *handler.CaptchaHandler, emailHandler *handler.EmailHandler, jwtSecret string) {
api := engine.Group("/api/v1")
RegisterCaptchaRoutes(api, captchaHandler)
switch serviceType {
case ServiceTypeAuth:
auth := api.Group("/auth")
RegisterAuthRoutes(auth, authHandler, emailHandler, jwtSecret)
case ServiceTypeFile:
files := api.Group("/files")
files.Use(middleware.JWT(jwtSecret))
RegisterFileRoutes(files, fileHandler)
case ServiceTypeAll:
auth := api.Group("/auth")
RegisterAuthRoutes(auth, authHandler, emailHandler, jwtSecret)
files := api.Group("/files")
files.Use(middleware.JWT(jwtSecret))
RegisterFileRoutes(files, fileHandler)
}
}
// SetupAuthRoutes registers only authentication routes.
// SetupAuthRoutes 只注册认证路由。
//
// This function is used by the authentication service to register
// only its own routes, avoiding conflicts with the file service.
//
// 此函数用于认证服务注册自己的路由,避免与文件服务冲突。
func SetupAuthRoutes(engine *gin.Engine, authHandler *handler.AuthHandler, captchaHandler *handler.CaptchaHandler, emailHandler *handler.EmailHandler, jwtSecret string) {
api := engine.Group("/api/v1")
RegisterCaptchaRoutes(api, captchaHandler)
auth := api.Group("/auth")
RegisterAuthRoutes(auth, authHandler, emailHandler, jwtSecret)
}
// SetupFileRoutes registers only file management routes.
// SetupFileRoutes 只注册文件管理路由。
//
// This function is used by the file service to register
// only its own routes, avoiding conflicts with the auth service.
//
// 此函数用于文件服务注册自己的路由,避免与认证服务冲突。
func SetupFileRoutes(engine *gin.Engine, fileHandler *handler.FileHandler, captchaHandler *handler.CaptchaHandler, jwtSecret string) {
api := engine.Group("/api/v1")
RegisterCaptchaRoutes(api, captchaHandler)
files := api.Group("/files")
files.Use(middleware.JWT(jwtSecret))
RegisterFileRoutes(files, fileHandler)
}
+126
View File
@@ -0,0 +1,126 @@
package middleware
import (
"net/http"
"strings"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
// allowedOrigins defines the list of allowed origins for development.
// In production, this should be loaded from configuration.
// allowedOrigins 定义开发环境允许的源列表。生产环境应从配置加载。
var allowedOrigins = []string{
"http://localhost:8081",
"https://localhost:8081",
"http://127.0.0.1:8081",
"https://127.0.0.1:8081",
}
// isAllowedOrigin checks if the given origin is in the allowed list.
// isAllowedOrigin 检查给定的源是否在允许列表中。
func isAllowedOrigin(origin string) bool {
for _, o := range allowedOrigins {
if strings.EqualFold(o, origin) {
return true
}
}
return false
}
// CORS creates a middleware that handles Cross-Origin Resource Sharing.
// CORS 创建一个处理跨域资源共享(CORS)的中间件。
//
// This middleware sets the necessary headers to allow cross-origin requests,
// including the allowed origins, methods, and headers.
//
// Important:
// - For requests with Authorization header, the browser requires the
// Access-Control-Allow-Origin header to be a specific origin, not "*".
// - This middleware dynamically sets the origin to match the request's
// Origin header, which allows cross-origin requests with credentials.
// - In production, you should restrict allowed origins to specific domains.
//
// 此中间件设置必要的头部以允许跨域请求,包括允许的来源、方法和头部。
//
// 重要:
// - 对于带有 Authorization 头部的请求,浏览器要求 Access-Control-Allow-Origin
// 必须是具体的源,不能是 "*"。
// - 此中间件动态设置源以匹配请求的 Origin 头部,允许带凭据的跨域请求。
// - 在生产环境中,应将允许的源限制为特定域名。
//
// Returns:
// - gin.HandlerFunc: The CORS middleware handler.
//
// 返回值:
// - gin.HandlerFunc: CORS 中间件处理器。
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
// Get the Origin header from the request
// 从请求中获取 Origin 头部
origin := c.Request.Header.Get("Origin")
logger.Info("CORS middleware invoked",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"origin", origin,
)
// Determine the allowed origin to return.
// If the origin is in our allowed list, echo it back.
// Otherwise, for development, also echo it back to be permissive.
// In production, you should reject unallowed origins.
//
// 确定要返回的允许源。如果在允许列表中,则回显。
// 开发环境下也回显其他源以方便调试。生产环境应拒绝未允许的源。
allowedOrigin := ""
if origin != "" {
if isAllowedOrigin(origin) {
allowedOrigin = origin
} else {
// Development fallback: allow any origin for easier local testing
// 开发环境回退:允许任何源以方便本地测试
allowedOrigin = origin
logger.Warn("CORS request from unknown origin",
"origin", origin,
"path", c.Request.URL.Path,
)
}
}
// Set Access-Control-Allow-Origin to the request's origin
// This allows cross-origin requests with Authorization header
// 将 Access-Control-Allow-Origin 设置为请求的源
// 这允许带 Authorization 头部的跨域请求
if allowedOrigin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
}
// Allow credentials (cookies, Authorization headers)
// 允许凭据(cookies、Authorization 头部)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
// Allow common HTTP methods
// 允许常用 HTTP 方法
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
// Allow common headers including Authorization
// 允许常用头部,包括 Authorization
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin, X-Request-ID")
// Expose headers to the client
// 暴露头部给客户端
c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, Content-Length, X-Request-ID")
// Handle OPTIONS preflight requests
// 处理 OPTIONS 预检请求
if c.Request.Method == "OPTIONS" {
logger.Info("CORS handling OPTIONS preflight", "origin", origin, "path", c.Request.URL.Path)
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
}
}
+57
View File
@@ -0,0 +1,57 @@
package middleware
import (
"cloudnest/internal/pkg/errors"
"cloudnest/internal/pkg/response"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
// ErrorHandler creates a middleware that handles errors and panics.
// ErrorHandler 创建一个处理错误和 panic 的中间件。
//
// This middleware:
// 1. Recovers from panics and logs the error.
// 2. Handles errors stored in the Gin context after processing.
// 3. Formats consistent error responses.
//
// Returns:
// - gin.HandlerFunc: The error handling middleware handler.
//
// Note:
// - Should be added as the first middleware to catch all panics.
// - Uses the custom response package for consistent error formatting.
//
// 此中间件:
// 1. 从 panic 中恢复并记录错误。
// 2. 处理处理后存储在 Gin 上下文中的错误。
// 3. 格式化一致的错误响应。
//
// 返回值:
// - gin.HandlerFunc: 错误处理中间件处理器。
//
// 注意:
// - 应该作为第一个中间件添加以捕获所有 panic。
// - 使用自定义的 response 包进行一致的错误格式化。
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
logger.Error("panic recovered", "error", err)
response.InternalError(c, "服务器内部错误")
}
}()
c.Next()
if len(c.Errors) > 0 {
err := c.Errors.Last().Err
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
logger.Error("request error", "error", err)
response.InternalError(c, err.Error())
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
package middleware
import (
"strings"
"cloudnest/internal/pkg/jwt"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
func JWT(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "OPTIONS" {
c.Next()
return
}
auth := c.GetHeader("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
response.Unauthorized(c, "未提供认证令牌")
c.Abort()
return
}
tokenStr := strings.TrimPrefix(auth, "Bearer ")
claims, err := jwt.ParseToken(tokenStr, jwtSecret)
if err != nil {
response.Unauthorized(c, "令牌无效")
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("user_code", claims.UserCode)
c.Next()
}
}
+53
View File
@@ -0,0 +1,53 @@
package crypto
import "golang.org/x/crypto/bcrypt"
// HashPassword hashes a plain text password using bcrypt.
// HashPassword 使用 bcrypt 对明文密码进行加密。
//
// Parameters:
// - password: The plain text password to hash.
//
// Returns:
// - string: The hashed password.
// - error: An error if hashing fails.
//
// Note:
// - Uses bcrypt.DefaultCost (10) for hashing.
// - The resulting hash is approximately 60 characters long.
//
// 参数:
// - password: 要加密的明文密码。
//
// 返回值:
// - string: 加密后的密码。
// - error: 如果加密失败则返回错误。
//
// 注意:
// - 使用 bcrypt.DefaultCost (10) 进行加密。
// - 生成的哈希值大约为 60 个字符。
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPasswordHash compares a plain text password with a hashed password.
// CheckPasswordHash 比较明文密码和加密密码。
//
// Parameters:
// - password: The plain text password to check.
// - hash: The hashed password to compare against.
//
// Returns:
// - bool: true if the password matches the hash, false otherwise.
//
// 参数:
// - password: 要检查的明文密码。
// - hash: 要比较的加密密码。
//
// 返回值:
// - bool: 如果密码匹配哈希值则返回 true,否则返回 false。
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
+258
View File
@@ -0,0 +1,258 @@
package errors
import "net/http"
// AppError represents an application-specific error.
// AppError 表示应用程序特定的错误。
//
// This struct extends the standard error interface with additional fields
// for HTTP status codes and custom error codes.
//
// 此结构体扩展了标准的 error 接口,添加了 HTTP 状态码和自定义错误码字段。
type AppError struct {
StatusCode int
Code int
Message string
Err error
}
// Error implements the error interface.
// Error 实现了 error 接口。
//
// Returns:
// - string: The error message, including the wrapped error if present.
//
// 返回值:
// - string: 错误消息,如果存在包装的错误则包含该错误。
func (e *AppError) Error() string {
if e.Err != nil {
return e.Message + ": " + e.Err.Error()
}
return e.Message
}
// New creates a new AppError with the specified code and message.
// New 创建一个具有指定代码和消息的新 AppError。
//
// Parameters:
// - code: The custom error code.
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the new AppError instance.
//
// 参数:
// - code: 自定义错误代码。
// - message: 错误消息。
//
// 返回值:
// - *AppError: 新创建的 AppError 实例指针。
func New(code int, message string) *AppError {
return &AppError{
StatusCode: http.StatusOK,
Code: code,
Message: message,
}
}
// NewWithErr creates a new AppError with the specified code, message, and wrapped error.
// NewWithErr 创建一个具有指定代码、消息和包装错误的新 AppError。
//
// Parameters:
// - code: The custom error code.
// - message: The error message.
// - err: The wrapped error.
//
// Returns:
// - *AppError: A pointer to the new AppError instance.
//
// 参数:
// - code: 自定义错误代码。
// - message: 错误消息。
// - err: 包装的错误。
//
// 返回值:
// - *AppError: 新创建的 AppError 实例指针。
func NewWithErr(code int, message string, err error) *AppError {
return &AppError{
StatusCode: http.StatusOK,
Code: code,
Message: message,
Err: err,
}
}
// BadRequest creates an AppError for 400 Bad Request.
// BadRequest 创建一个 400 Bad Request 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the BadRequest AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: BadRequest AppError 的指针。
func BadRequest(message string) *AppError {
return &AppError{
StatusCode: http.StatusBadRequest,
Code: 400,
Message: message,
}
}
// Unauthorized creates an AppError for 401 Unauthorized.
// Unauthorized 创建一个 401 Unauthorized 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the Unauthorized AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: Unauthorized AppError 的指针。
func Unauthorized(message string) *AppError {
return &AppError{
StatusCode: http.StatusUnauthorized,
Code: 401,
Message: message,
}
}
// Forbidden creates an AppError for 403 Forbidden.
// Forbidden 创建一个 403 Forbidden 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the Forbidden AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: Forbidden AppError 的指针。
func Forbidden(message string) *AppError {
return &AppError{
StatusCode: http.StatusForbidden,
Code: 403,
Message: message,
}
}
// Conflict creates an AppError for 409 Conflict.
// Conflict 创建一个 409 Conflict 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the Conflict AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: Conflict AppError 的指针。
func Conflict(message string) *AppError {
return &AppError{
StatusCode: http.StatusConflict,
Code: 409,
Message: message,
}
}
// NotFound creates an AppError for 404 Not Found.
// NotFound 创建一个 404 Not Found 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the NotFound AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: NotFound AppError 的指针。
func NotFound(message string) *AppError {
return &AppError{
StatusCode: http.StatusNotFound,
Code: 404,
Message: message,
}
}
// InternalError creates an AppError for 500 Internal Server Error.
// InternalError 创建一个 500 Internal Server Error 的 AppError。
//
// Parameters:
// - message: The error message.
// - err: The wrapped error (typically the underlying cause).
//
// Returns:
// - *AppError: A pointer to the InternalError AppError.
//
// 参数:
// - message: 错误消息。
// - err: 包装的错误(通常是根本原因)。
//
// 返回值:
// - *AppError: InternalError AppError 的指针。
func InternalError(message string, err error) *AppError {
return &AppError{
StatusCode: http.StatusInternalServerError,
Code: 500,
Message: message,
Err: err,
}
}
// IsAppError checks if an error is an AppError.
// IsAppError 检查错误是否是 AppError。
//
// Parameters:
// - err: The error to check.
//
// Returns:
// - bool: true if the error is an AppError, false otherwise.
//
// 参数:
// - err: 要检查的错误。
//
// 返回值:
// - bool: 如果错误是 AppError 则返回 true,否则返回 false。
func IsAppError(err error) bool {
_, ok := err.(*AppError)
return ok
}
// AsAppError attempts to convert an error to an AppError.
// AsAppError 尝试将错误转换为 AppError。
//
// Parameters:
// - err: The error to convert.
//
// Returns:
// - *AppError: The converted AppError if successful.
// - bool: true if the conversion was successful, false otherwise.
//
// 参数:
// - err: 要转换的错误。
//
// 返回值:
// - *AppError: 如果转换成功则返回转换后的 AppError。
// - bool: 如果转换成功则返回 true,否则返回 false。
func AsAppError(err error) (*AppError, bool) {
e, ok := err.(*AppError)
return e, ok
}
+41
View File
@@ -0,0 +1,41 @@
package jwt
import (
"time"
"cloudnest/internal/pkg/errors"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
UserCode string `json:"user_code"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username, userCode string, secret string, expiresIn time.Duration) (string, error) {
claims := Claims{
UserID: userID,
Username: username,
UserCode: userCode,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ParseToken(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, errors.Unauthorized("invalid token")
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.Unauthorized("invalid token")
}
+131
View File
@@ -0,0 +1,131 @@
package logger
import (
"encoding/json"
"fmt"
"io"
"os"
"sync"
"sync/atomic"
"time"
)
// Level represents log level.
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarn
LevelError
)
func (l Level) String() string {
switch l {
case LevelDebug:
return "DEBUG"
case LevelInfo:
return "INFO"
case LevelWarn:
return "WARN"
case LevelError:
return "ERROR"
default:
return "INFO"
}
}
// Logger is a simple JSON logger compatible with Go 1.20.
type Logger struct {
level Level
writer io.Writer
mu sync.Mutex
}
// Default is the global default logger.
var Default atomic.Value
func init() {
Default.Store(&Logger{level: LevelInfo, writer: os.Stderr})
}
func getDefault() *Logger {
if l, ok := Default.Load().(*Logger); ok && l != nil {
return l
}
return &Logger{level: LevelInfo, writer: os.Stderr}
}
// Init initializes the logger with the specified log level.
func Init(level string) {
InitWithWriter(level, os.Stdout)
}
// InitWithWriter initializes the logger with the specified log level and writer.
func InitWithWriter(level string, writer io.Writer) {
var lvl Level
switch level {
case "debug":
lvl = LevelDebug
case "info":
lvl = LevelInfo
case "warn":
lvl = LevelWarn
case "error":
lvl = LevelError
default:
lvl = LevelInfo
}
if writer == nil {
writer = os.Stdout
}
Default.Store(&Logger{level: lvl, writer: writer})
}
func (l *Logger) log(level Level, msg string, args ...any) {
if level < l.level {
return
}
record := map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"level": level.String(),
"msg": msg,
}
for i := 0; i < len(args)-1; i += 2 {
key, ok := args[i].(string)
if !ok {
continue
}
record[key] = args[i+1]
}
l.mu.Lock()
defer l.mu.Unlock()
data, _ := json.Marshal(record)
fmt.Fprintln(l.writer, string(data))
}
// Debug logs a message at Debug level.
func Debug(msg string, args ...any) {
getDefault().log(LevelDebug, msg, args...)
}
// Info logs a message at Info level.
func Info(msg string, args ...any) {
getDefault().log(LevelInfo, msg, args...)
}
// Warn logs a message at Warn level.
func Warn(msg string, args ...any) {
getDefault().log(LevelWarn, msg, args...)
}
// Error logs a message at Error level.
func Error(msg string, args ...any) {
getDefault().log(LevelError, msg, args...)
}
// Fatal logs a message at Error level and exits.
func Fatal(msg string, args ...any) {
getDefault().log(LevelError, msg, args...)
os.Exit(1)
}
+182
View File
@@ -0,0 +1,182 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response represents the standard API response structure.
// Response 表示标准的 API 响应结构。
//
// This struct provides a consistent format for all API responses.
//
// 此结构体为所有 API 响应提供了一致的格式。
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Success sends a successful response with data.
// Success 发送包含数据的成功响应。
//
// Parameters:
// - c: The Gin context.
// - data: The data to include in the response.
//
// Note:
// - Sets HTTP status code to 200 OK.
// - Sets code to 0 (success).
//
// 参数:
// - c: Gin 上下文。
// - data: 要包含在响应中的数据。
//
// 注意:
// - 设置 HTTP 状态码为 200 OK。
// - 设置 code 为 0(成功)。
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: 0,
Message: "success",
Data: data,
})
}
// SuccessMsg sends a successful response with only a message.
// SuccessMsg 发送仅包含消息的成功响应。
//
// Parameters:
// - c: The Gin context.
// - message: The success message.
//
// Note:
// - Sets HTTP status code to 200 OK.
// - Sets code to 0 (success).
//
// 参数:
// - c: Gin 上下文。
// - message: 成功消息。
//
// 注意:
// - 设置 HTTP 状态码为 200 OK。
// - 设置 code 为 0(成功)。
func SuccessMsg(c *gin.Context, message string) {
c.JSON(http.StatusOK, Response{
Code: 0,
Message: message,
})
}
// Error sends an error response with a custom code and message.
// Error 发送包含自定义代码和消息的错误响应。
//
// Parameters:
// - c: The Gin context.
// - code: The custom error code.
// - message: The error message.
//
// Note:
// - Sets HTTP status code to 200 OK.
// - The custom code indicates the specific error type.
//
// 参数:
// - c: Gin 上下文。
// - code: 自定义错误代码。
// - message: 错误消息。
//
// 注意:
// - 设置 HTTP 状态码为 200 OK。
// - 自定义代码表示特定的错误类型。
func Error(c *gin.Context, code int, message string) {
c.JSON(http.StatusOK, Response{
Code: code,
Message: message,
})
}
// BadRequest sends a 400 Bad Request response.
// BadRequest 发送 400 Bad Request 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func BadRequest(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, Response{
Code: 400,
Message: message,
})
}
// Unauthorized sends a 401 Unauthorized response.
// Unauthorized 发送 401 Unauthorized 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, Response{
Code: 401,
Message: message,
})
}
// Forbidden sends a 403 Forbidden response.
// Forbidden 发送 403 Forbidden 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func Forbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, Response{
Code: 403,
Message: message,
})
}
// NotFound sends a 404 Not Found response.
// NotFound 发送 404 Not Found 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func NotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, Response{
Code: 404,
Message: message,
})
}
// InternalError sends a 500 Internal Server Error response.
// InternalError 发送 500 Internal Server Error 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func InternalError(c *gin.Context, message string) {
c.JSON(http.StatusInternalServerError, Response{
Code: 500,
Message: message,
})
}
+25
View File
@@ -0,0 +1,25 @@
package validator
import (
"cloudnest/internal/pkg/logger"
"github.com/go-playground/validator/v10"
)
// Validator is the global validator instance.
// Validator 是全局验证器实例。
var Validator *validator.Validate
// Init initializes the validator.
// Init 初始化验证器。
//
// Note:
// - Should be called once during application startup.
// - Creates a new validator instance with default settings.
//
// 注意:
// - 应在应用程序启动时调用一次。
// - 使用默认设置创建新的验证器实例。
func Init() {
Validator = validator.New()
logger.Info("validator initialized")
}
+65
View File
@@ -0,0 +1,65 @@
@echo off
chcp 65001 >nul
echo === CloudNest 本地开发启动 ===
:: 设置默认环境 / Set default environment
if "%CLOUDNEST_ENV%"=="" set CLOUDNEST_ENV=dev
echo 环境 / Environment: %CLOUDNEST_ENV%
where go >nul 2>nul
if %errorlevel% neq 0 (
echo [错误] 未找到 Go。请先安装 Go 1.25+: https://go.dev/dl/
pause
exit /b 1
)
:: 检查是否跳过基础设施 / Check if skip infrastructure
if /i "%SKIP_INFRA%"=="true" (
echo [信息] SKIP_INFRA=true,跳过基础设施启动
goto :skipInfra
)
where docker >nul 2>nul
if %errorlevel% neq 0 (
echo [错误] 未找到 Docker。请先安装 Docker Desktop
echo 或者设置 SKIP_INFRA=true 使用外部基础设施
pause
exit /b 1
)
echo [信息] 检查基础设施...
docker ps --filter "name=cloudnest-mysql" --format "{{.Names}}" | findstr "cloudnest-mysql" >nul
if %errorlevel% neq 0 (
echo [信息] 启动基础设施容器...
docker compose -f docker-compose.infra.yml up -d
echo [信息] 等待 MySQL 就绪(约 30 秒)...
timeout /t 30 /nobreak >nul
)
:skipInfra
echo [信息] 下载 Go 依赖...
set "GOROOT=D:\go"
set "PATH=D:\go\bin;%PATH%"
go mod tidy
if %errorlevel% neq 0 (
echo [错误] go mod tidy 失败
pause
exit /b 1
)
echo [信息] 启动 Auth Service (端口: 8081)...
start "Auth Service" cmd /k "set CLOUDNEST_ENV=%CLOUDNEST_ENV% && set GOROOT=D:\go && set PATH=D:\go\bin;!PATH! && go run ./cmd/auth-service"
echo [信息] 启动 File Service (端口: 8082)...
start "File Service" cmd /k "set CLOUDNEST_ENV=%CLOUDNEST_ENV% && set SERVER_PORT=8082 && set GOROOT=D:\go && set PATH=D:\go\bin;!PATH! && go run ./cmd/file-service"
echo.
echo === 本地服务已启动 ===
echo 配置文件 / Config: configs/config.%CLOUDNEST_ENV%.yaml
echo Auth Service: http://localhost:8081 (若占用则自动切换端口)
echo File Service: http://localhost:8082 (若占用则自动切换端口)
echo MinIO Console: http://localhost:9001
echo.
pause
+115
View File
@@ -0,0 +1,115 @@
# CloudNest 本地开发启动脚本 (Windows PowerShell)
# CloudNest Local Development Startup Script (Windows PowerShell)
#
# 前置条件 / Prerequisites:
# 1. 安装 Go 1.25+ (https://go.dev/dl/)
# 2. 安装 Docker Desktop (https://www.docker.com/products/docker-desktop/)
#
# 用法 / Usage:
# .\start-local.ps1 # 使用默认 dev 配置启动(自动启动基础设施)
# $env:CLOUDNEST_ENV="test"; .\start-local.ps1 # 使用 test 配置启动
#
# 环境变量 / Environment Variables:
# CLOUDNEST_ENV - 运行环境: dev / test / prod(默认: dev
# SKIP_INFRA - 是否跳过基础设施启动: true / false(默认: false
# DB_PASSWORD - MySQL 密码(默认: sun900120
# DB_NAME - 数据库名(默认: cloudnest
# REDIS_PORT - Redis 端口(默认: 6379
# MINIO_ACCESS_KEY - MinIO 访问密钥(默认: minioadmin
# MINIO_SECRET_KEY - MinIO 秘密密钥(默认: minioadmin
$ErrorActionPreference = "Stop"
function Test-Command($cmd) {
return [bool](Get-Command $cmd -ErrorAction SilentlyContinue)
}
# 设置默认环境 / Set default environment
if (-not $env:CLOUDNEST_ENV) {
$env:CLOUDNEST_ENV = "dev"
}
Write-Host "=== CloudNest 本地开发启动 ===" -ForegroundColor Cyan
Write-Host "环境 / Environment: $($env:CLOUDNEST_ENV)" -ForegroundColor Cyan
# 检查依赖 / Check dependencies
if (-not (Test-Command "go")) {
Write-Host "[错误] 未找到 Go。请先安装 Go 1.25+: https://go.dev/dl/" -ForegroundColor Red
exit 1
}
# 检查基础设施 / Check infrastructure
$skipInfra = $false
if ($env:SKIP_INFRA -eq "true") {
$skipInfra = $true
Write-Host "[信息] SKIP_INFRA=true,跳过基础设施启动" -ForegroundColor Yellow
} else {
if (-not (Test-Command "docker")) {
Write-Host "[错误] 未找到 Docker。请先安装 Docker Desktop: https://www.docker.com/products/docker-desktop/" -ForegroundColor Red
Write-Host " 或者设置 SKIP_INFRA=true 使用外部基础设施" -ForegroundColor Yellow
exit 1
}
# 检查基础设施是否已启动 / Check if infrastructure is running
$mysqlRunning = docker ps --filter "name=cloudnest-mysql" --format "{{.Names}}" 2>$null
$redisRunning = docker ps --filter "name=cloudnest-redis" --format "{{.Names}}" 2>$null
$minioRunning = docker ps --filter "name=cloudnest-minio" --format "{{.Names}}" 2>$null
if (-not $mysqlRunning -or -not $redisRunning -or -not $minioRunning) {
Write-Host "[信息] 基础设施未运行,尝试自动启动..." -ForegroundColor Yellow
# 构建 Docker Compose 命令(支持环境变量覆盖)
$composeCmd = "docker compose -f docker-compose.infra.yml up -d"
Write-Host "[命令] $composeCmd" -ForegroundColor Cyan
Invoke-Expression $composeCmd
Write-Host "[信息] 等待 MySQL 就绪..." -ForegroundColor Yellow
$maxWait = 60
$waited = 0
while ($waited -lt $maxWait) {
$healthy = docker inspect --format='{{.State.Health.Status}}' cloudnest-mysql 2>$null
if ($healthy -eq "healthy") { break }
Start-Sleep -Seconds 2
$waited += 2
Write-Host " 已等待 ${waited}s..." -ForegroundColor DarkGray
}
if ($healthy -ne "healthy") {
Write-Host "[警告] MySQL 健康检查超时,可能还在初始化,继续尝试启动服务..." -ForegroundColor Yellow
}
} else {
Write-Host "[信息] 基础设施已运行" -ForegroundColor Green
}
}
# 下载 Go 依赖 / Download Go dependencies
Write-Host "[信息] 下载 Go 依赖..." -ForegroundColor Cyan
$env:GOROOT = "D:\go"
$env:PATH = "D:\go\bin;" + $env:PATH
go mod tidy
if ($LASTEXITCODE -ne 0) {
Write-Host "[错误] go mod tidy 失败" -ForegroundColor Red
exit 1
}
# 编译并启动 Auth Service / Build and start Auth Service
Write-Host "[信息] 启动 Auth Service (端口: 8081)..." -ForegroundColor Cyan
Start-Process powershell -ArgumentList "-NoExit", "-Command", "`$Host.UI.RawUI.WindowTitle='Auth Service'; `$env:CLOUDNEST_ENV='$($env:CLOUDNEST_ENV)'; `$env:GOROOT='D:\go'; `$env:PATH='D:\go\bin;' + `$env:PATH; cd '$PWD'; go run ./cmd/auth-service" -WindowStyle Normal
# 编译并启动 File Service (固定端口 8082,避免与 Auth Service 冲突)
# Build and start File Service (fixed port 8082 to avoid conflict with Auth Service)
Write-Host "[信息] 启动 File Service (端口: 8082)..." -ForegroundColor Cyan
Start-Process powershell -ArgumentList "-NoExit", "-Command", "`$Host.UI.RawUI.WindowTitle='File Service'; `$env:CLOUDNEST_ENV='$($env:CLOUDNEST_ENV)'; `$env:SERVER_PORT='8082'; `$env:GOROOT='D:\go'; `$env:PATH='D:\go\bin;' + `$env:PATH; cd '$PWD'; go run ./cmd/file-service" -WindowStyle Normal
Write-Host ""
Write-Host "=== 本地服务已启动 ===" -ForegroundColor Green
Write-Host "配置文件 / Config: configs/config.$($env:CLOUDNEST_ENV).yaml" -ForegroundColor Green
Write-Host "Auth Service: http://localhost:8081 (若占用则自动切换端口)" -ForegroundColor Green
Write-Host "File Service: http://localhost:8082 (若占用则自动切换端口)" -ForegroundColor Green
Write-Host "MinIO Console: http://localhost:9001 (minioadmin / minioadmin)" -ForegroundColor Green
Write-Host ""
Write-Host "测试命令 / Test commands:" -ForegroundColor Cyan
Write-Host " 注册 / Register: curl -X POST http://localhost:8081/api/v1/auth/register -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"123456\"}'"
Write-Host " 登录 / Login: curl -X POST http://localhost:8081/api/v1/auth/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"123456\"}'"
Write-Host ""
Write-Host "按 Enter 退出此窗口(服务进程会继续在后台运行)" -ForegroundColor DarkGray
Read-Host
+51
View File
@@ -0,0 +1,51 @@
# CloudNest 本地服务清理脚本
# CloudNest Local Services Cleanup Script
#
# 用法 / Usage: .\stop-local.ps1
# 停止所有本地运行的 auth-service、file-service、go 进程
$ErrorActionPreference = "Continue"
Write-Host "=== CloudNest 服务清理 ===" -ForegroundColor Cyan
# 查找端口占用 / Find port usage
Write-Host "[信息] 检查端口占用..." -ForegroundColor Yellow
$ports = Get-NetTCPConnection -LocalPort 8080,8081,8082 -ErrorAction SilentlyContinue
if ($ports) {
$ports | Select-Object LocalPort, OwningProcess, State | Format-Table
} else {
Write-Host " 无端口占用" -ForegroundColor Green
}
# 终止 Go 相关进程 / Kill Go-related processes
Write-Host "[信息] 终止服务进程..." -ForegroundColor Yellow
$processes = Get-Process | Where-Object {
$_.ProcessName -eq "go" -or
$_.ProcessName -eq "auth-service" -or
$_.ProcessName -eq "file-service"
}
if ($processes) {
$processes | Select-Object Id, ProcessName, StartTime | Format-Table
foreach ($p in $processes) {
Write-Host " 终止进程: $($p.ProcessName) (PID: $($p.Id))" -ForegroundColor DarkGray
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
Write-Host "[完成] 服务进程已终止" -ForegroundColor Green
} else {
Write-Host " 无服务进程运行" -ForegroundColor Green
}
# 再次检查端口 / Verify ports are free
Write-Host "[验证] 再次检查端口..." -ForegroundColor Yellow
$remaining = Get-NetTCPConnection -LocalPort 8080,8081,8082 -ErrorAction SilentlyContinue
if ($remaining) {
Write-Host "[警告] 仍有端口占用,可能需要手动处理" -ForegroundColor Red
$remaining | Select-Object LocalPort, OwningProcess, State | Format-Table
} else {
Write-Host "[完成] 端口 8080/8081/8082 已释放" -ForegroundColor Green
}
Write-Host ""
Write-Host "提示: Docker 基础设施容器仍在运行,如需停止请执行:" -ForegroundColor DarkGray
Write-Host " docker compose -f docker-compose.infra.yml down" -ForegroundColor DarkGray
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB