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 {
|
||||
|
||||
Reference in New Issue
Block a user