package app import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "net/http" "path/filepath" "slices" "sort" "strings" "time" "github.com/chai2010/webp" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "go.uber.org/zap" _ "golang.org/x/image/webp" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" "github.com/geo-platform/tenant-api/internal/shared/middleware" "github.com/geo-platform/tenant-api/internal/shared/objectstorage" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) const ( maxImageUploadSizeBytes = 10 * 1024 * 1024 maxImageDimension = 16384 maxImageDecodePixels = 40_000_000 webpEncodeQuality = 82 defaultImageQuotaBytes = 104857600 imageAssetQueueIOTimeout = 5 * time.Second timeLayoutRFC3339 = "2006-01-02T15:04:05Z07:00" ) var allowedImageMIMEs = map[string]struct{}{ "image/gif": {}, "image/jpeg": {}, "image/png": {}, "image/webp": {}, } type ImageService struct { pool *pgxpool.Pool objectStorage objectstorage.Client rabbitMQ *rabbitmq.Client logger *zap.Logger } func NewImageService(pool *pgxpool.Pool, objectStorage objectstorage.Client, rabbitMQClient *rabbitmq.Client, logger *zap.Logger) *ImageService { return &ImageService{pool: pool, objectStorage: objectStorage, rabbitMQ: rabbitMQClient, logger: logger} } type ImageUploadResult struct { ID int64 `json:"id"` URL string `json:"url"` Name string `json:"name"` SizeBytes int64 `json:"size_bytes"` Reused bool `json:"reused"` } type ImageListResult struct { Items []ImageListItem `json:"items"` Total int `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` } type ImageListItem struct { ID int64 `json:"id"` FolderID *int64 `json:"folder_id"` Name string `json:"name"` URL string `json:"url"` SizeBytes int64 `json:"size_bytes"` CreatedAt string `json:"created_at"` } type NullableInt64Input struct { Set bool Value *int64 } func (n *NullableInt64Input) UnmarshalJSON(data []byte) error { n.Set = true var value *int64 if err := json.Unmarshal(data, &value); err != nil { return err } n.Value = value return nil } type ImageUpdateRequest struct { Name *string `json:"name"` FolderID NullableInt64Input `json:"folder_id"` } type ImageReferenceDetail struct { Total int `json:"total"` ArticleBody int `json:"article_body"` ArticleCover int `json:"article_cover"` Articles []ImageReferenceArticleBrief `json:"articles"` } type StorageUsageResponse struct { UsedBytes int64 `json:"used_bytes"` QuotaBytes int64 `json:"quota_bytes"` UsedPct float64 `json:"used_pct"` } func (s *ImageService) Upload(ctx context.Context, fileName string, content []byte, folderID *int64, buildURL func(string) string) (*ImageUploadResult, error) { if err := s.objectStorage.Validate(); err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return nil, response.ErrInternal(50010, "image_storage_validation_failed", "failed to validate object storage") } if len(content) == 0 { return nil, response.ErrBadRequest(40010, "invalid_image", "image file is required") } if len(content) > maxImageUploadSizeBytes { return nil, response.ErrBadRequest(40011, "image_too_large", "image size cannot exceed 10 MB") } contentType := http.DetectContentType(content) if _, ok := allowedImageMIMEs[contentType]; !ok { return nil, response.ErrBadRequest(40012, "invalid_image_type", "only PNG, JPEG, GIF, and WebP images are supported") } imageConfig, _, err := image.DecodeConfig(bytes.NewReader(content)) if err != nil { return nil, response.ErrBadRequest(40013, "invalid_image", "unable to decode image") } if imageConfig.Width <= 0 || imageConfig.Height <= 0 { return nil, response.ErrBadRequest(40013, "invalid_image", "unable to decode image") } if imageConfig.Width > maxImageDimension || imageConfig.Height > maxImageDimension { return nil, response.ErrBadRequest(40014, "image_dimensions_too_large", "image dimensions exceed the maximum allowed size") } if int64(imageConfig.Width)*int64(imageConfig.Height) > maxImageDecodePixels { return nil, response.ErrBadRequest(40014, "image_dimensions_too_large", "image pixel count exceeds the maximum allowed") } folderID, err = normalizeAssignableFolderID(folderID) if err != nil { return nil, err } tenantID := middleware.TenantIDFromCtx(ctx) actor := auth.MustActor(ctx) if folderID != nil { if err := s.ensureFolderExists(ctx, tenantID, *folderID); err != nil { return nil, err } } webpBytes := content if contentType != "image/webp" { decodedImage, _, err := image.Decode(bytes.NewReader(content)) if err != nil { return nil, response.ErrBadRequest(40013, "invalid_image", "unable to decode image") } var webpBuffer bytes.Buffer if err := webp.Encode(&webpBuffer, decodedImage, &webp.Options{Quality: float32(webpEncodeQuality)}); err != nil { return nil, response.ErrInternal(50011, "image_encode_failed", "failed to encode image") } webpBytes = webpBuffer.Bytes() } sizeBytes := int64(len(webpBytes)) contentHash := hashImageContent(webpBytes) existingAsset, err := s.findActiveImageAssetByContentHash(ctx, tenantID, contentHash) if err != nil { return nil, response.ErrInternal(50012, "image_asset_lookup_failed", "failed to check existing image asset") } if existingAsset != nil { return &ImageUploadResult{ ID: existingAsset.ID, URL: buildURL(existingAsset.ObjectKey), Name: existingAsset.Name, SizeBytes: existingAsset.SizeBytes, Reused: true, }, nil } displayName := deriveImageName(fileName) objectKey := buildImageObjectKey(tenantID) usageRepo := repository.NewImageUsageRepository(s.pool) if err := usageRepo.EnsureRow(ctx, tenantID); err != nil { return nil, response.ErrInternal(50013, "image_usage_init_failed", "failed to initialize image storage usage") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50014, "image_upload_tx_begin_failed", "failed to start upload transaction") } defer func() { _ = tx.Rollback(ctx) }() usageTx := repository.NewImageUsageRepository(tx) if _, err := usageTx.AtomicReserve(ctx, tenantID, sizeBytes); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, response.ErrConflict(40903, "image_storage_quota_exceeded", "image storage quota exceeded") } return nil, response.ErrInternal(50015, "image_quota_reserve_failed", "failed to reserve image storage quota") } assetID, err := s.insertPendingImageAsset(ctx, tx, tenantID, folderID, objectKey, displayName, sizeBytes, contentHash, actor.UserID) if err != nil { if isConstraintError(err, "fk_image_assets_folder") { return nil, response.ErrNotFound(40402, "image_folder_not_found", "folder not found") } return nil, response.ErrInternal(50016, "image_asset_create_failed", "failed to create image asset") } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50017, "image_upload_tx_commit_failed", "failed to finalize upload transaction") } if err := s.objectStorage.PutBytes(ctx, objectKey, webpBytes, "image/webp"); err != nil { s.logError("image upload to object storage failed", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.String("object_key", objectKey), zap.Error(err)) s.rollbackUpload(ctx, tenantID, assetID, sizeBytes) if errors.Is(err, objectstorage.ErrNotConfigured) { return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return nil, response.ErrInternal(50018, "image_upload_failed", "failed to upload image") } imageRepo := repository.NewImageRepository(s.pool) if err := imageRepo.UpdateStatus(ctx, tenantID, assetID, "active"); err != nil { s.logError("failed to mark image active after upload", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err)) if queueErr := s.enqueueFinalizeUploadTask(tenantID, assetID); queueErr == nil { return &ImageUploadResult{ ID: assetID, URL: buildURL(objectKey), Name: displayName, SizeBytes: sizeBytes, }, nil } else { s.logError("failed to enqueue upload finalization compensation", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(queueErr)) } if deleteErr := s.objectStorage.Delete(ctx, objectKey); deleteErr != nil { s.logError("failed to clean up uploaded object after finalize error", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.String("object_key", objectKey), zap.Error(deleteErr)) } s.rollbackUpload(ctx, tenantID, assetID, sizeBytes) return nil, response.ErrInternal(50019, "image_upload_finalize_failed", "failed to finalize image upload") } return &ImageUploadResult{ ID: assetID, URL: buildURL(objectKey), Name: displayName, SizeBytes: sizeBytes, }, nil } func (s *ImageService) List(ctx context.Context, folderID *int64, query *string, page, pageSize int, buildURL func(string) string) (*ImageListResult, error) { if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } searchQuery := normalizeSearchQuery(query) if searchQuery != nil { folderID = nil } tenantID := middleware.TenantIDFromCtx(ctx) repo := repository.NewImageRepository(s.pool) assets, total, err := repo.List(ctx, repository.ImageAssetListParams{ TenantID: tenantID, FolderID: folderID, SearchQuery: searchQuery, PageSize: pageSize, PageOffset: (page - 1) * pageSize, }) if err != nil { return nil, err } items := make([]ImageListItem, len(assets)) for i, asset := range assets { items[i] = ImageListItem{ ID: asset.ID, FolderID: asset.FolderID, Name: asset.Name, URL: buildURL(asset.ObjectKey), SizeBytes: asset.SizeBytes, CreatedAt: asset.CreatedAt.Format(timeLayoutRFC3339), } } if items == nil { items = []ImageListItem{} } return &ImageListResult{ Items: items, Total: total, Page: page, PageSize: pageSize, }, nil } func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequest) error { if req.Name == nil && !req.FolderID.Set { return response.ErrBadRequest(40000, "invalid_request", "at least one field is required") } tenantID := middleware.TenantIDFromCtx(ctx) repo := repository.NewImageRepository(s.pool) asset, err := repo.Get(ctx, tenantID, id) if err != nil { return mapImageNotFound(err) } if asset.Status != "active" { return response.ErrNotFound(40401, "image_not_found", "image not found") } if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { return response.ErrBadRequest(40001, "invalid_name", "name is required") } if len(name) > 255 { return response.ErrBadRequest(40002, "invalid_name", "name must be at most 255 characters") } if err := repo.UpdateName(ctx, tenantID, id, name); err != nil { return err } } if req.FolderID.Set { folderID, err := normalizeAssignableFolderID(req.FolderID.Value) if err != nil { return err } if folderID != nil { if err := s.ensureFolderExists(ctx, tenantID, *folderID); err != nil { return err } } if err := repo.UpdateFolder(ctx, tenantID, id, folderID); err != nil { if isConstraintError(err, "fk_image_assets_folder") { return response.ErrNotFound(40402, "image_folder_not_found", "folder not found") } return err } } return nil } type existingImageAsset struct { ID int64 ObjectKey string Name string SizeBytes int64 } func (s *ImageService) findActiveImageAssetByContentHash(ctx context.Context, tenantID int64, contentHash string) (*existingImageAsset, error) { if strings.TrimSpace(contentHash) == "" { return nil, nil } var asset existingImageAsset err := s.pool.QueryRow(ctx, ` SELECT id, object_key, name, size_bytes FROM image_assets WHERE tenant_id = $1 AND content_hash = $2 AND status = 'active' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1 `, tenantID, contentHash).Scan(&asset.ID, &asset.ObjectKey, &asset.Name, &asset.SizeBytes) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } return &asset, nil } func (s *ImageService) insertPendingImageAsset( ctx context.Context, tx pgx.Tx, tenantID int64, folderID *int64, objectKey string, name string, sizeBytes int64, contentHash string, createdBy int64, ) (int64, error) { row := tx.QueryRow(ctx, ` INSERT INTO image_assets (tenant_id, folder_id, object_key, name, size_bytes, status, content_hash, created_by) VALUES ($1, $2, $3, $4, $5, 'pending_upload', $6, $7) RETURNING id `, tenantID, nullableImageFolderValue(folderID), objectKey, name, sizeBytes, contentHash, createdBy) var assetID int64 if err := row.Scan(&assetID); err != nil { return 0, err } return assetID, nil } func (s *ImageService) GetReferences(ctx context.Context, imageID int64) (*ImageReferenceDetail, error) { tenantID := middleware.TenantIDFromCtx(ctx) imageRepo := repository.NewImageRepository(s.pool) refRepo := repository.NewImageReferenceRepository(s.pool) asset, err := imageRepo.Get(ctx, tenantID, imageID) if err != nil { return nil, mapImageNotFound(err) } if asset.Status != "active" { return nil, response.ErrNotFound(40401, "image_not_found", "image not found") } articles, err := refRepo.ListArticles(ctx, tenantID, imageID) if err != nil { return nil, err } articleMap := make(map[int64]map[string]struct{}) bodyCount := 0 coverCount := 0 for _, article := range articles { if articleMap[article.ArticleID] == nil { articleMap[article.ArticleID] = make(map[string]struct{}) } articleMap[article.ArticleID][article.RefScope] = struct{}{} switch article.RefScope { case "article_body": bodyCount++ case "article_cover": coverCount++ } } briefs := make([]ImageReferenceArticleBrief, 0, len(articleMap)) for articleID, scopeSet := range articleMap { scopes := make([]string, 0, len(scopeSet)) for scope := range scopeSet { scopes = append(scopes, scope) } slices.Sort(scopes) briefs = append(briefs, ImageReferenceArticleBrief{ ArticleID: articleID, Scopes: scopes, }) } sort.Slice(briefs, func(i, j int) bool { return briefs[i].ArticleID < briefs[j].ArticleID }) if briefs == nil { briefs = []ImageReferenceArticleBrief{} } return &ImageReferenceDetail{ Total: len(articles), ArticleBody: bodyCount, ArticleCover: coverCount, Articles: briefs, }, nil } func (s *ImageService) Delete(ctx context.Context, id int64, force bool) error { if err := s.objectStorage.Validate(); err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return response.ErrInternal(50019, "image_storage_validation_failed", "failed to validate object storage") } tenantID := middleware.TenantIDFromCtx(ctx) imageRepo := repository.NewImageRepository(s.pool) refRepo := repository.NewImageReferenceRepository(s.pool) asset, err := imageRepo.Get(ctx, tenantID, id) if err != nil { return mapImageNotFound(err) } if asset.Status != "active" { return response.ErrNotFound(40401, "image_not_found", "image not found") } if !force { count, err := refRepo.Count(ctx, tenantID, id) if err != nil { return err } if count > 0 { return response.ErrConflict(40904, "image_asset_in_use", "image is referenced by articles") } } if err := imageRepo.UpdateStatus(ctx, tenantID, id, "pending_delete"); err != nil { return err } return s.finalizeDeleteNowOrEnqueue(ctx, tenantID, id) } func (s *ImageService) GetStorageUsage(ctx context.Context) (*StorageUsageResponse, error) { tenantID := middleware.TenantIDFromCtx(ctx) usageRepo := repository.NewImageUsageRepository(s.pool) if err := usageRepo.EnsureRow(ctx, tenantID); err != nil { return nil, response.ErrInternal(50025, "image_usage_init_failed", "failed to initialize image storage usage") } usage, err := usageRepo.Get(ctx, tenantID) if err != nil { return nil, response.ErrInternal(50026, "image_usage_query_failed", "failed to query image storage usage") } quotaBytes, err := s.lookupImageQuotaBytes(ctx, tenantID) if err != nil { return nil, response.ErrInternal(50027, "image_quota_query_failed", "failed to query image storage quota") } usedPct := 0.0 if quotaBytes > 0 { usedPct = float64(usage.UsedBytes) / float64(quotaBytes) * 100 } return &StorageUsageResponse{ UsedBytes: usage.UsedBytes, QuotaBytes: quotaBytes, UsedPct: usedPct, }, nil } func (s *ImageService) rollbackUpload(ctx context.Context, tenantID, assetID, sizeBytes int64) { imageRepo := repository.NewImageRepository(s.pool) usageRepo := repository.NewImageUsageRepository(s.pool) if err := imageRepo.UpdateStatus(ctx, tenantID, assetID, "upload_failed"); err != nil { s.logError("failed to mark image upload as failed", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err)) } if err := usageRepo.Release(ctx, tenantID, sizeBytes); err != nil { s.logError("failed to release image storage quota after upload rollback", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err)) } } func (s *ImageService) ProcessFinalizeUpload(ctx context.Context, tenantID, assetID int64) error { asset, err := s.loadImageAsset(ctx, tenantID, assetID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil } return response.ErrInternal(50028, "image_upload_lookup_failed", "failed to load image upload state") } if asset.Status != "pending_upload" { return nil } exists, err := s.objectStorage.Exists(ctx, asset.ObjectKey) if err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return response.ErrInternal(50029, "image_upload_finalize_failed", "failed to check image upload state") } tx, err := s.pool.Begin(ctx) if err != nil { return response.ErrInternal(50030, "image_upload_tx_begin_failed", "failed to finalize image upload") } defer func() { _ = tx.Rollback(ctx) }() lockedAsset, found, err := s.lockImageAsset(ctx, tx, tenantID, assetID) if err != nil { return response.ErrInternal(50031, "image_upload_lock_failed", "failed to lock image upload state") } if !found || lockedAsset.Status != "pending_upload" { if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50032, "image_upload_tx_commit_failed", "failed to finalize image upload") } return nil } imageRepoTx := repository.NewImageRepository(tx) if exists { if err := imageRepoTx.UpdateStatus(ctx, tenantID, assetID, "active"); err != nil { return response.ErrInternal(50033, "image_upload_finalize_failed", "failed to activate uploaded image") } } else { if err := imageRepoTx.UpdateStatus(ctx, tenantID, assetID, "upload_failed"); err != nil { return response.ErrInternal(50034, "image_upload_finalize_failed", "failed to mark image upload as failed") } usageRepoTx := repository.NewImageUsageRepository(tx) if err := usageRepoTx.Release(ctx, tenantID, lockedAsset.SizeBytes); err != nil { return response.ErrInternal(50035, "image_quota_release_failed", "failed to release image storage quota") } } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50036, "image_upload_tx_commit_failed", "failed to finalize image upload") } return nil } func (s *ImageService) ProcessFinalizeDelete(ctx context.Context, tenantID, assetID int64) error { asset, err := s.loadImageAsset(ctx, tenantID, assetID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil } return response.ErrInternal(50037, "image_delete_lookup_failed", "failed to load image delete state") } if asset.Status != "pending_delete" { return nil } exists, err := s.objectStorage.Exists(ctx, asset.ObjectKey) if err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return response.ErrInternal(50038, "image_delete_finalize_failed", "failed to check image object state") } if exists { if err := s.objectStorage.Delete(ctx, asset.ObjectKey); err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return response.ErrInternal(50039, "image_delete_failed", "failed to delete image from storage") } } tx, err := s.pool.Begin(ctx) if err != nil { return response.ErrInternal(50040, "image_delete_tx_begin_failed", "failed to finalize image deletion") } defer func() { _ = tx.Rollback(ctx) }() lockedAsset, found, err := s.lockImageAsset(ctx, tx, tenantID, assetID) if err != nil { return response.ErrInternal(50041, "image_delete_lock_failed", "failed to lock image delete state") } if !found || lockedAsset.Status != "pending_delete" { if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50042, "image_delete_tx_commit_failed", "failed to finalize image deletion") } return nil } if _, err := tx.Exec(ctx, ` DELETE FROM image_asset_references WHERE tenant_id = $1 AND image_asset_id = $2 `, tenantID, assetID); err != nil { return response.ErrInternal(50043, "image_delete_reference_cleanup_failed", "failed to clean image references") } imageRepoTx := repository.NewImageRepository(tx) usageRepoTx := repository.NewImageUsageRepository(tx) if err := imageRepoTx.MarkDeleted(ctx, tenantID, assetID); err != nil { return response.ErrInternal(50044, "image_delete_finalize_failed", "failed to finalize image deletion") } if err := usageRepoTx.Release(ctx, tenantID, lockedAsset.SizeBytes); err != nil { return response.ErrInternal(50045, "image_quota_release_failed", "failed to release image storage quota") } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50046, "image_delete_tx_commit_failed", "failed to finalize image deletion") } return nil } func (s *ImageService) finalizeDeleteNowOrEnqueue(ctx context.Context, tenantID, assetID int64) error { if err := s.ProcessFinalizeDelete(ctx, tenantID, assetID); err != nil { s.logError("failed to finalize image delete synchronously", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err)) if queueErr := s.enqueueFinalizeDeleteTask(tenantID, assetID); queueErr != nil { s.logError("failed to enqueue image delete compensation", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(queueErr)) return err } } return nil } func (s *ImageService) enqueueFinalizeUploadTask(tenantID, assetID int64) error { ctx, cancel := context.WithTimeout(context.Background(), imageAssetQueueIOTimeout) defer cancel() return publishImageAssetTask(ctx, s.rabbitMQ, imageAssetTaskEnvelope{ Kind: imageAssetTaskKindFinalizeUpload, TenantID: tenantID, AssetID: assetID, }) } func (s *ImageService) enqueueFinalizeDeleteTask(tenantID, assetID int64) error { ctx, cancel := context.WithTimeout(context.Background(), imageAssetQueueIOTimeout) defer cancel() return publishImageAssetTask(ctx, s.rabbitMQ, imageAssetTaskEnvelope{ Kind: imageAssetTaskKindFinalizeDelete, TenantID: tenantID, AssetID: assetID, }) } func (s *ImageService) loadImageAsset(ctx context.Context, tenantID, assetID int64) (*repository.ImageAsset, error) { return repository.NewImageRepository(s.pool).Get(ctx, tenantID, assetID) } type lockedImageAsset struct { ObjectKey string SizeBytes int64 Status string Deleted bool } func (s *ImageService) lockImageAsset(ctx context.Context, tx pgx.Tx, tenantID, assetID int64) (*lockedImageAsset, bool, error) { var asset lockedImageAsset var deletedAt *time.Time err := tx.QueryRow(ctx, ` SELECT object_key, size_bytes, status, deleted_at FROM image_assets WHERE tenant_id = $1 AND id = $2 FOR UPDATE `, tenantID, assetID).Scan(&asset.ObjectKey, &asset.SizeBytes, &asset.Status, &deletedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil } return nil, false, err } asset.Deleted = deletedAt != nil return &asset, true, nil } func (s *ImageService) ensureFolderExists(ctx context.Context, tenantID, folderID int64) error { folderRepo := repository.NewImageFolderRepository(s.pool) if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil { return mapImageFolderNotFound(err) } return nil } func (s *ImageService) lookupImageQuotaBytes(ctx context.Context, tenantID int64) (int64, error) { var quotaBytes int64 err := s.pool.QueryRow(ctx, ` SELECT COALESCE((p.quota_policy_json ->> 'image_storage_bytes')::BIGINT, $2) AS quota_bytes FROM tenant_plan_subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.tenant_id = $1 AND s.status = 'active' AND s.deleted_at IS NULL AND s.end_at > NOW() LIMIT 1 `, tenantID, defaultImageQuotaBytes).Scan("aBytes) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return defaultImageQuotaBytes, nil } return 0, err } return quotaBytes, nil } func mapImageNotFound(err error) error { if errors.Is(err, pgx.ErrNoRows) { return response.ErrNotFound(40401, "image_not_found", "image not found") } return err } func normalizeSearchQuery(query *string) *string { if query == nil { return nil } trimmed := strings.TrimSpace(*query) if trimmed == "" { return nil } return &trimmed } func normalizeAssignableFolderID(folderID *int64) (*int64, error) { if folderID == nil { return nil, nil } if *folderID <= 0 { return nil, response.ErrBadRequest(40003, "invalid_folder_id", "folder_id must be a positive integer or null") } return folderID, nil } func buildImageObjectKey(tenantID int64) string { return fmt.Sprintf("tenants/%d/images/%s.webp", tenantID, uuid.NewString()) } func hashImageContent(content []byte) string { if len(content) == 0 { return "" } digest := sha256.Sum256(content) return hex.EncodeToString(digest[:]) } func nullableImageFolderValue(value *int64) interface{} { if value == nil { return nil } return *value } func deriveImageName(fileName string) string { name := strings.TrimSpace(filepath.Base(fileName)) if name == "" { return "untitled" } if ext := filepath.Ext(name); ext != "" { name = strings.TrimSuffix(name, ext) } name = strings.TrimSpace(name) if name == "" { return "untitled" } if len(name) > 255 { name = name[:255] } return name } func (s *ImageService) logError(msg string, fields ...zap.Field) { if s.logger != nil { s.logger.Error(msg, fields...) } }