feat: deep link digital sync failures

This commit is contained in:
baiyanyun
2026-06-07 01:35:08 +08:00
parent 508406ff10
commit 646901b5fa
16 changed files with 142 additions and 33 deletions

View File

@@ -11,6 +11,7 @@ public interface DigitalEmployeeSyncApplicationService {
String clientKey, String clientKey,
String entityType, String entityType,
String entityId, String entityId,
String outboxId,
Long pageNo, Long pageNo,
Long pageSize Long pageSize
); );

View File

@@ -10,6 +10,7 @@ public interface DigitalEmployeeSyncRecordRepository extends IService<DigitalEmp
String clientKey, String clientKey,
String entityType, String entityType,
String entityId, String entityId,
String outboxId,
long pageNo, long pageNo,
long pageSize long pageSize
); );

View File

@@ -93,6 +93,7 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
String clientKey, String clientKey,
String entityType, String entityType,
String entityId, String entityId,
String outboxId,
Long pageNo, Long pageNo,
Long pageSize Long pageSize
) { ) {
@@ -115,6 +116,7 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
clientKey, clientKey,
entityType, entityType,
entityId, entityId,
outboxId,
currentPage, currentPage,
currentPageSize currentPageSize
); );

View File

@@ -21,6 +21,7 @@ public class DigitalEmployeeSyncRecordRepositoryImpl
String clientKey, String clientKey,
String entityType, String entityType,
String entityId, String entityId,
String outboxId,
long pageNo, long pageNo,
long pageSize long pageSize
) { ) {
@@ -29,6 +30,7 @@ public class DigitalEmployeeSyncRecordRepositoryImpl
.eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeSyncRecord::getClientKey, clientKey) .eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeSyncRecord::getClientKey, clientKey)
.eq(StringUtils.isNotBlank(entityType), DigitalEmployeeSyncRecord::getEntityType, entityType) .eq(StringUtils.isNotBlank(entityType), DigitalEmployeeSyncRecord::getEntityType, entityType)
.eq(StringUtils.isNotBlank(entityId), DigitalEmployeeSyncRecord::getEntityId, entityId) .eq(StringUtils.isNotBlank(entityId), DigitalEmployeeSyncRecord::getEntityId, entityId)
.eq(StringUtils.isNotBlank(outboxId), DigitalEmployeeSyncRecord::getOutboxId, outboxId)
.orderByDesc(DigitalEmployeeSyncRecord::getSyncedAt) .orderByDesc(DigitalEmployeeSyncRecord::getSyncedAt)
.orderByDesc(DigitalEmployeeSyncRecord::getId); .orderByDesc(DigitalEmployeeSyncRecord::getId);
return page(new Page<>(pageNo, pageSize), queryWrapper); return page(new Page<>(pageNo, pageSize), queryWrapper);

View File

@@ -33,6 +33,7 @@ public class DigitalEmployeeSyncController {
@RequestParam(name = "client_key", required = false) String clientKey, @RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "entity_type", required = false) String entityType, @RequestParam(name = "entity_type", required = false) String entityType,
@RequestParam(name = "entity_id", required = false) String entityId, @RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "outbox_id", required = false) String outboxId,
@RequestParam(name = "page_no", required = false) Long pageNo, @RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize @RequestParam(name = "page_size", required = false) Long pageSize
) { ) {
@@ -40,6 +41,7 @@ public class DigitalEmployeeSyncController {
clientKey, clientKey,
entityType, entityType,
entityId, entityId,
outboxId,
pageNo, pageNo,
pageSize pageSize
)); ));

View File

