From ba03f5d4dbb93dc399c379497e74251b69e9f58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E8=BD=A9?= <18726128489@163.com> Date: Tue, 4 Aug 2026 18:04:01 +0800 Subject: [PATCH] feat: add mica-smdm-scaffold proposal and rollback files; create unit testing specifications and SKILL documentation --- skill-workshop/proposals.json | 2 +- .../PROPOSAL.md | 613 ++++++++++++++++++ .../proposal.json | 1 + .../rollback.json | 1 + ...2eb8de19a15809148f907a6e836ef62d5.attested | 2 +- ...4867a7504adb2222b73b483beacc96d18.attested | 2 +- workspace-backend/AGENTS.md | 2 + workspace-backend/TOOLS.md | 5 + workspace-frontend/AGENTS.md | 2 + workspace-frontend/TOOLS.md | 5 + workspace-planner/AGENTS.md | 2 + workspace-planner/MEMORY.md | 51 ++ workspace-planner/TOOLS.md | 5 + workspace-planner/docs/单元测试规范.md | 544 ++++++++++++++++ workspace-planner/memory/2026-08-04.md | 195 +++++- .../skills/mica-smdm-scaffold/SKILL.md | 610 +++++++++++++++++ 16 files changed, 2035 insertions(+), 7 deletions(-) create mode 100644 skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/PROPOSAL.md create mode 100644 skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/proposal.json create mode 100644 skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/rollback.json create mode 100644 workspace-planner/docs/单元测试规范.md create mode 100644 workspace-planner/skills/mica-smdm-scaffold/SKILL.md diff --git a/skill-workshop/proposals.json b/skill-workshop/proposals.json index d98f9aa..5161f5a 100644 --- a/skill-workshop/proposals.json +++ b/skill-workshop/proposals.json @@ -1 +1 @@ -{"schema":"openclaw.skill-workshop.proposals-manifest.v1","updatedAt":"2026-07-09T09:56:20.877Z","proposals":[{"id":"dianping-review-20260611-d402abec10","kind":"create","status":"applied","title":"Create dianping-review","description":"通过大众点评搜索查询餐厅、老店、美食推荐和评论","skillName":"dianping-review","skillKey":"dianping-review","createdAt":"2026-06-11T23:50:15.731Z","updatedAt":"2026-07-09T09:56:20.865Z","scanState":"clean"},{"id":"calibre-cleaner-20260624-0fc374efd7","kind":"update","status":"applied","title":"Update calibre-cleaner","description":"清洗 Calibre 书库:修复损坏书名/提取作者/检测编码损坏、小文件的批量删除","skillName":"calibre-cleaner","skillKey":"calibre-cleaner","createdAt":"2026-06-24T03:23:27.062Z","updatedAt":"2026-07-09T09:56:20.404Z","scanState":"clean"},{"id":"jellyfin-nfo-builder-20260701-6434352e0d","kind":"create","status":"applied","title":"Create jellyfin-nfo-builder","description":"为剧集整理统一文件名、生成缺失的 Jellyfin .nfo 剧集元数据","skillName":"jellyfin-nfo-builder","skillKey":"jellyfin-nfo-builder","createdAt":"2026-07-01T23:52:24.406Z","updatedAt":"2026-07-09T09:56:19.928Z","scanState":"clean"},{"id":"jav-media-sorter-20260707-b1f90c8a4d","kind":"create","status":"applied","title":"Create jav-media-sorter","description":"JAV 视频整理:组装文件、按演员分组、刮削 nfo 元数据与封面图","skillName":"jav-media-sorter","skillKey":"jav-media-sorter","createdAt":"2026-07-07T12:14:43.074Z","updatedAt":"2026-07-09T09:56:18.413Z","scanState":"clean"},{"id":"f-download-sorter-20260706-902cc0d5aa","kind":"create","status":"applied","title":"Create f-download-sorter","description":"整理 /mnt/f 下下载的视频文件:扫描→清垃圾→按规则归档到7个大类目录","skillName":"f-download-sorter","skillKey":"f-download-sorter","createdAt":"2026-07-06T09:03:54.941Z","updatedAt":"2026-07-06T09:04:18.513Z","scanState":"clean"},{"id":"weekly-report-g5-20260626-7595273f1d","kind":"create","status":"applied","title":"Create weekly-report-g5","description":"维云智造G5周报生成:整理每日工作→多段邮件(纯文本+HTML含签名图)→存入钉邮草稿箱","skillName":"weekly-report-g5","skillKey":"weekly-report-g5","createdAt":"2026-06-26T00:46:22.478Z","updatedAt":"2026-06-26T00:46:35.812Z","scanState":"clean"},{"id":"calibre-cleaner-20260624-cdbeb219a3","kind":"update","status":"applied","title":"Update calibre-cleaner","description":"清洗 Calibre 书库:修复损坏书名/提取作者/检测编码损坏、小文件的批量删除","skillName":"calibre-cleaner","skillKey":"calibre-cleaner","createdAt":"2026-06-24T03:03:28.166Z","updatedAt":"2026-06-24T03:03:30.915Z","scanState":"clean"},{"id":"calibre-cleaner-20260624-a441798af9","kind":"create","status":"applied","title":"Create calibre-cleaner","description":"清洗 Calibre 书库:修复损坏书名/提取作者/检测编码损坏、小文件的批量删除","skillName":"calibre-cleaner","skillKey":"calibre-cleaner","createdAt":"2026-06-24T02:29:02.278Z","updatedAt":"2026-06-24T02:29:27.227Z","scanState":"clean"},{"id":"calibre-title-cleaner-20260624-3760b64e8b","kind":"create","status":"applied","title":"Create calibre-title-cleaner","description":"清洗 Calibre 书库中损坏的 txt 文档书名(HTML标签/长文本/乱码/路径超限修复)","skillName":"calibre-title-cleaner","skillKey":"calibre-title-cleaner","createdAt":"2026-06-24T01:10:56.296Z","updatedAt":"2026-06-24T01:13:39.670Z","scanState":"clean"}]} +{"schema":"openclaw.skill-workshop.proposals-manifest.v1","updatedAt":"2026-08-04T09:12:42.079Z","proposals":[{"id":"mica-smdm-scaffold-20260804-af318dd6c2","kind":"create","status":"applied","title":"Create mica-smdm-scaffold","description":"mica 项目 SMDM 模块骨架代码生成规范(含数据字典填充)","skillName":"mica-smdm-scaffold","skillKey":"mica-smdm-scaffold","createdAt":"2026-08-04T08:59:50.395Z","updatedAt":"2026-08-04T09:12:42.043Z","scanState":"clean"},{"id":"dianping-review-20260611-d402abec10","kind":"create","status":"applied","title":"Create dianping-review","description":"通过大众点评搜索查询餐厅、老店、美食推荐和评论","skillName":"dianping-review","skillKey":"dianping-review","createdAt":"2026-06-11T23:50:15.731Z","updatedAt":"2026-07-09T09:56:20.865Z","scanState":"clean"},{"id":"calibre-cleaner-20260624-0fc374efd7","kind":"update","status":"applied","title":"Update calibre-cleaner","description":"清洗 Calibre 书库:修复损坏书名/提取作者/检测编码损坏、小文件的批量删除","skillName":"calibre-cleaner","skillKey":"calibre-cleaner","createdAt":"2026-06-24T03:23:27.062Z","updatedAt":"2026-07-09T09:56:20.404Z","scanState":"clean"},{"id":"jellyfin-nfo-builder-20260701-6434352e0d","kind":"create","status":"applied","title":"Create jellyfin-nfo-builder","description":"为剧集整理统一文件名、生成缺失的 Jellyfin .nfo 剧集元数据","skillName":"jellyfin-nfo-builder","skillKey":"jellyfin-nfo-builder","createdAt":"2026-07-01T23:52:24.406Z","updatedAt":"2026-07-09T09:56:19.928Z","scanState":"clean"},{"id":"jav-media-sorter-20260707-b1f90c8a4d","kind":"create","status":"applied","title":"Create jav-media-sorter","description":"JAV 视频整理:组装文件、按演员分组、刮削 nfo 元数据与封面图","skillName":"jav-media-sorter","skillKey":"jav-media-sorter","createdAt":"2026-07-07T12:14:43.074Z","updatedAt":"2026-07-09T09:56:18.413Z","scanState":"clean"},{"id":"f-download-sorter-20260706-902cc0d5aa","kind":"create","status":"applied","title":"Create f-download-sorter","description":"整理 /mnt/f 下下载的视频文件:扫描→清垃圾→按规则归档到7个大类目录","skillName":"f-download-sorter","skillKey":"f-download-sorter","createdAt":"2026-07-06T09:03:54.941Z","updatedAt":"2026-07-06T09:04:18.513Z","scanState":"clean"},{"id":"weekly-report-g5-20260626-7595273f1d","kind":"create","status":"applied","title":"Create weekly-report-g5","description":"维云智造G5周报生成:整理每日工作→多段邮件(纯文本+HTML含签名图)→存入钉邮草稿箱","skillName":"weekly-report-g5","skillKey":"weekly-report-g5","createdAt":"2026-06-26T00:46:22.478Z","updatedAt":"2026-06-26T00:46:35.812Z","scanState":"clean"},{"id":"calibre-cleaner-20260624-cdbeb219a3","kind":"update","status":"applied","title":"Update calibre-cleaner","description":"清洗 Calibre 书库:修复损坏书名/提取作者/检测编码损坏、小文件的批量删除","skillName":"calibre-cleaner","skillKey":"calibre-cleaner","createdAt":"2026-06-24T03:03:28.166Z","updatedAt":"2026-06-24T03:03:30.915Z","scanState":"clean"},{"id":"calibre-cleaner-20260624-a441798af9","kind":"create","status":"applied","title":"Create calibre-cleaner","description":"清洗 Calibre 书库:修复损坏书名/提取作者/检测编码损坏、小文件的批量删除","skillName":"calibre-cleaner","skillKey":"calibre-cleaner","createdAt":"2026-06-24T02:29:02.278Z","updatedAt":"2026-06-24T02:29:27.227Z","scanState":"clean"},{"id":"calibre-title-cleaner-20260624-3760b64e8b","kind":"create","status":"applied","title":"Create calibre-title-cleaner","description":"清洗 Calibre 书库中损坏的 txt 文档书名(HTML标签/长文本/乱码/路径超限修复)","skillName":"calibre-title-cleaner","skillKey":"calibre-title-cleaner","createdAt":"2026-06-24T01:10:56.296Z","updatedAt":"2026-06-24T01:13:39.670Z","scanState":"clean"}]} diff --git a/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/PROPOSAL.md b/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/PROPOSAL.md new file mode 100644 index 0000000..ed214bc --- /dev/null +++ b/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/PROPOSAL.md @@ -0,0 +1,613 @@ +--- +name: "mica-smdm-scaffold" +description: "mica 项目 SMDM 模块骨架代码生成规范(含数据字典填充)" +status: proposal +version: "v2" +date: "2026-08-04T09:09:00.667Z" +--- + +# mica SMDM 模块骨架代码生成技能 + +## 技能定位 + +为 mica 项目的主数据管理模块 (SMDM) 生成标准化的骨架代码,包含完整的 CRUD 功能、数据字典填充、单元测试和集成测试。 + +## 适用范围 + +- mica 项目的主数据管理模块开发 +- 需要 Feign 远程调用 SMDM 服务的场景 +- 需要数据字典翻译的业务模块 + +--- + +## 🔴 强制性红线 + +### SMDM 物料管理模块开发限制 + +| # | 红线 | 违规示例 | 正确做法 | +|---|------|---------|----------| +| 1 | **表前缀必须过滤** | `DmpMdItemInfo` | `ItemInfo` | +| 2 | **API 必须放在 apis.smdm 包** | `smd.api.ItemApi` | `apis.smdm.ItemApi` | +| 3 | **查询只能用 Mapper** | 使用 MyBatis-Plus | 原生 MyBatis XML | +| 4 | **改删必须 Feign 远程调用** | 本地直接 UPDATE/DELETE | 调用 SMDM 服务 API | +| 5 | **实体必须继承 BaseDomain** | 独立定义审计字段 | `extends BaseDomain` | +| 6 | **只使用指定业务字段** | 添加表中其他字段 | 仅用规范内字段 | +| 7 | **代码必须生成到 smd 目录** | `com.witsoft.mica.item.*` | `com.witsoft.mica.smd.*` | + +### 数据字典模块开发限制 + +| # | 红线 | 正确做法 | +|---|------|---------| +| 1 | **不需要 Controller** | 纯内部工具服务 | +| 2 | **不需要 Feign API** | 不暴露外部接口 | +| 3 | **不需要缓存实现** | 后续可调用 Feign 接口缓存 | +| 4 | **不需要继承 BaseDomain** | 配置项不是业务实体 | + +--- + +## 📋 核心规范摘要 + +### 包路径规范 +- **本地业务**: `com.witsoft.mica.smd.*` +- **Feign 接口**: `com.witsoft.mica.apis.smdm.*` + +### 表前缀过滤 +- `dmp_md_` 全部过滤 (如 `dmp_md_item_info` → `ItemInfo`) + +### 查询方式 +- **分页查询**: 原生 MyBatis XML +- **详情查询**: MyBatis-Plus `selectById()` + +### 改删操作 +- Feign 远程调用 SMDM 服务 + +### 注释规范 +- `@Author: yangxuan` +- `@Date: 精确到日` + +### 日志规范 +- SLF4J + Lombok `@Slf4j` +- Feign 调用添加 debug 日志(记录入参和耗时,**禁止记录返回值**) + +### ecid 处理 +- Controller 层调用 `GlobalUtils.getEcid()` 并传递给 Service + +### 分页方式 +- 使用项目 `PageDomain` +- **不使用** `com.github.pagehelper` + +### XML 规范 +- 使用 `` + `` 片段复用方式 + +--- + +## 📁 标准文件清单 + +### 第 1 部分:数据字典模块 (基础设施) + +| 文件 | 说明 | 路径 | +|------|------|------| +| `DictionaryMapper.java` | 字典查询 Mapper 接口 | `smd/mapper/` | +| `DictionaryMapper.xml` | 原生 SQL 查询 | `resources/mapper/smd/` | +| `DictionaryItem.java` | 字典数据项 DTO | `smd/dto/` | +| `DictionaryService.java` | 字典服务接口 | `smd/service/` | +| `DictionaryServiceImpl.java` | 字典服务实现 | `smd/service/impl/` | + +### 第 2 部分:枚举类 (按需) + +| 文件 | 说明 | +|------|------| +| `StatusEnum.java` | 状态枚举 (Y=启用,N=禁用) | +| `YesNoEnum.java` | 是否枚举 (Y=是,N=否) | + +### 第 3 部分:业务模块 (10 个文件) + +| 文件 | 说明 | 路径 | +|------|------|------| +| `XxxApi.java` | Feign 远程调用接口 | `apis/smdm/` | +| `XxxInfo.java` | 实体类 | `smd/entity/` | +| `XxxMapper.java` | Mapper 接口 | `smd/mapper/` | +| `XxxMapper.xml` | MyBatis XML 映射 | `resources/mapper/smd/` | +| `XxxService.java` | 服务接口 | `smd/service/` | +| `XxxServiceImpl.java` | 服务实现 | `smd/service/impl/` | +| `XxxController.java` | 控制器 | `smd/controller/` | +| `XxxQueryDTO.java` | 查询 DTO | `smd/dto/` | +| `XxxFormDTO.java` | 表单 DTO | `smd/dto/` | +| `XxxVO.java` | 视图对象 | `smd/vo/` | + +### 第 4 部分:测试文件 (扩展模块) + +| 文件 | 说明 | 路径 | +|------|------|------| +| `XxxServiceTest.java` | Service 层单元测试 | `src/test/java/com/witsoft/mica/smd/service/` | +| `XxxControllerIntegrationTest.java` | Controller 集成测试 | `src/test/java/com/witsoft/mica/smd/controller/` | + +--- + +## 🔧 数据字典填充规范 + +### VO 设计 +- 添加 `xxxName` 字段存储字典翻译后的中文名称 +- 示例:`itemType` (en_code) + `itemTypeName` (中文名称) + +### Service 层填充 +- **批量查询字典**: 一次查询多个字典类型,避免 N+1 问题 +- **通用填充方法**: `fillDictionaryData(ItemVO itemVO, Map> dictMaps)` +- **性能要求**: 列表查询只查 1 次字典,批量填充 + +### 填充示例代码 + +```java +@Override +public PageDomain queryPageList(ItemQueryDTO dto) { + // 查询分页数据 + PageDomain page = itemMapper.queryPageList(dto); + + // 只查 1 次字典(批量查询) + Map> dictMaps = dictionaryService.getDictionaryMaps( + Arrays.asList("materialsType", "itemProperties", "pickingProperty") + ); + + // 遍历列表填充 + page.getList().forEach(item -> fillDictionaryData(item, dictMaps)); + + return page; +} + +@Override +public ItemVO queryById(String id) { + ItemVO itemVO = itemMapper.selectById(id); + + // 查 1 次字典 + Map> dictMaps = dictionaryService.getDictionaryMaps( + Arrays.asList("materialsType", "itemProperties", "pickingProperty") + ); + + fillDictionaryData(itemVO, dictMaps); + return itemVO; +} + +/** + * 填充物料字典数据 + * @param itemVO 物料 VO 对象 + * @param dictMaps 已查询的字典 Map(外部传入,避免重复查询) + */ +private void fillDictionaryData(ItemVO itemVO, Map> dictMaps) { + if (itemVO == null || dictMaps == null) return; + + // 物料类型 + Map materialsMap = dictMaps.get("materialsType"); + if (materialsMap != null) { + itemVO.setItemTypeName(materialsMap.getOrDefault(itemVO.getItemType(), itemVO.getItemType())); + } + + // 物料属性 + Map propertiesMap = dictMaps.get("itemProperties"); + if (propertiesMap != null) { + itemVO.setPropertiesName(propertiesMap.getOrDefault(itemVO.getProperties(), itemVO.getProperties())); + } + + // 领料属性 + Map pickingMap = dictMaps.get("pickingProperty"); + if (pickingMap != null) { + itemVO.setPickingPropertyName(pickingMap.getOrDefault(itemVO.getPickingProperty(), itemVO.getPickingProperty())); + } + + // 状态(枚举) + itemVO.setStatusName(StatusEnum.getNameByCode(itemVO.getStatus())); +} +``` + +--- + +## 🧪 测试生成规范 + +### 单元测试 (Service 层) + +#### 文件位置 +- `src/test/java/com/witsoft/mica/smd/service/*ServiceTest.java` + +#### 测试类结构模板 +```java +package com.witsoft.mica.smd.service; + +import com.witsoft.gen.base.common.ResponseModel; +import com.witsoft.gen.base.page.PageDomain; +import com.witsoft.mica.smd.dto.ItemQueryDTO; +import com.witsoft.mica.smd.entity.ItemInfo; +import com.witsoft.mica.smd.mapper.ItemMapper; +import com.witsoft.mica.smd.service.impl.ItemServiceImpl; +import com.witsoft.mica.apis.smdm.ItemApi; +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.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ItemServiceTest { + + @Mock + private ItemMapper itemMapper; + + @Mock + private ItemApi itemApi; + + @InjectMocks + private ItemServiceImpl itemService; + + @Test + void testQueryPageList_normalQuery() { + // 准备测试数据 + ItemQueryDTO dto = new ItemQueryDTO(); + dto.setEcid("test-ecid"); + dto.setPageNo(1); + dto.setPageSize(10); + + List mockList = new ArrayList<>(); + ItemInfo item = new ItemInfo(); + item.setId("test-id"); + item.setItemCode("TEST001"); + mockList.add(item); + + when(itemMapper.queryPageCount(any())).thenReturn(1L); + when(itemMapper.queryPageList(any())).thenReturn(mockList); + + // 执行测试 + PageDomain result = itemService.queryPageList(dto); + + // 验证结果 + assertNotNull(result); + assertEquals(1, result.getList().size()); + assertEquals(1L, result.getTotal()); + } + + @Test + void testQueryById_notFound() { + when(itemMapper.selectById("not-exist")).thenReturn(null); + + ItemVO result = itemService.queryById("not-exist"); + + assertNull(result); + } + + @Test + void testCreateItem_success() { + ItemFormDTO dto = new ItemFormDTO(); + dto.setItemCode("TEST001"); + dto.setItemName("测试物料"); + + when(itemApi.createItem(any())).thenReturn(ResponseModel.succeed(null)); + + ResponseModel result = itemService.insertItem(dto); + + assertNotNull(result); + assertEquals(200, result.getCode()); + } + + @Test + void testCreateItem_nullParam() { + ResponseModel result = itemService.insertItem(null); + + assertNotNull(result); + assertEquals(500, result.getCode()); + assertTrue(result.getMsg().contains("参数不能为空")); + } + + @Test + void testUpdateItem_success() { + ItemFormDTO dto = new ItemFormDTO(); + dto.setId("test-id"); + dto.setItemCode("TEST001"); + + when(itemApi.updateItem(any())).thenReturn(ResponseModel.succeed(null)); + + ResponseModel result = itemService.updateItem(dto); + + assertNotNull(result); + assertEquals(200, result.getCode()); + } + + @Test + void testDeleteItem_success() { + when(itemApi.deleteItem(any())).thenReturn(ResponseModel.succeed(null)); + + ResponseModel result = itemService.deleteItem("test-id"); + + assertNotNull(result); + assertEquals(200, result.getCode()); + } +} +``` + +#### 测试方法清单 +| 方法 | 测试场景 | 断言 | +|------|---------|------| +| `testQueryPageList_normalQuery` | 正常分页查询 | 返回非空,size>0 | +| `testQueryPageList_emptyResult` | 无数据 | 返回空列表 | +| `testQueryById_notFound` | ID 不存在 | 返回 null | +| `testQueryById_found` | ID 存在 | 返回非空 VO | +| `testCreateItem_success` | 创建成功 | 返回 succeed | +| `testCreateItem_nullParam` | 空参数 | 返回 failed | +| `testUpdateItem_success` | 更新成功 | 返回 succeed | +| `testUpdateItem_nullId` | 空 ID | 返回 failed | +| `testDeleteItem_success` | 删除成功 | 返回 succeed | +| `testDeleteItem_nullId` | 空 ID | 返回 failed | + +#### Mock 处理规范 +- `GlobalUtils.getEcid()` → 改为实例方法或传入参数 +- Feign API → `@Mock` + `when().thenReturn()` +- Mapper → `@Mock` + 返回测试数据 +- 使用 `ArgumentMatchers.any()` 匹配任意参数 + +--- + +### 集成测试 (Controller 层) + +#### 文件位置 +- `src/test/java/com/witsoft/mica/smd/controller/*ControllerIntegrationTest.java` + +#### 测试类结构模板 +```java +package com.witsoft.mica.smd.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.witsoft.mica.smd.dto.ItemFormDTO; +import com.witsoft.mica.smd.dto.ItemQueryDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ItemControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + // 准备测试数据(可选) + } + + @Test + void testQueryPageList_integration() throws Exception { + ItemQueryDTO dto = new ItemQueryDTO(); + dto.setPageNo(1); + dto.setPageSize(10); + + mockMvc.perform(post("/web/itemInfo/getPageList") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.list").isArray()); + } + + @Test + void testQueryById_integration() throws Exception { + mockMvc.perform(get("/web/itemInfo/getById") + .param("id", "test-id")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void testCreateItem_integration() throws Exception { + ItemFormDTO dto = new ItemFormDTO(); + dto.setItemCode("TEST001"); + dto.setItemName("测试物料"); + dto.setStatus("Y"); + + mockMvc.perform(post("/web/itemInfo/create") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void testUpdateItem_integration() throws Exception { + ItemFormDTO dto = new ItemFormDTO(); + dto.setId("test-id"); + dto.setItemCode("TEST001"); + dto.setItemName("测试物料更新"); + + mockMvc.perform(post("/web/itemInfo/update") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void testDeleteItem_integration() throws Exception { + mockMvc.perform(post("/web/itemInfo/delete") + .param("id", "test-id")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void testAuth_ecidRequired() throws Exception { + // 测试没有 ecid 时的权限验证(如果配置了拦截器) + ItemQueryDTO dto = new ItemQueryDTO(); + + mockMvc.perform(post("/web/itemInfo/getPageList") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()); + // 根据实际权限配置调整断言 + } +} +``` + +#### 测试场景清单 +| 方法 | 测试场景 | 验证点 | +|------|---------|--------| +| `testQueryPageList_integration` | 完整查询流程 | HTTP 200 + 返回结构 | +| `testQueryById_integration` | 详情查询 | HTTP 200 + 数据存在 | +| `testCreateItem_integration` | 创建流程 | HTTP 200 + 数据入库 | +| `testUpdateItem_integration` | 更新流程 | HTTP 200 + 数据更新 | +| `testDeleteItem_integration` | 删除流程 | HTTP 200 + 数据删除 | +| `testAuth_ecidRequired` | 权限验证 | 无 ecid 时拒绝 | + +#### 测试数据准备 +- `@BeforeEach` 插入测试数据(可选) +- `@AfterEach` 清理测试数据(可选) +- 使用 `@Transactional` 自动回滚(推荐) + +--- + +### 测试生成触发条件 + +| 场景 | 单元测试 | 集成测试 | +|------|---------|---------| +| 新增业务模块 | ✅ 必选 | ✅ 推荐 | +| 新增字典模块 | ✅ 必选 | ⏸️ 可选 | +| 修改核心逻辑 | ✅ 必选 | ⏸️ 可选 | +| 修复 Bug | ✅ 添加回归测试 | ⏸️ 可选 | +| 性能优化 | ⏸️ 可选 | ✅ 必选 | + +--- + +## 🐛 常见问题修正清单 + +| # | 问题 | 修正方案 | +|---|------|----------| +| 1 | ResponseModel 静态引用 | `ResponseModel.succeed(data)` | +| 2 | 分页 XML / 详情 MP 混合使用 | 分页用 XML,详情用 MP | +| 3 | 编码查询不需要 | 移除编码查询条件 | +| 4 | Feign 调用缺少日志 | 添加入参和耗时日志(debug 级别) | +| 5 | Controller 层 ecid 处理 | Controller 层获取并传递 | +| 6 | 分页 XML 查询被删除 | 恢复分页 XML 查询 | +| 7 | 使用 PageHelper | 改用 PageDomain | +| 8 | queryByCode 方法不需要 | 删除 | +| 9 | ResponseModel 泛型参数化 | `ResponseModel` | +| 10 | Map 类型转换警告 | 添加 `@SuppressWarnings("unchecked")` | +| 11 | XML 不方便联查 | 使用 `` + `` 片段 | +| 12 | ItemApi ResponseModel 静态引用 | 泛型参数化 | +| 13 | ResponseModel 编译报错 | 使用 `ResponseModel` 或无参 | +| 14 | 日志记录返回值太大 | 只记录耗时,不记录返回值 | +| 15 | JsonUtils 方法不存在 | 使用 `JSON.toJSONString()` (fastjson) | +| 16 | fillDictionaryData 重复查询 | 接收 dictMaps 参数,避免 N+1 问题 | +| 17 | 单元测试中文字符命名 | 改为英文命名 | +| 18 | GlobalUtils 无法 Mock | 改为实例方法或传入参数 | + +--- + +## 📊 性能优化要点 + +| 优化点 | 说明 | 效果 | +|--------|------|------| +| **批量查询字典** | 一次查询多个字典类型 | 避免多次数据库查询 | +| **字典 Map 传入** | `fillDictionaryData()` 接收外部传入的 dictMaps | 避免重复查询 | +| **列表查询优化** | 100 条数据从 101 次查询降低到 2 次 | 性能提升 50 倍 | +| **枚举静态方法** | 枚举翻译使用静态方法 | 无运行时开销 | + +--- + +## 🚀 使用流程 + +### 1. 需求分析阶段 +- 确认业务模块的表结构 +- 确认需要 Feign 远程调用还是本地 CRUD +- 确认涉及的数据字典类型 + +### 2. 代码生成阶段 +- 生成数据字典模块 (5 个文件) +- 生成枚举类 (按需) +- 生成业务模块 (10 个文件) +- 生成单元测试 (按需) +- 生成集成测试 (按需) + +### 3. 修正阶段 +- 对照 18 个常见问题修正清单 +- 运行 Maven 编译验证 +- 运行单元测试 + +### 4. 验证阶段 +- 编译通过 +- 单元测试通过 +- 集成测试通过(可选) + +--- + +## 📝 输出物 + +1. 完整的骨架代码(10+5+2 个文件) +2. 单元测试文件(按需) +3. 集成测试文件(按需) +4. 编译验证报告 +5. 测试通过报告 + +--- + +## 🔗 相关资源 + +- 数据库连接:`mysql -h 47.99.209.185 -P 50036 -u witsoftd -p mica` +- 项目路径:`/root/projects/wit/` +- 规范文档:`/root/projects/wit/mica-doc/主数据骨架代码.md` +- 规范文档:`/root/projects/wit/mica-doc/数据字典模块.md` + +--- + +## 🔄 技能更新机制 + +### 如何丰富这个技能 + +1. **发现新需求/问题** + - 开发过程中遇到新问题 + - 用户提出新需求 + - 最佳实践总结 + +2. **添加到技能的对应扩展模块** + - 新增测试类型 → 添加到"测试生成规范" + - 新增文件类型 → 添加到"标准文件清单" + - 新问题 → 添加到"常见问题修正清单" + +3. **更新技能提案** + ```bash + skill_workshop action=revise name=mica-smdm-scaffold \ + proposal_content="[完整技能内容]" + ``` + +4. **应用新版本** + ```bash + skill_workshop action=apply proposal_id=mica-smdm-scaffold-xxxxx + ``` + +5. **后续开发自动使用新版本** + +### 可扩展模块示例 + +| 模块 | 说明 | 状态 | +|------|------|------| +| 单元测试生成 | Service 层测试模板 | ✅ 已实现 | +| 集成测试生成 | Controller 层测试模板 | ✅ 已实现 | +| API 文档生成 | Swagger 注解规范 | ⏸️ 待添加 | +| 前端代码生成 | Vue3 + TypeScript 模板 | ⏸️ 待添加 | +| 数据迁移脚本 | Flyway 迁移脚本 | ⏸️ 待添加 | +| 性能测试 | JMeter 测试脚本 | ⏸️ 待添加 | +| 部署脚本 | Docker/K8s 配置 | ⏸️ 待添加 | diff --git a/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/proposal.json b/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/proposal.json new file mode 100644 index 0000000..64b87b4 --- /dev/null +++ b/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/proposal.json @@ -0,0 +1 @@ +{"schema":"openclaw.skill-workshop.proposal.v1","id":"mica-smdm-scaffold-20260804-af318dd6c2","kind":"create","status":"applied","title":"Create mica-smdm-scaffold","description":"mica 项目 SMDM 模块骨架代码生成规范(含数据字典填充)","createdAt":"2026-08-04T08:59:50.395Z","updatedAt":"2026-08-04T09:12:42.043Z","createdBy":"skill-workshop","origin":{"agentId":"planner","sessionKey":"agent:planner:main","runId":"16c1345e-77e6-475b-8d0f-e25f2f5c99f1","messageId":"16c1345e-77e6-475b-8d0f-e25f2f5c99f1"},"proposedVersion":"v2","draftFile":"PROPOSAL.md","draftHash":"63dba30b3768a646bc9fd7dc3484738f7bf6cfdb80c3a58e9ebcde04a42c07e3","target":{"skillName":"mica-smdm-scaffold","skillKey":"mica-smdm-scaffold","skillDir":"/root/.openclaw/workspace-planner/skills/mica-smdm-scaffold","skillFile":"/root/.openclaw/workspace-planner/skills/mica-smdm-scaffold/SKILL.md","source":"openclaw-workspace"},"scan":{"state":"clean","scannedAt":"2026-08-04T09:12:42.015Z","critical":0,"warn":0,"info":0,"findings":[]},"appliedAt":"2026-08-04T09:12:42.043Z"} diff --git a/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/rollback.json b/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/rollback.json new file mode 100644 index 0000000..e3f49c9 --- /dev/null +++ b/skill-workshop/proposals/mica-smdm-scaffold-20260804-af318dd6c2/rollback.json @@ -0,0 +1 @@ +{"schema":"openclaw.skill-workshop.rollback.v1","proposalId":"mica-smdm-scaffold-20260804-af318dd6c2","writtenAt":"2026-08-04T09:12:42.018Z","targetSkillFile":"/root/.openclaw/workspace-planner/skills/mica-smdm-scaffold/SKILL.md","action":"create"} diff --git a/workspace-attestations/0c7b240386c2ff0dc0802cc3e9ce7542eb8de19a15809148f907a6e836ef62d5.attested b/workspace-attestations/0c7b240386c2ff0dc0802cc3e9ce7542eb8de19a15809148f907a6e836ef62d5.attested index 317cc7d..165e872 100644 --- a/workspace-attestations/0c7b240386c2ff0dc0802cc3e9ce7542eb8de19a15809148f907a6e836ef62d5.attested +++ b/workspace-attestations/0c7b240386c2ff0dc0802cc3e9ce7542eb8de19a15809148f907a6e836ef62d5.attested @@ -1,2 +1,2 @@ openclaw-workspace-attestation:v1 -2026-08-04T06:55:13.181Z +2026-08-04T09:52:44.904Z diff --git a/workspace-attestations/7998712e71dadc13b041354cd823ffc4867a7504adb2222b73b483beacc96d18.attested b/workspace-attestations/7998712e71dadc13b041354cd823ffc4867a7504adb2222b73b483beacc96d18.attested index 7c16162..e9f26d6 100644 --- a/workspace-attestations/7998712e71dadc13b041354cd823ffc4867a7504adb2222b73b483beacc96d18.attested +++ b/workspace-attestations/7998712e71dadc13b041354cd823ffc4867a7504adb2222b73b483beacc96d18.attested @@ -1,2 +1,2 @@ openclaw-workspace-attestation:v1 -2026-08-04T06:54:25.728Z +2026-08-04T09:53:35.716Z diff --git a/workspace-backend/AGENTS.md b/workspace-backend/AGENTS.md index 3702d39..b73fd7c 100644 --- a/workspace-backend/AGENTS.md +++ b/workspace-backend/AGENTS.md @@ -51,6 +51,8 @@ - 不要在没有询问的情况下运行破坏性命令。 - `trash` 优于 `rm`(可恢复总是好过永久消失) - 有疑问时,请询问。 +- **禁止擅自提交项目代码到 git** - 必须先询问用户确认后再提交 +- **禁止 git push** - 只能 commit,push 操作必须由用户手动执行 ## 外部 vs 内部 diff --git a/workspace-backend/TOOLS.md b/workspace-backend/TOOLS.md index 6ef8539..cad4e50 100644 --- a/workspace-backend/TOOLS.md +++ b/workspace-backend/TOOLS.md @@ -24,6 +24,11 @@ - `wit` → `/root/projects/wit`(前端 mica-web / 后端 mica-server / 文档 mica-doc) +### 🔴 mica 项目 git 操作红线 + +- **禁止 git push** - 只能 commit,push 操作必须由用户手动执行 +- **禁止擅自提交项目代码到 git** - 必须先询问用户确认后再提交 + ## mica 项目数据库配置 **连接信息:** diff --git a/workspace-frontend/AGENTS.md b/workspace-frontend/AGENTS.md index 4180388..4892a35 100644 --- a/workspace-frontend/AGENTS.md +++ b/workspace-frontend/AGENTS.md @@ -51,6 +51,8 @@ - 不要在没有询问的情况下运行破坏性命令。 - `trash` 优于 `rm`(可恢复总是好过永久消失) - 有疑问时,请询问。 +- **禁止擅自提交项目代码到 git** - 必须先询问用户确认后再提交 +- **禁止 git push** - 只能 commit,push 操作必须由用户手动执行 ## 外部 vs 内部 diff --git a/workspace-frontend/TOOLS.md b/workspace-frontend/TOOLS.md index e0bb020..2900363 100644 --- a/workspace-frontend/TOOLS.md +++ b/workspace-frontend/TOOLS.md @@ -39,6 +39,11 @@ 添加任何能帮助你完成工作内容。这是你的备忘单。 +### 🔴 mica 项目 git 操作红线 + +- **禁止 git push** - 只能 commit,push 操作必须由用户手动执行 +- **禁止擅自提交项目代码到 git** - 必须先询问用户确认后再提交 + ### 前端项目 - **erp**(erp-app)→ `/mnt/d/Projects/HBuilderProjects/erp-app/` diff --git a/workspace-planner/AGENTS.md b/workspace-planner/AGENTS.md index 070f714..e1a60a3 100644 --- a/workspace-planner/AGENTS.md +++ b/workspace-planner/AGENTS.md @@ -63,6 +63,8 @@ - `trash` > `rm`(可恢复优于永久删除) - 有疑问时,先询问。 - **禁止擅自提交项目代码到 git** - 必须先询问用户确认后再提交 +- **禁止 git push** - 只能 commit,push 操作必须由用户手动执行 +- **planner 禁止生成代码** - planner 仅输出技术方案文档,代码生成由 backend/frontend agent 执行 --- diff --git a/workspace-planner/MEMORY.md b/workspace-planner/MEMORY.md index 7685a30..fc7ab37 100644 --- a/workspace-planner/MEMORY.md +++ b/workspace-planner/MEMORY.md @@ -83,3 +83,54 @@ 12. ItemApi ResponseModel 泛型 --- + +### 数据字典模块 - 开发规范 (2026-08-04) + +**📋 定位**: 配置项/工具类,不是业务实体 + +**🔴 强制性红线**: + +| # | 红线 | 正确做法 | +|---|------|---------| +| 1 | **不需要 Controller** | 纯内部工具服务 | +| 2 | **不需要 Feign API** | 不暴露外部接口 | +| 3 | **不需要缓存实现** | 后续可调用 Feign 接口缓存 | +| 4 | **不需要继承 BaseDomain** | 配置项不是业务实体 | + +**📋 核心规范**: + +- **包路径**: `com.witsoft.mica.smd.*` +- **功能**: 通过 `dictionary_type.en_code` 查询 `dictionary_data` 的 `en_code → full_name` 映射 +- **输出格式**: `Map` (en_code → full_name) +- **重复处理**: 同一 typeCode 下 en_code 重复时取第一条 (按 sort_code + id 排序) +- **多租户支持**: 自动获取 ecid 过滤 +- **软删除过滤**: delete_mark = 0 + +**📁 已生成文件** (5 个): +- `smd/mapper/DictionaryMapper.java` - 字典查询 Mapper 接口 +- `smd/mapper/DictionaryMapper.xml` - 原生 SQL 查询 (JOIN type + data) +- `smd/dto/DictionaryItem.java` - 字典数据项 DTO +- `smd/service/DictionaryService.java` - 字典服务接口 +- `smd/service/impl/DictionaryServiceImpl.java` - 字典服务实现 + +**📁 已生成枚举类** (2 个): +- `smd/enums/StatusEnum.java` - 状态枚举 (Y=启用,N=禁用) +- `smd/enums/YesNoEnum.java` - 是否枚举 (Y=是,N=否) + +**🔧 字典填充规范**: + +- **VO 设计**: 添加 xxxName 字段存储字典翻译后的中文名称 +- **Service 层填充**: 批量查询字典,避免 N+1 问题 +- **通用方法**: `fillDictionaryData(ItemVO itemVO, Map> dictMaps)` +- **性能要求**: 列表查询只查 1 次字典,批量填充 + +**📊 物料信息字典映射**: + +| VO 字段 | 字典类型 | 说明 | +|--------|---------|------| +| `itemTypeName` | `materialsType` | 物料类型名称 | +| `propertiesName` | `itemProperties` | 物料属性名称 | +| `pickingPropertyName` | `pickingProperty` | 领料属性名称 | +| `statusName` | `StatusEnum` | 状态名称 (枚举) | + +--- diff --git a/workspace-planner/TOOLS.md b/workspace-planner/TOOLS.md index 12991fe..a274aed 100644 --- a/workspace-planner/TOOLS.md +++ b/workspace-planner/TOOLS.md @@ -48,6 +48,11 @@ Add whatever helps you do your job. This is your cheat sheet. - **类型:** MySQL - **连接字符串:** `mysql://witsoftd:***@47.99.209.185:50036/mica` +### 🔴 mica 项目 git 操作红线 + +- **禁止 git push** - 只能 commit,push 操作必须由用户手动执行 +- **禁止擅自提交项目代码到 git** - 必须先询问用户确认后再提交 + ### sql-toolkit 技能使用 **技能位置:** `/root/.openclaw/workspace-backend/skills/sql-toolkit/SKILL.md` diff --git a/workspace-planner/docs/单元测试规范.md b/workspace-planner/docs/单元测试规范.md new file mode 100644 index 0000000..8c2418d --- /dev/null +++ b/workspace-planner/docs/单元测试规范.md @@ -0,0 +1,544 @@ +# mica 项目单元测试规范 + +## 1. 测试框架与依赖 + +### 1.1 核心依赖 + +```xml + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.mockito + mockito-inline + 4.6.1 + test + +``` + +### 1.2 测试目录结构 + +``` +src/test/ +├── java/ +│ └── com/witsoft/mica/ +│ ├── smd/ +│ │ ├── controller/ +│ │ │ └── ItemControllerTest.java +│ │ ├── service/ +│ │ │ └── ItemServiceTest.java +│ │ └── mapper/ +│ │ └── ItemMapperTest.java +│ └── ... +└── resources/ + └── application-test.yml +``` + +--- + +## 2. Controller 层测试规范 + +### 2.1 测试类结构 + +```java +package com.witsoft.mica.smd.controller; + +import com.witsoft.gen.base.common.ResponseModel; +import com.witsoft.gen.base.page.PageDomain; +import com.witsoft.mica.smd.dto.ItemQueryDTO; +import com.witsoft.mica.smd.service.ItemService; +import com.witsoft.mica.smd.vo.ItemVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("物料信息管理 Controller 测试") +class ItemControllerTest { + + @Mock + private ItemService itemService; + + @InjectMocks + private ItemController itemController; + + private ItemQueryDTO queryDTO; + private PageDomain pageDomain; + + @BeforeEach + void setUp() { + // 准备测试数据 + queryDTO = new ItemQueryDTO(); + queryDTO.setPageNo(1); + queryDTO.setPageSize(10); + queryDTO.setEcid("test-ecid-001"); + + pageDomain = new PageDomain<>(1, 10, 1); + List list = new ArrayList<>(); + ItemVO itemVO = new ItemVO(); + itemVO.setId("1"); + itemVO.setItemCode("ITEM001"); + list.add(itemVO); + pageDomain.setList(list); + } + + @Test + @DisplayName("分页查询 - 成功") + void testQueryPageListSuccess() { + // Arrange + when(itemService.queryPageList(any(ItemQueryDTO.class))).thenReturn(pageDomain); + + // Act + ResponseModel> response = itemController.queryPageList(queryDTO); + + // Assert + assertNotNull(response); + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(1, response.getData().getTotal()); + + verify(itemService, times(1)).queryPageList(any(ItemQueryDTO.class)); + } +} +``` + +### 2.2 测试要点 + +#### ✅ 必须测试的场景 + +1. **成功场景** - 正常业务流程 +2. **参数校验** - 空值、非法值处理 +3. **异常处理** - Service 层异常捕获 +4. **边界条件** - 空列表、null 值 + +#### ⚠️ 项目特殊挑战 + +由于项目使用了 `GlobalUtils.getEcid()` 和 `ResponseModel.succeed()` 等依赖 Spring 上下文的静态方法,纯单元测试会遇到困难。 + +**解决方案:** + +**方案 A:集成测试(推荐)** +```java +@SpringBootTest +@AutoConfigureMockMvc +class ItemControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void testQueryPageList() throws Exception { + mockMvc.perform(post("/web/itemInfo/getPageList") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"pageNo\":1,\"pageSize\":10}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } +} +``` + +**方案 B:Service 层单元测试(优先)** +- Controller 层逻辑简单,主要通过集成测试覆盖 +- Service 层使用 Mockito 进行纯单元测试 + +--- + +## 3. Service 层测试规范 + +### 3.1 测试类结构 + +```java +package com.witsoft.mica.smd.service; + +import com.witsoft.gen.base.page.PageDomain; +import com.witsoft.mica.smd.dto.ItemQueryDTO; +import com.witsoft.mica.smd.mapper.ItemMapper; +import com.witsoft.mica.smd.vo.ItemVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("物料信息服务测试") +class ItemServiceTest { + + @Mock + private ItemMapper itemMapper; + + @InjectMocks + private ItemServiceImpl itemService; + + private ItemQueryDTO queryDTO; + private List mockList; + + @BeforeEach + void setUp() { + queryDTO = new ItemQueryDTO(); + queryDTO.setPageNo(1); + queryDTO.setPageSize(10); + + mockList = new ArrayList<>(); + ItemVO item = new ItemVO(); + item.setId("1"); + item.setItemCode("ITEM001"); + mockList.add(item); + } + + @Test + @DisplayName("分页查询 - 成功") + void testQueryPageListSuccess() { + // Arrange + when(itemMapper.queryPageCount(any(ItemQueryDTO.class))).thenReturn(1L); + when(itemMapper.queryPageList(any(ItemQueryDTO.class))).thenReturn(mockList); + + // Act + PageDomain result = itemService.queryPageList(queryDTO); + + // Assert + assertNotNull(result); + assertEquals(1, result.getTotal()); + assertEquals(1, result.getList().size()); + assertEquals("ITEM001", result.getList().get(0).getItemCode()); + + verify(itemMapper, times(1)).queryPageCount(any(ItemQueryDTO.class)); + verify(itemMapper, times(1)).queryPageList(any(ItemQueryDTO.class)); + } + + @Test + @DisplayName("分页查询 - 无数据") + void testQueryPageListEmpty() { + // Arrange + when(itemMapper.queryPageCount(any(ItemQueryDTO.class))).thenReturn(0L); + when(itemMapper.queryPageList(any(ItemQueryDTO.class))).thenReturn(new ArrayList<>()); + + // Act + PageDomain result = itemService.queryPageList(queryDTO); + + // Assert + assertNotNull(result); + assertEquals(0, result.getTotal()); + assertTrue(result.getList().isEmpty()); + } + + @Test + @DisplayName("详情查询 - 成功") + void testQueryByIdSuccess() { + // Arrange + String testId = "123"; + ItemVO mockItem = new ItemVO(); + mockItem.setId(testId); + mockItem.setItemCode("ITEM001"); + + when(itemMapper.selectById(testId)).thenReturn(mockItem); + + // Act + ItemVO result = itemService.queryById(testId); + + // Assert + assertNotNull(result); + assertEquals(testId, result.getId()); + assertEquals("ITEM001", result.getItemCode()); + } + + @Test + @DisplayName("详情查询 - 不存在") + void testQueryByIdNotFound() { + // Arrange + when(itemMapper.selectById("not-exist")).thenReturn(null); + + // Act + ItemVO result = itemService.queryById("not-exist"); + + // Assert + assertNull(result); + } +} +``` + +### 3.2 Feign 调用测试 + +```java +@Test +@DisplayName("新增物料 - Feign 调用成功") +void testInsertItemSuccess() { + // Arrange + ItemFormDTO dto = new ItemFormDTO(); + dto.setItemCode("ITEM001"); + dto.setItemName("测试物料"); + + ResponseModel mockResponse = ResponseModel.succeed(null); + when(itemApi.insertItem(any(Map.class))).thenReturn(mockResponse); + + // Act + ResponseModel result = itemService.insertItem(dto); + + // Assert + assertNotNull(result); + assertTrue(result.isSuccess()); + + verify(itemApi, times(1)).insertItem(any(Map.class)); +} +``` + +--- + +## 4. Mapper 层测试规范 + +### 4.1 使用 MyBatis Spring Boot Test + +```xml + + org.mybatis.spring.boot + mybatis-spring-boot-starter-test + 2.3.1 + test + +``` + +### 4.2 测试类结构 + +```java +package com.witsoft.mica.smd.mapper; + +import com.witsoft.mica.smd.dto.ItemQueryDTO; +import com.witsoft.mica.smd.vo.ItemVO; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class ItemMapperTest { + + @Autowired + private ItemMapper itemMapper; + + @Test + void testQueryPageList() { + // Arrange + ItemQueryDTO dto = new ItemQueryDTO(); + dto.setPageNo(1); + dto.setPageSize(10); + dto.setEcid("test-ecid"); + + // Act + List list = itemMapper.queryPageList(dto); + long total = itemMapper.queryPageCount(dto); + + // Assert + assertNotNull(list); + assertTrue(total >= 0); + } + + @Test + void testSelectById() { + // Act + ItemVO item = itemMapper.selectById("1"); + + // Assert + assertNotNull(item); + assertNotNull(item.getItemCode()); + } +} +``` + +--- + +## 5. 测试命名规范 + +### 5.1 测试类命名 + +``` +{被测试类名}Test.java +示例:ItemControllerTest.java, ItemServiceTest.java +``` + +### 5.2 测试方法命名 + +``` +test{方法名}{场景}.java +示例: +- testQueryPageListSuccess() +- testQueryPageListWithEmptyParams() +- testQueryPageListServiceException() +- testInsertItemValidationFailed() +``` + +### 5.3 DisplayName 注解 + +```java +@DisplayName("分页查询 - 成功") +@DisplayName("分页查询 - 参数为空时返回空结果") +@DisplayName("分页查询 - 服务异常处理") +@DisplayName("新增物料 - 参数校验失败") +``` + +--- + +## 6. 断言规范 + +### 6.1 常用断言 + +```java +// 非空断言 +assertNotNull(result); +assertNull(result); + +// 布尔值断言 +assertTrue(response.isSuccess()); +assertFalse(response.isSuccess()); + +// 相等断言 +assertEquals(1, result.getTotal()); +assertEquals("ITEM001", result.getItemCode()); + +// 集合断言 +assertTrue(list.isEmpty()); +assertEquals(1, list.size()); + +// 异常断言 +assertThrows(Exception.class, () -> { + itemController.deleteItem(""); +}); +``` + +### 6.2 Mockito 验证 + +```java +// 验证方法调用次数 +verify(itemService, times(1)).queryPageList(any()); +verify(itemService, never()).deleteItem(any()); + +// 验证调用参数 +verify(itemService).insertItem(argThat(dto -> + dto.getItemCode() != null && + dto.getEcid() != null +)); +``` + +--- + +## 7. 测试覆盖率要求 + +| 层级 | 覆盖率要求 | 优先级 | +|------|-----------|--------| +| Service | ≥ 80% | 高 | +| Controller | ≥ 70% | 中 | +| Mapper | ≥ 60% | 中 | +| Entity/DTO | 不要求 | 低 | + +--- + +## 8. 测试执行命令 + +```bash +# 运行所有测试 +mvn test + +# 运行指定测试类 +mvn test -Dtest=ItemControllerTest + +# 运行指定测试方法 +mvn test -Dtest=ItemControllerTest#testQueryPageListSuccess + +# 生成覆盖率报告 +mvn clean test jacoco:report +``` + +--- + +## 9. 最佳实践 + +### ✅ DO + +1. **测试独立** - 每个测试方法相互独立,不依赖执行顺序 +2. **命名清晰** - 测试方法名清晰表达测试意图 +3. **Arrange-Act-Assert** - 遵循 AAA 模式组织测试代码 +4. **测试边界** - 重点测试边界条件和异常情况 +5. **Mock 外部依赖** - 使用 Mockito 隔离外部依赖 + +### ❌ DON'T + +1. **不要测试私有方法** - 通过公共方法间接测试 +2. **不要过度测试** - 聚焦业务逻辑,不测试 getter/setter +3. **不要依赖外部状态** - 每个测试自包含,不依赖数据库真实数据 +4. **不要忽略失败** - 测试失败必须修复或合理解释 + +--- + +## 10. 示例:完整测试类 + +参考 `ItemControllerTest.java` 和 `ItemServiceTest.java`。 + +--- + +## 11. 持续集成 + +### Jenkins Pipeline 配置 + +```groovy +pipeline { + agent any + stages { + stage('Test') { + steps { + sh 'mvn clean test' + } + post { + always { + junit 'target/surefire-reports/*.xml' + } + } + } + stage('Coverage') { + steps { + sh 'mvn jacoco:report' + } + post { + always { + jacoco execPattern: 'target/jacoco.exec' + } + } + } + } +} +``` + +--- + +## 12. 参考资料 + +- [JUnit 5 官方文档](https://junit.org/junit5/docs/current/user-guide/) +- [Mockito 官方文档](https://site.mockito.org/) +- [Spring Boot Testing](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing) +- [MyBatis Spring Boot Test](https://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/) diff --git a/workspace-planner/memory/2026-08-04.md b/workspace-planner/memory/2026-08-04.md index de28be6..f892f8a 100644 --- a/workspace-planner/memory/2026-08-04.md +++ b/workspace-planner/memory/2026-08-04.md @@ -149,7 +149,6 @@ public interface WorkshopApi { - 请求入口:`log.info("物料查询请求:itemCode={}", dto.getItemCode())` - 异常捕获:`log.error("物料查询失败", e)` - 关键业务节点:`log.info("物料创建成功:itemCode={}", result.getItemCode())` -- Feign 调用:`log.debug("Feign 调用 - 创建物料,入参:{}", JsonUtils.toJson(dto))` **示例**: ```java @@ -169,7 +168,31 @@ public class ItemController { } ``` -### 4. 字段注释 +### 4. Feign 调用日志规范 + +**正确示例**: +```java +@Override +public ResponseModel insertItem(ItemFormDTO dto) { + log.debug("Feign 调用 - 创建物料,入参:{}", JSON.toJSONString(dto)); + long start = System.currentTimeMillis(); + + Map itemMap = Convert.convert(Map.class, dto); + ResponseModel response = itemApi.insertItem(itemMap); + + log.debug("Feign 调用 - 创建物料完成,耗时:{}ms", + System.currentTimeMillis() - start); + return response; +} +``` + +**规范**: +- 日志级别:`debug` +- 记录内容:入参 + 耗时 +- **禁止记录返回值**(避免日志过大) +- 使用 `JSON.toJSONString()` (fastjson) 序列化对象 + +### 5. 字段注释 单行注释即可: @@ -183,7 +206,7 @@ private String itemName; --- -## 🐛 已修正的问题清单 (1-12) +## 🐛 已修正的问题清单 (1-15) 以下问题在开发过程中发现并已修正,**后续开发必须遵守**: @@ -192,7 +215,7 @@ private String itemName; | 1 | **ResponseModel 静态引用错误** | 使用 `ResponseModel.succeed(data)` 静态方法 | `ItemController.java`, `ItemServiceImpl.java` | | 2 | **分页 XML / 详情 MP 混合使用** | 分页查询用原生 XML,详情查询用 `BaseMapper.selectById` | `ItemMapper.xml`, `ItemServiceImpl.java` | | 3 | **移除编码查询条件** | 从 DTO 和 XML 移除 `itemCode` 模糊搜索 | `ItemQueryDTO.java`, `ItemMapper.xml` | -| 4 | **Feign 调用添加日志** | debug 级别:入参 + 耗时 + 返回 | `ItemServiceImpl.java` | +| 4 | **Feign 调用添加日志** | debug 级别:入参 + 耗时 | `ItemServiceImpl.java` | | 5 | **Controller 层 ecid 处理** | `GlobalUtils.getEcid()` 在 Controller 层获取并传递给 Service | `ItemController.java` | | 6 | **恢复分页 XML 查询** | 保留完整的分页查询 SQL | `ItemMapper.xml` | | 7 | **使用 PageDomain (非 PageHelper)** | 手动分页:先 count 查询总数,再 LIMIT 查询数据 | `ItemServiceImpl.java` | @@ -201,6 +224,9 @@ private String itemName; | 10 | **Map 类型转换警告** | 使用 `Convert.convert(Map.class, dto)` 或 `@SuppressWarnings` | `ItemController.java`, `ItemServiceImpl.java` | | 11 | **XML 使用 `` + `` 片段** | 公共列定义和查询条件使用 SQL 片段复用 | `ItemMapper.xml` | | 12 | **ItemApi ResponseModel 泛型** | Feign 接口返回 `ResponseModel` | `ItemApi.java` | +| 13 | **ResponseModel 使用裸类型** | 与项目其他 Feign 接口一致,使用裸类型 `ResponseModel` | `ItemApi.java`, `ItemService.java`, `ItemServiceImpl.java`, `ItemController.java` | +| 14 | **日志禁止记录返回值** | 只记录耗时,不记录返回值(避免日志过大) | `ItemServiceImpl.java` | +| 15 | **JsonUtils.toJson() 方法不存在** | 使用 `JSON.toJSONString()` (fastjson) 替代 | `ItemServiceImpl.java` | --- @@ -240,6 +266,32 @@ ItemInfo item = itemMapper.selectById(id); --- +### 问题 4 详解:Feign 调用日志 + +**正确示例**: +```java +@Override +public ResponseModel insertItem(ItemFormDTO dto) { + log.debug("Feign 调用 - 创建物料,入参:{}", JSON.toJSONString(dto)); + long start = System.currentTimeMillis(); + + Map itemMap = Convert.convert(Map.class, dto); + ResponseModel response = itemApi.insertItem(itemMap); + + log.debug("Feign 调用 - 创建物料完成,耗时:{}ms", + System.currentTimeMillis() - start); + return response; +} +``` + +**规范**: +- 日志级别:`debug` +- 记录内容:入参 + 耗时 +- **禁止记录返回值**(避免日志过大) +- 使用 `JSON.toJSONString()` (fastjson) 序列化对象 + +--- + ### 问题 5 详解:Controller 层 ecid 处理 **Controller**: @@ -345,6 +397,53 @@ public PageDomain queryPageList(ItemQueryDTO dto) { --- +### 问题 13 详解:ResponseModel 裸类型 + +**错误**: +```java +public ResponseModel> insertItem(ItemFormDTO dto) { // ❌ 泛型参数 + // ... +} +``` + +**正确** (与项目其他 Feign 接口一致): +```java +public ResponseModel insertItem(ItemFormDTO dto) { // ✅ 裸类型 + // ... +} +``` + +**参考项目其他 Feign 接口**: +```java +// WorkshopApi.java +ResponseModel queryWorkshopPageList(@RequestBody Map workshop); +ResponseModel insertWorkshop(@RequestBody Map workshop); + +// FactoryApi.java +ResponseModel getWorkShops(@RequestParam("ecid") String ecid); +ResponseModel getProdLineList(@RequestParam String workshopCode, @RequestParam String ecid); +``` + +--- + +### 问题 15 详解:JsonUtils 替代方案 + +**错误**: +```java +log.debug("入参:{}", JsonUtils.toJson(dto)); // ❌ JsonUtils.toJson() 方法不存在 +``` + +**正确** (使用 fastjson): +```java +import com.alibaba.fastjson.JSON; + +log.debug("入参:{}", JSON.toJSONString(dto)); // ✅ 使用 fastjson +``` + +**原因**: `common-utils` 依赖中的 `JsonUtils` 类没有 `toJson()` 方法,项目已引入 `fastjson` 依赖。 + +--- + ## 📁 后端完整文件清单 (10 个) | # | 文件 | 路径 | 状态 | @@ -362,6 +461,88 @@ public PageDomain queryPageList(ItemQueryDTO dto) { --- +## 数据字典模块开发 (2026-08-04 下午) + +### 🔴 数据字典模块强制性红线 + +| # | 红线 | 正确做法 | +|---|------|----------| +| 1 | **不需要 Controller** | 纯内部工具服务 | +| 2 | **不需要 Feign API** | 不暴露外部接口 | +| 3 | **不需要缓存实现** | 后续可调用 Feign 接口缓存 | +| 4 | **不需要继承 BaseDomain** | 配置项不是业务实体 | + +### 📋 数据字典核心功能 + +1. **单个字典类型查询**: `Map getDictionaryMap(String typeCode)` +2. **批量字典类型查询**: `Map> getDictionaryMaps(List typeCodes)` +3. **输出格式**: `Map` (en_code → full_name) +4. **重复处理**: 同一 typeCode 下 en_code 重复时取第一条 (按 sort_code + id 排序) +5. **多租户支持**: 自动获取 ecid 过滤 +6. **软删除过滤**: delete_mark = 0 + +### 📁 数据字典已生成文件 (5 个) + +| # | 文件 | 路径 | 状态 | +|---|------|------|------| +| 1 | `DictionaryMapper.java` | `smd/mapper/` | ✅ 已写入 | +| 2 | `DictionaryMapper.xml` | `resources/mapper/smd/` | ✅ 已写入 | +| 3 | `DictionaryItem.java` | `smd/dto/` | ✅ 已写入 | +| 4 | `DictionaryService.java` | `smd/service/` | ✅ 已写入 | +| 5 | `DictionaryServiceImpl.java` | `smd/service/impl/` | ✅ 已写入 | + +### 📁 已生成枚举类 (2 个) + +| # | 文件 | 说明 | +|---|------|------| +| 1 | `StatusEnum.java` | 状态枚举 (Y=启用,N=禁用) | +| 2 | `YesNoEnum.java` | 是否枚举 (Y=是,N=否) | + +### 🔧 字典填充规范 + +#### VO 设计 +- 添加 `xxxName` 字段存储字典翻译后的中文名称 +- 示例:`itemType` (en_code) + `itemTypeName` (中文名称) + +#### Service 层填充 +- **批量查询字典**: 一次查询多个字典类型,避免 N+1 问题 +- **通用填充方法**: `fillDictionaryData(ItemVO itemVO, Map> dictMaps)` +- **性能要求**: 列表查询只查 1 次字典,批量填充 + +#### 物料信息字典映射 + +| VO 字段 | 字典类型 | 说明 | +|--------|---------|------| +| `itemTypeName` | `materialsType` | 物料类型名称 | +| `propertiesName` | `itemProperties` | 物料属性名称 | +| `pickingPropertyName` | `pickingProperty` | 领料属性名称 | +| `statusName` | `StatusEnum` | 状态名称 (枚举) | + +### 📊 性能优化 + +| 优化点 | 说明 | 效果 | +|--------|------|------| +| **批量查询字典** | 一次查询多个字典类型 | 避免多次数据库查询 | +| **字典 Map 传入** | `fillDictionaryData()` 接收外部传入的 dictMaps | 避免重复查询 | +| **列表查询优化** | 100 条数据从 101 次查询降低到 2 次 | 性能提升 50 倍 | +| **枚举静态方法** | 枚举翻译使用静态方法 | 无运行时开销 | + +### 🧪 单元测试 + +- 测试文件:`src/test/java/com/witsoft/mica/smd/service/DictionaryServiceTest.java` +- 测试方法:英文命名(避免中文字符) +- Mock 处理:`GlobalUtils.getEcid()` 改为可 mock 的实例方法 +- 测试结果:✅ 5 个测试全部通过 + +### 🐛 数据字典开发修正问题 + +| # | 问题 | 修正方案 | +|---|------|----------| +| 16 | **fillDictionaryData 重复查询** | 修改方法签名接收 dictMaps 参数,避免 N+1 问题 | +| 17 | **planner 越权生成代码** | 明确 planner 禁止生成代码,由 backend agent 执行 | + +--- + ## 📝 待办事项 ### Backend (mica-server) @@ -375,6 +556,12 @@ public PageDomain queryPageList(ItemQueryDTO dto) { - [x] 创建 `smd/dto/ItemQueryDTO.java` - [x] 创建 `smd/dto/ItemFormDTO.java` - [x] 创建 `smd/vo/ItemVO.java` +- [x] 修正 15 个编译错误 +- [x] Maven 编译验证通过 +- [x] 数据字典模块开发 (5 个文件 + 2 个枚举类) +- [x] 字典填充逻辑实现 +- [x] 单元测试通过 +- [ ] 集成测试 ### Frontend (mica-web) - [ ] 创建物料管理 API 封装 (`app/composables/smd/useItemApi.ts`) diff --git a/workspace-planner/skills/mica-smdm-scaffold/SKILL.md b/workspace-planner/skills/mica-smdm-scaffold/SKILL.md new file mode 100644 index 0000000..be01f58 --- /dev/null +++ b/workspace-planner/skills/mica-smdm-scaffold/SKILL.md @@ -0,0 +1,610 @@ +--- +name: "mica-smdm-scaffold" +description: "mica 项目 SMDM 模块骨架代码生成规范(含数据字典填充)" +--- + +# mica SMDM 模块骨架代码生成技能 + +## 技能定位 + +为 mica 项目的主数据管理模块 (SMDM) 生成标准化的骨架代码,包含完整的 CRUD 功能、数据字典填充、单元测试和集成测试。 + +## 适用范围 + +- mica 项目的主数据管理模块开发 +- 需要 Feign 远程调用 SMDM 服务的场景 +- 需要数据字典翻译的业务模块 + +--- + +## 🔴 强制性红线 + +### SMDM 物料管理模块开发限制 + +| # | 红线 | 违规示例 | 正确做法 | +|---|------|---------|----------| +| 1 | **表前缀必须过滤** | `DmpMdItemInfo` | `ItemInfo` | +| 2 | **API 必须放在 apis.smdm 包** | `smd.api.ItemApi` | `apis.smdm.ItemApi` | +| 3 | **查询只能用 Mapper** | 使用 MyBatis-Plus | 原生 MyBatis XML | +| 4 | **改删必须 Feign 远程调用** | 本地直接 UPDATE/DELETE | 调用 SMDM 服务 API | +| 5 | **实体必须继承 BaseDomain** | 独立定义审计字段 | `extends BaseDomain` | +| 6 | **只使用指定业务字段** | 添加表中其他字段 | 仅用规范内字段 | +| 7 | **代码必须生成到 smd 目录** | `com.witsoft.mica.item.*` | `com.witsoft.mica.smd.*` | + +### 数据字典模块开发限制 + +| # | 红线 | 正确做法 | +|---|------|---------| +| 1 | **不需要 Controller** | 纯内部工具服务 | +| 2 | **不需要 Feign API** | 不暴露外部接口 | +| 3 | **不需要缓存实现** | 后续可调用 Feign 接口缓存 | +| 4 | **不需要继承 BaseDomain** | 配置项不是业务实体 | + +--- + +## 📋 核心规范摘要 + +### 包路径规范 +- **本地业务**: `com.witsoft.mica.smd.*` +- **Feign 接口**: `com.witsoft.mica.apis.smdm.*` + +### 表前缀过滤 +- `dmp_md_` 全部过滤 (如 `dmp_md_item_info` → `ItemInfo`) + +### 查询方式 +- **分页查询**: 原生 MyBatis XML +- **详情查询**: MyBatis-Plus `selectById()` + +### 改删操作 +- Feign 远程调用 SMDM 服务 + +### 注释规范 +- `@Author: yangxuan` +- `@Date: 精确到日` + +### 日志规范 +- SLF4J + Lombok `@Slf4j` +- Feign 调用添加 debug 日志(记录入参和耗时,**禁止记录返回值**) + +### ecid 处理 +- Controller 层调用 `GlobalUtils.getEcid()` 并传递给 Service + +### 分页方式 +- 使用项目 `PageDomain` +- **不使用** `com.github.pagehelper` + +### XML 规范 +- 使用 `` + `` 片段复用方式 + +--- + +## 📁 标准文件清单 + +### 第 1 部分:数据字典模块 (基础设施) + +| 文件 | 说明 | 路径 | +|------|------|------| +| `DictionaryMapper.java` | 字典查询 Mapper 接口 | `smd/mapper/` | +| `DictionaryMapper.xml` | 原生 SQL 查询 | `resources/mapper/smd/` | +| `DictionaryItem.java` | 字典数据项 DTO | `smd/dto/` | +| `DictionaryService.java` | 字典服务接口 | `smd/service/` | +| `DictionaryServiceImpl.java` | 字典服务实现 | `smd/service/impl/` | + +### 第 2 部分:枚举类 (按需) + +| 文件 | 说明 | +|------|------| +| `StatusEnum.java` | 状态枚举 (Y=启用,N=禁用) | +| `YesNoEnum.java` | 是否枚举 (Y=是,N=否) | + +### 第 3 部分:业务模块 (10 个文件) + +| 文件 | 说明 | 路径 | +|------|------|------| +| `XxxApi.java` | Feign 远程调用接口 | `apis/smdm/` | +| `XxxInfo.java` | 实体类 | `smd/entity/` | +| `XxxMapper.java` | Mapper 接口 | `smd/mapper/` | +| `XxxMapper.xml` | MyBatis XML 映射 | `resources/mapper/smd/` | +| `XxxService.java` | 服务接口 | `smd/service/` | +| `XxxServiceImpl.java` | 服务实现 | `smd/service/impl/` | +| `XxxController.java` | 控制器 | `smd/controller/` | +| `XxxQueryDTO.java` | 查询 DTO | `smd/dto/` | +| `XxxFormDTO.java` | 表单 DTO | `smd/dto/` | +| `XxxVO.java` | 视图对象 | `smd/vo/` | + +### 第 4 部分:测试文件 (扩展模块) + +| 文件 | 说明 | 路径 | +|------|------|------| +| `XxxServiceTest.java` | Service 层单元测试 | `src/test/java/com/witsoft/mica/smd/service/` | +| `XxxControllerIntegrationTest.java` | Controller 集成测试 | `src/test/java/com/witsoft/mica/smd/controller/` | + +--- + +## 🔧 数据字典填充规范 + +### VO 设计 +- 添加 `xxxName` 字段存储字典翻译后的中文名称 +- 示例:`itemType` (en_code) + `itemTypeName` (中文名称) + +### Service 层填充 +- **批量查询字典**: 一次查询多个字典类型,避免 N+1 问题 +- **通用填充方法**: `fillDictionaryData(ItemVO itemVO, Map> dictMaps)` +- **性能要求**: 列表查询只查 1 次字典,批量填充 + +### 填充示例代码 + +```java +@Override +public PageDomain queryPageList(ItemQueryDTO dto) { + // 查询分页数据 + PageDomain page = itemMapper.queryPageList(dto); + + // 只查 1 次字典(批量查询) + Map> dictMaps = dictionaryService.getDictionaryMaps( + Arrays.asList("materialsType", "itemProperties", "pickingProperty") + ); + + // 遍历列表填充 + page.getList().forEach(item -> fillDictionaryData(item, dictMaps)); + + return page; +} + +@Override +public ItemVO queryById(String id) { + ItemVO itemVO = itemMapper.selectById(id); + + // 查 1 次字典 + Map> dictMaps = dictionaryService.getDictionaryMaps( + Arrays.asList("materialsType", "itemProperties", "pickingProperty") + ); + + fillDictionaryData(itemVO, dictMaps); + return itemVO; +} + +/** + * 填充物料字典数据 + * @param itemVO 物料 VO 对象 + * @param dictMaps 已查询的字典 Map(外部传入,避免重复查询) + */ +private void fillDictionaryData(ItemVO itemVO, Map> dictMaps) { + if (itemVO == null || dictMaps == null) return; + + // 物料类型 + Map materialsMap = dictMaps.get("materialsType"); + if (materialsMap != null) { + itemVO.setItemTypeName(materialsMap.getOrDefault(itemVO.getItemType(), itemVO.getItemType())); + } + + // 物料属性 + Map propertiesMap = dictMaps.get("itemProperties"); + if (propertiesMap != null) { + itemVO.setPropertiesName(propertiesMap.getOrDefault(itemVO.getProperties(), itemVO.getProperties())); + } + + // 领料属性 + Map pickingMap = dictMaps.get("pickingProperty"); + if (pickingMap != null) { + itemVO.setPickingPropertyName(pickingMap.getOrDefault(itemVO.getPickingProperty(), itemVO.getPickingProperty())); + } + + // 状态(枚举) + itemVO.setStatusName(StatusEnum.getNameByCode(itemVO.getStatus())); +} +``` + +--- + +## 🧪 测试生成规范 + +### 单元测试 (Service 层) + +#### 文件位置 +- `src/test/java/com/witsoft/mica/smd/service/*ServiceTest.java` + +#### 测试类结构模板 +```java +package com.witsoft.mica.smd.service; + +import com.witsoft.gen.base.common.ResponseModel; +import com.witsoft.gen.base.page.PageDomain; +import com.witsoft.mica.smd.dto.ItemQueryDTO; +import com.witsoft.mica.smd.entity.ItemInfo; +import com.witsoft.mica.smd.mapper.ItemMapper; +import com.witsoft.mica.smd.service.impl.ItemServiceImpl; +import com.witsoft.mica.apis.smdm.ItemApi; +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.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ItemServiceTest { + + @Mock + private ItemMapper itemMapper; + + @Mock + private ItemApi itemApi; + + @InjectMocks + private ItemServiceImpl itemService; + + @Test + void testQueryPageList_normalQuery() { + // 准备测试数据 + ItemQueryDTO dto = new ItemQueryDTO(); + dto.setEcid("test-ecid"); + dto.setPageNo(1); + dto.setPageSize(10); + + List mockList = new ArrayList<>(); + ItemInfo item = new ItemInfo(); + item.setId("test-id"); + item.setItemCode("TEST001"); + mockList.add(item); + + when(itemMapper.queryPageCount(any())).thenReturn(1L); + when(itemMapper.queryPageList(any())).thenReturn(mockList); + + // 执行测试 + PageDomain result = itemService.queryPageList(dto); + + // 验证结果 + assertNotNull(result); + assertEquals(1, result.getList().size()); + assertEquals(1L, result.getTotal()); + } + + @Test + void testQueryById_notFound() { + when(itemMapper.selectById("not-exist")).thenReturn(null); + + ItemVO result = itemService.queryById("not-exist"); + + assertNull(result); + } + + @Test + void testCreateItem_success() { + ItemFormDTO dto = new ItemFormDTO(); + dto.setItemCode("TEST001"); + dto.setItemName("测试物料"); + + when(itemApi.createItem(any())).thenReturn(ResponseModel.succeed(null)); + + ResponseModel result = itemService.insertItem(dto); + + assertNotNull(result); + assertEquals(200, result.getCode()); + } + + @Test + void testCreateItem_nullParam() { + ResponseModel result = itemService.insertItem(null); + + assertNotNull(result); + assertEquals(500, result.getCode()); + assertTrue(result.getMsg().contains("参数不能为空")); + } + + @Test + void testUpdateItem_success() { + ItemFormDTO dto = new ItemFormDTO(); + dto.setId("test-id"); + dto.setItemCode("TEST001"); + + when(itemApi.updateItem(any())).thenReturn(ResponseModel.succeed(null)); + + ResponseModel result = itemService.updateItem(dto); + + assertNotNull(result); + assertEquals(200, result.getCode()); + } + + @Test + void testDeleteItem_success() { + when(itemApi.deleteItem(any())).thenReturn(ResponseModel.succeed(null)); + + ResponseModel result = itemService.deleteItem("test-id"); + + assertNotNull(result); + assertEquals(200, result.getCode()); + } +} +``` + +#### 测试方法清单 +| 方法 | 测试场景 | 断言 | +|------|---------|------| +| `testQueryPageList_normalQuery` | 正常分页查询 | 返回非空,size>0 | +| `testQueryPageList_emptyResult` | 无数据 | 返回空列表 | +| `testQueryById_notFound` | ID 不存在 | 返回 null | +| `testQueryById_found` | ID 存在 | 返回非空 VO | +| `testCreateItem_success` | 创建成功 | 返回 succeed | +| `testCreateItem_nullParam` | 空参数 | 返回 failed | +| `testUpdateItem_success` | 更新成功 | 返回 succeed | +| `testUpdateItem_nullId` | 空 ID | 返回 failed | +| `testDeleteItem_success` | 删除成功 | 返回 succeed | +| `testDeleteItem_nullId` | 空 ID | 返回 failed | + +#### Mock 处理规范 +- `GlobalUtils.getEcid()` → 改为实例方法或传入参数 +- Feign API → `@Mock` + `when().thenReturn()` +- Mapper → `@Mock` + 返回测试数据 +- 使用 `ArgumentMatchers.any()` 匹配任意参数 + +--- + +### 集成测试 (Controller 层) + +#### 文件位置 +- `src/test/java/com/witsoft/mica/smd/controller/*ControllerIntegrationTest.java` + +#### 测试类结构模板 +```java +package com.witsoft.mica.smd.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.witsoft.mica.smd.dto.ItemFormDTO; +import com.witsoft.mica.smd.dto.ItemQueryDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ItemControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + // 准备测试数据(可选) + } + + @Test + void testQueryPageList_integration() throws Exception { + ItemQueryDTO dto = new ItemQueryDTO(); + dto.setPageNo(1); + dto.setPageSize(10); + + mockMvc.perform(post("/web/itemInfo/getPageList") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.list").isArray()); + } + + @Test + void testQueryById_integration() throws Exception { + mockMvc.perform(get("/web/itemInfo/getById") + .param("id", "test-id")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void testCreateItem_integration() throws Exception { + ItemFormDTO dto = new ItemFormDTO(); + dto.setItemCode("TEST001"); + dto.setItemName("测试物料"); + dto.setStatus("Y"); + + mockMvc.perform(post("/web/itemInfo/create") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void testUpdateItem_integration() throws Exception { + ItemFormDTO dto = new ItemFormDTO(); + dto.setId("test-id"); + dto.setItemCode("TEST001"); + dto.setItemName("测试物料更新"); + + mockMvc.perform(post("/web/itemInfo/update") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void testDeleteItem_integration() throws Exception { + mockMvc.perform(post("/web/itemInfo/delete") + .param("id", "test-id")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void testAuth_ecidRequired() throws Exception { + // 测试没有 ecid 时的权限验证(如果配置了拦截器) + ItemQueryDTO dto = new ItemQueryDTO(); + + mockMvc.perform(post("/web/itemInfo/getPageList") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()); + // 根据实际权限配置调整断言 + } +} +``` + +#### 测试场景清单 +| 方法 | 测试场景 | 验证点 | +|------|---------|--------| +| `testQueryPageList_integration` | 完整查询流程 | HTTP 200 + 返回结构 | +| `testQueryById_integration` | 详情查询 | HTTP 200 + 数据存在 | +| `testCreateItem_integration` | 创建流程 | HTTP 200 + 数据入库 | +| `testUpdateItem_integration` | 更新流程 | HTTP 200 + 数据更新 | +| `testDeleteItem_integration` | 删除流程 | HTTP 200 + 数据删除 | +| `testAuth_ecidRequired` | 权限验证 | 无 ecid 时拒绝 | + +#### 测试数据准备 +- `@BeforeEach` 插入测试数据(可选) +- `@AfterEach` 清理测试数据(可选) +- 使用 `@Transactional` 自动回滚(推荐) + +--- + +### 测试生成触发条件 + +| 场景 | 单元测试 | 集成测试 | +|------|---------|---------| +| 新增业务模块 | ✅ 必选 | ✅ 推荐 | +| 新增字典模块 | ✅ 必选 | ⏸️ 可选 | +| 修改核心逻辑 | ✅ 必选 | ⏸️ 可选 | +| 修复 Bug | ✅ 添加回归测试 | ⏸️ 可选 | +| 性能优化 | ⏸️ 可选 | ✅ 必选 | + +--- + +## 🐛 常见问题修正清单 + +| # | 问题 | 修正方案 | +|---|------|----------| +| 1 | ResponseModel 静态引用 | `ResponseModel.succeed(data)` | +| 2 | 分页 XML / 详情 MP 混合使用 | 分页用 XML,详情用 MP | +| 3 | 编码查询不需要 | 移除编码查询条件 | +| 4 | Feign 调用缺少日志 | 添加入参和耗时日志(debug 级别) | +| 5 | Controller 层 ecid 处理 | Controller 层获取并传递 | +| 6 | 分页 XML 查询被删除 | 恢复分页 XML 查询 | +| 7 | 使用 PageHelper | 改用 PageDomain | +| 8 | queryByCode 方法不需要 | 删除 | +| 9 | ResponseModel 泛型参数化 | `ResponseModel` | +| 10 | Map 类型转换警告 | 添加 `@SuppressWarnings("unchecked")` | +| 11 | XML 不方便联查 | 使用 `` + `` 片段 | +| 12 | ItemApi ResponseModel 静态引用 | 泛型参数化 | +| 13 | ResponseModel 编译报错 | 使用 `ResponseModel` 或无参 | +| 14 | 日志记录返回值太大 | 只记录耗时,不记录返回值 | +| 15 | JsonUtils 方法不存在 | 使用 `JSON.toJSONString()` (fastjson) | +| 16 | fillDictionaryData 重复查询 | 接收 dictMaps 参数,避免 N+1 问题 | +| 17 | 单元测试中文字符命名 | 改为英文命名 | +| 18 | GlobalUtils 无法 Mock | 改为实例方法或传入参数 | + +--- + +## 📊 性能优化要点 + +| 优化点 | 说明 | 效果 | +|--------|------|------| +| **批量查询字典** | 一次查询多个字典类型 | 避免多次数据库查询 | +| **字典 Map 传入** | `fillDictionaryData()` 接收外部传入的 dictMaps | 避免重复查询 | +| **列表查询优化** | 100 条数据从 101 次查询降低到 2 次 | 性能提升 50 倍 | +| **枚举静态方法** | 枚举翻译使用静态方法 | 无运行时开销 | + +--- + +## 🚀 使用流程 + +### 1. 需求分析阶段 +- 确认业务模块的表结构 +- 确认需要 Feign 远程调用还是本地 CRUD +- 确认涉及的数据字典类型 + +### 2. 代码生成阶段 +- 生成数据字典模块 (5 个文件) +- 生成枚举类 (按需) +- 生成业务模块 (10 个文件) +- 生成单元测试 (按需) +- 生成集成测试 (按需) + +### 3. 修正阶段 +- 对照 18 个常见问题修正清单 +- 运行 Maven 编译验证 +- 运行单元测试 + +### 4. 验证阶段 +- 编译通过 +- 单元测试通过 +- 集成测试通过(可选) + +--- + +## 📝 输出物 + +1. 完整的骨架代码(10+5+2 个文件) +2. 单元测试文件(按需) +3. 集成测试文件(按需) +4. 编译验证报告 +5. 测试通过报告 + +--- + +## 🔗 相关资源 + +- 数据库连接:`mysql -h 47.99.209.185 -P 50036 -u witsoftd -p mica` +- 项目路径:`/root/projects/wit/` +- 规范文档:`/root/projects/wit/mica-doc/主数据骨架代码.md` +- 规范文档:`/root/projects/wit/mica-doc/数据字典模块.md` + +--- + +## 🔄 技能更新机制 + +### 如何丰富这个技能 + +1. **发现新需求/问题** + - 开发过程中遇到新问题 + - 用户提出新需求 + - 最佳实践总结 + +2. **添加到技能的对应扩展模块** + - 新增测试类型 → 添加到"测试生成规范" + - 新增文件类型 → 添加到"标准文件清单" + - 新问题 → 添加到"常见问题修正清单" + +3. **更新技能提案** + ```bash + skill_workshop action=revise name=mica-smdm-scaffold \ + proposal_content="[完整技能内容]" + ``` + +4. **应用新版本** + ```bash + skill_workshop action=apply proposal_id=mica-smdm-scaffold-xxxxx + ``` + +5. **后续开发自动使用新版本** + +### 可扩展模块示例 + +| 模块 | 说明 | 状态 | +|------|------|------| +| 单元测试生成 | Service 层测试模板 | ✅ 已实现 | +| 集成测试生成 | Controller 层测试模板 | ✅ 已实现 | +| API 文档生成 | Swagger 注解规范 | ⏸️ 待添加 | +| 前端代码生成 | Vue3 + TypeScript 模板 | ⏸️ 待添加 | +| 数据迁移脚本 | Flyway 迁移脚本 | ⏸️ 待添加 | +| 性能测试 | JMeter 测试脚本 | ⏸️ 待添加 | +| 部署脚本 | Docker/K8s 配置 | ⏸️ 待添加 |