package app import ( "context" "encoding/json" "errors" "fmt" "net/http" "path/filepath" "strconv" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "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/objectstorage" "github.com/geo-platform/tenant-api/internal/shared/response" ) type ArticleService struct { pool *pgxpool.Pool auditLogs *auditlog.AsyncWriter objectStorage objectstorage.Client } func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService { return &ArticleService{pool: pool, auditLogs: auditLogs, objectStorage: objectStorage} } const maxArticleImageSizeBytes = 10 * 1024 * 1024 var supportedArticleImageExtensions = map[string]string{ "image/gif": "gif", "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", } type ArticleListParams struct { Page int PageSize int GenerateStatus *string PublishStatus *string SourceType *string GenerationMode *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"` GenerationMode *string `json:"generation_mode"` 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) sourceType := 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 // 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 sourceType != nil { countQuery += ` AND a.source_type = $` + strconv.Itoa(argIdx) countArgs = append(countArgs, *sourceType) argIdx++ } 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.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 sourceType != nil { listQuery += ` AND a.source_type = $` + strconv.Itoa(listIdx) listArgs = append(listArgs, *sourceType) listIdx++ } 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.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 dbSourceType string var markdownContent *string var wizardStateJSON []byte var inputParamsJSON []byte if err := rows.Scan(&item.ID, &dbSourceType, &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.SourceType = dbSourceType item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON) 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"` GenerationMode *string `json:"generation_mode"` 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 dbSourceType string 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, &dbSourceType, &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") } d.SourceType = dbSourceType if len(wizardStateJSON) > 0 { d.WizardState = map[string]interface{}{} _ = json.Unmarshal(wizardStateJSON, &d.WizardState) } d.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON) 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"` } 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) 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) } func (s *ArticleService) UploadImage( ctx context.Context, articleID int64, fileName string, content []byte, ) (*ArticleImageUploadResponse, error) { actor := auth.MustActor(ctx) 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") } generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID) if err != nil { return nil, err } if generateStatus != "completed" { return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited") } objectKey := buildArticleImageObjectKey(actor.TenantID, articleID, fileName, resolvedExt) if err := s.objectStorage.PutBytes(ctx, objectKey, content, contentType); 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: contentType, SizeBytes: int64(len(content)), }, 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) // 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 (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID 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 deleted_at IS NULL `, articleID, tenantID).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 { 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 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 nilIfEmptyString(value string) *string { value = strings.TrimSpace(value) if value == "" { return nil } return &value }