首次提交:初始化项目代码
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user