diff --git a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/application/DigitalEmployeeSyncApplicationService.java b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/application/DigitalEmployeeSyncApplicationService.java index 1da9d993..1a24c257 100644 --- a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/application/DigitalEmployeeSyncApplicationService.java +++ b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/application/DigitalEmployeeSyncApplicationService.java @@ -4,6 +4,9 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenc import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionPageDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordRequestDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordResultDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBillingEstimateDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewPageDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto; @@ -88,4 +91,15 @@ public interface DigitalEmployeeSyncApplicationService { String status, List reasonCodes ); + + DigitalEmployeeAdminRecordResultDto recordDailyReportSendResult(DigitalEmployeeAdminRecordRequestDto request); + + DigitalEmployeeBillingEstimateDto queryBillingEstimates( + String clientKey, + String deviceId, + Date updatedFrom, + Date updatedTo + ); + + DigitalEmployeeAdminRecordResultDto recordAuditArchive(DigitalEmployeeAdminRecordRequestDto request); } diff --git a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeAdminRecordRequestDto.java b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeAdminRecordRequestDto.java new file mode 100644 index 00000000..14d470f2 --- /dev/null +++ b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeAdminRecordRequestDto.java @@ -0,0 +1,33 @@ +package com.xspaceagi.agent.core.adapter.dto.digital; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.Map; + +@Data +public class DigitalEmployeeAdminRecordRequestDto { + @JsonAlias("sync_record_id") + private Long syncRecordId; + @JsonAlias("client_key") + private String clientKey; + @JsonAlias("device_id") + private String deviceId; + @JsonAlias("entity_type") + private String entityType; + @JsonAlias("entity_id") + private String entityId; + private String status; + private String endpoint; + @JsonAlias("remote_id") + private String remoteId; + @JsonAlias("archive_location") + private String archiveLocation; + @JsonAlias("archive_summary") + private Map archiveSummary; + private String reason; + @JsonProperty("request_payload") + @JsonAlias("requestPayload") + private Map requestPayload; +} diff --git a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeAdminRecordResultDto.java b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeAdminRecordResultDto.java new file mode 100644 index 00000000..f1c9f233 --- /dev/null +++ b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeAdminRecordResultDto.java @@ -0,0 +1,17 @@ +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 DigitalEmployeeAdminRecordResultDto { + private Boolean ok; + private String status; + @JsonProperty("reason_codes") + private List reasonCodes; + private DigitalEmployeeAdminActionDto record; +} diff --git a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeBillingEstimateDto.java b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeBillingEstimateDto.java new file mode 100644 index 00000000..bd24274e --- /dev/null +++ b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/src/main/java/com/xspaceagi/agent/core/adapter/dto/digital/DigitalEmployeeBillingEstimateDto.java @@ -0,0 +1,47 @@ +package com.xspaceagi.agent.core.adapter.dto.digital; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; +import java.util.Map; + +@Data +@Builder +public class DigitalEmployeeBillingEstimateDto { + @JsonProperty("total_tokens") + private Long totalTokens; + @JsonProperty("input_tokens") + private Long inputTokens; + @JsonProperty("output_tokens") + private Long outputTokens; + @JsonProperty("estimated_cost_usd") + private BigDecimal estimatedCostUsd; + @JsonProperty("request_count") + private Long requestCount; + private Boolean estimated; + @JsonProperty("by_model") + private Map byModel; + @JsonProperty("updated_at") + private Date updatedAt; + @JsonProperty("source_records") + private List sourceRecords; + + @Data + @Builder + public static class ModelEstimate { + @JsonProperty("total_tokens") + private Long totalTokens; + @JsonProperty("input_tokens") + private Long inputTokens; + @JsonProperty("output_tokens") + private Long outputTokens; + @JsonProperty("estimated_cost_usd") + private BigDecimal estimatedCostUsd; + @JsonProperty("request_count") + private Long requestCount; + } +} diff --git a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/src/main/java/com/xspaceagi/agent/core/application/service/DigitalEmployeeSyncApplicationServiceImpl.java b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/src/main/java/com/xspaceagi/agent/core/application/service/DigitalEmployeeSyncApplicationServiceImpl.java index d97690b1..f46f35e4 100644 --- a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/src/main/java/com/xspaceagi/agent/core/application/service/DigitalEmployeeSyncApplicationServiceImpl.java +++ b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/src/main/java/com/xspaceagi/agent/core/application/service/DigitalEmployeeSyncApplicationServiceImpl.java @@ -11,8 +11,11 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionDt import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionPageDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordRequestDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordResultDto; 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.DigitalEmployeeBillingEstimateDto; 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; @@ -40,6 +43,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; +import java.math.BigDecimal; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; @@ -724,6 +728,184 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye .build(); } + @Override + @DSTransactional + public DigitalEmployeeAdminRecordResultDto recordDailyReportSendResult(DigitalEmployeeAdminRecordRequestDto request) { + return recordAdminObservation(enrichAdminRecordRequestFromSyncRecord(request), "daily_report", "daily_report_send_recorded", "send_record_recorded"); + } + + @Override + public DigitalEmployeeBillingEstimateDto queryBillingEstimates(String clientKey, String deviceId, Date updatedFrom, Date updatedTo) { + Long tenantId = currentTenantId(); + if (tenantId == null) { + return DigitalEmployeeBillingEstimateDto.builder() + .totalTokens(0L) + .inputTokens(0L) + .outputTokens(0L) + .estimatedCostUsd(BigDecimal.ZERO) + .requestCount(0L) + .estimated(true) + .byModel(Map.of()) + .sourceRecords(List.of()) + .build(); + } + IPage page = digitalEmployeeSyncRecordRepository.queryBusinessRecords( + tenantId, + clientKey, + deviceId, + "event", + null, + updatedFrom, + updatedTo, + null, + DEFAULT_PAGE_NO, + MAX_PAGE_SIZE + ); + long inputTokens = 0L; + long outputTokens = 0L; + long totalTokens = 0L; + long requestCount = 0L; + boolean estimated = false; + BigDecimal cost = BigDecimal.ZERO; + Date updatedAt = null; + List sourceRecords = new ArrayList<>(); + Map byModel = new LinkedHashMap<>(); + for (DigitalEmployeeSyncRecord record : page.getRecords()) { + Map view = readJsonObject(record.getBusinessView()); + if (!StringUtils.equals(firstString(view.get("category")), "token_cost")) continue; + long input = numberValue(view.get("input_tokens")) == null ? 0L : numberValue(view.get("input_tokens")); + long output = numberValue(view.get("output_tokens")) == null ? 0L : numberValue(view.get("output_tokens")); + long total = numberValue(view.get("total_tokens")) == null ? input + output : numberValue(view.get("total_tokens")); + BigDecimal rowCost = decimalValue(view.get("estimated_cost_usd")); + inputTokens += input; + outputTokens += output; + totalTokens += total; + cost = cost.add(rowCost); + requestCount += 1; + estimated = estimated || Boolean.TRUE.equals(view.get("estimated")) || rowCost.compareTo(BigDecimal.ZERO) == 0; + sourceRecords.add(record.getId()); + if (record.getSyncedAt() != null && (updatedAt == null || record.getSyncedAt().after(updatedAt))) updatedAt = record.getSyncedAt(); + String model = firstString(view.get("model"), "unknown"); + byModel.computeIfAbsent(model, ignored -> new ModelAccumulator()).add(input, output, total, rowCost); + } + Map modelEstimates = new LinkedHashMap<>(); + byModel.forEach((model, accumulator) -> modelEstimates.put(model, accumulator.toDto())); + return DigitalEmployeeBillingEstimateDto.builder() + .totalTokens(totalTokens) + .inputTokens(inputTokens) + .outputTokens(outputTokens) + .estimatedCostUsd(cost) + .requestCount(requestCount) + .estimated(estimated || requestCount == 0) + .byModel(modelEstimates) + .updatedAt(updatedAt) + .sourceRecords(sourceRecords) + .build(); + } + + @Override + @DSTransactional + public DigitalEmployeeAdminRecordResultDto recordAuditArchive(DigitalEmployeeAdminRecordRequestDto request) { + return recordAdminObservation(enrichAdminRecordRequestFromSyncRecord(request), "audit", "audit_archive_recorded", "audit_archive_recorded"); + } + + private DigitalEmployeeAdminRecordResultDto recordAdminObservation( + DigitalEmployeeAdminRecordRequestDto request, + String defaultEntityType, + String action, + String reasonCode + ) { + if (request == null) return DigitalEmployeeAdminRecordResultDto.builder().ok(false).status("rejected").reasonCodes(List.of("request_required")).build(); + String clientKey = StringUtils.trimToNull(request.getClientKey()); + String entityType = firstString(normalizedText(request.getEntityType()), defaultEntityType); + String entityId = StringUtils.trimToNull(request.getEntityId()); + if (StringUtils.isBlank(clientKey) || StringUtils.isBlank(entityType) || StringUtils.isBlank(entityId)) { + return DigitalEmployeeAdminRecordResultDto.builder().ok(false).status("rejected").reasonCodes(List.of("required_field_missing")).build(); + } + Date now = new Date(); + Map payload = new LinkedHashMap<>(); + payload.put("status", firstString(request.getStatus(), "recorded")); + payload.put("endpoint", request.getEndpoint()); + payload.put("remote_id", request.getRemoteId()); + payload.put("archive_location", request.getArchiveLocation()); + payload.put("archive_summary", sanitizeAdminRecordValue(request.getArchiveSummary())); + payload.put("reason", request.getReason()); + payload.put("request_payload", sanitizeAdminRecordValue(request.getRequestPayload())); + DigitalEmployeeAdminAction record = new DigitalEmployeeAdminAction(); + record.setTenantId(currentTenantId()); + record.setUserId(currentUserId()); + record.setClientKey(clientKey); + record.setDeviceId(StringUtils.trimToNull(request.getDeviceId())); + record.setEntityType(entityType); + record.setEntityId(entityId); + record.setAction(action); + record.setStatus("recorded"); + record.setReasonCodes(toJson(List.of(reasonCode))); + record.setRequestPayload(toJson(payload)); + record.setProcessedAt(now); + record.setCreated(now); + record.setModified(now); + digitalEmployeeAdminActionRepository.save(record); + return DigitalEmployeeAdminRecordResultDto.builder() + .ok(true) + .status("recorded") + .reasonCodes(List.of(reasonCode)) + .record(toAdminActionDto(record)) + .build(); + } + + private DigitalEmployeeAdminRecordRequestDto enrichAdminRecordRequestFromSyncRecord(DigitalEmployeeAdminRecordRequestDto request) { + if (request == null || request.getSyncRecordId() == null || StringUtils.isNotBlank(request.getClientKey())) { + return request; + } + DigitalEmployeeSyncRecord record = digitalEmployeeSyncRecordRepository.getById(request.getSyncRecordId()); + if (record == null || currentTenantId() == null || !Objects.equals(record.getTenantId(), currentTenantId())) { + return request; + } + request.setClientKey(record.getClientKey()); + if (StringUtils.isBlank(request.getDeviceId())) request.setDeviceId(record.getDeviceId()); + if (StringUtils.isBlank(request.getEntityType())) request.setEntityType(record.getEntityType()); + if (StringUtils.isBlank(request.getEntityId())) request.setEntityId(record.getEntityId()); + return request; + } + + private BigDecimal decimalValue(Object value) { + if (value == null) return BigDecimal.ZERO; + if (value instanceof BigDecimal decimal) return decimal; + if (value instanceof Number number) return BigDecimal.valueOf(number.doubleValue()); + try { + return new BigDecimal(String.valueOf(value)); + } catch (Exception ignored) { + return BigDecimal.ZERO; + } + } + + private static class ModelAccumulator { + private long inputTokens; + private long outputTokens; + private long totalTokens; + private long requestCount; + private BigDecimal estimatedCostUsd = BigDecimal.ZERO; + + void add(long input, long output, long total, BigDecimal cost) { + inputTokens += input; + outputTokens += output; + totalTokens += total; + requestCount += 1; + estimatedCostUsd = estimatedCostUsd.add(cost); + } + + DigitalEmployeeBillingEstimateDto.ModelEstimate toDto() { + return DigitalEmployeeBillingEstimateDto.ModelEstimate.builder() + .inputTokens(inputTokens) + .outputTokens(outputTokens) + .totalTokens(totalTokens) + .requestCount(requestCount) + .estimatedCostUsd(estimatedCostUsd) + .build(); + } + } + private List validateAdminAction(DigitalEmployeeAdminActionRequestDto request) { if (request == null) return List.of("request_required"); String clientKey = StringUtils.trimToNull(request.getClientKey()); @@ -899,6 +1081,23 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye return Set.of("token", "authorization", "raw_input", "command", "prompt", "raw_prompt", "input").contains(normalized); } + private Object sanitizeAdminRecordValue(Object value) { + if (value instanceof Map map) { + Map sanitized = new LinkedHashMap<>(); + map.forEach((key, nestedValue) -> { + String textKey = String.valueOf(key); + if (!isSensitivePayloadKey(textKey)) { + sanitized.put(textKey, sanitizeAdminRecordValue(nestedValue)); + } + }); + return sanitized; + } + if (value instanceof List list) { + return list.stream().map(this::sanitizeAdminRecordValue).toList(); + } + return value; + } + private List rowsByType(List rows, String entityType) { return rows.stream().filter(row -> StringUtils.equals(row.getEntityType(), entityType)).toList(); } diff --git a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/src/test/java/com/xspaceagi/agent/core/application/service/DigitalEmployeeSyncApplicationServiceImplTest.java b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/src/test/java/com/xspaceagi/agent/core/application/service/DigitalEmployeeSyncApplicationServiceImplTest.java index 4cd6c7ad..1885f57a 100644 --- a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/src/test/java/com/xspaceagi/agent/core/application/service/DigitalEmployeeSyncApplicationServiceImplTest.java +++ b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/src/test/java/com/xspaceagi/agent/core/application/service/DigitalEmployeeSyncApplicationServiceImplTest.java @@ -11,6 +11,9 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionDt import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionPageDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordRequestDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordResultDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBillingEstimateDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto; import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotRowDto; @@ -416,6 +419,100 @@ class DigitalEmployeeSyncApplicationServiceImplTest { assertThat(row.getRecommendedResolution()).isEqualTo("review_latest_policy_snapshot"); } + @Test + void recordDailyReportSendResultStoresRecordedObservation() { + DigitalEmployeeAdminRecordRequestDto request = adminRecordRequest("daily_report", "report-1"); + request.setStatus("sent"); + request.setEndpoint("email"); + request.setRemoteId("mail-1"); + request.setReason("operator_recorded_delivery"); + + DigitalEmployeeAdminRecordResultDto result = service.recordDailyReportSendResult(request); + + assertThat(result.getOk()).isTrue(); + assertThat(result.getStatus()).isEqualTo("recorded"); + assertThat(result.getReasonCodes()).contains("send_record_recorded"); + ArgumentCaptor captor = ArgumentCaptor.forClass(DigitalEmployeeAdminAction.class); + verify(adminActionRepository).save(captor.capture()); + DigitalEmployeeAdminAction record = captor.getValue(); + assertThat(record.getEntityType()).isEqualTo("daily_report"); + assertThat(record.getEntityId()).isEqualTo("report-1"); + assertThat(record.getAction()).isEqualTo("daily_report_send_recorded"); + assertThat(record.getStatus()).isEqualTo("recorded"); + assertThat(record.getRequestPayload()).contains("mail-1").contains("operator_recorded_delivery"); + } + + @Test + void queryBillingEstimatesAggregatesTokenCostByModel() { + when(repository.queryBusinessRecords(eq(101L), eq("client-key-001"), eq("device-001"), eq("event"), eq(null), eq(null), eq(null), eq(null), eq(1L), eq(100L))) + .thenReturn(page(List.of( + record("outbox-cost-1", "event", "event-cost-1", 101L, new Date(10_000), + Map.of( + "entity_type", "event", + "category", "token_cost", + "model", "gpt-test", + "input_tokens", 100, + "output_tokens", 50, + "total_tokens", 150, + "estimated_cost_usd", "0.012", + "estimated", false + ), + Map.of("id", "event-cost-1")), + record("outbox-cost-2", "event", "event-cost-2", 101L, new Date(11_000), + Map.of( + "entity_type", "event", + "category", "token_cost", + "model", "gpt-test", + "input_tokens", 20, + "output_tokens", 10, + "total_tokens", 30, + "estimated_cost_usd", "0.003", + "estimated", true + ), + Map.of("id", "event-cost-2")), + eventRecord("event-audit", "plan-1", "task-1", "run-1", "tool_call_audit", "工具调用", "allowed", 101L, new Date(12_000)) + ))); + + DigitalEmployeeBillingEstimateDto result = service.queryBillingEstimates("client-key-001", "device-001", null, null); + + assertThat(result.getInputTokens()).isEqualTo(120L); + assertThat(result.getOutputTokens()).isEqualTo(60L); + assertThat(result.getTotalTokens()).isEqualTo(180L); + assertThat(result.getEstimatedCostUsd()).isEqualByComparingTo("0.015"); + assertThat(result.getRequestCount()).isEqualTo(2L); + assertThat(result.getEstimated()).isTrue(); + assertThat(result.getByModel()).containsKey("gpt-test"); + assertThat(result.getByModel().get("gpt-test").getTotalTokens()).isEqualTo(180L); + assertThat(result.getSourceRecords()).hasSize(2); + } + + @Test + void recordAuditArchiveRedactsSensitiveSummaryAndPayload() { + DigitalEmployeeAdminRecordRequestDto request = adminRecordRequest("audit", "audit-1"); + request.setArchiveLocation("s3://audit/safe-summary.json"); + request.setArchiveSummary(Map.of( + "status", "archived", + "token", "secret-token", + "nested", Map.of("authorization", "Bearer secret", "status", "ok") + )); + request.setRequestPayload(Map.of( + "raw_input", "full prompt", + "prompt", "sensitive prompt", + "safe_reason", "sandbox archived" + )); + + DigitalEmployeeAdminRecordResultDto result = service.recordAuditArchive(request); + + assertThat(result.getOk()).isTrue(); + ArgumentCaptor captor = ArgumentCaptor.forClass(DigitalEmployeeAdminAction.class); + verify(adminActionRepository).save(captor.capture()); + DigitalEmployeeAdminAction record = captor.getValue(); + assertThat(record.getAction()).isEqualTo("audit_archive_recorded"); + assertThat(record.getRequestPayload()).contains("s3://audit/safe-summary.json"); + assertThat(record.getRequestPayload()).contains("sandbox archived"); + assertThat(record.getRequestPayload()).doesNotContain("secret-token", "Bearer secret", "full prompt", "sensitive prompt"); + } + private IPage page(List records) { Page page = new Page<>(1, 20, records.size()); page.setRecords(records); @@ -456,6 +553,15 @@ class DigitalEmployeeSyncApplicationServiceImplTest { return record; } + private DigitalEmployeeAdminRecordRequestDto adminRecordRequest(String entityType, String entityId) { + DigitalEmployeeAdminRecordRequestDto request = new DigitalEmployeeAdminRecordRequestDto(); + request.setClientKey("client-key-001"); + request.setDeviceId("device-001"); + request.setEntityType(entityType); + request.setEntityId(entityId); + return request; + } + private DigitalEmployeeBusinessFact businessFact(String planId, String title, String status, Date syncedAt) { DigitalEmployeeBusinessFact fact = new DigitalEmployeeBusinessFact(); fact.setId(7001L); diff --git a/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-ui/src/main/java/com/xspaceagi/agent/web/ui/controller/DigitalEmployeeAdminRecordController.java b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-ui/src/main/java/com/xspaceagi/agent/web/ui/controller/DigitalEmployeeAdminRecordController.java new file mode 100644 index 00000000..13ceafa4 --- /dev/null +++ b/qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-ui/src/main/java/com/xspaceagi/agent/web/ui/controller/DigitalEmployeeAdminRecordController.java @@ -0,0 +1,78 @@ +package com.xspaceagi.agent.web.ui.controller; + +import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordRequestDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordResultDto; +import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBillingEstimateDto; +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.RequestBody; +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") +public class DigitalEmployeeAdminRecordController { + + @Resource + private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService; + + @RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST) + @RequestMapping(path = "/daily-reports/send-records", method = RequestMethod.POST) + public ReqResult recordDailyReportSendResult( + @RequestBody(required = false) DigitalEmployeeAdminRecordRequestDto request + ) { + return ReqResult.success(digitalEmployeeSyncApplicationService.recordDailyReportSendResult(request)); + } + + @RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST) + @RequestMapping(path = "/billing/estimates", method = RequestMethod.GET) + public ReqResult queryBillingEstimates( + @RequestParam(name = "client_key", required = false) String clientKey, + @RequestParam(name = "device_id", required = false) String deviceId, + @RequestParam(name = "updated_from", required = false) String updatedFrom, + @RequestParam(name = "updated_to", required = false) String updatedTo + ) { + return ReqResult.success(digitalEmployeeSyncApplicationService.queryBillingEstimates( + clientKey, + deviceId, + parseDate(updatedFrom), + parseDate(updatedTo) + )); + } + + @RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST) + @RequestMapping(path = "/audit-archives", method = RequestMethod.POST) + public ReqResult recordAuditArchive( + @RequestBody(required = false) DigitalEmployeeAdminRecordRequestDto request + ) { + return ReqResult.success(digitalEmployeeSyncApplicationService.recordAuditArchive(request)); + } + + 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; + } + } + } +} diff --git a/qiming/src/pages/SystemManagement/Content/DigitalEmployeeOps/index.tsx b/qiming/src/pages/SystemManagement/Content/DigitalEmployeeOps/index.tsx index ee68d413..5801680d 100644 --- a/qiming/src/pages/SystemManagement/Content/DigitalEmployeeOps/index.tsx +++ b/qiming/src/pages/SystemManagement/Content/DigitalEmployeeOps/index.tsx @@ -3,20 +3,33 @@ import WorkspaceLayout from '@/components/WorkspaceLayout'; import { SUCCESS_CODE } from '@/constants/codes.constants'; import { apiDigitalEmployeeAdminActionSubmit, + apiDigitalEmployeeAdminAuditArchive, + apiDigitalEmployeeAdminBillingEstimates, + apiDigitalEmployeeAdminDailyReportSendRecord, apiDigitalEmployeeAdminPolicySnapshots, apiDigitalEmployeeAdminWorkbenchList, } from '@/services/systemManage'; import type { DigitalEmployeeAdminWorkbenchRow, DigitalEmployeeAdminWorkbenchView, + DigitalEmployeeBillingEstimate, } from '@/types/interfaces/systemManage'; import type { ActionType, FormInstance, ProColumns, } from '@ant-design/pro-components'; -import { Button, Drawer, message, Space, Tabs, Tag, Typography } from 'antd'; -import { useMemo, useRef, useState } from 'react'; +import { + Button, + Drawer, + message, + Space, + Statistic, + Tabs, + Tag, + Typography, +} from 'antd'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { history, useModel } from 'umi'; const WORKBENCH_TABS: Array<{ @@ -200,6 +213,9 @@ const DigitalEmployeeOps: React.FC = () => { useState('business'); const [detailRecord, setDetailRecord] = useState(null); + const [billingEstimate, setBillingEstimate] = + useState(null); + const [billingLoading, setBillingLoading] = useState(false); const businessViewJson = useMemo( () => jsonText(detailRecord?.business_view), [detailRecord], @@ -210,6 +226,26 @@ const DigitalEmployeeOps: React.FC = () => { ); const [submittingAction, setSubmittingAction] = useState(null); + const loadBillingEstimate = async () => { + setBillingLoading(true); + try { + const response = await apiDigitalEmployeeAdminBillingEstimates({}); + if (response.code === SUCCESS_CODE) { + setBillingEstimate(response.data || null); + } else { + message.error(response.message || '查询成本估算失败'); + } + } finally { + setBillingLoading(false); + } + }; + + useEffect(() => { + if (activeView === 'audits') { + loadBillingEstimate(); + } + }, [activeView]); + const submitAdminAction = async ( record: DigitalEmployeeAdminWorkbenchRow, action: string, @@ -243,6 +279,74 @@ const DigitalEmployeeOps: React.FC = () => { } }; + const submitDailyReportSendRecord = async ( + record: DigitalEmployeeAdminWorkbenchRow, + ) => { + setSubmittingAction(`${record.sync_record_id}:send-record`); + try { + const response = await apiDigitalEmployeeAdminDailyReportSendRecord({ + syncRecordId: record.sync_record_id, + deviceId: record.device_id, + entityType: 'daily_report', + entityId: record.report_id || record.entity_id, + status: 'sent', + endpoint: 'admin_record', + reason: 'operator_recorded_delivery', + requestPayload: { + source_view: activeView, + sync_record_id: record.sync_record_id, + }, + }); + if (response.code === SUCCESS_CODE && response.data?.ok !== false) { + message.success('发送结果已记录'); + } else { + message.error( + response.message || + response.data?.reason_codes?.join(', ') || + '记录发送结果失败', + ); + } + } finally { + setSubmittingAction(null); + } + }; + + const submitAuditArchive = async (record: DigitalEmployeeAdminWorkbenchRow) => { + setSubmittingAction(`${record.sync_record_id}:audit-archive`); + try { + const response = await apiDigitalEmployeeAdminAuditArchive({ + syncRecordId: record.sync_record_id, + deviceId: record.device_id, + entityType: 'audit', + entityId: record.entity_id, + status: 'archived', + archiveLocation: 'admin://audit-archive', + archiveSummary: { + category: record.category, + level: record.level, + status: record.status, + summary: record.summary, + }, + reason: 'operator_marked_archive', + requestPayload: { + source_view: activeView, + sync_record_id: record.sync_record_id, + }, + }); + if (response.code === SUCCESS_CODE && response.data?.ok !== false) { + message.success('审计归档已记录'); + } else { + message.error( + response.message || + response.data?.reason_codes?.join(', ') || + '标记归档失败', + ); + } + } finally { + setSubmittingAction(null); + } + }; + const columns: ProColumns[] = useMemo( () => [ { @@ -482,6 +586,39 @@ const DigitalEmployeeOps: React.FC = () => { actionRef.current?.reload(); }} /> + {activeView === 'audits' && ( +
+ + + + + + + {billingEstimate?.estimated && ( + + 成本为估算来源,不代表真实扣费账单。 + + )} +
+ )} key={activeView} actionRef={actionRef} @@ -567,6 +704,46 @@ const DigitalEmployeeOps: React.FC = () => { )} + {activeView === 'daily_reports' && detailRecord.report_id && ( +
+ 日报发送 + + + 仅记录发送请求或结果,不触发第三方真实发送。 + +
+ )} + {activeView === 'audits' && ( +
+ 审计归档 + + + 只保存脱敏摘要和归档位置,不保存 prompt、raw command、authorization 或 token。 + +
+ )} Business View
> {
+  return request('/api/digital-employee/admin/daily-reports/send-records', {
+    method: 'POST',
+    data: {
+      sync_record_id: data.syncRecordId,
+      client_key: data.clientKey,
+      device_id: data.deviceId,
+      entity_type: data.entityType,
+      entity_id: data.entityId,
+      status: data.status,
+      endpoint: data.endpoint,
+      remote_id: data.remoteId,
+      reason: data.reason,
+      request_payload: data.requestPayload,
+    },
+  });
+}
+
+// 查询数字员工账单估算
+export async function apiDigitalEmployeeAdminBillingEstimates(
+  data: DigitalEmployeeBillingEstimateParams,
+): Promise> {
+  return request('/api/digital-employee/admin/billing/estimates', {
+    method: 'GET',
+    params: {
+      client_key: data.clientKey,
+      device_id: data.deviceId,
+      updated_from: data.updatedFrom,
+      updated_to: data.updatedTo,
+    },
+  });
+}
+
+// 记录数字员工审计归档摘要
+export async function apiDigitalEmployeeAdminAuditArchive(
+  data: DigitalEmployeeAdminRecordSubmitParams,
+): Promise> {
+  return request('/api/digital-employee/admin/audit-archives', {
+    method: 'POST',
+    data: {
+      sync_record_id: data.syncRecordId,
+      client_key: data.clientKey,
+      device_id: data.deviceId,
+      entity_type: data.entityType,
+      entity_id: data.entityId,
+      status: data.status,
+      archive_location: data.archiveLocation,
+      archive_summary: data.archiveSummary,
+      reason: data.reason,
+      request_payload: data.requestPayload,
+    },
+  });
+}
+
 // 查询网页应用列表
 export async function apiSystemResourceWebappList(
   data: SystemWebappListParams,
diff --git a/qiming/src/types/interfaces/systemManage.ts b/qiming/src/types/interfaces/systemManage.ts
index e59020ad..00626db6 100644
--- a/qiming/src/types/interfaces/systemManage.ts
+++ b/qiming/src/types/interfaces/systemManage.ts
@@ -681,6 +681,55 @@ export interface DigitalEmployeeAdminActionResult {
   };
 }
 
