Commit 642c5f98fd4d0f580e1647780e7767eb75fe3337
1 parent
9bb41b2b
docs(mod): spec + plan REQ-MOD-001
Showing
2 changed files
with
571 additions
and
0 deletions
docs/superpowers/plans/2026-04-29-REQ-MOD-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-MOD-001 | |
| 3 | +date: 2026-04-29 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-04-29-REQ-MOD-001.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-MOD-001 模块新增 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 10 | + | |
| 11 | +**Goal:** 在尚未存在的 `backend/` Spring Boot 工程中,从零搭起最小后端脚手架(统一响应 / 异常 / JWT Filter / Flyway / MyBatis-Plus)+ 实现 `POST /api/mod/modules` 完成模块新增(写入 `tModule`),并以单元 + 集成双层覆盖 spec 验收清单。 | |
| 12 | + | |
| 13 | +**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`。 | |
| 14 | + | |
| 15 | +**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。 | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Schema 改动 | |
| 20 | + | |
| 21 | +无(`tModule` 已在 `sql/migrations/V1__initial_schema.sql` 由 A4 落地,本 REQ 仅写入数据,不动 DDL)。 | |
| 22 | + | |
| 23 | +## 文件变更清单 | |
| 24 | + | |
| 25 | +### 工程脚手架 | |
| 26 | + | |
| 27 | +- `backend/pom.xml` — 新建(声明依赖 + 编译/测试插件) | |
| 28 | +- `backend/src/main/java/com/xly/erp/ErpApplication.java` — 新建(`@SpringBootApplication` 启动类) | |
| 29 | +- `backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java` — 新建(`@MapperScan("com.xly.erp.**.mapper")`) | |
| 30 | +- `backend/src/main/resources/application.yml` — 新建(默认 profile + Flyway + datasource 用 `${ENV}` 占位) | |
| 31 | +- `backend/src/main/resources/application-test.yml` — 新建(test profile,沿用同一测试库) | |
| 32 | +- `backend/src/main/resources/logback-spring.xml` — 新建(最小日志配置,关闭 SQL 详细日志以外的噪音) | |
| 33 | +- `backend/src/test/resources/application-test.yml` — 与 main 同名 override 用,仅在测试时生效 | |
| 34 | +- `backend/.gitignore` — 新建(`target/`、`*.iml`) | |
| 35 | + | |
| 36 | +### 通用层 | |
| 37 | + | |
| 38 | +- `backend/src/main/java/com/xly/erp/common/response/Result.java` — 通用响应 `{code,msg,data}`,提供 `ok(T)` / `fail(int,String)` 静态工厂 | |
| 39 | +- `backend/src/main/java/com/xly/erp/common/exception/BizException.java` — `RuntimeException` 子类,含 `code`、`msg` | |
| 40 | +- `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` — `@RestControllerAdvice`,处理 `BizException` / `MethodArgumentNotValidException` / `Exception` 兜底 | |
| 41 | +- `backend/src/main/java/com/xly/erp/common/config/TenantProperties.java` — `@ConfigurationProperties("erp.tenant")`,字段 `brandsId` / `subsidiaryId` | |
| 42 | +- `backend/src/main/java/com/xly/erp/common/config/StubSecurityProperties.java` — `@ConfigurationProperties("erp.security")`,字段 `stubUserNo`(默认 `STUB_ADMIN`) | |
| 43 | +- `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` — 签发 + 解析 HS256;`sign(String userNo)` / `parse(String token) : String userNo`;密钥读 `JWT_SECRET` | |
| 44 | +- `backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java` — `OncePerRequestFilter`,存在 `Authorization: Bearer` 时尝试解析;解析失败写 `Result(20001,"未认证")` 并短路;缺失则 chain 透传(permitAll 路径需要这种放行能力) | |
| 45 | +- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — `SecurityFilterChain`:禁 CSRF,`POST /api/mod/modules` permitAll,其他 `authenticated()`,注册 Filter | |
| 46 | +- `backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java` — 静态方法 `currentUserNo() : String`(无认证返回 `null`) | |
| 47 | + | |
| 48 | +### MOD 业务模块 | |
| 49 | + | |
| 50 | +- `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`) | |
| 51 | +- `backend/src/main/java/com/xly/erp/module/mod/dto/CreateModuleDTO.java` — Bean Validation 注解的入参 DTO | |
| 52 | +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 继承 `BaseMapper<Module>`,自定义 `boolean existsActiveById(Integer iIncrement)` | |
| 53 | +- `backend/src/main/resources/mapper/mod/ModuleMapper.xml` — `existsActiveById` 的 SELECT 1 实现 | |
| 54 | +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 接口,方法 `Integer create(CreateModuleDTO dto)` | |
| 55 | +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现,`@Transactional`,含枚举校验/父校验/标准列填充/唯一冲突捕获 | |
| 56 | +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — `@PostMapping("/api/mod/modules")` 接受 `@Valid CreateModuleDTO`,返回 `Result<Map<String,Integer>>` | |
| 57 | + | |
| 58 | +### 测试 | |
| 59 | + | |
| 60 | +- `backend/src/test/java/com/xly/erp/SmokeTest.java` — `@SpringBootTest` 启动 + 验 Flyway 已 apply(`tModule` 表存在) | |
| 61 | +- `backend/src/test/java/com/xly/erp/common/security/TestJwtHelper.java` — 测试辅助,`signFor(String userNo) : String` | |
| 62 | +- `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java` — Mock MVC 单测,`@WebMvcTest` 限定 + 一个抛 `BizException` 的 stub controller | |
| 63 | +- `backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java` — 单测,`signAndParse_roundTrip` | |
| 64 | +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — Mockito 单测,6 个用例(参 spec 单元测试清单) | |
| 65 | +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — `@SpringBootTest(webEnvironment=RANDOM_PORT)` + `TestRestTemplate`,7 个用例(参 spec 集成测试清单) | |
| 66 | + | |
| 67 | +## 任务步骤 | |
| 68 | + | |
| 69 | +> 全局约束:每个 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 替换。 | |
| 70 | + | |
| 71 | +### Task 1: 工程脚手架立起来(pom + Application + yml + smoke) | |
| 72 | + | |
| 73 | +**Files:** | |
| 74 | +- Create: `backend/pom.xml` | |
| 75 | +- Create: `backend/src/main/java/com/xly/erp/ErpApplication.java` | |
| 76 | +- Create: `backend/src/main/resources/application.yml` | |
| 77 | +- Create: `backend/src/main/resources/application-test.yml` | |
| 78 | +- Create: `backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java` | |
| 79 | +- Create: `backend/src/main/resources/logback-spring.xml` | |
| 80 | +- Create: `backend/.gitignore` | |
| 81 | +- Test: `backend/src/test/java/com/xly/erp/SmokeTest.java` | |
| 82 | + | |
| 83 | +**API shape:** N/A(脚手架) | |
| 84 | + | |
| 85 | +**关键依赖**(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。 | |
| 86 | + | |
| 87 | +**application.yml 必填字段**(值通过环境变量注入,对照 `.env.local`): | |
| 88 | +- `spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai` | |
| 89 | +- `spring.datasource.username=${DB_USER}` | |
| 90 | +- `spring.datasource.password=${DB_PASSWORD}` | |
| 91 | +- `spring.flyway.enabled=true` / `locations=classpath:db/migration,filesystem:./sql/migrations`(指向仓库根 `sql/migrations/`,不复制文件) | |
| 92 | +- `spring.flyway.baseline-on-migrate=true` | |
| 93 | +- `server.port=8080` | |
| 94 | +- `erp.tenant.brands-id=XLY` | |
| 95 | +- `erp.tenant.subsidiary-id=XLY` | |
| 96 | +- `erp.security.stub-user-no=STUB_ADMIN` | |
| 97 | +- `erp.security.jwt-secret=${JWT_SECRET}` | |
| 98 | +- `mybatis-plus.mapper-locations=classpath:mapper/**/*.xml` | |
| 99 | + | |
| 100 | +- [ ] **Step 1: 建 pom.xml + ErpApplication + yml + logback-spring + .gitignore** | |
| 101 | + - 直接创建上述文件骨架,不写任何业务代码 | |
| 102 | + - `ErpApplication` 仅 `@SpringBootApplication` + `main` | |
| 103 | + | |
| 104 | +- [ ] **Step 2: 写失败测试 `SmokeTest`** | |
| 105 | + - 测试名: `SmokeTest#contextLoads_andFlywayApplied` | |
| 106 | + - 意图: `@SpringBootTest(webEnvironment=NONE)` 启动 + 注入 `JdbcTemplate`,断言 `SHOW TABLES LIKE 'tModule'` 命中 1 行 | |
| 107 | + - 子会话先跑:编译应通过、测试失败的原因应是 Spring 启动错误(如缺 driver)或 Flyway 未 apply | |
| 108 | + | |
| 109 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 110 | + - 命令:`cd backend && mvn -B test -Dtest=SmokeTest` | |
| 111 | + - 期望:BUILD SUCCESS,1 test passed | |
| 112 | + - 排查点:DB 连接(用 `.env.local` 现有凭据 `118.178.19.35:3318`)、Flyway location 是否能解析到 `sql/migrations/V1` | |
| 113 | + - **测试前置**:本会话先在主会话调 `bash scripts/setup-test-db.sh` 清库,让 SmokeTest 依赖的 Flyway apply 从 V1 重放 | |
| 114 | + | |
| 115 | +- [ ] **Step 4: Commit** | |
| 116 | + - `git add backend/` | |
| 117 | + - `git commit -m "chore(mod): bootstrap backend scaffold REQ-MOD-001"` | |
| 118 | + | |
| 119 | +### Task 2: 通用响应 + 异常处理框架 | |
| 120 | + | |
| 121 | +**Files:** | |
| 122 | +- Create: `backend/src/main/java/com/xly/erp/common/response/Result.java` | |
| 123 | +- Create: `backend/src/main/java/com/xly/erp/common/exception/BizException.java` | |
| 124 | +- Create: `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` | |
| 125 | +- Test: `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java` | |
| 126 | + | |
| 127 | +**API shape:** | |
| 128 | +- `Result<T>` 字段: `int code`, `String msg`, `T data`;静态方法 `ok(T)` / `ok()` / `fail(int, String)` | |
| 129 | +- `BizException(int code, String msg)` extends `RuntimeException` | |
| 130 | +- `GlobalExceptionHandler` `@RestControllerAdvice`: | |
| 131 | + - `handleBiz(BizException) -> Result.fail(e.code, e.msg)`,HTTP 200 | |
| 132 | + - `handleValidation(MethodArgumentNotValidException) -> Result.fail(40001, "<field>: <message>")`,HTTP 200 | |
| 133 | + - `handleAny(Exception) -> Result.fail(50000, "系统繁忙")`,HTTP 200,**只记日志、不回显堆栈** | |
| 134 | + | |
| 135 | +- [ ] **Step 1: 写失败测试** | |
| 136 | + - 测试文件: `GlobalExceptionHandlerTest` | |
| 137 | + - `@WebMvcTest` 限定 + 一个 stub controller `/__test/throw-biz`(抛 `BizException(30001,"x")`)/ `/__test/throw-validate`(接 `@Valid` 入参)/ `/__test/throw-runtime`(抛 `IllegalStateException`) | |
| 138 | + - 三个测试: | |
| 139 | + - `bizException_returnsResultWithBizCode` 期望 body `{code:30001,msg:"x"}` | |
| 140 | + - `validationException_returns40001WithFieldHint` 期望 `code=40001` 且 `msg` 包含字段名 | |
| 141 | + - `uncaughtException_returns50000` 期望 `code=50000` | |
| 142 | + - 子会话确认 FAIL(类不存在) | |
| 143 | + | |
| 144 | +- [ ] **Step 2: 实现最小代码** | |
| 145 | + - 涉及文件:`Result.java`、`BizException.java`、`GlobalExceptionHandler.java` | |
| 146 | + - 不超出 spec § 边界与约束 列出的错误码语义 | |
| 147 | + | |
| 148 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 149 | + - 命令:`cd backend && mvn -B test -Dtest=GlobalExceptionHandlerTest` | |
| 150 | + - 期望:3 tests passed | |
| 151 | + | |
| 152 | +- [ ] **Step 4: Commit** | |
| 153 | + - `git commit -m "feat(mod): unified Result + global exception handler REQ-MOD-001"` | |
| 154 | + | |
| 155 | +### Task 3: 租户配置 + JWT 工具 + 测试辅助 | |
| 156 | + | |
| 157 | +**Files:** | |
| 158 | +- Create: `backend/src/main/java/com/xly/erp/common/config/TenantProperties.java` | |
| 159 | +- Create: `backend/src/main/java/com/xly/erp/common/config/StubSecurityProperties.java` | |
| 160 | +- Create: `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` | |
| 161 | +- Test: `backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java` | |
| 162 | +- Test: `backend/src/test/java/com/xly/erp/common/security/TestJwtHelper.java`(生产代码意义上是测试基础设施) | |
| 163 | + | |
| 164 | +**API shape:** | |
| 165 | +- `TenantProperties` 字段 `String brandsId`, `String subsidiaryId`,绑定前缀 `erp.tenant` | |
| 166 | +- `StubSecurityProperties` 字段 `String stubUserNo`, `String jwtSecret`,绑定前缀 `erp.security` | |
| 167 | +- `JwtUtil#sign(String userNo) : String`(HS256,subject=userNo,过期 8 小时) | |
| 168 | +- `JwtUtil#parse(String token) : String`(返回 subject;过期/签名错抛 `BizException(20001,"未认证或 token 已失效")`) | |
| 169 | +- `TestJwtHelper#signFor(String userNo) : String`(包装 JwtUtil,方便 IT 用) | |
| 170 | + | |
| 171 | +- [ ] **Step 1: 写失败测试 `JwtUtilTest`** | |
| 172 | + - 测试名: | |
| 173 | + - `signAndParse_roundTrip` — `parse(sign("u1")) == "u1"` | |
| 174 | + - `parseTamperedToken_throwsBizException20001` | |
| 175 | + - 用 `@SpringBootTest` 注入 `JwtUtil`,密钥走 application-test.yml 的 `${JWT_SECRET}` | |
| 176 | + | |
| 177 | +- [ ] **Step 2: 实现最小代码** | |
| 178 | + - `TenantProperties` / `StubSecurityProperties` + 在 `ErpApplication` 加 `@EnableConfigurationProperties({TenantProperties.class, StubSecurityProperties.class})` | |
| 179 | + - `JwtUtil` 用 `io.jsonwebtoken.Jwts.builder()/parser()` | |
| 180 | + - `TestJwtHelper` `@Component` 注 `JwtUtil` | |
| 181 | + | |
| 182 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 183 | + - 命令:`cd backend && mvn -B test -Dtest=JwtUtilTest` | |
| 184 | + | |
| 185 | +- [ ] **Step 4: Commit** | |
| 186 | + - `git commit -m "feat(mod): tenant + jwt config + util REQ-MOD-001"` | |
| 187 | + | |
| 188 | +### Task 4: JWT Filter + SecurityConfig | |
| 189 | + | |
| 190 | +**Files:** | |
| 191 | +- Create: `backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java` | |
| 192 | +- Create: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` | |
| 193 | +- Create: `backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java` | |
| 194 | +- Test: `backend/src/test/java/com/xly/erp/common/security/SecurityFilterIT.java` | |
| 195 | + | |
| 196 | +**API shape:** | |
| 197 | +- `JwtAuthenticationFilter extends OncePerRequestFilter` | |
| 198 | + - 头解析:`Authorization: Bearer <token>` | |
| 199 | + - 有 token 且解析成功 → `SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(userNo, null, List.of()))` | |
| 200 | + - 有 token 但解析失败 → 写 `Result.fail(20001,"未认证或 token 已失效")` JSON 到 response,状态码 200,**短路 chain** | |
| 201 | + - 无 token → chain 直接放行(让 SecurityConfig 的 permitAll/authenticated 规则决定) | |
| 202 | +- `SecurityConfig#filterChain(HttpSecurity)`: | |
| 203 | + - `csrf().disable()` / `sessionManagement().sessionCreationPolicy(STATELESS)` | |
| 204 | + - `authorizeHttpRequests` 顺序: | |
| 205 | + 1. `requestMatchers(HttpMethod.POST, "/api/mod/modules").permitAll()` — REQ-MOD-001 stub: see USR-004 follow-up | |
| 206 | + 2. `anyRequest().authenticated()` | |
| 207 | + - `addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)` | |
| 208 | +- `SecurityContextHelper.currentUserNo() : String` — 读 `SecurityContextHolder` principal;无认证返回 `null` | |
| 209 | + | |
| 210 | +- [ ] **Step 1: 写失败测试 `SecurityFilterIT`** | |
| 211 | + - 测试名(用 `@SpringBootTest(webEnvironment=RANDOM_PORT)` + `TestRestTemplate`,命中一个真实接口;本任务先 stub 一个 `/__test/principal` 暴露 `currentUserNo()`,但更稳妥的做法是放在 Task 8 实现 controller 后回填,**故本任务测试范围限制在 SecurityConfig 的路径规则**): | |
| 212 | + - `validJwt_authenticatedEndpoint_returnsPrincipal` — POST `/api/mod/modules` 带合法 token,期望 200(permitAll,sCreatedBy 由后续 Task 验证) | |
| 213 | + - `tamperedJwt_anyEndpoint_returns20001` — Authorization 伪造 → JSON `code=20001` | |
| 214 | + - `noJwt_permitAllEndpoint_passes` — 无 Authorization 头 → 200(不阻断 permitAll) | |
| 215 | + - `noJwt_protectedEndpoint_returns20001OrSpring403` — 命中默认 protected,期望状态码非 200 或 `code=20001`(Spring Security 默认会返回 403/401,二者皆可) | |
| 216 | + - 上述测试 POST `/api/mod/modules` 在本任务尚未实现 controller,会返回 404;将 405/404 视作 "permitAll 通过 filter 链" 的间接证据,断言不要求 200 | |
| 217 | + - 子会话确认 FAIL(filter/config 类不存在) | |
| 218 | + | |
| 219 | +- [ ] **Step 2: 实现最小代码** | |
| 220 | + - 三个类按 API shape 实现;filter 中"短路 + 写 JSON" 用 `ObjectMapper` 序列化 `Result.fail(20001,...)`,content-type=application/json | |
| 221 | + | |
| 222 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 223 | + - 命令:`cd backend && mvn -B test -Dtest=SecurityFilterIT` | |
| 224 | + | |
| 225 | +- [ ] **Step 4: Commit** | |
| 226 | + - `git commit -m "feat(mod): jwt filter + security config (role stub) REQ-MOD-001"` | |
| 227 | + | |
| 228 | +### Task 5: tModule Entity + Mapper(数据层) | |
| 229 | + | |
| 230 | +**Files:** | |
| 231 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/entity/Module.java` | |
| 232 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` | |
| 233 | +- Create: `backend/src/main/resources/mapper/mod/ModuleMapper.xml` | |
| 234 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` | |
| 235 | + | |
| 236 | +**API shape:** | |
| 237 | +- `Module` 类字段(与 `tModule` 1:1,使用 `@TableName("tModule")` + `@TableField` 显式映射): | |
| 238 | + - `Integer iIncrement` (`@TableId(type=IdType.AUTO)`) | |
| 239 | + - `String sId`, `String sBrandsId`, `String sSubsidiaryId` | |
| 240 | + - `LocalDateTime tCreateDate` | |
| 241 | + - `String sDisplayType`, `String sProcedureName`, `String sModuleType`, `String sManageDeptEn` | |
| 242 | + - `Boolean bShowPermission` | |
| 243 | + - `String sModuleNameZh` | |
| 244 | + - `Integer iParentId`, `Integer iSortOrder` | |
| 245 | + - `String sCreatedBy` | |
| 246 | + - `Boolean bDeleted`, `LocalDateTime tDeletedDate`, `String sDeletedBy` | |
| 247 | +- `ModuleMapper extends BaseMapper<Module>`,附加方法: | |
| 248 | + - `boolean existsActiveById(@Param("id") Integer iIncrement)` — XML 中 `SELECT 1 FROM tModule WHERE iIncrement=#{id} AND bDeleted=0 LIMIT 1`,返回非空结果即 true | |
| 249 | + | |
| 250 | +- [ ] **Step 1: 写失败测试 `ModuleMapperIT`** | |
| 251 | + - 测试名: | |
| 252 | + - `insertAndSelectById_persistsAllStandardCols` — 构造 Module 实例 set 全字段后 `mapper.insert(...)` → `mapper.selectById(...)` 比较各字段(含 `sBrandsId='XLY'`) | |
| 253 | + - `existsActiveById_trueForAlive_falseForDeleted` — 插两条,一条 `bDeleted=0`、一条 `bDeleted=1`,分别校验 | |
| 254 | + - 用 `@SpringBootTest` + `@Transactional`(自动回滚不污染库),`@Autowired ModuleMapper` | |
| 255 | + - 子会话先跑 → FAIL(类不存在) | |
| 256 | + | |
| 257 | +- [ ] **Step 2: 实现最小代码** | |
| 258 | + - Module entity + Mapper 接口 + XML | |
| 259 | + - 标准列 `tCreateDate` 由测试代码填,不依赖 DB 默认(spec 边界对齐) | |
| 260 | + | |
| 261 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 262 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT` | |
| 263 | + | |
| 264 | +- [ ] **Step 4: Commit** | |
| 265 | + - `git commit -m "feat(mod): tModule entity + mapper REQ-MOD-001"` | |
| 266 | + | |
| 267 | +### Task 6: CreateModuleDTO + ModuleService 主流程(合法路径 + 父校验) | |
| 268 | + | |
| 269 | +**Files:** | |
| 270 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/CreateModuleDTO.java` | |
| 271 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` | |
| 272 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | |
| 273 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | |
| 274 | + | |
| 275 | +**API shape:** | |
| 276 | +- `CreateModuleDTO` 字段(带 Bean Validation): | |
| 277 | + - `@NotBlank String sDisplayType` | |
| 278 | + - `@NotBlank @Size(max=100) String sProcedureName` | |
| 279 | + - `@NotBlank @Size(max=50) String sModuleType` | |
| 280 | + - `@NotBlank @Size(max=50) String sManageDeptEn` | |
| 281 | + - `Boolean bShowPermission`(可空,service 层默认 false) | |
| 282 | + - `@NotBlank @Size(max=100) String sModuleNameZh` | |
| 283 | + - `Integer iParentId`(可空) | |
| 284 | + - `Integer iSortOrder`(可空,service 层默认 0) | |
| 285 | +- `ModuleService#create(CreateModuleDTO dto) : Integer`(返回新 `iIncrement`) | |
| 286 | +- `ModuleServiceImpl` 依赖:`ModuleMapper` / `TenantProperties` / `StubSecurityProperties` | |
| 287 | +- 流程: | |
| 288 | + 1. 校验 `sDisplayType` ∈ `{手机端,前端业务,系统配置,接口}`,否则 `BizException(40010,"显示类型枚举不合法")` | |
| 289 | + 2. 若 `iParentId != null` 调 `mapper.existsActiveById(iParentId)`;不存在 → `BizException(40021,"父模块不存在或已删除")` | |
| 290 | + 3. 构造 `Module` 实例:DTO 字段透传 + 标准列填充: | |
| 291 | + - `tCreateDate = LocalDateTime.now()` | |
| 292 | + - `sBrandsId = tenantProps.brandsId` | |
| 293 | + - `sSubsidiaryId = tenantProps.subsidiaryId` | |
| 294 | + - `sCreatedBy = SecurityContextHelper.currentUserNo()` 或 `stubProps.stubUserNo`(前者为 null 时回退) | |
| 295 | + - `bShowPermission = dto.bShowPermission != null ? dto : false` | |
| 296 | + - `iSortOrder = dto.iSortOrder != null ? dto : 0` | |
| 297 | + - `bDeleted = false` | |
| 298 | + 4. `mapper.insert(entity)`;MyBatis-Plus 自动回填 `iIncrement` | |
| 299 | + 5. `return entity.getIIncrement()` | |
| 300 | +- 类上加 `@Transactional(rollbackFor = Exception.class)` | |
| 301 | + | |
| 302 | +- [ ] **Step 1: 写失败测试 `ModuleServiceImplTest`(本任务两用例)** | |
| 303 | + - 测试名: | |
| 304 | + - `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) | |
| 305 | + - `createWithParentNotFound_throws40021` — Mock `existsActiveById(false)` + DTO `iParentId=42`,断言抛 `BizException`,`code=40021` | |
| 306 | + - 子会话先跑 → FAIL | |
| 307 | + | |
| 308 | +- [ ] **Step 2: 实现最小代码** | |
| 309 | + - DTO + Service interface + Impl,仅覆盖本任务两用例的最小逻辑(枚举校验和重复键捕获放下个 Task 写测试驱动) | |
| 310 | + | |
| 311 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 312 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` | |
| 313 | + | |
| 314 | +- [ ] **Step 4: Commit** | |
| 315 | + - `git commit -m "feat(mod): module create dto + service happy path REQ-MOD-001"` | |
| 316 | + | |
| 317 | +### Task 7: Service 层异常分支补全(枚举非法 + 唯一冲突) | |
| 318 | + | |
| 319 | +**Files:** | |
| 320 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | |
| 321 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | |
| 322 | + | |
| 323 | +**API shape:** 同 Task 6(仅补分支逻辑) | |
| 324 | + | |
| 325 | +- [ ] **Step 1: 在测试类中追加 4 个用例** | |
| 326 | + - `createWithInvalidDisplayType_throws40010` — DTO `sDisplayType="未知"`,期望 `BizException.code=40010` | |
| 327 | + - `createWithNullParentId_skipsParentCheck` — DTO `iParentId=null`,期望 `mapper.existsActiveById` 不被调用,且仍走完插入 | |
| 328 | + - `mapperDuplicateKey_throws40020` — Mock `mapper.insert` 抛 `org.springframework.dao.DuplicateKeyException`,期望转抛 `BizException.code=40020` | |
| 329 | + - `usesAuthenticatedUserNoAsCreatedBy` — `SecurityContextHolder` 注入 `userNo="ALICE"`,断言传给 mapper 的 entity.sCreatedBy="ALICE"(**用 `@AfterEach SecurityContextHolder.clearContext()` 隔离**) | |
| 330 | + - 子会话先跑 → 4 用例 FAIL | |
| 331 | + | |
| 332 | +- [ ] **Step 2: 在 ServiceImpl 中补充分支** | |
| 333 | + - 枚举校验(白名单 `Set.of("手机端","前端业务","系统配置","接口")`) | |
| 334 | + - try/catch `DuplicateKeyException` → `BizException(40020,"存储过程名称已存在")` | |
| 335 | + - sCreatedBy 优先取 `SecurityContextHelper.currentUserNo()` | |
| 336 | + | |
| 337 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 338 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` | |
| 339 | + - 期望:6 tests passed(含 Task 6 的 2 个) | |
| 340 | + | |
| 341 | +- [ ] **Step 4: Commit** | |
| 342 | + - `git commit -m "feat(mod): module create error branches REQ-MOD-001"` | |
| 343 | + | |
| 344 | +### Task 8: Controller + 集成测试(正常路径) | |
| 345 | + | |
| 346 | +**Files:** | |
| 347 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | |
| 348 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | |
| 349 | + | |
| 350 | +**API shape:** | |
| 351 | +- `@RestController` + `@RequestMapping("/api/mod")` | |
| 352 | +- `@PostMapping("/modules") public Result<Map<String,Integer>> create(@Valid @RequestBody CreateModuleDTO dto)` | |
| 353 | +- 返回 `Result.ok(Map.of("iIncrement", service.create(dto)))` | |
| 354 | + | |
| 355 | +- [ ] **Step 1: 写失败测试 IT 正常路径** | |
| 356 | + - 测试名: `ModuleControllerIT#postValidBody_with_jwt_returns200_andPersists` | |
| 357 | + - 用 `@SpringBootTest(webEnvironment=RANDOM_PORT)` + `TestRestTemplate` + `@Autowired TestJwtHelper` + `@Autowired JdbcTemplate` | |
| 358 | + - 步骤: | |
| 359 | + 1. `String token = testJwtHelper.signFor("ADMIN001")` | |
| 360 | + 2. POST `/api/mod/modules` body `{sDisplayType:"手机端", sProcedureName:"sp_test_001", sModuleType:"业务模块", sManageDeptEn:"IT", sModuleNameZh:"测试模块"}`,header `Authorization: Bearer <token>` | |
| 361 | + 3. 期望 HTTP 200,响应 `code=0`,`data.iIncrement` 是正整数 N | |
| 362 | + 4. JdbcTemplate 查 `tModule WHERE iIncrement=N`,断言 `sCreatedBy="ADMIN001"`、`sBrandsId="XLY"` | |
| 363 | + - **测试隔离**:`@BeforeEach`/`@AfterEach` 用 JdbcTemplate `DELETE FROM tModule WHERE sProcedureName LIKE 'sp_test_%'`,避免污染 | |
| 364 | + - 子会话先跑 → FAIL(controller 不存在 → 404) | |
| 365 | + | |
| 366 | +- [ ] **Step 2: 实现 ModuleController** | |
| 367 | + - 严格按 API shape,不加任何额外路径 | |
| 368 | + | |
| 369 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 370 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleControllerIT#postValidBody_with_jwt_returns200_andPersists` | |
| 371 | + | |
| 372 | +- [ ] **Step 4: Commit** | |
| 373 | + - `git commit -m "feat(mod): POST /api/mod/modules controller REQ-MOD-001"` | |
| 374 | + | |
| 375 | +### Task 9: 集成测试异常路径(参数缺失 / 枚举非法 / 唯一冲突 / 父不存在 / 鉴权 stub 行为) | |
| 376 | + | |
| 377 | +**Files:** | |
| 378 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | |
| 379 | + | |
| 380 | +**API shape:** 不新增(覆盖现有接口 6 条异常路径) | |
| 381 | + | |
| 382 | +- [ ] **Step 1: 在 IT 中追加 6 个用例** | |
| 383 | + - `postEmptyBody_returns40001_withFieldHint` — body `{}`,期望 `code=40001`,`msg` 含 `sProcedureName` 等任一字段名 | |
| 384 | + - `postInvalidDisplayType_returns40010` — body `sDisplayType="火星"`,期望 `code=40010` | |
| 385 | + - `postDuplicateProcedureName_returns40020` — 先插一条(直接 JdbcTemplate 或先 POST 一次),再 POST 同名 → `code=40020` | |
| 386 | + - `postWithMissingParent_returns40021` — `iParentId=999999` → `code=40021` | |
| 387 | + - `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` — 不带 Authorization 头,期望 200 + 新行 `sCreatedBy="STUB_ADMIN"`(验证 stub 行为,spec 已声明 USR-004 后改为 401) | |
| 388 | + - `postWithTamperedJwt_returns20001` — Authorization 头伪造(`Bearer xxx.yyy.zzz`),期望 `code=20001`(filter 拦截短路) | |
| 389 | + - 6 个用例先跑 → FAIL(缺分支/HTTP 状态预期不符) | |
| 390 | + | |
| 391 | +- [ ] **Step 2: 让测试通过** | |
| 392 | + - 多数用例的服务端逻辑已在 Task 7 + Task 4 实现;本步骤主要是排查测试中 RestTemplate 行为(如 4xx 是否抛、是否需要用 `String.class` 接收 body 再手动 parse JSON) | |
| 393 | + - **不应当**为让测试通过新增业务分支;如发现确实缺分支,回炉对应 Task 的 service/filter | |
| 394 | + | |
| 395 | +- [ ] **Step 3: 子会话验证全 IT PASS** | |
| 396 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleControllerIT` | |
| 397 | + - 期望:7 tests passed(含 Task 8 的 1 个) | |
| 398 | + | |
| 399 | +- [ ] **Step 4: 子会话跑全模块单测套件做回归** | |
| 400 | + - 命令:`cd backend && mvn -B test` | |
| 401 | + - 期望:所有用例 PASS(SmokeTest + GlobalExceptionHandlerTest + JwtUtilTest + SecurityFilterIT + ModuleMapperIT + ModuleServiceImplTest + ModuleControllerIT) | |
| 402 | + | |
| 403 | +- [ ] **Step 5: Commit** | |
| 404 | + - `git commit -m "test(mod): module create integration coverage REQ-MOD-001"` | |
| 405 | + | |
| 406 | +## 提交计划 | |
| 407 | + | |
| 408 | +| commit | 覆盖 | | |
| 409 | +|---|---| | |
| 410 | +| `chore(mod): bootstrap backend scaffold REQ-MOD-001` | Task 1 | | |
| 411 | +| `feat(mod): unified Result + global exception handler REQ-MOD-001` | Task 2 | | |
| 412 | +| `feat(mod): tenant + jwt config + util REQ-MOD-001` | Task 3 | | |
| 413 | +| `feat(mod): jwt filter + security config (role stub) REQ-MOD-001` | Task 4 | | |
| 414 | +| `feat(mod): tModule entity + mapper REQ-MOD-001` | Task 5 | | |
| 415 | +| `feat(mod): module create dto + service happy path REQ-MOD-001` | Task 6 | | |
| 416 | +| `feat(mod): module create error branches REQ-MOD-001` | Task 7 | | |
| 417 | +| `feat(mod): POST /api/mod/modules controller REQ-MOD-001` | Task 8 | | |
| 418 | +| `test(mod): module create integration coverage REQ-MOD-001` | Task 9 | | ... | ... |
docs/superpowers/specs/2026-04-29-REQ-MOD-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-MOD-001 | |
| 3 | +date: 2026-04-29 | |
| 4 | +module: module_mod | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-MOD-001 — 模块新增 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +新增一条 ERP 业务模块定义记录(`tModule`),作为系统功能与权限分组的基础单位,提交后持久化并返回新模块 `iIncrement`。 | |
| 12 | + | |
| 13 | +> 本 REQ 是项目首个 REQ,`backend/` 工程目录尚未存在,因此本 spec 同时承担**最小后端脚手架建立**的职责。范围抉择已与用户确认(详见「实现范围与边界抉择」一节)。 | |
| 14 | + | |
| 15 | +## 输入 / 触发 | |
| 16 | + | |
| 17 | +### HTTP 接口(来自 docs/05 § module_mod.REQ-MOD-001) | |
| 18 | + | |
| 19 | +- Method / Path: `POST /api/mod/modules` | |
| 20 | +- Auth: 必需(JWT Bearer Token) | |
| 21 | +- Permission: 仅超级管理员(**本 REQ 内 stub 为 permitAll,留待 USR-004 完成后回填硬校验**) | |
| 22 | +- Content-Type: `application/json` | |
| 23 | + | |
| 24 | +### 请求 DTO `CreateModuleDTO` | |
| 25 | + | |
| 26 | +| JSON 字段 | Java 类型 | 必填 | Bean Validation | 业务校验 | | |
| 27 | +|---|---|---|---|---| | |
| 28 | +| `sDisplayType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[手机端, 前端业务, 系统配置, 接口]` 内;非法 → `40010` | | |
| 29 | +| `sProcedureName` | `String` | 是 | `@NotBlank @Size(max=100)` | 系统内唯一(依赖 `tModule.uk_procedure_name`);冲突 → `40020` | | |
| 30 | +| `sModuleType` | `String` | 是 | `@NotBlank @Size(max=50)` | 自由文本,不做枚举约束(参 docs/03 业务注记) | | |
| 31 | +| `sManageDeptEn` | `String` | 是 | `@NotBlank @Size(max=50)` | — | | |
| 32 | +| `bShowPermission` | `Boolean` | 否 | — | 缺省 `false`(写入 `0`) | | |
| 33 | +| `sModuleNameZh` | `String` | 是 | `@NotBlank @Size(max=100)` | — | | |
| 34 | +| `iParentId` | `Integer` | 否 | — | 非 null 时必须命中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40021` | | |
| 35 | +| `iSortOrder` | `Integer` | 否 | — | 缺省 `0` | | |
| 36 | + | |
| 37 | +> REQ 卡 § 输入只列了前 6 个字段(业务关心的最小集合),API 契约扩展了 `iParentId` / `iSortOrder` 两个可选字段以匹配 `tModule` 自引用 + 排序的 schema。两者一致——前 6 必填,后 2 可选。 | |
| 38 | + | |
| 39 | +### 鉴权与上下文 | |
| 40 | + | |
| 41 | +- JWT 从 `Authorization: Bearer <token>` 头解析 | |
| 42 | +- 解析成功 → 写入 `SecurityContextHolder` 的 `Authentication.principal = sUserNo` | |
| 43 | +- 解析失败 / 缺失 → `JwtAuthenticationFilter` 直接返回 `Result(20001, "未认证")`,不进入 controller | |
| 44 | +- **超级管理员判定本期 stub**(`SecurityConfig` 对 `/api/mod/modules` 的 POST 用 `permitAll()`,但 Filter 仍需解析 token 写入 principal 用于 `sCreatedBy`) | |
| 45 | + | |
| 46 | +## 输出 / 结果 | |
| 47 | + | |
| 48 | +### 成功响应 | |
| 49 | + | |
| 50 | +```json | |
| 51 | +{ | |
| 52 | + "code": 0, | |
| 53 | + "msg": "ok", | |
| 54 | + "data": { "iIncrement": 123 } | |
| 55 | +} | |
| 56 | +``` | |
| 57 | + | |
| 58 | +### 持久化效果 | |
| 59 | + | |
| 60 | +新增一行 `tModule` 记录,字段填充规则: | |
| 61 | + | |
| 62 | +| 字段 | 来源 | | |
| 63 | +|---|---| | |
| 64 | +| `iIncrement` | DB 自增 | | |
| 65 | +| `sId` | NULL(本期不生成业务 ID) | | |
| 66 | +| `sBrandsId` | 配置 `erp.tenant.brands-id`(默认 `XLY`) | | |
| 67 | +| `sSubsidiaryId` | 配置 `erp.tenant.subsidiary-id`(默认 `XLY`) | | |
| 68 | +| `tCreateDate` | `LocalDateTime.now()`(service 层填,不依赖 DB 默认) | | |
| 69 | +| `sDisplayType` ~ `iSortOrder` | DTO 字段 | | |
| 70 | +| `sCreatedBy` | `SecurityContextHolder` 中 `principal`(即 JWT 中 `sUserNo`);**stub 期未携带 token 时该接口因 permitAll 也能进,此时 `sCreatedBy` = 配置 `erp.security.stub-user-no`(默认 `STUB_ADMIN`),USR-004 完成后改为强制取 token** | | |
| 71 | +| `bDeleted` | `0`(DB 默认) | | |
| 72 | +| `tDeletedDate` / `sDeletedBy` | NULL | | |
| 73 | + | |
| 74 | +## 业务规则 | |
| 75 | + | |
| 76 | +1. **唯一性**:`sProcedureName` 系统内全局唯一,依赖 DB `uk_procedure_name` 索引兜底;service 层捕获 `org.springframework.dao.DuplicateKeyException` → `BizException(40020, "存储过程名称已存在")`。不做"先查后插"二次校验,避免竞态。 | |
| 77 | +2. **父模块存在性**:`iParentId != null` 时执行 `SELECT iIncrement FROM tModule WHERE iIncrement=? AND bDeleted=0`;查不到则 `BizException(40021, "父模块不存在或已删除")`。 | |
| 78 | +3. **枚举校验**:`sDisplayType` 在白名单内(参输入表);非法 → `BizException(40010, "显示类型枚举不合法")`。在 service 入口处用 `Set<String>.contains()` 校验。 | |
| 79 | +4. **类型与长度校验**:交给 Bean Validation(`@Valid` + `@NotBlank` / `@Size`);统一异常处理器把 `MethodArgumentNotValidException` 转 `Result(40001, "<字段名>: <message>")`。 | |
| 80 | +5. **事务边界**:`ModuleServiceImpl.create(...)` 上 `@Transactional(rollbackFor = Exception.class)`,整个新增是单条 INSERT 不存在跨表事务,但保持事务注解以便后续业务扩展。 | |
| 81 | + | |
| 82 | +## 边界与约束 | |
| 83 | + | |
| 84 | +- **必填项缺失** → `40001`(参数校验失败,由 GlobalExceptionHandler 统一处理) | |
| 85 | +- **`sDisplayType` 非枚举** → `40010` | |
| 86 | +- **`sProcedureName` 唯一冲突** → `40020` | |
| 87 | +- **`iParentId` 不存在或已软删** → `40021` | |
| 88 | +- **超级管理员判定**:本 REQ 不做硬校验(`permitAll` stub),后续在 USR-004 完成后闭环回填——`SecurityConfig` 中 `/api/mod/modules` 的 POST 改为 `hasAuthority('SUPER_ADMIN')`;`JwtAuthenticationFilter` 增加 `tUser` 查询填充 authorities(**本 REQ 不实现**,仅在代码注释中以 `// REQ-MOD-001 stub: see USR-004 follow-up` 形式标注待回填位置) | |
| 89 | +- **接口异常响应禁回显堆栈**(docs/04 § 1.4),`Result` 仅含 `code + msg`,堆栈走 `logback` | |
| 90 | +- **错误码段位差异说明**:docs/04 § 1.3 定义段位 `1xxxx/2xxxx/3xxxx/5xxxx`,但 docs/05 接口契约 § REQ-MOD-001 用 `40001/40010/40020/40021/40300`(沿用 HTTP 语义前缀)。本 spec **以 docs/05 为准**——`docs/04` 与 `docs/05` 的不一致属跨 REQ 文档问题,本 REQ 不修复。 | |
| 91 | + | |
| 92 | +## 实现范围与边界抉择(与用户确认) | |
| 93 | + | |
| 94 | +经 `feature-brainstorm` Q&A 确认(2026-04-29): | |
| 95 | + | |
| 96 | +1. **范围 = 脚手架 + 接口 + Filter(角色 stub)**: | |
| 97 | + - 建立 `backend/` Spring Boot 工程(pom + Application + 基础配置) | |
| 98 | + - 引入 Flyway、MyBatis-Plus、Spring Security 依赖 | |
| 99 | + - 实现统一响应 `Result<T>` + 全局异常处理 `GlobalExceptionHandler` + 业务异常 `BizException` | |
| 100 | + - 实现 `JwtAuthenticationFilter`(解析 token 写 principal)+ 简易 `SecurityConfig`(除 MOD 接口允许 permitAll,其余 `authenticated()`,但本 REQ 仅 MOD-001 一个接口) | |
| 101 | + - 实现 MOD-001 接口完整链路(controller / service / mapper / entity / dto) | |
| 102 | +2. **多租户字段处理 = 用配置默认值 `XLY` / `XLY`**:写入 `application.yml` 的 `erp.tenant.*`,由 `@ConfigurationProperties` 注入 service。 | |
| 103 | + | |
| 104 | +## 依赖的 schema 表 / 字段 | |
| 105 | + | |
| 106 | +写入表:`tModule` | |
| 107 | + | |
| 108 | +| 字段 | 用途 | 来源 | | |
| 109 | +|---|---|---| | |
| 110 | +| `iIncrement` | 主键,DB 自增返回 | `useGeneratedKeys=true` | | |
| 111 | +| `sBrandsId` / `sSubsidiaryId` | 多租户列 | `application.yml` 配置 | | |
| 112 | +| `tCreateDate` | 创建时间 | service 填 `LocalDateTime.now()` | | |
| 113 | +| `sDisplayType` / `sProcedureName` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | `CreateModuleDTO` | | |
| 114 | +| `sCreatedBy` | 创建人 | JWT principal(stub 期回退到配置) | | |
| 115 | +| `bDeleted` | 软删除标记 | DB 默认 `0` | | |
| 116 | +| `sId` / `tDeletedDate` / `sDeletedBy` | 暂未使用 | NULL | | |
| 117 | + | |
| 118 | +约束:唯一索引 `uk_procedure_name(sProcedureName)`;外键 `fk_module_parent: iParentId → tModule.iIncrement (ON DELETE RESTRICT)`。 | |
| 119 | + | |
| 120 | +## 依赖的接口 | |
| 121 | + | |
| 122 | +无(首个 REQ,无前置接口依赖)。 | |
| 123 | + | |
| 124 | +## 验收标准 | |
| 125 | + | |
| 126 | +### 单元测试(`ModuleServiceImplTest`,Mockito 隔离 mapper) | |
| 127 | + | |
| 128 | +- [x] 给定合法 DTO + 父模块存在 → service 调用 `mapper.insert(...)` 一次,返回 `iIncrement` | |
| 129 | +- [x] 给定合法 DTO + `iParentId=null` → 跳过父模块校验直接 insert | |
| 130 | +- [x] 给定 `sDisplayType` 非枚举值 → 抛 `BizException(40010)` | |
| 131 | +- [x] 给定 `iParentId` 在 mapper 中查不到 → 抛 `BizException(40021)` | |
| 132 | +- [x] mapper.insert 抛 `DuplicateKeyException` → 转抛 `BizException(40020)` | |
| 133 | +- [x] `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` 字段被正确填充进传给 mapper 的 entity | |
| 134 | + | |
| 135 | +### 集成测试(`ModuleControllerIT`,Spring Boot Test + 真实 MySQL via `setup-test-db.sh` + Flyway) | |
| 136 | + | |
| 137 | +测试库由 `scripts/setup-test-db.sh` DROP + CREATE,启动时 Flyway 自动 apply `V1__initial_schema.sql`。每个用例前后清空 `tModule`。 | |
| 138 | + | |
| 139 | +- [x] **正常路径**:POST 合法 body + 测试用 JWT → 200 / `code=0`,DB 中存在新行,`sCreatedBy` = JWT 解出的 sUserNo | |
| 140 | +- [x] **缺必填**:POST `{}` → 400 / `code=40001`,body 含字段定位 | |
| 141 | +- [x] **枚举非法**:`sDisplayType=未知` → `code=40010` | |
| 142 | +- [x] **唯一冲突**:先 POST 一次成功;再 POST 同 `sProcedureName` → `code=40020` | |
| 143 | +- [x] **父模块不存在**:`iParentId=99999` → `code=40021` | |
| 144 | +- [x] **未携带 JWT**:本 REQ 因 stub permitAll,仍返回 200 但 `sCreatedBy=STUB_ADMIN`(仅验证当前 stub 行为;USR-004 闭环后该用例期望值改为 `code=20001`) | |
| 145 | +- [x] **JWT 解析失败**(恶意 token) → `code=20001`(filter 拦截) | |
| 146 | + | |
| 147 | +### 工程脚手架验收 | |
| 148 | + | |
| 149 | +- [x] `mvn -f backend/pom.xml clean test` 全绿 | |
| 150 | +- [x] Spring Boot 启动后 Flyway 自动 apply V1(如已 apply 则跳过) | |
| 151 | +- [x] `Result<T>` 在 controller 返回类型上一致使用 | |
| 152 | +- [x] `GlobalExceptionHandler` 至少处理 `BizException` / `MethodArgumentNotValidException` / 兜底 `Exception` | |
| 153 | +- [x] `JwtAuthenticationFilter` 正确解析 `Authorization` 头;token 缺失时不阻断 stub permitAll 路径 | ... | ... |