Commit d05911cd730edf9758c0747d53df1e1d9351f6d7
1 parent
eb40632c
feat(usr): BaseEntity 与 MP 自动填充、Security/JWT 基础设施 REQ-USR-001
Showing
8 changed files
with
410 additions
and
0 deletions
backend/src/main/java/com/xly/erp/common/base/BaseEntity.java
0 → 100644
| 1 | +package com.xly.erp.common.base; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.FieldFill; | |
| 4 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 6 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 7 | +import java.io.Serializable; | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 标准列公共字段基类(docs/03 标准列 + docs/04 § 3.4)。 | |
| 12 | + * | |
| 13 | + * <p>REQ-USR-001 T3:所有业务实体复用的公共列——整数主键、业务 ID、多租户隔离列、创建时间。 | |
| 14 | + * {@code tCreateDate} 在 INSERT 时由 MybatisPlusConfig 的 MetaObjectHandler 自动填充。</p> | |
| 15 | + */ | |
| 16 | +public abstract class BaseEntity implements Serializable { | |
| 17 | + | |
| 18 | + private static final long serialVersionUID = 1L; | |
| 19 | + | |
| 20 | + /** 整数主键 ID(标准列,自增)。 */ | |
| 21 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 22 | + private Integer iIncrement; | |
| 23 | + | |
| 24 | + /** 业务 ID(标准列,可空)。 */ | |
| 25 | + @TableField("sId") | |
| 26 | + private String sId; | |
| 27 | + | |
| 28 | + /** 品牌 ID,多租户隔离(标准列,DB 默认 1111111111)。 */ | |
| 29 | + @TableField("sBrandsId") | |
| 30 | + private String sBrandsId; | |
| 31 | + | |
| 32 | + /** 子公司 ID,组织层级隔离(标准列,DB 默认 1111111111)。 */ | |
| 33 | + @TableField("sSubsidiaryId") | |
| 34 | + private String sSubsidiaryId; | |
| 35 | + | |
| 36 | + /** 创建时间(标准列,INSERT 自动填充)。 */ | |
| 37 | + @TableField(value = "tCreateDate", fill = FieldFill.INSERT) | |
| 38 | + private LocalDateTime tCreateDate; | |
| 39 | + | |
| 40 | + public Integer getIIncrement() { | |
| 41 | + return iIncrement; | |
| 42 | + } | |
| 43 | + | |
| 44 | + public void setIIncrement(Integer iIncrement) { | |
| 45 | + this.iIncrement = iIncrement; | |
| 46 | + } | |
| 47 | + | |
| 48 | + public String getSId() { | |
| 49 | + return sId; | |
| 50 | + } | |
| 51 | + | |
| 52 | + public void setSId(String sId) { | |
| 53 | + this.sId = sId; | |
| 54 | + } | |
| 55 | + | |
| 56 | + public String getSBrandsId() { | |
| 57 | + return sBrandsId; | |
| 58 | + } | |
| 59 | + | |
| 60 | + public void setSBrandsId(String sBrandsId) { | |
| 61 | + this.sBrandsId = sBrandsId; | |
| 62 | + } | |
| 63 | + | |
| 64 | + public String getSSubsidiaryId() { | |
| 65 | + return sSubsidiaryId; | |
| 66 | + } | |
| 67 | + | |
| 68 | + public void setSSubsidiaryId(String sSubsidiaryId) { | |
| 69 | + this.sSubsidiaryId = sSubsidiaryId; | |
| 70 | + } | |
| 71 | + | |
| 72 | + public LocalDateTime getTCreateDate() { | |
| 73 | + return tCreateDate; | |
| 74 | + } | |
| 75 | + | |
| 76 | + public void setTCreateDate(LocalDateTime tCreateDate) { | |
| 77 | + this.tCreateDate = tCreateDate; | |
| 78 | + } | |
| 79 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.DbType; | |
| 4 | +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; | |
| 5 | +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; | |
| 6 | +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; | |
| 7 | +import java.time.LocalDateTime; | |
| 8 | +import org.apache.ibatis.reflection.MetaObject; | |
| 9 | +import org.springframework.context.annotation.Bean; | |
| 10 | +import org.springframework.context.annotation.Configuration; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * MyBatis-Plus 配置(docs/04 § 3.4)。 | |
| 14 | + * | |
| 15 | + * <p>REQ-USR-001 T3:注册创建时间自动填充处理器(INSERT 填 tCreateDate), | |
| 16 | + * 并注册分页插件供后续 REQ(如 REQ-USR-003 查询)复用。</p> | |
| 17 | + */ | |
| 18 | +@Configuration | |
| 19 | +public class MybatisPlusConfig { | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * 分页插件(MySQL)。 | |
| 23 | + */ | |
| 24 | + @Bean | |
| 25 | + public MybatisPlusInterceptor mybatisPlusInterceptor() { | |
| 26 | + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); | |
| 27 | + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); | |
| 28 | + return interceptor; | |
| 29 | + } | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * 审计字段自动填充:INSERT 时若 tCreateDate 为空则填当前时间。 | |
| 33 | + */ | |
| 34 | + @Bean | |
| 35 | + public MetaObjectHandler metaObjectHandler() { | |
| 36 | + return new MetaObjectHandler() { | |
| 37 | + @Override | |
| 38 | + public void insertFill(MetaObject metaObject) { | |
| 39 | + strictInsertFill(metaObject, "tCreateDate", LocalDateTime.class, LocalDateTime.now()); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Override | |
| 43 | + public void updateFill(MetaObject metaObject) { | |
| 44 | + // 本阶段无更新审计字段需求。 | |
| 45 | + } | |
| 46 | + }; | |
| 47 | + } | |
| 48 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.security.JwtAuthenticationFilter; | |
| 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.annotation.web.configurers.AbstractHttpConfigurer; | |
| 8 | +import org.springframework.security.config.http.SessionCreationPolicy; | |
| 9 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 10 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 11 | +import org.springframework.security.web.SecurityFilterChain; | |
| 12 | +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Spring Security 配置(docs/04 § 1.7)。 | |
| 16 | + * | |
| 17 | + * <p>REQ-USR-001 T3:无状态 JWT 认证;放行 /api/usr/login、swagger、actuator/health, | |
| 18 | + * 其余需认证;注册 BCryptPasswordEncoder Bean;JwtAuthenticationFilter 挂在 | |
| 19 | + * UsernamePasswordAuthenticationFilter 之前。</p> | |
| 20 | + */ | |
| 21 | +@Configuration | |
| 22 | +public class SecurityConfig { | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * 密码哈希编码器(BCrypt),禁止明文存储 / 比对。 | |
| 26 | + */ | |
| 27 | + @Bean | |
| 28 | + public PasswordEncoder passwordEncoder() { | |
| 29 | + return new BCryptPasswordEncoder(); | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Bean | |
| 33 | + public SecurityFilterChain securityFilterChain(HttpSecurity http, | |
| 34 | + JwtAuthenticationFilter jwtAuthenticationFilter) | |
| 35 | + throws Exception { | |
| 36 | + http | |
| 37 | + .csrf(AbstractHttpConfigurer::disable) | |
| 38 | + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | |
| 39 | + .authorizeHttpRequests(auth -> auth | |
| 40 | + .requestMatchers( | |
| 41 | + "/api/usr/login", | |
| 42 | + "/swagger-ui/**", | |
| 43 | + "/swagger-ui.html", | |
| 44 | + "/v3/api-docs/**", | |
| 45 | + "/actuator/health") | |
| 46 | + .permitAll() | |
| 47 | + .anyRequest().authenticated()) | |
| 48 | + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); | |
| 49 | + return http.build(); | |
| 50 | + } | |
| 51 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import jakarta.servlet.FilterChain; | |
| 4 | +import jakarta.servlet.ServletException; | |
| 5 | +import jakarta.servlet.http.HttpServletRequest; | |
| 6 | +import jakarta.servlet.http.HttpServletResponse; | |
| 7 | +import java.io.IOException; | |
| 8 | +import java.util.List; | |
| 9 | +import org.springframework.lang.NonNull; | |
| 10 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | |
| 11 | +import org.springframework.security.core.authority.SimpleGrantedAuthority; | |
| 12 | +import org.springframework.security.core.context.SecurityContextHolder; | |
| 13 | +import org.springframework.stereotype.Component; | |
| 14 | +import org.springframework.web.filter.OncePerRequestFilter; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * JWT 认证过滤器。 | |
| 18 | + * | |
| 19 | + * <p>REQ-USR-001 T3:解析 {@code Authorization: Bearer <token>},校验通过后把 | |
| 20 | + * sUserName 作为 principal、{@code TYPE_<sUserType>} 作为 authority 放入 SecurityContext。</p> | |
| 21 | + */ | |
| 22 | +@Component | |
| 23 | +public class JwtAuthenticationFilter extends OncePerRequestFilter { | |
| 24 | + | |
| 25 | + private static final String HEADER = "Authorization"; | |
| 26 | + private static final String PREFIX = "Bearer "; | |
| 27 | + | |
| 28 | + private final JwtUtil jwtUtil; | |
| 29 | + | |
| 30 | + public JwtAuthenticationFilter(JwtUtil jwtUtil) { | |
| 31 | + this.jwtUtil = jwtUtil; | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Override | |
| 35 | + protected void doFilterInternal(@NonNull HttpServletRequest request, | |
| 36 | + @NonNull HttpServletResponse response, | |
| 37 | + @NonNull FilterChain filterChain) | |
| 38 | + throws ServletException, IOException { | |
| 39 | + String header = request.getHeader(HEADER); | |
| 40 | + if (header != null && header.startsWith(PREFIX)) { | |
| 41 | + String token = header.substring(PREFIX.length()); | |
| 42 | + if (jwtUtil.validateToken(token)) { | |
| 43 | + String userName = jwtUtil.getUserName(token); | |
| 44 | + String userType = jwtUtil.getUserType(token); | |
| 45 | + List<SimpleGrantedAuthority> authorities = | |
| 46 | + List.of(new SimpleGrantedAuthority(SecurityUtil.TYPE_PREFIX + userType)); | |
| 47 | + UsernamePasswordAuthenticationToken authentication = | |
| 48 | + new UsernamePasswordAuthenticationToken(userName, null, authorities); | |
| 49 | + SecurityContextHolder.getContext().setAuthentication(authentication); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + filterChain.doFilter(request, response); | |
| 53 | + } | |
| 54 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import io.jsonwebtoken.Claims; | |
| 4 | +import io.jsonwebtoken.Jwts; | |
| 5 | +import io.jsonwebtoken.security.Keys; | |
| 6 | +import java.nio.charset.StandardCharsets; | |
| 7 | +import java.util.Date; | |
| 8 | +import javax.crypto.SecretKey; | |
| 9 | +import org.springframework.beans.factory.annotation.Value; | |
| 10 | +import org.springframework.stereotype.Component; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * JWT 工具(docs/04 § 1.7)。 | |
| 14 | + * | |
| 15 | + * <p>REQ-USR-001 T3:签发 / 解析含 sUserName + sUserType claim 的无状态 token; | |
| 16 | + * 密钥取自 config-vars.yaml secrets.jwt_secret(经 application.yml 注入),禁止硬编码。</p> | |
| 17 | + */ | |
| 18 | +@Component | |
| 19 | +public class JwtUtil { | |
| 20 | + | |
| 21 | + /** claim:用户名。 */ | |
| 22 | + public static final String CLAIM_USER_NAME = "sUserName"; | |
| 23 | + /** claim:用户类型。 */ | |
| 24 | + public static final String CLAIM_USER_TYPE = "sUserType"; | |
| 25 | + | |
| 26 | + private final SecretKey secretKey; | |
| 27 | + private final long expireMillis; | |
| 28 | + | |
| 29 | + public JwtUtil(@Value("${jwt.secret}") String secret, | |
| 30 | + @Value("${jwt.expire-millis}") long expireMillis) { | |
| 31 | + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); | |
| 32 | + this.expireMillis = expireMillis; | |
| 33 | + } | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * 签发 token,主体为用户名,并附带用户类型 claim。 | |
| 37 | + */ | |
| 38 | + public String generateToken(String userName, String userType) { | |
| 39 | + Date now = new Date(); | |
| 40 | + Date expiry = new Date(now.getTime() + expireMillis); | |
| 41 | + return Jwts.builder() | |
| 42 | + .subject(userName) | |
| 43 | + .claim(CLAIM_USER_NAME, userName) | |
| 44 | + .claim(CLAIM_USER_TYPE, userType) | |
| 45 | + .issuedAt(now) | |
| 46 | + .expiration(expiry) | |
| 47 | + .signWith(secretKey) | |
| 48 | + .compact(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + /** | |
| 52 | + * 校验 token 签名与有效期;非法 / 过期返回 false。 | |
| 53 | + */ | |
| 54 | + public boolean validateToken(String token) { | |
| 55 | + try { | |
| 56 | + parseClaims(token); | |
| 57 | + return true; | |
| 58 | + } catch (RuntimeException ex) { | |
| 59 | + return false; | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + public String getUserName(String token) { | |
| 64 | + return parseClaims(token).get(CLAIM_USER_NAME, String.class); | |
| 65 | + } | |
| 66 | + | |
| 67 | + public String getUserType(String token) { | |
| 68 | + return parseClaims(token).get(CLAIM_USER_TYPE, String.class); | |
| 69 | + } | |
| 70 | + | |
| 71 | + private Claims parseClaims(String token) { | |
| 72 | + return Jwts.parser() | |
| 73 | + .verifyWith(secretKey) | |
| 74 | + .build() | |
| 75 | + .parseSignedClaims(token) | |
| 76 | + .getPayload(); | |
| 77 | + } | |
| 78 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/SecurityUtil.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import org.springframework.security.core.Authentication; | |
| 4 | +import org.springframework.security.core.GrantedAuthority; | |
| 5 | +import org.springframework.security.core.context.SecurityContextHolder; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 安全上下文工具:取当前登录用户名 / 用户类型。 | |
| 9 | + * | |
| 10 | + * <p>REQ-USR-001 T3。用户名为 {@link Authentication#getName()}(principal), | |
| 11 | + * 用户类型存放在 authorities 中(以 {@code TYPE_} 前缀标识)。</p> | |
| 12 | + */ | |
| 13 | +public final class SecurityUtil { | |
| 14 | + | |
| 15 | + /** 用户类型 authority 前缀。 */ | |
| 16 | + public static final String TYPE_PREFIX = "TYPE_"; | |
| 17 | + | |
| 18 | + private SecurityUtil() { | |
| 19 | + } | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * 当前登录用户名(JWT 主体 sUserName);未认证返回 null。 | |
| 23 | + */ | |
| 24 | + public static String currentUserName() { | |
| 25 | + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | |
| 26 | + if (auth == null || !auth.isAuthenticated()) { | |
| 27 | + return null; | |
| 28 | + } | |
| 29 | + return auth.getName(); | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * 当前登录用户类型(sUserType,用于管理员判定);取不到返回 null。 | |
| 34 | + */ | |
| 35 | + public static String currentUserType() { | |
| 36 | + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | |
| 37 | + if (auth == null || !auth.isAuthenticated()) { | |
| 38 | + return null; | |
| 39 | + } | |
| 40 | + for (GrantedAuthority authority : auth.getAuthorities()) { | |
| 41 | + String role = authority.getAuthority(); | |
| 42 | + if (role != null && role.startsWith(TYPE_PREFIX)) { | |
| 43 | + return role.substring(TYPE_PREFIX.length()); | |
| 44 | + } | |
| 45 | + } | |
| 46 | + return null; | |
| 47 | + } | |
| 48 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 7 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * REQ-USR-001 T3:BCryptPasswordEncoder Bean 行为(轻量单元,直接 new SecurityConfig 取 Bean)。 | |
| 11 | + */ | |
| 12 | +class SecurityConfigTest { | |
| 13 | + | |
| 14 | + @Test | |
| 15 | + void bcryptEncoderEncodesAndMatches() { | |
| 16 | + SecurityConfig config = new SecurityConfig(); | |
| 17 | + PasswordEncoder encoder = config.passwordEncoder(); | |
| 18 | + assertThat(encoder).isInstanceOf(BCryptPasswordEncoder.class); | |
| 19 | + | |
| 20 | + String hash = encoder.encode("666666"); | |
| 21 | + assertThat(hash).isNotEqualTo("666666"); | |
| 22 | + assertThat(encoder.matches("666666", hash)).isTrue(); | |
| 23 | + assertThat(encoder.matches("wrong", hash)).isFalse(); | |
| 24 | + } | |
| 25 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * REQ-USR-001 T3:JWT 签发 / 解析往返。 | |
| 9 | + * | |
| 10 | + * <p>用 config-vars 的 jwt_secret 签发含 sUserName + sUserType claim 的 token, | |
| 11 | + * 解析后能取回相同值且校验通过。</p> | |
| 12 | + */ | |
| 13 | +class JwtUtilTest { | |
| 14 | + | |
| 15 | + private static final String SECRET = | |
| 16 | + "a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2"; | |
| 17 | + | |
| 18 | + @Test | |
| 19 | + void generateThenParseRoundTrip() { | |
| 20 | + JwtUtil jwtUtil = new JwtUtil(SECRET, 43200000L); | |
| 21 | + String token = jwtUtil.generateToken("admin", "超级管理员"); | |
| 22 | + | |
| 23 | + assertThat(jwtUtil.validateToken(token)).isTrue(); | |
| 24 | + assertThat(jwtUtil.getUserName(token)).isEqualTo("admin"); | |
| 25 | + assertThat(jwtUtil.getUserType(token)).isEqualTo("超级管理员"); | |
| 26 | + } | |
| 27 | +} | ... | ... |