685 lines
24 KiB
Go
685 lines
24 KiB
Go
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"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
)
|
|
|
|
type ArticleService struct {
|
|
pool *pgxpool.Pool
|
|
auditLogs *auditlog.AsyncWriter
|
|
objectStorage objectstorage.Client
|
|
}
|
|
|
|
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 {
|
|
Page int
|
|
PageSize int
|
|
GenerateStatus *string
|
|
PublishStatus *string
|
|
SourceType *string
|
|
GenerationMode *string
|
|
TemplateID *int64
|
|
PromptRuleID *int64
|
|
Keyword *string
|
|
CreatedFrom *time.Time
|
|
CreatedTo *time.Time
|
|
}
|
|
|
|
type ArticleListItem struct {
|
|
ID int64 `json:"id"`
|
|
SourceType string `json:"source_type"`
|
|
TemplateID *int64 `json:"template_id"`
|
|
TemplateName *string `json:"template_name"`
|
|
PromptRuleID *int64 `json:"prompt_rule_id"`
|
|
PromptRuleName *string `json:"prompt_rule_name"`
|
|
GenerationMode *string `json:"generation_mode"`
|
|
Platforms []string `json:"platforms"`
|
|
GenerateStatus string `json:"generate_status"`
|
|
PublishStatus string `json:"publish_status"`
|
|
Title *string `json:"title"`
|
|
GenerationErrorMessage *string `json:"generation_error_message"`
|
|
WordCount int `json:"word_count"`
|
|
SourceLabel *string `json:"source_label"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type ArticleListResponse struct {
|
|
Items []ArticleListItem `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
}
|
|
|
|
func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*ArticleListResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
sourceType := params.SourceType
|
|
|
|
if params.Page < 1 {
|
|
params.Page = 1
|
|
}
|
|
if params.PageSize < 1 || params.PageSize > 100 {
|
|
params.PageSize = 20
|
|
}
|
|
offset := (params.Page - 1) * params.PageSize
|
|
|
|
// Count
|
|
var total int64
|
|
countArgs := []interface{}{actor.TenantID}
|
|
countQuery := `SELECT COUNT(*) FROM articles a
|
|
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
|
LEFT JOIN prompt_rules pr ON pr.id = a.prompt_rule_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT input_params_json
|
|
FROM generation_tasks
|
|
WHERE article_id = a.id
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
) gt ON true
|
|
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
|
argIdx := 2
|
|
|
|
if params.GenerateStatus != nil {
|
|
countQuery += ` AND a.generate_status = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.GenerateStatus)
|
|
argIdx++
|
|
}
|
|
if params.PublishStatus != nil {
|
|
countQuery += ` AND a.publish_status = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.PublishStatus)
|
|
argIdx++
|
|
}
|
|
if sourceType != nil {
|
|
countQuery += ` AND a.source_type = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *sourceType)
|
|
argIdx++
|
|
}
|
|
if params.GenerationMode != nil {
|
|
countQuery += ` AND a.source_type = 'custom_generation' AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), 'instant') = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.GenerationMode)
|
|
argIdx++
|
|
}
|
|
if params.TemplateID != nil {
|
|
countQuery += ` AND a.template_id = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.TemplateID)
|
|
argIdx++
|
|
}
|
|
if params.PromptRuleID != nil {
|
|
countQuery += ` AND a.prompt_rule_id = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.PromptRuleID)
|
|
argIdx++
|
|
}
|
|
if params.Keyword != nil {
|
|
countQuery += ` AND COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'`
|
|
countArgs = append(countArgs, *params.Keyword)
|
|
argIdx++
|
|
}
|
|
if params.CreatedFrom != nil {
|
|
countQuery += ` AND a.created_at >= $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.CreatedFrom)
|
|
argIdx++
|
|
}
|
|
if params.CreatedTo != nil {
|
|
countQuery += ` AND a.created_at <= $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.CreatedTo)
|
|
argIdx++
|
|
}
|
|
|
|
if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
|
|
return nil, response.ErrInternal(50010, "query_failed", "failed to count articles")
|
|
}
|
|
|
|
// List
|
|
listQuery := `SELECT a.id, a.source_type, a.template_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at,
|
|
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json
|
|
FROM articles a
|
|
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
|
LEFT JOIN article_templates t ON t.id = a.template_id
|
|
LEFT JOIN prompt_rules pr ON pr.id = a.prompt_rule_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT error_message, input_params_json
|
|
FROM generation_tasks
|
|
WHERE article_id = a.id
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
) gt ON true
|
|
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
|
listArgs := []interface{}{actor.TenantID}
|
|
listIdx := 2
|
|
|
|
if params.GenerateStatus != nil {
|
|
listQuery += ` AND a.generate_status = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.GenerateStatus)
|
|
listIdx++
|
|
}
|
|
if params.PublishStatus != nil {
|
|
listQuery += ` AND a.publish_status = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.PublishStatus)
|
|
listIdx++
|
|
}
|
|
if sourceType != nil {
|
|
listQuery += ` AND a.source_type = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *sourceType)
|
|
listIdx++
|
|
}
|
|
if params.GenerationMode != nil {
|
|
listQuery += ` AND a.source_type = 'custom_generation' AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), 'instant') = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.GenerationMode)
|
|
listIdx++
|
|
}
|
|
if params.TemplateID != nil {
|
|
listQuery += ` AND a.template_id = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.TemplateID)
|
|
listIdx++
|
|
}
|
|
if params.PromptRuleID != nil {
|
|
listQuery += ` AND a.prompt_rule_id = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.PromptRuleID)
|
|
listIdx++
|
|
}
|
|
if params.Keyword != nil {
|
|
listQuery += ` AND COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'`
|
|
listArgs = append(listArgs, *params.Keyword)
|
|
listIdx++
|
|
}
|
|
if params.CreatedFrom != nil {
|
|
listQuery += ` AND a.created_at >= $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.CreatedFrom)
|
|
listIdx++
|
|
}
|
|
if params.CreatedTo != nil {
|
|
listQuery += ` AND a.created_at <= $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.CreatedTo)
|
|
listIdx++
|
|
}
|
|
|
|
listQuery += ` ORDER BY a.created_at DESC LIMIT $` + strconv.Itoa(listIdx) + ` OFFSET $` + strconv.Itoa(listIdx+1)
|
|
listArgs = append(listArgs, params.PageSize, offset)
|
|
|
|
rows, err := s.pool.Query(ctx, listQuery, listArgs...)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50010, "query_failed", "failed to list articles")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []ArticleListItem
|
|
for rows.Next() {
|
|
var item ArticleListItem
|
|
var dbSourceType string
|
|
var wizardStateJSON []byte
|
|
var inputParamsJSON []byte
|
|
if err := rows.Scan(&item.ID, &dbSourceType, &item.TemplateID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
|
&item.Title, &item.GenerationErrorMessage, &item.WordCount, &item.SourceLabel, &item.TemplateName, &item.PromptRuleName, &wizardStateJSON, &inputParamsJSON); err != nil {
|
|
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
|
}
|
|
item.SourceType = dbSourceType
|
|
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
|
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
|
items = append(items, item)
|
|
}
|
|
if items == nil {
|
|
items = []ArticleListItem{}
|
|
}
|
|
|
|
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
|
|
}
|
|
|
|
type ArticleDetailResponse struct {
|
|
ID int64 `json:"id"`
|
|
SourceType string `json:"source_type"`
|
|
TemplateID *int64 `json:"template_id"`
|
|
TemplateName *string `json:"template_name"`
|
|
GenerationMode *string `json:"generation_mode"`
|
|
Platforms []string `json:"platforms"`
|
|
CoverAssetURL *string `json:"cover_asset_url"`
|
|
GenerateStatus string `json:"generate_status"`
|
|
PublishStatus string `json:"publish_status"`
|
|
Title *string `json:"title"`
|
|
HTMLContent *string `json:"html_content"`
|
|
MarkdownContent *string `json:"markdown_content"`
|
|
WordCount int `json:"word_count"`
|
|
SourceLabel *string `json:"source_label"`
|
|
VersionNo *int `json:"version_no"`
|
|
GenerationErrorMessage *string `json:"generation_error_message"`
|
|
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
var d ArticleDetailResponse
|
|
var dbSourceType string
|
|
var currentVersionID *int64
|
|
|
|
var wizardStateJSON []byte
|
|
var inputParamsJSON []byte
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT a.id, a.source_type, a.template_id, a.current_version_id, a.generate_status, a.publish_status, a.created_at,
|
|
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), COALESCE(av.word_count, 0), av.source_label, av.version_no,
|
|
t.template_name, gt.error_message, a.wizard_state_json, gt.input_params_json
|
|
FROM articles a
|
|
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
|
LEFT JOIN article_templates t ON t.id = a.template_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT error_message, input_params_json
|
|
FROM generation_tasks
|
|
WHERE article_id = a.id
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
) gt ON true
|
|
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
|
|
`, id, actor.TenantID).Scan(&d.ID, &dbSourceType, &d.TemplateID, ¤tVersionID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
|
|
&d.Title, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
|
if err != nil {
|
|
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
|
}
|
|
d.SourceType = dbSourceType
|
|
if currentVersionID != nil {
|
|
content, err := repository.LoadArticleVersionContent(ctx, s.pool, d.ID, *currentVersionID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
|
|
}
|
|
d.HTMLContent = content.HTMLContent
|
|
d.MarkdownContent = content.MarkdownContent
|
|
}
|
|
if len(wizardStateJSON) > 0 {
|
|
d.WizardState = map[string]interface{}{}
|
|
_ = json.Unmarshal(wizardStateJSON, &d.WizardState)
|
|
}
|
|
d.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
|
d.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
|
d.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
|
d.WordCount = resolveWordCount(d.MarkdownContent, d.WordCount)
|
|
return &d, nil
|
|
}
|
|
|
|
type CreateArticleRequest struct {
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (*ArticleDetailResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
title := strings.TrimSpace(req.Title)
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50014, "create_failed", "failed to begin article creation transaction")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var articleID int64
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO articles (tenant_id, source_type, template_id, generate_status, publish_status)
|
|
VALUES ($1, 'free_create', NULL, 'completed', 'unpublished')
|
|
RETURNING id
|
|
`, actor.TenantID).Scan(&articleID); err != nil {
|
|
return nil, response.ErrInternal(50014, "create_failed", "failed to create article")
|
|
}
|
|
|
|
sourceLabel := "自由创作"
|
|
articleRepo := repository.NewArticleRepository(tx)
|
|
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
|
ArticleID: articleID,
|
|
VersionNo: 1,
|
|
Title: title,
|
|
HTMLContent: "",
|
|
MarkdownContent: "",
|
|
WordCount: 0,
|
|
SourceLabel: sourceLabel,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50014, "create_failed", "failed to create article version")
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE articles SET current_version_id = $1, updated_at = NOW()
|
|
WHERE id = $2 AND tenant_id = $3
|
|
`, versionID, articleID, actor.TenantID); err != nil {
|
|
return nil, response.ErrInternal(50014, "create_failed", "failed to link article version")
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, response.ErrInternal(50014, "create_failed", "failed to commit article creation")
|
|
}
|
|
|
|
return s.Detail(ctx, articleID)
|
|
}
|
|
|
|
type UpdateArticleRequest struct {
|
|
Title string `json:"title"`
|
|
MarkdownContent string `json:"markdown_content"`
|
|
Platforms []string `json:"platforms"`
|
|
CoverAssetURL *string `json:"cover_asset_url"`
|
|
}
|
|
|
|
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)
|
|
|
|
title := strings.TrimSpace(req.Title)
|
|
if title == "" {
|
|
return nil, response.ErrBadRequest(40012, "article_title_required", "article title is required")
|
|
}
|
|
|
|
markdown := strings.TrimSpace(req.MarkdownContent)
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50012, "update_failed", "failed to begin article update transaction")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var generateStatus string
|
|
var wizardStateJSON []byte
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT generate_status, wizard_state_json
|
|
FROM articles
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
FOR UPDATE
|
|
`, id, actor.TenantID).Scan(&generateStatus, &wizardStateJSON)
|
|
if err != nil {
|
|
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
|
}
|
|
|
|
if generateStatus != "completed" {
|
|
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
|
|
}
|
|
|
|
var nextVersionNo int
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT COALESCE(MAX(version_no), 0) + 1
|
|
FROM article_versions
|
|
WHERE article_id = $1
|
|
`, id).Scan(&nextVersionNo); err != nil {
|
|
return nil, response.ErrInternal(50012, "update_failed", "failed to resolve next article version")
|
|
}
|
|
|
|
wordCount := countArticleWords(markdown)
|
|
sourceLabel := "手动编辑"
|
|
nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms, req.CoverAssetURL)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
|
|
}
|
|
|
|
articleRepo := repository.NewArticleRepository(tx)
|
|
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
|
ArticleID: id,
|
|
VersionNo: nextVersionNo,
|
|
Title: title,
|
|
HTMLContent: "",
|
|
MarkdownContent: markdown,
|
|
WordCount: wordCount,
|
|
SourceLabel: sourceLabel,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50012, "update_failed", "failed to create article version")
|
|
}
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE articles
|
|
SET current_version_id = $1,
|
|
generate_status = 'completed',
|
|
publish_status = 'unpublished',
|
|
wizard_state_json = $4,
|
|
updated_at = NOW()
|
|
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
|
`, versionID, id, actor.TenantID, nextWizardStateJSON)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50012, "update_failed", "failed to update article")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, response.ErrInternal(50012, "update_failed", "failed to commit article update")
|
|
}
|
|
|
|
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"`
|
|
Title *string `json:"title"`
|
|
WordCount int `json:"word_count"`
|
|
SourceLabel *string `json:"source_label"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]VersionItem, error) {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
// Verify ownership
|
|
var exists bool
|
|
_ = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)`,
|
|
articleID, actor.TenantID).Scan(&exists)
|
|
if !exists {
|
|
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, version_no, title, word_count, source_label, created_at
|
|
FROM article_versions WHERE article_id = $1 ORDER BY version_no DESC
|
|
`, articleID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50010, "query_failed", "failed to list versions")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var versions []VersionItem
|
|
for rows.Next() {
|
|
var v VersionItem
|
|
if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &v.WordCount, &v.SourceLabel, &v.CreatedAt); err != nil {
|
|
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
|
}
|
|
versions = append(versions, v)
|
|
}
|
|
if versions == nil {
|
|
versions = []VersionItem{}
|
|
}
|
|
return versions, nil
|
|
}
|
|
|
|
func (s *ArticleService) Delete(ctx context.Context, id int64) error {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
// Read before_json for audit
|
|
var beforeJSON []byte
|
|
_ = 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, `
|
|
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")
|
|
}
|
|
|
|
result := "success"
|
|
resourceType := "article"
|
|
requestID := middleware.RequestIDFromContext(ctx)
|
|
s.auditLogs.Log(auditlog.Entry{
|
|
OperatorID: actor.UserID,
|
|
TenantID: &actor.TenantID,
|
|
Module: "article",
|
|
Action: "delete",
|
|
ResourceType: &resourceType,
|
|
ResourceID: &id,
|
|
RequestID: nilIfEmptyString(requestID),
|
|
BeforeJSON: json.RawMessage(beforeJSON),
|
|
Result: &result,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
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 {
|
|
return counted
|
|
}
|
|
}
|
|
return stored
|
|
}
|
|
|
|
func resolveArticleGenerationMode(sourceType string, inputParamsJSON []byte) *string {
|
|
if sourceType != "custom_generation" {
|
|
return nil
|
|
}
|
|
|
|
if len(inputParamsJSON) > 0 {
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(inputParamsJSON, &payload); err == nil {
|
|
if mode := strings.TrimSpace(extractString(payload, "generation_mode")); mode != "" {
|
|
return &mode
|
|
}
|
|
}
|
|
}
|
|
|
|
mode := "instant"
|
|
return &mode
|
|
}
|
|
|
|
func nilIfEmptyString(value string) *string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|