吸收数字员工跨设备路由干预冲突预览

This commit is contained in:
baiyanyun
2026-06-13 15:58:10 +08:00
parent 5ab6bc0fd0
commit 7ea963dc9a
7 changed files with 258 additions and 6 deletions

View File

@@ -27,6 +27,18 @@ public class DigitalEmployeePolicySnapshotRowDto {
private Date latestSyncedAt;
@JsonProperty("recommended_resolution")
private String recommendedResolution;
@JsonProperty("route_decision_count")
private Integer routeDecisionCount;
@JsonProperty("blocked_count")
private Integer blockedCount;
@JsonProperty("approval_required_count")
private Integer approvalRequiredCount;
@JsonProperty("missing_context_count")
private Integer missingContextCount;
@JsonProperty("not_mapped_count")
private Integer notMappedCount;
@JsonProperty("intervention_conflict_status")
private String interventionConflictStatus;
@JsonProperty("sync_record_url")
private String syncRecordUrl;
@JsonProperty("client_key_masked")

View File

@@ -546,6 +546,9 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
for (DigitalEmployeeSyncRecord record : page.getRecords()) {
Map<String, Object> view = readJsonObject(record.getBusinessView());
String kind = firstString(view.get("policy_kind"));
if (StringUtils.isBlank(kind) && isRouteInterventionPolicySnapshotView(view)) {
kind = "route_decision";
}
if (StringUtils.isBlank(kind)) continue;
if (StringUtils.isNotBlank(policyKind) && !StringUtils.equals(kind, policyKind)) continue;
grouped.computeIfAbsent(kind, ignored -> new ArrayList<>()).add(record);
@@ -570,22 +573,40 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
private DigitalEmployeePolicySnapshotRowDto toPolicySnapshotRow(String policyKind, List<DigitalEmployeeSyncRecord> records) {
List<Map<String, Object>> views = records.stream().map(record -> readJsonObject(record.getBusinessView())).toList();
List<String> signatures = views.stream().map(this::policySignature).distinct().toList();
List<Map<String, Object>> policyViews = views.stream()
.filter(view -> StringUtils.isNotBlank(firstString(view.get("policy_kind"))))
.toList();
List<Map<String, Object>> routeInterventionViews = views.stream()
.filter(this::isRouteInterventionPolicySnapshotView)
.toList();
List<String> signatures = policyViews.stream().map(this::policySignature).distinct().toList();
DigitalEmployeeSyncRecord latest = records.stream()
.max(Comparator.comparing(DigitalEmployeeSyncRecord::getSyncedAt, Comparator.nullsLast(Comparator.naturalOrder())))
.orElse(records.get(0));
Map<String, Object> latestView = readJsonObject(latest.getBusinessView());
String status = signatures.size() > 1 ? "conflict" : "consistent";
String interventionConflictStatus = routeInterventionConflictStatus(routeInterventionViews);
boolean routeConflict = StringUtils.equals(interventionConflictStatus, "conflict");
String status = signatures.size() > 1 || routeConflict ? "conflict" : "consistent";
List<String> conflictKeys = new ArrayList<>("conflict".equals(status) ? policyConflictKeys(policyViews) : List.of());
if (routeConflict && !conflictKeys.contains("route_intervention_state")) {
conflictKeys.add("route_intervention_state");
}
return DigitalEmployeePolicySnapshotRowDto.builder()
.policyKind(policyKind)
.policyKey(firstString(latestView.get("policy_key"), policyKind))
.status(status)
.summary(firstString(latestView.get("summary"), latestView.get("title"), policyKind))
.summary(routeConflict ? "跨设备路由干预冲突" : firstString(latestView.get("summary"), latestView.get("title"), policyKind))
.clientCount((int) records.stream().map(DigitalEmployeeSyncRecord::getClientKey).filter(StringUtils::isNotBlank).distinct().count())
.deviceCount((int) records.stream().map(DigitalEmployeeSyncRecord::getDeviceId).filter(StringUtils::isNotBlank).distinct().count())
.conflictKeys("conflict".equals(status) ? policyConflictKeys(views) : List.of())
.conflictKeys(conflictKeys)
.latestSyncedAt(latest.getSyncedAt())
.recommendedResolution("conflict".equals(status) ? "review_latest_policy_snapshot" : "keep_current")
.routeDecisionCount(routeInterventionViews.size())
.blockedCount(routeInterventionCount(routeInterventionViews, "blocked"))
.approvalRequiredCount(routeInterventionCount(routeInterventionViews, "approval_required"))
.missingContextCount(routeInterventionCount(routeInterventionViews, "missing_context"))
.notMappedCount(routeInterventionCount(routeInterventionViews, "not_mapped"))
.interventionConflictStatus(interventionConflictStatus)
.syncRecordUrl(syncRecordUrl(latest.getOutboxId()))
.clientKeyMasked(maskClientKey(latest.getClientKey()))
.deviceId(latest.getDeviceId())
@@ -593,6 +614,47 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
.build();
}
private boolean isRouteInterventionPolicySnapshotView(Map<String, Object> view) {
String category = firstString(view.get("category"));
String kind = firstString(view.get("kind"));
return StringUtils.equals(category, "route_intervention")
|| StringUtils.equals(category, "route_governance")
|| contains(kind, "route_decision_blocked")
|| contains(kind, "route_decision_approval_required")
|| contains(kind, "route_action_policy_blocked")
|| contains(kind, "route_action_missing_context")
|| contains(kind, "route_action_not_mapped")
|| StringUtils.isNotBlank(firstString(view.get("route_decision_id"), view.get("routeDecisionId")));
}
private String routeInterventionConflictStatus(List<Map<String, Object>> views) {
if (views.isEmpty()) return "none";
return views.stream().map(this::routeInterventionState).distinct().count() > 1 ? "conflict" : "consistent";
}
private int routeInterventionCount(List<Map<String, Object>> views, String state) {
return (int) views.stream().filter(view -> StringUtils.equals(routeInterventionState(view), state)).count();
}
private String routeInterventionState(Map<String, Object> view) {
String kind = firstString(view.get("kind"));
String policyResult = firstString(view.get("policy_result"), view.get("policyResult"));
String status = firstString(view.get("status"));
if (contains(kind, "route_decision_approval_required") || contains(policyResult, "approval_required") || contains(status, "approval_required")) {
return "approval_required";
}
if (contains(kind, "route_action_missing_context") || contains(status, "missing_context")) {
return "missing_context";
}
if (contains(kind, "route_action_not_mapped") || contains(status, "not_mapped")) {
return "not_mapped";
}
if (contains(kind, "route_decision_blocked") || contains(kind, "route_action_policy_blocked") || contains(policyResult, "blocked") || contains(status, "blocked")) {
return "blocked";
}
return firstString(policyResult, status, "observed");
}
private String policySignature(Map<String, Object> view) {
Map<String, Object> signature = new LinkedHashMap<>();
for (String key : policyConflictCandidateKeys()) {

View File

@@ -1460,6 +1460,86 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
assertThat(row.getRecommendedResolution()).isEqualTo("review_latest_policy_snapshot");
}
@Test
void queryAdminPolicySnapshotsSummarizesRouteInterventionConflicts() {
DigitalEmployeeSyncRecord routeBlocked = record("outbox-route-blocked", "event", "event-route-1", 101L, new Date(10_000),
Map.of(
"entity_type", "event",
"category", "route_intervention",
"kind", "route_decision_blocked",
"status", "blocked",
"title", "入口路由被阻断",
"route_decision_id", "route-1",
"policy_result", "blocked",
"blocked_reason", "sensitive_action"
),
Map.of("id", "event-route-1", "kind", "route_decision_blocked"));
routeBlocked.setClientKey("client-key-a");
routeBlocked.setDeviceId("device-a");
DigitalEmployeeSyncRecord routeApproval = record("outbox-route-approval", "event", "event-route-2", 101L, new Date(11_000),
Map.of(
"entity_type", "event",
"category", "route_intervention",
"kind", "route_decision_approval_required",
"status", "approval_required",
"title", "入口路由待审批",
"route_decision_id", "route-2",
"policy_result", "approval_required",
"approval_id", "approval-1"
),
Map.of("id", "event-route-2", "kind", "route_decision_approval_required"));
routeApproval.setClientKey("client-key-b");
routeApproval.setDeviceId("device-b");
DigitalEmployeeSyncRecord routeMissingContext = record("outbox-route-missing-context", "event", "event-route-3", 101L, new Date(12_000),
Map.of(
"entity_type", "event",
"category", "route_governance",
"kind", "route_action_missing_context",
"status", "missing_context",
"title", "路由动作缺少上下文",
"route_decision_id", "route-3"
),
Map.of("id", "event-route-3", "kind", "route_action_missing_context"));
routeMissingContext.setClientKey("client-key-c");
routeMissingContext.setDeviceId("device-c");
when(repository.queryBusinessRecords(eq(101L), eq(null), eq(null), eq("event"), eq(null), eq(null), eq(null), eq(null), eq(1L), eq(100L)))
.thenReturn(page(List.of(
routeBlocked,
routeApproval,
routeMissingContext,
record("outbox-policy", "event", "event-policy-1", 101L, new Date(13_000),
Map.of(
"entity_type", "event",
"category", "policy_snapshot",
"kind", "digital_workday_pull_route_decision_policy",
"policy_kind", "route_decision",
"policy_key", "route_decision",
"enabled", true,
"auto_create_governance_commands", true,
"title", "入口路由策略"
),
Map.of("kind", "digital_workday_pull_route_decision_policy")),
record("outbox-skill", "event", "event-skill-1", 101L, new Date(14_000),
Map.of("entity_type", "event", "category", "skill_call_audit", "title", "技能审计", "status", "allowed"),
Map.of("id", "event-skill-1", "status", "allowed"))
)));
DigitalEmployeePolicySnapshotPageDto result = service.queryAdminPolicySnapshots(null, null, "conflict", "route_decision", null, null, 1L, 20L);
assertThat(result.getTotal()).isEqualTo(1);
DigitalEmployeePolicySnapshotRowDto row = result.getRecords().get(0);
assertThat(row.getPolicyKind()).isEqualTo("route_decision");
assertThat(row.getStatus()).isEqualTo("conflict");
assertThat(row.getRouteDecisionCount()).isEqualTo(3);
assertThat(row.getBlockedCount()).isEqualTo(1);
assertThat(row.getApprovalRequiredCount()).isEqualTo(1);
assertThat(row.getMissingContextCount()).isEqualTo(1);
assertThat(row.getNotMappedCount()).isZero();
assertThat(row.getInterventionConflictStatus()).isEqualTo("conflict");
assertThat(row.getConflictKeys()).contains("route_intervention_state");
}
@Test
void recordDailyReportSendResultStoresRecordedObservation() {
DigitalEmployeeAdminRecordRequestDto request = adminRecordRequest("daily_report", "report-1");