feat(mdm): 打通智能体物料导入端到端链路
- Java 桥接:补录会话、附件绑定、服务令牌守卫,统一真实 HTTP 状态码与错误体
- 只读预检:分类/单位解析、编码存在性、CREATE/SKIP/BLOCKED 与确定性 preflight_hash
- 幂等提交:command_id + idempotency_key、PLAN_STALE 校验、逐行独立事务与回执查询
- 抽取物料导入解析供 Excel 导入与桥接复用,原导入行为保持不变
- 新增 3 张智能体导入表迁移及合并初始化脚本 config_export_all_20261022
- v1 只新建物料、不同步 MES,Java/Python 写开关默认关闭
- 补充前端联调方案与业务可视化、开发进度文档
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | # æºè½ä½ç©æå¯¼å
¥ - å端èè°æ¹æ¡ |
| | | |
| | | ## æ¶åé¡µé¢ |
| | | |
| | | - ç©æç®¡ç - ç©æå表ï¼å
¥å£ä¸ç»æç¡®è®¤ï¼ |
| | | - ç©æç®¡ç - æºè½ä½å¯¼å
¥ï¼ä¸ä¼ ãå®¡æ ¸ãæäº¤ãåæ§ï¼ |
| | | |
| | | ## ä¸å¡å®ä½ |
| | | |
| | | æºè½ä½å¯¼å
¥æ¯ç¬ç«äº Excel æ¹é导å
¥çæ°å
¥å£ï¼ç¨æ·ä¸ä¼ ç©ææ¸
åï¼æºè½ä½è§£æå¹¶è°ç¨ Java ä¸»æ°æ®é¢æ£ï¼ç¨æ·æ ¸å¯¹åæäº¤ã**æ¬é¶æ®µåªæ°å»ºç©æ**ï¼ç¼ç å·²å卿¶è·³è¿ï¼ä¸æ´æ°åç©æï¼ä¸è§¦å MES 忥ã |
| | | |
| | | ## ä¸å¡æµç¨ä¸æ°æ®å¸¦å
¥ |
| | | |
| | | 1. è¿å
¥å¯¼å
¥é¡µ â å端å建补å½ä¼è¯ï¼æ¿å° `sessionId`ã |
| | | 2. éæ© Excel/CSV â èµ°æ¢ææä»¶ä¸ä¼ ï¼æ¿å° `blobId`ã |
| | | 3. å° `blobId` ç»å®å°ä¼è¯ â æ¿å° `attachmentId`ãéä»¶å½å±ã大å°ä¸æ ¼å¼ç±åç«¯æ ¡éªã |
| | | 4. ç¨ `attachmentId` å建æºè½ä½ä»»å¡ â è¿åä»»å¡ IDãåéè¡ãJava 颿£ç»æï¼åç±»ãåä½ãç¼ç ç¶æï¼ä¸äººå®¡ä¸æä¿¡æ¯ã |
| | | 5. ç¨æ·å¨å®¡æ ¸é¡µä¿®è®¢åæ®µå¹¶éæ°æ£æ¥ â æ¯æ¬¡ä¿®è®¢å `revision` å 1ï¼å¹¶éæ°é¢æ£ã |
| | | 6. ç¨æ·ç¡®è®¤ â æäº¤ï¼æååå±ç¤ºéè¡åæ§ã |
| | | 7. ç»æé¡µå¯å次æ¥è¯¢åæ§ï¼ä»»å¡ ID å¯åæ¥ä»»å¡ç¶æã |
| | | |
| | | å
³é®å¸¦å
¥å
³ç³»ï¼ |
| | | |
| | | | æ¥æº | 带å
¥ç®æ | åæ®µ | |
| | | |---|---|---| |
| | | | æä»¶ä¸ä¼ | ä¼è¯ç»å® | `blobId` | |
| | | | ä¼è¯ç»å® | åå»ºä»»å¡ | `attachmentId`ï¼ä¸æ¯ blobIdï¼ | |
| | | | å建任å¡ååº | å®¡æ ¸é¡µ | `task_id`ã`state.revision`ã`state.preflight`ã`interrupts` | |
| | | | 颿£ç»æ | ç¡®è®¤è¯·æ± | `state.preflight_hash` | |
| | | | å®¡æ ¸ä¿®è®¢ | ä¸ä¸æ¬¡é¢æ£ | 修订åçåæ®µå¼ï¼`revision` èªå¢ | |
| | | | æäº¤ååº | 忧页 | `receipt.rows[].targetItemId`ã`status` | |
| | | |
| | | ## API |
| | | |
| | | ### Java 管ç端ï¼`/admin-api`ï¼æºå¸¦ç¨æ· Bearer tokenï¼ |
| | | |
| | | | æ¹æ³ | è·¯å¾ | 说æ | |
| | | |---|---|---| |
| | | | POST | `/agent/bridge/import-sessions` | å建补å½ä¼è¯ï¼è¿å `sessionId` | |
| | | | POST | `/agent/bridge/import-sessions/{sessionId}/attachments` | ç»å®éä»¶ï¼è¿å `attachmentId` | |
| | | |
| | | **ç»å®è¯·æ±åæ°ï¼** |
| | | |
| | | | åæ° | ç±»å | å¿
å¡« | 说æ | |
| | | |---|---|---|---| |
| | | | `blobId` | Long | æ¯ | æ¢æä¸ä¼ æ¥å£è¿åç blob ID | |
| | | |
| | | **éå¶ï¼** |
| | | |
| | | - åä¸ä¼è¯éå¤ç»å®ä¼æ¿æ¢æ§éä»¶ï¼æ§ `attachmentId` éåä¸å¯ç¨ã |
| | | - æä»¶ä¸é 8 MiBï¼ä»
å
许 CSV/XLSXã |
| | | - ç¨æ·éå
·å¤ `mdm:item:import` æéï¼å¦åè¿å 403 `FORBIDDEN`ã |
| | | |
| | | ### Python æºè½ä½æå¡ï¼é»è®¤ `http://127.0.0.1:8091`ï¼æºå¸¦åä¸ç¨æ· Bearer tokenï¼ |
| | | |
| | | > Python æå¡ä¸å¨ `/admin-api` ä¸ï¼éç±ç½å
³æå端代ç转åï¼æå¡å£ä»¤ `X-Agent-Service-Token` ç± Python æå¡å
é¨ä½¿ç¨ï¼**å端ä¸å¾æºå¸¦æä¸å**ã |
| | | |
| | | | æ¹æ³ | è·¯å¾ | 说æ | |
| | | |---|---|---| |
| | | | POST | `/v1/material-imports` | å建任å¡å¹¶è§¦å颿£ï¼è¿åä»»å¡ä¸å®¡æ ¸ä¸æ | |
| | | | GET | `/v1/material-imports/{task_id}` | 读åä»»å¡å½åç¶æ | |
| | | | POST | `/v1/material-imports/{task_id}/review` | 修订æç¡®è®¤ | |
| | | | GET | `/v1/material-imports/{task_id}/receipt` | æ¥è¯¢æäº¤åæ§ | |
| | | |
| | | **å建任å¡è¯·æ±åæ°ï¼** |
| | | |
| | | | åæ° | ç±»å | å¿
å¡« | 说æ | |
| | | |---|---|---|---| |
| | | | `attachment_id` | String | æ¯ | Java è¿åçéä»¶ ID | |
| | | | `filename` | String | å¦ | å
¼å®¹å段ï¼è§£æä»¥ Java è¿åçæä»¶å为å | |
| | | |
| | | **å建任å¡ååºï¼** |
| | | |
| | | | åæ®µ | ç±»å | 说æ | |
| | | |---|---|---| |
| | | | `task_id` | String | ä»»å¡ IDï¼åç»è¯»å/å®¡æ ¸/åæ§åä½¿ç¨ | |
| | | | `state.revision` | Integer | æ¹æ¡çæ¬ï¼ç¡®è®¤æ¶å¿
é¡»åä¼ å½åå¼ | |
| | | | `state.candidates` | Array | è§£æåçåéè¡ååå
æ ¼æ¥æº | |
| | | | `state.issues` | Array | åæ®µç¼ºå¤±ãéå¤ç¼ç çæ¬å°æ ¡éªé®é¢ | |
| | | | `state.preflight` | Object | Java 颿£ç»æï¼`can_commit`ã`summary`ã`rows[]` ç | |
| | | | `state.preflight_hash` | String | 颿£è®¡åæè¦ | |
| | | | `interrupts` | Array | äººå®¡ä¸æä¿¡æ¯ï¼`type=material_review` | |
| | | |
| | | **å®¡æ ¸è¯·æ±åæ°ï¼** |
| | | |
| | | | åæ° | ç±»å | å¿
å¡« | 说æ | |
| | | |---|---|---|---| |
| | | | `expected_revision` | Integer | æ¯ | å½å `state.revision` | |
| | | | `action` | String | æ¯ | `revise` / `confirm` / `cancel` | |
| | | | `corrections` | Array | 修订æ¶å¿
å¡« | æ¯é¡¹å« `candidate_id`ã`field`ã`value`ã`reason` | |
| | | | `expected_preflight_hash` | String | 确认æ¶å»ºè®®å¡«å | å½å `state.preflight_hash` | |
| | | | `expected_proposal_hash` | String | å¦ | äºæ¬¡ç¡®è®¤æ¶æ ¡éªæ¹æ¡æªåå | |
| | | |
| | | **åæ§ååºå
³é®å段ï¼** |
| | | |
| | | | åæ®µ | 说æ | |
| | | |---|---| |
| | | | `status` | `SUCCEEDED` / `PARTIAL_FAILED` / `FAILED` / `UNKNOWN` | |
| | | | `summary` | `total/success/failed/skipped/unknown` | |
| | | | `rows[].action` | `CREATE` / `SKIP` | |
| | | | `rows[].status` | `SUCCESS` / `SKIPPED` / `FAILED` / `UNKNOWN` | |
| | | | `rows[].targetItemId` | æ°å»ºç©æ IDï¼`SKIP` è¡ä¸ºå·²æç©æ ID | |
| | | | `rows[].errorCode` / `errorMessage` | è¡çº§å¤±è´¥åå | |
| | | | `rows[].retryable` | æ¯å¦å»ºè®®éè¯ | |
| | | |
| | | ## åæ®µå±ç¤ºè§å |
| | | |
| | | | åæ®µ | å±ç¤ºä½ç½® | 说æ | |
| | | |---|---|---| |
| | | | ç©æç¼ç | å®¡æ ¸å表ãåæ§ | æä»¶å
é夿¶æ´æ¹é»æ | |
| | | | ç©æåç§° | å®¡æ ¸å表ãåæ§ | 为空æ¶é»æè¯¥è¡ | |
| | | | è§æ ¼åå· | å®¡æ ¸å表 | å¯é | |
| | | | åç±»ç¼ç | å®¡æ ¸å表 | 颿£è§£æä¸º `resolved.categoryName` åä¼å
æ¾ç¤ºåç§° | |
| | | | 计éåä½ç¼ç | å®¡æ ¸å表 | 颿£è§£æä¸º `resolved.unitMeasureName` åä¼å
æ¾ç¤ºåç§° | |
| | | | è¡å¨ä½ | å®¡æ ¸å表 | `CREATE` æ°å»ºã`SKIP` å·²åå¨å°è·³è¿ã`BLOCKED` 黿 | |
| | | | 黿/è¦å | å®¡æ ¸å表è¡å
| å±ç¤º `blockers[]`ã`warnings[]` ç `message`ï¼å¹¶é«äº®è¯¥è¡ | |
| | | | 颿£æ±æ» | å®¡æ ¸é¡µé¡¶é¨ | æ»æ°ãæ°å»ºãè·³è¿ã黿 | |
| | | | æ¯å¦å¯æäº¤ | å®¡æ ¸é¡µåºé¨ | ç± `preflight.can_commit` ä¸ `state.issues` å
±åå³å® | |
| | | | ç®æ ç©æ ID | 忧页 | `rows[].targetItemId`ï¼å¯è·³è½¬ç©æè¯¦æ
| |
| | | | åæ§æ±æ» | åæ§é¡µé¡¶é¨ | æåãè·³è¿ãå¤±è´¥ãæªç¥æ°é | |
| | | |
| | | ## ä¸å¡è§å说æ |
| | | |
| | | | åºæ¯ | è§å | |
| | | |---|---| |
| | | | ç¼ç å·²åå¨ | 颿£ `SKIP`ï¼ä¸é»ææ´æ¹ï¼æäº¤æ¶è·³è¿ï¼ä¸æ´æ°åç©æ | |
| | | | åç±»/åä½ä¸åå¨ | è¯¥è¡ `BLOCKED`ï¼æ´æ¹ä¸å¯æäº¤ï¼æç¤ºç¨æ·ä¿®æ£ç¼ç | |
| | | | æä»¶å
ç¼ç éå¤ | ææéå¤è¡å `BLOCKED` | |
| | | | 请æ±åæ¥ MES | è¿å黿项 `MES_SYNC_DISABLED`ï¼æ¬é¶æ®µä¸æ¯æ | |
| | | | è¶
è¿ 5000 è¡ | æ´æ¹é»æ `ROW_LIMIT_EXCEEDED` | |
| | | | æ¹æ¡è¢«ä¿®è®¢ | `revision` èªå¢ï¼å¿
é¡»ç¨æ°çæ¬éæ°ç¡®è®¤ | |
| | | | ç¡®è®¤åæ°æ®åå | æäº¤æ¶éæ°é¢æ£ï¼è®¡åååè¿å 409 `PLAN_STALE`ï¼ééæ°è¯»å并确认 | |
| | | | éå¤ç¹å»æäº¤ | æå¡ç«¯å¹çï¼è¿åååæ§ï¼ä¸éå¤å»ºç©æ | |
| | | | æäº¤è¶
æ¶ | åç«¯åºæ¥è¯¢åæ§ï¼ä¸è¦éæ°å起确认 | |
| | | | åå¼å
³å
³é | Python è¿å 409 `COMMIT_DISABLED`ï¼Java è¿å 503 `WRITE_DISABLED` | |
| | | |
| | | ## 注æäºé¡¹ |
| | | |
| | | - åç«¯åªæºå¸¦ç¨æ·ç»å½ tokenï¼æå¡å£ä»¤ãéä»¶ä¸è½½æ¥å£ä»
é Python æå¡å
ç½è®¿é®ã |
| | | - `attachmentId` 䏿¯ `blobId`ï¼ç»å®æ¥å£è¿åçææ¯ä»»å¡å
¥åã |
| | | - 确认åå¿
é¡»éæ°è¯»åä»»å¡ï¼ä½¿ç¨ææ°ç `revision` ä¸ `preflight_hash`ï¼ç¦æ¢ç¼åæ§å¼ç´æ¥ç¡®è®¤ã |
| | | - 409 `PLAN_STALE` ä¸ 409 `COMMIT_DISABLED` è¯ä¹ä¸åï¼åè
é鿰颿£ï¼åè
éè系管çåæå¼åå¼å
³ã |
| | | - `SKIP` è¡ä¹ä¼åºç°å¨åæ§ä¸ï¼å±ç¤ºä¸ºâå·²åå¨ï¼å·²è·³è¿âï¼ä¸æ¯å¤±è´¥ã |
| | | - ä»»å¡æç§æ· + ç¨æ·éç¦»ï¼æ¢è´¦å·æè·¨ç§æ·è®¿é®è¿å 404ã |
| | | - æ¬é¶æ®µä¸æä¾è¿åº¦æ¨éï¼æäº¤ä¸ºåæ¥è¯·æ±ï¼å端é设置足å¤é¿çè¶
æ¶ï¼å»ºè®® 120 ç§ï¼ï¼è¶
æ¶åæ¥è¯¢åæ§ã |
| | | - 䏿¯ææ´æ°å·²æç©æï¼å¦ä¸å¡éè¦ä¿®æ¹ç©æï¼ä»èµ°æ¢æç©æç¼è¾æµç¨ã |
| | |
| | | "coreObjects": 48, |
| | | "documentFlows": 6, |
| | | "approvalListeners": 13, |
| | | "aiCapabilities": 8, |
| | | "aiCapabilities": 9, |
| | | "wmBillTypes": 15 |
| | | }, |
| | | "businessAnswers": [ |
| | |
| | | ], |
| | | [ |
| | | "AI å¨åªéåä¸", |
| | | "å票/æ¥ä»· OCR é¢å¡«ãè´¨æ£ç»æå»ºè®®ã缺æ/æ¶é¿/é£é©/交ä»åç±»é¢æµï¼å为ãä¸å¡ç»è£
Promptâæ¨¡åâJSONâäººå·¥ç¡®è®¤ãæ¨¡å¼" |
| | | "å票/æ¥ä»· OCR é¢å¡«ãè´¨æ£ç»æå»ºè®®ã缺æ/æ¶é¿/é£é©/交ä»åç±»é¢æµï¼å为ãä¸å¡ç»è£
Promptâæ¨¡åâJSONâäººå·¥ç¡®è®¤ãæ¨¡å¼ãæºè½ä½ç©æå¯¼å
¥å¦èµ°è§å+Java 颿£é¾è·¯ï¼äººå·¥ç¡®è®¤åå¹çæäº¤ï¼åªæ°å»ºä¸åæ¥ MES" |
| | | ], |
| | | [ |
| | | "模åé´æ°æ®å
³ç³»", |
| | |
| | | "name": "RAG ç¥è¯åº", |
| | | "flow": "ææ¡£ â POI/Tika æææ¬ â åç(800token) â åéåå
¥ Milvus â æ£ç´¢æ¥å£(topK3,éå¼0.7)ãå½åæ ä¸å¡æ¨¡åæ¶è´¹ï¼é¢çè½åã", |
| | | "callsApis": [] |
| | | }, |
| | | { |
| | | "name": "æºè½ä½ç©æå¯¼å
¥", |
| | | "flow": "å建补å½ä¼è¯âä¸ä¼ å¹¶ç»å®éä»¶âPython è§£æ CSV/XLSX åéâJava åªè¯»é¢æ£å类·åä½Â·ç¼ç å卿§å¹¶çæç¡®å®æ§æè¦â人工修订确认âcommandId+å¹çé®éè¡ç¬ç«äºå¡æäº¤âéè¡åæ§å«ç®æ ç©æIDï¼ç¼ç å·²åå¨è·³è¿ï¼ä¸æ´æ°ç©æãä¸è§¦å MES", |
| | | "callsApis": [ |
| | | "SystemAgentImportBridgeService", |
| | | "MdmItemService", |
| | | "MdmAgentImportPreflightService", |
| | | "MdmAgentImportCommandService" |
| | | ] |
| | | } |
| | | ], |
| | | "pendingConfirm": [ |
| | |
| | | {"stage": "ç»´ä¿®", "action": "ç°åº/è¿å/è¿ç¨ç»´ä¿®ï¼è´¹ç¨ä¸ç»æè®°å½ï¼å®æèå¨å·¥åå®ç»", "roles": ["维修工ç¨å¸"]}, |
| | | {"stage": "éè´§", "action": "éè´§ç³è¯·å®¡æ ¸âèªå¨å»º MES éå®éè´§å(RQC)âè´¨æ£/å
¥åºåè°â鿬¾åè°âå
³é", "roles": ["å®å", "è´¨æ£", "è´¢å¡"]}]}, |
| | | {"id": "ai", "name": "AI ä¸å¡æµç¨", "mermaid": "mermaid/11-ai-business-flow.mmd", "color": "#56c8d8", |
| | | "summary": "ç»ä¸æ¨¡å¼ï¼ä¸å¡ä¾§æ¶éä¸ä¸æ(å¿
è¦æ¶è° MDM/SRM/CRM API ååç§°)âç»è£
Promptâchat/chatWithImageâè§£æ JSONâå端é¢å¡«æå»ºè®®ï¼æ Function Callingï¼RAG ç¥è¯åºå·²å»ºæä½ææ ä¸å¡æ¶è´¹ã", |
| | | "summary": "ç»ä¸æ¨¡å¼ï¼ä¸å¡ä¾§æ¶éä¸ä¸æ(å¿
è¦æ¶è° MDM/SRM/CRM API ååç§°)âç»è£
Promptâchat/chatWithImageâè§£æ JSONâå端é¢å¡«æå»ºè®®ï¼æ Function Callingï¼RAG ç¥è¯åºå·²å»ºæä½ææ ä¸å¡æ¶è´¹ãæºè½ä½ç©æå¯¼å
¥ä¸ºè§å+Java 颿£é¾è·¯ï¼ç»äººå·¥ç¡®è®¤åå¹çæäº¤ã", |
| | | "steps": [ |
| | | {"stage": "å½å
¥ææ", "action": "å票 OCR(å¾ç夿¨¡æ/PDFææ¬)ãæ¥ä»·å OCR â ç»æåé¢å¡«", "roles": ["éè´å", "éå®"]}, |
| | | {"stage": "ä¸»æ°æ®å¯¼å
¥", "action": "ä¸ä¼ ç©æ CSV/XLSX â Python è§£æåé â Java åªè¯»é¢æ£åç±»/åä½/éå¤ç¼ç â 人工修订确认 â å¹çæäº¤ â éè¡åæ§ï¼åªæ°å»ºã䏿´æ°ãä¸åæ¥ MES", "roles": ["ç©æç®¡çå"]}, |
| | | {"stage": "è¾
å©å¤å®", "action": "è´¨æ£å+ææ +ç¼ºé· â å»ºè®®åæ ¼/ç¹é/éè´§/æ¥åº+çç±", "roles": ["è´¨æ£å"]}, |
| | | {"stage": "å³ç颿µ", "action": "缺æ/ç产æ¶é¿/ç产é£é©/交ä»åç±»é¢æµä¾è®¡åæäº§åè", "roles": ["计åå"]}, |
| | | {"stage": "模æ¿è¯å«", "action": "ä¸ä¼ æ¢ææ£éªæ¥åæä»¶(æ«æä»¶/ç
§çãçµåç PDFãWord/Excel) â åééæ½å(æææ¬å±èµ°ææ¬ãæ«æçé页转å¾) â 大模åæå端ä¸ä¼ çç»ä»¶æ¸
åäº§åºæå¹³ç»ä»¶è稿 â 人工å¨è®¾è®¡å¨ç¡®è®¤åä¿å为模æ¿çæ¬", "roles": ["è´¨æ£å", "è´¨éå·¥ç¨å¸"]}, |
| | |
| | | "coreObjects": 48, |
| | | "documentFlows": 6, |
| | | "approvalListeners": 13, |
| | | "aiCapabilities": 8, |
| | | "aiCapabilities": 9, |
| | | "wmBillTypes": 15 |
| | | }, |
| | | "businessAnswers": [ |
| | |
| | | ], |
| | | [ |
| | | "AI å¨åªéåä¸", |
| | | "å票/æ¥ä»· OCR é¢å¡«ãè´¨æ£ç»æå»ºè®®ã缺æ/æ¶é¿/é£é©/交ä»åç±»é¢æµï¼å为ãä¸å¡ç»è£
Promptâæ¨¡åâJSONâäººå·¥ç¡®è®¤ãæ¨¡å¼" |
| | | "å票/æ¥ä»· OCR é¢å¡«ãè´¨æ£ç»æå»ºè®®ã缺æ/æ¶é¿/é£é©/交ä»åç±»é¢æµï¼å为ãä¸å¡ç»è£
Promptâæ¨¡åâJSONâäººå·¥ç¡®è®¤ãæ¨¡å¼ãæºè½ä½ç©æå¯¼å
¥å¦èµ°è§å+Java 颿£é¾è·¯ï¼äººå·¥ç¡®è®¤åå¹çæäº¤ï¼åªæ°å»ºä¸åæ¥ MES" |
| | | ], |
| | | [ |
| | | "模åé´æ°æ®å
³ç³»", |
| | |
| | | "name": "RAG ç¥è¯åº", |
| | | "flow": "ææ¡£ â POI/Tika æææ¬ â åç(800token) â åéåå
¥ Milvus â æ£ç´¢æ¥å£(topK3,éå¼0.7)ãå½åæ ä¸å¡æ¨¡åæ¶è´¹ï¼é¢çè½åã", |
| | | "callsApis": [] |
| | | }, |
| | | { |
| | | "name": "æºè½ä½ç©æå¯¼å
¥", |
| | | "flow": "å建补å½ä¼è¯âä¸ä¼ å¹¶ç»å®éä»¶âPython è§£æ CSV/XLSX åéâJava åªè¯»é¢æ£å类·åä½Â·ç¼ç å卿§å¹¶çæç¡®å®æ§æè¦â人工修订确认âcommandId+å¹çé®éè¡ç¬ç«äºå¡æäº¤âéè¡åæ§å«ç®æ ç©æIDï¼ç¼ç å·²åå¨è·³è¿ï¼ä¸æ´æ°ç©æãä¸è§¦å MES", |
| | | "callsApis": [ |
| | | "SystemAgentImportBridgeService", |
| | | "MdmItemService", |
| | | "MdmAgentImportPreflightService", |
| | | "MdmAgentImportCommandService" |
| | | ] |
| | | } |
| | | ], |
| | | "pendingConfirm": [ |
| | |
| | | {"key": "hrm", "name": "人åèµæº", "color": "#9db85a", "tag": "HRM", "flow": null, |
| | | "positioning": "åå·¥å
¥è½¬è°ç¦»ä¸èå¤èªé
¬", |
| | | "problem": "äººäºæµç¨å®¡æ¹åãç¦»èæ°æ®ä¸ä¸¢(交æ¥)ãç®èªèªå¨å", |
| | | "features": ["å工档æ¡(å
¥èèªå¨å»ºç³»ç»è´¦å·)", "请å/å ç/è°å²/离è BPM 审æ¹", "离èäº¤æ¥æ å° expandUserIds", "èå¤(æå¡Â·è§å·æç·补å¡å®¡æ¹Â·æåº¦æ±æ»)", "èªé
¬(åºå-社ä¿å
¬ç§¯é-个ç¨-è¯·åæ£æ¬¾Â·1.5/2/3åå çè´¹Â·åæ¾å°è´¦)", "å¹è®è¯ä¹¦(SQLå
è¡å¾
å®ç°)"], |
| | | "features": ["å工档æ¡(å
¥èèªå¨å»ºç³»ç»è´¦å·)", "请å/å ç/è°å²/离è BPM 审æ¹", "离èäº¤æ¥æ å° expandUserIds", "èå¤(ç§»å¨ç«¯æçèå¤ç¶æÂ·æå¡Â·è§å·æç·补å¡å®¡æ¹Â·æåº¦æ±æ»)", "èªé
¬(åºå-社ä¿å
¬ç§¯é-个ç¨-è¯·åæ£æ¬¾Â·1.5/2/3åå çè´¹Â·åæ¾å°è´¦)", "å¹è®è¯ä¹¦(SQLå
è¡å¾
å®ç°)"], |
| | | "objects": ["åå·¥", "请åå", "离èç³è¯·", "äº¤æ¥æ å°", "èå¤è®°å½", "èªé
¬æ ¸ç®"], |
| | | "roles": ["HR", "é¨é¨ä¸»ç®¡", "åå·¥æ¬äºº"], |
| | | "upstream": [], "downstream": ["忍¡å(æ°æ®å¯è§æ§æ©å±)", "system(è´¦å·)"], |
| | |
| | | {"stage": "ç»´ä¿®", "action": "ç°åº/è¿å/è¿ç¨ç»´ä¿®ï¼è´¹ç¨ä¸ç»æè®°å½ï¼å®æèå¨å·¥åå®ç»", "roles": ["维修工ç¨å¸"]}, |
| | | {"stage": "éè´§", "action": "éè´§ç³è¯·å®¡æ ¸âèªå¨å»º MES éå®éè´§å(RQC)âè´¨æ£/å
¥åºåè°â鿬¾åè°âå
³é", "roles": ["å®å", "è´¨æ£", "è´¢å¡"]}]}, |
| | | {"id": "ai", "name": "AI ä¸å¡æµç¨", "mermaid": "mermaid/11-ai-business-flow.mmd", "color": "#56c8d8", |
| | | "summary": "ç»ä¸æ¨¡å¼ï¼ä¸å¡ä¾§æ¶éä¸ä¸æ(å¿
è¦æ¶è° MDM/SRM/CRM API ååç§°)âç»è£
Promptâchat/chatWithImageâè§£æ JSONâå端é¢å¡«æå»ºè®®ï¼æ Function Callingï¼RAG ç¥è¯åºå·²å»ºæä½ææ ä¸å¡æ¶è´¹ã", |
| | | "summary": "ç»ä¸æ¨¡å¼ï¼ä¸å¡ä¾§æ¶éä¸ä¸æ(å¿
è¦æ¶è° MDM/SRM/CRM API ååç§°)âç»è£
Promptâchat/chatWithImageâè§£æ JSONâå端é¢å¡«æå»ºè®®ï¼æ Function Callingï¼RAG ç¥è¯åºå·²å»ºæä½ææ ä¸å¡æ¶è´¹ãæºè½ä½ç©æå¯¼å
¥ä¸ºè§å+Java 颿£é¾è·¯ï¼ç»äººå·¥ç¡®è®¤åå¹çæäº¤ã", |
| | | "steps": [ |
| | | {"stage": "å½å
¥ææ", "action": "å票 OCR(å¾ç夿¨¡æ/PDFææ¬)ãæ¥ä»·å OCR â ç»æåé¢å¡«", "roles": ["éè´å", "éå®"]}, |
| | | {"stage": "ä¸»æ°æ®å¯¼å
¥", "action": "ä¸ä¼ ç©æ CSV/XLSX â Python è§£æåé â Java åªè¯»é¢æ£åç±»/åä½/éå¤ç¼ç â 人工修订确认 â å¹çæäº¤ â éè¡åæ§ï¼åªæ°å»ºã䏿´æ°ãä¸åæ¥ MES", "roles": ["ç©æç®¡çå"]}, |
| | | {"stage": "è¾
å©å¤å®", "action": "è´¨æ£å+ææ +ç¼ºé· â å»ºè®®åæ ¼/ç¹é/éè´§/æ¥åº+çç±", "roles": ["è´¨æ£å"]}, |
| | | {"stage": "å³ç颿µ", "action": "缺æ/ç产æ¶é¿/ç产é£é©/交ä»åç±»é¢æµä¾è®¡åæäº§åè", "roles": ["计åå"]}, |
| | | {"stage": "模æ¿è¯å«", "action": "ä¸ä¼ æ¢ææ£éªæ¥åæä»¶(æ«æä»¶/ç
§çãçµåç PDFãWord/Excel) â åééæ½å(æææ¬å±èµ°ææ¬ãæ«æçé页转å¾) â 大模åæå端ä¸ä¼ çç»ä»¶æ¸
åäº§åºæå¹³ç»ä»¶è稿 â 人工å¨è®¾è®¡å¨ç¡®è®¤åä¿å为模æ¿çæ¬", "roles": ["è´¨æ£å", "è´¨éå·¥ç¨å¸"]}, |
| | |
| | | {"role": "设å¤å/维修工", "modules": ["MES-DV"], "operations": ["ç¹æ£ä¿å
»", "维修工å", "å·¥è£
é¢ç¨"], "data": ["mes_dv_*", "mes_tm_*"], "scope": "chargeUserId å½å±"}, |
| | | {"role": "客æ/å®åå·¥ç¨å¸", "modules": ["å®å"], "operations": ["å·¥ååçæ´¾å·¥", "ç»´ä¿®", "éè´§ç³è¯·"], "data": ["after_sale_*"], "scope": "è¡çº§æ°æ®æéåè§å¾ï¼æè´è´£/æåä¸/ä¸å±è´è´£/å
¨é¨(aftersales:view-all)"}, |
| | | {"role": "è´¢å¡", "modules": ["ERP", "CRM"], "operations": ["æ¶æ¬¾å", "仿¬¾å", "å¼ç¥¨å®¡æ ¸", "ç»ç®è´¦æ·"], "data": ["erp_finance_*", "crm_invoice", "crm_receivable"], "scope": "financeUserId å½å±"}, |
| | | {"role": "HR", "modules": ["HRM"], "operations": ["å工档æ¡å
¥è½¬è°ç¦»", "èå¤è§åæç", "èªé
¬æ ¸ç®åæ¾", "离è交æ¥"], "data": ["hrm_*"], "scope": "é¨é¨èå´ dataScope"}, |
| | | {"role": "HR", "modules": ["HRM"], "operations": ["å工档æ¡å
¥è½¬è°ç¦»", "èå¤è§åæç(æ¬äººæå¡å
¨åå¯ç¨ï¼ä¸è®¾æéç¹)", "èªé
¬æ ¸ç®åæ¾", "离è交æ¥"], "data": ["hrm_*"], "scope": "é¨é¨èå´ dataScope"}, |
| | | {"role": "管çå±", "modules": ["BI"], "operations": ["æ¥çéè´éå®/ä»åº/ç产设å¤/è´¨é/人åäºå¤§ä¸»é¢å¤§å±"], "data": ["bi_dashboard"], "scope": "åªè¯»"} |
| | | ] |
| | | }</script> |
| | |
| | | KB4 -.-> KB5[ææªæ¥å
¥ä¸å¡é®çæµç¨Â·å¾
确认] |
| | | MODE[模åå±:éä¹åé®ä¸ºä¸»Â·é¢çDeepSeek/OpenAI/Ollamaæªå®ç°] --- O2 |
| | | NOTE[å
¨é¡¹ç®æ Function Calling·AIåªè¾åºç»æåJSONç±ä¸å¡ä¾§è§£æ] --- J2 |
| | | A1[ç©æç®¡çåä¸ä¼ ç©æCSV/XLSXâå建补å½ä¼è¯å¹¶ç»å®éä»¶] --> A2[Javaæ¡¥æ¥ææ:ç¨æ·token+æå¡ä»¤ç+mdm:item:import] |
| | | A2 --> A3[Pythonè§£æåé·è¯å«å
¬å¼/æ°åç¼ç /缺失åç±»æåä½] |
| | | A3 --> A4[Javaåªè¯»é¢æ£Â·åç±»åä½ç¼ç å卿§âCREATE/SKIP/BLOCKED+ç¡®å®æ§æè¦] |
| | | A4 --> A5[äººå·¥å®¡æ ¸ä¿®è®¢Â·çæ¬èªå¢Â·éæ°é¢æ£] |
| | | A5 --> A6[å¹çæäº¤Â·commandId+å¹çé®Â·éè¡ç¬ç«äºå¡Â·æäº¤å鿥ç¼ç ] |
| | | A6 --> A7[éè¡åæ§Â·SUCCESS/SKIPPED/FAILED+ç®æ ç©æID] |
| | | A6 --> A8[è¶
æ¶æä¸æâæ¥è¯¢å½ä»¤åæ§å¯¹è´¦Â·ä¸ç²ç®éè¯] |
| | | A9[åªæ°å»ºä¸æ´æ°Â·syncMes=false·ä¸è§¦åMES忥] --- A6 |
| | | </pre> |
| | | |
| | | <script type="module"> |
| | |
| | | KB4 -.-> KB5[ææªæ¥å
¥ä¸å¡é®çæµç¨Â·å¾
确认] |
| | | MODE[模åå±:éä¹åé®ä¸ºä¸»Â·é¢çDeepSeek/OpenAI/Ollamaæªå®ç°] --- O2 |
| | | NOTE[å
¨é¡¹ç®æ Function Calling·AIåªè¾åºç»æåJSONç±ä¸å¡ä¾§è§£æ] --- J2 |
| | | A1[ç©æç®¡çåä¸ä¼ ç©æCSV/XLSXâå建补å½ä¼è¯å¹¶ç»å®éä»¶] --> A2[Javaæ¡¥æ¥ææ:ç¨æ·token+æå¡ä»¤ç+mdm:item:import] |
| | | A2 --> A3[Pythonè§£æåé·è¯å«å
¬å¼/æ°åç¼ç /缺失åç±»æåä½] |
| | | A3 --> A4[Javaåªè¯»é¢æ£Â·åç±»åä½ç¼ç å卿§âCREATE/SKIP/BLOCKED+ç¡®å®æ§æè¦] |
| | | A4 --> A5[äººå·¥å®¡æ ¸ä¿®è®¢Â·çæ¬èªå¢Â·éæ°é¢æ£] |
| | | A5 --> A6[å¹çæäº¤Â·commandId+å¹çé®Â·éè¡ç¬ç«äºå¡Â·æäº¤å鿥ç¼ç ] |
| | | A6 --> A7[éè¡åæ§Â·SUCCESS/SKIPPED/FAILED+ç®æ ç©æID] |
| | | A6 --> A8[è¶
æ¶æä¸æâæ¥è¯¢å½ä»¤åæ§å¯¹è´¦Â·ä¸ç²ç®éè¯] |
| | | A9[åªæ°å»ºä¸æ´æ°Â·syncMes=false·ä¸è§¦åMES忥] --- A6 |
| | |
| | | æ¬é¡¹ç®å
å«ç SQL èæ¬æ±æ»å¦ä¸ï¼ |
| | | |
| | | - [mes_pro_task_assignee.sql](mes_pro_task_assignee.sql): å¢å çäº§ä»»å¡æ¥å·¥äººåå
³è表ï¼ç¨äºå¤äººåæ¥å·¥åé
ã |
| | | - [system_agent_import_session_20261022.sql](../../sql/mysql/system_agent_import_session_20261022.sql)ï¼å建æºè½ä½è¡¥å½ä¼è¯è¡¨ï¼ç»å®ç§æ·ååå»ºç¨æ·ã |
| | | - [mdm_agent_import_command_20261022.sql](../../sql/mysql/mdm_agent_import_command_20261022.sql)ï¼å建æºè½ä½ç©æå¯¼å
¥å¹çå½ä»¤è¡¨ä¸éè¡åæ§è¡¨ã |
| | | - [config_export_all_20261022.sql](config_export_all_20261022.sql)ï¼å
¨åºåå¹¶åå§åèæ¬ï¼é¶æ®µä¸ 419 å¼ è¡¨å«æºè½ä½å¯¼å
¥ 3 å¼ è¡¨ + é
ç½®æ°æ®ï¼é¶æ®µäºèº«ä»½/èå补导ï¼é¶æ®µä¸/åå¢éï¼ã |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | -- æºè½ä½ç©æå¯¼å
¥å¹çå½ä»¤ä¸éè¡åæ§ãæ§è¡åå¤ä»½ç®æ åºï¼å¹¶å¨åºç¨åå¸å宿è¿ç§»ã |
| | | CREATE TABLE IF NOT EXISTS `mdm_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', |
| | | `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 'æºè½ä½æ¹æ¡æè¦', |
| | | `sync_mes` bit(1) NOT NULL DEFAULT b'0' COMMENT 'æ¯å¦åæ¥ MESï¼æ¬é¶æ®µåºå®å
³éï¼', |
| | | `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_command` (`tenant_id`, `owner_user_id`, `command_id`), |
| | | UNIQUE KEY `uk_agent_import_idempotency` (`tenant_id`, `owner_user_id`, `idempotency_key`), |
| | | KEY `idx_agent_import_command_session` (`tenant_id`, `session_id`, `create_time`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='æºè½ä½ç©æå¯¼å
¥å½ä»¤'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `mdm_agent_import_command_row` ( |
| | | `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主é®', |
| | | `command_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 'å建çç©æ ID', |
| | | `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_row` (`command_id`, `candidate_id`), |
| | | KEY `idx_agent_import_row_command` (`command_id`, `source_row`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='æºè½ä½ç©æå¯¼å
¥éè¡åæ§'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | -- Java Bridge è¡¥å½ä¼è¯ãæ§è¡åå¤ä»½ç®æ åºï¼å¹¶å¨åºç¨åå¸å宿è¿ç§»ã |
| | | CREATE TABLE IF NOT EXISTS `system_agent_import_session` ( |
| | | `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'è¡¥å½ä¼è¯ ID', |
| | | `owner_user_id` bigint NOT NULL COMMENT 'åå»ºç¨æ· ID', |
| | | `status` varchar(20) NOT NULL DEFAULT 'active' COMMENT 'ä¼è¯ç¶æ', |
| | | `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT 'ç§æ· ID', |
| | | `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`), |
| | | KEY `idx_agent_import_owner` (`tenant_id`, `owner_user_id`, `status`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='æºè½ä½æ°æ®è¡¥å½ä¼è¯'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.controller.admin.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportCommitReqVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportReceiptRespVO; |
| | | import cn.iocoder.yudao.module.mdm.service.agent.MdmAgentImportCommandService; |
| | | import cn.iocoder.yudao.module.mdm.service.agent.MdmAgentImportPreflightService; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | import cn.iocoder.yudao.module.system.framework.agent.SystemAgentBridgeGuard; |
| | | import jakarta.annotation.Resource; |
| | | import jakarta.validation.Valid; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| | | |
| | | /** æºè½ä½ç©æå¯¼å
¥æ¡¥æ¥ï¼åªè¯»é¢æ£ + å¹çæäº¤ + åæ§æ¥è¯¢ã */ |
| | | @RestController |
| | | @RequestMapping("/agent/bridge/material-imports") |
| | | public class MdmAgentImportBridgeController { |
| | | |
| | | @Resource |
| | | private MdmAgentImportPreflightService preflightService; |
| | | @Resource |
| | | private MdmAgentImportCommandService commandService; |
| | | @Resource |
| | | private SystemAgentBridgeGuard bridgeGuard; |
| | | |
| | | @PostMapping("/preflight") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | public CommonResult<MdmAgentImportPreflightRespVO> preflight( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @Valid @RequestBody MdmAgentImportPreflightReqVO reqVO) { |
| | | AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT); |
| | | return success(preflightService.preflight(reqVO, principal)); |
| | | } |
| | | |
| | | @PostMapping("/commit") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | public CommonResult<MdmAgentImportReceiptRespVO> commit( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @Valid @RequestBody MdmAgentImportCommitReqVO reqVO) { |
| | | AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT); |
| | | return success(commandService.commit(reqVO, principal)); |
| | | } |
| | | |
| | | @GetMapping("/commands/{commandId}") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | public CommonResult<MdmAgentImportReceiptRespVO> getCommand( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @PathVariable String commandId) { |
| | | AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT); |
| | | return success(commandService.getCommand(commandId, principal)); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.controller.admin.agent.vo; |
| | | |
| | | import jakarta.validation.Valid; |
| | | import jakarta.validation.constraints.NotBlank; |
| | | import jakarta.validation.constraints.NotEmpty; |
| | | import jakarta.validation.constraints.NotNull; |
| | | import jakarta.validation.constraints.Size; |
| | | import lombok.Data; |
| | | |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * æºè½ä½ç©æå¯¼å
¥æäº¤è¯·æ±ãcommandId ä¸ idempotencyKey å
±åä¿è¯éå¤æäº¤å¹çã |
| | | */ |
| | | @Data |
| | | public class MdmAgentImportCommitReqVO { |
| | | |
| | | @NotBlank(message = "å½ä»¤æ è¯ä¸è½ä¸ºç©º") |
| | | @Size(max = 64, message = "å½ä»¤æ è¯è¿é¿") |
| | | private String commandId; |
| | | |
| | | @NotBlank(message = "å¹çé®ä¸è½ä¸ºç©º") |
| | | @Size(max = 128, message = "å¹çé®è¿é¿") |
| | | private String idempotencyKey; |
| | | |
| | | @NotNull(message = "éä»¶ç¼å·ä¸è½ä¸ºç©º") |
| | | private Long attachmentId; |
| | | |
| | | private Long sessionId; |
| | | |
| | | @NotBlank(message = "éä»¶æè¦ä¸è½ä¸ºç©º") |
| | | private String sourceSha256; |
| | | |
| | | @NotBlank(message = "颿£æè¦ä¸è½ä¸ºç©º") |
| | | private String preflightHash; |
| | | |
| | | private String proposalHash; |
| | | private String proposalRevision; |
| | | private Boolean syncMes; |
| | | |
| | | @NotEmpty(message = "导å
¥è¡ä¸è½ä¸ºç©º") |
| | | @Valid |
| | | private List<MdmAgentImportPreflightReqVO.Row> rows; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.controller.admin.agent.vo; |
| | | |
| | | 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; |
| | | |
| | | /** |
| | | * æºè½ä½ç©æå¯¼å
¥é¢æ£è¯·æ±ãåªè¯»ï¼ä¸åä¸å¡è¡¨ã |
| | | */ |
| | | @Data |
| | | public class MdmAgentImportPreflightReqVO { |
| | | |
| | | @NotNull(message = "éä»¶ç¼å·ä¸è½ä¸ºç©º") |
| | | private Long attachmentId; |
| | | |
| | | private Long sessionId; |
| | | |
| | | @NotBlank(message = "éä»¶æè¦ä¸è½ä¸ºç©º") |
| | | private String sourceSha256; |
| | | |
| | | private String proposalRevision; |
| | | |
| | | private Boolean syncMes; |
| | | |
| | | @NotEmpty(message = "导å
¥è¡ä¸è½ä¸ºç©º") |
| | | @Valid |
| | | private List<Row> rows; |
| | | |
| | | @Data |
| | | public static class Row { |
| | | |
| | | @NotBlank(message = "è¡æ è¯ä¸è½ä¸ºç©º") |
| | | private String candidateId; |
| | | |
| | | private Integer rowNo; |
| | | private String code; |
| | | private String name; |
| | | private String specification; |
| | | private String categoryCode; |
| | | private String unitMeasureCode; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.controller.admin.agent.vo; |
| | | |
| | | import lombok.Builder; |
| | | import lombok.Data; |
| | | |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * æºè½ä½ç©æå¯¼å
¥é¢æ£ç»æãcanCommit=false æ¶æäº¤å¿
ç¶è¢«æç»ã |
| | | */ |
| | | @Data |
| | | @Builder |
| | | public class MdmAgentImportPreflightRespVO { |
| | | |
| | | private String preflightHash; |
| | | private Source source; |
| | | private Authorization authorization; |
| | | private Policy 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 Authorization { |
| | | private Long userId; |
| | | private Long tenantId; |
| | | private String permission; |
| | | private String policyRevision; |
| | | } |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Policy { |
| | | private Boolean updateAllowed; |
| | | private Boolean mesSyncAllowed; |
| | | private Integer maxRows; |
| | | } |
| | | |
| | | @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; |
| | | private String code; |
| | | private String name; |
| | | private String action; |
| | | private Resolved resolved; |
| | | private List<Issue> blockers; |
| | | private List<Issue> warnings; |
| | | private Long targetItemId; |
| | | } |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Resolved { |
| | | private Long categoryId; |
| | | private String categoryName; |
| | | private Long unitMeasureId; |
| | | private String unitMeasureName; |
| | | } |
| | | |
| | | @Data |
| | | @Builder |
| | | public static class Issue { |
| | | private String code; |
| | | private String field; |
| | | private String message; |
| | | private Boolean retryable; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.controller.admin.agent.vo; |
| | | |
| | | import lombok.Builder; |
| | | import lombok.Data; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * æºè½ä½ç©æå¯¼å
¥åæ§ãé夿¥è¯¢/éå¤æäº¤è¿ååä¸ç»æã |
| | | */ |
| | | @Data |
| | | @Builder |
| | | public class MdmAgentImportReceiptRespVO { |
| | | |
| | | 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; |
| | | private String status; |
| | | private Long targetItemId; |
| | | private String targetVersion; |
| | | private String errorCode; |
| | | private String errorMessage; |
| | | private Boolean retryable; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.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, commandId/idempotencyKey) åªå
è®¸ä¸æ¡ã |
| | | */ |
| | | @TableName("mdm_agent_import_command") |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @ToString(callSuper = true) |
| | | @Builder |
| | | @NoArgsConstructor |
| | | @AllArgsConstructor |
| | | public class MdmAgentImportCommandDO extends BaseDO { |
| | | |
| | | @TableId |
| | | private Long id; |
| | | |
| | | private String commandId; |
| | | private String idempotencyKey; |
| | | private String requestDigest; |
| | | private Long tenantId; |
| | | private Long ownerUserId; |
| | | private Long sessionId; |
| | | private Long attachmentId; |
| | | private String sourceSha256; |
| | | private String preflightHash; |
| | | private String proposalHash; |
| | | private Boolean syncMes; |
| | | 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.mdm.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("mdm_agent_import_command_row") |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @ToString(callSuper = true) |
| | | @Builder |
| | | @NoArgsConstructor |
| | | @AllArgsConstructor |
| | | public class MdmAgentImportCommandRowDO extends BaseDO { |
| | | |
| | | @TableId |
| | | private Long id; |
| | | |
| | | private String commandId; |
| | | 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.mdm.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.mdm.dal.dataobject.agent.MdmAgentImportCommandDO; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | @Mapper |
| | | public interface MdmAgentImportCommandMapper extends BaseMapperX<MdmAgentImportCommandDO> { |
| | | |
| | | default MdmAgentImportCommandDO selectByCommandId(Long tenantId, Long ownerUserId, String commandId) { |
| | | return selectOne(new LambdaQueryWrapperX<MdmAgentImportCommandDO>() |
| | | .eq(MdmAgentImportCommandDO::getTenantId, tenantId) |
| | | .eq(MdmAgentImportCommandDO::getOwnerUserId, ownerUserId) |
| | | .eq(MdmAgentImportCommandDO::getCommandId, commandId)); |
| | | } |
| | | |
| | | default MdmAgentImportCommandDO selectByIdempotencyKey(Long tenantId, Long ownerUserId, String idempotencyKey) { |
| | | return selectOne(new LambdaQueryWrapperX<MdmAgentImportCommandDO>() |
| | | .eq(MdmAgentImportCommandDO::getTenantId, tenantId) |
| | | .eq(MdmAgentImportCommandDO::getOwnerUserId, ownerUserId) |
| | | .eq(MdmAgentImportCommandDO::getIdempotencyKey, idempotencyKey)); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.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.mdm.dal.dataobject.agent.MdmAgentImportCommandRowDO; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | import java.util.List; |
| | | |
| | | @Mapper |
| | | public interface MdmAgentImportCommandRowMapper extends BaseMapperX<MdmAgentImportCommandRowDO> { |
| | | |
| | | default List<MdmAgentImportCommandRowDO> selectListByCommandId(String commandId) { |
| | | return selectList(new LambdaQueryWrapperX<MdmAgentImportCommandRowDO>() |
| | | .eq(MdmAgentImportCommandRowDO::getCommandId, commandId) |
| | | .orderByAsc(MdmAgentImportCommandRowDO::getSourceRow) |
| | | .orderByAsc(MdmAgentImportCommandRowDO::getId)); |
| | | } |
| | | } |
| | |
| | | return selectOne(MdmItemDO::getCode, code); |
| | | } |
| | | |
| | | default List<MdmItemDO> selectListByCode(String code) { |
| | | return selectList(MdmItemDO::getCode, code); |
| | | } |
| | | |
| | | /** |
| | | * æ ¹æ®ç¼ç åå é¤ç¶ææ¥è¯¢ç©æï¼ç¨äºè§£å³è½¯å é¤å¯ä¸é®å²çªï¼ |
| | | */ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.service.agent; |
| | | |
| | | import cn.hutool.core.util.StrUtil; |
| | | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportCommitReqVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportReceiptRespVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO; |
| | | import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandDO; |
| | | import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandRowDO; |
| | | import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO; |
| | | import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandMapper; |
| | | import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandRowMapper; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemService; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | import com.fasterxml.jackson.core.JsonProcessingException; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import jakarta.annotation.Resource; |
| | | 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.security.MessageDigest; |
| | | import java.security.NoSuchAlgorithmException; |
| | | import java.time.Duration; |
| | | import java.time.LocalDateTime; |
| | | import java.util.*; |
| | | import java.util.concurrent.atomic.AtomicReference; |
| | | |
| | | /** |
| | | * ç©æå¯¼å
¥å¹çæäº¤ï¼åä¸ commandId/idempotencyKey éå¤æäº¤è¿åååæ§ï¼ç»ä¸éå¤å»ºç©æã |
| | | */ |
| | | @Service |
| | | @Validated |
| | | public class MdmAgentImportCommandService { |
| | | |
| | | 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 MdmAgentImportPreflightService preflightService; |
| | | @Resource |
| | | private MdmItemService itemService; |
| | | @Resource |
| | | private MdmAgentImportPolicy policy; |
| | | @Resource |
| | | private MdmAgentImportCommandMapper commandMapper; |
| | | @Resource |
| | | private MdmAgentImportCommandRowMapper commandRowMapper; |
| | | @Resource |
| | | private PlatformTransactionManager transactionManager; |
| | | |
| | | public MdmAgentImportReceiptRespVO commit(MdmAgentImportCommitReqVO reqVO, AgentPrincipal principal) { |
| | | if (!policy.isWriteEnabled()) { |
| | | throw new AgentBridgeException(HttpStatus.SERVICE_UNAVAILABLE, "WRITE_DISABLED", |
| | | "æºè½ä½åå
¥å¼å
³æªæå¼ï¼æäº¤å·²æç»"); |
| | | } |
| | | String requestDigest = buildRequestDigest(reqVO); |
| | | Long tenantId = principal.tenantId(); |
| | | Long ownerUserId = principal.userId(); |
| | | |
| | | // 1. å¹çå½ä¸ï¼å½ä»¤ ID ä¼å
ï¼å
¶æ¬¡å¹çé® |
| | | MdmAgentImportCommandDO existing = commandMapper.selectByCommandId(tenantId, ownerUserId, reqVO.getCommandId()); |
| | | if (existing != null) { |
| | | if (!existing.getRequestDigest().equals(requestDigest)) { |
| | | throw new AgentBridgeException(HttpStatus.CONFLICT, "COMMAND_ID_CONFLICT", |
| | | "åä¸å½ä»¤ ID æºå¸¦äºä¸åç请æ±å
容ï¼è¯·æ£æ¥ä»»å¡ç¶æåéè¯"); |
| | | } |
| | | return toReceipt(existing); |
| | | } |
| | | existing = commandMapper.selectByIdempotencyKey(tenantId, ownerUserId, reqVO.getIdempotencyKey()); |
| | | if (existing != null) { |
| | | if (!existing.getRequestDigest().equals(requestDigest)) { |
| | | throw new AgentBridgeException(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", |
| | | "åä¸å¹ç鮿ºå¸¦äºä¸åç请æ±å
容ï¼è¯·æ£æ¥ä»»å¡ç¶æåéè¯"); |
| | | } |
| | | return toReceipt(existing); |
| | | } |
| | | |
| | | // 2. 鿰颿£å¹¶æ¯å¯¹è®¡åæè¦ï¼é²æ¢ç¡®è®¤åæ°æ®å·²åå |
| | | MdmAgentImportPreflightRespVO preflight = preflightService.preflight(toPreflightRequest(reqVO), principal); |
| | | if (!reqVO.getPreflightHash().equalsIgnoreCase(preflight.getPreflightHash())) { |
| | | throw new AgentBridgeException(HttpStatus.CONFLICT, "PLAN_STALE", |
| | | "颿£è®¡åå·²ååï¼ç¼ç ç¶ææä¸»æ°æ®è¢«æ¹å¨ï¼ï¼è¯·éæ°é¢æ£å¹¶ç¡®è®¤", false, |
| | | Map.of("preflight", preflight)); |
| | | } |
| | | if (!Boolean.TRUE.equals(preflight.getCanCommit())) { |
| | | throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "PREFLIGHT_BLOCKED", |
| | | "颿£åå¨é»æé¡¹ï¼ä¸è½æäº¤", false, Map.of("preflight", preflight)); |
| | | } |
| | | |
| | | // 3. ç»è®°å½ä»¤ï¼å¯ä¸é®å
ä½å¹¶åéå¤æäº¤ï¼ |
| | | MdmAgentImportCommandDO command = MdmAgentImportCommandDO.builder() |
| | | .commandId(reqVO.getCommandId()) |
| | | .idempotencyKey(reqVO.getIdempotencyKey()) |
| | | .requestDigest(requestDigest) |
| | | .tenantId(tenantId) |
| | | .ownerUserId(ownerUserId) |
| | | .sessionId(reqVO.getSessionId()) |
| | | .attachmentId(reqVO.getAttachmentId()) |
| | | .sourceSha256(reqVO.getSourceSha256().toLowerCase(Locale.ROOT)) |
| | | .preflightHash(reqVO.getPreflightHash().toLowerCase(Locale.ROOT)) |
| | | .proposalHash(reqVO.getProposalHash()) |
| | | .syncMes(Boolean.TRUE.equals(reqVO.getSyncMes())) |
| | | .status(STATUS_RUNNING) |
| | | .totalCount(reqVO.getRows().size()) |
| | | .successCount(0).failedCount(0).skippedCount(0) |
| | | .leaseExpiresAt(LocalDateTime.now().plus(LEASE_DURATION)) |
| | | .build(); |
| | | try { |
| | | commandMapper.insert(command); |
| | | } catch (DuplicateKeyException ex) { |
| | | MdmAgentImportCommandDO raced = commandMapper.selectByCommandId(tenantId, ownerUserId, reqVO.getCommandId()); |
| | | if (raced == null) { |
| | | raced = commandMapper.selectByIdempotencyKey(tenantId, ownerUserId, reqVO.getIdempotencyKey()); |
| | | } |
| | | if (raced == null) { |
| | | throw ex; |
| | | } |
| | | if (!raced.getRequestDigest().equals(requestDigest)) { |
| | | throw new AgentBridgeException(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", |
| | | "åä¸å½ä»¤/å¹ç鮿ºå¸¦äºä¸åç请æ±å
容ï¼è¯·æ£æ¥ä»»å¡ç¶æåéè¯"); |
| | | } |
| | | return toReceipt(raced); |
| | | } |
| | | |
| | | // 4. éè¡ç¬ç«äºå¡æ§è¡ï¼ä»»ä¸è¡å¤±è´¥ä¸å½±åå
¶ä»è¡ |
| | | int success = 0; |
| | | int failed = 0; |
| | | int skipped = 0; |
| | | TransactionTemplate rowTxTemplate = new TransactionTemplate(transactionManager); |
| | | List<MdmItemImportExcelVO> excelRows = reqVO.getRows().stream() |
| | | .map(MdmAgentImportCommandService::toExcelVO).toList(); |
| | | MdmItemImportContext context = itemService.buildImportContext(excelRows); |
| | | try { |
| | | for (int index = 0; index < reqVO.getRows().size(); index++) { |
| | | MdmAgentImportPreflightReqVO.Row row = reqVO.getRows().get(index); |
| | | MdmAgentImportPreflightRespVO.Row planned = preflight.getRows().get(index); |
| | | if ("SKIP".equals(planned.getAction())) { |
| | | commandRowMapper.insert(baseRow(command.getCommandId(), row) |
| | | .action("SKIP").status("SKIPPED") |
| | | .targetItemId(planned.getTargetItemId()) |
| | | .retryable(false).build()); |
| | | skipped++; |
| | | continue; |
| | | } |
| | | if (!"CREATE".equals(planned.getAction())) { |
| | | commandRowMapper.insert(baseRow(command.getCommandId(), row) |
| | | .action("CREATE").status("FAILED") |
| | | .errorCode("PREFLIGHT_BLOCKED").errorMessage("该è¡é¢æ£æªéè¿").retryable(false).build()); |
| | | failed++; |
| | | continue; |
| | | } |
| | | MdmItemRowResolution resolution = itemService.resolveImportRow(excelRows.get(index), context); |
| | | if (!resolution.isSuccess()) { |
| | | commandRowMapper.insert(baseRow(command.getCommandId(), row) |
| | | .action("CREATE").status("FAILED") |
| | | .errorCode("ROW_INVALID").errorMessage(trim(resolution.failureMessage(), 512)) |
| | | .retryable(false).build()); |
| | | failed++; |
| | | continue; |
| | | } |
| | | AtomicReference<Throwable> rowError = new AtomicReference<>(); |
| | | boolean[] concurrentSkip = {false}; |
| | | try { |
| | | rowTxTemplate.executeWithoutResult(status -> { |
| | | try { |
| | | // æäº¤å鿥ï¼ç¼©å°å¹¶åéå¤å»ºç©æççªå£ |
| | | List<MdmItemDO> current = itemService.getItemListByCode(row.getCode().trim()); |
| | | if (!current.isEmpty()) { |
| | | concurrentSkip[0] = true; |
| | | commandRowMapper.insert(baseRow(command.getCommandId(), row) |
| | | .action("SKIP").status("SKIPPED") |
| | | .targetItemId(current.get(0).getId()).retryable(false).build()); |
| | | return; |
| | | } |
| | | MdmItemSaveReqVO saveReqVO = resolution.saveReqVO(); |
| | | saveReqVO.setSyncMes(false); // æ¬é¶æ®µåºå®ä¸åæ¥ MES |
| | | Long itemId = itemService.createItem(saveReqVO); |
| | | commandRowMapper.insert(baseRow(command.getCommandId(), row) |
| | | .action("CREATE").status("SUCCESS").targetItemId(itemId) |
| | | .retryable(false).build()); |
| | | } catch (Throwable t) { |
| | | rowError.set(t); |
| | | throw t; |
| | | } |
| | | }); |
| | | if (concurrentSkip[0]) { |
| | | skipped++; |
| | | } else { |
| | | success++; |
| | | } |
| | | } catch (Exception ex) { |
| | | Throwable cause = rowError.get() != null ? rowError.get() : ex; |
| | | String message = cause instanceof ServiceException |
| | | ? ((ServiceException) cause).getMessage() : cause.getMessage(); |
| | | String errorCode = cause instanceof ServiceException ? "ITEM_CREATE_REJECTED" : "ITEM_CREATE_FAILED"; |
| | | commandRowMapper.insert(baseRow(command.getCommandId(), row) |
| | | .action("CREATE").status("FAILED") |
| | | .errorCode(errorCode).errorMessage(trim(message, 512)) |
| | | .retryable(!(cause instanceof ServiceException)).build()); |
| | | failed++; |
| | | } |
| | | } |
| | | } catch (Exception ex) { |
| | | // åºç¡è®¾æ½å¼å¸¸ä¸æï¼ä¿ç已宿è¡åæ§ï¼å½ä»¤æ è®° UNKNOWN ä¾å¯¹è´¦ |
| | | command.setStatus(STATUS_UNKNOWN); |
| | | command.setErrorCode("COMMIT_INTERRUPTED"); |
| | | command.setErrorMessage(trim(ex.getMessage(), 512)); |
| | | command.setSuccessCount(success); |
| | | command.setFailedCount(failed); |
| | | command.setSkippedCount(skipped); |
| | | 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()); |
| | | commandMapper.updateById(command); |
| | | return toReceipt(command); |
| | | } |
| | | |
| | | public MdmAgentImportReceiptRespVO getCommand(String commandId, AgentPrincipal principal) { |
| | | MdmAgentImportCommandDO command = commandMapper.selectByCommandId( |
| | | principal.tenantId(), principal.userId(), commandId); |
| | | if (command == null) { |
| | | throw new AgentBridgeException(HttpStatus.NOT_FOUND, "COMMAND_NOT_FOUND", "导å
¥å½ä»¤ä¸åå¨"); |
| | | } |
| | | return toReceipt(command); |
| | | } |
| | | |
| | | private MdmAgentImportReceiptRespVO toReceipt(MdmAgentImportCommandDO command) { |
| | | String status = command.getStatus(); |
| | | if (STATUS_RUNNING.equals(status) && command.getLeaseExpiresAt() != null |
| | | && command.getLeaseExpiresAt().isBefore(LocalDateTime.now())) { |
| | | // ç§çº¦è¿æè¯´ææ§è¡è¿ç¨å¯è½å·²ä¸æï¼ä¸æ¹åºï¼ä»
å¦å®åç¥è°ç¨æ¹ç»ææªç¥ |
| | | status = STATUS_UNKNOWN; |
| | | } |
| | | List<MdmAgentImportCommandRowDO> rowDOs = commandRowMapper.selectListByCommandId(command.getCommandId()); |
| | | List<MdmAgentImportReceiptRespVO.Row> rows = rowDOs.stream() |
| | | .map(row -> MdmAgentImportReceiptRespVO.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 MdmAgentImportReceiptRespVO.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(MdmAgentImportReceiptRespVO.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 MdmAgentImportCommandRowDO.MdmAgentImportCommandRowDOBuilder baseRow( |
| | | String commandId, MdmAgentImportPreflightReqVO.Row row) { |
| | | return MdmAgentImportCommandRowDO.builder() |
| | | .commandId(commandId) |
| | | .candidateId(row.getCandidateId()) |
| | | .sourceRow(row.getRowNo()) |
| | | .code(row.getCode()) |
| | | .name(row.getName()); |
| | | } |
| | | |
| | | private static MdmItemImportExcelVO toExcelVO(MdmAgentImportPreflightReqVO.Row row) { |
| | | return MdmItemImportExcelVO.builder() |
| | | .code(row.getCode()) |
| | | .name(row.getName()) |
| | | .specification(row.getSpecification()) |
| | | .categoryCode(row.getCategoryCode()) |
| | | .unitMeasureCode(row.getUnitMeasureCode()) |
| | | .build(); |
| | | } |
| | | |
| | | private static MdmAgentImportPreflightReqVO toPreflightRequest(MdmAgentImportCommitReqVO reqVO) { |
| | | MdmAgentImportPreflightReqVO preflightReqVO = new MdmAgentImportPreflightReqVO(); |
| | | preflightReqVO.setAttachmentId(reqVO.getAttachmentId()); |
| | | preflightReqVO.setSessionId(reqVO.getSessionId()); |
| | | preflightReqVO.setSourceSha256(reqVO.getSourceSha256()); |
| | | preflightReqVO.setProposalRevision(reqVO.getProposalRevision()); |
| | | preflightReqVO.setSyncMes(reqVO.getSyncMes()); |
| | | preflightReqVO.setRows(reqVO.getRows()); |
| | | return preflightReqVO; |
| | | } |
| | | |
| | | /** |
| | | * è¯·æ±æè¦åªå
å«ä¸å¡å
容ï¼ä¸å« commandId/idempotencyKeyï¼ä¾¿äºè¯å«âåé®ä¸åå
容âã |
| | | */ |
| | | private static String buildRequestDigest(MdmAgentImportCommitReqVO reqVO) { |
| | | Map<String, Object> canonical = new LinkedHashMap<>(); |
| | | canonical.put("attachment_id", reqVO.getAttachmentId()); |
| | | canonical.put("session_id", reqVO.getSessionId()); |
| | | canonical.put("source_sha256", StrUtil.nullToEmpty(reqVO.getSourceSha256()).toLowerCase(Locale.ROOT)); |
| | | canonical.put("preflight_hash", StrUtil.nullToEmpty(reqVO.getPreflightHash()).toLowerCase(Locale.ROOT)); |
| | | canonical.put("proposal_hash", StrUtil.nullToEmpty(reqVO.getProposalHash())); |
| | | canonical.put("proposal_revision", StrUtil.nullToEmpty(reqVO.getProposalRevision())); |
| | | canonical.put("sync_mes", Boolean.TRUE.equals(reqVO.getSyncMes())); |
| | | List<Map<String, Object>> rows = new ArrayList<>(); |
| | | for (MdmAgentImportPreflightReqVO.Row row : reqVO.getRows()) { |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("candidate_id", row.getCandidateId()); |
| | | item.put("row_no", row.getRowNo()); |
| | | item.put("code", StrUtil.nullToEmpty(row.getCode())); |
| | | item.put("name", StrUtil.nullToEmpty(row.getName())); |
| | | item.put("specification", StrUtil.nullToEmpty(row.getSpecification())); |
| | | item.put("category_code", StrUtil.nullToEmpty(row.getCategoryCode())); |
| | | item.put("unit_measure_code", StrUtil.nullToEmpty(row.getUnitMeasureCode())); |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | private static String trim(String value, int maxLength) { |
| | | if (value == null) { |
| | | return null; |
| | | } |
| | | return value.length() <= maxLength ? value : value.substring(0, maxLength); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.service.agent; |
| | | |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | /** |
| | | * æºè½ä½ç©æå¯¼å
¥çåå
¥çç¥ï¼é»è®¤å
¨å
³ï¼èè°æ¶æ¾å¼æå¼ã |
| | | */ |
| | | @Component |
| | | public class MdmAgentImportPolicy { |
| | | |
| | | public static final String POLICY_REVISION = "mdm-item-import-v1"; |
| | | |
| | | @Value("${yudao.agent.bridge.write-enabled:false}") |
| | | private boolean writeEnabled; |
| | | |
| | | @Value("${yudao.agent.bridge.allow-mes-sync:false}") |
| | | private boolean allowMesSync; |
| | | |
| | | @Value("${yudao.agent.bridge.max-rows:5000}") |
| | | private int maxRows; |
| | | |
| | | public boolean isWriteEnabled() { |
| | | return writeEnabled; |
| | | } |
| | | |
| | | public boolean isAllowMesSync() { |
| | | return allowMesSync; |
| | | } |
| | | |
| | | public int getMaxRows() { |
| | | return maxRows; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.service.agent; |
| | | |
| | | import cn.hutool.core.util.StrUtil; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO; |
| | | import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemService; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException; |
| | | 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.storage.SystemAgentAttachmentRef; |
| | | import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import jakarta.annotation.Resource; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.validation.annotation.Validated; |
| | | |
| | | import java.security.MessageDigest; |
| | | import java.security.NoSuchAlgorithmException; |
| | | import java.util.*; |
| | | |
| | | /** |
| | | * ç©æå¯¼å
¥åªè¯»é¢æ£ï¼æ ¡éªéä»¶ãè§£æåç±»/åä½ã夿ç¼ç æ¯å¦å·²åå¨ï¼ä¸åä»»ä½ä¸å¡è¡¨ã |
| | | */ |
| | | @Service |
| | | @Validated |
| | | public class MdmAgentImportPreflightService { |
| | | |
| | | private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); |
| | | |
| | | @Resource |
| | | private SystemAgentImportBridgeService bridgeService; |
| | | @Resource |
| | | private MdmItemService itemService; |
| | | @Resource |
| | | private MdmAgentImportPolicy policy; |
| | | |
| | | public MdmAgentImportPreflightRespVO preflight(MdmAgentImportPreflightReqVO reqVO, AgentPrincipal principal) { |
| | | if (reqVO.getRows().size() > policy.getMaxRows()) { |
| | | throw new AgentBridgeException(HttpStatus.PAYLOAD_TOO_LARGE, "ROW_LIMIT_EXCEEDED", |
| | | "导å
¥è¡æ°è¶
è¿ä¸éï¼" + policy.getMaxRows()); |
| | | } |
| | | // 1. é件䏿è¦å¿
须䏿¬æ¬¡ä¼è¯ç»å®çä¸è´ |
| | | SystemAgentAttachmentRef attachment = bridgeService.requireOwnedAttachmentRef(reqVO.getAttachmentId()); |
| | | if (reqVO.getSessionId() != null && !reqVO.getSessionId().equals(attachment.sessionId())) { |
| | | throw new AgentBridgeException(HttpStatus.CONFLICT, "SESSION_MISMATCH", |
| | | "éä»¶ä¸å±äºæå®è¡¥å½ä¼è¯ï¼è¯·éæ°ä¸ä¼ ååè¯", true, null); |
| | | } |
| | | String sourceSha256 = bridgeService.sha256OfAttachment(reqVO.getAttachmentId()); |
| | | if (!sourceSha256.equalsIgnoreCase(reqVO.getSourceSha256().trim())) { |
| | | throw new AgentBridgeException(HttpStatus.CONFLICT, "SOURCE_DIGEST_MISMATCH", |
| | | "éä»¶å
容已ååï¼æè¦ä¸ä¸è´ï¼ï¼è¯·éæ°ä¸ä¼ ååè¯", true, null); |
| | | } |
| | | // 2. candidate_id å¿
é¡»å¯ä¸ï¼å¦ååæ§æ æ³éè¡å¯¹åº |
| | | Set<String> candidateIds = new HashSet<>(); |
| | | for (MdmAgentImportPreflightReqVO.Row row : reqVO.getRows()) { |
| | | if (!candidateIds.add(row.getCandidateId())) { |
| | | throw new AgentBridgeException(HttpStatus.BAD_REQUEST, "CANDIDATE_ID_DUPLICATED", |
| | | "导å
¥è¡æ è¯éå¤ï¼" + row.getCandidateId()); |
| | | } |
| | | } |
| | | |
| | | // 3. é¢çåç±»/å使 å° |
| | | List<MdmItemImportExcelVO> excelRows = reqVO.getRows().stream() |
| | | .map(MdmAgentImportPreflightService::toExcelVO).toList(); |
| | | MdmItemImportContext context = itemService.buildImportContext(excelRows); |
| | | |
| | | boolean syncMesRequested = Boolean.TRUE.equals(reqVO.getSyncMes()); |
| | | List<MdmAgentImportPreflightRespVO.Issue> blockingItems = new ArrayList<>(); |
| | | if (syncMesRequested) { |
| | | blockingItems.add(issue("MES_SYNC_DISABLED", "syncMes", |
| | | "æ¬é¶æ®µä¸å¼æ¾åæ¥ MESï¼è¯·å
³é syncMes åéè¯", false)); |
| | | } |
| | | |
| | | // 4. æä»¶å
éå¤ç¼ç ï¼ææéå¤è¡ä¸èµ·é»æï¼é¿å
âé¦è¡å»ºã次è¡è·³âç误导 |
| | | Map<String, Long> codeCounts = reqVO.getRows().stream() |
| | | .map(MdmAgentImportPreflightReqVO.Row::getCode).filter(StrUtil::isNotBlank) |
| | | .map(String::trim).collect(java.util.stream.Collectors.groupingBy(code -> code, |
| | | java.util.stream.Collectors.counting())); |
| | | Set<String> duplicateCodes = codeCounts.entrySet().stream() |
| | | .filter(entry -> entry.getValue() > 1).map(Map.Entry::getKey) |
| | | .collect(java.util.stream.Collectors.toSet()); |
| | | |
| | | // 5. éè¡å¤å® CREATE / SKIP / BLOCKED |
| | | List<MdmAgentImportPreflightRespVO.Row> rows = new ArrayList<>(); |
| | | int createCount = 0; |
| | | int skipCount = 0; |
| | | int blockedCount = 0; |
| | | for (int index = 0; index < reqVO.getRows().size(); index++) { |
| | | MdmAgentImportPreflightReqVO.Row row = reqVO.getRows().get(index); |
| | | MdmItemImportExcelVO excelVO = excelRows.get(index); |
| | | List<MdmAgentImportPreflightRespVO.Issue> blockers = new ArrayList<>(); |
| | | List<MdmAgentImportPreflightRespVO.Issue> warnings = new ArrayList<>(); |
| | | |
| | | if (StrUtil.isBlank(row.getCode())) { |
| | | blockers.add(issue("ROW_CODE_REQUIRED", "code", "ç©æç¼ç ä¸è½ä¸ºç©º", false)); |
| | | } else if (duplicateCodes.contains(row.getCode().trim())) { |
| | | blockers.add(issue("DUPLICATE_CODE_IN_FILE", "code", |
| | | "æä»¶å
ç©æç¼ç éå¤ï¼" + row.getCode().trim(), false)); |
| | | } |
| | | if (StrUtil.isBlank(row.getName())) { |
| | | blockers.add(issue("ROW_NAME_REQUIRED", "name", "ç©æåç§°ä¸è½ä¸ºç©º", false)); |
| | | } |
| | | if (StrUtil.isNotBlank(row.getCategoryCode()) |
| | | && !context.categoryCodeIdMap().containsKey(row.getCategoryCode())) { |
| | | blockers.add(issue("CATEGORY_NOT_FOUND", "categoryCode", |
| | | "ç©æåç±»ç¼ç ä¸åå¨ï¼" + row.getCategoryCode(), false)); |
| | | } |
| | | if (StrUtil.isNotBlank(row.getUnitMeasureCode()) |
| | | && !context.unitCodeIdMap().containsKey(row.getUnitMeasureCode())) { |
| | | blockers.add(issue("UNIT_NOT_FOUND", "unitMeasureCode", |
| | | "计éåä½ç¼ç ä¸åå¨ï¼" + row.getUnitMeasureCode(), false)); |
| | | } |
| | | |
| | | MdmItemRowResolution resolution = itemService.resolveImportRow(excelVO, context); |
| | | if (!resolution.isSuccess() && blockers.isEmpty()) { |
| | | // ç论ä¸ä¸é¢çéé¡¹æ ¡éªå·²è¦çï¼å
åºä¿æä¸ Excel 导å
¥ä¸è´çææ¡ |
| | | blockers.add(issue("ROW_INVALID", null, resolution.failureMessage(), false)); |
| | | } |
| | | |
| | | String action; |
| | | Long targetItemId = null; |
| | | if (!blockers.isEmpty()) { |
| | | action = "BLOCKED"; |
| | | blockedCount++; |
| | | } else { |
| | | List<MdmItemDO> existing = itemService.getItemListByCode(row.getCode().trim()); |
| | | if (!existing.isEmpty()) { |
| | | action = "SKIP"; |
| | | targetItemId = existing.get(0).getId(); |
| | | skipCount++; |
| | | warnings.add(issue("ITEM_CODE_EXISTS", "code", "ç©æç¼ç å·²åå¨ï¼æ¬é¶æ®µåªæ°å»ºä¸æ´æ°ï¼å°è·³è¿è¯¥è¡", false)); |
| | | } else { |
| | | action = "CREATE"; |
| | | createCount++; |
| | | } |
| | | } |
| | | |
| | | rows.add(MdmAgentImportPreflightRespVO.Row.builder() |
| | | .candidateId(row.getCandidateId()) |
| | | .rowNo(row.getRowNo()) |
| | | .code(row.getCode()) |
| | | .name(row.getName()) |
| | | .action(action) |
| | | .resolved(resolve(row, context)) |
| | | .blockers(blockers) |
| | | .warnings(warnings) |
| | | .targetItemId(targetItemId) |
| | | .build()); |
| | | } |
| | | |
| | | boolean canCommit = blockingItems.isEmpty() && blockedCount == 0; |
| | | String preflightHash = buildPreflightHash(reqVO, principal, attachment, sourceSha256, |
| | | syncMesRequested, rows); |
| | | return MdmAgentImportPreflightRespVO.builder() |
| | | .preflightHash(preflightHash) |
| | | .source(MdmAgentImportPreflightRespVO.Source.builder() |
| | | .attachmentId(attachment.attachmentId()) |
| | | .sessionId(attachment.sessionId()) |
| | | .sourceSha256(sourceSha256) |
| | | .proposalRevision(reqVO.getProposalRevision()) |
| | | .build()) |
| | | .authorization(MdmAgentImportPreflightRespVO.Authorization.builder() |
| | | .userId(principal.userId()) |
| | | .tenantId(principal.tenantId()) |
| | | .permission(SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT) |
| | | .policyRevision(MdmAgentImportPolicy.POLICY_REVISION) |
| | | .build()) |
| | | .policy(MdmAgentImportPreflightRespVO.Policy.builder() |
| | | .updateAllowed(false) |
| | | .mesSyncAllowed(policy.isAllowMesSync()) |
| | | .maxRows(policy.getMaxRows()) |
| | | .build()) |
| | | .summary(MdmAgentImportPreflightRespVO.Summary.builder() |
| | | .total(rows.size()) |
| | | .create(createCount) |
| | | .skip(skipCount) |
| | | .blocked(blockedCount) |
| | | .build()) |
| | | .canCommit(canCommit) |
| | | .sideEffects(canCommit ? List.of("mdm_item.create:" + createCount) : List.of()) |
| | | .rows(rows) |
| | | .blockingItems(blockingItems) |
| | | .build(); |
| | | } |
| | | |
| | | private static MdmItemImportExcelVO toExcelVO(MdmAgentImportPreflightReqVO.Row row) { |
| | | return MdmItemImportExcelVO.builder() |
| | | .code(row.getCode()) |
| | | .name(row.getName()) |
| | | .specification(row.getSpecification()) |
| | | .categoryCode(row.getCategoryCode()) |
| | | .unitMeasureCode(row.getUnitMeasureCode()) |
| | | .build(); |
| | | } |
| | | |
| | | private static MdmAgentImportPreflightRespVO.Resolved resolve(MdmAgentImportPreflightReqVO.Row row, |
| | | MdmItemImportContext context) { |
| | | Long categoryId = StrUtil.isBlank(row.getCategoryCode()) ? null |
| | | : context.categoryCodeIdMap().get(row.getCategoryCode()); |
| | | String categoryName = StrUtil.isBlank(row.getCategoryCode()) ? null |
| | | : context.categoryCodeNameMap().get(row.getCategoryCode()); |
| | | Long unitMeasureId = StrUtil.isBlank(row.getUnitMeasureCode()) ? null |
| | | : context.unitCodeIdMap().get(row.getUnitMeasureCode()); |
| | | String unitMeasureName = StrUtil.isBlank(row.getUnitMeasureCode()) ? null |
| | | : context.unitCodeNameMap().get(row.getUnitMeasureCode()); |
| | | return MdmAgentImportPreflightRespVO.Resolved.builder() |
| | | .categoryId(categoryId).categoryName(categoryName) |
| | | .unitMeasureId(unitMeasureId).unitMeasureName(unitMeasureName) |
| | | .build(); |
| | | } |
| | | |
| | | private static MdmAgentImportPreflightRespVO.Issue issue(String code, String field, String message, |
| | | boolean retryable) { |
| | | return MdmAgentImportPreflightRespVO.Issue.builder() |
| | | .code(code).field(field).message(message).retryable(retryable).build(); |
| | | } |
| | | |
| | | /** |
| | | * æåºå®åæ®µé¡ºåºæ¼è£
è§è JSON åå SHA-256ï¼åä¸ä»½è®¡åå¿
é¡»å¾å°åä¸ä¸ª hashã |
| | | */ |
| | | private static String buildPreflightHash(MdmAgentImportPreflightReqVO reqVO, AgentPrincipal principal, |
| | | SystemAgentAttachmentRef attachment, String sourceSha256, |
| | | boolean syncMesRequested, List<MdmAgentImportPreflightRespVO.Row> rows) { |
| | | Map<String, Object> canonical = new LinkedHashMap<>(); |
| | | canonical.put("policy_revision", MdmAgentImportPolicy.POLICY_REVISION); |
| | | canonical.put("user_id", principal.userId()); |
| | | canonical.put("tenant_id", principal.tenantId()); |
| | | canonical.put("attachment_id", attachment.attachmentId()); |
| | | canonical.put("session_id", attachment.sessionId()); |
| | | canonical.put("source_sha256", sourceSha256.toLowerCase(Locale.ROOT)); |
| | | canonical.put("proposal_revision", StrUtil.blankToDefault(reqVO.getProposalRevision(), "")); |
| | | canonical.put("sync_mes", syncMesRequested); |
| | | List<Map<String, Object>> canonicalRows = new ArrayList<>(); |
| | | for (MdmAgentImportPreflightRespVO.Row row : rows) { |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("candidate_id", row.getCandidateId()); |
| | | item.put("row_no", row.getRowNo()); |
| | | item.put("code", row.getCode()); |
| | | item.put("name", row.getName()); |
| | | canonicalRows.add(item); |
| | | } |
| | | canonical.put("rows", canonicalRows); |
| | | 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 (com.fasterxml.jackson.core.JsonProcessingException ex) { |
| | | throw new IllegalStateException("颿£æè¦çæå¤±è´¥", ex); |
| | | } |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.service.item; |
| | | |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * ç©æå¯¼å
¥çé¢å è½½ä¸ä¸æï¼åç±»/åä½ç¼ç å° IDãåç§°çæ å°ï¼é¿å
éè¡æ¥åºã |
| | | */ |
| | | public record MdmItemImportContext(Map<String, Long> categoryCodeIdMap, |
| | | Map<String, String> categoryCodeNameMap, |
| | | Map<String, Long> unitCodeIdMap, |
| | | Map<String, String> unitCodeNameMap) { |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.service.item; |
| | | |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO; |
| | | |
| | | /** |
| | | * åè¡å¯¼å
¥çè§£æç»æï¼å¤±è´¥æ¶åªå¸¦ææ¡ï¼æåæ¶å¸¦ç»è£
好ç SaveReqVOã |
| | | */ |
| | | public record MdmItemRowResolution(String failureMessage, MdmItemSaveReqVO saveReqVO) { |
| | | |
| | | public boolean isSuccess() { |
| | | return failureMessage == null; |
| | | } |
| | | |
| | | public static MdmItemRowResolution failure(String failureMessage) { |
| | | return new MdmItemRowResolution(failureMessage, null); |
| | | } |
| | | |
| | | public static MdmItemRowResolution success(MdmItemSaveReqVO saveReqVO) { |
| | | return new MdmItemRowResolution(null, saveReqVO); |
| | | } |
| | | } |
| | |
| | | */ |
| | | MdmItemImportRespVO importItemList(List<MdmItemImportExcelVO> importList, Boolean isUpdateSupport); |
| | | |
| | | /** |
| | | * é¢ç导å
¥ä¸ä¸æï¼åç±»ã计éåä½ç¼ç æ å°ï¼ |
| | | * |
| | | * @param importList 导å
¥å表 |
| | | * @return 导å
¥ä¸ä¸æ |
| | | */ |
| | | MdmItemImportContext buildImportContext(List<MdmItemImportExcelVO> importList); |
| | | |
| | | /** |
| | | * è§£æåè¡å¯¼å
¥æ°æ®ï¼æ ¡éªå¿
填项ãè§£æåç±»ä¸åä½ãç»è£
SaveReqVOï¼ |
| | | * |
| | | * @param importVO 导å
¥è¡ |
| | | * @param context 导å
¥ä¸ä¸æ |
| | | * @return è§£æç»æ |
| | | */ |
| | | MdmItemRowResolution resolveImportRow(MdmItemImportExcelVO importVO, MdmItemImportContext context); |
| | | |
| | | /** |
| | | * æç¼ç æ¥è¯¢ç©æå表ï¼ç¼ç æ å¯ä¸ç´¢å¼ï¼å¯è½è¿å夿¡ï¼ |
| | | * |
| | | * @param code ç©æç¼ç |
| | | * @return ç©æå表 |
| | | */ |
| | | List<MdmItemDO> getItemListByCode(String code); |
| | | |
| | | } |
| | |
| | | .failureList(new LinkedHashMap<>()).build(); |
| | | |
| | | // 1. é¢çï¼åç±»ç¼ç -> IDï¼MES ç©æåç±»ï¼ä¸ç©æè¡¨åä¸ææ°æ®æºä¸è´ï¼ãåä½ç¼ç -> ID |
| | | Map<String, Long> categoryCodeIdMap = new HashMap<>(); |
| | | for (String categoryCode : importList.stream() |
| | | .map(MdmItemImportExcelVO::getCategoryCode).filter(StrUtil::isNotBlank).distinct().toList()) { |
| | | CommonResult<MesMdItemTypeRespDTO> categoryResult = mesMdItemTypeApi.getItemTypeByCode(categoryCode); |
| | | if (categoryResult != null && categoryResult.getData() != null) { |
| | | categoryCodeIdMap.put(categoryCode, categoryResult.getData().getId()); |
| | | } |
| | | } |
| | | Map<String, Long> unitCodeIdMap = new HashMap<>(); |
| | | for (MdmUnitMeasureDO unit : unitMeasureMapper.selectList()) { |
| | | unitCodeIdMap.put(unit.getCode(), unit.getId()); |
| | | } |
| | | MdmItemImportContext context = buildImportContext(importList); |
| | | |
| | | // 2. éå导å
¥ï¼æ¯è¡ä¸ä¸ªç¬ç«äºå¡ï¼åè¡å¤±è´¥åªåæ»è¯¥è¡ï¼ä¸å½±åå
¶ä½è¡ï¼ |
| | | TransactionTemplate importTxTemplate = new TransactionTemplate(transactionManager); |
| | | for (MdmItemImportExcelVO importVO : importList) { |
| | | // 2.1 åºç¡æ ¡éª |
| | | if (StrUtil.isBlank(importVO.getCode()) || StrUtil.isBlank(importVO.getName())) { |
| | | respVO.getFailureList().put(buildFailKey(importVO), "ç©æç¼ç åç©æåç§°ä¸è½ä¸ºç©º"); |
| | | // 2.1~2.6 æ ¡éªå¿
填项ãè§£æåç±»ä¸åä½ãç»è£
SaveReqVO |
| | | MdmItemRowResolution resolution = resolveImportRow(importVO, context); |
| | | if (!resolution.isSuccess()) { |
| | | respVO.getFailureList().put(buildFailKey(importVO), resolution.failureMessage()); |
| | | continue; |
| | | } |
| | | // 2.2 è§£æåç±» |
| | | Long categoryId = null; |
| | | if (StrUtil.isNotBlank(importVO.getCategoryCode())) { |
| | | categoryId = categoryCodeIdMap.get(importVO.getCategoryCode()); |
| | | if (categoryId == null) { |
| | | respVO.getFailureList().put(buildFailKey(importVO), "ç©æåç±»ç¼ç ä¸åå¨ï¼" + importVO.getCategoryCode()); |
| | | continue; |
| | | } |
| | | } |
| | | // 2.3 è§£æä¸»åä½ |
| | | Long unitMeasureId = null; |
| | | if (StrUtil.isNotBlank(importVO.getUnitMeasureCode())) { |
| | | unitMeasureId = unitCodeIdMap.get(importVO.getUnitMeasureCode()); |
| | | if (unitMeasureId == null) { |
| | | respVO.getFailureList().put(buildFailKey(importVO), "计éåä½ç¼ç ä¸åå¨ï¼" + importVO.getUnitMeasureCode()); |
| | | continue; |
| | | } |
| | | } |
| | | // 2.4 è§£æè¾
åä½1 |
| | | Long unitMeasureId2 = null; |
| | | if (StrUtil.isNotBlank(importVO.getUnitMeasureCode2())) { |
| | | unitMeasureId2 = unitCodeIdMap.get(importVO.getUnitMeasureCode2()); |
| | | if (unitMeasureId2 == null) { |
| | | respVO.getFailureList().put(buildFailKey(importVO), "è¾
åä½1ç¼ç ä¸åå¨ï¼" + importVO.getUnitMeasureCode2()); |
| | | continue; |
| | | } |
| | | } |
| | | // 2.5 è§£æè¾
åä½2 |
| | | Long unitMeasureId3 = null; |
| | | if (StrUtil.isNotBlank(importVO.getUnitMeasureCode3())) { |
| | | unitMeasureId3 = unitCodeIdMap.get(importVO.getUnitMeasureCode3()); |
| | | if (unitMeasureId3 == null) { |
| | | respVO.getFailureList().put(buildFailKey(importVO), "è¾
åä½2ç¼ç ä¸åå¨ï¼" + importVO.getUnitMeasureCode3()); |
| | | continue; |
| | | } |
| | | } |
| | | |
| | | // 2.6 ç»è£
SaveReqVOï¼å¤ç¨ create/update é»è¾ï¼ |
| | | MdmItemSaveReqVO saveReqVO = new MdmItemSaveReqVO(); |
| | | saveReqVO.setCode(importVO.getCode()); |
| | | saveReqVO.setName(importVO.getName()); |
| | | saveReqVO.setBarCode(importVO.getBarCode()); |
| | | saveReqVO.setSpecification(importVO.getSpecification()); |
| | | saveReqVO.setCategoryId(categoryId); |
| | | saveReqVO.setUnitMeasureId(unitMeasureId); |
| | | saveReqVO.setUnitMeasureId2(unitMeasureId2); |
| | | saveReqVO.setUnitMeasureRate1(importVO.getUnitMeasureRate1()); |
| | | saveReqVO.setUnitMeasureId3(unitMeasureId3); |
| | | saveReqVO.setUnitMeasureRate2(importVO.getUnitMeasureRate2()); |
| | | saveReqVO.setPurchasePrice(importVO.getPurchasePrice()); |
| | | saveReqVO.setSalesPrice(importVO.getSalesPrice()); |
| | | saveReqVO.setCostPrice(importVO.getCostPrice()); |
| | | saveReqVO.setSafetyStock(importVO.getSafetyStock()); |
| | | saveReqVO.setMinStock(importVO.getMinStock()); |
| | | saveReqVO.setMaxStock(importVO.getMaxStock()); |
| | | saveReqVO.setIsBatchManaged(importVO.getIsBatchManaged()); |
| | | saveReqVO.setExpiryDay(importVO.getExpiryDay()); |
| | | saveReqVO.setStatus(importVO.getStatus() != null ? importVO.getStatus() : cn.iocoder.yudao.framework.common.enums.CommonStatusEnum.ENABLE.getStatus()); |
| | | saveReqVO.setSyncMes(importVO.getSyncMes() != null ? importVO.getSyncMes() : false); |
| | | saveReqVO.setRemark(importVO.getRemark()); |
| | | saveReqVO.setItemType(1); // é»è®¤ç©æç±»åï¼ç©æ |
| | | MdmItemSaveReqVO saveReqVO = resolution.saveReqVO(); |
| | | |
| | | // 2.7 å·²åå¨ï¼æç¼ç å¤æï¼ |
| | | MdmItemDO existByCode = itemMapper.selectByCode(importVO.getCode()); |
| | |
| | | return respVO; |
| | | } |
| | | |
| | | @Override |
| | | public MdmItemImportContext buildImportContext(List<MdmItemImportExcelVO> importList) { |
| | | Map<String, Long> categoryCodeIdMap = new HashMap<>(); |
| | | Map<String, String> categoryCodeNameMap = new HashMap<>(); |
| | | for (String categoryCode : importList.stream() |
| | | .map(MdmItemImportExcelVO::getCategoryCode).filter(StrUtil::isNotBlank).distinct().toList()) { |
| | | CommonResult<MesMdItemTypeRespDTO> categoryResult = mesMdItemTypeApi.getItemTypeByCode(categoryCode); |
| | | if (categoryResult != null && categoryResult.getData() != null) { |
| | | categoryCodeIdMap.put(categoryCode, categoryResult.getData().getId()); |
| | | categoryCodeNameMap.put(categoryCode, categoryResult.getData().getName()); |
| | | } |
| | | } |
| | | Map<String, Long> unitCodeIdMap = new HashMap<>(); |
| | | Map<String, String> unitCodeNameMap = new HashMap<>(); |
| | | for (MdmUnitMeasureDO unit : unitMeasureMapper.selectList()) { |
| | | unitCodeIdMap.put(unit.getCode(), unit.getId()); |
| | | unitCodeNameMap.put(unit.getCode(), unit.getName()); |
| | | } |
| | | return new MdmItemImportContext(categoryCodeIdMap, categoryCodeNameMap, unitCodeIdMap, unitCodeNameMap); |
| | | } |
| | | |
| | | @Override |
| | | public MdmItemRowResolution resolveImportRow(MdmItemImportExcelVO importVO, MdmItemImportContext context) { |
| | | // åºç¡æ ¡éª |
| | | if (StrUtil.isBlank(importVO.getCode()) || StrUtil.isBlank(importVO.getName())) { |
| | | return MdmItemRowResolution.failure("ç©æç¼ç åç©æåç§°ä¸è½ä¸ºç©º"); |
| | | } |
| | | // è§£æåç±» |
| | | Long categoryId = null; |
| | | if (StrUtil.isNotBlank(importVO.getCategoryCode())) { |
| | | categoryId = context.categoryCodeIdMap().get(importVO.getCategoryCode()); |
| | | if (categoryId == null) { |
| | | return MdmItemRowResolution.failure("ç©æåç±»ç¼ç ä¸åå¨ï¼" + importVO.getCategoryCode()); |
| | | } |
| | | } |
| | | // è§£æä¸»åä½ |
| | | Long unitMeasureId = null; |
| | | if (StrUtil.isNotBlank(importVO.getUnitMeasureCode())) { |
| | | unitMeasureId = context.unitCodeIdMap().get(importVO.getUnitMeasureCode()); |
| | | if (unitMeasureId == null) { |
| | | return MdmItemRowResolution.failure("计éåä½ç¼ç ä¸åå¨ï¼" + importVO.getUnitMeasureCode()); |
| | | } |
| | | } |
| | | // è§£æè¾
åä½1 |
| | | Long unitMeasureId2 = null; |
| | | if (StrUtil.isNotBlank(importVO.getUnitMeasureCode2())) { |
| | | unitMeasureId2 = context.unitCodeIdMap().get(importVO.getUnitMeasureCode2()); |
| | | if (unitMeasureId2 == null) { |
| | | return MdmItemRowResolution.failure("è¾
åä½1ç¼ç ä¸åå¨ï¼" + importVO.getUnitMeasureCode2()); |
| | | } |
| | | } |
| | | // è§£æè¾
åä½2 |
| | | Long unitMeasureId3 = null; |
| | | if (StrUtil.isNotBlank(importVO.getUnitMeasureCode3())) { |
| | | unitMeasureId3 = context.unitCodeIdMap().get(importVO.getUnitMeasureCode3()); |
| | | if (unitMeasureId3 == null) { |
| | | return MdmItemRowResolution.failure("è¾
åä½2ç¼ç ä¸åå¨ï¼" + importVO.getUnitMeasureCode3()); |
| | | } |
| | | } |
| | | |
| | | // ç»è£
SaveReqVOï¼å¤ç¨ create/update é»è¾ï¼ |
| | | MdmItemSaveReqVO saveReqVO = new MdmItemSaveReqVO(); |
| | | saveReqVO.setCode(importVO.getCode()); |
| | | saveReqVO.setName(importVO.getName()); |
| | | saveReqVO.setBarCode(importVO.getBarCode()); |
| | | saveReqVO.setSpecification(importVO.getSpecification()); |
| | | saveReqVO.setCategoryId(categoryId); |
| | | saveReqVO.setUnitMeasureId(unitMeasureId); |
| | | saveReqVO.setUnitMeasureId2(unitMeasureId2); |
| | | saveReqVO.setUnitMeasureRate1(importVO.getUnitMeasureRate1()); |
| | | saveReqVO.setUnitMeasureId3(unitMeasureId3); |
| | | saveReqVO.setUnitMeasureRate2(importVO.getUnitMeasureRate2()); |
| | | saveReqVO.setPurchasePrice(importVO.getPurchasePrice()); |
| | | saveReqVO.setSalesPrice(importVO.getSalesPrice()); |
| | | saveReqVO.setCostPrice(importVO.getCostPrice()); |
| | | saveReqVO.setSafetyStock(importVO.getSafetyStock()); |
| | | saveReqVO.setMinStock(importVO.getMinStock()); |
| | | saveReqVO.setMaxStock(importVO.getMaxStock()); |
| | | saveReqVO.setIsBatchManaged(importVO.getIsBatchManaged()); |
| | | saveReqVO.setExpiryDay(importVO.getExpiryDay()); |
| | | saveReqVO.setStatus(importVO.getStatus() != null ? importVO.getStatus() : cn.iocoder.yudao.framework.common.enums.CommonStatusEnum.ENABLE.getStatus()); |
| | | saveReqVO.setSyncMes(importVO.getSyncMes() != null ? importVO.getSyncMes() : false); |
| | | saveReqVO.setRemark(importVO.getRemark()); |
| | | saveReqVO.setItemType(1); // é»è®¤ç©æç±»åï¼ç©æ |
| | | return MdmItemRowResolution.success(saveReqVO); |
| | | } |
| | | |
| | | @Override |
| | | public List<MdmItemDO> getItemListByCode(String code) { |
| | | return itemMapper.selectListByCode(code); |
| | | } |
| | | |
| | | /** |
| | | * 失败æ¸
åç keyï¼ç¨ãç¼ç åç§°ãï¼è䏿¯åç¨åç§°ã |
| | | * ç©æå
许ååä¸åç¼ç åï¼åªæåç§°å key ä¼è®©ååè¡äºç¸è¦çãå°æ¥å¤±è´¥è¡ï¼ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.service.agent; |
| | | |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportCommitReqVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportReceiptRespVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO; |
| | | import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandDO; |
| | | import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandMapper; |
| | | import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandRowMapper; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemService; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.mockito.InjectMocks; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | import org.mockito.junit.jupiter.MockitoSettings; |
| | | import org.mockito.quality.Strictness; |
| | | import org.springframework.transaction.PlatformTransactionManager; |
| | | import org.springframework.transaction.TransactionDefinition; |
| | | import org.springframework.transaction.TransactionStatus; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.concurrent.atomic.AtomicReference; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.*; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.*; |
| | | |
| | | @ExtendWith(MockitoExtension.class) |
| | | @MockitoSettings(strictness = Strictness.LENIENT) |
| | | class MdmAgentImportCommandServiceTest { |
| | | |
| | | private static final String PREFLIGHT_HASH = "b".repeat(64); |
| | | private static final String SOURCE_SHA = "a".repeat(64); |
| | | |
| | | @Mock private MdmAgentImportPreflightService preflightService; |
| | | @Mock private MdmItemService itemService; |
| | | @Mock private MdmAgentImportPolicy policy; |
| | | @Mock private MdmAgentImportCommandMapper commandMapper; |
| | | @Mock private MdmAgentImportCommandRowMapper commandRowMapper; |
| | | @Mock private PlatformTransactionManager transactionManager; |
| | | @InjectMocks private MdmAgentImportCommandService service; |
| | | |
| | | private final AgentPrincipal principal = new AgentPrincipal(7L, 0L, "live"); |
| | | |
| | | @BeforeEach |
| | | void setUp() { |
| | | when(policy.isWriteEnabled()).thenReturn(true); |
| | | when(transactionManager.getTransaction(any(TransactionDefinition.class))) |
| | | .thenReturn(mock(TransactionStatus.class)); |
| | | when(itemService.buildImportContext(any())).thenReturn(new MdmItemImportContext( |
| | | Map.of("RAW", 11L), Map.of("RAW", "åæ"), |
| | | Map.of("PCS", 22L), Map.of("PCS", "个"))); |
| | | when(itemService.resolveImportRow(any(MdmItemImportExcelVO.class), any())) |
| | | .thenAnswer(invocation -> MdmItemRowResolution.success(new MdmItemSaveReqVO())); |
| | | when(itemService.getItemListByCode(any())).thenReturn(List.of()); |
| | | when(itemService.createItem(any())).thenReturn(77L); |
| | | when(commandRowMapper.selectListByCommandId(any())).thenReturn(List.of()); |
| | | } |
| | | |
| | | @Test |
| | | void writeDisabledRejects() { |
| | | when(policy.isWriteEnabled()).thenReturn(false); |
| | | AgentBridgeException ex = assertThrows(AgentBridgeException.class, |
| | | () -> service.commit(request("ç©æA"), principal)); |
| | | assertEquals("WRITE_DISABLED", ex.getErrorCode()); |
| | | verify(itemService, never()).createItem(any()); |
| | | } |
| | | |
| | | @Test |
| | | void repeatedCommitCreatesItemOnceAndReplaysReceipt() { |
| | | stubPreflight(PREFLIGHT_HASH); |
| | | AtomicReference<MdmAgentImportCommandDO> inserted = captureInsertedCommand(); |
| | | |
| | | MdmAgentImportReceiptRespVO first = service.commit(request("ç©æA"), principal); |
| | | when(commandMapper.selectByCommandId(0L, 7L, "cmd-1")).thenReturn(inserted.get()); |
| | | MdmAgentImportReceiptRespVO second = service.commit(request("ç©æA"), principal); |
| | | |
| | | assertEquals("SUCCEEDED", first.getStatus()); |
| | | assertEquals("SUCCEEDED", second.getStatus()); |
| | | assertEquals(first.getPreflightHash(), second.getPreflightHash()); |
| | | verify(itemService, times(1)).createItem(any()); |
| | | } |
| | | |
| | | @Test |
| | | void sameCommandWithDifferentContentConflicts() { |
| | | when(preflightService.preflight(any(), any())).thenReturn(preflight(PREFLIGHT_HASH)); |
| | | AtomicReference<MdmAgentImportCommandDO> inserted = captureInsertedCommand(); |
| | | service.commit(request("ç©æA"), principal); |
| | | when(commandMapper.selectByCommandId(0L, 7L, "cmd-1")).thenReturn(inserted.get()); |
| | | when(commandMapper.selectByIdempotencyKey(0L, 7L, "idem-1")).thenReturn(null); |
| | | |
| | | AgentBridgeException ex = assertThrows(AgentBridgeException.class, |
| | | () -> service.commit(request("ç©æB"), principal)); |
| | | assertEquals("COMMAND_ID_CONFLICT", ex.getErrorCode()); |
| | | } |
| | | |
| | | @Test |
| | | void stalePreflightIsRejected() { |
| | | stubPreflight("c".repeat(64)); |
| | | |
| | | AgentBridgeException ex = assertThrows(AgentBridgeException.class, |
| | | () -> service.commit(request("ç©æA"), principal)); |
| | | assertEquals("PLAN_STALE", ex.getErrorCode()); |
| | | verify(commandMapper, never()).insert(any(MdmAgentImportCommandDO.class)); |
| | | } |
| | | |
| | | @Test |
| | | void missingCommandReturnsNotFound() { |
| | | when(commandMapper.selectByCommandId(0L, 7L, "cmd-x")).thenReturn(null); |
| | | |
| | | AgentBridgeException ex = assertThrows(AgentBridgeException.class, |
| | | () -> service.getCommand("cmd-x", principal)); |
| | | assertEquals("COMMAND_NOT_FOUND", ex.getErrorCode()); |
| | | } |
| | | |
| | | private void stubPreflight(String preflightHash) { |
| | | when(preflightService.preflight(any(), any())).thenReturn(preflight(preflightHash)); |
| | | } |
| | | |
| | | private static MdmAgentImportPreflightRespVO preflight(String hash) { |
| | | return MdmAgentImportPreflightRespVO.builder() |
| | | .preflightHash(hash) |
| | | .canCommit(true) |
| | | .rows(List.of(MdmAgentImportPreflightRespVO.Row.builder() |
| | | .candidateId("c-0").action("CREATE").build())) |
| | | .summary(MdmAgentImportPreflightRespVO.Summary.builder() |
| | | .total(1).create(1).skip(0).blocked(0).build()) |
| | | .build(); |
| | | } |
| | | |
| | | private AtomicReference<MdmAgentImportCommandDO> captureInsertedCommand() { |
| | | AtomicReference<MdmAgentImportCommandDO> holder = new AtomicReference<>(); |
| | | doAnswer(invocation -> { |
| | | MdmAgentImportCommandDO command = invocation.getArgument(0); |
| | | command.setId(1L); |
| | | holder.set(command); |
| | | return 1; |
| | | }).when(commandMapper).insert(any(MdmAgentImportCommandDO.class)); |
| | | return holder; |
| | | } |
| | | |
| | | private static MdmAgentImportCommitReqVO request(String name) { |
| | | MdmAgentImportPreflightReqVO.Row row = new MdmAgentImportPreflightReqVO.Row(); |
| | | row.setCandidateId("c-0"); |
| | | row.setRowNo(1); |
| | | row.setCode("AGENT-E2E-1"); |
| | | row.setName(name); |
| | | row.setCategoryCode("RAW"); |
| | | row.setUnitMeasureCode("PCS"); |
| | | |
| | | MdmAgentImportCommitReqVO reqVO = new MdmAgentImportCommitReqVO(); |
| | | reqVO.setCommandId("cmd-1"); |
| | | reqVO.setIdempotencyKey("idem-1"); |
| | | reqVO.setAttachmentId(5L); |
| | | reqVO.setSessionId(21L); |
| | | reqVO.setSourceSha256(SOURCE_SHA); |
| | | reqVO.setPreflightHash(PREFLIGHT_HASH); |
| | | reqVO.setProposalRevision("rev-1"); |
| | | reqVO.setRows(List.of(row)); |
| | | return reqVO; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.service.agent; |
| | | |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO; |
| | | import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution; |
| | | import cn.iocoder.yudao.module.mdm.service.item.MdmItemService; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal; |
| | | import cn.iocoder.yudao.module.system.service.storage.SystemAgentAttachmentRef; |
| | | import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.mockito.InjectMocks; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | import org.mockito.junit.jupiter.MockitoSettings; |
| | | import org.mockito.quality.Strictness; |
| | | |
| | | import java.nio.file.Path; |
| | | 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.ArgumentMatchers.anyString; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | @ExtendWith(MockitoExtension.class) |
| | | @MockitoSettings(strictness = Strictness.LENIENT) |
| | | class MdmAgentImportPreflightServiceTest { |
| | | |
| | | private static final String SHA = "a".repeat(64); |
| | | |
| | | @Mock private SystemAgentImportBridgeService bridgeService; |
| | | @Mock private MdmItemService itemService; |
| | | @Mock private MdmAgentImportPolicy policy; |
| | | @InjectMocks private MdmAgentImportPreflightService service; |
| | | |
| | | private final AgentPrincipal principal = new AgentPrincipal(7L, 0L, "live"); |
| | | |
| | | @Test |
| | | void classifiesCreateSkipAndBlockedRows() { |
| | | stubAttachmentAndPolicy(); |
| | | MdmItemDO existing = new MdmItemDO(); |
| | | existing.setId(99L); |
| | | when(itemService.getItemListByCode("A")).thenReturn(List.of()); |
| | | when(itemService.getItemListByCode("B")).thenReturn(List.of(existing)); |
| | | |
| | | MdmAgentImportPreflightRespVO result = service.preflight( |
| | | request("A", "B", "C"), principal); |
| | | |
| | | assertEquals(3, result.getSummary().getTotal()); |
| | | assertEquals(1, result.getSummary().getCreate()); |
| | | assertEquals(1, result.getSummary().getSkip()); |
| | | assertEquals(1, result.getSummary().getBlocked()); |
| | | assertEquals("CREATE", result.getRows().get(0).getAction()); |
| | | assertEquals("SKIP", result.getRows().get(1).getAction()); |
| | | assertEquals(99L, result.getRows().get(1).getTargetItemId()); |
| | | assertFalse(result.getRows().get(1).getWarnings().isEmpty()); |
| | | assertEquals("BLOCKED", result.getRows().get(2).getAction()); |
| | | assertEquals("CATEGORY_NOT_FOUND", result.getRows().get(2).getBlockers().get(0).getCode()); |
| | | assertFalse(result.getCanCommit()); |
| | | } |
| | | |
| | | @Test |
| | | void duplicateCodeInFileIsBlocked() { |
| | | stubAttachmentAndPolicy(); |
| | | when(itemService.getItemListByCode(anyString())).thenReturn(List.of()); |
| | | |
| | | MdmAgentImportPreflightRespVO result = service.preflight( |
| | | request("A", "A"), principal); |
| | | |
| | | assertEquals(2, result.getSummary().getBlocked()); |
| | | assertEquals("DUPLICATE_CODE_IN_FILE", result.getRows().get(1).getBlockers().get(0).getCode()); |
| | | } |
| | | |
| | | @Test |
| | | void hashIsStableAcrossIdenticalRuns() { |
| | | stubAttachmentAndPolicy(); |
| | | when(itemService.getItemListByCode("A")).thenReturn(List.of()); |
| | | |
| | | MdmAgentImportPreflightRespVO first = service.preflight(request("A"), principal); |
| | | MdmAgentImportPreflightRespVO second = service.preflight(request("A"), principal); |
| | | |
| | | assertTrue(first.getCanCommit()); |
| | | assertEquals(first.getPreflightHash(), second.getPreflightHash()); |
| | | } |
| | | |
| | | private void stubAttachmentAndPolicy() { |
| | | when(bridgeService.requireOwnedAttachmentRef(5L)) |
| | | .thenReturn(new SystemAgentAttachmentRef(5L, 21L, "materials.csv", 100L, Path.of("x"))); |
| | | when(bridgeService.sha256OfAttachment(5L)).thenReturn(SHA); |
| | | when(policy.getMaxRows()).thenReturn(5000); |
| | | when(policy.isAllowMesSync()).thenReturn(false); |
| | | MdmItemImportContext context = new MdmItemImportContext( |
| | | Map.of("RAW", 11L), Map.of("RAW", "åæ"), |
| | | Map.of("PCS", 22L), Map.of("PCS", "个")); |
| | | when(itemService.buildImportContext(any())).thenReturn(context); |
| | | when(itemService.resolveImportRow(any(MdmItemImportExcelVO.class), any())) |
| | | .thenAnswer(invocation -> MdmItemRowResolution.success(new MdmItemSaveReqVO())); |
| | | } |
| | | |
| | | private static MdmAgentImportPreflightReqVO request(String... codes) { |
| | | MdmAgentImportPreflightReqVO reqVO = new MdmAgentImportPreflightReqVO(); |
| | | reqVO.setAttachmentId(5L); |
| | | reqVO.setSessionId(21L); |
| | | reqVO.setSourceSha256(SHA); |
| | | reqVO.setProposalRevision("rev-1"); |
| | | reqVO.setRows(java.util.stream.IntStream.range(0, codes.length).mapToObj(index -> { |
| | | String code = codes[index]; |
| | | MdmAgentImportPreflightReqVO.Row row = new MdmAgentImportPreflightReqVO.Row(); |
| | | row.setCandidateId("c-" + index); |
| | | row.setRowNo(index + 1); |
| | | row.setCode(code); |
| | | row.setName("ç©æ" + code); |
| | | row.setCategoryCode("C".equals(code) ? "NOPE" : "RAW"); |
| | | row.setUnitMeasureCode("PCS"); |
| | | return row; |
| | | }).toList()); |
| | | return reqVO; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.mdm.service.item; |
| | | |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportRespVO; |
| | | import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO; |
| | | import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO; |
| | | import cn.iocoder.yudao.module.mdm.dal.dataobject.unit.MdmUnitMeasureDO; |
| | | import cn.iocoder.yudao.module.mdm.dal.mysql.item.MdmItemMapper; |
| | | import cn.iocoder.yudao.module.mdm.dal.mysql.unit.MdmUnitMeasureMapper; |
| | | import cn.iocoder.yudao.module.mes.api.item.MesMdItemTypeApi; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.mockito.InjectMocks; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.*; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | @ExtendWith(MockitoExtension.class) |
| | | class MdmItemImportParsingTest { |
| | | |
| | | @Mock private MdmItemMapper itemMapper; |
| | | @Mock private MdmUnitMeasureMapper unitMeasureMapper; |
| | | @Mock private MesMdItemTypeApi mesMdItemTypeApi; |
| | | @InjectMocks private MdmItemServiceImpl service; |
| | | |
| | | @Test |
| | | void emptyCodeOrNameFails() { |
| | | MdmItemRowResolution resolution = service.resolveImportRow( |
| | | MdmItemImportExcelVO.builder().name("ç©æA").build(), emptyContext()); |
| | | assertFalse(resolution.isSuccess()); |
| | | assertEquals("ç©æç¼ç åç©æåç§°ä¸è½ä¸ºç©º", resolution.failureMessage()); |
| | | } |
| | | |
| | | @Test |
| | | void missingCategoryFails() { |
| | | MdmItemRowResolution resolution = service.resolveImportRow( |
| | | MdmItemImportExcelVO.builder().code("A1").name("ç©æA").categoryCode("NOPE").build(), |
| | | emptyContext()); |
| | | assertFalse(resolution.isSuccess()); |
| | | assertEquals("ç©æåç±»ç¼ç ä¸åå¨ï¼NOPE", resolution.failureMessage()); |
| | | } |
| | | |
| | | @Test |
| | | void missingUnitFails() { |
| | | MdmItemRowResolution resolution = service.resolveImportRow( |
| | | MdmItemImportExcelVO.builder().code("A1").name("ç©æA").unitMeasureCode("NOPE").build(), |
| | | emptyContext()); |
| | | assertFalse(resolution.isSuccess()); |
| | | assertEquals("计éåä½ç¼ç ä¸åå¨ï¼NOPE", resolution.failureMessage()); |
| | | } |
| | | |
| | | @Test |
| | | void successBuildsSaveReqVO() { |
| | | MdmItemImportContext context = new MdmItemImportContext( |
| | | Map.of("RAW", 11L), Map.of("RAW", "åæ"), |
| | | Map.of("PCS", 22L), Map.of("PCS", "个")); |
| | | MdmItemRowResolution resolution = service.resolveImportRow( |
| | | MdmItemImportExcelVO.builder().code("A1").name("ç©æA") |
| | | .categoryCode("RAW").unitMeasureCode("PCS").build(), context); |
| | | assertTrue(resolution.isSuccess()); |
| | | MdmItemSaveReqVO reqVO = resolution.saveReqVO(); |
| | | assertEquals("A1", reqVO.getCode()); |
| | | assertEquals(11L, reqVO.getCategoryId()); |
| | | assertEquals(22L, reqVO.getUnitMeasureId()); |
| | | assertEquals(1, reqVO.getItemType()); |
| | | assertFalse(reqVO.getSyncMes()); |
| | | } |
| | | |
| | | @Test |
| | | void existingCodeStillReportsSameMessage() { |
| | | when(unitMeasureMapper.selectList()).thenReturn(List.of()); |
| | | when(itemMapper.selectByCode("A1")).thenReturn(new MdmItemDO()); |
| | | |
| | | MdmItemImportRespVO respVO = service.importItemList(List.of( |
| | | MdmItemImportExcelVO.builder().code("A1").name("ç©æA").build()), false); |
| | | |
| | | assertEquals(Map.of("A1 ç©æA", "ç©æç¼ç å·²åå¨"), respVO.getFailureList()); |
| | | assertTrue(respVO.getCreateList().isEmpty()); |
| | | } |
| | | |
| | | private static MdmItemImportContext emptyContext() { |
| | | return new MdmItemImportContext(Map.of(), Map.of(), Map.of(), Map.of()); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.controller.admin.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException; |
| | | 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.storage.SystemAgentAttachmentRef; |
| | | import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService; |
| | | import jakarta.annotation.Resource; |
| | | import jakarta.validation.Valid; |
| | | import jakarta.validation.constraints.NotNull; |
| | | import lombok.Data; |
| | | import org.springframework.core.io.FileSystemResource; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.http.ResponseEntity; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.Base64; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| | | |
| | | /** Read-only bridge for material onboarding; business writes live in the MDM module. */ |
| | | @RestController |
| | | @RequestMapping("/agent/bridge") |
| | | public class SystemAgentImportBridgeController { |
| | | |
| | | 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"); |
| | | |
| | | @Resource |
| | | private SystemAgentImportBridgeService bridgeService; |
| | | @Resource |
| | | private SystemAgentBridgeGuard bridgeGuard; |
| | | |
| | | @PostMapping("/import-sessions") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | public CommonResult<Long> createSession() { |
| | | return success(bridgeService.createSession()); |
| | | } |
| | | |
| | | @PostMapping("/import-sessions/{sessionId}/attachments") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | 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')") |
| | | 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", "䏿¯æçæºè½ä½æä½"); |
| | | } |
| | | if (requiresAttachmentOwnership(request.getAction())) { |
| | | bridgeService.requireOwnedAttachmentRef(parseId(request.getResource())); |
| | | } |
| | | // read/review/read_receipt çä»»å¡ UUID æææç± Python checkpoint ä½ç¨åæ ¸éªï¼æ¬ç«¯éæ¥å½åæé®æéã |
| | | return success(Map.of("user_id", String.valueOf(principal.userId()), |
| | | "tenant_id", String.valueOf(principal.tenantId()), |
| | | "policy_revision", principal.policyRevision())); |
| | | } |
| | | |
| | | @GetMapping("/attachments/{attachmentId}") |
| | | @PreAuthorize("@ss.hasPermission('mdm:item:import')") |
| | | public ResponseEntity<FileSystemResource> downloadAttachment( |
| | | @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken, |
| | | @PathVariable Long attachmentId) { |
| | | bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT); |
| | | SystemAgentAttachmentRef ref = bridgeService.requireOwnedAttachmentRef(attachmentId); |
| | | return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM) |
| | | .contentLength(ref.byteSize()) |
| | | .header("X-Agent-Filename-B64", Base64.getEncoder().encodeToString( |
| | | ref.originalFilename().getBytes(StandardCharsets.UTF_8))) |
| | | .body(new FileSystemResource(ref.path())); |
| | | } |
| | | |
| | | private static boolean requiresAttachmentOwnership(String action) { |
| | | return "material_import.prepare".equals(action) |
| | | || "material_import.preflight".equals(action) |
| | | || "material_import.commit".equals(action); |
| | | } |
| | | |
| | | private Long parseId(String value) { |
| | | try { |
| | | return Long.valueOf(value); |
| | | } catch (NumberFormatException ex) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_FORBIDDEN", "éä»¶ä¸å¯è®¿é®"); |
| | | } |
| | | } |
| | | |
| | | @Data |
| | | public static class BindRequest { |
| | | @NotNull |
| | | private Long blobId; |
| | | } |
| | | |
| | | @Data |
| | | public static class AuthorizeRequest { |
| | | @NotNull |
| | | private String action; |
| | | @NotNull |
| | | private String resource; |
| | | } |
| | | } |
| | |
| | | @PreAuthorize("@ss.hasPermission('system:storage-attachment:query')") |
| | | public CommonResult<List<SystemStorageBlobRespVO>> listAttachments( |
| | | @Valid SystemStorageAttachmentListReqVO reqVO) { |
| | | if ("agent_import_session".equals(reqVO.getRecordType())) { |
| | | throw new org.springframework.web.server.ResponseStatusException( |
| | | org.springframework.http.HttpStatus.FORBIDDEN, "æºè½ä½è¡¥å½é件请使ç¨è¡¥å½ä¼è¯æ¥å£æ¥è¯¢"); |
| | | } |
| | | return success(storageAttachmentService.listAttachments(reqVO)); |
| | | } |
| | | |
| | |
| | | @PreAuthorize("@ss.hasPermission('system:storage-attachment:create')") |
| | | public CommonResult<Boolean> bindAttachments( |
| | | @Valid @RequestBody SystemStorageAttachmentSaveReqVO reqVO) { |
| | | if ("agent_import_session".equals(reqVO.getRecordType())) { |
| | | throw new org.springframework.web.server.ResponseStatusException( |
| | | org.springframework.http.HttpStatus.FORBIDDEN, "æºè½ä½è¡¥å½é件请使ç¨è¡¥å½ä¼è¯ç»å®æ¥å£"); |
| | | } |
| | | storageAttachmentService.bindAttachments(reqVO); |
| | | return success(true); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.dal.dataobject.storage; |
| | | |
| | | import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.Data; |
| | | import lombok.EqualsAndHashCode; |
| | | |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @TableName("system_agent_import_session") |
| | | public class SystemAgentImportSessionDO extends TenantBaseDO { |
| | | @TableId |
| | | private Long id; |
| | | private Long ownerUserId; |
| | | private String status; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.dal.mysql.storage; |
| | | |
| | | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemAgentImportSessionDO; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | @Mapper |
| | | public interface SystemAgentImportSessionMapper extends BaseMapperX<SystemAgentImportSessionDO> { |
| | | } |
| | |
| | | |
| | | // ===== éç¨ ===== |
| | | COMMON_FILE("common_file", "éç¨æä»¶"), |
| | | AGENT_IMPORT_SESSION("agent_import_session", "æºè½ä½è¡¥å½ä¼è¯"), |
| | | |
| | | // ===== ç产 ===== |
| | | PRODUCTION_ORDER("production_order", "ç产工å"), |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.framework.agent; |
| | | |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.web.server.ResponseStatusException; |
| | | |
| | | import java.util.Map; |
| | | |
| | | /** æ¡¥æ¥æ¥å£çä¸å¡å¼å¸¸ï¼æºå¸¦çå® HTTP ç¶æã稳å®é误ç ä¸å¯å±ç¤ºæç»ã */ |
| | | public class AgentBridgeException extends ResponseStatusException { |
| | | |
| | | private final String errorCode; |
| | | private final boolean retryable; |
| | | private final Map<String, Object> details; |
| | | |
| | | public AgentBridgeException(HttpStatus status, String errorCode, String message) { |
| | | this(status, errorCode, message, false, null); |
| | | } |
| | | |
| | | public AgentBridgeException(HttpStatus status, String errorCode, String message, boolean retryable, |
| | | Map<String, Object> details) { |
| | | super(status, message); |
| | | this.errorCode = errorCode; |
| | | this.retryable = retryable; |
| | | this.details = details; |
| | | } |
| | | |
| | | public String getErrorCode() { |
| | | return errorCode; |
| | | } |
| | | |
| | | public boolean isRetryable() { |
| | | return retryable; |
| | | } |
| | | |
| | | public Map<String, Object> getDetails() { |
| | | return details; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.framework.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.core.Ordered; |
| | | import org.springframework.core.annotation.Order; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.http.ResponseEntity; |
| | | import org.springframework.http.converter.HttpMessageNotReadableException; |
| | | import org.springframework.security.access.AccessDeniedException; |
| | | import org.springframework.validation.FieldError; |
| | | import org.springframework.web.bind.MethodArgumentNotValidException; |
| | | import org.springframework.web.bind.annotation.ExceptionHandler; |
| | | import org.springframework.web.bind.annotation.RestControllerAdvice; |
| | | import org.springframework.web.server.ResponseStatusException; |
| | | |
| | | import java.util.LinkedHashMap; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * æºè½ä½æ¡¥æ¥ä¸ç¨å¼å¸¸æ å°ï¼ç¶æç å¿
é¡»çå®ï¼Python æè½åºå 401/403/409/422ã |
| | | * ä»
ä½ç¨äº agent å
ï¼é¿å
å½±åå
¶ä»ç®¡ç端æ¥å£çæ¢æååºæ ¼å¼ã |
| | | */ |
| | | @Slf4j |
| | | @Order(Ordered.HIGHEST_PRECEDENCE) |
| | | @RestControllerAdvice(basePackages = { |
| | | "cn.iocoder.yudao.module.system.controller.admin.agent", |
| | | "cn.iocoder.yudao.module.mdm.controller.admin.agent" |
| | | }) |
| | | public class AgentBridgeExceptionHandler { |
| | | |
| | | @ExceptionHandler(AgentBridgeException.class) |
| | | public ResponseEntity<CommonResult<Map<String, Object>>> handleAgentBridge(AgentBridgeException ex) { |
| | | return build(ex.getStatusCode().value(), ex.getReason(), ex.getErrorCode(), |
| | | ex.isRetryable(), ex.getDetails()); |
| | | } |
| | | |
| | | @ExceptionHandler(ResponseStatusException.class) |
| | | public ResponseEntity<CommonResult<Map<String, Object>>> handleResponseStatus(ResponseStatusException ex) { |
| | | int status = ex.getStatusCode().value(); |
| | | return build(status, ex.getReason(), defaultCode(status), false, null); |
| | | } |
| | | |
| | | @ExceptionHandler(AccessDeniedException.class) |
| | | public ResponseEntity<CommonResult<Map<String, Object>>> handleAccessDenied(AccessDeniedException ex) { |
| | | return build(HttpStatus.FORBIDDEN.value(), "å½å身份缺å°ç©æå¯¼å
¥æéï¼mdm:item:importï¼", |
| | | "FORBIDDEN", false, null); |
| | | } |
| | | |
| | | @ExceptionHandler(MethodArgumentNotValidException.class) |
| | | public ResponseEntity<CommonResult<Map<String, Object>>> handleInvalidArgument(MethodArgumentNotValidException ex) { |
| | | FieldError fieldError = ex.getBindingResult().getFieldError(); |
| | | String message = fieldError == null ? "请æ±åæ°ä¸åæ³" |
| | | : fieldError.getField() + "ï¼" + fieldError.getDefaultMessage(); |
| | | return build(HttpStatus.BAD_REQUEST.value(), message, "BAD_REQUEST", false, null); |
| | | } |
| | | |
| | | @ExceptionHandler(HttpMessageNotReadableException.class) |
| | | public ResponseEntity<CommonResult<Map<String, Object>>> handleUnreadable(HttpMessageNotReadableException ex) { |
| | | return build(HttpStatus.BAD_REQUEST.value(), "请æ±ä½æ ¼å¼ä¸æ£ç¡®", "BAD_REQUEST", false, null); |
| | | } |
| | | |
| | | @ExceptionHandler(Exception.class) |
| | | public ResponseEntity<CommonResult<Map<String, Object>>> handleUnexpected(Exception ex) { |
| | | log.error("[handleUnexpected][æºè½ä½æ¡¥æ¥æªé¢æå¼å¸¸]", ex); |
| | | return build(HttpStatus.INTERNAL_SERVER_ERROR.value(), "æºè½ä½æ¡¥æ¥å¤ç失败", "INTERNAL_ERROR", true, null); |
| | | } |
| | | |
| | | private ResponseEntity<CommonResult<Map<String, Object>>> build( |
| | | int status, String message, String errorCode, boolean retryable, Map<String, Object> details) { |
| | | Map<String, Object> data = new LinkedHashMap<>(); |
| | | data.put("error_code", errorCode); |
| | | data.put("retryable", retryable); |
| | | data.put("details", details == null ? Map.of() : details); |
| | | CommonResult<Map<String, Object>> result = CommonResult.error(status, |
| | | message == null ? defaultMessage(status) : message); |
| | | result.setData(data); |
| | | return ResponseEntity.status(status).body(result); |
| | | } |
| | | |
| | | private static String defaultCode(int status) { |
| | | return switch (status) { |
| | | case 401 -> "UNAUTHORIZED"; |
| | | case 403 -> "FORBIDDEN"; |
| | | case 404 -> "NOT_FOUND"; |
| | | case 409 -> "CONFLICT"; |
| | | case 413 -> "FILE_TOO_LARGE"; |
| | | case 422 -> "VALIDATION_FAILED"; |
| | | case 503 -> "SERVICE_UNAVAILABLE"; |
| | | default -> status >= 500 ? "INTERNAL_ERROR" : "BAD_REQUEST"; |
| | | }; |
| | | } |
| | | |
| | | private static String defaultMessage(int status) { |
| | | return switch (status) { |
| | | case 401 -> "æªç»å½æç»å½å·²è¿æ"; |
| | | case 403 -> "å½åèº«ä»½æ æè®¿é®"; |
| | | case 404 -> "èµæºä¸åå¨"; |
| | | case 409 -> "请æ±å²çª"; |
| | | case 413 -> "æä»¶è¶
è¿å¤§å°éå¶"; |
| | | case 422 -> "è¯·æ±æ°æ®æªéè¿æ ¡éª"; |
| | | case 503 -> "æå¡æä¸å¯ç¨"; |
| | | default -> status >= 500 ? "æå¡å¤ç失败" : "请æ±ä¸åæ³"; |
| | | }; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.framework.agent; |
| | | |
| | | /** æºè½ä½è°ç¨ç» Java ç°æ¥åç身份ä¸ä¸æï¼ä¸æ¥èªå®¢æ·ç«¯èªæ¥ã */ |
| | | public record AgentPrincipal(Long userId, Long tenantId, String policyRevision) { |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.framework.agent; |
| | | |
| | | import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; |
| | | import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; |
| | | import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; |
| | | import jakarta.annotation.Resource; |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.MessageDigest; |
| | | |
| | | /** æ¡¥æ¥ç»ä¸é´æï¼æå¡ä»¤ç + å½åç»å½ç¨æ· + ä¸å¡æéï¼ç¼ºä¸ä¸å¯ã */ |
| | | @Component |
| | | public class SystemAgentBridgeGuard { |
| | | |
| | | public static final String PERMISSION_ITEM_IMPORT = "mdm:item:import"; |
| | | |
| | | @Value("${yudao.agent.bridge-service-token:}") |
| | | private String bridgeServiceToken; |
| | | |
| | | @Resource |
| | | private SecurityFrameworkService securityFrameworkService; |
| | | |
| | | public void verifyServiceToken(String provided) { |
| | | if (bridgeServiceToken == null || bridgeServiceToken.isBlank() || provided == null |
| | | || !MessageDigest.isEqual(bridgeServiceToken.getBytes(StandardCharsets.UTF_8), |
| | | provided.getBytes(StandardCharsets.UTF_8))) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "SERVICE_TOKEN_INVALID", |
| | | "æºè½ä½æå¡èº«ä»½æ æï¼è¯·æ£æ¥æå¡å¯é¥é
ç½®"); |
| | | } |
| | | } |
| | | |
| | | public AgentPrincipal require(String providedServiceToken, String permission) { |
| | | verifyServiceToken(providedServiceToken); |
| | | Long userId = SecurityFrameworkUtils.getLoginUserId(); |
| | | if (userId == null) { |
| | | throw new AgentBridgeException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "æªç»å½æç»å½å·²è¿æ"); |
| | | } |
| | | if (!securityFrameworkService.hasPermission(permission)) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN", |
| | | "å½åç¨æ·ç¼ºå°ç©æå¯¼å
¥æéï¼" + permission + "ï¼"); |
| | | } |
| | | Long tenantId = TenantContextHolder.getTenantId(); |
| | | return new AgentPrincipal(userId, tenantId == null ? 0L : tenantId, "live"); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.service.storage; |
| | | |
| | | import java.nio.file.Path; |
| | | |
| | | /** å·²éè¿ææææ ¡éªçè¡¥å½éä»¶å¼ç¨ï¼path ä»
便¡¥æ¥è¯»åï¼ä¸å¯¹å¤è¿åã */ |
| | | public record SystemAgentAttachmentRef(Long attachmentId, Long sessionId, String originalFilename, |
| | | long byteSize, Path path) { |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.service.storage; |
| | | |
| | | import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; |
| | | import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemAgentImportSessionDO; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO; |
| | | import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemAgentImportSessionMapper; |
| | | import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException; |
| | | import cn.iocoder.yudao.module.system.framework.storage.config.StorageProperties; |
| | | import jakarta.annotation.Resource; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | | import java.io.IOException; |
| | | import java.io.InputStream; |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.security.MessageDigest; |
| | | import java.security.NoSuchAlgorithmException; |
| | | import java.util.HexFormat; |
| | | import java.util.List; |
| | | import java.util.Objects; |
| | | |
| | | @Service |
| | | public class SystemAgentImportBridgeService { |
| | | |
| | | public static final long MAX_FILE_BYTES = 8L * 1024 * 1024; |
| | | public static final String RECORD_TYPE = "agent_import_session"; |
| | | |
| | | @Resource |
| | | private SystemAgentImportSessionMapper sessionMapper; |
| | | @Resource |
| | | private SystemStorageAttachmentMapper attachmentMapper; |
| | | @Resource |
| | | private SystemStorageBlobService blobService; |
| | | @Resource |
| | | private SystemStorageAttachmentService attachmentService; |
| | | @Resource |
| | | private StorageProperties storageProperties; |
| | | |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public Long createSession() { |
| | | Long userId = SecurityFrameworkUtils.getLoginUserId(); |
| | | if (userId == null) { |
| | | throw new AgentBridgeException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "æªç»å½æç»å½å·²è¿æ"); |
| | | } |
| | | SystemAgentImportSessionDO session = new SystemAgentImportSessionDO(); |
| | | session.setOwnerUserId(userId); |
| | | session.setStatus("active"); |
| | | sessionMapper.insert(session); |
| | | return session.getId(); |
| | | } |
| | | |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public Long bindAttachment(Long sessionId, Long blobId) { |
| | | requireOwnedSession(sessionId); |
| | | SystemStorageBlobDO blob = blobService.getStorageBlob(blobId); |
| | | requireOwnedBlob(blob); |
| | | SystemStorageAttachmentSaveReqVO req = new SystemStorageAttachmentSaveReqVO(); |
| | | req.setApplication("file"); |
| | | req.setRecordType(RECORD_TYPE); |
| | | req.setRecordId(sessionId); |
| | | SystemStorageAttachmentSaveReqVO.BlobItem item = new SystemStorageAttachmentSaveReqVO.BlobItem(); |
| | | item.setBlobId(blobId); |
| | | req.setBlobItems(List.of(item)); |
| | | attachmentService.bindAttachments(req); |
| | | // éç¨ bind 对åä¸ä¼è¯æ¯æ¿æ¢è¯ä¹ï¼éå¤ç»å®æ¶æ§éä»¶è¡ä¼è¢«å é¤ã |
| | | return attachmentMapper.selectListByApplicationAndRecordTypeAndRecordId("file", RECORD_TYPE, sessionId) |
| | | .stream().filter(row -> blobId.equals(row.getStorageBlobId())) |
| | | .map(SystemStorageAttachmentDO::getId).findFirst() |
| | | .orElseThrow(() -> new AgentBridgeException(HttpStatus.CONFLICT, "ATTACHMENT_BIND_FAILED", |
| | | "éä»¶ç»å®å¤±è´¥ï¼è¯·éæ°ä¸ä¼ ååè¯", true, null)); |
| | | } |
| | | |
| | | public SystemAgentAttachmentRef requireOwnedAttachmentRef(Long attachmentId) { |
| | | SystemStorageAttachmentDO attachment = attachmentMapper.selectById(attachmentId); |
| | | if (attachment == null) { |
| | | throw new AgentBridgeException(HttpStatus.CONFLICT, "ATTACHMENT_REPLACED", |
| | | "é件已失æï¼å¯è½è¢«éæ°ä¸ä¼ æ¿æ¢ï¼ï¼è¯·éæ°ä¸ä¼ ååè¯", true, null); |
| | | } |
| | | if (!RECORD_TYPE.equals(attachment.getRecordType())) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_FORBIDDEN", "éä»¶ä¸å¯è®¿é®"); |
| | | } |
| | | requireOwnedSession(attachment.getRecordId()); |
| | | SystemStorageBlobDO blob = blobService.getStorageBlob(attachment.getStorageBlobId()); |
| | | requireOwnedBlob(blob); |
| | | Path file = resolveBlobFile(blob); |
| | | String filename = blob.getOriginalFilename(); |
| | | if (filename == null || filename.isBlank()) { |
| | | filename = "attachment"; |
| | | } |
| | | return new SystemAgentAttachmentRef(attachmentId, attachment.getRecordId(), filename, |
| | | blob.getByteSize(), file); |
| | | } |
| | | |
| | | public String sha256OfAttachment(Long attachmentId) { |
| | | SystemAgentAttachmentRef ref = requireOwnedAttachmentRef(attachmentId); |
| | | try (InputStream in = Files.newInputStream(ref.path())) { |
| | | MessageDigest digest = MessageDigest.getInstance("SHA-256"); |
| | | byte[] buffer = new byte[8192]; |
| | | long total = 0; |
| | | int read; |
| | | while ((read = in.read(buffer)) != -1) { |
| | | total += read; |
| | | if (total > MAX_FILE_BYTES) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FILE_TOO_LARGE", "éä»¶è¶
è¿ 8 MiB"); |
| | | } |
| | | digest.update(buffer, 0, read); |
| | | } |
| | | return HexFormat.of().formatHex(digest.digest()); |
| | | } catch (IOException ex) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_UNREADABLE", "éä»¶ä¸å¯è®¿é®"); |
| | | } catch (NoSuchAlgorithmException ex) { |
| | | throw new IllegalStateException("SHA-256 ä¸å¯ç¨", ex); |
| | | } |
| | | } |
| | | |
| | | private Path resolveBlobFile(SystemStorageBlobDO blob) { |
| | | try { |
| | | Path root = Path.of(storageProperties.getPath()).toRealPath(); |
| | | Path file = root.resolve(blob.getPath() == null ? "" : blob.getPath()) |
| | | .resolve(blob.getUidFilename()).toRealPath(); |
| | | if (!file.startsWith(root) || !Files.isRegularFile(file) |
| | | || Files.size(file) > MAX_FILE_BYTES) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_UNREADABLE", "éä»¶ä¸å¯è®¿é®"); |
| | | } |
| | | return file; |
| | | } catch (IOException ex) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_UNREADABLE", "éä»¶ä¸å¯è®¿é®"); |
| | | } |
| | | } |
| | | |
| | | private void requireOwnedSession(Long sessionId) { |
| | | SystemAgentImportSessionDO session = sessionMapper.selectById(sessionId); |
| | | Long userId = SecurityFrameworkUtils.getLoginUserId(); |
| | | if (session == null || userId == null || !Objects.equals(session.getOwnerUserId(), userId) |
| | | || !"active".equals(session.getStatus())) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "SESSION_FORBIDDEN", "è¡¥å½ä¼è¯ä¸å¯è®¿é®"); |
| | | } |
| | | } |
| | | |
| | | private void requireOwnedBlob(SystemStorageBlobDO blob) { |
| | | Long userId = SecurityFrameworkUtils.getLoginUserId(); |
| | | if (blob == null || userId == null || !String.valueOf(userId).equals(blob.getCreator()) |
| | | || blob.getByteSize() == null || blob.getByteSize() > MAX_FILE_BYTES) { |
| | | throw new AgentBridgeException(HttpStatus.FORBIDDEN, "BLOB_FORBIDDEN", "æä»¶ä¸å¯è®¿é®æè¶
è¿ 8 MiB"); |
| | | } |
| | | } |
| | | } |
| | |
| | | */ |
| | | void deleteAttachments(List<Long> ids); |
| | | |
| | | |
| | | /** |
| | | * å 餿å®ç¨éãæå®ä¸å¡è®°å½ä¸çéä»¶å
³ç³» |
| | | */ |
| | |
| | | import cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum; |
| | | import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum; |
| | | import cn.iocoder.yudao.module.system.util.storage.StorageFileUtil; |
| | | import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper; |
| | | import jakarta.annotation.Resource; |
| | | import lombok.RequiredArgsConstructor; |
| | | import org.springframework.stereotype.Service; |
| | |
| | | public class SystemStorageAttachmentServiceImpl implements SystemStorageAttachmentService { |
| | | |
| | | private final StorageFileUtil fileUtil; |
| | | private final SystemStorageAttachmentMapper attachmentMapper; |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void deleteAttachments(List<Long> ids) { |
| | | assertNoAgentImportAttachments(ids); |
| | | fileUtil.deleteStorageAttachmentsByStorageAttachmentIds(ids); |
| | | } |
| | | |
| | | private void assertNoAgentImportAttachments(List<Long> ids) { |
| | | if (ids == null || ids.isEmpty()) { |
| | | return; |
| | | } |
| | | if (attachmentMapper.selectBatchIds(ids).stream() |
| | | .anyMatch(item -> "agent_import_session".equals(item.getRecordType()))) { |
| | | throw new org.springframework.web.server.ResponseStatusException( |
| | | org.springframework.http.HttpStatus.FORBIDDEN, "æºè½ä½è¡¥å½é件请使ç¨è¡¥å½ä¼è¯æ¥å£ç®¡ç"); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void deleteAttachmentsByApplicationAndRecordTypeAndRecordId(String application, String recordType, Long recordId) { |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.system.service.storage; |
| | | |
| | | import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemAgentImportSessionDO; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO; |
| | | import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemAgentImportSessionMapper; |
| | | import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper; |
| | | import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException; |
| | | import cn.iocoder.yudao.module.system.framework.storage.config.StorageProperties; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.junit.jupiter.api.io.TempDir; |
| | | import org.mockito.InjectMocks; |
| | | import org.mockito.Mock; |
| | | import org.mockito.MockedStatic; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.mockito.Mockito.*; |
| | | |
| | | @ExtendWith(MockitoExtension.class) |
| | | class SystemAgentImportBridgeServiceTest { |
| | | |
| | | @Mock private SystemAgentImportSessionMapper sessionMapper; |
| | | @Mock private SystemStorageAttachmentMapper attachmentMapper; |
| | | @Mock private SystemStorageBlobService blobService; |
| | | @Mock private SystemStorageAttachmentService attachmentService; |
| | | @Mock private StorageProperties storageProperties; |
| | | @InjectMocks private SystemAgentImportBridgeService service; |
| | | |
| | | @Test |
| | | void foreignSessionCannotReadAttachment() { |
| | | SystemStorageAttachmentDO attachment = new SystemStorageAttachmentDO(); |
| | | attachment.setRecordType("agent_import_session"); |
| | | attachment.setRecordId(21L); |
| | | attachment.setStorageBlobId(90L); |
| | | when(attachmentMapper.selectById(5L)).thenReturn(attachment); |
| | | SystemAgentImportSessionDO session = new SystemAgentImportSessionDO(); |
| | | session.setOwnerUserId(8L); |
| | | session.setStatus("active"); |
| | | when(sessionMapper.selectById(21L)).thenReturn(session); |
| | | try (MockedStatic<SecurityFrameworkUtils> auth = mockStatic(SecurityFrameworkUtils.class)) { |
| | | auth.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(7L); |
| | | assertThrows(AgentBridgeException.class, () -> service.requireOwnedAttachmentRef(5L)); |
| | | } |
| | | verifyNoInteractions(blobService); |
| | | } |
| | | |
| | | @Test |
| | | void ownSessionCannotBindForeignUpload() { |
| | | SystemAgentImportSessionDO session = new SystemAgentImportSessionDO(); |
| | | session.setOwnerUserId(7L); |
| | | session.setStatus("active"); |
| | | when(sessionMapper.selectById(21L)).thenReturn(session); |
| | | SystemStorageBlobDO blob = new SystemStorageBlobDO(); |
| | | blob.setCreator("8"); |
| | | blob.setByteSize(100L); |
| | | when(blobService.getStorageBlob(90L)).thenReturn(blob); |
| | | try (MockedStatic<SecurityFrameworkUtils> auth = mockStatic(SecurityFrameworkUtils.class)) { |
| | | auth.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(7L); |
| | | assertThrows(AgentBridgeException.class, () -> service.bindAttachment(21L, 90L)); |
| | | } |
| | | verifyNoInteractions(attachmentService); |
| | | } |
| | | |
| | | @Test |
| | | void ownerReadsBoundAttachment(@TempDir Path tempDir) throws Exception { |
| | | Path blobDir = Files.createDirectories(tempDir.resolve("2026/1022")); |
| | | byte[] content = "code,name\nA,ç©æA\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); |
| | | Files.write(blobDir.resolve("uuid-materials.csv"), content); |
| | | when(storageProperties.getPath()).thenReturn(tempDir.toString()); |
| | | |
| | | SystemStorageAttachmentDO attachment = new SystemStorageAttachmentDO(); |
| | | attachment.setRecordType("agent_import_session"); |
| | | attachment.setRecordId(21L); |
| | | attachment.setStorageBlobId(90L); |
| | | when(attachmentMapper.selectById(5L)).thenReturn(attachment); |
| | | SystemAgentImportSessionDO session = new SystemAgentImportSessionDO(); |
| | | session.setOwnerUserId(7L); |
| | | session.setStatus("active"); |
| | | when(sessionMapper.selectById(21L)).thenReturn(session); |
| | | SystemStorageBlobDO blob = new SystemStorageBlobDO(); |
| | | blob.setCreator("7"); |
| | | blob.setByteSize((long) content.length); |
| | | blob.setOriginalFilename("materials.csv"); |
| | | blob.setPath("2026/1022"); |
| | | blob.setUidFilename("uuid-materials.csv"); |
| | | when(blobService.getStorageBlob(90L)).thenReturn(blob); |
| | | try (MockedStatic<SecurityFrameworkUtils> auth = mockStatic(SecurityFrameworkUtils.class)) { |
| | | auth.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(7L); |
| | | SystemAgentAttachmentRef ref = service.requireOwnedAttachmentRef(5L); |
| | | assertEquals("materials.csv", ref.originalFilename()); |
| | | assertEquals(21L, ref.sessionId()); |
| | | assertEquals(64, service.sha256OfAttachment(5L).length()); |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | --- #################### æ°æ®åºç¸å
³é
ç½® #################### |
| | | spring: |
| | | config: |
| | | # æ¬å°æ¡¥æ¥å¯é¥æ¾å¨ä»åºæ ¹ç®å½ç Git å¿½ç¥æä»¶ä¸ï¼ä¸æè¿ Java å
ã |
| | | import: optional:file:${MOM_AGENT_JAVA_CONFIG:./application-my.yaml} |
| | | autoconfigure: |
| | | # noinspection SpringBootApplicationYaml |
| | | exclude: |
| | |
| | | primary: master |
| | | datasource: |
| | | master: |
| | | url: jdbc:mysql://localhost:3306/mom-hm?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X è¿æ¥çç¤ºä¾ |
| | | url: jdbc:mysql://localhost:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # æ¬å°èè°æµè¯åº |
| | | username: root |
| | | password: 123456 |
| | | |
| | |
| | | enable: false # æ¬å°ç¯å¢ï¼ææ¶å
³éå¾çéªè¯ç ï¼æ¹ä¾¿ç»å½çæ¥å£çæµè¯ï¼ |
| | | security: |
| | | mock-enable: true |
| | | agent: |
| | | bridge: |
| | | write-enabled: false # æºè½ä½ç©æå¯¼å
¥åå¼å
³ï¼ä»
èè°éªæ¶æ¶ä¸´æ¶æå¼ |
| | | allow-mes-sync: false |
| | | max-rows: 5000 |
| | | access-log: # è®¿é®æ¥å¿çé
置项 |
| | | enable: false |
| | | demo: false # å
³éæ¼ç¤ºæ¨¡å¼ |