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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
)
|
||||
|
||||
const (
|
||||
dispatchWriteWait = 10 * time.Second
|
||||
dispatchPongWait = 60 * time.Second
|
||||
dispatchPingPeriod = 25 * time.Second
|
||||
dispatchReadLimitBytes = 2048
|
||||
dispatchCloseGracePause = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type DesktopDispatchHandler struct {
|
||||
app *bootstrap.App
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func NewDesktopDispatchHandler(a *bootstrap.App) *DesktopDispatchHandler {
|
||||
return &DesktopDispatchHandler{
|
||||
app: a,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(_ *http.Request) bool {
|
||||
// Origin enforcement happens at the gateway / LB layer; this
|
||||
// endpoint is already gated by the bearer token middleware.
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch upgrades the HTTP request into a WebSocket and streams dispatch
|
||||
// events for the current desktop client. The contract intentionally mirrors
|
||||
// the existing SSE events endpoint: connect with a bearer token, receive JSON
|
||||
// frames whose shape matches DesktopDispatchEvent, and reconnect on drop.
|
||||
func (h *DesktopDispatchHandler) Dispatch(c *gin.Context) {
|
||||
client := MustDesktopClient(c.Request.Context())
|
||||
hub := h.app.DesktopDispatch
|
||||
if hub == nil {
|
||||
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
if h.app.Logger != nil {
|
||||
h.app.Logger.Warn("desktop dispatch upgrade failed",
|
||||
zap.String("client_id", client.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
subscriber, cancel := hub.Subscribe(client.ID.String())
|
||||
defer cancel()
|
||||
|
||||
conn.SetReadLimit(dispatchReadLimitBytes)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(dispatchPongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(dispatchPongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Reader goroutine: drains control frames and signals disconnect. We do
|
||||
// not expect application data from the client over this socket.
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
connected := stream.DesktopDispatchEvent{
|
||||
Type: "connected",
|
||||
TargetClientID: client.ID.String(),
|
||||
WorkspaceID: client.WorkspaceID,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := writeDispatchFrame(conn, connected); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
pingTicker := time.NewTicker(dispatchPingPeriod)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
defer func() {
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
|
||||
_ = conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
time.Sleep(dispatchCloseGracePause)
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
outbound := subscriber.Outbound()
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-readerDone:
|
||||
return
|
||||
case event, ok := <-outbound:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := writeDispatchFrame(conn, event); err != nil {
|
||||
return
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeDispatchFrame(conn *websocket.Conn, event stream.DesktopDispatchEvent) error {
|
||||
body, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
|
||||
return conn.WriteMessage(websocket.TextMessage, body)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
desktopAccountHandler := NewDesktopAccountHandler(app)
|
||||
desktopTaskHandler := NewDesktopTaskHandler(app)
|
||||
desktopEventsHandler := NewDesktopEventsHandler(app)
|
||||
desktopDispatchHandler := NewDesktopDispatchHandler(app)
|
||||
desktopContentHandler := NewDesktopContentHandler(app)
|
||||
publishJobHandler := NewPublishJobHandler(app)
|
||||
protected.POST("/desktop/clients/register", desktopClientHandler.Register)
|
||||
@@ -40,6 +41,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
desktopAuth.POST("/clients/offline", desktopClientHandler.Offline)
|
||||
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
|
||||
desktopAuth.GET("/events", desktopEventsHandler.Stream)
|
||||
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)
|
||||
desktopAuth.GET("/accounts", desktopAccountHandler.List)
|
||||
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
|
||||
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)
|
||||
|
||||
Reference in New Issue
Block a user