Files
geo/server/internal/tenant/app/article_service.go
T
root 6e0519a232
Frontend CI / Frontend (push) Successful in 3m23s
Backend CI / Backend (push) Failing after 6m46s
feat: add brand asset cleanup worker and related functionality
- 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.
2026-06-19 11:45:13 +08:00

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, &currentVersionID, &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
}