feat: add image upload functionality for articles

- Implemented image upload API in the article service, allowing users to upload images associated with articles.
- Added validation for image size and type, ensuring only supported formats (PNG, JPG, GIF, WebP) are accepted.
- Created a new Aliyun object storage client to handle image storage and retrieval.
- Updated the article handler to include an endpoint for image uploads.
- Enhanced error handling for image upload failures and added relevant error messages.
- Introduced public URL generation for stored images, allowing access via signed tokens.
- Updated localization files to include new messages related to image upload functionality.
- Added tests for image upload and retrieval processes.
This commit is contained in:
2026-04-05 22:10:05 +08:00
parent 1401b50556
commit 94f7186cce
19 changed files with 1033 additions and 14 deletions
+119 -4
View File
@@ -3,26 +3,43 @@ package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
"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"
)
type ArticleService struct {
pool *pgxpool.Pool
auditLogs *auditlog.AsyncWriter
pool *pgxpool.Pool
auditLogs *auditlog.AsyncWriter
objectStorage objectstorage.Client
}
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *ArticleService {
return &ArticleService{pool: pool, auditLogs: auditLogs}
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
return &ArticleService{pool: pool, auditLogs: auditLogs, objectStorage: objectStorage}
}
const maxArticleImageSizeBytes = 10 * 1024 * 1024
var supportedArticleImageExtensions = map[string]string{
"image/gif": "gif",
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
type ArticleListParams struct {
@@ -303,6 +320,13 @@ type UpdateArticleRequest struct {
Platforms []string `json:"platforms"`
}
type ArticleImageUploadResponse struct {
URL string `json:"url"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticleRequest) (*ArticleDetailResponse, error) {
actor := auth.MustActor(ctx)
@@ -383,6 +407,54 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
return s.Detail(ctx, id)
}
func (s *ArticleService) UploadImage(
ctx context.Context,
articleID int64,
fileName string,
content []byte,
) (*ArticleImageUploadResponse, error) {
actor := auth.MustActor(ctx)
if err := s.objectStorage.Validate(); err != nil {
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
if len(content) == 0 {
return nil, response.ErrBadRequest(40016, "invalid_image", "image file is required")
}
if len(content) > maxArticleImageSizeBytes {
return nil, response.ErrBadRequest(40017, "article_image_too_large", "image size cannot exceed 10 MB")
}
contentType := http.DetectContentType(content)
resolvedExt, ok := supportedArticleImageExtensions[contentType]
if !ok {
return nil, response.ErrBadRequest(40018, "article_image_type_not_supported", "only PNG, JPG, GIF, and WebP images are supported")
}
generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID)
if err != nil {
return nil, err
}
if generateStatus != "completed" {
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
}
objectKey := buildArticleImageObjectKey(actor.TenantID, articleID, fileName, resolvedExt)
if err := s.objectStorage.PutBytes(ctx, objectKey, content, contentType); 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(50013, "article_image_upload_failed", "failed to upload article image")
}
return &ArticleImageUploadResponse{
URL: "",
ObjectKey: objectKey,
ContentType: contentType,
SizeBytes: int64(len(content)),
}, nil
}
type VersionItem struct {
ID int64 `json:"id"`
VersionNo int `json:"version_no"`
@@ -466,6 +538,49 @@ func countArticleWords(input string) int {
return contentstats.CountWords(input)
}
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) {
var generateStatus string
if err := s.pool.QueryRow(ctx, `
SELECT generate_status
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, articleID, tenantID).Scan(&generateStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", response.ErrNotFound(40411, "article_not_found", "article not found")
}
return "", response.ErrInternal(50013, "article_lookup_failed", "failed to query article state")
}
return generateStatus, nil
}
func buildArticleImageObjectKey(tenantID int64, articleID int64, fileName string, fallbackExt string) string {
now := time.Now().UTC()
extension := normalizeArticleImageExtension(fileName, fallbackExt)
return fmt.Sprintf(
"tenants/%d/articles/%d/images/%04d/%02d/%02d/%s.%s",
tenantID,
articleID,
now.Year(),
int(now.Month()),
now.Day(),
uuid.NewString(),
extension,
)
}
func normalizeArticleImageExtension(fileName string, fallbackExt string) string {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))), ".")
if extension != "" {
switch extension {
case "jpg", "jpeg":
return "jpg"
case "png", "gif", "webp":
return extension
}
}
return fallbackExt
}
func resolveWordCount(markdown *string, stored int) int {
if markdown != nil {
if counted := countArticleWords(*markdown); counted > 0 {