Commit d05911cd730edf9758c0747d53df1e1d9351f6d7

Authored by zichun
1 parent eb40632c

feat(usr): BaseEntity 与 MP 自动填充、Security/JWT 基础设施 REQ-USR-001

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 +}