---
req_id: REQ-MOD-001
date: 2026-04-29
spec_ref: docs/superpowers/specs/2026-04-29-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:** 在尚未存在的 `backend/` Spring Boot 工程中，从零搭起最小后端脚手架（统一响应 / 异常 / JWT Filter / Flyway / MyBatis-Plus）+ 实现 `POST /api/mod/modules` 完成模块新增（写入 `tModule`），并以单元 + 集成双层覆盖 spec 验收清单。

**Architecture:** 三层（controller / service / mapper） + 通用层（response / exception / security / config）。Spring Security 启 `JwtAuthenticationFilter` 解析 token 写 `principal=sUserNo`；本 REQ 对 `POST /api/mod/modules` 走 `permitAll` stub（角色硬校验留 USR-004 闭环）。多租户字段 `sBrandsId` / `sSubsidiaryId` 通过 `application.yml` 的 `erp.tenant.*` 注入，默认 `XLY`/`XLY`。错误码沿用 `docs/05` 已声明值（`40001/40010/40020/40021`），认证层错误用 `20001`。

**Tech Stack:** Spring Boot 3.x · MyBatis-Plus · Spring Security · JJWT · Flyway 10.x · MySQL 8 · Java 17 · Maven 3.9 · JUnit 5 · Mockito · Spring Boot Test。

---

## Schema 改动

无（`tModule` 已在 `sql/migrations/V1__initial_schema.sql` 由 A4 落地，本 REQ 仅写入数据，不动 DDL）。

## 文件变更清单

### 工程脚手架

- `backend/pom.xml` — 新建（声明依赖 + 编译/测试插件）
- `backend/src/main/java/com/xly/erp/ErpApplication.java` — 新建（`@SpringBootApplication` 启动类）
- `backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java` — 新建（`@MapperScan("com.xly.erp.**.mapper")`）
- `backend/src/main/resources/application.yml` — 新建（默认 profile + Flyway + datasource 用 `${ENV}` 占位）
- `backend/src/main/resources/application-test.yml` — 新建（test profile，沿用同一测试库）
- `backend/src/main/resources/logback-spring.xml` — 新建（最小日志配置，关闭 SQL 详细日志以外的噪音）
- `backend/src/test/resources/application-test.yml` — 与 main 同名 override 用，仅在测试时生效
- `backend/.gitignore` — 新建（`target/`、`*.iml`）

### 通用层

- `backend/src/main/java/com/xly/erp/common/response/Result.java` — 通用响应 `{code,msg,data}`，提供 `ok(T)` / `fail(int,String)` 静态工厂
- `backend/src/main/java/com/xly/erp/common/exception/BizException.java` — `RuntimeException` 子类，含 `code`、`msg`
- `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` — `@RestControllerAdvice`，处理 `BizException` / `MethodArgumentNotValidException` / `Exception` 兜底
- `backend/src/main/java/com/xly/erp/common/config/TenantProperties.java` — `@ConfigurationProperties("erp.tenant")`，字段 `brandsId` / `subsidiaryId`
- `backend/src/main/java/com/xly/erp/common/config/StubSecurityProperties.java` — `@ConfigurationProperties("erp.security")`，字段 `stubUserNo`（默认 `STUB_ADMIN`）
- `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` — 签发 + 解析 HS256；`sign(String userNo)` / `parse(String token) : String userNo`；密钥读 `JWT_SECRET`
- `backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java` — `OncePerRequestFilter`，存在 `Authorization: Bearer` 时尝试解析；解析失败写 `Result(20001,"未认证")` 并短路；缺失则 chain 透传（permitAll 路径需要这种放行能力）
- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — `SecurityFilterChain`：禁 CSRF，`POST /api/mod/modules` permitAll，其他 `authenticated()`，注册 Filter
- `backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java` — 静态方法 `currentUserNo() : String`（无认证返回 `null`）

### MOD 业务模块