@@ -10,7 +10,7 @@ import type {
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { Button, Drawer, message, Tag, Typography } from 'antd'; import { Button, Drawer, message, Tag, Typography } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useModel } from 'umi'; import { useLocation, useModel, useSearchParams } from 'umi';
const ENTITY_TYPE_OPTIONS = { const ENTITY_TYPE_OPTIONS = {
plan: { text: 'Plan' }, plan: { text: 'Plan' },
@@ -39,11 +39,20 @@ function formatPayload(payload?: string): string {
} }
} }
function readSearchValue(
params: URLSearchParams,
key: string,
): string | undefined {
const value = params.get(key)?.trim();
return value || undefined;
}
const DigitalEmployeeSync: React.FC = () => { const DigitalEmployeeSync: React.FC = () => {
const { hasPermission } = useModel('menuModel'); const { hasPermission } = useModel('menuModel');
const actionRef = useRef<ActionType>(); const actionRef = useRef<ActionType>();
const formRef = useRef<FormInstance>(); const formRef = useRef<FormInstance>();
const location = useLocation(); const location = useLocation();
const [, setSearchParams] = useSearchParams();
const [payloadRecord, setPayloadRecord] = const [payloadRecord, setPayloadRecord] =
useState<DigitalEmployeeSyncRecordInfo | null>(null); useState<DigitalEmployeeSyncRecordInfo | null>(null);
const formattedPayload = useMemo( const formattedPayload = useMemo(
@@ -51,12 +60,32 @@ const DigitalEmployeeSync: React.FC = () => {
[payloadRecord], [payloadRecord],
); );
const formInitialValues = useMemo(() => {
const params = new URLSearchParams(location.search || '');
const values = {
client_key: readSearchValue(params, 'client_key'),
entity_type: readSearchValue(params, 'entity_type'),
entity_id: readSearchValue(params, 'entity_id'),
outbox_id: readSearchValue(params, 'outbox_id'),
};
return Object.values(values).some(Boolean) ? values : undefined;
}, [location.search]);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
formRef.current?.resetFields(); formRef.current?.resetFields();
actionRef.current?.reset?.(); formRef.current?.setFieldsValue({
client_key: undefined,
entity_type: undefined,
entity_id: undefined,
outbox_id: undefined,
});
actionRef.current?.setPageInfo?.({ current: 1, pageSize: 15 }); actionRef.current?.setPageInfo?.({ current: 1, pageSize: 15 });
if (location.search) {
setSearchParams(new URLSearchParams());
return;
}
actionRef.current?.reload(); actionRef.current?.reload();
}, []); }, [location.search, setSearchParams]);
useEffect(() => { useEffect(() => {
const state = location.state as any; const state = location.state as any;
@@ -116,7 +145,10 @@ const DigitalEmployeeSync: React.FC = () => {
dataIndex: 'outbox_id', dataIndex: 'outbox_id',
width: 220, width: 220,
ellipsis: true, ellipsis: true,
hideInSearch: true, fieldProps: {
placeholder: '输入 Outbox ID',
allowClear: true,
},
}, },
{ {
title: '操作', title: '操作',
@@ -164,6 +196,7 @@ const DigitalEmployeeSync: React.FC = () => {
client_key?: string; client_key?: string;
entity_type?: string; entity_type?: string;
entity_id?: string; entity_id?: string;
outbox_id?: string;
}) => { }) => {
const response = await apiDigitalEmployeeSyncRecordList({ const response = await apiDigitalEmployeeSyncRecordList({
pageNo: params.current || 1, pageNo: params.current || 1,
@@ -171,6 +204,7 @@ const DigitalEmployeeSync: React.FC = () => {
clientKey: params.client_key, clientKey: params.client_key,
entityType: params.entity_type, entityType: params.entity_type,
entityId: params.entity_id, entityId: params.entity_id,
outboxId: params.outbox_id,
}); });
if (response.code !== SUCCESS_CODE) { if (response.code !== SUCCESS_CODE) {
@@ -187,12 +221,16 @@ const DigitalEmployeeSync: React.FC = () => {
return ( return (
<WorkspaceLayout title="数字员工同步记录" hideScroll> <WorkspaceLayout title="数字员工同步记录" hideScroll>
<XProTable<DigitalEmployeeSyncRecordInfo> <XProTable<DigitalEmployeeSyncRecordInfo>
key={location.pathname + (location.search || '')}
actionRef={actionRef} actionRef={actionRef}
formRef={formRef} formRef={formRef}
rowKey="id" rowKey="id"
columns={columns} columns={columns}
request={request} request={request}
onReset={handleReset} onReset={handleReset}
form={
formInitialValues ? { initialValues: formInitialValues } : undefined
}
showQueryButtons={hasPermission( showQueryButtons={hasPermission(
'content_digital_employee_sync_query_list', 'content_digital_employee_sync_query_list',
)} )}

View File

@@ -276,6 +276,7 @@ export async function apiDigitalEmployeeSyncRecordList(
client_key: data.clientKey, client_key: data.clientKey,
entity_type: data.entityType, entity_type: data.entityType,
entity_id: data.entityId, entity_id: data.entityId,
outbox_id: data.outboxId,
page_no: data.pageNo, page_no: data.pageNo,
page_size: data.pageSize, page_size: data.pageSize,
}, },

View File

@@ -544,6 +544,7 @@ export interface DigitalEmployeeSyncRecordListParams
clientKey?: string; clientKey?: string;
entityType?: string; entityType?: string;
entityId?: string; entityId?: string;
outboxId?: string;
} }
// 数字员工同步记录分页响应 // 数字员工同步记录分页响应

View File

@@ -1701,8 +1701,14 @@
.de-event-modal-actions { .de-event-modal-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.de-event-modal-actions .de-btn-sm {
display: inline-flex;
align-items: center;
gap: 6px;
}
.de-event-history-toolbar { .de-event-history-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -73,6 +73,7 @@ interface QimingclawSyncFailure {
lastAttemptAt: string | null; lastAttemptAt: string | null;
nextRetryAt?: string | null; nextRetryAt?: string | null;
dueForRetry?: boolean; dueForRetry?: boolean;
managementRecordUrl?: string | null;
error: string | null; error: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -73,6 +73,7 @@ interface ManagementSyncFailure {
lastAttemptAt: string | null; lastAttemptAt: string | null;
nextRetryAt?: string | null; nextRetryAt?: string | null;
dueForRetry?: boolean; dueForRetry?: boolean;
managementRecordUrl?: string | null;
error: string | null; error: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -1837,12 +1838,36 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<p>{selectedSyncFailure.error || '暂无错误详情。'}</p> <p>{selectedSyncFailure.error || '暂无错误详情。'}</p>
</div> </div>
<div className="de-event-modal-actions"> <div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => setSelectedSyncFailure(null)}></button> <button
type="button"
className="de-btn-secondary de-btn-sm"
onClick={() => setSelectedSyncFailure(null)}
>
</button>
{selectedSyncFailure.managementRecordUrl && (
<button
type="button"
className="de-btn-secondary de-btn-sm"
onClick={() =>
window.open(
selectedSyncFailure.managementRecordUrl || '',
'_blank',
'noopener,noreferrer',
)
}
>
<Eye size={14} />
</button>
)}
<button <button
type="button" type="button"
className="de-btn-primary de-btn-sm" className="de-btn-primary de-btn-sm"
disabled={actionBusy === 'flush_management_sync'} disabled={actionBusy === 'flush_management_sync'}
onClick={() => { void flushManagementSync().then(() => setSelectedSyncFailure(null)); }} onClick={() => {
void flushManagementSync().then(() => setSelectedSyncFailure(null));
}}
> >
{actionBusy === 'flush_management_sync' ? '同步中...' : '立即重试'} {actionBusy === 'flush_management_sync' ? '同步中...' : '立即重试'}
</button> </button>

View File

@@ -13,8 +13,8 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error); console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
} }
</script> </script>
<script type="module" crossorigin src="./assets/index-DHy6Ywa5.js"></script> <script type="module" crossorigin src="./assets/index-BmpavZF5.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-YI8Pgcr5.css"> <link rel="stylesheet" crossorigin href="./assets/index-DDOlQz2O.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -4,6 +4,8 @@ import { getDomainTokenKey } from "@shared/utils/domain";
import { getDb, readSetting } from "../../db"; import { getDb, readSetting } from "../../db";
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox"; const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync";
const DEFAULT_INTERVAL_MS = 30_000; const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_BATCH_SIZE = 50; const DEFAULT_BATCH_SIZE = 50;
const REQUEST_TIMEOUT_MS = 15_000; const REQUEST_TIMEOUT_MS = 15_000;
@@ -42,6 +44,7 @@ export interface DigitalEmployeeSyncFailure {
lastAttemptAt: string | null; lastAttemptAt: string | null;
nextRetryAt: string | null; nextRetryAt: string | null;
dueForRetry: boolean; dueForRetry: boolean;
managementRecordUrl: string | null;
error: string | null; error: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -109,7 +112,7 @@ export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
failed: countOutbox("failed"), failed: countOutbox("failed"),
lastSyncAt, lastSyncAt,
lastSyncError, lastSyncError,
recentFailures: readRecentFailures(), recentFailures: readRecentFailures(config),
}; };
} }
@@ -385,7 +388,10 @@ function countOutbox(status: string): number {
return row?.count ?? 0; return row?.count ?? 0;
} }
function readRecentFailures(limit = 3): DigitalEmployeeSyncFailure[] { function readRecentFailures(
config: DigitalEmployeeSyncConfig,
limit = 3,
): DigitalEmployeeSyncFailure[] {
const db = getDb(); const db = getDb();
if (!db) return []; if (!db) return [];
const rows = db const rows = db
@@ -422,6 +428,7 @@ function readRecentFailures(limit = 3): DigitalEmployeeSyncFailure[] {
lastAttemptAt: row.last_attempt_at, lastAttemptAt: row.last_attempt_at,
nextRetryAt, nextRetryAt,
dueForRetry: !nextRetryAt || Date.now() >= Date.parse(nextRetryAt), dueForRetry: !nextRetryAt || Date.now() >= Date.parse(nextRetryAt),
managementRecordUrl: buildManagementRecordUrl(config, row),
error: row.error, error: row.error,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@@ -429,6 +436,28 @@ function readRecentFailures(limit = 3): DigitalEmployeeSyncFailure[] {
}); });
} }
function buildManagementRecordUrl(
config: DigitalEmployeeSyncConfig,
row: {
id: string;
entity_type: string;
entity_id: string;
},
): string | null {
if (!config.endpoint) return null;
try {
const endpoint = new URL(config.endpoint);
const url = new URL(MANAGEMENT_SYNC_RECORD_PATH, endpoint.origin);
if (config.clientKey) url.searchParams.set("client_key", config.clientKey);
url.searchParams.set("entity_type", row.entity_type);
url.searchParams.set("entity_id", row.entity_id);
url.searchParams.set("outbox_id", row.id);
return url.toString();
} catch {
return null;
}
}
function nextRetryAtForFailure( function nextRetryAtForFailure(
lastAttemptAt: string | null, lastAttemptAt: string | null,
attempts: number, attempts: number,

View File

@@ -446,7 +446,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。 - 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、最近同步时间和最近失败原因摘要。 - 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、最近同步时间和最近失败原因摘要。
- 同步状态通过 bridge 返回最近失败 outbox 明细首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。 - 同步状态通过 bridge 返回最近失败 outbox 明细首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态并可触发手动 `flushSync` 立即重试。 - 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态并可触发手动 `flushSync` 立即重试;若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链
新增 IPC / Bridge 新增 IPC / Bridge
@@ -479,15 +479,15 @@ qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-a
GET /api/digital-employee/sync/records GET /api/digital-employee/sync/records
``` ```
- 支持 `client_key``entity_type``entity_id``page_no``page_size` 查询参数。 - 支持 `client_key``entity_type``entity_id``outbox_id``page_no``page_size` 查询参数。
- 按当前登录上下文租户隔离,默认每页 20 条,最大每页 100 条。 - 按当前登录上下文租户隔离,默认每页 20 条,最大每页 100 条。
- 当前返回通用同步记录和原始 payload用于管理端页面或联调工具先查看同步结果后续再拆分为更丰富的 Plan / Task / Run / Event 查询模型。 - 当前返回通用同步记录和原始 payload用于管理端页面或联调工具先查看同步结果后续再拆分为更丰富的 Plan / Task / Run / Event 查询模型。
- 管理端已增加“数字员工同步记录”菜单、权限资源和列表页,可按客户端 Key、实体类型、实体 ID 查询并查看 payload。 - 管理端已增加“数字员工同步记录”菜单、权限资源和列表页,可按客户端 Key、实体类型、实体 ID、Outbox ID 查询并查看 payload,且支持 URL 查询参数预填筛选条件
下一步: 下一步:
- 后续再拆分管理端 Plan / Task / Run / Event 查询模型,让同步记录从通用 payload 观察面升级为业务视图。 - 后续再拆分管理端 Plan / Task / Run / Event 查询模型,让同步记录从通用 payload 观察面升级为业务视图。
- 数字员工页面继续补充更完整的同步重试历史和管理端记录深链 - 数字员工页面继续补充更完整的同步重试历史。
## 管理端联动原则 ## 管理端联动原则