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) }