360 lines
10 KiB
Go
360 lines
10 KiB
Go
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
|
|
}
|