吸收数字员工业务事实物化查询

This commit is contained in:
baiyanyun
2026-06-12 22:24:38 +08:00
parent 8a5d5d6474
commit 26fbf51fc8
20 changed files with 721 additions and 1 deletions

View File

@@ -58,4 +58,5 @@ public class DigitalEmployeeBusinessViewRowDto {
private String latestEventAt;
@JsonProperty("last_event_message")
private String lastEventMessage;
private Boolean materialized;
}

View File

@@ -0,0 +1,8 @@
package com.xspaceagi.agent.core.adapter.repository;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeArtifactLifecycle;
public interface DigitalEmployeeArtifactLifecycleRepository extends IService<DigitalEmployeeArtifactLifecycle> {
void upsertBySyncRecord(DigitalEmployeeArtifactLifecycle lifecycle);
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.agent.core.adapter.repository;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeBusinessFact;
import java.util.Date;
public interface DigitalEmployeeBusinessFactRepository extends IService<DigitalEmployeeBusinessFact> {
void upsertBySyncRecord(DigitalEmployeeBusinessFact fact);
IPage<DigitalEmployeeBusinessFact> queryFacts(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
);
}

View File

@@ -0,0 +1,8 @@
package com.xspaceagi.agent.core.adapter.repository;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeDailyReportFact;
public interface DigitalEmployeeDailyReportFactRepository extends IService<DigitalEmployeeDailyReportFact> {
void upsertBySyncRecord(DigitalEmployeeDailyReportFact fact);
}

View File

@@ -0,0 +1,41 @@
package com.xspaceagi.agent.core.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@TableName("digital_employee_artifact_lifecycle")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DigitalEmployeeArtifactLifecycle {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long syncRecordId;
private String clientKey;
private String deviceId;
private String artifactId;
private String planId;
private String taskId;
private String runId;
private String status;
private String retentionStatus;
private String cleanupStatus;
private String deletedAt;
private String versionKey;
private String source;
private String businessView;
private Date syncedAt;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,50 @@
package com.xspaceagi.agent.core.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@TableName("digital_employee_business_fact")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DigitalEmployeeBusinessFact {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long userId;
private Long syncRecordId;
private String clientKey;
private String deviceId;
private String outboxId;
private String entityType;
private String entityId;
private String operation;
private String title;
private String status;
private String summary;
private String planId;
private String taskId;
private String runId;
private String kind;
private String category;
private String occurredAt;
private String startedAt;
private String finishedAt;
private Long durationMs;
private String syncStatus;
private String businessView;
private String payload;
private Date syncedAt;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,38 @@
package com.xspaceagi.agent.core.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@TableName("digital_employee_daily_report_fact")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DigitalEmployeeDailyReportFact {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long syncRecordId;
private String clientKey;
private String deviceId;
private String reportId;
private String planId;
private String status;
private String deliveryStatus;
private String approvalStatus;
private String confirmedAt;
private String reportDate;
private String businessView;
private Date syncedAt;
private Date modified;
private Date created;
}

View File

@@ -25,8 +25,14 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPag
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeAdminActionRepository;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeArtifactLifecycleRepository;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeBusinessFactRepository;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeDailyReportFactRepository;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeArtifactLifecycle;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeBusinessFact;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeDailyReportFact;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
import com.xspaceagi.system.spec.common.RequestContext;
import jakarta.annotation.Resource;
@@ -70,6 +76,15 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
@Resource
private DigitalEmployeeAdminActionRepository digitalEmployeeAdminActionRepository;
@Resource
private DigitalEmployeeBusinessFactRepository digitalEmployeeBusinessFactRepository;
@Resource
private DigitalEmployeeArtifactLifecycleRepository digitalEmployeeArtifactLifecycleRepository;
@Resource
private DigitalEmployeeDailyReportFactRepository digitalEmployeeDailyReportFactRepository;
@Resource
private ObjectMapper objectMapper;
@@ -116,6 +131,7 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
} else {
digitalEmployeeSyncRecordRepository.updateById(record);
}
materializeSyncRecord(record, now);
acked.add(DigitalEmployeeSyncAckDto.builder()
.outboxId(item.getOutboxId())
@@ -190,6 +206,94 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
.build();
}
private void materializeSyncRecord(DigitalEmployeeSyncRecord record, Date now) {
if (record == null || record.getId() == null || digitalEmployeeBusinessFactRepository == null) return;
DigitalEmployeeBusinessViewRowDto row = toBusinessRow(record);
digitalEmployeeBusinessFactRepository.upsertBySyncRecord(DigitalEmployeeBusinessFact.builder()
.tenantId(record.getTenantId())
.userId(record.getUserId())
.syncRecordId(record.getId())
.clientKey(record.getClientKey())
.deviceId(record.getDeviceId())
.outboxId(record.getOutboxId())
.entityType(row.getEntityType())
.entityId(row.getEntityId())
.operation(row.getOperation())
.title(row.getTitle())
.status(row.getStatus())
.summary(row.getSummary())
.planId(row.getPlanId())
.taskId(row.getTaskId())
.runId(row.getRunId())
.kind(row.getKind())
.category(firstString(row.getBusinessView().get("category"), row.getEntityType()))
.occurredAt(row.getOccurredAt())
.startedAt(row.getStartedAt())
.finishedAt(row.getFinishedAt())
.durationMs(row.getDurationMs())
.syncStatus(row.getSyncStatus())
.businessView(record.getBusinessView())
.payload(record.getPayload())
.syncedAt(record.getSyncedAt())
.modified(now)
.created(now)
.build());
materializeArtifactLifecycle(record, row, now);
materializeDailyReportFact(record, row, now);
}
private void materializeArtifactLifecycle(DigitalEmployeeSyncRecord record, DigitalEmployeeBusinessViewRowDto row, Date now) {
if (digitalEmployeeArtifactLifecycleRepository == null || !StringUtils.equals(row.getEntityType(), "artifact")) return;
Map<String, Object> view = row.getBusinessView();
String artifactId = firstString(view.get("artifact_id"), view.get("artifactId"), row.getEntityId());
digitalEmployeeArtifactLifecycleRepository.upsertBySyncRecord(DigitalEmployeeArtifactLifecycle.builder()
.tenantId(record.getTenantId())
.syncRecordId(record.getId())
.clientKey(record.getClientKey())
.deviceId(record.getDeviceId())
.artifactId(artifactId)
.planId(row.getPlanId())
.taskId(row.getTaskId())
.runId(row.getRunId())
.status(row.getStatus())
.retentionStatus(firstString(view.get("retention_status"), view.get("retentionStatus")))
.cleanupStatus(firstString(view.get("cleanup_status"), view.get("cleanupStatus")))
.deletedAt(firstString(view.get("deleted_at"), view.get("deletedAt")))
.versionKey(firstString(view.get("version_key"), view.get("versionKey")))
.source(firstString(view.get("source"), view.get("artifact_source"), view.get("artifactSource")))
.businessView(record.getBusinessView())
.syncedAt(record.getSyncedAt())
.modified(now)
.created(now)
.build());
}
private void materializeDailyReportFact(DigitalEmployeeSyncRecord record, DigitalEmployeeBusinessViewRowDto row, Date now) {
Map<String, Object> view = row.getBusinessView();
if (digitalEmployeeDailyReportFactRepository == null
|| !(StringUtils.equals(row.getEntityType(), "daily_report") || StringUtils.equals(firstString(view.get("category")), "daily_report"))) {
return;
}
String reportId = firstString(view.get("report_id"), view.get("reportId"), row.getEntityId());
digitalEmployeeDailyReportFactRepository.upsertBySyncRecord(DigitalEmployeeDailyReportFact.builder()
.tenantId(record.getTenantId())
.syncRecordId(record.getId())
.clientKey(record.getClientKey())
.deviceId(record.getDeviceId())
.reportId(reportId)
.planId(row.getPlanId())
.status(row.getStatus())
.deliveryStatus(firstString(view.get("delivery_status"), view.get("deliveryStatus")))
.approvalStatus(firstString(view.get("approval_status"), view.get("approvalStatus")))
.confirmedAt(firstString(view.get("confirmed_at"), view.get("confirmedAt")))
.reportDate(firstString(view.get("report_date"), view.get("reportDate")))
.businessView(record.getBusinessView())
.syncedAt(record.getSyncedAt())
.modified(now)
.created(now)
.build());
}
@Override
public DigitalEmployeeBusinessViewPageDto queryBusinessViews(
String entityType,
@@ -214,6 +318,28 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
String normalizedEntityType = normalizeBusinessEntityType(entityType);
String repositoryEntityType = "plan".equals(normalizedEntityType) ? null : normalizedEntityType;
List<DigitalEmployeeBusinessViewRowDto> materializedRows = queryMaterializedBusinessRows(
tenantId,
clientKey,
deviceId,
repositoryEntityType,
entityId,
updatedFrom,
updatedTo,
syncStatus,
currentPage,
currentPageSize
);
if (materializedRows != null) {
List<DigitalEmployeeBusinessViewRowDto> rows = "plan".equals(normalizedEntityType)
? enrichPlanRows(materializedRows)
: materializedRows.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);
}
IPage<DigitalEmployeeSyncRecord> page = digitalEmployeeSyncRecordRepository.queryBusinessRecords(
tenantId,
clientKey,
@@ -239,6 +365,35 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
return businessPage(rows, currentPage, currentPageSize);
}
private List<DigitalEmployeeBusinessViewRowDto> queryMaterializedBusinessRows(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
) {
if (digitalEmployeeBusinessFactRepository == null) return null;
IPage<DigitalEmployeeBusinessFact> factPage = digitalEmployeeBusinessFactRepository.queryFacts(
tenantId,
clientKey,
deviceId,
entityType,
entityId,
updatedFrom,
updatedTo,
syncStatus,
pageNo,
pageSize
);
if (factPage == null || factPage.getTotal() <= 0) return null;
return factPage.getRecords().stream().map(this::toBusinessRow).toList();
}
@Override
public DigitalEmployeePlanBusinessViewDetailDto queryPlanBusinessViewDetail(
String planId,
@@ -814,6 +969,38 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
.businessView(businessView)
.payload(payload)
.lastEventMessage(lastEventMessage(entityType, businessView, payload))
.materialized(false)
.build();
}
private DigitalEmployeeBusinessViewRowDto toBusinessRow(DigitalEmployeeBusinessFact fact) {
return DigitalEmployeeBusinessViewRowDto.builder()
.syncRecordId(fact.getSyncRecordId())
.syncRecordUrl(syncRecordUrl(fact.getOutboxId()))
.entityType(fact.getEntityType())
.entityId(fact.getEntityId())
.operation(fact.getOperation())
.title(fact.getTitle())
.status(fact.getStatus())
.summary(fact.getSummary())
.planId(fact.getPlanId())
.taskId(fact.getTaskId())
.runId(fact.getRunId())
.kind(fact.getKind())
.occurredAt(fact.getOccurredAt())
.startedAt(fact.getStartedAt())
.finishedAt(fact.getFinishedAt())
.durationMs(fact.getDurationMs())
.syncStatus(fact.getSyncStatus())
.clientKeyMasked(maskClientKey(fact.getClientKey()))
.deviceId(fact.getDeviceId())
.syncedAt(fact.getSyncedAt())
.modified(fact.getModified())
.created(fact.getCreated())
.businessView(readJsonObject(fact.getBusinessView()))
.payload(readJsonObject(fact.getPayload()))
.lastEventMessage(lastEventMessage(fact.getEntityType(), readJsonObject(fact.getBusinessView()), readJsonObject(fact.getPayload())))
.materialized(true)
.build();
}

View File

@@ -18,8 +18,12 @@ 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.DigitalEmployeeAdminActionRepository;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeArtifactLifecycleRepository;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeBusinessFactRepository;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeDailyReportFactRepository;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeBusinessFact;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
import com.xspaceagi.system.spec.common.RequestContext;
import org.junit.jupiter.api.AfterEach;
@@ -35,6 +39,7 @@ 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.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -44,6 +49,9 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
private DigitalEmployeeSyncRecordRepository repository;
private DigitalEmployeeAdminActionRepository adminActionRepository;
private DigitalEmployeeBusinessFactRepository businessFactRepository;
private DigitalEmployeeArtifactLifecycleRepository artifactLifecycleRepository;
private DigitalEmployeeDailyReportFactRepository dailyReportFactRepository;
private DigitalEmployeeSyncApplicationServiceImpl service;
@BeforeEach
@@ -55,9 +63,15 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
repository = mock(DigitalEmployeeSyncRecordRepository.class);
adminActionRepository = mock(DigitalEmployeeAdminActionRepository.class);
businessFactRepository = mock(DigitalEmployeeBusinessFactRepository.class);
artifactLifecycleRepository = mock(DigitalEmployeeArtifactLifecycleRepository.class);
dailyReportFactRepository = mock(DigitalEmployeeDailyReportFactRepository.class);
service = new DigitalEmployeeSyncApplicationServiceImpl();
ReflectionTestUtils.setField(service, "digitalEmployeeSyncRecordRepository", repository);
ReflectionTestUtils.setField(service, "digitalEmployeeAdminActionRepository", adminActionRepository);
ReflectionTestUtils.setField(service, "digitalEmployeeBusinessFactRepository", businessFactRepository);
ReflectionTestUtils.setField(service, "digitalEmployeeArtifactLifecycleRepository", artifactLifecycleRepository);
ReflectionTestUtils.setField(service, "digitalEmployeeDailyReportFactRepository", dailyReportFactRepository);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@@ -82,6 +96,11 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
request.setClientKey("client-key-001");
request.setDeviceId("device-001");
request.setItems(List.of(item));
doAnswer(invocation -> {
DigitalEmployeeSyncRecord saved = invocation.getArgument(0);
saved.setId(1001L);
return true;
}).when(repository).save(any(DigitalEmployeeSyncRecord.class));
service.syncOutbox(request);
@@ -94,6 +113,11 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
assertThat(record.getPayload()).contains("plan-1");
assertThat(record.getTenantId()).isEqualTo(101L);
assertThat(record.getUserId()).isEqualTo(202L);
ArgumentCaptor<DigitalEmployeeBusinessFact> factCaptor = ArgumentCaptor.forClass(DigitalEmployeeBusinessFact.class);
verify(businessFactRepository).upsertBySyncRecord(factCaptor.capture());
assertThat(factCaptor.getValue().getSyncRecordId()).isEqualTo(1001L);
assertThat(factCaptor.getValue().getTitle()).isEqualTo("客户回访");
assertThat(factCaptor.getValue().getStatus()).isEqualTo("active");
}
@Test
@@ -140,6 +164,22 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
assertThat(plan.getClientKeyMasked()).isEqualTo("clie*********001");
}
@Test
void queryBusinessViewsPrefersMaterializedFactsWhenAvailable() {
DigitalEmployeeBusinessFact fact = businessFact("plan-1", "客户回访", "active", new Date(10_000));
Page<DigitalEmployeeBusinessFact> page = new Page<>(1, 20, 1);
page.setRecords(List.of(fact));
when(businessFactRepository.queryFacts(eq(101L), eq("client-key-001"), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(1L), eq(20L)))
.thenReturn(page);
DigitalEmployeeBusinessViewPageDto result = service.queryBusinessViews("plan", "client-key-001", null, null, null, null, null, null, 1L, 20L);
assertThat(result.getRecords()).hasSize(1);
assertThat(result.getRecords().get(0).getTitle()).isEqualTo("客户回访");
assertThat(result.getRecords().get(0).getMaterialized()).isTrue();
verify(repository, never()).queryBusinessRecords(any(), any(), any(), any(), any(), any(), any(), any(), any(Long.class), any(Long.class));
}
@Test
void queryEventsFiltersStatusEntityAndTimeRangeFromBusinessView() {
Date from = new Date(10_000);
@@ -416,6 +456,30 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
return record;
}
private DigitalEmployeeBusinessFact businessFact(String planId, String title, String status, Date syncedAt) {
DigitalEmployeeBusinessFact fact = new DigitalEmployeeBusinessFact();
fact.setId(7001L);
fact.setTenantId(101L);
fact.setUserId(202L);
fact.setSyncRecordId(1001L);
fact.setClientKey("client-key-001");
fact.setDeviceId("device-001");
fact.setOutboxId("outbox-" + planId);
fact.setEntityType("plan");
fact.setEntityId(planId);
fact.setOperation("upsert");
fact.setTitle(title);
fact.setStatus(status);
fact.setSummary("计划摘要");
fact.setPlanId(planId);
fact.setBusinessView(toJson(Map.of("entity_type", "plan", "local_id", planId, "title", title, "status", status)));
fact.setPayload(toJson(Map.of("id", planId, "title", title, "status", status)));
fact.setSyncedAt(syncedAt);
fact.setCreated(syncedAt);
fact.setModified(syncedAt);
return fact;
}
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", "计划摘要"),

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.agent.core.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeArtifactLifecycle;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DigitalEmployeeArtifactLifecycleMapper extends BaseMapper<DigitalEmployeeArtifactLifecycle> {
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.agent.core.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeBusinessFact;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DigitalEmployeeBusinessFactMapper extends BaseMapper<DigitalEmployeeBusinessFact> {
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.agent.core.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeDailyReportFact;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DigitalEmployeeDailyReportFactMapper extends BaseMapper<DigitalEmployeeDailyReportFact> {
}

View File

@@ -0,0 +1,29 @@
package com.xspaceagi.agent.core.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeArtifactLifecycleRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeArtifactLifecycle;
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeArtifactLifecycleMapper;
import org.springframework.stereotype.Service;
@Service
public class DigitalEmployeeArtifactLifecycleRepositoryImpl
extends ServiceImpl<DigitalEmployeeArtifactLifecycleMapper, DigitalEmployeeArtifactLifecycle>
implements DigitalEmployeeArtifactLifecycleRepository {
@Override
public void upsertBySyncRecord(DigitalEmployeeArtifactLifecycle lifecycle) {
if (lifecycle == null || lifecycle.getTenantId() == null || lifecycle.getSyncRecordId() == null) return;
DigitalEmployeeArtifactLifecycle existing = getOne(new LambdaQueryWrapper<DigitalEmployeeArtifactLifecycle>()
.eq(DigitalEmployeeArtifactLifecycle::getTenantId, lifecycle.getTenantId())
.eq(DigitalEmployeeArtifactLifecycle::getSyncRecordId, lifecycle.getSyncRecordId()), false);
if (existing == null) {
save(lifecycle);
} else {
lifecycle.setId(existing.getId());
lifecycle.setCreated(existing.getCreated());
updateById(lifecycle);
}
}
}

View File

@@ -0,0 +1,61 @@
package com.xspaceagi.agent.core.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeBusinessFactRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeBusinessFact;
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeBusinessFactMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class DigitalEmployeeBusinessFactRepositoryImpl
extends ServiceImpl<DigitalEmployeeBusinessFactMapper, DigitalEmployeeBusinessFact>
implements DigitalEmployeeBusinessFactRepository {
@Override
public void upsertBySyncRecord(DigitalEmployeeBusinessFact fact) {
if (fact == null || fact.getTenantId() == null || fact.getSyncRecordId() == null) return;
DigitalEmployeeBusinessFact existing = getOne(new LambdaQueryWrapper<DigitalEmployeeBusinessFact>()
.eq(DigitalEmployeeBusinessFact::getTenantId, fact.getTenantId())
.eq(DigitalEmployeeBusinessFact::getSyncRecordId, fact.getSyncRecordId()), false);
if (existing == null) {
save(fact);
} else {
fact.setId(existing.getId());
fact.setCreated(existing.getCreated());
updateById(fact);
}
}
@Override
public IPage<DigitalEmployeeBusinessFact> queryFacts(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
) {
LambdaQueryWrapper<DigitalEmployeeBusinessFact> queryWrapper = new LambdaQueryWrapper<DigitalEmployeeBusinessFact>()
.eq(tenantId != null, DigitalEmployeeBusinessFact::getTenantId, tenantId)
.eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeBusinessFact::getClientKey, clientKey)
.eq(StringUtils.isNotBlank(deviceId), DigitalEmployeeBusinessFact::getDeviceId, deviceId)
.eq(StringUtils.isNotBlank(entityType), DigitalEmployeeBusinessFact::getEntityType, entityType)
.eq(StringUtils.isNotBlank(entityId), DigitalEmployeeBusinessFact::getEntityId, entityId)
.eq(StringUtils.isNotBlank(syncStatus), DigitalEmployeeBusinessFact::getSyncStatus, syncStatus)
.ge(updatedFrom != null, DigitalEmployeeBusinessFact::getSyncedAt, updatedFrom)
.le(updatedTo != null, DigitalEmployeeBusinessFact::getSyncedAt, updatedTo)
.orderByDesc(DigitalEmployeeBusinessFact::getSyncedAt)
.orderByDesc(DigitalEmployeeBusinessFact::getId);
return page(new Page<>(pageNo, pageSize), queryWrapper);
}
}

View File

@@ -0,0 +1,29 @@
package com.xspaceagi.agent.core.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeDailyReportFactRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeDailyReportFact;
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeDailyReportFactMapper;
import org.springframework.stereotype.Service;
@Service
public class DigitalEmployeeDailyReportFactRepositoryImpl
extends ServiceImpl<DigitalEmployeeDailyReportFactMapper, DigitalEmployeeDailyReportFact>
implements DigitalEmployeeDailyReportFactRepository {
@Override
public void upsertBySyncRecord(DigitalEmployeeDailyReportFact fact) {
if (fact == null || fact.getTenantId() == null || fact.getSyncRecordId() == null) return;
DigitalEmployeeDailyReportFact existing = getOne(new LambdaQueryWrapper<DigitalEmployeeDailyReportFact>()
.eq(DigitalEmployeeDailyReportFact::getTenantId, fact.getTenantId())
.eq(DigitalEmployeeDailyReportFact::getSyncRecordId, fact.getSyncRecordId()), false);
if (existing == null) {
save(fact);
} else {
fact.setId(existing.getId());
fact.setCreated(existing.getCreated());
updateById(fact);
}
}
}

View File

@@ -23,6 +23,91 @@ CREATE TABLE IF NOT EXISTS `digital_employee_admin_action` (
KEY `idx_de_admin_action_entity` (`_tenant_id`, `entity_type`, `entity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工管理端远程动作意图';
CREATE TABLE IF NOT EXISTS `digital_employee_business_fact` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`_tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`user_id` BIGINT DEFAULT NULL COMMENT '用户ID',
`sync_record_id` BIGINT NOT NULL COMMENT '同步记录ID',
`client_key` VARCHAR(255) DEFAULT NULL COMMENT '客户端Key',
`device_id` VARCHAR(255) DEFAULT NULL COMMENT '设备ID',
`outbox_id` VARCHAR(255) DEFAULT NULL COMMENT '客户端outbox ID',
`entity_type` VARCHAR(64) NOT NULL COMMENT '实体类型',
`entity_id` VARCHAR(255) NOT NULL COMMENT '实体ID',
`operation` VARCHAR(64) DEFAULT NULL COMMENT '操作',
`title` VARCHAR(512) DEFAULT NULL COMMENT '标题',
`status` VARCHAR(128) DEFAULT NULL COMMENT '业务状态',
`summary` VARCHAR(1024) DEFAULT NULL COMMENT '摘要',
`plan_id` VARCHAR(255) DEFAULT NULL COMMENT '计划ID',
`task_id` VARCHAR(255) DEFAULT NULL COMMENT '任务ID',
`run_id` VARCHAR(255) DEFAULT NULL COMMENT '运行ID',
`kind` VARCHAR(255) DEFAULT NULL COMMENT '类型',
`category` VARCHAR(128) DEFAULT NULL COMMENT '分类',
`occurred_at` VARCHAR(64) DEFAULT NULL COMMENT '发生时间',
`started_at` VARCHAR(64) DEFAULT NULL COMMENT '开始时间',
`finished_at` VARCHAR(64) DEFAULT NULL COMMENT '结束时间',
`duration_ms` BIGINT DEFAULT NULL COMMENT '耗时毫秒',
`sync_status` VARCHAR(128) DEFAULT NULL COMMENT '同步状态',
`business_view` JSON DEFAULT NULL COMMENT '业务视图快照',
`payload` JSON DEFAULT NULL COMMENT '安全payload快照',
`synced_at` DATETIME DEFAULT NULL COMMENT '同步时间',
`modified` DATETIME NOT NULL COMMENT '修改时间',
`created` DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_de_business_fact_record` (`_tenant_id`, `sync_record_id`),
KEY `idx_de_business_fact_entity` (`_tenant_id`, `entity_type`, `entity_id`),
KEY `idx_de_business_fact_plan` (`_tenant_id`, `plan_id`, `entity_type`),
KEY `idx_de_business_fact_client` (`_tenant_id`, `client_key`, `device_id`, `synced_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工业务事实物化表';
CREATE TABLE IF NOT EXISTS `digital_employee_artifact_lifecycle` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`_tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`sync_record_id` BIGINT NOT NULL COMMENT '同步记录ID',
`client_key` VARCHAR(255) DEFAULT NULL COMMENT '客户端Key',
`device_id` VARCHAR(255) DEFAULT NULL COMMENT '设备ID',
`artifact_id` VARCHAR(255) NOT NULL COMMENT '产物ID',
`plan_id` VARCHAR(255) DEFAULT NULL COMMENT '计划ID',
`task_id` VARCHAR(255) DEFAULT NULL COMMENT '任务ID',
`run_id` VARCHAR(255) DEFAULT NULL COMMENT '运行ID',
`status` VARCHAR(128) DEFAULT NULL COMMENT '状态',
`retention_status` VARCHAR(128) DEFAULT NULL COMMENT '保留状态',
`cleanup_status` VARCHAR(128) DEFAULT NULL COMMENT '清理状态',
`deleted_at` VARCHAR(64) DEFAULT NULL COMMENT '删除时间',
`version_key` VARCHAR(255) DEFAULT NULL COMMENT '版本Key',
`source` VARCHAR(255) DEFAULT NULL COMMENT '来源',
`business_view` JSON DEFAULT NULL COMMENT '业务视图快照',
`synced_at` DATETIME DEFAULT NULL COMMENT '同步时间',
`modified` DATETIME NOT NULL COMMENT '修改时间',
`created` DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_de_artifact_lifecycle_record` (`_tenant_id`, `sync_record_id`),
KEY `idx_de_artifact_lifecycle_artifact` (`_tenant_id`, `artifact_id`),
KEY `idx_de_artifact_lifecycle_plan` (`_tenant_id`, `plan_id`, `task_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工产物生命周期物化表';
CREATE TABLE IF NOT EXISTS `digital_employee_daily_report_fact` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`_tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`sync_record_id` BIGINT NOT NULL COMMENT '同步记录ID',
`client_key` VARCHAR(255) DEFAULT NULL COMMENT '客户端Key',
`device_id` VARCHAR(255) DEFAULT NULL COMMENT '设备ID',
`report_id` VARCHAR(255) NOT NULL COMMENT '日报ID',
`plan_id` VARCHAR(255) DEFAULT NULL COMMENT '计划ID',
`status` VARCHAR(128) DEFAULT NULL COMMENT '状态',
`delivery_status` VARCHAR(128) DEFAULT NULL COMMENT '交付状态',
`approval_status` VARCHAR(128) DEFAULT NULL COMMENT '审批状态',
`confirmed_at` VARCHAR(64) DEFAULT NULL COMMENT '确认时间',
`report_date` VARCHAR(64) DEFAULT NULL COMMENT '日报日期',
`business_view` JSON DEFAULT NULL COMMENT '业务视图快照',
`synced_at` DATETIME DEFAULT NULL COMMENT '同步时间',
`modified` DATETIME NOT NULL COMMENT '修改时间',
`created` DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_de_daily_report_fact_record` (`_tenant_id`, `sync_record_id`),
KEY `idx_de_daily_report_fact_report` (`_tenant_id`, `report_id`),
KEY `idx_de_daily_report_fact_client` (`_tenant_id`, `client_key`, `device_id`, `synced_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工日报事实物化表';
-- Add digital employee admin operations workbench menu and resource.
-- Idempotent for existing tenants: creates menu/resource rows, binds menu to resource,
-- and grants super_admin default access. Other roles can be granted this menu

View File

@@ -99,6 +99,7 @@ function renderSummary(record: DigitalEmployeeAdminWorkbenchRow) {
{title}
</Typography.Text>
{record.status && <Tag color={tone(record.status)}>{record.status}</Tag>}
{record.materialized && <Tag color="green">materialized</Tag>}
</Space>
<Typography.Text
type="secondary"

View File

@@ -595,6 +595,7 @@ export interface DigitalEmployeeAdminWorkbenchRow {
occurred_at?: string;
synced_at?: string;
latest_synced_at?: string;
materialized?: boolean;
client_key_masked?: string;
device_id?: string;
business_view?: Record<string, unknown>;

View File

@@ -1241,6 +1241,71 @@ function checkDigitalAdminPolicySnapshots() {
console.log("[DigitalEmployeeCheck] admin policy snapshot conflict view hooks OK");
}
function checkDigitalBackendMaterializedFacts() {
console.log("\n[DigitalEmployeeCheck] Verify backend materialized business fact hooks");
const backendRoot = path.resolve(packageRoot, "..", "..", "..", "qiming-backend");
const qimingRoot = path.resolve(packageRoot, "..", "..", "..", "qiming");
const servicePath = path.join(
backendRoot,
"app-platform-modules",
"app-platform-agent",
"app-platform-agent-core-application",
"src",
"main",
"java",
"com",
"xspaceagi",
"agent",
"core",
"application",
"service",
"DigitalEmployeeSyncApplicationServiceImpl.java",
);
const sqlPath = path.join(backendRoot, "sql", "update-20260612.sql");
const dtoPath = path.join(
backendRoot,
"app-platform-modules",
"app-platform-agent",
"app-platform-agent-core-adapter",
"src",
"main",
"java",
"com",
"xspaceagi",
"agent",
"core",
"adapter",
"dto",
"digital",
"DigitalEmployeeBusinessViewRowDto.java",
);
const qimingTypesPath = path.join(qimingRoot, "src", "types", "interfaces", "systemManage.ts");
const docsPath = path.resolve(packageRoot, "..", "..", "docs", "digital-employee-frontend-integration-plan.md");
const service = fs.readFileSync(servicePath, "utf8");
const sql = fs.readFileSync(sqlPath, "utf8");
const dto = fs.readFileSync(dtoPath, "utf8");
const qimingTypes = fs.readFileSync(qimingTypesPath, "utf8");
const docs = fs.readFileSync(docsPath, "utf8");
const requiredSnippets = [
[sql, "digital_employee_business_fact", "business fact table migration"],
[sql, "digital_employee_artifact_lifecycle", "artifact lifecycle table migration"],
[sql, "digital_employee_daily_report_fact", "daily report fact table migration"],
[service, "materializeSyncRecord", "sync materialization hook"],
[service, "upsertBySyncRecord", "materialized upsert call"],
[service, "queryMaterializedBusinessRows", "materialized query preference"],
[dto, "materialized", "business row materialized flag"],
[qimingTypes, "materialized", "qiming materialized type field"],
[docs, "业务事实物化查询", "docs materialized fact absorption"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing backend materialized fact hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] backend materialized business fact hooks OK");
}
function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -1301,6 +1366,7 @@ function main() {
checkDigitalPolicySchedulerWorkbenches();
checkDigitalAdminActionLoop();
checkDigitalAdminPolicySnapshots();
checkDigitalBackendMaterializedFacts();
checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) {

View File

@@ -609,7 +609,7 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
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 深链和脱敏客户端 KeyPlan 视图会聚合 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 接收、查询、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 深链和脱敏客户端 KeyPlan 视图会聚合 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 深链。业务事实物化查询已开始吸收:后端新增 `digital_employee_business_fact``digital_employee_artifact_lifecycle``digital_employee_daily_report_fact` 三张增量物化表,同步入库时 upsert 安全业务摘要,业务查询优先返回 `materialized: true` 行,旧记录继续回退 sync record。
- 缺口:复杂组合检索、跨设备聚合图、远程裁决/策略编辑动作和物化业务表仍待吸收。
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。