- `backend/src/main/java/com/xly/erp/module/mod/entity/Module.java` — MyBatis-Plus `@TableName("tModule")` PO（含 `iIncrement`/`sId`/`sBrandsId`/`sSubsidiaryId`/`tCreateDate`/`sDisplayType`/`sProcedureName`/`sModuleType`/`sManageDeptEn`/`bShowPermission`/`sModuleNameZh`/`iParentId`/`iSortOrder`/`sCreatedBy`/`bDeleted`/`tDeletedDate`/`sDeletedBy`）
- `backend/src/main/java/com/xly/erp/module/mod/dto/CreateModuleDTO.java` — Bean Validation 注解的入参 DTO
- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 继承 `BaseMapper<Module>`，自定义 `boolean existsActiveById(Integer iIncrement)`
- `backend/src/main/resources/mapper/mod/ModuleMapper.xml` — `existsActiveById` 的 SELECT 1 实现
- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 接口，方法 `Integer create(CreateModuleDTO dto)`
- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现，`@Transactional`，含枚举校验/父校验/标准列填充/唯一冲突捕获
- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — `@PostMapping("/api/mod/modules")` 接受 `@Valid CreateModuleDTO`，返回 `Result<Map<String,Integer>>`

### 测试

- `backend/src/test/java/com/xly/erp/SmokeTest.java` — `@SpringBootTest` 启动 + 验 Flyway 已 apply（`tModule` 表存在）
- `backend/src/test/java/com/xly/erp/common/security/TestJwtHelper.java` — 测试辅助，`signFor(String userNo) : String`
- `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java` — Mock MVC 单测，`@WebMvcTest` 限定 + 一个抛 `BizException` 的 stub controller
- `backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java` — 单测，`signAndParse_roundTrip`
- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — Mockito 单测，6 个用例（参 spec 单元测试清单）
- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — `@SpringBootTest(webEnvironment=RANDOM_PORT)` + `TestRestTemplate`，7 个用例（参 spec 集成测试清单）

## 任务步骤

> 全局约束：每个 commit 形如 `<type>(mod): <subject> REQ-MOD-001`；测试运行强制派发到子会话执行（`mvn -B test -pl backend` 或带 `-Dtest=` 单测过滤）；spec 中任意 `permitAll stub` 注释统一带 `// REQ-MOD-001 stub: see USR-004 follow-up` 形式锚点，便于后续 grep 替换。

### Task 1: 工程脚手架立起来（pom + Application + yml + smoke）

**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/main/java/com/xly/erp/common/config/MybatisPlusConfig.java`
- Create: `backend/src/main/resources/logback-spring.xml`
- Create: `backend/.gitignore`
- Test: `backend/src/test/java/com/xly/erp/SmokeTest.java`

**API shape:** N/A（脚手架）

**关键依赖**（pom.xml 必须含）：`spring-boot-starter-web`、`spring-boot-starter-validation`、`spring-boot-starter-security`、`mybatis-plus-spring-boot3-starter`、`mysql-connector-j`、`flyway-core`、`flyway-mysql`、`io.jsonwebtoken:jjwt-api/impl/jackson 0.12.x`、`spring-boot-starter-test`。Java 17，编码 UTF-8。

**application.yml 必填字段**（值通过环境变量注入，对照 `.env.local`）：
- `spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai`
- `spring.datasource.username=${DB_USER}`
- `spring.datasource.password=${DB_PASSWORD}`
- `spring.flyway.enabled=true` / `locations=classpath:db/migration,filesystem:./sql/migrations`（指向仓库根 `sql/migrations/`，不复制文件）
- `spring.flyway.baseline-on-migrate=true`
- `server.port=8080`
- `erp.tenant.brands-id=XLY`
- `erp.tenant.subsidiary-id=XLY`
- `erp.security.stub-user-no=STUB_ADMIN`
- `erp.security.jwt-secret=${JWT_SECRET}`
- `mybatis-plus.mapper-locations=classpath:mapper/**/*.xml`

- [ ] **Step 1: 建 pom.xml + ErpApplication + yml + logback-spring + .gitignore**
  - 直接创建上述文件骨架，不写任何业务代码
  - `ErpApplication` 仅 `@SpringBootApplication` + `main`

- [ ] **Step 2: 写失败测试 `SmokeTest`**
  - 测试名: `SmokeTest#contextLoads_andFlywayApplied`
  - 意图: `@SpringBootTest(webEnvironment=NONE)` 启动 + 注入 `JdbcTemplate`，断言 `SHOW TABLES LIKE 'tModule'` 命中 1 行
  - 子会话先跑：编译应通过、测试失败的原因应是 Spring 启动错误（如缺 driver）或 Flyway 未 apply

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=SmokeTest`
  - 期望：BUILD SUCCESS，1 test passed
  - 排查点：DB 连接（用 `.env.local` 现有凭据 `118.178.19.35:3318`）、Flyway location 是否能解析到 `sql/migrations/V1`
  - **测试前置**：本会话先在主会话调 `bash scripts/setup-test-db.sh` 清库，让 SmokeTest 依赖的 Flyway apply 从 V1 重放

- [ ] **Step 4: Commit**
  - `git add backend/`
  - `git commit -m "chore(mod): bootstrap backend scaffold REQ-MOD-001"`

### Task 2: 通用响应 + 异常处理框架

**Files:**
- Create: `backend/src/main/java/com/xly/erp/common/response/Result.java`
- 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`

