feat(mdm): 新增智能体通用业务导入功能
- 添加了MES同步开关验证和控制逻辑
- 实现了多模块导入权限管理和会话创建
- 引入了通用导入命令表结构存储执行状态
- 支持物料、供应商、BOM等多种业务模块导入
- 提供了幂等性保证和逐行回执机制
- 增加了BOM导入适配器和循环引用检测功能
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | -- éç¨ä¸å¡å¯¼å
¥å¹çå½ä»¤ä¸éè¡åæ§ï¼ä¾åºåãBOM çï¼ãæ§è¡åå¤ä»½ç®æ åºã |
| | | CREATE TABLE IF NOT EXISTS `agent_import_command` ( |
| | | `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主é®', |
| | | `command_id` varchar(64) NOT NULL COMMENT 'æºè½ä½æäº¤å½ä»¤ IDï¼å¹çï¼', |
| | | `idempotency_key` varchar(128) NOT NULL COMMENT 'å¹çé®', |
| | | `request_digest` char(64) NOT NULL COMMENT 'è¯·æ±æè¦ SHA-256', |
| | | `module_id` varchar(64) NOT NULL COMMENT 'ä¸å¡æ¨¡å IDï¼å¦ srm.supplier', |
| | | `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT 'ç§æ· ID', |
| | | `owner_user_id` bigint NOT NULL COMMENT 'æäº¤ç¨æ· ID', |
| | | `session_id` bigint DEFAULT NULL COMMENT 'è¡¥å½ä¼è¯ ID', |
| | | `attachment_id` bigint NOT NULL COMMENT 'éä»¶ ID', |
| | | `source_sha256` char(64) NOT NULL COMMENT 'éä»¶å
容æè¦', |
| | | `preflight_hash` char(64) NOT NULL COMMENT '颿£è®¡åæè¦', |
| | | `proposal_hash` varchar(128) DEFAULT NULL COMMENT 'æºè½ä½æ¹æ¡æè¦', |
| | | `status` varchar(20) NOT NULL DEFAULT 'RUNNING' COMMENT 'RUNNING/SUCCEEDED/PARTIAL_FAILED/FAILED/UNKNOWN', |
| | | `total_count` int NOT NULL DEFAULT 0 COMMENT 'æ»è¡æ°', |
| | | `success_count` int NOT NULL DEFAULT 0 COMMENT 'æåè¡æ°', |
| | | `failed_count` int NOT NULL DEFAULT 0 COMMENT 'å¤±è´¥è¡æ°', |
| | | `skipped_count` int NOT NULL DEFAULT 0 COMMENT 'è·³è¿è¡æ°', |
| | | `error_code` varchar(64) DEFAULT NULL COMMENT 'å½ä»¤çº§é误ç ', |
| | | `error_message` varchar(512) DEFAULT NULL COMMENT 'å½ä»¤çº§é误信æ¯', |
| | | `lease_expires_at` datetime DEFAULT NULL COMMENT 'æ§è¡ç§çº¦å°ææ¶é´', |
| | | `creator` varchar(64) DEFAULT NULL, |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| | | `updater` varchar(64) DEFAULT NULL, |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| | | `deleted` bit(1) NOT NULL DEFAULT b'0', |
| | | PRIMARY KEY (`id`), |
| | | UNIQUE KEY `uk_agent_import_generic_command` (`tenant_id`, `owner_user_id`, `module_id`, `command_id`), |
| | | UNIQUE KEY `uk_agent_import_generic_idempotency` (`tenant_id`, `owner_user_id`, `module_id`, `idempotency_key`), |
| | | KEY `idx_agent_import_generic_module` (`tenant_id`, `module_id`, `create_time`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='æºè½ä½éç¨ä¸å¡å¯¼å
¥å½ä»¤'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `agent_import_command_row` ( |
| | | `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主é®', |
| | | `command_id` varchar(64) NOT NULL COMMENT 'æäº¤å½ä»¤ ID', |
| | | `module_id` varchar(64) NOT NULL COMMENT 'ä¸å¡æ¨¡å ID', |
| | | `candidate_id` varchar(64) NOT NULL COMMENT '导å
¥è¡æ è¯', |
| | | `source_row` int DEFAULT NULL COMMENT 'æºæä»¶è¡å·', |
| | | `code` varchar(64) DEFAULT NULL COMMENT 'ä¸å¡ç¼ç ', |
| | | `name` varchar(255) DEFAULT NULL COMMENT 'ä¸å¡åç§°', |
| | | `action` varchar(16) NOT NULL COMMENT 'CREATE/SKIP', |
| | | `status` varchar(16) NOT NULL COMMENT 'SUCCESS/FAILED/SKIPPED/UNKNOWN', |
| | | `target_item_id` bigint DEFAULT NULL COMMENT 'åå
¥åçç®æ 主é®', |
| | | `target_version` varchar(64) DEFAULT NULL COMMENT 'ç®æ çæ¬ï¼é¢çï¼', |
| | | `error_code` varchar(64) DEFAULT NULL COMMENT 'è¡çº§é误ç ', |
| | | `error_message` varchar(512) DEFAULT NULL COMMENT 'è¡çº§é误信æ¯', |
| | | `retryable` bit(1) NOT NULL DEFAULT b'0' COMMENT 'æ¯å¦å¯éè¯', |
| | | `creator` varchar(64) DEFAULT NULL, |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| | | `updater` varchar(64) DEFAULT NULL, |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| | | `deleted` bit(1) NOT NULL DEFAULT b'0', |
| | | PRIMARY KEY (`id`), |
| | | UNIQUE KEY `uk_agent_import_generic_row` (`command_id`, `candidate_id`), |
| | | KEY `idx_agent_import_generic_row` (`command_id`, `source_row`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='æºè½ä½éç¨ä¸å¡å¯¼å
¥éè¡åæ§'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | -- æ¨¡åæ°æ®è¡¥å½èåè¡¥ä¸ï¼å¯é夿§è¡ã |
| | | -- 顶级ç®å½âæ°æ®è¡¥å½â + åèåâæ¨¡åæ°æ®è¡¥å½âï¼å端ç»ä»¶ agent/dataEntry/indexã |
| | | -- ä»
è¶
级管çåèªå¨å¯è§ï¼å
¶ä»è§è²è¯·å¨è§è²ç®¡çä¸å¾éè¿ä¸¤ä¸ªèåã |
| | | |
| | | INSERT INTO system_menu (id,name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,create_time,updater,update_time,deleted) |
| | | SELECT next_id.id,'æ°æ®è¡¥å½','',1,2,0,'/agent','ep:magic-stick','',NULL,0,b'1',b'0',b'1','1',NOW(),'1',NOW(),b'0' |
| | | FROM (SELECT COALESCE(MAX(id),0)+1 AS id FROM system_menu) next_id |
| | | WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE parent_id=0 AND path='/agent' AND deleted=b'0'); |
| | | |
| | | INSERT INTO system_menu (id,name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,create_time,updater,update_time,deleted) |
| | | SELECT next_id.id,'æ¨¡åæ°æ®è¡¥å½','',2,1,parent.id,'data-entry','ep:chat-dot-round','agent/dataEntry/index','AgentDataEntry',0,b'1',b'0',b'1','1',NOW(),'1',NOW(),b'0' |
| | | FROM system_menu parent CROSS JOIN (SELECT COALESCE(MAX(id),0)+1 AS id FROM system_menu) next_id |
| | | WHERE parent.parent_id=0 AND parent.path='/agent' AND parent.deleted=b'0' |
| | | AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component='agent/dataEntry/index' AND deleted=b'0'); |
| | | |
| | | UPDATE system_menu SET status=0,visible=b'1',deleted=b'0',updater='1',update_time=NOW() |
| | | WHERE (component='agent/dataEntry/index' OR (parent_id=0 AND path='/agent')) AND deleted<>b'0'; |
| | | |
| | | INSERT INTO system_role_menu (role_id,menu_id,creator,create_time,updater,update_time,deleted) |
| | | SELECT r.id,m.id,'1',NOW(),'1',NOW(),b'0' |
| | | FROM system_role r JOIN system_menu m ON m.deleted=b'0' AND (m.component='agent/dataEntry/index' OR (m.parent_id=0 AND m.path='/agent')) |
| | | WHERE r.code='super_admin' AND r.deleted=b'0' |
| | | AND NOT EXISTS (SELECT 1 FROM system_role_menu rm WHERE rm.role_id=r.id AND rm.menu_id=m.id); |
| | |
| | | throw new AgentBridgeException(HttpStatus.SERVICE_UNAVAILABLE, "WRITE_DISABLED", |
| | | "æºè½ä½åå
¥å¼å
³æªæå¼ï¼æäº¤å·²æç»"); |
| | | } |
| | | if (Boolean.TRUE.equals(reqVO.getSyncMes()) && !policy.isAllowMesSync()) { |
| | | throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "MES_SYNC_DISABLED", |
| | | "æå¡æªå¼æ¾åæ¥ MESï¼è¯·å
³é syncMes åéè¯"); |
| | | } |
| | | String requestDigest = buildRequestDigest(reqVO); |
| | | Long tenantId = principal.tenantId(); |
| | | Long ownerUserId = principal.userId(); |
| | |
| | | return; |
| | | } |
| | | MdmItemSaveReqVO saveReqVO = resolution.saveReqVO(); |
| | | saveReqVO.setSyncMes(false); // æ¬é¶æ®µåºå®ä¸åæ¥ MES |
| | | saveReqVO.setSyncMes(Boolean.TRUE.equals(reqVO.getSyncMes())); |
| | | Long itemId = itemService.createItem(saveReqVO); |
| | | commandRowMapper.insert(baseRow(command.getCommandId(), row) |
| | | .action("CREATE").status("SUCCESS").targetItemId(itemId) |
| | |
| | | |
| | | boolean syncMesRequested = Boolean.TRUE.equals(reqVO.getSyncMes()); |
| | | List<MdmAgentImportPreflightRespVO.Issue> blockingItems = new ArrayList<>(); |
| | | if (syncMesRequested) { |
| | | if (syncMesRequested && !policy.isAllowMesSync()) { |
| | | blockingItems.add(issue("MES_SYNC_DISABLED", "syncMes", |
| | | "æ¬é¶æ®µä¸å¼æ¾åæ¥ MESï¼è¯·å
³é syncMes åéè¯", false)); |
| | | "æå¡æªå¼æ¾åæ¥ MESï¼è¯·å
³é syncMes åéè¯", false)); |
| | | } |
| | | |
| | | // 4. æä»¶å
éå¤ç¼ç ï¼ææéå¤è¡ä¸èµ·é»æï¼é¿å
âé¦è¡å»ºã次è¡è·³âç误导 |
| | |
| | | .blocked(blockedCount) |
| | | .build()) |
| | | .canCommit(canCommit) |
| | | .sideEffects(canCommit ? List.of("mdm_item.create:" + createCount) : List.of()) |
| | | .sideEffects(canCommit |
| | | ? (syncMesRequested |
| | | ? List.of("mdm_item.create:" + createCount, "mes_md_item.sync:" + createCount) |
| | | : List.of("mdm_item.create:" + createCount)) |
| | | : List.of()) |
| | | .rows(rows) |
| | | .blockingItems(blockingItems) |
| | | .build(); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mes.service.md.item; |
| | | |
| | | import cn.hutool.core.util.StrUtil; |
| | | import cn.iocoder.yudao.module.mes.controller.admin.md.item.vo.bom.MesMdProductBomSaveReqVO; |
| | | import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO; |
| | | import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO; |
| | | import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdProductBomMapper; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportModuleAdapter; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportRowReceipt; |
| | | 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.agent.imports.SystemAgentGenericImportService; |
| | | import jakarta.annotation.Resource; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.*; |
| | | |
| | | /** |
| | | * BOM 导å
¥ï¼ç¶/åç©æå¿
é¡»å·²åæ¥å° MESï¼å·²åå¨ç¶åç»åè·³è¿ï¼åªæ°å»ºä¸æ´æ°ã |
| | | */ |
| | | @Component |
| | | public class MesBomImportAdapter implements AgentImportModuleAdapter { |
| | | |
| | | public static final String MODULE_ID = "mes.bom"; |
| | | private static final String PERMISSION = "mes:md-item:create"; |
| | | |
| | | @Resource |
| | | private MesMdItemService itemService; |
| | | @Resource |
| | | private MesMdProductBomService productBomService; |
| | | @Resource |
| | | private MesMdProductBomMapper productBomMapper; |
| | | |
| | | @Override |
| | | public String moduleId() { |
| | | return MODULE_ID; |
| | | } |
| | | |
| | | @Override |
| | | public String permission() { |
| | | return PERMISSION; |
| | | } |
| | | |
| | | @Override |
| | | public AgentImportPreflightRespVO preflight(AgentPrincipal principal, AgentImportPreflightReqVO request) { |
| | | // 1. ç°æ BOM å¾ä¸ç¶åç»åç´¢å¼ï¼ç¨äºè·³è¿ä¸ç¯æ£æµ |
| | | Map<Long, Set<Long>> graph = new HashMap<>(); |
| | | Map<String, Long> pairs = new HashMap<>(); |
| | | for (MesMdProductBomDO bom : productBomMapper.selectAll()) { |
| | | graph.computeIfAbsent(bom.getItemId(), key -> new HashSet<>()).add(bom.getBomItemId()); |
| | | pairs.put(pairKey(bom.getItemId(), bom.getBomItemId()), bom.getId()); |
| | | } |
| | | |
| | | Map<String, Optional<MesMdItemDO>> itemCache = new HashMap<>(); |
| | | Set<String> filePairs = new HashSet<>(); |
| | | List<AgentImportPreflightRespVO.Row> rows = new ArrayList<>(); |
| | | List<Map<String, Object>> resolvedPayload = new ArrayList<>(); |
| | | int create = 0; |
| | | int skip = 0; |
| | | int blocked = 0; |
| | | |
| | | for (AgentImportPreflightReqVO.Row row : request.getRows()) { |
| | | List<AgentImportPreflightRespVO.Issue> blockers = new ArrayList<>(); |
| | | List<AgentImportPreflightRespVO.Issue> warnings = new ArrayList<>(); |
| | | String parentCode = value(row, "parentCode"); |
| | | String componentCode = value(row, "componentCode"); |
| | | if (parentCode.isEmpty()) { |
| | | blockers.add(issue("PARENT_CODE_REQUIRED", "parentCode", "ç¶é¡¹ç©æç¼ç ä¸è½ä¸ºç©º")); |
| | | } |
| | | if (componentCode.isEmpty()) { |
| | | blockers.add(issue("COMPONENT_CODE_REQUIRED", "componentCode", "åé¡¹ç©æç¼ç ä¸è½ä¸ºç©º")); |
| | | } |
| | | BigDecimal quantity = parseQuantity(row.getValues().get("quantity"), blockers); |
| | | Integer status = parseStatus(row.getValues().get("status"), blockers); |
| | | |
| | | MesMdItemDO parent = parentCode.isEmpty() ? null : lookup(itemCache, parentCode); |
| | | MesMdItemDO component = componentCode.isEmpty() ? null : lookup(itemCache, componentCode); |
| | | if (!parentCode.isEmpty() && parent == null) { |
| | | blockers.add(issue("MES_ITEM_NOT_SYNCED", "parentCode", |
| | | "ç¶é¡¹ç©æç¼ç " + parentCode + " å¨ MES ç©æä¸ä¸åå¨ï¼è¯·å
导å
¥å¹¶åæ¥ç©æ")); |
| | | } |
| | | if (!componentCode.isEmpty() && component == null) { |
| | | blockers.add(issue("MES_ITEM_NOT_SYNCED", "componentCode", |
| | | "åé¡¹ç©æç¼ç " + componentCode + " å¨ MES ç©æä¸ä¸åå¨ï¼è¯·å
导å
¥å¹¶åæ¥ç©æ")); |
| | | } |
| | | |
| | | String action; |
| | | Long targetId = null; |
| | | if (!blockers.isEmpty()) { |
| | | action = "BLOCKED"; |
| | | blocked++; |
| | | } else { |
| | | String key = pairKey(parent.getId(), component.getId()); |
| | | if (parent.getId().equals(component.getId())) { |
| | | blockers.add(issue("BOM_SELF_REFERENCE", "componentCode", "ç¶é¡¹ä¸å项ä¸è½æ¯åä¸ç©æ")); |
| | | action = "BLOCKED"; |
| | | blocked++; |
| | | } else if (pairs.containsKey(key)) { |
| | | action = "SKIP"; |
| | | targetId = pairs.get(key); |
| | | skip++; |
| | | warnings.add(issue("BOM_EXISTS", "componentCode", |
| | | "该ç¶åç©æç»åå·²åå¨ BOMï¼å·²è·³è¿ä¸ä¸ä¼æ´æ°ç¨é")); |
| | | } else if (filePairs.contains(key)) { |
| | | blockers.add(issue("DUPLICATE_IN_FILE", "componentCode", |
| | | "æä»¶å
ç¶é¡¹ä¸å项ç»åéå¤ï¼è¯·æ ¸å®ç¨éçæ¬")); |
| | | action = "BLOCKED"; |
| | | blocked++; |
| | | } else if (reaches(graph, component.getId(), parent.getId())) { |
| | | blockers.add(issue("BOM_CYCLE", "componentCode", |
| | | "æ°å¢å BOM å°å½¢æå¾ªç¯å¼ç¨ï¼è¯·è°æ´ç¶åå
³ç³»")); |
| | | action = "BLOCKED"; |
| | | blocked++; |
| | | } else { |
| | | action = "CREATE"; |
| | | create++; |
| | | filePairs.add(key); |
| | | graph.computeIfAbsent(parent.getId(), item -> new HashSet<>()).add(component.getId()); |
| | | } |
| | | } |
| | | |
| | | Map<String, Object> resolved = new LinkedHashMap<>(); |
| | | resolved.put("candidate_id", row.getCandidateId()); |
| | | resolved.put("parent_item_id", parent == null ? null : parent.getId()); |
| | | resolved.put("component_item_id", component == null ? null : component.getId()); |
| | | resolved.put("quantity", quantity); |
| | | resolved.put("status", status); |
| | | resolved.put("action", action); |
| | | resolved.put("target_id", targetId); |
| | | resolvedPayload.add(resolved); |
| | | |
| | | Map<String, Object> resolvedValues = new LinkedHashMap<>(); |
| | | resolvedValues.put("parentCode", parentCode); |
| | | resolvedValues.put("componentCode", componentCode); |
| | | resolvedValues.put("quantity", quantity); |
| | | resolvedValues.put("status", status); |
| | | rows.add(AgentImportPreflightRespVO.Row.builder() |
| | | .candidateId(row.getCandidateId()) |
| | | .rowNo(row.getRowNo()) |
| | | .action(action) |
| | | .resolved(resolvedValues) |
| | | .blockers(blockers) |
| | | .warnings(warnings) |
| | | .targetId(targetId) |
| | | .build()); |
| | | } |
| | | |
| | | int total = rows.size(); |
| | | return AgentImportPreflightRespVO.builder() |
| | | .moduleId(MODULE_ID) |
| | | .preflightHash(SystemAgentGenericImportService.preflightHash(MODULE_ID, request, resolvedPayload)) |
| | | .source(AgentImportPreflightRespVO.Source.builder() |
| | | .attachmentId(request.getAttachmentId()) |
| | | .sessionId(request.getSessionId()) |
| | | .sourceSha256(request.getSourceSha256()) |
| | | .proposalRevision(request.getProposalRevision()) |
| | | .build()) |
| | | .summary(AgentImportPreflightRespVO.Summary.builder() |
| | | .total(total).create(create).skip(skip).blocked(blocked).build()) |
| | | .canCommit(blocked == 0) |
| | | .sideEffects(List.of("å建 BOM ç¶åç¨æå
³ç³»", "䏿´æ°å·²åå¨ç BOM")) |
| | | .rows(rows) |
| | | .blockingItems(List.of()) |
| | | .build(); |
| | | } |
| | | |
| | | @Override |
| | | public AgentImportRowReceipt commitRow(AgentPrincipal principal, AgentImportCommitReqVO request, |
| | | AgentImportPreflightRespVO.Row planned, |
| | | AgentImportPreflightReqVO.Row row) { |
| | | String parentCode = value(row, "parentCode"); |
| | | String componentCode = value(row, "componentCode"); |
| | | MesMdItemDO parent = itemService.getItemByCode(parentCode); |
| | | MesMdItemDO component = itemService.getItemByCode(componentCode); |
| | | if (parent == null || component == null) { |
| | | throw new AgentBridgeException(HttpStatus.CONFLICT, "MES_ITEM_NOT_SYNCED", |
| | | "ç©æå¨ MES ä¸å·²ä¸åå¨ï¼è¯·éæ°åæ¥åéè¯", true, null); |
| | | } |
| | | BigDecimal quantity = new BigDecimal(value(row, "quantity")); |
| | | if (quantity.signum() <= 0) { |
| | | throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "INVALID_QUANTITY", |
| | | "ç¨éå¿
é¡»å¤§äº 0", false, null); |
| | | } |
| | | |
| | | MesMdProductBomSaveReqVO saveReqVO = new MesMdProductBomSaveReqVO(); |
| | | saveReqVO.setItemId(parent.getId()); |
| | | saveReqVO.setBomItemId(component.getId()); |
| | | saveReqVO.setQuantity(quantity); |
| | | saveReqVO.setStatus(parseStatus(row.getValues().get("status"), null)); |
| | | saveReqVO.setRemark(StrUtil.blankToDefault(row.getValues().get("remark"), null)); |
| | | |
| | | Long id = productBomService.createProductBom(saveReqVO); |
| | | return AgentImportRowReceipt.created(id, parentCode, componentCode); |
| | | } |
| | | |
| | | private MesMdItemDO lookup(Map<String, Optional<MesMdItemDO>> cache, String code) { |
| | | return cache.computeIfAbsent(code, |
| | | key -> Optional.ofNullable(itemService.getItemByCode(key))).orElse(null); |
| | | } |
| | | |
| | | /** 沿已æï¼å«æ¬æ¬¡å·²æ¥åçï¼ç¶åè¾¹ä» start åºåï¼å¤æè½å¦å°è¾¾ targetã */ |
| | | private static boolean reaches(Map<Long, Set<Long>> graph, Long start, Long target) { |
| | | Deque<Long> stack = new ArrayDeque<>(); |
| | | Set<Long> visited = new HashSet<>(); |
| | | stack.push(start); |
| | | while (!stack.isEmpty()) { |
| | | Long node = stack.pop(); |
| | | if (!visited.add(node)) { |
| | | continue; |
| | | } |
| | | if (node.equals(target)) { |
| | | return true; |
| | | } |
| | | for (Long next : graph.getOrDefault(node, Set.of())) { |
| | | stack.push(next); |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | private static String pairKey(Long parentId, Long componentId) { |
| | | return parentId + ":" + componentId; |
| | | } |
| | | |
| | | private static AgentImportPreflightRespVO.Issue issue(String code, String field, String message) { |
| | | return AgentImportPreflightRespVO.Issue.builder() |
| | | .code(code).field(field).message(message).retryable(false).build(); |
| | | } |
| | | |
| | | private static String value(AgentImportPreflightReqVO.Row row, String key) { |
| | | String raw = row.getValues() == null ? null : row.getValues().get(key); |
| | | return raw == null ? "" : raw.trim(); |
| | | } |
| | | |
| | | private static BigDecimal parseQuantity(String raw, List<AgentImportPreflightRespVO.Issue> blockers) { |
| | | if (StrUtil.isBlank(raw)) { |
| | | if (blockers != null) { |
| | | blockers.add(issue("INVALID_QUANTITY", "quantity", "ç¨éå¿
é¡»æ¯å¤§äº 0 çæ°å")); |
| | | } |
| | | return null; |
| | | } |
| | | try { |
| | | BigDecimal quantity = new BigDecimal(raw.trim()); |
| | | if (quantity.signum() <= 0) { |
| | | if (blockers != null) { |
| | | blockers.add(issue("INVALID_QUANTITY", "quantity", "ç¨éå¿
é¡»å¤§äº 0")); |
| | | } |
| | | return quantity; |
| | | } |
| | | return quantity; |
| | | } catch (NumberFormatException ex) { |
| | | if (blockers != null) { |
| | | blockers.add(issue("INVALID_QUANTITY", "quantity", "ç¨éå¿
é¡»æ¯å¤§äº 0 çæ°å")); |
| | | } |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private static Integer parseStatus(String raw, List<AgentImportPreflightRespVO.Issue> blockers) { |
| | | if (StrUtil.isBlank(raw)) { |
| | | return 0; |
| | | } |
| | | String value = raw.trim(); |
| | | if ("0".equals(value) || "1".equals(value)) { |
| | | return Integer.valueOf(value); |
| | | } |
| | | if (blockers != null) { |
| | | blockers.add(issue("INVALID_STATUS", "status", "ç¶æåªè½æ¯ 0ï¼å¯ç¨ï¼æ 1ï¼ç¦ç¨ï¼")); |
| | | } |
| | | return 0; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mes.service.md.item; |
| | | |
| | | import cn.iocoder.yudao.module.mes.controller.admin.md.item.vo.bom.MesMdProductBomSaveReqVO; |
| | | import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO; |
| | | import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO; |
| | | import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdProductBomMapper; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportRowReceipt; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.test.util.ReflectionTestUtils; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.*; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.*; |
| | | |
| | | class MesBomImportAdapterTest { |
| | | |
| | | private static final AgentPrincipal PRINCIPAL = new AgentPrincipal(1L, 1L, "live"); |
| | | |
| | | private MesBomImportAdapter adapter; |
| | | private MesMdItemService itemService; |
| | | private MesMdProductBomService productBomService; |
| | | private MesMdProductBomMapper productBomMapper; |
| | | |
| | | @BeforeEach |
| | | void setUp() { |
| | | adapter = new MesBomImportAdapter(); |
| | | itemService = mock(MesMdItemService.class); |
| | | productBomService = mock(MesMdProductBomService.class); |
| | | productBomMapper = mock(MesMdProductBomMapper.class); |
| | | ReflectionTestUtils.setField(adapter, "itemService", itemService); |
| | | ReflectionTestUtils.setField(adapter, "productBomService", productBomService); |
| | | ReflectionTestUtils.setField(adapter, "productBomMapper", productBomMapper); |
| | | when(itemService.getItemByCode("P")).thenReturn(item(1L, "P")); |
| | | when(itemService.getItemByCode("C")).thenReturn(item(2L, "C")); |
| | | when(itemService.getItemByCode("A")).thenReturn(item(3L, "A")); |
| | | when(itemService.getItemByCode("X")).thenReturn(null); |
| | | when(productBomMapper.selectAll()).thenReturn(List.of()); |
| | | } |
| | | |
| | | @Test |
| | | void preflightCreatesWhenItemsAreSynced() { |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "C", "2"))); |
| | | |
| | | assertEquals("CREATE", plan.getRows().get(0).getAction()); |
| | | assertEquals(1, plan.getSummary().getCreate()); |
| | | assertTrue(plan.getCanCommit()); |
| | | assertNotNull(plan.getPreflightHash()); |
| | | } |
| | | |
| | | @Test |
| | | void existingCombinationIsSkippedWithWarning() { |
| | | when(productBomMapper.selectAll()).thenReturn(List.of(bom(50L, 1L, 2L))); |
| | | |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "C", "2"))); |
| | | |
| | | assertEquals("SKIP", plan.getRows().get(0).getAction()); |
| | | assertEquals(50L, plan.getRows().get(0).getTargetId()); |
| | | assertEquals(1, plan.getSummary().getSkip()); |
| | | assertTrue(plan.getRows().get(0).getWarnings().stream() |
| | | .anyMatch(item -> "BOM_EXISTS".equals(item.getCode()))); |
| | | } |
| | | |
| | | @Test |
| | | void unsyncedComponentBlocksRow() { |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "X", "1"))); |
| | | |
| | | assertEquals("BLOCKED", plan.getRows().get(0).getAction()); |
| | | assertTrue(plan.getRows().get(0).getBlockers().stream() |
| | | .anyMatch(item -> "MES_ITEM_NOT_SYNCED".equals(item.getCode()))); |
| | | assertFalse(plan.getCanCommit()); |
| | | } |
| | | |
| | | @Test |
| | | void selfReferenceBlocksRow() { |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "P", "1"))); |
| | | |
| | | assertEquals("BLOCKED", plan.getRows().get(0).getAction()); |
| | | assertTrue(plan.getRows().get(0).getBlockers().stream() |
| | | .anyMatch(item -> "BOM_SELF_REFERENCE".equals(item.getCode()))); |
| | | } |
| | | |
| | | @Test |
| | | void duplicateCombinationInFileBlocksSecondRow() { |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request( |
| | | row("r1", "P", "C", "1"), |
| | | row("r2", "P", "C", "2"))); |
| | | |
| | | assertEquals("CREATE", plan.getRows().get(0).getAction()); |
| | | assertEquals("BLOCKED", plan.getRows().get(1).getAction()); |
| | | assertTrue(plan.getRows().get(1).getBlockers().stream() |
| | | .anyMatch(item -> "DUPLICATE_IN_FILE".equals(item.getCode()))); |
| | | } |
| | | |
| | | @Test |
| | | void cycleAgainstExistingGraphBlocksRow() { |
| | | when(productBomMapper.selectAll()).thenReturn(List.of(bom(60L, 3L, 1L))); |
| | | |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "A", "1"))); |
| | | |
| | | assertEquals("BLOCKED", plan.getRows().get(0).getAction()); |
| | | assertTrue(plan.getRows().get(0).getBlockers().stream() |
| | | .anyMatch(item -> "BOM_CYCLE".equals(item.getCode()))); |
| | | } |
| | | |
| | | @Test |
| | | void invalidQuantityBlocksRow() { |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request(row("r1", "P", "C", "0"))); |
| | | |
| | | assertEquals("BLOCKED", plan.getRows().get(0).getAction()); |
| | | assertTrue(plan.getRows().get(0).getBlockers().stream() |
| | | .anyMatch(item -> "INVALID_QUANTITY".equals(item.getCode()))); |
| | | } |
| | | |
| | | @Test |
| | | void commitCreatesBomWithResolvedItemIds() { |
| | | when(productBomService.createProductBom(any())).thenReturn(88L); |
| | | |
| | | AgentImportRowReceipt receipt = adapter.commitRow(PRINCIPAL, new AgentImportCommitReqVO(), |
| | | AgentImportPreflightRespVO.Row.builder().candidateId("r1").action("CREATE").build(), |
| | | row("r1", "P", "C", "2")); |
| | | |
| | | assertEquals(88L, receipt.targetId()); |
| | | assertEquals("P", receipt.code()); |
| | | verify(productBomService).createProductBom(any(MesMdProductBomSaveReqVO.class)); |
| | | } |
| | | |
| | | private static MesMdItemDO item(Long id, String code) { |
| | | MesMdItemDO item = new MesMdItemDO(); |
| | | item.setId(id); |
| | | item.setCode(code); |
| | | return item; |
| | | } |
| | | |
| | | private static MesMdProductBomDO bom(Long id, Long itemId, Long bomItemId) { |
| | | MesMdProductBomDO bom = new MesMdProductBomDO(); |
| | | bom.setId(id); |
| | | bom.setItemId(itemId); |
| | | bom.setBomItemId(bomItemId); |
| | | bom.setQuantity(BigDecimal.ONE); |
| | | return bom; |
| | | } |
| | | |
| | | private static AgentImportPreflightReqVO request(AgentImportPreflightReqVO.Row... rows) { |
| | | AgentImportPreflightReqVO request = new AgentImportPreflightReqVO(); |
| | | request.setAttachmentId(1L); |
| | | request.setSourceSha256("a".repeat(64)); |
| | | request.setProposalRevision("0"); |
| | | request.setRows(List.of(rows)); |
| | | return request; |
| | | } |
| | | |
| | | private static AgentImportPreflightReqVO.Row row(String candidateId, String parent, String component, |
| | | String quantity) { |
| | | Map<String, String> values = new LinkedHashMap<>(); |
| | | values.put("parentCode", parent); |
| | | values.put("componentCode", component); |
| | | values.put("quantity", quantity); |
| | | AgentImportPreflightReqVO.Row row = new AgentImportPreflightReqVO.Row(); |
| | | row.setCandidateId(candidateId); |
| | | row.setRowNo(1); |
| | | row.setValues(values); |
| | | return row; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.srm.service.supplier; |
| | | |
| | | import cn.hutool.core.util.StrUtil; |
| | | import cn.iocoder.yudao.module.srm.controller.admin.supplier.vo.SrmSupplierSaveReqVO; |
| | | import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierDO; |
| | | import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierMapper; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportModuleAdapter; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportRowReceipt; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | import cn.iocoder.yudao.module.system.service.agent.imports.SystemAgentGenericImportService; |
| | | import jakarta.annotation.Resource; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.*; |
| | | |
| | | /** |
| | | * ä¾åºåæ¡£æ¡å¯¼å
¥ï¼å·²åå¨ç¼ç è·³è¿å¹¶ç»è¦åï¼åªæ°å»ºä¸æ´æ°ã |
| | | */ |
| | | @Component |
| | | public class SrmSupplierImportAdapter implements AgentImportModuleAdapter { |
| | | |
| | | public static final String MODULE_ID = "srm.supplier"; |
| | | private static final String PERMISSION = "srm:supplier:create"; |
| | | |
| | | @Resource |
| | | private SrmSupplierService supplierService; |
| | | @Resource |
| | | private SrmSupplierMapper supplierMapper; |
| | | |
| | | @Override |
| | | public String moduleId() { |
| | | return MODULE_ID; |
| | | } |
| | | |
| | | @Override |
| | | public String permission() { |
| | | return PERMISSION; |
| | | } |
| | | |
| | | @Override |
| | | public AgentImportPreflightRespVO preflight(AgentPrincipal principal, AgentImportPreflightReqVO request) { |
| | | // 1. å
ç»è®¡æä»¶å
éå¤ç¼ç ï¼å
¨é¨æ ä¸ºé»æï¼é¿å
æ¿ç¨æ·éçæ¬ |
| | | Map<String, Integer> codeCounts = new HashMap<>(); |
| | | for (AgentImportPreflightReqVO.Row row : request.getRows()) { |
| | | String code = value(row, "code"); |
| | | if (!code.isEmpty()) { |
| | | codeCounts.merge(code, 1, Integer::sum); |
| | | } |
| | | } |
| | | |
| | | List<AgentImportPreflightRespVO.Row> rows = new ArrayList<>(); |
| | | List<Map<String, Object>> resolvedPayload = new ArrayList<>(); |
| | | int create = 0; |
| | | int skip = 0; |
| | | int blocked = 0; |
| | | for (AgentImportPreflightReqVO.Row row : request.getRows()) { |
| | | List<AgentImportPreflightRespVO.Issue> blockers = new ArrayList<>(); |
| | | List<AgentImportPreflightRespVO.Issue> warnings = new ArrayList<>(); |
| | | String code = value(row, "code"); |
| | | String name = value(row, "name"); |
| | | if (code.isEmpty()) { |
| | | blockers.add(issue("CODE_REQUIRED", "code", "ä¾åºåç¼ç ä¸è½ä¸ºç©º")); |
| | | } |
| | | if (name.isEmpty()) { |
| | | blockers.add(issue("NAME_REQUIRED", "name", "ä¾åºååç§°ä¸è½ä¸ºç©º")); |
| | | } |
| | | if (!code.isEmpty() && codeCounts.getOrDefault(code, 0) > 1) { |
| | | blockers.add(issue("DUPLICATE_CODE_IN_FILE", "code", |
| | | "æä»¶å
åå¨éå¤çä¾åºåç¼ç ï¼" + code)); |
| | | } |
| | | Integer status = parseStatus(row.getValues().get("status"), blockers); |
| | | BigDecimal registeredCapital = parseDecimal(row.getValues().get("registeredCapital"), blockers); |
| | | |
| | | Long targetId = null; |
| | | String action; |
| | | SrmSupplierDO existing = code.isEmpty() ? null : supplierMapper.selectByCode(code); |
| | | if (!blockers.isEmpty()) { |
| | | action = "BLOCKED"; |
| | | blocked++; |
| | | } else if (existing != null) { |
| | | action = "SKIP"; |
| | | targetId = existing.getId(); |
| | | skip++; |
| | | warnings.add(issue("SUPPLIER_EXISTS", "code", |
| | | "ä¾åºåç¼ç å·²åå¨ï¼å·²è·³è¿ä¸ä¸ä¼æ´æ°ç°ææ¡£æ¡")); |
| | | } else { |
| | | action = "CREATE"; |
| | | create++; |
| | | } |
| | | |
| | | Map<String, Object> resolved = new LinkedHashMap<>(); |
| | | resolved.put("candidate_id", row.getCandidateId()); |
| | | resolved.put("code", code); |
| | | resolved.put("name", name); |
| | | resolved.put("status", status); |
| | | resolved.put("action", action); |
| | | resolved.put("target_id", targetId); |
| | | resolvedPayload.add(resolved); |
| | | |
| | | Map<String, Object> resolvedValues = new LinkedHashMap<>(); |
| | | resolvedValues.put("code", code); |
| | | resolvedValues.put("name", name); |
| | | resolvedValues.put("status", status); |
| | | resolvedValues.put("registeredCapital", registeredCapital); |
| | | rows.add(AgentImportPreflightRespVO.Row.builder() |
| | | .candidateId(row.getCandidateId()) |
| | | .rowNo(row.getRowNo()) |
| | | .action(action) |
| | | .resolved(resolvedValues) |
| | | .blockers(blockers) |
| | | .warnings(warnings) |
| | | .targetId(targetId) |
| | | .build()); |
| | | } |
| | | |
| | | int total = rows.size(); |
| | | return AgentImportPreflightRespVO.builder() |
| | | .moduleId(MODULE_ID) |
| | | .preflightHash(SystemAgentGenericImportService.preflightHash(MODULE_ID, request, resolvedPayload)) |
| | | .source(source(request)) |
| | | .summary(AgentImportPreflightRespVO.Summary.builder() |
| | | .total(total).create(create).skip(skip).blocked(blocked).build()) |
| | | .canCommit(blocked == 0) |
| | | .sideEffects(List.of("å建ä¾åºåæ¡£æ¡", "䏿´æ°å·²åå¨çä¾åºå")) |
| | | .rows(rows) |
| | | .blockingItems(List.of()) |
| | | .build(); |
| | | } |
| | | |
| | | @Override |
| | | public AgentImportRowReceipt commitRow(AgentPrincipal principal, AgentImportCommitReqVO request, |
| | | AgentImportPreflightRespVO.Row planned, |
| | | AgentImportPreflightReqVO.Row row) { |
| | | Map<String, String> values = row.getValues(); |
| | | SrmSupplierSaveReqVO saveReqVO = new SrmSupplierSaveReqVO(); |
| | | saveReqVO.setCode(value(row, "code")); |
| | | saveReqVO.setName(value(row, "name")); |
| | | saveReqVO.setShortName(blankToNull(values.get("shortName"))); |
| | | saveReqVO.setType(blankToNull(values.get("type"))); |
| | | saveReqVO.setCompanyNature(blankToNull(values.get("companyNature"))); |
| | | saveReqVO.setRegisteredCapital(decimalOrNull(values.get("registeredCapital"))); |
| | | saveReqVO.setBusinessLicenseNo(blankToNull(values.get("businessLicenseNo"))); |
| | | saveReqVO.setTaxNo(blankToNull(values.get("taxNo"))); |
| | | saveReqVO.setLegalPerson(blankToNull(values.get("legalPerson"))); |
| | | saveReqVO.setAddress(blankToNull(values.get("address"))); |
| | | saveReqVO.setWebsite(blankToNull(values.get("website"))); |
| | | saveReqVO.setContactName(blankToNull(values.get("contactName"))); |
| | | saveReqVO.setContactMobile(blankToNull(values.get("contactMobile"))); |
| | | saveReqVO.setContactEmail(blankToNull(values.get("contactEmail"))); |
| | | saveReqVO.setBankName(blankToNull(values.get("bankName"))); |
| | | saveReqVO.setBankAccount(blankToNull(values.get("bankAccount"))); |
| | | saveReqVO.setStatus(parseStatus(values.get("status"), null)); |
| | | saveReqVO.setRemark(blankToNull(values.get("remark"))); |
| | | |
| | | Long id = supplierService.createSupplier(saveReqVO); |
| | | return AgentImportRowReceipt.created(id, saveReqVO.getCode(), saveReqVO.getName()); |
| | | } |
| | | |
| | | private static AgentImportPreflightRespVO.Source source(AgentImportPreflightReqVO request) { |
| | | return AgentImportPreflightRespVO.Source.builder() |
| | | .attachmentId(request.getAttachmentId()) |
| | | .sessionId(request.getSessionId()) |
| | | .sourceSha256(request.getSourceSha256()) |
| | | .proposalRevision(request.getProposalRevision()) |
| | | .build(); |
| | | } |
| | | |
| | | private static AgentImportPreflightRespVO.Issue issue(String code, String field, String message) { |
| | | return AgentImportPreflightRespVO.Issue.builder() |
| | | .code(code).field(field).message(message).retryable(false).build(); |
| | | } |
| | | |
| | | private static String value(AgentImportPreflightReqVO.Row row, String key) { |
| | | String raw = row.getValues() == null ? null : row.getValues().get(key); |
| | | return raw == null ? "" : raw.trim(); |
| | | } |
| | | |
| | | private static Integer parseStatus(String raw, List<AgentImportPreflightRespVO.Issue> blockers) { |
| | | if (StrUtil.isBlank(raw)) { |
| | | return 0; |
| | | } |
| | | String value = raw.trim(); |
| | | if ("0".equals(value) || "1".equals(value)) { |
| | | return Integer.valueOf(value); |
| | | } |
| | | if (blockers != null) { |
| | | blockers.add(issue("INVALID_STATUS", "status", "ç¶æåªè½æ¯ 0ï¼å¯ç¨ï¼æ 1ï¼ç¦ç¨ï¼")); |
| | | } |
| | | return 0; |
| | | } |
| | | |
| | | private static BigDecimal parseDecimal(String raw, List<AgentImportPreflightRespVO.Issue> blockers) { |
| | | if (StrUtil.isBlank(raw)) { |
| | | return null; |
| | | } |
| | | try { |
| | | return new BigDecimal(raw.trim()); |
| | | } catch (NumberFormatException ex) { |
| | | if (blockers != null) { |
| | | blockers.add(issue("INVALID_DECIMAL", "registeredCapital", "注åèµéå¿
é¡»æ¯æ°å")); |
| | | } |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private static BigDecimal decimalOrNull(String raw) { |
| | | return StrUtil.isBlank(raw) ? null : new BigDecimal(raw.trim()); |
| | | } |
| | | |
| | | private static String blankToNull(String raw) { |
| | | return StrUtil.isBlank(raw) ? null : raw.trim(); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.srm.service.supplier; |
| | | |
| | | import cn.iocoder.yudao.module.srm.controller.admin.supplier.vo.SrmSupplierSaveReqVO; |
| | | import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierDO; |
| | | import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierMapper; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportRowReceipt; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.test.util.ReflectionTestUtils; |
| | | |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.*; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.*; |
| | | |
| | | class SrmSupplierImportAdapterTest { |
| | | |
| | | private static final AgentPrincipal PRINCIPAL = new AgentPrincipal(1L, 1L, "live"); |
| | | |
| | | private SrmSupplierImportAdapter adapter; |
| | | private SrmSupplierService supplierService; |
| | | private SrmSupplierMapper supplierMapper; |
| | | |
| | | @BeforeEach |
| | | void setUp() { |
| | | adapter = new SrmSupplierImportAdapter(); |
| | | supplierService = mock(SrmSupplierService.class); |
| | | supplierMapper = mock(SrmSupplierMapper.class); |
| | | ReflectionTestUtils.setField(adapter, "supplierService", supplierService); |
| | | ReflectionTestUtils.setField(adapter, "supplierMapper", supplierMapper); |
| | | } |
| | | |
| | | @Test |
| | | void preflightMarksCreateSkipAndBlockedRows() { |
| | | SrmSupplierDO existing = new SrmSupplierDO(); |
| | | existing.setId(7L); |
| | | when(supplierMapper.selectByCode("SUP-OLD")).thenReturn(existing); |
| | | when(supplierMapper.selectByCode("SUP-NEW")).thenReturn(null); |
| | | |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request( |
| | | row("r1", "SUP-NEW", "æ°ä¾åºå"), |
| | | row("r2", "SUP-OLD", "æ§ä¾åºå"), |
| | | row("r3", "", "缺ç¼ç "))); |
| | | |
| | | assertEquals("CREATE", plan.getRows().get(0).getAction()); |
| | | assertEquals("SKIP", plan.getRows().get(1).getAction()); |
| | | assertEquals(7L, plan.getRows().get(1).getTargetId()); |
| | | assertEquals("BLOCKED", plan.getRows().get(2).getAction()); |
| | | assertEquals(1, plan.getSummary().getCreate()); |
| | | assertEquals(1, plan.getSummary().getSkip()); |
| | | assertEquals(1, plan.getSummary().getBlocked()); |
| | | assertFalse(plan.getCanCommit()); |
| | | assertNotNull(plan.getPreflightHash()); |
| | | assertTrue(plan.getRows().get(1).getWarnings().stream() |
| | | .anyMatch(item -> "SUPPLIER_EXISTS".equals(item.getCode()))); |
| | | } |
| | | |
| | | @Test |
| | | void duplicateCodeInFileBlocksAllOccurrences() { |
| | | when(supplierMapper.selectByCode(any())).thenReturn(null); |
| | | |
| | | AgentImportPreflightRespVO plan = adapter.preflight(PRINCIPAL, request( |
| | | row("r1", "SUP-DUP", "ä¾åºåA"), |
| | | row("r2", "SUP-DUP", "ä¾åºåB"))); |
| | | |
| | | assertEquals("BLOCKED", plan.getRows().get(0).getAction()); |
| | | assertEquals("BLOCKED", plan.getRows().get(1).getAction()); |
| | | assertEquals(2, plan.getSummary().getBlocked()); |
| | | assertFalse(plan.getCanCommit()); |
| | | } |
| | | |
| | | @Test |
| | | void preflightHashIsStableForSameInput() { |
| | | when(supplierMapper.selectByCode(any())).thenReturn(null); |
| | | AgentImportPreflightReqVO request = request(row("r1", "SUP-NEW", "æ°ä¾åºå")); |
| | | |
| | | assertEquals(adapter.preflight(PRINCIPAL, request).getPreflightHash(), |
| | | adapter.preflight(PRINCIPAL, request).getPreflightHash()); |
| | | } |
| | | |
| | | @Test |
| | | void commitCreatesSupplierAndReturnsReceipt() { |
| | | when(supplierService.createSupplier(any())).thenReturn(99L); |
| | | AgentImportPreflightReqVO.Row row = row("r1", "SUP-NEW", "æ°ä¾åºå"); |
| | | |
| | | AgentImportRowReceipt receipt = adapter.commitRow(PRINCIPAL, new AgentImportCommitReqVO(), |
| | | AgentImportPreflightRespVO.Row.builder().candidateId("r1").action("CREATE").build(), row); |
| | | |
| | | assertEquals("CREATE", receipt.action()); |
| | | assertEquals(99L, receipt.targetId()); |
| | | assertEquals("SUP-NEW", receipt.code()); |
| | | verify(supplierService).createSupplier(any(SrmSupplierSaveReqVO.class)); |
| | | } |
| | | |
| | | private static AgentImportPreflightReqVO request(AgentImportPreflightReqVO.Row... rows) { |
| | | AgentImportPreflightReqVO request = new AgentImportPreflightReqVO(); |
| | | request.setAttachmentId(1L); |
| | | request.setSourceSha256("a".repeat(64)); |
| | | request.setProposalRevision("0"); |
| | | request.setRows(List.of(rows)); |
| | | return request; |
| | | } |
| | | |
| | | private static AgentImportPreflightReqVO.Row row(String candidateId, String code, String name) { |
| | | Map<String, String> values = new LinkedHashMap<>(); |
| | | values.put("code", code); |
| | | values.put("name", name); |
| | | AgentImportPreflightReqVO.Row row = new AgentImportPreflightReqVO.Row(); |
| | | row.setCandidateId(candidateId); |
| | | row.setRowNo(1); |
| | | row.setValues(values); |
| | | return row; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.api.agent.imports; |
| | | |
| | | import jakarta.validation.constraints.NotBlank; |
| | | import lombok.Data; |
| | | import lombok.EqualsAndHashCode; |
| | | |
| | | /** |
| | | * éç¨ä¸å¡å¯¼å
¥æäº¤è¯·æ±ï¼å¨é¢æ£è¯·æ±ä¸è¿½å å¹çä¸è®¡åæè¦ã |
| | | */ |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | public class AgentImportCommitReqVO extends AgentImportPreflightReqVO { |
| | | |
| | | @NotBlank(message = "å½ä»¤ç¼å·ä¸è½ä¸ºç©º") |
| | | private String commandId; |
| | | |
| | | @NotBlank(message = "å¹çé®ä¸è½ä¸ºç©º") |
| | | private String idempotencyKey; |
| | | |
| | | @NotBlank(message = "颿£æè¦ä¸è½ä¸ºç©º") |
| | | private String preflightHash; |
| | | |
| | | private String proposalHash; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.api.agent.imports; |
| | | |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | |
| | | /** |
| | | * ä¸å¡æ¨¡å导å
¥éé
å¨ï¼system 模åè´è´£å¹çãéè¡äºå¡ä¸åæ§ï¼éé
å¨åªè´è´£åè¡æ ¡éªååå
¥ã |
| | | */ |
| | | public interface AgentImportModuleAdapter { |
| | | |
| | | /** ä¸ Python ä¾§ ModuleSpec.module_id 对é½ï¼å¦ srm.supplierã */ |
| | | String moduleId(); |
| | | |
| | | /** æäº¤è¯¥æ¨¡åæéçä¸å¡æéã */ |
| | | String permission(); |
| | | |
| | | /** åªè¯»é¢æ£ï¼å¿
é¡»è¿å稳å®ç preflightHash ä¾æäº¤æ¶æ¯å¯¹ã */ |
| | | AgentImportPreflightRespVO preflight(AgentPrincipal principal, AgentImportPreflightReqVO request); |
| | | |
| | | /** åè¡åå
¥ï¼å¤±è´¥æ¶æåºå¼å¸¸ï¼ç±ç¼æå±åå
¥ FAILED åæ§ã */ |
| | | AgentImportRowReceipt commitRow(AgentPrincipal principal, AgentImportCommitReqVO request, |
| | | AgentImportPreflightRespVO.Row planned, |
| | | AgentImportPreflightReqVO.Row row); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.api.agent.imports; |
| | | |
| | | import jakarta.validation.Valid; |
| | | import jakarta.validation.constraints.NotBlank; |
| | | import jakarta.validation.constraints.NotEmpty; |
| | | import jakarta.validation.constraints.NotNull; |
| | | import lombok.Data; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * éç¨ä¸å¡å¯¼å
¥é¢æ£è¯·æ±ãåªè¯»ï¼ä¸åä¸å¡è¡¨ã |
| | | */ |
| | | @Data |
| | | public class AgentImportPreflightReqVO { |
| | | |
| | | @NotNull(message = "éä»¶ç¼å·ä¸è½ä¸ºç©º") |
| | | private Long attachmentId; |
| | | |
| | | private Long sessionId; |
| | | |
| | | @NotBlank(message = "éä»¶æè¦ä¸è½ä¸ºç©º") |
| | | private String sourceSha256; |
| | | |
| | | private String proposalRevision; |
| | | |
| | | /** 模å级é项ï¼å¦ syncMesï¼æªè¯å«çé®ç±éé
å¨å¿½ç¥ã */ |
| | | private Map<String, Object> options; |
| | | |
| | | @NotEmpty(message = "导å
¥è¡ä¸è½ä¸ºç©º") |
| | | @Valid |
| | | private List<Row> rows; |
| | | |
| | | @Data |
| | | public static class Row { |
| | | |
| | | @NotBlank(message = "è¡æ è¯ä¸è½ä¸ºç©º") |
| | | private String candidateId; |
| | | |
| | | private Integer rowNo; |
| | | |
| | | @NotEmpty(message = "è¡æ°æ®ä¸è½ä¸ºç©º") |
| | | private Map<String, String> values; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.api.agent.imports; |
| | | |
| | | import lombok.Builder; |
| | | import lombok.Data; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * éç¨ä¸å¡å¯¼å
¥é¢æ£ååºï¼åæ®µä¸ Python 端 GenericPreflightResponse 对é½ã |
| | | */ |
| | | @Data |
| | | @Builder |
| | | public class AgentImportPreflightRespVO { |
| | | |
| | | private String moduleId; |
| | | private String preflightHash; |
| | | private Source source; |
| | | private Map<String, Object> policy; |
| | | private Summary summary; |
| | | private Boolean canCommit; |
| | | private List<String> sideEffects; |
| | | private List<Row> rows; |
| | | private List<Issue> blockingItems; |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Source { |
| | | private Long attachmentId; |
| | | private Long sessionId; |
| | | private String sourceSha256; |
| | | private String proposalRevision; |
| | | } |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Summary { |
| | | private Integer total; |
| | | private Integer create; |
| | | private Integer skip; |
| | | private Integer blocked; |
| | | } |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Row { |
| | | private String candidateId; |
| | | private Integer rowNo; |
| | | /** CREATE / SKIP / BLOCKED */ |
| | | private String action; |
| | | private Map<String, Object> resolved; |
| | | private List<Issue> blockers; |
| | | private List<Issue> warnings; |
| | | private Long targetId; |
| | | } |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Issue { |
| | | private String code; |
| | | private String field; |
| | | private String message; |
| | | private Boolean retryable; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.api.agent.imports; |
| | | |
| | | import lombok.Builder; |
| | | import lombok.Data; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * éç¨ä¸å¡å¯¼å
¥åæ§ï¼åæ®µä¸ Python 端 CommitReceipt 对é½ã |
| | | */ |
| | | @Data |
| | | @Builder |
| | | public class AgentImportReceiptRespVO { |
| | | |
| | | private String commandId; |
| | | private String idempotencyKey; |
| | | private String status; |
| | | private Long attachmentId; |
| | | private Long sessionId; |
| | | private String sourceSha256; |
| | | private String preflightHash; |
| | | private String proposalHash; |
| | | private Summary summary; |
| | | private String errorCode; |
| | | private String errorMessage; |
| | | private List<Row> rows; |
| | | private LocalDateTime createTime; |
| | | private LocalDateTime completedTime; |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Summary { |
| | | private Integer total; |
| | | private Integer success; |
| | | private Integer failed; |
| | | private Integer skipped; |
| | | private Integer unknown; |
| | | } |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Row { |
| | | private String candidateId; |
| | | private Integer rowNo; |
| | | private String code; |
| | | private String name; |
| | | private String action; |
| | | /** SUCCESS / FAILED / SKIPPED / UNKNOWN */ |
| | | private String status; |
| | | private Long targetItemId; |
| | | private String targetVersion; |
| | | private String errorCode; |
| | | private String errorMessage; |
| | | private Boolean retryable; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.api.agent.imports; |
| | | |
| | | /** |
| | | * åè¡åå
¥æååçæå°åæ§ã |
| | | */ |
| | | public record AgentImportRowReceipt(String action, Long targetId, String targetVersion, |
| | | String code, String name) { |
| | | |
| | | public static AgentImportRowReceipt created(Long targetId, String code, String name) { |
| | | return new AgentImportRowReceipt("CREATE", targetId, null, code, name); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.controller.admin.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportCommitReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.system.api.agent.imports.AgentImportReceiptRespVO; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | import cn.iocoder.yudao.module.system.framework.agent.SystemAgentBridgeGuard; |
| | | import cn.iocoder.yudao.module.system.service.agent.imports.SystemAgentGenericImportService; |
| | | import jakarta.annotation.Resource; |
| | | import jakarta.validation.Valid; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| | | |
| | | /** |
| | | * éç¨ä¸å¡å¯¼å
¥æ¡¥æ¥ï¼é¢æ£ãæäº¤ä¸åæ§ã |
| | | * å
·ä½ä¸å¡è§åç±å模åç {@code AgentImportModuleAdapter} å®ç°ï¼æ¬ç«¯åªåé´æä¸ç¼æå
¥å£ã |
| | | */ |
| | | @RestController |
| | | @RequestMapping("/agent/bridge/imports") |
| | | public class SystemAgentGenericImportBridgeController { |
| | | |
| | | @Resource |
| | | private SystemAgentGenericImportService genericImportService; |
| | | @Resource |
| | | private SystemAgentBridgeGuard bridgeGuard; |
| | | |
| | | @GetMapping |
| | | public CommonResult<List<Map<String, String>>> modules( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken) { |
| | | bridgeGuard.requireAny(serviceToken); |
| | | return success(genericImportService.modules()); |
| | | } |
| | | |
| | | @PostMapping("/{moduleId}/preflight") |
| | | public CommonResult<AgentImportPreflightRespVO> preflight( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @PathVariable String moduleId, |
| | | @Valid @RequestBody AgentImportPreflightReqVO request) { |
| | | AgentPrincipal principal = bridgeGuard.requireAny(serviceToken); |
| | | return success(genericImportService.preflight(moduleId, request, principal)); |
| | | } |
| | | |
| | | @PostMapping("/{moduleId}/commit") |
| | | public CommonResult<AgentImportReceiptRespVO> commit( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @PathVariable String moduleId, |
| | | @Valid @RequestBody AgentImportCommitReqVO request) { |
| | | AgentPrincipal principal = bridgeGuard.requireAny(serviceToken); |
| | | return success(genericImportService.commit(moduleId, request, principal)); |
| | | } |
| | | |
| | | @GetMapping("/{moduleId}/commands/{commandId}") |
| | | public CommonResult<AgentImportReceiptRespVO> getCommand( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @PathVariable String moduleId, |
| | | @PathVariable String commandId) { |
| | | AgentPrincipal principal = bridgeGuard.requireAny(serviceToken); |
| | | return success(genericImportService.getCommand(moduleId, commandId, principal)); |
| | | } |
| | | } |
| | |
| | | |
| | | private static final Set<String> ACTIONS = Set.of("material_import.prepare", "material_import.read", |
| | | "material_import.review", "material_import.preflight", "material_import.commit", |
| | | "material_import.read_receipt"); |
| | | "material_import.read_receipt", |
| | | "conversation.create", "conversation.read", "conversation.message", |
| | | "conversation.action", "conversation.report"); |
| | | |
| | | /** module_id â ä¸å¡å¯¼å
¥æéï¼Python 以模å ID ä½ä¸ºææ resourceã */ |
| | | private static final Map<String, String> MODULE_PERMISSIONS = Map.of( |
| | | "mdm.item", SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT, |
| | | "srm.supplier", "srm:supplier:create", |
| | | "mes.bom", "mes:md-item:create", |
| | | "mes.qc-template", "mes:qc-template:create", |
| | | "mes.mps", "mes:pro-mps:create", |
| | | "mes.route", "mes:pro-route:create"); |
| | | |
| | | @Resource |
| | | private SystemAgentImportBridgeService bridgeService; |
| | | @Resource |
| | | private SystemAgentBridgeGuard bridgeGuard; |
| | | |
| | | private static final String IMPORT_PERMISSIONS_EXPR = |
| | | "@ss.hasAnyPermissions('mdm:item:import','srm:supplier:create','mes:md-item:create'," |
| | | + "'mes:qc-template:create','mes:pro-mps:create','mes:pro-route:create')"; |
| | | |
| | | @PostMapping("/import-sessions") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | @PreAuthorize(IMPORT_PERMISSIONS_EXPR) |
| | | public CommonResult<Long> createSession() { |
| | | return success(bridgeService.createSession()); |
| | | } |
| | | |
| | | @PostMapping("/import-sessions/{sessionId}/attachments") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | @PreAuthorize(IMPORT_PERMISSIONS_EXPR) |
| | | public CommonResult<Long> bindAttachment(@PathVariable Long sessionId, @Valid @RequestBody BindRequest request) { |
| | | return success(bridgeService.bindAttachment(sessionId, request.getBlobId())); |
| | | } |
| | | |
| | | @PostMapping("/authorize") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | @PreAuthorize(IMPORT_PERMISSIONS_EXPR) |
| | | public CommonResult<Map<String, String>> authorize( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @Valid @RequestBody AuthorizeRequest request) { |
| | | AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT); |
| | | if (!ACTIONS.contains(request.getAction())) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ACTION_FORBIDDEN", "䏿¯æçæºè½ä½æä½"); |
| | | } |
| | | AgentPrincipal principal; |
| | | if (request.getAction().startsWith("conversation.")) { |
| | | String permission = MODULE_PERMISSIONS.get(request.getResource()); |
| | | principal = permission == null |
| | | ? bridgeGuard.requireAny(serviceToken) |
| | | : bridgeGuard.require(serviceToken, permission); |
| | | } else { |
| | | principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT); |
| | | } |
| | | if (requiresAttachmentOwnership(request.getAction())) { |
| | | bridgeService.requireOwnedAttachmentRef(parseId(request.getResource())); |
| | |
| | | } |
| | | |
| | | @GetMapping("/attachments/{attachmentId}") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | @PreAuthorize(IMPORT_PERMISSIONS_EXPR) |
| | | public ResponseEntity<FileSystemResource> downloadAttachment( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @PathVariable Long attachmentId) { |
| | | bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT); |
| | | bridgeGuard.requireAny(serviceToken); |
| | | SystemAgentAttachmentRef ref = bridgeService.requireOwnedAttachmentRef(attachmentId); |
| | | return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM) |
| | | .contentLength(ref.byteSize()) |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.dal.dataobject.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.*; |
| | | |
| | | import java.time.LocalDateTime; |
| | | |
| | | /** |
| | | * éç¨ä¸å¡å¯¼å
¥å½ä»¤ãåä¸ (tenant, owner, module, commandId/idempotencyKey) åªå
è®¸ä¸æ¡ã |
| | | */ |
| | | @TableName("agent_import_command") |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @ToString(callSuper = true) |
| | | @Builder |
| | | @NoArgsConstructor |
| | | @AllArgsConstructor |
| | | public class AgentImportCommandDO extends BaseDO { |
| | | |
| | | @TableId |
| | | private Long id; |
| | | |
| | | private String commandId; |
| | | private String idempotencyKey; |
| | | private String requestDigest; |
| | | private String moduleId; |
| | | private Long tenantId; |
| | | private Long ownerUserId; |
| | | private Long sessionId; |
| | | private Long attachmentId; |
| | | private String sourceSha256; |
| | | private String preflightHash; |
| | | private String proposalHash; |
| | | private String status; |
| | | private Integer totalCount; |
| | | private Integer successCount; |
| | | private Integer failedCount; |
| | | private Integer skippedCount; |
| | | private String errorCode; |
| | | private String errorMessage; |
| | | private LocalDateTime leaseExpiresAt; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.dal.dataobject.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.*; |
| | | |
| | | /** |
| | | * éç¨ä¸å¡å¯¼å
¥éè¡åæ§ã |
| | | */ |
| | | @TableName("agent_import_command_row") |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @ToString(callSuper = true) |
| | | @Builder |
| | | @NoArgsConstructor |
| | | @AllArgsConstructor |
| | | public class AgentImportCommandRowDO extends BaseDO { |
| | | |
| | | @TableId |
| | | private Long id; |
| | | |
| | | private String commandId; |
| | | private String moduleId; |
| | | private String candidateId; |
| | | private Integer sourceRow; |
| | | private String code; |
| | | private String name; |
| | | private String action; |
| | | private String status; |
| | | private Long targetItemId; |
| | | private String targetVersion; |
| | | private String errorCode; |
| | | private String errorMessage; |
| | | private Boolean retryable; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.dal.mysql.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| | | import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.agent.AgentImportCommandDO; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | @Mapper |
| | | public interface AgentImportCommandMapper extends BaseMapperX<AgentImportCommandDO> { |
| | | |
| | | default AgentImportCommandDO selectByCommandId(Long tenantId, Long ownerUserId, |
| | | String moduleId, String commandId) { |
| | | return selectOne(new LambdaQueryWrapperX<AgentImportCommandDO>() |
| | | .eq(AgentImportCommandDO::getTenantId, tenantId) |
| | | .eq(AgentImportCommandDO::getOwnerUserId, ownerUserId) |
| | | .eq(AgentImportCommandDO::getModuleId, moduleId) |
| | | .eq(AgentImportCommandDO::getCommandId, commandId)); |
| | | } |
| | | |
| | | default AgentImportCommandDO selectByIdempotencyKey(Long tenantId, Long ownerUserId, |
| | | String moduleId, String idempotencyKey) { |
| | | return selectOne(new LambdaQueryWrapperX<AgentImportCommandDO>() |
| | | .eq(AgentImportCommandDO::getTenantId, tenantId) |
| | | .eq(AgentImportCommandDO::getOwnerUserId, ownerUserId) |
| | | .eq(AgentImportCommandDO::getModuleId, moduleId) |
| | | .eq(AgentImportCommandDO::getIdempotencyKey, idempotencyKey)); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.dal.mysql.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.agent.AgentImportCommandRowDO; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | import java.util.List; |
| | | |
| | | @Mapper |
| | | public interface AgentImportCommandRowMapper extends BaseMapperX<AgentImportCommandRowDO> { |
| | | |
| | | default List<AgentImportCommandRowDO> selectListByCommandId(String commandId) { |
| | | return selectList(AgentImportCommandRowDO::getCommandId, commandId); |
| | | } |
| | | } |
| | |
| | | |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.MessageDigest; |
| | | import java.util.List; |
| | | |
| | | /** æ¡¥æ¥ç»ä¸é´æï¼æå¡ä»¤ç + å½åç»å½ç¨æ· + ä¸å¡æéï¼ç¼ºä¸ä¸å¯ã */ |
| | | @Component |
| | | public class SystemAgentBridgeGuard { |
| | | |
| | | public static final String PERMISSION_ITEM_IMPORT = "mdm:item:import"; |
| | | |
| | | /** åä¸å¡æ¨¡åç导å
¥æéï¼ä¼è¯å建ä¸é件读ååªéå½ä¸å
¶ä¸ä»»æä¸é¡¹ã */ |
| | | public static final List<String> IMPORT_PERMISSIONS = List.of( |
| | | PERMISSION_ITEM_IMPORT, |
| | | "srm:supplier:create", |
| | | "mes:md-item:create", |
| | | "mes:qc-template:create", |
| | | "mes:pro-mps:create", |
| | | "mes:pro-route:create"); |
| | | |
| | | @Value("${yudao.agent.bridge-service-token:}") |
| | | private String bridgeServiceToken; |
| | |
| | | } |
| | | if (!securityFrameworkService.hasPermission(permission)) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN", |
| | | "å½åç¨æ·ç¼ºå°ç©æå¯¼å
¥æéï¼" + permission + "ï¼"); |
| | | "å½åç¨æ·ç¼ºå°æ°æ®å¯¼å
¥æéï¼" + permission + "ï¼"); |
| | | } |
| | | return principalOf(userId); |
| | | } |
| | | |
| | | /** ä¼è¯ä¸éä»¶å
¥å£æ æ³é¢ç¥æ¨¡åï¼åªè¦æ±å
·å¤ä»»æä¸é¡¹å¯¼å
¥æéã */ |
| | | public AgentPrincipal requireAny(String providedServiceToken) { |
| | | verifyServiceToken(providedServiceToken); |
| | | Long userId = SecurityFrameworkUtils.getLoginUserId(); |
| | | if (userId == null) { |
| | | throw new AgentBridgeException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "æªç»å½æç»å½å·²è¿æ"); |
| | | } |
| | | if (!securityFrameworkService.hasAnyPermissions(IMPORT_PERMISSIONS.toArray(String[]::new))) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN", "å½åç¨æ·ç¼ºå°æ°æ®å¯¼å
¥æé"); |
| | | } |
| | | return principalOf(userId); |
| | | } |
| | | |
| | | private AgentPrincipal principalOf(Long userId) { |
| | | Long tenantId = TenantContextHolder.getTenantId(); |
| | | return new AgentPrincipal(userId, tenantId == null ? 0L : tenantId, "live"); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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<AgentImportModuleAdapter> 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<Map<String, String>> 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 tenantId = principal.tenantId(); |
| | | Long ownerUserId = principal.userId(); |
| | | String digest = requestDigest(moduleId, request); |
| | | |
| | | // 1. å¹çå½ä¸ï¼å½ä»¤ ID ä¼å
ï¼å
¶æ¬¡å¹çé® |
| | | AgentImportCommandDO existing = commandMapper.selectByCommandId( |
| | | tenantId, ownerUserId, moduleId, request.getCommandId()); |
| | | if (existing != null) { |
| | | requireSameDigest(existing, digest); |
| | | return toReceipt(existing); |
| | | } |
| | | existing = commandMapper.selectByIdempotencyKey( |
| | | tenantId, 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) |
| | | .tenantId(tenantId) |
| | | .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( |
| | | tenantId, ownerUserId, moduleId, request.getCommandId()); |
| | | if (raced == null) { |
| | | raced = commandMapper.selectByIdempotencyKey( |
| | | tenantId, 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.tenantId(), 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) { |
| | | if (!securityFrameworkService.hasPermission(adapter.permission())) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN", |
| | | "å½åç¨æ·ç¼ºå°è¯¥æ¨¡åç导å
¥æéï¼" + adapter.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<String, AgentImportModuleAdapter> adapters() { |
| | | Map<String, AgentImportModuleAdapter> 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<String, Object> 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<Map<String, Object>> rows = new ArrayList<>(); |
| | | for (AgentImportPreflightReqVO.Row row : request.getRows()) { |
| | | Map<String, Object> 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<String, Object> 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<Map<String, Object>> rows = new ArrayList<>(); |
| | | for (AgentImportPreflightReqVO.Row row : request.getRows()) { |
| | | Map<String, Object> 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<AgentImportCommandRowDO> rowDOs = commandRowMapper.selectListByCommandId(command.getCommandId()); |
| | | List<AgentImportReceiptRespVO.Row> 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); |
| | | } |
| | | } |