3436d9536d
WeChat 公众号在管理员/运营者授权发布场景下需要 2.35:1 封面, 新增共享 publishPlatformRequiresCover 校验并同步前端封面要求与文案, 避免在调度与即时发布两条路径上重复维护平台名单。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
457 lines
16 KiB
Go
457 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
goredis "github.com/redis/go-redis/v9"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
|
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
)
|
|
|
|
type PublishJobService struct {
|
|
pool *pgxpool.Pool
|
|
messaging *rabbitmq.Client
|
|
redis *goredis.Client
|
|
logger *zap.Logger
|
|
cache sharedcache.Cache
|
|
}
|
|
|
|
func NewPublishJobService(
|
|
pool *pgxpool.Pool,
|
|
messaging *rabbitmq.Client,
|
|
redis *goredis.Client,
|
|
logger *zap.Logger,
|
|
) *PublishJobService {
|
|
return &PublishJobService{
|
|
pool: pool,
|
|
messaging: messaging,
|
|
redis: redis,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (s *PublishJobService) WithCache(c sharedcache.Cache) *PublishJobService {
|
|
s.cache = c
|
|
return s
|
|
}
|
|
|
|
type CreatePublishJobAccountRequest struct {
|
|
AccountID string `json:"account_id" binding:"required"`
|
|
}
|
|
|
|
type CreatePublishJobRequest struct {
|
|
Title string `json:"title" binding:"required"`
|
|
ContentRef map[string]any `json:"content_ref" binding:"required"`
|
|
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
|
|
ScheduledAt *time.Time `json:"scheduled_at"`
|
|
}
|
|
|
|
type CreatePublishJobResponse struct {
|
|
JobID string `json:"job_id"`
|
|
TaskIDs []string `json:"task_ids"`
|
|
}
|
|
|
|
func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req CreatePublishJobRequest) (*CreatePublishJobResponse, error) {
|
|
if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
|
|
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
|
}
|
|
|
|
if s.pool == nil {
|
|
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
|
|
}
|
|
|
|
contentRefJSON, err := marshalOptionalJSON(req.ContentRef)
|
|
if err != nil {
|
|
return nil, response.ErrBadRequest(40091, "invalid_content_ref", "content_ref must be serializable")
|
|
}
|
|
articleID, err := resolvePublishArticleID(req.ContentRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
requestedAccountIDs := make([]uuid.UUID, 0, len(req.Accounts))
|
|
for _, accountReq := range req.Accounts {
|
|
accountID, parseErr := uuid.Parse(strings.TrimSpace(accountReq.AccountID))
|
|
if parseErr != nil {
|
|
return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "account_id must be a uuid")
|
|
}
|
|
requestedAccountIDs = append(requestedAccountIDs, accountID)
|
|
}
|
|
runtimeHealth := loadDesktopAccountRuntimeHealth(ctx, s.redis, actor.PrimaryWorkspaceID, requestedAccountIDs)
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50096, "desktop_publish_job_begin_failed", "failed to start desktop publish job transaction")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
accountRepo := repository.NewDesktopAccountRepository(tx)
|
|
taskRepo := repository.NewDesktopTaskRepository(tx)
|
|
articleMeta, err := loadPublishableArticleMeta(ctx, tx, actor.TenantID, articleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type publishTarget struct {
|
|
account *repository.DesktopAccount
|
|
accountSeed *platformAccountSeed
|
|
targetClientID uuid.UUID
|
|
}
|
|
targets := make([]publishTarget, 0, len(req.Accounts))
|
|
|
|
for _, accountID := range requestedAccountIDs {
|
|
account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID)
|
|
if lookupErr != nil {
|
|
if errors.Is(lookupErr, pgx.ErrNoRows) {
|
|
return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts do not exist")
|
|
}
|
|
return nil, response.ErrInternal(50098, "desktop_account_lookup_failed", "failed to load selected desktop account")
|
|
}
|
|
accountHealth := account.Health
|
|
if report, ok := runtimeHealth[account.DesktopID]; ok && strings.TrimSpace(report.Health) != "" {
|
|
accountHealth = effectiveDesktopAccountRuntimeHealth(report)
|
|
}
|
|
if accountHealth != "live" {
|
|
return nil, response.ErrConflict(40993, "desktop_account_not_publishable", "one or more selected desktop accounts require re-authorization before publishing")
|
|
}
|
|
targetClientID := s.resolveTargetClientID(ctx, account)
|
|
if targetClientID == nil {
|
|
return nil, response.ErrConflict(40992, "desktop_account_client_missing", "one or more selected desktop accounts are not currently available on any desktop client")
|
|
}
|
|
|
|
accountSeed, seedErr := loadPlatformAccountSeedByDesktopID(ctx, tx, actor.TenantID, actor.PrimaryWorkspaceID, account.DesktopID)
|
|
if seedErr != nil {
|
|
return nil, seedErr
|
|
}
|
|
|
|
targets = append(targets, publishTarget{
|
|
account: account,
|
|
accountSeed: accountSeed,
|
|
targetClientID: *targetClientID,
|
|
})
|
|
}
|
|
|
|
jobID := uuid.New()
|
|
job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{
|
|
DesktopID: jobID,
|
|
TenantID: actor.TenantID,
|
|
WorkspaceID: actor.PrimaryWorkspaceID,
|
|
CreatedByUserID: actor.UserID,
|
|
Title: req.Title,
|
|
ContentRef: contentRefJSON,
|
|
ScheduledAt: req.ScheduledAt,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
|
|
}
|
|
|
|
accountSeeds := make([]platformAccountSeed, 0, len(targets))
|
|
for _, target := range targets {
|
|
accountSeeds = append(accountSeeds, *target.accountSeed)
|
|
}
|
|
if publishBatchRequiresCover(accountSeeds) && strings.TrimSpace(pointerStringValue(articleMeta.CoverAssetURL)) == "" {
|
|
return nil, response.ErrBadRequest(40044, "publish_cover_required", "selected publishing platform requires a cover image")
|
|
}
|
|
|
|
publishBatchID, publishRecordIDs, err := createPublishBatchRecords(
|
|
ctx,
|
|
tx,
|
|
actor.TenantID,
|
|
actor.UserID,
|
|
articleID,
|
|
"publish",
|
|
articleMeta.CoverAssetURL,
|
|
accountSeeds,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
taskIDs := make([]string, 0, len(targets))
|
|
createdTasks := make([]*repository.DesktopTask, 0, len(targets))
|
|
for _, target := range targets {
|
|
publishRecordID := publishRecordIDs[target.accountSeed.ID]
|
|
payloadJSON, payloadErr := marshalOptionalJSON(map[string]any{
|
|
"title": req.Title,
|
|
"content_ref": req.ContentRef,
|
|
"account_id": target.account.DesktopID.String(),
|
|
"platform": target.account.Platform,
|
|
"article_id": articleID,
|
|
"publish_batch_id": publishBatchID,
|
|
"publish_record_id": publishRecordID,
|
|
"platform_account_id": target.accountSeed.ID,
|
|
})
|
|
if payloadErr != nil {
|
|
return nil, response.ErrBadRequest(40094, "invalid_desktop_task_payload", "publish job payload must be serializable")
|
|
}
|
|
|
|
task, createErr := taskRepo.CreateTask(ctx, repository.CreateDesktopTaskParams{
|
|
DesktopID: uuid.New(),
|
|
JobID: job.DesktopID,
|
|
TenantID: actor.TenantID,
|
|
WorkspaceID: actor.PrimaryWorkspaceID,
|
|
TargetAccountID: target.account.DesktopID,
|
|
TargetClientID: target.targetClientID,
|
|
Platform: target.account.Platform,
|
|
Kind: "publish",
|
|
Payload: payloadJSON,
|
|
Status: "queued",
|
|
})
|
|
if createErr != nil {
|
|
return nil, response.ErrInternal(50099, "desktop_task_create_failed", "failed to create desktop publish task")
|
|
}
|
|
|
|
createdTasks = append(createdTasks, task)
|
|
taskIDs = append(taskIDs, task.DesktopID.String())
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job")
|
|
}
|
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
|
|
|
for _, task := range createdTasks {
|
|
title, businessDate, schedulerGroupKey, questionText := deriveDesktopTaskEventMetadataFromPayload(task.Kind, task.Payload)
|
|
if publishErr := publishDesktopTaskEvent(context.Background(), s.messaging, DesktopTaskEvent{
|
|
Type: "task_available",
|
|
TaskID: task.DesktopID.String(),
|
|
JobID: task.JobID.String(),
|
|
WorkspaceID: task.WorkspaceID,
|
|
TargetAccountID: task.TargetAccountID.String(),
|
|
TargetClientID: task.TargetClientID.String(),
|
|
Platform: task.Platform,
|
|
Title: title,
|
|
BusinessDate: businessDate,
|
|
SchedulerGroupKey: schedulerGroupKey,
|
|
QuestionText: questionText,
|
|
Status: task.Status,
|
|
Kind: task.Kind,
|
|
UpdatedAt: task.UpdatedAt,
|
|
}); publishErr != nil {
|
|
s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String()))
|
|
}
|
|
|
|
s.publishDispatchAvailable(task, stream.DesktopDispatchEvent{
|
|
Type: "task_available",
|
|
TaskID: task.DesktopID.String(),
|
|
JobID: task.JobID.String(),
|
|
WorkspaceID: task.WorkspaceID,
|
|
TargetAccountID: task.TargetAccountID.String(),
|
|
TargetClientID: task.TargetClientID.String(),
|
|
Platform: task.Platform,
|
|
Title: title,
|
|
BusinessDate: businessDate,
|
|
SchedulerGroupKey: schedulerGroupKey,
|
|
QuestionText: questionText,
|
|
Status: task.Status,
|
|
Kind: task.Kind,
|
|
UpdatedAt: task.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
return &CreatePublishJobResponse{
|
|
JobID: job.DesktopID.String(),
|
|
TaskIDs: taskIDs,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PublishJobService) RetryByDesktopTask(
|
|
ctx context.Context,
|
|
client *repository.DesktopClient,
|
|
taskID uuid.UUID,
|
|
) (*CreatePublishJobResponse, error) {
|
|
if client == nil {
|
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
|
}
|
|
if s.pool == nil {
|
|
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
|
|
}
|
|
|
|
taskRepo := repository.NewDesktopTaskRepository(s.pool)
|
|
task, err := taskRepo.GetByDesktopID(ctx, taskID, client.WorkspaceID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found")
|
|
}
|
|
return nil, response.ErrInternal(50095, "desktop_task_lookup_failed", "failed to inspect desktop task state")
|
|
}
|
|
if task.Kind != "publish" {
|
|
return nil, response.ErrBadRequest(40095, "desktop_task_not_publish", "only publish tasks can be retried")
|
|
}
|
|
if err := s.authorizePublishTaskForClientUser(ctx, client, task); err != nil {
|
|
return nil, err
|
|
}
|
|
if task.Status == "queued" || task.Status == "in_progress" {
|
|
return nil, response.ErrConflict(40994, "desktop_task_retry_conflict", "desktop task is still pending or running")
|
|
}
|
|
|
|
payload := unmarshalJSONObject(task.Payload)
|
|
articleID, err := s.resolveRetryArticleID(ctx, client.TenantID, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
title := strings.TrimSpace(stringPointerValue(extractStringPointer(payload, "title")))
|
|
if title == "" {
|
|
title = "文章发布"
|
|
}
|
|
|
|
return s.Create(ctx, auth.Actor{
|
|
TenantID: client.TenantID,
|
|
UserID: client.UserID,
|
|
PrimaryWorkspaceID: client.WorkspaceID,
|
|
}, CreatePublishJobRequest{
|
|
Title: title,
|
|
ContentRef: map[string]any{
|
|
"article_id": articleID,
|
|
},
|
|
Accounts: []CreatePublishJobAccountRequest{
|
|
{AccountID: task.TargetAccountID.String()},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *PublishJobService) authorizePublishTaskForClientUser(
|
|
ctx context.Context,
|
|
client *repository.DesktopClient,
|
|
task *repository.DesktopTask,
|
|
) error {
|
|
if client == nil || task == nil {
|
|
return response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
|
}
|
|
|
|
var ownsTask bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM desktop_publish_jobs
|
|
WHERE desktop_id = $1
|
|
AND workspace_id = $2
|
|
AND created_by_user_id = $3
|
|
)
|
|
`, task.JobID, client.WorkspaceID, client.UserID).Scan(&ownsTask)
|
|
if err != nil {
|
|
return response.ErrInternal(50115, "desktop_publish_job_lookup_failed", "failed to validate desktop publish task ownership")
|
|
}
|
|
if !ownsTask {
|
|
return response.ErrForbidden(40384, "desktop_publish_task_not_owned", "desktop publish task belongs to another user")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PublishJobService) resolveRetryArticleID(ctx context.Context, tenantID int64, payload map[string]any) (int64, error) {
|
|
if articleID, ok := resolveRetryArticleIDFromPayload(payload); ok {
|
|
return articleID, nil
|
|
}
|
|
|
|
if publishRecordID, ok := extractInt64FromMap(payload, "publish_record_id"); ok && publishRecordID > 0 {
|
|
var articleID int64
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT article_id
|
|
FROM publish_records
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, publishRecordID, tenantID).Scan(&articleID)
|
|
if err == nil && articleID > 0 {
|
|
return articleID, nil
|
|
}
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, response.ErrInternal(50113, "desktop_publish_record_lookup_failed", "failed to lookup desktop publish record")
|
|
}
|
|
}
|
|
|
|
if publishBatchID, ok := extractInt64FromMap(payload, "publish_batch_id"); ok && publishBatchID > 0 {
|
|
var articleID int64
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT article_id
|
|
FROM publish_batches
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, publishBatchID, tenantID).Scan(&articleID)
|
|
if err == nil && articleID > 0 {
|
|
return articleID, nil
|
|
}
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, response.ErrInternal(50114, "desktop_publish_batch_lookup_failed", "failed to lookup desktop publish batch")
|
|
}
|
|
}
|
|
|
|
return 0, response.ErrBadRequest(40096, "desktop_task_article_missing", "desktop publish task is missing article_id")
|
|
}
|
|
|
|
func resolveRetryArticleIDFromPayload(payload map[string]any) (int64, bool) {
|
|
if articleID, ok := extractInt64FromMap(payload, "article_id"); ok && articleID > 0 {
|
|
return articleID, true
|
|
}
|
|
if len(payload) == 0 {
|
|
return 0, false
|
|
}
|
|
nestedContentRef, ok := payload["content_ref"].(map[string]any)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
if articleID, ok := extractInt64FromMap(nestedContentRef, "article_id", "id"); ok && articleID > 0 {
|
|
return articleID, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// publishDispatchAvailable pushes a per-client dispatch frame onto the topic
|
|
// exchange. Every tenant-api instance consumes the exchange and forwards the
|
|
// frame to the WebSocket owning the target client id. This is the primary
|
|
// signal the desktop client reacts to; the fanout event above is kept only so
|
|
// that other UIs (admin-web) see the same task lifecycle.
|
|
func (s *PublishJobService) publishDispatchAvailable(task *repository.DesktopTask, event stream.DesktopDispatchEvent) {
|
|
if s == nil || s.messaging == nil || task == nil {
|
|
return
|
|
}
|
|
if err := publishDesktopDispatchEvent(context.Background(), s.messaging, s.logger, event); err != nil {
|
|
s.logWarn("desktop dispatch publish failed", err,
|
|
zap.String("task_id", task.DesktopID.String()),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) {
|
|
if s.logger == nil || err == nil {
|
|
return
|
|
}
|
|
fields = append(fields, zap.Error(err))
|
|
s.logger.Warn(message, fields...)
|
|
}
|
|
|
|
func (s *PublishJobService) resolveTargetClientID(ctx context.Context, account *repository.DesktopAccount) *uuid.UUID {
|
|
if account == nil {
|
|
return nil
|
|
}
|
|
|
|
presence := loadDesktopAccountPresence(ctx, s.redis, []uuid.UUID{account.DesktopID})
|
|
if clientID, ok := presence[account.DesktopID]; ok {
|
|
return &clientID
|
|
}
|
|
|
|
if account.ClientID == nil {
|
|
return nil
|
|
}
|
|
|
|
clientID := *account.ClientID
|
|
return &clientID
|
|
}
|
|
|
|
func stringPointerValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(*value)
|
|
}
|