**API shape:**
- `Result<T>` 字段: `int code`, `String msg`, `T data`；静态方法 `ok(T)` / `ok()` / `fail(int, String)`
- `BizException(int code, String msg)` extends `RuntimeException`
- `GlobalExceptionHandler` `@RestControllerAdvice`：
  - `handleBiz(BizException) -> Result.fail(e.code, e.msg)`，HTTP 200
  - `handleValidation(MethodArgumentNotValidException) -> Result.fail(40001, "<field>: <message>")`，HTTP 200
  - `handleAny(Exception) -> Result.fail(50000, "系统繁忙")`，HTTP 200，**只记日志、不回显堆栈**

- [ ] **Step 1: 写失败测试**
  - 测试文件: `GlobalExceptionHandlerTest`
  - `@WebMvcTest` 限定 + 一个 stub controller `/__test/throw-biz`（抛 `BizException(30001,"x")`）/ `/__test/throw-validate`（接 `@Valid` 入参）/ `/__test/throw-runtime`（抛 `IllegalStateException`）
  - 三个测试：
    - `bizException_returnsResultWithBizCode` 期望 body `{code:30001,msg:"x"}`
    - `validationException_returns40001WithFieldHint` 期望 `code=40001` 且 `msg` 包含字段名
    - `uncaughtException_returns50000` 期望 `code=50000`
  - 子会话确认 FAIL（类不存在）

- [ ] **Step 2: 实现最小代码**
  - 涉及文件：`Result.java`、`BizException.java`、`GlobalExceptionHandler.java`
  - 不超出 spec § 边界与约束 列出的错误码语义

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=GlobalExceptionHandlerTest`
  - 期望：3 tests passed

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): unified Result + global exception handler REQ-MOD-001"`

### Task 3: 租户配置 + JWT 工具 + 测试辅助

**Files:**
- Create: `backend/src/main/java/com/xly/erp/common/config/TenantProperties.java`
- Create: `backend/src/main/java/com/xly/erp/common/config/StubSecurityProperties.java`
- Create: `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java`
- Test: `backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java`
- Test: `backend/src/test/java/com/xly/erp/common/security/TestJwtHelper.java`（生产代码意义上是测试基础设施）

**API shape:**
- `TenantProperties` 字段 `String brandsId`, `String subsidiaryId`，绑定前缀 `erp.tenant`
- `StubSecurityProperties` 字段 `String stubUserNo`, `String jwtSecret`，绑定前缀 `erp.security`
- `JwtUtil#sign(String userNo) : String`（HS256，subject=userNo，过期 8 小时）
- `JwtUtil#parse(String token) : String`（返回 subject；过期/签名错抛 `BizException(20001,"未认证或 token 已失效")`）
- `TestJwtHelper#signFor(String userNo) : String`（包装 JwtUtil，方便 IT 用）

