init push
This commit is contained in:
769
internal/service/file_processing_usecase.go
Normal file
769
internal/service/file_processing_usecase.go
Normal file
@@ -0,0 +1,769 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user