首次提交:初始化项目代码
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
deployments/k8s
|
||||
*.md
|
||||
Dockerfile*
|
||||
deploy-all.sh
|
||||
+18
@@ -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
@@ -0,0 +1,19 @@
|
||||
# Go
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# 构建产物
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# 部署脚本无执行权限提醒
|
||||
*.sh.bak
|
||||
Binary file not shown.
+1320
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
- **配置管理**: Viper(YAML + 环境变量)
|
||||
- **日志**: Go 标准库 log/slog(JSON 格式)
|
||||
|
||||
## 架构设计
|
||||
|
||||
项目采用 **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
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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'"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Reference in New Issue
Block a user