- [ ] **Step 1: 写失败测试 `JwtUtilTest`**
  - 测试名:
    - `signAndParse_roundTrip` — `parse(sign("u1")) == "u1"`
    - `parseTamperedToken_throwsBizException20001`
  - 用 `@SpringBootTest` 注入 `JwtUtil`，密钥走 application-test.yml 的 `${JWT_SECRET}`

- [ ] **Step 2: 实现最小代码**
  - `TenantProperties` / `StubSecurityProperties` + 在 `ErpApplication` 加 `@EnableConfigurationProperties({TenantProperties.class, StubSecurityProperties.class})`
  - `JwtUtil` 用 `io.jsonwebtoken.Jwts.builder()/parser()`
  - `TestJwtHelper` `@Component` 注 `JwtUtil`

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=JwtUtilTest`

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): tenant + jwt config + util REQ-MOD-001"`

### Task 4: JWT Filter + SecurityConfig

**Files:**
- Create: `backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java`
- Create: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java`
- Create: `backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java`
- Test: `backend/src/test/java/com/xly/erp/common/security/SecurityFilterIT.java`

**API shape:**
- `JwtAuthenticationFilter extends OncePerRequestFilter`
  - 头解析：`Authorization: Bearer <token>`
  - 有 token 且解析成功 → `SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(userNo, null, List.of()))`
  - 有 token 但解析失败 → 写 `Result.fail(20001,"未认证或 token 已失效")` JSON 到 response，状态码 200，**短路 chain**
  - 无 token → chain 直接放行（让 SecurityConfig 的 permitAll/authenticated 规则决定）
- `SecurityConfig#filterChain(HttpSecurity)`:
  - `csrf().disable()` / `sessionManagement().sessionCreationPolicy(STATELESS)`
  - `authorizeHttpRequests` 顺序：
    1. `requestMatchers(HttpMethod.POST, "/api/mod/modules").permitAll()` — REQ-MOD-001 stub: see USR-004 follow-up
    2. `anyRequest().authenticated()`
  - `addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)`
- `SecurityContextHelper.currentUserNo() : String` — 读 `SecurityContextHolder` principal；无认证返回 `null`

- [ ] **Step 1: 写失败测试 `SecurityFilterIT`**
  - 测试名（用 `@SpringBootTest(webEnvironment=RANDOM_PORT)` + `TestRestTemplate`，命中一个真实接口；本任务先 stub 一个 `/__test/principal` 暴露 `currentUserNo()`，但更稳妥的做法是放在 Task 8 实现 controller 后回填，**故本任务测试范围限制在 SecurityConfig 的路径规则**）：
    - `validJwt_authenticatedEndpoint_returnsPrincipal` — POST `/api/mod/modules` 带合法 token，期望 200（permitAll，sCreatedBy 由后续 Task 验证）
    - `tamperedJwt_anyEndpoint_returns20001` — Authorization 伪造 → JSON `code=20001`
    - `noJwt_permitAllEndpoint_passes` — 无 Authorization 头 → 200（不阻断 permitAll）
    - `noJwt_protectedEndpoint_returns20001OrSpring403` — 命中默认 protected，期望状态码非 200 或 `code=20001`（Spring Security 默认会返回 403/401，二者皆可）
  - 上述测试 POST `/api/mod/modules` 在本任务尚未实现 controller，会返回 404；将 405/404 视作 "permitAll 通过 filter 链" 的间接证据，断言不要求 200
  - 子会话确认 FAIL（filter/config 类不存在）

- [ ] **Step 2: 实现最小代码**
  - 三个类按 API shape 实现；filter 中"短路 + 写 JSON" 用 `ObjectMapper` 序列化 `Result.fail(20001,...)`，content-type=application/json

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=SecurityFilterIT`

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): jwt filter + security config (role stub) REQ-MOD-001"`

