feat(tenant/articles): aggregate publish status from per-account records

a.publish_status only stored a coarse summary so the article list/detail
mis-reported as 失败 when one of several accounts failed. Compute the
effective status via a LATERAL join over latest publish_records per
platform_account_id and bucket into success / publishing / failed /
partial_success. Wire the same expression into list filtering and detail
queries, and let normalizePublishStatus / batch-status helpers retain
partial_success end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 21:02:03 +08:00
parent 677cfc5aff
commit e8c4fedb45
3 changed files with 53 additions and 5 deletions
+39 -5
View File
@@ -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, &currentVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
&item.Title, &item.WordCount, &item.SourceLabel, &item.VersionNo, &item.TemplateName, &item.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
@@ -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:
@@ -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)
}
}