吸收数字员工管理端低风险动作闭环
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
package com.xspaceagi.agent.core.adapter.application;
|
package com.xspaceagi.agent.core.adapter.application;
|
||||||
|
|
||||||
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.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.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.DigitalEmployeeSyncRecordPageDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
|
||||||
@@ -8,6 +11,7 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDt
|
|||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface DigitalEmployeeSyncApplicationService {
|
public interface DigitalEmployeeSyncApplicationService {
|
||||||
DigitalEmployeeSyncResponseDto syncOutbox(DigitalEmployeeSyncRequestDto request);
|
DigitalEmployeeSyncResponseDto syncOutbox(DigitalEmployeeSyncRequestDto request);
|
||||||
@@ -57,4 +61,19 @@ public interface DigitalEmployeeSyncApplicationService {
|
|||||||
Long pageNo,
|
Long pageNo,
|
||||||
Long pageSize
|
Long pageSize
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request);
|
||||||
|
|
||||||
|
DigitalEmployeeAdminActionPageDto queryPendingAdminActions(
|
||||||
|
String clientKey,
|
||||||
|
String deviceId,
|
||||||
|
Long pageNo,
|
||||||
|
Long pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
DigitalEmployeeAdminActionResultDto markAdminActionResult(
|
||||||
|
Long actionId,
|
||||||
|
String status,
|
||||||
|
List<String> reasonCodes
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.dto.digital;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class DigitalEmployeeAdminActionDto {
|
||||||
|
private Long id;
|
||||||
|
@JsonProperty("client_key_masked")
|
||||||
|
private String clientKeyMasked;
|
||||||
|
@JsonProperty("device_id")
|
||||||
|
private String deviceId;
|
||||||
|
@JsonProperty("entity_type")
|
||||||
|
private String entityType;
|
||||||
|
@JsonProperty("entity_id")
|
||||||
|
private String entityId;
|
||||||
|
private String action;
|
||||||
|
private String status;
|
||||||
|
@JsonProperty("reason_codes")
|
||||||
|
private List<String> reasonCodes;
|
||||||
|
@JsonProperty("request_payload")
|
||||||
|
private Map<String, Object> requestPayload;
|
||||||
|
@JsonProperty("created_at")
|
||||||
|
private Date createdAt;
|
||||||
|
@JsonProperty("processed_at")
|
||||||
|
private Date processedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.dto.digital;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class DigitalEmployeeAdminActionPageDto {
|
||||||
|
private Long total;
|
||||||
|
@JsonProperty("page_no")
|
||||||
|
private Long pageNo;
|
||||||
|
@JsonProperty("page_size")
|
||||||
|
private Long pageSize;
|
||||||
|
private List<DigitalEmployeeAdminActionDto> records;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.dto.digital;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class DigitalEmployeeAdminActionRequestDto {
|
||||||
|
@JsonProperty("sync_record_id")
|
||||||
|
private Long syncRecordId;
|
||||||
|
@JsonProperty("client_key")
|
||||||
|
private String clientKey;
|
||||||
|
@JsonProperty("device_id")
|
||||||
|
private String deviceId;
|
||||||
|
@JsonProperty("entity_type")
|
||||||
|
private String entityType;
|
||||||
|
@JsonProperty("entity_id")
|
||||||
|
private String entityId;
|
||||||
|
private String action;
|
||||||
|
@JsonProperty("request_payload")
|
||||||
|
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 DigitalEmployeeAdminActionResultDto {
|
||||||
|
private Boolean ok;
|
||||||
|
private String status;
|
||||||
|
@JsonProperty("reason_codes")
|
||||||
|
private List<String> reasonCodes;
|
||||||
|
private DigitalEmployeeAdminActionDto action;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
|
||||||
|
|
||||||
|
public interface DigitalEmployeeAdminActionRepository extends IService<DigitalEmployeeAdminAction> {
|
||||||
|
IPage<DigitalEmployeeAdminAction> queryActions(
|
||||||
|
Long tenantId,
|
||||||
|
String clientKey,
|
||||||
|
String deviceId,
|
||||||
|
String status,
|
||||||
|
long pageNo,
|
||||||
|
long pageSize
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.repository.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@TableName("digital_employee_admin_action")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class DigitalEmployeeAdminAction {
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
@TableField(value = "_tenant_id")
|
||||||
|
private Long tenantId;
|
||||||
|
private Long userId;
|
||||||
|
private String clientKey;
|
||||||
|
private String deviceId;
|
||||||
|
private String entityType;
|
||||||
|
private String entityId;
|
||||||
|
private String action;
|
||||||
|
private String status;
|
||||||
|
private String reasonCodes;
|
||||||
|
private String requestPayload;
|
||||||
|
private Date processedAt;
|
||||||
|
private Date modified;
|
||||||
|
private Date created;
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
|
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionDto;
|
||||||
|
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.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.DigitalEmployeeBusinessViewPageDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewPageDto;
|
||||||
@@ -18,7 +22,9 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordDto
|
|||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeAdminActionRepository;
|
||||||
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
|
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
|
||||||
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
|
||||||
import com.xspaceagi.system.spec.common.RequestContext;
|
import com.xspaceagi.system.spec.common.RequestContext;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -42,10 +48,26 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
|
|||||||
private static final long DEFAULT_PAGE_NO = 1L;
|
private static final long DEFAULT_PAGE_NO = 1L;
|
||||||
private static final long DEFAULT_PAGE_SIZE = 20L;
|
private static final long DEFAULT_PAGE_SIZE = 20L;
|
||||||
private static final long MAX_PAGE_SIZE = 100L;
|
private static final long MAX_PAGE_SIZE = 100L;
|
||||||
|
private static final Set<String> HIGH_RISK_ADMIN_ACTIONS = Set.of(
|
||||||
|
"accept_remote_approval", "cleanup_file_batch", "start_service", "stop_service", "hard_interrupt"
|
||||||
|
);
|
||||||
|
private static final Set<String> LOW_RISK_ADMIN_ACTIONS = Set.of(
|
||||||
|
"approval:keep_local", "approval:mark_reviewed",
|
||||||
|
"route_decision:mark_reviewed", "route_decision:dismiss", "route_decision:retry_policy_check",
|
||||||
|
"route:mark_reviewed", "route:dismiss", "route:retry_policy_check",
|
||||||
|
"notification:mark_read", "notification:dismiss",
|
||||||
|
"artifact:mark_keep", "artifact:mark_expire", "artifact:mark_reviewed",
|
||||||
|
"daily_report:mark_delivered", "daily_report:mark_confirmed", "daily_report:request_approval",
|
||||||
|
"audit:mark_reviewed", "audit:dismiss",
|
||||||
|
"event:mark_reviewed", "event:dismiss"
|
||||||
|
);
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private DigitalEmployeeSyncRecordRepository digitalEmployeeSyncRecordRepository;
|
private DigitalEmployeeSyncRecordRepository digitalEmployeeSyncRecordRepository;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DigitalEmployeeAdminActionRepository digitalEmployeeAdminActionRepository;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
@@ -321,6 +343,151 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
|
|||||||
return adminWorkbenchPage(rows, currentPage, currentPageSize);
|
return adminWorkbenchPage(rows, currentPage, currentPageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@DSTransactional
|
||||||
|
public DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request) {
|
||||||
|
Date now = new Date();
|
||||||
|
request = enrichAdminActionRequestFromSyncRecord(request);
|
||||||
|
List<String> reasonCodes = validateAdminAction(request);
|
||||||
|
boolean allowed = reasonCodes.isEmpty();
|
||||||
|
if (allowed) {
|
||||||
|
reasonCodes = List.of("action_recorded");
|
||||||
|
}
|
||||||
|
DigitalEmployeeAdminAction action = new DigitalEmployeeAdminAction();
|
||||||
|
action.setTenantId(currentTenantId());
|
||||||
|
action.setUserId(currentUserId());
|
||||||
|
action.setClientKey(StringUtils.trimToNull(request == null ? null : request.getClientKey()));
|
||||||
|
action.setDeviceId(StringUtils.trimToNull(request == null ? null : request.getDeviceId()));
|
||||||
|
action.setEntityType(normalizedText(request == null ? null : request.getEntityType()));
|
||||||
|
action.setEntityId(StringUtils.trimToNull(request == null ? null : request.getEntityId()));
|
||||||
|
action.setAction(normalizedText(request == null ? null : request.getAction()));
|
||||||
|
action.setStatus(allowed ? "pending" : "rejected");
|
||||||
|
action.setReasonCodes(toJson(reasonCodes));
|
||||||
|
action.setRequestPayload(toJson(request == null ? null : request.getRequestPayload()));
|
||||||
|
action.setProcessedAt(allowed ? null : now);
|
||||||
|
action.setModified(now);
|
||||||
|
action.setCreated(now);
|
||||||
|
digitalEmployeeAdminActionRepository.save(action);
|
||||||
|
return DigitalEmployeeAdminActionResultDto.builder()
|
||||||
|
.ok(allowed)
|
||||||
|
.status(action.getStatus())
|
||||||
|
.reasonCodes(reasonCodes)
|
||||||
|
.action(toAdminActionDto(action))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DigitalEmployeeAdminActionRequestDto enrichAdminActionRequestFromSyncRecord(DigitalEmployeeAdminActionRequestDto 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DigitalEmployeeAdminActionPageDto queryPendingAdminActions(String clientKey, String deviceId, Long pageNo, Long pageSize) {
|
||||||
|
long currentPage = pageNo == null || pageNo < 1 ? DEFAULT_PAGE_NO : pageNo;
|
||||||
|
long currentPageSize = pageSize == null || pageSize < 1
|
||||||
|
? DEFAULT_PAGE_SIZE
|
||||||
|
: Math.min(pageSize, MAX_PAGE_SIZE);
|
||||||
|
Long tenantId = currentTenantId();
|
||||||
|
if (tenantId == null || StringUtils.isBlank(clientKey)) {
|
||||||
|
return DigitalEmployeeAdminActionPageDto.builder()
|
||||||
|
.total(0L)
|
||||||
|
.pageNo(currentPage)
|
||||||
|
.pageSize(currentPageSize)
|
||||||
|
.records(List.of())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
IPage<DigitalEmployeeAdminAction> page = digitalEmployeeAdminActionRepository.queryActions(
|
||||||
|
tenantId,
|
||||||
|
StringUtils.trimToNull(clientKey),
|
||||||
|
StringUtils.trimToNull(deviceId),
|
||||||
|
"pending",
|
||||||
|
currentPage,
|
||||||
|
currentPageSize
|
||||||
|
);
|
||||||
|
return DigitalEmployeeAdminActionPageDto.builder()
|
||||||
|
.total(page.getTotal())
|
||||||
|
.pageNo(page.getCurrent())
|
||||||
|
.pageSize(page.getSize())
|
||||||
|
.records(page.getRecords().stream().map(this::toAdminActionDto).toList())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@DSTransactional
|
||||||
|
public DigitalEmployeeAdminActionResultDto markAdminActionResult(Long actionId, String status, List<String> reasonCodes) {
|
||||||
|
Long tenantId = currentTenantId();
|
||||||
|
DigitalEmployeeAdminAction action = actionId == null ? null : digitalEmployeeAdminActionRepository.getById(actionId);
|
||||||
|
if (action == null || tenantId == null || !Objects.equals(action.getTenantId(), tenantId)) {
|
||||||
|
return DigitalEmployeeAdminActionResultDto.builder()
|
||||||
|
.ok(false)
|
||||||
|
.status("not_found")
|
||||||
|
.reasonCodes(List.of("admin_action_not_found"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
String normalizedStatus = normalizedText(status);
|
||||||
|
if (!Set.of("applied", "rejected", "failed").contains(normalizedStatus)) {
|
||||||
|
normalizedStatus = "failed";
|
||||||
|
}
|
||||||
|
Date now = new Date();
|
||||||
|
List<String> normalizedReasons = CollectionUtils.isEmpty(reasonCodes) ? List.of("admin_action_" + normalizedStatus) : reasonCodes;
|
||||||
|
action.setStatus(normalizedStatus);
|
||||||
|
action.setReasonCodes(toJson(normalizedReasons));
|
||||||
|
action.setProcessedAt(now);
|
||||||
|
action.setModified(now);
|
||||||
|
digitalEmployeeAdminActionRepository.updateById(action);
|
||||||
|
return DigitalEmployeeAdminActionResultDto.builder()
|
||||||
|
.ok(true)
|
||||||
|
.status(action.getStatus())
|
||||||
|
.reasonCodes(normalizedReasons)
|
||||||
|
.action(toAdminActionDto(action))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> validateAdminAction(DigitalEmployeeAdminActionRequestDto request) {
|
||||||
|
if (request == null) return List.of("request_required");
|
||||||
|
String clientKey = StringUtils.trimToNull(request.getClientKey());
|
||||||
|
String entityType = normalizedText(request.getEntityType());
|
||||||
|
String entityId = StringUtils.trimToNull(request.getEntityId());
|
||||||
|
String action = normalizedText(request.getAction());
|
||||||
|
if (StringUtils.isBlank(clientKey) || StringUtils.isBlank(entityType) || StringUtils.isBlank(entityId) || StringUtils.isBlank(action)) {
|
||||||
|
return List.of("required_field_missing");
|
||||||
|
}
|
||||||
|
if (HIGH_RISK_ADMIN_ACTIONS.contains(action)) {
|
||||||
|
return List.of("high_risk_action_disabled");
|
||||||
|
}
|
||||||
|
if (!LOW_RISK_ADMIN_ACTIONS.contains(entityType + ":" + action)) {
|
||||||
|
return List.of("unsupported_admin_action");
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DigitalEmployeeAdminActionDto toAdminActionDto(DigitalEmployeeAdminAction action) {
|
||||||
|
if (action == null) return null;
|
||||||
|
return DigitalEmployeeAdminActionDto.builder()
|
||||||
|
.id(action.getId())
|
||||||
|
.clientKeyMasked(maskClientKey(action.getClientKey()))
|
||||||
|
.deviceId(action.getDeviceId())
|
||||||
|
.entityType(action.getEntityType())
|
||||||
|
.entityId(action.getEntityId())
|
||||||
|
.action(action.getAction())
|
||||||
|
.status(action.getStatus())
|
||||||
|
.reasonCodes(readStringList(action.getReasonCodes()))
|
||||||
|
.requestPayload(readJsonObject(action.getRequestPayload()))
|
||||||
|
.createdAt(action.getCreated())
|
||||||
|
.processedAt(action.getProcessedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private DigitalEmployeeAdminWorkbenchPageDto adminWorkbenchPage(List<DigitalEmployeeAdminWorkbenchRowDto> rows, long pageNo, long pageSize) {
|
private DigitalEmployeeAdminWorkbenchPageDto adminWorkbenchPage(List<DigitalEmployeeAdminWorkbenchRowDto> rows, long pageNo, long pageSize) {
|
||||||
int fromIndex = (int) Math.min(Math.max(pageNo - 1, 0) * pageSize, rows.size());
|
int fromIndex = (int) Math.min(Math.max(pageNo - 1, 0) * pageSize, rows.size());
|
||||||
int toIndex = (int) Math.min(fromIndex + pageSize, rows.size());
|
int toIndex = (int) Math.min(fromIndex + pageSize, rows.size());
|
||||||
@@ -572,6 +739,18 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizedText(String value) {
|
||||||
|
return StringUtils.trimToEmpty(value).toLowerCase().replace('-', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long currentTenantId() {
|
||||||
|
return RequestContext.get() == null ? null : RequestContext.get().getTenantId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long currentUserId() {
|
||||||
|
return RequestContext.get() == null ? null : RequestContext.get().getUserId();
|
||||||
|
}
|
||||||
|
|
||||||
private String syncRecordUrl(String outboxId) {
|
private String syncRecordUrl(String outboxId) {
|
||||||
return "/system/content/content-digital-employee-sync?outbox_id=" + StringUtils.defaultString(outboxId);
|
return "/system/content/content-digital-employee-sync?outbox_id=" + StringUtils.defaultString(outboxId);
|
||||||
}
|
}
|
||||||
@@ -593,6 +772,16 @@ public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmploye
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> readStringList(String value) {
|
||||||
|
if (StringUtils.isBlank(value)) return List.of();
|
||||||
|
try {
|
||||||
|
List<String> parsed = objectMapper.readValue(value, new TypeReference<>() {});
|
||||||
|
return parsed == null ? List.of() : parsed;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return List.of(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String firstString(Object... values) {
|
private String firstString(Object... values) {
|
||||||
for (Object value : values) {
|
for (Object value : values) {
|
||||||
String candidate = stringValue(value, null);
|
String candidate = stringValue(value, null);
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewP
|
|||||||
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.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.DigitalEmployeeAdminActionDto;
|
||||||
|
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.DigitalEmployeePlanBusinessViewDetailDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
|
||||||
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeAdminActionRepository;
|
||||||
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
|
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
|
||||||
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
|
||||||
import com.xspaceagi.system.spec.common.RequestContext;
|
import com.xspaceagi.system.spec.common.RequestContext;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
@@ -35,6 +41,7 @@ import static org.mockito.Mockito.when;
|
|||||||
class DigitalEmployeeSyncApplicationServiceImplTest {
|
class DigitalEmployeeSyncApplicationServiceImplTest {
|
||||||
|
|
||||||
private DigitalEmployeeSyncRecordRepository repository;
|
private DigitalEmployeeSyncRecordRepository repository;
|
||||||
|
private DigitalEmployeeAdminActionRepository adminActionRepository;
|
||||||
private DigitalEmployeeSyncApplicationServiceImpl service;
|
private DigitalEmployeeSyncApplicationServiceImpl service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -45,8 +52,10 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
|
|||||||
RequestContext.set(context);
|
RequestContext.set(context);
|
||||||
|
|
||||||
repository = mock(DigitalEmployeeSyncRecordRepository.class);
|
repository = mock(DigitalEmployeeSyncRecordRepository.class);
|
||||||
|
adminActionRepository = mock(DigitalEmployeeAdminActionRepository.class);
|
||||||
service = new DigitalEmployeeSyncApplicationServiceImpl();
|
service = new DigitalEmployeeSyncApplicationServiceImpl();
|
||||||
ReflectionTestUtils.setField(service, "digitalEmployeeSyncRecordRepository", repository);
|
ReflectionTestUtils.setField(service, "digitalEmployeeSyncRecordRepository", repository);
|
||||||
|
ReflectionTestUtils.setField(service, "digitalEmployeeAdminActionRepository", adminActionRepository);
|
||||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,12 +269,111 @@ class DigitalEmployeeSyncApplicationServiceImplTest {
|
|||||||
assertThat(row.getPayload()).containsEntry("tool_name", "shell");
|
assertThat(row.getPayload()).containsEntry("tool_name", "shell");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAdminActionRecordsLowRiskActionAsPending() {
|
||||||
|
DigitalEmployeeAdminActionRequestDto request = adminActionRequest("approval", "approval-1", "keep_local");
|
||||||
|
request.setRequestPayload(Map.of("reason", "protect_local_fact"));
|
||||||
|
|
||||||
|
DigitalEmployeeAdminActionResultDto result = service.createAdminAction(request);
|
||||||
|
|
||||||
|
ArgumentCaptor<DigitalEmployeeAdminAction> captor = ArgumentCaptor.forClass(DigitalEmployeeAdminAction.class);
|
||||||
|
verify(adminActionRepository).save(captor.capture());
|
||||||
|
DigitalEmployeeAdminAction action = captor.getValue();
|
||||||
|
assertThat(result.getOk()).isTrue();
|
||||||
|
assertThat(result.getStatus()).isEqualTo("pending");
|
||||||
|
assertThat(action.getTenantId()).isEqualTo(101L);
|
||||||
|
assertThat(action.getUserId()).isEqualTo(202L);
|
||||||
|
assertThat(action.getClientKey()).isEqualTo("client-key-001");
|
||||||
|
assertThat(action.getDeviceId()).isEqualTo("device-001");
|
||||||
|
assertThat(action.getStatus()).isEqualTo("pending");
|
||||||
|
assertThat(action.getReasonCodes()).contains("action_recorded");
|
||||||
|
assertThat(action.getRequestPayload()).contains("protect_local_fact");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAdminActionRejectsHighRiskActionWithoutPendingExecution() {
|
||||||
|
DigitalEmployeeAdminActionRequestDto request = adminActionRequest("artifact", "artifact-1", "cleanup_file_batch");
|
||||||
|
|
||||||
|
DigitalEmployeeAdminActionResultDto result = service.createAdminAction(request);
|
||||||
|
|
||||||
|
assertThat(result.getOk()).isFalse();
|
||||||
|
assertThat(result.getStatus()).isEqualTo("rejected");
|
||||||
|
assertThat(result.getReasonCodes()).contains("high_risk_action_disabled");
|
||||||
|
ArgumentCaptor<DigitalEmployeeAdminAction> captor = ArgumentCaptor.forClass(DigitalEmployeeAdminAction.class);
|
||||||
|
verify(adminActionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getStatus()).isEqualTo("rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryPendingAdminActionsFiltersTenantClientDeviceAndMasksKey() {
|
||||||
|
when(adminActionRepository.queryActions(eq(101L), eq("client-key-001"), eq("device-001"), eq("pending"), eq(1L), eq(20L)))
|
||||||
|
.thenReturn(adminActionPage(List.of(adminAction(10L, "approval", "approval-1", "keep_local", "pending"))));
|
||||||
|
|
||||||
|
DigitalEmployeeAdminActionPageDto result = service.queryPendingAdminActions("client-key-001", "device-001", 1L, 20L);
|
||||||
|
|
||||||
|
assertThat(result.getTotal()).isEqualTo(1L);
|
||||||
|
DigitalEmployeeAdminActionDto action = result.getRecords().get(0);
|
||||||
|
assertThat(action.getId()).isEqualTo(10L);
|
||||||
|
assertThat(action.getClientKeyMasked()).isEqualTo("clie*********001");
|
||||||
|
assertThat(action.getStatus()).isEqualTo("pending");
|
||||||
|
assertThat(action.getReasonCodes()).contains("action_recorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAdminActionResultUpdatesStatusAndProcessedAt() {
|
||||||
|
DigitalEmployeeAdminAction action = adminAction(10L, "notification", "notification-1", "dismiss", "pending");
|
||||||
|
when(adminActionRepository.getById(10L)).thenReturn(action);
|
||||||
|
|
||||||
|
DigitalEmployeeAdminActionResultDto result = service.markAdminActionResult(10L, "applied", List.of("admin_action_applied"));
|
||||||
|
|
||||||
|
assertThat(result.getOk()).isTrue();
|
||||||
|
assertThat(result.getStatus()).isEqualTo("applied");
|
||||||
|
verify(adminActionRepository).updateById(action);
|
||||||
|
assertThat(action.getStatus()).isEqualTo("applied");
|
||||||
|
assertThat(action.getProcessedAt()).isNotNull();
|
||||||
|
assertThat(action.getReasonCodes()).contains("admin_action_applied");
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IPage<DigitalEmployeeAdminAction> adminActionPage(List<DigitalEmployeeAdminAction> records) {
|
||||||
|
Page<DigitalEmployeeAdminAction> page = new Page<>(1, 20, records.size());
|
||||||
|
page.setRecords(records);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DigitalEmployeeAdminActionRequestDto adminActionRequest(String entityType, String entityId, String action) {
|
||||||
|
DigitalEmployeeAdminActionRequestDto request = new DigitalEmployeeAdminActionRequestDto();
|
||||||
|
request.setClientKey("client-key-001");
|
||||||
|
request.setDeviceId("device-001");
|
||||||
|
request.setEntityType(entityType);
|
||||||
|
request.setEntityId(entityId);
|
||||||
|
request.setAction(action);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DigitalEmployeeAdminAction adminAction(Long id, String entityType, String entityId, String action, String status) {
|
||||||
|
DigitalEmployeeAdminAction record = new DigitalEmployeeAdminAction();
|
||||||
|
record.setId(id);
|
||||||
|
record.setTenantId(101L);
|
||||||
|
record.setUserId(202L);
|
||||||
|
record.setClientKey("client-key-001");
|
||||||
|
record.setDeviceId("device-001");
|
||||||
|
record.setEntityType(entityType);
|
||||||
|
record.setEntityId(entityId);
|
||||||
|
record.setAction(action);
|
||||||
|
record.setStatus(status);
|
||||||
|
record.setReasonCodes("[\"action_recorded\"]");
|
||||||
|
record.setRequestPayload("{\"reason\":\"protect_local_fact\"}");
|
||||||
|
record.setCreated(new Date(1_000));
|
||||||
|
record.setModified(new Date(1_000));
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
private DigitalEmployeeSyncRecord planRecord(String planId, String title, String status, Long tenantId, Date syncedAt) {
|
private DigitalEmployeeSyncRecord planRecord(String planId, String title, String status, Long tenantId, Date syncedAt) {
|
||||||
return record("outbox-" + planId, "plan", planId, tenantId, syncedAt,
|
return record("outbox-" + planId, "plan", planId, tenantId, syncedAt,
|
||||||
Map.of("entity_type", "plan", "local_id", planId, "title", title, "status", status, "summary", "计划摘要"),
|
Map.of("entity_type", "plan", "local_id", planId, "title", title, "status", status, "summary", "计划摘要"),
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.xspaceagi.agent.core.infra.dao.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface DigitalEmployeeAdminActionMapper extends BaseMapper<DigitalEmployeeAdminAction> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.xspaceagi.agent.core.infra.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeAdminActionRepository;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
|
||||||
|
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeAdminActionMapper;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DigitalEmployeeAdminActionRepositoryImpl
|
||||||
|
extends ServiceImpl<DigitalEmployeeAdminActionMapper, DigitalEmployeeAdminAction>
|
||||||
|
implements DigitalEmployeeAdminActionRepository {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<DigitalEmployeeAdminAction> queryActions(
|
||||||
|
Long tenantId,
|
||||||
|
String clientKey,
|
||||||
|
String deviceId,
|
||||||
|
String status,
|
||||||
|
long pageNo,
|
||||||
|
long pageSize
|
||||||
|
) {
|
||||||
|
LambdaQueryWrapper<DigitalEmployeeAdminAction> queryWrapper = new LambdaQueryWrapper<DigitalEmployeeAdminAction>()
|
||||||
|
.eq(tenantId != null, DigitalEmployeeAdminAction::getTenantId, tenantId)
|
||||||
|
.eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeAdminAction::getClientKey, clientKey)
|
||||||
|
.eq(StringUtils.isNotBlank(deviceId), DigitalEmployeeAdminAction::getDeviceId, deviceId)
|
||||||
|
.eq(StringUtils.isNotBlank(status), DigitalEmployeeAdminAction::getStatus, status)
|
||||||
|
.orderByAsc(DigitalEmployeeAdminAction::getCreated)
|
||||||
|
.orderByAsc(DigitalEmployeeAdminAction::getId);
|
||||||
|
return page(new Page<>(pageNo, pageSize), queryWrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.xspaceagi.agent.web.ui.controller;
|
||||||
|
|
||||||
|
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
|
||||||
|
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.system.spec.annotation.RequireResource;
|
||||||
|
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
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.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static com.xspaceagi.system.spec.enums.ResourceEnum.CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/digital-employee/admin/actions")
|
||||||
|
public class DigitalEmployeeAdminActionController {
|
||||||
|
|
||||||
|
private static final String CLIENT_KEY_HEADER = "X-Qiming-Client-Key";
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
|
||||||
|
|
||||||
|
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
|
||||||
|
@RequestMapping(method = RequestMethod.POST)
|
||||||
|
public ReqResult<DigitalEmployeeAdminActionResultDto> createAction(
|
||||||
|
@RequestBody(required = false) DigitalEmployeeAdminActionRequestDto request
|
||||||
|
) {
|
||||||
|
return ReqResult.success(digitalEmployeeSyncApplicationService.createAdminAction(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequestMapping(path = "/pending", method = RequestMethod.GET)
|
||||||
|
public ReqResult<DigitalEmployeeAdminActionPageDto> pendingActions(
|
||||||
|
@RequestHeader(name = CLIENT_KEY_HEADER, required = false) String headerClientKey,
|
||||||
|
@RequestParam(name = "client_key", required = false) String clientKey,
|
||||||
|
@RequestParam(name = "device_id", required = false) String deviceId,
|
||||||
|
@RequestParam(name = "page_no", required = false) Long pageNo,
|
||||||
|
@RequestParam(name = "page_size", required = false) Long pageSize
|
||||||
|
) {
|
||||||
|
String normalizedClientKey = StringUtils.defaultIfBlank(clientKey, headerClientKey);
|
||||||
|
if (StringUtils.isBlank(normalizedClientKey)) {
|
||||||
|
return ReqResult.create("0001", "客户端 Key 缺失", null);
|
||||||
|
}
|
||||||
|
return ReqResult.success(digitalEmployeeSyncApplicationService.queryPendingAdminActions(
|
||||||
|
normalizedClientKey.trim(),
|
||||||
|
deviceId,
|
||||||
|
pageNo,
|
||||||
|
pageSize
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequestMapping(path = "/{actionId}/result", method = RequestMethod.POST)
|
||||||
|
public ReqResult<DigitalEmployeeAdminActionResultDto> markResult(
|
||||||
|
@PathVariable("actionId") Long actionId,
|
||||||
|
@RequestBody(required = false) Map<String, Object> body
|
||||||
|
) {
|
||||||
|
String status = body == null ? null : String.valueOf(body.get("status"));
|
||||||
|
List<String> reasonCodes = reasonCodes(body == null ? null : body.get("reason_codes"));
|
||||||
|
return ReqResult.success(digitalEmployeeSyncApplicationService.markAdminActionResult(actionId, status, reasonCodes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> reasonCodes(Object value) {
|
||||||
|
if (value instanceof List<?> list) {
|
||||||
|
return list.stream().map(String::valueOf).toList();
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,26 @@ ALTER TABLE `digital_employee_sync_record`
|
|||||||
ADD COLUMN `entity_summary` JSON DEFAULT NULL COMMENT '客户端实体摘要' AFTER `contract_version`,
|
ADD COLUMN `entity_summary` JSON DEFAULT NULL COMMENT '客户端实体摘要' AFTER `contract_version`,
|
||||||
ADD COLUMN `business_view` JSON DEFAULT NULL COMMENT '客户端业务视图投影' AFTER `entity_summary`;
|
ADD COLUMN `business_view` JSON DEFAULT NULL COMMENT '客户端业务视图投影' AFTER `entity_summary`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `digital_employee_admin_action` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`_tenant_id` BIGINT DEFAULT NULL COMMENT '租户 ID',
|
||||||
|
`user_id` BIGINT DEFAULT NULL COMMENT '创建用户 ID',
|
||||||
|
`client_key` VARCHAR(128) DEFAULT NULL COMMENT '客户端 Key',
|
||||||
|
`device_id` VARCHAR(128) DEFAULT NULL COMMENT '设备 ID',
|
||||||
|
`entity_type` VARCHAR(64) DEFAULT NULL COMMENT '实体类型',
|
||||||
|
`entity_id` VARCHAR(128) DEFAULT NULL COMMENT '实体 ID',
|
||||||
|
`action` VARCHAR(64) DEFAULT NULL COMMENT '管理端动作',
|
||||||
|
`status` VARCHAR(32) DEFAULT NULL COMMENT '状态 pending/applied/rejected/failed',
|
||||||
|
`reason_codes` JSON DEFAULT NULL COMMENT '原因码',
|
||||||
|
`request_payload` JSON DEFAULT NULL COMMENT '请求附加参数',
|
||||||
|
`processed_at` DATETIME DEFAULT NULL COMMENT '客户端处理时间',
|
||||||
|
`modified` DATETIME DEFAULT NULL COMMENT '更新时间',
|
||||||
|
`created` DATETIME DEFAULT NULL COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_de_admin_action_pending` (`_tenant_id`, `client_key`, `device_id`, `status`, `created`),
|
||||||
|
KEY `idx_de_admin_action_entity` (`_tenant_id`, `entity_type`, `entity_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工管理端远程动作意图';
|
||||||
|
|
||||||
-- Add digital employee admin operations workbench menu and resource.
|
-- Add digital employee admin operations workbench menu and resource.
|
||||||
-- Idempotent for existing tenants: creates menu/resource rows, binds menu to resource,
|
-- Idempotent for existing tenants: creates menu/resource rows, binds menu to resource,
|
||||||
-- and grants super_admin default access. Other roles can be granted this menu
|
-- and grants super_admin default access. Other roles can be granted this menu
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { XProTable } from '@/components/ProComponents';
|
import { XProTable } from '@/components/ProComponents';
|
||||||
import WorkspaceLayout from '@/components/WorkspaceLayout';
|
import WorkspaceLayout from '@/components/WorkspaceLayout';
|
||||||
import { SUCCESS_CODE } from '@/constants/codes.constants';
|
import { SUCCESS_CODE } from '@/constants/codes.constants';
|
||||||
import { apiDigitalEmployeeAdminWorkbenchList } from '@/services/systemManage';
|
import {
|
||||||
|
apiDigitalEmployeeAdminActionSubmit,
|
||||||
|
apiDigitalEmployeeAdminWorkbenchList,
|
||||||
|
} from '@/services/systemManage';
|
||||||
import type {
|
import type {
|
||||||
DigitalEmployeeAdminWorkbenchRow,
|
DigitalEmployeeAdminWorkbenchRow,
|
||||||
DigitalEmployeeAdminWorkbenchView,
|
DigitalEmployeeAdminWorkbenchView,
|
||||||
@@ -107,6 +110,71 @@ function renderSummary(record: DigitalEmployeeAdminWorkbenchRow) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function actionTarget(record: DigitalEmployeeAdminWorkbenchRow, view: DigitalEmployeeAdminWorkbenchView) {
|
||||||
|
if (view === 'interventions' && record.approval_id) {
|
||||||
|
return { entityType: 'approval', entityId: record.approval_id };
|
||||||
|
}
|
||||||
|
if ((view === 'interventions' || view === 'route_governance') && record.route_decision_id) {
|
||||||
|
return { entityType: 'route_decision', entityId: record.route_decision_id };
|
||||||
|
}
|
||||||
|
if (view === 'notifications' && record.notification_id) {
|
||||||
|
return { entityType: 'notification', entityId: record.notification_id };
|
||||||
|
}
|
||||||
|
if (view === 'artifact_lifecycle' && record.artifact_id) {
|
||||||
|
return { entityType: 'artifact', entityId: record.artifact_id };
|
||||||
|
}
|
||||||
|
if (view === 'daily_reports' && record.report_id) {
|
||||||
|
return { entityType: 'daily_report', entityId: record.report_id };
|
||||||
|
}
|
||||||
|
if (view === 'audits') {
|
||||||
|
return { entityType: 'audit', entityId: record.entity_id };
|
||||||
|
}
|
||||||
|
return { entityType: record.entity_type, entityId: record.entity_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedActions(record: DigitalEmployeeAdminWorkbenchRow, view: DigitalEmployeeAdminWorkbenchView) {
|
||||||
|
if (view === 'interventions' && record.approval_id) {
|
||||||
|
return [
|
||||||
|
{ action: 'keep_local', label: '保留本地' },
|
||||||
|
{ action: 'mark_reviewed', label: '标记复核' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ((view === 'interventions' || view === 'route_governance') && record.route_decision_id) {
|
||||||
|
return [
|
||||||
|
{ action: 'mark_reviewed', label: '标记已看' },
|
||||||
|
{ action: 'dismiss', label: '忽略' },
|
||||||
|
{ action: 'retry_policy_check', label: '重试策略检查' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (view === 'notifications' && record.notification_id) {
|
||||||
|
return [
|
||||||
|
{ action: 'mark_read', label: '标记已读' },
|
||||||
|
{ action: 'dismiss', label: '忽略' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (view === 'artifact_lifecycle' && record.artifact_id) {
|
||||||
|
return [
|
||||||
|
{ action: 'mark_keep', label: '标记保留' },
|
||||||
|
{ action: 'mark_expire', label: '标记到期' },
|
||||||
|
{ action: 'mark_reviewed', label: '标记复核' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (view === 'daily_reports' && record.report_id) {
|
||||||
|
return [
|
||||||
|
{ action: 'mark_delivered', label: '记录已送达' },
|
||||||
|
{ action: 'mark_confirmed', label: '确认回执' },
|
||||||
|
{ action: 'request_approval', label: '请求审批' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (view === 'audits') {
|
||||||
|
return [
|
||||||
|
{ action: 'mark_reviewed', label: '标记复核' },
|
||||||
|
{ action: 'dismiss', label: '忽略' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const DigitalEmployeeOps: React.FC = () => {
|
const DigitalEmployeeOps: React.FC = () => {
|
||||||
const { hasPermission } = useModel('menuModel');
|
const { hasPermission } = useModel('menuModel');
|
||||||
const actionRef = useRef<ActionType>();
|
const actionRef = useRef<ActionType>();
|
||||||
@@ -123,6 +191,40 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
() => jsonText(detailRecord?.payload),
|
() => jsonText(detailRecord?.payload),
|
||||||
[detailRecord],
|
[detailRecord],
|
||||||
);
|
);
|
||||||
|
const [submittingAction, setSubmittingAction] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submitAdminAction = async (
|
||||||
|
record: DigitalEmployeeAdminWorkbenchRow,
|
||||||
|
action: string,
|
||||||
|
) => {
|
||||||
|
const target = actionTarget(record, activeView);
|
||||||
|
if (!target.entityType || !target.entityId) {
|
||||||
|
message.warning('当前记录缺少可操作实体');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const submitKey = `${record.sync_record_id}:${action}`;
|
||||||
|
setSubmittingAction(submitKey);
|
||||||
|
try {
|
||||||
|
const response = await apiDigitalEmployeeAdminActionSubmit({
|
||||||
|
syncRecordId: record.sync_record_id,
|
||||||
|
deviceId: record.device_id,
|
||||||
|
entityType: target.entityType,
|
||||||
|
entityId: target.entityId,
|
||||||
|
action,
|
||||||
|
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(
|
||||||
() => [
|
() => [
|
||||||
@@ -340,6 +442,28 @@ const DigitalEmployeeOps: React.FC = () => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' / ') || '--'}
|
.join(' / ') || '--'}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
{allowedActions(detailRecord, activeView).length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={5}>低风险动作</Typography.Title>
|
||||||
|
<Space size={[8, 8]} wrap>
|
||||||
|
{allowedActions(detailRecord, activeView).map((item) => {
|
||||||
|
const submitKey = `${detailRecord.sync_record_id}:${item.action}`;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.action}
|
||||||
|
loading={submittingAction === submitKey}
|
||||||
|
onClick={() => submitAdminAction(detailRecord, item.action)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
|
||||||
|
动作只会记录意图并等待客户端拉取执行。
|
||||||
|
</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
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { Page, RequestResponse } from '@/types/interfaces/request';
|
|||||||
import type {
|
import type {
|
||||||
AccessStatsResult,
|
AccessStatsResult,
|
||||||
AddSystemUserParams,
|
AddSystemUserParams,
|
||||||
|
DigitalEmployeeAdminActionResult,
|
||||||
|
DigitalEmployeeAdminActionSubmitParams,
|
||||||
ConversationStatsResult,
|
ConversationStatsResult,
|
||||||
DigitalEmployeeAdminWorkbenchListParams,
|
DigitalEmployeeAdminWorkbenchListParams,
|
||||||
DigitalEmployeeAdminWorkbenchPage,
|
DigitalEmployeeAdminWorkbenchPage,
|
||||||
@@ -310,6 +312,24 @@ export async function apiDigitalEmployeeAdminWorkbenchList(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录数字员工管理端低风险动作意图
|
||||||
|
export async function apiDigitalEmployeeAdminActionSubmit(
|
||||||
|
data: DigitalEmployeeAdminActionSubmitParams,
|
||||||
|
): Promise<RequestResponse<DigitalEmployeeAdminActionResult>> {
|
||||||
|
return request('/api/digital-employee/admin/actions', {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
sync_record_id: data.syncRecordId,
|
||||||
|
client_key: data.clientKey,
|
||||||
|
device_id: data.deviceId,
|
||||||
|
entity_type: data.entityType,
|
||||||
|
entity_id: data.entityId,
|
||||||
|
action: data.action,
|
||||||
|
request_payload: data.requestPayload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 查询网页应用列表
|
// 查询网页应用列表
|
||||||
export async function apiSystemResourceWebappList(
|
export async function apiSystemResourceWebappList(
|
||||||
data: SystemWebappListParams,
|
data: SystemWebappListParams,
|
||||||
|
|||||||
@@ -618,6 +618,29 @@ export interface DigitalEmployeeAdminWorkbenchPage {
|
|||||||
records: DigitalEmployeeAdminWorkbenchRow[];
|
records: DigitalEmployeeAdminWorkbenchRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeAdminActionSubmitParams {
|
||||||
|
syncRecordId?: number;
|
||||||
|
clientKey?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
action: string;
|
||||||
|
requestPayload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeAdminActionResult {
|
||||||
|
ok: boolean;
|
||||||
|
status?: string;
|
||||||
|
reason_codes?: string[];
|
||||||
|
action?: {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
action: string;
|
||||||
|
entity_type?: string;
|
||||||
|
entity_id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 网页应用信息
|
// 网页应用信息
|
||||||
export type SystemWebappInfo = SystemResourceInfo;
|
export type SystemWebappInfo = SystemResourceInfo;
|
||||||
|
|
||||||
|
|||||||
@@ -1249,6 +1249,13 @@ export function pullApprovalUpdates(): Promise<{ ok: boolean; applied?: number;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pullAdminActions(): Promise<{ ok: boolean; applied?: number; rejected?: number; failed?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
|
||||||
|
return apiFetch<{ ok: boolean; applied?: number; rejected?: number; failed?: number; ignored?: number; skipped?: boolean; error?: string | null }>('/api/digital-employee/admin/actions/pull', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getDebugConversations(): Promise<any[]> {
|
export function getDebugConversations(): Promise<any[]> {
|
||||||
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
|
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
|
||||||
.then((data) => data.conversations ?? []);
|
.then((data) => data.conversations ?? []);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ declare global {
|
|||||||
pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
|
pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
|
||||||
pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
|
pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
|
||||||
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }>;
|
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }>;
|
||||||
|
pullAdminActions?: () => Promise<{ ok?: boolean; applied?: number; rejected?: number; failed?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
||||||
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
||||||
resolveApprovalConflict?: (input: QimingclawApprovalConflictResolutionInput) => Promise<QimingclawApprovalConflictResolutionResponse>;
|
resolveApprovalConflict?: (input: QimingclawApprovalConflictResolutionInput) => Promise<QimingclawApprovalConflictResolutionResponse>;
|
||||||
pullNotificationUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
pullNotificationUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
||||||
@@ -1183,6 +1184,9 @@ export async function handleQimingclawDigitalApi<T>(
|
|||||||
if (method === 'POST' && pathname === '/api/notifications/updates/pull') {
|
if (method === 'POST' && pathname === '/api/notifications/updates/pull') {
|
||||||
return await pullBridgeNotificationUpdates() as T;
|
return await pullBridgeNotificationUpdates() as T;
|
||||||
}
|
}
|
||||||
|
if (method === 'POST' && pathname === '/api/digital-employee/admin/actions/pull') {
|
||||||
|
return await pullBridgeAdminActions() as T;
|
||||||
|
}
|
||||||
if (method === 'POST' && pathname === '/api/notifications/desktop') {
|
if (method === 'POST' && pathname === '/api/notifications/desktop') {
|
||||||
return await showBridgeDesktopNotification(parseOptionalJsonBody(options.body)) as T;
|
return await showBridgeDesktopNotification(parseOptionalJsonBody(options.body)) as T;
|
||||||
}
|
}
|
||||||
@@ -1501,6 +1505,27 @@ async function pullBridgeNotificationUpdates(): Promise<{ ok: boolean; applied?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pullBridgeAdminActions(): Promise<{ ok: boolean; applied?: number; rejected?: number; failed?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
|
||||||
|
const bridge = window.QimingClawBridge?.digital;
|
||||||
|
if (!bridge?.pullAdminActions) {
|
||||||
|
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await bridge.pullAdminActions();
|
||||||
|
return {
|
||||||
|
ok: result?.ok !== false,
|
||||||
|
applied: Number(result?.applied ?? 0),
|
||||||
|
rejected: Number(result?.rejected ?? 0),
|
||||||
|
failed: Number(result?.failed ?? 0),
|
||||||
|
ignored: Number(result?.ignored ?? 0),
|
||||||
|
...(result?.skipped ? { skipped: true } : {}),
|
||||||
|
...(result?.error ? { error: result.error } : {}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function showBridgeDesktopNotification(input: Record<string, unknown>): Promise<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }> {
|
async function showBridgeDesktopNotification(input: Record<string, unknown>): Promise<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }> {
|
||||||
const bridge = window.QimingClawBridge?.digital;
|
const bridge = window.QimingClawBridge?.digital;
|
||||||
if (!bridge?.showDesktopNotification) {
|
if (!bridge?.showDesktopNotification) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
|||||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="./assets/index-DUKd5ydN.js"></script>
|
<script type="module" crossorigin src="./assets/index-KaWiGpaZ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-D7Rysr2C.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-D7Rysr2C.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1149,6 +1149,38 @@ function checkDigitalPolicySchedulerWorkbenches() {
|
|||||||
console.log("[DigitalEmployeeCheck] policy center and scheduler workbench hooks OK");
|
console.log("[DigitalEmployeeCheck] policy center and scheduler workbench hooks OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkDigitalAdminActionLoop() {
|
||||||
|
console.log("\n[DigitalEmployeeCheck] Verify admin action pull/apply loop hooks");
|
||||||
|
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
|
||||||
|
const ipcPath = path.join(packageRoot, "src", "main", "ipc", "digitalEmployeeHandlers.ts");
|
||||||
|
const preloadPath = path.join(packageRoot, "src", "preload", "webviewPerfBridge.ts");
|
||||||
|
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
|
||||||
|
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
|
||||||
|
const syncService = fs.readFileSync(syncServicePath, "utf8");
|
||||||
|
const ipc = fs.readFileSync(ipcPath, "utf8");
|
||||||
|
const preload = fs.readFileSync(preloadPath, "utf8");
|
||||||
|
const adapter = fs.readFileSync(adapterPath, "utf8");
|
||||||
|
const api = fs.readFileSync(apiPath, "utf8");
|
||||||
|
const requiredSnippets = [
|
||||||
|
[syncService, "pullAndApplyDigitalEmployeeAdminActions", "admin action pull service"],
|
||||||
|
[syncService, "admin_action_applied", "admin action applied event"],
|
||||||
|
[syncService, "admin_action_rejected", "admin action rejected event"],
|
||||||
|
[syncService, "admin_action_failed", "admin action failed event"],
|
||||||
|
[syncService, "cleanup_file_batch", "high risk cleanup rejection guard"],
|
||||||
|
[ipc, "digitalEmployee:pullAdminActions", "admin action IPC"],
|
||||||
|
[preload, "pullAdminActions", "admin action preload bridge"],
|
||||||
|
[adapter, "/api/digital-employee/admin/actions/pull", "admin action adapter endpoint"],
|
||||||
|
[api, "pullAdminActions", "embedded admin action API helper"],
|
||||||
|
];
|
||||||
|
const missing = requiredSnippets
|
||||||
|
.filter(([content, snippet]) => !content.includes(snippet))
|
||||||
|
.map(([, , label]) => label);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing admin action loop hooks: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
console.log("[DigitalEmployeeCheck] admin action pull/apply loop 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");
|
||||||
@@ -1207,6 +1239,7 @@ function main() {
|
|||||||
checkDigitalMemoryGovernanceAudit();
|
checkDigitalMemoryGovernanceAudit();
|
||||||
checkDigitalOpsNotificationReportWorkbenches();
|
checkDigitalOpsNotificationReportWorkbenches();
|
||||||
checkDigitalPolicySchedulerWorkbenches();
|
checkDigitalPolicySchedulerWorkbenches();
|
||||||
|
checkDigitalAdminActionLoop();
|
||||||
checkLocalDatabaseSchema();
|
checkLocalDatabaseSchema();
|
||||||
console.log("\n[DigitalEmployeeCheck] All checks passed");
|
console.log("\n[DigitalEmployeeCheck] All checks passed");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
|
|||||||
pullDigitalEmployeeApprovalUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
|
pullDigitalEmployeeApprovalUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
|
||||||
submitDigitalEmployeeApprovalDecision: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
|
submitDigitalEmployeeApprovalDecision: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
|
||||||
pullDigitalEmployeeNotificationUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
|
pullDigitalEmployeeNotificationUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
|
||||||
|
pullAndApplyDigitalEmployeeAdminActions: vi.fn(async () => ({ ok: true, applied: 1, rejected: 0, failed: 0, ignored: 0 })),
|
||||||
submitDigitalEmployeeNotificationAction: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
|
submitDigitalEmployeeNotificationAction: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -481,6 +482,22 @@ describe("digital employee managed service actions", () => {
|
|||||||
expect(syncService.submitDigitalEmployeeNotificationAction).toHaveBeenCalledWith({});
|
expect(syncService.submitDigitalEmployeeNotificationAction).toHaveBeenCalledWith({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers admin action pull through IPC", async () => {
|
||||||
|
const electron = await import("electron");
|
||||||
|
const syncService = await import("../services/digitalEmployee/syncService");
|
||||||
|
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||||
|
|
||||||
|
registerDigitalEmployeeHandlers(createCtx());
|
||||||
|
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
|
||||||
|
const pullCall = calls.find(([channel]) => channel === "digitalEmployee:pullAdminActions");
|
||||||
|
expect(pullCall).toBeTruthy();
|
||||||
|
|
||||||
|
const result = await (pullCall?.[1] as (_event: unknown) => Promise<unknown>)({});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: true, applied: 1 });
|
||||||
|
expect(syncService.pullAndApplyDigitalEmployeeAdminActions).toHaveBeenCalledWith({ force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("registers desktop notification requests through IPC", async () => {
|
it("registers desktop notification requests through IPC", async () => {
|
||||||
const electron = await import("electron");
|
const electron = await import("electron");
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
pullDigitalEmployeeRouteDecisionPolicy,
|
pullDigitalEmployeeRouteDecisionPolicy,
|
||||||
submitDigitalEmployeeApprovalDecision,
|
submitDigitalEmployeeApprovalDecision,
|
||||||
pullDigitalEmployeeNotificationUpdates,
|
pullDigitalEmployeeNotificationUpdates,
|
||||||
|
pullAndApplyDigitalEmployeeAdminActions,
|
||||||
submitDigitalEmployeeNotificationAction,
|
submitDigitalEmployeeNotificationAction,
|
||||||
} from "../services/digitalEmployee/syncService";
|
} from "../services/digitalEmployee/syncService";
|
||||||
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
||||||
@@ -240,6 +241,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
return pullDigitalEmployeeNotificationUpdates({ force: true });
|
return pullDigitalEmployeeNotificationUpdates({ force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("digitalEmployee:pullAdminActions", async () => {
|
||||||
|
return pullAndApplyDigitalEmployeeAdminActions({ force: true });
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("digitalEmployee:submitNotificationAction", async (_, input: unknown) => {
|
ipcMain.handle("digitalEmployee:submitNotificationAction", async (_, input: unknown) => {
|
||||||
return submitDigitalEmployeeNotificationAction(typeof input === "object" && input ? input as Record<string, unknown> : {});
|
return submitDigitalEmployeeNotificationAction(typeof input === "object" && input ? input as Record<string, unknown> : {});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -650,6 +650,73 @@ describe("digital employee sync service", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pulls low-risk admin actions, applies them locally, and reports results", async () => {
|
||||||
|
mockState.fetch
|
||||||
|
.mockResolvedValueOnce(jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
entity_type: "artifact",
|
||||||
|
entity_id: "artifact-1",
|
||||||
|
action: "mark_expire",
|
||||||
|
request_payload: { reason: "管理端标记到期" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ success: true, data: { ok: true } }));
|
||||||
|
|
||||||
|
const { pullAndApplyDigitalEmployeeAdminActions } = await import("./syncService");
|
||||||
|
const result = await pullAndApplyDigitalEmployeeAdminActions({ force: true });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
endpoint: "https://manage.example.com/api/digital-employee/admin/actions/pending?device_id=device-001",
|
||||||
|
applied: 1,
|
||||||
|
rejected: 0,
|
||||||
|
failed: 0,
|
||||||
|
ignored: 0,
|
||||||
|
});
|
||||||
|
expect(mockState.fetch).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"https://manage.example.com/api/digital-employee/admin/actions/pending?device_id=device-001",
|
||||||
|
expect.objectContaining({ method: "GET" }),
|
||||||
|
);
|
||||||
|
expect(mockState.fetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"https://manage.example.com/api/digital-employee/admin/actions/10/result",
|
||||||
|
expect.objectContaining({ method: "POST", body: expect.stringContaining("admin_action_applied") }),
|
||||||
|
);
|
||||||
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "artifact_retention_marked" }),
|
||||||
|
expect.objectContaining({ kind: "admin_action_applied" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects high-risk admin actions without changing local facts", async () => {
|
||||||
|
mockState.fetch
|
||||||
|
.mockResolvedValueOnce(jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: { records: [{ id: 11, entity_type: "artifact", entity_id: "artifact-1", action: "cleanup_file_batch" }] },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ success: true, data: { ok: true } }));
|
||||||
|
|
||||||
|
const { pullAndApplyDigitalEmployeeAdminActions } = await import("./syncService");
|
||||||
|
const result = await pullAndApplyDigitalEmployeeAdminActions({ force: true });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: true, applied: 0, rejected: 1, failed: 0 });
|
||||||
|
expect(mockState.fetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"https://manage.example.com/api/digital-employee/admin/actions/11/result",
|
||||||
|
expect.objectContaining({ method: "POST", body: expect.stringContaining("admin_action_rejected") }),
|
||||||
|
);
|
||||||
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "admin_action_rejected" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["skillPolicies"],
|
["skillPolicies"],
|
||||||
["policies"],
|
["policies"],
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import {
|
|||||||
normalizePlanStepDispatchPolicy,
|
normalizePlanStepDispatchPolicy,
|
||||||
normalizeRiskApprovalPolicy,
|
normalizeRiskApprovalPolicy,
|
||||||
normalizeRouteDecisionPolicy,
|
normalizeRouteDecisionPolicy,
|
||||||
|
recordDigitalEmployeeArtifactLifecycle,
|
||||||
|
recordDigitalEmployeeDailyReportDelivery,
|
||||||
|
recordDigitalEmployeeRouteInterventionResolution,
|
||||||
|
recordDigitalEmployeeRuntimeEvent,
|
||||||
readDigitalEmployeeUiState,
|
readDigitalEmployeeUiState,
|
||||||
|
resolveDigitalEmployeeApprovalConflict,
|
||||||
saveDigitalEmployeeUiState,
|
saveDigitalEmployeeUiState,
|
||||||
upsertDigitalEmployeeApprovalFromRemote,
|
upsertDigitalEmployeeApprovalFromRemote,
|
||||||
type DigitalEmployeeApprovalUpdateInput,
|
type DigitalEmployeeApprovalUpdateInput,
|
||||||
@@ -30,6 +35,7 @@ const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
|
|||||||
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
|
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
|
||||||
const DEFAULT_NOTIFICATION_UPDATES_PATH = "/api/digital-employee/notifications/updates";
|
const DEFAULT_NOTIFICATION_UPDATES_PATH = "/api/digital-employee/notifications/updates";
|
||||||
const DEFAULT_NOTIFICATION_ACTIONS_PATH = "/api/digital-employee/notifications/actions";
|
const DEFAULT_NOTIFICATION_ACTIONS_PATH = "/api/digital-employee/notifications/actions";
|
||||||
|
const DEFAULT_ADMIN_ACTIONS_PATH = "/api/digital-employee/admin/actions";
|
||||||
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
|
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
|
||||||
const MANAGEMENT_SYNC_RECORD_PATH =
|
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||||
"/system/content/content-digital-employee-sync";
|
"/system/content/content-digital-employee-sync";
|
||||||
@@ -41,6 +47,7 @@ const POLICY_PULL_THROTTLE_MS = 60_000;
|
|||||||
const MAX_RETRY_DELAY_MS = 30 * 60_000;
|
const MAX_RETRY_DELAY_MS = 30 * 60_000;
|
||||||
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
|
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
|
||||||
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||||
|
const HIGH_RISK_ADMIN_ACTIONS = new Set(["accept_remote_approval", "cleanup_file_batch", "start_service", "stop_service", "hard_interrupt"]);
|
||||||
|
|
||||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
@@ -75,6 +82,7 @@ interface DigitalEmployeeSyncConfig {
|
|||||||
approvalDecisionsEndpoint: string | null;
|
approvalDecisionsEndpoint: string | null;
|
||||||
notificationUpdatesEndpoint: string | null;
|
notificationUpdatesEndpoint: string | null;
|
||||||
notificationActionsEndpoint: string | null;
|
notificationActionsEndpoint: string | null;
|
||||||
|
adminActionsEndpoint: string | null;
|
||||||
leaseEndpoint: string | null;
|
leaseEndpoint: string | null;
|
||||||
clientKey: string | null;
|
clientKey: string | null;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
@@ -152,6 +160,26 @@ export interface DigitalEmployeeNotificationActionSubmitResult {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeAdminActionPullResult {
|
||||||
|
ok: boolean;
|
||||||
|
skipped?: boolean;
|
||||||
|
endpoint: string | null;
|
||||||
|
pulled_at: string | null;
|
||||||
|
applied: number;
|
||||||
|
rejected: number;
|
||||||
|
failed: number;
|
||||||
|
ignored: number;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DigitalEmployeeAdminActionItem {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
action: string;
|
||||||
|
requestPayload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigitalEmployeePlanStepRemoteLease {
|
export interface DigitalEmployeePlanStepRemoteLease {
|
||||||
lease_id: string;
|
lease_id: string;
|
||||||
state: "active" | "released" | "expired";
|
state: "active" | "released" | "expired";
|
||||||
@@ -827,6 +855,64 @@ export async function submitDigitalEmployeeNotificationAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pullAndApplyDigitalEmployeeAdminActions(
|
||||||
|
options: { force?: boolean } = {},
|
||||||
|
): Promise<DigitalEmployeeAdminActionPullResult> {
|
||||||
|
const config = resolveSyncConfig();
|
||||||
|
const endpoint = buildAdminActionsPendingEndpoint(config.adminActionsEndpoint);
|
||||||
|
const missingCredentials = missingSyncCredentialLabels(config);
|
||||||
|
const pulledAt = new Date().toISOString();
|
||||||
|
if (!endpoint || missingCredentials.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
endpoint,
|
||||||
|
pulled_at: pulledAt,
|
||||||
|
applied: 0,
|
||||||
|
rejected: 0,
|
||||||
|
failed: 0,
|
||||||
|
ignored: 0,
|
||||||
|
error: !endpoint ? "management admin actions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetchJsonWithAuth(config, endpoint);
|
||||||
|
const responseError = readSyncResponseError(response as SyncResponse);
|
||||||
|
if (responseError) throw new Error(responseError);
|
||||||
|
const actions = readRemoteAdminActions(response);
|
||||||
|
let applied = 0;
|
||||||
|
let rejected = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let ignored = 0;
|
||||||
|
for (const action of actions) {
|
||||||
|
if (!action.id) {
|
||||||
|
ignored += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await applyDigitalEmployeeAdminAction(action);
|
||||||
|
if (result.status === "applied") applied += 1;
|
||||||
|
else if (result.status === "rejected") rejected += 1;
|
||||||
|
else failed += 1;
|
||||||
|
await postDigitalEmployeeAdminActionResult(config, action.id, result.status, result.reasonCodes);
|
||||||
|
}
|
||||||
|
recordDigitalEmployeeRuntimeEvent({
|
||||||
|
kind: "admin_actions_pulled",
|
||||||
|
status: "completed",
|
||||||
|
message: "管理端远程运营动作已拉取",
|
||||||
|
payload: { action: "pull_admin_actions", endpoint, applied, rejected, failed, ignored, source: "management-api" },
|
||||||
|
});
|
||||||
|
return { ok: true, endpoint, pulled_at: pulledAt, applied, rejected, failed, ignored, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
const message = normalizeError(error);
|
||||||
|
recordDigitalEmployeeRuntimeEvent({
|
||||||
|
kind: "admin_action_failed",
|
||||||
|
status: "failed",
|
||||||
|
message: "管理端远程运营动作拉取失败",
|
||||||
|
payload: { action: "pull_admin_actions", endpoint, error: message, source: "management-api" },
|
||||||
|
});
|
||||||
|
return { ok: false, endpoint, pulled_at: pulledAt, applied: 0, rejected: 0, failed: 0, ignored: 0, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function acquireDigitalEmployeePlanStepRemoteLease(
|
export async function acquireDigitalEmployeePlanStepRemoteLease(
|
||||||
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
|
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
|
||||||
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
||||||
@@ -1066,6 +1152,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
|||||||
? config.notificationActionsEndpoint.trim()
|
? config.notificationActionsEndpoint.trim()
|
||||||
: null;
|
: null;
|
||||||
const notificationActionsEndpoint = notificationActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_ACTIONS_PATH);
|
const notificationActionsEndpoint = notificationActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_ACTIONS_PATH);
|
||||||
|
const adminActionsEndpointOverride =
|
||||||
|
typeof config.adminActionsEndpoint === "string" && config.adminActionsEndpoint.trim()
|
||||||
|
? config.adminActionsEndpoint.trim()
|
||||||
|
: null;
|
||||||
|
const adminActionsEndpoint = adminActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_ADMIN_ACTIONS_PATH);
|
||||||
const leaseEndpointOverride =
|
const leaseEndpointOverride =
|
||||||
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
|
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
|
||||||
? config.planStepDispatchLeaseEndpoint.trim()
|
? config.planStepDispatchLeaseEndpoint.trim()
|
||||||
@@ -1088,6 +1179,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
|||||||
approvalDecisionsEndpoint,
|
approvalDecisionsEndpoint,
|
||||||
notificationUpdatesEndpoint,
|
notificationUpdatesEndpoint,
|
||||||
notificationActionsEndpoint,
|
notificationActionsEndpoint,
|
||||||
|
adminActionsEndpoint,
|
||||||
leaseEndpoint,
|
leaseEndpoint,
|
||||||
clientKey: readClientKey(serverHost),
|
clientKey: readClientKey(serverHost),
|
||||||
authToken: readAuthToken(serverHost),
|
authToken: readAuthToken(serverHost),
|
||||||
@@ -1100,6 +1192,17 @@ function buildLeaseOperationEndpoint(baseEndpoint: string | null, operation: "ac
|
|||||||
return `${baseEndpoint.replace(/\/+$/, "")}/${operation}`;
|
return `${baseEndpoint.replace(/\/+$/, "")}/${operation}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAdminActionsPendingEndpoint(baseEndpoint: string | null): string | null {
|
||||||
|
if (!baseEndpoint) return null;
|
||||||
|
const params = new URLSearchParams({ device_id: getDeviceId() });
|
||||||
|
return `${baseEndpoint.replace(/\/+$/, "")}/pending?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminActionResultEndpoint(baseEndpoint: string | null, actionId: string): string | null {
|
||||||
|
if (!baseEndpoint) return null;
|
||||||
|
return `${baseEndpoint.replace(/\/+$/, "")}/${encodeURIComponent(actionId)}/result`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
|
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
|
||||||
if (!serverHost) return null;
|
if (!serverHost) return null;
|
||||||
const normalized = serverHost.startsWith("http")
|
const normalized = serverHost.startsWith("http")
|
||||||
@@ -1320,6 +1423,145 @@ function readRemoteNotificationUpdates(response: Record<string, unknown>): Array
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readRemoteAdminActions(response: Record<string, unknown>): DigitalEmployeeAdminActionItem[] {
|
||||||
|
const data = objectRecord(response.data) ?? response;
|
||||||
|
const raw = Array.isArray(data.records)
|
||||||
|
? data.records
|
||||||
|
: Array.isArray(data.actions)
|
||||||
|
? data.actions
|
||||||
|
: Array.isArray(data.items)
|
||||||
|
? data.items
|
||||||
|
: [];
|
||||||
|
return raw
|
||||||
|
.map((item) => objectRecord(item))
|
||||||
|
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||||
|
.map((item) => ({
|
||||||
|
id: stringField(item.id) || numericIdField(item.id),
|
||||||
|
entityType: normalizeAdminActionText(item.entity_type ?? item.entityType),
|
||||||
|
entityId: stringField(item.entity_id ?? item.entityId) || "",
|
||||||
|
action: normalizeAdminActionText(item.action),
|
||||||
|
requestPayload: objectRecord(item.request_payload ?? item.requestPayload) ?? {},
|
||||||
|
}))
|
||||||
|
.filter((item) => Boolean(item.id && item.entityType && item.entityId && item.action));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDigitalEmployeeAdminAction(action: DigitalEmployeeAdminActionItem): Promise<{ status: "applied" | "rejected" | "failed"; reasonCodes: string[] }> {
|
||||||
|
try {
|
||||||
|
if (!isAllowedAdminAction(action)) {
|
||||||
|
return recordAdminActionRuntimeResult(action, "rejected", ["admin_action_rejected", "unsupported_or_high_risk_action"]);
|
||||||
|
}
|
||||||
|
const payload = action.requestPayload;
|
||||||
|
if (action.entityType === "approval") {
|
||||||
|
const result = resolveDigitalEmployeeApprovalConflict({
|
||||||
|
approvalId: action.entityId,
|
||||||
|
resolution: action.action,
|
||||||
|
actor: "management-admin-action",
|
||||||
|
comment: stringField(payload.reason ?? payload.comment) || null,
|
||||||
|
});
|
||||||
|
const ok = result?.status !== "rejected";
|
||||||
|
return recordAdminActionRuntimeResult(action, ok ? "applied" : "rejected", ok ? ["admin_action_applied"] : ["admin_action_rejected", ...(result?.reason_codes ?? ["approval_action_rejected"])]);
|
||||||
|
}
|
||||||
|
if (action.entityType === "artifact") {
|
||||||
|
const artifactAction = action.action === "mark_reviewed" ? "mark_review" : action.action;
|
||||||
|
recordDigitalEmployeeArtifactLifecycle({
|
||||||
|
artifactId: action.entityId,
|
||||||
|
action: artifactAction,
|
||||||
|
actor: "management-admin-action",
|
||||||
|
reason: stringField(payload.reason) || "admin_action",
|
||||||
|
retentionUntil: stringField(payload.retention_until ?? payload.retentionUntil) || null,
|
||||||
|
source: "management-admin-action",
|
||||||
|
});
|
||||||
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
|
||||||
|
}
|
||||||
|
if (action.entityType === "daily_report") {
|
||||||
|
const deliveryAction = action.action === "mark_confirmed" ? "confirm" : action.action === "request_approval" ? "request_approval" : "send";
|
||||||
|
recordDigitalEmployeeDailyReportDelivery({
|
||||||
|
reportId: action.entityId,
|
||||||
|
action: deliveryAction,
|
||||||
|
status: action.action.replace(/^mark_/, ""),
|
||||||
|
actor: "management-admin-action",
|
||||||
|
message: stringField(payload.message ?? payload.reason) || null,
|
||||||
|
});
|
||||||
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
|
||||||
|
}
|
||||||
|
if (action.entityType === "route" || action.entityType === "route_decision") {
|
||||||
|
recordDigitalEmployeeRouteInterventionResolution({
|
||||||
|
routeDecisionId: action.entityId,
|
||||||
|
decision: action.action,
|
||||||
|
actor: "management-admin-action",
|
||||||
|
reasonCodes: ["admin_action_applied", action.action],
|
||||||
|
commandAccepted: false,
|
||||||
|
});
|
||||||
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
|
||||||
|
}
|
||||||
|
if (action.entityType === "notification") {
|
||||||
|
const currentState = readDigitalEmployeeUiState();
|
||||||
|
const notificationStateById: Record<string, unknown> = { ...(currentState.notificationStateById ?? {}) };
|
||||||
|
const current = objectRecord(notificationStateById[action.entityId]) ?? {};
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
notificationStateById[action.entityId] = {
|
||||||
|
...current,
|
||||||
|
status: action.action === "dismiss" ? "dismissed" : "read",
|
||||||
|
read_at: action.action === "mark_read" ? now : current.read_at,
|
||||||
|
dismissed_at: action.action === "dismiss" ? now : current.dismissed_at,
|
||||||
|
source: "management-admin-action",
|
||||||
|
};
|
||||||
|
saveDigitalEmployeeUiState({ action: "admin_notification_action", metadata: { admin_action_id: action.id, notification_id: action.entityId }, state: { ...currentState, notificationStateById } });
|
||||||
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
|
||||||
|
}
|
||||||
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied", "audit_overlay_recorded"]);
|
||||||
|
} catch (error) {
|
||||||
|
return recordAdminActionRuntimeResult(action, "failed", ["admin_action_failed", normalizeError(error)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedAdminAction(action: DigitalEmployeeAdminActionItem): boolean {
|
||||||
|
if (HIGH_RISK_ADMIN_ACTIONS.has(action.action)) return false;
|
||||||
|
return new Set([
|
||||||
|
"approval:keep_local", "approval:mark_reviewed",
|
||||||
|
"route:mark_reviewed", "route:dismiss", "route:retry_policy_check",
|
||||||
|
"route_decision:mark_reviewed", "route_decision:dismiss", "route_decision:retry_policy_check",
|
||||||
|
"notification:mark_read", "notification:dismiss",
|
||||||
|
"artifact:mark_keep", "artifact:mark_expire", "artifact:mark_reviewed",
|
||||||
|
"daily_report:mark_delivered", "daily_report:mark_confirmed", "daily_report:request_approval",
|
||||||
|
"audit:mark_reviewed", "audit:dismiss",
|
||||||
|
"event:mark_reviewed", "event:dismiss",
|
||||||
|
]).has(`${action.entityType}:${action.action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordAdminActionRuntimeResult(
|
||||||
|
action: DigitalEmployeeAdminActionItem,
|
||||||
|
status: "applied" | "rejected" | "failed",
|
||||||
|
reasonCodes: string[],
|
||||||
|
): { status: "applied" | "rejected" | "failed"; reasonCodes: string[] } {
|
||||||
|
recordDigitalEmployeeRuntimeEvent({
|
||||||
|
kind: status === "applied" ? "admin_action_applied" : status === "rejected" ? "admin_action_rejected" : "admin_action_failed",
|
||||||
|
status: status === "applied" ? "completed" : "failed",
|
||||||
|
message: `管理端动作${status}: ${action.action}`,
|
||||||
|
payload: {
|
||||||
|
admin_action_id: action.id,
|
||||||
|
entity_type: action.entityType,
|
||||||
|
entity_id: action.entityId,
|
||||||
|
action: action.action,
|
||||||
|
status,
|
||||||
|
reason_codes: reasonCodes,
|
||||||
|
source: "management-admin-action",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { status, reasonCodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDigitalEmployeeAdminActionResult(
|
||||||
|
config: DigitalEmployeeSyncConfig,
|
||||||
|
actionId: string,
|
||||||
|
status: "applied" | "rejected" | "failed",
|
||||||
|
reasonCodes: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const endpoint = buildAdminActionResultEndpoint(config.adminActionsEndpoint, actionId);
|
||||||
|
if (!endpoint) return;
|
||||||
|
await fetchJsonWithAuth(config, endpoint, { status, reason_codes: reasonCodes });
|
||||||
|
}
|
||||||
|
|
||||||
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
|
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
|
||||||
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
|
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
|
||||||
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
|
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
|
||||||
@@ -2489,6 +2731,14 @@ function stringField(value: unknown): string {
|
|||||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAdminActionText(value: unknown): string {
|
||||||
|
return stringField(value).toLowerCase().replace(/-/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericIdField(value: unknown): string {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? String(value) : "";
|
||||||
|
}
|
||||||
|
|
||||||
function stringArrayField(value: unknown): string[] {
|
function stringArrayField(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.map(stringField).filter(Boolean);
|
return value.map(stringField).filter(Boolean);
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
|||||||
async pullNotificationUpdates() {
|
async pullNotificationUpdates() {
|
||||||
return ipcRenderer.invoke("digitalEmployee:pullNotificationUpdates");
|
return ipcRenderer.invoke("digitalEmployee:pullNotificationUpdates");
|
||||||
},
|
},
|
||||||
|
async pullAdminActions() {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:pullAdminActions");
|
||||||
|
},
|
||||||
async submitNotificationAction(input: unknown) {
|
async submitNotificationAction(input: unknown) {
|
||||||
return ipcRenderer.invoke("digitalEmployee:submitNotificationAction", input);
|
return ipcRenderer.invoke("digitalEmployee:submitNotificationAction", input);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ GET /api/digital-employee/approvals
|
|||||||
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
|
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
|
||||||
|
|
||||||
12. **管理端业务查询模型**
|
12. **管理端业务查询模型**
|
||||||
- 已吸收:管理端已有通用 sync record 接收、查询、payload 摘要和深链;后端 sync record 已开始保存客户端顶层 `contract_version`、`entity_summary` 和 `business_view`,并基于现有 `digital_employee_sync_record` 拆出 `/api/digital-employee/plans`、`/plans/{plan_id}`、`/tasks`、`/runs`、`/events`、`/artifacts`、`/approvals` 管理端业务查询模型;列表支持客户端 Key、设备、实体 ID、状态、同步状态和同步时间范围过滤,业务 row 会提炼 title/status/summary、Plan/Task/Run 关联、发生/开始/结束时间、sync record 深链和脱敏客户端 Key;Plan 视图会聚合 task/run 计数与最近事件,Plan 详情会返回关联 tasks/runs/events/artifacts/approvals 摘要。embedded adapter 已拆出同名只读业务查询接口,从 qimingclaw runtime、artifact、approval 和 canonical event 投影生成统一分页视图;embedded 数字员工页已新增“业务视图”入口,支持计划/任务/运行/事件/产物/审批分段、实体/状态/同步状态/时间筛选、分页和详情 JSON 查看;客户端 outbox 的 event `business_view` 已开始按 route decision、governance command、approval action、artifact lifecycle/access、skill call audit 和 ready PlanStep dispatch 细分常用字段,本地与管理端业务视图投影已开始闭环。正式外部管理端台面已开始吸收:qiming-backend 新增 `/api/digital-employee/admin/workbench/{view}`,qiming 管理端新增“数字员工运营台”,统一展示业务视图、介入台、产物治理、通知运营、审计诊断、日报运营和路由治理,只读消费 sync record `business_view` 并提供同步记录深链。
|
- 已吸收:管理端已有通用 sync record 接收、查询、payload 摘要和深链;后端 sync record 已开始保存客户端顶层 `contract_version`、`entity_summary` 和 `business_view`,并基于现有 `digital_employee_sync_record` 拆出 `/api/digital-employee/plans`、`/plans/{plan_id}`、`/tasks`、`/runs`、`/events`、`/artifacts`、`/approvals` 管理端业务查询模型;列表支持客户端 Key、设备、实体 ID、状态、同步状态和同步时间范围过滤,业务 row 会提炼 title/status/summary、Plan/Task/Run 关联、发生/开始/结束时间、sync record 深链和脱敏客户端 Key;Plan 视图会聚合 task/run 计数与最近事件,Plan 详情会返回关联 tasks/runs/events/artifacts/approvals 摘要。embedded adapter 已拆出同名只读业务查询接口,从 qimingclaw runtime、artifact、approval 和 canonical event 投影生成统一分页视图;embedded 数字员工页已新增“业务视图”入口,支持计划/任务/运行/事件/产物/审批分段、实体/状态/同步状态/时间筛选、分页和详情 JSON 查看;客户端 outbox 的 event `business_view` 已开始按 route decision、governance command、approval action、artifact lifecycle/access、skill call audit 和 ready PlanStep dispatch 细分常用字段,本地与管理端业务视图投影已开始闭环。正式外部管理端台面已开始吸收:qiming-backend 新增 `/api/digital-employee/admin/workbench/{view}`,qiming 管理端新增“数字员工运营台”,统一展示业务视图、介入台、产物治理、通知运营、审计诊断、日报运营和路由治理,只读消费 sync record `business_view` 并提供同步记录深链。管理端低风险动作闭环已开始吸收:后端新增 `/api/digital-employee/admin/actions` 记录动作意图和 `/pending` 拉取队列,qimingclaw 只应用保留本地、标记复核、通知已读/忽略、产物保留/到期、日报交付/确认/审批请求等低风险动作并写入 `admin_action_applied` / `admin_action_rejected` / `admin_action_failed`,qiming 运营台抽屉只提示“已记录,等待客户端拉取”。
|
||||||
- 缺口:复杂组合检索、跨设备聚合图、远程裁决/策略编辑动作和物化业务表仍待吸收。
|
- 缺口:复杂组合检索、跨设备聚合图、远程裁决/策略编辑动作和物化业务表仍待吸收。
|
||||||
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。
|
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user