feat(desktop): push publish tasks via AMQP topic dispatch WebSocket

Introduce a desktop.task.dispatch topic exchange with per-client routing
keys. Tenant-api publishes a task_available frame keyed by target client
ID when a publish job is created; every instance binds its own transient
queue and forwards to the matching WebSocket (/api/desktop/dispatch) it
owns. Desktop-client now prefers this push channel and only falls back
to HTTP /lease polling when the socket is down. Also drop admin-web
monitoring-plugin remnants and scope the publish modal account list to
publish platforms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 19:46:59 +08:00
parent 7abac1e9c4
commit 9fa091c150
20 changed files with 1083 additions and 177 deletions
@@ -2,7 +2,9 @@ package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -16,6 +18,7 @@ import (
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"
)
@@ -232,6 +235,23 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
}); 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{
@@ -351,6 +371,43 @@ func resolveRetryArticleIDFromPayload(payload map[string]any) (int64, bool) {
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
}
cfg := s.messaging.Config()
prefix := strings.TrimSpace(cfg.DesktopDispatchPublishPrefix)
if prefix == "" {
prefix = "publish"
}
targetClientID := task.TargetClientID.String()
if targetClientID == "" {
return
}
routingKey := fmt.Sprintf("%s.%s", prefix, targetClientID)
body, err := json.Marshal(event)
if err != nil {
s.logWarn("desktop dispatch event encode failed", err,
zap.String("task_id", task.DesktopID.String()),
)
return
}
if err := s.messaging.PublishDesktopDispatch(context.Background(), routingKey, body); err != nil {
s.logWarn("desktop dispatch publish failed", err,
zap.String("task_id", task.DesktopID.String()),
zap.String("routing_key", routingKey),
)
}
}
func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) {
if s.logger == nil || err == nil {
return