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
@@ -46,6 +46,16 @@ func New(_ context.Context, cfg config.RabbitMQConfig) (*Client, error) {
return client, nil
}
// Config returns a copy of the effective rabbitmq configuration. Services that
// need to derive routing keys (e.g. desktop dispatch) consult this instead of
// hard-coding string constants.
func (c *Client) Config() config.RabbitMQConfig {
if c == nil {
return config.RabbitMQConfig{}
}
return c.cfg
}
func (c *Client) Close() error {
if c == nil {
return nil
@@ -102,6 +112,91 @@ func (c *Client) PublishDesktopTaskEvent(ctx context.Context, body []byte) error
return c.publishFanout(ctx, c.cfg.DesktopTaskEventExchange, body)
}
func (c *Client) PublishDesktopDispatch(ctx context.Context, routingKey string, body []byte) error {
return c.publish(ctx, c.cfg.DesktopDispatchExchange, routingKey, body)
}
// ConsumeDesktopDispatch declares a transient per-process queue bound to the
// desktop dispatch topic exchange using bindingKeys, and begins consuming from
// it. Every tenant-api instance gets its own queue so every instance sees every
// dispatch message; the hub inside the instance then filters by target client
// presence. Messages are auto-ack (transient delivery is OK: if the instance
// dies, the client reconnects and the server re-drives the task via the
// existing lease/recovery path).
func (c *Client) ConsumeDesktopDispatch(consumerName string, bindingKeys []string) (<-chan amqp.Delivery, *amqp.Channel, error) {
if len(bindingKeys) == 0 {
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch: bindingKeys is required")
}
for attempt := 0; attempt < 2; attempt++ {
conn, ch, err := c.openChannel()
if err != nil {
return nil, nil, err
}
queue, err := ch.QueueDeclare(
"",
false,
true,
true,
false,
nil,
)
if err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("declare rabbitmq desktop dispatch queue: %w", err)
}
continue
}
bindFailed := false
for _, key := range bindingKeys {
if err := ch.QueueBind(
queue.Name,
key,
c.cfg.DesktopDispatchExchange,
false,
nil,
); err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("bind rabbitmq desktop dispatch queue: %w", err)
}
bindFailed = true
break
}
}
if bindFailed {
continue
}
deliveries, err := ch.Consume(
queue.Name,
consumerName,
true,
true,
false,
false,
nil,
)
if err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch queue: %w", err)
}
continue
}
return deliveries, ch, nil
}
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch queue: retry exhausted")
}
func (c *Client) PublishTemplateAssistTask(ctx context.Context, body []byte) error {
return c.publish(ctx, c.cfg.TemplateAssistExchange, c.cfg.TemplateAssistRoutingKey, body)
}
@@ -578,6 +673,18 @@ func (c *Client) ensureTopology(conn *amqp.Connection) error {
return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.DesktopTaskEventExchange, err)
}
if err := ch.ExchangeDeclare(
c.cfg.DesktopDispatchExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.DesktopDispatchExchange, err)
}
if err := c.ensureWorkQueue(
ch,
c.cfg.TemplateAssistExchange,