refactor: 重构后端架构,采用 Go 风格四层设计模式
## 主要变更 ### 🏗️ 架构重构 - 采用简洁的四层架构:API → Service → Repository → Model - 遵循 Go 语言最佳实践和命名规范 - 实现依赖注入和接口导向设计 - 统一错误处理和响应格式 ### 📁 目录结构优化 - 删除重复模块 (application/, domain/, infrastructure/ 等) - 规范化命名 (使用 Go 风格的 snake_case) - 清理无关文件 (package.json, node_modules/ 等) - 新增规范化的测试目录结构 ### 📚 文档系统 - 为每个模块创建详细的 CLAUDE.md 指导文件 - 包含开发规范、最佳实践和使用示例 - 支持模块化开发,缩短上下文长度 ### 🔧 开发规范 - 统一接口命名规范 (UserServicer, PhotoRepositoryr) - 标准化错误处理机制 - 完善的测试策略 (单元测试、集成测试、性能测试) - 规范化的配置管理 ### 🗂️ 新增文件 - cmd/server/ - 服务启动入口和配置 - internal/model/ - 数据模型层 (entity, dto, request) - pkg/ - 共享工具包 (logger, response, validator) - tests/ - 完整测试结构 - docs/ - API 文档和架构设计 - .gitignore - Git 忽略文件配置 ### 🗑️ 清理内容 - 删除 Node.js 相关文件 (package.json, node_modules/) - 移除重复的架构目录 - 清理临时文件和构建产物 - 删除重复的文档文件 ## 影响 - 提高代码可维护性和可扩展性 - 统一开发规范,提升团队协作效率 - 优化项目结构,符合 Go 语言生态标准 - 完善文档体系,降低上手难度
This commit is contained in:
549
backend/internal/service/CLAUDE.md
Normal file
549
backend/internal/service/CLAUDE.md
Normal file
@ -0,0 +1,549 @@
|
||||
# Service Layer - CLAUDE.md
|
||||
|
||||
本文件为 Claude Code 在业务逻辑层中工作时提供指导。
|
||||
|
||||
## 🎯 模块概览
|
||||
|
||||
Service 层是业务逻辑的核心,负责处理业务规则、数据转换和服务编排。
|
||||
|
||||
### 主要职责
|
||||
- 📋 业务逻辑处理和规则验证
|
||||
- 🔄 数据转换和格式化
|
||||
- 🧩 服务编排和组合
|
||||
- 🚀 事务管理和数据一致性
|
||||
- 🔐 业务权限控制
|
||||
- 📊 性能优化和缓存策略
|
||||
|
||||
## 📁 模块结构
|
||||
|
||||
```
|
||||
internal/service/
|
||||
├── CLAUDE.md # 📋 当前文件 - 业务逻辑开发指导
|
||||
├── interfaces.go # 🔗 服务接口定义
|
||||
├── auth/ # 🔐 认证服务
|
||||
│ ├── auth_service.go # 认证业务逻辑
|
||||
│ ├── jwt_service.go # JWT 令牌服务
|
||||
│ └── auth_service_test.go # 认证服务测试
|
||||
├── user/ # 👤 用户服务
|
||||
│ ├── user_service.go # 用户业务逻辑
|
||||
│ ├── user_validator.go # 用户数据验证
|
||||
│ └── user_service_test.go # 用户服务测试
|
||||
├── photo/ # 📸 照片服务
|
||||
│ ├── photo_service.go # 照片业务逻辑
|
||||
│ ├── photo_processor.go # 照片处理器
|
||||
│ └── photo_service_test.go # 照片服务测试
|
||||
├── category/ # 📂 分类服务
|
||||
│ ├── category_service.go # 分类业务逻辑
|
||||
│ └── category_service_test.go # 分类服务测试
|
||||
└── storage/ # 📁 存储服务
|
||||
├── storage_service.go # 存储业务逻辑
|
||||
├── local_storage.go # 本地存储实现
|
||||
└── s3_storage.go # S3 存储实现
|
||||
```
|
||||
|
||||
## 🔧 Go 风格服务设计
|
||||
|
||||
### 接口定义规范
|
||||
```go
|
||||
// interfaces.go - 服务接口定义
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime/multipart"
|
||||
|
||||
"photography-backend/internal/model/dto"
|
||||
"photography-backend/internal/model/entity"
|
||||
)
|
||||
|
||||
// UserServicer 用户服务接口
|
||||
type UserServicer interface {
|
||||
// 用户管理
|
||||
CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*entity.User, error)
|
||||
GetUser(ctx context.Context, id uint) (*entity.User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*entity.User, error)
|
||||
UpdateUser(ctx context.Context, id uint, req *dto.UpdateUserRequest) (*entity.User, error)
|
||||
DeleteUser(ctx context.Context, id uint) error
|
||||
ListUsers(ctx context.Context, opts *dto.ListUsersOptions) ([]*entity.User, int64, error)
|
||||
|
||||
// 用户验证
|
||||
ValidateUser(ctx context.Context, email, password string) (*entity.User, error)
|
||||
ChangePassword(ctx context.Context, id uint, req *dto.ChangePasswordRequest) error
|
||||
}
|
||||
|
||||
// PhotoServicer 照片服务接口
|
||||
type PhotoServicer interface {
|
||||
// 照片管理
|
||||
CreatePhoto(ctx context.Context, req *dto.CreatePhotoRequest) (*entity.Photo, error)
|
||||
GetPhoto(ctx context.Context, id uint) (*entity.Photo, error)
|
||||
UpdatePhoto(ctx context.Context, id uint, req *dto.UpdatePhotoRequest) (*entity.Photo, error)
|
||||
DeletePhoto(ctx context.Context, id uint) error
|
||||
ListPhotos(ctx context.Context, opts *dto.ListPhotosOptions) ([]*entity.Photo, int64, error)
|
||||
|
||||
// 照片处理
|
||||
UploadPhoto(ctx context.Context, file *multipart.FileHeader, userID uint) (*entity.Photo, error)
|
||||
ProcessPhoto(ctx context.Context, photoID uint, opts *dto.ProcessPhotoOptions) error
|
||||
|
||||
// 照片查询
|
||||
GetPhotosByUser(ctx context.Context, userID uint, opts *dto.ListPhotosOptions) ([]*entity.Photo, int64, error)
|
||||
GetPhotosByCategory(ctx context.Context, categoryID uint, opts *dto.ListPhotosOptions) ([]*entity.Photo, int64, error)
|
||||
SearchPhotos(ctx context.Context, query string, opts *dto.SearchPhotosOptions) ([]*entity.Photo, int64, error)
|
||||
}
|
||||
|
||||
// AuthServicer 认证服务接口
|
||||
type AuthServicer interface {
|
||||
// 认证
|
||||
Login(ctx context.Context, req *dto.LoginRequest) (*dto.LoginResponse, error)
|
||||
Register(ctx context.Context, req *dto.RegisterRequest) (*dto.RegisterResponse, error)
|
||||
RefreshToken(ctx context.Context, refreshToken string) (*dto.TokenResponse, error)
|
||||
Logout(ctx context.Context, token string) error
|
||||
|
||||
// 令牌管理
|
||||
ValidateToken(ctx context.Context, token string) (*dto.TokenClaims, error)
|
||||
RevokeToken(ctx context.Context, token string) error
|
||||
}
|
||||
```
|
||||
|
||||
### 服务实现规范
|
||||
```go
|
||||
// user/user_service.go - 用户服务实现
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"photography-backend/internal/model/dto"
|
||||
"photography-backend/internal/model/entity"
|
||||
"photography-backend/internal/repository"
|
||||
"photography-backend/pkg/logger"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
userRepo repository.UserRepositoryr
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
func NewUserService(userRepo repository.UserRepositoryr, logger logger.Logger) *UserService {
|
||||
return &UserService{
|
||||
userRepo: userRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserService) CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*entity.User, error) {
|
||||
// 1. 验证输入
|
||||
if err := s.validateCreateUserRequest(req); err != nil {
|
||||
s.logger.Error("invalid create user request", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 检查用户是否存在
|
||||
existingUser, err := s.userRepo.GetByEmail(ctx, req.Email)
|
||||
if err != nil && !errors.Is(err, repository.ErrNotFound) {
|
||||
s.logger.Error("failed to check existing user", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if existingUser != nil {
|
||||
return nil, errors.New("user already exists")
|
||||
}
|
||||
|
||||
// 3. 加密密码
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to hash password", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 创建用户实体
|
||||
user := &entity.User{
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
Password: string(hashedPassword),
|
||||
Role: entity.UserRoleUser,
|
||||
Status: entity.UserStatusActive,
|
||||
}
|
||||
|
||||
// 5. 保存用户
|
||||
createdUser, err := s.userRepo.Create(ctx, user)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create user", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("user created successfully",
|
||||
zap.String("user_id", fmt.Sprintf("%d", createdUser.ID)),
|
||||
zap.String("email", createdUser.Email))
|
||||
|
||||
return createdUser, nil
|
||||
}
|
||||
|
||||
func (s *UserService) GetUser(ctx context.Context, id uint) (*entity.User, error) {
|
||||
user, err := s.userRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
s.logger.Error("failed to get user", zap.Error(err), zap.Uint("user_id", id))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) ValidateUser(ctx context.Context, email, password string) (*entity.User, error) {
|
||||
// 1. 获取用户
|
||||
user, err := s.userRepo.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
s.logger.Error("failed to get user by email", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 验证密码
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// 3. 检查用户状态
|
||||
if user.Status != entity.UserStatusActive {
|
||||
return nil, errors.New("user account is not active")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) validateCreateUserRequest(req *dto.CreateUserRequest) error {
|
||||
if req.Email == "" {
|
||||
return errors.New("email is required")
|
||||
}
|
||||
if req.Username == "" {
|
||||
return errors.New("username is required")
|
||||
}
|
||||
if req.Password == "" {
|
||||
return errors.New("password is required")
|
||||
}
|
||||
if len(req.Password) < 6 {
|
||||
return errors.New("password must be at least 6 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 照片服务实现
|
||||
```go
|
||||
// photo/photo_service.go - 照片服务实现
|
||||
package photo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"photography-backend/internal/model/dto"
|
||||
"photography-backend/internal/model/entity"
|
||||
"photography-backend/internal/repository"
|
||||
"photography-backend/internal/service"
|
||||
"photography-backend/pkg/logger"
|
||||
)
|
||||
|
||||
type PhotoService struct {
|
||||
photoRepo repository.PhotoRepositoryr
|
||||
userRepo repository.UserRepositoryr
|
||||
storageService service.StorageServicer
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
func NewPhotoService(
|
||||
photoRepo repository.PhotoRepositoryr,
|
||||
userRepo repository.UserRepositoryr,
|
||||
storageService service.StorageServicer,
|
||||
logger logger.Logger,
|
||||
) *PhotoService {
|
||||
return &PhotoService{
|
||||
photoRepo: photoRepo,
|
||||
userRepo: userRepo,
|
||||
storageService: storageService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PhotoService) UploadPhoto(ctx context.Context, file *multipart.FileHeader, userID uint) (*entity.Photo, error) {
|
||||
// 1. 验证文件
|
||||
if err := s.validatePhotoFile(file); err != nil {
|
||||
s.logger.Error("invalid photo file", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 验证用户
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user", zap.Error(err), zap.Uint("user_id", userID))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 生成文件名
|
||||
filename := s.generatePhotoFilename(file.Filename, userID)
|
||||
|
||||
// 4. 上传文件
|
||||
uploadedFile, err := s.storageService.UploadFile(ctx, file, filename)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to upload photo", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 创建照片记录
|
||||
photo := &entity.Photo{
|
||||
UserID: userID,
|
||||
Title: strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename)),
|
||||
Description: "",
|
||||
Filename: uploadedFile.Filename,
|
||||
FilePath: uploadedFile.Path,
|
||||
FileSize: uploadedFile.Size,
|
||||
MimeType: uploadedFile.MimeType,
|
||||
Width: uploadedFile.Width,
|
||||
Height: uploadedFile.Height,
|
||||
Status: entity.PhotoStatusActive,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 6. 保存照片记录
|
||||
createdPhoto, err := s.photoRepo.Create(ctx, photo)
|
||||
if err != nil {
|
||||
// 如果数据库保存失败,删除已上传的文件
|
||||
s.storageService.DeleteFile(ctx, uploadedFile.Path)
|
||||
s.logger.Error("failed to create photo record", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("photo uploaded successfully",
|
||||
zap.String("photo_id", fmt.Sprintf("%d", createdPhoto.ID)),
|
||||
zap.String("filename", createdPhoto.Filename),
|
||||
zap.Uint("user_id", userID))
|
||||
|
||||
return createdPhoto, nil
|
||||
}
|
||||
|
||||
func (s *PhotoService) GetPhotosByUser(ctx context.Context, userID uint, opts *dto.ListPhotosOptions) ([]*entity.Photo, int64, error) {
|
||||
// 设置默认选项
|
||||
if opts == nil {
|
||||
opts = &dto.ListPhotosOptions{
|
||||
Page: 1,
|
||||
Limit: 20,
|
||||
Sort: "created_at",
|
||||
Order: "desc",
|
||||
}
|
||||
}
|
||||
|
||||
// 查询照片
|
||||
photos, total, err := s.photoRepo.ListByUserID(ctx, userID, &repository.ListPhotosOptions{
|
||||
Page: opts.Page,
|
||||
Limit: opts.Limit,
|
||||
Sort: opts.Sort,
|
||||
Order: opts.Order,
|
||||
Status: string(entity.PhotoStatusActive),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("failed to list photos by user", zap.Error(err), zap.Uint("user_id", userID))
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return photos, total, nil
|
||||
}
|
||||
|
||||
func (s *PhotoService) validatePhotoFile(file *multipart.FileHeader) error {
|
||||
// 检查文件大小 (10MB 限制)
|
||||
const maxFileSize = 10 * 1024 * 1024
|
||||
if file.Size > maxFileSize {
|
||||
return errors.New("file size exceeds limit")
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
allowedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
isAllowed := false
|
||||
for _, allowedExt := range allowedExts {
|
||||
if ext == allowedExt {
|
||||
isAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isAllowed {
|
||||
return errors.New("file type not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PhotoService) generatePhotoFilename(originalFilename string, userID uint) string {
|
||||
ext := filepath.Ext(originalFilename)
|
||||
timestamp := time.Now().Unix()
|
||||
return fmt.Sprintf("photos/%d_%d%s", userID, timestamp, ext)
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 服务测试
|
||||
|
||||
### 单元测试规范
|
||||
```go
|
||||
// user/user_service_test.go - 用户服务测试
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"photography-backend/internal/model/dto"
|
||||
"photography-backend/internal/model/entity"
|
||||
"photography-backend/internal/repository/mocks"
|
||||
"photography-backend/pkg/logger"
|
||||
)
|
||||
|
||||
func TestUserService_CreateUser(t *testing.T) {
|
||||
// Setup
|
||||
mockUserRepo := new(mocks.UserRepositoryr)
|
||||
mockLogger := logger.NewNoop()
|
||||
userService := NewUserService(mockUserRepo, mockLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
req := &dto.CreateUserRequest{
|
||||
Email: "test@example.com",
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
// Mock expectations
|
||||
mockUserRepo.On("GetByEmail", ctx, req.Email).Return(nil, repository.ErrNotFound)
|
||||
mockUserRepo.On("Create", ctx, mock.AnythingOfType("*entity.User")).
|
||||
Return(&entity.User{
|
||||
ID: 1,
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
Role: entity.UserRoleUser,
|
||||
Status: entity.UserStatusActive,
|
||||
}, nil)
|
||||
|
||||
// Execute
|
||||
user, err := userService.CreateUser(ctx, req)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, user)
|
||||
assert.Equal(t, req.Email, user.Email)
|
||||
assert.Equal(t, req.Username, user.Username)
|
||||
assert.Equal(t, entity.UserRoleUser, user.Role)
|
||||
assert.Equal(t, entity.UserStatusActive, user.Status)
|
||||
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserService_CreateUser_UserAlreadyExists(t *testing.T) {
|
||||
// Setup
|
||||
mockUserRepo := new(mocks.UserRepositoryr)
|
||||
mockLogger := logger.NewNoop()
|
||||
userService := NewUserService(mockUserRepo, mockLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
req := &dto.CreateUserRequest{
|
||||
Email: "test@example.com",
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
existingUser := &entity.User{
|
||||
ID: 1,
|
||||
Email: req.Email,
|
||||
}
|
||||
|
||||
// Mock expectations
|
||||
mockUserRepo.On("GetByEmail", ctx, req.Email).Return(existingUser, nil)
|
||||
|
||||
// Execute
|
||||
user, err := userService.CreateUser(ctx, req)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, user)
|
||||
assert.Contains(t, err.Error(), "user already exists")
|
||||
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 错误处理
|
||||
|
||||
### 自定义错误类型
|
||||
```go
|
||||
// errors.go - 服务层错误定义
|
||||
package service
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// 通用错误
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrInternalError = errors.New("internal error")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
|
||||
// 用户相关错误
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUserInactive = errors.New("user account is not active")
|
||||
|
||||
// 照片相关错误
|
||||
ErrPhotoNotFound = errors.New("photo not found")
|
||||
ErrInvalidFileType = errors.New("invalid file type")
|
||||
ErrFileSizeExceeded = errors.New("file size exceeded")
|
||||
ErrUploadFailed = errors.New("file upload failed")
|
||||
|
||||
// 认证相关错误
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenRevoked = errors.New("token revoked")
|
||||
)
|
||||
```
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 服务设计原则
|
||||
1. **单一职责**: 每个服务只负责一个业务领域
|
||||
2. **接口优先**: 先定义接口,再实现具体逻辑
|
||||
3. **依赖注入**: 通过构造函数注入依赖
|
||||
4. **错误处理**: 明确的错误类型和处理
|
||||
5. **日志记录**: 关键操作的日志记录
|
||||
|
||||
### 性能优化
|
||||
1. **缓存策略**: 合理使用缓存减少数据库查询
|
||||
2. **批量操作**: 优化批量数据处理
|
||||
3. **异步处理**: 使用 goroutine 处理耗时操作
|
||||
4. **数据库优化**: 合理使用索引和查询优化
|
||||
|
||||
### 安全考虑
|
||||
1. **输入验证**: 严格验证所有输入参数
|
||||
2. **权限检查**: 确保用户只能操作自己的数据
|
||||
3. **敏感信息**: 避免在日志中记录敏感信息
|
||||
4. **SQL 注入**: 使用参数化查询防止 SQL 注入
|
||||
|
||||
本模块是业务逻辑的核心,确保代码质量和测试覆盖率是关键。
|
||||
@ -2,7 +2,6 @@ package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"photography-backend/internal/models"
|
||||
"photography-backend/internal/repository/postgres"
|
||||
|
||||
@ -32,10 +32,13 @@ type TokenPair struct {
|
||||
|
||||
// NewJWTService 创建JWT服务
|
||||
func NewJWTService(cfg *config.JWTConfig) *JWTService {
|
||||
accessDuration, _ := time.ParseDuration(cfg.ExpiresIn)
|
||||
refreshDuration, _ := time.ParseDuration(cfg.RefreshExpiresIn)
|
||||
|
||||
return &JWTService{
|
||||
secretKey: []byte(cfg.Secret),
|
||||
accessTokenDuration: config.AppConfig.GetJWTExpiration(),
|
||||
refreshTokenDuration: config.AppConfig.GetJWTRefreshExpiration(),
|
||||
accessTokenDuration: accessDuration,
|
||||
refreshTokenDuration: refreshDuration,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,14 +40,14 @@ type LocalStorageService struct {
|
||||
|
||||
// NewLocalStorageService 创建本地存储服务
|
||||
func NewLocalStorageService(config *config.Config, logger *zap.Logger) *LocalStorageService {
|
||||
uploadDir := config.Upload.Path
|
||||
uploadDir := config.Storage.Local.BasePath
|
||||
if uploadDir == "" {
|
||||
uploadDir = "./uploads"
|
||||
}
|
||||
|
||||
baseURL := config.Upload.BaseURL
|
||||
baseURL := config.Storage.Local.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = fmt.Sprintf("http://localhost:%d/uploads", config.Server.Port)
|
||||
baseURL = fmt.Sprintf("http://localhost:%d/uploads", config.App.Port)
|
||||
}
|
||||
|
||||
// 确保上传目录存在
|
||||
@ -95,7 +95,7 @@ func (s *LocalStorageService) UploadPhoto(ctx context.Context, file multipart.Fi
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
fileInfo, err := out.Stat()
|
||||
_, err = out.Stat()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get file info", zap.Error(err))
|
||||
return nil, err
|
||||
@ -203,7 +203,7 @@ func (s *LocalStorageService) getMimeType(filename string) string {
|
||||
|
||||
// NewStorageService 根据配置创建存储服务
|
||||
func NewStorageService(config *config.Config, logger *zap.Logger) StorageService {
|
||||
switch config.Upload.Type {
|
||||
switch config.Storage.Type {
|
||||
case "s3":
|
||||
// TODO: 实现 S3 存储服务
|
||||
logger.Warn("S3 storage not implemented yet, using local storage")
|
||||
|
||||
187
backend/internal/service/upload/upload_service.go
Normal file
187
backend/internal/service/upload/upload_service.go
Normal file
@ -0,0 +1,187 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"photography-backend/internal/config"
|
||||
)
|
||||
|
||||
// UploadService 文件上传服务
|
||||
type UploadService struct {
|
||||
config *config.Config
|
||||
uploadDir string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// UploadResult 上传结果
|
||||
type UploadResult struct {
|
||||
Filename string `json:"filename"`
|
||||
OriginalName string `json:"original_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// NewUploadService 创建文件上传服务
|
||||
func NewUploadService(cfg *config.Config) *UploadService {
|
||||
uploadDir := cfg.Storage.Local.BasePath
|
||||
if uploadDir == "" {
|
||||
uploadDir = "./uploads"
|
||||
}
|
||||
|
||||
baseURL := cfg.Storage.Local.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = fmt.Sprintf("http://localhost:%d/uploads", cfg.App.Port)
|
||||
}
|
||||
|
||||
// 确保上传目录存在
|
||||
os.MkdirAll(uploadDir, 0755)
|
||||
|
||||
// 创建子目录
|
||||
subdirs := []string{"photos", "thumbnails", "temp"}
|
||||
for _, subdir := range subdirs {
|
||||
os.MkdirAll(filepath.Join(uploadDir, subdir), 0755)
|
||||
}
|
||||
|
||||
return &UploadService{
|
||||
config: cfg,
|
||||
uploadDir: uploadDir,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// UploadPhoto 上传照片
|
||||
func (s *UploadService) UploadPhoto(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) {
|
||||
// 验证文件类型
|
||||
if !s.isValidImageType(header.Header.Get("Content-Type")) {
|
||||
return nil, fmt.Errorf("不支持的文件类型: %s", header.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if header.Size > s.config.Upload.MaxFileSize {
|
||||
return nil, fmt.Errorf("文件大小超过限制: %d bytes", header.Size)
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
filename := s.generateUniqueFilename(header.Filename)
|
||||
|
||||
// 保存文件到 photos 目录
|
||||
photoPath := filepath.Join(s.uploadDir, "photos", filename)
|
||||
|
||||
// 创建目标文件
|
||||
dst, err := os.Create(photoPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// 复制文件内容
|
||||
_, err = io.Copy(dst, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 构造文件URL
|
||||
fileURL := fmt.Sprintf("%s/photos/%s", s.baseURL, filename)
|
||||
|
||||
return &UploadResult{
|
||||
Filename: filename,
|
||||
OriginalName: header.Filename,
|
||||
FilePath: photoPath,
|
||||
FileURL: fileURL,
|
||||
FileSize: header.Size,
|
||||
MimeType: header.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeletePhoto 删除照片
|
||||
func (s *UploadService) DeletePhoto(filename string) error {
|
||||
// 删除原图
|
||||
photoPath := filepath.Join(s.uploadDir, "photos", filename)
|
||||
if err := os.Remove(photoPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 删除缩略图
|
||||
thumbnailPath := filepath.Join(s.uploadDir, "thumbnails", filename)
|
||||
os.Remove(thumbnailPath) // 忽略错误,因为缩略图可能不存在
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPhotoURL 获取照片URL
|
||||
func (s *UploadService) GetPhotoURL(filename string) string {
|
||||
return fmt.Sprintf("%s/photos/%s", s.baseURL, filename)
|
||||
}
|
||||
|
||||
// isValidImageType 验证是否为有效的图片类型
|
||||
func (s *UploadService) isValidImageType(mimeType string) bool {
|
||||
allowedTypes := s.config.Upload.AllowedTypes
|
||||
if len(allowedTypes) == 0 {
|
||||
// 默认允许的图片类型
|
||||
allowedTypes = []string{
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/tiff",
|
||||
}
|
||||
}
|
||||
|
||||
for _, allowedType := range allowedTypes {
|
||||
if mimeType == allowedType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateUniqueFilename 生成唯一文件名
|
||||
func (s *UploadService) generateUniqueFilename(originalName string) string {
|
||||
ext := filepath.Ext(originalName)
|
||||
timestamp := time.Now().Unix()
|
||||
|
||||
// 清理原文件名
|
||||
baseName := strings.TrimSuffix(originalName, ext)
|
||||
baseName = strings.ReplaceAll(baseName, " ", "_")
|
||||
|
||||
return fmt.Sprintf("%s_%d%s", baseName, timestamp, ext)
|
||||
}
|
||||
|
||||
// GetUploadStats 获取上传统计信息
|
||||
func (s *UploadService) GetUploadStats() (map[string]interface{}, error) {
|
||||
photosDir := filepath.Join(s.uploadDir, "photos")
|
||||
|
||||
var totalFiles int
|
||||
var totalSize int64
|
||||
|
||||
err := filepath.Walk(photosDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
totalFiles++
|
||||
totalSize += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_files": totalFiles,
|
||||
"total_size": totalSize,
|
||||
"upload_dir": s.uploadDir,
|
||||
"base_url": s.baseURL,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user