+export interface DigitalEmployeeAdminRecordSubmitParams {
+  syncRecordId?: number;
+  clientKey?: string;
+  deviceId?: string;
+  entityType?: string;
+  entityId?: string;
+  status?: string;
+  endpoint?: string;
+  remoteId?: string;
+  archiveLocation?: string;
+  archiveSummary?: Record;
+  reason?: string;
+  requestPayload?: Record;
+}
+
+export interface DigitalEmployeeAdminRecordResult {
+  ok: boolean;
+  status?: string;
+  reason_codes?: string[];
+  record?: DigitalEmployeeAdminActionResult['action'];
+}
+
+export interface DigitalEmployeeBillingEstimateParams {
+  clientKey?: string;
+  deviceId?: string;
+  updatedFrom?: string;
+  updatedTo?: string;
+}
+
+export interface DigitalEmployeeBillingModelEstimate {
+  total_tokens?: number;
+  input_tokens?: number;
+  output_tokens?: number;
+  estimated_cost_usd?: number | string;
+  request_count?: number;
+}
+
+export interface DigitalEmployeeBillingEstimate {
+  total_tokens?: number;
+  input_tokens?: number;
+  output_tokens?: number;
+  estimated_cost_usd?: number | string;
+  request_count?: number;
+  estimated?: boolean;
+  by_model?: Record;
+  updated_at?: string;
+  source_records?: number[];
+}
+
 // 网页应用信息
 export type SystemWebappInfo = SystemResourceInfo;
 
