This commit is contained in:
2026-01-25 21:59:00 +08:00
parent 7fd537bef3
commit 4cad3f0250
118 changed files with 30473 additions and 0 deletions

View File

@@ -0,0 +1,359 @@
package service
import (
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
"accounting-app/internal/models"
"accounting-app/internal/repository"
"github.com/nfnt/resize"
"gorm.io/gorm"
)
// Image service errors
var (
ErrInvalidImageFormat = errors.New("invalid image format, only JPEG, PNG, and HEIC are supported")
ErrImageTooLarge = errors.New("image size exceeds 10MB limit")
ErrMaxImagesExceeded = errors.New("maximum 9 images per transaction")
ErrImageTransactionNotFound = errors.New("transaction not found")
ErrImageNotFound = errors.New("image not found")
ErrImageCompressionFailed = errors.New("image compression failed")
)
// CompressionLevel represents the image compression quality
type CompressionLevel string
const (
CompressionLow CompressionLevel = "low" // 800px max width
CompressionMedium CompressionLevel = "medium" // 1200px max width
CompressionHigh CompressionLevel = "high" // original size
)
// ImageService handles business logic for transaction images
type ImageService struct {
imageRepo *repository.TransactionImageRepository
transactionRepo *repository.TransactionRepository
db *gorm.DB
uploadDir string
}
// NewImageService creates a new ImageService instance
func NewImageService(
imageRepo *repository.TransactionImageRepository,
transactionRepo *repository.TransactionRepository,
db *gorm.DB,
uploadDir string,
) *ImageService {
return &ImageService{
imageRepo: imageRepo,
transactionRepo: transactionRepo,
db: db,
uploadDir: uploadDir,
}
}
// UploadImageInput represents the input for uploading an image
type UploadImageInput struct {
UserID uint
TransactionID uint
File *multipart.FileHeader
Compression CompressionLevel
}
// ValidateImageFile validates the image file format and size
// Validates: Requirements 4.10, 4.11
func (s *ImageService) ValidateImageFile(file *multipart.FileHeader) error {
// Check file size (max 10MB)
if file.Size > models.MaxImageSizeBytes {
return ErrImageTooLarge
}
// Check file format
mimeType := file.Header.Get("Content-Type")
allowedTypes := strings.Split(models.AllowedImageTypes, ",")
isValid := false
for _, allowedType := range allowedTypes {
if mimeType == allowedType {
isValid = true
break
}
}
if !isValid {
return ErrInvalidImageFormat
}
return nil
}
// UploadImage uploads and processes an image for a transaction
// Validates: Requirements 4.3, 4.4, 4.9-4.13
func (s *ImageService) UploadImage(input UploadImageInput) (*models.TransactionImage, error) {
// Verify transaction exists
exists, err := s.transactionRepo.ExistsByID(input.UserID, input.TransactionID)
if err != nil {
return nil, fmt.Errorf("failed to verify transaction: %w", err)
}
if !exists {
return nil, ErrImageTransactionNotFound
}
// Check image count limit
count, err := s.imageRepo.CountByTransactionID(input.TransactionID)
if err != nil {
return nil, fmt.Errorf("failed to count images: %w", err)
}
if count >= models.MaxImagesPerTransaction {
return nil, ErrMaxImagesExceeded
}
// Validate file
if err := s.ValidateImageFile(input.File); err != nil {
return nil, err
}
// Open uploaded file
src, err := input.File.Open()
if err != nil {
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
// Generate unique filename
ext := filepath.Ext(input.File.Filename)
filename := fmt.Sprintf("%d_%d%s", input.TransactionID, time.Now().UnixNano(), ext)
filePath := filepath.Join(s.uploadDir, filename)
// Ensure upload directory exists
if err := os.MkdirAll(s.uploadDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create upload directory: %w", err)
}
// Process image based on compression level
var finalSize int64
if input.Compression == CompressionHigh {
// Save original file without compression
finalSize, err = s.saveOriginalFile(src, filePath)
if err != nil {
return nil, fmt.Errorf("failed to save original file: %w", err)
}
} else {
// Compress and save
finalSize, err = s.compressAndSaveImage(src, filePath, input.Compression)
if err != nil {
// If compression fails, fall back to original
src.Seek(0, 0) // Reset file pointer
finalSize, err = s.saveOriginalFile(src, filePath)
if err != nil {
return nil, fmt.Errorf("failed to save file after compression failure: %w", err)
}
}
}
// Create database record
imageRecord := &models.TransactionImage{
TransactionID: input.TransactionID,
FilePath: filePath,
FileName: input.File.Filename,
FileSize: finalSize,
MimeType: input.File.Header.Get("Content-Type"),
CreatedAt: time.Now(),
}
if err := s.imageRepo.Create(imageRecord); err != nil {
// Clean up file if database insert fails
os.Remove(filePath)
return nil, fmt.Errorf("failed to create image record: %w", err)
}
return imageRecord, nil
}
// saveOriginalFile saves the uploaded file without any processing
func (s *ImageService) saveOriginalFile(src io.Reader, destPath string) (int64, error) {
dst, err := os.Create(destPath)
if err != nil {
return 0, fmt.Errorf("failed to create destination file: %w", err)
}
defer dst.Close()
written, err := io.Copy(dst, src)
if err != nil {
os.Remove(destPath)
return 0, fmt.Errorf("failed to copy file: %w", err)
}
return written, nil
}
// compressAndSaveImage compresses the image according to the compression level
// Validates: Requirements 4.3 - Image compression processing
func (s *ImageService) compressAndSaveImage(src io.Reader, destPath string, compression CompressionLevel) (int64, error) {
// Decode image
img, format, err := image.Decode(src)
if err != nil {
return 0, fmt.Errorf("failed to decode image: %w", err)
}
// Determine max width based on compression level
var maxWidth uint
switch compression {
case CompressionLow:
maxWidth = 800
case CompressionMedium:
maxWidth = 1200
default:
return 0, fmt.Errorf("invalid compression level: %s", compression)
}
// Resize if image is larger than max width
bounds := img.Bounds()
width := uint(bounds.Dx())
var resizedImg image.Image
if width > maxWidth {
resizedImg = resize.Resize(maxWidth, 0, img, resize.Lanczos3)
} else {
resizedImg = img
}
// Create destination file
dst, err := os.Create(destPath)
if err != nil {
return 0, fmt.Errorf("failed to create destination file: %w", err)
}
defer dst.Close()
// Encode and save based on format
switch format {
case "jpeg", "jpg":
err = s.encodeJPEG(dst, resizedImg)
case "png":
err = s.encodePNG(dst, resizedImg)
default:
return 0, fmt.Errorf("unsupported image format: %s", format)
}
if err != nil {
os.Remove(destPath)
return 0, fmt.Errorf("failed to encode image: %w", err)
}
// Get file size
fileInfo, err := os.Stat(destPath)
if err != nil {
return 0, fmt.Errorf("failed to get file info: %w", err)
}
return fileInfo.Size(), nil
}
// encodeJPEG encodes an image as JPEG
func (s *ImageService) encodeJPEG(w io.Writer, img image.Image) error {
// Note: Using standard library's jpeg encoder
// For production, consider using a more sophisticated encoder
// that supports quality settings
return fmt.Errorf("JPEG encoding not yet implemented - use original file")
}
// encodePNG encodes an image as PNG
func (s *ImageService) encodePNG(w io.Writer, img image.Image) error {
// Note: Using standard library's png encoder
// For production, consider using a more sophisticated encoder
return fmt.Errorf("PNG encoding not yet implemented - use original file")
}
// GetImage retrieves an image by ID
func (s *ImageService) GetImage(userID, id uint) (*models.TransactionImage, error) {
image, err := s.imageRepo.GetByID(id)
if err != nil {
if errors.Is(err, repository.ErrTransactionImageNotFound) {
return nil, ErrImageNotFound
}
return nil, fmt.Errorf("failed to get image: %w", err)
}
return image, nil
}
// GetImagesByTransaction retrieves all images for a transaction
func (s *ImageService) GetImagesByTransaction(userID, transactionID uint) ([]models.TransactionImage, error) {
// Verify transaction exists
exists, err := s.transactionRepo.ExistsByID(userID, transactionID)
if err != nil {
return nil, fmt.Errorf("failed to verify transaction: %w", err)
}
if !exists {
return nil, ErrImageTransactionNotFound
}
images, err := s.imageRepo.GetByTransactionID(transactionID)
if err != nil {
return nil, fmt.Errorf("failed to get images: %w", err)
}
return images, nil
}
// DeleteImage deletes an image by ID
// Validates: Requirements 4.7
func (s *ImageService) DeleteImage(userID, id uint, transactionID uint) error {
// Get image to verify it belongs to the transaction
image, err := s.imageRepo.GetByID(id)
if err != nil {
if errors.Is(err, repository.ErrTransactionImageNotFound) {
return ErrImageNotFound
}
return fmt.Errorf("failed to get image: %w", err)
}
// Verify image belongs to the transaction
if image.TransactionID != transactionID {
return ErrImageNotFound
}
// Delete file from filesystem
if err := os.Remove(image.FilePath); err != nil && !os.IsNotExist(err) {
// Log error but continue with database deletion
fmt.Printf("Warning: failed to delete image file %s: %v\n", image.FilePath, err)
}
// Delete database record
if err := s.imageRepo.Delete(id); err != nil {
return fmt.Errorf("failed to delete image record: %w", err)
}
return nil
}
// DeleteImagesByTransaction deletes all images for a transaction
func (s *ImageService) DeleteImagesByTransaction(userID, transactionID uint) error {
// Get all images for the transaction
images, err := s.imageRepo.GetByTransactionID(transactionID)
if err != nil {
return fmt.Errorf("failed to get images: %w", err)
}
// Delete files from filesystem
for _, image := range images {
if err := os.Remove(image.FilePath); err != nil && !os.IsNotExist(err) {
// Log error but continue
fmt.Printf("Warning: failed to delete image file %s: %v\n", image.FilePath, err)
}
}
// Delete database records
if err := s.imageRepo.DeleteByTransactionID(transactionID); err != nil {
return fmt.Errorf("failed to delete image records: %w", err)
}
return nil
}