feat(tenant): add brand publish-records list and harden enterprise site management
- Add GET /api/tenant/publish-records: brand-scoped paginated list that merges in-flight (queued/publishing) records ahead of history (success/failed/cancelled), enriched with article title and latest desktop task id; wire up service/handler/router/swagger/router_test - Rework PublishManagementView to consume publish-records instead of desktop publish tasks; add publishRecordsApi + shared-types (ListPublishRecordsParams, PublishRecordListResponse) - Enterprise site Update: distinguish explicit null from missing PATCH fields via EnterpriseSitePatchString, dedup site_url before write, verify RowsAffected, and map unique-constraint violations to a clear conflict - MediaView: enterprise site edit/delete flows with favicon fallback handling - Localize enterprise site / PBootCMS error messages to Chinese across the backend and the admin-web error map, plus legacy raw-message translation - deploy: gate migration-coupled service rollouts behind RUN_MIGRATIONS unless ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT=true; handle empty SERVICES safely Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,8 +38,10 @@ type PublishRecordResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
PublishBatchID int64 `json:"publish_batch_id"`
|
||||
ArticleID int64 `json:"article_id"`
|
||||
ArticleTitle *string `json:"article_title,omitempty"`
|
||||
PlatformAccountID *int64 `json:"platform_account_id"`
|
||||
DesktopAccountID *string `json:"desktop_account_id"`
|
||||
DesktopTaskID *string `json:"desktop_task_id,omitempty"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetConnectionID *int64 `json:"target_connection_id"`
|
||||
PlatformID string `json:"platform_id"`
|
||||
@@ -55,6 +57,21 @@ type PublishRecordResponse struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListPublishRecordsRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Title string
|
||||
}
|
||||
|
||||
type PublishRecordListResponse struct {
|
||||
Items []PublishRecordResponse `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
HistoryTotal int `json:"history_total"`
|
||||
}
|
||||
|
||||
func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformResponse, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, platform_id, name, category, short_name, accent_color, login_url, logo_url, status, sort_order
|
||||
@@ -89,6 +106,206 @@ func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformRespon
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) ListPublishRecords(ctx context.Context, req ListPublishRecordsRequest) (*PublishRecordListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 || pageSize > 50 {
|
||||
pageSize = 10
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
pendingStatuses := []string{"queued", "publishing"}
|
||||
historyStatuses := []string{"success", "failed", "cancelled", "canceled"}
|
||||
|
||||
pendingItems, err := s.listPublishRecordsByStatuses(ctx, actor.TenantID, brandID, pendingStatuses, title, 0, 0, "pr.created_at ASC, pr.id ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
historyTotal, err := s.countPublishRecordsByStatuses(ctx, actor.TenantID, brandID, historyStatuses, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
items := make([]PublishRecordResponse, 0, pageSize)
|
||||
total := len(pendingItems) + historyTotal
|
||||
|
||||
if offset < len(pendingItems) {
|
||||
end := offset + pageSize
|
||||
if end > len(pendingItems) {
|
||||
end = len(pendingItems)
|
||||
}
|
||||
items = append(items, pendingItems[offset:end]...)
|
||||
}
|
||||
|
||||
remaining := pageSize - len(items)
|
||||
historyOffset := offset - len(pendingItems)
|
||||
if historyOffset < 0 {
|
||||
historyOffset = 0
|
||||
}
|
||||
if remaining > 0 && historyOffset < historyTotal {
|
||||
historyItems, listErr := s.listPublishRecordsByStatuses(ctx, actor.TenantID, brandID, historyStatuses, title, remaining, historyOffset, "pr.updated_at DESC, pr.id DESC")
|
||||
if listErr != nil {
|
||||
return nil, listErr
|
||||
}
|
||||
items = append(items, historyItems...)
|
||||
}
|
||||
|
||||
return &PublishRecordListResponse{
|
||||
Items: items,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
PendingCount: len(pendingItems),
|
||||
HistoryTotal: historyTotal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) listPublishRecordsByStatuses(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
brandID int64,
|
||||
statuses []string,
|
||||
title string,
|
||||
limit int,
|
||||
offset int,
|
||||
orderBy string,
|
||||
) ([]PublishRecordResponse, error) {
|
||||
query := `
|
||||
SELECT pr.id, pr.publish_batch_id, pr.article_id,
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', '')) AS article_title,
|
||||
pr.platform_account_id, pa.desktop_id::text, dt.desktop_id::text,
|
||||
COALESCE(pr.target_type, 'platform_account'), pr.target_connection_id, pr.platform_id,
|
||||
COALESCE(mp.name, esc.site_name, esc.name, pr.platform_id) AS platform_name,
|
||||
CASE
|
||||
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
|
||||
THEN COALESCE(esc.site_name, esc.name, '企业站点')
|
||||
ELSE COALESCE(pa.nickname, '')
|
||||
END AS platform_nickname,
|
||||
pr.status, pr.external_article_id, pr.external_article_url,
|
||||
pr.external_manage_url, pr.published_at, pr.error_message, pr.created_at, pr.updated_at
|
||||
FROM publish_records pr
|
||||
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
|
||||
LEFT JOIN article_versions av ON av.id = a.current_version_id AND av.article_id = a.id
|
||||
LEFT JOIN media_platforms mp ON mp.platform_id = pr.platform_id
|
||||
LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
|
||||
LEFT JOIN enterprise_site_connections esc
|
||||
ON esc.id = pr.target_connection_id
|
||||
AND esc.tenant_id = pr.tenant_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT desktop_id
|
||||
FROM desktop_tasks
|
||||
WHERE tenant_id = pr.tenant_id
|
||||
AND kind = 'publish'
|
||||
AND payload->>'publish_record_id' = pr.id::text
|
||||
ORDER BY updated_at DESC, desktop_id DESC
|
||||
LIMIT 1
|
||||
) dt ON TRUE
|
||||
WHERE pr.tenant_id = $1
|
||||
AND a.brand_id = $2
|
||||
AND a.deleted_at IS NULL
|
||||
AND pr.status = ANY($3)
|
||||
AND (
|
||||
$4 = ''
|
||||
OR COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '') ILIKE '%' || $4 || '%'
|
||||
)
|
||||
`
|
||||
args := []any{tenantID, brandID, statuses, title}
|
||||
|
||||
if strings.TrimSpace(orderBy) != "" {
|
||||
query += "\nORDER BY " + orderBy
|
||||
}
|
||||
if limit > 0 {
|
||||
query += "\nLIMIT $5 OFFSET $6"
|
||||
args = append(args, limit, offset)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50043, "publish_record_query_failed", "failed to list publish records")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]PublishRecordResponse, 0)
|
||||
for rows.Next() {
|
||||
var item PublishRecordResponse
|
||||
var desktopAccountID *string
|
||||
var desktopTaskID *string
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.PublishBatchID,
|
||||
&item.ArticleID,
|
||||
&item.ArticleTitle,
|
||||
&item.PlatformAccountID,
|
||||
&desktopAccountID,
|
||||
&desktopTaskID,
|
||||
&item.TargetType,
|
||||
&item.TargetConnectionID,
|
||||
&item.PlatformID,
|
||||
&item.PlatformName,
|
||||
&item.PlatformNickname,
|
||||
&item.Status,
|
||||
&item.ExternalArticleID,
|
||||
&item.ExternalArticleURL,
|
||||
&item.ExternalManageURL,
|
||||
&item.PublishedAt,
|
||||
&item.ErrorMessage,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, response.ErrInternal(50043, "publish_record_scan_failed", err.Error())
|
||||
}
|
||||
if desktopAccountID != nil && strings.TrimSpace(*desktopAccountID) != "" {
|
||||
item.DesktopAccountID = desktopAccountID
|
||||
}
|
||||
if desktopTaskID != nil && strings.TrimSpace(*desktopTaskID) != "" {
|
||||
item.DesktopTaskID = desktopTaskID
|
||||
}
|
||||
normalizePublishRecordForResponse(&item)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50043, "publish_record_query_failed", "failed to list publish records")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) countPublishRecordsByStatuses(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
brandID int64,
|
||||
statuses []string,
|
||||
title string,
|
||||
) (int, error) {
|
||||
var count int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(1)
|
||||
FROM publish_records pr
|
||||
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
|
||||
LEFT JOIN article_versions av ON av.id = a.current_version_id AND av.article_id = a.id
|
||||
WHERE pr.tenant_id = $1
|
||||
AND a.brand_id = $2
|
||||
AND a.deleted_at IS NULL
|
||||
AND pr.status = ANY($3)
|
||||
AND (
|
||||
$4 = ''
|
||||
OR COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '') ILIKE '%' || $4 || '%'
|
||||
)
|
||||
`, tenantID, brandID, statuses, title).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, response.ErrInternal(50043, "publish_record_query_failed", "failed to list publish records")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) ListArticlePublishRecords(ctx context.Context, articleID int64) ([]PublishRecordResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
|
||||
Reference in New Issue
Block a user