init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View 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
}