fail(int code, String message) {
+ return new ApiResponse<>(code, message, null, System.currentTimeMillis());
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java b/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
new file mode 100644
index 0000000..54b2f9a
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
@@ -0,0 +1,23 @@
+package com.xly.erp.common.response;
+
+import lombok.Getter;
+
+@Getter
+public enum ErrorCode {
+ SUCCESS(200, "操作成功"),
+ PARAM_INVALID(40010, "参数错误"),
+ MOD_PARENT_NOT_FOUND(40411, "父模块不存在或已删除"),
+ MOD_NOT_FOUND(40421, "模块不存在或已删除"),
+ MOD_PROC_NAME_DUP(40911, "存储过程名称已存在"),
+ MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除"),
+ MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代"),
+ INTERNAL_ERROR(50000, "服务器内部错误");
+
+ private final int code;
+ private final String message;
+
+ ErrorCode(int code, String message) {
+ this.code = code;
+ this.message = message;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/config/JacksonConfig.java b/backend/src/main/java/com/xly/erp/config/JacksonConfig.java
new file mode 100644
index 0000000..a8f68b6
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/config/JacksonConfig.java
@@ -0,0 +1,28 @@
+package com.xly.erp.config;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 让 Jackson 通过字段名(而非 getter/setter 推断)确定 JSON 属性名。
+ *
+ * 项目沿用 docs/03 的匈牙利前缀命名(如 {@code iIncrement} / {@code sUserName}),
+ * Lombok 生成的 getter({@code getIIncrement})经 JavaBeans Introspector 解析为
+ * {@code IIncrement}(首两字符全大写时保留),导致 JSON 输出 {@code "IIncrement"}
+ * 而非期望的 {@code "iIncrement"}。改为字段访问后,Jackson 直接用字段名作 JSON key。
+ */
+@Configuration
+public class JacksonConfig {
+
+ @Bean
+ public Jackson2ObjectMapperBuilderCustomizer fieldOnlyVisibility() {
+ return builder -> builder
+ .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
+ .visibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE)
+ .visibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE)
+ .visibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.NONE);
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/config/SecurityConfig.java b/backend/src/main/java/com/xly/erp/config/SecurityConfig.java
new file mode 100644
index 0000000..f5e8afc
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/config/SecurityConfig.java
@@ -0,0 +1,24 @@
+package com.xly.erp.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Configuration
+public class SecurityConfig {
+
+ /**
+ * REQ-MOD-001 临时配置:所有 /api/** 一律 permitAll,禁用 CSRF / 表单登录。
+ * REQ-USR-004 完成时改为 .authenticated() + JWT filter。
+ */
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(csrf -> csrf.disable())
+ .formLogin(form -> form.disable())
+ .httpBasic(basic -> basic.disable())
+ .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
+ return http.build();
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java b/backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java
new file mode 100644
index 0000000..12365c4
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java
@@ -0,0 +1,54 @@
+package com.xly.erp.module.mod.controller;
+
+import com.xly.erp.common.response.ApiResponse;
+import com.xly.erp.module.mod.dto.ModuleCreateDTO;
+import com.xly.erp.module.mod.dto.ModuleQueryDTO;
+import com.xly.erp.module.mod.dto.ModuleUpdateDTO;
+import com.xly.erp.module.mod.service.ModuleService;
+import com.xly.erp.module.mod.vo.ModuleDeleteResultVO;
+import com.xly.erp.module.mod.vo.ModuleTreeNodeVO;
+import com.xly.erp.module.mod.vo.ModuleVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/modules")
+@RequiredArgsConstructor
+public class ModuleController {
+
+ private final ModuleService moduleService;
+
+ /** REQ-MOD-001 模块新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:CREATE')") */
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody ModuleCreateDTO dto) {
+ return ApiResponse.ok(moduleService.create(dto));
+ }
+
+ /** REQ-MOD-002 模块修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')") */
+ @PutMapping("/{id}")
+ public ApiResponse update(@PathVariable Integer id, @Valid @RequestBody ModuleUpdateDTO dto) {
+ return ApiResponse.ok(moduleService.update(id, dto));
+ }
+
+ /** REQ-MOD-003 模块软删除 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:DELETE')") */
+ @DeleteMapping("/{id}")
+ public ApiResponse delete(@PathVariable Integer id) {
+ return ApiResponse.ok(moduleService.delete(id));
+ }
+
+ /** REQ-MOD-004 模块树查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')") */
+ @GetMapping
+ public ApiResponse> tree(@Valid ModuleQueryDTO query) {
+ return ApiResponse.ok(moduleService.tree(query));
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java b/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java
new file mode 100644
index 0000000..2682f80
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java
@@ -0,0 +1,40 @@
+package com.xly.erp.module.mod.dto;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class ModuleCreateDTO {
+
+ @NotBlank
+ @Pattern(regexp = "^(手机端|前端业务|系统配置|接口)$", message = "sDisplayType 必须是 手机端/前端业务/系统配置/接口 之一")
+ private String sDisplayType;
+
+ @NotBlank
+ @Size(max = 100)
+ private String sProcedureName;
+
+ @NotBlank
+ @Size(max = 50)
+ private String sModuleType;
+
+ @NotBlank
+ @Size(max = 50)
+ private String sManageDeptEn;
+
+ /** 可空,service 层 default false */
+ private Boolean bShowPermission;
+
+ @NotBlank
+ @Size(max = 100)
+ private String sModuleNameZh;
+
+ /** 可空 */
+ private Integer iParentId;
+
+ @Min(0)
+ private Integer iSortOrder;
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java b/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java
new file mode 100644
index 0000000..ee543b2
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java
@@ -0,0 +1,13 @@
+package com.xly.erp.module.mod.dto;
+
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/** REQ-MOD-004 模块查询参数 DTO */
+@Data
+public class ModuleQueryDTO {
+
+ /** 可空:对 sModuleNameZh 模糊匹配;空字符串视为不过滤 */
+ @Size(max = 50, message = "keyword 长度不能超过 50")
+ private String keyword;
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java b/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java
new file mode 100644
index 0000000..1368f05
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java
@@ -0,0 +1,41 @@
+package com.xly.erp.module.mod.dto;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * REQ-MOD-002 模块修改入参。
+ * 与 {@link ModuleCreateDTO} 相比剥除了 sProcedureName(不可改);其余 7 个字段规则一致。
+ */
+@Data
+public class ModuleUpdateDTO {
+
+ @NotBlank
+ @Pattern(regexp = "^(手机端|前端业务|系统配置|接口)$", message = "sDisplayType 必须是 手机端/前端业务/系统配置/接口 之一")
+ private String sDisplayType;
+
+ @NotBlank
+ @Size(max = 50)
+ private String sModuleType;
+
+ @NotBlank
+ @Size(max = 50)
+ private String sManageDeptEn;
+
+ /** 可空:null 表示保持原值 */
+ private Boolean bShowPermission;
+
+ @NotBlank
+ @Size(max = 100)
+ private String sModuleNameZh;
+
+ /** 可空:null 表示设为根模块 */
+ private Integer iParentId;
+
+ /** 可空:null 表示保持原值 */
+ @Min(0)
+ private Integer iSortOrder;
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java b/backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java
new file mode 100644
index 0000000..166e9c4
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java
@@ -0,0 +1,73 @@
+package com.xly.erp.module.mod.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 业务模块定义。表 {@code tModule}(详见 docs/03-数据库设计文档.md § tModule)。
+ *
+ * 字段名沿用 docs/03 的匈牙利前缀命名(i/s/t/b),保持 schema 与 Java 字段一一对应,
+ * 避免双向映射歧义。@TableField 显式声明列名以兼容 MyBatis-Plus 默认的下划线转换关闭场景。
+ */
+@Data
+@TableName("tModule")
+public class ModuleEntity {
+
+ @TableId(value = "iIncrement", type = IdType.AUTO)
+ private Integer iIncrement;
+
+ @TableField("sId")
+ private String sId;
+
+ @TableField("sBrandsId")
+ private String sBrandsId;
+
+ @TableField("sSubsidiaryId")
+ private String sSubsidiaryId;
+
+ @TableField("tCreateDate")
+ private LocalDateTime tCreateDate;
+
+ @TableField("sDisplayType")
+ private String sDisplayType;
+
+ @TableField("sProcedureName")
+ private String sProcedureName;
+
+ @TableField("sModuleType")
+ private String sModuleType;
+
+ @TableField("sManageDeptEn")
+ private String sManageDeptEn;
+
+ @TableField("bShowPermission")
+ private Boolean bShowPermission;
+
+ @TableField("sModuleNameZh")
+ private String sModuleNameZh;
+
+ /** REQ-MOD-002 允许更新为 null(清空父模块),用 IGNORED 让 MP updateById 把 NULL 写入 SQL。 */
+ @TableField(value = "iParentId", updateStrategy = FieldStrategy.IGNORED)
+ private Integer iParentId;
+
+ @TableField("iSortOrder")
+ private Integer iSortOrder;
+
+ @TableField("sCreatedBy")
+ private String sCreatedBy;
+
+ @TableField("bDeleted")
+ private Boolean bDeleted;
+
+ @TableField("tDeletedDate")
+ private LocalDateTime tDeletedDate;
+
+ @TableField("sDeletedBy")
+ private String sDeletedBy;
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java b/backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java
new file mode 100644
index 0000000..7abc096
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java
@@ -0,0 +1,7 @@
+package com.xly.erp.module.mod.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xly.erp.module.mod.entity.ModuleEntity;
+
+public interface ModuleMapper extends BaseMapper {
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java b/backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java
new file mode 100644
index 0000000..96fb08f
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java
@@ -0,0 +1,24 @@
+package com.xly.erp.module.mod.service;
+
+import com.xly.erp.module.mod.dto.ModuleCreateDTO;
+import com.xly.erp.module.mod.dto.ModuleQueryDTO;
+import com.xly.erp.module.mod.dto.ModuleUpdateDTO;
+import com.xly.erp.module.mod.vo.ModuleDeleteResultVO;
+import com.xly.erp.module.mod.vo.ModuleTreeNodeVO;
+import com.xly.erp.module.mod.vo.ModuleVO;
+
+import java.util.List;
+
+public interface ModuleService {
+ /** REQ-MOD-001 模块新增 */
+ ModuleVO create(ModuleCreateDTO dto);
+
+ /** REQ-MOD-002 模块修改 */
+ ModuleVO update(Integer id, ModuleUpdateDTO dto);
+
+ /** REQ-MOD-003 模块软删除 */
+ ModuleDeleteResultVO delete(Integer id);
+
+ /** REQ-MOD-004 模块树查询(可选 keyword 模糊匹配 + 祖先链) */
+ List tree(ModuleQueryDTO query);
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java b/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java
new file mode 100644
index 0000000..9529b85
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java
@@ -0,0 +1,258 @@
+package com.xly.erp.module.mod.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.xly.erp.common.exception.BizException;
+import com.xly.erp.common.response.ErrorCode;
+import com.xly.erp.module.mod.dto.ModuleCreateDTO;
+import com.xly.erp.module.mod.dto.ModuleQueryDTO;
+import com.xly.erp.module.mod.dto.ModuleUpdateDTO;
+import com.xly.erp.module.mod.entity.ModuleEntity;
+import com.xly.erp.module.mod.mapper.ModuleMapper;
+import com.xly.erp.module.mod.service.ModuleService;
+import com.xly.erp.module.mod.vo.ModuleDeleteResultVO;
+import com.xly.erp.module.mod.vo.ModuleTreeNodeVO;
+import com.xly.erp.module.mod.vo.ModuleVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class ModuleServiceImpl implements ModuleService {
+
+ private final ModuleMapper moduleMapper;
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public ModuleVO create(ModuleCreateDTO dto) {
+ // 1. 父模块校验(仅当 iParentId 非空)
+ if (dto.getIParentId() != null) {
+ ModuleEntity parent = moduleMapper.selectById(dto.getIParentId());
+ if (parent == null || Boolean.TRUE.equals(parent.getBDeleted())) {
+ throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND);
+ }
+ }
+
+ // 2. sProcedureName 唯一性预检(未软删除范围)
+ Long exist = moduleMapper.selectCount(
+ new LambdaQueryWrapper()
+ .eq(ModuleEntity::getSProcedureName, dto.getSProcedureName())
+ .eq(ModuleEntity::getBDeleted, false));
+ if (exist != null && exist > 0L) {
+ throw new BizException(ErrorCode.MOD_PROC_NAME_DUP);
+ }
+
+ // 3. 构造 entity
+ ModuleEntity e = new ModuleEntity();
+ e.setSDisplayType(dto.getSDisplayType());
+ e.setSProcedureName(dto.getSProcedureName());
+ e.setSModuleType(dto.getSModuleType());
+ e.setSManageDeptEn(dto.getSManageDeptEn());
+ e.setBShowPermission(dto.getBShowPermission() != null ? dto.getBShowPermission() : Boolean.FALSE);
+ e.setSModuleNameZh(dto.getSModuleNameZh());
+ e.setIParentId(dto.getIParentId());
+ e.setISortOrder(dto.getISortOrder() != null ? dto.getISortOrder() : 0);
+ e.setTCreateDate(LocalDateTime.now());
+ e.setBDeleted(Boolean.FALSE);
+ // sId / sBrandsId / sSubsidiaryId / sCreatedBy / tDeletedDate / sDeletedBy 留 null(REQ-USR-004 后由登录上下文 / 多租户上下文回填)
+
+ // 4. 插入;并发下唯一约束兜底
+ try {
+ moduleMapper.insert(e);
+ } catch (DuplicateKeyException dup) {
+ throw new BizException(ErrorCode.MOD_PROC_NAME_DUP);
+ }
+
+ // 5. 返回 VO
+ return ModuleVO.from(e);
+ }
+
+ /** REQ-MOD-002 模块修改 */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public ModuleVO update(Integer id, ModuleUpdateDTO dto) {
+ // 1. 目标模块校验
+ ModuleEntity target = moduleMapper.selectById(id);
+ if (target == null || Boolean.TRUE.equals(target.getBDeleted())) {
+ throw new BizException(ErrorCode.MOD_NOT_FOUND);
+ }
+
+ // 2. iParentId 校验
+ Integer newParentId = dto.getIParentId();
+ if (newParentId != null) {
+ // 2a. 自引用
+ if (newParentId.equals(id)) {
+ throw new BizException(ErrorCode.MOD_PARENT_LOOP);
+ }
+ // 2b. 父存在性
+ ModuleEntity parent = moduleMapper.selectById(newParentId);
+ if (parent == null || Boolean.TRUE.equals(parent.getBDeleted())) {
+ throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND);
+ }
+ // 2c. 环路检查:从 newParentId 沿父链 walk up,最多 5 层;遇到 id 即环路
+ ModuleEntity cur = parent;
+ int depth = 1;
+ while (cur != null && cur.getIParentId() != null && depth <= 5) {
+ if (cur.getIParentId().equals(id)) {
+ throw new BizException(ErrorCode.MOD_PARENT_LOOP);
+ }
+ ModuleEntity next = moduleMapper.selectById(cur.getIParentId());
+ if (next == null || Boolean.TRUE.equals(next.getBDeleted())) {
+ break;
+ }
+ cur = next;
+ depth++;
+ }
+ }
+
+ // 3. 字段合并到 target(sProcedureName / iIncrement / tCreateDate / sCreatedBy / 多租户字段 / bDeleted 等保留原值)
+ target.setSDisplayType(dto.getSDisplayType());
+ target.setSModuleType(dto.getSModuleType());
+ target.setSManageDeptEn(dto.getSManageDeptEn());
+ target.setSModuleNameZh(dto.getSModuleNameZh());
+ if (dto.getBShowPermission() != null) {
+ target.setBShowPermission(dto.getBShowPermission());
+ }
+ target.setIParentId(dto.getIParentId()); // null 设根
+ if (dto.getISortOrder() != null) {
+ target.setISortOrder(dto.getISortOrder());
+ }
+
+ // 4. 落库
+ moduleMapper.updateById(target);
+
+ // 5. 返回 VO
+ return ModuleVO.from(target);
+ }
+
+ /** REQ-MOD-003 模块软删除 */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public ModuleDeleteResultVO delete(Integer id) {
+ // 1. 目标存在 + 未软删除
+ ModuleEntity target = moduleMapper.selectById(id);
+ if (target == null || Boolean.TRUE.equals(target.getBDeleted())) {
+ throw new BizException(ErrorCode.MOD_NOT_FOUND);
+ }
+
+ // 2. 子模块(未软删除)引用检查
+ Long childCount = moduleMapper.selectCount(
+ new LambdaQueryWrapper()
+ .eq(ModuleEntity::getIParentId, id)
+ .eq(ModuleEntity::getBDeleted, false));
+ if (childCount != null && childCount > 0L) {
+ throw new BizException(ErrorCode.MOD_HAS_REFERENCES);
+ }
+
+ // 3. 软删除写入:用 LambdaUpdateWrapper.set 显式声明 SET 列,避免 entity 驱动 SET
+ // (iParentId 字段的 FieldStrategy.IGNORED 策略会让 entity-driven update 把
+ // iParentId 写成 NULL,破坏父引用——这里用 wrapper.set 完全绕开 entity)
+ // sDeletedBy 暂写 null(REQ-USR-004 后由登录上下文回填)。
+ // eq(bDeleted, false) 兜底并发:影响 0 行视为已被并发删除 → 40421
+ LambdaUpdateWrapper uw = new LambdaUpdateWrapper()
+ .eq(ModuleEntity::getIIncrement, id)
+ .eq(ModuleEntity::getBDeleted, false)
+ .set(ModuleEntity::getBDeleted, true)
+ .set(ModuleEntity::getTDeletedDate, LocalDateTime.now())
+ .set(ModuleEntity::getSDeletedBy, null);
+
+ int affected = moduleMapper.update(null, uw);
+ if (affected == 0) {
+ throw new BizException(ErrorCode.MOD_NOT_FOUND);
+ }
+
+ return ModuleDeleteResultVO.of(id, true);
+ }
+
+ /** REQ-MOD-004 模块树查询 */
+ @Override
+ @Transactional(readOnly = true)
+ public List tree(ModuleQueryDTO query) {
+ // 1. 拉取所有未软删除模块
+ List all = moduleMapper.selectList(
+ new LambdaQueryWrapper().eq(ModuleEntity::getBDeleted, false));
+ if (all.isEmpty()) {
+ return new ArrayList<>();
+ }
+
+ // 2. id → entity 索引
+ Map byId = all.stream()
+ .collect(Collectors.toMap(ModuleEntity::getIIncrement, e -> e));
+
+ // 3. 计算 survivors 集合
+ String keyword = query == null ? null : query.getKeyword();
+ Set survivorIds;
+ if (keyword == null || keyword.isEmpty()) {
+ survivorIds = byId.keySet();
+ } else {
+ survivorIds = new HashSet<>();
+ // 命中节点
+ List hits = all.stream()
+ .filter(e -> e.getSModuleNameZh() != null && e.getSModuleNameZh().contains(keyword))
+ .toList();
+ for (ModuleEntity hit : hits) {
+ survivorIds.add(hit.getIIncrement());
+ // 沿父链向上收集祖先(深度上限 5)
+ Integer parentId = hit.getIParentId();
+ int depth = 0;
+ while (parentId != null && depth < 5) {
+ if (!survivorIds.add(parentId)) {
+ break; // 已存在,提前结束避免循环
+ }
+ ModuleEntity parent = byId.get(parentId);
+ if (parent == null) break;
+ parentId = parent.getIParentId();
+ depth++;
+ }
+ }
+ if (survivorIds.isEmpty()) {
+ return new ArrayList<>();
+ }
+ }
+
+ // 4. 转 VO + 按 (iSortOrder, iIncrement) 排序
+ Comparator cmp = Comparator
+ .comparingInt((ModuleTreeNodeVO v) -> v.getISortOrder() == null ? 0 : v.getISortOrder())
+ .thenComparingInt(ModuleTreeNodeVO::getIIncrement);
+
+ Map nodeById = new HashMap<>();
+ for (Integer id : survivorIds) {
+ ModuleEntity e = byId.get(id);
+ if (e != null) {
+ nodeById.put(id, ModuleTreeNodeVO.from(e));
+ }
+ }
+
+ // 5. 挂 children + 收集 roots
+ List roots = new ArrayList<>();
+ for (ModuleTreeNodeVO node : nodeById.values()) {
+ Integer pid = node.getIParentId();
+ if (pid == null || !nodeById.containsKey(pid)) {
+ // 父不存在于 survivors(被过滤掉或本身就是根)→ 提为根
+ roots.add(node);
+ } else {
+ nodeById.get(pid).getChildren().add(node);
+ }
+ }
+
+ // 6. 排序:根节点 + 每个节点的 children
+ roots.sort(cmp);
+ for (ModuleTreeNodeVO node : nodeById.values()) {
+ node.getChildren().sort(cmp);
+ }
+
+ return roots;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java b/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java
new file mode 100644
index 0000000..f5bd2f6
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java
@@ -0,0 +1,17 @@
+package com.xly.erp.module.mod.vo;
+
+import lombok.Data;
+
+/** REQ-MOD-003 模块删除返回精简 VO(仅含主键 + 删除标记) */
+@Data
+public class ModuleDeleteResultVO {
+ private Integer iIncrement;
+ private Boolean bDeleted;
+
+ public static ModuleDeleteResultVO of(Integer id, Boolean deleted) {
+ ModuleDeleteResultVO v = new ModuleDeleteResultVO();
+ v.setIIncrement(id);
+ v.setBDeleted(deleted);
+ return v;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java b/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java
new file mode 100644
index 0000000..1a5673e
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java
@@ -0,0 +1,31 @@
+package com.xly.erp.module.mod.vo;
+
+import com.xly.erp.module.mod.entity.ModuleEntity;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** REQ-MOD-004 模块树节点 VO(不暴露内部字段) */
+@Data
+public class ModuleTreeNodeVO {
+ private Integer iIncrement;
+ private String sModuleNameZh;
+ private String sDisplayType;
+ private String sManageDeptEn;
+ private Integer iParentId;
+ private Integer iSortOrder;
+ private List children = new ArrayList<>();
+
+ public static ModuleTreeNodeVO from(ModuleEntity e) {
+ ModuleTreeNodeVO v = new ModuleTreeNodeVO();
+ v.setIIncrement(e.getIIncrement());
+ v.setSModuleNameZh(e.getSModuleNameZh());
+ v.setSDisplayType(e.getSDisplayType());
+ v.setSManageDeptEn(e.getSManageDeptEn());
+ v.setIParentId(e.getIParentId());
+ v.setISortOrder(e.getISortOrder());
+ v.setChildren(new ArrayList<>());
+ return v;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java b/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java
new file mode 100644
index 0000000..14fa9cf
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java
@@ -0,0 +1,37 @@
+package com.xly.erp.module.mod.vo;
+
+import com.xly.erp.module.mod.entity.ModuleEntity;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class ModuleVO {
+ private Integer iIncrement;
+ private String sDisplayType;
+ private String sProcedureName;
+ private String sModuleType;
+ private String sManageDeptEn;
+ private Boolean bShowPermission;
+ private String sModuleNameZh;
+ private Integer iParentId;
+ private Integer iSortOrder;
+ private LocalDateTime tCreateDate;
+ private Boolean bDeleted;
+
+ public static ModuleVO from(ModuleEntity e) {
+ ModuleVO v = new ModuleVO();
+ v.setIIncrement(e.getIIncrement());
+ v.setSDisplayType(e.getSDisplayType());
+ v.setSProcedureName(e.getSProcedureName());
+ v.setSModuleType(e.getSModuleType());
+ v.setSManageDeptEn(e.getSManageDeptEn());
+ v.setBShowPermission(e.getBShowPermission());
+ v.setSModuleNameZh(e.getSModuleNameZh());
+ v.setIParentId(e.getIParentId());
+ v.setISortOrder(e.getISortOrder());
+ v.setTCreateDate(e.getTCreateDate());
+ v.setBDeleted(e.getBDeleted());
+ return v;
+ }
+}
diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml
new file mode 100644
index 0000000..9274cad
--- /dev/null
+++ b/backend/src/main/resources/application-test.yml
@@ -0,0 +1,7 @@
+spring:
+ flyway:
+ enabled: true
+ locations: filesystem:../sql/migrations
+ baseline-on-migrate: true
+ baseline-version: 1
+ baseline-description: "REQ-MOD-001 baseline (V1 already applied manually in A4)"
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
new file mode 100644
index 0000000..7a119a9
--- /dev/null
+++ b/backend/src/main/resources/application.yml
@@ -0,0 +1,31 @@
+server:
+ port: 8080
+ servlet:
+ context-path: /
+
+spring:
+ profiles:
+ active: ${SPRING_PROFILES_ACTIVE:dev}
+ datasource:
+ url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+ username: ${DB_USER}
+ password: ${DB_PASSWORD}
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ flyway:
+ enabled: true
+ locations: filesystem:../sql/migrations
+ baseline-on-migrate: true
+ baseline-version: 0
+ validate-on-migrate: true
+
+mybatis-plus:
+ configuration:
+ map-underscore-to-camel-case: false
+ global-config:
+ db-config:
+ id-type: auto
+
+erp:
+ jwt:
+ secret: ${JWT_SECRET}
+ expires-in-seconds: 7200
diff --git a/backend/src/main/resources/mapper/mod/ModuleMapper.xml b/backend/src/main/resources/mapper/mod/ModuleMapper.xml
new file mode 100644
index 0000000..9e7b093
--- /dev/null
+++ b/backend/src/main/resources/mapper/mod/ModuleMapper.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/backend/src/test/java/com/xly/erp/ErpApplicationTest.java b/backend/src/test/java/com/xly/erp/ErpApplicationTest.java
new file mode 100644
index 0000000..b00c11d
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/ErpApplicationTest.java
@@ -0,0 +1,14 @@
+package com.xly.erp;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest
+@ActiveProfiles("test")
+class ErpApplicationTest {
+ @Test
+ void contextLoads() {
+ // Spring ApplicationContext 启动成功 + Flyway 完成 baseline / migrate 即视为通过
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java
new file mode 100644
index 0000000..cf73c76
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java
@@ -0,0 +1,86 @@
+package com.xly.erp.common.exception;
+
+import com.xly.erp.common.response.ErrorCode;
+import jakarta.validation.constraints.NotBlank;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class GlobalExceptionHandlerTest {
+
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(new DummyController())
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .build();
+ }
+
+ @RestController
+ @RequestMapping("/api/__dummy")
+ static class DummyController {
+ @GetMapping("/biz")
+ public Object biz() { throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND); }
+
+ @GetMapping("/runtime")
+ public Object runtime() { throw new RuntimeException("internal boom\nstack-line-1\nstack-line-2"); }
+
+ @PostMapping(value = "/validation", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public Object validation(@jakarta.validation.Valid @RequestBody Payload p) { return "ok"; }
+ }
+
+ static class Payload {
+ @NotBlank String name;
+ public String getName() { return name; }
+ public void setName(String n) { this.name = n; }
+ }
+
+ @Test
+ void bizException_returns200WithBizCode() throws Exception {
+ mockMvc.perform(get("/api/__dummy/biz"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40411))
+ .andExpect(jsonPath("$.message").value("父模块不存在或已删除"));
+ }
+
+ @Test
+ void validationException_returns200WithParamInvalidCode() throws Exception {
+ mockMvc.perform(post("/api/__dummy/validation")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40010));
+ }
+
+ @Test
+ void uncaughtException_returns200WithInternalErrorCode() throws Exception {
+ mockMvc.perform(get("/api/__dummy/runtime"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(50000));
+ }
+
+ @Test
+ void response_doesNotContainStackTrace() throws Exception {
+ String body = mockMvc.perform(get("/api/__dummy/runtime"))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body)
+ .doesNotContain("stack-line-1")
+ .doesNotContain("internal boom")
+ .doesNotContain("at java.")
+ .doesNotContain("Caused by");
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java b/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java
new file mode 100644
index 0000000..12eebe3
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java
@@ -0,0 +1,55 @@
+package com.xly.erp.common.response;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ApiResponseTest {
+
+ @Test
+ void ok_setsCode200AndDataAndTimestamp() {
+ long before = System.currentTimeMillis();
+ ApiResponse r = ApiResponse.ok("hello");
+ long after = System.currentTimeMillis();
+
+ assertThat(r.getCode()).isEqualTo(200);
+ assertThat(r.getMessage()).isEqualTo("操作成功");
+ assertThat(r.getData()).isEqualTo("hello");
+ assertThat(r.getTimestamp()).isBetween(before, after);
+ }
+
+ @Test
+ void okWithMessage_overridesDefaultMessage() {
+ ApiResponse r = ApiResponse.ok("created", 42);
+ assertThat(r.getCode()).isEqualTo(200);
+ assertThat(r.getMessage()).isEqualTo("created");
+ assertThat(r.getData()).isEqualTo(42);
+ }
+
+ @Test
+ void fail_mapsErrorCodeFields() {
+ ApiResponse r = ApiResponse.fail(ErrorCode.PARAM_INVALID);
+ assertThat(r.getCode()).isEqualTo(40010);
+ assertThat(r.getMessage()).isEqualTo("参数错误");
+ assertThat(r.getData()).isNull();
+ }
+
+ @Test
+ void failWithDetail_overridesDefaultMessage() {
+ ApiResponse r = ApiResponse.fail(ErrorCode.PARAM_INVALID, "sUserName: blank");
+ assertThat(r.getCode()).isEqualTo(40010);
+ assertThat(r.getMessage()).isEqualTo("sUserName: blank");
+ }
+
+ @Test
+ void errorCode_constantsMatchDocs05Spec() {
+ assertThat(ErrorCode.SUCCESS.getCode()).isEqualTo(200);
+ assertThat(ErrorCode.PARAM_INVALID.getCode()).isEqualTo(40010);
+ assertThat(ErrorCode.MOD_PARENT_NOT_FOUND.getCode()).isEqualTo(40411);
+ assertThat(ErrorCode.MOD_PROC_NAME_DUP.getCode()).isEqualTo(40911);
+ assertThat(ErrorCode.INTERNAL_ERROR.getCode()).isEqualTo(50000);
+ assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421);
+ assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921);
+ assertThat(ErrorCode.MOD_HAS_REFERENCES.getCode()).isEqualTo(40912);
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/config/SecurityConfigTest.java b/backend/src/test/java/com/xly/erp/config/SecurityConfigTest.java
new file mode 100644
index 0000000..57ad8c2
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/config/SecurityConfigTest.java
@@ -0,0 +1,44 @@
+package com.xly.erp.config;
+
+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.context.annotation.Bean;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class SecurityConfigTest {
+
+ @Autowired MockMvc mockMvc;
+
+ @TestConfiguration
+ static class PingConfig {
+ @Bean PingController pingController() { return new PingController(); }
+ }
+
+ @RestController
+ @RequestMapping("/api/__ping")
+ static class PingController {
+ @GetMapping
+ public String ping() { return "pong"; }
+ }
+
+ @Test
+ void anyApiEndpoint_isPermittedWithoutAuth() throws Exception {
+ mockMvc.perform(get("/api/__ping"))
+ .andExpect(status().isOk())
+ .andExpect(content().string("pong"));
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java b/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java
new file mode 100644
index 0000000..de75253
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java
@@ -0,0 +1,531 @@
+package com.xly.erp.module.mod.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xly.erp.module.mod.dto.ModuleCreateDTO;
+import com.xly.erp.module.mod.dto.ModuleUpdateDTO;
+import com.xly.erp.module.mod.entity.ModuleEntity;
+import com.xly.erp.module.mod.mapper.ModuleMapper;
+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.annotation.Rollback;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+@Rollback
+class ModuleControllerIT {
+
+ @Autowired MockMvc mockMvc;
+ @Autowired ObjectMapper objectMapper;
+ @Autowired ModuleMapper moduleMapper;
+
+ private ModuleCreateDTO valid(String procName) {
+ ModuleCreateDTO d = new ModuleCreateDTO();
+ d.setSDisplayType("前端业务");
+ d.setSProcedureName(procName);
+ d.setSModuleType("USR");
+ d.setSManageDeptEn("IT");
+ d.setBShowPermission(false);
+ d.setSModuleNameZh("用户管理");
+ d.setIParentId(null);
+ d.setISortOrder(0);
+ return d;
+ }
+
+ private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); }
+
+ @Test
+ void post_validRootModule_returns200WithVO() throws Exception {
+ ModuleCreateDTO dto = valid("sp_audit_root_" + System.nanoTime());
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(dto)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.iIncrement").isNumber())
+ .andExpect(jsonPath("$.data.sProcedureName").value(dto.getSProcedureName()))
+ .andExpect(jsonPath("$.data.bDeleted").value(false));
+ }
+
+ @Test
+ void post_validChildModule_returns200() throws Exception {
+ // 先建 root
+ ModuleCreateDTO root = valid("sp_audit_parent_" + System.nanoTime());
+ String rootBody = mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(root)))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ Integer parentId = objectMapper.readTree(rootBody).path("data").path("iIncrement").asInt();
+
+ // 再建 child
+ ModuleCreateDTO child = valid("sp_audit_child_" + System.nanoTime());
+ child.setIParentId(parentId);
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(child)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.iParentId").value(parentId));
+ }
+
+ @Test
+ void post_duplicateProcedureName_returns200WithCode40911() throws Exception {
+ String procName = "sp_audit_dup_" + System.nanoTime();
+ ModuleCreateDTO first = valid(procName);
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(first)))
+ .andExpect(status().isOk());
+
+ ModuleCreateDTO second = valid(procName);
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(second)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40911));
+ }
+
+ @Test
+ void post_parentNotFound_returns200WithCode40411() throws Exception {
+ ModuleCreateDTO d = valid("sp_audit_orphan_" + System.nanoTime());
+ d.setIParentId(999999);
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40411));
+ }
+
+ @Test
+ void post_missingRequiredField_returns200WithCode40010() throws Exception {
+ ModuleCreateDTO d = valid("sp_audit_miss_" + System.nanoTime());
+ d.setSModuleNameZh(null); // 必填缺失
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40010));
+ }
+
+ @Test
+ void post_invalidDisplayTypeEnum_returns200WithCode40010() throws Exception {
+ ModuleCreateDTO d = valid("sp_audit_enum_" + System.nanoTime());
+ d.setSDisplayType("非法值");
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40010));
+ }
+
+ // ============================================================
+ // REQ-MOD-002 PUT 系列
+ // ============================================================
+
+ private Integer insertExisting(String procName, Integer parentId) {
+ ModuleEntity e = new ModuleEntity();
+ e.setSDisplayType("前端业务");
+ e.setSProcedureName(procName);
+ e.setSModuleType("USR");
+ e.setSManageDeptEn("IT");
+ e.setBShowPermission(false);
+ e.setSModuleNameZh("用户管理");
+ e.setIParentId(parentId);
+ e.setISortOrder(0);
+ e.setBDeleted(false);
+ e.setTCreateDate(LocalDateTime.now());
+ moduleMapper.insert(e);
+ return e.getIIncrement();
+ }
+
+ private ModuleUpdateDTO updateDto() {
+ ModuleUpdateDTO d = new ModuleUpdateDTO();
+ d.setSDisplayType("系统配置");
+ d.setSModuleType("USR_REVISED");
+ d.setSManageDeptEn("OPS");
+ d.setBShowPermission(true);
+ d.setSModuleNameZh("用户管理(修订)");
+ d.setIParentId(null);
+ d.setISortOrder(5);
+ return d;
+ }
+
+ @Test
+ void put_validUpdate_returns200() throws Exception {
+ String origProc = "sp_put_valid_" + System.nanoTime();
+ Integer id = insertExisting(origProc, null);
+ mockMvc.perform(put("/api/modules/" + id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(updateDto())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.iIncrement").value(id))
+ .andExpect(jsonPath("$.data.sDisplayType").value("系统配置"))
+ .andExpect(jsonPath("$.data.sModuleNameZh").value("用户管理(修订)"))
+ .andExpect(jsonPath("$.data.sProcedureName").value(origProc));
+
+ ModuleEntity reloaded = moduleMapper.selectById(id);
+ assertThat(reloaded.getSModuleType()).isEqualTo("USR_REVISED");
+ assertThat(reloaded.getSManageDeptEn()).isEqualTo("OPS");
+ assertThat(reloaded.getBShowPermission()).isTrue();
+ assertThat(reloaded.getISortOrder()).isEqualTo(5);
+ assertThat(reloaded.getSProcedureName()).isEqualTo(origProc);
+ }
+
+ @Test
+ void put_setParentToNull_clearsParent() throws Exception {
+ Integer parentId = insertExisting("sp_put_parent_" + System.nanoTime(), null);
+ Integer childId = insertExisting("sp_put_child_" + System.nanoTime(), parentId);
+
+ ModuleUpdateDTO d = updateDto();
+ d.setIParentId(null);
+
+ mockMvc.perform(put("/api/modules/" + childId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.iParentId").doesNotExist());
+
+ assertThat(moduleMapper.selectById(childId).getIParentId()).isNull();
+ }
+
+ @Test
+ void put_targetNotFound_returns40421() throws Exception {
+ mockMvc.perform(put("/api/modules/999999")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(updateDto())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40421));
+ }
+
+ @Test
+ void put_parentNotFound_returns40411() throws Exception {
+ Integer id = insertExisting("sp_put_orphan_" + System.nanoTime(), null);
+ ModuleUpdateDTO d = updateDto();
+ d.setIParentId(999999);
+ mockMvc.perform(put("/api/modules/" + id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40411));
+ }
+
+ @Test
+ void put_parentSelfRef_returns40921() throws Exception {
+ Integer id = insertExisting("sp_put_self_" + System.nanoTime(), null);
+ ModuleUpdateDTO d = updateDto();
+ d.setIParentId(id);
+ mockMvc.perform(put("/api/modules/" + id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40921));
+ }
+
+ @Test
+ void put_parentIsDescendant_returns40921() throws Exception {
+ // grandparent -> parent -> child;尝试把 grandparent 的 iParentId 设为 child
+ Integer grandId = insertExisting("sp_put_grand_" + System.nanoTime(), null);
+ Integer parentId = insertExisting("sp_put_par_" + System.nanoTime(), grandId);
+ Integer childId = insertExisting("sp_put_chi_" + System.nanoTime(), parentId);
+
+ ModuleUpdateDTO d = updateDto();
+ d.setIParentId(childId);
+ mockMvc.perform(put("/api/modules/" + grandId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40921));
+ }
+
+ @Test
+ void put_missingRequired_returns40010() throws Exception {
+ Integer id = insertExisting("sp_put_miss_" + System.nanoTime(), null);
+ ModuleUpdateDTO d = updateDto();
+ d.setSModuleNameZh(null);
+ mockMvc.perform(put("/api/modules/" + id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40010));
+ }
+
+ // ============================================================
+ // REQ-MOD-003 DELETE 系列
+ // ============================================================
+
+ @Test
+ void delete_validLeaf_returns200WithBDeletedTrue() throws Exception {
+ Integer id = insertExisting("sp_del_leaf_" + System.nanoTime(), null);
+
+ mockMvc.perform(delete("/api/modules/" + id))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.iIncrement").value(id))
+ .andExpect(jsonPath("$.data.bDeleted").value(true));
+
+ ModuleEntity reloaded = moduleMapper.selectById(id);
+ assertThat(reloaded.getBDeleted()).isTrue();
+ assertThat(reloaded.getTDeletedDate()).isNotNull();
+ }
+
+ @Test
+ void delete_targetNotFound_returns40421() throws Exception {
+ mockMvc.perform(delete("/api/modules/999999"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40421));
+ }
+
+ @Test
+ void delete_targetAlreadyDeleted_returns40421() throws Exception {
+ Integer id = insertExisting("sp_del_already_" + System.nanoTime(), null);
+ // 手工置 bDeleted=true
+ ModuleEntity patch = new ModuleEntity();
+ patch.setIIncrement(id);
+ patch.setBDeleted(true);
+ moduleMapper.updateById(patch);
+
+ mockMvc.perform(delete("/api/modules/" + id))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40421));
+ }
+
+ @Test
+ void delete_hasUndeletedChildren_returns40912() throws Exception {
+ Integer parentId = insertExisting("sp_del_par_" + System.nanoTime(), null);
+ insertExisting("sp_del_chi_" + System.nanoTime(), parentId);
+
+ mockMvc.perform(delete("/api/modules/" + parentId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40912));
+
+ // parent 仍未删除
+ assertThat(moduleMapper.selectById(parentId).getBDeleted()).isFalse();
+ }
+
+ @Test
+ void delete_softDeletedChildren_doesNotBlock_returns200() throws Exception {
+ Integer parentId = insertExisting("sp_del_pp_" + System.nanoTime(), null);
+ Integer childId = insertExisting("sp_del_cc_" + System.nanoTime(), parentId);
+
+ // 先 DELETE child(应成功)
+ mockMvc.perform(delete("/api/modules/" + childId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ // 再 DELETE parent(已删除子不阻塞)
+ mockMvc.perform(delete("/api/modules/" + parentId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ assertThat(moduleMapper.selectById(parentId).getBDeleted()).isTrue();
+ }
+
+ @Test
+ void delete_preservesOtherFields_onChildModule() throws Exception {
+ // 反例:非 root 子模块软删除后,iParentId / sProcedureName / sModuleNameZh /
+ // iSortOrder 等字段必须保持原值(spec § 业务规则 #4 + 验收 #1)。
+ Integer parentId = insertExisting("sp_del_pres_p_" + System.nanoTime(), null);
+ String childProc = "sp_del_pres_c_" + System.nanoTime();
+ // 用自定义字段值的 child(与 insertExisting 默认值不同),便于事后比对
+ ModuleEntity child = new ModuleEntity();
+ child.setSDisplayType("接口");
+ child.setSProcedureName(childProc);
+ child.setSModuleType("AUDIT");
+ child.setSManageDeptEn("OPS");
+ child.setBShowPermission(true);
+ child.setSModuleNameZh("待保留中文名");
+ child.setIParentId(parentId);
+ child.setISortOrder(7);
+ child.setBDeleted(false);
+ child.setTCreateDate(LocalDateTime.now());
+ moduleMapper.insert(child);
+ Integer childId = child.getIIncrement();
+
+ mockMvc.perform(delete("/api/modules/" + childId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.bDeleted").value(true));
+
+ ModuleEntity reloaded = moduleMapper.selectById(childId);
+ // 软删除三件套生效
+ assertThat(reloaded.getBDeleted()).isTrue();
+ assertThat(reloaded.getTDeletedDate()).isNotNull();
+ // 关键:其他列保持原值(曾经 iParentId.FieldStrategy.IGNORED 会清零这一列,本断言钉死该回归)
+ assertThat(reloaded.getIParentId()).isEqualTo(parentId);
+ assertThat(reloaded.getSProcedureName()).isEqualTo(childProc);
+ assertThat(reloaded.getSDisplayType()).isEqualTo("接口");
+ assertThat(reloaded.getSModuleType()).isEqualTo("AUDIT");
+ assertThat(reloaded.getSManageDeptEn()).isEqualTo("OPS");
+ assertThat(reloaded.getBShowPermission()).isTrue();
+ assertThat(reloaded.getSModuleNameZh()).isEqualTo("待保留中文名");
+ assertThat(reloaded.getISortOrder()).isEqualTo(7);
+ }
+
+ @Test
+ void delete_responseVOContainsOnlyIIncrementAndBDeleted() throws Exception {
+ Integer id = insertExisting("sp_del_vo_" + System.nanoTime(), null);
+
+ mockMvc.perform(delete("/api/modules/" + id))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.iIncrement").value(id))
+ .andExpect(jsonPath("$.data.bDeleted").value(true))
+ .andExpect(jsonPath("$.data.sProcedureName").doesNotExist())
+ .andExpect(jsonPath("$.data.sDisplayType").doesNotExist())
+ .andExpect(jsonPath("$.data.sModuleNameZh").doesNotExist());
+ }
+
+ @Test
+ void put_ignoresProcedureNameField_doesNotChange() throws Exception {
+ String origProc = "sp_put_keep_" + System.nanoTime();
+ Integer id = insertExisting(origProc, null);
+ // 手工拼一个含 sProcedureName 的请求体(DTO 没声明该字段,Jackson 默认忽略)
+ String body = """
+ {
+ "sDisplayType": "系统配置",
+ "sProcedureName": "hijack",
+ "sModuleType": "USR_REVISED",
+ "sManageDeptEn": "OPS",
+ "bShowPermission": true,
+ "sModuleNameZh": "用户管理(修订)",
+ "iSortOrder": 5
+ }
+ """;
+ mockMvc.perform(put("/api/modules/" + id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.sProcedureName").value(origProc));
+
+ assertThat(moduleMapper.selectById(id).getSProcedureName()).isEqualTo(origProc);
+ }
+
+ // ============================================================
+ // REQ-MOD-004 GET 系列
+ // ============================================================
+
+ @Test
+ void get_emptyKeyword_returnsAllUndeletedAsTree() throws Exception {
+ // 插入一个 root + 一个 child;不带 keyword 的 GET 应能看到二者
+ Integer rootId = insertExisting("sp_get_root_" + System.nanoTime(), null);
+ Integer childId = insertExisting("sp_get_child_" + System.nanoTime(), rootId);
+
+ mockMvc.perform(get("/api/modules"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[?(@.iIncrement==" + rootId + ")]").exists())
+ .andExpect(jsonPath("$.data[?(@.iIncrement==" + rootId + ")].children[?(@.iIncrement==" + childId + ")]").exists());
+ }
+
+ @Test
+ void get_keyword_filtersByModuleNameZhWithAncestors() throws Exception {
+ // grandparent("系统配置") -> parent("用户管理") -> child("登录认证")
+ ModuleEntity gp = new ModuleEntity();
+ gp.setSDisplayType("前端业务"); gp.setSProcedureName("sp_get_kw_gp_" + System.nanoTime());
+ gp.setSModuleType("MOD"); gp.setSManageDeptEn("IT"); gp.setBShowPermission(false);
+ gp.setSModuleNameZh("系统配置-keyword test"); gp.setIParentId(null); gp.setISortOrder(0);
+ gp.setBDeleted(false); gp.setTCreateDate(LocalDateTime.now());
+ moduleMapper.insert(gp);
+
+ ModuleEntity p = new ModuleEntity();
+ p.setSDisplayType("前端业务"); p.setSProcedureName("sp_get_kw_p_" + System.nanoTime());
+ p.setSModuleType("MOD"); p.setSManageDeptEn("IT"); p.setBShowPermission(false);
+ p.setSModuleNameZh("用户管理-keyword test"); p.setIParentId(gp.getIIncrement()); p.setISortOrder(0);
+ p.setBDeleted(false); p.setTCreateDate(LocalDateTime.now());
+ moduleMapper.insert(p);
+
+ ModuleEntity c = new ModuleEntity();
+ c.setSDisplayType("前端业务"); c.setSProcedureName("sp_get_kw_c_" + System.nanoTime());
+ c.setSModuleType("MOD"); c.setSManageDeptEn("IT"); c.setBShowPermission(false);
+ c.setSModuleNameZh("唯一登录认证关键词"); c.setIParentId(p.getIIncrement()); c.setISortOrder(0);
+ c.setBDeleted(false); c.setTCreateDate(LocalDateTime.now());
+ moduleMapper.insert(c);
+
+ mockMvc.perform(get("/api/modules").param("keyword", "唯一登录认证关键词"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ // 命中 child + 全部祖先:grandparent 在 root 数组中
+ .andExpect(jsonPath("$.data[?(@.iIncrement==" + gp.getIIncrement() + ")]").exists())
+ // grandparent.children 含 parent
+ .andExpect(jsonPath("$.data[?(@.iIncrement==" + gp.getIIncrement() + ")].children[?(@.iIncrement==" + p.getIIncrement() + ")]").exists())
+ // parent.children 含 child
+ .andExpect(jsonPath("$.data[?(@.iIncrement==" + gp.getIIncrement() + ")].children[?(@.iIncrement==" + p.getIIncrement() + ")].children[?(@.iIncrement==" + c.getIIncrement() + ")]").exists());
+ }
+
+ @Test
+ void get_keywordNoMatch_returnsEmptyArray() throws Exception {
+ insertExisting("sp_get_nm_" + System.nanoTime(), null);
+
+ mockMvc.perform(get("/api/modules").param("keyword", "绝对不存在的关键词xyz"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").isArray())
+ .andExpect(jsonPath("$.data.length()").value(0));
+ }
+
+ @Test
+ void get_keywordTooLong_returns40010() throws Exception {
+ String longKw = "a".repeat(51);
+ mockMvc.perform(get("/api/modules").param("keyword", longKw))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40010));
+ }
+
+ @Test
+ void get_softDeletedNotInResult() throws Exception {
+ Integer id = insertExisting("sp_get_sd_" + System.nanoTime(), null);
+ // 软删除该模块
+ ModuleEntity patch = new ModuleEntity();
+ patch.setIIncrement(id);
+ patch.setBDeleted(true);
+ moduleMapper.updateById(patch);
+
+ mockMvc.perform(get("/api/modules"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[?(@.iIncrement==" + id + ")]").doesNotExist());
+ }
+
+ @Test
+ void get_responseExcludesInternalFields() throws Exception {
+ insertExisting("sp_get_priv_" + System.nanoTime(), null);
+
+ mockMvc.perform(get("/api/modules"))
+ .andExpect(status().isOk())
+ // 树节点不应包含内部字段
+ .andExpect(jsonPath("$.data[0].sProcedureName").doesNotExist())
+ .andExpect(jsonPath("$.data[0].sModuleType").doesNotExist())
+ .andExpect(jsonPath("$.data[0].bShowPermission").doesNotExist())
+ .andExpect(jsonPath("$.data[0].tCreateDate").doesNotExist())
+ .andExpect(jsonPath("$.data[0].bDeleted").doesNotExist());
+ }
+
+ @Test
+ void get_leafNodeChildrenIsEmptyArrayNotNull() throws Exception {
+ Integer id = insertExisting("sp_get_leaf_" + System.nanoTime(), null);
+
+ mockMvc.perform(get("/api/modules"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[?(@.iIncrement==" + id + ")].children").isArray())
+ .andExpect(jsonPath("$.data[?(@.iIncrement==" + id + ")].children.length()").value(0));
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/module/mod/dto/ModuleCreateDTOValidationTest.java b/backend/src/test/java/com/xly/erp/module/mod/dto/ModuleCreateDTOValidationTest.java
new file mode 100644
index 0000000..4ace5ec
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/module/mod/dto/ModuleCreateDTOValidationTest.java
@@ -0,0 +1,71 @@
+package com.xly.erp.module.mod.dto;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import jakarta.validation.ValidatorFactory;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ModuleCreateDTOValidationTest {
+
+ private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory();
+ private final Validator validator = FACTORY.getValidator();
+
+ private ModuleCreateDTO valid() {
+ ModuleCreateDTO d = new ModuleCreateDTO();
+ d.setSDisplayType("前端业务");
+ d.setSProcedureName("sp_audit_user");
+ d.setSModuleType("USR");
+ d.setSManageDeptEn("IT");
+ d.setBShowPermission(false);
+ d.setSModuleNameZh("用户管理");
+ d.setIParentId(null);
+ d.setISortOrder(0);
+ return d;
+ }
+
+ @Test
+ void allValidFields_yieldsNoViolations() {
+ Set> v = validator.validate(valid());
+ assertThat(v).isEmpty();
+ }
+
+ @Test
+ void blankRequiredFields_yieldsViolations() {
+ ModuleCreateDTO d = new ModuleCreateDTO();
+ // 全部不填,触发 5 个 @NotBlank
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
+ .contains("sDisplayType", "sProcedureName", "sModuleType", "sManageDeptEn", "sModuleNameZh");
+ }
+
+ @Test
+ void invalidDisplayTypeEnum_yieldsViolation() {
+ ModuleCreateDTO d = valid();
+ d.setSDisplayType("非法值");
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sDisplayType");
+ }
+
+ @Test
+ void overSizedFields_yieldsViolations() {
+ ModuleCreateDTO d = valid();
+ d.setSProcedureName("a".repeat(101));
+ d.setSModuleType("a".repeat(51));
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
+ .contains("sProcedureName", "sModuleType");
+ }
+
+ @Test
+ void negativeSortOrder_yieldsViolation() {
+ ModuleCreateDTO d = valid();
+ d.setISortOrder(-1);
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("iSortOrder");
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java b/backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java
new file mode 100644
index 0000000..98418ed
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java
@@ -0,0 +1,61 @@
+package com.xly.erp.module.mod.dto;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import jakarta.validation.ValidatorFactory;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ModuleUpdateDTOValidationTest {
+
+ private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory();
+ private final Validator validator = FACTORY.getValidator();
+
+ private ModuleUpdateDTO valid() {
+ ModuleUpdateDTO d = new ModuleUpdateDTO();
+ d.setSDisplayType("前端业务");
+ d.setSModuleType("USR");
+ d.setSManageDeptEn("IT");
+ d.setBShowPermission(true);
+ d.setSModuleNameZh("用户管理(修订)");
+ d.setIParentId(null);
+ d.setISortOrder(0);
+ return d;
+ }
+
+ @Test
+ void allValidFields_yieldsNoViolations() {
+ Set> v = validator.validate(valid());
+ assertThat(v).isEmpty();
+ }
+
+ @Test
+ void blankRequiredFields_yieldsViolations() {
+ ModuleUpdateDTO d = new ModuleUpdateDTO();
+ Set> v = validator.validate(d);
+ // 5 个 @NotBlank:sDisplayType / sModuleType / sManageDeptEn / sModuleNameZh
+ // (bShowPermission / iParentId / iSortOrder 可空)
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
+ .contains("sDisplayType", "sModuleType", "sManageDeptEn", "sModuleNameZh");
+ }
+
+ @Test
+ void invalidDisplayTypeEnum_yieldsViolation() {
+ ModuleUpdateDTO d = valid();
+ d.setSDisplayType("非法值");
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sDisplayType");
+ }
+
+ @Test
+ void negativeSortOrder_yieldsViolation() {
+ ModuleUpdateDTO d = valid();
+ d.setISortOrder(-1);
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("iSortOrder");
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java b/backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java
new file mode 100644
index 0000000..bd61361
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java
@@ -0,0 +1,77 @@
+package com.xly.erp.module.mod.mapper;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xly.erp.module.mod.entity.ModuleEntity;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.annotation.Rollback;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@ActiveProfiles("test")
+@Transactional
+@Rollback
+class ModuleMapperIT {
+
+ @Autowired ModuleMapper moduleMapper;
+
+ @Test
+ void insertAndSelectById_persistsAllFields() {
+ ModuleEntity e = new ModuleEntity();
+ e.setSDisplayType("前端业务");
+ e.setSProcedureName("sp_audit_test_" + System.nanoTime());
+ e.setSModuleType("USR");
+ e.setSManageDeptEn("IT");
+ e.setBShowPermission(false);
+ e.setSModuleNameZh("测试模块");
+ e.setIParentId(null);
+ e.setISortOrder(0);
+ e.setBDeleted(false);
+ e.setTCreateDate(LocalDateTime.now());
+
+ int rows = moduleMapper.insert(e);
+ assertThat(rows).isEqualTo(1);
+ assertThat(e.getIIncrement()).isNotNull().isPositive();
+
+ ModuleEntity loaded = moduleMapper.selectById(e.getIIncrement());
+ assertThat(loaded).isNotNull();
+ assertThat(loaded.getSDisplayType()).isEqualTo("前端业务");
+ assertThat(loaded.getSProcedureName()).isEqualTo(e.getSProcedureName());
+ assertThat(loaded.getSModuleType()).isEqualTo("USR");
+ assertThat(loaded.getSManageDeptEn()).isEqualTo("IT");
+ assertThat(loaded.getBShowPermission()).isFalse();
+ assertThat(loaded.getSModuleNameZh()).isEqualTo("测试模块");
+ assertThat(loaded.getIParentId()).isNull();
+ assertThat(loaded.getISortOrder()).isZero();
+ assertThat(loaded.getBDeleted()).isFalse();
+ }
+
+ @Test
+ void selectCountByProcedureName_returnsExisting() {
+ String name = "sp_audit_uniq_" + System.nanoTime();
+ ModuleEntity e = new ModuleEntity();
+ e.setSDisplayType("接口");
+ e.setSProcedureName(name);
+ e.setSModuleType("MOD");
+ e.setSManageDeptEn("IT");
+ e.setBShowPermission(false);
+ e.setSModuleNameZh("唯一性检测");
+ e.setISortOrder(0);
+ e.setBDeleted(false);
+ e.setTCreateDate(LocalDateTime.now());
+ moduleMapper.insert(e);
+
+ Long count = moduleMapper.selectCount(
+ new LambdaQueryWrapper()
+ .eq(ModuleEntity::getSProcedureName, name)
+ .eq(ModuleEntity::getBDeleted, false)
+ );
+ assertThat(count).isEqualTo(1L);
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java
new file mode 100644
index 0000000..60aec2f
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java
@@ -0,0 +1,506 @@
+package com.xly.erp.module.mod.service;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.xly.erp.common.exception.BizException;
+import com.xly.erp.common.response.ErrorCode;
+import com.xly.erp.module.mod.dto.ModuleCreateDTO;
+import com.xly.erp.module.mod.dto.ModuleQueryDTO;
+import com.xly.erp.module.mod.dto.ModuleUpdateDTO;
+import com.xly.erp.module.mod.entity.ModuleEntity;
+import com.xly.erp.module.mod.mapper.ModuleMapper;
+import com.xly.erp.module.mod.service.impl.ModuleServiceImpl;
+import com.xly.erp.module.mod.vo.ModuleDeleteResultVO;
+import com.xly.erp.module.mod.vo.ModuleTreeNodeVO;
+import com.xly.erp.module.mod.vo.ModuleVO;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ModuleServiceImplTest {
+
+ @Mock ModuleMapper moduleMapper;
+
+ @InjectMocks ModuleServiceImpl service;
+
+ private ModuleCreateDTO baseDto() {
+ ModuleCreateDTO d = new ModuleCreateDTO();
+ d.setSDisplayType("前端业务");
+ d.setSProcedureName("sp_audit_user");
+ d.setSModuleType("USR");
+ d.setSManageDeptEn("IT");
+ d.setBShowPermission(false);
+ d.setSModuleNameZh("用户管理");
+ return d;
+ }
+
+ @Test
+ void create_rootModule_returnsVOWithGeneratedId() {
+ when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+ when(moduleMapper.insert((ModuleEntity) any())).thenAnswer(inv -> {
+ ModuleEntity e = inv.getArgument(0);
+ e.setIIncrement(123);
+ return 1;
+ });
+
+ ModuleVO vo = service.create(baseDto());
+
+ assertThat(vo.getIIncrement()).isEqualTo(123);
+ assertThat(vo.getSProcedureName()).isEqualTo("sp_audit_user");
+ assertThat(vo.getBShowPermission()).isFalse();
+ assertThat(vo.getISortOrder()).isZero();
+ assertThat(vo.getBDeleted()).isFalse();
+ assertThat(vo.getTCreateDate()).isNotNull();
+ }
+
+ @Test
+ void create_childModule_validatesParentExists() {
+ ModuleCreateDTO d = baseDto();
+ d.setIParentId(7);
+
+ ModuleEntity parent = new ModuleEntity();
+ parent.setIIncrement(7);
+ parent.setBDeleted(false);
+ when(moduleMapper.selectById(7)).thenReturn(parent);
+ when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+ when(moduleMapper.insert((ModuleEntity) any())).thenAnswer(inv -> {
+ ModuleEntity e = inv.getArgument(0);
+ e.setIIncrement(8);
+ return 1;
+ });
+
+ ModuleVO vo = service.create(d);
+ assertThat(vo.getIIncrement()).isEqualTo(8);
+ assertThat(vo.getIParentId()).isEqualTo(7);
+ }
+
+ @Test
+ void create_parentNotFound_throwsBizException40411() {
+ ModuleCreateDTO d = baseDto();
+ d.setIParentId(999999);
+ when(moduleMapper.selectById(999999)).thenReturn(null);
+
+ assertThatThrownBy(() -> service.create(d))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_PARENT_NOT_FOUND.getCode());
+ verify(moduleMapper, never()).insert((ModuleEntity) any());
+ }
+
+ @Test
+ void create_parentSoftDeleted_throwsBizException40411() {
+ ModuleCreateDTO d = baseDto();
+ d.setIParentId(5);
+
+ ModuleEntity deleted = new ModuleEntity();
+ deleted.setIIncrement(5);
+ deleted.setBDeleted(true);
+ when(moduleMapper.selectById(5)).thenReturn(deleted);
+
+ assertThatThrownBy(() -> service.create(d))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_PARENT_NOT_FOUND.getCode());
+ }
+
+ @Test
+ void create_duplicateProcedureName_preCheck_throwsBizException40911() {
+ when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
+
+ assertThatThrownBy(() -> service.create(baseDto()))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_PROC_NAME_DUP.getCode());
+ verify(moduleMapper, never()).insert((ModuleEntity) any());
+ }
+
+ @Test
+ void create_duplicateProcedureName_concurrentInsert_throwsBizException40911() {
+ when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+ when(moduleMapper.insert((ModuleEntity) any()))
+ .thenThrow(new DuplicateKeyException("uk_procedure_name"));
+
+ assertThatThrownBy(() -> service.create(baseDto()))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_PROC_NAME_DUP.getCode());
+ }
+
+ // ============================================================
+ // REQ-MOD-002 update 系列
+ // ============================================================
+
+ private ModuleUpdateDTO updateDto() {
+ ModuleUpdateDTO d = new ModuleUpdateDTO();
+ d.setSDisplayType("系统配置");
+ d.setSModuleType("USR_REVISED");
+ d.setSManageDeptEn("OPS");
+ d.setBShowPermission(true);
+ d.setSModuleNameZh("用户管理(修订)");
+ d.setIParentId(null);
+ d.setISortOrder(5);
+ return d;
+ }
+
+ private ModuleEntity existingTarget(int id) {
+ ModuleEntity t = new ModuleEntity();
+ t.setIIncrement(id);
+ t.setSDisplayType("前端业务");
+ t.setSProcedureName("sp_audit_existing");
+ t.setSModuleType("USR");
+ t.setSManageDeptEn("IT");
+ t.setBShowPermission(false);
+ t.setSModuleNameZh("用户管理");
+ t.setIParentId(null);
+ t.setISortOrder(0);
+ t.setBDeleted(false);
+ t.setTCreateDate(LocalDateTime.of(2026, 1, 1, 0, 0));
+ t.setSCreatedBy("admin");
+ return t;
+ }
+
+ @Test
+ void update_targetNotFound_throws40421() {
+ when(moduleMapper.selectById(10)).thenReturn(null);
+
+ assertThatThrownBy(() -> service.update(10, updateDto()))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode());
+ }
+
+ @Test
+ void update_targetSoftDeleted_throws40421() {
+ ModuleEntity t = existingTarget(11);
+ t.setBDeleted(true);
+ when(moduleMapper.selectById(11)).thenReturn(t);
+
+ assertThatThrownBy(() -> service.update(11, updateDto()))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode());
+ }
+
+ @Test
+ void update_parentSelfReference_throws40921() {
+ ModuleEntity t = existingTarget(12);
+ when(moduleMapper.selectById(12)).thenReturn(t);
+ ModuleUpdateDTO d = updateDto();
+ d.setIParentId(12);
+
+ assertThatThrownBy(() -> service.update(12, d))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_PARENT_LOOP.getCode());
+ }
+
+ @Test
+ void update_parentNotFound_throws40411() {
+ ModuleEntity t = existingTarget(13);
+ when(moduleMapper.selectById(13)).thenReturn(t);
+ when(moduleMapper.selectById(999999)).thenReturn(null);
+ ModuleUpdateDTO d = updateDto();
+ d.setIParentId(999999);
+
+ assertThatThrownBy(() -> service.update(13, d))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_PARENT_NOT_FOUND.getCode());
+ }
+
+ @Test
+ void update_parentIsDescendant_throws40921() {
+ // 三层结构: 14(target) <- 20(child) <- 30(grand)
+ ModuleEntity target = existingTarget(14);
+ ModuleEntity child = existingTarget(20);
+ child.setIParentId(14);
+ ModuleEntity grand = existingTarget(30);
+ grand.setIParentId(20);
+
+ when(moduleMapper.selectById(14)).thenReturn(target);
+ when(moduleMapper.selectById(30)).thenReturn(grand);
+ when(moduleMapper.selectById(20)).thenReturn(child);
+
+ ModuleUpdateDTO d = updateDto();
+ d.setIParentId(30); // 想把 14 的父设成 30,但 30 是 14 的孙子 → 环路
+
+ assertThatThrownBy(() -> service.update(14, d))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_PARENT_LOOP.getCode());
+ }
+
+ @Test
+ void update_full_returnsVOWithUpdatedFields() {
+ ModuleEntity target = existingTarget(15);
+ when(moduleMapper.selectById(15)).thenReturn(target);
+ when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1);
+
+ ModuleVO vo = service.update(15, updateDto());
+
+ ArgumentCaptor cap = ArgumentCaptor.forClass(ModuleEntity.class);
+ verify(moduleMapper).updateById(cap.capture());
+ ModuleEntity saved = cap.getValue();
+
+ // 已修改字段
+ assertThat(saved.getSDisplayType()).isEqualTo("系统配置");
+ assertThat(saved.getSModuleType()).isEqualTo("USR_REVISED");
+ assertThat(saved.getSManageDeptEn()).isEqualTo("OPS");
+ assertThat(saved.getSModuleNameZh()).isEqualTo("用户管理(修订)");
+ assertThat(saved.getBShowPermission()).isTrue();
+ assertThat(saved.getISortOrder()).isEqualTo(5);
+ assertThat(saved.getIParentId()).isNull();
+ // 保持原值
+ assertThat(saved.getIIncrement()).isEqualTo(15);
+ assertThat(saved.getSProcedureName()).isEqualTo("sp_audit_existing");
+ assertThat(saved.getTCreateDate()).isEqualTo(LocalDateTime.of(2026, 1, 1, 0, 0));
+ assertThat(saved.getSCreatedBy()).isEqualTo("admin");
+ assertThat(saved.getBDeleted()).isFalse();
+
+ assertThat(vo.getSDisplayType()).isEqualTo("系统配置");
+ assertThat(vo.getSProcedureName()).isEqualTo("sp_audit_existing");
+ }
+
+ @Test
+ void update_partialNullFields_keepsOriginalValues() {
+ ModuleEntity target = existingTarget(16);
+ target.setBShowPermission(true);
+ target.setISortOrder(99);
+ when(moduleMapper.selectById(16)).thenReturn(target);
+ when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1);
+
+ ModuleUpdateDTO d = updateDto();
+ d.setBShowPermission(null);
+ d.setISortOrder(null);
+
+ service.update(16, d);
+
+ ArgumentCaptor cap = ArgumentCaptor.forClass(ModuleEntity.class);
+ verify(moduleMapper).updateById(cap.capture());
+ ModuleEntity saved = cap.getValue();
+ assertThat(saved.getBShowPermission()).isTrue(); // 原值保留
+ assertThat(saved.getISortOrder()).isEqualTo(99); // 原值保留
+ }
+
+ @Test
+ void update_clearParent_setsParentToNull() {
+ ModuleEntity target = existingTarget(17);
+ target.setIParentId(7); // 原本有父
+ when(moduleMapper.selectById(17)).thenReturn(target);
+ when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1);
+
+ ModuleUpdateDTO d = updateDto();
+ d.setIParentId(null); // 显式清空
+
+ service.update(17, d);
+
+ ArgumentCaptor cap = ArgumentCaptor.forClass(ModuleEntity.class);
+ verify(moduleMapper).updateById(cap.capture());
+ assertThat(cap.getValue().getIParentId()).isNull();
+ }
+
+ // ============================================================
+ // REQ-MOD-003 delete 系列
+ // ============================================================
+
+ @Test
+ void delete_targetNotFound_throws40421() {
+ when(moduleMapper.selectById(20)).thenReturn(null);
+
+ assertThatThrownBy(() -> service.delete(20))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode());
+ }
+
+ @Test
+ void delete_targetAlreadyDeleted_throws40421() {
+ ModuleEntity t = existingTarget(21);
+ t.setBDeleted(true);
+ when(moduleMapper.selectById(21)).thenReturn(t);
+
+ assertThatThrownBy(() -> service.delete(21))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode());
+ }
+
+ @Test
+ void delete_hasUndeletedChildren_throws40912() {
+ ModuleEntity t = existingTarget(22);
+ when(moduleMapper.selectById(22)).thenReturn(t);
+ when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(3L);
+
+ assertThatThrownBy(() -> service.delete(22))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_HAS_REFERENCES.getCode());
+ }
+
+ @Test
+ void delete_leafModule_writesSoftDeleteFields_returnsResult() {
+ ModuleEntity t = existingTarget(23);
+ when(moduleMapper.selectById(23)).thenReturn(t);
+ when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+ when(moduleMapper.update((ModuleEntity) any(), (Wrapper) any())).thenReturn(1);
+
+ ModuleDeleteResultVO vo = service.delete(23);
+
+ assertThat(vo.getIIncrement()).isEqualTo(23);
+ assertThat(vo.getBDeleted()).isTrue();
+ // SET 列由 LambdaUpdateWrapper 声明,entity 第一参数为 null(避免 iParentId.IGNORED 策略副作用)
+ // 实际 SQL SET 子句的列覆盖由 IT (delete_preservesOtherFields_onChildModule) 验证
+ verify(moduleMapper).update((ModuleEntity) isNull(), (Wrapper) any());
+ }
+
+ @Test
+ void delete_softDeletedChildren_doesNotBlock() {
+ ModuleEntity t = existingTarget(24);
+ when(moduleMapper.selectById(24)).thenReturn(t);
+ // 子全部已软删除 → selectCount(bDeleted=0 过滤) 返回 0
+ when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+ when(moduleMapper.update((ModuleEntity) any(), (Wrapper) any())).thenReturn(1);
+
+ ModuleDeleteResultVO vo = service.delete(24);
+ assertThat(vo.getBDeleted()).isTrue();
+ }
+
+ @Test
+ void delete_concurrentRace_throws40421() {
+ ModuleEntity t = existingTarget(25);
+ when(moduleMapper.selectById(25)).thenReturn(t);
+ when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+ // update 影响行数 0 → 视为并发删除
+ when(moduleMapper.update((ModuleEntity) any(), (Wrapper) any())).thenReturn(0);
+
+ assertThatThrownBy(() -> service.delete(25))
+ .isInstanceOf(BizException.class)
+ .extracting(e -> ((BizException) e).getCode())
+ .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode());
+ }
+
+ // ============================================================
+ // REQ-MOD-004 tree 系列
+ // ============================================================
+
+ private ModuleEntity buildModule(int id, Integer parentId, String name, int sortOrder) {
+ ModuleEntity e = new ModuleEntity();
+ e.setIIncrement(id);
+ e.setIParentId(parentId);
+ e.setSModuleNameZh(name);
+ e.setSDisplayType("前端业务");
+ e.setSManageDeptEn("IT");
+ e.setSProcedureName("sp_" + id);
+ e.setSModuleType("MOD");
+ e.setBShowPermission(false);
+ e.setISortOrder(sortOrder);
+ e.setBDeleted(false);
+ e.setTCreateDate(LocalDateTime.now());
+ return e;
+ }
+
+ @Test
+ void tree_emptyDb_returnsEmptyList() {
+ when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
+ List result = service.tree(new ModuleQueryDTO());
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void tree_singleRoot_returnsOneNodeWithEmptyChildren() {
+ when(moduleMapper.selectList(any(Wrapper.class)))
+ .thenReturn(Arrays.asList(buildModule(1, null, "系统配置", 0)));
+
+ List result = service.tree(new ModuleQueryDTO());
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getIIncrement()).isEqualTo(1);
+ assertThat(result.get(0).getChildren()).isNotNull().isEmpty();
+ }
+
+ @Test
+ void tree_multiLevel_buildsNestedStructureSortedByISortOrder() {
+ when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList(
+ buildModule(1, null, "RootB", 2),
+ buildModule(2, null, "RootA", 1),
+ buildModule(3, 1, "ChildOfB", 0)
+ ));
+
+ List result = service.tree(new ModuleQueryDTO());
+
+ // 根节点按 sortOrder 升序:RootA (sort=1) 在前,RootB (sort=2) 在后
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).getIIncrement()).isEqualTo(2); // RootA
+ assertThat(result.get(1).getIIncrement()).isEqualTo(1); // RootB
+ // RootB 的 children 含 ChildOfB
+ assertThat(result.get(1).getChildren()).hasSize(1);
+ assertThat(result.get(1).getChildren().get(0).getIIncrement()).isEqualTo(3);
+ // RootA 是叶子
+ assertThat(result.get(0).getChildren()).isEmpty();
+ }
+
+ @Test
+ void tree_keywordHit_includesAncestorChain() {
+ when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList(
+ buildModule(1, null, "系统配置", 0),
+ buildModule(2, 1, "用户管理", 0),
+ buildModule(3, 2, "登录", 0),
+ buildModule(99, null, "无关模块", 0)
+ ));
+
+ ModuleQueryDTO q = new ModuleQueryDTO();
+ q.setKeyword("登录");
+ List result = service.tree(q);
+
+ // 应返回 1 → 2 → 3 三层链;不含 99
+ assertThat(result).hasSize(1);
+ ModuleTreeNodeVO root = result.get(0);
+ assertThat(root.getIIncrement()).isEqualTo(1);
+ assertThat(root.getChildren()).hasSize(1);
+ ModuleTreeNodeVO mid = root.getChildren().get(0);
+ assertThat(mid.getIIncrement()).isEqualTo(2);
+ assertThat(mid.getChildren()).hasSize(1);
+ assertThat(mid.getChildren().get(0).getIIncrement()).isEqualTo(3);
+ }
+
+ @Test
+ void tree_keywordNoMatch_returnsEmptyList() {
+ when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList(
+ buildModule(1, null, "系统配置", 0),
+ buildModule(2, null, "权限分配", 0)
+ ));
+
+ ModuleQueryDTO q = new ModuleQueryDTO();
+ q.setKeyword("不存在的关键词");
+ List result = service.tree(q);
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void tree_softDeletedExcluded_passesBDeletedZeroToMapper() {
+ when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+ service.tree(new ModuleQueryDTO());
+
+ // 验证调用了 selectList 一次(mapper 端用 wrapper.eq(bDeleted, false) 已是 service 实现细节,
+ // 这里仅验证 service 调用了 selectList,wrapper 形状由 IT 在真实 DB 上 cross-check)
+ verify(moduleMapper).selectList(any(Wrapper.class));
+ }
+}
diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml
new file mode 100644
index 0000000..9274cad
--- /dev/null
+++ b/backend/src/test/resources/application-test.yml
@@ -0,0 +1,7 @@
+spring:
+ flyway:
+ enabled: true
+ locations: filesystem:../sql/migrations
+ baseline-on-migrate: true
+ baseline-version: 1
+ baseline-description: "REQ-MOD-001 baseline (V1 already applied manually in A4)"
diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md
index c0d985e..2bbdca8 100644
--- a/docs/08-模块任务管理.md
+++ b/docs/08-模块任务管理.md
@@ -58,12 +58,12 @@
- module_mod 模块管理
- 依赖: —
- 路径: backend/src/main/java/com/xly/erp/module/mod/, frontend/src/pages/mod/
- - MR: —
+ - MR: !1
- 功能:
- - [ ] REQ-MOD-001 模块新增
- - [ ] REQ-MOD-002 模块修改
- - [ ] REQ-MOD-003 模块删除
- - [ ] REQ-MOD-004 模块查询
+ - [x] REQ-MOD-001 模块新增
+ - [x] REQ-MOD-002 模块修改
+ - [x] REQ-MOD-003 模块删除
+ - [x] REQ-MOD-004 模块查询
- module_usr 用户管理
- 依赖: —
diff --git a/docs/superpowers/module-reports/2026-05-06-module_mod.md b/docs/superpowers/module-reports/2026-05-06-module_mod.md
new file mode 100644
index 0000000..f114a10
--- /dev/null
+++ b/docs/superpowers/module-reports/2026-05-06-module_mod.md
@@ -0,0 +1,140 @@
+---
+module_id: module_mod
+date: 2026-05-06
+git_range: ace5841..c64a026 (24 commits)
+---
+
+# 模块完成报告 — module_mod 模块管理
+
+## ① 模块信息
+- 模块 ID: module_mod
+- 模块名: 模块管理(业务模块定义 / 树形菜单 / 权限分组基础单位)
+- 开发区间: ace5841(master, plan phase done)→ c64a026(test-gate evidence),共 24 个 commits
+
+## ② REQ 完成清单
+
+- [x] REQ-MOD-001 — 模块新增(`POST /api/modules`)
+ - spec: docs/superpowers/specs/2026-05-06-REQ-MOD-001.md
+ - plan: docs/superpowers/plans/2026-05-06-REQ-MOD-001.md
+ - review: docs/superpowers/reviews/2026-05-06-REQ-MOD-001.md
+- [x] REQ-MOD-002 — 模块修改(`PUT /api/modules/{id}`)
+ - spec: docs/superpowers/specs/2026-05-06-REQ-MOD-002.md
+ - plan: docs/superpowers/plans/2026-05-06-REQ-MOD-002.md
+ - review: docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md
+- [x] REQ-MOD-003 — 模块软删除(`DELETE /api/modules/{id}`)
+ - spec: docs/superpowers/specs/2026-05-06-REQ-MOD-003.md
+ - plan: docs/superpowers/plans/2026-05-06-REQ-MOD-003.md
+ - review: docs/superpowers/reviews/2026-05-06-REQ-MOD-003.md
+- [x] REQ-MOD-004 — 模块树查询(`GET /api/modules?keyword=`)
+ - spec: docs/superpowers/specs/2026-05-06-REQ-MOD-004.md
+ - plan: docs/superpowers/plans/2026-05-06-REQ-MOD-004.md
+ - review: docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md
+
+## ③ 文件变更表
+
+| 文件 | 操作 | 说明 |
+|---|---|---|
+| backend/pom.xml | A | Spring Boot 3.2.5 父 pom + 依赖(mybatis-plus 3.5.7、flyway 10、hutool、mapstruct、test)+ surefire includes 修复(让 *IT.java 跑入 mvn test) |
+| backend/src/main/java/com/xly/erp/ErpApplication.java | A | Spring Boot 启动类 + @MapperScan |
+| backend/src/main/resources/application.yml | A | DB / Flyway / MyBatis-Plus 配置;从 .env.local 注入凭据 |
+| backend/src/main/resources/application-test.yml | A | test profile:Flyway baseline-on-migrate=true / baseline-version=1 |
+| backend/src/main/java/com/xly/erp/common/response/ApiResponse.java | A | 统一响应 `{code, message, data, timestamp}` + 静态工厂 |
+| backend/src/main/java/com/xly/erp/common/response/ErrorCode.java | A | 错误码枚举:SUCCESS / PARAM_INVALID / MOD_PARENT_NOT_FOUND / MOD_NOT_FOUND / MOD_PROC_NAME_DUP / MOD_HAS_REFERENCES / MOD_PARENT_LOOP / INTERNAL_ERROR |
+| backend/src/main/java/com/xly/erp/common/exception/BizException.java | A | 业务异常(带 ErrorCode) |
+| backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java | A | @RestControllerAdvice:BizException → ApiResponse.fail(code),MethodArgumentNotValidException → 40010,Exception → 50000,**不回显堆栈** |
+| backend/src/main/java/com/xly/erp/config/SecurityConfig.java | A | SecurityFilterChain permitAll(占位,REQ-USR-004 时收紧为 JWT) |
+| backend/src/main/java/com/xly/erp/config/JacksonConfig.java | A | Jackson 字段访问可见性配置(解决 Lombok getter `iIncrement` 被 Introspector 解析为 `IIncrement` 导致 JSON key 错位) |
+| backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java | A | tModule 实体;保留匈牙利前缀;iParentId 用 `FieldStrategy.IGNORED` 让 update 时显式 NULL 写入 |
+| backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java | A | extends BaseMapper;只用 BaseMapper 默认 SQL |
+| backend/src/main/resources/mapper/mod/ModuleMapper.xml | A | 空骨架(预留扩展) |
+| backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java | A | POST 入参 + Bean Validation(@NotBlank / @Pattern 枚举 / @Size / @Min) |
+| backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java | A | PUT 入参;剥除 sProcedureName(不可改) |
+| backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java | A | GET 入参 keyword(@Size max=50) |
+| backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java | A | 创建/修改返回 VO(11 字段)+ 静态 from(ModuleEntity) |
+| backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java | A | 删除返回精简 VO(iIncrement + bDeleted) |
+| backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java | A | 树查询节点 VO(7 字段 + children=[]) |
+| backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java | A | 接口:create / update / delete / tree |
+| backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java | A | 4 业务方法:create(唯一性预检 + DuplicateKey 兜底)/ update(父 FK 校验 + 环路 walk-up depth=5)/ delete(子模块引用检查 + LambdaUpdateWrapper.set 显式 SET 列)/ tree(内存树构造 + keyword 模糊 + 祖先链 walk-up) |
+| backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java | A | 4 端点:POST / PUT / DELETE / GET |
+| backend/src/test/java/com/xly/erp/ErpApplicationTest.java | A | contextLoads + Flyway baseline |
+| backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java | A | 5 单测(含 7 个错误码常量断言) |
+| backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java | A | 4 单测(standaloneSetup) |
+| backend/src/test/java/com/xly/erp/config/SecurityConfigTest.java | A | 1 集成(permitAll 验证) |
+| backend/src/test/java/com/xly/erp/module/mod/dto/ModuleCreateDTOValidationTest.java | A | 5 单测(Bean Validation) |
+| backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java | A | 4 单测(Bean Validation) |
+| backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java | A | 2 集成(@SpringBootTest + insert/select) |
+| backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java | A | 26 单测(Mockito + ArgumentCaptor):6 create / 8 update / 6 delete / 6 tree |
+| backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java | A | 28 集成(MockMvc + @Transactional rollback):6 POST / 8 PUT / 7 DELETE / 7 GET |
+| backend/src/test/resources/application-test.yml | A | test profile 副本 |
+| docs/08-模块任务管理.md | M | § 二 module_mod 4 个 REQ 全部勾选 |
+| docs/superpowers/specs/2026-05-06-REQ-MOD-00{1..4}.md | A | 4 份功能规格 |
+| docs/superpowers/plans/2026-05-06-REQ-MOD-00{1..4}.md | A | 4 份任务级实现计划 |
+| docs/superpowers/reviews/2026-05-06-REQ-MOD-00{1..4}.md | A | 4 份 AI 审阅报告 |
+| docs/superpowers/module-reports/module_mod-test-gate.md | A | 本模块 test-gate 闸门证据 |
+
+## ④ 数据库使用表
+
+- 读: `tModule`(4 个 REQ 都读:create 唯一性预检 / update 父校验 + 后代环路 / delete 子模块引用检查 + 状态校验 / tree selectList)
+- 写: `tModule`(create insert / update updateById / delete update bDeleted=1 软删除)
+
+本模块不读 / 不写其他表。FK 关系:tModule 自引用 `iParentId → iIncrement`(V1 由 db-init 创建)。
+
+## ⑤ 测试结果
+
+- `scripts/test.sh` 最终:green
+- 通过: 76 / 失败: 0 / 跳过: 0
+- 覆盖率: 未启用 JaCoCo 等覆盖率工具(docs/04 § 零 技术栈表未列入);后续模块若需要可作为软规则 S1 引入
+
+测试分布:
+- 单元测试 49(ApiResponse 5 + GlobalExceptionHandler 4 + DTO Validation 5+4 + ServiceImpl 26 + 其他 5)
+- 集成测试 31(ApplicationTest 1 + SecurityConfig 1 + ModuleMapperIT 2 + ModuleControllerIT 28)
+
+`./scripts/test.sh` 流程:setup-test-db.sh DROP+CREATE → mvn build → mvn lint(compile) → mvn test → frontend skip → e2e 略 → reset DB。耗时 ~15s。
+
+## ⑥ 本模块新增 Migration
+
+—(本模块未引入 schema 改动;tModule 由 V1__initial_schema.sql 在 A4 阶段创建)
+
+## ⑦ 跨模块改动清单(软规则 S2)
+
+—(本模块为 module_mod 独立工作,未触碰其他模块的代码 / schema;hook `log-cross-module.sh` 未生成存根,docs/superpowers/module-reports/module_mod-cross-module.md 不存在)
+
+## ⑧ 偏离 spec 清单
+
+- **REQ-MOD-001** → 顺手新增了计划外的 `JacksonConfig.java`(spec / plan 文件清单未列出)。原因:项目沿用 docs/03 匈牙利前缀命名(`iIncrement`),Lombok 生成的 `getIIncrement()` 经 JavaBeans Introspector 解析为属性 `IIncrement`(首两字符全大写时保留),导致 JSON key 输出 `"IIncrement"` 而非期望的 `"iIncrement"`,破坏 API 契约。配置 Jackson 用字段直接读取属性名修复。已在 review 报告 § Nice-to-have 披露。
+- **REQ-MOD-001** → spec 要求 Controller / Service / Mapper / DTO / VO 关键类贴 `// REQ-MOD-001` 标签;实现中只在 Controller / SecurityConfig 上写注释,其他类未贴。原因:实施时疏漏,已在 review § Nice-to-have 记录,不影响功能。
+- **REQ-MOD-002** → 在 ModuleEntity#iParentId 上加了 `FieldStrategy.IGNORED` 让 NULL 写入生效(spec 未规划 entity 层改动)。原因:MyBatis-Plus 默认 update 跳过 null,导致 `setIParentId(null)` 不能写入 SQL。这是 entity 全局行为变更,已在 review 报告标注为 Nice-to-have(未来其他 service 走 partial updateById 路径会埋雷)。
+- **REQ-MOD-003** → round 1 review 发现 high 隐患:iParentId.IGNORED 副作用导致 delete 时 entity-driven update 静默清空父引用。round 2 fix commit (2419659) 改用 `LambdaUpdateWrapper.set(...)` 显式 SET,并补 IT `delete_preservesOtherFields_onChildModule` 钉死回归。
+- **REQ-MOD-004** → 顺手修复了 `backend/pom.xml` 中 maven-surefire-plugin 默认 includes 不含 `*IT.java` 的配置缺陷。原因:之前 REQ-MOD-001/002/003 的 IT 测试在 `mvn test` 全量阶段根本未被发现 / 执行(仅在单独 `-Dtest=xxx` 下运行过),所有 verify approval 都基于不完整覆盖。修复后所有 IT 进入 mvn test,本模块累计 76 测试全部参与全量。已在 review 报告披露。
+- **跨 REQ — 鉴权延期**:所有 4 个 REQ 的 Controller 都加了说明性注释 `REQ-USR-004 完成后追加 @PreAuthorize(...)`,contract 要求的 `MOD:CREATE/UPDATE/DELETE/READ` 权限在本模块**未启用**。这是计划性技术债(docs/02 § 三关键说明 + 各 REQ spec § 边界都明确点到),由 REQ-USR-004 首次落地登录上下文时统一收紧 SecurityConfig。
+- **跨 REQ — 多租户字段 / sCreatedBy 留 NULL**:所有写入路径中 `sBrandsId / sSubsidiaryId / sCreatedBy` 都落 NULL(spec 明确点到,等 REQ-USR-004 引入登录上下文 / 多租户上下文后回填)。
+
+## ⑨ AI reviewer 报告汇总
+
+- REQ-MOD-001: round 1 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-MOD-001.md)
+- REQ-MOD-002: round 1 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md)
+- REQ-MOD-003: round 1 — request-changes → round 2 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-MOD-003.md,修复 commit 2419659)
+- REQ-MOD-004: round 1 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md)
+
+## ⑩ 已知问题
+
+1. **鉴权延期**:4 个端点目前 SecurityConfig permitAll;任何客户端无 token 也能调通。**REQ-USR-004 必须回头**给本模块端点加 `@PreAuthorize` + 把 `permitAll()` 改为 `authenticated()`,并补端到端鉴权 IT。
+2. **多租户字段 NULL**:tModule 中 `sBrandsId / sSubsidiaryId / sCreatedBy` 列在所有写入路径都为 NULL。REQ-USR-004 后需要:(a) 引入多租户拦截器从 SecurityContext 注入;(b) 写一个 V_n migration 给历史 NULL 行回填默认值;(c) 收紧 schema 把这几列改为 NOT NULL(视业务决策)。
+3. **iParentId 的 `FieldStrategy.IGNORED` 全局副作用**:本期所有 update 路径都走 load-then-modify 全量回填模式,所以安全;但未来若新增"只更新单字段"的 partial update 路径需特别注意——直接调 `updateById(patchEntity)` 会把 iParentId 静默清空。建议未来用 `LambdaUpdateWrapper.set(...)` 模式(参考 REQ-MOD-003 delete 实现)。
+4. **REQ-MOD-004 同级排序 IT 缺失**:spec § 验收 #7 同级排序仅在单测覆盖,端到端 IT 未直接断言。Jackson 默认保留 List 顺序,风险低,但理论上有序列化层差异的可能。下一模块或 docs sweep 时补一个端到端用例。
+5. **keyword 通配符未转义**:spec § 边界已声明本期不处理 `%` / `_`;业务模块名通常不含这些字符。后续若需要严格语义可在 service 层加 `escape \`。
+6. **覆盖率工具未引入**:docs/04 § 零 技术栈未列 JaCoCo / SonarQube;本期 76 测试由人工分析覆盖。
+7. **docs/05 § REQ-MOD-003 `data.references` 描述与实现不一致**:契约写 40912 响应附 `data.references` 列出阻塞引用清单,但实现只返回错误码 + 文案;BizException 没有 data 通道。后续 docs sweep 或 REQ-USR-004 引入完整 data payload 时统一对齐。
+
+## ⑪ 下一模块预览
+
+按 docs/02 § 二 顺序,下一个模块是 **module_usr 用户管理**(4 个 REQ:USR-001 用户新增 / USR-002 用户修改 / USR-003 用户查询 / USR-004 用户登录)。
+
+关键关注点:
+- REQ-USR-004 是项目 SecurityConfig + JWT + 登录上下文 + 多租户拦截器**首次落地**点,会同时收紧 module_mod 的 4 个 permitAll 端点。
+- REQ-USR-001 写 tUser + tUserPermission 关联表(多对多),需联动 tStaff(员工选)+ tPermissionCategory(权限分类只读字典)。tStaff 与 tPermissionCategory 当前**无增删改接口**,作为只读字典——若实施过程中发现需要增删改,可能要拆出新模块(docs/03 § tStaff / § tPermissionCategory 业务注记已点到此问题)。
+- REQ-USR-003 查询接口的列表 VO 包含跨表 JOIN(员工名 / 部门来自 tStaff),需要在 ModuleMapper 模式之外引入 selectJoinList 或 自定义 ResultMap。
+
+## ⑫ MR 链接
+
+http://git.xlyprint.cn/zhuzc/test2/merge_requests/1 (!1)
diff --git a/docs/superpowers/module-reports/module_mod-test-gate.md b/docs/superpowers/module-reports/module_mod-test-gate.md
new file mode 100644
index 0000000..50d8717
--- /dev/null
+++ b/docs/superpowers/module-reports/module_mod-test-gate.md
@@ -0,0 +1,34 @@
+## Local test gate — module_mod
+
+执行时间: 2026-05-06T20:07:28+08:00
+
+### scripts/test.sh (subagent)
+- 子会话: aba8704557f137ad2
+- 命令: `./scripts/test.sh`(setup-test-db.sh DROP+CREATE → mvn build/lint/test → frontend skip → e2e 略 → setup-test-db.sh reset)
+- 退出码: 0
+- 通过: 76 / 失败: 0
+- 关键 stdout (≤30 行):
+
+```
+[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.046 s -- in com.xly.erp.common.exception.GlobalExceptionHandlerTest
+[INFO]
+[INFO] Results:
+[INFO]
+[INFO] Tests run: 76, Failures: 0, Errors: 0, Skipped: 0
+[INFO]
+[INFO] ------------------------------------------------------------------------
+[INFO] BUILD SUCCESS
+[INFO] ------------------------------------------------------------------------
+[INFO] Total time: 15.358 s
+[INFO] Finished at: 2026-05-06T20:07:28+08:00
+[INFO] ------------------------------------------------------------------------
+[test.sh] skip frontend test
+[test.sh] 5/6 E2E
+[test.sh] e2e 略
+[test.sh] 6/6 reset test db
+[setup-test-db] 即将 DROP + CREATE `xlyweberp_vibe_erp_test` on 118.178.19.35:3318
+[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts
+[test.sh] GREEN
+```
+
+结论: green
diff --git a/docs/superpowers/plans/2026-05-06-REQ-MOD-001.md b/docs/superpowers/plans/2026-05-06-REQ-MOD-001.md
new file mode 100644
index 0000000..af0871c
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-06-REQ-MOD-001.md
@@ -0,0 +1,339 @@
+---
+req_id: REQ-MOD-001
+date: 2026-05-06
+spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-001.md
+---
+
+# REQ-MOD-001 模块新增 Implementation Plan
+
+> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现 `POST /api/modules` 接口,把一条新业务模块写入 `tModule`,并顺带建立服务于本 REQ 的最小 Spring Boot 骨架。
+
+**Architecture:** 标准三层(Controller → Service → Mapper)。Spring Boot 3 + MyBatis-Plus + Flyway 自动 apply migrations。鉴权暂以 `SecurityConfig` permitAll 占位,REQ-USR-004 时回头收紧。统一响应 `ApiResponse`、统一异常 `BizException` + `GlobalExceptionHandler`。
+
+**Tech Stack:** Spring Boot 3.x、MyBatis-Plus(spring-boot3-starter)、Flyway 10.x、MySQL 8、JUnit 5 + Spring Boot Test + MockMvc + Spring Security Test、Hutool、MapStruct(可选)、Maven 3.9。
+
+---
+
+## Schema 改动
+
+无(`tModule` 已由 A4 `V1__initial_schema.sql` 创建)。
+
+## 文件变更清单
+
+- `backend/pom.xml` — 创建(Spring Boot 父 pom + 依赖清单)
+- `backend/src/main/java/com/xly/erp/ErpApplication.java` — 创建(启动类)
+- `backend/src/main/resources/application.yml` — 创建(DB / Flyway / MyBatis-Plus / 端口配置,从环境变量注入)
+- `backend/src/main/resources/application-test.yml` — 创建(测试 profile,连 DB_SCHEMA test 库)
+- `backend/src/main/java/com/xly/erp/common/response/ApiResponse.java` — 创建(统一响应包装)
+- `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 创建(错误码枚举)
+- `backend/src/main/java/com/xly/erp/common/exception/BizException.java` — 创建(业务异常)
+- `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` — 创建(@RestControllerAdvice)
+- `backend/src/main/java/com/xly/erp/config/SecurityConfig.java` — 创建(permitAll 临时配置)
+- `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java` — 创建(分页插件,本 REQ 不用,REQ-MOD-004 用,本任务先建空骨架)
+- `backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java` — 创建(@TableName("tModule"))
+- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 创建(继承 BaseMapper)
+- `backend/src/main/resources/mapper/mod/ModuleMapper.xml` — 创建(空骨架,本 REQ 仅用 BaseMapper 默认 SQL)
+- `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java` — 创建(入参 + Bean Validation)
+- `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java` — 创建(出参)
+- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 创建(接口)
+- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 创建(实现,含 create 方法)
+- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 创建(POST /api/modules)
+- `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 创建(单元)
+- `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java` — 创建(单元)
+- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 创建(@SpringBootTest 集成)
+- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 创建(单元 + Mockito)
+- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 创建(MockMvc + @SpringBootTest)
+- `backend/src/test/resources/application-test.yml` — 创建(测试 profile 副本,便于 IDE 跑测试)
+- `.env.local` — 不修改(已有 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_SCHEMA / JWT_SECRET)
+
+---
+
+## 任务步骤
+
+### Task 1: 引导 Spring Boot 项目骨架(compile + boot 通过)
+
+**Files:**
+- Create: `backend/pom.xml`
+- Create: `backend/src/main/java/com/xly/erp/ErpApplication.java`
+- Create: `backend/src/main/resources/application.yml`
+- Create: `backend/src/main/resources/application-test.yml`
+- Create: `backend/src/test/resources/application-test.yml`(与上一行同内容,Spring Boot 测试时从 test/resources 优先加载)
+- Create: `backend/src/main/java/com/xly/erp/ErpApplicationTest.java`(验证 context 启动)
+
+**API shape:**
+- `ErpApplication` 主类标 `@SpringBootApplication`、`@MapperScan("com.xly.erp.**.mapper")`
+- `application.yml` 通过 `${DB_HOST}` / `${DB_PORT}` / `${DB_USER}` / `${DB_PASSWORD}` / `${DB_SCHEMA}` / `${JWT_SECRET}` 占位(值由本地启动脚本或 IDE EnvFile 插件从 `.env.local` 注入;详见 docs/04 § 3.5)
+- 启用 Flyway:`spring.flyway.locations=classpath:db/migration` —— 但项目 migrations 在 `sql/migrations/`;改为 `spring.flyway.locations=filesystem:../sql/migrations` 让 backend 直接复用仓库根的 V*.sql
+
+**pom.xml 锁定依赖**(写死,不让 TDD 自由选):
+- `spring-boot-starter-parent` 3.2.x
+- `spring-boot-starter-web`、`-validation`、`-security`、`-test`
+- `spring-security-test`
+- `mybatis-plus-spring-boot3-starter` 最新稳定版(写明确版本号,本任务由 TDD 确认 mvn central 最新可用)
+- `flyway-core` + `flyway-mysql` 10.x
+- `mysql-connector-j` 8.x
+- `org.projectlombok:lombok`(编译期)+ `lombok-mapstruct-binding`
+- `org.mapstruct:mapstruct` 1.5.x + `mapstruct-processor`
+- `cn.hutool:hutool-all` 5.x
+- Java 编译目标 17
+
+- [ ] **Step 1.1 写测试**
+ - 文件: `backend/src/test/java/com/xly/erp/ErpApplicationTest.java`
+ - 测试名: `ErpApplicationTest#contextLoads`
+ - 内容: 空 `@SpringBootTest @ActiveProfiles("test")` + 空 test 方法
+ - 意图: ApplicationContext 能在 test profile 下成功启动 + Flyway 能 apply V1(apply 到现有 test schema 应是 no-op,因为表已存在;Flyway 跑完会建 `flyway_schema_history` 表 + 注册 V1 已 baseline)
+ - 子会话确认 FAIL(项目还没建好)
+
+- [ ] **Step 1.2 创建 pom.xml**
+ - 仅声明依赖、Java 17、surefire 配置
+ - `mvn -B -f backend/pom.xml dependency:resolve` 应通过
+
+- [ ] **Step 1.3 创建 ErpApplication + application.yml**
+ - `ErpApplication` 仅做启动 + `@MapperScan`
+ - `application.yml` 主键:`server.port=8080`、`spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev}`、datasource、mybatis-plus、flyway
+ - `application-test.yml`:override `spring.flyway.baseline-on-migrate=true` + `spring.flyway.baseline-version=0`(让 Flyway 把当前已存在的 schema 作为 baseline,再追 apply)
+ - 子会话 `cd backend && mvn -B test -Dtest=ErpApplicationTest#contextLoads -DSPRING_PROFILES_ACTIVE=test` 应 PASS
+
+- [ ] **Step 1.4 提交**
+ - `git add backend/pom.xml backend/src/main/java/com/xly/erp/ErpApplication.java backend/src/main/resources/application*.yml backend/src/test/resources/application*.yml backend/src/test/java/com/xly/erp/ErpApplicationTest.java`
+ - `git commit -m "chore(mod): bootstrap spring boot skeleton REQ-MOD-001"`
+
+---
+
+### Task 2: 统一响应与错误码(ApiResponse + ErrorCode)
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/common/response/ApiResponse.java`
+- Create: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
+- Test: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`
+
+**API shape:**
+- `ApiResponse`:字段 `int code`、`String message`、`T data`、`long timestamp`;静态工厂 `ok(T data)`、`ok(String message, T data)`、`fail(ErrorCode ec)`、`fail(int code, String message)`
+- `ErrorCode` 枚举(int code, String message)至少包含:
+ - `SUCCESS(200, "操作成功")`
+ - `PARAM_INVALID(40010, "参数错误")`
+ - `MODULE_PARENT_NOT_FOUND(40411, "父模块不存在或已删除")`
+ - `MODULE_PROCEDURE_NAME_DUPLICATE(40911, "存储过程名称已存在")`
+ - `INTERNAL_ERROR(50000, "服务器内部错误")`
+
+**锁定常量**(跨模块复用,写死):
+```
+SUCCESS = 200
+PARAM_INVALID = 40010
+MOD_PARENT_NOT_FOUND = 40411
+MOD_PROC_NAME_DUP = 40911
+INTERNAL_ERROR = 50000
+```
+
+- [ ] **Step 2.1 写失败测试**
+ - 测试名: `ApiResponseTest#ok_setsCode200AndDataAndTimestamp`、`ApiResponseTest#fail_mapsErrorCodeFields`
+ - 意图: `ok(data)` → code=200,message="操作成功",data=入参,timestamp 在合理范围;`fail(ErrorCode.PARAM_INVALID)` → code=40010,message="参数错误"
+ - 子会话 PASS:FAIL(class 不存在)
+
+- [ ] **Step 2.2 实现 ApiResponse + ErrorCode**
+ - 用 Lombok `@Data` 减少样板,但保留显式工厂方法
+ - 子会话 PASS
+
+- [ ] **Step 2.3 提交**
+ - `git commit -m "feat(common): unified ApiResponse and ErrorCode REQ-MOD-001"`
+
+---
+
+### Task 3: 业务异常 + 全局异常处理器
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/common/exception/BizException.java`
+- Create: `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java`
+- Test: `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java`(用 MockMvc + 一个临时 controller 触发各类异常)
+
+**API shape:**
+- `BizException extends RuntimeException`:构造 `BizException(ErrorCode ec)`、`BizException(ErrorCode ec, String detail)`;getter: `code()`, `message()`
+- `GlobalExceptionHandler` 处理:
+ - `BizException` → `ApiResponse.fail(ec)` 200 OK(业务错误也走 200 + 内部 code,docs/05 § 全局约定)
+ - `MethodArgumentNotValidException`(@Valid 失败)→ 提取 fieldError,组合 `ApiResponse.fail(PARAM_INVALID, ": ")`
+ - `Exception`(兜底)→ `ApiResponse.fail(INTERNAL_ERROR)` + log error;**响应不含堆栈**(docs/04 § 1.4)
+
+- [ ] **Step 3.1 写失败测试**
+ - 测试名(4 个):
+ - `GlobalExceptionHandlerTest#bizException_returns200WithBizCode`
+ - `GlobalExceptionHandlerTest#validationException_returns200WithParamInvalidCode`
+ - `GlobalExceptionHandlerTest#uncaughtException_returns200WithInternalErrorCode`
+ - `GlobalExceptionHandlerTest#response_doesNotContainStackTrace`
+ - 测试方式: `@WebMvcTest(controllers=DummyController.class)` 内置一个 `DummyController` 故意抛各类异常,断言 `MockMvc.perform(...).andExpect(jsonPath("$.code").value(...))`
+ - 子会话: FAIL
+
+- [ ] **Step 3.2 实现 BizException + Handler**
+ - 子会话: PASS
+
+- [ ] **Step 3.3 提交**
+ - `git commit -m "feat(common): biz exception and global handler REQ-MOD-001"`
+
+---
+
+### Task 4: SecurityConfig(permitAll 占位)
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java`
+- Test: `backend/src/test/java/com/xly/erp/config/SecurityConfigTest.java`
+
+**API shape:**
+- `SecurityFilterChain securityFilterChain(HttpSecurity http)`:禁用 CSRF(API 项目)、禁用默认表单登录、所有 `/api/**` permitAll
+- 显式注释:`// REQ-USR-004 完成后改为 .authenticated() + JWT filter`
+
+- [ ] **Step 4.1 写失败测试**
+ - 测试名: `SecurityConfigTest#anyApiEndpoint_isPermittedWithoutAuth`
+ - 测试方式: `@SpringBootTest @AutoConfigureMockMvc` + 在 controller package 临时加一个 `/api/__ping` GET(**或**复用 Task 3 的 `DummyController`),无 token 请求应返回 200 而非 401/403
+ - 子会话: FAIL(默认 Spring Security 401)
+
+- [ ] **Step 4.2 实现 SecurityConfig**
+ - 子会话: PASS
+
+- [ ] **Step 4.3 提交**
+ - `git commit -m "feat(config): permitAll security skeleton REQ-MOD-001"`
+
+---
+
+### Task 5: ModuleEntity + ModuleMapper(Mapper 集成测试落库)
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java`
+- Create: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java`
+- Create: `backend/src/main/resources/mapper/mod/ModuleMapper.xml`(仅头部 ``,预留扩展)
+- Test: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java`
+
+**API shape:**
+- `@TableName("tModule")` ;字段名严格匹配 docs/03(保留匈牙利前缀,使用 `@TableField` 显式映射),主键 `iIncrement` 用 `@TableId(type = IdType.AUTO)`
+- 列 ↔ Java 属性命名采用**保留前缀**(如 `iIncrement` Java 字段名也叫 `iIncrement`,方便对照 schema),与 docs/04 § 1.2 命名约定中的"小驼峰"略有出入——本约束**优先**:DB schema SSoT 是 docs/03,Java 字段直接复用列名以避免双向映射歧义。Lombok `@Data`。
+- `ModuleMapper extends BaseMapper`,本 REQ 仅用 `insert` + `selectById` + `selectCount`(条件);不写自定义 SQL
+
+- [ ] **Step 5.1 写失败测试**
+ - 测试名: `ModuleMapperIT#insertAndSelectById_persistsAllFields`、`ModuleMapperIT#selectCountByProcedureName_returnsExisting`
+ - 测试方式: `@SpringBootTest @ActiveProfiles("test")` + `@Transactional`(自动回滚),DI `ModuleMapper`,构造 entity 调 `insert`,再 `selectById` 断言全部字段;用 `selectCount(new LambdaQueryWrapper().eq(ModuleEntity::getSProcedureName, ...))` 验证唯一查询
+ - 子会话: FAIL
+
+- [ ] **Step 5.2 实现 Entity + Mapper**
+ - 子会话: PASS(DDL 已存在,直接 insert)
+
+- [ ] **Step 5.3 提交**
+ - `git commit -m "feat(mod): module entity and mapper REQ-MOD-001"`
+
+---
+
+### Task 6: DTO + VO
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java`
+- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java`
+- Test: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleCreateDTOValidationTest.java`
+
+**API shape:**
+
+`ModuleCreateDTO` 字段(带 Bean Validation):
+- `@NotBlank @Pattern(regexp="^(手机端|前端业务|系统配置|接口)$") String sDisplayType`
+- `@NotBlank @Size(max=100) String sProcedureName`
+- `@NotBlank @Size(max=50) String sModuleType`
+- `@NotBlank @Size(max=50) String sManageDeptEn`
+- `Boolean bShowPermission`(可空,service 层 default false)
+- `@NotBlank @Size(max=100) String sModuleNameZh`
+- `Integer iParentId`(可空)
+- `@Min(0) Integer iSortOrder`(可空,default 0)
+
+`ModuleVO` 字段(11 个,见 spec § 输出):`iIncrement`, `sDisplayType`, `sProcedureName`, `sModuleType`, `sManageDeptEn`, `bShowPermission`, `sModuleNameZh`, `iParentId`, `iSortOrder`, `tCreateDate`, `bDeleted`。
+
+> Entity → VO 的转换写在 `ModuleVO` 的静态工厂 `from(ModuleEntity)` 里,不引入 MapStruct 单独 mapper 类(YAGNI;REQ 多了再抽)。
+
+- [ ] **Step 6.1 写失败测试**
+ - 测试名: `ModuleCreateDTOValidationTest#blankRequiredFields_yieldsViolations`、`ModuleCreateDTOValidationTest#invalidDisplayTypeEnum_yieldsViolation`、`ModuleCreateDTOValidationTest#allValidFields_yieldsNoViolations`
+ - 测试方式: `Validation.buildDefaultValidatorFactory().getValidator()` + 断言 violation 集合
+ - 子会话: FAIL
+
+- [ ] **Step 6.2 实现 DTO + VO**
+ - 子会话: PASS
+
+- [ ] **Step 6.3 提交**
+ - `git commit -m "feat(mod): module create DTO and VO REQ-MOD-001"`
+
+---
+
+### Task 7: ModuleService.create(核心业务)
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`
+- Create: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`
+- Test: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(Mockito 单元测试,mock ModuleMapper)
+
+**API shape:**
+- `interface ModuleService { ModuleVO create(ModuleCreateDTO dto); }`
+- `@Service @RequiredArgsConstructor class ModuleServiceImpl implements ModuleService`,字段 `private final ModuleMapper moduleMapper;`
+- `create` 方法步骤(写在 plan 锁定,不让 TDD 自由发挥逻辑顺序):
+ 1. 若 `dto.iParentId != null`:`moduleMapper.selectById(iParentId)`,结果为 `null` 或 `bDeleted == true` → 抛 `BizException(MOD_PARENT_NOT_FOUND)`
+ 2. 唯一性预检:`moduleMapper.selectCount(LambdaQueryWrapper.eq(ModuleEntity::getSProcedureName, dto.sProcedureName).eq(ModuleEntity::getBDeleted, 0))` > 0 → 抛 `BizException(MOD_PROC_NAME_DUP)`
+ 3. 构造 `ModuleEntity`:复制 dto 字段;`bShowPermission` null → `false`;`iSortOrder` null → `0`;`tCreateDate = LocalDateTime.now()`;`bDeleted = 0`;`sBrandsId/sSubsidiaryId/sCreatedBy/sId/tDeletedDate/sDeletedBy = null`
+ 4. `moduleMapper.insert(entity)`;MyBatis-Plus 回写 `iIncrement` 到 entity
+ 5. 捕获 `DuplicateKeyException`(并发下 uk_procedure_name 兜底)→ 抛 `BizException(MOD_PROC_NAME_DUP)`
+ 6. `return ModuleVO.from(entity)`
+- 标 `@Transactional(rollbackFor = Exception.class)`
+
+- [ ] **Step 7.1 写失败测试(6 个)**
+ - `ModuleServiceImplTest#create_rootModule_returnsVOWithGeneratedId`
+ - `ModuleServiceImplTest#create_childModule_validatesParentExists`
+ - `ModuleServiceImplTest#create_parentNotFound_throwsBizException40411`
+ - `ModuleServiceImplTest#create_parentSoftDeleted_throwsBizException40411`
+ - `ModuleServiceImplTest#create_duplicateProcedureName_preCheck_throwsBizException40911`
+ - `ModuleServiceImplTest#create_duplicateProcedureName_concurrentInsert_throwsBizException40911`(mock `moduleMapper.insert` 抛 `DuplicateKeyException`)
+ - 测试方式: `@ExtendWith(MockitoExtension.class)` + `@InjectMocks ModuleServiceImpl` + `@Mock ModuleMapper`
+ - 子会话: FAIL
+
+- [ ] **Step 7.2 实现 ModuleService + ModuleServiceImpl**
+ - 子会话: PASS
+
+- [ ] **Step 7.3 提交**
+ - `git commit -m "feat(mod): create module service REQ-MOD-001"`
+
+---
+
+### Task 8: ModuleController + 端到端集成测试
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java`
+- Test: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`
+
+**API shape:**
+- `@RestController @RequestMapping("/api/modules")`
+- `@PostMapping public ApiResponse create(@Valid @RequestBody ModuleCreateDTO dto)` → `ApiResponse.ok(moduleService.create(dto))`
+- 类上保留注释:`// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:CREATE')")`
+
+- [ ] **Step 8.1 写失败测试(6 个验收用例)**
+ - `ModuleControllerIT#post_validRootModule_returns200WithVO`
+ - `ModuleControllerIT#post_validChildModule_returns200`
+ - `ModuleControllerIT#post_duplicateProcedureName_returns200WithCode40911`
+ - `ModuleControllerIT#post_parentNotFound_returns200WithCode40411`
+ - `ModuleControllerIT#post_missingRequiredField_returns200WithCode40010`
+ - `ModuleControllerIT#post_invalidDisplayTypeEnum_returns200WithCode40010`
+ - 测试方式: `@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Transactional`(自动回滚),用 `MockMvc` 发 POST,断言 `$.code` / `$.data.iIncrement` / `$.data.sProcedureName` 等
+ - 子会话: FAIL
+
+- [ ] **Step 8.2 实现 ModuleController**
+ - 子会话: PASS
+
+- [ ] **Step 8.3 全量 backend 测试**
+ - `cd backend && mvn -B test -DSPRING_PROFILES_ACTIVE=test`
+ - 期望全绿(Task 1-8 累计 ~25 个测试方法)
+
+- [ ] **Step 8.4 提交**
+ - `git commit -m "feat(mod): POST /api/modules controller REQ-MOD-001"`
+
+---
+
+## 提交计划
+
+- `chore(mod): bootstrap spring boot skeleton REQ-MOD-001`(覆盖 Task 1)
+- `feat(common): unified ApiResponse and ErrorCode REQ-MOD-001`(覆盖 Task 2)
+- `feat(common): biz exception and global handler REQ-MOD-001`(覆盖 Task 3)
+- `feat(config): permitAll security skeleton REQ-MOD-001`(覆盖 Task 4)
+- `feat(mod): module entity and mapper REQ-MOD-001`(覆盖 Task 5)
+- `feat(mod): module create DTO and VO REQ-MOD-001`(覆盖 Task 6)
+- `feat(mod): create module service REQ-MOD-001`(覆盖 Task 7)
+- `feat(mod): POST /api/modules controller REQ-MOD-001`(覆盖 Task 8)
diff --git a/docs/superpowers/plans/2026-05-06-REQ-MOD-002.md b/docs/superpowers/plans/2026-05-06-REQ-MOD-002.md
new file mode 100644
index 0000000..20cc428
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-06-REQ-MOD-002.md
@@ -0,0 +1,195 @@
+---
+req_id: REQ-MOD-002
+date: 2026-05-06
+spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-002.md
+---
+
+# REQ-MOD-002 模块修改 Implementation Plan
+
+> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现 `PUT /api/modules/{id}` 接口:复用 REQ-MOD-001 已落地的 entity / mapper / common / config 体系,仅追加 ModuleUpdateDTO + Service.update + Controller 方法 + 错误码常量。
+
+**Architecture:** Service 层先校验目标存在性(按 PK + bDeleted=0 查),再校验 iParentId 自引用 / 父不存在 / 父是后代环路(沿父链 walk up,深度上限 5),最后 `updateById` 落库。环路检查走"自下而上"路径,O(depth) 复杂度。
+
+**Tech Stack:** 沿用 REQ-MOD-001(Spring Boot 3.2.5 + MyBatis-Plus 3.5.7 + JUnit 5 + Mockito)。
+
+---
+
+## Schema 改动
+
+无(`tModule` schema 已由 V1 提供,本 REQ 不加列)。
+
+## 文件变更清单
+
+- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `MOD_NOT_FOUND(40421, "模块不存在或已删除")` 和 `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")`
+- 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` — PUT 入参(无 sProcedureName)
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `update(Integer id, ModuleUpdateDTO dto): ModuleVO`
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update` 方法(含父校验、环路检查、字段合并)
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/{id}") ModuleVO update(...)`
+- 创建: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` — DTO Bean Validation 单测
+- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 `update_*` 系列单元测试(mock ModuleMapper)
+- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 `put_*` 系列集成测试
+
+## 任务步骤
+
+### Task 1: 追加错误码常量
+
+**Files:**
+- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
+- Test: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`(追加断言)
+
+**API shape:**
+- `MOD_NOT_FOUND(40421, "模块不存在或已删除")`
+- `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")`
+
+- [ ] **Step 1.1 写失败断言**
+ - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加:
+ - `assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421);`
+ - `assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921);`
+ - 子会话确认 FAIL(编译错:枚举常量不存在)
+
+- [ ] **Step 1.2 追加枚举常量**
+
+- [ ] **Step 1.3 子会话确认 ApiResponseTest 全绿(5 个测试,第 5 个含新断言)**
+
+- [ ] **Step 1.4 提交**
+ - `git commit -m "feat(common): error codes for module update REQ-MOD-002"`
+
+---
+
+### Task 2: ModuleUpdateDTO + 校验单测
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java`
+- Test: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java`
+
+**API shape:**
+- 字段(与 REQ-MOD-001 的 `ModuleCreateDTO` 相比剥除 `sProcedureName`;其余 7 个字段、注解、长度规则**完全一致**):
+ - `@NotBlank @Pattern(...) String sDisplayType`
+ - `@NotBlank @Size(max=50) String sModuleType`
+ - `@NotBlank @Size(max=50) String sManageDeptEn`
+ - `Boolean bShowPermission`(可空)
+ - `@NotBlank @Size(max=100) String sModuleNameZh`
+ - `Integer iParentId`(可空)
+ - `@Min(0) Integer iSortOrder`(可空)
+
+- [ ] **Step 2.1 写失败测试(4 个)**
+ - `ModuleUpdateDTOValidationTest#allValidFields_yieldsNoViolations`
+ - `ModuleUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(5 个 @NotBlank)
+ - `ModuleUpdateDTOValidationTest#invalidDisplayTypeEnum_yieldsViolation`
+ - `ModuleUpdateDTOValidationTest#negativeSortOrder_yieldsViolation`
+ - 子会话: FAIL(DTO 不存在)
+
+- [ ] **Step 2.2 实现 ModuleUpdateDTO**
+ - 子会话: PASS
+
+- [ ] **Step 2.3 提交**
+ - `git commit -m "feat(mod): module update DTO REQ-MOD-002"`
+
+---
+
+### Task 3: ModuleService.update — 业务逻辑(mock 单元测试)
+
+**Files:**
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`(追加方法签名)
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`(实现 + 私有 helper)
+- Test: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(追加 8 个测试)
+
+**API shape:**
+- `interface ModuleService` 追加:`ModuleVO update(Integer id, ModuleUpdateDTO dto)`
+- 实现步骤(写在 plan 锁定):
+ 1. `target = moduleMapper.selectById(id)`;`target == null || target.bDeleted == true` → 抛 `BizException(MOD_NOT_FOUND)`
+ 2. iParentId 校验(仅当 `dto.iParentId != null`):
+ - 等于 `id` → `BizException(MOD_PARENT_LOOP)`
+ - `parent = moduleMapper.selectById(dto.iParentId)`;`parent == null || parent.bDeleted` → `BizException(MOD_PARENT_NOT_FOUND)`
+ - **环路检查**(沿父链 walk up,从 `dto.iParentId` 出发,最多 5 层):
+ ```
+ cur = parent; depth = 1
+ while cur.iParentId != null && depth <= 5:
+ if cur.iParentId == id: throw MOD_PARENT_LOOP
+ cur = moduleMapper.selectById(cur.iParentId)
+ if cur == null or cur.bDeleted: break // 链断在已删除节点,视为非环
+ depth += 1
+ ```
+ depth 超 5 仍未结束 → 视为深度违规,但 docs/03 业务注记说"深度上限 5"是预期不变量;本期不强制拦截更深,仅环路检查。
+ 3. 字段合并到 `target`:
+ - 必填字段(5 个 @NotBlank + sDisplayType):直接覆盖(dto 为 null 不可能,validation 已挡)
+ - `bShowPermission`:dto 非 null 覆盖;null 保留 `target.bShowPermission`
+ - `iParentId`:dto 中存在该 key 即覆盖(含 null 设根)。**实现细节**:DTO 用 Integer,区分"未传"和"显式传 null"在 Spring MVC 反序列化层不区分(都是 Integer null)。本 REQ 取语义"传 null = 设根","key 缺失 = 设根"等价。
+ - `iSortOrder`:dto 非 null 覆盖;null 保留
+ - `sProcedureName` / `iIncrement` / `tCreateDate` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`:**完全不动**(沿用 target 上的原值)
+ 4. `moduleMapper.updateById(target)`
+ 5. `return ModuleVO.from(target)`
+
+- 标 `@Transactional(rollbackFor = Exception.class)`
+
+- [ ] **Step 3.1 写失败测试(8 个)**
+ - `update_targetNotFound_throws40421`:`selectById(id)` → null
+ - `update_targetSoftDeleted_throws40421`:`selectById(id).bDeleted=true`
+ - `update_parentSelfReference_throws40921`:`dto.iParentId == id`
+ - `update_parentNotFound_throws40411`:`selectById(parentId)` → null
+ - `update_parentIsDescendant_throws40921`:构造 grandparent(id)→parent→child 链,dto.iParentId=child.id
+ - `update_full_returnsVOWithUpdatedFields`:mock target 与 update 路径,断言传给 `updateById` 的 entity:
+ - 已修改字段:sDisplayType / sModuleType / sManageDeptEn / sModuleNameZh / iParentId / iSortOrder / bShowPermission
+ - 保持原值:sProcedureName / iIncrement / tCreateDate / sCreatedBy / bDeleted
+ - `update_partialNullFields_keepsOriginalValues`:dto 中 bShowPermission=null + iSortOrder=null,断言落库 entity 的对应字段保留 target 原值
+ - `update_clearParent_setsParentToNull`:dto.iParentId=null,target.iParentId 原本是 7;断言 entity.iParentId == null
+ - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor` 捕获 `updateById` 实参
+ - 子会话: FAIL(方法不存在)
+
+- [ ] **Step 3.2 实现 ModuleService 接口签名 + ModuleServiceImpl.update**
+
+- [ ] **Step 3.3 子会话确认全部 mock 单测通过**
+ - 全量 `mvn -B test -Dtest=ModuleServiceImplTest` 应绿(含 REQ-MOD-001 的 6 个 + 新增 8 个 = 14 个)
+
+- [ ] **Step 3.4 提交**
+ - `git commit -m "feat(mod): update module service REQ-MOD-002"`
+
+---
+
+### Task 4: ModuleController PUT 端点 + 端到端 IT
+
+**Files:**
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java`
+- Test: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`(追加 8 个集成用例)
+
+**API shape:**
+- 类上保留 `@RequestMapping("/api/modules")`
+- 新方法:
+ ```
+ @PutMapping("/{id}")
+ public ApiResponse update(@PathVariable Integer id, @Valid @RequestBody ModuleUpdateDTO dto)
+ ```
+- 注释:`// REQ-MOD-002 模块修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`
+
+- [ ] **Step 4.1 写失败测试(8 个)**
+ - `put_validUpdate_returns200`:先用 ModuleMapper 直接 insert 一条,再 PUT 改若干字段,断言响应 + DB 字段
+ - `put_setParentToNull_clearsParent`:先建 parent + child(iParentId=parent.id),PUT child 把 iParentId=null,断言 DB 中 child.iParentId IS NULL
+ - `put_targetNotFound_returns40421`:`PUT /api/modules/999999`
+ - `put_parentNotFound_returns40411`:`iParentId=999999`
+ - `put_parentSelfRef_returns40921`:`PUT /api/modules/{id}` body `iParentId={id}`
+ - `put_parentIsDescendant_returns40921`:建 grandparent→parent→child 三层;PUT grandparent 把 iParentId=child.id
+ - `put_missingRequired_returns40010`:缺 sModuleNameZh
+ - `put_ignoresProcedureNameField_doesNotChange`:body 含 `"sProcedureName":"hijack"`,断言 DB 中 sProcedureName 仍为原值
+ - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` 直接预置数据
+ - 子会话: FAIL(端点不存在)
+
+- [ ] **Step 4.2 实现 PUT 端点**
+ - 子会话: PASS
+
+- [ ] **Step 4.3 跑全量 backend 测试**
+ - `cd backend && mvn -B test`
+ - 期望累计 22 + 4(DTO valid) + 8(service update) + 8(controller put) = 42 个,全绿
+
+- [ ] **Step 4.4 提交**
+ - `git commit -m "feat(mod): PUT /api/modules/{id} controller REQ-MOD-002"`
+
+---
+
+## 提交计划
+
+- `feat(common): error codes for module update REQ-MOD-002`(覆盖 Task 1)
+- `feat(mod): module update DTO REQ-MOD-002`(覆盖 Task 2)
+- `feat(mod): update module service REQ-MOD-002`(覆盖 Task 3)
+- `feat(mod): PUT /api/modules/{id} controller REQ-MOD-002`(覆盖 Task 4)
diff --git a/docs/superpowers/plans/2026-05-06-REQ-MOD-003.md b/docs/superpowers/plans/2026-05-06-REQ-MOD-003.md
new file mode 100644
index 0000000..3e68cc7
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-06-REQ-MOD-003.md
@@ -0,0 +1,148 @@
+---
+req_id: REQ-MOD-003
+date: 2026-05-06
+spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-003.md
+---
+
+# REQ-MOD-003 模块删除 Implementation Plan
+
+> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现 `DELETE /api/modules/{id}`:在校验子模块未删除引用的前提下,对目标做软删除(写 bDeleted/tDeletedDate/sDeletedBy)。
+
+**Architecture:** 复用 REQ-MOD-001/002 已建立的体系。Service 先 selectById 校验目标存在 + 未删除(40421),再 selectCount 子模块未删除引用(40912),最后用 `mapper.update(entity, wrapper)` 加 `bDeleted = 0` 条件做并发安全的软删除(影响 0 行视为并发删除 → 40421)。返回精简 VO。
+
+**Tech Stack:** 沿用 REQ-MOD-001/002(Spring Boot 3.2.5 + MyBatis-Plus 3.5.7 + JUnit 5 + Mockito)。
+
+---
+
+## Schema 改动
+
+无(软删除字段 `bDeleted` / `tDeletedDate` / `sDeletedBy` 在 V1 已建)。
+
+## 文件变更清单
+
+- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除")`
+- 创建: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java` — 精简 VO(iIncrement + bDeleted)
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `delete(Integer id): ModuleDeleteResultVO`
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 delete
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@DeleteMapping("/{id}")`
+- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 1 个错误码断言
+- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 6 个 delete 单测
+- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 个 DELETE 集成测试
+
+## 任务步骤
+
+### Task 1: 错误码 + 精简 VO
+
+**Files:**
+- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
+- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java`
+- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`
+
+**API shape:**
+- `MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除")`
+- `ModuleDeleteResultVO` 字段:`Integer iIncrement` + `Boolean bDeleted`(带 `@Data` + 静态工厂 `of(Integer id, Boolean deleted)`)
+
+- [ ] **Step 1.1 写失败断言**
+ - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加:
+ `assertThat(ErrorCode.MOD_HAS_REFERENCES.getCode()).isEqualTo(40912);`
+ - 子会话 FAIL(枚举常量不存在)
+
+- [ ] **Step 1.2 追加枚举常量 + 创建 VO**
+
+- [ ] **Step 1.3 子会话验证 ApiResponseTest 全绿**
+
+- [ ] **Step 1.4 提交**
+ - `git commit -m "feat(common): error code MOD_HAS_REFERENCES + delete VO REQ-MOD-003"`
+
+---
+
+### Task 2: ModuleService.delete — 业务逻辑(mock 单元测试)
+
+**Files:**
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`(追加方法签名)
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`
+- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(追加 6 个测试)
+
+**API shape:**
+- `interface ModuleService` 追加:`ModuleDeleteResultVO delete(Integer id)`
+- 实现步骤(写在 plan 锁定):
+ 1. `target = moduleMapper.selectById(id)`;`target == null || target.bDeleted == true` → `BizException(MOD_NOT_FOUND)`(40421)
+ 2. 子模块计数:`childCount = moduleMapper.selectCount(LambdaQueryWrapper.eq(iParentId, id).eq(bDeleted, false))`
+ - `childCount > 0` → `BizException(MOD_HAS_REFERENCES)`(40912)
+ 3. 构造**只含软删除三件套 + 主键**的更新 entity(避免触碰其他字段):
+ ```
+ ModuleEntity patch = new ModuleEntity();
+ patch.setIIncrement(id);
+ patch.setBDeleted(true);
+ patch.setTDeletedDate(LocalDateTime.now());
+ patch.setSDeletedBy(null); // FieldStrategy 默认 NOT_NULL,会被 MP 跳过;这里靠 IGNORED 或显式 update wrapper 写入。
+ ```
+ **方式选择**:用 `moduleMapper.update(patch, new LambdaUpdateWrapper().eq(iIncrement, id).eq(bDeleted, false))`,确保只更新仍未删除的目标,且 `update` 影响行数为并发兜底信号。
+ 4. `int affected = moduleMapper.update(...)`;`affected == 0` → `BizException(MOD_NOT_FOUND)`(视为目标在校验后被并发删除)
+ 5. `return ModuleDeleteResultVO.of(id, true)`
+- 标 `@Transactional(rollbackFor = Exception.class)`
+
+- [ ] **Step 2.1 写失败测试(6 个)**
+ - `delete_targetNotFound_throws40421`:`selectById` → null
+ - `delete_targetAlreadyDeleted_throws40421`:target.bDeleted=true
+ - `delete_hasUndeletedChildren_throws40912`:selectCount > 0
+ - `delete_leafModule_returnsResult`:selectCount=0、update 返回 1,断言 VO + 断言传给 update 的 entity 字段(bDeleted=true、tDeletedDate 非 null、iIncrement 正确)
+ - `delete_softDeletedChildren_doesNotBlock`:selectCount=0(已删子不计入),update 返回 1,断言成功
+ - `delete_concurrentRace_throws40421`:selectById 返回未删除目标,selectCount=0,update 返回 0 → 抛 40421
+ - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor` 捕获 `update` 实参;用 `any(Wrapper.class)` 占位 wrapper
+ - 子会话: FAIL(方法不存在)
+
+- [ ] **Step 2.2 实现 delete**
+ - 注意:`moduleMapper.update(entity, wrapper)` 在 BaseMapper 里有重载,可能与 `update(entity, T)` 冲突。预期签名 `int update(@Param("et") T entity, @Param("ew") Wrapper updateWrapper)`,调用为 `moduleMapper.update(patch, wrapper)`。Mockito stub 时用 `when(moduleMapper.update(any(ModuleEntity.class), any(Wrapper.class)))` 应能消歧;若仍 ambiguous,按 REQ-MOD-001 经验改用显式 cast `(ModuleEntity) any()` + `(Wrapper) any()`。
+
+- [ ] **Step 2.3 子会话确认 ModuleServiceImplTest 全部绿**
+ - 累计:6 (create) + 8 (update) + 6 (delete) = 20
+
+- [ ] **Step 2.4 提交**
+ - `git commit -m "feat(mod): delete module service REQ-MOD-003"`
+
+---
+
+### Task 3: ModuleController DELETE 端点 + 端到端 IT
+
+**Files:**
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java`
+- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`(追加 6 个集成用例)
+
+**API shape:**
+- 新方法:
+ ```
+ @DeleteMapping("/{id}")
+ public ApiResponse delete(@PathVariable Integer id)
+ ```
+- Javadoc:`REQ-MOD-003 模块删除 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:DELETE')")`
+
+- [ ] **Step 3.1 写失败测试(6 个)**
+ - `delete_validLeaf_returns200WithBDeletedTrue`:mapper.insert 一条 → DELETE → 断言 200 + data.bDeleted=true + selectById 验证 DB 中 bDeleted=true / tDeletedDate 非 null
+ - `delete_targetNotFound_returns40421`:DELETE /api/modules/999999
+ - `delete_targetAlreadyDeleted_returns40421`:mapper.insert 一条并立刻把 bDeleted 置 true,DELETE 返回 40421
+ - `delete_hasUndeletedChildren_returns40912`:parent + child(bDeleted=0),DELETE parent → 40912;selectById parent 验证 bDeleted 仍 false
+ - `delete_softDeletedChildren_doesNotBlock_returns200`:先 DELETE child(应成功),再 DELETE parent → 200
+ - `delete_responseVOContainsOnlyIIncrementAndBDeleted`:断言 `$.data` 路径只有 iIncrement + bDeleted 两个字段(用 jsonPath `$.data.sProcedureName` 不存在)
+ - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` 直接预置数据
+ - 子会话: FAIL(端点不存在)
+
+- [ ] **Step 3.2 实现 DELETE 端点**
+ - 子会话: PASS
+
+- [ ] **Step 3.3 跑全量 backend 测试**
+ - `cd backend && mvn -B test`
+ - 期望累计 34 + 1(error code 断言扩展) + 6(service delete unit) + 6(controller delete IT) = 47 个,全绿
+
+- [ ] **Step 3.4 提交**
+ - `git commit -m "feat(mod): DELETE /api/modules/{id} controller REQ-MOD-003"`
+
+---
+
+## 提交计划
+
+- `feat(common): error code MOD_HAS_REFERENCES + delete VO REQ-MOD-003`(覆盖 Task 1)
+- `feat(mod): delete module service REQ-MOD-003`(覆盖 Task 2)
+- `feat(mod): DELETE /api/modules/{id} controller REQ-MOD-003`(覆盖 Task 3)
diff --git a/docs/superpowers/plans/2026-05-06-REQ-MOD-004.md b/docs/superpowers/plans/2026-05-06-REQ-MOD-004.md
new file mode 100644
index 0000000..8ab118d
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-06-REQ-MOD-004.md
@@ -0,0 +1,137 @@
+---
+req_id: REQ-MOD-004
+date: 2026-05-06
+spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-004.md
+---
+
+# REQ-MOD-004 模块查询 Implementation Plan
+
+> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现 `GET /api/modules?keyword=...`:返回未软删除模块的树形结构,可选按 `sModuleNameZh` 模糊匹配并保留命中节点的祖先链。
+
+**Architecture:** 一次性 selectList 拉所有未删除模块到内存,按 keyword 过滤命中集合,沿父链向上收集祖先合并到结果集,最后在内存按 iParentId 组装树。同级按 iSortOrder ASC + iIncrement ASC 排序。叶子 `children=[]`。
+
+**Tech Stack:** 沿用 REQ-MOD-001~003。
+
+---
+
+## Schema 改动
+
+无。
+
+## 文件变更清单
+
+- 创建: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java` — 树形节点 VO(7 字段 + children 列表)
+- 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java` — 查询参数 DTO(keyword)
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `tree(ModuleQueryDTO query): List`
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 tree
+- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@GetMapping`
+- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 6 个 tree 单测
+- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 7 个 GET 集成测试
+
+## 任务步骤
+
+### Task 1: VO + DTO
+
+**Files:**
+- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java`
+- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java`
+
+**API shape:**
+- `ModuleTreeNodeVO`:7 字段 + `List children`,Lombok `@Data`,含静态工厂 `from(ModuleEntity e)`(创建带空 children 列表的节点)。
+- `ModuleQueryDTO`:单字段 `@Size(max=50) String keyword`(可空)。Bean Validation 由 controller 触发。
+
+- [ ] **Step 1.1 创建 VO + DTO(无独立单测;Bean Validation 在 IT 层覆盖)**
+
+- [ ] **Step 1.2 提交**
+ - `git commit -m "feat(mod): module tree VO + query DTO REQ-MOD-004"`
+
+---
+
+### Task 2: ModuleService.tree — 树构建逻辑(mock 单元测试)
+
+**Files:**
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`
+- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`
+
+**API shape:**
+- `interface ModuleService` 追加:`List tree(ModuleQueryDTO query)`
+- 实现步骤:
+ 1. `all = moduleMapper.selectList(LambdaQueryWrapper.eq(BDeleted, false))`
+ 2. 若 `query.keyword == null || keyword.isEmpty()` → 跳到步骤 5(用全量构树)
+ 3. 否则在 `all` 内存里筛选 `hits = all.filter(e -> e.sModuleNameZh.contains(keyword))`
+ 4. 对每个 hit 沿 `iParentId` 链向上收集祖先(用 `byId = all.stream().toMap(IIncrement)` 索引);合并到 `survivors = hits ∪ ancestors`
+ 5. 按 iIncrement 索引 `survivors`,按 iParentId 分组子节点列表,同级按 (iSortOrder, iIncrement) ASC 排序
+ 6. 取 `iParentId == null || iParentId not in survivors` 的节点作为根(这样过滤后一些祖先链断裂的节点也会被提到根,避免孤立);递归挂 children
+ 7. 返回根节点列表
+- 标 `@Transactional(readOnly = true)`
+
+**关键不变量**(写入 plan 锁定):
+- 叶子节点 `children = new ArrayList<>()`,**不为 null**
+- 排序键稳定:`Comparator.comparingInt(ModuleTreeNodeVO::getISortOrder).thenComparingInt(ModuleTreeNodeVO::getIIncrement)`
+- 祖先链深度上限 5(与 docs/03 § tModule 注记一致);超 5 仍向上视为业务异常并记一行 `log.warn`,不抛错(不影响 200 返回)
+
+- [ ] **Step 2.1 写失败测试(6 个)**
+ - `tree_emptyDb_returnsEmptyList`:mapper.selectList 返回空 → service 返回 `List.of()`
+ - `tree_singleRoot_returnsOneNodeWithEmptyChildren`:1 个 root,断言返回 list 长度 1,children 是 `[]`
+ - `tree_multiLevel_buildsNestedStructureSortedByISortOrder`:root(sort=2) + root(sort=1) + child of root1,断言返回顺序 [sort=1, sort=2],root1.children 包含 child
+ - `tree_keywordHit_includesAncestorChain`:grandparent → parent → child(sModuleNameZh 各不同),keyword 匹配 child;断言返回 grandparent → parent → child 三层
+ - `tree_keywordNoMatch_returnsEmptyList`
+ - `tree_softDeletedExcluded`(验证 mapper 调用时 wrapper 含 `eq(bDeleted, false)`;ArgumentCaptor)
+ - 测试方式:`@ExtendWith(MockitoExtension.class)`,构造 `List` 喂 mock 返回值
+ - 子会话: FAIL(方法不存在)
+
+- [ ] **Step 2.2 实现 service.tree**
+ - 子会话: PASS
+
+- [ ] **Step 2.3 提交**
+ - `git commit -m "feat(mod): query module tree service REQ-MOD-004"`
+
+---
+
+### Task 3: ModuleController GET 端点 + 端到端 IT
+
+**Files:**
+- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java`
+- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`
+
+**API shape:**
+- 新方法:
+ ```
+ @GetMapping
+ public ApiResponse> tree(@Valid ModuleQueryDTO query)
+ ```
+- Javadoc:`REQ-MOD-004 模块查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')")`
+- `@Valid` 触发 Bean Validation;keyword 长度超限 → MethodArgumentNotValidException → GlobalExceptionHandler → 40010
+
+> **注意**:query 参数对象用 `@Valid ModuleQueryDTO` 时,Spring MVC 会把 `keyword` query 参数绑定到 DTO 字段(无需 @ModelAttribute,bean 类型默认走 query string)。
+
+- [ ] **Step 3.1 写失败测试(7 个)**
+ - `get_emptyKeyword_returnsAllUndeletedAsTree`:先 mapper.insert 几条(含 root + child + soft-deleted),GET 不带 keyword;断言 200 + data 长度等于未删 root 数;递归断言子树。
+ - `get_keyword_filtersByModuleNameZhWithAncestors`:插入 grandparent("系统配置") → parent("用户管理") → child("登录");GET `?keyword=登录`;断言返回三层链。
+ - `get_keywordNoMatch_returnsEmptyArray`:GET `?keyword=不存在`,断言 `data=[]`,code=200。
+ - `get_keywordTooLong_returns40010`:keyword 51 字符。
+ - `get_softDeletedNotInResult`:插入一条并立即 update bDeleted=1,GET 全量,断言不在结果。
+ - `get_responseExcludesInternalFields`:断言 `$.data[0].sProcedureName` doesNotExist;同时验证 `sModuleType` / `bShowPermission` / `tCreateDate` / `bDeleted` 不出现。
+ - `get_leafNodeChildrenIsEmptyArrayNotNull`:断言叶子 `$.data[*].children` is array 且 length=0(非 null)。
+ - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper`
+ - 子会话: FAIL(端点不存在)
+
+- [ ] **Step 3.2 实现 GET 端点**
+ - 子会话: PASS
+
+- [ ] **Step 3.3 跑全量 backend 测试**
+ - 期望累计 47 + 6(service tree) + 7(controller GET) = 60 个,全绿(mvn 报数会因共享 context 而合并)
+
+- [ ] **Step 3.4 提交**
+ - `git commit -m "feat(mod): GET /api/modules controller REQ-MOD-004"`
+
+---
+
+## 提交计划
+
+- `feat(mod): module tree VO + query DTO REQ-MOD-004`(Task 1)
+- `feat(mod): query module tree service REQ-MOD-004`(Task 2)
+- `feat(mod): GET /api/modules controller REQ-MOD-004`(Task 3)
diff --git a/docs/superpowers/reviews/2026-05-06-REQ-MOD-001.md b/docs/superpowers/reviews/2026-05-06-REQ-MOD-001.md
new file mode 100644
index 0000000..4d6c5df
--- /dev/null
+++ b/docs/superpowers/reviews/2026-05-06-REQ-MOD-001.md
@@ -0,0 +1,35 @@
+---
+req_id: REQ-MOD-001
+date: 2026-05-06
+round: 1
+reviewer: superpower-code-reviewer
+---
+
+# Review: REQ-MOD-001 — round 1
+
+## 结论
+approve
+
+## Must-fix
+(无)
+
+## Nice-to-have
+
+- backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java:30 — 兜底 `@ExceptionHandler(Exception.class)` 会吃掉 `HttpMessageNotReadableException` / `HttpRequestMethodNotSupportedException` / `MissingServletRequestParameterException` 等参数类异常并映射到 50000;语义上更接近 40010。建议下一个 REQ 顺手为这几类异常补独立 handler。
+- backend/src/main/java/com/xly/erp/config/SecurityConfig.java:18 — `csrf -> csrf.disable()` 等三处 lambda 可改用方法引用 `AbstractHttpConfigurer::disable`,更符合 Spring Security 6 官方示例风格。
+- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:39 — `.eq(ModuleEntity::getBDeleted, false)` 可读性建议统一用 `Boolean.FALSE` 或抽常量,依赖 JDBC 默认转换不影响正确性。
+- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:26 / Controller / Mapper / Entity — spec § 代码与文档要求关键类贴 `// REQ-MOD-001 模块新增` 标签;目前 Controller 仅有 REQ-USR-004 备忘注释,缺 REQ-MOD-001 标签,Service / Mapper / Entity / DTO / VO 也均未显式标。建议补一行类级注释。
+- backend/src/main/java/com/xly/erp/config/JacksonConfig.java:18 — JacksonConfig 是计划外补丁(修复匈牙利前缀字段 JSON 序列化);plan 文件清单未列出。建议补一条 ObjectMapper 序列化测试断言 `iIncrement` 而非 `IIncrement`,把这个隐式契约钉死。
+- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:55 — `post_validRootModule` 应额外断言 VO 字段值(sDisplayType / sModuleNameZh / iSortOrder / tCreateDate / bShowPermission),spec 验收 § 1 要求"DB 中查询新记录字段与入参一致"。
+- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:77 — `post_validChildModule` 没有断言 iSortOrder / bShowPermission / sCreatedBy 留 NULL 等持久化字段,建议补 select-after-insert 验证。
+- backend/src/main/java/com/xly/erp/common/response/ApiResponse.java:28 — `fail(ErrorCode ec, String detail)` 把 message 替换为 detail,前端会吃到带英文字段名的中文混合串。建议将 detail 单列字段或保留 ec.getMessage() 作为 message。
+- backend/src/main/resources/application.yml:21 — 全局 `map-underscore-to-camel-case: false` 对未来非匈牙利前缀字段有传染影响;当前 entity 已显式 `@TableField`,可改回 MyBatis-Plus 默认 true。
+
+## 反例 / 测试覆盖缺口
+
+1. spec § 验收 § 功能正确性 1 要求"DB 中查询新记录字段与入参一致"——目前 `ControllerIT` 只断言 3 个字段、未回查 DB;`ModuleMapperIT` 单独覆盖 mapper 层往返,端到端层面字段一致性不直观。
+2. spec § 测试覆盖列出"并发同 sProcedureName"用例标可选,未实现——可接受。
+3. spec § 业务规则 5 要求 `bDeleted=0` / `tDeletedDate=NULL` / `sDeletedBy=NULL` / `sBrandsId/sSubsidiaryId/sCreatedBy=NULL` 在 INSERT 后真正落库——`ModuleServiceImplTest` 用 mock 不能验证 entity 在传给 `mapper.insert` 前的赋值;建议加 `ArgumentCaptor` 断言。
+4. Controller 层缺 `HttpMessageNotReadableException` 用例(POST 非法 JSON 当前会走 50000 兜底);spec 未显式要求,但 docs/04 § 1.4 友好错误码原则下属盲区。
+5. 计划清单列出的 `MybatisPlusConfig.java` 未创建——plan 自身注明"REQ-MOD-001 不用,先建空骨架",YAGNI 角度可接受;建议在 plan 上同步勾掉或删除该行。
+6. 未发现硬编码凭据、未跨模块改动、未引入技术栈外组件、commit 全部带 `REQ-MOD-001` 标签、响应不回显堆栈——全部合规。spec 写明的 REQ-USR-004 技术债(permitAll + 多租户字段 NULL + sCreatedBy NULL)落地受控,无失控扩散。
diff --git a/docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md b/docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md
new file mode 100644
index 0000000..abb6201
--- /dev/null
+++ b/docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md
@@ -0,0 +1,31 @@
+---
+req_id: REQ-MOD-002
+date: 2026-05-06
+round: 1
+reviewer: superpower-code-reviewer
+---
+
+# Review: REQ-MOD-002 — round 1
+
+## 结论
+approve
+
+## Must-fix
+(无)
+
+## Nice-to-have
+
+- backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java:56 — `iParentId` 改为 `FieldStrategy.IGNORED` 是 entity 全局行为变更。本期 update 走 load-then-modify 全量回填路径所以安全;但未来若有 partial updateById 路径会把 iParentId 写成 NULL。建议在字段注释加 "调用方必须 selectById 后再 updateById",或将 NULL 写入语义收敛到 update 方法本地。
+- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:207 — spec § 验收 #2「正向 — 设置父模块到合法 sibling」只覆盖了 setParentToNull,没有覆盖"把 iParentId 改到另一个未删除模块"的正向写入路径。建议追加一个 IT 用例。
+- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:630 — `update_full_returnsVOWithUpdatedFields` 的 dto.iParentId 与 target.iParentId 都是 null,断言只覆盖了 null→null。建议把 dto.setIParentId(非 null) 并 mock 合法父返回,覆盖 walk-up depth=1 直接退出循环的非环场景。
+- backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java:24 — 本 REQ 顺手把 REQ-MOD-001 那行 `// REQ-USR-004 完成后追加 @PreAuthorize(...)` 类外注释改成了 Javadoc,触及既有代码(非严格 surgical)。可读性改善但 commit message 未披露;下次类似情况建议拆 refactor commit 或在 body 注脚说明。
+- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:597 — `when(moduleMapper.selectById(999999)).thenReturn(null)` 冗余(Mockito 默认就是 null),可删除。
+
+## 反例 / 测试覆盖缺口
+
+1. spec § 验收 #2「正向设置父模块到 sibling」在 IT 与单元两层都缺成功路径用例;只覆盖了"清空父"和各种 parent 校验失败用例,未直接验证 iParentId 从 null/某值改到另一个有效模块后 DB 实际写入了新父 id 的主线路径。
+2. spec § 验收 #6「目标已软删除」仅在单元层覆盖(`update_targetSoftDeleted_throws40421`),IT 缺一个"先 update bDeleted=1 再 PUT 返回 40421"用例。
+3. spec § 验收 #8(枚举非法 `sDisplayType="X"`)/ #9(`sModuleType=51 字符`)只在 `ModuleUpdateDTOValidationTest` 单元层验证,未在 IT 走一遍 PUT 端到端断言 40010。
+4. 环路检查的"4-5 层深度边界"和"父链中存在已软删除节点导致提前 break 的非环场景"未单独覆盖;本期数据量低可接受。
+5. `FieldStrategy.IGNORED` 是 entity 全局变更,对未来其他 service 走 partial `updateById` 路径会埋雷;建议要么文档化要么把 NULL 写入收敛到本地策略。
+6. 未发现硬编码凭据、未跨模块改动、未引入技术栈外组件、commit 全部带 `REQ-MOD-002` 标签、响应不回显堆栈——全部合规。错误码(40010/40411/40421/40921)与 docs/05 / spec 一致。环路检查实现 walk-up 沿父链最多 5 层 + 已删节点 break,逻辑正确。
diff --git a/docs/superpowers/reviews/2026-05-06-REQ-MOD-003.md b/docs/superpowers/reviews/2026-05-06-REQ-MOD-003.md
new file mode 100644
index 0000000..d1b07df
--- /dev/null
+++ b/docs/superpowers/reviews/2026-05-06-REQ-MOD-003.md
@@ -0,0 +1,32 @@
+---
+req_id: REQ-MOD-003
+date: 2026-05-06
+round: 2
+reviewer: superpower-code-reviewer
+---
+
+# Review: REQ-MOD-003 — round 2
+
+## 结论
+approve
+
+## Must-fix
+(无;round 1 两条 must_fix 已在 commit 2419659 中修复)
+
+## Nice-to-have
+
+- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:16 — `org.mockito.ArgumentMatchers` 仍未使用(round 1 提过,pre-existing;可顺手清掉)。
+- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:358/375/387 — `(Wrapper) any()` 强转触发 unchecked 警告,可在类上 `@SuppressWarnings("unchecked")` 或就近用 `ArgumentMatchers.>any()`。
+- 可选:增加 `delete_writesSDeletedByNull_onSoftDelete` 单元测试,用 ArgumentCaptor 捕 `LambdaUpdateWrapper` 解析 `wrapper.getSqlSet()` 断言含 `sDeletedBy=NULL / bDeleted=1 / tDeletedDate=...`,把 SET 子句的列覆盖也在单元层钉一遍(目前 SET 列内容仅由 IT 兜底)。
+- docs/05-API接口契约.md § REQ-MOD-003 写「40912 响应附 data.references」,spec 未实现;属于已知契约漂移,留给 module-report 时统一对齐。
+
+## 反例 / 测试覆盖缺口
+
+Round 1 两条 must_fix 均已落实:
+
+1. `ModuleServiceImpl.delete()` 改为 `moduleMapper.update(null, uw)` + `LambdaUpdateWrapper.set(BDeleted,true).set(TDeletedDate,now()).set(SDeletedBy,null)`;`eq(BDeleted,false)` 并发兜底保留;`affected==0 → 40421` 保留。三件套全部由 wrapper 显式声明,**绕开** `iParentId.FieldStrategy.IGNORED` 副作用。
+2. `ModuleControllerIT#delete_preservesOtherFields_onChildModule` 已新增:用自定义字段值(sDisplayType='接口' / sModuleType='AUDIT' / sManageDeptEn='OPS' / bShowPermission=true / sModuleNameZh='待保留中文名' / iSortOrder=7)建 child(parentId),DELETE 后 reload 断言 8 个字段全部保持原值 + bDeleted=true + tDeletedDate 非 null。
+
+**单元测试降级合理性**:架构改动后 entity 参数为 null,原 ArgumentCaptor 对 entity 字段的断言失去对象;MP 真正写入的 SET 列在 wrapper 内部 SqlSegment,单元层断言列覆盖复杂度高且偏离职责。新 IT 在真实 MySQL 端到端验证「除三件套外其他列保持原值 + iParentId 不被清空」,比 mock 层 ArgumentCaptor 严格得多。`verify(moduleMapper).update((ModuleEntity) isNull(), ...)` 把"entity 参数必须是 null"这一架构不变量钉死,防止未来误回滚到 entity-driven update。整体是 mock 层小幅放宽 + IT 层显著加强的净增强。
+
+非阻塞遗留:(a) docs/05 § REQ-MOD-003 `data.references` 描述与实现不一致;(b) 单元测试 `ArgumentMatchers` 未使用 import;(c) 重复 DELETE 集成层显式用例缺失(间接覆盖足够)。
diff --git a/docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md b/docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md
new file mode 100644
index 0000000..7d43fb2
--- /dev/null
+++ b/docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md
@@ -0,0 +1,36 @@
+---
+req_id: REQ-MOD-004
+date: 2026-05-06
+round: 1
+reviewer: superpower-code-reviewer
+---
+
+# Review: REQ-MOD-004 — round 1
+
+## 结论
+approve
+
+## Must-fix
+(无)
+
+## Nice-to-have
+
+- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:530 — 补 IT 覆盖 spec § 验收 #7『同级排序』:插入 root(iSortOrder=2) + root(iSortOrder=1),断言 GET 返回数组首个 iIncrement 是 sort=1 那条。当前同级排序仅在单测覆盖,缺端到端断言。
+- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:530 — 补 IT 覆盖 spec § 验收 #6『keyword 含中英混合』:插入 sModuleNameZh='user 用户',GET ?keyword=user 用户 命中。
+- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:497 — `tree_softDeletedExcluded` 仅 verify(...).selectList(any()),未捕 wrapper 形状(plan 要求 ArgumentCaptor)。建议改名或真用 ArgumentCaptor 抽 `wrapper.getTargetSql()` 断言含 `bDeleted = 0`。
+- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:227 — Comparator 对 iSortOrder 做了 null→0 防御,但 docs/03 已注明 NOT NULL;可简化为 `Comparator.comparingInt(ModuleTreeNodeVO::getISortOrder).thenComparingInt(::getIIncrement)`。
+- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:198 — `survivorIds = byId.keySet()` 是 live view,与 keyword 分支返回的独立 HashSet 类型不一致;建议改成 `new HashSet<>(byId.keySet())` 一致化。
+- backend/pom.xml:121 — surefire includes 修改是项目级配置;建议在 docs/04 测试规范节追加一行说明『*IT.java 走 surefire (mvn test) 而非 failsafe (mvn verify)』,避免后续贡献者破坏 includes。
+- docs/03-数据库设计文档.md § tModule 业务注记 — 建议追加一行『模块树最大深度 5 层(iParentId 自引用),由 service 层校验,REQ-MOD-002 / REQ-MOD-004 引用本约束』,把深度上限提升为 SSoT。
+- backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java:11 — 在 @Size 上方 javadoc 写明『empty 视为不过滤』。
+
+## 反例 / 测试覆盖缺口
+
+1. spec § 验收 #7 同级排序的端到端 IT 缺失(仅单测)。
+2. spec § 验收 #6 中英混合 keyword 没有专属 IT。
+3. `tree_softDeletedExcluded` 单测断言强度低于 plan 期望。
+4. 反例缺:keyword 含 SQL 通配符 `%` / `_`(spec 声明本期不转义,作为已知边界)。
+5. 反例缺:祖先链深度 ≥ 5 的截断边界用例;docs/03 已限深度 5,正常打不到边界,可后续补 6 层强构数据单测固化截断语义。
+6. Controller javadoc 标了『REQ-USR-004 完成后追加 @PreAuthorize』但缺集中跟踪——建议在 docs/08 § 二 USR-004 子项挂 follow-up 备注。
+
+**核心结论**:实现忠实于 spec — keyword 过滤 + 祖先链 walk-up + 内存树构造 + 同级 (iSortOrder, iIncrement) 排序 + 叶子 children=[] 全部正确。API 契约字段精简到位(VO 7 个公开字段 + children)。surefire includes 修复是揭示性发现——本仓库之前 *IT.java 没在 mvn test 跑过,REQ-MOD-001/002/003 的 IT 都是"死代码";本 REQ commit body 已披露,REQ-MOD-004 的 IT 是首次在 mvn test 跑通。
diff --git a/docs/superpowers/specs/2026-05-06-REQ-MOD-001.md b/docs/superpowers/specs/2026-05-06-REQ-MOD-001.md
new file mode 100644
index 0000000..58480d6
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-06-REQ-MOD-001.md
@@ -0,0 +1,196 @@
+---
+req_id: REQ-MOD-001
+date: 2026-05-06
+module: module_mod
+---
+
+# Spec: REQ-MOD-001 — 模块新增
+
+## 目标
+
+实现后端 `POST /api/modules` 接口:将一条新的业务模块定义写入 `tModule` 表,作为 ERP 系统功能与权限分组的基础单位,返回新模块主键 `iIncrement` 与完整 VO。
+
+本 REQ 是 module_mod 模块的第一个 REQ,也是整个 B 阶段的首个 REQ;需顺带建立 Spring Boot 项目骨架(最小可运行单元),仅落地能让本接口工作的横切组件。其余横切组件(JWT 鉴权、登录用户上下文)按 docs/02 § 三 约定由 REQ-USR-004 首次落地。
+
+## 输入 / 触发
+
+**接口**:`POST /api/modules`,Content-Type `application/json`。
+
+**Request body**(`ModuleCreateDTO`)字段:
+
+| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 |
+|---|---|---|---|---|
+| `sDisplayType` | String | 是 | 枚举:`手机端` / `前端业务` / `系统配置` / `接口` | `tModule.sDisplayType` |
+| `sProcedureName` | String | 是 | 长度 1-100;系统内唯一(`bDeleted=0` 范围内) | `tModule.sProcedureName` |
+| `sModuleType` | String | 是 | 长度 1-50(自由文本) | `tModule.sModuleType` |
+| `sManageDeptEn` | String | 是 | 长度 1-50(自由文本) | `tModule.sManageDeptEn` |
+| `bShowPermission` | Boolean | 否 | 默认 `false` | `tModule.bShowPermission` |
+| `sModuleNameZh` | String | 是 | 长度 1-100 | `tModule.sModuleNameZh` |
+| `iParentId` | Integer | 否 | 必须指向已存在且未软删除的 `tModule.iIncrement` | `tModule.iParentId` |
+| `iSortOrder` | Integer | 否 | 默认 `0`;非负整数 | `tModule.iSortOrder` |
+
+**鉴权**:契约要求 `Authorization: Bearer ` + 权限码 `MOD:CREATE`。本 REQ 暂不实施实际鉴权(见 § 边界与约束),但 Controller 仍按需要鉴权的形态编写(无 `@AnonymousAccess` 标注),以便 REQ-USR-004 加入 SecurityFilterChain 后零改动。
+
+## 输出 / 结果
+
+**HTTP 200,响应体**(统一响应格式):
+
+```json
+{
+ "code": 200,
+ "message": "操作成功",
+ "data": {
+ "iIncrement": 12,
+ "sDisplayType": "前端业务",
+ "sProcedureName": "sp_audit_user_module",
+ "sModuleType": "USR",
+ "sManageDeptEn": "IT",
+ "bShowPermission": false,
+ "sModuleNameZh": "用户管理",
+ "iParentId": null,
+ "iSortOrder": 0,
+ "tCreateDate": "2026-05-06T10:30:00",
+ "bDeleted": false
+ },
+ "timestamp": 1746528600000
+}
+```
+
+返回 VO(`ModuleVO`)字段:`iIncrement` / `sDisplayType` / `sProcedureName` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder` / `tCreateDate` / `bDeleted`。其他标准列(`sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `tDeletedDate` / `sDeletedBy`)不对外暴露。
+
+## 业务规则
+
+1. **唯一性**:`sProcedureName` 在未软删除范围内(`bDeleted=0`)系统内唯一。冲突返回错误码 `40911`。
+2. **父模块校验**:若 `iParentId` 非空,必须指向 `tModule` 中存在且 `bDeleted=0` 的记录;不存在或已删除返回错误码 `40411`。
+3. **bShowPermission 默认值**:未传入或传 `null` → 落库 `0`。
+4. **iSortOrder 默认值**:未传入 → 落库 `0`。
+5. **新建记录初始状态**:`bDeleted=0`、`tDeletedDate=NULL`、`sDeletedBy=NULL`、`tCreateDate=now()`(应用层取系统时间,不依赖 DB DEFAULT)。
+6. **多租户字段 (`sBrandsId` / `sSubsidiaryId`)**:本 REQ **不写入**(落库 `NULL`,列已 nullable)。多租户上下文将由 REQ-USR-004 引入登录会话后注入;后续 REQ 通过迁移补齐已有数据。
+7. **`sCreatedBy`**:本 REQ 落库 `NULL`。等待 REQ-USR-004 引入登录用户上下文后从 `SecurityContextHolder` 取当前用户号回写。
+8. **`sId`**(业务 ID 标准列):本 REQ 落库 `NULL`,不在本接口分配。后续若有外部对接需求再以独立 migration 补齐策略。
+
+## 边界与约束
+
+### 鉴权策略(本 REQ 限定)
+
+- 项目首个 REQ,尚无 SecurityFilterChain。本 REQ 在 `SecurityConfig` 里配置 `permitAll()` 临时放行所有 `/api/**`。
+- Controller 不写 `@PreAuthorize("hasAuthority('MOD:CREATE')")`(无 SecurityContext 时该注解会因 `Authentication=null` 抛 `AccessDeniedException`);改为在 Controller 上方写一行说明性注释:`// REQ-USR-004 完成后改为 @PreAuthorize("hasAuthority('MOD:CREATE')")`。
+- REQ-USR-004 完成后会回头给本接口加 `@PreAuthorize`,并把 `permitAll` 改为 `authenticated()`。这是已知技术债,**仅在 REQ-USR-004 范围内偿还**,本 REQ 不擅自前置。
+
+### 字段长度与字符集
+
+- 字符集统一 `utf8mb4`(DDL 已约束),允许中文落库。
+- 字符串字段超出 DDL 长度限制视为参数错误(`40010`),不截断。
+
+### 事务
+
+- Service 方法标注 `@Transactional(rollbackFor = Exception.class)`,单表写入即可,事务范围最小化。
+
+### 性能与并发
+
+- 本 REQ 是单条 INSERT,预期无高并发。`uk_procedure_name` 唯一约束兜底并发竞争;唯一冲突映射为 `40911`。
+
+### 项目骨架引导(首 REQ 一次性附带)
+
+本 REQ 顺带建立**最小可运行**的 Spring Boot 项目骨架(仅引入服务于本 REQ 的部分;后续 REQ 按需扩充)。预期新增目录与文件:
+
+```
+backend/
+├── pom.xml
+└── src/main/
+ ├── java/com/xly/erp/
+ │ ├── ErpApplication.java
+ │ ├── config/
+ │ │ ├── MybatisPlusConfig.java
+ │ │ └── SecurityConfig.java // 临时 permitAll
+ │ ├── common/
+ │ │ ├── response/
+ │ │ │ ├── ApiResponse.java
+ │ │ │ └── ErrorCode.java
+ │ │ └── exception/
+ │ │ ├── BizException.java
+ │ │ └── GlobalExceptionHandler.java
+ │ └── module/mod/
+ │ ├── controller/ModuleController.java
+ │ ├── service/ModuleService.java
+ │ ├── service/impl/ModuleServiceImpl.java
+ │ ├── mapper/ModuleMapper.java
+ │ ├── entity/Module.java
+ │ ├── dto/ModuleCreateDTO.java
+ │ └── vo/ModuleVO.java
+ └── resources/
+ ├── application.yml // ${DB_HOST}/${DB_PORT}/... 占位,从 .env.local 注入
+ └── mapper/mod/ModuleMapper.xml
+```
+
+`pom.xml` 依赖:`spring-boot-starter-web` / `-validation` / `-security`、`mybatis-plus-spring-boot3-starter`、`flyway-core` + `flyway-mysql`、`mysql-connector-j`、`mapstruct`、`hutool-all`、`spring-boot-starter-test`、`spring-security-test`。
+
+`application.yml` 通过 Spring Profile + `dotenv-java`(或本地启动脚本)加载 `.env.local`;不在仓内硬编码任何凭据(与 `docs/04 § 3.5` 一致)。
+
+## 依赖的 schema 表 / 字段
+
+**写表**:`tModule`(详见 `docs/03-数据库设计文档.md` § tModule)
+
+| 字段 | 落库逻辑 |
+|---|---|
+| `iIncrement` | DB 自增分配 |
+| `sId` | 落 `NULL`(本 REQ 不分配业务 ID) |
+| `sBrandsId` | 落 `NULL`(多租户 REQ-USR-004 后引入) |
+| `sSubsidiaryId` | 落 `NULL`(同上) |
+| `tCreateDate` | 应用层 `LocalDateTime.now()` |
+| `sDisplayType` | 入参(必填) |
+| `sProcedureName` | 入参(必填,唯一) |
+| `sModuleType` | 入参(必填) |
+| `sManageDeptEn` | 入参(必填) |
+| `bShowPermission` | 入参(默认 `false`) |
+| `sModuleNameZh` | 入参(必填) |
+| `iParentId` | 入参(可选;FK 校验通过的 `tModule.iIncrement`) |
+| `iSortOrder` | 入参(默认 `0`) |
+| `sCreatedBy` | 落 `NULL`(REQ-USR-004 后引入登录上下文) |
+| `bDeleted` | 落 `0` |
+| `tDeletedDate` | 落 `NULL` |
+| `sDeletedBy` | 落 `NULL` |
+
+**索引利用**:
+- `uk_procedure_name` UNIQUE:唯一性约束触发并发兜底
+- `idx_parent`:父模块查询场景(FK 校验时按 `iParentId` 查 `tModule`)
+
+**外键**:本 REQ 利用 `fk_module_parent` 作为 DB 层兜底;应用层在写入前先查父模块存在性,提前返回 `40411`,避免直接抛 SQL 完整性异常。
+
+## 依赖的接口
+
+无(本接口是 module_mod 的入口接口,不依赖其他业务接口)。
+
+后续在 REQ-USR-004 完成后,需要回归补齐本接口的 `@PreAuthorize("hasAuthority('MOD:CREATE')")`,并要求请求携带有效 JWT。
+
+## 验收标准
+
+### 功能正确性
+
+1. **正向 — 根模块**:提交完整字段、`iParentId=null`,返回 200 + `data.iIncrement` 非空;DB 中查询新记录字段与入参一致;`bDeleted=0` / `tCreateDate` 已写入。
+2. **正向 — 子模块**:先创建 root 再以其 `iIncrement` 作为 `iParentId` 创建子模块,返回 200。
+3. **唯一性冲突**:用相同 `sProcedureName` 二次提交,返回 `code=40911` + 中文 message;DB 不产生重复记录。
+4. **父模块不存在**:传入 `iParentId=999999`,返回 `code=40411`。
+5. **必填缺失 / 枚举非法 / 长度超限**:返回 `code=40010` + 错误字段名定位。
+6. **可选默认值**:不传 `bShowPermission` / `iSortOrder` → DB 落 `false / 0`。
+
+### 接口契约一致性
+
+- 响应格式严格符合 `{code, message, data, timestamp}`(docs/05 § 全局约定)。
+- 错误码段位与 docs/05 一致:`200` / `40010` / `40911` / `40411` / `500xx`。
+- 异常堆栈不出现在响应里(GlobalExceptionHandler 拦截并映射为友好错误,docs/04 § 1.4)。
+
+### 测试覆盖(feature-tdd 阶段)
+
+- **单元测试**:`ModuleServiceImpl#create` 覆盖 6 类正/反路径(含唯一冲突的 `DuplicateKeyException` 映射)。
+- **集成测试(MockMvc + 真实 MySQL test schema)**:
+ - 正向:根模块、子模块创建后查 DB 验证字段
+ - 反向:唯一冲突、父模块不存在、必填缺失、枚举非法、长度超限 ≥ 5 个 case
+ - 并发:两线程同时插入同 `sProcedureName`,断言一条成功 + 一条 40911(可选,若 CI 跑得动)
+- **测试数据隔离**:每个测试方法 `@Transactional` 自动回滚;不污染其他测试。
+
+### 代码与文档
+
+- `// REQ-MOD-001` 注释贴在 Controller / Service / Mapper / DTO / VO 关键类。
+- 提交按 `feat(mod): add module create endpoint REQ-MOD-001` 规范。
+- 不引入 docs/04 § 零 技术栈外的依赖(如需,按软规则 S1 经 AskUserQuestion)。
diff --git a/docs/superpowers/specs/2026-05-06-REQ-MOD-002.md b/docs/superpowers/specs/2026-05-06-REQ-MOD-002.md
new file mode 100644
index 0000000..5c5c293
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-06-REQ-MOD-002.md
@@ -0,0 +1,186 @@
+---
+req_id: REQ-MOD-002
+date: 2026-05-06
+module: module_mod
+---
+
+# Spec: REQ-MOD-002 — 模块修改
+
+## 目标
+
+实现后端 `PUT /api/modules/{id}` 接口:在不破坏唯一性 / 树结构完整性的前提下,更新已有模块的可编辑字段,返回最新模块 VO。
+
+## 输入 / 触发
+
+**接口**:`PUT /api/modules/{id}`,Content-Type `application/json`。`{id}` = `tModule.iIncrement`。
+
+**Request body**(`ModuleUpdateDTO`)字段——与 REQ-MOD-001 输入相比**剥除 `sProcedureName`**(不可改,contract 约束);其余 7 个业务字段含义和校验规则保持一致:
+
+| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 |
+|---|---|---|---|---|
+| `sDisplayType` | String | 是 | 枚举:`手机端` / `前端业务` / `系统配置` / `接口` | `tModule.sDisplayType` |
+| `sModuleType` | String | 是 | 长度 1-50 | `tModule.sModuleType` |
+| `sManageDeptEn` | String | 是 | 长度 1-50 | `tModule.sManageDeptEn` |
+| `bShowPermission` | Boolean | 否 | 默认保持原值;显式传 `null` 视为不变 | `tModule.bShowPermission` |
+| `sModuleNameZh` | String | 是 | 长度 1-100 | `tModule.sModuleNameZh` |
+| `iParentId` | Integer | 否 | 可空(设为根模块);非空必须存在且未软删除;不能等于 `{id}` 自身或其后代 | `tModule.iParentId` |
+| `iSortOrder` | Integer | 否 | 默认保持原值;非负整数 | `tModule.iSortOrder` |
+
+> **`sProcedureName` 不在 DTO 中**:Jackson 反序列化时若客户端误传将被忽略(`@JsonIgnoreProperties(ignoreUnknown = true)` 由 Jackson 默认行为兜底;不抛错)。前端 UI 应把该字段渲染为只读。
+>
+> **PUT 语义**:本接口采用全量替换语义。请求体中显式存在的字段均落库;若未提供(JSON 中 key 缺失或值为 `null`),按字段下方"必填"列:必填字段缺失 → `40010`;可选字段缺失 → 保持数据库原值。
+
+**鉴权**:契约要求 `Authorization: Bearer ` + 权限码 `MOD:UPDATE`。本 REQ 沿用 REQ-MOD-001 的 SecurityConfig permitAll 占位(REQ-USR-004 后回头收紧);Controller 写注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`。
+
+## 输出 / 结果
+
+**HTTP 200,响应体**(统一响应格式):
+
+```json
+{
+ "code": 200,
+ "message": "操作成功",
+ "data": {
+ "iIncrement": 12,
+ "sDisplayType": "前端业务",
+ "sProcedureName": "sp_audit_user_module",
+ "sModuleType": "USR",
+ "sManageDeptEn": "IT",
+ "bShowPermission": true,
+ "sModuleNameZh": "用户管理(修订)",
+ "iParentId": 3,
+ "iSortOrder": 5,
+ "tCreateDate": "2026-05-06T10:30:00",
+ "bDeleted": false
+ },
+ "timestamp": 1746528600000
+}
+```
+
+VO 复用 REQ-MOD-001 的 `ModuleVO`(11 个字段)。
+
+## 业务规则
+
+1. **目标模块必须存在且未软删除**:`SELECT ... WHERE iIncrement = {id} AND bDeleted = 0`。不存在或已删 → `40421`。
+2. **`sProcedureName` 不可改**:DTO 不接受该字段;后端读取目标记录后保留原 `sProcedureName` 不变。
+3. **`iParentId` 自引用校验**:
+ - 若 `iParentId` 等于路径参数 `{id}`(自引用)→ `40921`。
+ - 若 `iParentId` 在 `tModule` 中不存在或已软删除 → `40411`。
+ - 若 `iParentId` 是 `{id}` 的后代(沿 `iParentId` 链向下走,深度上限 5 层与 docs/03 § tModule 业务注记一致)→ `40921`。
+4. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。
+5. **`bShowPermission` / `iSortOrder` 部分更新**:DTO 中为 `null` → 保持原值;显式传值 → 覆盖。
+6. **审计**:本 REQ 暂不维护"最近修改时间"和"修改人"列(schema 未规划相关字段,docs/03 也未要求)。后续若需,按 V_n migration 加列同步更新 docs/03。
+7. **多租户字段不写入**:与 REQ-MOD-001 一致,本接口不动 `sBrandsId / sSubsidiaryId`。
+
+## 边界与约束
+
+### 鉴权策略(本 REQ 限定)
+
+沿用 REQ-MOD-001:SecurityConfig permitAll;Controller 上写说明性注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`。
+
+### 事务
+
+- Service 方法标 `@Transactional(rollbackFor = Exception.class)`。读取目标模块 → 校验 → 更新 全在同一事务。
+- 父模块校验 + 后代环路检查需多次 `selectById`,事务内可能产生几次小查询;本期数据量低,不做缓存优化。
+
+### 并发
+
+- 用 `moduleMapper.updateById(entity)` 走 PK 更新;不引入乐观锁版本号(schema 没规划 `version` 列)。
+- 并发同时更新同一模块时遵循"后写覆盖"语义,可接受。需要更强一致性时另开 REQ。
+
+### 性能
+
+- 后代环路检查用迭代 BFS(队列),每次查 `selectList(eq("iParentId", ...))` 拿子节点;深度上限 5 层 + 单层节点数受限于业务,不做递归 SQL。
+
+### 错误码映射(与 docs/05 对齐)
+
+| 场景 | 错误码 |
+|---|---|
+| 必填字段缺失 / 类型错误 / 长度超限 / 枚举非法 | `40010` |
+| `{id}` 模块不存在或已软删除 | `40421` |
+| `iParentId` 指向不存在 / 已删模块 | `40411` |
+| `iParentId == {id}` 或为 `{id}` 的后代 | `40921` |
+| 服务端兜底 | `50000` |
+
+> docs/05 列出的 `40911`(sProcedureName 冲突)在本实现里不会触发(DTO 不接受 sProcedureName);保留契约文档不变即可。
+> 新增错误码 `40921` 需补到 `ErrorCode` 枚举(命名 `MOD_PARENT_LOOP`);`40421` 命名 `MOD_NOT_FOUND`。
+
+## 依赖的 schema 表 / 字段
+
+**写表**:`tModule`(详见 docs/03 § tModule)
+
+| 字段 | 行为 |
+|---|---|
+| `iIncrement` | 路径参数 `{id}` 定位行,**不修改** |
+| `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` | **不修改** |
+| `sDisplayType` | 入参覆盖 |
+| `sProcedureName` | **不修改**(保留原值) |
+| `sModuleType` | 入参覆盖 |
+| `sManageDeptEn` | 入参覆盖 |
+| `bShowPermission` | 入参非 null 覆盖;null 保留 |
+| `sModuleNameZh` | 入参覆盖 |
+| `iParentId` | 入参覆盖(含 null 设根) |
+| `iSortOrder` | 入参非 null 覆盖;null 保留 |
+| `bDeleted` / `tDeletedDate` / `sDeletedBy` | **不修改** |
+
+**索引利用**:
+- 主键定位 `{id}`
+- `idx_parent` / `fk_module_parent`:iParentId 校验时按父链 / 子链查询
+
+**外键**:`fk_module_parent` 仍兜底;应用层环路检查在写入前显式拦截。
+
+## 依赖的接口
+
+无(本接口独立工作;与 REQ-MOD-001 并列同模块同 schema)。
+
+## 验收标准
+
+### 功能正确性
+
+1. **正向 — 全量更新非父字段**:传入合法的 7 个字段(不含 `iParentId` 自引用),返回 200 + 最新 VO;DB 中查询新值与入参一致;`sProcedureName` / `tCreateDate` 与原值相同。
+2. **正向 — 设置父模块**:先建 root + child,再 `PUT /api/modules/{child_id}` 把 `iParentId` 改到另一个 sibling;返回 200,DB 中 `iParentId` 更新成功。
+3. **正向 — 清空父模块(设为根)**:`PUT` 时显式传 `"iParentId": null`,DB 中 `iParentId` 变 NULL。
+4. **正向 — 部分字段保留原值**:DTO 中 `bShowPermission` / `iSortOrder` 传 null,DB 中保留原值。
+5. **目标不存在**:`PUT /api/modules/999999`,返回 200 + `code=40421`。
+6. **目标已软删除**:先把模块 `bDeleted` 置 1(直接 DB UPDATE 模拟),再 `PUT`,返回 `40421`。
+7. **必填缺失**:DTO 缺 `sModuleNameZh`,返回 `40010`。
+8. **枚举非法**:`sDisplayType="X"`,返回 `40010`。
+9. **长度超限**:`sModuleType` = 51 字符,返回 `40010`。
+10. **iParentId 自引用**:`PUT /api/modules/{id}` 把 `iParentId` 设为 `{id}` 本身,返回 `40921`。
+11. **iParentId 不存在**:`PUT` 时 `iParentId=999999`,返回 `40411`。
+12. **iParentId 是后代**:祖父→父→子三层结构,`PUT` 祖父把 `iParentId` 设为子的 id,返回 `40921`。
+13. **sProcedureName 字段被忽略**:客户端误传 `sProcedureName="other"`,DB 中该字段保持原值。
+
+### 接口契约一致性
+
+- 响应格式严格符合 `{code, message, data, timestamp}`(docs/05 § 全局约定)。
+- 错误码段位与 docs/05 一致:`40010` / `40411` / `40421` / `40921` / `50000`。
+- 异常堆栈不出现在响应里。
+
+### 测试覆盖
+
+- **单元测试** `ModuleServiceImplTest`(继续 mock ModuleMapper):
+ - update_targetNotFound_throws40421
+ - update_targetSoftDeleted_throws40421
+ - update_parentSelfReference_throws40921
+ - update_parentNotFound_throws40411
+ - update_parentIsDescendant_throws40921
+ - update_full_returnsVOWithUpdatedFields(断言传给 mapper.updateById 的 entity 字段值,包括 sProcedureName 保留)
+ - update_partialNullFields_keepsOriginalValues
+ - update_clearParent_setsParentToNull
+
+- **集成测试** `ModuleControllerIT` 追加(`@Transactional` 自动回滚;用 ModuleMapper 直接预置数据):
+ - put_validUpdate_returns200
+ - put_setParentToNull_clearsParent
+ - put_targetNotFound_returns40421
+ - put_parentNotFound_returns40411
+ - put_parentSelfRef_returns40921
+ - put_parentIsDescendant_returns40921
+ - put_missingRequired_returns40010
+ - put_ignoresProcedureNameField_doesNotChange
+
+### 代码与文档
+
+- `// REQ-MOD-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode 枚举常量上。
+- 提交按 `feat(mod): REQ-MOD-002` 规范,每 Task 一个 commit。
+- 不引入 docs/04 § 零 技术栈外的依赖。
diff --git a/docs/superpowers/specs/2026-05-06-REQ-MOD-003.md b/docs/superpowers/specs/2026-05-06-REQ-MOD-003.md
new file mode 100644
index 0000000..ed88d35
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-06-REQ-MOD-003.md
@@ -0,0 +1,137 @@
+---
+req_id: REQ-MOD-003
+date: 2026-05-06
+module: module_mod
+---
+
+# Spec: REQ-MOD-003 — 模块删除
+
+## 目标
+
+实现后端 `DELETE /api/modules/{id}` 接口:对指定模块做**软删除**(写入 `bDeleted=1` / `tDeletedDate=now` / `sDeletedBy=NULL`),并在删除前校验子模块引用,避免破坏树结构完整性。
+
+## 输入 / 触发
+
+**接口**:`DELETE /api/modules/{id}`,无请求体。`{id}` = `tModule.iIncrement`。
+
+**鉴权**:契约要求 `Authorization: Bearer ` + 权限码 `MOD:DELETE`。本 REQ 沿用 SecurityConfig permitAll;Controller 上写 Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:DELETE')")`。
+
+## 输出 / 结果
+
+**HTTP 200,响应体**:
+
+```json
+{
+ "code": 200,
+ "message": "操作成功",
+ "data": {
+ "iIncrement": 12,
+ "bDeleted": true
+ },
+ "timestamp": 1746528600000
+}
+```
+
+返回精简 VO:仅 `iIncrement` + `bDeleted`,足以让前端在表格里直接更新该行的删除标记。新增 `ModuleDeleteResultVO` 单独承载该结构(避免复用 `ModuleVO` 暴露不必要字段)。
+
+## 业务规则
+
+1. **目标存在且未被软删除**:`SELECT iIncrement, bDeleted FROM tModule WHERE iIncrement = {id}`。`null` 或 `bDeleted = 1` → `BizException(MOD_NOT_FOUND)` (40421)。
+2. **子模块引用检查**:`SELECT COUNT(*) FROM tModule WHERE iParentId = {id} AND bDeleted = 0`。`> 0` → `BizException(MOD_HAS_REFERENCES)` (40912)。
+3. **外部业务引用**:本期 schema 无其他业务表通过 FK 引用 `tModule`(V1 SQL 中无外部 FK 指向 tModule)。本 REQ 仅检查子模块;后续若新增模块引用 tModule(如菜单 / 权限分配),需在该模块的 service 层追加引用检查并复用 `MOD_HAS_REFERENCES`。
+4. **软删除字段写入**:
+ - `bDeleted = 1`
+ - `tDeletedDate = LocalDateTime.now()`
+ - `sDeletedBy = NULL`(REQ-USR-004 后由登录上下文回填)
+ - 其他字段保持原值
+5. **已删除模块不可再删**:bDeleted=1 直接走 40421(与 #1 等效)。
+6. **重复请求语义**:连续两次 DELETE 同一 id,第一次成功(200 + bDeleted=true),第二次 40421。**非幂等**——但响应可预测,不会破坏数据。
+7. **事务边界**:service 方法 `@Transactional`,校验 + 软删除单事务内完成。
+
+## 边界与约束
+
+### 鉴权策略
+
+沿用 REQ-MOD-001/002 SecurityConfig permitAll。
+
+### 错误码映射
+
+| 场景 | 错误码 | ErrorCode 枚举常量 |
+|---|---|---|
+| `{id}` 不存在或已软删除 | 40421 | `MOD_NOT_FOUND`(已存在) |
+| 存在未软删除子模块 | 40912 | `MOD_HAS_REFERENCES`(**新增**) |
+| 服务端兜底 | 50000 | `INTERNAL_ERROR` |
+
+### 并发
+
+- 用 `moduleMapper.update(entity, wrapper)` 加 `bDeleted = 0` 条件,让"两个并发删除"中只有一个能写成功(影响行数 1),另一个影响 0 行。Service 检查影响行数:`= 0` → 返回 40421(视为已被并发删除)。
+- 不引入乐观锁版本号。
+
+### 性能
+
+- 子模块计数走 `idx_parent` 索引,O(1)。
+
+## 依赖的 schema 表 / 字段
+
+**写表**:`tModule`
+
+| 字段 | 行为 |
+|---|---|
+| `iIncrement` | 路径参数 `{id}` 定位行,**不修改** |
+| `bDeleted` | 0 → 1 |
+| `tDeletedDate` | 写入 `LocalDateTime.now()` |
+| `sDeletedBy` | 写入 `NULL`(REQ-USR-004 后回填) |
+| 其他全部字段 | **不修改** |
+
+**索引利用**:
+- `pk_module`:定位 `{id}`
+- `idx_parent`:子模块计数
+
+**外键**:本期无其他表 FK 指向 tModule,无需额外检查。
+
+## 依赖的接口
+
+无(独立接口)。
+
+## 验收标准
+
+### 功能正确性
+
+1. **正向 — 叶子模块删除**:先建一个无子模块的 root,DELETE,返回 200 + `data.iIncrement` + `data.bDeleted=true`。DB 中 `bDeleted=1` / `tDeletedDate` 非空 / `sDeletedBy=NULL` / 其他字段保持原值。
+2. **正向 — 删除后再 GET 列表(REQ-MOD-004)跳过该模块**:本 REQ 不实现 GET,但通过 `selectById` 后断言查询接口的过滤效果。
+3. **目标不存在**:DELETE `/api/modules/999999`,返回 `40421`。
+4. **目标已软删除**:手工 update bDeleted=1 后 DELETE,返回 `40421`。
+5. **存在未删除子模块**:先建 parent + child,DELETE parent,返回 `40912`;DB 中 parent.bDeleted 仍为 0。
+6. **存在已删除子模块(不阻塞)**:先建 parent + child,DELETE child(成功),再 DELETE parent,应成功(已删子模块不计入引用)。
+7. **重复 DELETE**:第二次返回 40421。
+8. **响应 VO 字段精简**:仅含 iIncrement + bDeleted(断言 sDisplayType / sProcedureName 等不在响应里)。
+
+### 接口契约一致性
+
+- 响应格式 `{code, message, data, timestamp}`。
+- 错误码:200 / 40421 / 40912 / 50000。
+- 不回显堆栈。
+
+### 测试覆盖
+
+- **单元测试** `ModuleServiceImplTest` 追加(mock ModuleMapper):
+ - delete_targetNotFound_throws40421
+ - delete_targetAlreadyDeleted_throws40421
+ - delete_hasUndeletedChildren_throws40912
+ - delete_leafModule_writesSoftDeleteFields_returnsResult
+ - delete_softDeletedChildren_doesNotBlock
+ - delete_concurrentRace_throws40421(mock update 影响 0 行)
+
+- **集成测试** `ModuleControllerIT` 追加:
+ - delete_validLeaf_returns200WithBDeletedTrue(先 mapper.insert,再 DELETE,再 selectById 验证 bDeleted=true / tDeletedDate 非空)
+ - delete_targetNotFound_returns40421
+ - delete_targetAlreadyDeleted_returns40421
+ - delete_hasUndeletedChildren_returns40912
+ - delete_softDeletedChildren_doesNotBlock_returns200
+ - delete_responseVOContainsOnlyIIncrementAndBDeleted
+
+### 代码与文档
+
+- `// REQ-MOD-003 模块删除` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode 枚举常量上。
+- 提交按 `feat(mod): REQ-MOD-003` 规范。
+- 不引入 docs/04 § 零 技术栈外的依赖。
diff --git a/docs/superpowers/specs/2026-05-06-REQ-MOD-004.md b/docs/superpowers/specs/2026-05-06-REQ-MOD-004.md
new file mode 100644
index 0000000..90702b3
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-06-REQ-MOD-004.md
@@ -0,0 +1,174 @@
+---
+req_id: REQ-MOD-004
+date: 2026-05-06
+module: module_mod
+---
+
+# Spec: REQ-MOD-004 — 模块查询
+
+## 目标
+
+实现后端 `GET /api/modules` 接口:以**树形结构**返回所有未软删除的模块,可选按 `sModuleNameZh` 模糊匹配过滤;过滤命中时同时保留命中节点的所有**祖先路径**以便定位上下文。
+
+## 输入 / 触发
+
+**接口**:`GET /api/modules`,无请求体。
+
+**Query parameters**:
+
+| 字段 | 类型 | 必填 | 校验 / 取值 |
+|---|---|---|---|
+| `keyword` | String | 否 | 长度 ≤ 50;非空时对 `sModuleNameZh` 做 `LIKE '%keyword%'`(不区分大小写——MySQL `utf8mb4_unicode_ci` 默认行为) |
+
+**鉴权**:契约要求 `Authorization: Bearer ` + 权限码 `MOD:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')")`。
+
+## 输出 / 结果
+
+**HTTP 200,响应体**:
+
+```json
+{
+ "code": 200,
+ "message": "操作成功",
+ "data": [
+ {
+ "iIncrement": 1,
+ "sModuleNameZh": "系统配置",
+ "sDisplayType": "系统配置",
+ "sManageDeptEn": "IT",
+ "iParentId": null,
+ "iSortOrder": 0,
+ "children": [
+ {
+ "iIncrement": 2,
+ "sModuleNameZh": "用户管理",
+ "sDisplayType": "前端业务",
+ "sManageDeptEn": "IT",
+ "iParentId": 1,
+ "iSortOrder": 1,
+ "children": []
+ }
+ ]
+ }
+ ],
+ "timestamp": 1746528600000
+}
+```
+
+`data` 是根节点数组(`iParentId == null` 的节点);每个节点带 `children` 数组(同结构递归)。
+
+新增 VO `ModuleTreeNodeVO`:字段 `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` / `children: List`。
+
+> **不返回的字段**(避免泄露内部):`sProcedureName` / `sModuleType` / `bShowPermission` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`。如未来 REQ 需要其中字段,再扩 VO。
+
+## 业务规则
+
+1. **范围过滤**:`bDeleted = 0`(默认仅返回未软删除)。
+2. **空 keyword**:返回所有未软删除模块构成的完整树。
+3. **非空 keyword**:
+ - 步骤 a:在所有未软删除模块中找出 `sModuleNameZh LIKE '%keyword%'` 的命中集合 `hits`。
+ - 步骤 b:对每个 `hit`,沿 `iParentId` 链向上收集所有未软删除祖先(深度上限 5 与 docs/03 § tModule 一致),并入结果集合。
+ - 步骤 c:用结果集合在内存中按 `iParentId` 组装树。
+ - 命中节点本身的子孙不会被强制纳入(除非也命中);这样避免一次过滤拉出整棵子树。
+4. **排序**:同级节点按 `iSortOrder ASC` 升序,`iSortOrder` 相同则按 `iIncrement ASC`(确定性排序)。
+5. **`children` 字段**:叶子节点为 `[]`,不为 `null`(确保前端可直接 `.map`)。
+6. **空结果**:keyword 无匹配 → `data = []`,**HTTP 200 + code=200**,不返回 404 类错误。
+7. **只读**:本接口不写库,无事务要求;标 `@Transactional(readOnly = true)`。
+
+## 边界与约束
+
+### 鉴权策略
+
+沿用 REQ-MOD-001/002/003 SecurityConfig permitAll。
+
+### 错误码
+
+| 场景 | 错误码 | ErrorCode 枚举 |
+|---|---|---|
+| `keyword` 长度 > 50 | 40010 | `PARAM_INVALID`(已存在) |
+| 服务端兜底 | 50000 | `INTERNAL_ERROR` |
+
+### 性能
+
+- 单次 `selectList(LambdaQueryWrapper.eq(bDeleted, false))` 拉取所有未删除模块;spec § 性能上限 docs/03 注明"单次返回不超过 500 项 / 树深度上限 5 层",本期数据量低不分页。
+- 在内存里 O(N) 建索引(id → entity)+ O(N) 建子节点列表 + O(N) 排序 + O(K * D) 沿祖先链向上(K=hits 数,D=深度上限 5)。
+- 不引入 SQL 递归 CTE / 自连接;保持 mapper 层轻量。
+
+### 大小写敏感
+
+`utf8mb4_unicode_ci` collation 默认不区分大小写。MyBatis-Plus `LambdaQueryWrapper.like(...)` 走 SQL `LIKE`,行为与 collation 一致。无需额外处理。
+
+### keyword 中的特殊字符
+
+`%` / `_` 在 SQL `LIKE` 模式里是通配符。简化处理:本期不做转义(业务模块名通常不含这些字符);如未来需要,service 层先把 keyword 中的 `%` / `_` / `\` 替换为转义形式(`MyBatis-Plus 5.x` 的 `like` 已自带 escape,本期 3.5.7 需手动)。**本 REQ 暂不处理转义**,作为已知边界记录。
+
+## 依赖的 schema 表 / 字段
+
+**读表**:`tModule`
+
+| 字段 | 用途 |
+|---|---|
+| `iIncrement` | 节点 id;输出 |
+| `sModuleNameZh` | 模糊匹配列;输出 |
+| `sDisplayType` | 输出 |
+| `sManageDeptEn` | 输出 |
+| `iParentId` | 父子关系;输出 |
+| `iSortOrder` | 排序;输出 |
+| `bDeleted` | 过滤未删除(=0) |
+
+**索引利用**:
+- `idx_module_name_zh`:`LIKE '%keyword%'` 实际不走索引(左模糊),但本期数据量低可接受。
+- `idx_parent`:祖先链查询时按 iIncrement PK 走 selectById,不用 idx_parent。
+- `idx_module_deleted`(未单独建,但 bDeleted 在多个 idx 中已有):bDeleted 过滤时 MySQL 优化器自行选择。
+
+**外键**:本接口只读,不触发 FK 检查。
+
+## 依赖的接口
+
+无(独立查询接口)。
+
+## 验收标准
+
+### 功能正确性
+
+1. **正向 — 空 keyword 返回完整树**:DB 中 5 棵根 + 多层子模块;GET 返回 5 个根节点,子孙完整嵌套;同级按 iSortOrder 升序。
+2. **正向 — keyword 模糊匹配 + 祖先**:DB 有 root("系统配置") → 子("用户管理") → 孙("登录");GET `?keyword=登录` 返回 root → 子 → 孙 三层(即命中节点 + 全部祖先)。
+3. **正向 — keyword 部分匹配**:keyword="管理",匹配多个节点;返回它们各自完整祖先链合并的树。
+4. **正向 — 软删除模块过滤**:DELETE 一个模块后再查,结果不含该模块。
+5. **正向 — 空结果**:keyword="不存在的关键词",返回 `data=[]` + code=200。
+6. **正向 — keyword 含中英混合**:keyword="user 用户" 也走 LIKE '%user 用户%'(与 spec § 业务规则 3 一致;不拆词)。
+7. **同级排序**:两根 iSortOrder=2/1,返回顺序 1 在前。
+8. **children 字段**:叶子节点 children 是 `[]` 而非 null(jsonPath 断言)。
+9. **keyword 长度超限**:keyword=51 字符,返回 `code=40010`。
+10. **无登录也可访问**(permitAll 阶段):直接 GET 不带 Authorization 返回 200。
+11. **响应字段精简**:响应不含 sProcedureName / sModuleType / bShowPermission / 标准列等内部字段(jsonPath 断言)。
+
+### 接口契约一致性
+
+- 响应格式 `{code, message, data, timestamp}`。
+- 错误码 200 / 40010 / 50000。
+- 不回显堆栈。
+
+### 测试覆盖
+
+- **单元测试** `ModuleServiceImplTest` 追加(mock ModuleMapper):
+ - tree_emptyDb_returnsEmptyList
+ - tree_singleRoot_returnsOneNodeWithEmptyChildren
+ - tree_multiLevel_buildsNestedStructureSortedByISortOrder
+ - tree_keywordHit_includesAncestorChain
+ - tree_keywordNoMatch_returnsEmptyList
+ - tree_softDeletedExcluded(mapper 已过滤;service 不重复过滤)
+
+- **集成测试** `ModuleControllerIT` 追加:
+ - get_emptyKeyword_returnsAllUndeletedAsTree
+ - get_keyword_filtersByModuleNameZhWithAncestors
+ - get_keywordNoMatch_returnsEmptyArray
+ - get_keywordTooLong_returns40010
+ - get_softDeletedNotInResult
+ - get_responseExcludesInternalFields
+ - get_leafNodeChildrenIsEmptyArrayNotNull
+
+### 代码与文档
+
+- `// REQ-MOD-004` 注释贴在 Controller / Service / VO。
+- 提交按 `feat(mod): REQ-MOD-004` 规范。
diff --git a/scripts/test.sh b/scripts/test.sh
index 1625250..35fa783 100755
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -8,6 +8,19 @@ set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_ROOT"
+# 让 .env.local 注入 JAVA_HOME / DB_* / JWT_SECRET 等运行期变量
+# 由 .githooks/pre-push 触发本脚本时也能拿到
+ENV_FILE="${PROJECT_ROOT}/.env.local"
+if [ -f "$ENV_FILE" ]; then
+ set -a; . "$ENV_FILE"; set +a
+fi
+if [ -n "${JAVA_HOME:-}" ] && [ -d "$JAVA_HOME" ]; then
+ export PATH="$JAVA_HOME/bin:$PATH"
+fi
+if [ -n "${EXTRA_PATH:-}" ]; then
+ export PATH="$EXTRA_PATH:$PATH"
+fi
+
# Stack detection (runtime, mode-agnostic)
HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1
HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1