Scope knowledge and image libraries by brand
Frontend CI / Frontend (push) Successful in 2m59s
Backend CI / Backend (push) Successful in 14m58s

This commit is contained in:
2026-06-30 12:30:10 +08:00
parent 95ddb3057f
commit ced0c4ec0f
35 changed files with 1136 additions and 326 deletions
+74 -28
View File
@@ -158,9 +158,13 @@ func (s *ImageService) Upload(ctx context.Context, fileName string, content []by
}
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, *folderID); err != nil {
if err := s.ensureFolderExists(ctx, tenantID, brandID, *folderID); err != nil {
return nil, err
}
}
@@ -181,7 +185,7 @@ func (s *ImageService) Upload(ctx context.Context, fileName string, content []by
sizeBytes := int64(len(webpBytes))
contentHash := hashImageContent(webpBytes)
existingAsset, err := s.findActiveImageAssetByContentHash(ctx, tenantID, contentHash)
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")
}
@@ -219,9 +223,9 @@ func (s *ImageService) Upload(ctx context.Context, fileName string, content []by
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)
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") {
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")
@@ -241,7 +245,7 @@ func (s *ImageService) Upload(ctx context.Context, fileName string, content []by
}
imageRepo := repository.NewImageRepository(s.pool)
if err := imageRepo.UpdateStatus(ctx, tenantID, assetID, "active"); err != nil {
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{
@@ -282,9 +286,14 @@ func (s *ImageService) List(ctx context.Context, folderID *int64, query *string,
}
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,
@@ -323,9 +332,13 @@ func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequ
}
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
repo := repository.NewImageRepository(s.pool)
asset, err := repo.Get(ctx, tenantID, id)
asset, err := repo.Get(ctx, tenantID, brandID, id)
if err != nil {
return mapImageNotFound(err)
}
@@ -341,7 +354,7 @@ func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequ
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 {
if err := repo.UpdateName(ctx, tenantID, brandID, id, name); err != nil {
return err
}
}
@@ -352,12 +365,12 @@ func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequ
return err
}
if folderID != nil {
if err := s.ensureFolderExists(ctx, tenantID, *folderID); err != nil {
if err := s.ensureFolderExists(ctx, tenantID, brandID, *folderID); err != nil {
return err
}
}
if err := repo.UpdateFolder(ctx, tenantID, id, folderID); err != nil {
if isConstraintError(err, "fk_image_assets_folder") {
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
@@ -374,7 +387,7 @@ type existingImageAsset struct {
SizeBytes int64
}
func (s *ImageService) findActiveImageAssetByContentHash(ctx context.Context, tenantID int64, contentHash string) (*existingImageAsset, error) {
func (s *ImageService) findActiveImageAssetByContentHash(ctx context.Context, tenantID, brandID int64, contentHash string) (*existingImageAsset, error) {
if strings.TrimSpace(contentHash) == "" {
return nil, nil
}
@@ -384,12 +397,13 @@ func (s *ImageService) findActiveImageAssetByContentHash(ctx context.Context, te
SELECT id, object_key, name, size_bytes
FROM image_assets
WHERE tenant_id = $1
AND content_hash = $2
AND brand_id = $2
AND content_hash = $3
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)
`, tenantID, brandID, contentHash).Scan(&asset.ID, &asset.ObjectKey, &asset.Name, &asset.SizeBytes)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
@@ -404,6 +418,7 @@ func (s *ImageService) insertPendingImageAsset(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
brandID int64,
folderID *int64,
objectKey string,
name string,
@@ -412,10 +427,10 @@ func (s *ImageService) insertPendingImageAsset(
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)
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, nullableImageFolderValue(folderID), objectKey, name, sizeBytes, contentHash, createdBy)
`, tenantID, brandID, nullableImageFolderValue(folderID), objectKey, name, sizeBytes, contentHash, createdBy)
var assetID int64
if err := row.Scan(&assetID); err != nil {
@@ -426,10 +441,14 @@ RETURNING id
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, imageID)
asset, err := imageRepo.Get(ctx, tenantID, brandID, imageID)
if err != nil {
return nil, mapImageNotFound(err)
}
@@ -494,10 +513,14 @@ func (s *ImageService) Delete(ctx context.Context, id int64, force bool) error {
}
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, id)
asset, err := imageRepo.Get(ctx, tenantID, brandID, id)
if err != nil {
return mapImageNotFound(err)
}
@@ -515,7 +538,7 @@ func (s *ImageService) Delete(ctx context.Context, id int64, force bool) error {
}
}
if err := imageRepo.UpdateStatus(ctx, tenantID, id, "pending_delete"); err != nil {
if err := imageRepo.UpdateStatus(ctx, tenantID, brandID, id, "pending_delete"); err != nil {
return err
}
@@ -556,7 +579,13 @@ func (s *ImageService) rollbackUpload(ctx context.Context, tenantID, assetID, si
imageRepo := repository.NewImageRepository(s.pool)
usageRepo := repository.NewImageUsageRepository(s.pool)
if err := imageRepo.UpdateStatus(ctx, tenantID, assetID, "upload_failed"); err != nil {
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 {
@@ -605,11 +634,11 @@ func (s *ImageService) ProcessFinalizeUpload(ctx context.Context, tenantID, asse
imageRepoTx := repository.NewImageRepository(tx)
if exists {
if err := imageRepoTx.UpdateStatus(ctx, tenantID, assetID, "active"); err != nil {
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, assetID, "upload_failed"); err != nil {
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)
@@ -680,7 +709,7 @@ WHERE tenant_id = $1 AND image_asset_id = $2
imageRepoTx := repository.NewImageRepository(tx)
usageRepoTx := repository.NewImageUsageRepository(tx)
if err := imageRepoTx.MarkDeleted(ctx, tenantID, assetID); err != nil {
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 {
@@ -725,10 +754,15 @@ func (s *ImageService) enqueueFinalizeDeleteTask(tenantID, assetID int64) error
}
func (s *ImageService) loadImageAsset(ctx context.Context, tenantID, assetID int64) (*repository.ImageAsset, error) {
return repository.NewImageRepository(s.pool).Get(ctx, tenantID, assetID)
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
@@ -739,11 +773,11 @@ func (s *ImageService) lockImageAsset(ctx context.Context, tx pgx.Tx, tenantID,
var asset lockedImageAsset
var deletedAt *time.Time
err := tx.QueryRow(ctx, `
SELECT object_key, size_bytes, status, deleted_at
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.ObjectKey, &asset.SizeBytes, &asset.Status, &deletedAt)
`, 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
@@ -754,14 +788,26 @@ FOR UPDATE
return &asset, true, nil
}
func (s *ImageService) ensureFolderExists(ctx context.Context, tenantID, folderID int64) error {
func (s *ImageService) ensureFolderExists(ctx context.Context, tenantID, brandID, folderID int64) error {
folderRepo := repository.NewImageFolderRepository(s.pool)
if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil {
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, `