diff --git a/server/internal/tenant/app/article_service.go b/server/internal/tenant/app/article_service.go index 662644d..bb16369 100644 --- a/server/internal/tenant/app/article_service.go +++ b/server/internal/tenant/app/article_service.go @@ -63,6 +63,34 @@ var supportedArticleImageExtensions = map[string]string{ 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 (pr.platform_account_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 pr.platform_account_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 @@ -173,7 +201,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param WHERE article_id = a.id ORDER BY created_at DESC LIMIT 1 - ) gt ON true + ) gt ON true` + if params.PublishStatus != nil { + countQuery += articlePublishStatusAggregateJoin + } + countQuery += ` WHERE a.tenant_id = $1 AND a.deleted_at IS NULL` argIdx := 2 @@ -183,7 +215,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param argIdx++ } if params.PublishStatus != nil { - countQuery += ` AND a.publish_status = $` + strconv.Itoa(argIdx) + countQuery += ` AND ` + articleEffectivePublishStatusExpr + ` = $` + strconv.Itoa(argIdx) countArgs = append(countArgs, *params.PublishStatus) argIdx++ } @@ -232,7 +264,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param return nil, response.ErrInternal(50010, "query_failed", "failed to count articles") } - listQuery := `SELECT a.id, a.source_type, a.template_id, a.kol_prompt_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at, + listQuery := `SELECT a.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 @@ -246,6 +278,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param ORDER BY created_at DESC LIMIT 1 ) gt ON true + ` + articlePublishStatusAggregateJoin + ` WHERE a.tenant_id = $1 AND a.deleted_at IS NULL` listArgs := []interface{}{tenantID} listIdx := 2 @@ -256,7 +289,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param listIdx++ } if params.PublishStatus != nil { - listQuery += ` AND a.publish_status = $` + strconv.Itoa(listIdx) + listQuery += ` AND ` + articleEffectivePublishStatusExpr + ` = $` + strconv.Itoa(listIdx) listArgs = append(listArgs, *params.PublishStatus) listIdx++ } @@ -336,7 +369,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl var wizardStateJSON []byte var inputParamsJSON []byte err := s.pool.QueryRow(ctx, ` - SELECT a.id, a.source_type, a.template_id, a.current_version_id, a.generate_status, a.publish_status, a.created_at, + SELECT a.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 @@ -349,6 +382,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl ORDER BY created_at DESC LIMIT 1 ) gt ON true + `+articlePublishStatusAggregateJoin+` WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL `, articleID, tenantID).Scan(&item.ID, &dbSourceType, &item.TemplateID, ¤tVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt, &item.Title, &item.WordCount, &item.SourceLabel, &item.VersionNo, &item.TemplateName, &item.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON) diff --git a/server/internal/tenant/app/media_service.go b/server/internal/tenant/app/media_service.go index 3ba4b08..e370ad3 100644 --- a/server/internal/tenant/app/media_service.go +++ b/server/internal/tenant/app/media_service.go @@ -154,6 +154,8 @@ func normalizePublishStatus(status string) string { switch status { case "success", "published", "publish_success": return "success" + case "partial_success": + return "partial_success" case "publishing", "pending", "queued", "running": return "publishing" default: diff --git a/server/internal/tenant/app/publish_record_support_test.go b/server/internal/tenant/app/publish_record_support_test.go index e0cb268..b0d815d 100644 --- a/server/internal/tenant/app/publish_record_support_test.go +++ b/server/internal/tenant/app/publish_record_support_test.go @@ -63,3 +63,15 @@ func TestNormalizePublishRecordForResponseUsesBaijiahaoPublicURL(t *testing.T) { t.Fatalf("ExternalManageURL = %v, want original edit URL", item.ExternalManageURL) } } + +func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) { + if got := normalizePublishStatus("partial_success"); got != "partial_success" { + t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got) + } +} + +func TestPublishBatchStatusToArticleStatusKeepsPartialSuccess(t *testing.T) { + if got := publishBatchStatusToArticleStatus("partial_success"); got != "partial_success" { + t.Fatalf("publishBatchStatusToArticleStatus(partial_success) = %q, want partial_success", got) + } +}