Files
geo/server/internal/tenant/app/article_service.go
T
root a5d89f0c48 fix(publish): cancel queued publish tasks when the article is unavailable
Stale publish jobs for deleted or invalid articles previously lingered in the
queue and kept failing compliance rechecks. Now they are cancelled cleanly and
batch/article publish status is recalculated.

- cancel queued desktop tasks, jobs and publish records on article delete
- detect deleted articles and invalid-article-version compliance errors during
  the client publish recheck and cancel the affected tasks
- add cancelQueuedPublishTasksForUnavailableArticle helper and a unit test for
  the compliance-error classifier

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:03:00 +08:00

1248 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
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
}