吸收数字员工跨设备策略冲突视图

This commit is contained in:
baiyanyun
2026-06-12 21:48:33 +08:00
parent b3f21adecb
commit 8a5d5d6474
13 changed files with 643 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRe
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
@@ -62,6 +63,17 @@ public interface DigitalEmployeeSyncApplicationService {
Long pageSize
);
DigitalEmployeePolicySnapshotPageDto queryAdminPolicySnapshots(
String clientKey,
String deviceId,
String status,
String policyKind,
Date updatedFrom,
Date updatedTo,
Long pageNo,
Long pageSize
);
DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request);
DigitalEmployeeAdminActionPageDto queryPendingAdminActions(

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeePolicySnapshotPageDto {
private Long total;
@JsonProperty("page_no")
private Long pageNo;
@JsonProperty("page_size")
private Long pageSize;
private List<DigitalEmployeePolicySnapshotRowDto> records;
}

View File

@@ -0,0 +1,38 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class DigitalEmployeePolicySnapshotRowDto {
@JsonProperty("policy_kind")
private String policyKind;
@JsonProperty("policy_key")
private String policyKey;
private String status;
private String summary;
@JsonProperty("client_count")
private Integer clientCount;
@JsonProperty("device_count")
private Integer deviceCount;
@JsonProperty("conflict_keys")
private List<String> conflictKeys;
@JsonProperty("latest_synced_at")
private Date latestSyncedAt;
@JsonProperty("recommended_resolution")
private String recommendedResolution;
@JsonProperty("sync_record_url")
private String syncRecordUrl;
@JsonProperty("client_key_masked")
private String clientKeyMasked;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("business_view")
private Map<String, Object> businessView;
}

View File

@@ -16,6 +16,8 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenc
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewRowDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotRowDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncAckDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordDto;
@@ -343,6 +345,120 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
return adminWorkbenchPage(rows, currentPage, currentPageSize);
}
@Override
public DigitalEmployeePolicySnapshotPageDto queryAdminPolicySnapshots(
String clientKey,
String deviceId,
String status,
String policyKind,
Date updatedFrom,
Date updatedTo,
Long pageNo,
Long pageSize
) {
long currentPage = pageNo == null || pageNo < 1 ? DEFAULT_PAGE_NO : pageNo;
long currentPageSize = pageSize == null || pageSize < 1
? DEFAULT_PAGE_SIZE
: Math.min(pageSize, MAX_PAGE_SIZE);
Long tenantId = currentTenantId();
if (tenantId == null) {
return policySnapshotPage(List.of(), currentPage, currentPageSize);
}
IPage<DigitalEmployeeSyncRecord> page = digitalEmployeeSyncRecordRepository.queryBusinessRecords(
tenantId,
clientKey,
deviceId,
"event",
null,
updatedFrom,
updatedTo,
null,
DEFAULT_PAGE_NO,
MAX_PAGE_SIZE
);
Map<String, List<DigitalEmployeeSyncRecord>> grouped = new LinkedHashMap<>();
for (DigitalEmployeeSyncRecord record : page.getRecords()) {
Map<String, Object> view = readJsonObject(record.getBusinessView());
String kind = firstString(view.get("policy_kind"));
if (StringUtils.isBlank(kind)) continue;
if (StringUtils.isNotBlank(policyKind) && !StringUtils.equals(kind, policyKind)) continue;
grouped.computeIfAbsent(kind, ignored -> new ArrayList<>()).add(record);
}
List<DigitalEmployeePolicySnapshotRowDto> rows = grouped.entrySet().stream()
.map(entry -> toPolicySnapshotRow(entry.getKey(), entry.getValue()))
.filter(row -> matchesText(status, row.getStatus()))
.toList();
return policySnapshotPage(rows, currentPage, currentPageSize);
}
private DigitalEmployeePolicySnapshotPageDto policySnapshotPage(List<DigitalEmployeePolicySnapshotRowDto> rows, long pageNo, long pageSize) {
int fromIndex = (int) Math.min(Math.max(pageNo - 1, 0) * pageSize, rows.size());
int toIndex = (int) Math.min(fromIndex + pageSize, rows.size());
return DigitalEmployeePolicySnapshotPageDto.builder()
.total((long) rows.size())
.pageNo(pageNo)
.pageSize(pageSize)
.records(rows.subList(fromIndex, toIndex))
.build();
}
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();
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";
return DigitalEmployeePolicySnapshotRowDto.builder()
.policyKind(policyKind)
.policyKey(firstString(latestView.get("policy_key"), policyKind))
.status(status)
.summary(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())
.latestSyncedAt(latest.getSyncedAt())
.recommendedResolution("conflict".equals(status) ? "review_latest_policy_snapshot" : "keep_current")
.syncRecordUrl(syncRecordUrl(latest.getOutboxId()))
.clientKeyMasked(maskClientKey(latest.getClientKey()))
.deviceId(latest.getDeviceId())
.businessView(latestView)
.build();
}
private String policySignature(Map<String, Object> view) {
Map<String, Object> signature = new LinkedHashMap<>();
for (String key : policyConflictCandidateKeys()) {
signature.put(key, view.get(key));
}
return toJson(signature);
}
private List<String> policyConflictKeys(List<Map<String, Object>> views) {
List<String> keys = new ArrayList<>();
for (String key : policyConflictCandidateKeys()) {
if (views.stream().map(view -> Objects.toString(view.get(key), "")).distinct().count() > 1) {
keys.add(key);
}
}
return keys;
}
private List<String> policyConflictCandidateKeys() {
return List.of(
"enabled",
"policy_version",
"policy_source",
"auto_create_governance_commands",
"max_per_sweep",
"catch_up_on_startup",
"approval_required_risk_levels",
"approval_required_actions",
"auto_reject_actions"
);
}
@Override
@DSTransactional
public DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request) {

View File

@@ -12,6 +12,8 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionPa
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotRowDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
@@ -334,6 +336,46 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
assertThat(action.getReasonCodes()).contains("admin_action_applied");
}
@Test
void queryAdminPolicySnapshotsDetectsCrossDeviceConflict() {
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(
record("outbox-policy-1", "event", "event-policy-1", 101L, new Date(10_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-policy-2", "event", "event-policy-2", 101L, new Date(11_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", false,
"title", "入口路由策略"
),
Map.of("kind", "digital_workday_pull_route_decision_policy"))
)));
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.getConflictKeys()).contains("auto_create_governance_commands");
assertThat(row.getRecommendedResolution()).isEqualTo("review_latest_policy_snapshot");
}
private IPage<DigitalEmployeeSyncRecord> page(List<DigitalEmployeeSyncRecord> records) {
Page<DigitalEmployeeSyncRecord> page = new Page<>(1, 20, records.size());
page.setRecords(records);

View File

@@ -0,0 +1,67 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.dto.ReqResult;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import static com.xspaceagi.system.spec.enums.ResourceEnum.CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST;
@RestController
@RequestMapping("/api/digital-employee/admin/policies")
public class DigitalEmployeeAdminPolicyController {
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
@RequestMapping(path = "/snapshots", method = RequestMethod.GET)
public ReqResult<DigitalEmployeePolicySnapshotPageDto> querySnapshots(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "policy_kind", required = false) String policyKind,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.queryAdminPolicySnapshots(
clientKey,
deviceId,
status,
policyKind,
parseDate(updatedFrom),
parseDate(updatedTo),
pageNo,
pageSize
));
}
private Date parseDate(String value) {
if (StringUtils.isBlank(value)) return null;
String trimmed = value.trim();
try {
return Date.from(Instant.parse(trimmed));
} catch (Exception ignored) {
try {
LocalDateTime localDateTime = LocalDateTime.parse(trimmed, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
} catch (Exception ignoredAgain) {
return null;
}
}
}
}