吸收数字员工跨设备策略冲突视图
This commit is contained in:
@@ -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.DigitalEmployeeAdminActionResultDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewPageDto;
|
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.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.DigitalEmployeeSyncRecordPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
||||||
@@ -62,6 +63,17 @@ public interface DigitalEmployeeSyncApplicationService {
|
|||||||
Long pageSize
|
Long pageSize
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DigitalEmployeePolicySnapshotPageDto queryAdminPolicySnapshots(
|
||||||
|
String clientKey,
|
||||||
|
String deviceId,
|
||||||
|
String status,
|
||||||
|
String policyKind,
|
||||||
|
Date updatedFrom,
|
||||||
|
Date updatedTo,
|
||||||
|
Long pageNo,
|
||||||
|
Long pageSize
|
||||||
|
);
|
||||||
|
|
||||||
DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request);
|
DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request);
|
||||||
|
|
||||||
DigitalEmployeeAdminActionPageDto queryPendingAdminActions(
|
DigitalEmployeeAdminActionPageDto queryPendingAdminActions(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.DigitalEmployeeBusinessViewPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewRowDto;
|
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.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.DigitalEmployeeSyncAckDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordDto;
|
||||||
@@ -343,6 +345,120 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
|
|||||||
return adminWorkbenchPage(rows, currentPage, currentPageSize);
|
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
|
@Override
|
||||||
@DSTransactional
|
@DSTransactional
|
||||||
public DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request) {
|
public DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request) {
|
||||||
|
|||||||
@@ -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.DigitalEmployeeAdminActionRequestDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
|
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.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.DigitalEmployeeSyncItemDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
|
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.DigitalEmployeeSyncRequestDto;
|
||||||
@@ -334,6 +336,46 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
|
|||||||
assertThat(action.getReasonCodes()).contains("admin_action_applied");
|
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) {
|
private IPage<DigitalEmployeeSyncRecord> page(List<DigitalEmployeeSyncRecord> records) {
|
||||||
Page<DigitalEmployeeSyncRecord> page = new Page<>(1, 20, records.size());
|
Page<DigitalEmployeeSyncRecord> page = new Page<>(1, 20, records.size());
|
||||||
page.setRecords(records);
|
page.setRecords(records);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import WorkspaceLayout from '@/components/WorkspaceLayout';
|
|||||||
import { SUCCESS_CODE } from '@/constants/codes.constants';
|
import { SUCCESS_CODE } from '@/constants/codes.constants';
|
||||||
import {
|
import {
|
||||||
apiDigitalEmployeeAdminActionSubmit,
|
apiDigitalEmployeeAdminActionSubmit,
|
||||||
|
apiDigitalEmployeeAdminPolicySnapshots,
|
||||||
apiDigitalEmployeeAdminWorkbenchList,
|
apiDigitalEmployeeAdminWorkbenchList,
|
||||||
} from '@/services/systemManage';
|
} from '@/services/systemManage';
|
||||||
import type {
|
import type {
|
||||||
@@ -29,6 +30,7 @@ const WORKBENCH_TABS: Array<{
|
|||||||
{ key: 'audits', label: '审计诊断' },
|
{ key: 'audits', label: '审计诊断' },
|
||||||
{ key: 'daily_reports', label: '日报运营' },
|
{ key: 'daily_reports', label: '日报运营' },
|
||||||
{ key: 'route_governance', label: '路由治理' },
|
{ key: 'route_governance', label: '路由治理' },
|
||||||
|
{ key: 'policy_conflicts', label: '策略冲突' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENTITY_TYPE_OPTIONS = {
|
const ENTITY_TYPE_OPTIONS = {
|
||||||
@@ -38,6 +40,13 @@ const ENTITY_TYPE_OPTIONS = {
|
|||||||
event: { text: 'Event' },
|
event: { text: 'Event' },
|
||||||
artifact: { text: 'Artifact' },
|
artifact: { text: 'Artifact' },
|
||||||
approval: { text: 'Approval' },
|
approval: { text: 'Approval' },
|
||||||
|
policy_snapshot: { text: 'Policy Snapshot' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLICY_KIND_OPTIONS = {
|
||||||
|
dispatch: { text: '派发策略' },
|
||||||
|
risk_approval: { text: '风险审批策略' },
|
||||||
|
route_decision: { text: '入口路由策略' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_TONE: Record<string, string> = {
|
const STATUS_TONE: Record<string, string> = {
|
||||||
@@ -80,6 +89,7 @@ function renderSummary(record: DigitalEmployeeAdminWorkbenchRow) {
|
|||||||
record.approval_id && `Approval ${record.approval_id}`,
|
record.approval_id && `Approval ${record.approval_id}`,
|
||||||
record.artifact_id && `Artifact ${record.artifact_id}`,
|
record.artifact_id && `Artifact ${record.artifact_id}`,
|
||||||
record.route_decision_id && `Route ${record.route_decision_id}`,
|
record.route_decision_id && `Route ${record.route_decision_id}`,
|
||||||
|
record.policy_kind && `Policy ${record.policy_kind}`,
|
||||||
].filter((item): item is string => Boolean(item));
|
].filter((item): item is string => Boolean(item));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,6 +139,12 @@ function actionTarget(record: DigitalEmployeeAdminWorkbenchRow, view: DigitalEmp
|
|||||||
if (view === 'audits') {
|
if (view === 'audits') {
|
||||||
return { entityType: 'audit', entityId: record.entity_id };
|
return { entityType: 'audit', entityId: record.entity_id };
|
||||||
}
|
}
|
||||||
|
if (view === 'policy_conflicts') {
|
||||||
|
return {
|
||||||
|
entityType: 'policy_snapshot',
|
||||||
|
entityId: record.policy_key || record.policy_kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
return { entityType: record.entity_type, entityId: record.entity_id };
|
return { entityType: record.entity_type, entityId: record.entity_id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +302,31 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
hideInTable: true,
|
hideInTable: true,
|
||||||
fieldProps: { placeholder: '输入 category', allowClear: true },
|
fieldProps: { placeholder: '输入 category', allowClear: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '策略类型',
|
||||||
|
dataIndex: 'policy_kind',
|
||||||
|
width: 140,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: POLICY_KIND_OPTIONS,
|
||||||
|
fieldProps: { placeholder: '选择策略类型', allowClear: true },
|
||||||
|
render: (_, record) => record.policy_kind ? <Tag color="purple">{record.policy_kind}</Tag> : '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '冲突字段',
|
||||||
|
dataIndex: 'conflict_keys',
|
||||||
|
width: 220,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{(record.conflict_keys || []).slice(0, 3).map((item) => (
|
||||||
|
<Tag key={item} color="orange" style={{ marginInlineEnd: 0 }}>
|
||||||
|
{item}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{(!record.conflict_keys || record.conflict_keys.length === 0) && '--'}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '等级',
|
title: '等级',
|
||||||
dataIndex: 'level',
|
dataIndex: 'level',
|
||||||
@@ -354,6 +395,52 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
|
|
||||||
const request = async (params: Record<string, any>) => {
|
const request = async (params: Record<string, any>) => {
|
||||||
const updatedRange = params.updated_range || [];
|
const updatedRange = params.updated_range || [];
|
||||||
|
if (activeView === 'policy_conflicts') {
|
||||||
|
const response = await apiDigitalEmployeeAdminPolicySnapshots({
|
||||||
|
pageNo: params.current || 1,
|
||||||
|
pageSize: params.pageSize || 15,
|
||||||
|
clientKey: params.client_key,
|
||||||
|
deviceId: params.device_id,
|
||||||
|
status: params.status,
|
||||||
|
policyKind: params.policy_kind || params.kind,
|
||||||
|
updatedFrom: updatedRange[0],
|
||||||
|
updatedTo: updatedRange[1],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code !== SUCCESS_CODE) {
|
||||||
|
message.error(response.message || '查询数字员工策略冲突失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: (response.data?.records || []).map((record, index) => ({
|
||||||
|
view: 'policy_conflicts',
|
||||||
|
sync_record_id: index + 1,
|
||||||
|
sync_record_url: record.sync_record_url,
|
||||||
|
entity_type: 'policy_snapshot',
|
||||||
|
entity_id: record.policy_key || record.policy_kind || `policy-${index}`,
|
||||||
|
title: record.summary || record.policy_kind,
|
||||||
|
status: record.status,
|
||||||
|
kind: record.policy_kind,
|
||||||
|
category: 'policy_snapshot',
|
||||||
|
summary: record.conflict_keys?.length
|
||||||
|
? `冲突字段:${record.conflict_keys.join(', ')}`
|
||||||
|
: record.summary,
|
||||||
|
policy_kind: record.policy_kind,
|
||||||
|
policy_key: record.policy_key,
|
||||||
|
conflict_keys: record.conflict_keys,
|
||||||
|
recommended_resolution: record.recommended_resolution,
|
||||||
|
latest_synced_at: record.latest_synced_at,
|
||||||
|
synced_at: record.latest_synced_at,
|
||||||
|
client_key_masked: record.client_key_masked,
|
||||||
|
device_id: record.device_id,
|
||||||
|
business_view: record.business_view,
|
||||||
|
payload: record.business_view,
|
||||||
|
})),
|
||||||
|
total: response.data?.total || 0,
|
||||||
|
success: response.code === SUCCESS_CODE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiDigitalEmployeeAdminWorkbenchList({
|
const response = await apiDigitalEmployeeAdminWorkbenchList({
|
||||||
view: activeView,
|
view: activeView,
|
||||||
pageNo: params.current || 1,
|
pageNo: params.current || 1,
|
||||||
@@ -438,10 +525,25 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
detailRecord.report_id && `Report ${detailRecord.report_id}`,
|
detailRecord.report_id && `Report ${detailRecord.report_id}`,
|
||||||
detailRecord.route_decision_id &&
|
detailRecord.route_decision_id &&
|
||||||
`Route ${detailRecord.route_decision_id}`,
|
`Route ${detailRecord.route_decision_id}`,
|
||||||
|
detailRecord.policy_kind && `Policy ${detailRecord.policy_kind}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' / ') || '--'}
|
.join(' / ') || '--'}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
{activeView === 'policy_conflicts' && (
|
||||||
|
<Typography.Paragraph>
|
||||||
|
<Typography.Text strong>策略冲突:</Typography.Text>
|
||||||
|
{detailRecord.conflict_keys?.length
|
||||||
|
? detailRecord.conflict_keys.join(' / ')
|
||||||
|
: '暂无冲突字段'}
|
||||||
|
{detailRecord.recommended_resolution && (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{' '}
|
||||||
|
· 建议:{detailRecord.recommended_resolution}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
{allowedActions(detailRecord, activeView).length > 0 && (
|
{allowedActions(detailRecord, activeView).length > 0 && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Typography.Title level={5}>低风险动作</Typography.Title>
|
<Typography.Title level={5}>低风险动作</Typography.Title>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
ConversationStatsResult,
|
ConversationStatsResult,
|
||||||
DigitalEmployeeAdminWorkbenchListParams,
|
DigitalEmployeeAdminWorkbenchListParams,
|
||||||
DigitalEmployeeAdminWorkbenchPage,
|
DigitalEmployeeAdminWorkbenchPage,
|
||||||
|
DigitalEmployeePolicySnapshotListParams,
|
||||||
|
DigitalEmployeePolicySnapshotPage,
|
||||||
DigitalEmployeeSyncRecordListParams,
|
DigitalEmployeeSyncRecordListParams,
|
||||||
DigitalEmployeeSyncRecordPage,
|
DigitalEmployeeSyncRecordPage,
|
||||||
ModelConfigDto,
|
ModelConfigDto,
|
||||||
@@ -312,6 +314,25 @@ export async function apiDigitalEmployeeAdminWorkbenchList(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询数字员工跨设备策略快照冲突
|
||||||
|
export async function apiDigitalEmployeeAdminPolicySnapshots(
|
||||||
|
data: DigitalEmployeePolicySnapshotListParams,
|
||||||
|
): Promise<RequestResponse<DigitalEmployeePolicySnapshotPage>> {
|
||||||
|
return request('/api/digital-employee/admin/policies/snapshots', {
|
||||||
|
method: 'GET',
|
||||||
|
params: {
|
||||||
|
client_key: data.clientKey,
|
||||||
|
device_id: data.deviceId,
|
||||||
|
status: data.status,
|
||||||
|
policy_kind: data.policyKind,
|
||||||
|
updated_from: data.updatedFrom,
|
||||||
|
updated_to: data.updatedTo,
|
||||||
|
page_no: data.pageNo,
|
||||||
|
page_size: data.pageSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 记录数字员工管理端低风险动作意图
|
// 记录数字员工管理端低风险动作意图
|
||||||
export async function apiDigitalEmployeeAdminActionSubmit(
|
export async function apiDigitalEmployeeAdminActionSubmit(
|
||||||
data: DigitalEmployeeAdminActionSubmitParams,
|
data: DigitalEmployeeAdminActionSubmitParams,
|
||||||
|
|||||||
@@ -563,7 +563,8 @@ export type DigitalEmployeeAdminWorkbenchView =
|
|||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'audits'
|
| 'audits'
|
||||||
| 'daily_reports'
|
| 'daily_reports'
|
||||||
| 'route_governance';
|
| 'route_governance'
|
||||||
|
| 'policy_conflicts';
|
||||||
|
|
||||||
export interface DigitalEmployeeAdminWorkbenchRow {
|
export interface DigitalEmployeeAdminWorkbenchRow {
|
||||||
view: DigitalEmployeeAdminWorkbenchView | string;
|
view: DigitalEmployeeAdminWorkbenchView | string;
|
||||||
@@ -587,8 +588,13 @@ export interface DigitalEmployeeAdminWorkbenchRow {
|
|||||||
notification_id?: string;
|
notification_id?: string;
|
||||||
report_id?: string;
|
report_id?: string;
|
||||||
route_decision_id?: string;
|
route_decision_id?: string;
|
||||||
|
policy_kind?: string;
|
||||||
|
policy_key?: string;
|
||||||
|
conflict_keys?: string[];
|
||||||
|
recommended_resolution?: string;
|
||||||
occurred_at?: string;
|
occurred_at?: string;
|
||||||
synced_at?: string;
|
synced_at?: string;
|
||||||
|
latest_synced_at?: string;
|
||||||
client_key_masked?: string;
|
client_key_masked?: string;
|
||||||
device_id?: string;
|
device_id?: string;
|
||||||
business_view?: Record<string, unknown>;
|
business_view?: Record<string, unknown>;
|
||||||
@@ -618,6 +624,39 @@ export interface DigitalEmployeeAdminWorkbenchPage {
|
|||||||
records: DigitalEmployeeAdminWorkbenchRow[];
|
records: DigitalEmployeeAdminWorkbenchRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeePolicySnapshotRow {
|
||||||
|
policy_kind?: string;
|
||||||
|
policy_key?: string;
|
||||||
|
status?: string;
|
||||||
|
summary?: string;
|
||||||
|
client_count?: number;
|
||||||
|
device_count?: number;
|
||||||
|
conflict_keys?: string[];
|
||||||
|
latest_synced_at?: string;
|
||||||
|
recommended_resolution?: string;
|
||||||
|
sync_record_url?: string;
|
||||||
|
client_key_masked?: string;
|
||||||
|
device_id?: string;
|
||||||
|
business_view?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeePolicySnapshotListParams
|
||||||
|
extends SystemPaginationParams {
|
||||||
|
clientKey?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
status?: string;
|
||||||
|
policyKind?: string;
|
||||||
|
updatedFrom?: string;
|
||||||
|
updatedTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeePolicySnapshotPage {
|
||||||
|
total: number;
|
||||||
|
page_no: number;
|
||||||
|
page_size: number;
|
||||||
|
records: DigitalEmployeePolicySnapshotRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigitalEmployeeAdminActionSubmitParams {
|
export interface DigitalEmployeeAdminActionSubmitParams {
|
||||||
syncRecordId?: number;
|
syncRecordId?: number;
|
||||||
clientKey?: string;
|
clientKey?: string;
|
||||||
|
|||||||
@@ -1181,6 +1181,66 @@ function checkDigitalAdminActionLoop() {
|
|||||||
console.log("[DigitalEmployeeCheck] admin action pull/apply loop hooks OK");
|
console.log("[DigitalEmployeeCheck] admin action pull/apply loop hooks OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkDigitalAdminPolicySnapshots() {
|
||||||
|
console.log("\n[DigitalEmployeeCheck] Verify admin policy snapshot conflict view hooks");
|
||||||
|
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
|
||||||
|
const syncTestPath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.test.ts");
|
||||||
|
const docsPath = path.resolve(packageRoot, "..", "..", "docs", "digital-employee-frontend-integration-plan.md");
|
||||||
|
const qimingRoot = path.resolve(packageRoot, "..", "..", "..", "qiming");
|
||||||
|
const backendRoot = path.resolve(packageRoot, "..", "..", "..", "qiming-backend");
|
||||||
|
const qimingOpsPath = path.join(qimingRoot, "src", "pages", "SystemManagement", "Content", "DigitalEmployeeOps", "index.tsx");
|
||||||
|
const qimingServicePath = path.join(qimingRoot, "src", "services", "systemManage.ts");
|
||||||
|
const qimingTypesPath = path.join(qimingRoot, "src", "types", "interfaces", "systemManage.ts");
|
||||||
|
const backendControllerPath = path.join(
|
||||||
|
backendRoot,
|
||||||
|
"app-platform-modules",
|
||||||
|
"app-platform-agent",
|
||||||
|
"app-platform-agent-core-ui",
|
||||||
|
"src",
|
||||||
|
"main",
|
||||||
|
"java",
|
||||||
|
"com",
|
||||||
|
"xspaceagi",
|
||||||
|
"agent",
|
||||||
|
"web",
|
||||||
|
"ui",
|
||||||
|
"controller",
|
||||||
|
"DigitalEmployeeAdminPolicyController.java",
|
||||||
|
);
|
||||||
|
const syncService = fs.readFileSync(syncServicePath, "utf8");
|
||||||
|
const syncTest = fs.readFileSync(syncTestPath, "utf8");
|
||||||
|
const docs = fs.readFileSync(docsPath, "utf8");
|
||||||
|
const qimingOps = fs.readFileSync(qimingOpsPath, "utf8");
|
||||||
|
const qimingService = fs.readFileSync(qimingServicePath, "utf8");
|
||||||
|
const qimingTypes = fs.readFileSync(qimingTypesPath, "utf8");
|
||||||
|
const backendController = fs.readFileSync(backendControllerPath, "utf8");
|
||||||
|
const requiredSnippets = [
|
||||||
|
[syncService, "policySnapshotBusinessView", "client policy snapshot business view"],
|
||||||
|
[syncService, "policy_snapshot", "client policy snapshot category"],
|
||||||
|
[syncService, "policy_kind", "client policy kind field"],
|
||||||
|
[syncService, "auto_create_governance_commands", "route policy conflict field"],
|
||||||
|
[syncService, "max_per_sweep", "dispatch policy conflict field"],
|
||||||
|
[syncService, "catch_up_on_startup", "scheduler policy conflict field"],
|
||||||
|
[syncTest, "event-route-policy-pull", "route policy snapshot sync test"],
|
||||||
|
[syncTest, "event-risk-policy-pull", "risk policy snapshot sync test"],
|
||||||
|
[backendController, "/snapshots", "backend policy snapshot endpoint"],
|
||||||
|
[qimingService, "apiDigitalEmployeeAdminPolicySnapshots", "qiming policy snapshot service"],
|
||||||
|
[qimingService, "/api/digital-employee/admin/policies/snapshots", "qiming policy snapshot endpoint"],
|
||||||
|
[qimingTypes, "DigitalEmployeePolicySnapshotRow", "qiming policy snapshot row type"],
|
||||||
|
[qimingTypes, "policy_conflicts", "qiming policy conflicts view type"],
|
||||||
|
[qimingOps, "策略冲突", "qiming policy conflict tab text"],
|
||||||
|
[qimingOps, "conflict_keys", "qiming conflict key display"],
|
||||||
|
[docs, "跨设备策略冲突视图", "docs policy conflict absorption"],
|
||||||
|
];
|
||||||
|
const missing = requiredSnippets
|
||||||
|
.filter(([content, snippet]) => !content.includes(snippet))
|
||||||
|
.map(([, , label]) => label);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing admin policy snapshot hooks: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
console.log("[DigitalEmployeeCheck] admin policy snapshot conflict view hooks OK");
|
||||||
|
}
|
||||||
|
|
||||||
function checkLocalDatabaseSchema() {
|
function checkLocalDatabaseSchema() {
|
||||||
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
|
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
|
||||||
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
|
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
|
||||||
@@ -1240,6 +1300,7 @@ function main() {
|
|||||||
checkDigitalOpsNotificationReportWorkbenches();
|
checkDigitalOpsNotificationReportWorkbenches();
|
||||||
checkDigitalPolicySchedulerWorkbenches();
|
checkDigitalPolicySchedulerWorkbenches();
|
||||||
checkDigitalAdminActionLoop();
|
checkDigitalAdminActionLoop();
|
||||||
|
checkDigitalAdminPolicySnapshots();
|
||||||
checkLocalDatabaseSchema();
|
checkLocalDatabaseSchema();
|
||||||
console.log("\n[DigitalEmployeeCheck] All checks passed");
|
console.log("\n[DigitalEmployeeCheck] All checks passed");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1655,6 +1655,41 @@ describe("digital employee sync service", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
insertEventEntity("event-risk-policy-pull", "digital_workday_pull_risk_approval_policy", {
|
||||||
|
metadata: {
|
||||||
|
action: "pull_risk_approval_policy",
|
||||||
|
source: "management-api",
|
||||||
|
ok: true,
|
||||||
|
risk_approval_policy: {
|
||||||
|
enabled: true,
|
||||||
|
source: "remote",
|
||||||
|
version: "risk-policy-v2",
|
||||||
|
approval_required_risk_levels: ["high", "critical"],
|
||||||
|
approval_required_actions: ["managed_service.restart.*"],
|
||||||
|
auto_reject_actions: ["service.stop.*"],
|
||||||
|
remote_updated_at: "2026-06-07T08:10:00.000Z",
|
||||||
|
last_pulled_at: "2026-06-07T08:11:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
insertEventEntity("event-route-policy-pull", "digital_workday_pull_route_decision_policy", {
|
||||||
|
metadata: {
|
||||||
|
action: "pull_route_decision_policy",
|
||||||
|
source: "management-api",
|
||||||
|
ok: true,
|
||||||
|
route_decision_policy: {
|
||||||
|
enabled: true,
|
||||||
|
source: "remote",
|
||||||
|
version: "route-policy-v3",
|
||||||
|
auto_create_governance_commands: false,
|
||||||
|
blocked_intents: ["delete"],
|
||||||
|
approval_required_intents: ["restart_service"],
|
||||||
|
preferred_route_kinds: ["PlanStepDispatch"],
|
||||||
|
remote_updated_at: "2026-06-07T08:12:00.000Z",
|
||||||
|
last_pulled_at: "2026-06-07T08:13:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
insertEventEntity("event-risk-approval", "risk_approval_required", {
|
insertEventEntity("event-risk-approval", "risk_approval_required", {
|
||||||
action_kind: "managed_service.restart.crm-sync",
|
action_kind: "managed_service.restart.crm-sync",
|
||||||
action_label: "重启 CRM 同步服务",
|
action_label: "重启 CRM 同步服务",
|
||||||
@@ -1701,6 +1736,8 @@ describe("digital employee sync service", () => {
|
|||||||
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
|
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
|
||||||
{ entity_type: "event", entity_id: "event-plan-step-policy" },
|
{ entity_type: "event", entity_id: "event-plan-step-policy" },
|
||||||
{ entity_type: "event", entity_id: "event-plan-step-policy-pull" },
|
{ entity_type: "event", entity_id: "event-plan-step-policy-pull" },
|
||||||
|
{ entity_type: "event", entity_id: "event-risk-policy-pull" },
|
||||||
|
{ entity_type: "event", entity_id: "event-route-policy-pull" },
|
||||||
{ entity_type: "event", entity_id: "event-risk-approval" },
|
{ entity_type: "event", entity_id: "event-risk-approval" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -1990,27 +2027,58 @@ describe("digital employee sync service", () => {
|
|||||||
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("state");
|
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("state");
|
||||||
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("approvalSubscriptionPreferences");
|
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("approvalSubscriptionPreferences");
|
||||||
expect(businessView("event-plan-step-policy")).toMatchObject({
|
expect(businessView("event-plan-step-policy")).toMatchObject({
|
||||||
category: "dispatch",
|
category: "policy_snapshot",
|
||||||
|
policy_kind: "dispatch",
|
||||||
|
policy_key: "dispatch",
|
||||||
action: "update_plan_step_dispatch_policy",
|
action: "update_plan_step_dispatch_policy",
|
||||||
policy_enabled: false,
|
enabled: false,
|
||||||
max_per_sweep: 1,
|
max_per_sweep: 1,
|
||||||
auto_dispatch_ready_steps: false,
|
auto_dispatch_ready_steps: false,
|
||||||
updated_at: "2026-06-07T08:07:00.000Z",
|
policy_version: "2026-06-07T08:07:00.000Z",
|
||||||
});
|
});
|
||||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("state");
|
expect(businessView("event-plan-step-policy")).not.toHaveProperty("state");
|
||||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
|
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
|
||||||
expect(businessView("event-plan-step-policy-pull")).toMatchObject({
|
expect(businessView("event-plan-step-policy-pull")).toMatchObject({
|
||||||
category: "dispatch",
|
category: "policy_snapshot",
|
||||||
|
policy_kind: "dispatch",
|
||||||
|
policy_key: "dispatch",
|
||||||
action: "pull_plan_step_dispatch_policy",
|
action: "pull_plan_step_dispatch_policy",
|
||||||
source: "management-api",
|
source: "management-api",
|
||||||
pull_ok: false,
|
pull_ok: false,
|
||||||
pull_error: "policy service unavailable",
|
pull_error: "policy service unavailable",
|
||||||
policy_enabled: true,
|
enabled: true,
|
||||||
max_per_sweep: 2,
|
max_per_sweep: 2,
|
||||||
auto_dispatch_ready_steps: true,
|
auto_dispatch_ready_steps: true,
|
||||||
remote_updated_at: "2026-06-07T08:08:00.000Z",
|
remote_updated_at: "2026-06-07T08:08:00.000Z",
|
||||||
last_pulled_at: "2026-06-07T08:09:00.000Z",
|
last_pulled_at: "2026-06-07T08:09:00.000Z",
|
||||||
});
|
});
|
||||||
|
expect(businessView("event-risk-policy-pull")).toMatchObject({
|
||||||
|
category: "policy_snapshot",
|
||||||
|
policy_kind: "risk_approval",
|
||||||
|
policy_key: "risk_approval",
|
||||||
|
policy_version: "risk-policy-v2",
|
||||||
|
policy_source: "management-api",
|
||||||
|
enabled: true,
|
||||||
|
approval_required_risk_levels: ["high", "critical"],
|
||||||
|
approval_required_actions: ["managed_service.restart.*"],
|
||||||
|
auto_reject_actions: ["service.stop.*"],
|
||||||
|
remote_updated_at: "2026-06-07T08:10:00.000Z",
|
||||||
|
last_pulled_at: "2026-06-07T08:11:00.000Z",
|
||||||
|
});
|
||||||
|
expect(businessView("event-route-policy-pull")).toMatchObject({
|
||||||
|
category: "policy_snapshot",
|
||||||
|
policy_kind: "route_decision",
|
||||||
|
policy_key: "route_decision",
|
||||||
|
policy_version: "route-policy-v3",
|
||||||
|
policy_source: "management-api",
|
||||||
|
enabled: true,
|
||||||
|
auto_create_governance_commands: false,
|
||||||
|
blocked_intents: ["delete"],
|
||||||
|
approval_required_intents: ["restart_service"],
|
||||||
|
preferred_route_kinds: ["PlanStepDispatch"],
|
||||||
|
remote_updated_at: "2026-06-07T08:12:00.000Z",
|
||||||
|
last_pulled_at: "2026-06-07T08:13:00.000Z",
|
||||||
|
});
|
||||||
expect(businessView("event-risk-approval")).toMatchObject({
|
expect(businessView("event-risk-approval")).toMatchObject({
|
||||||
category: "approval",
|
category: "approval",
|
||||||
action_kind: "managed_service.restart.crm-sync",
|
action_kind: "managed_service.restart.crm-sync",
|
||||||
|
|||||||
@@ -1979,7 +1979,7 @@ function eventSpecificBusinessView(
|
|||||||
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
|
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
|
||||||
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
|
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
|
||||||
if (isPlanStepLeaseEvent(kind)) return planStepLeaseBusinessView(payload);
|
if (isPlanStepLeaseEvent(kind)) return planStepLeaseBusinessView(payload);
|
||||||
if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload);
|
if (isPolicySnapshotEvent(kind)) return policySnapshotBusinessView(kind, payload);
|
||||||
if (kind.startsWith("governance_")) return governanceCommandBusinessView(kind, payload);
|
if (kind.startsWith("governance_")) return governanceCommandBusinessView(kind, payload);
|
||||||
if (kind === "sandbox_audit") return sandboxAuditBusinessView(payload);
|
if (kind === "sandbox_audit") return sandboxAuditBusinessView(payload);
|
||||||
if (kind === "token_cost") return tokenCostBusinessView(payload);
|
if (kind === "token_cost") return tokenCostBusinessView(payload);
|
||||||
@@ -2268,6 +2268,48 @@ function planStepDispatchPolicyBusinessView(payload: Record<string, unknown>): R
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function policySnapshotBusinessView(kind: string, payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const metadata = objectRecord(payload.metadata) ?? {};
|
||||||
|
const state = objectRecord(payload.state) ?? {};
|
||||||
|
const policyKind = kind.includes("risk_approval")
|
||||||
|
? "risk_approval"
|
||||||
|
: kind.includes("route_decision")
|
||||||
|
? "route_decision"
|
||||||
|
: "dispatch";
|
||||||
|
const policy = policyKind === "risk_approval"
|
||||||
|
? objectRecord(metadata.risk_approval_policy) ?? objectRecord(state.riskApprovalPolicy) ?? {}
|
||||||
|
: policyKind === "route_decision"
|
||||||
|
? objectRecord(metadata.route_decision_policy) ?? objectRecord(state.routeDecisionPolicy) ?? {}
|
||||||
|
: objectRecord(metadata.plan_step_dispatch_policy) ?? objectRecord(state.planStepDispatchPolicy) ?? {};
|
||||||
|
return {
|
||||||
|
category: "policy_snapshot",
|
||||||
|
policy_kind: policyKind,
|
||||||
|
policy_key: policyKind,
|
||||||
|
policy_version: stringField(policy.version) || stringField(policy.updated_at ?? policy.updatedAt) || null,
|
||||||
|
policy_source: stringField(metadata.source ?? policy.source) || null,
|
||||||
|
source: stringField(metadata.source ?? policy.source) || null,
|
||||||
|
action: stringField(metadata.action ?? payload.action) || kind.replace(/^digital_workday_/, ""),
|
||||||
|
status: metadata.ok === false ? "pull_failed" : "snapshot",
|
||||||
|
enabled: typeof policy.enabled === "boolean" ? policy.enabled : null,
|
||||||
|
pull_ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
|
||||||
|
pull_error: stringField(metadata.error ?? policy.last_pull_error) || null,
|
||||||
|
pull_endpoint: stringField(metadata.endpoint) || null,
|
||||||
|
max_per_sweep: numberField(policy.max_per_sweep ?? policy.maxPerSweep),
|
||||||
|
auto_dispatch_ready_steps: typeof policy.auto_dispatch_ready_steps === "boolean" ? policy.auto_dispatch_ready_steps : typeof policy.autoDispatchReadySteps === "boolean" ? policy.autoDispatchReadySteps : null,
|
||||||
|
auto_create_governance_commands: typeof policy.auto_create_governance_commands === "boolean" ? policy.auto_create_governance_commands : typeof policy.autoCreateGovernanceCommands === "boolean" ? policy.autoCreateGovernanceCommands : null,
|
||||||
|
catch_up_on_startup: typeof policy.catch_up_on_startup === "boolean" ? policy.catch_up_on_startup : typeof policy.catchUpOnStartup === "boolean" ? policy.catchUpOnStartup : null,
|
||||||
|
approval_required_risk_levels: stringArrayField(policy.approval_required_risk_levels ?? policy.approvalRequiredRiskLevels),
|
||||||
|
approval_required_actions: stringArrayField(policy.approval_required_actions ?? policy.approvalRequiredActions),
|
||||||
|
auto_reject_actions: stringArrayField(policy.auto_reject_actions ?? policy.autoRejectActions),
|
||||||
|
blocked_intents: stringArrayField(policy.blocked_intents ?? policy.blockedIntents),
|
||||||
|
approval_required_intents: stringArrayField(policy.approval_required_intents ?? policy.approvalRequiredIntents),
|
||||||
|
preferred_route_kinds: stringArrayField(policy.preferred_route_kinds ?? policy.preferredRouteKinds),
|
||||||
|
remote_updated_at: stringField(policy.remote_updated_at ?? policy.remoteUpdatedAt) || null,
|
||||||
|
last_pulled_at: stringField(policy.last_pulled_at ?? policy.lastPulledAt) || null,
|
||||||
|
summary: `${policyKind} policy snapshot`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function governanceCommandBusinessView(kind: string, payload: Record<string, unknown>): Record<string, unknown> {
|
function governanceCommandBusinessView(kind: string, payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
const commandPayload = objectRecord(payload.payload) ?? {};
|
const commandPayload = objectRecord(payload.payload) ?? {};
|
||||||
const result = objectRecord(payload.result) ?? {};
|
const result = objectRecord(payload.result) ?? {};
|
||||||
@@ -2383,7 +2425,7 @@ function eventBusinessCategory(kind: string): string {
|
|||||||
if (isPlanStepDependencyEvent(kind)) return "dispatch";
|
if (isPlanStepDependencyEvent(kind)) return "dispatch";
|
||||||
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
|
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
|
||||||
if (isPlanStepLeaseEvent(kind)) return "dispatch";
|
if (isPlanStepLeaseEvent(kind)) return "dispatch";
|
||||||
if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch";
|
if (isPolicySnapshotEvent(kind)) return "policy_snapshot";
|
||||||
if (isNotificationEvent(kind)) return "notification";
|
if (isNotificationEvent(kind)) return "notification";
|
||||||
if (kind.startsWith("governance_")) return "governance";
|
if (kind.startsWith("governance_")) return "governance";
|
||||||
if (kind === "sandbox_audit") return "sandbox_audit";
|
if (kind === "sandbox_audit") return "sandbox_audit";
|
||||||
@@ -2419,6 +2461,14 @@ function isPlanStepDispatchPolicyEvent(kind: string): boolean {
|
|||||||
|| kind === "digital_workday_pull_plan_step_dispatch_policy";
|
|| kind === "digital_workday_pull_plan_step_dispatch_policy";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPolicySnapshotEvent(kind: string): boolean {
|
||||||
|
return isPlanStepDispatchPolicyEvent(kind)
|
||||||
|
|| kind === "digital_workday_pull_risk_approval_policy"
|
||||||
|
|| kind === "digital_workday_pull_route_decision_policy"
|
||||||
|
|| kind === "digital_workday_update_risk_approval_policy"
|
||||||
|
|| kind === "digital_workday_update_route_decision_policy";
|
||||||
|
}
|
||||||
|
|
||||||
function isPlanStepLeaseEvent(kind: string): boolean {
|
function isPlanStepLeaseEvent(kind: string): boolean {
|
||||||
return kind === "plan_step_lease_acquired" || kind === "plan_step_lease_released" || kind === "plan_step_lease_skipped";
|
return kind === "plan_step_lease_acquired" || kind === "plan_step_lease_released" || kind === "plan_step_lease_skipped";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ GET /api/digital-employee/approvals
|
|||||||
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
|
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
|
||||||
|
|
||||||
12. **管理端业务查询模型**
|
12. **管理端业务查询模型**
|
||||||
- 已吸收:管理端已有通用 sync record 接收、查询、payload 摘要和深链;后端 sync record 已开始保存客户端顶层 `contract_version`、`entity_summary` 和 `business_view`,并基于现有 `digital_employee_sync_record` 拆出 `/api/digital-employee/plans`、`/plans/{plan_id}`、`/tasks`、`/runs`、`/events`、`/artifacts`、`/approvals` 管理端业务查询模型;列表支持客户端 Key、设备、实体 ID、状态、同步状态和同步时间范围过滤,业务 row 会提炼 title/status/summary、Plan/Task/Run 关联、发生/开始/结束时间、sync record 深链和脱敏客户端 Key;Plan 视图会聚合 task/run 计数与最近事件,Plan 详情会返回关联 tasks/runs/events/artifacts/approvals 摘要。embedded adapter 已拆出同名只读业务查询接口,从 qimingclaw runtime、artifact、approval 和 canonical event 投影生成统一分页视图;embedded 数字员工页已新增“业务视图”入口,支持计划/任务/运行/事件/产物/审批分段、实体/状态/同步状态/时间筛选、分页和详情 JSON 查看;客户端 outbox 的 event `business_view` 已开始按 route decision、governance command、approval action、artifact lifecycle/access、skill call audit 和 ready PlanStep dispatch 细分常用字段,本地与管理端业务视图投影已开始闭环。正式外部管理端台面已开始吸收:qiming-backend 新增 `/api/digital-employee/admin/workbench/{view}`,qiming 管理端新增“数字员工运营台”,统一展示业务视图、介入台、产物治理、通知运营、审计诊断、日报运营和路由治理,只读消费 sync record `business_view` 并提供同步记录深链。管理端低风险动作闭环已开始吸收:后端新增 `/api/digital-employee/admin/actions` 记录动作意图和 `/pending` 拉取队列,qimingclaw 只应用保留本地、标记复核、通知已读/忽略、产物保留/到期、日报交付/确认/审批请求等低风险动作并写入 `admin_action_applied` / `admin_action_rejected` / `admin_action_failed`,qiming 运营台抽屉只提示“已记录,等待客户端拉取”。
|
- 已吸收:管理端已有通用 sync record 接收、查询、payload 摘要和深链;后端 sync record 已开始保存客户端顶层 `contract_version`、`entity_summary` 和 `business_view`,并基于现有 `digital_employee_sync_record` 拆出 `/api/digital-employee/plans`、`/plans/{plan_id}`、`/tasks`、`/runs`、`/events`、`/artifacts`、`/approvals` 管理端业务查询模型;列表支持客户端 Key、设备、实体 ID、状态、同步状态和同步时间范围过滤,业务 row 会提炼 title/status/summary、Plan/Task/Run 关联、发生/开始/结束时间、sync record 深链和脱敏客户端 Key;Plan 视图会聚合 task/run 计数与最近事件,Plan 详情会返回关联 tasks/runs/events/artifacts/approvals 摘要。embedded adapter 已拆出同名只读业务查询接口,从 qimingclaw runtime、artifact、approval 和 canonical event 投影生成统一分页视图;embedded 数字员工页已新增“业务视图”入口,支持计划/任务/运行/事件/产物/审批分段、实体/状态/同步状态/时间筛选、分页和详情 JSON 查看;客户端 outbox 的 event `business_view` 已开始按 route decision、governance command、approval action、artifact lifecycle/access、skill call audit 和 ready PlanStep dispatch 细分常用字段,本地与管理端业务视图投影已开始闭环。正式外部管理端台面已开始吸收:qiming-backend 新增 `/api/digital-employee/admin/workbench/{view}`,qiming 管理端新增“数字员工运营台”,统一展示业务视图、介入台、产物治理、通知运营、审计诊断、日报运营和路由治理,只读消费 sync record `business_view` 并提供同步记录深链。管理端低风险动作闭环已开始吸收:后端新增 `/api/digital-employee/admin/actions` 记录动作意图和 `/pending` 拉取队列,qimingclaw 只应用保留本地、标记复核、通知已读/忽略、产物保留/到期、日报交付/确认/审批请求等低风险动作并写入 `admin_action_applied` / `admin_action_rejected` / `admin_action_failed`,qiming 运营台抽屉只提示“已记录,等待客户端拉取”。跨设备策略冲突视图已开始吸收:qimingclaw 将派发、风险审批和入口路由策略同步事件投影为 `policy_snapshot` business view,后端新增 `/api/digital-employee/admin/policies/snapshots` 聚合 client/device 维度差异并输出 `conflict_keys`,qiming 运营台新增“策略冲突” tab 展示 policy drift 与 sync record 深链。
|
||||||
- 缺口:复杂组合检索、跨设备聚合图、远程裁决/策略编辑动作和物化业务表仍待吸收。
|
- 缺口:复杂组合检索、跨设备聚合图、远程裁决/策略编辑动作和物化业务表仍待吸收。
|
||||||
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。
|
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user