Files
geo/server/internal/tenant/app/image_service.go
T
root ced0c4ec0f
Frontend CI / Frontend (push) Successful in 2m59s
Backend CI / Backend (push) Successful in 14m58s
Scope knowledge and image libraries by brand
2026-06-30 12:30:10 +08:00

902 lines
28 KiB
Go

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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
actor := auth.MustActor(ctx)
if folderID != nil {
if err := s.ensureFolderExists(ctx, tenantID, brandID, *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, brandID, 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, brandID, folderID, objectKey, displayName, sizeBytes, contentHash, actor.UserID)
if err != nil {
if isConstraintError(err, "fk_image_assets_folder") || isConstraintError(err, "fk_image_assets_folder_brand") {
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, brandID, 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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
repo := repository.NewImageRepository(s.pool)
assets, total, err := repo.List(ctx, repository.ImageAssetListParams{
TenantID: tenantID,
BrandID: brandID,
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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
repo := repository.NewImageRepository(s.pool)
asset, err := repo.Get(ctx, tenantID, brandID, 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, brandID, 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, brandID, *folderID); err != nil {
return err
}
}
if err := repo.UpdateFolder(ctx, tenantID, brandID, id, folderID); err != nil {
if isConstraintError(err, "fk_image_assets_folder") || isConstraintError(err, "fk_image_assets_folder_brand") {
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, brandID 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 brand_id = $2
AND content_hash = $3
AND status = 'active'
AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 1
`, tenantID, brandID, 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,
brandID 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, brand_id, folder_id, object_key, name, size_bytes, status, content_hash, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'pending_upload', $7, $8)
RETURNING id
`, tenantID, brandID, 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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
imageRepo := repository.NewImageRepository(s.pool)
refRepo := repository.NewImageReferenceRepository(s.pool)
asset, err := imageRepo.Get(ctx, tenantID, brandID, 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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
imageRepo := repository.NewImageRepository(s.pool)
refRepo := repository.NewImageReferenceRepository(s.pool)
asset, err := imageRepo.Get(ctx, tenantID, brandID, 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, brandID, 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)
brandID, err := s.lookupImageAssetBrandID(ctx, tenantID, assetID)
if err != nil {
s.logError("failed to resolve image brand after upload rollback", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err))
return
}
if err := imageRepo.UpdateStatus(ctx, tenantID, brandID, 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, lockedAsset.BrandID, assetID, "active"); err != nil {
return response.ErrInternal(50033, "image_upload_finalize_failed", "failed to activate uploaded image")
}
} else {
if err := imageRepoTx.UpdateStatus(ctx, tenantID, lockedAsset.BrandID, 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, lockedAsset.BrandID, 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) {
brandID, err := s.lookupImageAssetBrandID(ctx, tenantID, assetID)
if err != nil {
return nil, err
}
return repository.NewImageRepository(s.pool).Get(ctx, tenantID, brandID, assetID)
}
type lockedImageAsset struct {
BrandID int64
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 brand_id, object_key, size_bytes, status, deleted_at
FROM image_assets
WHERE tenant_id = $1 AND id = $2
FOR UPDATE
`, tenantID, assetID).Scan(&asset.BrandID, &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, brandID, folderID int64) error {
folderRepo := repository.NewImageFolderRepository(s.pool)
if _, err := folderRepo.Get(ctx, tenantID, brandID, folderID); err != nil {
return mapImageFolderNotFound(err)
}
return nil
}
func (s *ImageService) lookupImageAssetBrandID(ctx context.Context, tenantID, assetID int64) (int64, error) {
var brandID int64
if err := s.pool.QueryRow(ctx, `
SELECT brand_id
FROM image_assets
WHERE tenant_id = $1 AND id = $2
`, tenantID, assetID).Scan(&brandID); err != nil {
return 0, err
}
return brandID, 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(&quotaBytes)
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...)
}
}