2026-04-19 14:18:20 +08:00
package app
import (
"context"
2026-05-05 20:48:14 +08:00
"encoding/json"
2026-04-19 14:18:20 +08:00
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
2026-04-20 09:52:48 +08:00
goredis "github.com/redis/go-redis/v9"
2026-04-19 14:18:20 +08:00
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
2026-04-20 09:52:48 +08:00
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
2026-05-05 20:48:14 +08:00
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
"github.com/geo-platform/tenant-api/internal/shared/config"
2026-04-19 14:18:20 +08:00
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/response"
2026-04-20 19:46:59 +08:00
"github.com/geo-platform/tenant-api/internal/shared/stream"
2026-05-05 20:48:14 +08:00
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
2026-04-19 14:18:20 +08:00
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type PublishJobService struct {
2026-05-05 20:48:14 +08:00
pool * pgxpool . Pool
messaging * rabbitmq . Client
redis * goredis . Client
logger * zap . Logger
cache sharedcache . Cache
compliance * tenantcompliance . Service
2026-04-19 14:18:20 +08:00
}
2026-04-20 09:52:48 +08:00
func NewPublishJobService (
pool * pgxpool . Pool ,
messaging * rabbitmq . Client ,
redis * goredis . Client ,
logger * zap . Logger ,
2026-05-05 20:48:14 +08:00
) * 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 ,
2026-04-20 09:52:48 +08:00
) * PublishJobService {
2026-04-19 14:18:20 +08:00
return & PublishJobService {
2026-05-05 20:48:14 +08:00
pool : pool ,
messaging : messaging ,
redis : redis ,
logger : logger ,
compliance : tenantcompliance . NewService ( pool , cfg , logger ) . WithRabbitMQ ( messaging ) ,
2026-04-19 14:18:20 +08:00
}
}
2026-04-20 09:52:48 +08:00
func ( s * PublishJobService ) WithCache ( c sharedcache . Cache ) * PublishJobService {
s . cache = c
return s
}
2026-04-19 14:18:20 +08:00
type CreatePublishJobAccountRequest struct {
AccountID string ` json:"account_id" binding:"required" `
}
type CreatePublishJobRequest struct {
2026-05-05 20:48:14 +08:00
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" `
2026-04-19 14:18:20 +08:00
}
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" )
}
2026-04-20 09:52:48 +08:00
articleID , err := resolvePublishArticleID ( req . ContentRef )
if err != nil {
return nil , err
}
2026-04-19 14:18:20 +08:00
2026-04-27 21:33:36 +08:00
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 )
2026-04-19 14:18:20 +08:00
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 )
2026-04-20 09:52:48 +08:00
articleMeta , err := loadPublishableArticleMeta ( ctx , tx , actor . TenantID , articleID )
2026-04-19 14:18:20 +08:00
if err != nil {
2026-04-20 09:52:48 +08:00
return nil , err
}
type publishTarget struct {
account * repository . DesktopAccount
accountSeed * platformAccountSeed
targetClientID uuid . UUID
2026-04-19 14:18:20 +08:00
}
2026-04-20 09:52:48 +08:00
targets := make ( [ ] publishTarget , 0 , len ( req . Accounts ) )
2026-05-05 20:48:14 +08:00
targetPlatformSet := make ( map [ string ] struct { } )
2026-04-19 14:18:20 +08:00
2026-04-27 21:33:36 +08:00
for _ , accountID := range requestedAccountIDs {
2026-04-19 14:18:20 +08:00
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" )
}
2026-04-27 21:33:36 +08:00
accountHealth := account . Health
if report , ok := runtimeHealth [ account . DesktopID ] ; ok && strings . TrimSpace ( report . Health ) != "" {
2026-04-29 15:58:25 +08:00
accountHealth = effectiveDesktopAccountRuntimeHealth ( report )
2026-04-27 21:33:36 +08:00
}
if accountHealth != "live" {
2026-04-20 09:52:48 +08:00
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" )
2026-04-19 14:18:20 +08:00
}
2026-04-20 09:52:48 +08:00
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 ,
} )
2026-05-05 20:48:14 +08:00
if platformID := strings . TrimSpace ( accountSeed . PlatformID ) ; platformID != "" {
targetPlatformSet [ platformID ] = struct { } { }
}
}
effectiveVersionID , err := s . compliance . ResolvePublishableVersion ( ctx , actor . TenantID , articleID , req . ArticleVersionID )
if err != nil {
return nil , err
}
targetPlatforms := make ( [ ] string , 0 , len ( targetPlatformSet ) )
for platformID := range targetPlatformSet {
targetPlatforms = append ( targetPlatforms , platformID )
}
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 )
}
2026-04-20 09:52:48 +08:00
}
jobID := uuid . New ( )
job , err := taskRepo . CreatePublishJob ( ctx , repository . CreateDesktopPublishJobParams {
2026-05-05 20:48:14 +08:00
DesktopID : jobID ,
TenantID : actor . TenantID ,
WorkspaceID : actor . PrimaryWorkspaceID ,
CreatedByUserID : actor . UserID ,
Title : req . Title ,
ContentRef : contentRefJSON ,
ScheduledAt : req . ScheduledAt ,
ArticleID : & articleID ,
ArticleVersionID : & effectiveVersionID ,
2026-04-20 09:52:48 +08:00
} )
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 ) ) == "" {
2026-05-01 10:21:46 +08:00
return nil , response . ErrBadRequest ( 40044 , "publish_cover_required" , "selected publishing platform requires a cover image" )
2026-04-20 09:52:48 +08:00
}
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 ]
2026-04-19 14:18:20 +08:00
payloadJSON , payloadErr := marshalOptionalJSON ( map [ string ] any {
2026-04-20 09:52:48 +08:00
"title" : req . Title ,
"content_ref" : req . ContentRef ,
"account_id" : target . account . DesktopID . String ( ) ,
"platform" : target . account . Platform ,
"article_id" : articleID ,
2026-05-05 20:48:14 +08:00
"article_version_id" : effectiveVersionID ,
2026-04-20 09:52:48 +08:00
"publish_batch_id" : publishBatchID ,
"publish_record_id" : publishRecordID ,
"platform_account_id" : target . accountSeed . ID ,
2026-04-19 14:18:20 +08:00
} )
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 ,
2026-04-20 09:52:48 +08:00
TargetAccountID : target . account . DesktopID ,
TargetClientID : target . targetClientID ,
Platform : target . account . Platform ,
2026-04-19 14:18:20 +08:00
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 ( ) )
}
2026-05-05 20:48:14 +08:00
if req . AckRecordID != nil {
if gate == nil || gate . Result == nil {
return nil , response . ErrBadRequest ( 41003 , "compliance_ack_mismatch" , "ack record cannot be consumed without a matching compliance result" )
}
consumed , consumeErr := s . compliance . ConsumeAckTx ( ctx , tx , * req . AckRecordID , tenantcompliance . ConsumeAckQuery {
TenantID : actor . TenantID ,
ArticleID : articleID ,
ArticleVersionID : effectiveVersionID ,
CheckRecordID : gate . Result . RecordID ,
ContentHash : gate . Result . ContentHash ,
PolicyFingerprint : gate . Result . PolicyFingerprint ,
ConsumedByJobID : jobID ,
} )
if consumeErr != nil {
return nil , response . ErrInternal ( 51004 , "compliance_ack_lookup_failed" , "failed to consume compliance ack" )
}
if ! consumed {
return nil , response . ErrBadRequest ( 41003 , "compliance_ack_mismatch" , "ack record is expired, consumed, or no longer matches this publish request" )
}
}
2026-04-19 14:18:20 +08:00
if err := tx . Commit ( ctx ) ; err != nil {
return nil , response . ErrInternal ( 50100 , "desktop_publish_job_commit_failed" , "failed to commit desktop publish job" )
}
2026-04-20 09:52:48 +08:00
invalidateArticleCaches ( ctx , s . cache , actor . TenantID , & articleID )
2026-04-19 14:18:20 +08:00
for _ , task := range createdTasks {
2026-04-20 17:16:15 +08:00
title , businessDate , schedulerGroupKey , questionText := deriveDesktopTaskEventMetadataFromPayload ( task . Kind , task . Payload )
2026-04-19 14:18:20 +08:00
if publishErr := publishDesktopTaskEvent ( context . Background ( ) , s . messaging , DesktopTaskEvent {
2026-04-20 17:16:15 +08:00
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 ,
2026-04-19 14:18:20 +08:00
} ) ; publishErr != nil {
s . logWarn ( "desktop task available event publish failed" , publishErr , zap . String ( "task_id" , task . DesktopID . String ( ) ) )
}
2026-04-20 19:46:59 +08:00
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 ,
} )
2026-04-19 14:18:20 +08:00
}
return & CreatePublishJobResponse {
JobID : job . DesktopID . String ( ) ,
TaskIDs : taskIDs ,
} , nil
}
2026-05-05 20:48:14 +08:00
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 & copyErr
}
2026-04-20 09:52:48 +08:00
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" )
}
2026-04-27 20:06:47 +08:00
if err := s . authorizePublishTaskForClientUser ( ctx , client , task ) ; err != nil {
return nil , err
2026-04-20 09:52:48 +08:00
}
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 )
2026-04-20 11:07:48 +08:00
articleID , err := s . resolveRetryArticleID ( ctx , client . TenantID , payload )
if err != nil {
return nil , err
2026-04-20 09:52:48 +08:00
}
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 ( ) } ,
} ,
} )
}
2026-05-07 12:07:28 +08:00
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" )
}
payload := unmarshalJSONObject ( task . Payload )
articleID , err := s . resolveRetryArticleID ( ctx , actor . 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 : 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 ( ) } ,
} ,
} )
}
2026-04-27 20:06:47 +08:00
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
}
2026-05-07 12:07:28 +08:00
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
}
2026-04-20 11:07:48 +08:00
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
}
2026-04-20 19:46:59 +08:00
// 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
}
2026-04-22 00:24:21 +08:00
if err := publishDesktopDispatchEvent ( context . Background ( ) , s . messaging , s . logger , event ) ; err != nil {
2026-04-20 19:46:59 +08:00
s . logWarn ( "desktop dispatch publish failed" , err ,
zap . String ( "task_id" , task . DesktopID . String ( ) ) ,
)
}
}
2026-04-19 14:18:20 +08:00
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 ... )
}
2026-04-20 09:52:48 +08:00
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 )
}