diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..fd7b564
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,4 @@
+target/
+*.iml
+.idea/
+HELP.md
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 0000000..bac8aeb
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,115 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ com.xly
+ erp-backend
+ 0.0.1-SNAPSHOT
+ erp-backend
+ 小羚羊 ERP backend
+
+
+ 17
+ UTF-8
+ 3.5.9
+ 0.12.6
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+ ${mybatis-plus.version}
+
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-mysql
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+ runtime
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*Test.java
+ **/*Tests.java
+ **/*IT.java
+
+
+
+
+
+
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..227f5e6
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/ErpApplication.java
@@ -0,0 +1,15 @@
+package com.xly.erp;
+
+import com.xly.erp.common.config.StubSecurityProperties;
+import com.xly.erp.common.config.TenantProperties;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+
+@SpringBootApplication
+@EnableConfigurationProperties({TenantProperties.class, StubSecurityProperties.class})
+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/config/MybatisPlusConfig.java b/backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java
new file mode 100644
index 0000000..2762add
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java
@@ -0,0 +1,9 @@
+package com.xly.erp.common.config;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@MapperScan("com.xly.erp.module.**.mapper")
+public class MybatisPlusConfig {
+}
diff --git a/backend/src/main/java/com/xly/erp/common/config/StubSecurityProperties.java b/backend/src/main/java/com/xly/erp/common/config/StubSecurityProperties.java
new file mode 100644
index 0000000..c037c31
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/config/StubSecurityProperties.java
@@ -0,0 +1,25 @@
+package com.xly.erp.common.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "erp.security")
+public class StubSecurityProperties {
+ private String stubUserNo;
+ private String jwtSecret;
+
+ public String getStubUserNo() {
+ return stubUserNo;
+ }
+
+ public void setStubUserNo(String stubUserNo) {
+ this.stubUserNo = stubUserNo;
+ }
+
+ public String getJwtSecret() {
+ return jwtSecret;
+ }
+
+ public void setJwtSecret(String jwtSecret) {
+ this.jwtSecret = jwtSecret;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/config/TenantProperties.java b/backend/src/main/java/com/xly/erp/common/config/TenantProperties.java
new file mode 100644
index 0000000..21d53d6
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/config/TenantProperties.java
@@ -0,0 +1,25 @@
+package com.xly.erp.common.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "erp.tenant")
+public class TenantProperties {
+ private String brandsId;
+ private String subsidiaryId;
+
+ public String getBrandsId() {
+ return brandsId;
+ }
+
+ public void setBrandsId(String brandsId) {
+ this.brandsId = brandsId;
+ }
+
+ public String getSubsidiaryId() {
+ return subsidiaryId;
+ }
+
+ public void setSubsidiaryId(String subsidiaryId) {
+ this.subsidiaryId = subsidiaryId;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/exception/BizException.java b/backend/src/main/java/com/xly/erp/common/exception/BizException.java
new file mode 100644
index 0000000..87f9bb4
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/exception/BizException.java
@@ -0,0 +1,14 @@
+package com.xly.erp.common.exception;
+
+public class BizException extends RuntimeException {
+ private final int code;
+
+ public BizException(int code, String msg) {
+ super(msg);
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+}
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..542158b
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
@@ -0,0 +1,37 @@
+package com.xly.erp.common.exception;
+
+import com.xly.erp.common.response.Result;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ @ExceptionHandler(BizException.class)
+ public Result handleBiz(BizException e) {
+ log.warn("BizException code={} msg={}", e.getCode(), e.getMessage());
+ return Result.fail(e.getCode(), e.getMessage());
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public Result handleValidation(MethodArgumentNotValidException e) {
+ FieldError fe = e.getBindingResult().getFieldError();
+ String msg = fe == null
+ ? "参数校验失败"
+ : fe.getField() + ": " + fe.getDefaultMessage();
+ log.warn("ValidationException {}", msg);
+ return Result.fail(40001, msg);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public Result handleAny(Exception e) {
+ log.error("Unhandled exception", e);
+ return Result.fail(50000, "系统繁忙");
+ }
+}
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..19391ce
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/response/Result.java
@@ -0,0 +1,52 @@
+package com.xly.erp.common.response;
+
+public class Result {
+ private int code;
+ private String msg;
+ private T data;
+
+ public Result() {
+ }
+
+ public Result(int code, String msg, T data) {
+ this.code = code;
+ this.msg = msg;
+ this.data = data;
+ }
+
+ public static Result ok(T data) {
+ return new Result<>(0, "ok", data);
+ }
+
+ public static Result ok() {
+ return new Result<>(0, "ok", null);
+ }
+
+ public static Result fail(int code, String msg) {
+ return new Result<>(code, msg, null);
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public void setCode(int code) {
+ this.code = code;
+ }
+
+ public String getMsg() {
+ return msg;
+ }
+
+ public void setMsg(String msg) {
+ this.msg = msg;
+ }
+
+ public T getData() {
+ return data;
+ }
+
+ public void setData(T data) {
+ this.data = data;
+ }
+}
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..a332602
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java
@@ -0,0 +1,66 @@
+package com.xly.erp.common.security;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xly.erp.common.exception.BizException;
+import com.xly.erp.common.response.Result;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private static final String HEADER = "Authorization";
+ private static final String PREFIX = "Bearer ";
+
+ private final JwtUtil jwtUtil;
+ private final ObjectMapper objectMapper;
+
+ @Autowired
+ public JwtAuthenticationFilter(JwtUtil jwtUtil, ObjectMapper objectMapper) {
+ this.jwtUtil = jwtUtil;
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain chain) throws ServletException, IOException {
+ String header = request.getHeader(HEADER);
+ if (header == null || !header.startsWith(PREFIX)) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ String token = header.substring(PREFIX.length());
+ String userNo;
+ try {
+ userNo = jwtUtil.parse(token);
+ } catch (BizException e) {
+ writeJsonResult(response, e.getCode(), e.getMessage());
+ return;
+ }
+
+ UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
+ userNo, null, Collections.emptyList());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ chain.doFilter(request, response);
+ }
+
+ private void writeJsonResult(HttpServletResponse response, int code, String msg) throws IOException {
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType("application/json;charset=UTF-8");
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.getWriter().write(objectMapper.writeValueAsString(Result.fail(code, msg)));
+ }
+}
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..45c8638
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
@@ -0,0 +1,54 @@
+package com.xly.erp.common.security;
+
+import com.xly.erp.common.config.StubSecurityProperties;
+import com.xly.erp.common.exception.BizException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.JwtException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Date;
+
+@Component
+public class JwtUtil {
+
+ private static final Duration TTL = Duration.ofHours(8);
+
+ private final SecretKey key;
+
+ public JwtUtil(String secret) {
+ this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+ }
+
+ @Autowired
+ public JwtUtil(StubSecurityProperties props) {
+ this(props.getJwtSecret());
+ }
+
+ public String sign(String userNo) {
+ Date now = new Date();
+ return Jwts.builder()
+ .subject(userNo)
+ .issuedAt(now)
+ .expiration(new Date(now.getTime() + TTL.toMillis()))
+ .signWith(key)
+ .compact();
+ }
+
+ public String parse(String token) {
+ try {
+ return Jwts.parser()
+ .verifyWith(key)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload()
+ .getSubject();
+ } catch (JwtException | IllegalArgumentException e) {
+ throw new BizException(20001, "未认证或 token 已失效");
+ }
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java b/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java
new file mode 100644
index 0000000..f5dd50e
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java
@@ -0,0 +1,33 @@
+package com.xly.erp.common.security;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+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.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@Configuration
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
+public class SecurityConfig {
+
+ private final JwtAuthenticationFilter jwtFilter;
+
+ public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
+ this.jwtFilter = jwtFilter;
+ }
+
+ @Bean
+ SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http.csrf(csrf -> csrf.disable())
+ .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ // REQ-MOD-001 stub: see USR-004 follow-up — 角色硬校验在 USR-004 完成后回填为 hasAuthority('SUPER_ADMIN')
+ .requestMatchers("/api/mod/**").permitAll()
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
+ return http.build();
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java b/backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java
new file mode 100644
index 0000000..d0b49ef
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java
@@ -0,0 +1,20 @@
+package com.xly.erp.common.security;
+
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+public final class SecurityContextHelper {
+
+ private SecurityContextHelper() {
+ }
+
+ public static String currentUserNo() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth == null || auth instanceof AnonymousAuthenticationToken) {
+ return null;
+ }
+ Object p = auth.getPrincipal();
+ return p instanceof String s ? s : null;
+ }
+}
diff --git a/backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java b/backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java
new file mode 100644
index 0000000..b067c29
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java
@@ -0,0 +1,55 @@
+package com.xly.erp.module.mod.controller;
+
+import com.xly.erp.common.response.Result;
+import com.xly.erp.module.mod.dto.CreateModuleDTO;
+import com.xly.erp.module.mod.dto.UpdateModuleDTO;
+import com.xly.erp.module.mod.service.ModuleService;
+import com.xly.erp.module.mod.vo.ModuleTreeVO;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/mod")
+public class ModuleController {
+
+ private final ModuleService moduleService;
+
+ public ModuleController(ModuleService moduleService) {
+ this.moduleService = moduleService;
+ }
+
+ @PostMapping("/modules")
+ public Result