package cn.iocoder.yudao.module.system.service.agent.imports; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; import cn.iocoder.yudao.module.system.api.agent.imports.*; import cn.iocoder.yudao.module.system.dal.dataobject.agent.AgentImportCommandDO; import cn.iocoder.yudao.module.system.dal.dataobject.agent.AgentImportCommandRowDO; import cn.iocoder.yudao.module.system.dal.mysql.agent.AgentImportCommandMapper; import cn.iocoder.yudao.module.system.dal.mysql.agent.AgentImportCommandRowMapper; import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException; import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.validation.annotation.Validated; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.LocalDateTime; import java.util.*; /** * 通用业务导入编排:适配器只负责单行校验/写入,本类统一处理权限、幂等、逐行事务与回执。 */ @Service @Validated public class SystemAgentGenericImportService { public static final String STATUS_RUNNING = "RUNNING"; public static final String STATUS_SUCCEEDED = "SUCCEEDED"; public static final String STATUS_PARTIAL_FAILED = "PARTIAL_FAILED"; public static final String STATUS_FAILED = "FAILED"; public static final String STATUS_UNKNOWN = "UNKNOWN"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final Duration LEASE_DURATION = Duration.ofMinutes(5); @Resource private ObjectProvider adapterProvider; @Resource private SecurityFrameworkService securityFrameworkService; @Resource private SystemAgentImportBridgeService attachmentService; @Resource private AgentImportCommandMapper commandMapper; @Resource private AgentImportCommandRowMapper commandRowMapper; @Resource private PlatformTransactionManager transactionManager; @Value("${yudao.agent.bridge.write-enabled:false}") private boolean writeEnabled; @Value("${yudao.agent.bridge.max-rows:5000}") private int maxRows; public List> modules() { return adapters().values().stream() .map(adapter -> Map.of("moduleId", adapter.moduleId(), "permission", adapter.permission())) .toList(); } public AgentImportPreflightRespVO preflight(String moduleId, AgentImportPreflightReqVO request, AgentPrincipal principal) { AgentImportModuleAdapter adapter = requireAdapter(moduleId); requirePermission(adapter); validateAttachment(request.getAttachmentId(), request.getSourceSha256()); validateRows(request); AgentImportPreflightRespVO result = adapter.preflight(principal, request); validatePlan(result, request); return result; } public AgentImportReceiptRespVO commit(String moduleId, AgentImportCommitReqVO request, AgentPrincipal principal) { if (!writeEnabled) { throw new AgentBridgeException(HttpStatus.SERVICE_UNAVAILABLE, "WRITE_DISABLED", "智能体写入开关未打开,提交已拒绝"); } AgentImportModuleAdapter adapter = requireAdapter(moduleId); requirePermission(adapter); validateAttachment(request.getAttachmentId(), request.getSourceSha256()); validateRows(request); Long ownerUserId = principal.userId(); String digest = requestDigest(moduleId, request); // 1. 幂等命中:命令 ID 优先,其次幂等键 AgentImportCommandDO existing = commandMapper.selectByCommandId( ownerUserId, moduleId, request.getCommandId()); if (existing != null) { requireSameDigest(existing, digest); return toReceipt(existing); } existing = commandMapper.selectByIdempotencyKey( ownerUserId, moduleId, request.getIdempotencyKey()); if (existing != null) { requireSameDigest(existing, digest); return toReceipt(existing); } // 2. 重新预检并比对计划摘要,防止确认后主数据变化 AgentImportPreflightRespVO plan = adapter.preflight(principal, request); validatePlan(plan, request); if (!StrUtil.equalsIgnoreCase(request.getPreflightHash(), plan.getPreflightHash())) { throw new AgentBridgeException(HttpStatus.CONFLICT, "PLAN_STALE", "预检计划已变化,请重新预检并确认", false, Map.of("preflight", plan)); } if (!Boolean.TRUE.equals(plan.getCanCommit())) { throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "PREFLIGHT_BLOCKED", "预检存在阻断项,不能提交", false, Map.of("preflight", plan)); } // 3. 登记命令(唯一键兜住并发重复提交) AgentImportCommandDO command = AgentImportCommandDO.builder() .commandId(request.getCommandId()) .idempotencyKey(request.getIdempotencyKey()) .requestDigest(digest) .moduleId(moduleId) .ownerUserId(ownerUserId) .sessionId(request.getSessionId()) .attachmentId(request.getAttachmentId()) .sourceSha256(request.getSourceSha256().toLowerCase(Locale.ROOT)) .preflightHash(request.getPreflightHash().toLowerCase(Locale.ROOT)) .proposalHash(request.getProposalHash()) .status(STATUS_RUNNING) .totalCount(request.getRows().size()) .successCount(0).failedCount(0).skippedCount(0) .leaseExpiresAt(LocalDateTime.now().plus(LEASE_DURATION)) .build(); try { commandMapper.insert(command); } catch (DuplicateKeyException ex) { AgentImportCommandDO raced = commandMapper.selectByCommandId( ownerUserId, moduleId, request.getCommandId()); if (raced == null) { raced = commandMapper.selectByIdempotencyKey( ownerUserId, moduleId, request.getIdempotencyKey()); } if (raced == null) { throw ex; } requireSameDigest(raced, digest); return toReceipt(raced); } // 4. 逐行独立事务执行;单行失败不影响其他行 int success = 0; int failed = 0; int skipped = 0; TransactionTemplate rowTx = new TransactionTemplate(transactionManager); try { for (int index = 0; index < request.getRows().size(); index++) { AgentImportPreflightReqVO.Row row = request.getRows().get(index); AgentImportPreflightRespVO.Row planned = plan.getRows().get(index); if ("SKIP".equals(planned.getAction())) { commandRowMapper.insert(baseRow(moduleId, request.getCommandId(), row) .action("SKIP").status("SKIPPED") .targetItemId(planned.getTargetId()).retryable(false).build()); skipped++; continue; } if (!"CREATE".equals(planned.getAction())) { commandRowMapper.insert(baseRow(moduleId, request.getCommandId(), row) .action("CREATE").status("FAILED") .errorCode("PREFLIGHT_BLOCKED").errorMessage("该行预检未通过").retryable(false).build()); failed++; continue; } try { AgentImportRowReceipt receipt = rowTx.execute(status -> adapter.commitRow(principal, request, planned, row)); if (receipt == null) { throw new IllegalStateException("适配器未返回回执"); } commandRowMapper.insert(baseRow(moduleId, request.getCommandId(), row) .action(StrUtil.blankToDefault(receipt.action(), "CREATE")) .status("SUCCESS") .targetItemId(receipt.targetId()) .targetVersion(receipt.targetVersion()) .code(StrUtil.blankToDefault(receipt.code(), row.getValues().get("code"))) .name(StrUtil.blankToDefault(receipt.name(), row.getValues().get("name"))) .retryable(false).build()); success++; } catch (Exception ex) { recordFailure(moduleId, request.getCommandId(), row, ex); failed++; } } } catch (Exception ex) { command.setStatus(STATUS_UNKNOWN); command.setErrorCode("COMMIT_INTERRUPTED"); command.setErrorMessage(trim(ex.getMessage(), 512)); command.setSuccessCount(success); command.setFailedCount(failed); command.setSkippedCount(skipped); command.setUpdateTime(LocalDateTime.now()); commandMapper.updateById(command); return toReceipt(command); } command.setSuccessCount(success); command.setFailedCount(failed); command.setSkippedCount(skipped); command.setStatus(failed == 0 ? STATUS_SUCCEEDED : (success + skipped == 0 ? STATUS_FAILED : STATUS_PARTIAL_FAILED)); command.setLeaseExpiresAt(LocalDateTime.now()); command.setUpdateTime(LocalDateTime.now()); commandMapper.updateById(command); return toReceipt(command); } public AgentImportReceiptRespVO getCommand(String moduleId, String commandId, AgentPrincipal principal) { requireAdapter(moduleId); AgentImportCommandDO command = commandMapper.selectByCommandId( principal.userId(), moduleId, commandId); if (command == null) { throw new AgentBridgeException(HttpStatus.NOT_FOUND, "COMMAND_NOT_FOUND", "导入命令不存在"); } return toReceipt(command); } private void recordFailure(String moduleId, String commandId, AgentImportPreflightReqVO.Row row, Exception ex) { String errorCode = "ROW_WRITE_FAILED"; boolean retryable = true; String message = ex.getMessage(); if (ex instanceof AgentBridgeException bridge) { errorCode = bridge.getErrorCode(); retryable = bridge.isRetryable(); message = bridge.getReason(); } else if (ex instanceof ServiceException service) { errorCode = "ROW_WRITE_REJECTED"; retryable = false; message = service.getMessage(); } commandRowMapper.insert(baseRow(moduleId, commandId, row) .action("CREATE").status("FAILED") .errorCode(errorCode).errorMessage(trim(message, 512)).retryable(retryable).build()); } private AgentImportPreflightRespVO.Row requirePlanRow(AgentImportPreflightRespVO plan, int index) { if (plan.getRows() == null || index >= plan.getRows().size()) { throw new AgentBridgeException(HttpStatus.BAD_GATEWAY, "ADAPTER_CONTRACT_INVALID", "适配器返回的预检行数与请求不一致"); } return plan.getRows().get(index); } private void validatePlan(AgentImportPreflightRespVO plan, AgentImportPreflightReqVO request) { if (plan == null || StrUtil.isBlank(plan.getPreflightHash())) { throw new AgentBridgeException(HttpStatus.BAD_GATEWAY, "ADAPTER_CONTRACT_INVALID", "适配器未返回预检摘要"); } if (plan.getRows() == null || plan.getRows().size() != request.getRows().size()) { throw new AgentBridgeException(HttpStatus.BAD_GATEWAY, "ADAPTER_CONTRACT_INVALID", "适配器返回的预检行数与请求不一致"); } for (int index = 0; index < plan.getRows().size(); index++) { if (!request.getRows().get(index).getCandidateId() .equals(plan.getRows().get(index).getCandidateId())) { throw new AgentBridgeException(HttpStatus.BAD_GATEWAY, "ADAPTER_CONTRACT_INVALID", "适配器返回的预检行顺序与请求不一致"); } } } private void validateAttachment(Long attachmentId, String sourceSha256) { String actual = attachmentService.sha256OfAttachment(attachmentId); if (!StrUtil.equalsIgnoreCase(actual, sourceSha256)) { throw new AgentBridgeException(HttpStatus.CONFLICT, "SOURCE_CHANGED", "附件内容已变化,请重新上传后再试", true, null); } } private void validateRows(AgentImportPreflightReqVO request) { if (request.getRows() == null || request.getRows().isEmpty()) { throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "ROWS_EMPTY", "没有可导入的数据行"); } if (request.getRows().size() > maxRows) { throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "ROWS_TOO_MANY", "单次导入超过 " + maxRows + " 行,请拆分文件"); } } private void requirePermission(AgentImportModuleAdapter adapter) { for (String permission : adapter.permissions()) { if (!securityFrameworkService.hasPermission(permission)) { throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN", "当前用户缺少该模块的导入权限(" + permission + ")"); } } } private AgentImportModuleAdapter requireAdapter(String moduleId) { AgentImportModuleAdapter adapter = adapters().get(moduleId); if (adapter == null) { throw new AgentBridgeException(HttpStatus.NOT_FOUND, "MODULE_NOT_SUPPORTED", "不支持的导入模块:" + moduleId); } return adapter; } private Map adapters() { Map result = new LinkedHashMap<>(); for (AgentImportModuleAdapter adapter : adapterProvider) { result.put(adapter.moduleId(), adapter); } return result; } private static AgentImportCommandRowDO.AgentImportCommandRowDOBuilder baseRow( String moduleId, String commandId, AgentImportPreflightReqVO.Row row) { return AgentImportCommandRowDO.builder() .commandId(commandId) .moduleId(moduleId) .candidateId(row.getCandidateId()) .sourceRow(row.getRowNo()) .code(row.getValues().get("code")) .name(row.getValues().get("name")); } private static void requireSameDigest(AgentImportCommandDO command, String digest) { if (!command.getRequestDigest().equals(digest)) { throw new AgentBridgeException(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "同一命令或幂等键携带了不同的请求内容,请检查任务状态后重试"); } } private static String requestDigest(String moduleId, AgentImportCommitReqVO request) { Map canonical = new LinkedHashMap<>(); canonical.put("module_id", moduleId); canonical.put("attachment_id", request.getAttachmentId()); canonical.put("session_id", request.getSessionId()); canonical.put("source_sha256", StrUtil.nullToEmpty(request.getSourceSha256()).toLowerCase(Locale.ROOT)); canonical.put("preflight_hash", StrUtil.nullToEmpty(request.getPreflightHash()).toLowerCase(Locale.ROOT)); canonical.put("proposal_hash", StrUtil.nullToEmpty(request.getProposalHash())); canonical.put("proposal_revision", StrUtil.nullToEmpty(request.getProposalRevision())); canonical.put("options", request.getOptions() == null ? Map.of() : new TreeMap<>(request.getOptions())); List> rows = new ArrayList<>(); for (AgentImportPreflightReqVO.Row row : request.getRows()) { Map item = new LinkedHashMap<>(); item.put("candidate_id", row.getCandidateId()); item.put("row_no", row.getRowNo()); item.put("values", row.getValues() == null ? Map.of() : new TreeMap<>(row.getValues())); rows.add(item); } canonical.put("rows", rows); try { byte[] payload = OBJECT_MAPPER.writeValueAsBytes(canonical); return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 不可用", ex); } catch (JsonProcessingException ex) { throw new IllegalStateException("请求摘要生成失败", ex); } } /** * 适配器计算预检摘要的统一入口,避免各模块摘要口径漂移。 */ public static String preflightHash(String moduleId, AgentImportPreflightReqVO request, Object resolvedPayload) { Map canonical = new LinkedHashMap<>(); canonical.put("module_id", moduleId); canonical.put("attachment_id", request.getAttachmentId()); canonical.put("source_sha256", StrUtil.nullToEmpty(request.getSourceSha256()).toLowerCase(Locale.ROOT)); canonical.put("proposal_revision", StrUtil.nullToEmpty(request.getProposalRevision())); canonical.put("options", request.getOptions() == null ? Map.of() : new TreeMap<>(request.getOptions())); List> rows = new ArrayList<>(); for (AgentImportPreflightReqVO.Row row : request.getRows()) { Map item = new LinkedHashMap<>(); item.put("candidate_id", row.getCandidateId()); item.put("row_no", row.getRowNo()); item.put("values", row.getValues() == null ? Map.of() : new TreeMap<>(row.getValues())); rows.add(item); } canonical.put("rows", rows); canonical.put("resolved", resolvedPayload); try { byte[] payload = OBJECT_MAPPER.writeValueAsBytes(canonical); return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 不可用", ex); } catch (JsonProcessingException ex) { throw new IllegalStateException("预检摘要生成失败", ex); } } private AgentImportReceiptRespVO toReceipt(AgentImportCommandDO command) { String status = command.getStatus(); if (STATUS_RUNNING.equals(status) && command.getLeaseExpiresAt() != null && command.getLeaseExpiresAt().isBefore(LocalDateTime.now())) { status = STATUS_UNKNOWN; } List rowDOs = commandRowMapper.selectListByCommandId(command.getCommandId()); List rows = rowDOs.stream() .map(row -> AgentImportReceiptRespVO.Row.builder() .candidateId(row.getCandidateId()) .rowNo(row.getSourceRow()) .code(row.getCode()) .name(row.getName()) .action(row.getAction()) .status(row.getStatus()) .targetItemId(row.getTargetItemId()) .targetVersion(row.getTargetVersion()) .errorCode(row.getErrorCode()) .errorMessage(row.getErrorMessage()) .retryable(row.getRetryable()) .build()) .toList(); int success = (int) rowDOs.stream().filter(row -> "SUCCESS".equals(row.getStatus())).count(); int failed = (int) rowDOs.stream().filter(row -> "FAILED".equals(row.getStatus())).count(); int skipped = (int) rowDOs.stream().filter(row -> "SKIPPED".equals(row.getStatus())).count(); int unknown = (int) rowDOs.stream().filter(row -> "UNKNOWN".equals(row.getStatus())).count(); return AgentImportReceiptRespVO.builder() .commandId(command.getCommandId()) .idempotencyKey(command.getIdempotencyKey()) .status(status) .attachmentId(command.getAttachmentId()) .sessionId(command.getSessionId()) .sourceSha256(command.getSourceSha256()) .preflightHash(command.getPreflightHash()) .proposalHash(command.getProposalHash()) .summary(AgentImportReceiptRespVO.Summary.builder() .total(command.getTotalCount()) .success(success).failed(failed).skipped(skipped).unknown(unknown) .build()) .errorCode(command.getErrorCode()) .errorMessage(command.getErrorMessage()) .rows(rows) .createTime(command.getCreateTime()) .completedTime(STATUS_RUNNING.equals(command.getStatus()) ? null : command.getUpdateTime()) .build(); } private static String trim(String value, int maxLength) { if (value == null) { return null; } return value.length() <= maxLength ? value : value.substring(0, maxLength); } }