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:
@@ -11,7 +11,12 @@ import { articlesApi, mediaApi, publishJobsApi, resolveApiURL, tenantAccountsApi
|
|||||||
import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements";
|
import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements";
|
||||||
import { formatDateTime } from "@/lib/display";
|
import { formatDateTime } from "@/lib/display";
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
import { getPublishPlatformMeta, normalizePublishPlatformId, normalizePublishPlatformIds } from "@/lib/publish-platforms";
|
import {
|
||||||
|
getPublishPlatformMeta,
|
||||||
|
isPublishPlatformId,
|
||||||
|
normalizePublishPlatformId,
|
||||||
|
normalizePublishPlatformIds,
|
||||||
|
} from "@/lib/publish-platforms";
|
||||||
|
|
||||||
type PublishState = "immediate" | "queued" | "unavailable";
|
type PublishState = "immediate" | "queued" | "unavailable";
|
||||||
|
|
||||||
@@ -109,8 +114,19 @@ const platformMap = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const publishAccounts = computed(() => {
|
||||||
|
return (accountsQuery.data.value ?? []).filter((account) => {
|
||||||
|
if (account.deleted_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformId = normalizePublishPlatformId(account.platform);
|
||||||
|
return platformMap.value.has(platformId) || isPublishPlatformId(platformId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const rawAccountMap = computed(() => {
|
const rawAccountMap = computed(() => {
|
||||||
return new Map((accountsQuery.data.value ?? []).map((account) => [account.id, account]));
|
return new Map(publishAccounts.value.map((account) => [account.id, account]));
|
||||||
});
|
});
|
||||||
|
|
||||||
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
|
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
|
||||||
@@ -118,8 +134,7 @@ const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuer
|
|||||||
const accountCards = computed<PublishAccountCard[]>(() => {
|
const accountCards = computed<PublishAccountCard[]>(() => {
|
||||||
const preferredPlatforms = new Set(articlePlatformIds.value);
|
const preferredPlatforms = new Set(articlePlatformIds.value);
|
||||||
|
|
||||||
return [...(accountsQuery.data.value ?? [])]
|
return [...publishAccounts.value]
|
||||||
.filter((account) => !account.deleted_at)
|
|
||||||
.map((account) => {
|
.map((account) => {
|
||||||
const platformId = normalizePublishPlatformId(account.platform);
|
const platformId = normalizePublishPlatformId(account.platform);
|
||||||
const platform = platformMap.value.get(platformId);
|
const platform = platformMap.value.get(platformId);
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ const zhCN = {
|
|||||||
collectBrandRequired: "请先选择品牌,再发起采集",
|
collectBrandRequired: "请先选择品牌,再发起采集",
|
||||||
collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
|
collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
|
||||||
collectClientChecking: "正在检查当前账号的桌面客户端状态",
|
collectClientChecking: "正在检查当前账号的桌面客户端状态",
|
||||||
collectClientOnlineRequired: "当前登录账号的 desktop-client 未在线,暂时不能立即采集",
|
collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集",
|
||||||
pluginMode: "插件采样",
|
pluginMode: "插件采样",
|
||||||
pluginHint: "插件模式下结果受设备在线率影响,低覆盖样本仅供趋势参考。",
|
pluginHint: "插件模式下结果受设备在线率影响,低覆盖样本仅供趋势参考。",
|
||||||
awaitingSnapshot: "等待下一次有效快照",
|
awaitingSnapshot: "等待下一次有效快照",
|
||||||
|
|||||||
@@ -59,12 +59,7 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
|
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
|
||||||
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
|
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
|
||||||
publisher_plugin_unknown_error: "浏览器插件调用失败,请稍后重试",
|
publisher_plugin_unknown_error: "浏览器插件调用失败,请稍后重试",
|
||||||
monitoring_plugin_timeout: "监测插件响应超时,请稍后重试",
|
installation_offline: "桌面客户端未在线,请打开桌面客户端后重试",
|
||||||
monitoring_plugin_invalid_response: "监测插件返回了无效数据,请稍后重试",
|
|
||||||
monitoring_plugin_unknown_error: "监测插件调用失败,请稍后重试",
|
|
||||||
monitoring_plugin_required: "请先安装并打开浏览器插件",
|
|
||||||
monitoring_plugin_register_required: "浏览器插件正在初始化,请稍后重试",
|
|
||||||
installation_offline: "监测插件离线,请打开浏览器插件后重试",
|
|
||||||
network_error: "网络连接失败,请稍后重试",
|
network_error: "网络连接失败,请稍后重试",
|
||||||
unknown_error: "发生未知错误",
|
unknown_error: "发生未知错误",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import type {
|
|
||||||
MonitoringCycleKickoffResponse,
|
|
||||||
MonitoringCycleResult,
|
|
||||||
MonitoringHeartbeatSyncResult,
|
|
||||||
MonitoringLocalPlatformState,
|
|
||||||
MonitoringPluginPingResponse,
|
|
||||||
MonitoringRuntimeStateSnapshot,
|
|
||||||
} from "@geo/shared-types";
|
|
||||||
|
|
||||||
type MonitoringAction = "ping" | "detectPlatforms" | "syncHeartbeat" | "runCycle" | "kickoffCycle" | "getState";
|
|
||||||
|
|
||||||
type MonitoringPluginSuccessPayload<T> = {
|
|
||||||
source: "geo-extension";
|
|
||||||
type: "geo.monitoring.response";
|
|
||||||
requestId: string;
|
|
||||||
success: true;
|
|
||||||
data: T;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MonitoringPluginErrorPayload = {
|
|
||||||
source: "geo-extension";
|
|
||||||
type: "geo.monitoring.response";
|
|
||||||
requestId: string;
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MonitoringPluginPayloadMap = {
|
|
||||||
ping: MonitoringPluginPingResponse;
|
|
||||||
detectPlatforms: MonitoringLocalPlatformState[];
|
|
||||||
syncHeartbeat: MonitoringHeartbeatSyncResult;
|
|
||||||
runCycle: MonitoringCycleResult;
|
|
||||||
kickoffCycle: MonitoringCycleKickoffResponse;
|
|
||||||
getState: MonitoringRuntimeStateSnapshot;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMonitoringPingPayload(value: unknown): value is MonitoringPluginPingResponse {
|
|
||||||
return isPlainObject(value) && typeof value.installed === "boolean";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMonitoringDetectPayload(value: unknown): value is MonitoringLocalPlatformState[] {
|
|
||||||
return Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMonitoringHeartbeatPayload(value: unknown): value is MonitoringHeartbeatSyncResult {
|
|
||||||
return isPlainObject(value) && typeof value.success === "boolean" && Array.isArray(value.platform_states);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMonitoringCyclePayload(value: unknown): value is MonitoringCycleResult {
|
|
||||||
return (
|
|
||||||
isPlainObject(value) &&
|
|
||||||
typeof value.success === "boolean" &&
|
|
||||||
typeof value.leased_task_count === "number" &&
|
|
||||||
typeof value.processed_task_count === "number" &&
|
|
||||||
typeof value.failed_task_count === "number" &&
|
|
||||||
typeof value.skipped_task_count === "number"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMonitoringKickoffPayload(value: unknown): value is MonitoringCycleKickoffResponse {
|
|
||||||
return isPlainObject(value) && value.accepted === true && typeof value.status === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMonitoringStatePayload(value: unknown): value is MonitoringRuntimeStateSnapshot {
|
|
||||||
return isPlainObject(value) && typeof value.installation_key === "string" && Array.isArray(value.monitoring_platforms);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMonitoringPayloadValid<T extends MonitoringAction>(action: T, value: unknown): value is MonitoringPluginPayloadMap[T] {
|
|
||||||
switch (action) {
|
|
||||||
case "ping":
|
|
||||||
return isMonitoringPingPayload(value);
|
|
||||||
case "detectPlatforms":
|
|
||||||
return isMonitoringDetectPayload(value);
|
|
||||||
case "syncHeartbeat":
|
|
||||||
return isMonitoringHeartbeatPayload(value);
|
|
||||||
case "runCycle":
|
|
||||||
return isMonitoringCyclePayload(value);
|
|
||||||
case "kickoffCycle":
|
|
||||||
return isMonitoringKickoffPayload(value);
|
|
||||||
case "getState":
|
|
||||||
return isMonitoringStatePayload(value);
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextRequestId(): string {
|
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `geo-monitoring-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestMonitoringPlugin<T extends MonitoringAction>(action: T, timeoutMs = 5000): Promise<MonitoringPluginPayloadMap[T]> {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
throw new Error("plugin bridge is only available in browser contexts");
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = nextRequestId();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error("monitoring_plugin_timeout"));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent<MonitoringPluginSuccessPayload<MonitoringPluginPayloadMap[T]> | MonitoringPluginErrorPayload>) => {
|
|
||||||
if (event.source !== window || !event.data || event.data.type !== "geo.monitoring.response") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.data.requestId !== requestId || event.data.source !== "geo-extension") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
if (event.data.success) {
|
|
||||||
if (!isMonitoringPayloadValid(action, event.data.data)) {
|
|
||||||
reject(new Error("monitoring_plugin_invalid_response"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(event.data.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reject(new Error(event.data.error || "monitoring_plugin_unknown_error"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
window.removeEventListener("message", handleMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("message", handleMessage);
|
|
||||||
window.postMessage(
|
|
||||||
{
|
|
||||||
source: "geo-admin",
|
|
||||||
type: "geo.monitoring.request",
|
|
||||||
requestId,
|
|
||||||
action,
|
|
||||||
},
|
|
||||||
window.location.origin,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runMonitoringCycle(): Promise<MonitoringCycleResult> {
|
|
||||||
return requestMonitoringPlugin("runCycle", 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function kickoffMonitoringCycle(): Promise<MonitoringCycleKickoffResponse> {
|
|
||||||
return requestMonitoringPlugin("kickoffCycle", 5000);
|
|
||||||
}
|
|
||||||
@@ -24,11 +24,13 @@
|
|||||||
"pino": "^9.0.0",
|
"pino": "^9.0.0",
|
||||||
"playwright-core": "^1.55.0",
|
"playwright-core": "^1.55.0",
|
||||||
"vue": "^3.5.31",
|
"vue": "^3.5.31",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.0.0",
|
"@playwright/test": "^1.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"electron": "41.2.0",
|
"electron": "41.2.0",
|
||||||
"electron-builder": "^25.0.0",
|
"electron-builder": "^25.0.0",
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ import {
|
|||||||
upsertDesktopAccount,
|
upsertDesktopAccount,
|
||||||
} from "./transport/api-client";
|
} from "./transport/api-client";
|
||||||
import { SseClient, SseClientError } from "./transport/sse-client";
|
import { SseClient, SseClientError } from "./transport/sse-client";
|
||||||
|
import { DispatchWsClient } from "./transport/dispatch-ws-client";
|
||||||
|
|
||||||
type RuntimeTone = "info" | "success" | "warn" | "danger";
|
type RuntimeTone = "info" | "success" | "warn" | "danger";
|
||||||
type RuntimeTaskStatus =
|
type RuntimeTaskStatus =
|
||||||
@@ -184,6 +185,8 @@ interface RuntimeState {
|
|||||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||||
pullTimer: ReturnType<typeof setInterval> | null;
|
pullTimer: ReturnType<typeof setInterval> | null;
|
||||||
sseClient: SseClient | null;
|
sseClient: SseClient | null;
|
||||||
|
dispatchWsClient: DispatchWsClient | null;
|
||||||
|
dispatchWsConnected: boolean;
|
||||||
lastHeartbeatAt: number;
|
lastHeartbeatAt: number;
|
||||||
lastHeartbeatStatus: "idle" | "success" | "failed";
|
lastHeartbeatStatus: "idle" | "success" | "failed";
|
||||||
lastPullAt: number;
|
lastPullAt: number;
|
||||||
@@ -210,6 +213,8 @@ const state: RuntimeState = {
|
|||||||
heartbeatTimer: null,
|
heartbeatTimer: null,
|
||||||
pullTimer: null,
|
pullTimer: null,
|
||||||
sseClient: null,
|
sseClient: null,
|
||||||
|
dispatchWsClient: null,
|
||||||
|
dispatchWsConnected: false,
|
||||||
lastHeartbeatAt: 0,
|
lastHeartbeatAt: 0,
|
||||||
lastHeartbeatStatus: "idle",
|
lastHeartbeatStatus: "idle",
|
||||||
lastPullAt: 0,
|
lastPullAt: 0,
|
||||||
@@ -354,6 +359,7 @@ function startRuntime(): void {
|
|||||||
recordActivity("success", "调度节点已启动", "已启动 SSE、心跳上报和兜底拉取。");
|
recordActivity("success", "调度节点已启动", "已启动 SSE、心跳上报和兜底拉取。");
|
||||||
|
|
||||||
connectEventStream();
|
connectEventStream();
|
||||||
|
connectDispatchWs();
|
||||||
scheduleHeartbeatLoop();
|
scheduleHeartbeatLoop();
|
||||||
schedulePullLoop();
|
schedulePullLoop();
|
||||||
|
|
||||||
@@ -383,6 +389,11 @@ function stopRuntime(): void {
|
|||||||
state.sseClient.stop();
|
state.sseClient.stop();
|
||||||
state.sseClient = null;
|
state.sseClient = null;
|
||||||
}
|
}
|
||||||
|
if (state.dispatchWsClient) {
|
||||||
|
state.dispatchWsClient.stop();
|
||||||
|
state.dispatchWsClient = null;
|
||||||
|
}
|
||||||
|
state.dispatchWsConnected = false;
|
||||||
|
|
||||||
setTransportSseState("idle");
|
setTransportSseState("idle");
|
||||||
setSchedulerPhase("idle");
|
setSchedulerPhase("idle");
|
||||||
@@ -521,6 +532,63 @@ function connectEventStream(): void {
|
|||||||
sseClient.start();
|
sseClient.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectDispatchWs(): void {
|
||||||
|
if (!canRunLive(state.session)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.dispatchWsClient?.stop();
|
||||||
|
|
||||||
|
const wsUrl = buildApiUrl(state.session.api_base_url, "/api/desktop/dispatch")
|
||||||
|
.replace(/^http:\/\//, "ws://")
|
||||||
|
.replace(/^https:\/\//, "wss://");
|
||||||
|
|
||||||
|
const client = new DispatchWsClient({
|
||||||
|
url: wsUrl,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${state.session.client_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("open", () => {
|
||||||
|
state.dispatchWsConnected = true;
|
||||||
|
setSchedulerError(null);
|
||||||
|
recordActivity("success", "任务分发通道已连接", "AMQP 直推 WebSocket 已就绪,发布任务将以低延迟到达。");
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("connected", () => {
|
||||||
|
state.dispatchWsConnected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("task_available", (payload) => {
|
||||||
|
if (!isDesktopTaskEvent(payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.dispatchWsConnected = true;
|
||||||
|
handleTaskEvent(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("error", (payload) => {
|
||||||
|
state.dispatchWsConnected = false;
|
||||||
|
const message = errorMessage(payload);
|
||||||
|
recordActivity("warn", "任务分发通道异常", message);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("close", () => {
|
||||||
|
state.dispatchWsConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("reconnect", (payload) => {
|
||||||
|
if (!isReconnectPayload(payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordActivity("info", "任务分发通道退避等待", `将在 ${payload.delayMs}ms 后重连 WebSocket。`);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.dispatchWsClient = client;
|
||||||
|
client.start();
|
||||||
|
}
|
||||||
|
|
||||||
function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||||||
const existing = state.tasks.get(event.task_id);
|
const existing = state.tasks.get(event.task_id);
|
||||||
const next: RuntimeTaskRecord = {
|
const next: RuntimeTaskRecord = {
|
||||||
@@ -871,11 +939,18 @@ function pumpExecutionLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.activeExecutions.size === 0) {
|
if (state.activeExecutions.size === 0) {
|
||||||
void pullNextTask("publish");
|
// Publish task_available is push-delivered over the dispatch WebSocket. We
|
||||||
|
// only fall back to the HTTP pull when the push channel is down, otherwise
|
||||||
|
// every scheduler tick would wake the /lease endpoint for no reason.
|
||||||
|
if (!state.dispatchWsConnected) {
|
||||||
|
void pullNextTask("publish");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canStartAnotherMonitorExecution()) {
|
if (canStartAnotherMonitorExecution()) {
|
||||||
|
// Monitor has not been migrated to the dispatch channel yet, so it still
|
||||||
|
// relies on the periodic pull as its primary signal.
|
||||||
void pullNextTask("monitor");
|
void pullNextTask("monitor");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import WebSocket, { type RawData } from "ws";
|
||||||
|
|
||||||
|
export type DispatchEventHandler = (payload: unknown) => void;
|
||||||
|
|
||||||
|
export interface DispatchWsClientOptions {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
initialRetryDelayMs?: number;
|
||||||
|
maxRetryDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DispatchWsState = "idle" | "connecting" | "open";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DispatchWsClient receives per-client task dispatch messages from the server
|
||||||
|
* over a single authenticated WebSocket. It mirrors {@link SseClient} so the
|
||||||
|
* runtime-controller can treat it as a drop-in replacement for the publish
|
||||||
|
* side of the SSE channel.
|
||||||
|
*
|
||||||
|
* The underlying contract is intentionally narrow:
|
||||||
|
* - The server pushes newline-free JSON frames whose shape matches
|
||||||
|
* `DesktopDispatchEvent` on the Go side.
|
||||||
|
* - The client never sends application frames back; acknowledgement is done
|
||||||
|
* out-of-band via `/tasks/lease`.
|
||||||
|
*
|
||||||
|
* Reconnect uses exponential backoff with jitter. On every fresh TCP
|
||||||
|
* connection the server replays a synthetic "connected" frame so callers can
|
||||||
|
* observe the auth + transport handshake is healthy.
|
||||||
|
*/
|
||||||
|
export class DispatchWsClient {
|
||||||
|
readonly #listeners = new Map<string, Set<DispatchEventHandler>>();
|
||||||
|
readonly #options: DispatchWsClientOptions;
|
||||||
|
#running = false;
|
||||||
|
#socket: WebSocket | null = null;
|
||||||
|
#retryDelayMs: number;
|
||||||
|
#reconnectTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(options: DispatchWsClientOptions) {
|
||||||
|
this.#options = options;
|
||||||
|
this.#retryDelayMs = options.initialRetryDelayMs ?? 1_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, handler: DispatchEventHandler): () => void {
|
||||||
|
const bucket = this.#listeners.get(event) ?? new Set<DispatchEventHandler>();
|
||||||
|
bucket.add(handler);
|
||||||
|
this.#listeners.set(event, bucket);
|
||||||
|
return () => {
|
||||||
|
bucket.delete(handler);
|
||||||
|
if (bucket.size === 0) {
|
||||||
|
this.#listeners.delete(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.#running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#running = true;
|
||||||
|
this.#connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.#running = false;
|
||||||
|
this.#clearReconnect();
|
||||||
|
this.#setState("idle");
|
||||||
|
|
||||||
|
const socket = this.#socket;
|
||||||
|
this.#socket = null;
|
||||||
|
if (!socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach every listener we registered so their handlers don't re-fire our
|
||||||
|
// own reconnect / error callbacks during teardown, but keep a noop error
|
||||||
|
// listener around: `ws` emits an asynchronous "error" when a socket still
|
||||||
|
// in the CONNECTING state is closed, and without a listener Node turns it
|
||||||
|
// into an uncaughtException.
|
||||||
|
socket.removeAllListeners("open");
|
||||||
|
socket.removeAllListeners("message");
|
||||||
|
socket.removeAllListeners("close");
|
||||||
|
socket.removeAllListeners("error");
|
||||||
|
socket.on("error", () => {
|
||||||
|
// swallow: teardown path, we no longer care about the socket's fate.
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
// `close()` on a CONNECTING socket throws asynchronously; `terminate()`
|
||||||
|
// destroys the underlying TCP connection and is always safe.
|
||||||
|
socket.terminate();
|
||||||
|
} else if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.close(1000, "client_stop");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: ignore; the reference is already detached.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, payload: unknown): void {
|
||||||
|
for (const handler of this.#listeners.get(event) ?? []) {
|
||||||
|
try {
|
||||||
|
handler(payload);
|
||||||
|
} catch {
|
||||||
|
// Swallow handler errors; listeners must not kill the transport.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#connect(): void {
|
||||||
|
if (!this.#running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#setState("connecting");
|
||||||
|
|
||||||
|
let socket: WebSocket;
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(this.#options.url, {
|
||||||
|
headers: this.#options.headers,
|
||||||
|
handshakeTimeout: 15_000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit("error", error);
|
||||||
|
this.#scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#socket = socket;
|
||||||
|
|
||||||
|
socket.on("open", () => {
|
||||||
|
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1_000;
|
||||||
|
this.#setState("open");
|
||||||
|
this.emit("open", null);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("message", (data: RawData, isBinary: boolean) => {
|
||||||
|
if (isBinary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = typeof data === "string" ? data : data.toString("utf-8");
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
this.emit("message", { event: "message", data: raw });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event = extractEventType(parsed) ?? "message";
|
||||||
|
this.emit(event, parsed);
|
||||||
|
this.emit("message", { event, data: parsed });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (error: Error) => {
|
||||||
|
this.emit("error", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("close", (code: number, reason: Buffer) => {
|
||||||
|
this.#socket = null;
|
||||||
|
this.emit("close", { code, reason: reason.toString() });
|
||||||
|
if (!this.#running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#scheduleReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#scheduleReconnect(): void {
|
||||||
|
if (!this.#running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#clearReconnect();
|
||||||
|
|
||||||
|
const baseDelay = this.#retryDelayMs;
|
||||||
|
const maxDelay = this.#options.maxRetryDelayMs ?? 30_000;
|
||||||
|
const jitter = Math.floor(baseDelay * 0.2 * Math.random());
|
||||||
|
const delay = Math.min(baseDelay + jitter, maxDelay);
|
||||||
|
|
||||||
|
this.emit("reconnect", { delayMs: delay });
|
||||||
|
this.#reconnectTimer = globalThis.setTimeout(() => {
|
||||||
|
this.#reconnectTimer = null;
|
||||||
|
this.#connect();
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, maxDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearReconnect(): void {
|
||||||
|
if (this.#reconnectTimer) {
|
||||||
|
globalThis.clearTimeout(this.#reconnectTimer);
|
||||||
|
this.#reconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#setState(state: DispatchWsState): void {
|
||||||
|
this.emit("state", state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEventType(payload: unknown): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const maybe = (payload as { type?: unknown }).type;
|
||||||
|
if (typeof maybe === "string" && maybe.length > 0) {
|
||||||
|
return maybe;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Generated
+27
@@ -165,6 +165,9 @@ importers:
|
|||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.5.1
|
specifier: ^4.5.1
|
||||||
version: 4.6.4(vue@3.5.31(typescript@5.9.3))
|
version: 4.6.4(vue@3.5.31(typescript@5.9.3))
|
||||||
|
ws:
|
||||||
|
specifier: ^8.20.0
|
||||||
|
version: 8.20.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
@@ -172,6 +175,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.0
|
specifier: ^24.0.0
|
||||||
version: 24.12.0
|
version: 24.12.0
|
||||||
|
'@types/ws':
|
||||||
|
specifier: ^8.18.1
|
||||||
|
version: 8.18.1
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.2.4
|
specifier: ^5.2.4
|
||||||
version: 5.2.4(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3))
|
version: 5.2.4(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3))
|
||||||
@@ -1366,6 +1372,9 @@ packages:
|
|||||||
'@types/web-bluetooth@0.0.21':
|
'@types/web-bluetooth@0.0.21':
|
||||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||||
|
|
||||||
@@ -4462,6 +4471,18 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
ws@8.20.0:
|
||||||
|
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
wsl-utils@0.3.1:
|
wsl-utils@0.3.1:
|
||||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -5923,6 +5944,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/web-bluetooth@0.0.21': {}
|
'@types/web-bluetooth@0.0.21': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.12.0
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.12.0
|
'@types/node': 24.12.0
|
||||||
@@ -9555,6 +9580,8 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.20.0: {}
|
||||||
|
|
||||||
wsl-utils@0.3.1:
|
wsl-utils@0.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-wsl: 3.1.1
|
is-wsl: 3.1.1
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func main() {
|
|||||||
|
|
||||||
app.GenerationStreams.Run(workerCtx)
|
app.GenerationStreams.Run(workerCtx)
|
||||||
app.DesktopTaskStreams.Run(workerCtx)
|
app.DesktopTaskStreams.Run(workerCtx)
|
||||||
|
app.DesktopDispatch.Run(workerCtx)
|
||||||
|
|
||||||
monitoringCallbackService := tenantapp.NewMonitoringCallbackService(app.DB, app.MonitoringDB, app.RabbitMQ, app.LLM, app.Logger)
|
monitoringCallbackService := tenantapp.NewMonitoringCallbackService(app.DB, app.MonitoringDB, app.RabbitMQ, app.LLM, app.Logger)
|
||||||
tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx)
|
tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx)
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ rabbitmq:
|
|||||||
generation_dlq: generation.task.run.dlq
|
generation_dlq: generation.task.run.dlq
|
||||||
generation_dlq_routing_key: generation.task.run.dlq
|
generation_dlq_routing_key: generation.task.run.dlq
|
||||||
generation_event_exchange: generation.event
|
generation_event_exchange: generation.event
|
||||||
|
desktop_task_event_exchange: desktop.task.event
|
||||||
|
desktop_dispatch_exchange: desktop.task.dispatch
|
||||||
|
desktop_dispatch_publish_prefix: publish
|
||||||
|
desktop_dispatch_monitor_prefix: monitor
|
||||||
template_assist_exchange: template.assist
|
template_assist_exchange: template.assist
|
||||||
template_assist_routing_key: template.assist.run
|
template_assist_routing_key: template.assist.run
|
||||||
template_assist_queue: template.assist.run
|
template_assist_queue: template.assist.run
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
module github.com/geo-platform/tenant-api
|
module github.com/geo-platform/tenant-api
|
||||||
|
|
||||||
go 1.22.2
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alicebob/miniredis/v2 v2.37.0
|
github.com/alicebob/miniredis/v2 v2.37.0
|
||||||
@@ -10,6 +10,7 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.5.5
|
github.com/jackc/pgx/v5 v5.5.5
|
||||||
github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3
|
github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3
|
||||||
github.com/minio/minio-go/v7 v7.0.83
|
github.com/minio/minio-go/v7 v7.0.83
|
||||||
|
|||||||
+2
-6
@@ -83,6 +83,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
|
|||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -223,8 +225,6 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
|
|||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||||
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
||||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
|
||||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
@@ -240,8 +240,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -250,8 +248,6 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ type App struct {
|
|||||||
VectorStore retrieval.VectorStore
|
VectorStore retrieval.VectorStore
|
||||||
ObjectStorage objectstorage.Client
|
ObjectStorage objectstorage.Client
|
||||||
AuditLogs *auditlog.AsyncWriter
|
AuditLogs *auditlog.AsyncWriter
|
||||||
GenerationStreams *stream.GenerationHub
|
GenerationStreams *stream.GenerationHub
|
||||||
DesktopTaskStreams *stream.DesktopTaskHub
|
DesktopTaskStreams *stream.DesktopTaskHub
|
||||||
|
DesktopDispatch *stream.DesktopDispatchHub
|
||||||
Cache cache.Cache
|
Cache cache.Cache
|
||||||
KolProfiles repository.KolProfileRepository
|
KolProfiles repository.KolProfileRepository
|
||||||
KolPackages repository.KolPackageRepository
|
KolPackages repository.KolPackageRepository
|
||||||
@@ -111,6 +112,10 @@ func New(configPath string) (*App, error) {
|
|||||||
auditLogs := auditlog.NewAsyncWriter(pool, logger)
|
auditLogs := auditlog.NewAsyncWriter(pool, logger)
|
||||||
generationStreams := stream.NewGenerationHub(mqClient)
|
generationStreams := stream.NewGenerationHub(mqClient)
|
||||||
desktopTaskStreams := stream.NewDesktopTaskHub(mqClient)
|
desktopTaskStreams := stream.NewDesktopTaskHub(mqClient)
|
||||||
|
desktopDispatchBindings := []string{
|
||||||
|
cfg.RabbitMQ.DesktopDispatchPublishPrefix + ".*",
|
||||||
|
}
|
||||||
|
desktopDispatch := stream.NewDesktopDispatchHub(mqClient, logger, desktopDispatchBindings)
|
||||||
appCache := cache.New(cfg.Cache.Driver, rdb)
|
appCache := cache.New(cfg.Cache.Driver, rdb)
|
||||||
kolProfiles := repository.NewKolProfileRepository(pool)
|
kolProfiles := repository.NewKolProfileRepository(pool)
|
||||||
kolPackages := repository.NewKolPackageRepository(pool)
|
kolPackages := repository.NewKolPackageRepository(pool)
|
||||||
@@ -170,8 +175,9 @@ func New(configPath string) (*App, error) {
|
|||||||
VectorStore: vectorStore,
|
VectorStore: vectorStore,
|
||||||
ObjectStorage: objectStorageClient,
|
ObjectStorage: objectStorageClient,
|
||||||
AuditLogs: auditLogs,
|
AuditLogs: auditLogs,
|
||||||
GenerationStreams: generationStreams,
|
GenerationStreams: generationStreams,
|
||||||
DesktopTaskStreams: desktopTaskStreams,
|
DesktopTaskStreams: desktopTaskStreams,
|
||||||
|
DesktopDispatch: desktopDispatch,
|
||||||
Cache: appCache,
|
Cache: appCache,
|
||||||
KolProfiles: kolProfiles,
|
KolProfiles: kolProfiles,
|
||||||
KolPackages: kolPackages,
|
KolPackages: kolPackages,
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ type RabbitMQConfig struct {
|
|||||||
GenerationDLQRouteKey string `mapstructure:"generation_dlq_routing_key"`
|
GenerationDLQRouteKey string `mapstructure:"generation_dlq_routing_key"`
|
||||||
GenerationEventExchange string `mapstructure:"generation_event_exchange"`
|
GenerationEventExchange string `mapstructure:"generation_event_exchange"`
|
||||||
DesktopTaskEventExchange string `mapstructure:"desktop_task_event_exchange"`
|
DesktopTaskEventExchange string `mapstructure:"desktop_task_event_exchange"`
|
||||||
|
DesktopDispatchExchange string `mapstructure:"desktop_dispatch_exchange"`
|
||||||
|
DesktopDispatchPublishPrefix string `mapstructure:"desktop_dispatch_publish_prefix"`
|
||||||
|
DesktopDispatchMonitorPrefix string `mapstructure:"desktop_dispatch_monitor_prefix"`
|
||||||
TemplateAssistExchange string `mapstructure:"template_assist_exchange"`
|
TemplateAssistExchange string `mapstructure:"template_assist_exchange"`
|
||||||
TemplateAssistRoutingKey string `mapstructure:"template_assist_routing_key"`
|
TemplateAssistRoutingKey string `mapstructure:"template_assist_routing_key"`
|
||||||
TemplateAssistQueue string `mapstructure:"template_assist_queue"`
|
TemplateAssistQueue string `mapstructure:"template_assist_queue"`
|
||||||
@@ -475,6 +478,15 @@ func normalizeRabbitMQConfig(cfg *RabbitMQConfig) {
|
|||||||
if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" {
|
if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" {
|
||||||
cfg.DesktopTaskEventExchange = "desktop.task.event"
|
cfg.DesktopTaskEventExchange = "desktop.task.event"
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cfg.DesktopDispatchExchange) == "" {
|
||||||
|
cfg.DesktopDispatchExchange = "desktop.task.dispatch"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.DesktopDispatchPublishPrefix) == "" {
|
||||||
|
cfg.DesktopDispatchPublishPrefix = "publish"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix) == "" {
|
||||||
|
cfg.DesktopDispatchMonitorPrefix = "monitor"
|
||||||
|
}
|
||||||
if strings.TrimSpace(cfg.TemplateAssistExchange) == "" {
|
if strings.TrimSpace(cfg.TemplateAssistExchange) == "" {
|
||||||
cfg.TemplateAssistExchange = "template.assist"
|
cfg.TemplateAssistExchange = "template.assist"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ func New(_ context.Context, cfg config.RabbitMQConfig) (*Client, error) {
|
|||||||
return client, nil
|
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 {
|
func (c *Client) Close() error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return 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)
|
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 {
|
func (c *Client) PublishTemplateAssistTask(ctx context.Context, body []byte) error {
|
||||||
return c.publish(ctx, c.cfg.TemplateAssistExchange, c.cfg.TemplateAssistRoutingKey, body)
|
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)
|
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(
|
if err := c.ensureWorkQueue(
|
||||||
ch,
|
ch,
|
||||||
c.cfg.TemplateAssistExchange,
|
c.cfg.TemplateAssistExchange,
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package stream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DesktopDispatchEvent is the payload pushed to a desktop client over the
|
||||||
|
// dispatch WebSocket. The shape intentionally mirrors DesktopTaskEvent so that
|
||||||
|
// the existing SSE handler on the desktop client can treat both channels the
|
||||||
|
// same way and dedupe by task_id.
|
||||||
|
type DesktopDispatchEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
JobID string `json:"job_id,omitempty"`
|
||||||
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
|
TargetAccountID string `json:"target_account_id,omitempty"`
|
||||||
|
TargetClientID string `json:"target_client_id"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
BusinessDate *string `json:"business_date,omitempty"`
|
||||||
|
SchedulerGroupKey *string `json:"scheduler_group_key,omitempty"`
|
||||||
|
QuestionText *string `json:"question_text,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchSubscriber represents a live WebSocket connection bound to a single
|
||||||
|
// desktop client id. A client may have multiple subscribers (e.g. during a
|
||||||
|
// reconnect race), so the hub stores them as a set per client id.
|
||||||
|
type DispatchSubscriber struct {
|
||||||
|
id uint64
|
||||||
|
out chan DesktopDispatchEvent
|
||||||
|
closed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outbound returns the channel the handler goroutine should read from to write
|
||||||
|
// frames to the WebSocket. The channel is closed when the subscriber is
|
||||||
|
// unregistered; readers should range over it.
|
||||||
|
func (s *DispatchSubscriber) Outbound() <-chan DesktopDispatchEvent {
|
||||||
|
return s.out
|
||||||
|
}
|
||||||
|
|
||||||
|
type dispatchClientStream struct {
|
||||||
|
subscribers map[uint64]*DispatchSubscriber
|
||||||
|
}
|
||||||
|
|
||||||
|
// DesktopDispatchHub delivers per-client dispatch messages pulled from the
|
||||||
|
// desktop.task.dispatch topic exchange. Every tenant-api instance owns its own
|
||||||
|
// autodelete queue bound to the exchange, so every instance sees every message
|
||||||
|
// and routes it to the subscribers it currently owns. Clients not connected to
|
||||||
|
// this instance are simply filtered out; the client reconnects via the load
|
||||||
|
// balancer and lands on whichever instance currently holds its WebSocket.
|
||||||
|
type DesktopDispatchHub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[string]*dispatchClientStream
|
||||||
|
rabbitMQ *rabbitmq.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
instanceID string
|
||||||
|
consumerName string
|
||||||
|
bindingKeys []string
|
||||||
|
nextSubID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDesktopDispatchHub wires the hub to the shared rabbitmq client. The
|
||||||
|
// supplied binding keys decide which dispatch routing keys this instance
|
||||||
|
// receives; initially that is just `publish.*`, and the monitor slice will be
|
||||||
|
// added when the monitor chain is migrated.
|
||||||
|
func NewDesktopDispatchHub(rabbitMQClient *rabbitmq.Client, logger *zap.Logger, bindingKeys []string) *DesktopDispatchHub {
|
||||||
|
instanceID := fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano())
|
||||||
|
keys := make([]string, 0, len(bindingKeys))
|
||||||
|
for _, key := range bindingKeys {
|
||||||
|
trimmed := strings.TrimSpace(key)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, trimmed)
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
keys = []string{"publish.*"}
|
||||||
|
}
|
||||||
|
return &DesktopDispatchHub{
|
||||||
|
clients: make(map[string]*dispatchClientStream),
|
||||||
|
rabbitMQ: rabbitMQClient,
|
||||||
|
logger: logger,
|
||||||
|
instanceID: instanceID,
|
||||||
|
consumerName: "desktop-dispatch-" + instanceID,
|
||||||
|
bindingKeys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the background AMQP consumer. It is safe to call Run on a hub
|
||||||
|
// without a rabbitmq client — the hub simply behaves as an in-process broker
|
||||||
|
// used in tests, useful for unit-level wiring.
|
||||||
|
func (h *DesktopDispatchHub) Run(ctx context.Context) {
|
||||||
|
if h == nil || h.rabbitMQ == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go h.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a new WebSocket consumer for the given client id. The
|
||||||
|
// returned cancel closes the outbound channel and removes the subscriber; the
|
||||||
|
// WebSocket handler must call it on disconnect.
|
||||||
|
func (h *DesktopDispatchHub) Subscribe(clientID string) (*DispatchSubscriber, func()) {
|
||||||
|
if h == nil || clientID == "" {
|
||||||
|
return nil, func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
stream, ok := h.clients[clientID]
|
||||||
|
if !ok {
|
||||||
|
stream = &dispatchClientStream{subscribers: make(map[uint64]*DispatchSubscriber)}
|
||||||
|
h.clients[clientID] = stream
|
||||||
|
}
|
||||||
|
h.nextSubID++
|
||||||
|
sub := &DispatchSubscriber{
|
||||||
|
id: h.nextSubID,
|
||||||
|
out: make(chan DesktopDispatchEvent, 32),
|
||||||
|
closed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
stream.subscribers[sub.id] = sub
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
cancel := func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
stream, ok := h.clients[clientID]
|
||||||
|
if !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, present := stream.subscribers[sub.id]
|
||||||
|
if !present {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(stream.subscribers, sub.id)
|
||||||
|
if len(stream.subscribers) == 0 {
|
||||||
|
delete(h.clients, clientID)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-existing.closed:
|
||||||
|
// already closed
|
||||||
|
default:
|
||||||
|
close(existing.closed)
|
||||||
|
close(existing.out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish delivers the event to every subscriber currently registered for the
|
||||||
|
// event's target client id on this instance. Events for clients that are not
|
||||||
|
// on this instance are dropped silently, by design.
|
||||||
|
func (h *DesktopDispatchHub) Publish(event DesktopDispatchEvent) {
|
||||||
|
if h == nil || event.TargetClientID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.RLock()
|
||||||
|
stream, ok := h.clients[event.TargetClientID]
|
||||||
|
if !ok {
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subs := make([]*DispatchSubscriber, 0, len(stream.subscribers))
|
||||||
|
for _, sub := range stream.subscribers {
|
||||||
|
subs = append(subs, sub)
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, sub := range subs {
|
||||||
|
select {
|
||||||
|
case <-sub.closed:
|
||||||
|
continue
|
||||||
|
case sub.out <- event:
|
||||||
|
default:
|
||||||
|
// Subscriber's outbound channel is full: the reader has fallen
|
||||||
|
// behind. We drop the message rather than block the dispatch
|
||||||
|
// consumer — the client will resync via the lease API on
|
||||||
|
// reconnect.
|
||||||
|
if h.logger != nil {
|
||||||
|
h.logger.Warn("desktop dispatch subscriber slow, dropping event",
|
||||||
|
zap.String("client_id", event.TargetClientID),
|
||||||
|
zap.String("task_id", event.TaskID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveClientIDs returns the set of client ids currently connected on this
|
||||||
|
// instance. Intended for diagnostics and the /api/health/ready endpoint.
|
||||||
|
func (h *DesktopDispatchHub) ActiveClientIDs() []string {
|
||||||
|
if h == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
out := make([]string, 0, len(h.clients))
|
||||||
|
for id := range h.clients {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DesktopDispatchHub) run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
deliveries, ch, err := h.rabbitMQ.ConsumeDesktopDispatch(h.consumerName, h.bindingKeys)
|
||||||
|
if err != nil {
|
||||||
|
if h.logger != nil {
|
||||||
|
h.logger.Warn("desktop dispatch consume failed, retrying",
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closed := false
|
||||||
|
for !closed {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = ch.Close()
|
||||||
|
return
|
||||||
|
case delivery, ok := <-deliveries:
|
||||||
|
if !ok {
|
||||||
|
closed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.handleDelivery(delivery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = ch.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DesktopDispatchHub) handleDelivery(delivery amqp.Delivery) {
|
||||||
|
var event DesktopDispatchEvent
|
||||||
|
if err := json.Unmarshal(delivery.Body, &event); err != nil {
|
||||||
|
if h.logger != nil {
|
||||||
|
h.logger.Warn("desktop dispatch event decode failed",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("routing_key", delivery.RoutingKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.TargetClientID == "" {
|
||||||
|
// Fall back to the routing key: `{prefix}.{client_id}` -> client_id.
|
||||||
|
if idx := strings.LastIndex(delivery.RoutingKey, "."); idx >= 0 && idx < len(delivery.RoutingKey)-1 {
|
||||||
|
event.TargetClientID = delivery.RoutingKey[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.Publish(event)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package stream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDispatchHub_PublishRoutesToLocalSubscriber(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
|
||||||
|
sub, cancel := hub.Subscribe("client-a")
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
event := DesktopDispatchEvent{
|
||||||
|
Type: "task_available",
|
||||||
|
TaskID: "task-1",
|
||||||
|
TargetClientID: "client-a",
|
||||||
|
Kind: "publish",
|
||||||
|
Status: "queued",
|
||||||
|
}
|
||||||
|
hub.Publish(event)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-sub.Outbound():
|
||||||
|
if got.TaskID != "task-1" {
|
||||||
|
t.Fatalf("unexpected task id: %s", got.TaskID)
|
||||||
|
}
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
t.Fatal("expected to receive event within 200ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchHub_DropsEventsForUnknownClient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
|
||||||
|
|
||||||
|
// Subscribe for a different client, publish for another one.
|
||||||
|
_, cancel := hub.Subscribe("client-b")
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
hub.Publish(DesktopDispatchEvent{
|
||||||
|
Type: "task_available",
|
||||||
|
TargetClientID: "client-a",
|
||||||
|
})
|
||||||
|
|
||||||
|
ids := hub.ActiveClientIDs()
|
||||||
|
if len(ids) != 1 || ids[0] != "client-b" {
|
||||||
|
t.Fatalf("expected only client-b subscribed, got %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchHub_CancelRemovesSubscriber(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
|
||||||
|
sub, cancel := hub.Subscribe("client-a")
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Subsequent publish must not panic even though the subscriber is gone;
|
||||||
|
// channel is closed, reads must yield zero value with !ok.
|
||||||
|
hub.Publish(DesktopDispatchEvent{TargetClientID: "client-a"})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case _, ok := <-sub.Outbound():
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected outbound channel to be closed after cancel")
|
||||||
|
}
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
t.Fatal("expected closed channel to yield immediately")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ids := hub.ActiveClientIDs(); len(ids) != 0 {
|
||||||
|
t.Fatalf("expected no subscribers after cancel, got %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchHub_MultipleSubscribersSameClient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
|
||||||
|
subA, cancelA := hub.Subscribe("client-a")
|
||||||
|
defer cancelA()
|
||||||
|
subB, cancelB := hub.Subscribe("client-a")
|
||||||
|
defer cancelB()
|
||||||
|
|
||||||
|
hub.Publish(DesktopDispatchEvent{
|
||||||
|
Type: "task_available",
|
||||||
|
TaskID: "task-1",
|
||||||
|
TargetClientID: "client-a",
|
||||||
|
})
|
||||||
|
|
||||||
|
for i, ch := range []<-chan DesktopDispatchEvent{subA.Outbound(), subB.Outbound()} {
|
||||||
|
select {
|
||||||
|
case got := <-ch:
|
||||||
|
if got.TaskID != "task-1" {
|
||||||
|
t.Fatalf("subscriber %d got unexpected task id: %s", i, got.TaskID)
|
||||||
|
}
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
t.Fatalf("subscriber %d did not receive event", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchHub_HandleDeliveryFallsBackToRoutingKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
|
||||||
|
sub, cancel := hub.Subscribe("client-c")
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Body intentionally omits target_client_id; the hub should recover it
|
||||||
|
// from the routing key "<prefix>.<client_id>".
|
||||||
|
delivery := amqp.Delivery{
|
||||||
|
RoutingKey: "publish.client-c",
|
||||||
|
Body: []byte(`{"type":"task_available","task_id":"task-9","kind":"publish","status":"queued"}`),
|
||||||
|
}
|
||||||
|
hub.handleDelivery(delivery)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-sub.Outbound():
|
||||||
|
if got.TargetClientID != "client-c" {
|
||||||
|
t.Fatalf("expected fallback to set target client id, got %q", got.TargetClientID)
|
||||||
|
}
|
||||||
|
if got.TaskID != "task-9" {
|
||||||
|
t.Fatalf("unexpected task id: %s", got.TaskID)
|
||||||
|
}
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
t.Fatal("expected fallback-routed event")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
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/messaging/rabbitmq"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"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"
|
"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 {
|
}); publishErr != nil {
|
||||||
s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String()))
|
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{
|
return &CreatePublishJobResponse{
|
||||||
@@ -351,6 +371,43 @@ func resolveRetryArticleIDFromPayload(payload map[string]any) (int64, bool) {
|
|||||||
return 0, false
|
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) {
|
func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) {
|
||||||
if s.logger == nil || err == nil {
|
if s.logger == nil || err == nil {
|
||||||
return
|
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)
|
desktopAccountHandler := NewDesktopAccountHandler(app)
|
||||||
desktopTaskHandler := NewDesktopTaskHandler(app)
|
desktopTaskHandler := NewDesktopTaskHandler(app)
|
||||||
desktopEventsHandler := NewDesktopEventsHandler(app)
|
desktopEventsHandler := NewDesktopEventsHandler(app)
|
||||||
|
desktopDispatchHandler := NewDesktopDispatchHandler(app)
|
||||||
desktopContentHandler := NewDesktopContentHandler(app)
|
desktopContentHandler := NewDesktopContentHandler(app)
|
||||||
publishJobHandler := NewPublishJobHandler(app)
|
publishJobHandler := NewPublishJobHandler(app)
|
||||||
protected.POST("/desktop/clients/register", desktopClientHandler.Register)
|
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/offline", desktopClientHandler.Offline)
|
||||||
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
|
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
|
||||||
desktopAuth.GET("/events", desktopEventsHandler.Stream)
|
desktopAuth.GET("/events", desktopEventsHandler.Stream)
|
||||||
|
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)
|
||||||
desktopAuth.GET("/accounts", desktopAccountHandler.List)
|
desktopAuth.GET("/accounts", desktopAccountHandler.List)
|
||||||
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
|
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
|
||||||
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)
|
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)
|
||||||
|
|||||||
Reference in New Issue
Block a user