diff --git a/qimingclaw/crates/agent-electron-client/scripts/check-digital-employee.js b/qimingclaw/crates/agent-electron-client/scripts/check-digital-employee.js
index 84a3b028..6641eef3 100644
--- a/qimingclaw/crates/agent-electron-client/scripts/check-digital-employee.js
+++ b/qimingclaw/crates/agent-electron-client/scripts/check-digital-employee.js
@@ -1306,6 +1306,82 @@ function checkDigitalBackendMaterializedFacts() {
   console.log("[DigitalEmployeeCheck] backend materialized business fact hooks OK");
 }
 
+function checkDigitalAdminRecordBillingArchiveShell() {
+  console.log("\n[DigitalEmployeeCheck] Verify admin send/billing/audit archive shell hooks");
+  const backendRoot = path.resolve(packageRoot, "..", "..", "..", "qiming-backend");
+  const qimingRoot = path.resolve(packageRoot, "..", "..", "..", "qiming");
+  const docsPath = path.resolve(packageRoot, "..", "..", "docs", "digital-employee-frontend-integration-plan.md");
+  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 controllerPath = path.join(
+    backendRoot,
+    "app-platform-modules",
+    "app-platform-agent",
+    "app-platform-agent-core-ui",
+    "src",
+    "main",
+    "java",
+    "com",
+    "xspaceagi",
+    "agent",
+    "web",
+    "ui",
+    "controller",
+    "DigitalEmployeeAdminRecordController.java",
+  );
+  const qimingServicePath = path.join(qimingRoot, "src", "services", "systemManage.ts");
+  const qimingTypesPath = path.join(qimingRoot, "src", "types", "interfaces", "systemManage.ts");
+  const qimingOpsPath = path.join(qimingRoot, "src", "pages", "SystemManagement", "Content", "DigitalEmployeeOps", "index.tsx");
+  const service = fs.readFileSync(servicePath, "utf8");
+  const controller = fs.readFileSync(controllerPath, "utf8");
+  const qimingService = fs.readFileSync(qimingServicePath, "utf8");
+  const qimingTypes = fs.readFileSync(qimingTypesPath, "utf8");
+  const qimingOps = fs.readFileSync(qimingOpsPath, "utf8");
+  const docs = fs.readFileSync(docsPath, "utf8");
+  const requiredSnippets = [
+    [controller, "/daily-reports/send-records", "backend daily report send record endpoint"],
+    [controller, "/billing/estimates", "backend billing estimate endpoint"],
+    [controller, "/audit-archives", "backend audit archive endpoint"],
+    [service, "recordDailyReportSendResult", "backend daily report send record service"],
+    [service, "queryBillingEstimates", "backend billing estimate service"],
+    [service, "recordAuditArchive", "backend audit archive service"],
+    [service, "sanitizeAdminRecordValue", "backend admin record redaction"],
+    [service, "authorization", "backend sensitive authorization guard"],
+    [service, "raw_input", "backend sensitive raw input guard"],
+    [qimingService, "apiDigitalEmployeeAdminDailyReportSendRecord", "qiming daily report send record helper"],
+    [qimingService, "apiDigitalEmployeeAdminBillingEstimates", "qiming billing estimate helper"],
+    [qimingService, "apiDigitalEmployeeAdminAuditArchive", "qiming audit archive helper"],
+    [qimingTypes, "DigitalEmployeeBillingEstimate", "qiming billing estimate type"],
+    [qimingTypes, "DigitalEmployeeAdminRecordSubmitParams", "qiming admin record submit type"],
+    [qimingOps, "记录发送结果", "qiming daily report send UI"],
+    [qimingOps, "标记归档", "qiming audit archive UI"],
+    [qimingOps, "估算 Token", "qiming billing token UI"],
+    [qimingOps, "估算成本 USD", "qiming billing cost UI"],
+    [docs, "发送账单审计外壳", "docs send billing audit shell absorption"],
+  ];
+  const missing = requiredSnippets
+    .filter(([content, snippet]) => !content.includes(snippet))
+    .map(([, , label]) => label);
+  if (missing.length > 0) {
+    throw new Error(`Missing admin send/billing/audit archive shell hooks: ${missing.join(", ")}`);
+  }
+  console.log("[DigitalEmployeeCheck] admin send/billing/audit archive shell hooks OK");
+}
+
 function checkLocalDatabaseSchema() {
   console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
   const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -1367,6 +1443,7 @@ function main() {
     checkDigitalAdminActionLoop();
     checkDigitalAdminPolicySnapshots();
     checkDigitalBackendMaterializedFacts();
+    checkDigitalAdminRecordBillingArchiveShell();
     checkLocalDatabaseSchema();
     console.log("\n[DigitalEmployeeCheck] All checks passed");
   } catch (error) {
diff --git a/qimingclaw/docs/digital-employee-frontend-integration-plan.md b/qimingclaw/docs/digital-employee-frontend-integration-plan.md
index e04b5dcf..c13b4428 100644
--- a/qimingclaw/docs/digital-employee-frontend-integration-plan.md
+++ b/qimingclaw/docs/digital-employee-frontend-integration-plan.md
@@ -604,8 +604,8 @@ GET /api/digital-employee/approvals
     - 建议:下一轮围绕管理端正式路由 UI 和跨设备冲突可视化,把 embedded 批量干预入口推进到管理端运营台。
 
 11. **诊断 / 成本 / 审计视图**
-    - 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在;embedded adapter 已接管 `/api/doctor`、`/api/cost`、`/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成诊断、估算成本和审计投影;sandbox audit、token/cost 和 CLI/tool 调用事件已开始通过 `digital_events` 与 outbox `business_view` 提炼为 `sandbox_audit`、`token_cost`、`tool_call_audit` 分类,并只同步 session、tool、server、operation、status、exit code、duration、model、token 计数和估算成本等安全字段;数字员工首页已展示诊断/审计/Token/估算成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。管理端正式审计视图预览已开始吸收,embedded “技能审计”会消费 sandbox/tool/cost 风险来源并保留安全 payload preview。诊断修复动作预览已开始吸收,“处置台”可把 doctor/audit/service/policy 风险转为可筛选、可标记和可安全重试的运营事项。
-    - 缺口:真实计费账单、独立 sandbox audit 文件归档、自动诊断修复动作和正式外部管理端审计视图尚未统一吸收。
+    - 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在;embedded adapter 已接管 `/api/doctor`、`/api/cost`、`/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成诊断、估算成本和审计投影;sandbox audit、token/cost 和 CLI/tool 调用事件已开始通过 `digital_events` 与 outbox `business_view` 提炼为 `sandbox_audit`、`token_cost`、`tool_call_audit` 分类,并只同步 session、tool、server、operation、status、exit code、duration、model、token 计数和估算成本等安全字段;数字员工首页已展示诊断/审计/Token/估算成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。管理端正式审计视图预览已开始吸收,embedded “技能审计”会消费 sandbox/tool/cost 风险来源并保留安全 payload preview。诊断修复动作预览已开始吸收,“处置台”可把 doctor/audit/service/policy 风险转为可筛选、可标记和可安全重试的运营事项。发送账单审计外壳已开始吸收,后端提供估算账单查询和审计归档记录接口,qiming 运营台可显示 Token/估算成本并标记脱敏归档;归档记录会剔除 token、authorization、raw_input、prompt 和 command 等敏感字段。
+    - 缺口:真实计费账单、独立 sandbox audit 文件归档、自动诊断修复动作和正式外部管理端审计视图尚未统一吸收;当前账单仍是估算,审计归档只记录脱敏摘要和归档位置。
     - 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
 
 12. **管理端业务查询模型**
@@ -614,7 +614,7 @@ GET /api/digital-employee/approvals
     - 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。
 
 13. **日报实体与导出交付**
-    - 已吸收:embedded 页面可基于当前 workday projection 导出本地文本日报;生成日报时会通过 qimingclaw bridge 写入 `daily_report` text / HTML / PDF Artifact 与 `daily_report_generated` Event,并可通过 `/api/digital-employee/daily-reports` 和详情接口读取只读日报投影。日报 HTML/PDF 导出、发送记录、管理端确认回执和日报发送审批已开始吸收,交付动作会写入 `daily_report_send_recorded` / `daily_report_confirmed` / `daily_report_approval_requested` 事件,sync `business_view.category = daily_report` 可被管理端筛选消费。管理端正式日报页面预览已开始吸收,embedded 新增“日报运营”入口,展示日报生成、交付、确认、审批和关联产物状态,并复用现有生成、记录发送、确认回执和请求审批动作。
+    - 已吸收:embedded 页面可基于当前 workday projection 导出本地文本日报;生成日报时会通过 qimingclaw bridge 写入 `daily_report` text / HTML / PDF Artifact 与 `daily_report_generated` Event,并可通过 `/api/digital-employee/daily-reports` 和详情接口读取只读日报投影。日报 HTML/PDF 导出、发送记录、管理端确认回执和日报发送审批已开始吸收,交付动作会写入 `daily_report_send_recorded` / `daily_report_confirmed` / `daily_report_approval_requested` 事件,sync `business_view.category = daily_report` 可被管理端筛选消费。管理端正式日报页面预览已开始吸收,embedded 新增“日报运营”入口,展示日报生成、交付、确认、审批和关联产物状态,并复用现有生成、记录发送、确认回执和请求审批动作。发送账单审计外壳已开始吸收,qiming-backend 新增 `/api/digital-employee/admin/daily-reports/send-records`,qiming 运营台可在日报详情中“记录发送结果”,但不接真实第三方发送通道。
     - 缺口:没有正式 DailyReport 物理表、真实管理端发送通道、正式外部管理端日报页面和审批裁决回写闭环。
     - 建议:先稳定 Artifact/Event 派生日报交付模型,再根据管理端联调需要决定是否物化 DailyReport 表与发送记录表。