吸收数字员工发送账单审计外壳
This commit is contained in:
@@ -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.DigitalEmployeeAdminActionPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.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.DigitalEmployeeBusinessViewPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
|
||||||
@@ -88,4 +91,15 @@ public interface DigitalEmployeeSyncApplicationService {
|
|||||||
String status,
|
String status,
|
||||||
List<String> reasonCodes
|
List<String> reasonCodes
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DigitalEmployeeAdminRecordResultDto recordDailyReportSendResult(DigitalEmployeeAdminRecordRequestDto request);
|
||||||
|
|
||||||
|
DigitalEmployeeBillingEstimateDto queryBillingEstimates(
|
||||||
|
String clientKey,
|
||||||
|
String deviceId,
|
||||||
|
Date updatedFrom,
|
||||||
|
Date updatedTo
|
||||||
|
);
|
||||||
|
|
||||||
|
DigitalEmployeeAdminRecordResultDto recordAuditArchive(DigitalEmployeeAdminRecordRequestDto request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, Object> archiveSummary;
|
||||||
|
private String reason;
|
||||||
|
@JsonProperty("request_payload")
|
||||||
|
@JsonAlias("requestPayload")
|
||||||
|
private Map<String, Object> requestPayload;
|
||||||
|
}
|
||||||
@@ -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<String> reasonCodes;
|
||||||
|
private DigitalEmployeeAdminActionDto record;
|
||||||
|
}
|
||||||
@@ -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<String, ModelEstimate> byModel;
|
||||||
|
@JsonProperty("updated_at")
|
||||||
|
private Date updatedAt;
|
||||||
|
@JsonProperty("source_records")
|
||||||
|
private List<Long> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.DigitalEmployeeAdminActionPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.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.DigitalEmployeeAdminWorkbenchPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenchRowDto;
|
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.DigitalEmployeeBusinessViewPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewRowDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewRowDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
|
||||||
@@ -40,6 +43,7 @@ import org.apache.commons.collections4.CollectionUtils;
|
|||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
@@ -724,6 +728,184 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
|
|||||||
.build();
|
.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<DigitalEmployeeSyncRecord> 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<Long> sourceRecords = new ArrayList<>();
|
||||||
|
Map<String, ModelAccumulator> byModel = new LinkedHashMap<>();
|
||||||
|
for (DigitalEmployeeSyncRecord record : page.getRecords()) {
|
||||||
|
Map<String, Object> 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<String, DigitalEmployeeBillingEstimateDto.ModelEstimate> 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<String, Object> 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<String> validateAdminAction(DigitalEmployeeAdminActionRequestDto request) {
|
private List<String> validateAdminAction(DigitalEmployeeAdminActionRequestDto request) {
|
||||||
if (request == null) return List.of("request_required");
|
if (request == null) return List.of("request_required");
|
||||||
String clientKey = StringUtils.trimToNull(request.getClientKey());
|
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);
|
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<String, Object> 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<DigitalEmployeeBusinessViewRowDto> rowsByType(List<DigitalEmployeeBusinessViewRowDto> rows, String entityType) {
|
private List<DigitalEmployeeBusinessViewRowDto> rowsByType(List<DigitalEmployeeBusinessViewRowDto> rows, String entityType) {
|
||||||
return rows.stream().filter(row -> StringUtils.equals(row.getEntityType(), entityType)).toList();
|
return rows.stream().filter(row -> StringUtils.equals(row.getEntityType(), entityType)).toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.DigitalEmployeeAdminActionPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.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.DigitalEmployeePlanBusinessViewDetailDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotRowDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotRowDto;
|
||||||
@@ -416,6 +419,100 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
|
|||||||
assertThat(row.getRecommendedResolution()).isEqualTo("review_latest_policy_snapshot");
|
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<DigitalEmployeeAdminAction> 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.<String, Object>of(
|
||||||
|
"status", "archived",
|
||||||
|
"token", "secret-token",
|
||||||
|
"nested", Map.<String, Object>of("authorization", "Bearer secret", "status", "ok")
|
||||||
|
));
|
||||||
|
request.setRequestPayload(Map.<String, Object>of(
|
||||||
|
"raw_input", "full prompt",
|
||||||
|
"prompt", "sensitive prompt",
|
||||||
|
"safe_reason", "sandbox archived"
|
||||||
|
));
|
||||||
|
|
||||||
|
DigitalEmployeeAdminRecordResultDto result = service.recordAuditArchive(request);
|
||||||
|
|
||||||
|
assertThat(result.getOk()).isTrue();
|
||||||
|
ArgumentCaptor<DigitalEmployeeAdminAction> 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<DigitalEmployeeSyncRecord> page(List<DigitalEmployeeSyncRecord> records) {
|
private IPage<DigitalEmployeeSyncRecord> page(List<DigitalEmployeeSyncRecord> records) {
|
||||||
Page<DigitalEmployeeSyncRecord> page = new Page<>(1, 20, records.size());
|
Page<DigitalEmployeeSyncRecord> page = new Page<>(1, 20, records.size());
|
||||||
page.setRecords(records);
|
page.setRecords(records);
|
||||||
@@ -456,6 +553,15 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
|
|||||||
return record;
|
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) {
|
private DigitalEmployeeBusinessFact businessFact(String planId, String title, String status, Date syncedAt) {
|
||||||
DigitalEmployeeBusinessFact fact = new DigitalEmployeeBusinessFact();
|
DigitalEmployeeBusinessFact fact = new DigitalEmployeeBusinessFact();
|
||||||
fact.setId(7001L);
|
fact.setId(7001L);
|
||||||
|
|||||||
@@ -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<DigitalEmployeeAdminRecordResultDto> 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<DigitalEmployeeBillingEstimateDto> 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<DigitalEmployeeAdminRecordResultDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,33 @@ import WorkspaceLayout from '@/components/WorkspaceLayout';
|
|||||||
import { SUCCESS_CODE } from '@/constants/codes.constants';
|
import { SUCCESS_CODE } from '@/constants/codes.constants';
|
||||||
import {
|
import {
|
||||||
apiDigitalEmployeeAdminActionSubmit,
|
apiDigitalEmployeeAdminActionSubmit,
|
||||||
|
apiDigitalEmployeeAdminAuditArchive,
|
||||||
|
apiDigitalEmployeeAdminBillingEstimates,
|
||||||
|
apiDigitalEmployeeAdminDailyReportSendRecord,
|
||||||
apiDigitalEmployeeAdminPolicySnapshots,
|
apiDigitalEmployeeAdminPolicySnapshots,
|
||||||
apiDigitalEmployeeAdminWorkbenchList,
|
apiDigitalEmployeeAdminWorkbenchList,
|
||||||
} from '@/services/systemManage';
|
} from '@/services/systemManage';
|
||||||
import type {
|
import type {
|
||||||
DigitalEmployeeAdminWorkbenchRow,
|
DigitalEmployeeAdminWorkbenchRow,
|
||||||
DigitalEmployeeAdminWorkbenchView,
|
DigitalEmployeeAdminWorkbenchView,
|
||||||
|
DigitalEmployeeBillingEstimate,
|
||||||
} from '@/types/interfaces/systemManage';
|
} from '@/types/interfaces/systemManage';
|
||||||
import type {
|
import type {
|
||||||
ActionType,
|
ActionType,
|
||||||
FormInstance,
|
FormInstance,
|
||||||
ProColumns,
|
ProColumns,
|
||||||
} from '@ant-design/pro-components';
|
} from '@ant-design/pro-components';
|
||||||
import { Button, Drawer, message, Space, Tabs, Tag, Typography } from 'antd';
|
import {
|
||||||
import { useMemo, useRef, useState } from 'react';
|
Button,
|
||||||
|
Drawer,
|
||||||
|
message,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { history, useModel } from 'umi';
|
import { history, useModel } from 'umi';
|
||||||
|
|
||||||
const WORKBENCH_TABS: Array<{
|
const WORKBENCH_TABS: Array<{
|
||||||
@@ -200,6 +213,9 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
useState<DigitalEmployeeAdminWorkbenchView>('business');
|
useState<DigitalEmployeeAdminWorkbenchView>('business');
|
||||||
const [detailRecord, setDetailRecord] =
|
const [detailRecord, setDetailRecord] =
|
||||||
useState<DigitalEmployeeAdminWorkbenchRow | null>(null);
|
useState<DigitalEmployeeAdminWorkbenchRow | null>(null);
|
||||||
|
const [billingEstimate, setBillingEstimate] =
|
||||||
|
useState<DigitalEmployeeBillingEstimate | null>(null);
|
||||||
|
const [billingLoading, setBillingLoading] = useState(false);
|
||||||
const businessViewJson = useMemo(
|
const businessViewJson = useMemo(
|
||||||
() => jsonText(detailRecord?.business_view),
|
() => jsonText(detailRecord?.business_view),
|
||||||
[detailRecord],
|
[detailRecord],
|
||||||
@@ -210,6 +226,26 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const [submittingAction, setSubmittingAction] = useState<string | null>(null);
|
const [submittingAction, setSubmittingAction] = useState<string | null>(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 (
|
const submitAdminAction = async (
|
||||||
record: DigitalEmployeeAdminWorkbenchRow,
|
record: DigitalEmployeeAdminWorkbenchRow,
|
||||||
action: string,
|
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<DigitalEmployeeAdminWorkbenchRow>[] = useMemo(
|
const columns: ProColumns<DigitalEmployeeAdminWorkbenchRow>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -482,6 +586,39 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
actionRef.current?.reload();
|
actionRef.current?.reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{activeView === 'audits' && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Space size={16} wrap>
|
||||||
|
<Statistic
|
||||||
|
title="估算 Token"
|
||||||
|
value={billingEstimate?.total_tokens || 0}
|
||||||
|
loading={billingLoading}
|
||||||
|
/>
|
||||||
|
<Statistic
|
||||||
|
title="估算成本 USD"
|
||||||
|
value={String(billingEstimate?.estimated_cost_usd ?? 0)}
|
||||||
|
loading={billingLoading}
|
||||||
|
/>
|
||||||
|
<Statistic
|
||||||
|
title="估算来源"
|
||||||
|
value={billingEstimate?.request_count || 0}
|
||||||
|
suffix="条"
|
||||||
|
loading={billingLoading}
|
||||||
|
/>
|
||||||
|
<Button loading={billingLoading} onClick={loadBillingEstimate}>
|
||||||
|
刷新成本估算
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
{billingEstimate?.estimated && (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ display: 'block', marginTop: 4 }}
|
||||||
|
>
|
||||||
|
成本为估算来源,不代表真实扣费账单。
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<XProTable<DigitalEmployeeAdminWorkbenchRow>
|
<XProTable<DigitalEmployeeAdminWorkbenchRow>
|
||||||
key={activeView}
|
key={activeView}
|
||||||
actionRef={actionRef}
|
actionRef={actionRef}
|
||||||
@@ -567,6 +704,46 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activeView === 'daily_reports' && detailRecord.report_id && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={5}>日报发送</Typography.Title>
|
||||||
|
<Button
|
||||||
|
loading={
|
||||||
|
submittingAction ===
|
||||||
|
`${detailRecord.sync_record_id}:send-record`
|
||||||
|
}
|
||||||
|
onClick={() => submitDailyReportSendRecord(detailRecord)}
|
||||||
|
>
|
||||||
|
记录发送结果
|
||||||
|
</Button>
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ display: 'block', marginTop: 8 }}
|
||||||
|
>
|
||||||
|
仅记录发送请求或结果,不触发第三方真实发送。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeView === 'audits' && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={5}>审计归档</Typography.Title>
|
||||||
|
<Button
|
||||||
|
loading={
|
||||||
|
submittingAction ===
|
||||||
|
`${detailRecord.sync_record_id}:audit-archive`
|
||||||
|
}
|
||||||
|
onClick={() => submitAuditArchive(detailRecord)}
|
||||||
|
>
|
||||||
|
标记归档
|
||||||
|
</Button>
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ display: 'block', marginTop: 8 }}
|
||||||
|
>
|
||||||
|
只保存脱敏摘要和归档位置,不保存 prompt、raw command、authorization 或 token。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Typography.Title level={5}>Business View</Typography.Title>
|
<Typography.Title level={5}>Business View</Typography.Title>
|
||||||
<Typography.Paragraph copyable={{ text: businessViewJson }}>
|
<Typography.Paragraph copyable={{ text: businessViewJson }}>
|
||||||
<pre
|
<pre
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import type {
|
|||||||
AddSystemUserParams,
|
AddSystemUserParams,
|
||||||
DigitalEmployeeAdminActionResult,
|
DigitalEmployeeAdminActionResult,
|
||||||
DigitalEmployeeAdminActionSubmitParams,
|
DigitalEmployeeAdminActionSubmitParams,
|
||||||
|
DigitalEmployeeAdminRecordResult,
|
||||||
|
DigitalEmployeeAdminRecordSubmitParams,
|
||||||
ConversationStatsResult,
|
ConversationStatsResult,
|
||||||
|
DigitalEmployeeBillingEstimate,
|
||||||
|
DigitalEmployeeBillingEstimateParams,
|
||||||
DigitalEmployeeAdminWorkbenchListParams,
|
DigitalEmployeeAdminWorkbenchListParams,
|
||||||
DigitalEmployeeAdminWorkbenchPage,
|
DigitalEmployeeAdminWorkbenchPage,
|
||||||
DigitalEmployeePolicySnapshotListParams,
|
DigitalEmployeePolicySnapshotListParams,
|
||||||
@@ -351,6 +355,63 @@ export async function apiDigitalEmployeeAdminActionSubmit(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录数字员工日报发送结果
|
||||||
|
export async function apiDigitalEmployeeAdminDailyReportSendRecord(
|
||||||
|
data: DigitalEmployeeAdminRecordSubmitParams,
|
||||||
|
): Promise<RequestResponse<DigitalEmployeeAdminRecordResult>> {
|
||||||
|
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<RequestResponse<DigitalEmployeeBillingEstimate>> {
|
||||||
|
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<RequestResponse<DigitalEmployeeAdminRecordResult>> {
|
||||||
|
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(
|
export async function apiSystemResourceWebappList(
|
||||||
data: SystemWebappListParams,
|
data: SystemWebappListParams,
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
reason?: string;
|
||||||
|
requestPayload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, DigitalEmployeeBillingModelEstimate>;
|
||||||
|
updated_at?: string;
|
||||||
|
source_records?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
// 网页应用信息
|
// 网页应用信息
|
||||||
export type SystemWebappInfo = SystemResourceInfo;
|
export type SystemWebappInfo = SystemResourceInfo;
|
||||||
|
|
||||||
|
|||||||
@@ -1306,6 +1306,82 @@ function checkDigitalBackendMaterializedFacts() {
|
|||||||
console.log("[DigitalEmployeeCheck] backend materialized business fact hooks OK");
|
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() {
|
function checkLocalDatabaseSchema() {
|
||||||
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
|
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
|
||||||
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
|
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
|
||||||
@@ -1367,6 +1443,7 @@ function main() {
|
|||||||
checkDigitalAdminActionLoop();
|
checkDigitalAdminActionLoop();
|
||||||
checkDigitalAdminPolicySnapshots();
|
checkDigitalAdminPolicySnapshots();
|
||||||
checkDigitalBackendMaterializedFacts();
|
checkDigitalBackendMaterializedFacts();
|
||||||
|
checkDigitalAdminRecordBillingArchiveShell();
|
||||||
checkLocalDatabaseSchema();
|
checkLocalDatabaseSchema();
|
||||||
console.log("\n[DigitalEmployeeCheck] All checks passed");
|
console.log("\n[DigitalEmployeeCheck] All checks passed");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -604,8 +604,8 @@ GET /api/digital-employee/approvals
|
|||||||
- 建议:下一轮围绕管理端正式路由 UI 和跨设备冲突可视化,把 embedded 批量干预入口推进到管理端运营台。
|
- 建议:下一轮围绕管理端正式路由 UI 和跨设备冲突可视化,把 embedded 批量干预入口推进到管理端运营台。
|
||||||
|
|
||||||
11. **诊断 / 成本 / 审计视图**
|
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 / 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 文件归档、自动诊断修复动作和正式外部管理端审计视图尚未统一吸收。
|
- 缺口:真实计费账单、独立 sandbox audit 文件归档、自动诊断修复动作和正式外部管理端审计视图尚未统一吸收;当前账单仍是估算,审计归档只记录脱敏摘要和归档位置。
|
||||||
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
|
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
|
||||||
|
|
||||||
12. **管理端业务查询模型**
|
12. **管理端业务查询模型**
|
||||||
@@ -614,7 +614,7 @@ GET /api/digital-employee/approvals
|
|||||||
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。
|
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。
|
||||||
|
|
||||||
13. **日报实体与导出交付**
|
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 物理表、真实管理端发送通道、正式外部管理端日报页面和审批裁决回写闭环。
|
- 缺口:没有正式 DailyReport 物理表、真实管理端发送通道、正式外部管理端日报页面和审批裁决回写闭环。
|
||||||
- 建议:先稳定 Artifact/Event 派生日报交付模型,再根据管理端联调需要决定是否物化 DailyReport 表与发送记录表。
|
- 建议:先稳定 Artifact/Event 派生日报交付模型,再根据管理端联调需要决定是否物化 DailyReport 表与发送记录表。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user