### Task 5: tModule Entity + Mapper（数据层）

**Files:**
- Create: `backend/src/main/java/com/xly/erp/module/mod/entity/Module.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:**
- `Module` 类字段（与 `tModule` 1:1，使用 `@TableName("tModule")` + `@TableField` 显式映射）：
  - `Integer iIncrement` (`@TableId(type=IdType.AUTO)`)
  - `String sId`, `String sBrandsId`, `String sSubsidiaryId`
  - `LocalDateTime tCreateDate`
  - `String sDisplayType`, `String sProcedureName`, `String sModuleType`, `String sManageDeptEn`
  - `Boolean bShowPermission`
  - `String sModuleNameZh`
  - `Integer iParentId`, `Integer iSortOrder`
  - `String sCreatedBy`
  - `Boolean bDeleted`, `LocalDateTime tDeletedDate`, `String sDeletedBy`
- `ModuleMapper extends BaseMapper<Module>`，附加方法：
  - `boolean existsActiveById(@Param("id") Integer iIncrement)` — XML 中 `SELECT 1 FROM tModule WHERE iIncrement=#{id} AND bDeleted=0 LIMIT 1`，返回非空结果即 true

- [ ] **Step 1: 写失败测试 `ModuleMapperIT`**
  - 测试名:
    - `insertAndSelectById_persistsAllStandardCols` — 构造 Module 实例 set 全字段后 `mapper.insert(...)` → `mapper.selectById(...)` 比较各字段（含 `sBrandsId='XLY'`）
    - `existsActiveById_trueForAlive_falseForDeleted` — 插两条，一条 `bDeleted=0`、一条 `bDeleted=1`，分别校验
  - 用 `@SpringBootTest` + `@Transactional`（自动回滚不污染库），`@Autowired ModuleMapper`
  - 子会话先跑 → FAIL（类不存在）

- [ ] **Step 2: 实现最小代码**
  - Module entity + Mapper 接口 + XML
  - 标准列 `tCreateDate` 由测试代码填，不依赖 DB 默认（spec 边界对齐）

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=ModuleMapperIT`

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): tModule entity + mapper REQ-MOD-001"`

### Task 6: CreateModuleDTO + ModuleService 主流程（合法路径 + 父校验）

**Files:**
- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/CreateModuleDTO.java`
- 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`

**API shape:**
- `CreateModuleDTO` 字段（带 Bean Validation）：
  - `@NotBlank String sDisplayType`
  - `@NotBlank @Size(max=100) String sProcedureName`
  - `@NotBlank @Size(max=50) String sModuleType`
  - `@NotBlank @Size(max=50) String sManageDeptEn`
  - `Boolean bShowPermission`（可空，service 层默认 false）
  - `@NotBlank @Size(max=100) String sModuleNameZh`
  - `Integer iParentId`（可空）
  - `Integer iSortOrder`（可空，service 层默认 0）
- `ModuleService#create(CreateModuleDTO dto) : Integer`（返回新 `iIncrement`）
- `ModuleServiceImpl` 依赖：`ModuleMapper` / `TenantProperties` / `StubSecurityProperties`
- 流程：
  1. 校验 `sDisplayType` ∈ `{手机端,前端业务,系统配置,接口}`，否则 `BizException(40010,"显示类型枚举不合法")`
  2. 若 `iParentId != null` 调 `mapper.existsActiveById(iParentId)`；不存在 → `BizException(40021,"父模块不存在或已删除")`
  3. 构造 `Module` 实例：DTO 字段透传 + 标准列填充：
     - `tCreateDate = LocalDateTime.now()`
     - `sBrandsId = tenantProps.brandsId`
     - `sSubsidiaryId = tenantProps.subsidiaryId`
     - `sCreatedBy = SecurityContextHelper.currentUserNo()` 或 `stubProps.stubUserNo`（前者为 null 时回退）
     - `bShowPermission = dto.bShowPermission != null ? dto : false`
     - `iSortOrder = dto.iSortOrder != null ? dto : 0`
     - `bDeleted = false`
  4. `mapper.insert(entity)`；MyBatis-Plus 自动回填 `iIncrement`
  5. `return entity.getIIncrement()`
