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, ¤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 }