diff --git a/backend/pom.xml b/backend/pom.xml
index ffbee2a..f20f97e 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -82,6 +82,25 @@
${hutool.version}
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
+
org.springframework.boot
spring-boot-starter-test
diff --git a/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java b/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
index 5ef5226..5e01a3f 100644
--- a/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
+++ b/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
@@ -6,6 +6,8 @@ import lombok.Getter;
public enum ErrorCode {
SUCCESS(200, "操作成功"),
PARAM_INVALID(40010, "参数错误"),
+ LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误"),
+ LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定"),
MOD_PARENT_NOT_FOUND(40411, "父模块不存在或已删除"),
MOD_NOT_FOUND(40421, "模块不存在或已删除"),
STAFF_NOT_FOUND(40421, "职员不存在或已删除"),
diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java
new file mode 100644
index 0000000..468202c
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java
@@ -0,0 +1,23 @@
+package com.xly.erp.module.usr.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/** REQ-USR-004 用户登录入参 */
+@Data
+public class LoginDTO {
+
+ @NotBlank
+ @Size(max = 50)
+ private String sUserName;
+
+ @NotBlank
+ @Size(max = 100)
+ private String sPassword;
+
+ @NotBlank
+ @Pattern(regexp = "^standard$", message = "sVersion 仅支持 standard")
+ private String sVersion;
+}
diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java
new file mode 100644
index 0000000..ba65a76
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java
@@ -0,0 +1,26 @@
+package com.xly.erp.module.usr.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/** REQ-USR-004 登录结果 VO(含 JWT + 用户基本信息) */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class LoginResultVO {
+ private String accessToken;
+ private long expiresIn;
+ private LoginUserInfo user;
+
+ @Data
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class LoginUserInfo {
+ private Integer iIncrement;
+ private String sUserNo;
+ private String sUserName;
+ private String sUserType;
+ private String sLanguage;
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java b/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java
index f2fa1c3..5239297 100644
--- a/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java
+++ b/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java
@@ -55,5 +55,7 @@ class ApiResponseTest {
assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422);
assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921);
assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431);
+ assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101);
+ assertThat(ErrorCode.LOGIN_ACCOUNT_LOCKED.getCode()).isEqualTo(40301);
}
}
diff --git a/backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java b/backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java
new file mode 100644
index 0000000..fb98d41
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java
@@ -0,0 +1,57 @@
+package com.xly.erp.module.usr.dto;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import jakarta.validation.ValidatorFactory;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LoginDTOValidationTest {
+
+ private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory();
+ private final Validator validator = FACTORY.getValidator();
+
+ private LoginDTO valid() {
+ LoginDTO d = new LoginDTO();
+ d.setSUserName("alice");
+ d.setSPassword("666666");
+ d.setSVersion("standard");
+ return d;
+ }
+
+ @Test
+ void allValid_yieldsNoViolations() {
+ Set> v = validator.validate(valid());
+ assertThat(v).isEmpty();
+ }
+
+ @Test
+ void blankRequiredFields_yieldsViolations() {
+ LoginDTO d = new LoginDTO();
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
+ .contains("sUserName", "sPassword", "sVersion");
+ }
+
+ @Test
+ void invalidVersion_yieldsViolation() {
+ LoginDTO d = valid();
+ d.setSVersion("experimental");
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sVersion");
+ }
+
+ @Test
+ void overSized_yieldsViolation() {
+ LoginDTO d = valid();
+ d.setSUserName("a".repeat(51));
+ d.setSPassword("p".repeat(101));
+ Set> v = validator.validate(d);
+ assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
+ .contains("sUserName", "sPassword");
+ }
+}