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

This commit is contained in:
sunct
2026-07-31 15:24:14 +08:00
commit 8d3c97bd01
73 changed files with 11720 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
package auth
// RegisterRequest represents the request body for user registration.
// RegisterRequest 表示用户注册的请求体。
//
// Fields:
// - Username: The desired username (3-50 characters).
// - Password: The desired password (minimum 6 characters).
//
// Validation:
// - Username is required and must be 3-50 characters.
// - Password is required and must be at least 6 characters.
//
// 字段:
// - Username: 期望的用户名(3-50 个字符)。
// - Password: 期望的密码(最少 6 个字符)。
//
// 验证规则:
// - Username 是必填项,必须是 3-50 个字符。
// - Password 是必填项,必须至少 6 个字符。
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=6"`
Email string `json:"email"`
Nickname string `json:"nickname"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaAnswer string `json:"captcha_answer" binding:"required"`
EmailCode string `json:"email_code"` // 邮箱注册时必填
}
// LoginRequest represents the request body for user login.
// LoginRequest 表示用户登录的请求体。
//
// Fields:
// - Username: The user's username.
// - Password: The user's password.
//
// Validation:
// - Both fields are required.
//
// 字段:
// - Username: 用户的用户名。
// - Password: 用户的密码。
//
// 验证规则:
// - 两个字段都是必填项。
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaAnswer string `json:"captcha_answer" binding:"required"`
}
// TokenResponse represents the response body for successful login.
// TokenResponse 表示成功登录的响应体。
//
// Fields:
// - Token: The JWT access token.
//
// 字段:
// - Token: JWT 访问令牌。
type TokenResponse struct {
Token string `json:"token"`
}
// UpdateProfileRequest represents the request body for updating user profile.
// UpdateProfileRequest 表示更新用户个人信息的请求体。
//
// Fields:
// - Email: The user's email address.
// - Phone: The user's phone number.
//
// 字段:
// - Email: 用户的邮箱地址。
// - Phone: 用户的手机号码。
type UpdateProfileRequest struct {
Email string `json:"email"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
}
// ChangePasswordRequest represents the request body for changing password.
// ChangePasswordRequest 表示修改密码的请求体。
//
// Fields:
// - CurrentPassword: The user's current password.
// - NewPassword: The new password.
//
// 字段:
// - CurrentPassword: 用户当前的密码。
// - NewPassword: 新密码。
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=6"`
}
+159
View File
@@ -0,0 +1,159 @@
package auth
import (
"fmt"
"math/rand"
"strings"
"time"
"cloudnest/internal/domain/auth/entity"
"cloudnest/internal/domain/auth/repository"
"cloudnest/internal/pkg/crypto"
"cloudnest/internal/pkg/errors"
"cloudnest/internal/pkg/jwt"
)
type EmailVerifier interface {
VerifyCode(email, code string) bool
}
type AuthService struct {
userRepo repository.UserRepository
emailService EmailVerifier
jwtSecret string
jwtExpires int
}
func NewAuthService(userRepo repository.UserRepository, emailService EmailVerifier, jwtSecret string, jwtExpires int) *AuthService {
return &AuthService{
userRepo: userRepo,
emailService: emailService,
jwtSecret: jwtSecret,
jwtExpires: jwtExpires,
}
}
func generateUserCode() string {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
r := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, 8)
for i := range b {
b[i] = charset[r.Intn(len(charset))]
}
return string(b)
}
func (s *AuthService) Register(username, password, email, nickname, emailCode string) error {
existing, err := s.userRepo.FindByUsername(username)
if err == nil && existing != nil {
return errors.Conflict("用户名已存在")
}
if email != "" {
existing, err := s.userRepo.FindByEmail(email)
if err == nil && existing != nil {
return errors.Conflict("邮箱已被注册")
}
if emailCode == "" {
return errors.BadRequest("邮箱验证码不能为空")
}
if !s.emailService.VerifyCode(email, emailCode) {
return errors.BadRequest("邮箱验证码错误")
}
}
hashed, err := crypto.HashPassword(password)
if err != nil {
return errors.InternalError("密码加密失败", err)
}
userCode := generateUserCode()
for {
existing, err := s.userRepo.FindByUserCode(userCode)
if err != nil || existing == nil {
break
}
userCode = generateUserCode()
}
user := entity.NewUser(username, hashed, userCode, nickname)
if email != "" {
user.Email = email
}
return s.userRepo.Create(user)
}
func (s *AuthService) Login(loginName, password string) (string, *entity.User, error) {
var user *entity.User
var err error
if strings.Contains(loginName, "@") {
user, err = s.userRepo.FindByEmail(loginName)
} else {
user, err = s.userRepo.FindByUsername(loginName)
}
if err != nil {
return "", nil, err
}
if !crypto.CheckPasswordHash(password, user.Password) {
return "", nil, errors.Unauthorized("用户名或密码错误")
}
expiresIn := time.Duration(s.jwtExpires) * time.Hour
token, err := jwt.GenerateToken(user.ID, user.Username, user.UserCode, s.jwtSecret, expiresIn)
return token, user, err
}
func (s *AuthService) CheckStorageLimit(userCode string, fileSize int64) error {
user, err := s.userRepo.FindByUserCode(userCode)
if err != nil {
return err
}
if user.StorageUsed+fileSize > user.StorageLimit {
return errors.BadRequest("存储空间不足,当前已使用 " + formatSize(user.StorageUsed) + ",限制 " + formatSize(user.StorageLimit))
}
return nil
}
func (s *AuthService) AddStorageUsed(userCode string, fileSize int64) error {
return s.userRepo.UpdateStorageUsed(userCode, fileSize)
}
func (s *AuthService) SubtractStorageUsed(userCode string, fileSize int64) error {
return s.userRepo.UpdateStorageUsed(userCode, -fileSize)
}
func (s *AuthService) UpdateProfile(userCode, email, phone, nickname string) error {
return s.userRepo.UpdateProfile(userCode, email, phone, nickname)
}
func (s *AuthService) ChangePassword(userCode, currentPassword, newPassword string) error {
user, err := s.userRepo.FindByUserCode(userCode)
if err != nil {
return err
}
if !crypto.CheckPasswordHash(currentPassword, user.Password) {
return errors.Unauthorized("当前密码不正确")
}
hashed, err := crypto.HashPassword(newPassword)
if err != nil {
return errors.InternalError("密码加密失败", err)
}
return s.userRepo.UpdatePassword(userCode, hashed)
}
func (s *AuthService) GetUserInfo(userCode string) (*entity.User, error) {
return s.userRepo.FindByUserCode(userCode)
}
func formatSize(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
} else if bytes < 1024*1024 {
return fmt.Sprintf("%.2f KB", float64(bytes)/1024)
} else if bytes < 1024*1024*1024 {
return fmt.Sprintf("%.2f MB", float64(bytes)/(1024*1024))
}
return fmt.Sprintf("%.2f GB", float64(bytes)/(1024*1024*1024))
}
+40
View File
@@ -0,0 +1,40 @@
package captcha
import (
"bytes"
"cloudnest/internal/infrastructure/database"
"github.com/dchest/captcha"
)
type CaptchaService struct {
redis *database.RedisClient
}
func NewCaptchaService(redis *database.RedisClient) *CaptchaService {
captcha.SetCustomStore(captcha.NewMemoryStore(100, 5*60*1e9))
return &CaptchaService{redis: redis}
}
func (s *CaptchaService) Generate() (captchaID string, imageBytes []byte, err error) {
captchaID = captcha.New()
var buf bytes.Buffer
if err = captcha.WriteImage(&buf, captchaID, 240, 80); err != nil {
return "", nil, err
}
return captchaID, buf.Bytes(), nil
}
func (s *CaptchaService) GetImage(captchaID string) ([]byte, error) {
var buf bytes.Buffer
if err := captcha.WriteImage(&buf, captchaID, 240, 80); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (s *CaptchaService) Verify(captchaID, answer string) bool {
return captcha.VerifyString(captchaID, answer)
}
+144
View File
@@ -0,0 +1,144 @@
package email
import (
"context"
"crypto/tls"
"fmt"
"math/rand"
"net"
"net/smtp"
"time"
"cloudnest/internal/config"
"cloudnest/internal/infrastructure/database"
"cloudnest/internal/pkg/logger"
)
// EmailService provides email sending and verification code management.
// EmailService 提供邮件发送和验证码管理功能。
type EmailService struct {
cfg *config.EmailConfig
redis *database.RedisClient
}
// NewEmailService creates a new EmailService.
// NewEmailService 创建一个新的 EmailService。
func NewEmailService(cfg *config.EmailConfig, redis *database.RedisClient) *EmailService {
return &EmailService{cfg: cfg, redis: redis}
}
// SendVerifyCode generates a 6-digit verification code, sends it via email, and stores it in Redis.
// SendVerifyCode 生成6位数字验证码,通过邮件发送,并存入 Redis。
//
// Returns:
// - code: The generated verification code.
// - err: Any error that occurred.
func (s *EmailService) SendVerifyCode(toEmail string) (code string, err error) {
if s.cfg.SMTPUsername == "" {
return "", fmt.Errorf("邮件服务未配置")
}
code = generateCode(6)
subject := "CloudNest 邮箱验证码"
body := fmt.Sprintf("您的验证码是:%s,有效期10分钟,请勿泄露给他人。", code)
if err = s.sendMail(toEmail, subject, body); err != nil {
logger.Error("failed to send email", "error", err, "to", toEmail)
return "", err
}
ctx := context.Background()
key := fmt.Sprintf("email_code:%s", toEmail)
if err = s.redis.Client.Set(ctx, key, code, 10*time.Minute).Err(); err != nil {
return "", err
}
return code, nil
}
// VerifyCode checks the email verification code and deletes it from Redis on success.
// VerifyCode 检查邮箱验证码,成功时从 Redis 中删除。
func (s *EmailService) VerifyCode(email, code string) bool {
ctx := context.Background()
key := fmt.Sprintf("email_code:%s", email)
val, err := s.redis.Client.Get(ctx, key).Result()
if err != nil {
return false
}
if val != code {
return false
}
s.redis.Client.Del(ctx, key)
return true
}
func generateCode(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = byte(rand.Intn(10)) + '0'
}
return string(b)
}
func (s *EmailService) sendMail(to, subject, body string) error {
from := s.cfg.SMTPUsername
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, s.cfg.SMTPPort)
msg := []byte(fmt.Sprintf("To: %s\r\nFrom: %s <%s>\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
to, s.cfg.FromName, from, subject, body))
if s.cfg.SMTPPort == 587 {
return sendMailSTARTTLS(addr, s.cfg.SMTPHost, from, s.cfg.SMTPPassword, []string{to}, msg)
}
auth := smtp.PlainAuth("", from, s.cfg.SMTPPassword, s.cfg.SMTPHost)
return smtp.SendMail(addr, auth, from, []string{to}, msg)
}
func sendMailSTARTTLS(addr, host, from, password string, to []string, msg []byte) error {
conn, err := net.Dial("tcp", addr)
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Close()
tlsConfig := &tls.Config{ServerName: host}
if err := client.StartTLS(tlsConfig); err != nil {
return err
}
auth := smtp.PlainAuth("", from, password, host)
if err := client.Auth(auth); err != nil {
return err
}
if err := client.Mail(from); err != nil {
return err
}
for _, addr := range to {
if err := client.Rcpt(addr); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(msg)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
return client.Quit()
}
+152
View File
@@ -0,0 +1,152 @@
package file
// UploadResponse represents the response body for successful file upload.
// UploadResponse 表示成功上传文件的响应体。
//
// Fields:
// - Message: A success message.
// - File: The storage path of the uploaded file.
// - Size: The file size in bytes.
//
// 字段:
// - Message: 成功消息。
// - File: 上传文件的存储路径。
// - Size: 文件大小(字节)。
type UploadResponse struct {
Message string `json:"message"`
File string `json:"file"`
Size int64 `json:"size"`
}
// FileItem represents a single file in the listing.
// FileItem 表示列表中的单个文件。
//
// Fields:
// - Key: The storage key/path of the file.
// - Size: The file size in bytes.
// - LastModified: The timestamp when the file was last modified.
//
// 字段:
// - Key: 文件的存储键/路径。
// - Size: 文件大小(字节)。
// - LastModified: 文件最后修改的时间戳。
type FileItem struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified string `json:"last_modified"`
}
// ListResponse represents the response body for file listing.
// ListResponse 表示文件列表的响应体。
//
// Fields:
// - Files: A list of file items with metadata.
//
// 字段:
// - Files: 包含元数据的文件项列表。
type ListResponse struct {
Files []FileItem `json:"files"`
}
// DeleteResponse represents the response body for successful file deletion.
// DeleteResponse 表示成功删除文件的响应体。
//
// Fields:
// - Message: A success message.
//
// 字段:
// - Message: 成功消息。
type DeleteResponse struct {
Message string `json:"message"`
}
// FavoriteResponse represents the response body for favorite operations.
// FavoriteResponse 表示收藏操作的响应体。
//
// Fields:
// - Message: A success message.
// - FileKey: The file key that was favorited/unfavorited.
//
// 字段:
// - Message: 成功消息。
// - FileKey: 被收藏/取消收藏的文件键。
type FavoriteResponse struct {
Message string `json:"message"`
FileKey string `json:"file_key"`
}
// FavoriteListResponse represents the response body for favorite list.
// FavoriteListResponse 表示收藏列表的响应体。
//
// Fields:
// - Favorites: A list of favorite file information.
//
// 字段:
// - Favorites: 收藏文件信息列表。
type FavoriteListResponse struct {
Favorites []FavoriteItem `json:"favorites"`
}
// FavoriteItem represents a single favorite file.
// FavoriteItem 表示单个收藏文件。
//
// Fields:
// - FileKey: The storage key of the file.
// - FileName: The original filename.
// - CreatedAt: Timestamp when the file was favorited.
//
// 字段:
// - FileKey: 文件的存储键。
// - FileName: 原始文件名。
// - CreatedAt: 文件被收藏的时间戳。
type FavoriteItem struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
Size int64 `json:"size"`
CreatedAt string `json:"created_at"`
}
// RecycleBinResponse represents the response body for recycle bin list.
// RecycleBinResponse 表示回收站列表的响应体。
//
// Fields:
// - Files: A list of deleted file information.
//
// 字段:
// - Files: 已删除文件信息列表。
type RecycleBinResponse struct {
Files []DeletedFileItem `json:"files"`
}
// DeletedFileItem represents a single deleted file.
// DeletedFileItem 表示单个已删除文件。
//
// Fields:
// - FileKey: The storage key of the file.
// - FileName: The original filename.
// - Size: The file size in bytes.
// - DeletedAt: Timestamp when the file was deleted.
//
// 字段:
// - FileKey: 文件的存储键。
// - FileName: 原始文件名。
// - Size: 文件大小(字节)。
// - DeletedAt: 文件被删除的时间戳。
type DeletedFileItem struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
Size int64 `json:"size"`
DeletedAt string `json:"deleted_at"`
}
// PreviewResponse represents the response body for file preview info.
// PreviewResponse 表示文件预览信息的响应体。
//
// Fields:
// - FileType: The type of file (image, text, pdf, other).
//
// 字段:
// - FileType: 文件类型(image, text, pdf, other)。
type PreviewResponse struct {
FileType string `json:"file_type"`
}
+627
View File
@@ -0,0 +1,627 @@
package file
import (
"cloudnest/internal/pkg/errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"cloudnest/internal/domain/file/entity"
"cloudnest/internal/domain/file/repository"
infrastructureRepo "cloudnest/internal/infrastructure/repository"
)
// FileService provides file management operations.
// FileService 提供文件管理操作。
//
// This service implements the application use cases for file upload, download, list, and delete.
// It organizes files by username, creating user-specific directories in the object storage.
//
// 此服务实现了文件上传、下载、列表和删除的应用用例。
// 它按用户名组织文件,在对象存储中创建用户特定的目录。
type FileService struct {
fileRepo repository.FileRepository
fileMetaRepo *infrastructureRepo.FileMetaRepository
bucket string
}
// NewFileService creates a new FileService instance.
// NewFileService 创建一个新的 FileService 实例。
//
// Parameters:
// - fileRepo: The file repository for storage operations.
// - bucket: The default bucket name for file storage.
//
// Returns:
// - *FileService: A pointer to the new FileService instance.
//
// 参数:
// - fileRepo: 文件存储操作的仓储。
// - bucket: 文件存储的默认桶名称。
//
// 返回值:
// - *FileService: 新创建的 FileService 实例指针。
func NewFileService(fileRepo repository.FileRepository, fileMetaRepo *infrastructureRepo.FileMetaRepository, bucket string) *FileService {
return &FileService{
fileRepo: fileRepo,
fileMetaRepo: fileMetaRepo,
bucket: bucket,
}
}
// userObject generates the storage key for a user's file.
// userObject 为用户文件生成存储键。
//
// Parameters:
// - username: The user's username.
// - name: The filename.
//
// Returns:
// - string: The storage key in format "username/filename".
//
// 参数:
// - username: 用户的用户名。
// - name: 文件名。
//
// 返回值:
// - string: 存储键,格式为 "username/filename"。
func userObject(userCode, name string) string {
return fmt.Sprintf("%s/%s", userCode, name)
}
// Upload uploads a file to the user's directory.
// Upload 将文件上传到用户的目录。
//
// Parameters:
// - username: The owner of the file.
// - filename: The original filename.
// - reader: The file content reader.
// - size: The file size in bytes.
// - contentType: The MIME type of the file.
//
// Returns:
// - error: An error if the upload fails.
//
// 参数:
// - username: 文件的所有者。
// - filename: 原始文件名。
// - reader: 文件内容读取器。
// - size: 文件大小(字节)。
// - contentType: 文件的 MIME 类型。
//
// 返回值:
// - error: 如果上传失败则返回错误。
func (s *FileService) Upload(userCode, filename string, reader io.Reader, size int64, contentType string) error {
return s.fileRepo.Upload(s.bucket, userObject(userCode, filename), reader, size, contentType)
}
// List retrieves all files for a user.
// List 获取用户的所有文件。
//
// Parameters:
// - username: The user's username.
//
// Returns:
// - []string: A list of file keys belonging to the user.
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
//
// 返回值:
// - []string: 用户所属的文件键列表。
// - error: 如果操作失败则返回错误。
func (s *FileService) List(userCode string) ([]repository.FileInfo, error) {
fileInfos, err := s.fileRepo.List(s.bucket, userCode+"/")
if err != nil {
return nil, err
}
deletedKeys, err := s.fileMetaRepo.GetDeletedFileKeys(userCode)
if err != nil {
return nil, err
}
deletedSet := make(map[string]bool)
for _, key := range deletedKeys {
deletedSet[key] = true
}
var filtered []repository.FileInfo
for _, info := range fileInfos {
if !deletedSet[info.Key] {
filtered = append(filtered, info)
}
}
return filtered, nil
}
// PresignDownload generates a pre-signed URL for file download.
// PresignDownload 生成用于文件下载的预签名 URL。
//
// Parameters:
// - username: The file owner's username.
// - filename: The name of the file to download.
//
// Returns:
// - string: The pre-signed download URL.
// - error: An error if the operation fails.
//
// 参数:
// - username: 文件所有者的用户名。
// - filename: 要下载的文件名。
//
// 返回值:
// - string: 预签名下载 URL。
// - error: 如果操作失败则返回错误。
func (s *FileService) PresignDownload(userCode, filename string) (string, error) {
return s.fileRepo.PresignDownload(s.bucket, userObject(userCode, filename))
}
// GetFileStream retrieves a file as a readable stream for proxying to the client.
// GetFileStream 获取文件的可读流,用于代理给客户端。
//
// This method allows the Go backend to stream file content directly to the HTTP response,
// avoiding CORS issues with MinIO and keeping MinIO internal to the server.
//
// Parameters:
// - username: The file owner's username.
// - filename: The name of the file to stream.
//
// Returns:
// - io.ReadCloser: A readable stream of the file content. Caller must close it.
// - int64: The file size in bytes.
// - string: The Content-Type of the file.
// - error: An error if the operation fails.
//
// 此方法允许 Go 后端将文件内容直接流式传输到 HTTP 响应,
// 避免 MinIO 的 CORS 问题,并保持 MinIO 对服务器内部可见。
//
// 参数:
// - username: 文件所有者的用户名。
// - filename: 要流式传输的文件名。
//
// 返回值:
// - io.ReadCloser: 文件内容的可读流。调用方必须关闭它。
// - int64: 文件大小(字节)。
// - string: 文件的 Content-Type。
// - error: 如果操作失败则返回错误。
func (s *FileService) GetFileStream(userCode, filename string) (io.ReadCloser, int64, string, error) {
return s.fileRepo.GetObject(s.bucket, userObject(userCode, filename))
}
// Delete removes a file from the user's directory.
// Delete 从用户的目录中删除文件。
//
// Parameters:
// - username: The file owner's username.
// - filename: The name of the file to delete.
//
// Returns:
// - error: An error if the deletion fails.
//
// 参数:
// - username: 文件所有者的用户名。
// - filename: 要删除的文件名。
//
// 返回值:
// - error: 如果删除失败则返回错误。
func (s *FileService) Delete(userCode, filename string) error {
return s.fileRepo.Delete(s.bucket, userObject(userCode, filename))
}
// SoftDelete moves a file to the recycle bin instead of permanent deletion.
// SoftDelete 将文件移动到回收站而不是永久删除。
//
// Parameters:
// - userID: The user's ID.
// - username: The user's username.
// - filename: The name of the file to delete.
// - size: The file size in bytes.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - userID: 用户的 ID。
// - username: 用户的用户名。
// - filename: 要删除的文件名。
// - size: 文件大小(字节)。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (s *FileService) SoftDelete(userID uint, userCode, filename string, _ int64) error {
fileKey := userObject(userCode, filename)
size, _, err := s.fileRepo.GetObjectInfo(s.bucket, fileKey)
if err != nil {
return errors.InternalError("获取文件信息失败", err)
}
s.fileMetaRepo.RemoveFavorite(userCode, fileKey)
deletedFile := entity.NewDeletedFile(userID, userCode, fileKey, filename, size)
return s.fileMetaRepo.AddToRecycleBin(deletedFile)
}
// Favorite adds a file to user's favorites.
// Favorite 将文件添加到用户的收藏夹。
//
// Parameters:
// - userID: The user's ID.
// - username: The user's username.
// - filename: The name of the file to favorite.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - userID: 用户的 ID。
// - username: 用户的用户名。
// - filename: 要收藏的文件名。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (s *FileService) Favorite(userID uint, userCode, filename string) error {
fileKey := userObject(userCode, filename)
size, _, err := s.fileRepo.GetObjectInfo(s.bucket, fileKey)
if err != nil {
return errors.InternalError("获取文件信息失败", err)
}
favorite := entity.NewFavorite(userID, userCode, fileKey, size)
return s.fileMetaRepo.AddFavorite(favorite)
}
// Unfavorite removes a file from user's favorites.
// Unfavorite 从用户的收藏夹中移除文件。
//
// Parameters:
// - username: The user's username.
// - filename: The name of the file to unfavorite.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
// - filename: 要取消收藏的文件名。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (s *FileService) Unfavorite(userCode, filename string) error {
fileKey := userObject(userCode, filename)
return s.fileMetaRepo.RemoveFavorite(userCode, fileKey)
}
// GetFavorites retrieves all favorites for a user.
// GetFavorites 获取用户的所有收藏。
//
// Parameters:
// - username: The user's username.
//
// Returns:
// - []entity.Favorite: A list of Favorite entities.
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
//
// 返回值:
// - []entity.Favorite: Favorite 实体列表。
// - error: 如果操作失败则返回错误。
func (s *FileService) GetFavorites(userCode string) ([]entity.Favorite, error) {
favorites, err := s.fileMetaRepo.GetFavorites(userCode)
if err != nil {
return nil, err
}
deletedKeys, err := s.fileMetaRepo.GetDeletedFileKeys(userCode)
if err != nil {
return nil, err
}
deletedSet := make(map[string]bool)
for _, key := range deletedKeys {
deletedSet[key] = true
}
var filtered []entity.Favorite
for _, f := range favorites {
if !deletedSet[f.FileKey] {
filtered = append(filtered, f)
}
}
return filtered, nil
}
// GetRecycleBin retrieves all deleted files for a user.
// GetRecycleBin 获取用户的所有已删除文件。
//
// Parameters:
// - username: The user's username.
//
// Returns:
// - []entity.DeletedFile: A list of DeletedFile entities.
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
//
// 返回值:
// - []entity.DeletedFile: DeletedFile 实体列表。
// - error: 如果操作失败则返回错误。
func (s *FileService) GetRecycleBin(userCode string) ([]entity.DeletedFile, error) {
deletedFiles, err := s.fileMetaRepo.GetRecycleBin(userCode)
if err != nil {
return nil, err
}
for i := range deletedFiles {
if deletedFiles[i].Size == 0 {
size, _, err := s.fileRepo.GetObjectInfo(s.bucket, deletedFiles[i].FileKey)
if err == nil {
deletedFiles[i].Size = size
}
}
}
return deletedFiles, nil
}
// RestoreFromRecycleBin restores a file from the recycle bin.
// RestoreFromRecycleBin 从回收站恢复文件。
//
// Parameters:
// - username: The user's username.
// - filename: The name of the file to restore.
//
// Returns:
// - *entity.DeletedFile: The restored DeletedFile entity.
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
// - filename: 要恢复的文件名。
//
// 返回值:
// - *entity.DeletedFile: 恢复的 DeletedFile 实体。
// - error: 如果操作失败则返回错误。
func (s *FileService) RestoreFromRecycleBin(userCode, filename string) (*entity.DeletedFile, error) {
fileKey := userObject(userCode, filename)
return s.fileMetaRepo.RestoreFromRecycleBin(userCode, fileKey)
}
// DeleteFromRecycleBin permanently deletes a file from the recycle bin.
// DeleteFromRecycleBin 从回收站永久删除文件。
//
// Parameters:
// - username: The user's username.
// - filename: The name of the file to permanently delete.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - username: 用户的用户名。
// - filename: 要永久删除的文件名。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (s *FileService) DeleteFromRecycleBin(userCode, filename string) error {
fileKey := userObject(userCode, filename)
if err := s.fileMetaRepo.DeleteFromRecycleBin(userCode, fileKey); err != nil {
return err
}
return s.fileRepo.Delete(s.bucket, fileKey)
}
// PresignPreview generates a pre-signed URL for file preview.
// PresignPreview 生成用于文件预览的预签名 URL。
//
// Parameters:
// - username: The file owner's username.
// - filename: The name of the file to preview.
//
// Returns:
// - string: The pre-signed preview URL.
// - error: An error if the operation fails.
//
// 参数:
// - username: 文件所有者的用户名。
// - filename: 要预览的文件名。
//
// 返回值:
// - string: 预签名预览 URL。
// - error: 如果操作失败则返回错误。
func (s *FileService) PresignPreview(userCode, filename string) (string, error) {
return s.fileRepo.PresignDownload(s.bucket, userObject(userCode, filename))
}
// GetFileExtension returns the file extension without the dot.
// GetFileExtension 返回文件扩展名(不含点)。
//
// Parameters:
// - filename: The filename.
//
// Returns:
// - string: The file extension in lowercase.
//
// 参数:
// - filename: 文件名。
//
// 返回值:
// - string: 小写的文件扩展名。
func (s *FileService) GetFileExtension(filename string) string {
idx := strings.LastIndex(filename, ".")
if idx == -1 {
return ""
}
return strings.ToLower(filename[idx+1:])
}
// IsImageFile checks if the file is an image based on its extension.
// IsImageFile 根据文件扩展名检查是否为图片文件。
//
// Parameters:
// - filename: The filename.
//
// Returns:
// - bool: True if the file is an image.
//
// 参数:
// - filename: 文件名。
//
// 返回值:
// - bool: 如果是图片文件则返回 true。
func (s *FileService) IsImageFile(filename string) bool {
ext := s.GetFileExtension(filename)
imageExts := map[string]bool{
"jpg": true,
"jpeg": true,
"png": true,
"gif": true,
"bmp": true,
"webp": true,
"svg": true,
}
return imageExts[ext]
}
// IsTextFile checks if the file is a text file based on its extension.
// IsTextFile 根据文件扩展名检查是否为文本文件。
//
// Parameters:
// - filename: The filename.
//
// Returns:
// - bool: True if the file is a text file.
//
// 参数:
// - filename: 文件名。
//
// 返回值:
// - bool: 如果是文本文件则返回 true。
func (s *FileService) IsTextFile(filename string) bool {
ext := s.GetFileExtension(filename)
textExts := map[string]bool{
"txt": true,
"md": true,
"json": true,
"xml": true,
"html": true,
"css": true,
"js": true,
"go": true,
"py": true,
"java": true,
"cpp": true,
"c": true,
}
return textExts[ext]
}
// IsPDFFile checks if the file is a PDF file based on its extension.
// IsPDFFile 根据文件扩展名检查是否为 PDF 文件。
//
// Parameters:
// - filename: The filename.
//
// Returns:
// - bool: True if the file is a PDF.
//
// 参数:
// - filename: 文件名。
//
// 返回值:
// - bool: 如果是 PDF 文件则返回 true。
func (s *FileService) IsPDFFile(filename string) bool {
ext := s.GetFileExtension(filename)
return ext == "pdf"
}
func (s *FileService) GetChunkDir() string {
return filepath.Join(os.TempDir(), "cloudnest_chunks")
}
func (s *FileService) SaveChunk(username, hash string, chunkIndex int, data []byte) error {
chunkDir := filepath.Join(s.GetChunkDir(), username, hash)
if err := os.MkdirAll(chunkDir, 0755); err != nil {
return errors.InternalError("创建分片目录失败", err)
}
chunkPath := filepath.Join(chunkDir, strconv.Itoa(chunkIndex))
if err := os.WriteFile(chunkPath, data, 0644); err != nil {
return errors.InternalError("保存分片失败", err)
}
return nil
}
func (s *FileService) GetUploadedChunks(username, hash string) ([]int, error) {
chunkDir := filepath.Join(s.GetChunkDir(), username, hash)
entries, err := os.ReadDir(chunkDir)
if err != nil {
if os.IsNotExist(err) {
return []int{}, nil
}
return nil, errors.InternalError("读取分片目录失败", err)
}
var chunks []int
for _, entry := range entries {
if !entry.IsDir() {
idx, err := strconv.Atoi(entry.Name())
if err == nil {
chunks = append(chunks, idx)
}
}
}
return chunks, nil
}
func (s *FileService) MergeChunks(username, hash, filename string, chunkCount int, fileSize int64) error {
chunkDir := filepath.Join(s.GetChunkDir(), username, hash)
destPath := filepath.Join(chunkDir, "merged_"+filename)
destFile, err := os.Create(destPath)
if err != nil {
return errors.InternalError("创建合并文件失败", err)
}
defer destFile.Close()
for i := 0; i < chunkCount; i++ {
chunkPath := filepath.Join(chunkDir, strconv.Itoa(i))
chunkData, err := os.ReadFile(chunkPath)
if err != nil {
return errors.InternalError("读取分片失败", err)
}
if _, err := destFile.Write(chunkData); err != nil {
return errors.InternalError("写入合并文件失败", err)
}
}
file, err := os.Open(destPath)
if err != nil {
return errors.InternalError("打开合并文件失败", err)
}
defer file.Close()
contentType := "application/octet-stream"
if err := s.Upload(username, filename, file, fileSize, contentType); err != nil {
return err
}
os.RemoveAll(chunkDir)
return nil
}
+250
View File
@@ -0,0 +1,250 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"cloudnest/internal/config"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
// Server encapsulates HTTP server lifecycle management with graceful start/restart/shutdown.
// Server 封装了 HTTP 服务器的生命周期管理,支持优雅启动/重启/关闭。
//
// Features:
// 1. Graceful startup with port auto-detection in dev mode
// 2. Graceful shutdown on SIGINT/SIGTERM signals
// 3. Hot reload on SIGHUP signal (Linux/Mac only) - re-reads config
// 4. Waits for in-flight requests to complete before shutdown
//
// 特性:
// 1. 开发模式下优雅启动,自动检测可用端口
// 2. 收到 SIGINT/SIGTERM 信号时优雅关闭
// 3. 收到 SIGHUP 信号时热重载(仅 Linux/Mac),重新读取配置
// 4. 关闭前等待正在进行的请求完成
type Server struct {
cfg *config.Config
engine *gin.Engine
srv *http.Server
mu sync.RWMutex
reloadChan chan os.Signal
shutdownOnce sync.Once
}
// NewServer creates a new Server instance with the given config and engine.
// NewServer 使用给定的配置和引擎创建新的 Server 实例。
//
// Parameters:
// - cfg: The application configuration.
// - engine: The Gin engine to serve.
//
// Returns:
// - *Server: A new Server instance.
//
// 参数:
// - cfg: 应用程序配置。
// - engine: 要服务的 Gin 引擎。
//
// 返回值:
// - *Server: 新的 Server 实例。
func NewServer(cfg *config.Config, engine *gin.Engine) *Server {
return &Server{
cfg: cfg,
engine: engine,
reloadChan: make(chan os.Signal, 1),
}
}
// Run starts the server and blocks until a shutdown signal is received.
// Run 启动服务器并阻塞,直到收到关闭信号。
//
// Lifecycle:
// 1. Auto-detect available port (dev mode only)
// 2. Start listening in a goroutine
// 3. Register signal handlers (SIGINT, SIGTERM for shutdown; SIGHUP for reload on Unix)
// 4. Block until shutdown signal
// 5. Gracefully shut down with timeout
//
// 生命周期:
// 1. 自动检测可用端口(仅开发模式)
// 2. 在 goroutine 中开始监听
// 3. 注册信号处理器(SIGINT、SIGTERM 用于关闭;SIGHUP 用于 Unix 上的重载)
// 4. 阻塞直到收到关闭信号
// 5. 使用超时优雅关闭
//
// Returns:
// - error: An error if startup or shutdown fails.
//
// 返回值:
// - error: 如果启动或关闭失败则返回错误。
func (s *Server) Run() error {
port := s.resolvePort()
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, port)
s.mu.Lock()
s.srv = &http.Server{
Addr: addr,
Handler: s.engine,
}
s.mu.Unlock()
// Register signal handlers / 注册信号处理器
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Register SIGHUP for hot reload (Unix only)
// 注册 SIGHUP 用于热重载(仅 Unix
if isUnix() {
signal.Notify(s.reloadChan, syscall.SIGHUP)
}
// Start server in a goroutine / 在 goroutine 中启动服务器
serverErr := make(chan error, 1)
go func() {
logger.Info("server listening", "address", addr, "env", s.cfg.App.Env)
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
close(serverErr)
}()
// Wait for signal / 等待信号
select {
case sig := <-sigChan:
logger.Info("shutdown signal received", "signal", sig.String())
return s.Shutdown()
case sig := <-s.reloadChan:
logger.Info("reload signal received, restarting", "signal", sig.String())
if err := s.Shutdown(); err != nil {
return err
}
// After shutdown, the caller should restart the process
// 关闭后,调用方应重启进程
return ErrReloadRequested
case err := <-serverErr:
if err != nil {
logger.Error("server failed", "error", err)
return err
}
return nil
}
}
// Shutdown gracefully shuts down the server with a timeout.
// Shutdown 使用超时优雅关闭服务器。
//
// Parameters:
// - timeout: The maximum time to wait for in-flight requests to complete.
//
// Returns:
// - error: An error if shutdown fails.
//
// Note:
// - Safe to call multiple times (subsequent calls are no-ops).
//
// 参数:
// - timeout: 等待正在进行的请求完成的最长时间。
//
// 返回值:
// - error: 如果关闭失败则返回错误。
//
// 注意:
// - 可以安全地多次调用(后续调用是空操作)。
func (s *Server) Shutdown() error {
var shutdownErr error
s.shutdownOnce.Do(func() {
s.mu.RLock()
srv := s.srv
s.mu.RUnlock()
if srv == nil {
return
}
timeout := time.Duration(s.cfg.Server.GracefulShutdownTimeout) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
logger.Info("graceful shutdown started", "timeout_seconds", timeout)
if err := srv.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed, forcing close", "error", err)
if closeErr := srv.Close(); closeErr != nil {
shutdownErr = fmt.Errorf("forced close failed: %w (original: %v)", closeErr, err)
return
}
shutdownErr = err
}
logger.Info("server exited gracefully")
})
return shutdownErr
}
// resolvePort finds an available port starting from the configured port.
// resolvePort 从配置的端口开始查找可用端口。
//
// In dev mode: tries configured port, then port+1, port+2, ..., up to +9.
// In prod mode: uses the configured port directly (no auto-switch).
//
// 开发模式:尝试配置端口,然后 port+1, port+2, ..., 最多 +9。
// 生产模式:直接使用配置的端口(不自动切换)。
//
// Returns:
// - int: The actual port to use.
//
// 返回值:
// - int: 实际使用的端口。
func (s *Server) resolvePort() int {
port := s.cfg.Server.Port
if !s.cfg.IsDev() {
return port
}
availablePort, err := config.FindAvailablePort(port, 10)
if err != nil {
logger.Fatal("no available port found", "preferred", port, "error", err)
}
if availablePort != port {
logger.Info("port auto-switched", "from", port, "to", availablePort)
}
return availablePort
}
// Addr returns the actual server address (host:port) after resolvePort has been called.
// Addr 返回调用 resolvePort 后的实际服务器地址(host:port)。
//
// Returns:
// - string: The server address, or empty string if server hasn't started.
//
// 返回值:
// - string: 服务器地址,如果服务器尚未启动则返回空字符串。
func (s *Server) Addr() string {
s.mu.RLock()
defer s.mu.RUnlock()
if s.srv == nil {
return ""
}
return s.srv.Addr
}
// isUnix returns true if the current OS is Unix-like (Linux, macOS, BSD, etc.).
// isUnix 如果当前操作系统是类 Unix 系统(Linux、macOS、BSD 等)则返回 true。
func isUnix() bool {
return os.PathSeparator == '/'
}
// ErrReloadRequested is returned by Run() when a SIGHUP signal requests a reload.
// ErrReloadRequested 当 SIGHUP 信号请求重载时由 Run() 返回。
//
// The caller should typically exit with a non-zero code so the process supervisor
// (systemd, Docker, k8s) can restart the application with the new config.
//
// 调用方通常应以非零退出码退出,以便进程管理器(systemd、Docker、k8s)可以使用新配置重启应用程序。
var ErrReloadRequested = errors.New("reload requested")
+454
View File
@@ -0,0 +1,454 @@
package config
import (
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"cloudnest/internal/pkg/logger"
"github.com/spf13/viper"
)
// Config holds all application configuration.
// Config 包含所有应用程序配置。
//
// Configuration loading order (later overrides earlier):
// 1. config.yaml (default values)
// 2. config.{env}.yaml (environment-specific values)
// 3. Environment variables (highest priority, auto-mapped)
//
// 配置加载顺序(后面的覆盖前面的):
// 1. config.yaml(默认值)
// 2. config.{env}.yaml(环境特定值)
// 3. 环境变量(最高优先级,自动映射)
type Config struct {
App AppConfig `mapstructure:"app"`
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Cache CacheConfig `mapstructure:"cache"`
Storage StorageConfig `mapstructure:"storage"`
JWT JWTConfig `mapstructure:"jwt"`
Email EmailConfig `mapstructure:"email"`
}
// AppConfig holds application-level configuration.
// AppConfig 包含应用程序级别的配置。
type AppConfig struct {
Name string `mapstructure:"name"`
Version string `mapstructure:"version"`
Env string `mapstructure:"env"`
LogLevel string `mapstructure:"log_level"`
FileServiceURL string `mapstructure:"file_service_url"`
}
// ServerConfig holds HTTP server configuration.
// ServerConfig 包含 HTTP 服务器配置。
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
}
// DatabaseConfig holds database connection configuration.
// DatabaseConfig 包含数据库连接配置。
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
Charset string `mapstructure:"charset"`
ParseTime bool `mapstructure:"parse_time"`
Loc string `mapstructure:"loc"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
}
// CacheConfig holds Redis cache configuration.
// CacheConfig 包含 Redis 缓存配置。
type CacheConfig struct {
Driver string `mapstructure:"driver"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConns int `mapstructure:"min_idle_conns"`
}
// StorageConfig holds MinIO object storage configuration.
// StorageConfig 包含 MinIO 对象存储配置。
type StorageConfig struct {
Driver string `mapstructure:"driver"`
Endpoint string `mapstructure:"endpoint"`
AccessKeyID string `mapstructure:"access_key_id"`
SecretAccessKey string `mapstructure:"secret_access_key"`
BucketName string `mapstructure:"bucket_name"`
UseSSL bool `mapstructure:"use_ssl"`
PresignDuration int `mapstructure:"presign_duration"`
MaxFileSize int `mapstructure:"max_file_size"`
RecycleBinExpireDays int `mapstructure:"recycle_bin_expire_days"`
}
// JWTConfig holds JWT authentication configuration.
// JWTConfig 包含 JWT 认证配置。
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpiresIn int `mapstructure:"expires_in"`
Issuer string `mapstructure:"issuer"`
}
// EmailConfig holds SMTP email configuration.
// EmailConfig 包含 SMTP 邮件配置。
type EmailConfig struct {
SMTPHost string `mapstructure:"smtp_host"`
SMTPPort int `mapstructure:"smtp_port"`
SMTPUsername string `mapstructure:"smtp_username"`
SMTPPassword string `mapstructure:"smtp_password"`
FromName string `mapstructure:"from_name"`
}
// DSN builds MySQL DSN string from config fields.
// DSN 从配置字段构建 MySQL DSN 字符串。
//
// Returns:
// - string: The formatted DSN string.
//
// 返回值:
// - string: 格式化后的 DSN 字符串。
func (c *DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=%t&loc=%s",
c.User,
c.Password,
c.Host,
c.Port,
c.DBName,
c.Charset,
c.ParseTime,
c.Loc,
)
}
// Addr builds Redis connection address.
// Addr 构建 Redis 连接地址。
//
// Returns:
// - string: The formatted "host:port" address.
//
// 返回值:
// - string: 格式化的 "host:port" 地址。
func (c *CacheConfig) Addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
// PresignDuration builds time.Duration from hours config.
// PresignDuration 从小时配置构建 time.Duration。
//
// Returns:
// - time.Duration: The presign duration.
//
// 返回值:
// - time.Duration: 预签名有效期。
func (c *StorageConfig) PresignDurationTime() time.Duration {
return time.Duration(c.PresignDuration) * time.Hour
}
// ExpiresIn builds time.Duration from hours config.
// ExpiresIn 从小时配置构建 time.Duration。
//
// Returns:
// - time.Duration: The JWT expiration duration.
//
// 返回值:
// - time.Duration: JWT 过期时间。
func (c *JWTConfig) ExpiresInDuration() time.Duration {
return time.Duration(c.ExpiresIn) * time.Hour
}
// getEnv returns the current environment name from CLOUDNEST_ENV or defaults to "dev".
// getEnv 从 CLOUDNEST_ENV 获取当前环境名称,默认为 "dev"。
func getEnv() string {
env := strings.ToLower(os.Getenv("CLOUDNEST_ENV"))
if env == "" {
env = "dev"
}
return env
}
// getConfigDir returns the absolute path to the configs directory.
// getConfigDir 返回 configs 目录的绝对路径。
//
// Note:
// - Tries to find configs/ relative to the executable or current working directory.
// - Falls back to current working directory if not found.
//
// 注意:
// - 尝试从可执行文件或当前工作目录相对位置找到 configs/。
// - 如果找不到则回退到当前工作目录。
func getConfigDir() string {
// Try to get the directory of the current file
// 尝试获取当前文件的目录
_, filename, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Join(filepath.Dir(filename), "..", "..", "configs")
if _, err := os.Stat(dir); err == nil {
return dir
}
}
// Try executable directory / 尝试可执行文件目录
ex, err := os.Executable()
if err == nil {
dir := filepath.Join(filepath.Dir(ex), "configs")
if _, err := os.Stat(dir); err == nil {
return dir
}
}
// Try current working directory / 尝试当前工作目录
cwd, err := os.Getwd()
if err == nil {
dir := filepath.Join(cwd, "configs")
if _, err := os.Stat(dir); err == nil {
return dir
}
}
return "configs"
}
// Load reads and parses configuration from YAML files and environment variables.
// Load 从 YAML 文件和环境变量中读取并解析配置。
//
// Loading order (later overrides earlier):
// 1. configs/config.yaml (base defaults)
// 2. configs/config.{env}.yaml (env-specific overrides)
// 3. Environment variables (highest priority)
//
// Environment selection via CLOUDNEST_ENV:
// - "dev" -> configs/config.dev.yaml
// - "test" -> configs/config.test.yaml
// - "prod" -> configs/config.prod.yaml
// - default -> configs/config.dev.yaml
//
// Environment variables are automatically mapped with the following prefixes:
// - APP_ -> app
// - SERVER_ -> server
// - DB_ -> database
// - REDIS_ -> cache
// - MINIO_ -> storage
// - JWT_ -> jwt
// - EMAIL_ -> email
//
// Returns:
// - *Config: A pointer to the parsed configuration struct.
//
// 配置加载顺序(后面的覆盖前面的):
// 1. configs/config.yaml(基础默认值)
// 2. configs/config.{env}.yaml(环境特定覆盖)
// 3. 环境变量(最高优先级)
//
// 通过 CLOUDNEST_ENV 选择环境:
// - "dev" -> configs/config.dev.yaml
// - "test" -> configs/config.test.yaml
// - "prod" -> configs/config.prod.yaml
// - 默认 -> configs/config.dev.yaml
//
// 环境变量自动映射的前缀:
// - APP_ -> app
// - SERVER_ -> server
// - DB_ -> database
// - REDIS_ -> cache
// - MINIO_ -> storage
// - JWT_ -> jwt
// - EMAIL_ -> email
//
// 返回值:
// - *Config: 解析后的配置结构体指针。
func Load() *Config {
env := getEnv()
configDir := getConfigDir()
v := viper.New()
v.SetConfigType("yaml")
// 1. Load base config / 加载基础配置
baseConfig := filepath.Join(configDir, "config.yaml")
v.SetConfigFile(baseConfig)
if err := v.ReadInConfig(); err != nil {
logger.Warn("failed to read base config, using defaults", "error", err, "path", baseConfig)
} else {
logger.Info("base config loaded", "path", baseConfig)
}
// 2. Load environment-specific config / 加载环境特定配置
envConfig := filepath.Join(configDir, fmt.Sprintf("config.%s.yaml", env))
if _, err := os.Stat(envConfig); err == nil {
envViper := viper.New()
envViper.SetConfigFile(envConfig)
if err := envViper.ReadInConfig(); err == nil {
// Merge environment config into base config
// 将环境配置合并到基础配置中
for _, key := range envViper.AllKeys() {
v.Set(key, envViper.Get(key))
}
logger.Info("env config loaded", "env", env, "path", envConfig)
} else {
logger.Warn("failed to read env config", "env", env, "error", err)
}
} else {
logger.Info("env config not found, using base config", "env", env, "path", envConfig)
}
// 3. Enable environment variable overrides / 启用环境变量覆盖
v.AutomaticEnv()
v.SetEnvPrefix("")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// Map environment variables to nested config keys
// 将环境变量映射到嵌套配置键
envMappings := map[string]string{
"APP_NAME": "app.name",
"APP_VERSION": "app.version",
"APP_ENV": "app.env",
"APP_LOG_LEVEL": "app.log_level",
"APP_FILE_SERVICE_URL": "app.file_service_url",
"SERVER_HOST": "server.host",
"SERVER_PORT": "server.port",
"SERVER_GRACEFUL_SHUTDOWN_TIMEOUT": "server.graceful_shutdown_timeout",
"DB_DRIVER": "database.driver",
"DB_HOST": "database.host",
"DB_PORT": "database.port",
"DB_USER": "database.user",
"DB_PASSWORD": "database.password",
"DB_NAME": "database.dbname",
"DB_CHARSET": "database.charset",
"DB_PARSE_TIME": "database.parse_time",
"DB_LOC": "database.loc",
"DB_MAX_IDLE_CONNS": "database.max_idle_conns",
"DB_MAX_OPEN_CONNS": "database.max_open_conns",
"DB_CONN_MAX_LIFETIME": "database.conn_max_lifetime",
"REDIS_HOST": "cache.host",
"REDIS_PORT": "cache.port",
"REDIS_PASSWORD": "cache.password",
"REDIS_DB": "cache.db",
"REDIS_POOL_SIZE": "cache.pool_size",
"REDIS_MIN_IDLE_CONNS": "cache.min_idle_conns",
"MINIO_ENDPOINT": "storage.endpoint",
"MINIO_ACCESS_KEY": "storage.access_key_id",
"MINIO_SECRET_KEY": "storage.secret_access_key",
"MINIO_BUCKET": "storage.bucket_name",
"MINIO_USE_SSL": "storage.use_ssl",
"MINIO_PRESIGN_DURATION": "storage.presign_duration",
"MINIO_MAX_FILE_SIZE": "storage.max_file_size",
"MINIO_RECYCLE_BIN_EXPIRE_DAYS": "storage.recycle_bin_expire_days",
"JWT_SECRET": "jwt.secret",
"JWT_EXPIRES_IN": "jwt.expires_in",
"JWT_ISSUER": "jwt.issuer",
"EMAIL_SMTP_HOST": "email.smtp_host",
"EMAIL_SMTP_PORT": "email.smtp_port",
"EMAIL_SMTP_USERNAME": "email.smtp_username",
"EMAIL_SMTP_PASSWORD": "email.smtp_password",
"EMAIL_FROM_NAME": "email.from_name",
}
for envKey, configKey := range envMappings {
if val := os.Getenv(envKey); val != "" {
// Try to parse as int/bool first, fallback to string
// 先尝试解析为 int/bool,否则回退为 string
if intVal, err := strconv.Atoi(val); err == nil {
v.Set(configKey, intVal)
} else if boolVal, err := strconv.ParseBool(val); err == nil {
v.Set(configKey, boolVal)
} else {
v.Set(configKey, val)
}
}
}
// 4. Unmarshal into struct / 反序列化到结构体
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
logger.Fatal("failed to unmarshal config", "error", err)
}
logger.Info("config loaded",
"app", fmt.Sprintf("%s:%s", cfg.App.Name, cfg.App.Version),
"env", cfg.App.Env,
"config_dir", configDir,
)
return &cfg
}
// IsDev returns true if running in development environment.
// IsDev 如果运行在开发环境则返回 true。
func (c *Config) IsDev() bool {
return strings.ToLower(c.App.Env) == "development" || strings.ToLower(c.App.Env) == "dev"
}
// IsProd returns true if running in production environment.
// IsProd 如果运行在生产环境则返回 true。
func (c *Config) IsProd() bool {
return strings.ToLower(c.App.Env) == "production" || strings.ToLower(c.App.Env) == "prod"
}
// IsTest returns true if running in test environment.
// IsTest 如果运行在测试环境则返回 true。
func (c *Config) IsTest() bool {
return strings.ToLower(c.App.Env) == "test"
}
// FindAvailablePort checks if the configured port is available, returns next available if occupied.
// FindAvailablePort 检查配置的端口是否可用,如果被占用则返回下一个可用端口。
//
// Parameters:
// - preferredPort: The preferred port to use.
// - maxAttempts: Maximum number of ports to try.
//
// Returns:
// - int: An available port number.
// - error: An error if no port is available after max attempts.
//
// Note:
// - Only searches in development mode. In production, it returns the configured port directly.
// - Search range: preferredPort to preferredPort + maxAttempts - 1.
//
// 参数:
// - preferredPort: 首选端口。
// - maxAttempts: 最大尝试次数。
//
// 返回值:
// - int: 可用端口号。
// - error: 如果超过最大尝试次数仍未找到可用端口则返回错误。
//
// 注意:
// - 仅在开发模式下搜索。生产模式下直接返回配置端口。
// - 搜索范围: preferredPort 到 preferredPort + maxAttempts - 1。
func FindAvailablePort(preferredPort int, maxAttempts int) (int, error) {
for i := 0; i < maxAttempts; i++ {
port := preferredPort + i
addr := fmt.Sprintf(":%d", port)
listener, err := net.Listen("tcp", addr)
if err != nil {
logger.Warn("port occupied, trying next", "port", port, "error", err)
continue
}
listener.Close()
return port, nil
}
return 0, fmt.Errorf("no available port found in range %d-%d", preferredPort, preferredPort+maxAttempts-1)
}
+209
View File
@@ -0,0 +1,209 @@
package di
import (
"net/http"
"time"
"cloudnest/internal/application/auth"
"cloudnest/internal/application/captcha"
"cloudnest/internal/application/email"
"cloudnest/internal/application/file"
"cloudnest/internal/config"
"cloudnest/internal/domain/auth/repository"
fileRepo "cloudnest/internal/domain/file/repository"
"cloudnest/internal/infrastructure/database"
infrastructureRepo "cloudnest/internal/infrastructure/repository"
"cloudnest/internal/infrastructure/storage"
"cloudnest/internal/interface/http/handler"
"cloudnest/internal/interface/http/routes"
"cloudnest/internal/middleware"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
"go.uber.org/dig"
)
type Container struct {
*dig.Container
}
func New() *Container {
c := dig.New()
c.Provide(func() *config.Config {
return config.Load()
})
c.Provide(func(cfg *config.Config) (*database.MySQLClient, error) {
return database.NewMySQLClient(cfg.Database.DSN())
})
c.Provide(func(cfg *config.Config) (*database.RedisClient, error) {
return database.NewRedisClient(cfg.Cache.Addr(), cfg.Cache.Password, cfg.Cache.DB)
})
c.Provide(func(cfg *config.Config) (*storage.MinioClient, error) {
return storage.NewMinioClient(
cfg.Storage.Endpoint,
cfg.Storage.AccessKeyID,
cfg.Storage.SecretAccessKey,
cfg.Storage.UseSSL,
)
})
c.Provide(func(mysql *database.MySQLClient) repository.UserRepository {
return infrastructureRepo.NewUserRepository(mysql)
})
c.Provide(func(minio *storage.MinioClient) fileRepo.FileRepository {
return infrastructureRepo.NewFileRepository(minio)
})
c.Provide(func(mysql *database.MySQLClient) *infrastructureRepo.FileMetaRepository {
return infrastructureRepo.NewFileMetaRepository(mysql)
})
c.Provide(func(userRepo repository.UserRepository, emailService *email.EmailService, cfg *config.Config) *auth.AuthService {
return auth.NewAuthService(userRepo, emailService, cfg.JWT.Secret, cfg.JWT.ExpiresIn)
})
c.Provide(func(fRepo fileRepo.FileRepository, fMetaRepo *infrastructureRepo.FileMetaRepository, cfg *config.Config) *file.FileService {
return file.NewFileService(fRepo, fMetaRepo, cfg.Storage.BucketName)
})
c.Provide(func(redis *database.RedisClient) *captcha.CaptchaService {
return captcha.NewCaptchaService(redis)
})
c.Provide(func(cfg *config.Config, redis *database.RedisClient) *email.EmailService {
return email.NewEmailService(&cfg.Email, redis)
})
c.Provide(func(service *auth.AuthService, captchaService *captcha.CaptchaService, emailService *email.EmailService) *handler.AuthHandler {
return handler.NewAuthHandler(service, captchaService, emailService)
})
c.Provide(func(emailService *email.EmailService, captchaService *captcha.CaptchaService) *handler.EmailHandler {
return handler.NewEmailHandler(emailService, captchaService)
})
c.Provide(func(service *file.FileService, authService *auth.AuthService) *handler.FileHandler {
return handler.NewFileHandler(service, authService)
})
c.Provide(func(service *captcha.CaptchaService) *handler.CaptchaHandler {
return handler.NewCaptchaHandler(service)
})
return &Container{c}
}
// BuildEngine builds the Gin engine with routes based on service type.
// BuildEngine 根据服务类型构建 Gin 引擎并注册路由。
//
// Parameters:
// - serviceType: The type of service (auth or file).
//
// Returns:
// - *gin.Engine: The configured Gin engine.
// - error: Any error that occurred during setup.
//
// 参数:
// - serviceType: 服务类型 (auth 或 file)。
//
// 返回值:
// - *gin.Engine: 配置好的 Gin 引擎。
// - error: 设置过程中发生的任何错误。
func (c *Container) BuildEngine(serviceType routes.ServiceType) (*gin.Engine, error) {
var engine *gin.Engine
err := c.Invoke(func(cfg *config.Config, authHandler *handler.AuthHandler, fileHandler *handler.FileHandler, captchaHandler *handler.CaptchaHandler, emailHandler *handler.EmailHandler) {
engine = gin.New()
engine.RedirectTrailingSlash = false
engine.RedirectFixedPath = false
engine.Use(gin.Recovery())
engine.Use(middleware.CORS())
engine.Use(middleware.ErrorHandler())
engine.GET("/healthz", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
})
routes.SetupRoutes(engine, serviceType, authHandler, fileHandler, captchaHandler, emailHandler, cfg.JWT.Secret)
logger.Info("engine built successfully")
})
return engine, err
}
// BuildAuthEngine builds the Gin engine for auth service.
// BuildAuthEngine 为认证服务构建 Gin 引擎。
//
// This method only registers authentication routes.
//
// 此方法只注册认证路由。
func (c *Container) BuildAuthEngine() (*gin.Engine, error) {
return c.BuildEngine(routes.ServiceTypeAuth)
}
// BuildFileEngine builds the Gin engine for file service.
// BuildFileEngine 为文件服务构建 Gin 引擎。
//
// This method only registers file management routes.
//
// 此方法只注册文件管理路由。
func (c *Container) BuildFileEngine() (*gin.Engine, error) {
return c.BuildEngine(routes.ServiceTypeFile)
}
func (c *Container) InitializeServices() error {
return c.Invoke(func(mysql *database.MySQLClient, redis *database.RedisClient, minio *storage.MinioClient, cfg *config.Config) error {
if err := mysql.Migrate(); err != nil {
return err
}
if err := minio.EnsureBucket(cfg.Storage.BucketName); err != nil {
return err
}
logger.Info("all services initialized")
go startRecycleBinCleaner(cfg, mysql, minio)
return nil
})
}
func startRecycleBinCleaner(cfg *config.Config, mysql *database.MySQLClient, minio *storage.MinioClient) {
expireDays := cfg.Storage.RecycleBinExpireDays
if expireDays <= 0 {
expireDays = 7
}
fMetaRepo := infrastructureRepo.NewFileMetaRepository(mysql)
fRepo := infrastructureRepo.NewFileRepository(minio)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
logger.Info("starting recycle bin cleanup", "expire_days", expireDays)
expiredFiles, err := fMetaRepo.CleanExpiredRecycleBin(expireDays)
if err != nil {
logger.Error("failed to clean recycle bin", "error", err)
continue
}
for _, df := range expiredFiles {
if err := fRepo.Delete(df.UserCode, df.FileKey); err != nil {
logger.Error("failed to delete file from storage", "file_key", df.FileKey, "error", err)
}
}
logger.Info("recycle bin cleanup completed", "files_cleaned", len(expiredFiles))
<-ticker.C
}
}
+44
View File
@@ -0,0 +1,44 @@
package entity
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `json:"id" gorm:"primaryKey;comment:用户ID"`
UserCode string `json:"user_code" gorm:"uniqueIndex;size:8;comment:用户码(8位随机字母数字)"`
Username string `json:"username" gorm:"uniqueIndex;size:50;comment:用户名"`
Password string `json:"-" gorm:"size:255;comment:密码(加密存储)"`
Email string `json:"email" gorm:"size:100;comment:邮箱"`
Phone string `json:"phone" gorm:"size:20;comment:手机号"`
Nickname string `json:"nickname" gorm:"size:50;comment:昵称"`
StorageUsed int64 `json:"storage_used" gorm:"default:0;comment:已使用存储空间(字节)"`
StorageLimit int64 `json:"storage_limit" gorm:"default:52428800;comment:存储空间限制(字节),默认50MB"`
CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"comment:删除时间"`
}
func (User) TableName() string {
return "users"
}
func (User) TableComment() string {
return "用户表"
}
func NewUser(username, password, userCode, nickname string) *User {
if nickname == "" {
nickname = username
}
return &User{
Username: username,
Password: password,
UserCode: userCode,
Nickname: nickname,
StorageUsed: 0,
StorageLimit: 52428800,
}
}
@@ -0,0 +1,21 @@
package repository
import "cloudnest/internal/domain/auth/entity"
// UserRepository defines the interface for user data access operations.
// UserRepository 定义了用户数据访问操作的接口。
//
// This interface follows the Dependency Inversion Principle (DIP),
// where the application layer depends on this abstraction rather than concrete implementations.
//
// 此接口遵循依赖倒置原则(DIP),应用层依赖于这个抽象而非具体实现。
type UserRepository interface {
FindByUsername(username string) (*entity.User, error)
FindByUserCode(userCode string) (*entity.User, error)
FindByEmail(email string) (*entity.User, error)
Create(user *entity.User) error
FindByID(id uint) (*entity.User, error)
UpdateStorageUsed(userCode string, size int64) error
UpdateProfile(userCode, email, phone, nickname string) error
UpdatePassword(userCode, newPassword string) error
}
@@ -0,0 +1,36 @@
package entity
import (
"time"
"gorm.io/gorm"
)
type DeletedFile struct {
ID uint `json:"id" gorm:"primaryKey;comment:删除记录ID"`
UserID uint `json:"user_id" gorm:"comment:用户ID"`
UserCode string `json:"user_code" gorm:"index;size:8;comment:用户码"`
FileKey string `json:"file_key" gorm:"size:500;comment:文件存储键"`
FileName string `json:"file_name" gorm:"size:255;comment:文件名"`
Size int64 `json:"size" gorm:"comment:文件大小(字节)"`
DeletedAt time.Time `json:"deleted_at" gorm:"comment:删除时间"`
RemovedAt gorm.DeletedAt `json:"-" gorm:"comment:物理删除时间"`
}
func (DeletedFile) TableName() string {
return "deleted_files"
}
func (DeletedFile) TableComment() string {
return "回收站文件表"
}
func NewDeletedFile(userID uint, userCode, fileKey, fileName string, size int64) *DeletedFile {
return &DeletedFile{
UserID: userID,
UserCode: userCode,
FileKey: fileKey,
FileName: fileName,
Size: size,
}
}
+34
View File
@@ -0,0 +1,34 @@
package entity
import (
"time"
"gorm.io/gorm"
)
type Favorite struct {
ID uint `json:"id" gorm:"primaryKey;comment:收藏ID"`
UserID uint `json:"user_id" gorm:"comment:用户ID"`
UserCode string `json:"user_code" gorm:"index;size:8;comment:用户码"`
FileKey string `json:"file_key" gorm:"size:500;comment:文件存储键"`
Size int64 `json:"size" gorm:"default:0;comment:文件大小(字节)"`
CreatedAt time.Time `json:"created_at" gorm:"comment:收藏时间"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"comment:删除时间"`
}
func (Favorite) TableName() string {
return "favorites"
}
func (Favorite) TableComment() string {
return "用户收藏表"
}
func NewFavorite(userID uint, userCode, fileKey string, size int64) *Favorite {
return &Favorite{
UserID: userID,
UserCode: userCode,
FileKey: fileKey,
Size: size,
}
}
+34
View File
@@ -0,0 +1,34 @@
package entity
// FileMeta contains metadata about a file stored in the object storage.
// FileMeta 包含存储在对象存储中的文件元数据。
//
// Fields:
// - Key: The unique key/path of the file in the storage.
// - Size: The size of the file in bytes.
//
// 字段:
// - Key: 文件在存储中的唯一键/路径。
// - Size: 文件大小(字节)。
type FileMeta struct {
Key string `json:"key"`
Size int64 `json:"size"`
}
// UploadRequest contains the necessary information to upload a file.
// UploadRequest 包含上传文件所需的必要信息。
//
// Fields:
// - Filename: The original filename.
// - Content: The file content as bytes.
// - ContentType: The MIME type of the file.
//
// 字段:
// - Filename: 原始文件名。
// - Content: 文件内容(字节)。
// - ContentType: 文件的 MIME 类型。
type UploadRequest struct {
Filename string
Content []byte
ContentType string
}
@@ -0,0 +1,144 @@
package repository
import "io"
// FileInfo contains metadata for a file.
// FileInfo 包含文件的元数据。
//
// Fields:
// - Key: The storage key/path of the file.
// - Size: The file size in bytes.
// - LastModified: The timestamp when the file was last modified.
//
// 字段:
// - Key: 文件的存储键/路径。
// - Size: 文件大小(字节)。
// - LastModified: 文件最后修改的时间戳。
type FileInfo struct {
Key string
Size int64
LastModified string
}
// FileRepository defines the interface for file storage operations.
// FileRepository 定义了文件存储操作的接口。
//
// This interface follows the Dependency Inversion Principle (DIP),
// where the application layer depends on this abstraction rather than concrete implementations.
//
// 此接口遵循依赖倒置原则(DIP),应用层依赖于这个抽象而非具体实现。
type FileRepository interface {
// Upload uploads a file to the object storage.
// Upload 将文件上传到对象存储。
//
// Parameters:
// - bucket: The target bucket name.
// - objectName: The unique name/path for the object.
// - reader: The file content reader.
// - size: The size of the file in bytes.
// - contentType: The MIME type of the file.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 目标桶名称。
// - objectName: 对象的唯一名称/路径。
// - reader: 文件内容读取器。
// - size: 文件大小(字节)。
// - contentType: 文件的 MIME 类型。
//
// 返回值:
// - error: 如果操作失败则返回错误。
Upload(bucket, objectName string, reader io.Reader, size int64, contentType string) error
// List retrieves a list of files under the specified prefix with metadata.
// List 获取指定前缀下的文件列表及其元数据。
//
// Parameters:
// - bucket: The bucket name.
// - prefix: The prefix/path to filter files.
//
// Returns:
// - []FileInfo: A list of file information including key, size, and last modified time.
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - prefix: 用于过滤文件的前缀/路径。
//
// 返回值:
// - []FileInfo: 文件信息列表,包含键、大小和最后修改时间。
// - error: 如果操作失败则返回错误。
List(bucket, prefix string) ([]FileInfo, error)
// PresignDownload generates a pre-signed URL for downloading a file.
// PresignDownload 生成用于下载文件的预签名 URL。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object.
//
// Returns:
// - string: The pre-signed download URL.
// - error: An error if the operation fails.
//
// Note:
// - The URL is time-limited and doesn't require authentication.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 对象的名称/路径。
//
// 返回值:
// - string: 预签名下载 URL。
// - error: 如果操作失败则返回错误。
//
// 注意:
// - URL 有时间限制且不需要认证。
PresignDownload(bucket, objectName string) (string, error)
// Delete removes a file from the object storage.
// Delete 从对象存储中删除文件。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object to delete.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 要删除的对象的名称/路径。
//
// 返回值:
// - error: 如果操作失败则返回错误。
Delete(bucket, objectName string) error
// GetObject retrieves a file as a readable stream from the object storage.
// GetObject 从对象存储中获取文件的可读流。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object.
//
// Returns:
// - io.ReadCloser: A readable stream of the file content. Caller must close it.
// - int64: The file size in bytes.
// - string: The Content-Type of the file.
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 对象的名称/路径。
//
// 返回值:
// - io.ReadCloser: 文件内容的可读流。调用方必须关闭它。
// - int64: 文件大小(字节)。
// - string: 文件的 Content-Type。
// - error: 如果操作失败则返回错误。
GetObject(bucket, objectName string) (io.ReadCloser, int64, string, error)
GetObjectInfo(bucket, objectName string) (int64, string, error)
}
+92
View File
@@ -0,0 +1,92 @@
package database
import (
"cloudnest/internal/domain/auth/entity"
fileentity "cloudnest/internal/domain/file/entity"
"cloudnest/internal/pkg/logger"
"gorm.io/driver/mysql"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// MySQLClient provides MySQL database operations.
// MySQLClient 提供 MySQL 数据库操作。
//
// This struct wraps the GORM database connection and provides methods
// for database initialization and migration.
//
// 此结构体封装了 GORM 数据库连接,并提供了数据库初始化和迁移的方法。
type MySQLClient struct {
DB *gorm.DB
}
// NewMySQLClient creates a new MySQL client.
// NewMySQLClient 创建一个新的 MySQL 客户端。
//
// Parameters:
// - dsn: The MySQL data source name in format "user:password@tcp(host:port)/database".
//
// Returns:
// - *MySQLClient: A pointer to the new MySQLClient instance.
// - error: An error if the connection fails.
//
// Note:
// - The connection is established immediately.
// - GORM logging is enabled at Info level.
//
// 参数:
// - dsn: MySQL 数据源名称,格式为 "user:password@tcp(host:port)/database"。
//
// 返回值:
// - *MySQLClient: 新创建的 MySQLClient 实例指针。
// - error: 如果连接失败则返回错误。
//
// 注意:
// - 连接会立即建立。
// - GORM 日志级别为 Info。
func NewMySQLClient(dsn string) (*MySQLClient, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Info),
})
if err != nil {
return nil, err
}
logger.Info("mysql connected")
return &MySQLClient{DB: db}, nil
}
// Migrate runs database migrations for all registered models.
// Migrate 对所有注册的模型运行数据库迁移。
//
// Returns:
// - error: An error if migration fails.
//
// Note:
// - This method should be called once during application startup.
// - It automatically creates or updates database tables based on model definitions.
//
// 返回值:
// - error: 如果迁移失败则返回错误。
//
// 注意:
// - 此方法应在应用程序启动时调用一次。
// - 它会根据模型定义自动创建或更新数据库表。
func (c *MySQLClient) Migrate() error {
if err := c.DB.AutoMigrate(&entity.User{}, &fileentity.Favorite{}, &fileentity.DeletedFile{}); err != nil {
return err
}
c.setTableComment("users", "用户表")
c.setTableComment("favorites", "用户收藏表")
c.setTableComment("deleted_files", "回收站文件表")
return nil
}
func (c *MySQLClient) setTableComment(tableName, comment string) {
sql := "ALTER TABLE `" + tableName + "` COMMENT = '" + comment + "'"
if err := c.DB.Exec(sql).Error; err != nil {
logger.Error("failed to set table comment", "table", tableName, "error", err)
}
}
+58
View File
@@ -0,0 +1,58 @@
package database
import (
"cloudnest/internal/pkg/logger"
"github.com/go-redis/redis/v8"
)
// RedisClient provides Redis cache operations.
// RedisClient 提供 Redis 缓存操作。
//
// This struct wraps the go-redis client and provides methods for cache operations.
//
// 此结构体封装了 go-redis 客户端,并提供了缓存操作的方法。
type RedisClient struct {
Client *redis.Client
}
// NewRedisClient creates a new Redis client.
// NewRedisClient 创建一个新的 Redis 客户端。
//
// Parameters:
// - addr: The Redis server address (host:port).
// - password: The Redis password (empty string for no password).
// - db: The Redis database number (0-15).
//
// Returns:
// - *RedisClient: A pointer to the new RedisClient instance.
// - error: An error if the connection fails.
//
// Note:
// - A PING command is sent to verify the connection.
//
// 参数:
// - addr: Redis 服务器地址(host:port)。
// - password: Redis 密码(无密码时为空字符串)。
// - db: Redis 数据库编号(0-15)。
//
// 返回值:
// - *RedisClient: 新创建的 RedisClient 实例指针。
// - error: 如果连接失败则返回错误。
//
// 注意:
// - 会发送 PING 命令来验证连接。
func NewRedisClient(addr, password string, db int) (*RedisClient, error) {
client := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
_, err := client.Ping(client.Context()).Result()
if err != nil {
return nil, err
}
logger.Info("redis connected")
return &RedisClient{Client: client}, nil
}
@@ -0,0 +1,241 @@
package repository
import (
"time"
"cloudnest/internal/domain/file/entity"
"cloudnest/internal/infrastructure/database"
"cloudnest/internal/pkg/errors"
)
// FileMetaRepository provides database operations for file metadata.
// FileMetaRepository 提供文件元数据的数据库操作。
//
// This repository handles operations related to file favorites and recycle bin,
// storing data in MySQL database.
//
// 此仓储处理文件收藏和回收站相关操作,数据存储在 MySQL 数据库中。
type FileMetaRepository struct {
db *database.MySQLClient
}
// NewFileMetaRepository creates a new FileMetaRepository.
// NewFileMetaRepository 创建一个新的 FileMetaRepository。
//
// Parameters:
// - db: The MySQL client for database operations.
//
// Returns:
// - *FileMetaRepository: A pointer to the new FileMetaRepository instance.
//
// 参数:
// - db: 用于数据库操作的 MySQL 客户端。
//
// 返回值:
// - *FileMetaRepository: 新创建的 FileMetaRepository 实例指针。
func NewFileMetaRepository(db *database.MySQLClient) *FileMetaRepository {
return &FileMetaRepository{db: db}
}
// AddFavorite adds a file to user's favorites.
// AddFavorite 将文件添加到用户的收藏夹。
//
// Parameters:
// - favorite: The Favorite entity to save.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - favorite: 要保存的 Favorite 实体。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) AddFavorite(favorite *entity.Favorite) error {
var exists entity.Favorite
if err := r.db.DB.Where("user_code = ? AND file_key = ?", favorite.UserCode, favorite.FileKey).First(&exists).Error; err == nil {
return errors.BadRequest("文件已在收藏夹中")
}
if err := r.db.DB.Create(favorite).Error; err != nil {
return errors.InternalError("添加收藏失败", err)
}
return nil
}
// RemoveFavorite removes a file from user's favorites.
// RemoveFavorite 从用户的收藏夹中移除文件。
//
// Parameters:
// - userCode: The user's userCode.
// - fileKey: The file storage key.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
// - fileKey: 文件的存储键。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) RemoveFavorite(userCode, fileKey string) error {
result := r.db.DB.Where("user_code = ? AND file_key = ?", userCode, fileKey).Delete(&entity.Favorite{})
if result.Error != nil {
return errors.InternalError("取消收藏失败", result.Error)
}
if result.RowsAffected == 0 {
return errors.NotFound("收藏记录不存在")
}
return nil
}
// GetFavorites retrieves all favorites for a user.
// GetFavorites 获取用户的所有收藏。
//
// Parameters:
// - userCode: The user's userCode.
//
// Returns:
// - []entity.Favorite: A list of Favorite entities.
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
//
// 返回值:
// - []entity.Favorite: Favorite 实体列表。
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) GetFavorites(userCode string) ([]entity.Favorite, error) {
var favorites []entity.Favorite
if err := r.db.DB.Where("user_code = ?", userCode).Order("created_at DESC").Find(&favorites).Error; err != nil {
return nil, errors.InternalError("获取收藏列表失败", err)
}
return favorites, nil
}
// AddToRecycleBin adds a file to the recycle bin.
// AddToRecycleBin 将文件添加到回收站。
//
// Parameters:
// - deletedFile: The DeletedFile entity to save.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - deletedFile: 要保存的 DeletedFile 实体。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) AddToRecycleBin(deletedFile *entity.DeletedFile) error {
if err := r.db.DB.Create(deletedFile).Error; err != nil {
return errors.InternalError("添加到回收站失败", err)
}
return nil
}
// RestoreFromRecycleBin restores a file from the recycle bin.
// RestoreFromRecycleBin 从回收站恢复文件。
//
// Parameters:
// - userCode: The user's userCode.
// - fileKey: The file storage key.
//
// Returns:
// - *entity.DeletedFile: The restored DeletedFile entity.
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
// - fileKey: 文件的存储键。
//
// 返回值:
// - *entity.DeletedFile: 恢复的 DeletedFile 实体。
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) RestoreFromRecycleBin(userCode, fileKey string) (*entity.DeletedFile, error) {
var deletedFile entity.DeletedFile
if err := r.db.DB.Where("user_code = ? AND file_key = ?", userCode, fileKey).First(&deletedFile).Error; err != nil {
return nil, errors.NotFound("回收站中不存在该文件")
}
if err := r.db.DB.Delete(&deletedFile).Error; err != nil {
return nil, errors.InternalError("恢复文件失败", err)
}
return &deletedFile, nil
}
// DeleteFromRecycleBin permanently deletes a file from the recycle bin.
// DeleteFromRecycleBin 从回收站永久删除文件。
//
// Parameters:
// - userCode: The user's userCode.
// - fileKey: The file storage key.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
// - fileKey: 文件的存储键。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) DeleteFromRecycleBin(userCode, fileKey string) error {
result := r.db.DB.Where("user_code = ? AND file_key = ?", userCode, fileKey).Delete(&entity.DeletedFile{})
if result.Error != nil {
return errors.InternalError("永久删除失败", result.Error)
}
if result.RowsAffected == 0 {
return errors.NotFound("回收站中不存在该文件")
}
return nil
}
// GetRecycleBin retrieves all deleted files for a user.
// GetRecycleBin 获取用户的所有已删除文件。
//
// Parameters:
// - userCode: The user's userCode.
//
// Returns:
// - []entity.DeletedFile: A list of DeletedFile entities.
// - error: An error if the operation fails.
//
// 参数:
// - userCode: 用户的用户代码。
//
// 返回值:
// - []entity.DeletedFile: DeletedFile 实体列表。
// - error: 如果操作失败则返回错误。
func (r *FileMetaRepository) GetRecycleBin(userCode string) ([]entity.DeletedFile, error) {
var deletedFiles []entity.DeletedFile
if err := r.db.DB.Where("user_code = ?", userCode).Order("deleted_at DESC").Find(&deletedFiles).Error; err != nil {
return nil, errors.InternalError("获取回收站列表失败", err)
}
return deletedFiles, nil
}
func (r *FileMetaRepository) GetDeletedFileKeys(userCode string) ([]string, error) {
var keys []string
if err := r.db.DB.Model(&entity.DeletedFile{}).Where("user_code = ?", userCode).Pluck("file_key", &keys).Error; err != nil {
return nil, errors.InternalError("获取已删除文件键失败", err)
}
return keys, nil
}
func (r *FileMetaRepository) CleanExpiredRecycleBin(expireDays int) ([]entity.DeletedFile, error) {
var expiredFiles []entity.DeletedFile
if err := r.db.DB.Where("deleted_at < ?", time.Now().AddDate(0, 0, -expireDays)).Find(&expiredFiles).Error; err != nil {
return nil, errors.InternalError("查询过期文件失败", err)
}
if len(expiredFiles) == 0 {
return []entity.DeletedFile{}, nil
}
result := r.db.DB.Where("deleted_at < ?", time.Now().AddDate(0, 0, -expireDays)).Delete(&entity.DeletedFile{})
if result.Error != nil {
return nil, errors.InternalError("清理过期文件失败", result.Error)
}
return expiredFiles, nil
}
@@ -0,0 +1,216 @@
package repository
import (
"context"
"io"
"cloudnest/internal/domain/file/repository"
"cloudnest/internal/infrastructure/storage"
"cloudnest/internal/pkg/errors"
"github.com/minio/minio-go/v7"
)
// fileRepository is the MinIO implementation of FileRepository.
// fileRepository 是 FileRepository 的 MinIO 实现。
//
// This struct implements the FileRepository interface using MinIO for object storage operations.
//
// 此结构体使用 MinIO 实现了 FileRepository 接口,用于对象存储操作。
type fileRepository struct {
minio *storage.MinioClient
}
// NewFileRepository creates a new file repository.
// NewFileRepository 创建一个新的文件仓储。
//
// Parameters:
// - minio: The MinIO client for storage operations.
//
// Returns:
// - repository.FileRepository: A FileRepository implementation.
//
// 参数:
// - minio: 用于存储操作的 MinIO 客户端。
//
// 返回值:
// - repository.FileRepository: FileRepository 的实现。
func NewFileRepository(minio *storage.MinioClient) repository.FileRepository {
return &fileRepository{minio: minio}
}
// Upload uploads a file to the object storage.
// Upload 将文件上传到对象存储。
//
// Parameters:
// - bucket: The target bucket name.
// - objectName: The unique name/path for the object.
// - reader: The file content reader.
// - size: The size of the file in bytes.
// - contentType: The MIME type of the file.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 目标桶名称。
// - objectName: 对象的唯一名称/路径。
// - reader: 文件内容读取器。
// - size: 文件大小(字节)。
// - contentType: 文件的 MIME 类型。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *fileRepository) Upload(bucket, objectName string, reader io.Reader, size int64, contentType string) error {
_, err := r.minio.Client.PutObject(context.Background(), bucket, objectName, reader, size, minio.PutObjectOptions{ContentType: contentType})
if err != nil {
return errors.InternalError("上传文件失败", err)
}
return nil
}
// List retrieves a list of files under the specified prefix with metadata.
// List 获取指定前缀下的文件列表及其元数据。
//
// Parameters:
// - bucket: The bucket name.
// - prefix: The prefix/path to filter files.
//
// Returns:
// - []repository.FileInfo: A list of file information.
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - prefix: 用于过滤文件的前缀/路径。
//
// 返回值:
// - []repository.FileInfo: 文件信息列表。
// - error: 如果操作失败则返回错误。
func (r *fileRepository) List(bucket, prefix string) ([]repository.FileInfo, error) {
var files []repository.FileInfo
for obj := range r.minio.Client.ListObjects(context.Background(), bucket, minio.ListObjectsOptions{Prefix: prefix}) {
if obj.Err != nil {
return nil, errors.InternalError("获取文件列表失败", obj.Err)
}
files = append(files, repository.FileInfo{
Key: obj.Key,
Size: obj.Size,
LastModified: obj.LastModified.Format("2006-01-02 15:04:05"),
})
}
return files, nil
}
// PresignDownload generates a pre-signed URL for downloading a file.
// PresignDownload 生成用于下载文件的预签名 URL。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object.
//
// Returns:
// - string: The pre-signed download URL.
// - error: An error if the operation fails.
//
// Note:
// - The URL expires after storage.PresignDuration (default 24 hours).
// - Returns errors.NotFoundError if the file doesn't exist.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 对象的名称/路径。
//
// 返回值:
// - string: 预签名下载 URL。
// - error: 如果操作失败则返回错误。
//
// 注意:
// - URL 在 storage.PresignDuration(默认 24 小时)后过期。
// - 如果文件不存在,返回 errors.NotFoundError。
func (r *fileRepository) PresignDownload(bucket, objectName string) (string, error) {
url, err := r.minio.Client.PresignedGetObject(context.Background(), bucket, objectName, storage.PresignDuration, nil)
if err != nil {
return "", errors.NotFound("文件不存在")
}
return url.String(), nil
}
// Delete removes a file from the object storage.
// Delete 从对象存储中删除文件。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object to delete.
//
// Returns:
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 要删除的对象的名称/路径。
//
// 返回值:
// - error: 如果操作失败则返回错误。
func (r *fileRepository) Delete(bucket, objectName string) error {
err := r.minio.Client.RemoveObject(context.Background(), bucket, objectName, minio.RemoveObjectOptions{})
if err != nil {
return errors.InternalError("删除文件失败", err)
}
return nil
}
// GetObject retrieves a file as a readable stream from the object storage.
// GetObject 从对象存储中获取文件的可读流。
//
// Parameters:
// - bucket: The bucket name.
// - objectName: The name/path of the object.
//
// Returns:
// - io.ReadCloser: A readable stream of the file content. Caller must close it.
// - int64: The file size in bytes.
// - string: The Content-Type of the file.
// - error: An error if the operation fails.
//
// 参数:
// - bucket: 桶名称。
// - objectName: 对象的名称/路径。
//
// 返回值:
// - io.ReadCloser: 文件内容的可读流。调用方必须关闭它。
// - int64: 文件大小(字节)。
// - string: 文件的 Content-Type。
// - error: 如果操作失败则返回错误。
func (r *fileRepository) GetObject(bucket, objectName string) (io.ReadCloser, int64, string, error) {
obj, err := r.minio.Client.GetObject(context.Background(), bucket, objectName, minio.GetObjectOptions{})
if err != nil {
return nil, 0, "", errors.NotFound("文件不存在")
}
info, err := obj.Stat()
if err != nil {
obj.Close()
return nil, 0, "", errors.NotFound("文件不存在")
}
contentType := info.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
return obj, info.Size, contentType, nil
}
func (r *fileRepository) GetObjectInfo(bucket, objectName string) (int64, string, error) {
info, err := r.minio.Client.StatObject(context.Background(), bucket, objectName, minio.StatObjectOptions{})
if err != nil {
return 0, "", errors.NotFound("文件不存在")
}
contentType := info.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
return info.Size, contentType, nil
}
@@ -0,0 +1,105 @@
package repository
import (
"cloudnest/internal/domain/auth/entity"
"cloudnest/internal/domain/auth/repository"
"cloudnest/internal/infrastructure/database"
"cloudnest/internal/pkg/errors"
"gorm.io/gorm"
"strings"
)
type userRepository struct {
db *database.MySQLClient
}
func NewUserRepository(db *database.MySQLClient) repository.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindByUsername(username string) (*entity.User, error) {
var user entity.User
err := r.db.DB.Where("username = ?", username).First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.NotFound("用户不存在")
}
return nil, errors.InternalError("查询用户失败", err)
}
return &user, nil
}
func (r *userRepository) FindByUserCode(userCode string) (*entity.User, error) {
var user entity.User
err := r.db.DB.Where("user_code = ?", userCode).First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.NotFound("用户不存在")
}
return nil, errors.InternalError("查询用户失败", err)
}
return &user, nil
}
func (r *userRepository) FindByEmail(email string) (*entity.User, error) {
var user entity.User
err := r.db.DB.Where("email = ?", email).First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.NotFound("用户不存在")
}
return nil, errors.InternalError("查询用户失败", err)
}
return &user, nil
}
func (r *userRepository) Create(user *entity.User) error {
err := r.db.DB.Create(user).Error
if err != nil {
if strings.Contains(err.Error(), "Duplicate entry") || strings.Contains(err.Error(), "unique constraint") {
return errors.Conflict("用户名或用户码已存在")
}
return errors.InternalError("创建用户失败", err)
}
return nil
}
func (r *userRepository) FindByID(id uint) (*entity.User, error) {
var user entity.User
err := r.db.DB.First(&user, id).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.NotFound("用户不存在")
}
return nil, errors.InternalError("查询用户失败", err)
}
return &user, nil
}
func (r *userRepository) UpdateStorageUsed(userCode string, size int64) error {
err := r.db.DB.Model(&entity.User{}).Where("user_code = ?", userCode).Update("storage_used", gorm.Expr("storage_used + ?", size)).Error
if err != nil {
return errors.InternalError("更新存储空间失败", err)
}
return nil
}
func (r *userRepository) UpdateProfile(userCode, email, phone, nickname string) error {
err := r.db.DB.Model(&entity.User{}).Where("user_code = ?", userCode).Updates(map[string]interface{}{
"email": email,
"phone": phone,
"nickname": nickname,
}).Error
if err != nil {
return errors.InternalError("更新个人信息失败", err)
}
return nil
}
func (r *userRepository) UpdatePassword(userCode, newPassword string) error {
err := r.db.DB.Model(&entity.User{}).Where("user_code = ?", userCode).Update("password", newPassword).Error
if err != nil {
return errors.InternalError("更新密码失败", err)
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package storage
import (
"context"
"time"
"cloudnest/internal/pkg/logger"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// PresignDuration is the default duration for pre-signed URLs.
// PresignDuration 是预签名 URL 的默认有效期。
var PresignDuration = 24 * time.Hour
// MinioClient provides MinIO object storage operations.
// MinioClient 提供 MinIO 对象存储操作。
//
// This struct wraps the MinIO client and provides methods for storage operations.
//
// 此结构体封装了 MinIO 客户端,并提供了存储操作的方法。
type MinioClient struct {
Client *minio.Client
}
// NewMinioClient creates a new MinIO client.
// NewMinioClient 创建一个新的 MinIO 客户端。
//
// Parameters:
// - endpoint: The MinIO server endpoint (host:port).
// - accessKey: The MinIO access key.
// - secretKey: The MinIO secret key.
// - useSSL: Whether to use SSL/TLS for connections.
//
// Returns:
// - *MinioClient: A pointer to the new MinioClient instance.
// - error: An error if the connection fails.
//
// 参数:
// - endpoint: MinIO 服务器端点(host:port)。
// - accessKey: MinIO 访问密钥。
// - secretKey: MinIO 秘密密钥。
// - useSSL: 是否使用 SSL/TLS 连接。
//
// 返回值:
// - *MinioClient: 新创建的 MinioClient 实例指针。
// - error: 如果连接失败则返回错误。
func NewMinioClient(endpoint, accessKey, secretKey string, useSSL bool) (*MinioClient, error) {
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: useSSL,
})
if err != nil {
return nil, err
}
logger.Info("minio connected")
return &MinioClient{Client: client}, nil
}
// EnsureBucket creates the bucket if it doesn't exist.
// EnsureBucket 如果桶不存在则创建桶。
//
// Parameters:
// - bucketName: The name of the bucket to ensure.
//
// Returns:
// - error: An error if the operation fails.
//
// Note:
// - This method is idempotent - it can be called safely even if the bucket exists.
//
// 参数:
// - bucketName: 要确保存在的桶名称。
//
// 返回值:
// - error: 如果操作失败则返回错误。
//
// 注意:
// - 此方法是幂等的 - 即使桶已存在也可以安全调用。
func (c *MinioClient) EnsureBucket(bucketName string) error {
exists, err := c.Client.BucketExists(context.Background(), bucketName)
if err != nil {
return err
}
if !exists {
if err := c.Client.MakeBucket(context.Background(), bucketName, minio.MakeBucketOptions{}); err != nil {
return err
}
logger.Info("bucket created", "bucket", bucketName)
}
return nil
}
@@ -0,0 +1,219 @@
package handler
import (
"cloudnest/internal/application/auth"
"cloudnest/internal/application/captcha"
"cloudnest/internal/application/email"
"cloudnest/internal/pkg/errors"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// AuthHandler handles HTTP requests for authentication operations.
// AuthHandler 处理认证操作的 HTTP 请求。
//
// This struct acts as the controller layer for authentication endpoints,
// converting HTTP requests to service calls and formatting responses.
//
// 此结构体作为认证端点的控制器层,将 HTTP 请求转换为服务调用并格式化响应。
type AuthHandler struct {
service *auth.AuthService
captchaService *captcha.CaptchaService
emailService *email.EmailService
}
// NewAuthHandler creates a new AuthHandler.
// NewAuthHandler 创建一个新的 AuthHandler。
//
// Parameters:
// - service: The authentication service to use.
// - captchaService: The captcha service to use.
// - emailService: The email service to use.
//
// Returns:
// - *AuthHandler: A pointer to the new AuthHandler instance.
//
// 参数:
// - service: 要使用的认证服务。
// - captchaService: 要使用的验证码服务。
// - emailService: 要使用的邮件服务。
//
// 返回值:
// - *AuthHandler: 新创建的 AuthHandler 实例指针。
func NewAuthHandler(service *auth.AuthService, captchaService *captcha.CaptchaService, emailService *email.EmailService) *AuthHandler {
return &AuthHandler{
service: service,
captchaService: captchaService,
emailService: emailService,
}
}
// Register handles user registration requests.
// Register 处理用户注册请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/auth/register
// - Body: {"username": "string", "password": "string"}
//
// Response:
// - Success: 200 OK {"code": 0, "message": "注册成功"}
// - Conflict: 409 {"code": 409, "message": "用户名已存在"}
// - Bad Request: 400 {"code": 400, "message": "参数错误"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/auth/register
// - 请求体: {"username": "string", "password": "string"}
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "注册成功"}
// - 冲突: 409 {"code": 409, "message": "用户名已存在"}
// - 请求错误: 400 {"code": 400, "message": "参数错误"}
func (h *AuthHandler) Register(c *gin.Context) {
var req auth.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if !h.captchaService.Verify(req.CaptchaID, req.CaptchaAnswer) {
response.BadRequest(c, "图形验证码错误")
return
}
if err := h.service.Register(req.Username, req.Password, req.Email, req.Nickname, req.EmailCode); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "注册成功")
}
// Login handles user login requests.
// Login 处理用户登录请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/auth/login
// - Body: {"username": "string", "password": "string"}
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"token": "string"}}
// - Unauthorized: 401 {"code": 401, "message": "用户名或密码错误"}
// - Bad Request: 400 {"code": 400, "message": "参数错误"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/auth/login
// - 请求体: {"username": "string", "password": "string"}
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"token": "string"}}
// - 未授权: 401 {"code": 401, "message": "用户名或密码错误"}
// - 请求错误: 400 {"code": 400, "message": "参数错误"}
func (h *AuthHandler) Login(c *gin.Context) {
var req auth.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if !h.captchaService.Verify(req.CaptchaID, req.CaptchaAnswer) {
response.BadRequest(c, "图形验证码错误")
return
}
token, _, err := h.service.Login(req.Username, req.Password)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.Success(c, auth.TokenResponse{Token: token})
}
func (h *AuthHandler) Profile(c *gin.Context) {
userCode := c.GetString("user_code")
if userCode == "" {
response.Unauthorized(c, "用户码不能为空")
return
}
user, err := h.service.GetUserInfo(userCode)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.Success(c, map[string]interface{}{
"username": user.Username,
"nickname": user.Nickname,
"email": user.Email,
"phone": user.Phone,
"storage_used": user.StorageUsed,
"storage_limit": user.StorageLimit,
})
}
func (h *AuthHandler) UpdateProfile(c *gin.Context) {
userCode := c.GetString("user_code")
if userCode == "" {
response.Unauthorized(c, "用户码不能为空")
return
}
var req auth.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if err := h.service.UpdateProfile(userCode, req.Email, req.Phone, req.Nickname); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "更新成功")
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
userCode := c.GetString("user_code")
if userCode == "" {
response.Unauthorized(c, "用户码不能为空")
return
}
var req auth.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if err := h.service.ChangePassword(userCode, req.CurrentPassword, req.NewPassword); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "密码修改成功")
}
@@ -0,0 +1,63 @@
package handler
import (
"net/http"
"cloudnest/internal/application/captcha"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// CaptchaHandler handles HTTP requests for captcha operations.
// CaptchaHandler 处理验证码操作的 HTTP 请求。
type CaptchaHandler struct {
service *captcha.CaptchaService
}
// NewCaptchaHandler creates a new CaptchaHandler.
// NewCaptchaHandler 创建一个新的 CaptchaHandler。
func NewCaptchaHandler(service *captcha.CaptchaService) *CaptchaHandler {
return &CaptchaHandler{service: service}
}
// GenerateCaptcha handles GET /api/v1/captcha.
// GenerateCaptcha 处理 GET /api/v1/captcha。
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"captcha_id": "string", "image_url": "string"}}
func (h *CaptchaHandler) GenerateCaptcha(c *gin.Context) {
captchaID, imageBytes, err := h.service.Generate()
if err != nil {
response.InternalError(c, "验证码生成失败")
return
}
imageUrl := "/api/v1/captcha/" + captchaID + "/image"
// Store image bytes in context for potential direct use, but we return URL here
_ = imageBytes
response.Success(c, gin.H{
"captcha_id": captchaID,
"image_url": imageUrl,
})
}
// GetCaptchaImage handles GET /api/v1/captcha/:id/image.
// GetCaptchaImage 处理 GET /api/v1/captcha/:id/image。
//
// Returns the captcha PNG image directly.
func (h *CaptchaHandler) GetCaptchaImage(c *gin.Context) {
captchaID := c.Param("id")
if captchaID == "" {
c.Status(http.StatusNotFound)
return
}
imageBytes, err := h.service.GetImage(captchaID)
if err != nil {
c.Status(http.StatusNotFound)
return
}
c.Data(http.StatusOK, "image/png", imageBytes)
}
@@ -0,0 +1,62 @@
package handler
import (
"cloudnest/internal/application/captcha"
"cloudnest/internal/application/email"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// EmailHandler handles HTTP requests for email verification code operations.
// EmailHandler 处理邮箱验证码操作的 HTTP 请求。
type EmailHandler struct {
emailService *email.EmailService
captchaService *captcha.CaptchaService
}
// NewEmailHandler creates a new EmailHandler.
// NewEmailHandler 创建一个新的 EmailHandler。
func NewEmailHandler(emailService *email.EmailService, captchaService *captcha.CaptchaService) *EmailHandler {
return &EmailHandler{
emailService: emailService,
captchaService: captchaService,
}
}
// SendEmailCodeRequest represents the request body for sending email verification code.
// SendEmailCodeRequest 表示发送邮箱验证码的请求体。
type SendEmailCodeRequest struct {
Email string `json:"email" binding:"required,email"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaAnswer string `json:"captcha_answer" binding:"required"`
}
// SendEmailCode handles POST /api/v1/auth/email-code.
// SendEmailCode 处理 POST /api/v1/auth/email-code。
//
// It first verifies the captcha, then sends the email verification code.
// 先验证图形验证码,通过后才发送邮箱验证码。
func (h *EmailHandler) SendEmailCode(c *gin.Context) {
var req SendEmailCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if !h.captchaService.Verify(req.CaptchaID, req.CaptchaAnswer) {
response.Error(c, 400, "图形验证码错误")
return
}
_, err := h.emailService.SendVerifyCode(req.Email)
if err != nil {
if err.Error() == "邮件服务未配置" {
response.Error(c, 503, "邮件服务未配置")
return
}
response.InternalError(c, "邮件发送失败")
return
}
response.SuccessMsg(c, "验证码已发送")
}
@@ -0,0 +1,713 @@
package handler
import (
"io"
"net/url"
"strconv"
"strings"
"cloudnest/internal/application/auth"
"cloudnest/internal/application/file"
"cloudnest/internal/pkg/errors"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
type FileHandler struct {
service *file.FileService
authService *auth.AuthService
}
func NewFileHandler(service *file.FileService, authService *auth.AuthService) *FileHandler {
return &FileHandler{service: service, authService: authService}
}
// Upload handles file upload requests.
// Upload 处理文件上传请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/upload
// - Content-Type: multipart/form-data
// - Body: file=<file>
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"message": "上传成功", "file": "string", "size": int}}
// - Bad Request: 400 {"code": 400, "message": "文件上传失败"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/files/upload
// - 内容类型: multipart/form-data
// - 请求体: file=<file>
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"message": "上传成功", "file": "string", "size": int}}
// - 请求错误: 400 {"code": 400, "message": "文件上传失败"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Upload(c *gin.Context) {
f, header, err := c.Request.FormFile("file")
if err != nil {
response.BadRequest(c, "文件上传失败")
return
}
defer f.Close()
userCode := c.GetString("user_code")
if err := h.authService.CheckStorageLimit(userCode, header.Size); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
if err := h.service.Upload(userCode, header.Filename, f, header.Size, header.Header.Get("Content-Type")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
if err := h.authService.AddStorageUsed(userCode, header.Size); err != nil {
response.InternalError(c, "更新存储空间失败")
return
}
response.Success(c, file.UploadResponse{
Message: "上传成功",
File: userCode + "/" + header.Filename,
Size: header.Size,
})
}
// List handles file listing requests.
// List 处理文件列表请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"files": ["string"]}}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"files": ["string"]}}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) List(c *gin.Context) {
userCode := c.GetString("user_code")
fileInfos, err := h.service.List(userCode)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
items := make([]file.FileItem, len(fileInfos))
for i, info := range fileInfos {
items[i] = file.FileItem{
Key: info.Key,
Size: info.Size,
LastModified: info.LastModified,
}
}
response.Success(c, file.ListResponse{Files: items})
}
// Download handles file download requests by streaming the file through the backend.
// Download 处理文件下载请求,通过后端流式传输文件。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/download/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK (file content streamed with Content-Disposition header)
// - Not Found: 404 {"code": 404, "message": "文件不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/download/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK(文件内容流式传输,带 Content-Disposition 头部)
// - 未找到: 404 {"code": 404, "message": "文件不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Download(c *gin.Context) {
userCode := c.GetString("user_code")
filename := c.Param("name")
reader, size, contentType, err := h.service.GetFileStream(userCode, filename)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
defer reader.Close()
// Set headers for file download / 设置文件下载头部
encodedName := url.PathEscape(filename)
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"; filename*=UTF-8''"+encodedName)
c.Header("Content-Type", contentType)
c.Header("Content-Length", strconv.FormatInt(size, 10))
// Stream the file content to the response / 将文件内容流式传输到响应
c.Status(200)
io.Copy(c.Writer, reader)
}
// Delete handles file deletion requests.
// Delete 处理文件删除请求。
//
// Request:
// - Method: DELETE
// - Path: /api/v1/files/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "删除成功"}
// - Not Found: 404 {"code": 404, "message": "文件不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: DELETE
// - 路径: /api/v1/files/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "删除成功"}
// - 未找到: 404 {"code": 404, "message": "文件不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Delete(c *gin.Context) {
userCode := c.GetString("user_code")
if err := h.service.Delete(userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "删除成功")
}
// SoftDelete handles soft delete requests, moving file to recycle bin.
// SoftDelete 处理软删除请求,将文件移动到回收站。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/soft-delete/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "已移到回收站"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/files/soft-delete/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "已移到回收站"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) SoftDelete(c *gin.Context) {
userCode := c.GetString("user_code")
userID := c.GetUint("user_id")
filename := c.Param("name")
var req struct {
Size int64 `json:"size"`
}
c.ShouldBindJSON(&req)
if err := h.service.SoftDelete(userID, userCode, filename, req.Size); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "已移到回收站")
}
// Favorite handles file favorite requests.
// Favorite 处理文件收藏请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/favorite/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "收藏成功"}
// - Bad Request: 400 {"code": 400, "message": "文件已在收藏夹中"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/files/favorite/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "收藏成功"}
// - 请求错误: 400 {"code": 400, "message": "文件已在收藏夹中"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Favorite(c *gin.Context) {
userCode := c.GetString("user_code")
userID := c.GetUint("user_id")
if err := h.service.Favorite(userID, userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "收藏成功")
}
// Unfavorite handles file unfavorite requests.
// Unfavorite 处理文件取消收藏请求。
//
// Request:
// - Method: DELETE
// - Path: /api/v1/files/favorite/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "取消收藏成功"}
// - Not Found: 404 {"code": 404, "message": "收藏记录不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: DELETE
// - 路径: /api/v1/files/favorite/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "取消收藏成功"}
// - 未找到: 404 {"code": 404, "message": "收藏记录不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Unfavorite(c *gin.Context) {
userCode := c.GetString("user_code")
if err := h.service.Unfavorite(userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "取消收藏成功")
}
// ListFavorites handles favorite listing requests.
// ListFavorites 处理收藏列表请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/favorites
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"favorites": [...]}}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/favorites
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"favorites": [...]}}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) ListFavorites(c *gin.Context) {
userCode := c.GetString("user_code")
favorites, err := h.service.GetFavorites(userCode)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
items := make([]file.FavoriteItem, len(favorites))
for i, f := range favorites {
items[i] = file.FavoriteItem{
FileKey: f.FileKey,
FileName: strings.TrimPrefix(f.FileKey, userCode+"/"),
Size: f.Size,
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
response.Success(c, file.FavoriteListResponse{Favorites: items})
}
// RecycleBin handles recycle bin listing requests.
// RecycleBin 处理回收站列表请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/recycle-bin
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"files": [...]}}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/recycle-bin
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"files": [...]}}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) RecycleBin(c *gin.Context) {
userCode := c.GetString("user_code")
deletedFiles, err := h.service.GetRecycleBin(userCode)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
items := make([]file.DeletedFileItem, len(deletedFiles))
for i, df := range deletedFiles {
items[i] = file.DeletedFileItem{
FileKey: df.FileKey,
FileName: df.FileName,
Size: df.Size,
DeletedAt: df.DeletedAt.Format("2006-01-02 15:04:05"),
}
}
response.Success(c, file.RecycleBinResponse{Files: items})
}
// Restore handles file restore requests from recycle bin.
// Restore 处理从回收站恢复文件的请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/restore/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "恢复成功"}
// - Not Found: 404 {"code": 404, "message": "回收站中不存在该文件"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: POST
// - 路径: /api/v1/files/restore/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "恢复成功"}
// - 未找到: 404 {"code": 404, "message": "回收站中不存在该文件"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Restore(c *gin.Context) {
userCode := c.GetString("user_code")
if _, err := h.service.RestoreFromRecycleBin(userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "恢复成功")
}
// DeleteFromRecycleBin handles permanent deletion requests from recycle bin.
// DeleteFromRecycleBin 处理从回收站永久删除文件的请求。
//
// Request:
// - Method: DELETE
// - Path: /api/v1/files/recycle-bin/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "永久删除成功"}
// - Not Found: 404 {"code": 404, "message": "回收站中不存在该文件"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: DELETE
// - 路径: /api/v1/files/recycle-bin/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "永久删除成功"}
// - 未找到: 404 {"code": 404, "message": "回收站中不存在该文件"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) DeleteFromRecycleBin(c *gin.Context) {
userCode := c.GetString("user_code")
if err := h.service.DeleteFromRecycleBin(userCode, c.Param("name")); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
response.SuccessMsg(c, "永久删除成功")
}
// Preview handles file preview requests.
// Preview 处理文件预览请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/preview/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"file_type": "string"}}
// - Not Found: 404 {"code": 404, "message": "文件不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/preview/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK {"code": 0, "message": "success", "data": {"file_type": "string"}}
// - 未找到: 404 {"code": 404, "message": "文件不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Preview(c *gin.Context) {
filename := c.Param("name")
fileType := "other"
if h.service.IsImageFile(filename) {
fileType = "image"
} else if h.service.IsTextFile(filename) {
fileType = "text"
} else if h.service.IsPDFFile(filename) {
fileType = "pdf"
}
response.Success(c, file.PreviewResponse{FileType: fileType})
}
// Content handles file content requests by streaming the file inline through the backend.
// Content 处理文件内容请求,通过后端内联流式传输文件。
//
// This endpoint is used for file preview (images, PDFs, text) by streaming
// the file content directly from MinIO through the Go backend, avoiding
// CORS issues and keeping MinIO internal.
//
// Request:
// - Method: GET
// - Path: /api/v1/files/content/:name
// - Header: Authorization: Bearer <token>
//
// Response:
// - Success: 200 OK (file content streamed inline)
// - Not Found: 404 {"code": 404, "message": "文件不存在"}
// - Unauthorized: 401 {"code": 401, "message": "未提供认证令牌"}
//
// 此端点用于文件预览(图片、PDF、文本),通过 Go 后端直接从 MinIO
// 流式传输文件内容,避免 CORS 问题并保持 MinIO 内部可见。
//
// 请求:
// - 方法: GET
// - 路径: /api/v1/files/content/:name
// - 头部: Authorization: Bearer <token>
//
// 响应:
// - 成功: 200 OK(文件内容内联流式传输)
// - 未找到: 404 {"code": 404, "message": "文件不存在"}
// - 未授权: 401 {"code": 401, "message": "未提供认证令牌"}
func (h *FileHandler) Content(c *gin.Context) {
userCode := c.GetString("user_code")
filename := c.Param("name")
reader, size, contentType, err := h.service.GetFileStream(userCode, filename)
if err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
defer reader.Close()
// Set headers for inline preview / 设置内联预览头部
c.Header("Content-Disposition", "inline")
c.Header("Content-Type", contentType)
c.Header("Content-Length", strconv.FormatInt(size, 10))
// Stream the file content to the response / 将文件内容流式传输到响应
c.Status(200)
io.Copy(c.Writer, reader)
}
// CheckUpload handles upload check requests for resumable uploads.
// CheckUpload 处理断点续传的上传检查请求。
//
// Request:
// - Method: GET
// - Path: /api/v1/files/upload/check
// - Query: hash=<file_hash>&filename=<filename>
//
// Response:
// - Success: 200 OK {"code": 0, "message": "success", "data": {"uploaded_chunks": [0, 1, 2]}}
func (h *FileHandler) CheckUpload(c *gin.Context) {
userCode := c.GetString("user_code")
hash := c.Query("hash")
if hash == "" {
response.BadRequest(c, "缺少文件哈希")
return
}
chunks, err := h.service.GetUploadedChunks(userCode, hash)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, map[string]interface{}{"uploaded_chunks": chunks})
}
// UploadChunk handles chunk upload requests for resumable uploads.
// UploadChunk 处理断点续传的分片上传请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/upload/chunk
// - Content-Type: multipart/form-data
// - Body: file=<chunk_data>&hash=<file_hash>&chunk_index=<index>&chunk_count=<count>&filename=<filename>&file_size=<size>
func (h *FileHandler) UploadChunk(c *gin.Context) {
userCode := c.GetString("user_code")
f, _, err := c.Request.FormFile("file")
if err != nil {
response.BadRequest(c, "分片上传失败")
return
}
defer f.Close()
hash := c.PostForm("hash")
chunkIndexStr := c.PostForm("chunk_index")
fileSizeStr := c.PostForm("file_size")
if hash == "" || chunkIndexStr == "" || fileSizeStr == "" {
response.BadRequest(c, "缺少必要参数")
return
}
chunkIndex, err := strconv.Atoi(chunkIndexStr)
if err != nil {
response.BadRequest(c, "分片索引无效")
return
}
fileSize, err := strconv.ParseInt(fileSizeStr, 10, 64)
if err != nil {
response.BadRequest(c, "文件大小无效")
return
}
if err := h.authService.CheckStorageLimit(userCode, fileSize); err != nil {
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
response.InternalError(c, err.Error())
}
return
}
data, err := io.ReadAll(f)
if err != nil {
response.BadRequest(c, "读取分片数据失败")
return
}
if err := h.service.SaveChunk(userCode, hash, chunkIndex, data); err != nil {
response.InternalError(c, err.Error())
return
}
response.SuccessMsg(c, "分片上传成功")
}
// CompleteUpload handles upload completion requests for resumable uploads.
// CompleteUpload 处理断点续传的上传完成请求。
//
// Request:
// - Method: POST
// - Path: /api/v1/files/upload/complete
// - Body: {"hash": "string", "filename": "string", "chunk_count": int, "file_size": int}
func (h *FileHandler) CompleteUpload(c *gin.Context) {
userCode := c.GetString("user_code")
var req struct {
Hash string `json:"hash"`
Filename string `json:"filename"`
ChunkCount int `json:"chunk_count"`
FileSize int64 `json:"file_size"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if req.Hash == "" || req.Filename == "" || req.ChunkCount <= 0 {
response.BadRequest(c, "缺少必要参数")
return
}
if err := h.service.MergeChunks(userCode, req.Hash, req.Filename, req.ChunkCount, req.FileSize); err != nil {
response.InternalError(c, err.Error())
return
}
if err := h.authService.AddStorageUsed(userCode, req.FileSize); err != nil {
response.InternalError(c, "更新存储空间失败")
return
}
response.SuccessMsg(c, "上传完成")
}
@@ -0,0 +1,47 @@
package routes
import (
"cloudnest/internal/interface/http/handler"
"cloudnest/internal/middleware"
"github.com/gin-gonic/gin"
)
// RegisterAuthRoutes registers authentication-related routes.
// RegisterAuthRoutes 注册认证相关的路由。
//
// Parameters:
// - r: The router group to register routes on.
// - h: The authentication handler.
// - emailHandler: The email verification handler.
//
// Routes:
// - POST /register: Register a new user.
// - POST /login: Authenticate and get JWT token.
// - POST /email-code: Send email verification code.
// - GET /profile: Get user profile (requires JWT).
// - PUT /profile: Update user profile (requires JWT).
// - PUT /password: Change password (requires JWT).
//
// 参数:
// - r: 要注册路由的路由器组。
// - h: 认证处理器。
// - emailHandler: 邮箱验证码处理器。
//
// 路由:
// - POST /register: 注册新用户。
// - POST /login: 认证并获取 JWT 令牌。
// - POST /email-code: 发送邮箱验证码。
// - GET /profile: 获取用户个人信息(需要 JWT)。
// - PUT /profile: 更新用户个人信息(需要 JWT)。
// - PUT /password: 修改密码(需要 JWT)。
func RegisterAuthRoutes(r *gin.RouterGroup, h *handler.AuthHandler, emailHandler *handler.EmailHandler, jwtSecret string) {
r.POST("/register", h.Register)
r.POST("/login", h.Login)
r.POST("/email-code", emailHandler.SendEmailCode)
protected := r.Group("")
protected.Use(middleware.JWT(jwtSecret))
protected.GET("/profile", h.Profile)
protected.PUT("/profile", h.UpdateProfile)
protected.PUT("/password", h.ChangePassword)
}
@@ -0,0 +1,17 @@
package routes
import (
"cloudnest/internal/interface/http/handler"
"github.com/gin-gonic/gin"
)
// RegisterCaptchaRoutes registers captcha-related routes.
// RegisterCaptchaRoutes 注册验证码相关的路由。
//
// Routes:
// - GET /captcha: Generate a new captcha (returns captcha_id and image_url).
// - GET /captcha/:id/image: Get captcha image by ID.
func RegisterCaptchaRoutes(r *gin.RouterGroup, h *handler.CaptchaHandler) {
r.GET("/captcha", h.GenerateCaptcha)
r.GET("/captcha/:id/image", h.GetCaptchaImage)
}
@@ -0,0 +1,53 @@
package routes
import (
"cloudnest/internal/interface/http/handler"
"github.com/gin-gonic/gin"
)
// RegisterFileRoutes registers file management routes.
// RegisterFileRoutes 注册文件管理路由。
//
// Parameters:
// - r: The router group to register routes on.
// - h: The file handler.
//
// Routes:
// - POST /upload: Upload a file.
// - GET /: List all files for the authenticated user.
// - GET /download/:name: Get a pre-signed download URL.
// - DELETE /:name: Delete a file.
//
// Note:
// - These routes require JWT authentication.
//
// 参数:
// - r: 要注册路由的路由器组。
// - h: 文件处理器。
//
// 路由:
// - POST /upload: 上传文件。
// - GET /: 获取认证用户的所有文件列表。
// - GET /download/:name: 获取预签名下载 URL。
// - DELETE /:name: 删除文件。
//
// 注意:
// - 这些路由需要 JWT 认证。
func RegisterFileRoutes(r *gin.RouterGroup, h *handler.FileHandler) {
r.POST("/upload", h.Upload)
r.GET("/upload/check", h.CheckUpload)
r.POST("/upload/chunk", h.UploadChunk)
r.POST("/upload/complete", h.CompleteUpload)
r.GET("/", h.List)
r.GET("/download/:name", h.Download)
r.GET("/content/:name", h.Content)
r.DELETE("/:name", h.Delete)
r.POST("/soft-delete/:name", h.SoftDelete)
r.POST("/favorite/:name", h.Favorite)
r.DELETE("/favorite/:name", h.Unfavorite)
r.GET("/favorites", h.ListFavorites)
r.GET("/recycle-bin", h.RecycleBin)
r.POST("/restore/:name", h.Restore)
r.DELETE("/recycle-bin/:name", h.DeleteFromRecycleBin)
r.GET("/preview/:name", h.Preview)
}
+102
View File
@@ -0,0 +1,102 @@
package routes
import (
"cloudnest/internal/interface/http/handler"
"cloudnest/internal/middleware"
"github.com/gin-gonic/gin"
)
// ServiceType defines the type of service that is running.
// ServiceType 定义运行的服务类型。
type ServiceType string
const (
// ServiceTypeAuth is the authentication service.
// ServiceTypeAuth 是认证服务。
ServiceTypeAuth ServiceType = "auth"
// ServiceTypeFile is the file management service.
// ServiceTypeFile 是文件管理服务。
ServiceTypeFile ServiceType = "file"
// ServiceTypeAll is for testing purposes, registers all routes.
// ServiceTypeAll 用于测试目的,注册所有路由。
ServiceTypeAll ServiceType = "all"
)
// SetupRoutes registers API routes based on the service type.
// SetupRoutes 根据服务类型注册 API 路由。
//
// Parameters:
// - engine: The Gin engine instance.
// - serviceType: The type of service (auth, file, or all).
// - authHandler: The authentication handler (required for auth service).
// - fileHandler: The file handler (required for file service).
// - jwtSecret: The secret key for JWT token validation.
//
// Note:
// - Authentication routes are public and don't require JWT.
// - File routes require JWT authentication via middleware.JWT.
// - Each service should only register its own routes to avoid conflicts.
//
// 参数:
// - engine: Gin 引擎实例。
// - serviceType: 服务类型 (auth, file, 或 all)。
// - authHandler: 认证处理器(认证服务必需)。
// - fileHandler: 文件处理器(文件服务必需)。
// - jwtSecret: JWT 令牌验证的密钥。
//
// 注意:
// - 认证路由是公开的,不需要 JWT。
// - 文件路由需要通过 middleware.JWT 进行 JWT 认证。
// - 每个服务应该只注册自己的路由以避免冲突。
func SetupRoutes(engine *gin.Engine, serviceType ServiceType, authHandler *handler.AuthHandler, fileHandler *handler.FileHandler, captchaHandler *handler.CaptchaHandler, emailHandler *handler.EmailHandler, jwtSecret string) {
api := engine.Group("/api/v1")
RegisterCaptchaRoutes(api, captchaHandler)
switch serviceType {
case ServiceTypeAuth:
auth := api.Group("/auth")
RegisterAuthRoutes(auth, authHandler, emailHandler, jwtSecret)
case ServiceTypeFile:
files := api.Group("/files")
files.Use(middleware.JWT(jwtSecret))
RegisterFileRoutes(files, fileHandler)
case ServiceTypeAll:
auth := api.Group("/auth")
RegisterAuthRoutes(auth, authHandler, emailHandler, jwtSecret)
files := api.Group("/files")
files.Use(middleware.JWT(jwtSecret))
RegisterFileRoutes(files, fileHandler)
}
}
// SetupAuthRoutes registers only authentication routes.
// SetupAuthRoutes 只注册认证路由。
//
// This function is used by the authentication service to register
// only its own routes, avoiding conflicts with the file service.
//
// 此函数用于认证服务注册自己的路由,避免与文件服务冲突。
func SetupAuthRoutes(engine *gin.Engine, authHandler *handler.AuthHandler, captchaHandler *handler.CaptchaHandler, emailHandler *handler.EmailHandler, jwtSecret string) {
api := engine.Group("/api/v1")
RegisterCaptchaRoutes(api, captchaHandler)
auth := api.Group("/auth")
RegisterAuthRoutes(auth, authHandler, emailHandler, jwtSecret)
}
// SetupFileRoutes registers only file management routes.
// SetupFileRoutes 只注册文件管理路由。
//
// This function is used by the file service to register
// only its own routes, avoiding conflicts with the auth service.
//
// 此函数用于文件服务注册自己的路由,避免与认证服务冲突。
func SetupFileRoutes(engine *gin.Engine, fileHandler *handler.FileHandler, captchaHandler *handler.CaptchaHandler, jwtSecret string) {
api := engine.Group("/api/v1")
RegisterCaptchaRoutes(api, captchaHandler)
files := api.Group("/files")
files.Use(middleware.JWT(jwtSecret))
RegisterFileRoutes(files, fileHandler)
}
+126
View File
@@ -0,0 +1,126 @@
package middleware
import (
"net/http"
"strings"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
// allowedOrigins defines the list of allowed origins for development.
// In production, this should be loaded from configuration.
// allowedOrigins 定义开发环境允许的源列表。生产环境应从配置加载。
var allowedOrigins = []string{
"http://localhost:8081",
"https://localhost:8081",
"http://127.0.0.1:8081",
"https://127.0.0.1:8081",
}
// isAllowedOrigin checks if the given origin is in the allowed list.
// isAllowedOrigin 检查给定的源是否在允许列表中。
func isAllowedOrigin(origin string) bool {
for _, o := range allowedOrigins {
if strings.EqualFold(o, origin) {
return true
}
}
return false
}
// CORS creates a middleware that handles Cross-Origin Resource Sharing.
// CORS 创建一个处理跨域资源共享(CORS)的中间件。
//
// This middleware sets the necessary headers to allow cross-origin requests,
// including the allowed origins, methods, and headers.
//
// Important:
// - For requests with Authorization header, the browser requires the
// Access-Control-Allow-Origin header to be a specific origin, not "*".
// - This middleware dynamically sets the origin to match the request's
// Origin header, which allows cross-origin requests with credentials.
// - In production, you should restrict allowed origins to specific domains.
//
// 此中间件设置必要的头部以允许跨域请求,包括允许的来源、方法和头部。
//
// 重要:
// - 对于带有 Authorization 头部的请求,浏览器要求 Access-Control-Allow-Origin
// 必须是具体的源,不能是 "*"。
// - 此中间件动态设置源以匹配请求的 Origin 头部,允许带凭据的跨域请求。
// - 在生产环境中,应将允许的源限制为特定域名。
//
// Returns:
// - gin.HandlerFunc: The CORS middleware handler.
//
// 返回值:
// - gin.HandlerFunc: CORS 中间件处理器。
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
// Get the Origin header from the request
// 从请求中获取 Origin 头部
origin := c.Request.Header.Get("Origin")
logger.Info("CORS middleware invoked",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"origin", origin,
)
// Determine the allowed origin to return.
// If the origin is in our allowed list, echo it back.
// Otherwise, for development, also echo it back to be permissive.
// In production, you should reject unallowed origins.
//
// 确定要返回的允许源。如果在允许列表中,则回显。
// 开发环境下也回显其他源以方便调试。生产环境应拒绝未允许的源。
allowedOrigin := ""
if origin != "" {
if isAllowedOrigin(origin) {
allowedOrigin = origin
} else {
// Development fallback: allow any origin for easier local testing
// 开发环境回退:允许任何源以方便本地测试
allowedOrigin = origin
logger.Warn("CORS request from unknown origin",
"origin", origin,
"path", c.Request.URL.Path,
)
}
}
// Set Access-Control-Allow-Origin to the request's origin
// This allows cross-origin requests with Authorization header
// 将 Access-Control-Allow-Origin 设置为请求的源
// 这允许带 Authorization 头部的跨域请求
if allowedOrigin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
}
// Allow credentials (cookies, Authorization headers)
// 允许凭据(cookies、Authorization 头部)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
// Allow common HTTP methods
// 允许常用 HTTP 方法
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
// Allow common headers including Authorization
// 允许常用头部,包括 Authorization
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin, X-Request-ID")
// Expose headers to the client
// 暴露头部给客户端
c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, Content-Length, X-Request-ID")
// Handle OPTIONS preflight requests
// 处理 OPTIONS 预检请求
if c.Request.Method == "OPTIONS" {
logger.Info("CORS handling OPTIONS preflight", "origin", origin, "path", c.Request.URL.Path)
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
}
}
+57
View File
@@ -0,0 +1,57 @@
package middleware
import (
"cloudnest/internal/pkg/errors"
"cloudnest/internal/pkg/response"
"cloudnest/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
// ErrorHandler creates a middleware that handles errors and panics.
// ErrorHandler 创建一个处理错误和 panic 的中间件。
//
// This middleware:
// 1. Recovers from panics and logs the error.
// 2. Handles errors stored in the Gin context after processing.
// 3. Formats consistent error responses.
//
// Returns:
// - gin.HandlerFunc: The error handling middleware handler.
//
// Note:
// - Should be added as the first middleware to catch all panics.
// - Uses the custom response package for consistent error formatting.
//
// 此中间件:
// 1. 从 panic 中恢复并记录错误。
// 2. 处理处理后存储在 Gin 上下文中的错误。
// 3. 格式化一致的错误响应。
//
// 返回值:
// - gin.HandlerFunc: 错误处理中间件处理器。
//
// 注意:
// - 应该作为第一个中间件添加以捕获所有 panic。
// - 使用自定义的 response 包进行一致的错误格式化。
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
logger.Error("panic recovered", "error", err)
response.InternalError(c, "服务器内部错误")
}
}()
c.Next()
if len(c.Errors) > 0 {
err := c.Errors.Last().Err
if appErr, ok := errors.AsAppError(err); ok {
response.Error(c, appErr.Code, appErr.Message)
} else {
logger.Error("request error", "error", err)
response.InternalError(c, err.Error())
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
package middleware
import (
"strings"
"cloudnest/internal/pkg/jwt"
"cloudnest/internal/pkg/response"
"github.com/gin-gonic/gin"
)
func JWT(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "OPTIONS" {
c.Next()
return
}
auth := c.GetHeader("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
response.Unauthorized(c, "未提供认证令牌")
c.Abort()
return
}
tokenStr := strings.TrimPrefix(auth, "Bearer ")
claims, err := jwt.ParseToken(tokenStr, jwtSecret)
if err != nil {
response.Unauthorized(c, "令牌无效")
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("user_code", claims.UserCode)
c.Next()
}
}
+53
View File
@@ -0,0 +1,53 @@
package crypto
import "golang.org/x/crypto/bcrypt"
// HashPassword hashes a plain text password using bcrypt.
// HashPassword 使用 bcrypt 对明文密码进行加密。
//
// Parameters:
// - password: The plain text password to hash.
//
// Returns:
// - string: The hashed password.
// - error: An error if hashing fails.
//
// Note:
// - Uses bcrypt.DefaultCost (10) for hashing.
// - The resulting hash is approximately 60 characters long.
//
// 参数:
// - password: 要加密的明文密码。
//
// 返回值:
// - string: 加密后的密码。
// - error: 如果加密失败则返回错误。
//
// 注意:
// - 使用 bcrypt.DefaultCost (10) 进行加密。
// - 生成的哈希值大约为 60 个字符。
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPasswordHash compares a plain text password with a hashed password.
// CheckPasswordHash 比较明文密码和加密密码。
//
// Parameters:
// - password: The plain text password to check.
// - hash: The hashed password to compare against.
//
// Returns:
// - bool: true if the password matches the hash, false otherwise.
//
// 参数:
// - password: 要检查的明文密码。
// - hash: 要比较的加密密码。
//
// 返回值:
// - bool: 如果密码匹配哈希值则返回 true,否则返回 false。
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
+258
View File
@@ -0,0 +1,258 @@
package errors
import "net/http"
// AppError represents an application-specific error.
// AppError 表示应用程序特定的错误。
//
// This struct extends the standard error interface with additional fields
// for HTTP status codes and custom error codes.
//
// 此结构体扩展了标准的 error 接口,添加了 HTTP 状态码和自定义错误码字段。
type AppError struct {
StatusCode int
Code int
Message string
Err error
}
// Error implements the error interface.
// Error 实现了 error 接口。
//
// Returns:
// - string: The error message, including the wrapped error if present.
//
// 返回值:
// - string: 错误消息,如果存在包装的错误则包含该错误。
func (e *AppError) Error() string {
if e.Err != nil {
return e.Message + ": " + e.Err.Error()
}
return e.Message
}
// New creates a new AppError with the specified code and message.
// New 创建一个具有指定代码和消息的新 AppError。
//
// Parameters:
// - code: The custom error code.
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the new AppError instance.
//
// 参数:
// - code: 自定义错误代码。
// - message: 错误消息。
//
// 返回值:
// - *AppError: 新创建的 AppError 实例指针。
func New(code int, message string) *AppError {
return &AppError{
StatusCode: http.StatusOK,
Code: code,
Message: message,
}
}
// NewWithErr creates a new AppError with the specified code, message, and wrapped error.
// NewWithErr 创建一个具有指定代码、消息和包装错误的新 AppError。
//
// Parameters:
// - code: The custom error code.
// - message: The error message.
// - err: The wrapped error.
//
// Returns:
// - *AppError: A pointer to the new AppError instance.
//
// 参数:
// - code: 自定义错误代码。
// - message: 错误消息。
// - err: 包装的错误。
//
// 返回值:
// - *AppError: 新创建的 AppError 实例指针。
func NewWithErr(code int, message string, err error) *AppError {
return &AppError{
StatusCode: http.StatusOK,
Code: code,
Message: message,
Err: err,
}
}
// BadRequest creates an AppError for 400 Bad Request.
// BadRequest 创建一个 400 Bad Request 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the BadRequest AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: BadRequest AppError 的指针。
func BadRequest(message string) *AppError {
return &AppError{
StatusCode: http.StatusBadRequest,
Code: 400,
Message: message,
}
}
// Unauthorized creates an AppError for 401 Unauthorized.
// Unauthorized 创建一个 401 Unauthorized 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the Unauthorized AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: Unauthorized AppError 的指针。
func Unauthorized(message string) *AppError {
return &AppError{
StatusCode: http.StatusUnauthorized,
Code: 401,
Message: message,
}
}
// Forbidden creates an AppError for 403 Forbidden.
// Forbidden 创建一个 403 Forbidden 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the Forbidden AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: Forbidden AppError 的指针。
func Forbidden(message string) *AppError {
return &AppError{
StatusCode: http.StatusForbidden,
Code: 403,
Message: message,
}
}
// Conflict creates an AppError for 409 Conflict.
// Conflict 创建一个 409 Conflict 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the Conflict AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: Conflict AppError 的指针。
func Conflict(message string) *AppError {
return &AppError{
StatusCode: http.StatusConflict,
Code: 409,
Message: message,
}
}
// NotFound creates an AppError for 404 Not Found.
// NotFound 创建一个 404 Not Found 的 AppError。
//
// Parameters:
// - message: The error message.
//
// Returns:
// - *AppError: A pointer to the NotFound AppError.
//
// 参数:
// - message: 错误消息。
//
// 返回值:
// - *AppError: NotFound AppError 的指针。
func NotFound(message string) *AppError {
return &AppError{
StatusCode: http.StatusNotFound,
Code: 404,
Message: message,
}
}
// InternalError creates an AppError for 500 Internal Server Error.
// InternalError 创建一个 500 Internal Server Error 的 AppError。
//
// Parameters:
// - message: The error message.
// - err: The wrapped error (typically the underlying cause).
//
// Returns:
// - *AppError: A pointer to the InternalError AppError.
//
// 参数:
// - message: 错误消息。
// - err: 包装的错误(通常是根本原因)。
//
// 返回值:
// - *AppError: InternalError AppError 的指针。
func InternalError(message string, err error) *AppError {
return &AppError{
StatusCode: http.StatusInternalServerError,
Code: 500,
Message: message,
Err: err,
}
}
// IsAppError checks if an error is an AppError.
// IsAppError 检查错误是否是 AppError。
//
// Parameters:
// - err: The error to check.
//
// Returns:
// - bool: true if the error is an AppError, false otherwise.
//
// 参数:
// - err: 要检查的错误。
//
// 返回值:
// - bool: 如果错误是 AppError 则返回 true,否则返回 false。
func IsAppError(err error) bool {
_, ok := err.(*AppError)
return ok
}
// AsAppError attempts to convert an error to an AppError.
// AsAppError 尝试将错误转换为 AppError。
//
// Parameters:
// - err: The error to convert.
//
// Returns:
// - *AppError: The converted AppError if successful.
// - bool: true if the conversion was successful, false otherwise.
//
// 参数:
// - err: 要转换的错误。
//
// 返回值:
// - *AppError: 如果转换成功则返回转换后的 AppError。
// - bool: 如果转换成功则返回 true,否则返回 false。
func AsAppError(err error) (*AppError, bool) {
e, ok := err.(*AppError)
return e, ok
}
+41
View File
@@ -0,0 +1,41 @@
package jwt
import (
"time"
"cloudnest/internal/pkg/errors"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
UserCode string `json:"user_code"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username, userCode string, secret string, expiresIn time.Duration) (string, error) {
claims := Claims{
UserID: userID,
Username: username,
UserCode: userCode,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ParseToken(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, errors.Unauthorized("invalid token")
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.Unauthorized("invalid token")
}
+131
View File
@@ -0,0 +1,131 @@
package logger
import (
"encoding/json"
"fmt"
"io"
"os"
"sync"
"sync/atomic"
"time"
)
// Level represents log level.
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarn
LevelError
)
func (l Level) String() string {
switch l {
case LevelDebug:
return "DEBUG"
case LevelInfo:
return "INFO"
case LevelWarn:
return "WARN"
case LevelError:
return "ERROR"
default:
return "INFO"
}
}
// Logger is a simple JSON logger compatible with Go 1.20.
type Logger struct {
level Level
writer io.Writer
mu sync.Mutex
}
// Default is the global default logger.
var Default atomic.Value
func init() {
Default.Store(&Logger{level: LevelInfo, writer: os.Stderr})
}
func getDefault() *Logger {
if l, ok := Default.Load().(*Logger); ok && l != nil {
return l
}
return &Logger{level: LevelInfo, writer: os.Stderr}
}
// Init initializes the logger with the specified log level.
func Init(level string) {
InitWithWriter(level, os.Stdout)
}
// InitWithWriter initializes the logger with the specified log level and writer.
func InitWithWriter(level string, writer io.Writer) {
var lvl Level
switch level {
case "debug":
lvl = LevelDebug
case "info":
lvl = LevelInfo
case "warn":
lvl = LevelWarn
case "error":
lvl = LevelError
default:
lvl = LevelInfo
}
if writer == nil {
writer = os.Stdout
}
Default.Store(&Logger{level: lvl, writer: writer})
}
func (l *Logger) log(level Level, msg string, args ...any) {
if level < l.level {
return
}
record := map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"level": level.String(),
"msg": msg,
}
for i := 0; i < len(args)-1; i += 2 {
key, ok := args[i].(string)
if !ok {
continue
}
record[key] = args[i+1]
}
l.mu.Lock()
defer l.mu.Unlock()
data, _ := json.Marshal(record)
fmt.Fprintln(l.writer, string(data))
}
// Debug logs a message at Debug level.
func Debug(msg string, args ...any) {
getDefault().log(LevelDebug, msg, args...)
}
// Info logs a message at Info level.
func Info(msg string, args ...any) {
getDefault().log(LevelInfo, msg, args...)
}
// Warn logs a message at Warn level.
func Warn(msg string, args ...any) {
getDefault().log(LevelWarn, msg, args...)
}
// Error logs a message at Error level.
func Error(msg string, args ...any) {
getDefault().log(LevelError, msg, args...)
}
// Fatal logs a message at Error level and exits.
func Fatal(msg string, args ...any) {
getDefault().log(LevelError, msg, args...)
os.Exit(1)
}
+182
View File
@@ -0,0 +1,182 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response represents the standard API response structure.
// Response 表示标准的 API 响应结构。
//
// This struct provides a consistent format for all API responses.
//
// 此结构体为所有 API 响应提供了一致的格式。
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Success sends a successful response with data.
// Success 发送包含数据的成功响应。
//
// Parameters:
// - c: The Gin context.
// - data: The data to include in the response.
//
// Note:
// - Sets HTTP status code to 200 OK.
// - Sets code to 0 (success).
//
// 参数:
// - c: Gin 上下文。
// - data: 要包含在响应中的数据。
//
// 注意:
// - 设置 HTTP 状态码为 200 OK。
// - 设置 code 为 0(成功)。
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: 0,
Message: "success",
Data: data,
})
}
// SuccessMsg sends a successful response with only a message.
// SuccessMsg 发送仅包含消息的成功响应。
//
// Parameters:
// - c: The Gin context.
// - message: The success message.
//
// Note:
// - Sets HTTP status code to 200 OK.
// - Sets code to 0 (success).
//
// 参数:
// - c: Gin 上下文。
// - message: 成功消息。
//
// 注意:
// - 设置 HTTP 状态码为 200 OK。
// - 设置 code 为 0(成功)。
func SuccessMsg(c *gin.Context, message string) {
c.JSON(http.StatusOK, Response{
Code: 0,
Message: message,
})
}
// Error sends an error response with a custom code and message.
// Error 发送包含自定义代码和消息的错误响应。
//
// Parameters:
// - c: The Gin context.
// - code: The custom error code.
// - message: The error message.
//
// Note:
// - Sets HTTP status code to 200 OK.
// - The custom code indicates the specific error type.
//
// 参数:
// - c: Gin 上下文。
// - code: 自定义错误代码。
// - message: 错误消息。
//
// 注意:
// - 设置 HTTP 状态码为 200 OK。
// - 自定义代码表示特定的错误类型。
func Error(c *gin.Context, code int, message string) {
c.JSON(http.StatusOK, Response{
Code: code,
Message: message,
})
}
// BadRequest sends a 400 Bad Request response.
// BadRequest 发送 400 Bad Request 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func BadRequest(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, Response{
Code: 400,
Message: message,
})
}
// Unauthorized sends a 401 Unauthorized response.
// Unauthorized 发送 401 Unauthorized 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, Response{
Code: 401,
Message: message,
})
}
// Forbidden sends a 403 Forbidden response.
// Forbidden 发送 403 Forbidden 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func Forbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, Response{
Code: 403,
Message: message,
})
}
// NotFound sends a 404 Not Found response.
// NotFound 发送 404 Not Found 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func NotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, Response{
Code: 404,
Message: message,
})
}
// InternalError sends a 500 Internal Server Error response.
// InternalError 发送 500 Internal Server Error 响应。
//
// Parameters:
// - c: The Gin context.
// - message: The error message.
//
// 参数:
// - c: Gin 上下文。
// - message: 错误消息。
func InternalError(c *gin.Context, message string) {
c.JSON(http.StatusInternalServerError, Response{
Code: 500,
Message: message,
})
}
+25
View File
@@ -0,0 +1,25 @@
package validator
import (
"cloudnest/internal/pkg/logger"
"github.com/go-playground/validator/v10"
)
// Validator is the global validator instance.
// Validator 是全局验证器实例。
var Validator *validator.Validate
// Init initializes the validator.
// Init 初始化验证器。
//
// Note:
// - Should be called once during application startup.
// - Creates a new validator instance with default settings.
//
// 注意:
// - 应在应用程序启动时调用一次。
// - 使用默认设置创建新的验证器实例。
func Init() {
Validator = validator.New()
logger.Info("validator initialized")
}