修复数字员工同步业务失败识别
This commit is contained in:
@@ -139,6 +139,80 @@ describe("digital employee sync service", () => {
|
|||||||
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts backend ReqResult acknowledgements", async () => {
|
||||||
|
insertPlan("plan-reqresult-ok");
|
||||||
|
insertOutbox("plan:upsert:plan-reqresult-ok", "plan", "plan-reqresult-ok");
|
||||||
|
mockState.fetch.mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
code: "0000",
|
||||||
|
message: "success",
|
||||||
|
data: {
|
||||||
|
acked: [
|
||||||
|
{
|
||||||
|
outbox_id: "plan:upsert:plan-reqresult-ok",
|
||||||
|
entity_type: "plan",
|
||||||
|
entity_id: "plan-reqresult-ok",
|
||||||
|
remote_id: "remote-plan-reqresult-ok",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
||||||
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
||||||
|
|
||||||
|
expect(status.lastSyncError).toBeNull();
|
||||||
|
expect(status.pending).toBe(0);
|
||||||
|
expect(status.failed).toBe(0);
|
||||||
|
expect(readOutbox("plan:upsert:plan-reqresult-ok")).toMatchObject({
|
||||||
|
status: "synced",
|
||||||
|
error: null,
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
expect(readPlan("plan-reqresult-ok")).toMatchObject({
|
||||||
|
sync_status: "synced",
|
||||||
|
remote_id: "remote-plan-reqresult-ok",
|
||||||
|
sync_error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks backend ReqResult business failures as failed", async () => {
|
||||||
|
insertPlan("plan-rejected");
|
||||||
|
insertOutbox("plan:upsert:plan-rejected", "plan", "plan-rejected");
|
||||||
|
mockState.fetch.mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
code: "0001",
|
||||||
|
message: "客户端 Key header 缺失",
|
||||||
|
data: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
||||||
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
||||||
|
|
||||||
|
expect(status.lastSyncError).toBe("客户端 Key header 缺失");
|
||||||
|
expect(status.pending).toBe(0);
|
||||||
|
expect(status.failed).toBe(1);
|
||||||
|
expect(readOutbox("plan:upsert:plan-rejected")).toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
error: "客户端 Key header 缺失",
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
expect(readPlan("plan-rejected")).toMatchObject({
|
||||||
|
sync_status: "failed",
|
||||||
|
sync_error: "客户端 Key header 缺失",
|
||||||
|
});
|
||||||
|
expect(readAttempts()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
outbox_id: "plan:upsert:plan-rejected",
|
||||||
|
attempt_no: 1,
|
||||||
|
status: "failed",
|
||||||
|
error: "客户端 Key header 缺失",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps unacknowledged rows retryable after a partial backend ack", async () => {
|
it("keeps unacknowledged rows retryable after a partial backend ack", async () => {
|
||||||
insertPlan("plan-1");
|
insertPlan("plan-1");
|
||||||
insertPlan("plan-2");
|
insertPlan("plan-2");
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ interface SyncAck {
|
|||||||
|
|
||||||
interface SyncResponse {
|
interface SyncResponse {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
|
code?: string | number;
|
||||||
|
status?: string | number;
|
||||||
acked?: SyncAck[];
|
acked?: SyncAck[];
|
||||||
data?: {
|
data?: {
|
||||||
acked?: SyncAck[];
|
acked?: SyncAck[];
|
||||||
@@ -185,9 +187,8 @@ export async function flushDigitalEmployeeSyncOutbox(
|
|||||||
const response = await postOutbox(config, rows);
|
const response = await postOutbox(config, rows);
|
||||||
const finishedAt = new Date().toISOString();
|
const finishedAt = new Date().toISOString();
|
||||||
const acked = response.data?.acked || response.acked || [];
|
const acked = response.data?.acked || response.acked || [];
|
||||||
if (response.success === false) {
|
const responseError = readSyncResponseError(response);
|
||||||
throw new Error(response.message || "digital employee sync rejected");
|
if (responseError) throw new Error(responseError);
|
||||||
}
|
|
||||||
const syncedRows = acked.length > 0
|
const syncedRows = acked.length > 0
|
||||||
? rows.filter((row) => acked.some((ack) => ackMatchesRow(ack, row)))
|
? rows.filter((row) => acked.some((ack) => ackMatchesRow(ack, row)))
|
||||||
: rows;
|
: rows;
|
||||||
@@ -236,6 +237,35 @@ export async function flushDigitalEmployeeSyncOutbox(
|
|||||||
return getDigitalEmployeeSyncStatus();
|
return getDigitalEmployeeSyncStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSyncResponseError(response: SyncResponse): string | null {
|
||||||
|
if (response.success === false) {
|
||||||
|
return response.message || "digital employee sync rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = normalizeResponseCode(response.code);
|
||||||
|
if (code && code !== "0000") {
|
||||||
|
return response.message || `digital employee sync rejected: ${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = normalizeResponseCode(response.status);
|
||||||
|
if (status && ["error", "fail", "failed", "failure"].includes(status.toLowerCase())) {
|
||||||
|
return response.message || "digital employee sync rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponseCode(value: unknown): string | null {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||||
const config = (readSetting("digital_employee_sync_config") || {}) as Record<
|
const config = (readSetting("digital_employee_sync_config") || {}) as Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
Reference in New Issue
Block a user