2026-04-16 20:40:41 +08:00
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
2026-04-18 20:56:05 +08:00
AND s.end_at > NOW()
2026-04-16 20:40:41 +08:00
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 ... )
}
}