diff --git a/backend/checkstyle.xml b/backend/checkstyle.xml
new file mode 100644
index 0000000..37af12a
--- /dev/null
+++ b/backend/checkstyle.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 0000000..54b73cc
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,131 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+
+ com.xly
+ erp-backend
+ 1.0.0
+ jar
+ xly-erp-backend
+ 小羚羊 ERP 后端服务
+
+
+ 17
+ 17
+ UTF-8
+ 3.5.7
+ 10.10.0
+ 0.12.5
+ 5.8.27
+ 3.3.1
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+ ${mybatis-plus.version}
+
+
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+
+
+ org.flywaydb
+ flyway-core
+ ${flyway.version}
+
+
+ org.flywaydb
+ flyway-mysql
+ ${flyway.version}
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+ runtime
+
+
+
+
+ cn.hutool
+ hutool-core
+ ${hutool.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+
+
+ erp-backend
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+ ${checkstyle.plugin.version}
+
+ checkstyle.xml
+ true
+ true
+ false
+
+
+
+
+
diff --git a/backend/src/main/java/com/xly/erp/ErpApplication.java b/backend/src/main/java/com/xly/erp/ErpApplication.java
new file mode 100644
index 0000000..fb9ddf7
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/ErpApplication.java
@@ -0,0 +1,19 @@
+package com.xly.erp;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * 小羚羊 ERP 后端启动类。
+ *
+ *
REQ-USR-001: 项目首个后端 REQ,初始化 Maven 骨架与公共基础设施。
+ */
+@SpringBootApplication
+@MapperScan("com.xly.erp.**.mapper")
+public class ErpApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(ErpApplication.class, args);
+ }
+}
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..c644da3
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java
@@ -0,0 +1,65 @@
+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.http.HttpStatus;
+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.HttpStatusEntryPoint;
+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 {
+
+ /**
+ * 放行清单(无需 token 即可访问)。
+ *
+ * REQ-USR-001 放行 /api/usr/login;REQ-USR-004 T5 追加 /api/usr/companies(登录页版本下拉)。
+ * 抽为常量供单测断言与过滤链共用,避免清单漂移。
+ */
+ public static final String[] PERMIT_ALL_PATHS = {
+ "/api/usr/login",
+ "/api/usr/companies",
+ "/swagger-ui/**",
+ "/swagger-ui.html",
+ "/v3/api-docs/**",
+ "/actuator/health",
+ };
+
+ /**
+ * 密码哈希编码器(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))
+ .exceptionHandling(eh -> eh
+ .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers(PERMIT_ALL_PATHS)
+ .permitAll()
+ .anyRequest().authenticated())
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+ return http.build();
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java b/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java
new file mode 100644
index 0000000..4059c4a
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java
@@ -0,0 +1,29 @@
+package com.xly.erp.common.exception;
+
+import com.xly.erp.common.response.ResultCode;
+
+/**
+ * 业务异常(docs/04 § 1.5 SSoT)。
+ *
+ * REQ-USR-001 T2。业务错误统一抛本异常,由 {@link GlobalExceptionHandler} 捕获转 Result。
+ */
+public class BusinessException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final ResultCode resultCode;
+
+ public BusinessException(ResultCode resultCode) {
+ super(resultCode.getMessage());
+ this.resultCode = resultCode;
+ }
+
+ public BusinessException(ResultCode resultCode, String message) {
+ super(message);
+ this.resultCode = resultCode;
+ }
+
+ public ResultCode getResultCode() {
+ return resultCode;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..327a87c
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
@@ -0,0 +1,60 @@
+package com.xly.erp.common.exception;
+
+import com.xly.erp.common.response.Result;
+import com.xly.erp.common.response.ResultCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/**
+ * 全局异常处理(docs/04 § 1.5 SSoT)。
+ *
+ * REQ-USR-001 T2:把 BusinessException / 参数校验失败 / 唯一键冲突 / 未捕获异常
+ * 统一转为 {@link Result},失败响应不抛栈到前端。
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ /**
+ * 业务异常 → 对应错误码。
+ */
+ @ExceptionHandler(BusinessException.class)
+ public Result handleBusinessException(BusinessException ex) {
+ return Result.fail(ex.getResultCode(), ex.getMessage());
+ }
+
+ /**
+ * Bean Validation(@Valid)失败 → 40001,message 取首个字段错误提示。
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public Result handleValidationException(MethodArgumentNotValidException ex) {
+ FieldError fieldError = ex.getBindingResult().getFieldError();
+ String message = fieldError != null
+ ? fieldError.getField() + ": " + fieldError.getDefaultMessage()
+ : ResultCode.PARAM_INVALID.getMessage();
+ return Result.fail(ResultCode.PARAM_INVALID, message);
+ }
+
+ /**
+ * 唯一键冲突(并发兜底)→ 40901 用户名已存在。
+ */
+ @ExceptionHandler(DuplicateKeyException.class)
+ public Result handleDuplicateKeyException(DuplicateKeyException ex) {
+ return Result.fail(ResultCode.USERNAME_EXISTS, ResultCode.USERNAME_EXISTS.getMessage());
+ }
+
+ /**
+ * 兜底:未捕获异常记录 ERROR 日志(含栈),对外只返回通用错误码,不泄露内部细节。
+ */
+ @ExceptionHandler(Exception.class)
+ public Result handleException(Exception ex) {
+ LOG.error("系统异常", ex);
+ return Result.fail(ResultCode.SYSTEM_ERROR, ResultCode.SYSTEM_ERROR.getMessage());
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/response/PageResult.java b/backend/src/main/java/com/xly/erp/common/response/PageResult.java
new file mode 100644
index 0000000..ca77a39
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/response/PageResult.java
@@ -0,0 +1,76 @@
+package com.xly.erp.common.response;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 通用分页响应体(docs/04 § 1.4 / § 3.2 SSoT)。
+ *
+ * REQ-USR-003 T1【本 REQ 新增公共契约】。分页接口统一返回 {@code Result>},
+ * 含当前页数据 {@code records}、真实总记录数 {@code total}、当前页号 {@code pageNum}
+ * (数据越界钳制后的实际页号)、每页条数 {@code pageSize}。后续分页 REQ 复用。
+ *
+ * @param 当前页元素类型
+ */
+public class PageResult implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 当前页数据。 */
+ private List records;
+ /** 真实总记录数。 */
+ private long total;
+ /** 当前页号(数据越界钳制后的实际页号)。 */
+ private long pageNum;
+ /** 每页条数。 */
+ private long pageSize;
+
+ public PageResult() {
+ }
+
+ public PageResult(List records, long total, long pageNum, long pageSize) {
+ this.records = records;
+ this.total = total;
+ this.pageNum = pageNum;
+ this.pageSize = pageSize;
+ }
+
+ /**
+ * 静态工厂:从分页要素装配。
+ */
+ public static PageResult of(List records, long total, long pageNum, long pageSize) {
+ return new PageResult<>(records, total, pageNum, pageSize);
+ }
+
+ public List getRecords() {
+ return records;
+ }
+
+ public void setRecords(List records) {
+ this.records = records;
+ }
+
+ public long getTotal() {
+ return total;
+ }
+
+ public void setTotal(long total) {
+ this.total = total;
+ }
+
+ public long getPageNum() {
+ return pageNum;
+ }
+
+ public void setPageNum(long pageNum) {
+ this.pageNum = pageNum;
+ }
+
+ public long getPageSize() {
+ return pageSize;
+ }
+
+ public void setPageSize(long pageSize) {
+ this.pageSize = pageSize;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/response/Result.java b/backend/src/main/java/com/xly/erp/common/response/Result.java
new file mode 100644
index 0000000..37930c4
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/response/Result.java
@@ -0,0 +1,73 @@
+package com.xly.erp.common.response;
+
+import java.io.Serializable;
+
+/**
+ * 统一响应体(docs/04 § 1.4 SSoT)。
+ *
+ * REQ-USR-001 T2。所有接口返回 {@code Result}:{@code code=0} 成功,非 0 为错误码。
+ *
+ * @param 业务数据类型
+ */
+public class Result implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private int code;
+ private String message;
+ private T data;
+
+ public Result() {
+ }
+
+ public Result(int code, String message, T data) {
+ this.code = code;
+ this.message = message;
+ this.data = data;
+ }
+
+ /**
+ * 成功且携带数据。
+ */
+ public static Result success(T data) {
+ return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
+ }
+
+ /**
+ * 成功且不携带数据。
+ */
+ public static Result success() {
+ return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null);
+ }
+
+ /**
+ * 失败,携带错误码与可读提示。
+ */
+ public static Result fail(ResultCode code, String message) {
+ return new Result<>(code.getCode(), message, null);
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public void setCode(int code) {
+ this.code = code;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public T getData() {
+ return data;
+ }
+
+ public void setData(T data) {
+ this.data = data;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/response/ResultCode.java b/backend/src/main/java/com/xly/erp/common/response/ResultCode.java
new file mode 100644
index 0000000..b1bb4f9
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/response/ResultCode.java
@@ -0,0 +1,47 @@
+package com.xly.erp.common.response;
+
+/**
+ * 统一错误码枚举(docs/05 / spec § 6 SSoT)。
+ *
+ * REQ-USR-001 T2:一次性建好本枚举,含本 REQ 使用的 0/40001/40301/40901,
+ * 并预留后续 REQ 复用的 40101/40302/40401/42201,避免后续重复修改公共文件。
+ */
+public enum ResultCode {
+
+ /** 成功。 */
+ SUCCESS(0, "success"),
+ /** 参数校验失败(字段格式 / 必填 / 枚举越界 / 关联 id 不存在)。 */
+ PARAM_INVALID(40001, "参数校验失败"),
+ /** 认证失败(用户名或密码错误;预留 REQ-USR-004)。 */
+ UNAUTHORIZED(40101, "认证失败"),
+ /** 无权限(非管理员调用)。 */
+ FORBIDDEN(40301, "无权限"),
+ /** 账号已禁用(预留 REQ-USR-004)。 */
+ ACCOUNT_DISABLED(40302, "账号已禁用"),
+ /** 资源不存在(预留 REQ-USR-002)。 */
+ NOT_FOUND(40401, "资源不存在"),
+ /** 用户名已存在(sUserName 全局唯一冲突)。 */
+ USERNAME_EXISTS(40901, "用户名已存在"),
+ /** 分页参数非法(预留 REQ-USR-003)。 */
+ PAGE_PARAM_INVALID(42201, "分页参数非法"),
+ /** 登录过于频繁(连续失败超阈值,账号临时锁定;REQ-USR-004)。 */
+ LOGIN_RATE_LIMITED(42901, "请求过于频繁,请稍后重试"),
+ /** 系统内部错误(兜底)。 */
+ SYSTEM_ERROR(50000, "系统繁忙,请稍后重试");
+
+ private final int code;
+ private final String message;
+
+ ResultCode(int code, String message) {
+ this.code = code;
+ this.message = message;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
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 },校验通过后把
+ * sUserName 作为 principal、{@code TYPE_} 作为 authority 放入 SecurityContext。
+ */
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private static final String HEADER = "Authorization";
+ private static final String PREFIX = "Bearer ";
+
+ private final JwtUtil jwtUtil;
+
+ public JwtAuthenticationFilter(JwtUtil jwtUtil) {
+ this.jwtUtil = jwtUtil;
+ }
+
+ @Override
+ protected void doFilterInternal(@NonNull HttpServletRequest request,
+ @NonNull HttpServletResponse response,
+ @NonNull FilterChain filterChain)
+ throws ServletException, IOException {
+ String header = request.getHeader(HEADER);
+ if (header != null && header.startsWith(PREFIX)) {
+ String token = header.substring(PREFIX.length());
+ if (jwtUtil.validateToken(token)) {
+ String userName = jwtUtil.getUserName(token);
+ String userType = jwtUtil.getUserType(token);
+ List authorities =
+ List.of(new SimpleGrantedAuthority(SecurityUtil.TYPE_PREFIX + userType));
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(userName, null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ }
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java b/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
new file mode 100644
index 0000000..b411fe6
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
@@ -0,0 +1,78 @@
+package com.xly.erp.common.security;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import javax.crypto.SecretKey;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * JWT 工具(docs/04 § 1.7)。
+ *
+ * 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/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java
new file mode 100644
index 0000000..44259fb
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java
@@ -0,0 +1,47 @@
+package com.xly.erp.modules.usr.controller;
+
+import com.xly.erp.common.response.Result;
+import com.xly.erp.modules.usr.dto.LoginDTO;
+import com.xly.erp.modules.usr.service.UsrAuthService;
+import com.xly.erp.modules.usr.vo.CompanyOptionVO;
+import com.xly.erp.modules.usr.vo.LoginVO;
+import jakarta.validation.Valid;
+import java.util.List;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 认证 Controller(docs/05 REQ-USR-004 / spec § 2)。REQ-USR-004 T5。
+ *
+ * 仅做参数校验 + 委派 Service,不写业务逻辑、不直接调 Mapper、无管理员前置
+ * (登录 / 公司列表为放行端点)。
+ */
+@RestController
+@RequestMapping("/api/usr")
+public class UsrAuthController {
+
+ private final UsrAuthService usrAuthService;
+
+ public UsrAuthController(UsrAuthService usrAuthService) {
+ this.usrAuthService = usrAuthService;
+ }
+
+ /**
+ * 登录认证(放行,无需 token):@Valid 校验入参后委派 Service。
+ */
+ @PostMapping("/login")
+ public Result login(@Valid @RequestBody LoginDTO dto) {
+ return Result.success(usrAuthService.login(dto));
+ }
+
+ /**
+ * 公司下拉列表(放行,无参):供登录页「版本」下拉。
+ */
+ @GetMapping("/companies")
+ public Result> listCompanies() {
+ return Result.success(usrAuthService.listCompanies());
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrUserController.java b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrUserController.java
new file mode 100644
index 0000000..775e7c6
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrUserController.java
@@ -0,0 +1,74 @@
+package com.xly.erp.modules.usr.controller;
+
+import com.xly.erp.common.exception.BusinessException;
+import com.xly.erp.common.response.PageResult;
+import com.xly.erp.common.response.Result;
+import com.xly.erp.common.response.ResultCode;
+import com.xly.erp.common.security.SecurityUtil;
+import com.xly.erp.modules.usr.dto.CreateUserDTO;
+import com.xly.erp.modules.usr.dto.UpdateUserDTO;
+import com.xly.erp.modules.usr.dto.UserQueryDTO;
+import com.xly.erp.modules.usr.service.UsrUserService;
+import com.xly.erp.modules.usr.vo.UserVO;
+import jakarta.validation.Valid;
+import java.util.Map;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 用户管理 Controller(docs/04 § 1.2 / docs/05 REQ-USR-001)。
+ *
+ * 仅做参数校验 + 管理员权限前置 + 委派 Service,不写业务逻辑、不直接调 Mapper。
+ */
+@RestController
+@RequestMapping("/api/usr")
+public class UsrUserController {
+
+ /** 管理员判定口径(spec § 8 D2)。 */
+ private static final String ADMIN_USER_TYPE = "超级管理员";
+
+ private final UsrUserService usrUserService;
+
+ public UsrUserController(UsrUserService usrUserService) {
+ this.usrUserService = usrUserService;
+ }
+
+ /**
+ * 新增用户:仅超级管理员可调用(spec § 3.9,先于业务校验)。
+ */
+ @PostMapping("/users")
+ public Result