吸收数字员工管理端业务查询模型

This commit is contained in:
baiyanyun
2026-06-12 11:21:43 +08:00
parent 9011b91dba
commit fffa0ebe50
14 changed files with 879 additions and 3 deletions

View File

@@ -1,9 +1,13 @@
package com.xspaceagi.agent.core.adapter.application;
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;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
import java.util.Date;
public interface DigitalEmployeeSyncApplicationService {
DigitalEmployeeSyncResponseDto syncOutbox(DigitalEmployeeSyncRequestDto request);
@@ -16,4 +20,23 @@ public interface DigitalEmployeeSyncApplicationService {
Long pageNo,
Long pageSize
);
DigitalEmployeeBusinessViewPageDto queryBusinessViews(
String entityType,
String clientKey,
String deviceId,
String entityId,
String status,
String syncStatus,
Date updatedFrom,
Date updatedTo,
Long pageNo,
Long pageSize
);
DigitalEmployeePlanBusinessViewDetailDto queryPlanBusinessViewDetail(
String planId,
String clientKey,
String deviceId
);
}

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

View File

@@ -0,0 +1,61 @@
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 DigitalEmployeeBusinessViewRowDto {
@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 summary;
@JsonProperty("plan_id")
private String planId;
@JsonProperty("task_id")
private String taskId;
@JsonProperty("run_id")
private String runId;
private String kind;
@JsonProperty("occurred_at")
private String occurredAt;
@JsonProperty("started_at")
private String startedAt;
@JsonProperty("finished_at")
private String finishedAt;
@JsonProperty("duration_ms")
private Long durationMs;
@JsonProperty("sync_status")
private String syncStatus;
@JsonProperty("client_key_masked")
private String clientKeyMasked;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("synced_at")
private Date syncedAt;
private Date modified;
private Date created;
@JsonProperty("business_view")
private Map<String, Object> businessView;
private Map<String, Object> payload;
@JsonProperty("task_count")
private Integer taskCount;
@JsonProperty("run_count")
private Integer runCount;
@JsonProperty("latest_event_at")
private String latestEventAt;
@JsonProperty("last_event_message")
private String lastEventMessage;
}

View File

@@ -0,0 +1,17 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeePlanBusinessViewDetailDto {
private DigitalEmployeeBusinessViewRowDto plan;
private List<DigitalEmployeeBusinessViewRowDto> tasks;
private List<DigitalEmployeeBusinessViewRowDto> runs;
private List<DigitalEmployeeBusinessViewRowDto> events;
private List<DigitalEmployeeBusinessViewRowDto> artifacts;
private List<DigitalEmployeeBusinessViewRowDto> approvals;
}

View File

@@ -14,5 +14,11 @@ public class DigitalEmployeeSyncItemDto {
@JsonAlias("entity_id")
private String entityId;
private String operation;
@JsonAlias("contract_version")
private String contractVersion;
@JsonAlias("entity_summary")
private Map<String, Object> entitySummary;
@JsonAlias("business_view")
private Map<String, Object> businessView;
private Map<String, Object> payload;
}

View File

