Add monitoring service and database schema

- Implement monitoring service with heartbeat, lease tasks, resume tasks, and task result handling.
- Create monitoring time utilities for business date calculations.
- Add unit tests for date window resolution and business day handling.
- Define database schema for monitoring-related tables including quotas, daily reports, and task management.
- Establish migration scripts for creating and dropping monitoring tables.
This commit is contained in:
2026-04-12 09:56:18 +08:00
parent 9b4dd09780
commit 6066f43a7d
67 changed files with 16312 additions and 125 deletions
+37
View File
@@ -32,6 +32,9 @@ import type {
LoginRequest,
LoginResponse,
MediaPlatform,
MonitoringCollectNowResponse,
MonitoringDashboardCompositeResponse,
MonitoringQuestionDetailResponse,
PlatformAccount,
PublishRecord,
RegisterPluginInstallationRequest,
@@ -549,6 +552,40 @@ export const mediaApi = {
},
};
export const monitoringApi = {
dashboardComposite(params: {
brand_id?: number;
keyword_id?: number | null;
days?: number;
business_date?: string;
}) {
return apiClient.get<MonitoringDashboardCompositeResponse>("/api/tenant/monitoring/dashboard/composite", {
params,
});
},
questionDetail(
brandId: number,
questionId: number,
params: {
date_from?: string;
date_to?: string;
question_hash?: string;
ai_platform_id?: string;
},
) {
return apiClient.get<MonitoringQuestionDetailResponse>(
`/api/tenant/monitoring/brands/${brandId}/questions/${questionId}/detail`,
{ params },
);
},
collectNow(brandId: number, payload?: { keyword_id?: number | null }) {
return apiClient.post<MonitoringCollectNowResponse, { keyword_id?: number | null }>(
`/api/tenant/monitoring/brands/${brandId}/collect-now`,
payload ?? {},
);
},
};
export const promptRulesApi = {
list(params?: PromptRuleListParams) {
return apiClient.get<PromptRuleListResponse>("/api/tenant/prompt-rules", { params });
+6
View File
@@ -40,6 +40,12 @@ const errorMessageMap: Record<string, string> = {
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
publisher_plugin_unknown_error: "浏览器插件调用失败,请稍后重试",
monitoring_plugin_timeout: "监测插件响应超时,请稍后重试",
monitoring_plugin_invalid_response: "监测插件返回了无效数据,请稍后重试",
monitoring_plugin_unknown_error: "监测插件调用失败,请稍后重试",
monitoring_plugin_required: "请先安装并打开浏览器插件",
monitoring_plugin_register_required: "浏览器插件正在初始化,请稍后重试",
installation_offline: "监测插件离线,请打开浏览器插件后重试",
network_error: "网络连接失败,请稍后重试",
unknown_error: "发生未知错误",
};
+155
View File
@@ -0,0 +1,155 @@
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);
}