吸收数字员工管理端运营台后端

This commit is contained in:
baiyanyun
2026-06-12 20:07:55 +08:00
parent 1a8e0fb51f
commit 48ac9bdf19
8 changed files with 550 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
package com.xspaceagi.agent.core.adapter.application;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenchPageDto;
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.DigitalEmployeeSyncRecordPageDto;
@@ -39,4 +40,21 @@ public interface DigitalEmployeeSyncApplicationService {
String clientKey,
String deviceId
);
DigitalEmployeeAdminWorkbenchPageDto queryAdminWorkbench(
String view,
String clientKey,
String deviceId,
String entityType,
String entityId,
String status,
String kind,
String category,
String level,
String priority,
Date updatedFrom,
Date updatedTo,
Long pageNo,
Long pageSize
);
}

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 DigitalEmployeeAdminWorkbenchPageDto {
private Long total;
@JsonProperty("page_no")
private Long pageNo;
@JsonProperty("page_size")
private Long pageSize;
private List<DigitalEmployeeAdminWorkbenchRowDto> records;
}

View File

@@ -0,0 +1,57 @@
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.Map;
@Data
@Builder
public class DigitalEmployeeAdminWorkbenchRowDto {
private String view;
@JsonProperty("sync_record_id")
private Long syncRecordId;
@JsonProperty("sync_record_url")
private String syncRecordUrl;
@JsonProperty("entity_type")
private String entityType;
@JsonProperty("entity_id")
private String entityId;
private String operation;
private String title;
private String status;
private String kind;
private String category;
private String level;
private String priority;
private String summary;
@JsonProperty("plan_id")
private String planId;
@JsonProperty("task_id")
private String taskId;
@JsonProperty("run_id")
private String runId;
@JsonProperty("approval_id")
private String approvalId;
@JsonProperty("artifact_id")
private String artifactId;
@JsonProperty("notification_id")
private String notificationId;
@JsonProperty("report_id")
private String reportId;
@JsonProperty("route_decision_id")
private String routeDecisionId;
@JsonProperty("occurred_at")
private String occurredAt;
@JsonProperty("synced_at")
private Date syncedAt;
@JsonProperty("client_key_masked")
private String clientKeyMasked;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("business_view")
private Map<String, Object> businessView;
private Map<String, Object> payload;
}

View File