- 类上加 `@Transactional(rollbackFor = Exception.class)`

- [ ] **Step 1: 写失败测试 `ModuleServiceImplTest`（本任务两用例）**
  - 测试名:
    - `createWithValidDto_persistsWithStandardCols` — Mock `ModuleMapper.insert`（用 `Answer` 设置 entity.iIncrement=99）+ Mock `existsActiveById(true)`，断言：返回 99；捕获 `ArgumentCaptor<Module>` 校验 `sBrandsId="XLY"` / `sSubsidiaryId="XLY"` / `tCreateDate != null` / `sCreatedBy="STUB_ADMIN"`（无认证上下文，回退 stub）
    - `createWithParentNotFound_throws40021` — Mock `existsActiveById(false)` + DTO `iParentId=42`，断言抛 `BizException`，`code=40021`
  - 子会话先跑 → FAIL

- [ ] **Step 2: 实现最小代码**
  - DTO + Service interface + Impl，仅覆盖本任务两用例的最小逻辑（枚举校验和重复键捕获放下个 Task 写测试驱动）

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=ModuleServiceImplTest`

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): module create dto + service happy path REQ-MOD-001"`

### Task 7: Service 层异常分支补全（枚举非法 + 唯一冲突）

**Files:**
- 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:** 同 Task 6（仅补分支逻辑）

- [ ] **Step 1: 在测试类中追加 4 个用例**
  - `createWithInvalidDisplayType_throws40010` — DTO `sDisplayType="未知"`，期望 `BizException.code=40010`
  - `createWithNullParentId_skipsParentCheck` — DTO `iParentId=null`，期望 `mapper.existsActiveById` 不被调用，且仍走完插入
  - `mapperDuplicateKey_throws40020` — Mock `mapper.insert` 抛 `org.springframework.dao.DuplicateKeyException`，期望转抛 `BizException.code=40020`
  - `usesAuthenticatedUserNoAsCreatedBy` — `SecurityContextHolder` 注入 `userNo="ALICE"`，断言传给 mapper 的 entity.sCreatedBy="ALICE"（**用 `@AfterEach SecurityContextHolder.clearContext()` 隔离**）
  - 子会话先跑 → 4 用例 FAIL

- [ ] **Step 2: 在 ServiceImpl 中补充分支**
  - 枚举校验（白名单 `Set.of("手机端","前端业务","系统配置","接口")`）
  - try/catch `DuplicateKeyException` → `BizException(40020,"存储过程名称已存在")`
  - sCreatedBy 优先取 `SecurityContextHelper.currentUserNo()`

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=ModuleServiceImplTest`
  - 期望：6 tests passed（含 Task 6 的 2 个）

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): module create error branches REQ-MOD-001"`

### Task 8: Controller + 集成测试（正常路径）

**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/mod")`
- `@PostMapping("/modules") public Result<Map<String,Integer>> create(@Valid @RequestBody CreateModuleDTO dto)`
- 返回 `Result.ok(Map.of("iIncrement", service.create(dto)))`

- [ ] **Step 1: 写失败测试 IT 正常路径**
  - 测试名: `ModuleControllerIT#postValidBody_with_jwt_returns200_andPersists`
  - 用 `@SpringBootTest(webEnvironment=RANDOM_PORT)` + `TestRestTemplate` + `@Autowired TestJwtHelper` + `@Autowired JdbcTemplate`
  - 步骤：
    1. `String token = testJwtHelper.signFor("ADMIN001")`
    2. POST `/api/mod/modules` body `{sDisplayType:"手机端", sProcedureName:"sp_test_001", sModuleType:"业务模块", sManageDeptEn:"IT", sModuleNameZh:"测试模块"}`，header `Authorization: Bearer <token>`
    3. 期望 HTTP 200，响应 `code=0`，`data.iIncrement` 是正整数 N
    4. JdbcTemplate 查 `tModule WHERE iIncrement=N`，断言 `sCreatedBy="ADMIN001"`、`sBrandsId="XLY"`
  - **测试隔离**：`@BeforeEach`/`@AfterEach` 用 JdbcTemplate `DELETE FROM tModule WHERE sProcedureName LIKE 'sp_test_%'`，避免污染
  - 子会话先跑 → FAIL（controller 不存在 → 404）

