1171 lines
39 KiB
Go
1171 lines
39 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"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"
|
|
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
|
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"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"
|
|
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
|
|
"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
|
|
compliance *tenantcompliance.Service
|
|
}
|
|
|
|
func NewPublishJobService(
|
|
pool *pgxpool.Pool,
|
|
messaging *rabbitmq.Client,
|
|
redis *goredis.Client,
|
|
logger *zap.Logger,
|
|
) *PublishJobService {
|
|
return NewPublishJobServiceWithConfig(pool, messaging, redis, logger, config.NewStaticProvider(&config.Config{
|
|
Compliance: config.ComplianceConfig{Enabled: true},
|
|
}))
|
|
}
|
|
|
|
func NewPublishJobServiceWithConfig(
|
|
pool *pgxpool.Pool,
|
|
messaging *rabbitmq.Client,
|
|
redis *goredis.Client,
|
|
logger *zap.Logger,
|
|
cfg config.Provider,
|
|
) *PublishJobService {
|
|
return &PublishJobService{
|
|
pool: pool,
|
|
messaging: messaging,
|
|
redis: redis,
|
|
logger: logger,
|
|
compliance: tenantcompliance.NewService(pool, cfg, logger).WithRabbitMQ(messaging),
|
|
}
|
|
}
|
|
|
|
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"`
|
|
ArticleVersionID *int64 `json:"article_version_id,omitempty"`
|
|
AckRecordID *int64 `json:"ack_record_id,omitempty"`
|
|
}
|
|
|
|
type CreatePublishJobResponse struct {
|
|
// JobID is set only when this request created a new publish job. It is empty
|
|
// when every requested target was already covered by an existing publish task.
|
|
JobID string `json:"job_id"`
|
|
// TaskIDs is the target-ordered union of created and already-existing tasks.
|
|
TaskIDs []string `json:"task_ids"`
|
|
CreatedTaskIDs []string `json:"created_task_ids"`
|
|
ExistingTaskIDs []string `json:"existing_task_ids"`
|
|
RequeuedTaskIDs []string `json:"requeued_task_ids,omitempty"`
|
|
}
|
|
|
|
type publishTarget struct {
|
|
account *repository.DesktopAccount
|
|
accountSeed *platformAccountSeed
|
|
targetClientID uuid.UUID
|
|
dedupKey string
|
|
}
|
|
|
|
type existingPublishRecordTarget struct {
|
|
TaskID uuid.UUID
|
|
PlatformAccountID int64
|
|
}
|
|
|
|
const desktopPublishDedupActiveConstraint = "uq_desktop_tasks_publish_dedup_active"
|
|
|
|
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")
|
|
}
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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, err := parseUniquePublishAccountIDs(req.Accounts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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, brandID, articleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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")
|
|
}
|
|
if account.DeletedAt != nil || account.DeleteRequestedAt != nil {
|
|
return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts are being unbound or no longer exist")
|
|
}
|
|
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,
|
|
dedupKey: desktopPublishDedupKey(actor.TenantID, actor.PrimaryWorkspaceID, articleID, accountSeed.PlatformID, account.DesktopID),
|
|
})
|
|
}
|
|
|
|
effectiveVersionID, err := s.compliance.ResolvePublishableVersion(ctx, actor.TenantID, articleID, req.ArticleVersionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targetPlatforms := publishTargetPlatforms(targets)
|
|
gate, err := s.compliance.GateForPublish(ctx, tenantcompliance.GateForPublishRequest{
|
|
TenantID: actor.TenantID,
|
|
ArticleID: articleID,
|
|
ArticleVersionID: effectiveVersionID,
|
|
ActorID: actor.UserID,
|
|
TargetPlatforms: targetPlatforms,
|
|
AckRecordID: req.AckRecordID,
|
|
TriggerSource: "publish_gate",
|
|
})
|
|
if err != nil {
|
|
return nil, compliancePublishError(err, gate)
|
|
}
|
|
if gate != nil {
|
|
switch gate.Decision {
|
|
case sharedcompliance.GateDecisionBlock:
|
|
return nil, compliancePublishError(response.ErrConflict(41001, "compliance_blocked", "content compliance blocked this publish request"), gate)
|
|
case sharedcompliance.GateDecisionNeedsAck:
|
|
return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "content compliance requires acknowledgement before publishing"), gate)
|
|
}
|
|
}
|
|
|
|
dedupKeys := desktopPublishDedupKeysFromTargets(targets)
|
|
if len(targets) > 0 && len(dedupKeys) == 0 {
|
|
return nil, response.ErrInternal(50130, "desktop_publish_dedup_key_missing", "failed to build desktop publish deduplication key")
|
|
}
|
|
if err := lockDesktopPublishDedupKeys(ctx, tx, dedupKeys); err != nil {
|
|
return nil, err
|
|
}
|
|
releasedPublishOutcomes := make([]*desktopPublishSyncOutcome, 0)
|
|
if len(dedupKeys) > 0 {
|
|
outcomes, releaseErr := releaseDefinitiveFailedUnknownPublishTasks(ctx, tx, actor.TenantID, actor.PrimaryWorkspaceID, dedupKeys)
|
|
if releaseErr != nil {
|
|
return nil, releaseErr
|
|
}
|
|
releasedPublishOutcomes = append(releasedPublishOutcomes, outcomes...)
|
|
}
|
|
existingTasks, err := loadExistingPublishRecordTargets(ctx, tx, actor.TenantID, actor.PrimaryWorkspaceID, articleID, targets)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newTargets := make([]publishTarget, 0, len(targets))
|
|
taskIDsByAccount := make(map[uuid.UUID]string, len(targets))
|
|
existingTaskIDs := make([]string, 0, len(targets))
|
|
responseJobID := ""
|
|
for _, target := range targets {
|
|
if existing, ok := existingTasks[target.accountSeed.ID]; ok {
|
|
taskIDsByAccount[target.account.DesktopID] = existing.TaskID.String()
|
|
existingTaskIDs = append(existingTaskIDs, existing.TaskID.String())
|
|
continue
|
|
}
|
|
newTargets = append(newTargets, target)
|
|
}
|
|
if len(newTargets) == 0 {
|
|
// No new task or job was created, so leave compliance acknowledgements
|
|
// untouched and let the deferred rollback release xact locks.
|
|
taskIDs := publishTaskIDsInTargetOrder(targets, taskIDsByAccount)
|
|
return &CreatePublishJobResponse{
|
|
JobID: responseJobID,
|
|
TaskIDs: taskIDs,
|
|
CreatedTaskIDs: []string{},
|
|
ExistingTaskIDs: existingTaskIDs,
|
|
}, nil
|
|
}
|
|
|
|
accountSeeds := make([]platformAccountSeed, 0, len(newTargets))
|
|
for _, target := range newTargets {
|
|
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")
|
|
}
|
|
|
|
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,
|
|
ArticleID: &articleID,
|
|
ArticleVersionID: &effectiveVersionID,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
|
|
}
|
|
responseJobID = job.DesktopID.String()
|
|
|
|
publishBatchID, publishRecordIDs, err := createPublishBatchRecords(
|
|
ctx,
|
|
tx,
|
|
actor.TenantID,
|
|
actor.UserID,
|
|
articleID,
|
|
"publish",
|
|
articleMeta.CoverAssetURL,
|
|
accountSeeds,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
createdTasks := make([]*repository.DesktopTask, 0, len(newTargets))
|
|
createdTaskIDs := make([]string, 0, len(newTargets))
|
|
for _, target := range newTargets {
|
|
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,
|
|
"article_version_id": effectiveVersionID,
|
|
"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",
|
|
DedupKey: &target.dedupKey,
|
|
})
|
|
if createErr != nil {
|
|
if isPostgresUniqueViolation(createErr, desktopPublishDedupActiveConstraint) {
|
|
return nil, response.ErrConflict(40995, "desktop_publish_duplicate", "article has already been submitted for this platform account")
|
|
}
|
|
return nil, response.ErrInternal(50099, "desktop_task_create_failed", "failed to create desktop publish task")
|
|
}
|
|
|
|
createdTasks = append(createdTasks, task)
|
|
taskIDsByAccount[target.account.DesktopID] = task.DesktopID.String()
|
|
createdTaskIDs = append(createdTaskIDs, task.DesktopID.String())
|
|
}
|
|
|
|
if err := s.consumePublishAckTx(ctx, tx, req.AckRecordID, gate, actor.TenantID, articleID, effectiveVersionID, &jobID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 _, outcome := range releasedPublishOutcomes {
|
|
if outcome != nil {
|
|
invalidateArticleCaches(ctx, s.cache, outcome.TenantID, &outcome.ArticleID)
|
|
}
|
|
}
|
|
|
|
for _, task := range createdTasks {
|
|
dispatchEvent := DesktopDispatchEventFromTask(task, "task_available")
|
|
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: dispatchEvent.Title,
|
|
BusinessDate: dispatchEvent.BusinessDate,
|
|
SchedulerGroupKey: dispatchEvent.SchedulerGroupKey,
|
|
QuestionText: dispatchEvent.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, dispatchEvent)
|
|
}
|
|
|
|
return &CreatePublishJobResponse{
|
|
JobID: responseJobID,
|
|
TaskIDs: publishTaskIDsInTargetOrder(targets, taskIDsByAccount),
|
|
CreatedTaskIDs: createdTaskIDs,
|
|
ExistingTaskIDs: existingTaskIDs,
|
|
}, nil
|
|
}
|
|
|
|
func compliancePublishError(err error, gate *tenantcompliance.GateOutcome) error {
|
|
appErr := response.Normalize(err)
|
|
if gate == nil || gate.Result == nil {
|
|
return appErr
|
|
}
|
|
detail, marshalErr := json.Marshal(gate.Result)
|
|
if marshalErr != nil {
|
|
return appErr
|
|
}
|
|
copyErr := *appErr
|
|
copyErr.Detail = string(detail)
|
|
return ©Err
|
|
}
|
|
|
|
func (s *PublishJobService) consumePublishAckTx(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
ackRecordID *int64,
|
|
gate *tenantcompliance.GateOutcome,
|
|
tenantID int64,
|
|
articleID int64,
|
|
articleVersionID int64,
|
|
consumedByJobID *uuid.UUID,
|
|
) error {
|
|
if ackRecordID == nil {
|
|
return nil
|
|
}
|
|
if gate == nil || gate.Result == nil {
|
|
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record cannot be consumed without a matching compliance result")
|
|
}
|
|
consumed, consumeErr := s.compliance.ConsumeAckTx(ctx, tx, *ackRecordID, tenantcompliance.ConsumeAckQuery{
|
|
TenantID: tenantID,
|
|
ArticleID: articleID,
|
|
ArticleVersionID: articleVersionID,
|
|
CheckRecordID: gate.Result.RecordID,
|
|
ContentHash: gate.Result.ContentHash,
|
|
PolicyFingerprint: gate.Result.PolicyFingerprint,
|
|
ConsumedByJobID: consumedByJobID,
|
|
})
|
|
if consumeErr != nil {
|
|
return response.ErrInternal(51004, "compliance_ack_lookup_failed", "failed to consume compliance ack")
|
|
}
|
|
if !consumed {
|
|
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record is expired, consumed, or no longer matches this publish request")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseUniquePublishAccountIDs(accounts []CreatePublishJobAccountRequest) ([]uuid.UUID, error) {
|
|
if len(accounts) == 0 {
|
|
return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "at least one account is required")
|
|
}
|
|
seen := make(map[uuid.UUID]struct{}, len(accounts))
|
|
ids := make([]uuid.UUID, 0, len(accounts))
|
|
for _, accountReq := range 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")
|
|
}
|
|
if _, ok := seen[accountID]; ok {
|
|
continue
|
|
}
|
|
seen[accountID] = struct{}{}
|
|
ids = append(ids, accountID)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "at least one account is required")
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func desktopPublishDedupKey(tenantID, workspaceID, articleID int64, platformID string, accountID uuid.UUID) string {
|
|
parts := []string{
|
|
"publish",
|
|
strconv.FormatInt(tenantID, 10),
|
|
strconv.FormatInt(workspaceID, 10),
|
|
strconv.FormatInt(articleID, 10),
|
|
strings.TrimSpace(platformID),
|
|
accountID.String(),
|
|
}
|
|
encoded, err := json.Marshal(parts)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
func desktopPublishDedupKeysFromTargets(targets []publishTarget) []string {
|
|
seen := make(map[string]struct{}, len(targets))
|
|
keys := make([]string, 0, len(targets))
|
|
for _, target := range targets {
|
|
key := strings.TrimSpace(target.dedupKey)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func publishTargetPlatforms(targets []publishTarget) []string {
|
|
seen := make(map[string]struct{}, len(targets))
|
|
platforms := make([]string, 0, len(targets))
|
|
for _, target := range targets {
|
|
if target.accountSeed == nil {
|
|
continue
|
|
}
|
|
platformID := strings.TrimSpace(target.accountSeed.PlatformID)
|
|
if platformID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[platformID]; ok {
|
|
continue
|
|
}
|
|
seen[platformID] = struct{}{}
|
|
platforms = append(platforms, platformID)
|
|
}
|
|
sort.Strings(platforms)
|
|
return platforms
|
|
}
|
|
|
|
func lockDesktopPublishDedupKeys(ctx context.Context, tx pgx.Tx, keys []string) error {
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT pg_advisory_xact_lock(hashtextextended(dedup_key, 0))
|
|
FROM (
|
|
SELECT dedup_key
|
|
FROM unnest($1::text[]) AS dedup_keys(dedup_key)
|
|
ORDER BY dedup_key
|
|
) AS ordered_dedup_keys
|
|
`, keys); err != nil {
|
|
return response.ErrInternal(50128, "desktop_publish_dedup_lock_failed", "failed to lock desktop publish deduplication key")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func releaseDefinitiveFailedUnknownPublishTasks(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
tenantID int64,
|
|
workspaceID int64,
|
|
dedupKeys []string,
|
|
) ([]*desktopPublishSyncOutcome, error) {
|
|
if len(dedupKeys) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT desktop_id, tenant_id, workspace_id, platform_id, kind, payload, status, result, error
|
|
FROM desktop_tasks
|
|
WHERE tenant_id = $1
|
|
AND workspace_id = $2
|
|
AND kind = 'publish'
|
|
AND status = 'unknown'
|
|
AND dedup_key = ANY($3::text[])
|
|
FOR UPDATE
|
|
`, tenantID, workspaceID, dedupKeys)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50131, "desktop_publish_dedup_cleanup_failed", "failed to inspect stale publish deduplication tasks")
|
|
}
|
|
defer rows.Close()
|
|
|
|
tasks := make([]repository.DesktopTask, 0)
|
|
for rows.Next() {
|
|
var task repository.DesktopTask
|
|
if scanErr := rows.Scan(
|
|
&task.DesktopID,
|
|
&task.TenantID,
|
|
&task.WorkspaceID,
|
|
&task.Platform,
|
|
&task.Kind,
|
|
&task.Payload,
|
|
&task.Status,
|
|
&task.Result,
|
|
&task.Error,
|
|
); scanErr != nil {
|
|
return nil, response.ErrInternal(50131, "desktop_publish_dedup_cleanup_failed", "failed to parse stale publish deduplication task")
|
|
}
|
|
tasks = append(tasks, task)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, response.ErrInternal(50131, "desktop_publish_dedup_cleanup_failed", "failed to iterate stale publish deduplication tasks")
|
|
}
|
|
|
|
outcomes := make([]*desktopPublishSyncOutcome, 0)
|
|
for index := range tasks {
|
|
task := tasks[index]
|
|
if !publishUnknownTaskHasDefinitiveFailure(&task) {
|
|
continue
|
|
}
|
|
updated, updateErr := markUnknownPublishTaskDefinitiveFailed(ctx, tx, &task)
|
|
if updateErr != nil {
|
|
return nil, updateErr
|
|
}
|
|
outcome, syncErr := syncDesktopPublishTaskState(ctx, tx, updated)
|
|
if syncErr != nil {
|
|
return nil, syncErr
|
|
}
|
|
if outcome != nil {
|
|
outcomes = append(outcomes, outcome)
|
|
}
|
|
}
|
|
return outcomes, nil
|
|
}
|
|
|
|
func markUnknownPublishTaskDefinitiveFailed(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
task *repository.DesktopTask,
|
|
) (*repository.DesktopTask, error) {
|
|
if task == nil {
|
|
return nil, response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found")
|
|
}
|
|
row := tx.QueryRow(ctx, `
|
|
UPDATE desktop_tasks
|
|
SET status = 'failed',
|
|
active_attempt_id = NULL,
|
|
lease_token_hash = NULL,
|
|
lease_expires_at = NULL,
|
|
publish_submit_started_at = NULL,
|
|
updated_at = NOW()
|
|
WHERE desktop_id = $1
|
|
AND workspace_id = $2
|
|
AND status = 'unknown'
|
|
RETURNING desktop_id, tenant_id, workspace_id, platform_id, kind, payload, status, result, error
|
|
`, task.DesktopID, task.WorkspaceID)
|
|
var updated repository.DesktopTask
|
|
if err := row.Scan(
|
|
&updated.DesktopID,
|
|
&updated.TenantID,
|
|
&updated.WorkspaceID,
|
|
&updated.Platform,
|
|
&updated.Kind,
|
|
&updated.Payload,
|
|
&updated.Status,
|
|
&updated.Result,
|
|
&updated.Error,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrConflict(40985, "desktop_task_reconcile_conflict", "desktop task is not waiting for reconcile")
|
|
}
|
|
return nil, response.ErrInternal(50131, "desktop_publish_dedup_cleanup_failed", "failed to release stale publish deduplication task")
|
|
}
|
|
return &updated, nil
|
|
}
|
|
|
|
func publishUnknownTaskHasDefinitiveFailure(task *repository.DesktopTask) bool {
|
|
if task == nil || task.Kind != "publish" || task.Status != "unknown" {
|
|
return false
|
|
}
|
|
errorPayload := unmarshalJSONObject(task.Error)
|
|
if boolValueFromMap(errorPayload, "publish_submit_uncertain") {
|
|
return false
|
|
}
|
|
combined := strings.ToLower(strings.Join([]string{
|
|
stringPointerValue(extractStringPointer(errorPayload, "code", "error_code")),
|
|
stringPointerValue(extractStringPointer(errorPayload, "message", "error_msg", "detail")),
|
|
}, " "))
|
|
if combined == "" {
|
|
return false
|
|
}
|
|
return definitivePublishFailurePattern.MatchString(combined)
|
|
}
|
|
|
|
func boolValueFromMap(payload map[string]any, key string) bool {
|
|
if payload == nil {
|
|
return false
|
|
}
|
|
value, ok := payload[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case string:
|
|
normalized := strings.TrimSpace(strings.ToLower(typed))
|
|
return normalized == "true" || normalized == "1" || normalized == "yes"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var definitivePublishFailurePattern = regexp.MustCompile(`csrf|forbidden|\b403\b|not_logged_in|login_required|token_missing|cookie_missing|challenge_required|risk_control|captcha|验证码|滑块|人机验证|安全验证|风控|风险|参数错误|内容为空|image_fetch_failed|cover_fetch_failed|upload_failed|signature_failed`)
|
|
|
|
func loadExistingPublishRecordTargets(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
tenantID int64,
|
|
workspaceID int64,
|
|
articleID int64,
|
|
targets []publishTarget,
|
|
) (map[int64]existingPublishRecordTarget, error) {
|
|
if len(targets) == 0 {
|
|
return map[int64]existingPublishRecordTarget{}, nil
|
|
}
|
|
|
|
platformAccountIDs := make([]int64, 0, len(targets))
|
|
platformAccountIDSet := make(map[int64]struct{}, len(targets))
|
|
for _, target := range targets {
|
|
if target.accountSeed == nil || target.accountSeed.ID <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := platformAccountIDSet[target.accountSeed.ID]; ok {
|
|
continue
|
|
}
|
|
platformAccountIDSet[target.accountSeed.ID] = struct{}{}
|
|
platformAccountIDs = append(platformAccountIDs, target.accountSeed.ID)
|
|
}
|
|
if len(platformAccountIDs) == 0 {
|
|
return map[int64]existingPublishRecordTarget{}, nil
|
|
}
|
|
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT DISTINCT ON (pr.platform_account_id)
|
|
dt.desktop_id,
|
|
pr.platform_account_id
|
|
FROM publish_records pr
|
|
JOIN platform_accounts pa
|
|
ON pa.id = pr.platform_account_id
|
|
AND pa.tenant_id = pr.tenant_id
|
|
JOIN desktop_tasks dt
|
|
ON dt.tenant_id = pr.tenant_id
|
|
AND dt.tenant_id = $1
|
|
AND dt.workspace_id = $2
|
|
AND dt.kind = 'publish'
|
|
AND dt.payload ? 'publish_record_id'
|
|
-- Keep this expression aligned with idx_desktop_tasks_publish_record_payload.
|
|
AND CASE
|
|
WHEN (dt.payload->>'publish_record_id') ~ '^[0-9]+$'
|
|
THEN (dt.payload->>'publish_record_id')::bigint
|
|
ELSE NULL
|
|
END = pr.id
|
|
WHERE pr.tenant_id = $1
|
|
AND pa.workspace_id = $2
|
|
AND pr.article_id = $3
|
|
AND pr.platform_account_id = ANY($4::bigint[])
|
|
AND pr.deleted_at IS NULL
|
|
AND (pr.status IN ('queued', 'publishing', 'success') OR dt.status = 'unknown')
|
|
AND dt.status IN ('queued', 'in_progress', 'succeeded', 'unknown')
|
|
ORDER BY pr.platform_account_id,
|
|
CASE
|
|
WHEN pr.status = 'success' OR dt.status = 'succeeded' THEN 0
|
|
WHEN pr.status IN ('queued', 'publishing') OR dt.status IN ('queued', 'in_progress') THEN 1
|
|
ELSE 2
|
|
END,
|
|
GREATEST(
|
|
COALESCE(dt.updated_at, '-infinity'::timestamptz),
|
|
COALESCE(pr.updated_at, '-infinity'::timestamptz)
|
|
) DESC,
|
|
GREATEST(
|
|
COALESCE(dt.created_at, '-infinity'::timestamptz),
|
|
COALESCE(pr.created_at, '-infinity'::timestamptz)
|
|
) DESC,
|
|
dt.created_at DESC
|
|
`, tenantID, workspaceID, articleID, platformAccountIDs)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50129, "desktop_publish_dedup_lookup_failed", "failed to inspect existing publish records")
|
|
}
|
|
defer rows.Close()
|
|
|
|
existing := make(map[int64]existingPublishRecordTarget, len(platformAccountIDs))
|
|
for rows.Next() {
|
|
var item existingPublishRecordTarget
|
|
if scanErr := rows.Scan(
|
|
&item.TaskID,
|
|
&item.PlatformAccountID,
|
|
); scanErr != nil {
|
|
return nil, response.ErrInternal(50129, "desktop_publish_dedup_lookup_failed", "failed to parse existing publish record")
|
|
}
|
|
existing[item.PlatformAccountID] = item
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, response.ErrInternal(50129, "desktop_publish_dedup_lookup_failed", "failed to iterate existing publish records")
|
|
}
|
|
return existing, nil
|
|
}
|
|
|
|
func publishTaskIDsInTargetOrder(targets []publishTarget, taskIDsByAccount map[uuid.UUID]string) []string {
|
|
taskIDs := make([]string, 0, len(targets))
|
|
seen := make(map[string]struct{}, len(targets))
|
|
for _, target := range targets {
|
|
taskID := strings.TrimSpace(taskIDsByAccount[target.account.DesktopID])
|
|
if taskID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[taskID]; ok {
|
|
continue
|
|
}
|
|
seen[taskID] = struct{}{}
|
|
taskIDs = append(taskIDs, taskID)
|
|
}
|
|
return taskIDs
|
|
}
|
|
|
|
func isPostgresUniqueViolation(err error, constraintName string) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraintName
|
|
}
|
|
|
|
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")
|
|
}
|
|
if shouldRequeuePublishTaskInsteadOfCreatingNew(task) {
|
|
return s.requeueUnknownPublishTask(ctx, task)
|
|
}
|
|
|
|
payload := unmarshalJSONObject(task.Payload)
|
|
articleID, err := s.resolveRetryArticleID(ctx, client.TenantID, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
brandID, err := s.resolveRetryArticleBrandID(ctx, client.TenantID, articleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
title := strings.TrimSpace(stringPointerValue(extractStringPointer(payload, "title")))
|
|
if title == "" {
|
|
title = "文章发布"
|
|
}
|
|
|
|
publishCtx := auth.WithCurrentBrandID(ctx, brandID)
|
|
return s.Create(publishCtx, 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) RetryTenantDesktopTask(
|
|
ctx context.Context,
|
|
actor auth.Actor,
|
|
taskID uuid.UUID,
|
|
) (*CreatePublishJobResponse, error) {
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
if actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 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")
|
|
}
|
|
|
|
taskRepo := repository.NewDesktopTaskRepository(s.pool)
|
|
task, err := taskRepo.GetByDesktopID(ctx, taskID, 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.authorizePublishTaskForActor(ctx, actor, workspaceID, 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")
|
|
}
|
|
if shouldRequeuePublishTaskInsteadOfCreatingNew(task) {
|
|
return s.requeueUnknownPublishTask(ctx, task)
|
|
}
|
|
|
|
payload := unmarshalJSONObject(task.Payload)
|
|
articleID, err := s.resolveRetryArticleID(ctx, actor.TenantID, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
brandID, err := s.resolveRetryArticleBrandID(ctx, actor.TenantID, articleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
title := strings.TrimSpace(stringPointerValue(extractStringPointer(payload, "title")))
|
|
if title == "" {
|
|
title = "文章发布"
|
|
}
|
|
|
|
publishCtx := auth.WithCurrentBrandID(ctx, brandID)
|
|
return s.Create(publishCtx, auth.Actor{
|
|
TenantID: actor.TenantID,
|
|
UserID: actor.UserID,
|
|
PrimaryWorkspaceID: workspaceID,
|
|
Role: actor.Role,
|
|
}, CreatePublishJobRequest{
|
|
Title: title,
|
|
ContentRef: map[string]any{
|
|
"article_id": articleID,
|
|
},
|
|
Accounts: []CreatePublishJobAccountRequest{
|
|
{AccountID: task.TargetAccountID.String()},
|
|
},
|
|
})
|
|
}
|
|
|
|
func shouldRequeuePublishTaskInsteadOfCreatingNew(task *repository.DesktopTask) bool {
|
|
return task != nil &&
|
|
task.Kind == "publish" &&
|
|
task.Status == "unknown" &&
|
|
!publishUnknownTaskHasDefinitiveFailure(task)
|
|
}
|
|
|
|
func (s *PublishJobService) requeueUnknownPublishTask(
|
|
ctx context.Context,
|
|
task *repository.DesktopTask,
|
|
) (*CreatePublishJobResponse, error) {
|
|
if task == nil {
|
|
return nil, response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found")
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50105, "desktop_task_reconcile_begin_failed", "failed to start desktop task reconcile")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
taskRepo := repository.NewDesktopTaskRepository(tx)
|
|
requeued, err := taskRepo.Reconcile(ctx, task.DesktopID, task.WorkspaceID, "retry", nil, nil)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrConflict(40985, "desktop_task_reconcile_conflict", "desktop task is not waiting for reconcile")
|
|
}
|
|
return nil, response.ErrInternal(50094, "desktop_task_reconcile_failed", "failed to reconcile desktop task")
|
|
}
|
|
|
|
publishOutcome, err := syncDesktopPublishTaskState(ctx, tx, requeued)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, response.ErrInternal(50106, "desktop_task_reconcile_commit_failed", "failed to commit desktop task reconcile")
|
|
}
|
|
|
|
if publishOutcome != nil {
|
|
invalidateArticleCaches(ctx, s.cache, publishOutcome.TenantID, &publishOutcome.ArticleID)
|
|
}
|
|
publishDesktopTaskLifecycleEvent(ctx, s.messaging, s.logger, requeued, "task_available")
|
|
return createPublishJobResponseForRequeuedTask(requeued), nil
|
|
}
|
|
|
|
func createPublishJobResponseForRequeuedTask(task *repository.DesktopTask) *CreatePublishJobResponse {
|
|
if task == nil {
|
|
return &CreatePublishJobResponse{
|
|
TaskIDs: []string{},
|
|
CreatedTaskIDs: []string{},
|
|
ExistingTaskIDs: []string{},
|
|
RequeuedTaskIDs: []string{},
|
|
}
|
|
}
|
|
taskID := task.DesktopID.String()
|
|
return &CreatePublishJobResponse{
|
|
JobID: "",
|
|
TaskIDs: []string{taskID},
|
|
CreatedTaskIDs: []string{},
|
|
ExistingTaskIDs: []string{},
|
|
RequeuedTaskIDs: []string{taskID},
|
|
}
|
|
}
|
|
|
|
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) authorizePublishTaskForActor(
|
|
ctx context.Context,
|
|
actor auth.Actor,
|
|
workspaceID int64,
|
|
task *repository.DesktopTask,
|
|
) error {
|
|
if task == nil || actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 {
|
|
return response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
|
}
|
|
|
|
var ownsTask bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM desktop_publish_jobs
|
|
WHERE tenant_id = $1
|
|
AND desktop_id = $2
|
|
AND workspace_id = $3
|
|
AND created_by_user_id = $4
|
|
)
|
|
`, actor.TenantID, task.JobID, workspaceID, actor.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) resolveRetryArticleBrandID(ctx context.Context, tenantID int64, articleID int64) (int64, error) {
|
|
var brandID int64
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT brand_id
|
|
FROM articles
|
|
WHERE id = $1
|
|
AND tenant_id = $2
|
|
AND deleted_at IS NULL
|
|
`, articleID, tenantID).Scan(&brandID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, response.ErrNotFound(40411, "article_not_found", "article not found")
|
|
}
|
|
return 0, response.ErrInternal(50037, "article_check_failed", "failed to validate article")
|
|
}
|
|
if brandID <= 0 {
|
|
return 0, response.ErrInternal(50037, "article_check_failed", "failed to validate article")
|
|
}
|
|
return brandID, 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 AND deleted_at IS NULL
|
|
`, 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)
|
|
}
|