@@ -7,6 +7,8 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenchPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenchRowDto;
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;
@@ -32,6 +34,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmployeeSyncApplicationService {
@@ -267,6 +270,197 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
.build();
}
@Override
public DigitalEmployeeAdminWorkbenchPageDto queryAdminWorkbench(
String view,
String clientKey,
String deviceId,
String entityType,
String entityId,
String status,
String kind,
String category,
String level,
String priority,
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 = RequestContext.get() == null ? null : RequestContext.get().getTenantId();
if (tenantId == null) {
return adminWorkbenchPage(List.of(), currentPage, currentPageSize);
}
String normalizedView = normalizeAdminView(view);
IPage<DigitalEmployeeSyncRecord> page = digitalEmployeeSyncRecordRepository.queryBusinessRecords(
tenantId,
clientKey,
deviceId,
StringUtils.trimToNull(entityType),
entityId,
updatedFrom,
updatedTo,
null,
currentPage,
currentPageSize
);
List<DigitalEmployeeAdminWorkbenchRowDto> rows = page.getRecords().stream()
.map(record -> toAdminWorkbenchRow(record, normalizedView))
.filter(row -> matchesAdminView(normalizedView, row))
.filter(row -> matchesText(status, row.getStatus()))
.filter(row -> matchesText(kind, row.getKind()))
.filter(row -> matchesText(category, row.getCategory()))
.filter(row -> matchesText(level, row.getLevel()))
.filter(row -> matchesText(priority, row.getPriority()))
.toList();
return adminWorkbenchPage(rows, currentPage, currentPageSize);
}
private DigitalEmployeeAdminWorkbenchPageDto adminWorkbenchPage(List<DigitalEmployeeAdminWorkbenchRowDto> 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 DigitalEmployeeAdminWorkbenchPageDto.builder()
.total((long) rows.size())
.pageNo(pageNo)
.pageSize(pageSize)
.records(rows.subList(fromIndex, toIndex))
.build();
}
private DigitalEmployeeAdminWorkbenchRowDto toAdminWorkbenchRow(DigitalEmployeeSyncRecord record, String view) {
DigitalEmployeeBusinessViewRowDto businessRow = toBusinessRow(record);
Map<String, Object> businessView = readJsonObject(record.getBusinessView());
Map<String, Object> payload = readJsonObject(record.getPayload());
String category = adminCategory(businessRow, businessView, payload);
return DigitalEmployeeAdminWorkbenchRowDto.builder()
.view(view)
.syncRecordId(record.getId())
.syncRecordUrl(syncRecordUrl(record.getOutboxId()))
.entityType(businessRow.getEntityType())
.entityId(businessRow.getEntityId())
.operation(record.getOperation())
.title(businessRow.getTitle())
.status(businessRow.getStatus())
.kind(businessRow.getKind())
.category(category)
.level(firstString(businessView.get("level"), payload.get("level")))
.priority(firstString(businessView.get("priority"), payload.get("priority")))
.summary(firstString(businessRow.getSummary(), businessView.get("reason"), payload.get("reason")))
.planId(businessRow.getPlanId())
.taskId(businessRow.getTaskId())
.runId(businessRow.getRunId())
.approvalId(firstString(businessView.get("approval_id"), payload.get("approval_id"), "approval".equals(record.getEntityType()) ? record.getEntityId() : null))
.artifactId(firstString(businessView.get("artifact_id"), payload.get("artifact_id"), "artifact".equals(record.getEntityType()) ? record.getEntityId() : null))
.notificationId(firstString(businessView.get("notification_id"), payload.get("notification_id")))
.reportId(firstString(businessView.get("report_id"), payload.get("report_id")))
.routeDecisionId(firstString(businessView.get("route_decision_id"), payload.get("route_decision_id")))
.occurredAt(businessRow.getOccurredAt())
.syncedAt(record.getSyncedAt())
.clientKeyMasked(maskClientKey(record.getClientKey()))
.deviceId(record.getDeviceId())
.businessView(businessView)
.payload(redactedPayload(payload))
.build();
}
private String normalizeAdminView(String view) {
String normalized = StringUtils.trimToEmpty(view).toLowerCase().replace('-', '_');
return StringUtils.isBlank(normalized) ? "business" : normalized;
}
private boolean matchesAdminView(String view, DigitalEmployeeAdminWorkbenchRowDto row) {
return switch (view) {
case "interventions" -> isInterventionRow(row);
case "artifact_lifecycle" -> isArtifactLifecycleRow(row);
case "notifications" -> isNotificationRow(row);
case "audits" -> isAuditRow(row);
case "daily_reports" -> isDailyReportRow(row);
case "route_governance" -> isRouteGovernanceRow(row);
default -> true;
};
}
private boolean isInterventionRow(DigitalEmployeeAdminWorkbenchRowDto row) {
return StringUtils.equals(row.getCategory(), "approval_conflict")
|| StringUtils.equals(row.getCategory(), "route_intervention")
|| StringUtils.equals(row.getCategory(), "risk_approval")
|| StringUtils.equals(row.getCategory(), "action_notification");
}
private boolean isArtifactLifecycleRow(DigitalEmployeeAdminWorkbenchRowDto row) {
return StringUtils.equals(row.getEntityType(), "artifact")
|| startsWithAny(row.getKind(), "artifact_")
|| StringUtils.equals(row.getCategory(), "artifact_lifecycle");
}
private boolean isNotificationRow(DigitalEmployeeAdminWorkbenchRowDto row) {
return StringUtils.equals(row.getCategory(), "notification")
|| StringUtils.equals(row.getCategory(), "action_notification")
|| contains(row.getKind(), "notification")
|| StringUtils.isNotBlank(row.getNotificationId());
}
private boolean isAuditRow(DigitalEmployeeAdminWorkbenchRowDto row) {
return Set.of("sandbox_audit", "token_cost", "tool_call_audit", "governance", "sync_failure").contains(row.getCategory())
|| contains(row.getKind(), "audit")
|| contains(row.getKind(), "cost");
}
private boolean isDailyReportRow(DigitalEmployeeAdminWorkbenchRowDto row) {
return StringUtils.equals(row.getCategory(), "daily_report")
|| startsWithAny(row.getKind(), "daily_report")
|| StringUtils.isNotBlank(row.getReportId());
}
private boolean isRouteGovernanceRow(DigitalEmployeeAdminWorkbenchRowDto row) {
return StringUtils.equals(row.getCategory(), "route_governance")
|| StringUtils.equals(row.getCategory(), "route_intervention")
|| StringUtils.equals(row.getCategory(), "governance")
|| startsWithAny(row.getKind(), "route_", "governance_")
|| StringUtils.isNotBlank(row.getRouteDecisionId());
}
private String adminCategory(DigitalEmployeeBusinessViewRowDto row, Map<String, Object> businessView, Map<String, Object> payload) {
if (booleanValue(businessView.get("approval_conflict_active"))) return "approval_conflict";
String kind = firstString(row.getKind(), businessView.get("kind"), payload.get("kind"));
if (contains(kind, "route_decision_blocked") || contains(kind, "route_decision_approval_required")
|| contains(kind, "route_action_missing_context") || contains(kind, "route_action_not_mapped")) {
return "route_intervention";
}
String category = firstString(businessView.get("category"), payload.get("category"));
if (StringUtils.isNotBlank(category)) return category;
if (contains(kind, "risk_approval")) return "risk_approval";
if (contains(kind, "notification") || StringUtils.isNotBlank(firstString(businessView.get("notification_id"), payload.get("notification_id")))) {
String level = firstString(businessView.get("level"), payload.get("level"));
return StringUtils.equals(level, "action") ? "action_notification" : "notification";
}
if (startsWithAny(kind, "artifact_")) return "artifact_lifecycle";
if (startsWithAny(kind, "daily_report")) return "daily_report";
if (startsWithAny(kind, "governance_")) return "governance";
if (contains(kind, "audit")) return kind;
return row.getEntityType();
}
private Map<String, Object> redactedPayload(Map<String, Object> payload) {
Map<String, Object> redacted = new LinkedHashMap<>();
payload.forEach((key, value) -> {
if (!isSensitivePayloadKey(key)) {
redacted.put(key, value);
}
});
return redacted;
}
private boolean isSensitivePayloadKey(String key) {
String normalized = StringUtils.trimToEmpty(key).toLowerCase();
return Set.of("token", "authorization", "raw_input", "command", "prompt", "raw_prompt", "input").contains(normalized);
}
private List<DigitalEmployeeBusinessViewRowDto> rowsByType(List<DigitalEmployeeBusinessViewRowDto> rows, String entityType) {
return rows.stream().filter(row -> StringUtils.equals(row.getEntityType(), entityType)).toList();
}
@@ -353,6 +547,25 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
return StringUtils.isBlank(expected) || StringUtils.equals(expected, actual);
}
private boolean booleanValue(Object value) {
if (value instanceof Boolean bool) return bool;
if (value instanceof String text) return Boolean.parseBoolean(text.trim());
return false;
}
private boolean contains(String value, String fragment) {
return StringUtils.containsIgnoreCase(value, fragment);
}
private boolean startsWithAny(String value, String... prefixes) {
if (StringUtils.isBlank(value)) return false;
String normalized = value.toLowerCase();
for (String prefix : prefixes) {
if (normalized.startsWith(prefix)) return true;
}
return false;
}
private String normalizeBusinessEntityType(String value) {
String normalized = StringUtils.trimToEmpty(value).toLowerCase();
if (normalized.endsWith("s")) normalized = normalized.substring(0, normalized.length() - 1);

View File

@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.ObjectMapper;
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.DigitalEmployeeAdminWorkbenchPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenchRowDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
@@ -177,6 +179,87 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
verify(repository, never()).queryBusinessRecords(any(), any(), any(), any(), any(), any(), any(), any(), any(Long.class), any(Long.class));
}
@Test
void queryAdminWorkbenchInterventionsReturnsConflictAndRouteRowsOnly() {
when(repository.queryBusinessRecords(eq(101L), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(1L), eq(20L)))
.thenReturn(page(List.of(
record("outbox-approval-conflict", "approval", "approval-1", 101L, new Date(10_000),
Map.of(
"entity_type", "approval",
"title", "远端审批冲突",
"status", "pending",
"approval_conflict_active", true,
"approval_conflict_reason", "terminal_status_mismatch",
"approval_id", "approval-1",
"plan_id", "plan-1",
"priority", "high"
),
Map.of("id", "approval-1", "status", "pending")),
record("outbox-route-blocked", "event", "event-1", 101L, new Date(11_000),
Map.of(
"entity_type", "event",
"category", "route_governance",
"kind", "route_decision_blocked",
"status", "blocked",
"title", "路由策略阻断",
"route_decision_id", "route-1",
"reason", "route_action_policy_blocked"
),
Map.of("id", "event-1", "kind", "route_decision_blocked")),
record("outbox-approval-normal", "approval", "approval-2", 101L, new Date(12_000),
Map.of("entity_type", "approval", "title", "普通审批", "status", "approved"),
Map.of("id", "approval-2", "status", "approved"))
)));
DigitalEmployeeAdminWorkbenchPageDto result = service.queryAdminWorkbench(
"interventions", null, null, null, null, null, null, null, null, null, null, null, 1L, 20L
);
assertThat(result.getTotal()).isEqualTo(2);
assertThat(result.getRecords()).extracting(DigitalEmployeeAdminWorkbenchRowDto::getEntityId)
.containsExactly("approval-1", "event-1");
assertThat(result.getRecords().get(0).getView()).isEqualTo("interventions");
assertThat(result.getRecords().get(0).getCategory()).isEqualTo("approval_conflict");
assertThat(result.getRecords().get(0).getApprovalId()).isEqualTo("approval-1");
assertThat(result.getRecords().get(1).getRouteDecisionId()).isEqualTo("route-1");
}
@Test
void queryAdminWorkbenchAuditsRedactsSensitivePayloadFields() {
when(repository.queryBusinessRecords(eq(101L), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(1L), eq(20L)))
.thenReturn(page(List.of(
record("outbox-audit", "event", "event-audit", 101L, new Date(10_000),
Map.of(
"entity_type", "event",
"category", "tool_call_audit",
"kind", "tool_call_audit",
"status", "rejected",
"level", "warn",
"tool_name", "shell",
"server_id", "local"
),
Map.of(
"id", "event-audit",
"token", "secret-token",
"authorization", "Bearer secret",
"raw_input", "full prompt",
"command", "cat /private/key",
"prompt", "sensitive prompt",
"tool_name", "shell"
))
)));
DigitalEmployeeAdminWorkbenchPageDto result = service.queryAdminWorkbench(
"audits", null, null, null, null, null, null, null, null, null, null, null, 1L, 20L
);
assertThat(result.getTotal()).isEqualTo(1);
DigitalEmployeeAdminWorkbenchRowDto row = result.getRecords().get(0);
assertThat(row.getCategory()).isEqualTo("tool_call_audit");
assertThat(row.getPayload()).doesNotContainKeys("token", "authorization", "raw_input", "command", "prompt");
assertThat(row.getPayload()).containsEntry("tool_name", "shell");
}
private IPage<DigitalEmployeeSyncRecord> page(List<DigitalEmployeeSyncRecord> records) {
Page<DigitalEmployeeSyncRecord> page = new Page<>(1, 20, records.size());
page.setRecords(records);

View File

@@ -0,0 +1,80 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenchPageDto;
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.PathVariable;
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/workbench")
public class DigitalEmployeeAdminWorkbenchController {
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
@RequestMapping(path = "/{view}", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeAdminWorkbenchPageDto> queryWorkbench(
@PathVariable("view") String view,
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_type", required = false) String entityType,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "kind", required = false) String kind,
@RequestParam(name = "category", required = false) String category,
@RequestParam(name = "level", required = false) String level,
@RequestParam(name = "priority", required = false) String priority,
@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.queryAdminWorkbench(
view,
clientKey,
deviceId,
entityType,
entityId,
status,
kind,
category,
level,
priority,
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;
}
}
}
}

View File

@@ -422,6 +422,11 @@ public enum ResourceEnum {
CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST(ResourceTypeEnum.OPERATION, "content_digital_employee_sync_query_list", "查询列表", "content_digital_employee_sync"),
// 2级模块 - 数字员工运营台
CONTENT_DIGITAL_EMPLOYEE_OPS(ResourceTypeEnum.MODULE, "content_digital_employee_ops", "数字员工运营台", "content_manage"),
CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST(ResourceTypeEnum.OPERATION, "content_digital_employee_ops_query_list", "查询列表", "content_digital_employee_ops"),
// ================== 多语言管理模块 ==================
I18N_LANG_MANAGE(ResourceTypeEnum.MODULE, "i18n_lang_manage", "多语言管理模块", "root"),
I18N_LANG_QUERY(ResourceTypeEnum.OPERATION, "i18n_lang_query", "查询", "i18n_lang_manage"),