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.
This commit is contained in:
@@ -1,20 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/sync/singleflight"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
@@ -106,6 +113,7 @@ type ArticleDetailResponse struct {
|
||||
GenerationMode *string `json:"generation_mode"`
|
||||
Platforms []string `json:"platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
@@ -345,6 +353,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
|
||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
||||
item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount)
|
||||
return &item, true, nil
|
||||
}
|
||||
@@ -404,10 +413,12 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
|
||||
}
|
||||
|
||||
type UpdateArticleRequest struct {
|
||||
Title string `json:"title"`
|
||||
MarkdownContent string `json:"markdown_content"`
|
||||
Platforms []string `json:"platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
Title string `json:"title"`
|
||||
MarkdownContent string `json:"markdown_content"`
|
||||
Platforms []string `json:"platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"`
|
||||
CoverImageAssetID NullableInt64Input `json:"cover_image_asset_id"`
|
||||
}
|
||||
|
||||
type ArticleImageUploadResponse struct {
|
||||
@@ -426,6 +437,14 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
}
|
||||
|
||||
markdown := strings.TrimSpace(req.MarkdownContent)
|
||||
referencedImageAssetIDs, err := normalizeImageAssetIDs(req.ReferencedImageAssetIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
coverImageAssetID, err := normalizeOptionalImageAssetID(req.CoverImageAssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -449,6 +468,11 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
|
||||
}
|
||||
|
||||
imageAssetIDsToValidate := collectImageAssetIDsForValidation(referencedImageAssetIDs, coverImageAssetID)
|
||||
if err := s.ensureActiveImageAssets(ctx, tx, actor.TenantID, imageAssetIDsToValidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var nextVersionNo int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(version_no), 0) + 1
|
||||
@@ -460,7 +484,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
|
||||
wordCount := countArticleWords(markdown)
|
||||
sourceLabel := "手动编辑"
|
||||
nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms, req.CoverAssetURL)
|
||||
nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms, req.CoverAssetURL, req.CoverImageAssetID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
|
||||
}
|
||||
@@ -495,6 +519,28 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
|
||||
imageRefRepo := repository.NewImageReferenceRepository(tx)
|
||||
if req.ReferencedImageAssetIDs != nil {
|
||||
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_body"); err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
|
||||
}
|
||||
for _, imageAssetID := range referencedImageAssetIDs {
|
||||
if err := imageRefRepo.Upsert(ctx, actor.TenantID, imageAssetID, id, "article_body"); err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.CoverImageAssetID.Set {
|
||||
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_cover"); err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article cover reference")
|
||||
}
|
||||
if coverImageAssetID != nil {
|
||||
if err := imageRefRepo.Upsert(ctx, actor.TenantID, *coverImageAssetID, id, "article_cover"); err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article cover reference")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to commit article update")
|
||||
}
|
||||
@@ -526,6 +572,19 @@ func (s *ArticleService) UploadImage(
|
||||
if !ok {
|
||||
return nil, response.ErrBadRequest(40018, "article_image_type_not_supported", "only PNG, JPG, GIF, and WebP images are supported")
|
||||
}
|
||||
imageConfig, _, err := image.DecodeConfig(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40016, "invalid_image", "image file cannot be decoded")
|
||||
}
|
||||
if imageConfig.Width <= 0 || imageConfig.Height <= 0 {
|
||||
return nil, response.ErrBadRequest(40016, "invalid_image", "image file cannot be decoded")
|
||||
}
|
||||
if imageConfig.Width > maxImageDimension || imageConfig.Height > maxImageDimension {
|
||||
return nil, response.ErrBadRequest(40018, "article_image_dimensions_too_large", "image dimensions exceed the maximum allowed size")
|
||||
}
|
||||
if int64(imageConfig.Width)*int64(imageConfig.Height) > maxImageDecodePixels {
|
||||
return nil, response.ErrBadRequest(40018, "article_image_dimensions_too_large", "image pixel count exceeds the maximum allowed")
|
||||
}
|
||||
|
||||
generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID)
|
||||
if err != nil {
|
||||
@@ -535,8 +594,25 @@ func (s *ArticleService) UploadImage(
|
||||
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
|
||||
}
|
||||
|
||||
storedContent := content
|
||||
storedContentType := contentType
|
||||
if contentType != "image/webp" {
|
||||
decodedImage, _, decodeErr := image.Decode(bytes.NewReader(content))
|
||||
if decodeErr != nil {
|
||||
return nil, response.ErrBadRequest(40016, "invalid_image", "image file cannot be decoded")
|
||||
}
|
||||
|
||||
var webpBuffer bytes.Buffer
|
||||
if encodeErr := webp.Encode(&webpBuffer, decodedImage, &webp.Options{Quality: float32(webpEncodeQuality)}); encodeErr != nil {
|
||||
return nil, response.ErrInternal(50013, "article_image_encode_failed", "failed to encode article image")
|
||||
}
|
||||
storedContent = webpBuffer.Bytes()
|
||||
storedContentType = "image/webp"
|
||||
resolvedExt = "webp"
|
||||
}
|
||||
|
||||
objectKey := buildArticleImageObjectKey(actor.TenantID, articleID, fileName, resolvedExt)
|
||||
if err := s.objectStorage.PutBytes(ctx, objectKey, content, contentType); err != nil {
|
||||
if err := s.objectStorage.PutBytes(ctx, objectKey, storedContent, storedContentType); err != nil {
|
||||
if errors.Is(err, objectstorage.ErrNotConfigured) {
|
||||
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
@@ -546,8 +622,8 @@ func (s *ArticleService) UploadImage(
|
||||
return &ArticleImageUploadResponse{
|
||||
URL: "",
|
||||
ObjectKey: objectKey,
|
||||
ContentType: contentType,
|
||||
SizeBytes: int64(len(content)),
|
||||
ContentType: storedContentType,
|
||||
SizeBytes: int64(len(storedContent)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -602,13 +678,25 @@ func (s *ArticleService) Delete(ctx context.Context, id int64) error {
|
||||
_ = s.pool.QueryRow(ctx, `SELECT row_to_json(a) FROM articles a WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||
id, actor.TenantID).Scan(&beforeJSON)
|
||||
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return response.ErrInternal(50010, "delete_failed", "failed to begin article delete transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE articles SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
if err := repository.NewImageReferenceRepository(tx).DeleteByArticle(ctx, actor.TenantID, id); err != nil {
|
||||
return response.ErrInternal(50010, "delete_failed", "failed to delete article image references")
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return response.ErrInternal(50010, "delete_failed", "failed to commit article delete")
|
||||
}
|
||||
|
||||
result := "success"
|
||||
resourceType := "article"
|
||||
@@ -633,6 +721,97 @@ func countArticleWords(input string) int {
|
||||
return contentstats.CountWords(input)
|
||||
}
|
||||
|
||||
func normalizeImageAssetIDs(ids []int64) ([]int64, error) {
|
||||
if ids == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
normalized := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
return nil, response.ErrBadRequest(40019, "invalid_image_asset_ids", "image asset ids must be positive integers")
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
normalized = append(normalized, id)
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return []int64{}, nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeOptionalImageAssetID(input NullableInt64Input) (*int64, error) {
|
||||
if !input.Set || input.Value == nil {
|
||||
return input.Value, nil
|
||||
}
|
||||
if *input.Value <= 0 {
|
||||
return nil, response.ErrBadRequest(40020, "invalid_cover_image_asset_id", "cover_image_asset_id must be a positive integer or null")
|
||||
}
|
||||
return input.Value, nil
|
||||
}
|
||||
|
||||
func collectImageAssetIDsForValidation(bodyImageIDs []int64, coverImageAssetID *int64) []int64 {
|
||||
if len(bodyImageIDs) == 0 && coverImageAssetID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(bodyImageIDs)+1)
|
||||
result := make([]int64, 0, len(bodyImageIDs)+1)
|
||||
for _, id := range bodyImageIDs {
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
if coverImageAssetID != nil {
|
||||
if _, exists := seen[*coverImageAssetID]; !exists {
|
||||
result = append(result, *coverImageAssetID)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ArticleService) ensureActiveImageAssets(ctx context.Context, tx pgx.Tx, tenantID int64, imageAssetIDs []int64) error {
|
||||
if len(imageAssetIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id
|
||||
FROM image_assets
|
||||
WHERE tenant_id = $1
|
||||
AND id = ANY($2::BIGINT[])
|
||||
AND status = 'active'
|
||||
AND deleted_at IS NULL
|
||||
`, tenantID, imageAssetIDs)
|
||||
if err != nil {
|
||||
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
validIDs := make(map[int64]struct{}, len(imageAssetIDs))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
||||
}
|
||||
validIDs[id] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
||||
}
|
||||
if len(validIDs) != len(imageAssetIDs) {
|
||||
return response.ErrBadRequest(40021, "invalid_image_asset_ids", "one or more image assets are invalid or inactive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) {
|
||||
var generateStatus string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
@@ -664,6 +843,11 @@ func buildArticleImageObjectKey(tenantID int64, articleID int64, fileName string
|
||||
}
|
||||
|
||||
func normalizeArticleImageExtension(fileName string, fallbackExt string) string {
|
||||
fallbackExt = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(fallbackExt)), ".")
|
||||
if fallbackExt == "webp" {
|
||||
return "webp"
|
||||
}
|
||||
|
||||
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))), ".")
|
||||
if extension != "" {
|
||||
switch extension {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
)
|
||||
|
||||
const (
|
||||
imageAssetTaskKindFinalizeUpload = "finalize_upload"
|
||||
imageAssetTaskKindFinalizeDelete = "finalize_delete"
|
||||
)
|
||||
|
||||
type imageAssetTaskEnvelope struct {
|
||||
Kind string `json:"kind"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
AssetID int64 `json:"asset_id"`
|
||||
}
|
||||
|
||||
type ImageAssetTaskEnvelope struct {
|
||||
Kind string `json:"kind"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
AssetID int64 `json:"asset_id"`
|
||||
}
|
||||
|
||||
func publishImageAssetTask(ctx context.Context, client *rabbitmq.Client, envelope imageAssetTaskEnvelope) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("rabbitmq client is not configured")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal image asset task envelope: %w", err)
|
||||
}
|
||||
|
||||
if err := client.PublishImageAssetTask(ctx, payload); err != nil {
|
||||
return fmt.Errorf("publish image asset task: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeImageAssetTaskEnvelope(payload []byte) (*imageAssetTaskEnvelope, error) {
|
||||
var envelope imageAssetTaskEnvelope
|
||||
if err := json.Unmarshal(payload, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode image asset task envelope: %w", err)
|
||||
}
|
||||
if envelope.TenantID <= 0 || envelope.AssetID <= 0 {
|
||||
return nil, fmt.Errorf("image asset task envelope is incomplete")
|
||||
}
|
||||
switch envelope.Kind {
|
||||
case imageAssetTaskKindFinalizeUpload, imageAssetTaskKindFinalizeDelete:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported image asset task kind %q", envelope.Kind)
|
||||
}
|
||||
return &envelope, nil
|
||||
}
|
||||
|
||||
func DecodeImageAssetTaskEnvelope(payload []byte) (*ImageAssetTaskEnvelope, error) {
|
||||
envelope, err := decodeImageAssetTaskEnvelope(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ImageAssetTaskEnvelope{
|
||||
Kind: envelope.Kind,
|
||||
TenantID: envelope.TenantID,
|
||||
AssetID: envelope.AssetID,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultImageAssetWorkerRetryInterval = 5 * time.Second
|
||||
defaultImageAssetWorkerProcessTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
var errImageAssetConsumerClosed = errors.New("image asset consumer channel closed")
|
||||
|
||||
type ImageAssetWorker struct {
|
||||
rabbitMQ *rabbitmq.Client
|
||||
service *ImageService
|
||||
logger *zap.Logger
|
||||
retryInterval time.Duration
|
||||
processTimeout time.Duration
|
||||
consumerPrefix string
|
||||
concurrency int
|
||||
}
|
||||
|
||||
func NewImageAssetWorker(service *ImageService, rabbitMQClient *rabbitmq.Client, logger *zap.Logger) *ImageAssetWorker {
|
||||
return &ImageAssetWorker{
|
||||
rabbitMQ: rabbitMQClient,
|
||||
service: service,
|
||||
logger: logger,
|
||||
retryInterval: defaultImageAssetWorkerRetryInterval,
|
||||
processTimeout: defaultImageAssetWorkerProcessTimeout,
|
||||
consumerPrefix: fmt.Sprintf("tenant-api-image-asset-%d", os.Getpid()),
|
||||
concurrency: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ImageAssetWorker) Start(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
for i := 0; i < w.concurrency; i++ {
|
||||
consumerName := fmt.Sprintf("%s-%d", w.consumerPrefix, i+1)
|
||||
go w.run(ctx, consumerName)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ImageAssetWorker) run(ctx context.Context, consumerName string) {
|
||||
if w.rabbitMQ == nil || w.service == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
if err := w.consumeOnce(ctx, consumerName); err != nil && ctx.Err() == nil {
|
||||
if w.logger != nil {
|
||||
if errors.Is(err, errImageAssetConsumerClosed) {
|
||||
w.logger.Info("image asset consumer channel closed, retrying",
|
||||
zap.String("consumer", consumerName),
|
||||
)
|
||||
} else {
|
||||
w.logger.Warn("image asset worker stopped unexpectedly",
|
||||
zap.Error(err),
|
||||
zap.String("consumer", consumerName),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(w.retryInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ImageAssetWorker) consumeOnce(ctx context.Context, consumerName string) error {
|
||||
deliveries, ch, err := w.rabbitMQ.ConsumeImageAssetTask(consumerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case delivery, ok := <-deliveries:
|
||||
if !ok {
|
||||
return errImageAssetConsumerClosed
|
||||
}
|
||||
w.handleDelivery(ctx, delivery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ImageAssetWorker) handleDelivery(parent context.Context, delivery amqp.Delivery) {
|
||||
envelope, err := decodeImageAssetTaskEnvelope(delivery.Body)
|
||||
if err != nil {
|
||||
w.rejectDelivery(delivery, false, err, 0, 0, "")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, w.processTimeout)
|
||||
defer cancel()
|
||||
|
||||
switch envelope.Kind {
|
||||
case imageAssetTaskKindFinalizeUpload:
|
||||
err = w.service.ProcessFinalizeUpload(ctx, envelope.TenantID, envelope.AssetID)
|
||||
case imageAssetTaskKindFinalizeDelete:
|
||||
err = w.service.ProcessFinalizeDelete(ctx, envelope.TenantID, envelope.AssetID)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported image asset task kind %q", envelope.Kind)
|
||||
}
|
||||
if err != nil {
|
||||
requeue := shouldRequeueImageAssetError(err, delivery.Redelivered)
|
||||
w.rejectDelivery(delivery, requeue, err, envelope.TenantID, envelope.AssetID, envelope.Kind)
|
||||
return
|
||||
}
|
||||
|
||||
if err := delivery.Ack(false); err != nil && w.logger != nil {
|
||||
w.logger.Warn("image asset ack failed",
|
||||
zap.Error(err),
|
||||
zap.Int64("tenant_id", envelope.TenantID),
|
||||
zap.Int64("asset_id", envelope.AssetID),
|
||||
zap.String("kind", envelope.Kind),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ImageAssetWorker) rejectDelivery(delivery amqp.Delivery, requeue bool, err error, tenantID, assetID int64, kind string) {
|
||||
if nackErr := delivery.Nack(false, requeue); nackErr != nil && w.logger != nil {
|
||||
w.logger.Warn("image asset nack failed",
|
||||
zap.Error(nackErr),
|
||||
zap.Int64("tenant_id", tenantID),
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("kind", kind),
|
||||
)
|
||||
}
|
||||
if w.logger != nil {
|
||||
w.logger.Warn("image asset processing failed",
|
||||
zap.Error(err),
|
||||
zap.Bool("requeue", requeue),
|
||||
zap.Bool("redelivered", delivery.Redelivered),
|
||||
zap.Int64("tenant_id", tenantID),
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("kind", kind),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRequeueImageAssetError(err error, redelivered bool) bool {
|
||||
if redelivered {
|
||||
return false
|
||||
}
|
||||
|
||||
appErr := response.Normalize(err)
|
||||
return appErr.HTTPStatus >= 500
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
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
|
||||
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...)
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
|
||||
}
|
||||
|
||||
inputJSON, _ := json.Marshal(inputParams)
|
||||
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs, nil)
|
||||
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs, nil, NullableInt64Input{})
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -86,6 +87,14 @@ func resolveArticleCoverAssetURL(wizardStateJSON []byte) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func resolveArticleCoverImageAssetID(wizardStateJSON []byte) *int64 {
|
||||
value, ok := extractInt64FromJSONPayload(wizardStateJSON, "cover_image_asset_id")
|
||||
if !ok || value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func extractStringFromJSONPayload(raw []byte, key string) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
@@ -103,6 +112,40 @@ func extractStringFromJSONPayload(raw []byte, key string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func extractInt64FromJSONPayload(raw []byte, key string) (int64, bool) {
|
||||
if len(raw) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch value := payload[key].(type) {
|
||||
case nil:
|
||||
return 0, false
|
||||
case float64:
|
||||
return int64(value), true
|
||||
case int64:
|
||||
return value, true
|
||||
case int:
|
||||
return int64(value), true
|
||||
case string:
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePlatformsFromValue(raw interface{}) []string {
|
||||
switch typed := raw.(type) {
|
||||
case nil:
|
||||
@@ -126,7 +169,7 @@ func normalizePlatformsFromValue(raw interface{}) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func mergeArticleWizardState(raw []byte, title string, platforms []string, coverAssetURL *string) ([]byte, error) {
|
||||
func mergeArticleWizardState(raw []byte, title string, platforms []string, coverAssetURL *string, coverImageAssetID NullableInt64Input) ([]byte, error) {
|
||||
state := make(map[string]interface{})
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &state)
|
||||
@@ -157,5 +200,13 @@ func mergeArticleWizardState(raw []byte, title string, platforms []string, cover
|
||||
}
|
||||
}
|
||||
|
||||
if coverImageAssetID.Set {
|
||||
if coverImageAssetID.Value == nil {
|
||||
delete(state, "cover_image_asset_id")
|
||||
} else {
|
||||
state["cover_image_asset_id"] = *coverImageAssetID.Value
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user