diff --git a/backend/src/main/java/com/xly/erp/common/base/BaseEntity.java b/backend/src/main/java/com/xly/erp/common/base/BaseEntity.java new file mode 100644 index 0000000..2a0d254 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/base/BaseEntity.java @@ -0,0 +1,79 @@ +package com.xly.erp.common.base; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 标准列公共字段基类(docs/03 标准列 + docs/04 § 3.4)。 + * + *
REQ-USR-001 T3:所有业务实体复用的公共列——整数主键、业务 ID、多租户隔离列、创建时间。 + * {@code tCreateDate} 在 INSERT 时由 MybatisPlusConfig 的 MetaObjectHandler 自动填充。
+ */ +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 整数主键 ID(标准列,自增)。 */ + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + /** 业务 ID(标准列,可空)。 */ + @TableField("sId") + private String sId; + + /** 品牌 ID,多租户隔离(标准列,DB 默认 1111111111)。 */ + @TableField("sBrandsId") + private String sBrandsId; + + /** 子公司 ID,组织层级隔离(标准列,DB 默认 1111111111)。 */ + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + /** 创建时间(标准列,INSERT 自动填充)。 */ + @TableField(value = "tCreateDate", fill = FieldFill.INSERT) + private LocalDateTime tCreateDate; + + public Integer getIIncrement() { + return iIncrement; + } + + public void setIIncrement(Integer iIncrement) { + this.iIncrement = iIncrement; + } + + public String getSId() { + return sId; + } + + public void setSId(String sId) { + this.sId = sId; + } + + public String getSBrandsId() { + return sBrandsId; + } + + public void setSBrandsId(String sBrandsId) { + this.sBrandsId = sBrandsId; + } + + public String getSSubsidiaryId() { + return sSubsidiaryId; + } + + public void setSSubsidiaryId(String sSubsidiaryId) { + this.sSubsidiaryId = sSubsidiaryId; + } + + public LocalDateTime getTCreateDate() { + return tCreateDate; + } + + public void setTCreateDate(LocalDateTime tCreateDate) { + this.tCreateDate = tCreateDate; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java b/backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java new file mode 100644 index 0000000..f8e1058 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java @@ -0,0 +1,48 @@ +package com.xly.erp.common.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import java.time.LocalDateTime; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis-Plus 配置(docs/04 § 3.4)。 + * + *REQ-USR-001 T3:注册创建时间自动填充处理器(INSERT 填 tCreateDate), + * 并注册分页插件供后续 REQ(如 REQ-USR-003 查询)复用。
+ */ +@Configuration +public class MybatisPlusConfig { + + /** + * 分页插件(MySQL)。 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } + + /** + * 审计字段自动填充:INSERT 时若 tCreateDate 为空则填当前时间。 + */ + @Bean + public MetaObjectHandler metaObjectHandler() { + return new MetaObjectHandler() { + @Override + public void insertFill(MetaObject metaObject) { + strictInsertFill(metaObject, "tCreateDate", LocalDateTime.class, LocalDateTime.now()); + } + + @Override + public void updateFill(MetaObject metaObject) { + // 本阶段无更新审计字段需求。 + } + }; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java new file mode 100644 index 0000000..0bef1c0 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java @@ -0,0 +1,51 @@ +package com.xly.erp.common.config; + +import com.xly.erp.common.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Spring Security 配置(docs/04 § 1.7)。 + * + *REQ-USR-001 T3:无状态 JWT 认证;放行 /api/usr/login、swagger、actuator/health, + * 其余需认证;注册 BCryptPasswordEncoder Bean;JwtAuthenticationFilter 挂在 + * UsernamePasswordAuthenticationFilter 之前。
+ */ +@Configuration +public class SecurityConfig { + + /** + * 密码哈希编码器(BCrypt),禁止明文存储 / 比对。 + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter) + throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/usr/login", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health") + .permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..e985496 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.xly.erp.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * JWT 认证过滤器。 + * + *REQ-USR-001 T3:解析 {@code Authorization: Bearer
REQ-USR-001 T3:签发 / 解析含 sUserName + sUserType claim 的无状态 token; + * 密钥取自 config-vars.yaml secrets.jwt_secret(经 application.yml 注入),禁止硬编码。
+ */ +@Component +public class JwtUtil { + + /** claim:用户名。 */ + public static final String CLAIM_USER_NAME = "sUserName"; + /** claim:用户类型。 */ + public static final String CLAIM_USER_TYPE = "sUserType"; + + private final SecretKey secretKey; + private final long expireMillis; + + public JwtUtil(@Value("${jwt.secret}") String secret, + @Value("${jwt.expire-millis}") long expireMillis) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expireMillis = expireMillis; + } + + /** + * 签发 token,主体为用户名,并附带用户类型 claim。 + */ + public String generateToken(String userName, String userType) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expireMillis); + return Jwts.builder() + .subject(userName) + .claim(CLAIM_USER_NAME, userName) + .claim(CLAIM_USER_TYPE, userType) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + } + + /** + * 校验 token 签名与有效期;非法 / 过期返回 false。 + */ + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (RuntimeException ex) { + return false; + } + } + + public String getUserName(String token) { + return parseClaims(token).get(CLAIM_USER_NAME, String.class); + } + + public String getUserType(String token) { + return parseClaims(token).get(CLAIM_USER_TYPE, String.class); + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/SecurityUtil.java b/backend/src/main/java/com/xly/erp/common/security/SecurityUtil.java new file mode 100644 index 0000000..3d62d75 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/SecurityUtil.java @@ -0,0 +1,48 @@ +package com.xly.erp.common.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * 安全上下文工具:取当前登录用户名 / 用户类型。 + * + *REQ-USR-001 T3。用户名为 {@link Authentication#getName()}(principal), + * 用户类型存放在 authorities 中(以 {@code TYPE_} 前缀标识)。
+ */ +public final class SecurityUtil { + + /** 用户类型 authority 前缀。 */ + public static final String TYPE_PREFIX = "TYPE_"; + + private SecurityUtil() { + } + + /** + * 当前登录用户名(JWT 主体 sUserName);未认证返回 null。 + */ + public static String currentUserName() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + return auth.getName(); + } + + /** + * 当前登录用户类型(sUserType,用于管理员判定);取不到返回 null。 + */ + public static String currentUserType() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + for (GrantedAuthority authority : auth.getAuthorities()) { + String role = authority.getAuthority(); + if (role != null && role.startsWith(TYPE_PREFIX)) { + return role.substring(TYPE_PREFIX.length()); + } + } + return null; + } +} diff --git a/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java b/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java new file mode 100644 index 0000000..d5389c6 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java @@ -0,0 +1,25 @@ +package com.xly.erp.common.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * REQ-USR-001 T3:BCryptPasswordEncoder Bean 行为(轻量单元,直接 new SecurityConfig 取 Bean)。 + */ +class SecurityConfigTest { + + @Test + void bcryptEncoderEncodesAndMatches() { + SecurityConfig config = new SecurityConfig(); + PasswordEncoder encoder = config.passwordEncoder(); + assertThat(encoder).isInstanceOf(BCryptPasswordEncoder.class); + + String hash = encoder.encode("666666"); + assertThat(hash).isNotEqualTo("666666"); + assertThat(encoder.matches("666666", hash)).isTrue(); + assertThat(encoder.matches("wrong", hash)).isFalse(); + } +} diff --git a/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java b/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java new file mode 100644 index 0000000..1808c43 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java @@ -0,0 +1,27 @@ +package com.xly.erp.common.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-001 T3:JWT 签发 / 解析往返。 + * + *用 config-vars 的 jwt_secret 签发含 sUserName + sUserType claim 的 token, + * 解析后能取回相同值且校验通过。
+ */ +class JwtUtilTest { + + private static final String SECRET = + "a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2"; + + @Test + void generateThenParseRoundTrip() { + JwtUtil jwtUtil = new JwtUtil(SECRET, 43200000L); + String token = jwtUtil.generateToken("admin", "超级管理员"); + + assertThat(jwtUtil.validateToken(token)).isTrue(); + assertThat(jwtUtil.getUserName(token)).isEqualTo("admin"); + assertThat(jwtUtil.getUserType(token)).isEqualTo("超级管理员"); + } +}