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