6e0519a232
- Implemented a new BrandAssetCleanupWorker to handle the cleanup of brand-related assets after a brand is deleted. - Added SQL queries for cleaning up articles, keywords, questions, competitors, and monitoring data associated with a brand. - Introduced a new API endpoint to delete publish records. - Updated the router to include the new delete publish record endpoint. - Added tests for the BrandAssetCleanupWorker to ensure proper functionality. - Created migration scripts to support soft deletion of publish records and to add the brand asset cleanup scheduler job.
1249 lines
43 KiB
Go
1249 lines
43 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"net/http"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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/image/webp"
|
|
"golang.org/x/sync/singleflight"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
|
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
|
"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/shared/stream"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
)
|
|
|
|
type ArticleService struct {
|
|
pool *pgxpool.Pool
|
|
auditLogs *auditlog.AsyncWriter
|
|
objectStorage objectstorage.Client
|
|
llm llm.Client
|
|
rabbitMQ *rabbitmq.Client
|
|
streams *stream.GenerationHub
|
|
cfg generationRuntimeConfig
|
|
configProvider runtimeConfigProvider
|
|
cache sharedcache.Cache
|
|
cacheGroup singleflight.Group
|
|
}
|
|
|
|
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
|
|
return &ArticleService{pool: pool, auditLogs: auditLogs, objectStorage: objectStorage}
|
|
}
|
|
|
|
func (s *ArticleService) WithCache(c sharedcache.Cache) *ArticleService {
|
|
s.cache = c
|
|
return s
|
|
}
|
|
|
|
func (s *ArticleService) WithGenerationRuntime(
|
|
llmClient llm.Client,
|
|
rabbitMQClient *rabbitmq.Client,
|
|
streams *stream.GenerationHub,
|
|
cfg config.GenerationConfig,
|
|
llmMaxOutputTokens int64,
|
|
) *ArticleService {
|
|
s.llm = llmClient
|
|
s.rabbitMQ = rabbitMQClient
|
|
s.streams = streams
|
|
s.cfg = generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens})
|
|
return s
|
|
}
|
|
|
|
func (s *ArticleService) WithConfigProvider(provider runtimeConfigProvider) *ArticleService {
|
|
s.configProvider = provider
|
|
return s
|
|
}
|
|
|
|
func (s *ArticleService) runtimeConfig() generationRuntimeConfig {
|
|
if s != nil && s.configProvider != nil {
|
|
if cfg := s.configProvider.Current(); cfg != nil {
|
|
return generationRuntimeFromConfig(cfg.Generation, cfg.LLM)
|
|
}
|
|
}
|
|
if s == nil {
|
|
return generationRuntimeFromConfig(config.GenerationConfig{}, config.LLMConfig{})
|
|
}
|
|
return s.cfg
|
|
}
|
|
|
|
const maxArticleImageSizeBytes = 10 * 1024 * 1024
|
|
|
|
var supportedArticleImageExtensions = map[string]string{
|
|
"image/gif": "gif",
|
|
"image/jpeg": "jpg",
|
|
"image/png": "png",
|
|
"image/webp": "webp",
|
|
}
|
|
|
|
var articleMarkdownImageAssetIDPattern = regexp.MustCompile(`data-asset-id=(?:"|')(\d+)(?:"|')`)
|
|
var articleMarkdownImageAssetAttributePattern = regexp.MustCompile(`\s*data-asset-id=(?:"|')(\d+)(?:"|')`)
|
|
|
|
const articlePublishStatusAggregateJoin = `
|
|
LEFT JOIN LATERAL (
|
|
SELECT COUNT(*) AS record_count,
|
|
BOOL_OR(latest.status_bucket = 'publishing') AS has_publishing,
|
|
BOOL_OR(latest.status_bucket = 'success') AS has_success,
|
|
BOOL_OR(latest.status_bucket = 'failed') AS has_failed
|
|
FROM (
|
|
SELECT DISTINCT ON (
|
|
COALESCE(pr.target_type, 'platform_account'),
|
|
COALESCE(pr.platform_account_id, pr.target_connection_id)
|
|
)
|
|
CASE
|
|
WHEN pr.status IN ('success', 'published', 'publish_success') THEN 'success'
|
|
WHEN pr.status IN ('publishing', 'pending', 'queued', 'running') THEN 'publishing'
|
|
ELSE 'failed'
|
|
END AS status_bucket
|
|
FROM publish_records pr
|
|
WHERE pr.tenant_id = a.tenant_id
|
|
AND pr.article_id = a.id
|
|
AND pr.deleted_at IS NULL
|
|
ORDER BY
|
|
COALESCE(pr.target_type, 'platform_account'),
|
|
COALESCE(pr.platform_account_id, pr.target_connection_id),
|
|
pr.created_at DESC,
|
|
pr.id DESC
|
|
) latest
|
|
) publish_status_summary ON true`
|
|
|
|
const articleEffectivePublishStatusExpr = `CASE
|
|
WHEN COALESCE(publish_status_summary.record_count, 0) = 0 THEN a.publish_status
|
|
WHEN COALESCE(publish_status_summary.has_publishing, false) THEN 'publishing'
|
|
WHEN COALESCE(publish_status_summary.has_success, false) AND COALESCE(publish_status_summary.has_failed, false) THEN 'partial_success'
|
|
WHEN COALESCE(publish_status_summary.has_success, false) THEN 'success'
|
|
ELSE 'failed'
|
|
END`
|
|
|
|
type ArticleListParams struct {
|
|
Page int
|
|
PageSize int
|
|
BrandID int64
|
|
GenerateStatus *string
|
|
PublishStatus *string
|
|
SourceType *string
|
|
GenerationMode *string
|
|
TemplateID *int64
|
|
KolPromptID *int64
|
|
PromptRuleID *int64
|
|
Keyword *string
|
|
CreatedFrom *time.Time
|
|
CreatedTo *time.Time
|
|
}
|
|
|
|
type ArticleListItem struct {
|
|
ID int64 `json:"id"`
|
|
BrandID *int64 `json:"brand_id"`
|
|
SourceType string `json:"source_type"`
|
|
TemplateID *int64 `json:"template_id"`
|
|
KolPromptID *int64 `json:"kol_prompt_id"`
|
|
TemplateName *string `json:"template_name"`
|
|
PromptRuleID *int64 `json:"prompt_rule_id"`
|
|
PromptRuleName *string `json:"prompt_rule_name"`
|
|
GenerationMode *string `json:"generation_mode"`
|
|
AutoPublishPlatforms []string `json:"auto_publish_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"`
|
|
SourceURL *string `json:"source_url,omitempty"`
|
|
SourceTitle *string `json:"source_title,omitempty"`
|
|
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)
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
params.BrandID = brandID
|
|
version := articleCacheVersion(ctx, s.cache, actor.TenantID)
|
|
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, articleListCacheKey(actor.TenantID, version, params), defaultCacheTTL(), func(loadCtx context.Context) (*ArticleListResponse, error) {
|
|
return s.loadArticles(loadCtx, actor.TenantID, brandID, params)
|
|
})
|
|
}
|
|
|
|
type ArticleDetailResponse struct {
|
|
ID int64 `json:"id"`
|
|
BrandID *int64 `json:"brand_id"`
|
|
SourceType string `json:"source_type"`
|
|
TemplateID *int64 `json:"template_id"`
|
|
CurrentVersionID *int64 `json:"current_version_id"`
|
|
TemplateName *string `json:"template_name"`
|
|
GenerationMode *string `json:"generation_mode"`
|
|
AutoPublishPlatforms []string `json:"auto_publish_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"`
|
|
HTMLContent *string `json:"html_content"`
|
|
MarkdownContent *string `json:"markdown_content"`
|
|
WordCount int `json:"word_count"`
|
|
SourceLabel *string `json:"source_label"`
|
|
SourceURL *string `json:"source_url,omitempty"`
|
|
SourceTitle *string `json:"source_title,omitempty"`
|
|
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)
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
version := articleCacheVersion(ctx, s.cache, actor.TenantID)
|
|
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, articleDetailCacheKey(actor.TenantID, id, version), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ArticleDetailResponse, bool, error) {
|
|
return s.loadArticleDetail(loadCtx, actor.TenantID, brandID, id)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found || record == nil {
|
|
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
func (s *ArticleService) loadArticles(ctx context.Context, tenantID, brandID int64, params ArticleListParams) (*ArticleListResponse, error) {
|
|
sourceTypes := normalizeArticleSourceTypes(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
|
|
|
|
var total int64
|
|
countArgs := []interface{}{tenantID, brandID}
|
|
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`
|
|
if params.PublishStatus != nil {
|
|
countQuery += articlePublishStatusAggregateJoin
|
|
}
|
|
countQuery += `
|
|
WHERE a.tenant_id = $1 AND a.brand_id = $2 AND a.deleted_at IS NULL`
|
|
argIdx := 3
|
|
|
|
if params.GenerateStatus != nil {
|
|
countQuery += ` AND a.generate_status = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.GenerateStatus)
|
|
argIdx++
|
|
}
|
|
if params.PublishStatus != nil {
|
|
countQuery += ` AND ` + articleEffectivePublishStatusExpr + ` = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.PublishStatus)
|
|
argIdx++
|
|
}
|
|
countQuery, countArgs, argIdx = appendArticleSourceTypeFilter(countQuery, countArgs, argIdx, sourceTypes)
|
|
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.KolPromptID != nil {
|
|
countQuery += ` AND a.kol_prompt_id = $` + strconv.Itoa(argIdx)
|
|
countArgs = append(countArgs, *params.KolPromptID)
|
|
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) + ` || '%'
|
|
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_title', ''), NULLIF(gt.input_params_json ->> 'source_title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
|
|
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_url', ''), NULLIF(gt.input_params_json ->> 'source_url', '')) 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")
|
|
}
|
|
|
|
listQuery := `SELECT a.id, a.brand_id, a.source_type, a.template_id, a.kol_prompt_id, a.prompt_rule_id, a.generate_status, ` + articleEffectivePublishStatusExpr + `, 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,
|
|
COALESCE(t.template_name, NULLIF(gt.input_params_json ->> 'prompt_name', ''), NULLIF(gt.input_params_json ->> 'package_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
|
|
` + articlePublishStatusAggregateJoin + `
|
|
WHERE a.tenant_id = $1 AND a.brand_id = $2 AND a.deleted_at IS NULL`
|
|
listArgs := []interface{}{tenantID, brandID}
|
|
listIdx := 3
|
|
|
|
if params.GenerateStatus != nil {
|
|
listQuery += ` AND a.generate_status = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.GenerateStatus)
|
|
listIdx++
|
|
}
|
|
if params.PublishStatus != nil {
|
|
listQuery += ` AND ` + articleEffectivePublishStatusExpr + ` = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.PublishStatus)
|
|
listIdx++
|
|
}
|
|
listQuery, listArgs, listIdx = appendArticleSourceTypeFilter(listQuery, listArgs, listIdx, sourceTypes)
|
|
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.KolPromptID != nil {
|
|
listQuery += ` AND a.kol_prompt_id = $` + strconv.Itoa(listIdx)
|
|
listArgs = append(listArgs, *params.KolPromptID)
|
|
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) + ` || '%'
|
|
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_title', ''), NULLIF(gt.input_params_json ->> 'source_title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
|
|
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_url', ''), NULLIF(gt.input_params_json ->> 'source_url', '')) 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()
|
|
|
|
items := make([]ArticleListItem, 0)
|
|
for rows.Next() {
|
|
var item ArticleListItem
|
|
var dbSourceType string
|
|
var wizardStateJSON []byte
|
|
var inputParamsJSON []byte
|
|
if err := rows.Scan(&item.ID, &item.BrandID, &dbSourceType, &item.TemplateID, &item.KolPromptID, &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)
|
|
autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, tenantID, inputParamsJSON)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50010, "query_failed", "failed to resolve article auto publish platforms")
|
|
}
|
|
item.AutoPublishPlatforms = autoPublishPlatforms
|
|
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
|
items = append(items, item)
|
|
}
|
|
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
|
|
}
|
|
|
|
func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, brandID, articleID int64) (*ArticleDetailResponse, bool, error) {
|
|
var item ArticleDetailResponse
|
|
var dbSourceType string
|
|
var currentVersionID *int64
|
|
var wizardStateJSON []byte
|
|
var inputParamsJSON []byte
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT a.id, a.brand_id, a.source_type, a.template_id, a.current_version_id, a.generate_status, `+articleEffectivePublishStatusExpr+`, 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
|
|
`+articlePublishStatusAggregateJoin+`
|
|
WHERE a.id = $1 AND a.tenant_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL
|
|
`, articleID, tenantID, brandID).Scan(&item.ID, &item.BrandID, &dbSourceType, &item.TemplateID, ¤tVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
|
&item.Title, &item.WordCount, &item.SourceLabel, &item.VersionNo, &item.TemplateName, &item.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, response.ErrInternal(50010, "query_failed", "failed to fetch article")
|
|
}
|
|
|
|
item.SourceType = dbSourceType
|
|
if currentVersionID != nil {
|
|
item.CurrentVersionID = currentVersionID
|
|
content, err := repository.LoadArticleVersionContent(ctx, s.pool, item.ID, *currentVersionID)
|
|
if err != nil {
|
|
return nil, false, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
|
|
}
|
|
item.HTMLContent = content.HTMLContent
|
|
item.MarkdownContent = content.MarkdownContent
|
|
}
|
|
if len(wizardStateJSON) > 0 {
|
|
item.WizardState = map[string]interface{}{}
|
|
_ = json.Unmarshal(wizardStateJSON, &item.WizardState)
|
|
}
|
|
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
|
autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, tenantID, inputParamsJSON)
|
|
if err != nil {
|
|
return nil, false, response.ErrInternal(50010, "query_failed", "failed to resolve article auto publish platforms")
|
|
}
|
|
item.AutoPublishPlatforms = autoPublishPlatforms
|
|
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
|
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
|
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
|
if item.MarkdownContent != nil || item.CoverImageAssetID != nil {
|
|
markdownValue := ""
|
|
if item.MarkdownContent != nil {
|
|
markdownValue = *item.MarkdownContent
|
|
}
|
|
sanitizedMarkdown, _, sanitizedCoverImageAssetID, err := s.resolveArticleImageMetadata(
|
|
ctx,
|
|
s.pool,
|
|
tenantID,
|
|
markdownValue,
|
|
item.CoverImageAssetID,
|
|
)
|
|
if err != nil {
|
|
return nil, false, response.ErrInternal(50012, "query_failed", "failed to resolve article image metadata")
|
|
}
|
|
if item.MarkdownContent != nil {
|
|
item.MarkdownContent = &sanitizedMarkdown
|
|
}
|
|
item.CoverImageAssetID = sanitizedCoverImageAssetID
|
|
if item.WizardState != nil && sanitizedCoverImageAssetID == nil {
|
|
delete(item.WizardState, "cover_image_asset_id")
|
|
}
|
|
}
|
|
item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount)
|
|
return &item, true, nil
|
|
}
|
|
|
|
type CreateArticleRequest struct {
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (*ArticleDetailResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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, brand_id, source_type, template_id, generate_status, publish_status)
|
|
VALUES ($1, $2, 'free_create', NULL, 'completed', 'unpublished')
|
|
RETURNING id
|
|
`, actor.TenantID, brandID).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")
|
|
}
|
|
|
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
|
return s.Detail(ctx, articleID)
|
|
}
|
|
|
|
type UpdateArticleRequest struct {
|
|
Title string `json:"title"`
|
|
MarkdownContent string `json:"markdown_content"`
|
|
CoverAssetURL *string `json:"cover_asset_url"`
|
|
ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"`
|
|
CoverImageAssetID NullableInt64Input `json:"cover_image_asset_id"`
|
|
}
|
|
|
|
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)
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
title := strings.TrimSpace(req.Title)
|
|
if title == "" {
|
|
return nil, response.ErrBadRequest(40012, "article_title_required", "article title is required")
|
|
}
|
|
|
|
markdown := strings.TrimSpace(req.MarkdownContent)
|
|
var referencedImageAssetIDs []int64
|
|
coverImageAssetID, err := normalizeOptionalImageAssetID(req.CoverImageAssetID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 brand_id = $3 AND deleted_at IS NULL
|
|
FOR UPDATE
|
|
`, id, actor.TenantID, brandID).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")
|
|
}
|
|
|
|
markdown, referencedImageAssetIDs, coverImageAssetID, err = s.resolveArticleImageMetadata(
|
|
ctx,
|
|
tx,
|
|
actor.TenantID,
|
|
markdown,
|
|
coverImageAssetID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 := "手动编辑"
|
|
resolvedCoverImageAssetInput := req.CoverImageAssetID
|
|
if req.CoverImageAssetID.Set {
|
|
resolvedCoverImageAssetInput = NullableInt64Input{
|
|
Set: true,
|
|
Value: coverImageAssetID,
|
|
}
|
|
}
|
|
nextWizardStateJSON, err := mergeArticleWizardState(
|
|
wizardStateJSON,
|
|
title,
|
|
req.CoverAssetURL,
|
|
resolvedCoverImageAssetInput,
|
|
)
|
|
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 brand_id = $5 AND deleted_at IS NULL
|
|
`, versionID, id, actor.TenantID, nextWizardStateJSON, brandID)
|
|
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")
|
|
}
|
|
|
|
imageRefRepo := repository.NewImageReferenceRepository(tx)
|
|
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")
|
|
}
|
|
|
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &id)
|
|
return s.Detail(ctx, id)
|
|
}
|
|
|
|
func (s *ArticleService) UploadImage(
|
|
ctx context.Context,
|
|
articleID int64,
|
|
fileName string,
|
|
content []byte,
|
|
) (*ArticleImageUploadResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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")
|
|
}
|
|
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, brandID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if generateStatus != "completed" {
|
|
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, storedContent, storedContentType); 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: storedContentType,
|
|
SizeBytes: int64(len(storedContent)),
|
|
}, 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)
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Verify ownership
|
|
var exists bool
|
|
_ = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL)`,
|
|
articleID, actor.TenantID, brandID).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)
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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 brand_id = $3 AND deleted_at IS NULL`,
|
|
id, actor.TenantID, brandID).Scan(&beforeJSON)
|
|
|
|
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 brand_id = $3 AND deleted_at IS NULL
|
|
`, id, actor.TenantID, brandID)
|
|
if err != nil || tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40411, "article_not_found", "article not found")
|
|
}
|
|
if err := cancelQueuedPublishTasksForUnavailableArticle(ctx, tx, actor.TenantID, id); err != nil {
|
|
return err
|
|
}
|
|
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"
|
|
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,
|
|
})
|
|
|
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &id)
|
|
return nil
|
|
}
|
|
|
|
func countArticleWords(input string) int {
|
|
return contentstats.CountWords(input)
|
|
}
|
|
|
|
type imageAssetLookupQueryer interface {
|
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
|
}
|
|
|
|
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 extractReferencedImageAssetIDsFromMarkdown(markdown string) []int64 {
|
|
if strings.TrimSpace(markdown) == "" {
|
|
return nil
|
|
}
|
|
|
|
matches := articleMarkdownImageAssetIDPattern.FindAllStringSubmatch(markdown, -1)
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ids := make([]int64, 0, len(matches))
|
|
for _, match := range matches {
|
|
id, err := strconv.ParseInt(match[1], 10, 64)
|
|
if err != nil || id <= 0 {
|
|
continue
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
normalized, err := normalizeImageAssetIDs(ids)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func filterActiveImageAssetIDs(ids []int64, activeIDs map[int64]struct{}) []int64 {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
filtered := make([]int64, 0, len(ids))
|
|
for _, id := range ids {
|
|
if _, ok := activeIDs[id]; ok {
|
|
filtered = append(filtered, id)
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
return nil
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func stripInactiveImageAssetIDsFromMarkdown(markdown string, activeIDs map[int64]struct{}) string {
|
|
if markdown == "" {
|
|
return markdown
|
|
}
|
|
|
|
return articleMarkdownImageAssetAttributePattern.ReplaceAllStringFunc(markdown, func(match string) string {
|
|
parts := articleMarkdownImageAssetAttributePattern.FindStringSubmatch(match)
|
|
if len(parts) < 2 {
|
|
return match
|
|
}
|
|
|
|
id, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if _, ok := activeIDs[id]; ok {
|
|
return match
|
|
}
|
|
return ""
|
|
})
|
|
}
|
|
|
|
func (s *ArticleService) resolveArticleImageMetadata(
|
|
ctx context.Context,
|
|
q imageAssetLookupQueryer,
|
|
tenantID int64,
|
|
markdown string,
|
|
coverImageAssetID *int64,
|
|
) (string, []int64, *int64, error) {
|
|
referencedImageAssetIDs := extractReferencedImageAssetIDsFromMarkdown(markdown)
|
|
imageAssetIDsToValidate := collectImageAssetIDsForValidation(referencedImageAssetIDs, coverImageAssetID)
|
|
activeImageAssetIDs, err := s.loadActiveImageAssetIDSet(ctx, q, tenantID, imageAssetIDsToValidate)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
|
|
resolvedCoverImageAssetID := coverImageAssetID
|
|
if coverImageAssetID != nil {
|
|
if _, ok := activeImageAssetIDs[*coverImageAssetID]; !ok {
|
|
resolvedCoverImageAssetID = nil
|
|
}
|
|
}
|
|
|
|
return stripInactiveImageAssetIDsFromMarkdown(markdown, activeImageAssetIDs),
|
|
filterActiveImageAssetIDs(referencedImageAssetIDs, activeImageAssetIDs),
|
|
resolvedCoverImageAssetID,
|
|
nil
|
|
}
|
|
|
|
func (s *ArticleService) loadActiveImageAssetIDSet(
|
|
ctx context.Context,
|
|
q imageAssetLookupQueryer,
|
|
tenantID int64,
|
|
imageAssetIDs []int64,
|
|
) (map[int64]struct{}, error) {
|
|
if len(imageAssetIDs) == 0 {
|
|
return map[int64]struct{}{}, nil
|
|
}
|
|
|
|
rows, err := q.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 nil, 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 nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
|
}
|
|
validIDs[id] = struct{}{}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
|
}
|
|
return validIDs, nil
|
|
}
|
|
|
|
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64, brandID 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 brand_id = $3 AND deleted_at IS NULL
|
|
`, articleID, tenantID, brandID).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 {
|
|
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 {
|
|
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 normalizeArticleSourceTypes(sourceType *string) []string {
|
|
if sourceType == nil {
|
|
return nil
|
|
}
|
|
|
|
parts := strings.Split(*sourceType, ",")
|
|
out := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for _, part := range parts {
|
|
value := strings.TrimSpace(part)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func appendArticleSourceTypeFilter(
|
|
query string,
|
|
args []interface{},
|
|
argIdx int,
|
|
sourceTypes []string,
|
|
) (string, []interface{}, int) {
|
|
if len(sourceTypes) == 0 {
|
|
return query, args, argIdx
|
|
}
|
|
|
|
placeholders := make([]string, 0, len(sourceTypes))
|
|
for _, sourceType := range sourceTypes {
|
|
placeholders = append(placeholders, `$`+strconv.Itoa(argIdx))
|
|
args = append(args, sourceType)
|
|
argIdx++
|
|
}
|
|
|
|
if len(placeholders) == 1 {
|
|
query += ` AND a.source_type = ` + placeholders[0]
|
|
} else {
|
|
query += ` AND a.source_type IN (` + strings.Join(placeholders, ", ") + `)`
|
|
}
|
|
|
|
return query, args, argIdx
|
|
}
|
|
|
|
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 resolveArticleImitationSourceInfo(sourceType string, wizardStateJSON, inputParamsJSON []byte) (*string, *string) {
|
|
if sourceType != articleImitationSourceType {
|
|
return nil, nil
|
|
}
|
|
|
|
sourceURL := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "source_url"))
|
|
if sourceURL == "" {
|
|
sourceURL = strings.TrimSpace(extractStringFromJSONPayload(inputParamsJSON, "source_url"))
|
|
}
|
|
|
|
sourceTitle := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "source_title"))
|
|
if sourceTitle == "" {
|
|
sourceTitle = strings.TrimSpace(extractStringFromJSONPayload(inputParamsJSON, "source_title"))
|
|
}
|
|
|
|
return nilIfEmptyString(sourceURL), nilIfEmptyString(sourceTitle)
|
|
}
|
|
|
|
func nilIfEmptyString(value string) *string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|