@@ -21,6 +21,12 @@ public class DigitalEmployeeSyncRecordDto {
@JsonProperty("entity_id")
private String entityId;
private String operation;
@JsonProperty("contract_version")
private String contractVersion;
@JsonProperty("entity_summary")
private String entitySummary;
@JsonProperty("business_view")
private String businessView;
private String payload;
@JsonProperty("synced_at")
private Date syncedAt;

View File

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
import java.util.Date;
public interface DigitalEmployeeSyncRecordRepository extends IService<DigitalEmployeeSyncRecord> {
IPage<DigitalEmployeeSyncRecord> queryRecords(
Long tenantId,
@@ -15,4 +17,17 @@ public interface DigitalEmployeeSyncRecordRepository extends IService<DigitalEmp
long pageNo,
long pageSize
);
IPage<DigitalEmployeeSyncRecord> queryBusinessRecords(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
);
}

View File

@@ -28,6 +28,9 @@ public class DigitalEmployeeSyncRecord {
private String entityType;
private String entityId;
private String operation;
private String contractVersion;
private String entitySummary;
private String businessView;
private String payload;
private Date syncedAt;
private Date modified;

View File

@@ -4,8 +4,12 @@ import com.baomidou.dynamic.datasource.annotation.DSTransactional;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
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.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.DigitalEmployeeSyncAckDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordDto;
@@ -17,11 +21,17 @@ import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRec
import com.xspaceagi.system.spec.common.RequestContext;
import jakarta.annotation.Resource;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmployeeSyncApplicationService {
@@ -67,6 +77,9 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
record.setEntityType(item.getEntityType());
record.setEntityId(item.getEntityId());
record.setOperation(item.getOperation());
record.setContractVersion(item.getContractVersion());
record.setEntitySummary(toJson(item.getEntitySummary()));
record.setBusinessView(toJson(item.getBusinessView()));
record.setPayload(toJson(item.getPayload()));
record.setSyncedAt(now);
record.setModified(now);
@@ -140,6 +153,9 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
.entityType(record.getEntityType())
.entityId(record.getEntityId())
.operation(record.getOperation())
.contractVersion(record.getContractVersion())
.entitySummary(record.getEntitySummary())
.businessView(record.getBusinessView())
.payload(record.getPayload())
.syncedAt(record.getSyncedAt())
.modified(record.getModified())
@@ -147,6 +163,258 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
.build();
}
@Override
public DigitalEmployeeBusinessViewPageDto queryBusinessViews(
String entityType,
String clientKey,
String deviceId,
String entityId,
String status,
String syncStatus,
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 businessPage(List.of(), currentPage, currentPageSize);
}
String normalizedEntityType = normalizeBusinessEntityType(entityType);
String repositoryEntityType = "plan".equals(normalizedEntityType) ? null : normalizedEntityType;
IPage<DigitalEmployeeSyncRecord> page = digitalEmployeeSyncRecordRepository.queryBusinessRecords(
tenantId,
clientKey,
deviceId,
repositoryEntityType,
entityId,
updatedFrom,
updatedTo,
syncStatus,
currentPage,
currentPageSize
);
List<DigitalEmployeeBusinessViewRowDto> allRows = page.getRecords().stream()
.map(this::toBusinessRow)
.toList();
List<DigitalEmployeeBusinessViewRowDto> rows = "plan".equals(normalizedEntityType)
? enrichPlanRows(allRows)
: allRows.stream().filter(row -> StringUtils.equals(row.getEntityType(), normalizedEntityType)).toList();
rows = rows.stream()
.filter(row -> matchesText(status, row.getStatus()))
.filter(row -> matchesText(syncStatus, row.getSyncStatus()))
.toList();
return businessPage(rows, currentPage, currentPageSize);
}
@Override
public DigitalEmployeePlanBusinessViewDetailDto queryPlanBusinessViewDetail(
String planId,
String clientKey,
String deviceId
) {
Long tenantId = RequestContext.get() == null ? null : RequestContext.get().getTenantId();
if (tenantId == null || StringUtils.isBlank(planId)) {
return planDetail(null, List.of());
}
IPage<DigitalEmployeeSyncRecord> page = digitalEmployeeSyncRecordRepository.queryBusinessRecords(
tenantId,
clientKey,
deviceId,
null,
null,
null,
null,
null,
DEFAULT_PAGE_NO,
MAX_PAGE_SIZE
);
List<DigitalEmployeeBusinessViewRowDto> rows = page.getRecords().stream()
.map(this::toBusinessRow)
.filter(row -> StringUtils.equals(planId, planIdOf(row)))
.toList();
DigitalEmployeeBusinessViewRowDto plan = enrichPlanRows(rows).stream()
.filter(row -> StringUtils.equals(row.getEntityId(), planId))
.findFirst()
.orElse(null);
return planDetail(plan, rows);
}
private DigitalEmployeeBusinessViewPageDto businessPage(List<DigitalEmployeeBusinessViewRowDto> 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 DigitalEmployeeBusinessViewPageDto.builder()
.total((long) rows.size())
.pageNo(pageNo)
.pageSize(pageSize)
.records(rows.subList(fromIndex, toIndex))
.build();
}
private DigitalEmployeePlanBusinessViewDetailDto planDetail(DigitalEmployeeBusinessViewRowDto plan, List<DigitalEmployeeBusinessViewRowDto> rows) {
return DigitalEmployeePlanBusinessViewDetailDto.builder()
.plan(plan)
.tasks(rowsByType(rows, "task"))
.runs(rowsByType(rows, "run"))
.events(rowsByType(rows, "event"))
.artifacts(rowsByType(rows, "artifact"))
.approvals(rowsByType(rows, "approval"))
.build();
}
private List<DigitalEmployeeBusinessViewRowDto> rowsByType(List<DigitalEmployeeBusinessViewRowDto> rows, String entityType) {
return rows.stream().filter(row -> StringUtils.equals(row.getEntityType(), entityType)).toList();
}
private List<DigitalEmployeeBusinessViewRowDto> enrichPlanRows(List<DigitalEmployeeBusinessViewRowDto> rows) {
return rows.stream()
.filter(row -> StringUtils.equals(row.getEntityType(), "plan"))
.map(plan -> {
String planId = plan.getEntityId();
int taskCount = (int) rows.stream().filter(row -> StringUtils.equals(row.getEntityType(), "task") && StringUtils.equals(planId, row.getPlanId())).count();
int runCount = (int) rows.stream().filter(row -> StringUtils.equals(row.getEntityType(), "run") && StringUtils.equals(planId, row.getPlanId())).count();
String latestEventAt = rows.stream()
.filter(row -> StringUtils.equals(row.getEntityType(), "event") && StringUtils.equals(planId, row.getPlanId()))
.map(DigitalEmployeeBusinessViewRowDto::getOccurredAt)
.filter(StringUtils::isNotBlank)
.max(Comparator.naturalOrder())
.orElse(null);
plan.setTaskCount(taskCount);
plan.setRunCount(runCount);
plan.setLatestEventAt(latestEventAt);
return plan;
})
.toList();
}
private DigitalEmployeeBusinessViewRowDto toBusinessRow(DigitalEmployeeSyncRecord record) {
Map<String, Object> businessView = readJsonObject(record.getBusinessView());
Map<String, Object> payload = readJsonObject(record.getPayload());
Map<String, Object> summary = readJsonObject(record.getEntitySummary());
String entityType = stringValue(businessView.get("entity_type"), record.getEntityType());
String entityId = record.getEntityId();
String title = firstString(
businessView.get("title"), businessView.get("name"),
summary.get("title"),
payload.get("title"), payload.get("name"), payload.get("objective"), payload.get("message"),
entityId
);
String status = firstString(businessView.get("status"), summary.get("status"), payload.get("status"), payload.get("syncStatus"), payload.get("sync_status"));
String startedAt = firstString(businessView.get("started_at"), businessView.get("startedAt"), payload.get("startedAt"), payload.get("started_at"));
String finishedAt = firstString(businessView.get("finished_at"), businessView.get("finishedAt"), payload.get("finishedAt"), payload.get("finished_at"));
Long durationMs = numberValue(businessView.get("duration_ms"));
if (durationMs == null) {
durationMs = durationMs(startedAt, finishedAt);
}
return DigitalEmployeeBusinessViewRowDto.builder()
.syncRecordId(record.getId())
.syncRecordUrl(syncRecordUrl(record.getOutboxId()))
.entityType(entityType)
.entityId(entityId)
.operation(record.getOperation())
.title(title)
.status(status)
.summary(firstString(businessView.get("summary"), businessView.get("objective"), businessView.get("message"), summary.get("summary"), payload.get("summary"), payload.get("objective"), payload.get("message")))
.planId(firstString(businessView.get("plan_id"), businessView.get("planId"), payload.get("plan_id"), payload.get("planId"), "plan".equals(entityType) ? entityId : null))
.taskId(firstString(businessView.get("task_id"), businessView.get("taskId"), payload.get("task_id"), payload.get("taskId"), "task".equals(entityType) ? entityId : null))
.runId(firstString(businessView.get("run_id"), businessView.get("runId"), payload.get("run_id"), payload.get("runId"), "run".equals(entityType) ? entityId : null))
.kind(firstString(businessView.get("kind"), payload.get("kind")))
.occurredAt(firstString(businessView.get("occurred_at"), businessView.get("occurredAt"), payload.get("occurred_at"), payload.get("occurredAt")))
.startedAt(startedAt)
.finishedAt(finishedAt)
.durationMs(durationMs)
.syncStatus(firstString(businessView.get("sync_status"), businessView.get("syncStatus"), payload.get("sync_status"), payload.get("syncStatus")))
.clientKeyMasked(maskClientKey(record.getClientKey()))
.deviceId(record.getDeviceId())
.syncedAt(record.getSyncedAt())
.modified(record.getModified())
.created(record.getCreated())
.businessView(businessView)
.payload(payload)
.lastEventMessage(lastEventMessage(entityType, businessView, payload))
.build();
}
private String planIdOf(DigitalEmployeeBusinessViewRowDto row) {
return StringUtils.equals(row.getEntityType(), "plan") ? row.getEntityId() : row.getPlanId();
}
private String lastEventMessage(String entityType, Map<String, Object> businessView, Map<String, Object> payload) {
if (!StringUtils.equals(entityType, "run")) return null;
return firstString(businessView.get("last_event_message"), payload.get("lastEventMessage"), payload.get("message"));
}
private boolean matchesText(String expected, String actual) {
return StringUtils.isBlank(expected) || StringUtils.equals(expected, actual);
}
private String normalizeBusinessEntityType(String value) {
String normalized = StringUtils.trimToEmpty(value).toLowerCase();
if (normalized.endsWith("s")) normalized = normalized.substring(0, normalized.length() - 1);
return normalized;
}
private String syncRecordUrl(String outboxId) {
return "/system/content/content-digital-employee-sync?outbox_id=" + StringUtils.defaultString(outboxId);
}
private String maskClientKey(String clientKey) {
if (StringUtils.isBlank(clientKey)) return null;
String trimmed = clientKey.trim();
if (trimmed.length() <= 7) return "***";
return trimmed.substring(0, 4) + "*********" + trimmed.substring(trimmed.length() - 3);
}
private Map<String, Object> readJsonObject(String value) {
if (StringUtils.isBlank(value)) return new LinkedHashMap<>();
try {
Map<String, Object> parsed = objectMapper.readValue(value, new TypeReference<>() {});
return parsed == null ? new LinkedHashMap<>() : parsed;
} catch (Exception e) {
return new LinkedHashMap<>();
}
}
private String firstString(Object... values) {
for (Object value : values) {
String candidate = stringValue(value, null);
if (StringUtils.isNotBlank(candidate)) return candidate;
}
return null;
}
private String stringValue(Object value, String fallback) {
if (value == null) return fallback;
String text = Objects.toString(value, "").trim();
return StringUtils.isBlank(text) ? fallback : text;
}
private Long numberValue(Object value) {
if (value instanceof Number number) return number.longValue();
if (value instanceof String text && StringUtils.isNotBlank(text)) {
try {
return Long.parseLong(text.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private Long durationMs(String startedAt, String finishedAt) {
if (StringUtils.isBlank(startedAt) || StringUtils.isBlank(finishedAt)) return null;
try {
return Instant.parse(finishedAt).toEpochMilli() - Instant.parse(startedAt).toEpochMilli();
} catch (Exception e) {
return null;
}
}
private String toJson(Object payload) {
if (payload == null) {
return "{}";

View File

@@ -0,0 +1,250 @@
package com.xspaceagi.agent.core.application.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
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.DigitalEmployeePlanBusinessViewDetailDto;
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;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
import com.xspaceagi.system.spec.common.RequestContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Date;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DigitalEmployeeSyncApplicationServiceImplTest {
private DigitalEmployeeSyncRecordRepository repository;
private DigitalEmployeeSyncApplicationServiceImpl service;
@BeforeEach
void setUp() {
RequestContext<Object> context = new RequestContext<>();
context.setTenantId(101L);
context.setUserId(202L);
RequestContext.set(context);
repository = mock(DigitalEmployeeSyncRecordRepository.class);
service = new DigitalEmployeeSyncApplicationServiceImpl();
ReflectionTestUtils.setField(service, "digitalEmployeeSyncRecordRepository", repository);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@AfterEach
void tearDown() {
RequestContext.remove();
}
@Test
void syncOutboxPersistsBusinessViewSummaryAndContractVersion() {
DigitalEmployeeSyncItemDto item = new DigitalEmployeeSyncItemDto();
item.setOutboxId("outbox-plan-1");
item.setEntityType("plan");
item.setEntityId("plan-1");
item.setOperation("upsert");
item.setContractVersion("qimingclaw.digital_employee.sync.v1");
item.setEntitySummary(Map.of("title", "客户回访", "status", "active", "summary", "跟进高价值客户"));
item.setBusinessView(Map.of("local_id", "plan-1", "title", "客户回访", "status", "active", "objective", "跟进高价值客户"));
item.setPayload(Map.of("id", "plan-1", "title", "客户回访", "status", "active"));
DigitalEmployeeSyncRequestDto request = new DigitalEmployeeSyncRequestDto();
request.setClientKey("client-key-001");
request.setDeviceId("device-001");
request.setItems(List.of(item));
service.syncOutbox(request);
ArgumentCaptor<DigitalEmployeeSyncRecord> captor = ArgumentCaptor.forClass(DigitalEmployeeSyncRecord.class);
verify(repository).save(captor.capture());
DigitalEmployeeSyncRecord record = captor.getValue();
assertThat(record.getContractVersion()).isEqualTo("qimingclaw.digital_employee.sync.v1");
assertThat(record.getEntitySummary()).contains("客户回访");
assertThat(record.getBusinessView()).contains("objective").contains("跟进高价值客户");
assertThat(record.getPayload()).contains("plan-1");
assertThat(record.getTenantId()).isEqualTo(101L);
assertThat(record.getUserId()).isEqualTo(202L);
}
@Test
void queryRecordsReturnsStoredBusinessMetadata() {
DigitalEmployeeSyncRecord record = planRecord("plan-1", "客户回访", "active", 101L, new Date(1_000));
Page<DigitalEmployeeSyncRecord> page = new Page<>(1, 20, 1);
page.setRecords(List.of(record));
when(repository.queryRecords(eq(101L), eq("client-key-001"), eq("device-001"), eq("plan"), eq("plan-1"), eq("outbox-plan-1"), eq(1L), eq(20L)))
.thenReturn(page);
DigitalEmployeeSyncRecordPageDto result = service.queryRecords("client-key-001", "device-001", "plan", "plan-1", "outbox-plan-1", 1L, 20L);
assertThat(result.getRecords()).hasSize(1);
assertThat(result.getRecords().get(0).getContractVersion()).isEqualTo("qimingclaw.digital_employee.sync.v1");
assertThat(result.getRecords().get(0).getEntitySummary()).contains("客户回访");
assertThat(result.getRecords().get(0).getBusinessView()).contains("active");
}
@Test
void queryPlansBuildsBusinessRowsAndAggregatesRelatedCounts() {
Date now = new Date(10_000);
List<DigitalEmployeeSyncRecord> records = List.of(
planRecord("plan-1", "客户回访", "active", 101L, now),
taskRecord("task-1", "plan-1", "running", 101L, new Date(11_000)),
runRecord("run-1", "plan-1", "task-1", "failed", 101L, new Date(12_000)),
eventRecord("event-1", "plan-1", "task-1", "run-1", "plan_step_dispatch_failed", "派发失败", "failed", 101L, new Date(13_000))
);
when(repository.queryBusinessRecords(eq(101L), eq("client-key-001"), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(1L), eq(20L)))
.thenReturn(page(records));
DigitalEmployeeBusinessViewPageDto result = service.queryBusinessViews("plan", "client-key-001", null, null, null, null, null, null, 1L, 20L);
assertThat(result.getTotal()).isEqualTo(1);
assertThat(result.getRecords()).hasSize(1);
DigitalEmployeeBusinessViewRowDto plan = result.getRecords().get(0);
assertThat(plan.getEntityType()).isEqualTo("plan");
assertThat(plan.getEntityId()).isEqualTo("plan-1");
assertThat(plan.getTitle()).isEqualTo("客户回访");
assertThat(plan.getStatus()).isEqualTo("active");
assertThat(plan.getTaskCount()).isEqualTo(1);
assertThat(plan.getRunCount()).isEqualTo(1);
assertThat(plan.getLatestEventAt()).isNotNull();
assertThat(plan.getSyncRecordUrl()).contains("outbox-plan-1");
assertThat(plan.getClientKeyMasked()).isEqualTo("clie*********001");
}
@Test
void queryEventsFiltersStatusEntityAndTimeRangeFromBusinessView() {
Date from = new Date(10_000);
Date to = new Date(20_000);
when(repository.queryBusinessRecords(eq(101L), eq(null), eq(null), eq("event"), eq("event-1"), eq(from), eq(to), eq(null), eq(1L), eq(20L)))
.thenReturn(page(List.of(
eventRecord("event-1", "plan-1", "task-1", "run-1", "tool_call_audit", "工具允许", "allowed", 101L, new Date(12_000)),
eventRecord("event-2", "plan-1", "task-1", "run-1", "tool_call_audit", "工具拒绝", "rejected", 101L, new Date(13_000))
)));
DigitalEmployeeBusinessViewPageDto result = service.queryBusinessViews("event", null, null, "event-1", "allowed", null, from, to, 1L, 20L);
assertThat(result.getTotal()).isEqualTo(1);
assertThat(result.getRecords().get(0).getEntityId()).isEqualTo("event-1");
assertThat(result.getRecords().get(0).getKind()).isEqualTo("tool_call_audit");
}
@Test
void queryPlanDetailReturnsRelatedBusinessFacts() {
when(repository.queryBusinessRecords(eq(101L), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(1L), eq(100L)))
.thenReturn(page(List.of(
planRecord("plan-1", "客户回访", "active", 101L, new Date(10_000)),
taskRecord("task-1", "plan-1", "running", 101L, new Date(11_000)),
runRecord("run-1", "plan-1", "task-1", "failed", 101L, new Date(12_000)),
eventRecord("event-1", "plan-1", "task-1", "run-1", "approval_decision", "审批通过", "approved", 101L, new Date(13_000)),
artifactRecord("artifact-1", "plan-1", "task-1", "ready", 101L, new Date(14_000)),
approvalRecord("approval-1", "plan-1", "task-1", "pending", 101L, new Date(15_000))
)));
DigitalEmployeePlanBusinessViewDetailDto result = service.queryPlanBusinessViewDetail("plan-1", null, null);
assertThat(result.getPlan().getEntityId()).isEqualTo("plan-1");
assertThat(result.getTasks()).hasSize(1);
assertThat(result.getRuns()).hasSize(1);
assertThat(result.getEvents()).hasSize(1);
assertThat(result.getArtifacts()).hasSize(1);
assertThat(result.getApprovals()).hasSize(1);
}
@Test
void queryBusinessViewsReturnsEmptyWhenTenantIsMissing() {
RequestContext.remove();
DigitalEmployeeBusinessViewPageDto result = service.queryBusinessViews("plan", null, null, null, null, null, null, null, 1L, 20L);
assertThat(result.getTotal()).isZero();
assertThat(result.getRecords()).isEmpty();
verify(repository, never()).queryBusinessRecords(any(), any(), any(), any(), any(), any(), any(), any(), any(Long.class), any(Long.class));
}
private IPage<DigitalEmployeeSyncRecord> page(List<DigitalEmployeeSyncRecord> records) {
Page<DigitalEmployeeSyncRecord> page = new Page<>(1, 20, records.size());
page.setRecords(records);
return page;
}
private DigitalEmployeeSyncRecord planRecord(String planId, String title, String status, Long tenantId, Date syncedAt) {
return record("outbox-" + planId, "plan", planId, tenantId, syncedAt,
Map.of("entity_type", "plan", "local_id", planId, "title", title, "status", status, "summary", "计划摘要"),
Map.of("id", planId, "title", title, "status", status));
}
private DigitalEmployeeSyncRecord taskRecord(String taskId, String planId, String status, Long tenantId, Date syncedAt) {
return record("outbox-" + taskId, "task", taskId, tenantId, syncedAt,
Map.of("entity_type", "task", "local_id", taskId, "plan_id", planId, "title", "任务", "status", status),
Map.of("id", taskId, "planId", planId, "status", status));
}
private DigitalEmployeeSyncRecord runRecord(String runId, String planId, String taskId, String status, Long tenantId, Date syncedAt) {
return record("outbox-" + runId, "run", runId, tenantId, syncedAt,
Map.of("entity_type", "run", "local_id", runId, "plan_id", planId, "task_id", taskId, "status", status, "started_at", "2026-06-12T08:00:00.000Z", "finished_at", "2026-06-12T08:01:00.000Z"),
Map.of("id", runId, "planId", planId, "taskId", taskId, "status", status));
}
private DigitalEmployeeSyncRecord eventRecord(String eventId, String planId, String taskId, String runId, String kind, String message, String status, Long tenantId, Date syncedAt) {
return record("outbox-" + eventId, "event", eventId, tenantId, syncedAt,
Map.of("entity_type", "event", "local_id", eventId, "plan_id", planId, "task_id", taskId, "run_id", runId, "kind", kind, "message", message, "status", status, "occurred_at", "2026-06-12T08:02:00.000Z"),
Map.of("id", eventId, "planId", planId, "taskId", taskId, "runId", runId, "kind", kind, "message", message, "status", status));
}
private DigitalEmployeeSyncRecord artifactRecord(String artifactId, String planId, String taskId, String status, Long tenantId, Date syncedAt) {
return record("outbox-" + artifactId, "artifact", artifactId, tenantId, syncedAt,
Map.of("entity_type", "artifact", "local_id", artifactId, "plan_id", planId, "task_id", taskId, "title", "交付物", "status", status),
Map.of("id", artifactId, "planId", planId, "taskId", taskId, "status", status));
}
private DigitalEmployeeSyncRecord approvalRecord(String approvalId, String planId, String taskId, String status, Long tenantId, Date syncedAt) {
return record("outbox-" + approvalId, "approval", approvalId, tenantId, syncedAt,
Map.of("entity_type", "approval", "local_id", approvalId, "plan_id", planId, "task_id", taskId, "title", "审批", "status", status),
Map.of("id", approvalId, "planId", planId, "taskId", taskId, "status", status));
}
private DigitalEmployeeSyncRecord record(String outboxId, String entityType, String entityId, Long tenantId, Date syncedAt, Map<String, Object> businessView, Map<String, Object> payload) {
DigitalEmployeeSyncRecord record = new DigitalEmployeeSyncRecord();
record.setId((long) Math.abs(outboxId.hashCode()));
record.setTenantId(tenantId);
record.setUserId(202L);
record.setClientKey("client-key-001");
record.setDeviceId("device-001");
record.setOutboxId(outboxId);
record.setEntityType(entityType);
record.setEntityId(entityId);
record.setOperation("upsert");
record.setContractVersion("qimingclaw.digital_employee.sync.v1");
record.setEntitySummary(toJson(Map.of("title", businessView.getOrDefault("title", entityId), "status", businessView.getOrDefault("status", "unknown"))));
record.setBusinessView(toJson(businessView));
record.setPayload(toJson(payload));
record.setSyncedAt(syncedAt);
record.setModified(syncedAt);
record.setCreated(syncedAt);
return record;
}
private String toJson(Map<String, Object> value) {
try {
return new ObjectMapper().writeValueAsString(value);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}

View File

@@ -10,6 +10,8 @@ import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeSyncRecordMapper
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class DigitalEmployeeSyncRecordRepositoryImpl
extends ServiceImpl<DigitalEmployeeSyncRecordMapper, DigitalEmployeeSyncRecord>
@@ -37,4 +39,30 @@ public class DigitalEmployeeSyncRecordRepositoryImpl
.orderByDesc(DigitalEmployeeSyncRecord::getId);
return page(new Page<>(pageNo, pageSize), queryWrapper);
}
@Override
public IPage<DigitalEmployeeSyncRecord> queryBusinessRecords(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
) {
LambdaQueryWrapper<DigitalEmployeeSyncRecord> queryWrapper = new LambdaQueryWrapper<DigitalEmployeeSyncRecord>()
.eq(tenantId != null, DigitalEmployeeSyncRecord::getTenantId, tenantId)
.eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeSyncRecord::getClientKey, clientKey)
.eq(StringUtils.isNotBlank(deviceId), DigitalEmployeeSyncRecord::getDeviceId, deviceId)
.eq(StringUtils.isNotBlank(entityType), DigitalEmployeeSyncRecord::getEntityType, entityType)
.eq(StringUtils.isNotBlank(entityId), DigitalEmployeeSyncRecord::getEntityId, entityId)
.ge(updatedFrom != null, DigitalEmployeeSyncRecord::getSyncedAt, updatedFrom)
.le(updatedTo != null, DigitalEmployeeSyncRecord::getSyncedAt, updatedTo)
.orderByDesc(DigitalEmployeeSyncRecord::getSyncedAt)
.orderByDesc(DigitalEmployeeSyncRecord::getId);
return page(new Page<>(pageNo, pageSize), queryWrapper);
}
}

View File

@@ -0,0 +1,177 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
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_SYNC_QUERY_LIST;
@RestController
@RequestMapping("/api/digital-employee")
public class DigitalEmployeeBusinessViewController {
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/plans", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryPlans(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@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(query("plan", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/plans/{plan_id}", method = RequestMethod.GET)
public ReqResult<DigitalEmployeePlanBusinessViewDetailDto> queryPlanDetail(
@PathVariable("plan_id") String planId,
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.queryPlanBusinessViewDetail(planId, clientKey, deviceId));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/tasks", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryTasks(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@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(query("task", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/runs", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryRuns(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@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(query("run", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/events", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryEvents(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@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(query("event", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/artifacts", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryArtifacts(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@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(query("artifact", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/approvals", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryApprovals(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@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(query("approval", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
private DigitalEmployeeBusinessViewPageDto query(
String entityType,
String clientKey,
String deviceId,
String entityId,
String status,
String syncStatus,
String updatedFrom,
String updatedTo,
Long pageNo,
Long pageSize
) {
return digitalEmployeeSyncApplicationService.queryBusinessViews(
entityType,
clientKey,
deviceId,
entityId,
status,
syncStatus,
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;
}
}
}
}