Files
geo/server/internal/tenant/app/media_service.go
T
2026-06-19 17:20:35 +08:00

574 lines
18 KiB
Go

package app
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type MediaService struct {
pool *pgxpool.Pool
}
func NewMediaService(pool *pgxpool.Pool) *MediaService {
return &MediaService{pool: pool}
}
type MediaPlatformResponse struct {
ID int64 `json:"id"`
PlatformID string `json:"platform_id"`
Name string `json:"name"`
Category string `json:"category"`
ShortName string `json:"short_name"`
AccentColor string `json:"accent_color"`
LoginURL *string `json:"login_url"`
LogoURL *string `json:"logo_url"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
type PublishRecordResponse struct {
ID int64 `json:"id"`
PublishBatchID int64 `json:"publish_batch_id"`
ArticleID int64 `json:"article_id"`
BrandID int64 `json:"brand_id"`
BrandName *string `json:"brand_name"`
BrandDeleted bool `json:"brand_deleted"`
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"`
PlatformName string `json:"platform_name"`
PlatformNickname string `json:"platform_nickname"`
Status string `json:"status"`
ExternalArticleID *string `json:"external_article_id"`
ExternalArticleURL *string `json:"external_article_url"`
ExternalManageURL *string `json:"external_manage_url"`
PublishedAt *time.Time `json:"published_at"`
ErrorMessage *string `json:"error_message"`
CreatedAt time.Time `json:"created_at"`
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"`
}
type DeletePublishRecordResponse struct {
Deleted bool `json:"deleted"`
}
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
FROM media_platforms
WHERE status = 'active'
ORDER BY sort_order ASC, id ASC
`)
if err != nil {
return nil, response.ErrInternal(50030, "media_platform_query_failed", "failed to list media platforms")
}
defer rows.Close()
items := make([]MediaPlatformResponse, 0)
for rows.Next() {
var item MediaPlatformResponse
if err := rows.Scan(
&item.ID,
&item.PlatformID,
&item.Name,
&item.Category,
&item.ShortName,
&item.AccentColor,
&item.LoginURL,
&item.LogoURL,
&item.Status,
&item.SortOrder,
); err != nil {
return nil, response.ErrInternal(50030, "media_platform_scan_failed", err.Error())
}
items = append(items, item)
}
return items, nil
}
func (s *MediaService) ListPublishRecords(ctx context.Context, req ListPublishRecordsRequest) (*PublishRecordListResponse, error) {
actor := auth.MustActor(ctx)
if actor.TenantID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
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, actor.UserID, pendingStatuses, title, 0, 0, "pr.created_at ASC, pr.id ASC")
if err != nil {
return nil, err
}
historyTotal, err := s.countPublishRecordsByStatuses(ctx, actor.TenantID, actor.UserID, 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, actor.UserID, 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,
userID int64,
statuses []string,
title string,
limit int,
offset int,
orderBy string,
) ([]PublishRecordResponse, error) {
query := `
SELECT pr.id, pr.publish_batch_id, pr.article_id,
a.brand_id, b.name AS brand_name, (b.id IS NULL OR b.deleted_at IS NOT NULL OR b.status IN ('deleting', 'deleted')) AS brand_deleted,
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 brands b ON b.id = a.brand_id AND b.tenant_id = a.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 EXISTS (
SELECT 1
FROM publish_batches pb
WHERE pb.id = pr.publish_batch_id
AND pb.tenant_id = pr.tenant_id
AND pb.initiator_user_id = $2
)
AND pr.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, userID, 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.BrandID,
&item.BrandName,
&item.BrandDeleted,
&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,
userID 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 EXISTS (
SELECT 1
FROM publish_batches pb
WHERE pb.id = pr.publish_batch_id
AND pb.tenant_id = pr.tenant_id
AND pb.initiator_user_id = $2
)
AND pr.deleted_at IS NULL
AND pr.status = ANY($3)
AND (
$4 = ''
OR COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '') ILIKE '%' || $4 || '%'
)
`, tenantID, userID, 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)
if err != nil {
return nil, err
}
return s.listArticlePublishRecords(ctx, actor.TenantID, brandID, articleID)
}
func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID, brandID, articleID int64) ([]PublishRecordResponse, error) {
rows, err := s.pool.Query(ctx, `
SELECT pr.id, pr.publish_batch_id, pr.article_id,
a.brand_id, b.name AS brand_name, (b.id IS NULL OR b.deleted_at IS NOT NULL OR b.status IN ('deleting', 'deleted')) AS brand_deleted,
pr.platform_account_id, pa.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
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
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
LEFT JOIN brands b ON b.id = a.brand_id AND b.tenant_id = a.tenant_id
WHERE pr.tenant_id = $1 AND pr.article_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL AND pr.deleted_at IS NULL
ORDER BY pr.created_at DESC, pr.id DESC
`, tenantID, articleID, brandID)
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
if err := rows.Scan(
&item.ID,
&item.PublishBatchID,
&item.ArticleID,
&item.BrandID,
&item.BrandName,
&item.BrandDeleted,
&item.PlatformAccountID,
&desktopAccountID,
&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
}
normalizePublishRecordForResponse(&item)
items = append(items, item)
}
return items, nil
}
func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64) (*DeletePublishRecordResponse, error) {
if recordID <= 0 {
return nil, response.ErrBadRequest(40001, "invalid_publish_record_id", "publish record id must be a positive number")
}
actor := auth.MustActor(ctx)
if actor.TenantID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_begin_failed", "failed to delete publish record")
}
defer tx.Rollback(ctx)
var publishBatchID int64
var articleID int64
var publishStatus string
cancelErrorJSONBytes, err := marshalOptionalJSON(map[string]any{
"reason": "publish_record_deleted",
"source": "tenant_web",
})
if err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_payload_failed", "failed to delete publish record")
}
cancelErrorJSON := string(cancelErrorJSONBytes)
err = tx.QueryRow(ctx, `
SELECT pr.publish_batch_id, pr.article_id, pr.status
FROM publish_records pr
JOIN articles a
ON a.id = pr.article_id
AND a.tenant_id = pr.tenant_id
JOIN publish_batches pb
ON pb.id = pr.publish_batch_id
AND pb.tenant_id = pr.tenant_id
WHERE pr.id = $1
AND pr.tenant_id = $2
AND pb.initiator_user_id = $3
AND pr.deleted_at IS NULL
FOR UPDATE OF pr
`, recordID, actor.TenantID, actor.UserID).Scan(&publishBatchID, &articleID, &publishStatus)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40435, "publish_record_not_found", "publish record not found")
}
return nil, response.ErrInternal(50044, "publish_record_delete_lookup_failed", "failed to delete publish record")
}
if tag, err := tx.Exec(ctx, `
UPDATE publish_records
SET deleted_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND deleted_at IS NULL
`, recordID, actor.TenantID); err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_failed", "failed to delete publish record")
} else if tag.RowsAffected() == 0 {
return nil, response.ErrNotFound(40435, "publish_record_not_found", "publish record not found")
}
if publishRecordMayHaveQueuedTask(publishStatus) {
if _, err := tx.Exec(ctx, `
UPDATE desktop_tasks
SET status = 'aborted',
error = $3::jsonb,
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = NOW()
WHERE tenant_id = $1
AND kind = 'publish'
AND status = 'queued'
AND payload->>'publish_record_id' = $2::text
`, actor.TenantID, recordID, cancelErrorJSON); err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_cancel_task_failed", "failed to cancel queued publish task before deleting record")
}
}
batchStatus, err := recalculatePublishBatchStatus(ctx, tx, publishBatchID)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE publish_batches
SET status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
`, batchStatus, publishBatchID, actor.TenantID); err != nil {
return nil, response.ErrInternal(50054, "publish_batch_update_failed", "failed to update publish batch")
}
articleStatus, err := recalculateArticlePublishStatus(ctx, tx, actor.TenantID, articleID)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE articles
SET publish_status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
`, articleStatus, articleID, actor.TenantID); err != nil {
return nil, response.ErrInternal(50055, "article_publish_update_failed", "failed to update article publish status")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_commit_failed", "failed to delete publish record")
}
return &DeletePublishRecordResponse{Deleted: true}, nil
}
func publishBatchRequiresCover(accounts []platformAccountSeed) bool {
for _, account := range accounts {
if publishPlatformRequiresCover(account.PlatformID) {
return true
}
}
return false
}
func publishPlatformRequiresCover(platformID string) bool {
switch strings.TrimSpace(platformID) {
case "weixin_gzh", "baijiahao", "dongchedi", "smzdm":
return true
default:
return false
}
}
func publishRecordMayHaveQueuedTask(status string) bool {
switch strings.TrimSpace(status) {
case "queued", "publishing", "pending", "running":
return true
default:
return false
}
}
func pointerStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
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:
return "failed"
}
}
func newSessionToken() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}