Files
geo/server/internal/tenant/app/image_folder_service.go
T
root 27389164b0 feat: add image management functionality
- Implemented image folder repository with CRUD operations.
- Added image reference repository for managing image references to articles.
- Created image repository for handling image assets, including listing, inserting, updating, and deleting images.
- Introduced image usage repository to track storage usage and quotas for tenants.
- Added SQL queries for image assets, folders, references, and usage.
- Developed image handler for HTTP endpoints to manage images and folders.
- Created database migration scripts for image-related tables and structures.
2026-04-16 20:40:41 +08:00

216 lines
6.3 KiB
Go

package app
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
"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"
)
type ImageFolderService struct {
pool *pgxpool.Pool
imageSvc *ImageService
}
func NewImageFolderService(pool *pgxpool.Pool, objectStorage objectstorage.Client, rabbitMQClient *rabbitmq.Client, logger *zap.Logger) *ImageFolderService {
return &ImageFolderService{
pool: pool,
imageSvc: NewImageService(pool, objectStorage, rabbitMQClient, logger),
}
}
type ImageFolderItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
ImageCount int `json:"image_count"`
CreatedAt string `json:"created_at"`
}
type FolderDeletePreview struct {
ImageCount int `json:"image_count"`
ReferencedList []ImageReferenceArticleBrief `json:"referenced_articles"`
}
type ImageReferenceArticleBrief struct {
ArticleID int64 `json:"article_id"`
Scopes []string `json:"scopes"`
}
func (s *ImageFolderService) List(ctx context.Context) ([]ImageFolderItem, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
repo := repository.NewImageFolderRepository(s.pool)
folders, err := repo.List(ctx, tenantID)
if err != nil {
return nil, err
}
items := make([]ImageFolderItem, len(folders))
for i, folder := range folders {
items[i] = ImageFolderItem{
ID: folder.ID,
Name: folder.Name,
ImageCount: folder.ImageCount,
CreatedAt: folder.CreatedAt.Format(timeLayoutRFC3339),
}
}
if items == nil {
items = []ImageFolderItem{}
}
return items, nil
}
func (s *ImageFolderService) Create(ctx context.Context, name string) (int64, error) {
name = strings.TrimSpace(name)
if err := validateImageFolderName(name); err != nil {
return 0, err
}
tenantID := middleware.TenantIDFromCtx(ctx)
repo := repository.NewImageFolderRepository(s.pool)
id, err := repo.Create(ctx, tenantID, name)
if err != nil {
if isConstraintError(err, "uq_image_folders_tenant_name") {
return 0, response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists")
}
return 0, err
}
return id, nil
}
func (s *ImageFolderService) Update(ctx context.Context, id int64, name string) error {
name = strings.TrimSpace(name)
if err := validateImageFolderName(name); err != nil {
return err
}
tenantID := middleware.TenantIDFromCtx(ctx)
repo := repository.NewImageFolderRepository(s.pool)
if _, err := repo.Get(ctx, tenantID, id); err != nil {
return mapImageFolderNotFound(err)
}
if err := repo.Update(ctx, tenantID, id, name); err != nil {
if isConstraintError(err, "uq_image_folders_tenant_name") {
return response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists")
}
return err
}
return nil
}
func (s *ImageFolderService) DeletePreview(ctx context.Context, folderID int64) (*FolderDeletePreview, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
folderRepo := repository.NewImageFolderRepository(s.pool)
if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil {
return nil, mapImageFolderNotFound(err)
}
imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, folderID)
if err != nil {
return nil, err
}
return &FolderDeletePreview{
ImageCount: imageCount,
ReferencedList: []ImageReferenceArticleBrief{},
}, nil
}
func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force bool) error {
tenantID := middleware.TenantIDFromCtx(ctx)
folderRepo := repository.NewImageFolderRepository(s.pool)
imageRepo := repository.NewImageRepository(s.pool)
if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil {
return mapImageFolderNotFound(err)
}
imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, folderID)
if err != nil {
return err
}
if imageCount == 0 {
return folderRepo.Delete(ctx, tenantID, folderID)
}
if !force {
return response.ErrConflict(40902, "folder_not_empty", "folder contains images that will be deleted with the folder")
}
if s.imageSvc != nil {
if err := s.imageSvc.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")
}
}
images, err := imageRepo.ListActiveByFolder(ctx, tenantID, folderID)
if err != nil {
return err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50047, "folder_delete_tx_begin_failed", "failed to delete folder")
}
defer func() {
_ = tx.Rollback(ctx)
}()
imageRepoTx := repository.NewImageRepository(tx)
folderRepoTx := repository.NewImageFolderRepository(tx)
if err := imageRepoTx.MarkImagesInFolderPendingDelete(ctx, tenantID, folderID); err != nil {
return response.ErrInternal(50048, "folder_image_delete_prepare_failed", "failed to prepare folder images for deletion")
}
if err := folderRepoTx.Delete(ctx, tenantID, folderID); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(50049, "folder_delete_tx_commit_failed", "failed to delete folder")
}
for _, image := range images {
if err := s.imageSvc.finalizeDeleteNowOrEnqueue(ctx, tenantID, image.ID); err != nil {
return err
}
}
return nil
}
func validateImageFolderName(name string) error {
if name == "" {
return response.ErrBadRequest(40001, "invalid_folder_name", "folder name is required")
}
if len(name) > 100 {
return response.ErrBadRequest(40002, "invalid_folder_name", "folder name must be at most 100 characters")
}
return nil
}
func mapImageFolderNotFound(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return response.ErrNotFound(40402, "image_folder_not_found", "folder not found")
}
return err
}
func isConstraintError(err error, constraint string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.ConstraintName == constraint
}