package service import ( "bytes" "context" "errors" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/jpeg" "image/png" _ "image/png" "io" "log/slog" "mime/multipart" "net/http" "path/filepath" "strings" "time" xdraw "golang.org/x/image/draw" _ "golang.org/x/image/webp" "gorm.io/gorm" filemanager "wucher/internal/domain/file_manager" "wucher/internal/shared/pkg/uuidv7" ) type fileProcessingPermanentError struct { err error } func (e *fileProcessingPermanentError) Error() string { if e == nil || e.err == nil { return "file processing permanent error" } return e.err.Error() } func (e *fileProcessingPermanentError) Unwrap() error { if e == nil { return nil } return e.err } func wrapFileProcessingPermanent(err error) error { if err == nil { return nil } var marker *fileProcessingPermanentError if errors.As(err, &marker) { return err } return &fileProcessingPermanentError{err: err} } func IsFileProcessingPermanentError(err error) bool { var marker *fileProcessingPermanentError return errors.As(err, &marker) } type FileProcessingUseCase struct { fileRepo filemanager.FileRepository storage FileProcessingObjectStorage logger *slog.Logger fileStatusEvents filemanager.FileStatusRealtimeEventWriter gotenbergURL string } type FileProcessingObjectStorage interface { GetObject(ctx context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) DeleteObject(ctx context.Context, objectKey string) error } type FileProcessingUseCaseDependencies struct { Logger *slog.Logger Storage FileProcessingObjectStorage FileStatusEvents filemanager.FileStatusRealtimeEventWriter GotenbergURL string } const ( jpegContentType = "image/jpeg" pngContentType = "image/png" jpegCompressionQuality = 80 thumbnailMaxDimension = 512 ) func stringPointerOrNil(value string) *string { value = strings.TrimSpace(value) if value == "" { return nil } return &value } func NewFileProcessingUseCase(fileRepo filemanager.FileRepository, deps FileProcessingUseCaseDependencies) *FileProcessingUseCase { logger := deps.Logger if logger == nil { logger = slog.Default() } return &FileProcessingUseCase{ fileRepo: fileRepo, logger: logger, storage: deps.Storage, fileStatusEvents: deps.FileStatusEvents, gotenbergURL: strings.TrimSpace(deps.GotenbergURL), } } func (u *FileProcessingUseCase) SetFileStatusRealtimeEventWriter(writer filemanager.FileStatusRealtimeEventWriter) { u.fileStatusEvents = writer } func (u *FileProcessingUseCase) SetObjectStorage(storage FileProcessingObjectStorage) { u.storage = storage } func (u *FileProcessingUseCase) Process(ctx context.Context, job filemanager.FileProcessingJob, correlationID string) error { if u.fileRepo == nil { return wrapFileProcessingPermanent(errors.New("file repository is required")) } job = canonicalFileProcessingJob(job) if err := validateFileProcessingJob(job); err != nil { return wrapFileProcessingPermanent(err) } fileID, err := uuidv7.ParseString(job.FileUUID) if err != nil { return wrapFileProcessingPermanent(fmt.Errorf("parse file uuid: %w", err)) } row, err := u.fileRepo.GetByIDAny(ctx, fileID) if err != nil { return err } if row == nil { return wrapFileProcessingPermanent(filemanager.ErrFileNotFound) } status := filemanager.NormalizeFileStatus(row.Status) switch status { case filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed: return nil case filemanager.FileStatusUploaded: changed, err := u.transitionToProcessing(ctx, fileID) if err != nil { return err } if changed { row.Status = filemanager.FileStatusProcessing row.UpdatedAt = time.Now().UTC() u.emitStatusEvent(ctx, row, filemanager.FileStatusProcessing, "") } case filemanager.FileStatusProcessing: // Continue from processing state for at-least-once delivery semantics. default: return wrapFileProcessingPermanent(fmt.Errorf("unexpected file status %q", status)) } if err := validateFileProcessingPayload(row, job); err != nil { reason := truncateFailureReason(err.Error()) changed, markErr := u.markFailedFrom(ctx, fileID, filemanager.FileStatusProcessing, reason) if markErr != nil { return markErr } if changed { row.Status = filemanager.FileStatusFailed row.FailureReason = reason row.UpdatedAt = time.Now().UTC() u.emitStatusEvent(ctx, row, filemanager.FileStatusFailed, reason) } u.logger.Warn("file processing failed", slog.String("file_uuid", job.FileUUID), slog.String("reason", reason), slog.String("correlation_id", correlationID), ) return nil } if err := u.compressObjectIfNeeded(ctx, row, correlationID); err != nil { return err } if err := u.convertDocumentIfNeeded(ctx, row, correlationID); err != nil { return err } changed, err := u.markValidated(ctx, fileID) if err != nil { return err } if changed { row.Status = filemanager.FileStatusValidated row.FailureReason = "" row.UpdatedAt = time.Now().UTC() u.emitStatusEvent(ctx, row, filemanager.FileStatusValidated, "") } readyChanged, err := u.markReady(ctx, fileID, row) if err != nil { reason := truncateFailureReason(err.Error()) failedChanged, markErr := u.markFailedFrom(ctx, fileID, filemanager.FileStatusValidated, reason) if markErr != nil { return markErr } if failedChanged { row.Status = filemanager.FileStatusFailed row.FailureReason = reason row.UpdatedAt = time.Now().UTC() u.emitStatusEvent(ctx, row, filemanager.FileStatusFailed, reason) } u.logger.Warn("file processing finalize failed", slog.String("file_uuid", job.FileUUID), slog.String("reason", reason), slog.String("correlation_id", correlationID), ) return nil } if readyChanged { row.Status = filemanager.FileStatusReady row.FailureReason = "" row.UpdatedAt = time.Now().UTC() u.emitStatusEvent(ctx, row, filemanager.FileStatusReady, "") } u.logger.Info("file processing completed", slog.String("file_uuid", job.FileUUID), slog.String("correlation_id", correlationID), ) return nil } func (u *FileProcessingUseCase) compressObjectIfNeeded(ctx context.Context, row *filemanager.File, correlationID string) error { if row == nil || u.storage == nil { return nil } originalMimeType := normalizeUploadedMimeType(row.MimeType) if !isCompressibleImageMimeType(originalMimeType) { return nil } body, _, err := u.storage.GetObject(ctx, row.ObjectKey) if err != nil { return err } defer body.Close() raw, err := io.ReadAll(body) if err != nil { return err } compressed, thumbnailContentType, err := compressImageForPreview(raw, originalMimeType) if err != nil { u.logger.Warn("file compression skipped: decode/encode failed", slog.String("object_key", row.ObjectKey), slog.String("mime_type", originalMimeType), slog.String("error", err.Error()), slog.String("correlation_id", correlationID), ) return nil } if len(compressed) == 0 { return nil } originalObjectKey := strings.TrimSpace(row.ObjectKey) targetObjectKey := thumbnailObjectKey(originalObjectKey, thumbnailContentType) meta, err := u.storage.PutObject(ctx, filemanager.PutObjectInput{ ObjectKey: targetObjectKey, ContentType: thumbnailContentType, SizeBytes: int64(len(compressed)), Body: bytes.NewReader(compressed), }) if err != nil { return err } row.ThumbnailMimeType = stringPointerOrNil(thumbnailContentType) row.ThumbnailSizeBytes = int64(len(compressed)) thumbnailBucket := strings.TrimSpace(meta.Bucket) if thumbnailBucket == "" { thumbnailBucket = strings.TrimSpace(row.Bucket) } row.ThumbnailBucket = stringPointerOrNil(thumbnailBucket) thumbnailObjectKey := strings.TrimSpace(meta.ObjectKey) if thumbnailObjectKey == "" { thumbnailObjectKey = targetObjectKey } row.ThumbnailObjectKey = stringPointerOrNil(thumbnailObjectKey) row.ThumbnailETag = stringPointerOrNil(meta.ETag) row.ThumbnailObjectVersion = stringPointerOrNil(meta.VersionID) if err := u.fileRepo.Update(ctx, row); err != nil { return err } u.logger.Info("file thumbnail generated", slog.String("object_key", row.ObjectKey), slog.String("thumbnail_object_key", thumbnailObjectKey), slog.String("mime_type_before", originalMimeType), slog.String("mime_type_after", thumbnailContentType), slog.Int("before_bytes", len(raw)), slog.Int("after_bytes", len(compressed)), slog.String("correlation_id", correlationID), ) return nil } func isCompressibleImageMimeType(mimeType string) bool { mimeType = strings.ToLower(strings.TrimSpace(mimeType)) if !strings.HasPrefix(mimeType, "image/") { return false } switch mimeType { case "image/svg+xml", "image/webp": return false default: return true } } func compressImageForPreview(raw []byte, originalMimeType string) ([]byte, string, error) { if len(raw) == 0 { return nil, "", errors.New("empty image payload") } img, _, err := image.Decode(bytes.NewReader(raw)) if err != nil { return nil, "", err } img = downscaleForThumbnail(img, thumbnailMaxDimension) var out bytes.Buffer switch normalizeUploadedMimeType(originalMimeType) { case pngContentType, "image/gif": encoder := png.Encoder{CompressionLevel: png.BestCompression} if err := encoder.Encode(&out, img); err != nil { return nil, "", err } return out.Bytes(), pngContentType, nil default: if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: jpegCompressionQuality}); err != nil { return nil, "", err } return out.Bytes(), jpegContentType, nil } } func downscaleForThumbnail(src image.Image, maxDim int) image.Image { if src == nil || maxDim <= 0 { return src } bounds := src.Bounds() width := bounds.Dx() height := bounds.Dy() if width <= 0 || height <= 0 { return src } if width <= maxDim && height <= maxDim { return src } scale := float64(maxDim) / float64(width) if height > width { scale = float64(maxDim) / float64(height) } newWidth := int(float64(width) * scale) newHeight := int(float64(height) * scale) if newWidth < 1 { newWidth = 1 } if newHeight < 1 { newHeight = 1 } dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, xdraw.Over, nil) return dst } func (u *FileProcessingUseCase) transitionToProcessing(ctx context.Context, fileID []byte) (bool, error) { now := time.Now().UTC() if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{ ID: fileID, ExpectedFrom: filemanager.FileStatusUploaded, To: filemanager.FileStatusProcessing, ProcessingStartedAt: &now, }); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } current, getErr := u.fileRepo.GetByIDAny(ctx, fileID) if getErr != nil { return false, getErr } if current == nil { return false, wrapFileProcessingPermanent(filemanager.ErrFileNotFound) } switch filemanager.NormalizeFileStatus(current.Status) { case filemanager.FileStatusProcessing, filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed: return false, nil default: return false, err } } return true, nil } func (u *FileProcessingUseCase) markValidated(ctx context.Context, fileID []byte) (bool, error) { now := time.Now().UTC() if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{ ID: fileID, ExpectedFrom: filemanager.FileStatusProcessing, To: filemanager.FileStatusValidated, ProcessedAt: &now, ValidatedAt: &now, }); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } current, getErr := u.fileRepo.GetByIDAny(ctx, fileID) if getErr != nil { return false, getErr } if current == nil { return false, nil } switch filemanager.NormalizeFileStatus(current.Status) { case filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed: return false, nil default: return false, err } } return true, nil } func (u *FileProcessingUseCase) markFailedFrom(ctx context.Context, fileID []byte, expectedFrom string, reason string) (bool, error) { now := time.Now().UTC() if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{ ID: fileID, ExpectedFrom: expectedFrom, To: filemanager.FileStatusFailed, ProcessedAt: &now, FailedAt: &now, FailureReason: &reason, }); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } current, getErr := u.fileRepo.GetByIDAny(ctx, fileID) if getErr != nil { return false, getErr } if current == nil { return false, nil } switch filemanager.NormalizeFileStatus(current.Status) { case filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed: return false, nil default: return false, err } } return true, nil } func (u *FileProcessingUseCase) markReady(ctx context.Context, fileID []byte, row *filemanager.File) (bool, error) { if row == nil { return false, errors.New("file row is required") } nameNormalized := strings.TrimSpace(row.NameNormalized) if nameNormalized == "" { nameNormalized = strings.ToLower(strings.TrimSpace(row.Name)) } updatedBy := row.UpdatedBy if len(updatedBy) != 16 { updatedBy = row.CreatedBy } if err := u.fileRepo.Finalize(ctx, filemanager.FileFinalizeParams{ ID: fileID, ExpectedFrom: filemanager.FileStatusValidated, FolderID: append([]byte(nil), row.FolderID...), Name: row.Name, NameNormalized: nameNormalized, Extension: normalizeExtensionToken(firstNonEmptyString(row.Extension, extensionFromName(row.Name))), UpdatedBy: updatedBy, }); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return false, filemanager.ErrDuplicateName } if !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } current, getErr := u.fileRepo.GetByIDAny(ctx, fileID) if getErr != nil { return false, getErr } if current == nil { return false, nil } switch filemanager.NormalizeFileStatus(current.Status) { case filemanager.FileStatusReady, filemanager.FileStatusFailed, filemanager.FileStatusTrashed: return false, nil default: return false, err } } return true, nil } func (u *FileProcessingUseCase) emitStatusEvent(ctx context.Context, row *filemanager.File, status, failureReason string) { if u.fileStatusEvents == nil || row == nil { return } userID := row.CreatedBy if len(userID) != 16 { userID = row.UpdatedBy } if len(userID) != 16 { return } occurredAt := row.UpdatedAt if occurredAt.IsZero() { occurredAt = time.Now().UTC() } _ = u.fileStatusEvents.CreateFileStatusRealtimeEvent(ctx, filemanager.FileStatusRealtimeEventInput{ FileID: append([]byte(nil), row.ID...), UserID: append([]byte(nil), userID...), Status: status, Name: row.Name, FailureReason: strings.TrimSpace(failureReason), OccurredAt: occurredAt, }) } func canonicalFileProcessingJob(job filemanager.FileProcessingJob) filemanager.FileProcessingJob { job.JobID = strings.TrimSpace(job.JobID) job.FileUUID = strings.TrimSpace(job.FileUUID) job.Bucket = strings.TrimSpace(job.Bucket) job.ObjectKey = strings.TrimSpace(job.ObjectKey) job.UploadedBy = strings.TrimSpace(job.UploadedBy) job.MimeType = strings.ToLower(strings.TrimSpace(job.MimeType)) job.TraceID = strings.TrimSpace(job.TraceID) job.RequestID = strings.TrimSpace(job.RequestID) return job } func validateFileProcessingJob(job filemanager.FileProcessingJob) error { if strings.TrimSpace(job.FileUUID) == "" { return errors.New("file uuid is required") } if strings.TrimSpace(job.Bucket) == "" { return errors.New("bucket is required") } if strings.TrimSpace(job.ObjectKey) == "" { return errors.New("object key is required") } if job.SizeBytes < 0 { return errors.New("size bytes must be non-negative") } return nil } func validateFileProcessingPayload(row *filemanager.File, job filemanager.FileProcessingJob) error { if row == nil { return errors.New("file row is missing") } if strings.TrimSpace(row.Bucket) == "" { return errors.New("file bucket is empty") } if strings.TrimSpace(row.ObjectKey) == "" { return errors.New("file object key is empty") } if !strings.EqualFold(strings.TrimSpace(row.Bucket), strings.TrimSpace(job.Bucket)) { return errors.New("file bucket mismatch") } rowObjectKey := strings.TrimSpace(row.ObjectKey) jobObjectKey := strings.TrimSpace(job.ObjectKey) if rowObjectKey != jobObjectKey { // Keep compatibility with records converted by earlier worker behavior. if rowObjectKey != webpObjectKey(jobObjectKey) { return errors.New("file object key mismatch") } } if row.SizeBytes < 0 { return errors.New("file size is invalid") } return nil } func webpObjectKey(objectKey string) string { objectKey = strings.TrimSpace(objectKey) if objectKey == "" { return objectKey } ext := strings.ToLower(strings.TrimSpace(filepath.Ext(objectKey))) if ext == ".webp" { return objectKey } if ext == "" { return objectKey + ".webp" } return strings.TrimSuffix(objectKey, ext) + ".webp" } func thumbnailObjectKey(objectKey, mimeType string) string { objectKey = strings.TrimSpace(objectKey) if objectKey == "" { return objectKey } thumbExt := ".jpg" switch normalizeUploadedMimeType(mimeType) { case pngContentType: thumbExt = ".png" } ext := strings.TrimSpace(filepath.Ext(objectKey)) if ext == "" { return objectKey + ".thumb" + thumbExt } return strings.TrimSuffix(objectKey, ext) + ".thumb" + thumbExt } func previewObjectKey(objectKey string) string { objectKey = strings.TrimSpace(objectKey) if objectKey == "" { return objectKey } ext := strings.TrimSpace(filepath.Ext(objectKey)) if ext == "" { return objectKey + ".preview.pdf" } return strings.TrimSuffix(objectKey, ext) + ".preview.pdf" } func isConvertibleDocumentMimeType(mimeType string) bool { switch strings.ToLower(strings.TrimSpace(mimeType)) { case "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // docx "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // xlsx "application/vnd.openxmlformats-officedocument.presentationml.presentation", // pptx "application/msword", // doc "application/vnd.ms-excel", // xls "application/vnd.ms-powerpoint": // ppt return true default: return false } } func (u *FileProcessingUseCase) convertDocumentIfNeeded(ctx context.Context, row *filemanager.File, correlationID string) error { if row == nil || u.storage == nil || u.gotenbergURL == "" { return nil } if !isConvertibleDocumentMimeType(row.MimeType) { return nil } body, _, err := u.storage.GetObject(ctx, row.ObjectKey) if err != nil { return fmt.Errorf("document preview: get original from storage: %w", err) } defer body.Close() raw, err := io.ReadAll(body) if err != nil { return fmt.Errorf("document preview: read original: %w", err) } pdfBytes, err := u.callGotenberg(ctx, raw, row.Name) if err != nil { return err } targetKey := previewObjectKey(strings.TrimSpace(row.ObjectKey)) meta, err := u.storage.PutObject(ctx, filemanager.PutObjectInput{ ObjectKey: targetKey, ContentType: "application/pdf", SizeBytes: int64(len(pdfBytes)), Body: bytes.NewReader(pdfBytes), }) if err != nil { return fmt.Errorf("document preview: upload preview to storage: %w", err) } previewMimeType := "application/pdf" row.PreviewMimeType = &previewMimeType row.PreviewSizeBytes = int64(len(pdfBytes)) previewBucket := strings.TrimSpace(meta.Bucket) if previewBucket == "" { previewBucket = strings.TrimSpace(row.Bucket) } row.PreviewBucket = &previewBucket previewKey := strings.TrimSpace(meta.ObjectKey) if previewKey == "" { previewKey = targetKey } row.PreviewObjectKey = &previewKey row.PreviewETag = stringPointerOrNil(meta.ETag) row.PreviewObjectVersion = stringPointerOrNil(meta.VersionID) if err := u.fileRepo.Update(ctx, row); err != nil { return fmt.Errorf("document preview: save preview metadata: %w", err) } u.logger.Info("document preview generated", slog.String("object_key", row.ObjectKey), slog.String("preview_object_key", previewKey), slog.String("mime_type", row.MimeType), slog.Int("preview_bytes", len(pdfBytes)), slog.String("correlation_id", correlationID), ) return nil } func (u *FileProcessingUseCase) callGotenberg(ctx context.Context, raw []byte, filename string) ([]byte, error) { var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("files", filename) if err != nil { return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: create multipart field: %w", err)) } if _, err := part.Write(raw); err != nil { return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: write file to multipart: %w", err)) } if err := writer.Close(); err != nil { return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: close multipart writer: %w", err)) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.gotenbergURL+"/forms/libreoffice/convert", &body) if err != nil { return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: build gotenberg request: %w", err)) } req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := http.DefaultClient.Do(req) if err != nil { // Network or connection error — transient, allow retry. return nil, fmt.Errorf("document preview: gotenberg request failed: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("document preview: read gotenberg response: %w", err) } if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError { // 4xx: bad document or unsupported format — permanent failure. return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: gotenberg rejected document (status %d)", resp.StatusCode)) } if resp.StatusCode != http.StatusOK { // 5xx or unexpected — transient, allow retry. return nil, fmt.Errorf("document preview: gotenberg returned status %d", resp.StatusCode) } if len(respBody) == 0 { return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: gotenberg returned empty PDF")) } return respBody, nil }