- [ ] **Step 2: 实现 ModuleController**
  - 严格按 API shape，不加任何额外路径

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=ModuleControllerIT#postValidBody_with_jwt_returns200_andPersists`

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): POST /api/mod/modules controller REQ-MOD-001"`

### Task 9: 集成测试异常路径（参数缺失 / 枚举非法 / 唯一冲突 / 父不存在 / 鉴权 stub 行为）

**Files:**
- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`

**API shape:** 不新增（覆盖现有接口 6 条异常路径）

- [ ] **Step 1: 在 IT 中追加 6 个用例**
  - `postEmptyBody_returns40001_withFieldHint` — body `{}`，期望 `code=40001`，`msg` 含 `sProcedureName` 等任一字段名
  - `postInvalidDisplayType_returns40010` — body `sDisplayType="火星"`，期望 `code=40010`
  - `postDuplicateProcedureName_returns40020` — 先插一条（直接 JdbcTemplate 或先 POST 一次），再 POST 同名 → `code=40020`
  - `postWithMissingParent_returns40021` — `iParentId=999999` → `code=40021`
  - `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` — 不带 Authorization 头，期望 200 + 新行 `sCreatedBy="STUB_ADMIN"`（验证 stub 行为，spec 已声明 USR-004 后改为 401）
  - `postWithTamperedJwt_returns20001` — Authorization 头伪造（`Bearer xxx.yyy.zzz`），期望 `code=20001`（filter 拦截短路）
  - 6 个用例先跑 → FAIL（缺分支/HTTP 状态预期不符）

- [ ] **Step 2: 让测试通过**
  - 多数用例的服务端逻辑已在 Task 7 + Task 4 实现；本步骤主要是排查测试中 RestTemplate 行为（如 4xx 是否抛、是否需要用 `String.class` 接收 body 再手动 parse JSON）
  - **不应当**为让测试通过新增业务分支；如发现确实缺分支，回炉对应 Task 的 service/filter

- [ ] **Step 3: 子会话验证全 IT PASS**
  - 命令：`cd backend && mvn -B test -Dtest=ModuleControllerIT`
  - 期望：7 tests passed（含 Task 8 的 1 个）

- [ ] **Step 4: 子会话跑全模块单测套件做回归**
  - 命令：`cd backend && mvn -B test`
  - 期望：所有用例 PASS（SmokeTest + GlobalExceptionHandlerTest + JwtUtilTest + SecurityFilterIT + ModuleMapperIT + ModuleServiceImplTest + ModuleControllerIT）

- [ ] **Step 5: Commit**
  - `git commit -m "test(mod): module create integration coverage REQ-MOD-001"`

## 提交计划

| commit | 覆盖 |
|---|---|
| `chore(mod): bootstrap backend scaffold REQ-MOD-001` | Task 1 |
| `feat(mod): unified Result + global exception handler REQ-MOD-001` | Task 2 |
| `feat(mod): tenant + jwt config + util REQ-MOD-001` | Task 3 |
| `feat(mod): jwt filter + security config (role stub) REQ-MOD-001` | Task 4 |
| `feat(mod): tModule entity + mapper REQ-MOD-001` | Task 5 |
| `feat(mod): module create dto + service happy path REQ-MOD-001` | Task 6 |
| `feat(mod): module create error branches REQ-MOD-001` | Task 7 |
| `feat(mod): POST /api/mod/modules controller REQ-MOD-001` | Task 8 |
| `test(mod): module create integration coverage REQ-MOD-001` | Task 9 |
