Compare View
Commits (32)
-
- setup-test-db.sh: prepend Homebrew mysql-client bin paths if present - test.sh: skip frontend build/lint/test segments when frontend/ absent
Showing
48 changed files
backend/.gitignore
0 → 100644
backend/pom.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | +<project xmlns="http://maven.apache.org/POM/4.0.0" | ||
| 3 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| 4 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
| 5 | + <modelVersion>4.0.0</modelVersion> | ||
| 6 | + | ||
| 7 | + <parent> | ||
| 8 | + <groupId>org.springframework.boot</groupId> | ||
| 9 | + <artifactId>spring-boot-starter-parent</artifactId> | ||
| 10 | + <version>3.3.5</version> | ||
| 11 | + <relativePath/> | ||
| 12 | + </parent> | ||
| 13 | + | ||
| 14 | + <groupId>com.xly</groupId> | ||
| 15 | + <artifactId>erp-backend</artifactId> | ||
| 16 | + <version>0.0.1-SNAPSHOT</version> | ||
| 17 | + <name>erp-backend</name> | ||
| 18 | + <description>小羚羊 ERP backend</description> | ||
| 19 | + | ||
| 20 | + <properties> | ||
| 21 | + <java.version>17</java.version> | ||
| 22 | + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | ||
| 23 | + <mybatis-plus.version>3.5.9</mybatis-plus.version> | ||
| 24 | + <jjwt.version>0.12.6</jjwt.version> | ||
| 25 | + </properties> | ||
| 26 | + | ||
| 27 | + <dependencies> | ||
| 28 | + <dependency> | ||
| 29 | + <groupId>org.springframework.boot</groupId> | ||
| 30 | + <artifactId>spring-boot-starter-web</artifactId> | ||
| 31 | + </dependency> | ||
| 32 | + <dependency> | ||
| 33 | + <groupId>org.springframework.boot</groupId> | ||
| 34 | + <artifactId>spring-boot-starter-validation</artifactId> | ||
| 35 | + </dependency> | ||
| 36 | + <dependency> | ||
| 37 | + <groupId>org.springframework.boot</groupId> | ||
| 38 | + <artifactId>spring-boot-starter-security</artifactId> | ||
| 39 | + </dependency> | ||
| 40 | + <dependency> | ||
| 41 | + <groupId>org.springframework.boot</groupId> | ||
| 42 | + <artifactId>spring-boot-starter-jdbc</artifactId> | ||
| 43 | + </dependency> | ||
| 44 | + | ||
| 45 | + <dependency> | ||
| 46 | + <groupId>com.baomidou</groupId> | ||
| 47 | + <artifactId>mybatis-plus-spring-boot3-starter</artifactId> | ||
| 48 | + <version>${mybatis-plus.version}</version> | ||
| 49 | + </dependency> | ||
| 50 | + | ||
| 51 | + <dependency> | ||
| 52 | + <groupId>com.mysql</groupId> | ||
| 53 | + <artifactId>mysql-connector-j</artifactId> | ||
| 54 | + <scope>runtime</scope> | ||
| 55 | + </dependency> | ||
| 56 | + | ||
| 57 | + <dependency> | ||
| 58 | + <groupId>org.flywaydb</groupId> | ||
| 59 | + <artifactId>flyway-core</artifactId> | ||
| 60 | + </dependency> | ||
| 61 | + <dependency> | ||
| 62 | + <groupId>org.flywaydb</groupId> | ||
| 63 | + <artifactId>flyway-mysql</artifactId> | ||
| 64 | + </dependency> | ||
| 65 | + | ||
| 66 | + <dependency> | ||
| 67 | + <groupId>io.jsonwebtoken</groupId> | ||
| 68 | + <artifactId>jjwt-api</artifactId> | ||
| 69 | + <version>${jjwt.version}</version> | ||
| 70 | + </dependency> | ||
| 71 | + <dependency> | ||
| 72 | + <groupId>io.jsonwebtoken</groupId> | ||
| 73 | + <artifactId>jjwt-impl</artifactId> | ||
| 74 | + <version>${jjwt.version}</version> | ||
| 75 | + <scope>runtime</scope> | ||
| 76 | + </dependency> | ||
| 77 | + <dependency> | ||
| 78 | + <groupId>io.jsonwebtoken</groupId> | ||
| 79 | + <artifactId>jjwt-jackson</artifactId> | ||
| 80 | + <version>${jjwt.version}</version> | ||
| 81 | + <scope>runtime</scope> | ||
| 82 | + </dependency> | ||
| 83 | + | ||
| 84 | + <dependency> | ||
| 85 | + <groupId>org.springframework.boot</groupId> | ||
| 86 | + <artifactId>spring-boot-starter-test</artifactId> | ||
| 87 | + <scope>test</scope> | ||
| 88 | + </dependency> | ||
| 89 | + <dependency> | ||
| 90 | + <groupId>org.springframework.security</groupId> | ||
| 91 | + <artifactId>spring-security-test</artifactId> | ||
| 92 | + <scope>test</scope> | ||
| 93 | + </dependency> | ||
| 94 | + </dependencies> | ||
| 95 | + | ||
| 96 | + <build> | ||
| 97 | + <plugins> | ||
| 98 | + <plugin> | ||
| 99 | + <groupId>org.springframework.boot</groupId> | ||
| 100 | + <artifactId>spring-boot-maven-plugin</artifactId> | ||
| 101 | + </plugin> | ||
| 102 | + <plugin> | ||
| 103 | + <groupId>org.apache.maven.plugins</groupId> | ||
| 104 | + <artifactId>maven-surefire-plugin</artifactId> | ||
| 105 | + <configuration> | ||
| 106 | + <includes> | ||
| 107 | + <include>**/*Test.java</include> | ||
| 108 | + <include>**/*Tests.java</include> | ||
| 109 | + <include>**/*IT.java</include> | ||
| 110 | + </includes> | ||
| 111 | + </configuration> | ||
| 112 | + </plugin> | ||
| 113 | + </plugins> | ||
| 114 | + </build> | ||
| 115 | +</project> |
backend/src/main/java/com/xly/erp/ErpApplication.java
0 → 100644
| 1 | +package com.xly.erp; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.config.StubSecurityProperties; | ||
| 4 | +import com.xly.erp.common.config.TenantProperties; | ||
| 5 | +import org.springframework.boot.SpringApplication; | ||
| 6 | +import org.springframework.boot.autoconfigure.SpringBootApplication; | ||
| 7 | +import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||
| 8 | + | ||
| 9 | +@SpringBootApplication | ||
| 10 | +@EnableConfigurationProperties({TenantProperties.class, StubSecurityProperties.class}) | ||
| 11 | +public class ErpApplication { | ||
| 12 | + public static void main(String[] args) { | ||
| 13 | + SpringApplication.run(ErpApplication.class, args); | ||
| 14 | + } | ||
| 15 | +} |
backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java
0 → 100644
backend/src/main/java/com/xly/erp/common/config/StubSecurityProperties.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | ||
| 2 | + | ||
| 3 | +import org.springframework.boot.context.properties.ConfigurationProperties; | ||
| 4 | + | ||
| 5 | +@ConfigurationProperties(prefix = "erp.security") | ||
| 6 | +public class StubSecurityProperties { | ||
| 7 | + private String stubUserNo; | ||
| 8 | + private String jwtSecret; | ||
| 9 | + | ||
| 10 | + public String getStubUserNo() { | ||
| 11 | + return stubUserNo; | ||
| 12 | + } | ||
| 13 | + | ||
| 14 | + public void setStubUserNo(String stubUserNo) { | ||
| 15 | + this.stubUserNo = stubUserNo; | ||
| 16 | + } | ||
| 17 | + | ||
| 18 | + public String getJwtSecret() { | ||
| 19 | + return jwtSecret; | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + public void setJwtSecret(String jwtSecret) { | ||
| 23 | + this.jwtSecret = jwtSecret; | ||
| 24 | + } | ||
| 25 | +} |
backend/src/main/java/com/xly/erp/common/config/TenantProperties.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | ||
| 2 | + | ||
| 3 | +import org.springframework.boot.context.properties.ConfigurationProperties; | ||
| 4 | + | ||
| 5 | +@ConfigurationProperties(prefix = "erp.tenant") | ||
| 6 | +public class TenantProperties { | ||
| 7 | + private String brandsId; | ||
| 8 | + private String subsidiaryId; | ||
| 9 | + | ||
| 10 | + public String getBrandsId() { | ||
| 11 | + return brandsId; | ||
| 12 | + } | ||
| 13 | + | ||
| 14 | + public void setBrandsId(String brandsId) { | ||
| 15 | + this.brandsId = brandsId; | ||
| 16 | + } | ||
| 17 | + | ||
| 18 | + public String getSubsidiaryId() { | ||
| 19 | + return subsidiaryId; | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + public void setSubsidiaryId(String subsidiaryId) { | ||
| 23 | + this.subsidiaryId = subsidiaryId; | ||
| 24 | + } | ||
| 25 | +} |
backend/src/main/java/com/xly/erp/common/exception/BizException.java
0 → 100644
backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.Result; | ||
| 4 | +import org.slf4j.Logger; | ||
| 5 | +import org.slf4j.LoggerFactory; | ||
| 6 | +import org.springframework.validation.FieldError; | ||
| 7 | +import org.springframework.web.bind.MethodArgumentNotValidException; | ||
| 8 | +import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| 9 | +import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
| 10 | + | ||
| 11 | +@RestControllerAdvice | ||
| 12 | +public class GlobalExceptionHandler { | ||
| 13 | + | ||
| 14 | + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); | ||
| 15 | + | ||
| 16 | + @ExceptionHandler(BizException.class) | ||
| 17 | + public Result<Void> handleBiz(BizException e) { | ||
| 18 | + log.warn("BizException code={} msg={}", e.getCode(), e.getMessage()); | ||
| 19 | + return Result.fail(e.getCode(), e.getMessage()); | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + @ExceptionHandler(MethodArgumentNotValidException.class) | ||
| 23 | + public Result<Void> handleValidation(MethodArgumentNotValidException e) { | ||
| 24 | + FieldError fe = e.getBindingResult().getFieldError(); | ||
| 25 | + String msg = fe == null | ||
| 26 | + ? "参数校验失败" | ||
| 27 | + : fe.getField() + ": " + fe.getDefaultMessage(); | ||
| 28 | + log.warn("ValidationException {}", msg); | ||
| 29 | + return Result.fail(40001, msg); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + @ExceptionHandler(Exception.class) | ||
| 33 | + public Result<Void> handleAny(Exception e) { | ||
| 34 | + log.error("Unhandled exception", e); | ||
| 35 | + return Result.fail(50000, "系统繁忙"); | ||
| 36 | + } | ||
| 37 | +} |
backend/src/main/java/com/xly/erp/common/response/Result.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | ||
| 2 | + | ||
| 3 | +public class Result<T> { | ||
| 4 | + private int code; | ||
| 5 | + private String msg; | ||
| 6 | + private T data; | ||
| 7 | + | ||
| 8 | + public Result() { | ||
| 9 | + } | ||
| 10 | + | ||
| 11 | + public Result(int code, String msg, T data) { | ||
| 12 | + this.code = code; | ||
| 13 | + this.msg = msg; | ||
| 14 | + this.data = data; | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + public static <T> Result<T> ok(T data) { | ||
| 18 | + return new Result<>(0, "ok", data); | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + public static <T> Result<T> ok() { | ||
| 22 | + return new Result<>(0, "ok", null); | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + public static <T> Result<T> fail(int code, String msg) { | ||
| 26 | + return new Result<>(code, msg, null); | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + public int getCode() { | ||
| 30 | + return code; | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + public void setCode(int code) { | ||
| 34 | + this.code = code; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + public String getMsg() { | ||
| 38 | + return msg; | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + public void setMsg(String msg) { | ||
| 42 | + this.msg = msg; | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + public T getData() { | ||
| 46 | + return data; | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + public void setData(T data) { | ||
| 50 | + this.data = data; | ||
| 51 | + } | ||
| 52 | +} |
backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.erp.common.exception.BizException; | ||
| 5 | +import com.xly.erp.common.response.Result; | ||
| 6 | +import jakarta.servlet.FilterChain; | ||
| 7 | +import jakarta.servlet.ServletException; | ||
| 8 | +import jakarta.servlet.http.HttpServletRequest; | ||
| 9 | +import jakarta.servlet.http.HttpServletResponse; | ||
| 10 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 11 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| 12 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 13 | +import org.springframework.stereotype.Component; | ||
| 14 | +import org.springframework.web.filter.OncePerRequestFilter; | ||
| 15 | + | ||
| 16 | +import java.io.IOException; | ||
| 17 | +import java.nio.charset.StandardCharsets; | ||
| 18 | +import java.util.Collections; | ||
| 19 | + | ||
| 20 | +@Component | ||
| 21 | +public class JwtAuthenticationFilter extends OncePerRequestFilter { | ||
| 22 | + | ||
| 23 | + private static final String HEADER = "Authorization"; | ||
| 24 | + private static final String PREFIX = "Bearer "; | ||
| 25 | + | ||
| 26 | + private final JwtUtil jwtUtil; | ||
| 27 | + private final ObjectMapper objectMapper; | ||
| 28 | + | ||
| 29 | + @Autowired | ||
| 30 | + public JwtAuthenticationFilter(JwtUtil jwtUtil, ObjectMapper objectMapper) { | ||
| 31 | + this.jwtUtil = jwtUtil; | ||
| 32 | + this.objectMapper = objectMapper; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + @Override | ||
| 36 | + protected void doFilterInternal(HttpServletRequest request, | ||
| 37 | + HttpServletResponse response, | ||
| 38 | + FilterChain chain) throws ServletException, IOException { | ||
| 39 | + String header = request.getHeader(HEADER); | ||
| 40 | + if (header == null || !header.startsWith(PREFIX)) { | ||
| 41 | + chain.doFilter(request, response); | ||
| 42 | + return; | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + String token = header.substring(PREFIX.length()); | ||
| 46 | + String userNo; | ||
| 47 | + try { | ||
| 48 | + userNo = jwtUtil.parse(token); | ||
| 49 | + } catch (BizException e) { | ||
| 50 | + writeJsonResult(response, e.getCode(), e.getMessage()); | ||
| 51 | + return; | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( | ||
| 55 | + userNo, null, Collections.emptyList()); | ||
| 56 | + SecurityContextHolder.getContext().setAuthentication(auth); | ||
| 57 | + chain.doFilter(request, response); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + private void writeJsonResult(HttpServletResponse response, int code, String msg) throws IOException { | ||
| 61 | + response.setStatus(HttpServletResponse.SC_OK); | ||
| 62 | + response.setContentType("application/json;charset=UTF-8"); | ||
| 63 | + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); | ||
| 64 | + response.getWriter().write(objectMapper.writeValueAsString(Result.fail(code, msg))); | ||
| 65 | + } | ||
| 66 | +} |
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.config.StubSecurityProperties; | ||
| 4 | +import com.xly.erp.common.exception.BizException; | ||
| 5 | +import io.jsonwebtoken.Jwts; | ||
| 6 | +import io.jsonwebtoken.JwtException; | ||
| 7 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 8 | +import org.springframework.stereotype.Component; | ||
| 9 | + | ||
| 10 | +import javax.crypto.SecretKey; | ||
| 11 | +import javax.crypto.spec.SecretKeySpec; | ||
| 12 | +import java.nio.charset.StandardCharsets; | ||
| 13 | +import java.time.Duration; | ||
| 14 | +import java.util.Date; | ||
| 15 | + | ||
| 16 | +@Component | ||
| 17 | +public class JwtUtil { | ||
| 18 | + | ||
| 19 | + private static final Duration TTL = Duration.ofHours(8); | ||
| 20 | + | ||
| 21 | + private final SecretKey key; | ||
| 22 | + | ||
| 23 | + public JwtUtil(String secret) { | ||
| 24 | + this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + @Autowired | ||
| 28 | + public JwtUtil(StubSecurityProperties props) { | ||
| 29 | + this(props.getJwtSecret()); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + public String sign(String userNo) { | ||
| 33 | + Date now = new Date(); | ||
| 34 | + return Jwts.builder() | ||
| 35 | + .subject(userNo) | ||
| 36 | + .issuedAt(now) | ||
| 37 | + .expiration(new Date(now.getTime() + TTL.toMillis())) | ||
| 38 | + .signWith(key) | ||
| 39 | + .compact(); | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + public String parse(String token) { | ||
| 43 | + try { | ||
| 44 | + return Jwts.parser() | ||
| 45 | + .verifyWith(key) | ||
| 46 | + .build() | ||
| 47 | + .parseSignedClaims(token) | ||
| 48 | + .getPayload() | ||
| 49 | + .getSubject(); | ||
| 50 | + } catch (JwtException | IllegalArgumentException e) { | ||
| 51 | + throw new BizException(20001, "未认证或 token 已失效"); | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | +} |
backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; | ||
| 4 | +import org.springframework.context.annotation.Bean; | ||
| 5 | +import org.springframework.context.annotation.Configuration; | ||
| 6 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; | ||
| 7 | +import org.springframework.security.config.http.SessionCreationPolicy; | ||
| 8 | +import org.springframework.security.web.SecurityFilterChain; | ||
| 9 | +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | ||
| 10 | + | ||
| 11 | +@Configuration | ||
| 12 | +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) | ||
| 13 | +public class SecurityConfig { | ||
| 14 | + | ||
| 15 | + private final JwtAuthenticationFilter jwtFilter; | ||
| 16 | + | ||
| 17 | + public SecurityConfig(JwtAuthenticationFilter jwtFilter) { | ||
| 18 | + this.jwtFilter = jwtFilter; | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + @Bean | ||
| 22 | + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { | ||
| 23 | + http.csrf(csrf -> csrf.disable()) | ||
| 24 | + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
| 25 | + .authorizeHttpRequests(auth -> auth | ||
| 26 | + // REQ-MOD-001 stub: see USR-004 follow-up — 角色硬校验在 USR-004 完成后回填为 hasAuthority('SUPER_ADMIN') | ||
| 27 | + .requestMatchers("/api/mod/**").permitAll() | ||
| 28 | + .anyRequest().authenticated() | ||
| 29 | + ) | ||
| 30 | + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); | ||
| 31 | + return http.build(); | ||
| 32 | + } | ||
| 33 | +} |
backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import org.springframework.security.authentication.AnonymousAuthenticationToken; | ||
| 4 | +import org.springframework.security.core.Authentication; | ||
| 5 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 6 | + | ||
| 7 | +public final class SecurityContextHelper { | ||
| 8 | + | ||
| 9 | + private SecurityContextHelper() { | ||
| 10 | + } | ||
| 11 | + | ||
| 12 | + public static String currentUserNo() { | ||
| 13 | + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | ||
| 14 | + if (auth == null || auth instanceof AnonymousAuthenticationToken) { | ||
| 15 | + return null; | ||
| 16 | + } | ||
| 17 | + Object p = auth.getPrincipal(); | ||
| 18 | + return p instanceof String s ? s : null; | ||
| 19 | + } | ||
| 20 | +} |
backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.controller; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.Result; | ||
| 4 | +import com.xly.erp.module.mod.dto.CreateModuleDTO; | ||
| 5 | +import com.xly.erp.module.mod.dto.UpdateModuleDTO; | ||
| 6 | +import com.xly.erp.module.mod.service.ModuleService; | ||
| 7 | +import com.xly.erp.module.mod.vo.ModuleTreeVO; | ||
| 8 | +import jakarta.validation.Valid; | ||
| 9 | +import org.springframework.web.bind.annotation.DeleteMapping; | ||
| 10 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 11 | +import org.springframework.web.bind.annotation.PathVariable; | ||
| 12 | +import org.springframework.web.bind.annotation.PostMapping; | ||
| 13 | +import org.springframework.web.bind.annotation.PutMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.RequestBody; | ||
| 15 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 16 | +import org.springframework.web.bind.annotation.RequestParam; | ||
| 17 | +import org.springframework.web.bind.annotation.RestController; | ||
| 18 | + | ||
| 19 | +import java.util.List; | ||
| 20 | +import java.util.Map; | ||
| 21 | + | ||
| 22 | +@RestController | ||
| 23 | +@RequestMapping("/api/mod") | ||
| 24 | +public class ModuleController { | ||
| 25 | + | ||
| 26 | + private final ModuleService moduleService; | ||
| 27 | + | ||
| 28 | + public ModuleController(ModuleService moduleService) { | ||
| 29 | + this.moduleService = moduleService; | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + @PostMapping("/modules") | ||
| 33 | + public Result<Map<String, Integer>> create(@Valid @RequestBody CreateModuleDTO dto) { | ||
| 34 | + Integer id = moduleService.create(dto); | ||
| 35 | + return Result.ok(Map.of("iIncrement", id)); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + @PutMapping("/modules/{id}") | ||
| 39 | + public Result<Map<String, Integer>> update(@PathVariable Integer id, | ||
| 40 | + @Valid @RequestBody UpdateModuleDTO dto) { | ||
| 41 | + Integer updated = moduleService.update(id, dto); | ||
| 42 | + return Result.ok(Map.of("iIncrement", updated)); | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + @DeleteMapping("/modules/{id}") | ||
| 46 | + public Result<Void> delete(@PathVariable Integer id) { | ||
| 47 | + moduleService.delete(id); | ||
| 48 | + return Result.ok(); | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + @GetMapping("/modules") | ||
| 52 | + public Result<List<ModuleTreeVO>> list(@RequestParam(required = false) String keyword) { | ||
| 53 | + return Result.ok(moduleService.listTree(keyword)); | ||
| 54 | + } | ||
| 55 | +} |
backend/src/main/java/com/xly/erp/module/mod/dto/CreateModuleDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.dto; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | ||
| 4 | +import jakarta.validation.constraints.NotBlank; | ||
| 5 | +import jakarta.validation.constraints.Size; | ||
| 6 | + | ||
| 7 | +public class CreateModuleDTO { | ||
| 8 | + | ||
| 9 | + @JsonProperty("sDisplayType") | ||
| 10 | + @NotBlank | ||
| 11 | + private String sDisplayType; | ||
| 12 | + | ||
| 13 | + @JsonProperty("sProcedureName") | ||
| 14 | + @NotBlank | ||
| 15 | + @Size(max = 100) | ||
| 16 | + private String sProcedureName; | ||
| 17 | + | ||
| 18 | + @JsonProperty("sModuleType") | ||
| 19 | + @NotBlank | ||
| 20 | + @Size(max = 50) | ||
| 21 | + private String sModuleType; | ||
| 22 | + | ||
| 23 | + @JsonProperty("sManageDeptEn") | ||
| 24 | + @NotBlank | ||
| 25 | + @Size(max = 50) | ||
| 26 | + private String sManageDeptEn; | ||
| 27 | + | ||
| 28 | + @JsonProperty("bShowPermission") | ||
| 29 | + private Boolean bShowPermission; | ||
| 30 | + | ||
| 31 | + @JsonProperty("sModuleNameZh") | ||
| 32 | + @NotBlank | ||
| 33 | + @Size(max = 100) | ||
| 34 | + private String sModuleNameZh; | ||
| 35 | + | ||
| 36 | + @JsonProperty("iParentId") | ||
| 37 | + private Integer iParentId; | ||
| 38 | + | ||
| 39 | + @JsonProperty("iSortOrder") | ||
| 40 | + private Integer iSortOrder; | ||
| 41 | + | ||
| 42 | + public String getSDisplayType() { return sDisplayType; } | ||
| 43 | + public void setSDisplayType(String sDisplayType) { this.sDisplayType = sDisplayType; } | ||
| 44 | + public String getSProcedureName() { return sProcedureName; } | ||
| 45 | + public void setSProcedureName(String sProcedureName) { this.sProcedureName = sProcedureName; } | ||
| 46 | + public String getSModuleType() { return sModuleType; } | ||
| 47 | + public void setSModuleType(String sModuleType) { this.sModuleType = sModuleType; } | ||
| 48 | + public String getSManageDeptEn() { return sManageDeptEn; } | ||
| 49 | + public void setSManageDeptEn(String sManageDeptEn) { this.sManageDeptEn = sManageDeptEn; } | ||
| 50 | + public Boolean getBShowPermission() { return bShowPermission; } | ||
| 51 | + public void setBShowPermission(Boolean bShowPermission) { this.bShowPermission = bShowPermission; } | ||
| 52 | + public String getSModuleNameZh() { return sModuleNameZh; } | ||
| 53 | + public void setSModuleNameZh(String sModuleNameZh) { this.sModuleNameZh = sModuleNameZh; } | ||
| 54 | + public Integer getIParentId() { return iParentId; } | ||
| 55 | + public void setIParentId(Integer iParentId) { this.iParentId = iParentId; } | ||
| 56 | + public Integer getISortOrder() { return iSortOrder; } | ||
| 57 | + public void setISortOrder(Integer iSortOrder) { this.iSortOrder = iSortOrder; } | ||
| 58 | +} |
backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.dto; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | ||
| 4 | +import jakarta.validation.constraints.NotBlank; | ||
| 5 | +import jakarta.validation.constraints.Size; | ||
| 6 | + | ||
| 7 | +public class UpdateModuleDTO { | ||
| 8 | + | ||
| 9 | + @JsonProperty("sDisplayType") | ||
| 10 | + @NotBlank | ||
| 11 | + private String sDisplayType; | ||
| 12 | + | ||
| 13 | + @JsonProperty("sModuleType") | ||
| 14 | + @NotBlank | ||
| 15 | + @Size(max = 50) | ||
| 16 | + private String sModuleType; | ||
| 17 | + | ||
| 18 | + @JsonProperty("sManageDeptEn") | ||
| 19 | + @NotBlank | ||
| 20 | + @Size(max = 50) | ||
| 21 | + private String sManageDeptEn; | ||
| 22 | + | ||
| 23 | + @JsonProperty("bShowPermission") | ||
| 24 | + private Boolean bShowPermission; | ||
| 25 | + | ||
| 26 | + @JsonProperty("sModuleNameZh") | ||
| 27 | + @NotBlank | ||
| 28 | + @Size(max = 100) | ||
| 29 | + private String sModuleNameZh; | ||
| 30 | + | ||
| 31 | + @JsonProperty("iParentId") | ||
| 32 | + private Integer iParentId; | ||
| 33 | + | ||
| 34 | + @JsonProperty("iSortOrder") | ||
| 35 | + private Integer iSortOrder; | ||
| 36 | + | ||
| 37 | + public String getSDisplayType() { return sDisplayType; } | ||
| 38 | + public void setSDisplayType(String sDisplayType) { this.sDisplayType = sDisplayType; } | ||
| 39 | + public String getSModuleType() { return sModuleType; } | ||
| 40 | + public void setSModuleType(String sModuleType) { this.sModuleType = sModuleType; } | ||
| 41 | + public String getSManageDeptEn() { return sManageDeptEn; } | ||
| 42 | + public void setSManageDeptEn(String sManageDeptEn) { this.sManageDeptEn = sManageDeptEn; } | ||
| 43 | + public Boolean getBShowPermission() { return bShowPermission; } | ||
| 44 | + public void setBShowPermission(Boolean bShowPermission) { this.bShowPermission = bShowPermission; } | ||
| 45 | + public String getSModuleNameZh() { return sModuleNameZh; } | ||
| 46 | + public void setSModuleNameZh(String sModuleNameZh) { this.sModuleNameZh = sModuleNameZh; } | ||
| 47 | + public Integer getIParentId() { return iParentId; } | ||
| 48 | + public void setIParentId(Integer iParentId) { this.iParentId = iParentId; } | ||
| 49 | + public Integer getISortOrder() { return iSortOrder; } | ||
| 50 | + public void setISortOrder(Integer iSortOrder) { this.iSortOrder = iSortOrder; } | ||
| 51 | +} |
backend/src/main/java/com/xly/erp/module/mod/entity/Module.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.entity; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | ||
| 4 | +import com.baomidou.mybatisplus.annotation.TableField; | ||
| 5 | +import com.baomidou.mybatisplus.annotation.TableId; | ||
| 6 | +import com.baomidou.mybatisplus.annotation.TableName; | ||
| 7 | + | ||
| 8 | +import java.time.LocalDateTime; | ||
| 9 | + | ||
| 10 | +@TableName("tModule") | ||
| 11 | +public class Module { | ||
| 12 | + | ||
| 13 | + @TableId(value = "iIncrement", type = IdType.AUTO) | ||
| 14 | + private Integer iIncrement; | ||
| 15 | + | ||
| 16 | + @TableField("sId") | ||
| 17 | + private String sId; | ||
| 18 | + | ||
| 19 | + @TableField("sBrandsId") | ||
| 20 | + private String sBrandsId; | ||
| 21 | + | ||
| 22 | + @TableField("sSubsidiaryId") | ||
| 23 | + private String sSubsidiaryId; | ||
| 24 | + | ||
| 25 | + @TableField("tCreateDate") | ||
| 26 | + private LocalDateTime tCreateDate; | ||
| 27 | + | ||
| 28 | + @TableField("sDisplayType") | ||
| 29 | + private String sDisplayType; | ||
| 30 | + | ||
| 31 | + @TableField("sProcedureName") | ||
| 32 | + private String sProcedureName; | ||
| 33 | + | ||
| 34 | + @TableField("sModuleType") | ||
| 35 | + private String sModuleType; | ||
| 36 | + | ||
| 37 | + @TableField("sManageDeptEn") | ||
| 38 | + private String sManageDeptEn; | ||
| 39 | + | ||
| 40 | + @TableField("bShowPermission") | ||
| 41 | + private Boolean bShowPermission; | ||
| 42 | + | ||
| 43 | + @TableField("sModuleNameZh") | ||
| 44 | + private String sModuleNameZh; | ||
| 45 | + | ||
| 46 | + @TableField("iParentId") | ||
| 47 | + private Integer iParentId; | ||
| 48 | + | ||
| 49 | + @TableField("iSortOrder") | ||
| 50 | + private Integer iSortOrder; | ||
| 51 | + | ||
| 52 | + @TableField("sCreatedBy") | ||
| 53 | + private String sCreatedBy; | ||
| 54 | + | ||
| 55 | + @TableField("bDeleted") | ||
| 56 | + private Boolean bDeleted; | ||
| 57 | + | ||
| 58 | + @TableField("tDeletedDate") | ||
| 59 | + private LocalDateTime tDeletedDate; | ||
| 60 | + | ||
| 61 | + @TableField("sDeletedBy") | ||
| 62 | + private String sDeletedBy; | ||
| 63 | + | ||
| 64 | + public Integer getIIncrement() { return iIncrement; } | ||
| 65 | + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } | ||
| 66 | + public String getSId() { return sId; } | ||
| 67 | + public void setSId(String sId) { this.sId = sId; } | ||
| 68 | + public String getSBrandsId() { return sBrandsId; } | ||
| 69 | + public void setSBrandsId(String sBrandsId) { this.sBrandsId = sBrandsId; } | ||
| 70 | + public String getSSubsidiaryId() { return sSubsidiaryId; } | ||
| 71 | + public void setSSubsidiaryId(String sSubsidiaryId) { this.sSubsidiaryId = sSubsidiaryId; } | ||
| 72 | + public LocalDateTime getTCreateDate() { return tCreateDate; } | ||
| 73 | + public void setTCreateDate(LocalDateTime tCreateDate) { this.tCreateDate = tCreateDate; } | ||
| 74 | + public String getSDisplayType() { return sDisplayType; } | ||
| 75 | + public void setSDisplayType(String sDisplayType) { this.sDisplayType = sDisplayType; } | ||
| 76 | + public String getSProcedureName() { return sProcedureName; } | ||
| 77 | + public void setSProcedureName(String sProcedureName) { this.sProcedureName = sProcedureName; } | ||
| 78 | + public String getSModuleType() { return sModuleType; } | ||
| 79 | + public void setSModuleType(String sModuleType) { this.sModuleType = sModuleType; } | ||
| 80 | + public String getSManageDeptEn() { return sManageDeptEn; } | ||
| 81 | + public void setSManageDeptEn(String sManageDeptEn) { this.sManageDeptEn = sManageDeptEn; } | ||
| 82 | + public Boolean getBShowPermission() { return bShowPermission; } | ||
| 83 | + public void setBShowPermission(Boolean bShowPermission) { this.bShowPermission = bShowPermission; } | ||
| 84 | + public String getSModuleNameZh() { return sModuleNameZh; } | ||
| 85 | + public void setSModuleNameZh(String sModuleNameZh) { this.sModuleNameZh = sModuleNameZh; } | ||
| 86 | + public Integer getIParentId() { return iParentId; } | ||
| 87 | + public void setIParentId(Integer iParentId) { this.iParentId = iParentId; } | ||
| 88 | + public Integer getISortOrder() { return iSortOrder; } | ||
| 89 | + public void setISortOrder(Integer iSortOrder) { this.iSortOrder = iSortOrder; } | ||
| 90 | + public String getSCreatedBy() { return sCreatedBy; } | ||
| 91 | + public void setSCreatedBy(String sCreatedBy) { this.sCreatedBy = sCreatedBy; } | ||
| 92 | + public Boolean getBDeleted() { return bDeleted; } | ||
| 93 | + public void setBDeleted(Boolean bDeleted) { this.bDeleted = bDeleted; } | ||
| 94 | + public LocalDateTime getTDeletedDate() { return tDeletedDate; } | ||
| 95 | + public void setTDeletedDate(LocalDateTime tDeletedDate) { this.tDeletedDate = tDeletedDate; } | ||
| 96 | + public String getSDeletedBy() { return sDeletedBy; } | ||
| 97 | + public void setSDeletedBy(String sDeletedBy) { this.sDeletedBy = sDeletedBy; } | ||
| 98 | +} |
backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.mapper; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | ||
| 4 | +import com.xly.erp.module.mod.entity.Module; | ||
| 5 | +import org.apache.ibatis.annotations.Param; | ||
| 6 | +import org.apache.ibatis.annotations.Select; | ||
| 7 | + | ||
| 8 | +import java.util.List; | ||
| 9 | + | ||
| 10 | +public interface ModuleMapper extends BaseMapper<Module> { | ||
| 11 | + | ||
| 12 | + @Select("SELECT 1 FROM tModule WHERE iIncrement = #{id} AND bDeleted = 0 LIMIT 1") | ||
| 13 | + Integer findActiveFlagById(@Param("id") Integer iIncrement); | ||
| 14 | + | ||
| 15 | + default boolean existsActiveById(Integer iIncrement) { | ||
| 16 | + return findActiveFlagById(iIncrement) != null; | ||
| 17 | + } | ||
| 18 | + | ||
| 19 | + @Select("SELECT iParentId FROM tModule WHERE iIncrement = #{id} AND bDeleted = 0") | ||
| 20 | + Integer selectParentIdById(@Param("id") Integer iIncrement); | ||
| 21 | + | ||
| 22 | + @Select("SELECT 1 FROM tModule WHERE iParentId = #{parentId} AND bDeleted = 0 LIMIT 1") | ||
| 23 | + Integer findActiveChildFlag(@Param("parentId") Integer parentId); | ||
| 24 | + | ||
| 25 | + default boolean hasActiveChildren(Integer parentId) { | ||
| 26 | + return findActiveChildFlag(parentId) != null; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @Select("SELECT iIncrement, sModuleNameZh, sDisplayType, sManageDeptEn, iParentId, iSortOrder " | ||
| 30 | + + "FROM tModule WHERE bDeleted = 0 AND sModuleNameZh LIKE CONCAT('%', #{keyword}, '%') " | ||
| 31 | + + "ORDER BY iSortOrder ASC, iIncrement ASC") | ||
| 32 | + List<Module> selectActiveByKeyword(@Param("keyword") String keyword); | ||
| 33 | +} |
backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.service; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.module.mod.dto.CreateModuleDTO; | ||
| 4 | +import com.xly.erp.module.mod.dto.UpdateModuleDTO; | ||
| 5 | +import com.xly.erp.module.mod.vo.ModuleTreeVO; | ||
| 6 | + | ||
| 7 | +import java.util.List; | ||
| 8 | + | ||
| 9 | +public interface ModuleService { | ||
| 10 | + Integer create(CreateModuleDTO dto); | ||
| 11 | + | ||
| 12 | + Integer update(Integer id, UpdateModuleDTO dto); | ||
| 13 | + | ||
| 14 | + void delete(Integer id); | ||
| 15 | + | ||
| 16 | + List<ModuleTreeVO> listTree(String keyword); | ||
| 17 | +} |
backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.service.impl; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.config.StubSecurityProperties; | ||
| 4 | +import com.xly.erp.common.config.TenantProperties; | ||
| 5 | +import com.xly.erp.common.exception.BizException; | ||
| 6 | +import com.xly.erp.common.security.SecurityContextHelper; | ||
| 7 | +import com.xly.erp.module.mod.dto.CreateModuleDTO; | ||
| 8 | +import com.xly.erp.module.mod.dto.UpdateModuleDTO; | ||
| 9 | +import com.xly.erp.module.mod.entity.Module; | ||
| 10 | +import com.xly.erp.module.mod.vo.ModuleTreeVO; | ||
| 11 | +import com.xly.erp.module.mod.mapper.ModuleMapper; | ||
| 12 | +import com.xly.erp.module.mod.service.ModuleService; | ||
| 13 | +import org.springframework.dao.DuplicateKeyException; | ||
| 14 | +import org.springframework.stereotype.Service; | ||
| 15 | +import org.springframework.transaction.annotation.Transactional; | ||
| 16 | + | ||
| 17 | +import java.time.LocalDateTime; | ||
| 18 | +import java.util.ArrayList; | ||
| 19 | +import java.util.HashMap; | ||
| 20 | +import java.util.List; | ||
| 21 | +import java.util.Map; | ||
| 22 | +import java.util.Set; | ||
| 23 | + | ||
| 24 | +@Service | ||
| 25 | +@Transactional(rollbackFor = Exception.class) | ||
| 26 | +public class ModuleServiceImpl implements ModuleService { | ||
| 27 | + | ||
| 28 | + private static final Set<String> DISPLAY_TYPES = Set.of("手机端", "前端业务", "系统配置", "接口"); | ||
| 29 | + private static final int MAX_PARENT_DEPTH = 50; | ||
| 30 | + | ||
| 31 | + private final ModuleMapper moduleMapper; | ||
| 32 | + private final TenantProperties tenant; | ||
| 33 | + private final StubSecurityProperties stub; | ||
| 34 | + | ||
| 35 | + public ModuleServiceImpl(ModuleMapper moduleMapper, | ||
| 36 | + TenantProperties tenant, | ||
| 37 | + StubSecurityProperties stub) { | ||
| 38 | + this.moduleMapper = moduleMapper; | ||
| 39 | + this.tenant = tenant; | ||
| 40 | + this.stub = stub; | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + @Override | ||
| 44 | + public Integer create(CreateModuleDTO dto) { | ||
| 45 | + if (!DISPLAY_TYPES.contains(dto.getSDisplayType())) { | ||
| 46 | + throw new BizException(40010, "显示类型枚举不合法"); | ||
| 47 | + } | ||
| 48 | + if (dto.getIParentId() != null && !moduleMapper.existsActiveById(dto.getIParentId())) { | ||
| 49 | + throw new BizException(40021, "父模块不存在或已删除"); | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + Module m = new Module(); | ||
| 53 | + m.setSBrandsId(tenant.getBrandsId()); | ||
| 54 | + m.setSSubsidiaryId(tenant.getSubsidiaryId()); | ||
| 55 | + m.setTCreateDate(LocalDateTime.now()); | ||
| 56 | + m.setSDisplayType(dto.getSDisplayType()); | ||
| 57 | + m.setSProcedureName(dto.getSProcedureName()); | ||
| 58 | + m.setSModuleType(dto.getSModuleType()); | ||
| 59 | + m.setSManageDeptEn(dto.getSManageDeptEn()); | ||
| 60 | + m.setBShowPermission(dto.getBShowPermission() != null ? dto.getBShowPermission() : false); | ||
| 61 | + m.setSModuleNameZh(dto.getSModuleNameZh()); | ||
| 62 | + m.setIParentId(dto.getIParentId()); | ||
| 63 | + m.setISortOrder(dto.getISortOrder() != null ? dto.getISortOrder() : 0); | ||
| 64 | + String authedUserNo = SecurityContextHelper.currentUserNo(); | ||
| 65 | + m.setSCreatedBy(authedUserNo != null ? authedUserNo : stub.getStubUserNo()); | ||
| 66 | + m.setBDeleted(false); | ||
| 67 | + | ||
| 68 | + try { | ||
| 69 | + moduleMapper.insert(m); | ||
| 70 | + } catch (DuplicateKeyException e) { | ||
| 71 | + throw new BizException(40020, "存储过程名称已存在"); | ||
| 72 | + } | ||
| 73 | + return m.getIIncrement(); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @Override | ||
| 77 | + public Integer update(Integer id, UpdateModuleDTO dto) { | ||
| 78 | + Module original = moduleMapper.selectById(id); | ||
| 79 | + if (original == null || Boolean.TRUE.equals(original.getBDeleted())) { | ||
| 80 | + throw new BizException(40400, "模块不存在或已删除"); | ||
| 81 | + } | ||
| 82 | + if (!DISPLAY_TYPES.contains(dto.getSDisplayType())) { | ||
| 83 | + throw new BizException(40010, "显示类型枚举不合法"); | ||
| 84 | + } | ||
| 85 | + validateParent(id, dto.getIParentId()); | ||
| 86 | + | ||
| 87 | + Module entity = new Module(); | ||
| 88 | + entity.setIIncrement(id); | ||
| 89 | + entity.setSDisplayType(dto.getSDisplayType()); | ||
| 90 | + entity.setSModuleType(dto.getSModuleType()); | ||
| 91 | + entity.setSManageDeptEn(dto.getSManageDeptEn()); | ||
| 92 | + entity.setBShowPermission(dto.getBShowPermission() != null ? dto.getBShowPermission() : false); | ||
| 93 | + entity.setSModuleNameZh(dto.getSModuleNameZh()); | ||
| 94 | + entity.setIParentId(dto.getIParentId()); | ||
| 95 | + entity.setISortOrder(dto.getISortOrder() != null ? dto.getISortOrder() : 0); | ||
| 96 | + | ||
| 97 | + moduleMapper.updateById(entity); | ||
| 98 | + return id; | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + @Override | ||
| 102 | + public void delete(Integer id) { | ||
| 103 | + Module original = moduleMapper.selectById(id); | ||
| 104 | + if (original == null || Boolean.TRUE.equals(original.getBDeleted())) { | ||
| 105 | + throw new BizException(40400, "模块不存在或已删除"); | ||
| 106 | + } | ||
| 107 | + if (moduleMapper.hasActiveChildren(id)) { | ||
| 108 | + throw new BizException(40901, "模块仍有未删除子节点"); | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + Module entity = new Module(); | ||
| 112 | + entity.setIIncrement(id); | ||
| 113 | + entity.setBDeleted(true); | ||
| 114 | + entity.setTDeletedDate(LocalDateTime.now()); | ||
| 115 | + String authedUserNo = SecurityContextHelper.currentUserNo(); | ||
| 116 | + entity.setSDeletedBy(authedUserNo != null ? authedUserNo : stub.getStubUserNo()); | ||
| 117 | + moduleMapper.updateById(entity); | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + @Override | ||
| 121 | + @Transactional(readOnly = true) | ||
| 122 | + public List<ModuleTreeVO> listTree(String keyword) { | ||
| 123 | + String normalized = keyword == null ? "" : keyword.trim(); | ||
| 124 | + if (normalized.length() > 100) { | ||
| 125 | + throw new BizException(40001, "keyword 长度超过 100 字符"); | ||
| 126 | + } | ||
| 127 | + List<Module> rows = moduleMapper.selectActiveByKeyword(normalized); | ||
| 128 | + Map<Integer, ModuleTreeVO> idIndex = new HashMap<>(); | ||
| 129 | + for (Module m : rows) { | ||
| 130 | + idIndex.put(m.getIIncrement(), toTreeVO(m)); | ||
| 131 | + } | ||
| 132 | + List<ModuleTreeVO> roots = new ArrayList<>(); | ||
| 133 | + for (Module m : rows) { | ||
| 134 | + ModuleTreeVO vo = idIndex.get(m.getIIncrement()); | ||
| 135 | + Integer parentId = m.getIParentId(); | ||
| 136 | + if (parentId != null && idIndex.containsKey(parentId)) { | ||
| 137 | + idIndex.get(parentId).getChildren().add(vo); | ||
| 138 | + } else { | ||
| 139 | + roots.add(vo); | ||
| 140 | + } | ||
| 141 | + } | ||
| 142 | + return roots; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + private ModuleTreeVO toTreeVO(Module m) { | ||
| 146 | + ModuleTreeVO vo = new ModuleTreeVO(); | ||
| 147 | + vo.setIIncrement(m.getIIncrement()); | ||
| 148 | + vo.setSModuleNameZh(m.getSModuleNameZh()); | ||
| 149 | + vo.setSDisplayType(m.getSDisplayType()); | ||
| 150 | + vo.setSManageDeptEn(m.getSManageDeptEn()); | ||
| 151 | + vo.setIParentId(m.getIParentId()); | ||
| 152 | + vo.setISortOrder(m.getISortOrder()); | ||
| 153 | + return vo; | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + private void validateParent(Integer id, Integer parentId) { | ||
| 157 | + if (parentId == null) { | ||
| 158 | + return; | ||
| 159 | + } | ||
| 160 | + if (parentId.equals(id)) { | ||
| 161 | + throw new BizException(40021, "父模块不能指向自身"); | ||
| 162 | + } | ||
| 163 | + if (!moduleMapper.existsActiveById(parentId)) { | ||
| 164 | + throw new BizException(40021, "父模块不存在或已删除"); | ||
| 165 | + } | ||
| 166 | + Integer cur = moduleMapper.selectParentIdById(parentId); | ||
| 167 | + for (int depth = 0; cur != null; depth++) { | ||
| 168 | + if (cur.equals(id)) { | ||
| 169 | + throw new BizException(40021, "父模块链构成环路"); | ||
| 170 | + } | ||
| 171 | + if (depth >= MAX_PARENT_DEPTH) { | ||
| 172 | + throw new BizException(40021, "父模块链超过最大层级"); | ||
| 173 | + } | ||
| 174 | + cur = moduleMapper.selectParentIdById(cur); | ||
| 175 | + } | ||
| 176 | + } | ||
| 177 | +} |
backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeVO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.vo; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | ||
| 4 | + | ||
| 5 | +import java.util.ArrayList; | ||
| 6 | +import java.util.List; | ||
| 7 | + | ||
| 8 | +public class ModuleTreeVO { | ||
| 9 | + | ||
| 10 | + @JsonProperty("iIncrement") | ||
| 11 | + private Integer iIncrement; | ||
| 12 | + | ||
| 13 | + @JsonProperty("sModuleNameZh") | ||
| 14 | + private String sModuleNameZh; | ||
| 15 | + | ||
| 16 | + @JsonProperty("sDisplayType") | ||
| 17 | + private String sDisplayType; | ||
| 18 | + | ||
| 19 | + @JsonProperty("sManageDeptEn") | ||
| 20 | + private String sManageDeptEn; | ||
| 21 | + | ||
| 22 | + @JsonProperty("iParentId") | ||
| 23 | + private Integer iParentId; | ||
| 24 | + | ||
| 25 | + @JsonProperty("iSortOrder") | ||
| 26 | + private Integer iSortOrder; | ||
| 27 | + | ||
| 28 | + @JsonProperty("children") | ||
| 29 | + private List<ModuleTreeVO> children = new ArrayList<>(); | ||
| 30 | + | ||
| 31 | + public Integer getIIncrement() { return iIncrement; } | ||
| 32 | + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } | ||
| 33 | + public String getSModuleNameZh() { return sModuleNameZh; } | ||
| 34 | + public void setSModuleNameZh(String sModuleNameZh) { this.sModuleNameZh = sModuleNameZh; } | ||
| 35 | + public String getSDisplayType() { return sDisplayType; } | ||
| 36 | + public void setSDisplayType(String sDisplayType) { this.sDisplayType = sDisplayType; } | ||
| 37 | + public String getSManageDeptEn() { return sManageDeptEn; } | ||
| 38 | + public void setSManageDeptEn(String sManageDeptEn) { this.sManageDeptEn = sManageDeptEn; } | ||
| 39 | + public Integer getIParentId() { return iParentId; } | ||
| 40 | + public void setIParentId(Integer iParentId) { this.iParentId = iParentId; } | ||
| 41 | + public Integer getISortOrder() { return iSortOrder; } | ||
| 42 | + public void setISortOrder(Integer iSortOrder) { this.iSortOrder = iSortOrder; } | ||
| 43 | + public List<ModuleTreeVO> getChildren() { return children; } | ||
| 44 | + public void setChildren(List<ModuleTreeVO> children) { this.children = children; } | ||
| 45 | +} |
backend/src/main/resources/application-test.yml
0 → 100644
backend/src/main/resources/application.yml
0 → 100644
| 1 | +spring: | ||
| 2 | + application: | ||
| 3 | + name: erp-backend | ||
| 4 | + datasource: | ||
| 5 | + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true | ||
| 6 | + username: ${DB_USER} | ||
| 7 | + password: ${DB_PASSWORD} | ||
| 8 | + driver-class-name: com.mysql.cj.jdbc.Driver | ||
| 9 | + flyway: | ||
| 10 | + enabled: true | ||
| 11 | + locations: filesystem:../sql/migrations | ||
| 12 | + baseline-on-migrate: true | ||
| 13 | + | ||
| 14 | +server: | ||
| 15 | + port: 8080 | ||
| 16 | + servlet: | ||
| 17 | + context-path: / | ||
| 18 | + | ||
| 19 | +mybatis-plus: | ||
| 20 | + mapper-locations: classpath:mapper/**/*.xml | ||
| 21 | + configuration: | ||
| 22 | + map-underscore-to-camel-case: false | ||
| 23 | + | ||
| 24 | +erp: | ||
| 25 | + tenant: | ||
| 26 | + brands-id: XLY | ||
| 27 | + subsidiary-id: XLY | ||
| 28 | + security: | ||
| 29 | + stub-user-no: STUB_ADMIN | ||
| 30 | + jwt-secret: ${JWT_SECRET} | ||
| 31 | + | ||
| 32 | +logging: | ||
| 33 | + level: | ||
| 34 | + root: INFO | ||
| 35 | + com.xly.erp: DEBUG |
backend/src/main/resources/logback-spring.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | +<configuration> | ||
| 3 | + <include resource="org/springframework/boot/logging/logback/defaults.xml"/> | ||
| 4 | + <include resource="org/springframework/boot/logging/logback/console-appender.xml"/> | ||
| 5 | + | ||
| 6 | + <root level="INFO"> | ||
| 7 | + <appender-ref ref="CONSOLE"/> | ||
| 8 | + </root> | ||
| 9 | +</configuration> |
backend/src/test/java/com/xly/erp/SmokeTest.java
0 → 100644
| 1 | +package com.xly.erp; | ||
| 2 | + | ||
| 3 | +import org.junit.jupiter.api.Test; | ||
| 4 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 5 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 6 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 7 | +import org.springframework.test.context.ActiveProfiles; | ||
| 8 | + | ||
| 9 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 10 | + | ||
| 11 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) | ||
| 12 | +@ActiveProfiles("test") | ||
| 13 | +class SmokeTest { | ||
| 14 | + | ||
| 15 | + @Autowired | ||
| 16 | + private JdbcTemplate jdbcTemplate; | ||
| 17 | + | ||
| 18 | + @Test | ||
| 19 | + void contextLoads_andFlywayApplied() { | ||
| 20 | + Integer count = jdbcTemplate.queryForObject( | ||
| 21 | + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'tModule'", | ||
| 22 | + Integer.class); | ||
| 23 | + assertThat(count).isEqualTo(1); | ||
| 24 | + } | ||
| 25 | +} |
backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.Result; | ||
| 4 | +import jakarta.validation.Valid; | ||
| 5 | +import jakarta.validation.constraints.NotBlank; | ||
| 6 | +import org.junit.jupiter.api.BeforeEach; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | +import org.springframework.test.web.servlet.MockMvc; | ||
| 9 | +import org.springframework.test.web.servlet.setup.MockMvcBuilders; | ||
| 10 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 11 | +import org.springframework.web.bind.annotation.PostMapping; | ||
| 12 | +import org.springframework.web.bind.annotation.RequestBody; | ||
| 13 | +import org.springframework.web.bind.annotation.RestController; | ||
| 14 | + | ||
| 15 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 16 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | ||
| 17 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | ||
| 18 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | ||
| 19 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
| 20 | + | ||
| 21 | +class GlobalExceptionHandlerTest { | ||
| 22 | + | ||
| 23 | + private MockMvc mockMvc; | ||
| 24 | + | ||
| 25 | + @BeforeEach | ||
| 26 | + void setUp() { | ||
| 27 | + mockMvc = MockMvcBuilders.standaloneSetup(new StubController()) | ||
| 28 | + .setControllerAdvice(new GlobalExceptionHandler()) | ||
| 29 | + .build(); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + @Test | ||
| 33 | + void bizException_returnsResultWithBizCode() throws Exception { | ||
| 34 | + mockMvc.perform(get("/__test/throw-biz")) | ||
| 35 | + .andExpect(status().isOk()) | ||
| 36 | + .andExpect(jsonPath("$.code").value(30001)) | ||
| 37 | + .andExpect(jsonPath("$.msg").value("business x")); | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + @Test | ||
| 41 | + void validationException_returns40001WithFieldHint() throws Exception { | ||
| 42 | + mockMvc.perform(post("/__test/throw-validate") | ||
| 43 | + .contentType("application/json") | ||
| 44 | + .content("{}")) | ||
| 45 | + .andExpect(status().isOk()) | ||
| 46 | + .andExpect(jsonPath("$.code").value(40001)) | ||
| 47 | + .andExpect(jsonPath("$.msg", org.hamcrest.Matchers.containsString("name"))); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + @Test | ||
| 51 | + void uncaughtException_returns50000() throws Exception { | ||
| 52 | + mockMvc.perform(get("/__test/throw-runtime")) | ||
| 53 | + .andExpect(status().isOk()) | ||
| 54 | + .andExpect(jsonPath("$.code").value(50000)); | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + @Test | ||
| 58 | + void resultOkHelper_buildsZeroCode() { | ||
| 59 | + Result<String> r = Result.ok("hi"); | ||
| 60 | + assertThat(r.getCode()).isZero(); | ||
| 61 | + assertThat(r.getData()).isEqualTo("hi"); | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + @RestController | ||
| 65 | + static class StubController { | ||
| 66 | + @GetMapping("/__test/throw-biz") | ||
| 67 | + public Result<Void> throwBiz() { | ||
| 68 | + throw new BizException(30001, "business x"); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + @PostMapping("/__test/throw-validate") | ||
| 72 | + public Result<Void> throwValidate(@Valid @RequestBody StubBody body) { | ||
| 73 | + return Result.ok(); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @GetMapping("/__test/throw-runtime") | ||
| 77 | + public Result<Void> throwRuntime() { | ||
| 78 | + throw new IllegalStateException("boom"); | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + static class StubBody { | ||
| 83 | + @NotBlank | ||
| 84 | + public String name; | ||
| 85 | + } | ||
| 86 | +} |
backend/src/test/java/com/xly/erp/common/security/JwtAuthenticationFilterTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | ||
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 5 | +import jakarta.servlet.FilterChain; | ||
| 6 | +import org.junit.jupiter.api.AfterEach; | ||
| 7 | +import org.junit.jupiter.api.BeforeEach; | ||
| 8 | +import org.junit.jupiter.api.Test; | ||
| 9 | +import org.springframework.mock.web.MockHttpServletRequest; | ||
| 10 | +import org.springframework.mock.web.MockHttpServletResponse; | ||
| 11 | +import org.springframework.security.core.Authentication; | ||
| 12 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 13 | + | ||
| 14 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 15 | +import static org.mockito.Mockito.mock; | ||
| 16 | +import static org.mockito.Mockito.never; | ||
| 17 | +import static org.mockito.Mockito.times; | ||
| 18 | +import static org.mockito.Mockito.verify; | ||
| 19 | + | ||
| 20 | +class JwtAuthenticationFilterTest { | ||
| 21 | + | ||
| 22 | + private static final String SECRET = "f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"; | ||
| 23 | + | ||
| 24 | + private JwtUtil jwtUtil; | ||
| 25 | + private JwtAuthenticationFilter filter; | ||
| 26 | + | ||
| 27 | + @BeforeEach | ||
| 28 | + void setUp() { | ||
| 29 | + jwtUtil = new JwtUtil(SECRET); | ||
| 30 | + filter = new JwtAuthenticationFilter(jwtUtil, new ObjectMapper()); | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + @AfterEach | ||
| 34 | + void clearContext() { | ||
| 35 | + SecurityContextHolder.clearContext(); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + @Test | ||
| 39 | + void validJwt_setsPrincipalInSecurityContext_andCallsChain() throws Exception { | ||
| 40 | + String token = jwtUtil.sign("USER001"); | ||
| 41 | + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); | ||
| 42 | + req.addHeader("Authorization", "Bearer " + token); | ||
| 43 | + MockHttpServletResponse resp = new MockHttpServletResponse(); | ||
| 44 | + FilterChain chain = mock(FilterChain.class); | ||
| 45 | + | ||
| 46 | + filter.doFilter(req, resp, chain); | ||
| 47 | + | ||
| 48 | + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | ||
| 49 | + assertThat(auth).isNotNull(); | ||
| 50 | + assertThat(auth.getPrincipal()).isEqualTo("USER001"); | ||
| 51 | + verify(chain, times(1)).doFilter(req, resp); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + void tamperedJwt_writesJsonResultWith20001_andDoesNotCallChain() throws Exception { | ||
| 56 | + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); | ||
| 57 | + req.addHeader("Authorization", "Bearer not.a.real.jwt"); | ||
| 58 | + MockHttpServletResponse resp = new MockHttpServletResponse(); | ||
| 59 | + FilterChain chain = mock(FilterChain.class); | ||
| 60 | + | ||
| 61 | + filter.doFilter(req, resp, chain); | ||
| 62 | + | ||
| 63 | + verify(chain, never()).doFilter(req, resp); | ||
| 64 | + assertThat(resp.getContentType()).contains("application/json"); | ||
| 65 | + JsonNode body = new ObjectMapper().readTree(resp.getContentAsString()); | ||
| 66 | + assertThat(body.get("code").asInt()).isEqualTo(20001); | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + @Test | ||
| 70 | + void noJwtHeader_passesChainThrough_withoutSettingContext() throws Exception { | ||
| 71 | + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); | ||
| 72 | + MockHttpServletResponse resp = new MockHttpServletResponse(); | ||
| 73 | + FilterChain chain = mock(FilterChain.class); | ||
| 74 | + | ||
| 75 | + filter.doFilter(req, resp, chain); | ||
| 76 | + | ||
| 77 | + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); | ||
| 78 | + verify(chain, times(1)).doFilter(req, resp); | ||
| 79 | + } | ||
| 80 | +} |
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.exception.BizException; | ||
| 4 | +import org.junit.jupiter.api.Test; | ||
| 5 | + | ||
| 6 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 7 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| 8 | + | ||
| 9 | +class JwtUtilTest { | ||
| 10 | + | ||
| 11 | + private static final String SECRET = "f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"; | ||
| 12 | + | ||
| 13 | + private final JwtUtil jwtUtil = new JwtUtil(SECRET); | ||
| 14 | + | ||
| 15 | + @Test | ||
| 16 | + void signAndParse_roundTrip() { | ||
| 17 | + String token = jwtUtil.sign("ALICE001"); | ||
| 18 | + assertThat(token).isNotBlank(); | ||
| 19 | + assertThat(jwtUtil.parse(token)).isEqualTo("ALICE001"); | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + @Test | ||
| 23 | + void parseTamperedToken_throwsBizException20001() { | ||
| 24 | + String token = jwtUtil.sign("ALICE001"); | ||
| 25 | + String tampered = token.substring(0, token.length() - 4) + "XXXX"; | ||
| 26 | + assertThatThrownBy(() -> jwtUtil.parse(tampered)) | ||
| 27 | + .isInstanceOf(BizException.class) | ||
| 28 | + .hasFieldOrPropertyWithValue("code", 20001); | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + @Test | ||
| 32 | + void parseGarbageToken_throwsBizException20001() { | ||
| 33 | + assertThatThrownBy(() -> jwtUtil.parse("not.a.real.jwt")) | ||
| 34 | + .isInstanceOf(BizException.class) | ||
| 35 | + .hasFieldOrPropertyWithValue("code", 20001); | ||
| 36 | + } | ||
| 37 | +} |
backend/src/test/java/com/xly/erp/common/security/TestJwtHelper.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 4 | +import org.springframework.stereotype.Component; | ||
| 5 | + | ||
| 6 | +@Component | ||
| 7 | +public class TestJwtHelper { | ||
| 8 | + | ||
| 9 | + private final JwtUtil jwtUtil; | ||
| 10 | + | ||
| 11 | + @Autowired | ||
| 12 | + public TestJwtHelper(JwtUtil jwtUtil) { | ||
| 13 | + this.jwtUtil = jwtUtil; | ||
| 14 | + } | ||
| 15 | + | ||
| 16 | + public String signFor(String userNo) { | ||
| 17 | + return jwtUtil.sign(userNo); | ||
| 18 | + } | ||
| 19 | +} |
backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.controller; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | ||
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 5 | +import com.xly.erp.common.security.TestJwtHelper; | ||
| 6 | +import org.junit.jupiter.api.AfterEach; | ||
| 7 | +import org.junit.jupiter.api.BeforeEach; | ||
| 8 | +import org.junit.jupiter.api.Test; | ||
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 10 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 11 | +import org.springframework.boot.test.web.client.TestRestTemplate; | ||
| 12 | +import org.springframework.boot.test.web.server.LocalServerPort; | ||
| 13 | +import org.springframework.http.HttpEntity; | ||
| 14 | +import org.springframework.http.HttpHeaders; | ||
| 15 | +import org.springframework.http.HttpMethod; | ||
| 16 | +import org.springframework.http.MediaType; | ||
| 17 | +import org.springframework.http.ResponseEntity; | ||
| 18 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 19 | +import org.springframework.test.context.ActiveProfiles; | ||
| 20 | + | ||
| 21 | +import java.util.HashMap; | ||
| 22 | +import java.util.Map; | ||
| 23 | + | ||
| 24 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 25 | + | ||
| 26 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) | ||
| 27 | +@ActiveProfiles("test") | ||
| 28 | +class ModuleControllerIT { | ||
| 29 | + | ||
| 30 | + @Autowired | ||
| 31 | + private TestRestTemplate rest; | ||
| 32 | + | ||
| 33 | + @Autowired | ||
| 34 | + private TestJwtHelper testJwtHelper; | ||
| 35 | + | ||
| 36 | + @Autowired | ||
| 37 | + private JdbcTemplate jdbcTemplate; | ||
| 38 | + | ||
| 39 | + @Autowired | ||
| 40 | + private ObjectMapper objectMapper; | ||
| 41 | + | ||
| 42 | + @LocalServerPort | ||
| 43 | + private int port; | ||
| 44 | + | ||
| 45 | + @BeforeEach | ||
| 46 | + @AfterEach | ||
| 47 | + void cleanup() { | ||
| 48 | + jdbcTemplate.update("DELETE FROM tModule WHERE sProcedureName LIKE 'sp_test_%'"); | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + @Test | ||
| 52 | + void postValidBody_with_jwt_returns200_andPersists() throws Exception { | ||
| 53 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 54 | + HttpHeaders headers = jsonHeaders(); | ||
| 55 | + headers.set("Authorization", "Bearer " + token); | ||
| 56 | + | ||
| 57 | + ResponseEntity<String> resp = rest.exchange( | ||
| 58 | + "http://localhost:" + port + "/api/mod/modules", | ||
| 59 | + HttpMethod.POST, | ||
| 60 | + new HttpEntity<>(validBody("sp_test_ctrl_ok", "正常路径"), headers), | ||
| 61 | + String.class); | ||
| 62 | + | ||
| 63 | + assertThat(resp.getStatusCode().value()).isEqualTo(200); | ||
| 64 | + JsonNode body = objectMapper.readTree(resp.getBody()); | ||
| 65 | + assertThat(body.get("code").asInt()).isZero(); | ||
| 66 | + int newId = body.get("data").get("iIncrement").asInt(); | ||
| 67 | + assertThat(newId).isPositive(); | ||
| 68 | + | ||
| 69 | + Map<String, Object> row = jdbcTemplate.queryForMap( | ||
| 70 | + "SELECT sProcedureName, sBrandsId, sCreatedBy FROM tModule WHERE iIncrement = ?", newId); | ||
| 71 | + assertThat(row.get("sProcedureName")).isEqualTo("sp_test_ctrl_ok"); | ||
| 72 | + assertThat(row.get("sBrandsId")).isEqualTo("XLY"); | ||
| 73 | + assertThat(row.get("sCreatedBy")).isEqualTo("ADMIN001"); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @Test | ||
| 77 | + void postEmptyBody_returns40001_withFieldHint() throws Exception { | ||
| 78 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 79 | + HttpHeaders headers = jsonHeaders(); | ||
| 80 | + headers.set("Authorization", "Bearer " + token); | ||
| 81 | + | ||
| 82 | + ResponseEntity<String> resp = rest.exchange( | ||
| 83 | + url(), | ||
| 84 | + HttpMethod.POST, | ||
| 85 | + new HttpEntity<>("{}", headers), | ||
| 86 | + String.class); | ||
| 87 | + | ||
| 88 | + JsonNode body = objectMapper.readTree(resp.getBody()); | ||
| 89 | + assertThat(body.get("code").asInt()).isEqualTo(40001); | ||
| 90 | + assertThat(body.get("msg").asText()).containsAnyOf( | ||
| 91 | + "sProcedureName", "sDisplayType", "sModuleType", "sManageDeptEn", "sModuleNameZh"); | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + @Test | ||
| 95 | + void postInvalidDisplayType_returns40010() throws Exception { | ||
| 96 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 97 | + HttpHeaders headers = jsonHeaders(); | ||
| 98 | + headers.set("Authorization", "Bearer " + token); | ||
| 99 | + Map<String, Object> bad = validBody("sp_test_bad_type", "枚举非法"); | ||
| 100 | + bad.put("sDisplayType", "火星"); | ||
| 101 | + | ||
| 102 | + ResponseEntity<String> resp = rest.exchange( | ||
| 103 | + url(), HttpMethod.POST, new HttpEntity<>(bad, headers), String.class); | ||
| 104 | + | ||
| 105 | + JsonNode body = objectMapper.readTree(resp.getBody()); | ||
| 106 | + assertThat(body.get("code").asInt()).isEqualTo(40010); | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + @Test | ||
| 110 | + void postDuplicateProcedureName_returns40020() throws Exception { | ||
| 111 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 112 | + HttpHeaders headers = jsonHeaders(); | ||
| 113 | + headers.set("Authorization", "Bearer " + token); | ||
| 114 | + Map<String, Object> first = validBody("sp_test_dup", "首次"); | ||
| 115 | + ResponseEntity<String> r1 = rest.exchange( | ||
| 116 | + url(), HttpMethod.POST, new HttpEntity<>(first, headers), String.class); | ||
| 117 | + assertThat(objectMapper.readTree(r1.getBody()).get("code").asInt()).isZero(); | ||
| 118 | + | ||
| 119 | + Map<String, Object> dup = validBody("sp_test_dup", "重复"); | ||
| 120 | + ResponseEntity<String> r2 = rest.exchange( | ||
| 121 | + url(), HttpMethod.POST, new HttpEntity<>(dup, headers), String.class); | ||
| 122 | + JsonNode body = objectMapper.readTree(r2.getBody()); | ||
| 123 | + assertThat(body.get("code").asInt()).isEqualTo(40020); | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + @Test | ||
| 127 | + void postWithMissingParent_returns40021() throws Exception { | ||
| 128 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 129 | + HttpHeaders headers = jsonHeaders(); | ||
| 130 | + headers.set("Authorization", "Bearer " + token); | ||
| 131 | + Map<String, Object> orphan = validBody("sp_test_orphan", "缺父"); | ||
| 132 | + orphan.put("iParentId", 99999999); | ||
| 133 | + | ||
| 134 | + ResponseEntity<String> resp = rest.exchange( | ||
| 135 | + url(), HttpMethod.POST, new HttpEntity<>(orphan, headers), String.class); | ||
| 136 | + | ||
| 137 | + JsonNode body = objectMapper.readTree(resp.getBody()); | ||
| 138 | + assertThat(body.get("code").asInt()).isEqualTo(40021); | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + @Test | ||
| 142 | + void postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN() throws Exception { | ||
| 143 | + HttpHeaders headers = jsonHeaders(); | ||
| 144 | + Map<String, Object> body = validBody("sp_test_nojwt", "无JWT"); | ||
| 145 | + | ||
| 146 | + ResponseEntity<String> resp = rest.exchange( | ||
| 147 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | ||
| 148 | + | ||
| 149 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | ||
| 150 | + assertThat(jb.get("code").asInt()).isZero(); | ||
| 151 | + int newId = jb.get("data").get("iIncrement").asInt(); | ||
| 152 | + String createdBy = jdbcTemplate.queryForObject( | ||
| 153 | + "SELECT sCreatedBy FROM tModule WHERE iIncrement = ?", String.class, newId); | ||
| 154 | + assertThat(createdBy).isEqualTo("STUB_ADMIN"); | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + @Test | ||
| 158 | + void postWithTamperedJwt_returns20001() throws Exception { | ||
| 159 | + HttpHeaders headers = jsonHeaders(); | ||
| 160 | + headers.set("Authorization", "Bearer not.a.real.jwt"); | ||
| 161 | + Map<String, Object> body = validBody("sp_test_tampered", "伪JWT"); | ||
| 162 | + | ||
| 163 | + ResponseEntity<String> resp = rest.exchange( | ||
| 164 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | ||
| 165 | + | ||
| 166 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | ||
| 167 | + assertThat(jb.get("code").asInt()).isEqualTo(20001); | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + @Test | ||
| 171 | + void putValidBody_with_jwt_returns200_andUpdatesEditableFields() throws Exception { | ||
| 172 | + Integer id = insertOriginal("sp_test_put_orig", "原名", "ORIG_USER"); | ||
| 173 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 174 | + HttpHeaders headers = jsonHeaders(); | ||
| 175 | + headers.set("Authorization", "Bearer " + token); | ||
| 176 | + Map<String, Object> body = updateBody(); | ||
| 177 | + body.put("sModuleNameZh", "新名"); | ||
| 178 | + body.put("sDisplayType", "前端业务"); | ||
| 179 | + | ||
| 180 | + ResponseEntity<String> resp = rest.exchange( | ||
| 181 | + idUrl(id), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | ||
| 182 | + | ||
| 183 | + assertThat(resp.getStatusCode().value()).isEqualTo(200); | ||
| 184 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | ||
| 185 | + assertThat(jb.get("code").asInt()).isZero(); | ||
| 186 | + assertThat(jb.get("data").get("iIncrement").asInt()).isEqualTo(id); | ||
| 187 | + | ||
| 188 | + Map<String, Object> row = jdbcTemplate.queryForMap( | ||
| 189 | + "SELECT sModuleNameZh, sDisplayType, sProcedureName, sCreatedBy FROM tModule WHERE iIncrement = ?", id); | ||
| 190 | + assertThat(row.get("sModuleNameZh")).isEqualTo("新名"); | ||
| 191 | + assertThat(row.get("sDisplayType")).isEqualTo("前端业务"); | ||
| 192 | + assertThat(row.get("sProcedureName")).isEqualTo("sp_test_put_orig"); | ||
| 193 | + assertThat(row.get("sCreatedBy")).isEqualTo("ORIG_USER"); | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + @Test | ||
| 197 | + void putNonExistentId_returns40400() throws Exception { | ||
| 198 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 199 | + HttpHeaders headers = jsonHeaders(); | ||
| 200 | + headers.set("Authorization", "Bearer " + token); | ||
| 201 | + | ||
| 202 | + ResponseEntity<String> resp = rest.exchange( | ||
| 203 | + idUrl(99999998), HttpMethod.PUT, new HttpEntity<>(updateBody(), headers), String.class); | ||
| 204 | + | ||
| 205 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | ||
| 206 | + assertThat(jb.get("code").asInt()).isEqualTo(40400); | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + @Test | ||
| 210 | + void putInvalidDisplayType_returns40010() throws Exception { | ||
| 211 | + Integer id = insertOriginal("sp_test_put_invalidtype", "原", "ORIG"); | ||
| 212 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 213 | + HttpHeaders headers = jsonHeaders(); | ||
| 214 | + headers.set("Authorization", "Bearer " + token); | ||
| 215 | + Map<String, Object> body = updateBody(); | ||
| 216 | + body.put("sDisplayType", "火星"); | ||
| 217 | + | ||
| 218 | + ResponseEntity<String> resp = rest.exchange( | ||
| 219 | + idUrl(id), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | ||
| 220 | + | ||
| 221 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40010); | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + @Test | ||
| 225 | + void putSelfParent_returns40021() throws Exception { | ||
| 226 | + Integer id = insertOriginal("sp_test_put_self", "原", "ORIG"); | ||
| 227 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 228 | + HttpHeaders headers = jsonHeaders(); | ||
| 229 | + headers.set("Authorization", "Bearer " + token); | ||
| 230 | + Map<String, Object> body = updateBody(); | ||
| 231 | + body.put("iParentId", id); | ||
| 232 | + | ||
| 233 | + ResponseEntity<String> resp = rest.exchange( | ||
| 234 | + idUrl(id), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | ||
| 235 | + | ||
| 236 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40021); | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + @Test | ||
| 240 | + void putCyclicParent_returns40021() throws Exception { | ||
| 241 | + Integer rootId = insertOriginal("sp_test_put_root", "根", "ORIG"); | ||
| 242 | + jdbcTemplate.update( | ||
| 243 | + "INSERT INTO tModule (sBrandsId, sSubsidiaryId, tCreateDate, sDisplayType, sProcedureName, " | ||
| 244 | + + "sModuleType, sManageDeptEn, bShowPermission, sModuleNameZh, iParentId, iSortOrder, sCreatedBy, bDeleted) " | ||
| 245 | + + "VALUES ('XLY','XLY', NOW(), '手机端', 'sp_test_put_child', '业务模块', 'IT', 0, '子', ?, 0, 'ORIG', 0)", | ||
| 246 | + rootId); | ||
| 247 | + Integer childId = jdbcTemplate.queryForObject( | ||
| 248 | + "SELECT iIncrement FROM tModule WHERE sProcedureName = 'sp_test_put_child'", Integer.class); | ||
| 249 | + | ||
| 250 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 251 | + HttpHeaders headers = jsonHeaders(); | ||
| 252 | + headers.set("Authorization", "Bearer " + token); | ||
| 253 | + Map<String, Object> body = updateBody(); | ||
| 254 | + body.put("iParentId", childId); | ||
| 255 | + | ||
| 256 | + ResponseEntity<String> resp = rest.exchange( | ||
| 257 | + idUrl(rootId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | ||
| 258 | + | ||
| 259 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40021); | ||
| 260 | + } | ||
| 261 | + | ||
| 262 | + @Test | ||
| 263 | + void putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy() throws Exception { | ||
| 264 | + Integer id = insertOriginal("sp_test_put_nojwt", "原", "ORIG_USER"); | ||
| 265 | + HttpHeaders headers = jsonHeaders(); | ||
| 266 | + Map<String, Object> body = updateBody(); | ||
| 267 | + body.put("sModuleNameZh", "更新无jwt"); | ||
| 268 | + | ||
| 269 | + ResponseEntity<String> resp = rest.exchange( | ||
| 270 | + idUrl(id), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | ||
| 271 | + | ||
| 272 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); | ||
| 273 | + String createdBy = jdbcTemplate.queryForObject( | ||
| 274 | + "SELECT sCreatedBy FROM tModule WHERE iIncrement = ?", String.class, id); | ||
| 275 | + assertThat(createdBy).isEqualTo("ORIG_USER"); | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + @Test | ||
| 279 | + void putTamperedJwt_returns20001() throws Exception { | ||
| 280 | + Integer id = insertOriginal("sp_test_put_tamper", "原", "ORIG"); | ||
| 281 | + HttpHeaders headers = jsonHeaders(); | ||
| 282 | + headers.set("Authorization", "Bearer not.a.real.jwt"); | ||
| 283 | + | ||
| 284 | + ResponseEntity<String> resp = rest.exchange( | ||
| 285 | + idUrl(id), HttpMethod.PUT, new HttpEntity<>(updateBody(), headers), String.class); | ||
| 286 | + | ||
| 287 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + @Test | ||
| 291 | + void deleteValidId_with_jwt_returns200_andSoftDeletes() throws Exception { | ||
| 292 | + Integer id = insertOriginal("sp_test_del_ok", "原", "ORIG_USER"); | ||
| 293 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 294 | + HttpHeaders headers = jsonHeaders(); | ||
| 295 | + headers.set("Authorization", "Bearer " + token); | ||
| 296 | + | ||
| 297 | + ResponseEntity<String> resp = rest.exchange( | ||
| 298 | + idUrl(id), HttpMethod.DELETE, new HttpEntity<>(headers), String.class); | ||
| 299 | + | ||
| 300 | + assertThat(resp.getStatusCode().value()).isEqualTo(200); | ||
| 301 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | ||
| 302 | + assertThat(jb.get("code").asInt()).isZero(); | ||
| 303 | + assertThat(jb.get("data").isNull()).isTrue(); | ||
| 304 | + | ||
| 305 | + Map<String, Object> row = jdbcTemplate.queryForMap( | ||
| 306 | + "SELECT bDeleted, sDeletedBy, tDeletedDate, sProcedureName, sCreatedBy FROM tModule WHERE iIncrement = ?", id); | ||
| 307 | + assertThat(row.get("bDeleted")).isEqualTo(true); | ||
| 308 | + assertThat(row.get("sDeletedBy")).isEqualTo("ADMIN001"); | ||
| 309 | + assertThat(row.get("tDeletedDate")).isNotNull(); | ||
| 310 | + assertThat(row.get("sProcedureName")).isEqualTo("sp_test_del_ok"); | ||
| 311 | + assertThat(row.get("sCreatedBy")).isEqualTo("ORIG_USER"); | ||
| 312 | + } | ||
| 313 | + | ||
| 314 | + @Test | ||
| 315 | + void deleteNonExistentId_returns40400() throws Exception { | ||
| 316 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 317 | + HttpHeaders headers = jsonHeaders(); | ||
| 318 | + headers.set("Authorization", "Bearer " + token); | ||
| 319 | + | ||
| 320 | + ResponseEntity<String> resp = rest.exchange( | ||
| 321 | + idUrl(99999996), HttpMethod.DELETE, new HttpEntity<>(headers), String.class); | ||
| 322 | + | ||
| 323 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40400); | ||
| 324 | + } | ||
| 325 | + | ||
| 326 | + @Test | ||
| 327 | + void deleteAlreadyDeletedId_returns40400() throws Exception { | ||
| 328 | + Integer id = insertOriginal("sp_test_del_already", "已删", "ORIG"); | ||
| 329 | + jdbcTemplate.update("UPDATE tModule SET bDeleted = 1 WHERE iIncrement = ?", id); | ||
| 330 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 331 | + HttpHeaders headers = jsonHeaders(); | ||
| 332 | + headers.set("Authorization", "Bearer " + token); | ||
| 333 | + | ||
| 334 | + ResponseEntity<String> resp = rest.exchange( | ||
| 335 | + idUrl(id), HttpMethod.DELETE, new HttpEntity<>(headers), String.class); | ||
| 336 | + | ||
| 337 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40400); | ||
| 338 | + } | ||
| 339 | + | ||
| 340 | + @Test | ||
| 341 | + void deleteWithActiveChildren_returns40901() throws Exception { | ||
| 342 | + Integer rootId = insertOriginal("sp_test_del_root", "根", "ORIG"); | ||
| 343 | + jdbcTemplate.update( | ||
| 344 | + "INSERT INTO tModule (sBrandsId, sSubsidiaryId, tCreateDate, sDisplayType, sProcedureName, " | ||
| 345 | + + "sModuleType, sManageDeptEn, bShowPermission, sModuleNameZh, iParentId, iSortOrder, sCreatedBy, bDeleted) " | ||
| 346 | + + "VALUES ('XLY','XLY', NOW(), '手机端', 'sp_test_del_child', '业务模块', 'IT', 0, '子', ?, 0, 'ORIG', 0)", | ||
| 347 | + rootId); | ||
| 348 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 349 | + HttpHeaders headers = jsonHeaders(); | ||
| 350 | + headers.set("Authorization", "Bearer " + token); | ||
| 351 | + | ||
| 352 | + ResponseEntity<String> resp = rest.exchange( | ||
| 353 | + idUrl(rootId), HttpMethod.DELETE, new HttpEntity<>(headers), String.class); | ||
| 354 | + | ||
| 355 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40901); | ||
| 356 | + Boolean stillAlive = jdbcTemplate.queryForObject( | ||
| 357 | + "SELECT bDeleted FROM tModule WHERE iIncrement = ?", Boolean.class, rootId); | ||
| 358 | + assertThat(stillAlive).isFalse(); | ||
| 359 | + } | ||
| 360 | + | ||
| 361 | + @Test | ||
| 362 | + void deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB() throws Exception { | ||
| 363 | + Integer id = insertOriginal("sp_test_del_nojwt", "原", "ORIG"); | ||
| 364 | + HttpHeaders headers = jsonHeaders(); | ||
| 365 | + | ||
| 366 | + ResponseEntity<String> resp = rest.exchange( | ||
| 367 | + idUrl(id), HttpMethod.DELETE, new HttpEntity<>(headers), String.class); | ||
| 368 | + | ||
| 369 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); | ||
| 370 | + Map<String, Object> row = jdbcTemplate.queryForMap( | ||
| 371 | + "SELECT bDeleted, sDeletedBy FROM tModule WHERE iIncrement = ?", id); | ||
| 372 | + assertThat(row.get("bDeleted")).isEqualTo(true); | ||
| 373 | + assertThat(row.get("sDeletedBy")).isEqualTo("STUB_ADMIN"); | ||
| 374 | + } | ||
| 375 | + | ||
| 376 | + @Test | ||
| 377 | + void deleteTamperedJwt_returns20001() throws Exception { | ||
| 378 | + Integer id = insertOriginal("sp_test_del_tamper", "原", "ORIG"); | ||
| 379 | + HttpHeaders headers = jsonHeaders(); | ||
| 380 | + headers.set("Authorization", "Bearer not.a.real.jwt"); | ||
| 381 | + | ||
| 382 | + ResponseEntity<String> resp = rest.exchange( | ||
| 383 | + idUrl(id), HttpMethod.DELETE, new HttpEntity<>(headers), String.class); | ||
| 384 | + | ||
| 385 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | ||
| 386 | + Boolean stillAlive = jdbcTemplate.queryForObject( | ||
| 387 | + "SELECT bDeleted FROM tModule WHERE iIncrement = ?", Boolean.class, id); | ||
| 388 | + assertThat(stillAlive).isFalse(); | ||
| 389 | + } | ||
| 390 | + | ||
| 391 | + @Test | ||
| 392 | + void getEmptyKeyword_returnsCompleteTreeAsForest() throws Exception { | ||
| 393 | + Integer rootId = insertOriginal("sp_test_get_root", "查询用根", "ORIG"); | ||
| 394 | + jdbcTemplate.update( | ||
| 395 | + "INSERT INTO tModule (sBrandsId, sSubsidiaryId, tCreateDate, sDisplayType, sProcedureName, " | ||
| 396 | + + "sModuleType, sManageDeptEn, bShowPermission, sModuleNameZh, iParentId, iSortOrder, sCreatedBy, bDeleted) " | ||
| 397 | + + "VALUES ('XLY','XLY', NOW(), '手机端', 'sp_test_get_child', '业务模块', 'IT', 0, '查询用子', ?, 0, 'ORIG', 0)", | ||
| 398 | + rootId); | ||
| 399 | + Integer childId = jdbcTemplate.queryForObject( | ||
| 400 | + "SELECT iIncrement FROM tModule WHERE sProcedureName = 'sp_test_get_child'", Integer.class); | ||
| 401 | + | ||
| 402 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 403 | + HttpHeaders headers = jsonHeaders(); | ||
| 404 | + headers.set("Authorization", "Bearer " + token); | ||
| 405 | + ResponseEntity<String> resp = rest.exchange( | ||
| 406 | + listUrl(null), HttpMethod.GET, new HttpEntity<>(headers), String.class); | ||
| 407 | + | ||
| 408 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | ||
| 409 | + assertThat(jb.get("code").asInt()).isZero(); | ||
| 410 | + JsonNode data = jb.get("data"); | ||
| 411 | + assertThat(data.isArray()).isTrue(); | ||
| 412 | + JsonNode rootNode = findById(data, rootId); | ||
| 413 | + assertThat(rootNode).isNotNull(); | ||
| 414 | + JsonNode childNode = findById(rootNode.get("children"), childId); | ||
| 415 | + assertThat(childNode).isNotNull(); | ||
| 416 | + assertThat(childNode.get("sModuleNameZh").asText()).isEqualTo("查询用子"); | ||
| 417 | + } | ||
| 418 | + | ||
| 419 | + @Test | ||
| 420 | + void getKeywordMatch_returnsForest() throws Exception { | ||
| 421 | + insertOriginal("sp_test_get_kw_a", "系统模块A", "ORIG"); | ||
| 422 | + insertOriginal("sp_test_get_kw_b", "用户模块B", "ORIG"); | ||
| 423 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 424 | + HttpHeaders headers = jsonHeaders(); | ||
| 425 | + headers.set("Authorization", "Bearer " + token); | ||
| 426 | + | ||
| 427 | + ResponseEntity<String> resp = rest.exchange( | ||
| 428 | + listUrl("系统"), HttpMethod.GET, new HttpEntity<>(headers), String.class); | ||
| 429 | + | ||
| 430 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | ||
| 431 | + assertThat(jb.get("code").asInt()).isZero(); | ||
| 432 | + JsonNode data = jb.get("data"); | ||
| 433 | + assertThat(data.isArray()).isTrue(); | ||
| 434 | + for (JsonNode node : data) { | ||
| 435 | + assertThat(node.get("sModuleNameZh").asText()).contains("系统"); | ||
| 436 | + } | ||
| 437 | + } | ||
| 438 | + | ||
| 439 | + @Test | ||
| 440 | + void getKeywordTooLong_returns40001() throws Exception { | ||
| 441 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 442 | + HttpHeaders headers = jsonHeaders(); | ||
| 443 | + headers.set("Authorization", "Bearer " + token); | ||
| 444 | + | ||
| 445 | + ResponseEntity<String> resp = rest.exchange( | ||
| 446 | + listUrl("x".repeat(101)), HttpMethod.GET, new HttpEntity<>(headers), String.class); | ||
| 447 | + | ||
| 448 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); | ||
| 449 | + } | ||
| 450 | + | ||
| 451 | + @Test | ||
| 452 | + void getNoMatch_returnsEmptyArray() throws Exception { | ||
| 453 | + String token = testJwtHelper.signFor("ADMIN001"); | ||
| 454 | + HttpHeaders headers = jsonHeaders(); | ||
| 455 | + headers.set("Authorization", "Bearer " + token); | ||
| 456 | + | ||
| 457 | + ResponseEntity<String> resp = rest.exchange( | ||
| 458 | + listUrl("不存在的关键字XYZ-zzz"), HttpMethod.GET, new HttpEntity<>(headers), String.class); | ||
| 459 | + | ||
| 460 | + JsonNode data = objectMapper.readTree(resp.getBody()).get("data"); | ||
| 461 | + assertThat(data.isArray()).isTrue(); | ||
| 462 | + assertThat(data.size()).isZero(); | ||
| 463 | + } | ||
| 464 | + | ||
| 465 | + @Test | ||
| 466 | + void getWithoutJwt_permitAllStub_returns200() throws Exception { | ||
| 467 | + HttpHeaders headers = jsonHeaders(); | ||
| 468 | + ResponseEntity<String> resp = rest.exchange( | ||
| 469 | + listUrl(null), HttpMethod.GET, new HttpEntity<>(headers), String.class); | ||
| 470 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); | ||
| 471 | + } | ||
| 472 | + | ||
| 473 | + @Test | ||
| 474 | + void getTamperedJwt_returns20001() throws Exception { | ||
| 475 | + HttpHeaders headers = jsonHeaders(); | ||
| 476 | + headers.set("Authorization", "Bearer not.a.real.jwt"); | ||
| 477 | + ResponseEntity<String> resp = rest.exchange( | ||
| 478 | + listUrl(null), HttpMethod.GET, new HttpEntity<>(headers), String.class); | ||
| 479 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | ||
| 480 | + } | ||
| 481 | + | ||
| 482 | + private String listUrl(String keyword) { | ||
| 483 | + String base = "http://localhost:" + port + "/api/mod/modules"; | ||
| 484 | + return keyword == null ? base : base + "?keyword=" + java.net.URLEncoder.encode(keyword, java.nio.charset.StandardCharsets.UTF_8); | ||
| 485 | + } | ||
| 486 | + | ||
| 487 | + private static JsonNode findById(JsonNode array, Integer id) { | ||
| 488 | + if (array == null || !array.isArray()) return null; | ||
| 489 | + for (JsonNode n : array) { | ||
| 490 | + if (n.get("iIncrement").asInt() == id) return n; | ||
| 491 | + } | ||
| 492 | + return null; | ||
| 493 | + } | ||
| 494 | + | ||
| 495 | + private Integer insertOriginal(String procedureName, String nameZh, String createdBy) { | ||
| 496 | + jdbcTemplate.update( | ||
| 497 | + "INSERT INTO tModule (sBrandsId, sSubsidiaryId, tCreateDate, sDisplayType, sProcedureName, " | ||
| 498 | + + "sModuleType, sManageDeptEn, bShowPermission, sModuleNameZh, iSortOrder, sCreatedBy, bDeleted) " | ||
| 499 | + + "VALUES ('XLY','XLY', NOW(), '手机端', ?, '业务模块', 'IT', 0, ?, 0, ?, 0)", | ||
| 500 | + procedureName, nameZh, createdBy); | ||
| 501 | + return jdbcTemplate.queryForObject( | ||
| 502 | + "SELECT iIncrement FROM tModule WHERE sProcedureName = ?", Integer.class, procedureName); | ||
| 503 | + } | ||
| 504 | + | ||
| 505 | + private static Map<String, Object> updateBody() { | ||
| 506 | + Map<String, Object> m = new HashMap<>(); | ||
| 507 | + m.put("sDisplayType", "手机端"); | ||
| 508 | + m.put("sModuleType", "业务模块"); | ||
| 509 | + m.put("sManageDeptEn", "IT"); | ||
| 510 | + m.put("bShowPermission", false); | ||
| 511 | + m.put("sModuleNameZh", "更新后"); | ||
| 512 | + return m; | ||
| 513 | + } | ||
| 514 | + | ||
| 515 | + private String idUrl(Integer id) { | ||
| 516 | + return "http://localhost:" + port + "/api/mod/modules/" + id; | ||
| 517 | + } | ||
| 518 | + | ||
| 519 | + private String url() { | ||
| 520 | + return "http://localhost:" + port + "/api/mod/modules"; | ||
| 521 | + } | ||
| 522 | + | ||
| 523 | + static HttpHeaders jsonHeaders() { | ||
| 524 | + HttpHeaders h = new HttpHeaders(); | ||
| 525 | + h.setContentType(MediaType.APPLICATION_JSON); | ||
| 526 | + return h; | ||
| 527 | + } | ||
| 528 | + | ||
| 529 | + static Map<String, Object> validBody(String procedureName, String nameZh) { | ||
| 530 | + Map<String, Object> m = new HashMap<>(); | ||
| 531 | + m.put("sDisplayType", "手机端"); | ||
| 532 | + m.put("sProcedureName", procedureName); | ||
| 533 | + m.put("sModuleType", "业务模块"); | ||
| 534 | + m.put("sManageDeptEn", "IT"); | ||
| 535 | + m.put("bShowPermission", false); | ||
| 536 | + m.put("sModuleNameZh", nameZh); | ||
| 537 | + return m; | ||
| 538 | + } | ||
| 539 | +} |
backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.mapper; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.module.mod.entity.Module; | ||
| 4 | +import org.junit.jupiter.api.AfterEach; | ||
| 5 | +import org.junit.jupiter.api.BeforeEach; | ||
| 6 | +import org.junit.jupiter.api.Test; | ||
| 7 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 8 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 9 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 10 | +import org.springframework.test.context.ActiveProfiles; | ||
| 11 | + | ||
| 12 | +import java.time.LocalDateTime; | ||
| 13 | + | ||
| 14 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 15 | + | ||
| 16 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) | ||
| 17 | +@ActiveProfiles("test") | ||
| 18 | +class ModuleMapperIT { | ||
| 19 | + | ||
| 20 | + @Autowired | ||
| 21 | + private ModuleMapper moduleMapper; | ||
| 22 | + | ||
| 23 | + @Autowired | ||
| 24 | + private JdbcTemplate jdbcTemplate; | ||
| 25 | + | ||
| 26 | + @BeforeEach | ||
| 27 | + @AfterEach | ||
| 28 | + void cleanup() { | ||
| 29 | + jdbcTemplate.update("DELETE FROM tModule WHERE sProcedureName LIKE 'sp_test_%'"); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + @Test | ||
| 33 | + void insertAndSelectById_persistsAllStandardCols() { | ||
| 34 | + Module m = newModule("sp_test_insert", "插入用例", null); | ||
| 35 | + int rows = moduleMapper.insert(m); | ||
| 36 | + assertThat(rows).isEqualTo(1); | ||
| 37 | + assertThat(m.getIIncrement()).isNotNull(); | ||
| 38 | + | ||
| 39 | + Module loaded = moduleMapper.selectById(m.getIIncrement()); | ||
| 40 | + assertThat(loaded.getSProcedureName()).isEqualTo("sp_test_insert"); | ||
| 41 | + assertThat(loaded.getSBrandsId()).isEqualTo("XLY"); | ||
| 42 | + assertThat(loaded.getSSubsidiaryId()).isEqualTo("XLY"); | ||
| 43 | + assertThat(loaded.getSDisplayType()).isEqualTo("手机端"); | ||
| 44 | + assertThat(loaded.getSModuleNameZh()).isEqualTo("插入用例"); | ||
| 45 | + assertThat(loaded.getBDeleted()).isFalse(); | ||
| 46 | + assertThat(loaded.getBShowPermission()).isFalse(); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + @Test | ||
| 50 | + void existsActiveById_trueForAlive_falseForDeleted() { | ||
| 51 | + Module alive = newModule("sp_test_alive", "活的", null); | ||
| 52 | + moduleMapper.insert(alive); | ||
| 53 | + | ||
| 54 | + Module dead = newModule("sp_test_dead", "死的", null); | ||
| 55 | + dead.setBDeleted(true); | ||
| 56 | + moduleMapper.insert(dead); | ||
| 57 | + | ||
| 58 | + assertThat(moduleMapper.existsActiveById(alive.getIIncrement())).isTrue(); | ||
| 59 | + assertThat(moduleMapper.existsActiveById(dead.getIIncrement())).isFalse(); | ||
| 60 | + assertThat(moduleMapper.existsActiveById(99999999)).isFalse(); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + @Test | ||
| 64 | + void selectParentIdById_returnsNullForRootOrMissing_andValueForChild() { | ||
| 65 | + Module root = newModule("sp_test_root", "根", null); | ||
| 66 | + moduleMapper.insert(root); | ||
| 67 | + | ||
| 68 | + Module child = newModule("sp_test_child", "子", root.getIIncrement()); | ||
| 69 | + moduleMapper.insert(child); | ||
| 70 | + | ||
| 71 | + Module deleted = newModule("sp_test_del", "删", root.getIIncrement()); | ||
| 72 | + deleted.setBDeleted(true); | ||
| 73 | + moduleMapper.insert(deleted); | ||
| 74 | + | ||
| 75 | + assertThat(moduleMapper.selectParentIdById(root.getIIncrement())).isNull(); | ||
| 76 | + assertThat(moduleMapper.selectParentIdById(child.getIIncrement())).isEqualTo(root.getIIncrement()); | ||
| 77 | + assertThat(moduleMapper.selectParentIdById(deleted.getIIncrement())).isNull(); | ||
| 78 | + assertThat(moduleMapper.selectParentIdById(99999998)).isNull(); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + @Test | ||
| 82 | + void hasActiveChildren_trueIfChildAliveExists_falseOtherwise() { | ||
| 83 | + Module root = newModule("sp_test_hac_root", "根", null); | ||
| 84 | + moduleMapper.insert(root); | ||
| 85 | + | ||
| 86 | + Module child1 = newModule("sp_test_hac_alive", "存活子", root.getIIncrement()); | ||
| 87 | + moduleMapper.insert(child1); | ||
| 88 | + | ||
| 89 | + Module child2 = newModule("sp_test_hac_dead", "已删子", root.getIIncrement()); | ||
| 90 | + child2.setBDeleted(true); | ||
| 91 | + moduleMapper.insert(child2); | ||
| 92 | + | ||
| 93 | + assertThat(moduleMapper.hasActiveChildren(root.getIIncrement())).isTrue(); | ||
| 94 | + assertThat(moduleMapper.hasActiveChildren(99999996)).isFalse(); | ||
| 95 | + | ||
| 96 | + jdbcTemplate.update("UPDATE tModule SET bDeleted = 1 WHERE iIncrement = ?", child1.getIIncrement()); | ||
| 97 | + assertThat(moduleMapper.hasActiveChildren(root.getIIncrement())).isFalse(); | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + @Test | ||
| 101 | + void selectActiveByKeyword_filtersAndOrders() { | ||
| 102 | + Module a = newModule("sp_test_kw_a", "系统-A", null); a.setISortOrder(1); moduleMapper.insert(a); | ||
| 103 | + Module b = newModule("sp_test_kw_b", "系统-B", null); b.setISortOrder(0); moduleMapper.insert(b); | ||
| 104 | + Module c = newModule("sp_test_kw_c", "用户", null); c.setISortOrder(2); moduleMapper.insert(c); | ||
| 105 | + Module d = newModule("sp_test_kw_d", "系统-D", null); d.setISortOrder(3); d.setBDeleted(true); moduleMapper.insert(d); | ||
| 106 | + Module e = newModule("sp_test_kw_e", "测试", null); e.setISortOrder(4); moduleMapper.insert(e); | ||
| 107 | + | ||
| 108 | + java.util.List<String> namesByEmpty = moduleMapper.selectActiveByKeyword("").stream() | ||
| 109 | + .filter(m -> m.getSProcedureName() == null && m.getSModuleNameZh().matches("系统-[ABDE]|用户|测试")) | ||
| 110 | + .map(Module::getSModuleNameZh).toList(); | ||
| 111 | + // 只取本测试用例插入的 4 行(用 sProcedureName 与名字过滤会丢字段,改为按 iIncrement 集合判定) | ||
| 112 | + java.util.Set<Integer> insertedIds = java.util.Set.of( | ||
| 113 | + a.getIIncrement(), b.getIIncrement(), c.getIIncrement(), e.getIIncrement()); | ||
| 114 | + java.util.List<Module> empty = moduleMapper.selectActiveByKeyword("").stream() | ||
| 115 | + .filter(m -> insertedIds.contains(m.getIIncrement())).toList(); | ||
| 116 | + assertThat(empty).extracting(Module::getIIncrement) | ||
| 117 | + .containsExactly(b.getIIncrement(), a.getIIncrement(), c.getIIncrement(), e.getIIncrement()); | ||
| 118 | + | ||
| 119 | + java.util.List<Module> sys = moduleMapper.selectActiveByKeyword("系统").stream() | ||
| 120 | + .filter(m -> insertedIds.contains(m.getIIncrement())).toList(); | ||
| 121 | + assertThat(sys).extracting(Module::getIIncrement) | ||
| 122 | + .containsExactly(b.getIIncrement(), a.getIIncrement()); | ||
| 123 | + | ||
| 124 | + assertThat(moduleMapper.selectActiveByKeyword("不存在XYZ-zzz")).isEmpty(); | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + private Module newModule(String procedureName, String nameZh, Integer parentId) { | ||
| 128 | + Module m = new Module(); | ||
| 129 | + m.setSBrandsId("XLY"); | ||
| 130 | + m.setSSubsidiaryId("XLY"); | ||
| 131 | + m.setTCreateDate(LocalDateTime.now()); | ||
| 132 | + m.setSDisplayType("手机端"); | ||
| 133 | + m.setSProcedureName(procedureName); | ||
| 134 | + m.setSModuleType("业务模块"); | ||
| 135 | + m.setSManageDeptEn("IT"); | ||
| 136 | + m.setBShowPermission(false); | ||
| 137 | + m.setSModuleNameZh(nameZh); | ||
| 138 | + m.setIParentId(parentId); | ||
| 139 | + m.setISortOrder(0); | ||
| 140 | + m.setSCreatedBy("STUB_ADMIN"); | ||
| 141 | + m.setBDeleted(false); | ||
| 142 | + return m; | ||
| 143 | + } | ||
| 144 | +} |
backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.service; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.config.StubSecurityProperties; | ||
| 4 | +import com.xly.erp.common.config.TenantProperties; | ||
| 5 | +import com.xly.erp.common.exception.BizException; | ||
| 6 | +import com.xly.erp.module.mod.dto.CreateModuleDTO; | ||
| 7 | +import com.xly.erp.module.mod.dto.UpdateModuleDTO; | ||
| 8 | +import com.xly.erp.module.mod.entity.Module; | ||
| 9 | +import com.xly.erp.module.mod.mapper.ModuleMapper; | ||
| 10 | +import com.xly.erp.module.mod.service.impl.ModuleServiceImpl; | ||
| 11 | +import com.xly.erp.module.mod.vo.ModuleTreeVO; | ||
| 12 | +import org.junit.jupiter.api.AfterEach; | ||
| 13 | +import org.junit.jupiter.api.BeforeEach; | ||
| 14 | +import org.junit.jupiter.api.Test; | ||
| 15 | +import org.mockito.ArgumentCaptor; | ||
| 16 | +import org.springframework.dao.DuplicateKeyException; | ||
| 17 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| 18 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 19 | + | ||
| 20 | +import java.util.Collections; | ||
| 21 | +import java.util.List; | ||
| 22 | + | ||
| 23 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 24 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| 25 | +import static org.mockito.ArgumentMatchers.any; | ||
| 26 | +import static org.mockito.Mockito.lenient; | ||
| 27 | +import static org.mockito.Mockito.mock; | ||
| 28 | +import static org.mockito.Mockito.never; | ||
| 29 | +import static org.mockito.Mockito.times; | ||
| 30 | +import static org.mockito.Mockito.verify; | ||
| 31 | +import static org.mockito.Mockito.when; | ||
| 32 | + | ||
| 33 | +class ModuleServiceImplTest { | ||
| 34 | + | ||
| 35 | + private ModuleMapper moduleMapper; | ||
| 36 | + private ModuleServiceImpl service; | ||
| 37 | + | ||
| 38 | + @BeforeEach | ||
| 39 | + void setUp() { | ||
| 40 | + moduleMapper = mock(ModuleMapper.class); | ||
| 41 | + TenantProperties tenant = new TenantProperties(); | ||
| 42 | + tenant.setBrandsId("XLY"); | ||
| 43 | + tenant.setSubsidiaryId("XLY"); | ||
| 44 | + StubSecurityProperties stub = new StubSecurityProperties(); | ||
| 45 | + stub.setStubUserNo("STUB_ADMIN"); | ||
| 46 | + service = new ModuleServiceImpl(moduleMapper, tenant, stub); | ||
| 47 | + | ||
| 48 | + lenient().when(moduleMapper.insert(any(Module.class))).thenAnswer(inv -> { | ||
| 49 | + Module m = inv.getArgument(0); | ||
| 50 | + m.setIIncrement(99); | ||
| 51 | + return 1; | ||
| 52 | + }); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @AfterEach | ||
| 56 | + void clearContext() { | ||
| 57 | + SecurityContextHolder.clearContext(); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + @Test | ||
| 61 | + void createWithValidDto_persistsWithStandardCols() { | ||
| 62 | + CreateModuleDTO dto = baseDto(); | ||
| 63 | + | ||
| 64 | + Integer id = service.create(dto); | ||
| 65 | + | ||
| 66 | + assertThat(id).isEqualTo(99); | ||
| 67 | + ArgumentCaptor<Module> captor = ArgumentCaptor.forClass(Module.class); | ||
| 68 | + verify(moduleMapper, times(1)).insert(captor.capture()); | ||
| 69 | + Module saved = captor.getValue(); | ||
| 70 | + assertThat(saved.getSBrandsId()).isEqualTo("XLY"); | ||
| 71 | + assertThat(saved.getSSubsidiaryId()).isEqualTo("XLY"); | ||
| 72 | + assertThat(saved.getTCreateDate()).isNotNull(); | ||
| 73 | + assertThat(saved.getSCreatedBy()).isEqualTo("STUB_ADMIN"); | ||
| 74 | + assertThat(saved.getBDeleted()).isFalse(); | ||
| 75 | + assertThat(saved.getBShowPermission()).isFalse(); | ||
| 76 | + assertThat(saved.getISortOrder()).isZero(); | ||
| 77 | + verify(moduleMapper, never()).findActiveFlagById(any()); | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + @Test | ||
| 81 | + void createWithParentNotFound_throws40021() { | ||
| 82 | + CreateModuleDTO dto = baseDto(); | ||
| 83 | + dto.setIParentId(42); | ||
| 84 | + when(moduleMapper.findActiveFlagById(42)).thenReturn(null); | ||
| 85 | + | ||
| 86 | + assertThatThrownBy(() -> service.create(dto)) | ||
| 87 | + .isInstanceOf(BizException.class) | ||
| 88 | + .hasFieldOrPropertyWithValue("code", 40021); | ||
| 89 | + verify(moduleMapper, never()).insert(any(Module.class)); | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + @Test | ||
| 93 | + void createWithInvalidDisplayType_throws40010() { | ||
| 94 | + CreateModuleDTO dto = baseDto(); | ||
| 95 | + dto.setSDisplayType("未知"); | ||
| 96 | + | ||
| 97 | + assertThatThrownBy(() -> service.create(dto)) | ||
| 98 | + .isInstanceOf(BizException.class) | ||
| 99 | + .hasFieldOrPropertyWithValue("code", 40010); | ||
| 100 | + verify(moduleMapper, never()).insert(any(Module.class)); | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + @Test | ||
| 104 | + void createWithNullParentId_skipsParentCheck() { | ||
| 105 | + CreateModuleDTO dto = baseDto(); | ||
| 106 | + dto.setIParentId(null); | ||
| 107 | + | ||
| 108 | + service.create(dto); | ||
| 109 | + | ||
| 110 | + verify(moduleMapper, never()).findActiveFlagById(any()); | ||
| 111 | + verify(moduleMapper, times(1)).insert(any(Module.class)); | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + @Test | ||
| 115 | + void mapperDuplicateKey_throws40020() { | ||
| 116 | + CreateModuleDTO dto = baseDto(); | ||
| 117 | + when(moduleMapper.insert(any(Module.class))) | ||
| 118 | + .thenThrow(new DuplicateKeyException("uk_procedure_name")); | ||
| 119 | + | ||
| 120 | + assertThatThrownBy(() -> service.create(dto)) | ||
| 121 | + .isInstanceOf(BizException.class) | ||
| 122 | + .hasFieldOrPropertyWithValue("code", 40020); | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + @Test | ||
| 126 | + void usesAuthenticatedUserNoAsCreatedBy() { | ||
| 127 | + SecurityContextHolder.getContext().setAuthentication( | ||
| 128 | + new UsernamePasswordAuthenticationToken("ALICE", null, Collections.emptyList())); | ||
| 129 | + CreateModuleDTO dto = baseDto(); | ||
| 130 | + | ||
| 131 | + service.create(dto); | ||
| 132 | + | ||
| 133 | + ArgumentCaptor<Module> captor = ArgumentCaptor.forClass(Module.class); | ||
| 134 | + verify(moduleMapper).insert(captor.capture()); | ||
| 135 | + assertThat(captor.getValue().getSCreatedBy()).isEqualTo("ALICE"); | ||
| 136 | + } | ||
| 137 | + | ||
| 138 | + @Test | ||
| 139 | + void updateWithValidDto_invokesUpdateById_withEditableFieldsOnly() { | ||
| 140 | + Module original = stubExistingModule(10); | ||
| 141 | + when(moduleMapper.selectById(10)).thenReturn(original); | ||
| 142 | + when(moduleMapper.updateById(any(Module.class))).thenReturn(1); | ||
| 143 | + | ||
| 144 | + UpdateModuleDTO dto = baseUpdateDto(); | ||
| 145 | + dto.setSModuleNameZh("新名"); | ||
| 146 | + | ||
| 147 | + Integer result = service.update(10, dto); | ||
| 148 | + assertThat(result).isEqualTo(10); | ||
| 149 | + | ||
| 150 | + ArgumentCaptor<Module> captor = ArgumentCaptor.forClass(Module.class); | ||
| 151 | + verify(moduleMapper).updateById(captor.capture()); | ||
| 152 | + Module passed = captor.getValue(); | ||
| 153 | + assertThat(passed.getIIncrement()).isEqualTo(10); | ||
| 154 | + assertThat(passed.getSModuleNameZh()).isEqualTo("新名"); | ||
| 155 | + assertThat(passed.getSDisplayType()).isEqualTo("手机端"); | ||
| 156 | + assertThat(passed.getSProcedureName()).isNull(); | ||
| 157 | + assertThat(passed.getSCreatedBy()).isNull(); | ||
| 158 | + assertThat(passed.getTCreateDate()).isNull(); | ||
| 159 | + assertThat(passed.getSBrandsId()).isNull(); | ||
| 160 | + assertThat(passed.getSSubsidiaryId()).isNull(); | ||
| 161 | + assertThat(passed.getBDeleted()).isNull(); | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + @Test | ||
| 165 | + void updateWithTargetNotFound_throws40400() { | ||
| 166 | + when(moduleMapper.selectById(99)).thenReturn(null); | ||
| 167 | + UpdateModuleDTO dto = baseUpdateDto(); | ||
| 168 | + | ||
| 169 | + assertThatThrownBy(() -> service.update(99, dto)) | ||
| 170 | + .isInstanceOf(BizException.class) | ||
| 171 | + .hasFieldOrPropertyWithValue("code", 40400); | ||
| 172 | + verify(moduleMapper, never()).updateById(any(Module.class)); | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + @Test | ||
| 176 | + void updateWithBShowPermissionNull_setsFalseInEntity() { | ||
| 177 | + when(moduleMapper.selectById(10)).thenReturn(stubExistingModule(10)); | ||
| 178 | + when(moduleMapper.updateById(any(Module.class))).thenReturn(1); | ||
| 179 | + UpdateModuleDTO dto = baseUpdateDto(); | ||
| 180 | + dto.setBShowPermission(null); | ||
| 181 | + | ||
| 182 | + service.update(10, dto); | ||
| 183 | + | ||
| 184 | + ArgumentCaptor<Module> captor = ArgumentCaptor.forClass(Module.class); | ||
| 185 | + verify(moduleMapper).updateById(captor.capture()); | ||
| 186 | + assertThat(captor.getValue().getBShowPermission()).isFalse(); | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + @Test | ||
| 190 | + void updateWithInvalidDisplayType_throws40010() { | ||
| 191 | + when(moduleMapper.selectById(10)).thenReturn(stubExistingModule(10)); | ||
| 192 | + UpdateModuleDTO dto = baseUpdateDto(); | ||
| 193 | + dto.setSDisplayType("未知"); | ||
| 194 | + | ||
| 195 | + assertThatThrownBy(() -> service.update(10, dto)) | ||
| 196 | + .isInstanceOf(BizException.class) | ||
| 197 | + .hasFieldOrPropertyWithValue("code", 40010); | ||
| 198 | + verify(moduleMapper, never()).updateById(any(Module.class)); | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + @Test | ||
| 202 | + void updateWithSelfParentId_throws40021() { | ||
| 203 | + when(moduleMapper.selectById(10)).thenReturn(stubExistingModule(10)); | ||
| 204 | + UpdateModuleDTO dto = baseUpdateDto(); | ||
| 205 | + dto.setIParentId(10); | ||
| 206 | + | ||
| 207 | + assertThatThrownBy(() -> service.update(10, dto)) | ||
| 208 | + .isInstanceOf(BizException.class) | ||
| 209 | + .hasFieldOrPropertyWithValue("code", 40021) | ||
| 210 | + .hasMessageContaining("自身"); | ||
| 211 | + verify(moduleMapper, never()).updateById(any(Module.class)); | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + @Test | ||
| 215 | + void updateWithMissingParent_throws40021() { | ||
| 216 | + when(moduleMapper.selectById(10)).thenReturn(stubExistingModule(10)); | ||
| 217 | + when(moduleMapper.existsActiveById(42)).thenReturn(false); | ||
| 218 | + UpdateModuleDTO dto = baseUpdateDto(); | ||
| 219 | + dto.setIParentId(42); | ||
| 220 | + | ||
| 221 | + assertThatThrownBy(() -> service.update(10, dto)) | ||
| 222 | + .isInstanceOf(BizException.class) | ||
| 223 | + .hasFieldOrPropertyWithValue("code", 40021) | ||
| 224 | + .hasMessageContaining("父模块不存在"); | ||
| 225 | + verify(moduleMapper, never()).updateById(any(Module.class)); | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + @Test | ||
| 229 | + void updateWithCyclicParent_throws40021() { | ||
| 230 | + when(moduleMapper.selectById(10)).thenReturn(stubExistingModule(10)); | ||
| 231 | + when(moduleMapper.existsActiveById(20)).thenReturn(true); | ||
| 232 | + when(moduleMapper.selectParentIdById(20)).thenReturn(10); | ||
| 233 | + UpdateModuleDTO dto = baseUpdateDto(); | ||
| 234 | + dto.setIParentId(20); | ||
| 235 | + | ||
| 236 | + assertThatThrownBy(() -> service.update(10, dto)) | ||
| 237 | + .isInstanceOf(BizException.class) | ||
| 238 | + .hasFieldOrPropertyWithValue("code", 40021) | ||
| 239 | + .hasMessageContaining("环路"); | ||
| 240 | + verify(moduleMapper, never()).updateById(any(Module.class)); | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + @Test | ||
| 244 | + void deleteWithValidId_softDeletes_andSetsAuditFields() { | ||
| 245 | + when(moduleMapper.selectById(10)).thenReturn(stubExistingModule(10)); | ||
| 246 | + when(moduleMapper.hasActiveChildren(10)).thenReturn(false); | ||
| 247 | + when(moduleMapper.updateById(any(Module.class))).thenReturn(1); | ||
| 248 | + | ||
| 249 | + service.delete(10); | ||
| 250 | + | ||
| 251 | + ArgumentCaptor<Module> captor = ArgumentCaptor.forClass(Module.class); | ||
| 252 | + verify(moduleMapper).updateById(captor.capture()); | ||
| 253 | + Module passed = captor.getValue(); | ||
| 254 | + assertThat(passed.getIIncrement()).isEqualTo(10); | ||
| 255 | + assertThat(passed.getBDeleted()).isTrue(); | ||
| 256 | + assertThat(passed.getTDeletedDate()).isNotNull(); | ||
| 257 | + assertThat(passed.getSDeletedBy()).isEqualTo("STUB_ADMIN"); | ||
| 258 | + assertThat(passed.getSProcedureName()).isNull(); | ||
| 259 | + assertThat(passed.getSCreatedBy()).isNull(); | ||
| 260 | + assertThat(passed.getSBrandsId()).isNull(); | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + @Test | ||
| 264 | + void deleteWithTargetNotFound_throws40400() { | ||
| 265 | + when(moduleMapper.selectById(99)).thenReturn(null); | ||
| 266 | + | ||
| 267 | + assertThatThrownBy(() -> service.delete(99)) | ||
| 268 | + .isInstanceOf(BizException.class) | ||
| 269 | + .hasFieldOrPropertyWithValue("code", 40400); | ||
| 270 | + verify(moduleMapper, never()).updateById(any(Module.class)); | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + @Test | ||
| 274 | + void deleteWithTargetAlreadyDeleted_throws40400() { | ||
| 275 | + Module deleted = stubExistingModule(10); | ||
| 276 | + deleted.setBDeleted(true); | ||
| 277 | + when(moduleMapper.selectById(10)).thenReturn(deleted); | ||
| 278 | + | ||
| 279 | + assertThatThrownBy(() -> service.delete(10)) | ||
| 280 | + .isInstanceOf(BizException.class) | ||
| 281 | + .hasFieldOrPropertyWithValue("code", 40400); | ||
| 282 | + verify(moduleMapper, never()).updateById(any(Module.class)); | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + @Test | ||
| 286 | + void deleteWithActiveChildren_throws40901() { | ||
| 287 | + when(moduleMapper.selectById(10)).thenReturn(stubExistingModule(10)); | ||
| 288 | + when(moduleMapper.hasActiveChildren(10)).thenReturn(true); | ||
| 289 | + | ||
| 290 | + assertThatThrownBy(() -> service.delete(10)) | ||
| 291 | + .isInstanceOf(BizException.class) | ||
| 292 | + .hasFieldOrPropertyWithValue("code", 40901); | ||
| 293 | + verify(moduleMapper, never()).updateById(any(Module.class)); | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + @Test | ||
| 297 | + void deleteSetsDeletedByFromAuthenticatedUser() { | ||
| 298 | + SecurityContextHolder.getContext().setAuthentication( | ||
| 299 | + new UsernamePasswordAuthenticationToken("BOB", null, Collections.emptyList())); | ||
| 300 | + when(moduleMapper.selectById(10)).thenReturn(stubExistingModule(10)); | ||
| 301 | + when(moduleMapper.hasActiveChildren(10)).thenReturn(false); | ||
| 302 | + when(moduleMapper.updateById(any(Module.class))).thenReturn(1); | ||
| 303 | + | ||
| 304 | + service.delete(10); | ||
| 305 | + | ||
| 306 | + ArgumentCaptor<Module> captor = ArgumentCaptor.forClass(Module.class); | ||
| 307 | + verify(moduleMapper).updateById(captor.capture()); | ||
| 308 | + assertThat(captor.getValue().getSDeletedBy()).isEqualTo("BOB"); | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + @Test | ||
| 312 | + void listTree_emptyKeyword_invokesMapperWithEmptyString_returnsAssembledTree() { | ||
| 313 | + Module root1 = treeRow(1, "根1", null, 0); | ||
| 314 | + Module root2 = treeRow(2, "根2", null, 0); | ||
| 315 | + Module child1 = treeRow(3, "子1", 1, 0); | ||
| 316 | + Module child2 = treeRow(4, "子2", 1, 1); | ||
| 317 | + Module grand1 = treeRow(5, "孙1", 3, 0); | ||
| 318 | + when(moduleMapper.selectActiveByKeyword("")).thenReturn(List.of(root1, root2, child1, child2, grand1)); | ||
| 319 | + | ||
| 320 | + List<ModuleTreeVO> result = service.listTree(""); | ||
| 321 | + | ||
| 322 | + assertThat(result).extracting(ModuleTreeVO::getIIncrement).containsExactly(1, 2); | ||
| 323 | + assertThat(result.get(0).getChildren()).extracting(ModuleTreeVO::getIIncrement).containsExactly(3, 4); | ||
| 324 | + assertThat(result.get(0).getChildren().get(0).getChildren()).extracting(ModuleTreeVO::getIIncrement).containsExactly(5); | ||
| 325 | + assertThat(result.get(1).getChildren()).isEmpty(); | ||
| 326 | + } | ||
| 327 | + | ||
| 328 | + @Test | ||
| 329 | + void listTree_nullKeyword_treatedAsEmpty() { | ||
| 330 | + when(moduleMapper.selectActiveByKeyword("")).thenReturn(List.of()); | ||
| 331 | + service.listTree(null); | ||
| 332 | + ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); | ||
| 333 | + verify(moduleMapper).selectActiveByKeyword(captor.capture()); | ||
| 334 | + assertThat(captor.getValue()).isEqualTo(""); | ||
| 335 | + } | ||
| 336 | + | ||
| 337 | + @Test | ||
| 338 | + void listTree_blankKeyword_treatedAsEmpty() { | ||
| 339 | + when(moduleMapper.selectActiveByKeyword("")).thenReturn(List.of()); | ||
| 340 | + service.listTree(" "); | ||
| 341 | + verify(moduleMapper).selectActiveByKeyword(""); | ||
| 342 | + } | ||
| 343 | + | ||
| 344 | + @Test | ||
| 345 | + void listTree_keywordTooLong_throws40001() { | ||
| 346 | + String longKw = "x".repeat(101); | ||
| 347 | + assertThatThrownBy(() -> service.listTree(longKw)) | ||
| 348 | + .isInstanceOf(BizException.class) | ||
| 349 | + .hasFieldOrPropertyWithValue("code", 40001); | ||
| 350 | + verify(moduleMapper, never()).selectActiveByKeyword(any()); | ||
| 351 | + } | ||
| 352 | + | ||
| 353 | + @Test | ||
| 354 | + void listTree_returnsEmptyListWhenNoMatch() { | ||
| 355 | + when(moduleMapper.selectActiveByKeyword("xyz")).thenReturn(List.of()); | ||
| 356 | + List<ModuleTreeVO> result = service.listTree("xyz"); | ||
| 357 | + assertThat(result).isEmpty(); | ||
| 358 | + } | ||
| 359 | + | ||
| 360 | + @Test | ||
| 361 | + void listTree_orphansBecomeRootsInForest() { | ||
| 362 | + Module orphan = treeRow(3, "孤儿", 99, 0); | ||
| 363 | + when(moduleMapper.selectActiveByKeyword("")).thenReturn(List.of(orphan)); | ||
| 364 | + | ||
| 365 | + List<ModuleTreeVO> result = service.listTree(""); | ||
| 366 | + | ||
| 367 | + assertThat(result).hasSize(1); | ||
| 368 | + assertThat(result.get(0).getIIncrement()).isEqualTo(3); | ||
| 369 | + assertThat(result.get(0).getChildren()).isEmpty(); | ||
| 370 | + } | ||
| 371 | + | ||
| 372 | + @Test | ||
| 373 | + void listTree_keywordIsTrimmedBeforeQuery() { | ||
| 374 | + when(moduleMapper.selectActiveByKeyword("系统")).thenReturn(List.of()); | ||
| 375 | + service.listTree(" 系统 "); | ||
| 376 | + verify(moduleMapper).selectActiveByKeyword("系统"); | ||
| 377 | + } | ||
| 378 | + | ||
| 379 | + private Module treeRow(int id, String name, Integer parentId, int sortOrder) { | ||
| 380 | + Module m = new Module(); | ||
| 381 | + m.setIIncrement(id); | ||
| 382 | + m.setSModuleNameZh(name); | ||
| 383 | + m.setSDisplayType("手机端"); | ||
| 384 | + m.setSManageDeptEn("IT"); | ||
| 385 | + m.setIParentId(parentId); | ||
| 386 | + m.setISortOrder(sortOrder); | ||
| 387 | + return m; | ||
| 388 | + } | ||
| 389 | + | ||
| 390 | + private UpdateModuleDTO baseUpdateDto() { | ||
| 391 | + UpdateModuleDTO dto = new UpdateModuleDTO(); | ||
| 392 | + dto.setSDisplayType("手机端"); | ||
| 393 | + dto.setSModuleType("业务模块"); | ||
| 394 | + dto.setSManageDeptEn("IT"); | ||
| 395 | + dto.setSModuleNameZh("更新后"); | ||
| 396 | + dto.setBShowPermission(false); | ||
| 397 | + return dto; | ||
| 398 | + } | ||
| 399 | + | ||
| 400 | + private Module stubExistingModule(Integer id) { | ||
| 401 | + Module m = new Module(); | ||
| 402 | + m.setIIncrement(id); | ||
| 403 | + m.setSProcedureName("sp_test_existing"); | ||
| 404 | + m.setSCreatedBy("ORIG_USER"); | ||
| 405 | + m.setBDeleted(false); | ||
| 406 | + return m; | ||
| 407 | + } | ||
| 408 | + | ||
| 409 | + private CreateModuleDTO baseDto() { | ||
| 410 | + CreateModuleDTO dto = new CreateModuleDTO(); | ||
| 411 | + dto.setSDisplayType("手机端"); | ||
| 412 | + dto.setSProcedureName("sp_test_unit"); | ||
| 413 | + dto.setSModuleType("业务模块"); | ||
| 414 | + dto.setSManageDeptEn("IT"); | ||
| 415 | + dto.setSModuleNameZh("单测模块"); | ||
| 416 | + return dto; | ||
| 417 | + } | ||
| 418 | +} |
docs/08-模块任务管理.md
| @@ -60,10 +60,10 @@ | @@ -60,10 +60,10 @@ | ||
| 60 | - 路径: backend/module/mod/, frontend/pages/mod/ | 60 | - 路径: backend/module/mod/, frontend/pages/mod/ |
| 61 | - MR: — | 61 | - MR: — |
| 62 | - 功能: | 62 | - 功能: |
| 63 | - - [ ] REQ-MOD-001 模块新增 | ||
| 64 | - - [ ] REQ-MOD-002 模块修改 | ||
| 65 | - - [ ] REQ-MOD-003 模块删除 | ||
| 66 | - - [ ] REQ-MOD-004 模块查询 | 63 | + - [x] REQ-MOD-001 模块新增 |
| 64 | + - [x] REQ-MOD-002 模块修改 | ||
| 65 | + - [x] REQ-MOD-003 模块删除 | ||
| 66 | + - [x] REQ-MOD-004 模块查询 | ||
| 67 | 67 | ||
| 68 | - module_usr 用户管理 | 68 | - module_usr 用户管理 |
| 69 | - 依赖: — | 69 | - 依赖: — |
docs/superpowers/module-reports/module_mod-test-gate.md
0 → 100644
| 1 | +## Local test gate — module_mod | ||
| 2 | + | ||
| 3 | +执行时间: 2026-04-30 09:14 +08:00 | ||
| 4 | + | ||
| 5 | +### scripts/test.sh (subagent) | ||
| 6 | +- 子会话: a7d5818a97a7b5c66 | ||
| 7 | +- 命令: `bash scripts/test.sh` | ||
| 8 | +- 退出码: 0 | ||
| 9 | +- 通过: 67 / 失败: 0 | ||
| 10 | +- 关键 stdout (≤30 行): | ||
| 11 | + | ||
| 12 | +``` | ||
| 13 | +[INFO] Tests run: 67, Failures: 0, Errors: 0, Skipped: 0 | ||
| 14 | +[INFO] BUILD SUCCESS | ||
| 15 | +[INFO] Total time: 17.058 s | ||
| 16 | +[INFO] Finished at: 2026-04-30T09:14:23+08:00 | ||
| 17 | +[test.sh] skip frontend unit tests (frontend/ not initialized yet) | ||
| 18 | +[test.sh] 5/6 E2E | ||
| 19 | +[test.sh] e2e 略 | ||
| 20 | +[test.sh] 6/6 reset test db | ||
| 21 | +[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts | ||
| 22 | +[test.sh] GREEN | ||
| 23 | +``` | ||
| 24 | + | ||
| 25 | +结论: green | ||
| 26 | + | ||
| 27 | +### 备注 | ||
| 28 | + | ||
| 29 | +- 首次 test-gate 因环境缺失 mysql CLI 退出 127;用户修复 PATH 后重跑。 | ||
| 30 | +- 项目阶段 `frontend/` 尚未初始化,scripts/test.sh 的 build / lint / unit 三段在 frontend 缺失时打印 skip 跳过(详见同 commit 的 `chore(infra): skip frontend test segments when frontend/ absent`)。前端正式启动后该守卫天然失效,回到完整跑链路。 | ||
| 31 | +- backend 全量 67 用例端到端验证 module_mod 4 个 REQ 的实现 + 工程脚手架;setup-test-db.sh DROP+CREATE → Spring Boot 启动 Flyway apply V1 → mvn test 路径全程通过。 |
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/plans/2026-04-29-REQ-MOD-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-002 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-04-29-REQ-MOD-002.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-MOD-002 模块修改 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 10 | + | ||
| 11 | +**Goal:** 在 MOD-001 已有工程基础上增量实现 `PUT /api/mod/modules/{id}`,更新 7 个可编辑字段,保留 `sProcedureName` / `sCreatedBy` / 标准列;含目标存在性、枚举、父链合法性(自指 / 不存在 / 环)四类校验。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 复用 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` / `Module entity`,新增 `UpdateModuleDTO`、`ModuleService#update`、controller `@PutMapping`、`mapper.selectParentIdById`(轻量父链查询)。SecurityConfig 路径白名单从 `POST /api/mod/modules` 扩展为 `/api/mod/**` 以覆盖 MOD-002~004。环检测在 service 层用循环回溯,最大深度 50。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** Spring Boot 3.3.5 / MyBatis-Plus / Spring Security(已有);JUnit 5 + Mockito + TestRestTemplate(已有)。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(`tModule` schema 已满足;本 REQ 仅 UPDATE 操作)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +### 新增 | ||
| 26 | + | ||
| 27 | +- `backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java` — 入参 DTO(无 `sProcedureName` 字段) | ||
| 28 | + | ||
| 29 | +### 修改 | ||
| 30 | + | ||
| 31 | +- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — `requestMatchers(POST, "/api/mod/modules")` → `requestMatchers("/api/mod/**")`,stub 注释保持 | ||
| 32 | +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `Integer selectParentIdById(Integer id)` 注解 SELECT 方法(仅查 iParentId 列,不取整行) | ||
| 33 | +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `Integer update(Integer id, UpdateModuleDTO dto)` 方法 | ||
| 34 | +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update(...)`:目标存在性 → 枚举 → iParentId 三重校验 → mapper.updateById | ||
| 35 | +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/modules/{id}")` 端点 | ||
| 36 | +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 7 个 update 用例 | ||
| 37 | +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 7 个 PUT IT 用例 | ||
| 38 | +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 个 `selectParentIdById` 用例 | ||
| 39 | + | ||
| 40 | +## 任务步骤 | ||
| 41 | + | ||
| 42 | +> 全局约束:每 commit 形如 `<type>(mod): <subject> REQ-MOD-002`;测试派发到子会话执行;现有 26 用例全程保持绿。 | ||
| 43 | + | ||
| 44 | +### Task 1: SecurityConfig 路径白名单扩范围 | ||
| 45 | + | ||
| 46 | +**Files:** | ||
| 47 | +- Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java:25` | ||
| 48 | + | ||
| 49 | +**API shape:** | ||
| 50 | +- 改一行:`requestMatchers(HttpMethod.POST, "/api/mod/modules").permitAll()` → `requestMatchers("/api/mod/**").permitAll()` | ||
| 51 | +- 注释保留 `// REQ-MOD-001 stub: see USR-004 follow-up`(语义不变,覆盖范围扩大) | ||
| 52 | + | ||
| 53 | +- [ ] **Step 1: 修改 SecurityConfig** | ||
| 54 | + - 单行 Edit;不动任何其他类 | ||
| 55 | + | ||
| 56 | +- [ ] **Step 2: 子会话验证 PASS** | ||
| 57 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleControllerIT` | ||
| 58 | + - 期望:现有 7 用例全绿(permitAll 范围扩大不收紧已有路径) | ||
| 59 | + | ||
| 60 | +- [ ] **Step 3: Commit** | ||
| 61 | + - `git commit -m "refactor(mod): widen permitAll stub to /api/mod/** REQ-MOD-002"` | ||
| 62 | + | ||
| 63 | +### Task 2: ModuleMapper 追加父 ID 查询 | ||
| 64 | + | ||
| 65 | +**Files:** | ||
| 66 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` | ||
| 67 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` | ||
| 68 | + | ||
| 69 | +**API shape:** | ||
| 70 | +- `@Select("SELECT iParentId FROM tModule WHERE iIncrement = #{id} AND bDeleted = 0")` `Integer selectParentIdById(@Param("id") Integer id)` | ||
| 71 | + - 命中行的 `iParentId` 可能为 NULL(根模块)→ 返回 null | ||
| 72 | + - 未命中(不存在 / 已软删)→ 返回 null(与"根模块"在调用方语义不同,需要调用方明确校验存在性) | ||
| 73 | + | ||
| 74 | +- [ ] **Step 1: 写失败测试 `ModuleMapperIT#selectParentIdById_returnsNullForRootOrMissing_andValueForChild`** | ||
| 75 | + - 准备 3 行:root(iParentId=NULL)、child(iParentId=root)、deleted(iParentId=root, bDeleted=1) | ||
| 76 | + - 断言:`selectParentIdById(root.id) == null`;`selectParentIdById(child.id) == root.id`;`selectParentIdById(deleted.id) == null`;`selectParentIdById(99999999) == null` | ||
| 77 | + | ||
| 78 | +- [ ] **Step 2: 实现 mapper 方法** | ||
| 79 | + | ||
| 80 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 81 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT` | ||
| 82 | + | ||
| 83 | +- [ ] **Step 4: Commit** | ||
| 84 | + - `git commit -m "feat(mod): mapper#selectParentIdById for cycle check REQ-MOD-002"` | ||
| 85 | + | ||
| 86 | +### Task 3: UpdateModuleDTO + Service.update 合法路径 | ||
| 87 | + | ||
| 88 | +**Files:** | ||
| 89 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java` | ||
| 90 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` | ||
| 91 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | ||
| 92 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | ||
| 93 | + | ||
| 94 | +**API shape:** | ||
| 95 | +- `UpdateModuleDTO` 字段(带 `@JsonProperty` 锁定 JSON 名 + Bean Validation): | ||
| 96 | + - `@NotBlank String sDisplayType` | ||
| 97 | + - `@NotBlank @Size(max=50) String sModuleType` | ||
| 98 | + - `@NotBlank @Size(max=50) String sManageDeptEn` | ||
| 99 | + - `Boolean bShowPermission`(可空) | ||
| 100 | + - `@NotBlank @Size(max=100) String sModuleNameZh` | ||
| 101 | + - `Integer iParentId`(可空) | ||
| 102 | + - `Integer iSortOrder`(可空) | ||
| 103 | + - **不含 `sProcedureName`** | ||
| 104 | +- `ModuleService#update(Integer id, UpdateModuleDTO dto) : Integer` | ||
| 105 | +- `ModuleServiceImpl#update`: | ||
| 106 | + 1. `Module original = moduleMapper.selectById(id)`;若 null 或 `original.getBDeleted()==true` → `BizException(40400, "模块不存在或已删除")` | ||
| 107 | + 2. 枚举校验同 MOD-001 → 40010 | ||
| 108 | + 3. `iParentId` 校验(4 类)(在本 task 不全实现,仅留 hook,详见 Task 4):本 task 暂只对 null/合法路径走通;非 null 时调 `existsActiveById` 但暂不做自指/环检测,留 Task 4 加。 | ||
| 109 | + 4. 构造 `Module entity`:仅 set `iIncrement` 和可改 7 字段(其余 null);`bShowPermission` null → false | ||
| 110 | + 5. `moduleMapper.updateById(entity)` 返回 `id` | ||
| 111 | + | ||
| 112 | +- [ ] **Step 1: 写失败测试** | ||
| 113 | + - 在 `ModuleServiceImplTest` 追加 3 用例: | ||
| 114 | + - `updateWithValidDto_invokesUpdateById_withEditableFieldsOnly` | ||
| 115 | + - `updateWithTargetNotFound_throws40400` | ||
| 116 | + - `updateWithBShowPermissionNull_setsFalseInEntity` | ||
| 117 | + - mock 准备:`moduleMapper.selectById(id)` 返回 stub Module(含原 sProcedureName / sCreatedBy);`moduleMapper.updateById(any(Module.class))` 返回 1 | ||
| 118 | + - ArgumentCaptor 抓传给 `updateById` 的 entity,断言:`iIncrement` 是路径 id;`sProcedureName == null`;`sCreatedBy == null`;`tCreateDate == null`;`sBrandsId == null`;`sSubsidiaryId == null`;可改字段被透传 | ||
| 119 | + - 子会话先跑 → FAIL(方法不存在) | ||
| 120 | + | ||
| 121 | +- [ ] **Step 2: 实现 DTO + Service** | ||
| 122 | + - DTO 与 CreateModuleDTO 平行结构;Service 实现仅覆盖 Task 3 三个用例所需逻辑 | ||
| 123 | + | ||
| 124 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 125 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` | ||
| 126 | + - 期望:6 (MOD-001) + 3 (本 task) = 9 用例全绿 | ||
| 127 | + | ||
| 128 | +- [ ] **Step 4: Commit** | ||
| 129 | + - `git commit -m "feat(mod): module update dto + service happy path REQ-MOD-002"` | ||
| 130 | + | ||
| 131 | +### Task 4: Service 校验分支(枚举 + iParentId 三类) | ||
| 132 | + | ||
| 133 | +**Files:** | ||
| 134 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | ||
| 135 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | ||
| 136 | + | ||
| 137 | +**API shape:** 不变(仅补 update 方法内部校验逻辑) | ||
| 138 | + | ||
| 139 | +**校验顺序**(service 实现): | ||
| 140 | + | ||
| 141 | +1. 目标存在 / 枚举(已在 Task 3 实现) | ||
| 142 | +2. 若 `iParentId != null`: | ||
| 143 | + a. `iParentId.equals(id)` → `BizException(40021, "父模块不能指向自身")` | ||
| 144 | + b. `!moduleMapper.existsActiveById(iParentId)` → `BizException(40021, "父模块不存在或已删除")` | ||
| 145 | + c. 环检测:`Integer cur = iParentId; for (int depth = 0; cur != null && depth < 50; depth++) { if (cur.equals(id)) throw BizException(40021,"父模块链构成环路"); cur = moduleMapper.selectParentIdById(cur); }`;若 depth 达到 50 → `BizException(40021, "父模块链超过最大层级")` | ||
| 146 | + | ||
| 147 | +- [ ] **Step 1: 写失败测试** | ||
| 148 | + - 在 `ModuleServiceImplTest` 追加 4 用例: | ||
| 149 | + - `updateWithInvalidDisplayType_throws40010` | ||
| 150 | + - `updateWithSelfParentId_throws40021`(msg contains "自身") | ||
| 151 | + - `updateWithMissingParent_throws40021`(msg contains "父模块不存在") | ||
| 152 | + - `updateWithCyclicParent_throws40021`(msg contains "环路";mock 编排:`existsActiveById(parent)=true`;`selectParentIdById(parent) = id`) | ||
| 153 | + - 子会话先跑 → 4 用例 FAIL | ||
| 154 | + | ||
| 155 | +- [ ] **Step 2: 实现校验分支** | ||
| 156 | + - 严格按上述 a/b/c 顺序,**a 在 b 之前**(自指应当先于存在性,否则用户传自身 id 在父表里又恰好不存在时报错信息会误导) | ||
| 157 | + | ||
| 158 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 159 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` | ||
| 160 | + - 期望:6 + 3 + 4 = 13 用例全绿 | ||
| 161 | + | ||
| 162 | +- [ ] **Step 4: Commit** | ||
| 163 | + - `git commit -m "feat(mod): module update parent validation REQ-MOD-002"` | ||
| 164 | + | ||
| 165 | +### Task 5: ModuleController PUT + IT 正常路径 | ||
| 166 | + | ||
| 167 | +**Files:** | ||
| 168 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | ||
| 169 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | ||
| 170 | + | ||
| 171 | +**API shape:** | ||
| 172 | +- `@PutMapping("/modules/{id}") public Result<Map<String,Integer>> update(@PathVariable Integer id, @Valid @RequestBody UpdateModuleDTO dto)` | ||
| 173 | +- 返回 `Result.ok(Map.of("iIncrement", moduleService.update(id, dto)))` | ||
| 174 | + | ||
| 175 | +- [ ] **Step 1: 写失败测试 `ModuleControllerIT#putValidBody_with_jwt_returns200_andUpdatesEditableFields`** | ||
| 176 | + - 步骤:① JdbcTemplate 直插一行原始数据(含 sProcedureName="sp_test_orig"、sCreatedBy="ORIG_USER"、sBrandsId="XLY"、sModuleNameZh="原名");② PUT body 改 sModuleNameZh="新名"、sDisplayType="前端业务";带 JWT="ADMIN001";③ 期望 `code=0`,`data.iIncrement` 等于 ① 的 id;④ JdbcTemplate 查行:`sModuleNameZh="新名"`、`sDisplayType="前端业务"`、`sProcedureName="sp_test_orig"`(保留)、`sCreatedBy="ORIG_USER"`(保留) | ||
| 177 | + | ||
| 178 | +- [ ] **Step 2: 实现 controller PUT** | ||
| 179 | + - 与 POST 同结构 | ||
| 180 | + | ||
| 181 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 182 | + - 命令:`cd backend && mvn -B test -Dtest='ModuleControllerIT#putValidBody_with_jwt_returns200_andUpdatesEditableFields'` | ||
| 183 | + | ||
| 184 | +- [ ] **Step 4: Commit** | ||
| 185 | + - `git commit -m "feat(mod): PUT /api/mod/modules/{id} controller REQ-MOD-002"` | ||
| 186 | + | ||
| 187 | +### Task 6: IT 异常路径补全 + 全量回归 | ||
| 188 | + | ||
| 189 | +**Files:** | ||
| 190 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | ||
| 191 | + | ||
| 192 | +**API shape:** 不新增(覆盖 PUT 接口 6 条异常路径) | ||
| 193 | + | ||
| 194 | +- [ ] **Step 1: 在 IT 中追加 6 个用例** | ||
| 195 | + - `putNonExistentId_returns40400` — PUT `/api/mod/modules/99999999` body 合法 → `code=40400` | ||
| 196 | + - `putInvalidDisplayType_returns40010` — `sDisplayType="火星"` → `code=40010` | ||
| 197 | + - `putSelfParent_returns40021` — body `iParentId == path id` → `code=40021` | ||
| 198 | + - `putCyclicParent_returns40021` — 准备:先插 root,再插 child(parent=root);PUT root 把 `iParentId=child.id` → `code=40021` | ||
| 199 | + - `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` — 先插原行(sCreatedBy="ORIG_USER"),无 token PUT;期望 `code=0`,DB 中 sCreatedBy 仍为 "ORIG_USER"(不被覆盖为 STUB_ADMIN) | ||
| 200 | + - `putTamperedJwt_returns20001` — Authorization 头 `Bearer not.a.real.jwt` → `code=20001` | ||
| 201 | + - 6 个用例先跑 → 期望 FAIL(部分分支需 Task 4/5 已经覆盖;这里主要补 IT 端到端) | ||
| 202 | + | ||
| 203 | +- [ ] **Step 2: 让测试通过** | ||
| 204 | + - service / controller / config 已实现;本 task 主要排查 RestTemplate 行为(4xx 是否抛、URL 拼接、JSON parse);不应当为让测试通过新增业务分支 | ||
| 205 | + | ||
| 206 | +- [ ] **Step 3: 子会话跑全量回归** | ||
| 207 | + - 命令:`cd backend && mvn -B test` | ||
| 208 | + - 期望:MOD-001 26 用例 + MOD-002 新增 1(mapperIT) + 7(serviceTest) + 7(controllerIT) = 41 用例全绿 | ||
| 209 | + | ||
| 210 | +- [ ] **Step 4: Commit** | ||
| 211 | + - `git commit -m "test(mod): module update integration coverage REQ-MOD-002"` | ||
| 212 | + | ||
| 213 | +## 提交计划 | ||
| 214 | + | ||
| 215 | +| commit | 覆盖 | | ||
| 216 | +|---|---| | ||
| 217 | +| `refactor(mod): widen permitAll stub to /api/mod/** REQ-MOD-002` | Task 1 | | ||
| 218 | +| `feat(mod): mapper#selectParentIdById for cycle check REQ-MOD-002` | Task 2 | | ||
| 219 | +| `feat(mod): module update dto + service happy path REQ-MOD-002` | Task 3 | | ||
| 220 | +| `feat(mod): module update parent validation REQ-MOD-002` | Task 4 | | ||
| 221 | +| `feat(mod): PUT /api/mod/modules/{id} controller REQ-MOD-002` | Task 5 | | ||
| 222 | +| `test(mod): module update integration coverage REQ-MOD-002` | Task 6 | |
docs/superpowers/plans/2026-04-29-REQ-MOD-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-003 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-04-29-REQ-MOD-003.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-MOD-003 模块删除 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | ||
| 10 | + | ||
| 11 | +**Goal:** 在 MOD-001/002 已建工程基础上增量实现 `DELETE /api/mod/modules/{id}` 软删除接口,含目标存在性、子模块拦截两类校验,软删除后 `bDeleted=1` + 审计字段。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 复用现有 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper`;新增 `mapper.hasActiveChildren(id)` + `service.delete(id)` + controller `@DeleteMapping`。SecurityConfig 已对 `/api/mod/**` permitAll,无需改。`sDeletedBy` 取 JWT principal 或回退 stub(与 MOD-001 `sCreatedBy` 同策略)。**40902 外部引用拦截不实现**——docs/03 当前 schema 中 tModule 无引用方表。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** Spring Boot 3.3.5 / MyBatis-Plus / JUnit 5 + Mockito + TestRestTemplate(沿用)。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(仅 UPDATE 软删除字段)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +### 修改 | ||
| 26 | + | ||
| 27 | +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `findActiveChildFlag` + default `hasActiveChildren` | ||
| 28 | +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `void delete(Integer id)` 方法 | ||
| 29 | +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `delete(...)` | ||
| 30 | +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@DeleteMapping("/modules/{id}")` 端点 | ||
| 31 | +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 5 用例 | ||
| 32 | +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 用例 | ||
| 33 | +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 用例 | ||
| 34 | + | ||
| 35 | +## 任务步骤 | ||
| 36 | + | ||
| 37 | +> 全局:每 commit `<type>(mod): <subject> REQ-MOD-003`;测试派发子会话;现有 41 用例全程绿。 | ||
| 38 | + | ||
| 39 | +### Task 1: Mapper#hasActiveChildren + IT | ||
| 40 | + | ||
| 41 | +**Files:** | ||
| 42 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` | ||
| 43 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` | ||
| 44 | + | ||
| 45 | +**API shape:** | ||
| 46 | +- `@Select("SELECT 1 FROM tModule WHERE iParentId = #{parentId} AND bDeleted = 0 LIMIT 1")` `Integer findActiveChildFlag(@Param("parentId") Integer parentId)` | ||
| 47 | +- `default boolean hasActiveChildren(Integer parentId) { return findActiveChildFlag(parentId) != null; }` | ||
| 48 | + | ||
| 49 | +- [ ] **Step 1: 写失败测试 `ModuleMapperIT#hasActiveChildren_trueIfChildAliveExists_falseOtherwise`** | ||
| 50 | + - 准备:root(无 parent);child1(parent=root, bDeleted=0);child2(parent=root, bDeleted=1) | ||
| 51 | + - 断言:`hasActiveChildren(root.id) == true` | ||
| 52 | + - 用 JdbcTemplate `UPDATE tModule SET bDeleted=1 WHERE iIncrement=child1.id` 软删唯一活跃子节点 | ||
| 53 | + - 再次断言:`hasActiveChildren(root.id) == false` | ||
| 54 | + - `hasActiveChildren(99999997) == false` | ||
| 55 | + | ||
| 56 | +- [ ] **Step 2: 实现 mapper 方法** | ||
| 57 | + | ||
| 58 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 59 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT` | ||
| 60 | + | ||
| 61 | +- [ ] **Step 4: Commit** | ||
| 62 | + - `git commit -m "feat(mod): mapper#hasActiveChildren for delete check REQ-MOD-003"` | ||
| 63 | + | ||
| 64 | +### Task 2: Service#delete + 单测 | ||
| 65 | + | ||
| 66 | +**Files:** | ||
| 67 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` | ||
| 68 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | ||
| 69 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | ||
| 70 | + | ||
| 71 | +**API shape:** | ||
| 72 | +- `ModuleService#delete(Integer id) : void` | ||
| 73 | +- `ModuleServiceImpl#delete(Integer id)`: | ||
| 74 | + 1. `Module original = moduleMapper.selectById(id)` → null 或 `bDeleted=true` → `BizException(40400, "模块不存在或已删除")` | ||
| 75 | + 2. `moduleMapper.hasActiveChildren(id)` → true → `BizException(40901, "模块仍有未删除子节点")` | ||
| 76 | + 3. 构造 `Module entity`:`setIIncrement(id)` / `setBDeleted(true)` / `setTDeletedDate(LocalDateTime.now())` / `setSDeletedBy(SecurityContextHelper.currentUserNo() ?: stub.getStubUserNo())`;其他字段 null | ||
| 77 | + 4. `moduleMapper.updateById(entity)` | ||
| 78 | + | ||
| 79 | +- [ ] **Step 1: 写失败测试(5 用例)** | ||
| 80 | + - `deleteWithValidId_softDeletes_andSetsAuditFields`:mock `selectById(10)=alive`、`hasActiveChildren(10)=false`、`updateById(any)=1`;ArgumentCaptor 抓 entity;断言 `iIncrement=10` / `bDeleted=true` / `tDeletedDate != null` / `sDeletedBy="STUB_ADMIN"` / 其他字段 null | ||
| 81 | + - `deleteWithTargetNotFound_throws40400`:`selectById(99)=null`;`updateById` 永不调用 | ||
| 82 | + - `deleteWithTargetAlreadyDeleted_throws40400`:`selectById(10)` 返回 `bDeleted=true` 的 Module | ||
| 83 | + - `deleteWithActiveChildren_throws40901`:`hasActiveChildren(10)=true` | ||
| 84 | + - `deleteSetsDeletedByFromAuthenticatedUser`:SecurityContextHolder 注入 principal "BOB";ArgumentCaptor `sDeletedBy="BOB"` | ||
| 85 | + - 子会话先跑 → 5 用例 FAIL | ||
| 86 | + | ||
| 87 | +- [ ] **Step 2: 实现 service** | ||
| 88 | + - 严格按 API shape 顺序 | ||
| 89 | + | ||
| 90 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 91 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` | ||
| 92 | + - 期望:13 (前) + 5 = 18 用例全绿 | ||
| 93 | + | ||
| 94 | +- [ ] **Step 4: Commit** | ||
| 95 | + - `git commit -m "feat(mod): module delete service + soft delete REQ-MOD-003"` | ||
| 96 | + | ||
| 97 | +### Task 3: Controller DELETE + 6 IT 用例 | ||
| 98 | + | ||
| 99 | +**Files:** | ||
| 100 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | ||
| 101 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | ||
| 102 | + | ||
| 103 | +**API shape:** | ||
| 104 | +- `@DeleteMapping("/modules/{id}") public Result<Void> delete(@PathVariable Integer id)` | ||
| 105 | +- 调 `moduleService.delete(id)`;返回 `Result.ok()` | ||
| 106 | + | ||
| 107 | +- [ ] **Step 1: 写失败测试(6 用例)** | ||
| 108 | + - `deleteValidId_with_jwt_returns200_andSoftDeletes`:JdbcTemplate 直插 alive 行;DELETE 带 JWT="ADMIN001";期望 `code=0` / `data=null`;JdbcTemplate 查 `bDeleted=1` / `sDeletedBy="ADMIN001"` / `tDeletedDate IS NOT NULL` / `sProcedureName` 不变 / `sCreatedBy` 不变 | ||
| 109 | + - `deleteNonExistentId_returns40400`:DELETE `/api/mod/modules/99999996` → `code=40400` | ||
| 110 | + - `deleteAlreadyDeletedId_returns40400`:JdbcTemplate 直插 `bDeleted=1` 行;DELETE → `code=40400` | ||
| 111 | + - `deleteWithActiveChildren_returns40901`:JdbcTemplate 直插 root + child(bDeleted=0, parent=root);DELETE root → `code=40901`;JdbcTemplate 查 root 仍 `bDeleted=0` | ||
| 112 | + - `deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB`:JdbcTemplate 直插 alive;无 token DELETE;DB 查 `sDeletedBy="STUB_ADMIN"` / `bDeleted=1` | ||
| 113 | + - `deleteTamperedJwt_returns20001`:JdbcTemplate 直插 alive;Authorization "Bearer not.a.real.jwt" DELETE;`code=20001`;DB 查行 `bDeleted=0`(filter 短路,service 未触发) | ||
| 114 | + - 6 用例先跑 → FAIL(controller 不存在 → 405/404) | ||
| 115 | + | ||
| 116 | +- [ ] **Step 2: 实现 controller DELETE** | ||
| 117 | + | ||
| 118 | +- [ ] **Step 3: 子会话跑全量回归** | ||
| 119 | + - 命令:`cd backend && mvn -B test` | ||
| 120 | + - 期望:MOD-001 26 + MOD-002 15 + MOD-003 新增 1(mapperIT) + 5(svc) + 6(it) = 53 用例全绿 | ||
| 121 | + | ||
| 122 | +- [ ] **Step 4: Commit** | ||
| 123 | + - `git commit -m "test(mod): module delete integration coverage REQ-MOD-003"` | ||
| 124 | + | ||
| 125 | +## 提交计划 | ||
| 126 | + | ||
| 127 | +| commit | 覆盖 | | ||
| 128 | +|---|---| | ||
| 129 | +| `feat(mod): mapper#hasActiveChildren for delete check REQ-MOD-003` | Task 1 | | ||
| 130 | +| `feat(mod): module delete service + soft delete REQ-MOD-003` | Task 2 | | ||
| 131 | +| `test(mod): module delete integration coverage REQ-MOD-003` | Task 3 | |
docs/superpowers/plans/2026-04-29-REQ-MOD-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-004 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-04-29-REQ-MOD-004.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-MOD-004 模块查询 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | ||
| 10 | + | ||
| 11 | +**Goal:** 在 MOD-001~003 已建工程基础上增量实现 `GET /api/mod/modules` 模块树查询:DB 模糊匹配 + 内存拼装森林。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 复用 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper`;新增 `ModuleTreeVO`、`mapper.selectActiveByKeyword(String)`、`service.listTree(String)`、controller `@GetMapping`。无新外部依赖。空 keyword 由 controller 归一化为 `""`,超长校验在 service。SecurityConfig 已对 `/api/mod/**` permitAll 覆盖该接口。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** 沿用(Spring Boot 3.3.5 / MyBatis-Plus / JUnit 5 + Mockito + TestRestTemplate)。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(仅 SELECT)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +### 新增 | ||
| 26 | + | ||
| 27 | +- `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeVO.java` — 树节点出参 VO | ||
| 28 | + | ||
| 29 | +### 修改 | ||
| 30 | + | ||
| 31 | +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `List<Module> selectActiveByKeyword(@Param("keyword") String keyword)` 注解 SELECT | ||
| 32 | +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `List<ModuleTreeVO> listTree(String keyword)` | ||
| 33 | +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 listTree(trim + 长度校验 + 拼树) | ||
| 34 | +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@GetMapping("/modules")` | ||
| 35 | +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 7 用例 | ||
| 36 | +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 用例 | ||
| 37 | +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 用例 | ||
| 38 | + | ||
| 39 | +## 任务步骤 | ||
| 40 | + | ||
| 41 | +> 全局:每 commit `<type>(mod): <subject> REQ-MOD-004`;测试派发子会话;现有 53 用例全程绿。 | ||
| 42 | + | ||
| 43 | +### Task 1: Mapper#selectActiveByKeyword + IT | ||
| 44 | + | ||
| 45 | +**Files:** | ||
| 46 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` | ||
| 47 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` | ||
| 48 | + | ||
| 49 | +**API shape:** | ||
| 50 | +- `@Select("SELECT iIncrement, sModuleNameZh, sDisplayType, sManageDeptEn, iParentId, iSortOrder FROM tModule WHERE bDeleted = 0 AND sModuleNameZh LIKE CONCAT('%', #{keyword}, '%') ORDER BY iSortOrder ASC, iIncrement ASC")` | ||
| 51 | +- `List<Module> selectActiveByKeyword(@Param("keyword") String keyword)` | ||
| 52 | +- 返回的 Module 实例只填查询的 6 列;其他字段为 null(MyBatis 默认行为) | ||
| 53 | + | ||
| 54 | +- [ ] **Step 1: 写失败测试 `ModuleMapperIT#selectActiveByKeyword_filtersAndOrders`** | ||
| 55 | + - 准备 5 行:A "系统-A" iSortOrder=1; B "系统-B" iSortOrder=0; C "用户" iSortOrder=2; D "系统-D" bDeleted=1; E "测试" iSortOrder=3 | ||
| 56 | + - 断言:`selectActiveByKeyword("")` → 4 行,顺序 [B(0), A(1), C(2), E(3)](D 被 bDeleted 过滤) | ||
| 57 | + - 断言:`selectActiveByKeyword("系统")` → [B, A](D 被 bDeleted 过滤) | ||
| 58 | + - 断言:`selectActiveByKeyword("不存在XYZ")` → 空 list | ||
| 59 | + | ||
| 60 | +- [ ] **Step 2: 实现 mapper 方法** | ||
| 61 | + | ||
| 62 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 63 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT` | ||
| 64 | + | ||
| 65 | +- [ ] **Step 4: Commit** | ||
| 66 | + - `git commit -m "feat(mod): mapper#selectActiveByKeyword REQ-MOD-004"` | ||
| 67 | + | ||
| 68 | +### Task 2: ModuleTreeVO + Service.listTree + 单测 | ||
| 69 | + | ||
| 70 | +**Files:** | ||
| 71 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeVO.java` | ||
| 72 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` | ||
| 73 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | ||
| 74 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | ||
| 75 | + | ||
| 76 | +**API shape:** | ||
| 77 | +- `ModuleTreeVO` POJO:字段 `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` / `List<ModuleTreeVO> children`(默认 new ArrayList<>());getter/setter;含 `@JsonProperty` 锁定 JSON 名(与 DTO 风格一致)。 | ||
| 78 | +- `ModuleService#listTree(String keyword) : List<ModuleTreeVO>` | ||
| 79 | +- `ModuleServiceImpl#listTree(String keyword)`: | ||
| 80 | + 1. `String normalized = keyword == null ? "" : keyword.trim()` | ||
| 81 | + 2. `if (normalized.length() > 100) throw new BizException(40001, "keyword 长度超过 100 字符")` | ||
| 82 | + 3. `List<Module> rows = moduleMapper.selectActiveByKeyword(normalized)` | ||
| 83 | + 4. 拼树:建 `Map<Integer, ModuleTreeVO> idIndex`,遍历 rows 转 VO 入 map;二次遍历:parent 在 map → 挂入 parent.children;否则视为 root 加入返回 list | ||
| 84 | + 5. 返回 list(保持 SQL ORDER BY 顺序,孤立子节点出现在 root 列表中按其行序) | ||
| 85 | +- 类级 `@Transactional` 不影响只读;可在方法上加 `@Transactional(readOnly = true)` 显式覆盖(建议) | ||
| 86 | + | ||
| 87 | +- [ ] **Step 1: 写失败测试(7 用例)** | ||
| 88 | + - `listTree_emptyKeyword_invokesMapperWithEmptyString_returnsAssembledTree`:mock 返回 [root1(id=1,parent=null), root2(id=2,parent=null), child1(id=3,parent=1), child2(id=4,parent=1), grand1(id=5,parent=3)];断言返回 list size==2;root1.children size==2 含 child1+child2;child1.children 含 grand1;root2.children 空 | ||
| 89 | + - `listTree_nullKeyword_treatedAsEmpty`:参数 null;ArgumentCaptor 抓 mapper 入参 == "" | ||
| 90 | + - `listTree_blankKeyword_treatedAsEmpty`:参数 " ";mapper 入参 == "" | ||
| 91 | + - `listTree_keywordTooLong_throws40001`:参数 = "x".repeat(101);BizException(40001);mapper 永不调用 | ||
| 92 | + - `listTree_returnsEmptyListWhenNoMatch`:mock 返回 emptyList;返回 List.of() | ||
| 93 | + - `listTree_orphansBecomeRootsInForest`:mock 返回 [child(id=3,parent=99)];返回 list size==1,第 0 项 iIncrement=3,children 空 | ||
| 94 | + - `listTree_keywordIsTrimmedBeforeQuery`:参数 " 系统 ";mapper 入参 == "系统" | ||
| 95 | + - 子会话先跑 → 7 用例 FAIL | ||
| 96 | + | ||
| 97 | +- [ ] **Step 2: 实现 VO + service** | ||
| 98 | + | ||
| 99 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 100 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` | ||
| 101 | + - 期望:18 (前) + 7 = 25 用例全绿 | ||
| 102 | + | ||
| 103 | +- [ ] **Step 4: Commit** | ||
| 104 | + - `git commit -m "feat(mod): module list tree service + vo REQ-MOD-004"` | ||
| 105 | + | ||
| 106 | +### Task 3: Controller GET + 6 IT + 全量回归 | ||
| 107 | + | ||
| 108 | +**Files:** | ||
| 109 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | ||
| 110 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | ||
| 111 | + | ||
| 112 | +**API shape:** | ||
| 113 | +- `@GetMapping("/modules") public Result<List<ModuleTreeVO>> list(@RequestParam(required = false) String keyword)` | ||
| 114 | +- 返回 `Result.ok(moduleService.listTree(keyword))` | ||
| 115 | + | ||
| 116 | +- [ ] **Step 1: 写失败测试(6 用例)** | ||
| 117 | + - `getEmptyKeyword_returnsCompleteTreeAsForest`:直插 root + child(parent=root);GET 带 JWT `/api/mod/modules`;`code=0`,`data` 是数组;找出 iIncrement=root 的节点,children 含 iIncrement=child | ||
| 118 | + - `getKeywordMatch_returnsForest`:直插 "系统模块A"+"用户模块B";GET `?keyword=系统`;返回数组只含 sModuleNameZh 含"系统"的节点 | ||
| 119 | + - `getKeywordTooLong_returns40001`:`keyword` 101 字符 → `code=40001` | ||
| 120 | + - `getNoMatch_returnsEmptyArray`:`keyword=不存在的关键字XYZ`;`data` JsonNode isArray && size==0 | ||
| 121 | + - `getWithoutJwt_permitAllStub_returns200`:无 token GET;`code=0` | ||
| 122 | + - `getTamperedJwt_returns20001`:Authorization "Bearer not.a.real.jwt" → `code=20001` | ||
| 123 | + - 子会话先跑 → FAIL | ||
| 124 | + | ||
| 125 | +- [ ] **Step 2: 实现 controller** | ||
| 126 | + | ||
| 127 | +- [ ] **Step 3: 子会话跑全量回归** | ||
| 128 | + - 命令:`cd backend && mvn -B test` | ||
| 129 | + - 期望:MOD-001 26 + MOD-002 15 + MOD-003 12 + MOD-004 新增 1(mapperIT) + 7(svc) + 6(IT) = 67 用例全绿 | ||
| 130 | + | ||
| 131 | +- [ ] **Step 4: Commit** | ||
| 132 | + - `git commit -m "test(mod): module list integration coverage REQ-MOD-004"` | ||
| 133 | + | ||
| 134 | +## 提交计划 | ||
| 135 | + | ||
| 136 | +| commit | 覆盖 | | ||
| 137 | +|---|---| | ||
| 138 | +| `feat(mod): mapper#selectActiveByKeyword REQ-MOD-004` | Task 1 | | ||
| 139 | +| `feat(mod): module list tree service + vo REQ-MOD-004` | Task 2 | | ||
| 140 | +| `test(mod): module list integration coverage REQ-MOD-004` | Task 3 | |
docs/superpowers/reviews/2026-04-29-REQ-MOD-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-001 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-MOD-001 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java:20 — `@Component` 加在 servlet filter 上会被 Spring Boot 自动注册到原生 servlet 链上(与 `addFilterBefore` 重叠注册)。`OncePerRequestFilter` 防住了重复执行,但顺序与作用域容易让人困惑。建议去掉 `@Component` 改在 SecurityConfig 用 `@Bean` 暴露,或追加 `FilterRegistrationBean<JwtAuthenticationFilter>` 并 `setEnabled(false)` 禁用原生注册。 | ||
| 19 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:58 — stub 回退 `stub.getStubUserNo()` 缺 spec/plan 约定的 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点;目前只有 SecurityConfig 一处带锚点,USR-004 上线时 grep 容易漏改这处。建议在该行加注释。 | ||
| 20 | +- backend/src/main/java/com/xly/erp/module/mod/entity/Module.java:11 — 类名 `Module` 与 `java.lang.Module`(JPMS)撞,未来 entity 与反射/模块 API 混用易踩坑。可考虑改名 `ModuleEntity` / `TModule`,本 REQ 不强制。 | ||
| 21 | +- backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java:32 — 兜底 `handleAny(Exception)` 会把 `HttpMessageNotReadableException`(非法 JSON)/ `HttpRequestMethodNotSupportedException` 等框架级 4xx 转成 50000,掩盖请求格式错误。建议后续 REQ 增加专用 handler 返回 40001。 | ||
| 22 | +- backend/src/main/resources/application.yml:30 — `erp.security.jwt-secret: ${JWT_SECRET}` 没有 fail-fast 默认值,未来漏 export 时会用字面量当 secret。建议 `${JWT_SECRET:?JWT_SECRET 必须从 .env.local 注入}` 让缺失即启动失败。 | ||
| 23 | +- backend/src/main/java/com/xly/erp/common/security/JwtUtil.java:23 — 暴露了两个 public 构造器(`JwtUtil(String)` 实质只供测试用)。建议改 package-private 加 `// test-only`,或在测试侧自行构造 StubSecurityProperties。 | ||
| 24 | +- backend/src/main/resources:0 — docs/09 § 二 列出的 `application-dev.yml` 缺失。本 REQ 不强求,建议在模块完成报告 follow-up 列表中提一下。 | ||
| 25 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:142 — `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` 用例硬写 STUB_ADMIN 期望,spec 已声明 USR-004 闭环后改 401。建议加 `@DisplayName` 或 javadoc 带 `REQ-MOD-001 stub: see USR-004 follow-up` 锚点便于 grep。 | ||
| 26 | + | ||
| 27 | +## 反例 / 测试覆盖缺口 | ||
| 28 | + | ||
| 29 | +Spec 验收清单(单测 6 + IT 7 + 工程脚手架 5)全部覆盖到位:surefire 报告 6 个 testsuite 共 26 用例,0 failure / 0 error;错误码 40001 / 40010 / 40020 / 40021 / 20001 全部端到端验证。 | ||
| 30 | + | ||
| 31 | +非阻塞可补缺口(留给后续 REQ): | ||
| 32 | +1. `@Size` 长度溢出(如 `sProcedureName` > 100 字符)路径仅在『缺必填』用例顺带覆盖,没有独立断言。 | ||
| 33 | +2. `iParentId` 指向 `bDeleted=1` 旧记录是否回 40021 没单独验证(目前只测了不存在的 99999999)。 | ||
| 34 | +3. 非法 JSON body 落到 `handleAny` 转 50000 的链路 spec 未列入,可在引入专用 4xx handler 时一起补。 |
docs/superpowers/reviews/2026-04-29-REQ-MOD-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-002 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-MOD-002 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:111 — 环检测 `depth >= MAX_PARENT_DEPTH` 守卫位于循环体中段,最大执行 51 次 mapper 调用而非 50(`cur.equals(id)` 判断先于守卫)。建议把守卫挪到 for 条件 `depth < MAX_PARENT_DEPTH` 或先判 depth 再判 equals。无功能影响,仅语义更精确。 | ||
| 19 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:263 — `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` 与 MOD-001 同名 POST 用例同样硬绑 stub 行为;建议加 javadoc 锚点 `// REQ-MOD-001 stub: see USR-004 follow-up`,便于 USR-004 上线时一次性 grep 替换为 401 期望。 | ||
| 20 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:188 — happy-path IT 仅断言 `sProcedureName` / `sCreatedBy` 保留,未端到端断言 `tCreateDate` / `sBrandsId` / `sSubsidiaryId`。Service 单测已 captureArgument 验证这些字段在 entity 上为 null(依赖 NOT_NULL 跳过),如要 IT 双重保险可加列断言。 | ||
| 21 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:215 — update 用例直接 stub interface default 方法 `existsActiveById` / `selectParentIdById`,与 MOD-001 create 用例 stub 抽象方法 `findActiveFlagById` 风格不一致。功能上 Mockito 支持;建议统一风格——推荐 MOD-001 改为 stub 默认方法(更直观)。 | ||
| 22 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:92 — `moduleMapper.updateById(entity)` 返回值(受影响行数)被丢弃。`@Transactional` + 先做 selectById 校验后并发 DELETE 概率极低,但若命中可能 0 行受影响而方法仍返回成功。可断言 affected==1 否则抛 BizException(40400)。 | ||
| 23 | +- backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java:32 — `iParentId` / `iSortOrder` 缺 `@PositiveOrZero` / `@Min` 约束。spec 未要求;负数等异常值需要在 service 层走父链失败才抛 40021。 | ||
| 24 | + | ||
| 25 | +## 反例 / 测试覆盖缺口 | ||
| 26 | + | ||
| 27 | +Spec 验收清单(单元 7 + 集成 7 + 工程 3)端到端实现并 41/41 全绿;分层 / 命名 / 统一响应 / 异常处理 / 事务边界 / 安全 stub 均符合 docs/04 规范。 | ||
| 28 | + | ||
| 29 | +非阻塞缺口: | ||
| 30 | +1. `tCreateDate` / `sBrandsId` / `sSubsidiaryId` 在 IT 端未断言保持原值(service 单测已断言 entity 字段 null + NOT_NULL 策略保证不覆盖)。 | ||
| 31 | +2. 父模块指向 `bDeleted=1` 旧记录是否回 40021 未单独验证(与 MOD-001 review gap #2 同源)。 | ||
| 32 | +3. `updateById` 返回 0 行(并发删除)的场景未覆盖。 | ||
| 33 | +4. DTO `@Size` 长度溢出(如 sModuleNameZh > 100)路径未单独 IT。 | ||
| 34 | +5. 环检测 `MAX_PARENT_DEPTH` 超深路径仅有间接覆盖(脏数据 50 层链未构造测试)。 |
docs/superpowers/reviews/2026-04-29-REQ-MOD-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-003 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-MOD-003 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:291 — `deleteValidId_with_jwt_returns200_andSoftDeletes` 仅断言 `sProcedureName` / `sCreatedBy` 不变,spec § 验收 第 95 行明确要求同时断 `sBrandsId`。建议把 SELECT 列表追加 `sBrandsId, sSubsidiaryId, tCreateDate, iSortOrder` 并加断言(service 单测已 captureArgument 验证 entity 上字段为 null + 依赖 NOT_NULL 策略,IT 端到端双保险更稳)。 | ||
| 19 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:362 — `deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB` 沿袭 MOD-001/002 stub 用例缺 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点注释;USR-004 上线时一次性 grep 替换为 401 会漏这条。 | ||
| 20 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:112 — `moduleMapper.updateById(entity)` 返回值(受影响行数)丢弃;并发场景下 selectById 与 updateById 之间另一线程已软删则 affected=0 而方法仍返回成功,覆盖之前的 `tDeletedDate` / `sDeletedBy`。可断言 affected==1 否则抛 BizException(40400)。同 MOD-002 update review 同源问题,stub 期接受。 | ||
| 21 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:97 — `delete()` 方法缺行内 `// REQ-MOD-003: 模块删除` 锚点(CLAUDE.md § 编码行为约束 #4 要求 REQ-XXX-NNN 行内可追溯);MOD-001/002 的 create/update 也缺,建议三处统一补。 | ||
| 22 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:97 — 删除路径未加悲观锁(无 SELECT … FOR UPDATE);spec 声明非幂等单实例顺序满足,多副本并发可能重复进入。建议未来用乐观条件 UPDATE WHERE iIncrement=? AND bDeleted=0 收口,本期接受。 | ||
| 23 | + | ||
| 24 | +## 反例 / 测试覆盖缺口 | ||
| 25 | + | ||
| 26 | +Spec 验收清单(service 5 + mapperIT 1 + controllerIT 6 + 工程 3)端到端实现 53/53 全绿。校验顺序(目标存在 → 子模块 → 写库)与 spec 一致;软删除字段策略与 MOD-001/002 风格统一;mapper `findActiveChildFlag` 用 SELECT 1 LIMIT 1 高效;JWT 伪造路径 IT 通过 filter 短路确认 service 未触发(`bDeleted` 仍 0);40902 外部引用拦截在 spec 双处显式声明本期不实现并指明 USR/后续模块入口;SecurityContextHelper 处理 anonymous 的方式与 MOD-001/002 一致;测试隔离用 sProcedureName LIKE 'sp_test_%' + Before/AfterEach 双清健壮。 | ||
| 27 | + | ||
| 28 | +非阻塞缺口: | ||
| 29 | +1. `deleteValidId` IT 未断言 `sBrandsId` 等保留列(spec 明言)。 | ||
| 30 | +2. `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点未在 MOD-003 stub 用例上贴。 | ||
| 31 | +3. `updateById` 返回值未校验,并发覆盖窗口存在。 | ||
| 32 | +4. service 方法缺行内 REQ 锚点(MOD-001/002/003 同源)。 | ||
| 33 | +5. 40902 外部引用待 USR/后续模块落地后回填。 |
docs/superpowers/reviews/2026-04-29-REQ-MOD-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-004 | ||
| 3 | +date: 2026-04-30 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-MOD-004 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:122 — spec § 业务规则 #1 与 plan Architecture 节均写「controller 先 trim」,实现把 trim 放在 service。行为等价(controller 立刻调 service),但 spec/plan 与代码不一致。要么把 trim 移到 controller,要么把 spec 改成「service 集中归一化」。建议改 spec(service 归一化 + 7 个单测覆盖更易测)。 | ||
| 19 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:420 — `getKeywordMatch_returnsForest` 只断言「返回数组里每个节点 sModuleNameZh 含『系统』」,没断言本测试 INSERT 的 `sp_test_get_kw_a` 行确实出现。日后 V1 seed 含「系统」的行恰好命中时,断言依旧通过——但本测试自身插入的真值未被验证。建议追加按 sModuleNameZh="系统模块A" 定位节点的断言。 | ||
| 20 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:466 — `getWithoutJwt_permitAllStub_returns200` / `getTamperedJwt_returns20001` 沿袭 MOD-001/002/003 stub 路径仍缺 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点。USR-004 上线时一次性 grep 替换为 `authenticated()` + 401 会漏这两条。 | ||
| 21 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:122 — `listTree` 方法体缺行内 `// REQ-MOD-004: 模块查询` 锚点;MOD-001/002/003 的 create/update/delete 同源缺失。建议在 module-report 阶段统一补齐 4 处。 | ||
| 22 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:312 — `listTree_emptyKeyword_invokesMapperWithEmptyString_returnsAssembledTree` 中 mock 返回顺序已是 SQL 期望顺序,children 顺序断言 `containsExactly(3, 4)` 实际验的是「mapper 返回顺序被 service 保留」。语义正确(依赖 ModuleMapperIT 验 SQL ORDER BY),加一行注释指向 mapper IT 让 review 路径更清晰。 | ||
| 23 | +- backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java:108 — `selectActiveByKeyword_filtersAndOrders` 第 108–110 行 `namesByEmpty` 局部变量构造后未被任何 assert 使用(dead code);真正的断言路径在第 111–117 行的 `insertedIds` 过滤。建议删除 108–110 这段未用 stream。 | ||
| 24 | + | ||
| 25 | +## 反例 / 测试覆盖缺口 | ||
| 26 | + | ||
| 27 | +Spec 验收清单(service 7 + mapperIT 1 + controllerIT 6 + 工程 3)100% 落地,mvn test 67/67 全绿。 | ||
| 28 | + | ||
| 29 | +拼树算法正确性核查通过:HashMap 仅用于 O(1) 查父,roots/children 均按 rows 迭代顺序填充,SQL ORDER BY 顺序穿透到 JSON;同一节点不会既出现在 roots 也挂在 parent.children(if/else 互斥)。VO 字段集严格匹配 docs/05(7 字段,敏感列 sProcedureName / sCreatedBy / sBrandsId 等不暴露)。keyword 归一化(null→''、trim、长度>100→40001)+ 拼树森林(孤儿节点→root)单测覆盖完整。LIKE `%`/`_` 注入在 spec § 边界与约束已显式说明本期不转义。Controller IT 通过 JsonNode 递归读取 children 数组层级,真正验证 JSON 端层级结构。trim 位置(spec/plan 写 controller,实现在 service)为文字不一致而非行为缺陷。 | ||
| 30 | + | ||
| 31 | +继承自 MOD-001~003 的 stub 锚点缺失 / REQ 行内锚点缺失沿袭存在但 spec 已纳入 USR-004 followup 范围,本期接受。**本 REQ 是模块最后一个 REQ,建议在 module-report 阶段一次性把 4 个 REQ 的行内锚点 + 6 处 stub 测试锚点补齐。** |
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 路径 |
docs/superpowers/specs/2026-04-29-REQ-MOD-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-002 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +module: module_mod | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-MOD-002 — 模块修改 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +在不破坏唯一性的前提下,更新已有模块的可编辑字段:`sDisplayType` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder`。`sProcedureName`、`sCreatedBy`、`tCreateDate`、`sBrandsId` / `sSubsidiaryId`、软删除字段一律保留原值。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +### HTTP 接口(来自 docs/05 § REQ-MOD-002) | ||
| 16 | + | ||
| 17 | +- Method / Path: `PUT /api/mod/modules/{id}`(`{id}` = `tModule.iIncrement`) | ||
| 18 | +- Auth: 必需(JWT Bearer) | ||
| 19 | +- Permission: 仅超级管理员(**沿用 MOD-001 的 stub:SecurityConfig 路径范围扩展为 `/api/mod/**` permitAll,USR-004 完成后统一改 `hasAuthority('SUPER_ADMIN')`**) | ||
| 20 | + | ||
| 21 | +### 请求 DTO `UpdateModuleDTO` | ||
| 22 | + | ||
| 23 | +| JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | | ||
| 24 | +|---|---|---|---|---| | ||
| 25 | +| `sDisplayType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[手机端, 前端业务, 系统配置, 接口]` 内;非法 → `40010` | | ||
| 26 | +| `sModuleType` | `String` | 是 | `@NotBlank @Size(max=50)` | 自由文本 | | ||
| 27 | +| `sManageDeptEn` | `String` | 是 | `@NotBlank @Size(max=50)` | — | | ||
| 28 | +| `bShowPermission` | `Boolean` | 否 | — | 缺省视为 `false`(写 0) | | ||
| 29 | +| `sModuleNameZh` | `String` | 是 | `@NotBlank @Size(max=100)` | — | | ||
| 30 | +| `iParentId` | `Integer` | 否 | — | 不为 null 时:① 不能等于路径 `{id}`(自指);② 必须命中存在且 `bDeleted=0` 的记录;③ 沿父链遍历不能在路径中出现 `{id}`(环检测)。三种违反统一 → `40021` | | ||
| 31 | +| `iSortOrder` | `Integer` | 否 | — | 缺省 `0` | | ||
| 32 | + | ||
| 33 | +> **`sProcedureName` 显式从 DTO 中剔除**——API 契约声明该字段不可改;前端表单仍可显示原值(REQ 卡列为必填仅是前端 UX 约束),但 PUT body 中即便传了也会被 Jackson 丢弃,不进入 service。这一处与 REQ 卡输入表的差异是**有意的**:以 docs/05 API 契约为准。 | ||
| 34 | + | ||
| 35 | +### 鉴权与上下文 | ||
| 36 | + | ||
| 37 | +同 MOD-001:JWT Filter 解析 token 写 `principal=sUserNo`;本 REQ 走 `permitAll` stub,不强制要求 token;伪造 token 仍被 filter 短路返回 `code=20001`。`sCreatedBy` 在更新时**不修改**,无论是否携带 token。 | ||
| 38 | + | ||
| 39 | +## 输出 / 结果 | ||
| 40 | + | ||
| 41 | +### 成功响应 | ||
| 42 | + | ||
| 43 | +```json | ||
| 44 | +{ | ||
| 45 | + "code": 0, | ||
| 46 | + "msg": "ok", | ||
| 47 | + "data": { "iIncrement": 123 } | ||
| 48 | +} | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +### 持久化效果 | ||
| 52 | + | ||
| 53 | +`UPDATE tModule SET <可编辑列> WHERE iIncrement = {id}`。 | ||
| 54 | + | ||
| 55 | +| 字段 | 更新策略 | | ||
| 56 | +|---|---| | ||
| 57 | +| `sDisplayType` / `sModuleType` / `sManageDeptEn` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | | ||
| 58 | +| `bShowPermission` | DTO null → `false`,否则 DTO 值 | | ||
| 59 | +| `sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `bDeleted` / `tDeletedDate` / `sDeletedBy` / `sId` | **不更新**(entity 上对应字段保持 null,依赖 MyBatis-Plus 默认 `FieldStrategy.NOT_NULL` 跳过 null 字段) | | ||
| 60 | + | ||
| 61 | +> 实施细节:MyBatis-Plus 默认全局 `update-strategy: NOT_NULL`,但需在 entity 字段上不显式标注其他 strategy。`bShowPermission` 因 DTO null 时要写 false,故 service 层先把 DTO null 展开为 false 再赋值给 entity;其余 null 字段保持 null。 | ||
| 62 | + | ||
| 63 | +## 业务规则 | ||
| 64 | + | ||
| 65 | +1. **目标存在性**:先 `SELECT * FROM tModule WHERE iIncrement = {id} AND bDeleted = 0`;找不到 → `BizException(40400, "模块不存在或已删除")`。 | ||
| 66 | +2. **枚举校验**:`sDisplayType` 在 `DISPLAY_TYPES` 内(复用 `ModuleServiceImpl.DISPLAY_TYPES`);非法 → `BizException(40010, "显示类型枚举不合法")`。 | ||
| 67 | + > docs/05 § MOD-002 错误码表只列了 40001/40021/40400,未单列 40010。本实现选择复用 MOD-001 已建立的 40010 语义(保持同一字段两个接口的错误码一致)。这条偏离已记入 spec,后续若需统一可在 docs/05 补一行。 | ||
| 68 | +3. **iParentId 校验**(`iParentId != null` 时): | ||
| 69 | + - 自指 (`iParentId == id`) → `BizException(40021, "父模块不能指向自身")` | ||
| 70 | + - 不存在 / 已软删 (`!moduleMapper.existsActiveById(iParentId)`) → `BizException(40021, "父模块不存在或已删除")` | ||
| 71 | + - 形成环:从 `iParentId` 沿 `tModule.iParentId` 链向上回溯,每跳一次查一次 mapper;若某层 ID 等于路径 `{id}` → `BizException(40021, "父模块链构成环路")`;遍历深度上限 `50`,超限抛 `BizException(40021, "父模块链超过最大层级")` 防止脏数据死循环。 | ||
| 72 | +4. **事务边界**:`update(...)` 上 `@Transactional(rollbackFor = Exception.class)`,包裹"目标查询 + 父链校验 + UPDATE"全部步骤。 | ||
| 73 | +5. **空 body / 非 JSON**:交给 Spring + GlobalExceptionHandler,目前会落 `handleAny` 转 `code=50000`(同 MOD-001 已知行为,spec 未要求 fix)。 | ||
| 74 | + | ||
| 75 | +## 边界与约束 | ||
| 76 | + | ||
| 77 | +- **必填项缺失** → `40001` | ||
| 78 | +- **`sDisplayType` 非枚举** → `40010` | ||
| 79 | +- **iParentId 不合法(自指 / 不存在 / 环 / 超深)** → `40021` | ||
| 80 | +- **目标 id 不存在或已软删** → `40400` | ||
| 81 | +- **JWT 伪造** → `20001`(filter 短路) | ||
| 82 | +- **JWT 缺失** → permitAll stub,不阻断(USR-004 后改 401) | ||
| 83 | +- **不允许修改 `sProcedureName`**:DTO 直接不暴露该字段;即便前端误传,service 也不读;不需要单独错误码。 | ||
| 84 | + | ||
| 85 | +## 实现范围与边界抉择 | ||
| 86 | + | ||
| 87 | +1. **复用 MOD-001 工程脚手架**:无需新增 pom 依赖、Application、SecurityConfig 等;仅在 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` 上做增量。 | ||
| 88 | +2. **SecurityConfig 路径调整**:把 MOD-001 的 `POST /api/mod/modules permitAll` 改为 `requestMatchers("/api/mod/**").permitAll()`,stub 范围一次性覆盖整个 MOD 模块的 4 个 REQ;注释保留 `// REQ-MOD-001 stub: see USR-004 follow-up`(路径更动,原 stub 锚点继续生效,无需新增锚点关键字)。 | ||
| 89 | +3. **环检测策略**:选择"递归向上查 mapper"而非"DB 层 CTE",因为:① 单条业务路径,递归层数小(典型 < 5);② docs/04 § 3.4 禁循环 N+1 主要针对**列表场景**,单条更新接口的小循环不属于该约束;③ 避免引入 MyBatis-Plus 的 CTE 写法增加复杂度。 | ||
| 90 | + | ||
| 91 | +## 依赖的 schema 表 / 字段 | ||
| 92 | + | ||
| 93 | +写入表:`tModule` | ||
| 94 | + | ||
| 95 | +| 字段 | 用途 | 来源 | | ||
| 96 | +|---|---|---| | ||
| 97 | +| `iIncrement` | path id,定位行 | `@PathVariable` | | ||
| 98 | +| `sDisplayType` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | `UpdateModuleDTO` | | ||
| 99 | +| 其他字段 | 不更新 | — | | ||
| 100 | + | ||
| 101 | +依赖索引:`uk_procedure_name` 不冲突(不动该字段);`fk_module_parent` 在父链校验通过后由 INSERT/UPDATE 默认约束兜底。 | ||
| 102 | + | ||
| 103 | +## 依赖的接口 | ||
| 104 | + | ||
| 105 | +无(仅本 REQ 路径内部使用 MOD-001 已实现的 `ModuleMapper` 工具方法 + 新增父链查询)。 | ||
| 106 | + | ||
| 107 | +## 验收标准 | ||
| 108 | + | ||
| 109 | +### 单元测试(追加到 `ModuleServiceImplTest`) | ||
| 110 | + | ||
| 111 | +- [x] `updateWithValidDto_invokesUpdateById_withEditableFieldsOnly` — Mock `selectById` 返回非空、`existsActiveById` 返回 true(若有 parent);断言传入 `updateById` 的 entity:` iIncrement` 是路径 id;`sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` 全部为 null(NOT_NULL 策略跳过);可改字段被透传。 | ||
| 112 | +- [x] `updateWithTargetNotFound_throws40400` — Mock `selectById` 返回 null;不调 `updateById`。 | ||
| 113 | +- [x] `updateWithInvalidDisplayType_throws40010` — DTO `sDisplayType="未知"`;不调 `updateById`。 | ||
| 114 | +- [x] `updateWithSelfParentId_throws40021` — DTO `iParentId == path id`;错误信息含"自身"。 | ||
| 115 | +- [x] `updateWithMissingParent_throws40021` — Mock `existsActiveById(parent) → false`;错误信息含"父模块不存在"。 | ||
| 116 | +- [x] `updateWithCyclicParent_throws40021` — 构造 mapper 行为:`existsActiveById(parent)=true`;递归向上 `selectById(parent).getIParentId() == path id`;期望抛 40021,错误信息含"环路"。 | ||
| 117 | +- [x] `updateWithBShowPermissionNull_setsFalseInEntity` — DTO `bShowPermission=null`;entity 字段为 `false`。 | ||
| 118 | + | ||
| 119 | +### 集成测试(追加到 `ModuleControllerIT`) | ||
| 120 | + | ||
| 121 | +- [x] `putValidBody_with_jwt_returns200_andUpdatesEditableFields` — 先 INSERT 一条原始行,再 PUT;查 DB:可改字段为新值,`sProcedureName` / `sCreatedBy` 保持原值。 | ||
| 122 | +- [x] `putNonExistentId_returns40400` — PUT `/api/mod/modules/99999999`;`code=40400`。 | ||
| 123 | +- [x] `putInvalidDisplayType_returns40010` — `code=40010`。 | ||
| 124 | +- [x] `putSelfParent_returns40021` — body `iParentId == path id`;`code=40021`。 | ||
| 125 | +- [x] `putCyclicParent_returns40021` — 准备数据:root → child;PUT root 把 `iParentId` 改成 child(构成环);`code=40021`。 | ||
| 126 | +- [x] `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` — 先 INSERT(通过 POST 接口或 JdbcTemplate),再无 token PUT;`sCreatedBy` 仍是原值(不被覆盖为 STUB_ADMIN)。 | ||
| 127 | +- [x] `putTamperedJwt_returns20001` — `code=20001`。 | ||
| 128 | + | ||
| 129 | +### 工程验收 | ||
| 130 | + | ||
| 131 | +- [x] `cd backend && mvn -B test` 全绿(含 MOD-001 已有 26 用例 + 本 REQ 新增至少 7+7=14 用例,总 ≥ 40 用例) | ||
| 132 | +- [x] SecurityConfig 路径规则更新后,MOD-001 已有 IT 仍 PASS(permitAll 范围扩大不收紧) | ||
| 133 | +- [x] DB 中 `sProcedureName` 在更新前后字面相同(验证未被覆盖) |
docs/superpowers/specs/2026-04-29-REQ-MOD-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-003 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +module: module_mod | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-MOD-003 — 模块删除 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +软删除一个已有模块(`bDeleted=0 → 1` + 审计字段填充),并阻止破坏树形数据完整性的删除(已存在未删子模块时拒绝)。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +### HTTP 接口(docs/05 § REQ-MOD-003) | ||
| 16 | + | ||
| 17 | +- Method / Path: `DELETE /api/mod/modules/{id}`(path 参数 `{id}` = `tModule.iIncrement`) | ||
| 18 | +- 无请求 body | ||
| 19 | +- Auth: 必需(沿用 MOD-001 stub:路径已在 SecurityConfig `/api/mod/**` permitAll,USR-004 完成后改 `hasAuthority('SUPER_ADMIN')`) | ||
| 20 | +- Permission: 仅超级管理员(stub 期不强制) | ||
| 21 | + | ||
| 22 | +### 鉴权与上下文 | ||
| 23 | + | ||
| 24 | +JWT Filter 解析 token 写 `principal=sUserNo`;伪造 token → `code=20001`;缺失 token → permitAll 透传。`sDeletedBy` 取 `SecurityContextHelper.currentUserNo()`,匿名状态回退 `stubProps.stubUserNo`(与 MOD-001 `sCreatedBy` 同策略)。 | ||
| 25 | + | ||
| 26 | +## 输出 / 结果 | ||
| 27 | + | ||
| 28 | +### 成功响应 | ||
| 29 | + | ||
| 30 | +```json | ||
| 31 | +{ "code": 0, "msg": "ok", "data": null } | ||
| 32 | +``` | ||
| 33 | + | ||
| 34 | +### 持久化效果 | ||
| 35 | + | ||
| 36 | +`UPDATE tModule SET bDeleted=1, tDeletedDate=NOW(), sDeletedBy='<userNo>' WHERE iIncrement={id}`。其他字段保持原值(依赖 MyBatis-Plus FieldStrategy.NOT_NULL 仅写非 null 字段)。 | ||
| 37 | + | ||
| 38 | +## 业务规则 | ||
| 39 | + | ||
| 40 | +1. **目标存在性**:`SELECT * FROM tModule WHERE iIncrement={id}`;行不存在 **或** `bDeleted=1` → `BizException(40400, "模块不存在或已删除")`。已删模块再次 DELETE 也返回 40400(删除接口非幂等设计——业务上想表达"目标已不存在")。 | ||
| 41 | +2. **子模块拦截**:检查 `tModule WHERE iParentId={id} AND bDeleted=0`;存在 → `BizException(40901, "模块仍有未删除子节点")`。新增 mapper 方法 `boolean hasActiveChildren(Integer parentId)`,实现用 `@Select("SELECT 1 FROM tModule WHERE iParentId = #{parentId} AND bDeleted = 0 LIMIT 1")` + Java default 包装。 | ||
| 42 | +3. **外部引用拦截(40902)**:docs/05 列了该错误码,但 docs/03 § tModule 业务注记明确"与本期其他表无外键关系"——本期 schema 中**不存在**菜单/权限/角色表引用 tModule 的字段,**本 REQ 不实现 40902 校验**。spec 显式记录该决策:当 USR 或后续模块引入 `tMenu` / `tRole` 等表并通过 `iModuleId` 等字段引用 tModule 时,再回头扩展 ModuleService#delete 加引用查询。当前实现保持纯 MOD 模块自包含。 | ||
| 43 | +4. **软删除字段填充**:构造 `Module entity` 仅 set `iIncrement` / `bDeleted=true` / `tDeletedDate=LocalDateTime.now()` / `sDeletedBy=<userNo or stub>`;其他字段保持 null(NOT_NULL 跳过,避免覆盖原值)。 | ||
| 44 | +5. **事务边界**:复用类级 `@Transactional(rollbackFor = Exception.class)`,包裹"目标查询 + 子模块查询 + UPDATE"。 | ||
| 45 | + | ||
| 46 | +## 边界与约束 | ||
| 47 | + | ||
| 48 | +- **id 不存在 / 已软删** → `40400` | ||
| 49 | +- **存在未删子模块** → `40901` | ||
| 50 | +- **JWT 伪造** → `20001`(filter 短路) | ||
| 51 | +- **JWT 缺失** → permitAll stub,正常 200 + `sDeletedBy=STUB_ADMIN`(USR-004 闭环后改 401) | ||
| 52 | +- **40902 外部引用拦截** → 本 REQ 不实现(docs/03 当前 schema 无引用方),spec 记录后续补点 | ||
| 53 | + | ||
| 54 | +## 实现范围与边界抉择 | ||
| 55 | + | ||
| 56 | +1. **复用 MOD-001/002 工程**:无新增依赖;仅在 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` 上做增量。 | ||
| 57 | +2. **删除接口非幂等**:连续 DELETE 同一 id,第二次返回 40400,不允许覆盖已删除记录的 `tDeletedDate` / `sDeletedBy`。这与"删除"语义对齐——目标不存在就是错。 | ||
| 58 | +3. **mapper 仅查 1 行不查全表**:`hasActiveChildren` 用 `SELECT 1 ... LIMIT 1` 避免全表扫描;与 MOD-001 `findActiveFlagById` 风格一致。 | ||
| 59 | +4. **不做 40902**:待引用方落地。 | ||
| 60 | + | ||
| 61 | +## 依赖的 schema 表 / 字段 | ||
| 62 | + | ||
| 63 | +写入表:`tModule` | ||
| 64 | + | ||
| 65 | +| 字段 | 用途 | 来源 | | ||
| 66 | +|---|---|---| | ||
| 67 | +| `iIncrement` | path id,定位行 | `@PathVariable` | | ||
| 68 | +| `bDeleted` | 软删除标记 | service 设 `true` | | ||
| 69 | +| `tDeletedDate` | 软删除时间 | `LocalDateTime.now()` | | ||
| 70 | +| `sDeletedBy` | 软删除操作人 | JWT principal 或 `stubProps.stubUserNo` | | ||
| 71 | +| 其他字段 | 不动 | — | | ||
| 72 | + | ||
| 73 | +读取表:`tModule`(含子模块查询)。 | ||
| 74 | + | ||
| 75 | +## 依赖的接口 | ||
| 76 | + | ||
| 77 | +无(本 REQ 内部使用 MOD-001/002 已有 mapper 工具方法 + 新增 `hasActiveChildren`)。 | ||
| 78 | + | ||
| 79 | +## 验收标准 | ||
| 80 | + | ||
| 81 | +### 单元测试(追加到 `ModuleServiceImplTest`) | ||
| 82 | + | ||
| 83 | +- [x] `deleteWithValidId_softDeletes_andSetsAuditFields` — Mock `selectById(10)` 返回 alive Module,`hasActiveChildren(10)=false`;ArgumentCaptor 抓 `updateById` 入参,断言 `iIncrement=10` / `bDeleted=true` / `tDeletedDate != null` / `sDeletedBy="STUB_ADMIN"`(无认证上下文);其他字段 null。 | ||
| 84 | +- [x] `deleteWithTargetNotFound_throws40400` — `selectById(99)=null`;不调 `updateById`。 | ||
| 85 | +- [x] `deleteWithTargetAlreadyDeleted_throws40400` — `selectById(10)` 返回 `bDeleted=true` 的 Module;不调 `updateById`。 | ||
| 86 | +- [x] `deleteWithActiveChildren_throws40901` — `selectById(10)` alive;`hasActiveChildren(10)=true`;不调 `updateById`。 | ||
| 87 | +- [x] `deleteSetsDeletedByFromAuthenticatedUser` — SecurityContextHolder 注入 principal `"BOB"`;ArgumentCaptor `sDeletedBy="BOB"`。 | ||
| 88 | + | ||
| 89 | +### Mapper IT(追加到 `ModuleMapperIT`) | ||
| 90 | + | ||
| 91 | +- [x] `hasActiveChildren_trueIfChildAliveExists_falseOtherwise` — 准备 root + alive child + deleted child;断言 `hasActiveChildren(root)=true`;删除 alive child 后再断言 `hasActiveChildren(root)=false`(用 JdbcTemplate UPDATE bDeleted=1)。 | ||
| 92 | + | ||
| 93 | +### 集成测试(追加到 `ModuleControllerIT`) | ||
| 94 | + | ||
| 95 | +- [x] `deleteValidId_with_jwt_returns200_andSoftDeletes` — 直插一行 alive;DELETE 带 JWT="ADMIN001";期望 `code=0` / `data=null`;DB 查 `bDeleted=1` / `sDeletedBy="ADMIN001"` / `tDeletedDate IS NOT NULL`;其他列保持原值(断 `sProcedureName` / `sCreatedBy` / `sBrandsId`)。 | ||
| 96 | +- [x] `deleteNonExistentId_returns40400` — DELETE `/api/mod/modules/99999997`;`code=40400`。 | ||
| 97 | +- [x] `deleteAlreadyDeletedId_returns40400` — 直插 `bDeleted=1` 行;DELETE → `code=40400`。 | ||
| 98 | +- [x] `deleteWithActiveChildren_returns40901` — 直插 root + alive child;DELETE root → `code=40901`;DB 查 root 仍 `bDeleted=0`。 | ||
| 99 | +- [x] `deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB` — 直插 alive;无 token DELETE;`code=0`;DB 查 `sDeletedBy="STUB_ADMIN"`。 | ||
| 100 | +- [x] `deleteTamperedJwt_returns20001` — Authorization 伪造 → `code=20001`,DB 行未被改动。 | ||
| 101 | + | ||
| 102 | +### 工程验收 | ||
| 103 | + | ||
| 104 | +- [x] `cd backend && mvn -B test` 全绿(含 MOD-001 26 + MOD-002 15 + MOD-003 新增 5 service + 1 mapperIT + 6 controllerIT = 53 用例) | ||
| 105 | +- [x] DELETE 接口经路径白名单 `/api/mod/**` permitAll 通过 | ||
| 106 | +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持不动 |
docs/superpowers/specs/2026-04-29-REQ-MOD-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-004 | ||
| 3 | +date: 2026-04-29 | ||
| 4 | +module: module_mod | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-MOD-004 — 模块查询 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +按关键字对 `tModule.sModuleNameZh` 模糊匹配,过滤 `bDeleted=0` 行,按 `iParentId` 拼装为树形(森林)输出。空关键字返回完整模块树。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +### HTTP 接口(docs/05 § REQ-MOD-004) | ||
| 16 | + | ||
| 17 | +- Method / Path: `GET /api/mod/modules` | ||
| 18 | +- Auth: 必需(沿用 MOD-001 stub:路径已在 SecurityConfig `/api/mod/**` permitAll;USR-004 后改 `authenticated()` 即可——不需要 hasAuthority,本接口面向所有登录用户) | ||
| 19 | +- Query: `keyword`(可选;缺省 / 空串 / 仅空白 → 视作空匹配返回完整树) | ||
| 20 | + | ||
| 21 | +### 校验 | ||
| 22 | + | ||
| 23 | +| 输入 | 校验 | 失败码 | | ||
| 24 | +|---|---|---| | ||
| 25 | +| `keyword` | 长度 ≤ 100 字符(与 `sModuleNameZh` 列长一致) | `40001` | | ||
| 26 | + | ||
| 27 | +## 输出 / 结果 | ||
| 28 | + | ||
| 29 | +### 成功响应 | ||
| 30 | + | ||
| 31 | +```json | ||
| 32 | +{ | ||
| 33 | + "code": 0, | ||
| 34 | + "msg": "ok", | ||
| 35 | + "data": [ | ||
| 36 | + { | ||
| 37 | + "iIncrement": 1, | ||
| 38 | + "sModuleNameZh": "系统管理", | ||
| 39 | + "sDisplayType": "手机端", | ||
| 40 | + "sManageDeptEn": "IT", | ||
| 41 | + "iParentId": null, | ||
| 42 | + "iSortOrder": 0, | ||
| 43 | + "children": [ | ||
| 44 | + { "iIncrement": 2, "sModuleNameZh": "用户管理", "sDisplayType": "手机端", | ||
| 45 | + "sManageDeptEn": "IT", "iParentId": 1, "iSortOrder": 0, "children": [] } | ||
| 46 | + ] | ||
| 47 | + } | ||
| 48 | + ] | ||
| 49 | +} | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +`data` 是数组(森林),无命中时为 `[]`。 | ||
| 53 | + | ||
| 54 | +### VO `ModuleTreeVO` | ||
| 55 | + | ||
| 56 | +| 字段 | 类型 | 来源 | | ||
| 57 | +|---|---|---| | ||
| 58 | +| `iIncrement` | `Integer` | `tModule.iIncrement` | | ||
| 59 | +| `sModuleNameZh` | `String` | `tModule.sModuleNameZh` | | ||
| 60 | +| `sDisplayType` | `String` | `tModule.sDisplayType` | | ||
| 61 | +| `sManageDeptEn` | `String` | `tModule.sManageDeptEn` | | ||
| 62 | +| `iParentId` | `Integer` | `tModule.iParentId`(null 表根) | | ||
| 63 | +| `iSortOrder` | `Integer` | `tModule.iSortOrder` | | ||
| 64 | +| `children` | `List<ModuleTreeVO>` | 拼装得到,叶节点为 `[]` | | ||
| 65 | + | ||
| 66 | +> 不返回 `sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `bShowPermission` / `sModuleType` / 软删除审计字段——这些不在 docs/05 输出 schema 中。 | ||
| 67 | + | ||
| 68 | +## 业务规则 | ||
| 69 | + | ||
| 70 | +1. **关键字归一化**:controller 先 trim;null 或空串均当作空匹配。 | ||
| 71 | +2. **长度校验**:trim 后长度 > 100 → `BizException(40001, "keyword 长度超过 100 字符")`。 | ||
| 72 | +3. **DB 查询**:`SELECT iIncrement, sModuleNameZh, sDisplayType, sManageDeptEn, iParentId, iSortOrder FROM tModule WHERE bDeleted=0 AND sModuleNameZh LIKE CONCAT('%', #{keyword}, '%') ORDER BY iSortOrder ASC, iIncrement ASC`;空 keyword → `LIKE '%%'` 命中所有。 | ||
| 73 | +4. **拼树**(service 内存算法): | ||
| 74 | + - 把命中行映射为 `ModuleTreeVO`;建 `Map<Integer, ModuleTreeVO> idIndex` | ||
| 75 | + - 遍历:若 `iParentId != null && idIndex.containsKey(iParentId)` → 挂到 parent.children;否则视作 root(含真 root 与"父被过滤掉"的孤立节点),加入返回 list | ||
| 76 | + - 由于 SQL 已 ORDER BY,拼装顺序天然有序;同 parent 下 children 顺序 = SQL 顺序 | ||
| 77 | +5. **只读**:service 上 `@Transactional(readOnly = true)`,明示无写副作用。 | ||
| 78 | +6. **空结果**:返回 `[]`,HTTP 200 / `code=0`。 | ||
| 79 | + | ||
| 80 | +## 边界与约束 | ||
| 81 | + | ||
| 82 | +- **`keyword` 缺失 / 空 / 仅空白** → 等价空匹配,返回完整有效树 | ||
| 83 | +- **`keyword` > 100 字符** → `40001` | ||
| 84 | +- **JWT 伪造** → `20001`(filter 短路) | ||
| 85 | +- **JWT 缺失** → permitAll stub,正常 200(USR-004 后改为要求登录) | ||
| 86 | +- **SQL LIKE 通配符注入**:`keyword` 含 `%` / `_` 时 MyBatis 直接拼到 LIKE 中——理论上影响匹配范围(`%abc%` 用户输入 `%` 等于 `LIKE '%%abc%%'` 仍匹配所有)。本期不做转义(业务上不敏感且 docs/05 未要求);后续若需要严格匹配可在 service 层做 `keyword.replace("%","\\%").replace("_","\\_")` 处理。spec 记录该选择。 | ||
| 87 | + | ||
| 88 | +## 实现范围与边界抉择 | ||
| 89 | + | ||
| 90 | +1. **拼树策略**:选择"过滤命中后拼树(孤立子节点视为 root,即森林)",与 docs/03 § tModule 业务注记一致。**不实现"扩展祖先链以保留树形"**——会让查询语义复杂化(要么二次查祖先,要么 SQL 用 CTE);REQ 卡仅要求"以树形结构展示匹配结果",森林是合法的树形结果。 | ||
| 91 | +2. **Mapper 直接 SQL LIKE**:相对"先全查再内存过滤"更高效;模块数据量大时也撑得住。 | ||
| 92 | +3. **Service 排序在 SQL**:拼树时不再排序,避免重复劳动;测试断言依赖 SQL `ORDER BY iSortOrder ASC, iIncrement ASC`。 | ||
| 93 | + | ||
| 94 | +## 依赖的 schema 表 / 字段 | ||
| 95 | + | ||
| 96 | +读取表:`tModule` | ||
| 97 | + | ||
| 98 | +| 字段 | 用途 | | ||
| 99 | +|---|---| | ||
| 100 | +| `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` | 输出 VO 字段 | | ||
| 101 | +| `bDeleted` | 过滤条件(=0) | | ||
| 102 | + | ||
| 103 | +依赖索引:`idx_module_name_zh(sModuleNameZh)` 命中 LIKE 前缀匹配;`idx_parent(iParentId)` 不直接命中(拼树用内存 Map),但保留供未来 join 用。 | ||
| 104 | + | ||
| 105 | +## 依赖的接口 | ||
| 106 | + | ||
| 107 | +无。 | ||
| 108 | + | ||
| 109 | +## 验收标准 | ||
| 110 | + | ||
| 111 | +### 单元测试(追加到 `ModuleServiceImplTest`) | ||
| 112 | + | ||
| 113 | +- [x] `listTree_emptyKeyword_invokesMapperWithEmptyString_returnsAssembledTree` — Mock `selectActiveByKeyword("")` 返回 5 行(root1, child1, child2, deepChild1, root2),ArgumentCaptor 验 mapper 入参为 `""`;返回结构符合树(root1.children 含 child1/child2;child1.children 含 deepChild1;root2.children 空) | ||
| 114 | +- [x] `listTree_nullKeyword_treatedAsEmpty` — DTO `keyword=null`,效果同空串 | ||
| 115 | +- [x] `listTree_blankKeyword_treatedAsEmpty` — keyword `" "` trim 后空 | ||
| 116 | +- [x] `listTree_keywordTooLong_throws40001` — keyword 101 字符 → BizException(40001) | ||
| 117 | +- [x] `listTree_returnsEmptyListWhenNoMatch` — Mock 返回空 list;service 返回 `List.of()` | ||
| 118 | +- [x] `listTree_orphansBecomeRootsInForest` — Mock 返回 [child](其父 iParentId=99 不在结果集);child 出现在顶层 list | ||
| 119 | +- [x] `listTree_keywordIsTrimmedBeforeQuery` — keyword `" 系统 "` → mapper 入参 `"系统"` | ||
| 120 | + | ||
| 121 | +### Mapper IT(追加到 `ModuleMapperIT`) | ||
| 122 | + | ||
| 123 | +- [x] `selectActiveByKeyword_filtersAndOrders` — 准备 5 行(含 1 个 bDeleted=1);查 `keyword=""` → 4 行(按 iSortOrder, iIncrement 升序);查 `keyword="系统"` → 仅命中 sModuleNameZh 含"系统"的活跃行;查 `keyword="不存在"` → 空 | ||
| 124 | + | ||
| 125 | +### 集成测试(追加到 `ModuleControllerIT`) | ||
| 126 | + | ||
| 127 | +- [x] `getEmptyKeyword_returnsCompleteTreeAsForest` — 直插 root + child;GET `/api/mod/modules` 带 JWT;`code=0`;`data` 是数组;至少含 root 节点且其 children 含 child | ||
| 128 | +- [x] `getKeywordMatch_returnsForest` — 直插含"系统"的 alive 模块 + 不含"系统"的;GET `?keyword=系统`;只返回含"系统"的 | ||
| 129 | +- [x] `getKeywordTooLong_returns40001` — `keyword` 101 字符 → `code=40001` | ||
| 130 | +- [x] `getNoMatch_returnsEmptyArray` — `keyword=不存在的关键字XYZ`;`data` 是 `[]` | ||
| 131 | +- [x] `getWithoutJwt_permitAllStub_returns200` — 无 JWT GET;`code=0` | ||
| 132 | +- [x] `getTamperedJwt_returns20001` — Authorization 伪造 → `code=20001` | ||
| 133 | + | ||
| 134 | +### 工程验收 | ||
| 135 | + | ||
| 136 | +- [x] `cd backend && mvn -B test` 全绿(53 + MOD-004 新增 7(svc) + 1(mapperIT) + 6(controllerIT) = 67 用例) | ||
| 137 | +- [x] 输出 VO 字段集严格匹配 docs/05 列表,不暴露敏感字段 | ||
| 138 | +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持(路径已 permitAll,无需新增) |
scripts/setup-test-db.sh
| @@ -13,6 +13,12 @@ | @@ -13,6 +13,12 @@ | ||
| 13 | 13 | ||
| 14 | set -euo pipefail | 14 | set -euo pipefail |
| 15 | 15 | ||
| 16 | +# macOS Homebrew 的 mysql-client 是 keg-only,默认不在 PATH;非交互式 bash 也不读 ~/.zshrc。 | ||
| 17 | +# 这里幂等 prepend 常见安装路径,命中则用,未命中(如 Linux CI 已自带 mysql)则跳过。 | ||
| 18 | +for p in /opt/homebrew/opt/mysql-client/bin /usr/local/opt/mysql-client/bin; do | ||
| 19 | + [ -d "$p" ] && case ":$PATH:" in *":$p:"*) ;; *) PATH="$p:$PATH" ;; esac | ||
| 20 | +done | ||
| 21 | + | ||
| 16 | ENV_FILE="$(dirname "$0")/../.env.local" | 22 | ENV_FILE="$(dirname "$0")/../.env.local" |
| 17 | [ -f "$ENV_FILE" ] || { echo "[setup-test-db] ⚠️ .env.local 不存在($ENV_FILE)" >&2; exit 1; } | 23 | [ -f "$ENV_FILE" ] || { echo "[setup-test-db] ⚠️ .env.local 不存在($ENV_FILE)" >&2; exit 1; } |
| 18 | 24 |
scripts/test.sh
| @@ -12,13 +12,27 @@ echo "[test.sh] 1/6 setup test db" | @@ -12,13 +12,27 @@ echo "[test.sh] 1/6 setup test db" | ||
| 12 | ./scripts/setup-test-db.sh | 12 | ./scripts/setup-test-db.sh |
| 13 | 13 | ||
| 14 | echo "[test.sh] 2/6 build" | 14 | echo "[test.sh] 2/6 build" |
| 15 | -(cd backend && mvn -B -DskipTests clean package) && (cd frontend && npm ci && npm run build) | 15 | +(cd backend && mvn -B -DskipTests clean package) |
| 16 | +if [ -d frontend ]; then | ||
| 17 | + (cd frontend && npm ci && npm run build) | ||
| 18 | +else | ||
| 19 | + echo "[test.sh] skip frontend build (frontend/ not initialized yet)" | ||
| 20 | +fi | ||
| 16 | 21 | ||
| 17 | echo "[test.sh] 3/6 lint" | 22 | echo "[test.sh] 3/6 lint" |
| 18 | -(cd frontend && npm run lint) | 23 | +if [ -d frontend ]; then |
| 24 | + (cd frontend && npm run lint) | ||
| 25 | +else | ||
| 26 | + echo "[test.sh] skip frontend lint (frontend/ not initialized yet)" | ||
| 27 | +fi | ||
| 19 | 28 | ||
| 20 | echo "[test.sh] 4/6 unit + integration" | 29 | echo "[test.sh] 4/6 unit + integration" |
| 21 | -(cd backend && mvn -B test) && (cd frontend && npm test -- --run) | 30 | +(cd backend && mvn -B test) |
| 31 | +if [ -d frontend ]; then | ||
| 32 | + (cd frontend && npm test -- --run) | ||
| 33 | +else | ||
| 34 | + echo "[test.sh] skip frontend unit tests (frontend/ not initialized yet)" | ||
| 35 | +fi | ||
| 22 | 36 | ||
| 23 | echo "[test.sh] 5/6 E2E" | 37 | echo "[test.sh] 5/6 E2E" |
| 24 | echo "[test.sh] e2e 略" | 38 | echo "[test.sh] e2e 略" |