--- req_id: REQ-MOD-001 date: 2026-04-29 module: module_mod --- # Spec: REQ-MOD-001 — 模块新增 ## 目标 新增一条 ERP 业务模块定义记录(`tModule`),作为系统功能与权限分组的基础单位,提交后持久化并返回新模块 `iIncrement`。 > 本 REQ 是项目首个 REQ,`backend/` 工程目录尚未存在,因此本 spec 同时承担**最小后端脚手架建立**的职责。范围抉择已与用户确认(详见「实现范围与边界抉择」一节)。 ## 输入 / 触发 ### HTTP 接口(来自 docs/05 § module_mod.REQ-MOD-001) - Method / Path: `POST /api/mod/modules` - Auth: 必需(JWT Bearer Token) - Permission: 仅超级管理员(**本 REQ 内 stub 为 permitAll,留待 USR-004 完成后回填硬校验**) - Content-Type: `application/json` ### 请求 DTO `CreateModuleDTO` | JSON 字段 | Java 类型 | 必填 | Bean Validation | 业务校验 | |---|---|---|---|---| | `sDisplayType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[手机端, 前端业务, 系统配置, 接口]` 内;非法 → `40010` | | `sProcedureName` | `String` | 是 | `@NotBlank @Size(max=100)` | 系统内唯一(依赖 `tModule.uk_procedure_name`);冲突 → `40020` | | `sModuleType` | `String` | 是 | `@NotBlank @Size(max=50)` | 自由文本,不做枚举约束(参 docs/03 业务注记) | | `sManageDeptEn` | `String` | 是 | `@NotBlank @Size(max=50)` | — | | `bShowPermission` | `Boolean` | 否 | — | 缺省 `false`(写入 `0`) | | `sModuleNameZh` | `String` | 是 | `@NotBlank @Size(max=100)` | — | | `iParentId` | `Integer` | 否 | — | 非 null 时必须命中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40021` | | `iSortOrder` | `Integer` | 否 | — | 缺省 `0` | > REQ 卡 § 输入只列了前 6 个字段(业务关心的最小集合),API 契约扩展了 `iParentId` / `iSortOrder` 两个可选字段以匹配 `tModule` 自引用 + 排序的 schema。两者一致——前 6 必填,后 2 可选。 ### 鉴权与上下文 - JWT 从 `Authorization: Bearer ` 头解析 - 解析成功 → 写入 `SecurityContextHolder` 的 `Authentication.principal = sUserNo` - 解析失败 / 缺失 → `JwtAuthenticationFilter` 直接返回 `Result(20001, "未认证")`,不进入 controller - **超级管理员判定本期 stub**(`SecurityConfig` 对 `/api/mod/modules` 的 POST 用 `permitAll()`,但 Filter 仍需解析 token 写入 principal 用于 `sCreatedBy`) ## 输出 / 结果 ### 成功响应 ```json { "code": 0, "msg": "ok", "data": { "iIncrement": 123 } } ``` ### 持久化效果 新增一行 `tModule` 记录,字段填充规则: | 字段 | 来源 | |---|---| | `iIncrement` | DB 自增 | | `sId` | NULL(本期不生成业务 ID) | | `sBrandsId` | 配置 `erp.tenant.brands-id`(默认 `XLY`) | | `sSubsidiaryId` | 配置 `erp.tenant.subsidiary-id`(默认 `XLY`) | | `tCreateDate` | `LocalDateTime.now()`(service 层填,不依赖 DB 默认) | | `sDisplayType` ~ `iSortOrder` | DTO 字段 | | `sCreatedBy` | `SecurityContextHolder` 中 `principal`(即 JWT 中 `sUserNo`);**stub 期未携带 token 时该接口因 permitAll 也能进,此时 `sCreatedBy` = 配置 `erp.security.stub-user-no`(默认 `STUB_ADMIN`),USR-004 完成后改为强制取 token** | | `bDeleted` | `0`(DB 默认) | | `tDeletedDate` / `sDeletedBy` | NULL | ## 业务规则 1. **唯一性**:`sProcedureName` 系统内全局唯一,依赖 DB `uk_procedure_name` 索引兜底;service 层捕获 `org.springframework.dao.DuplicateKeyException` → `BizException(40020, "存储过程名称已存在")`。不做"先查后插"二次校验,避免竞态。 2. **父模块存在性**:`iParentId != null` 时执行 `SELECT iIncrement FROM tModule WHERE iIncrement=? AND bDeleted=0`;查不到则 `BizException(40021, "父模块不存在或已删除")`。 3. **枚举校验**:`sDisplayType` 在白名单内(参输入表);非法 → `BizException(40010, "显示类型枚举不合法")`。在 service 入口处用 `Set.contains()` 校验。 4. **类型与长度校验**:交给 Bean Validation(`@Valid` + `@NotBlank` / `@Size`);统一异常处理器把 `MethodArgumentNotValidException` 转 `Result(40001, "<字段名>: ")`。 5. **事务边界**:`ModuleServiceImpl.create(...)` 上 `@Transactional(rollbackFor = Exception.class)`,整个新增是单条 INSERT 不存在跨表事务,但保持事务注解以便后续业务扩展。 ## 边界与约束 - **必填项缺失** → `40001`(参数校验失败,由 GlobalExceptionHandler 统一处理) - **`sDisplayType` 非枚举** → `40010` - **`sProcedureName` 唯一冲突** → `40020` - **`iParentId` 不存在或已软删** → `40021` - **超级管理员判定**:本 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` 形式标注待回填位置) - **接口异常响应禁回显堆栈**(docs/04 § 1.4),`Result` 仅含 `code + msg`,堆栈走 `logback` - **错误码段位差异说明**: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 不修复。 ## 实现范围与边界抉择(与用户确认) 经 `feature-brainstorm` Q&A 确认(2026-04-29): 1. **范围 = 脚手架 + 接口 + Filter(角色 stub)**: - 建立 `backend/` Spring Boot 工程(pom + Application + 基础配置) - 引入 Flyway、MyBatis-Plus、Spring Security 依赖 - 实现统一响应 `Result` + 全局异常处理 `GlobalExceptionHandler` + 业务异常 `BizException` - 实现 `JwtAuthenticationFilter`(解析 token 写 principal)+ 简易 `SecurityConfig`(除 MOD 接口允许 permitAll,其余 `authenticated()`,但本 REQ 仅 MOD-001 一个接口) - 实现 MOD-001 接口完整链路(controller / service / mapper / entity / dto) 2. **多租户字段处理 = 用配置默认值 `XLY` / `XLY`**:写入 `application.yml` 的 `erp.tenant.*`,由 `@ConfigurationProperties` 注入 service。 ## 依赖的 schema 表 / 字段 写入表:`tModule` | 字段 | 用途 | 来源 | |---|---|---| | `iIncrement` | 主键,DB 自增返回 | `useGeneratedKeys=true` | | `sBrandsId` / `sSubsidiaryId` | 多租户列 | `application.yml` 配置 | | `tCreateDate` | 创建时间 | service 填 `LocalDateTime.now()` | | `sDisplayType` / `sProcedureName` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | `CreateModuleDTO` | | `sCreatedBy` | 创建人 | JWT principal(stub 期回退到配置) | | `bDeleted` | 软删除标记 | DB 默认 `0` | | `sId` / `tDeletedDate` / `sDeletedBy` | 暂未使用 | NULL | 约束:唯一索引 `uk_procedure_name(sProcedureName)`;外键 `fk_module_parent: iParentId → tModule.iIncrement (ON DELETE RESTRICT)`。 ## 依赖的接口 无(首个 REQ,无前置接口依赖)。 ## 验收标准 ### 单元测试(`ModuleServiceImplTest`,Mockito 隔离 mapper) - [x] 给定合法 DTO + 父模块存在 → service 调用 `mapper.insert(...)` 一次,返回 `iIncrement` - [x] 给定合法 DTO + `iParentId=null` → 跳过父模块校验直接 insert - [x] 给定 `sDisplayType` 非枚举值 → 抛 `BizException(40010)` - [x] 给定 `iParentId` 在 mapper 中查不到 → 抛 `BizException(40021)` - [x] mapper.insert 抛 `DuplicateKeyException` → 转抛 `BizException(40020)` - [x] `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` 字段被正确填充进传给 mapper 的 entity ### 集成测试(`ModuleControllerIT`,Spring Boot Test + 真实 MySQL via `setup-test-db.sh` + Flyway) 测试库由 `scripts/setup-test-db.sh` DROP + CREATE,启动时 Flyway 自动 apply `V1__initial_schema.sql`。每个用例前后清空 `tModule`。 - [x] **正常路径**:POST 合法 body + 测试用 JWT → 200 / `code=0`,DB 中存在新行,`sCreatedBy` = JWT 解出的 sUserNo - [x] **缺必填**:POST `{}` → 400 / `code=40001`,body 含字段定位 - [x] **枚举非法**:`sDisplayType=未知` → `code=40010` - [x] **唯一冲突**:先 POST 一次成功;再 POST 同 `sProcedureName` → `code=40020` - [x] **父模块不存在**:`iParentId=99999` → `code=40021` - [x] **未携带 JWT**:本 REQ 因 stub permitAll,仍返回 200 但 `sCreatedBy=STUB_ADMIN`(仅验证当前 stub 行为;USR-004 闭环后该用例期望值改为 `code=20001`) - [x] **JWT 解析失败**(恶意 token) → `code=20001`(filter 拦截) ### 工程脚手架验收 - [x] `mvn -f backend/pom.xml clean test` 全绿 - [x] Spring Boot 启动后 Flyway 自动 apply V1(如已 apply 则跳过) - [x] `Result` 在 controller 返回类型上一致使用 - [x] `GlobalExceptionHandler` 至少处理 `BizException` / `MethodArgumentNotValidException` / 兜底 `Exception` - [x] `JwtAuthenticationFilter` 正确解析 `Authorization` 头;token 缺失时不阻断 stub permitAll 路径