diff --git a/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java b/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java
new file mode 100644
index 0000000..0147222
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java
@@ -0,0 +1,316 @@
+package com.xly.erp.modules.usr;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.xly.erp.modules.usr.entity.UsrCompany;
+import com.xly.erp.modules.usr.entity.UsrUser;
+import com.xly.erp.modules.usr.mapper.UsrCompanyMapper;
+import com.xly.erp.modules.usr.mapper.UsrUserMapper;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+/**
+ * REQ-USR-004 T7:登录端到端验收回归(spec § 7 验收标准 1-12)。
+ *
+ *
@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1);
+ * 真实 JwtUtil / BCryptPasswordEncoder。@AfterEach 按前缀清理 fixture
+ * (用户前缀 it_login_,公司名前缀 IT_LOGIN_CO_)。登录用户 sPassword 用真实 encode("666666")。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class UsrLoginIT {
+
+ private static final String USER_PREFIX = "it_login_";
+ private static final String RL_USER_PREFIX = "it_login_rl_";
+ private static final String CO_PREFIX = "IT_LOGIN_CO_";
+ private static final String RAW_PWD = "666666";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+
+ @Autowired
+ private UsrUserMapper usrUserMapper;
+
+ @Autowired
+ private UsrCompanyMapper usrCompanyMapper;
+
+ private Integer companyId;
+
+ @BeforeEach
+ void seedCompany() {
+ UsrCompany c = new UsrCompany();
+ c.setSCompanyName(CO_PREFIX + "总部");
+ c.setSVersion("企业版");
+ usrCompanyMapper.insert(c);
+ companyId = c.getIIncrement();
+ }
+
+ @AfterEach
+ void cleanup() {
+ usrUserMapper.delete(Wrappers.lambdaQuery()
+ .likeRight(UsrUser::getSUserName, USER_PREFIX));
+ usrCompanyMapper.delete(Wrappers.lambdaQuery()
+ .likeRight(UsrCompany::getSCompanyName, CO_PREFIX));
+ }
+
+ private Integer insertUser(String userName, int isVoid) {
+ UsrUser u = new UsrUser();
+ u.setSUserName(userName);
+ u.setSPassword(passwordEncoder.encode(RAW_PWD));
+ u.setSUserType("普通用户");
+ u.setSLanguage("中文");
+ u.setICanModifyBill(0);
+ u.setIIsVoid(isVoid);
+ u.setSCreator("it_login_seed");
+ usrUserMapper.insert(u);
+ return u.getIIncrement();
+ }
+
+ private String loginBody(String userName, String password, Integer compId) throws Exception {
+ Map body = new HashMap<>();
+ if (userName != null) {
+ body.put("sUserName", userName);
+ }
+ if (password != null) {
+ body.put("password", password);
+ }
+ if (compId != null) {
+ body.put("companyId", compId);
+ }
+ return objectMapper.writeValueAsString(body);
+ }
+
+ private MvcResult doLogin(String userName, String password, Integer compId) throws Exception {
+ return mockMvc.perform(post("/api/usr/login")
+ .contentType("application/json")
+ .content(loginBody(userName, password, compId)))
+ .andReturn();
+ }
+
+ @Test
+ void ac1LoginSuccess() throws Exception {
+ String userName = USER_PREFIX + "ac1";
+ Integer id = insertUser(userName, 0);
+
+ MvcResult result = mockMvc.perform(post("/api/usr/login")
+ .contentType("application/json")
+ .content(loginBody(userName, RAW_PWD, companyId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.token").isNotEmpty())
+ .andExpect(jsonPath("$.data.user.id").value(id))
+ .andExpect(jsonPath("$.data.user.sUserName").value(userName))
+ .andExpect(jsonPath("$.data.user.sUserType").value("普通用户"))
+ .andExpect(jsonPath("$.data.user.sLanguage").value("中文"))
+ .andReturn();
+
+ String resp = result.getResponse().getContentAsString();
+ assertThat(resp).doesNotContain("sPassword");
+ assertThat(resp.toLowerCase()).doesNotContain("password");
+ assertThat(resp).doesNotContain(RAW_PWD);
+ }
+
+ @Test
+ void ac2TokenAcceptedByProtectedApi() throws Exception {
+ String userName = USER_PREFIX + "ac2";
+ insertUser(userName, 0);
+ MvcResult login = doLogin(userName, RAW_PWD, companyId);
+ String token = objectMapper.readTree(login.getResponse().getContentAsString())
+ .path("data").path("token").asText();
+ assertThat(token).isNotBlank();
+
+ // 用 token 调受保护接口(REQ-USR-003)→ 非 401。
+ mockMvc.perform(get("/api/usr/users")
+ .param("pageNum", "1").param("pageSize", "10")
+ .header("Authorization", "Bearer " + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ @Test
+ void ac3LoginUpdatesLastLoginDate() throws Exception {
+ String userName = USER_PREFIX + "ac3";
+ Integer id = insertUser(userName, 0);
+ assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull();
+
+ doLogin(userName, RAW_PWD, companyId);
+
+ LocalDateTime after = usrUserMapper.selectById(id).getTLastLoginDate();
+ assertThat(after).isNotNull();
+ }
+
+ @Test
+ void ac4WrongPassword40101() throws Exception {
+ String userName = USER_PREFIX + "ac4";
+ insertUser(userName, 0);
+
+ MvcResult result = doLogin(userName, "wrongpwd", companyId);
+ JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString());
+ assertThat(resp.path("code").asInt()).isEqualTo(40101);
+ assertThat(resp.path("data").isNull()).isTrue();
+ assertThat(result.getResponse().getContentAsString()).doesNotContain("密码错误");
+ }
+
+ @Test
+ void ac5UserNotFound40101SameAsWrongPassword() throws Exception {
+ String userName = USER_PREFIX + "ac5";
+ insertUser(userName, 0);
+
+ // 密码错误响应
+ String wrongPwdMsg = objectMapper.readTree(
+ doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString())
+ .path("message").asText();
+
+ // 用户不存在响应
+ JsonNode notFound = objectMapper.readTree(
+ doLogin(USER_PREFIX + "ghost_never_exists", RAW_PWD, companyId)
+ .getResponse().getContentAsString());
+
+ assertThat(notFound.path("code").asInt()).isEqualTo(40101);
+ assertThat(notFound.path("message").asText()).isEqualTo(wrongPwdMsg);
+ }
+
+ @Test
+ void ac6DisabledUser40302() throws Exception {
+ String userName = USER_PREFIX + "ac6";
+ Integer id = insertUser(userName, 1);
+
+ MvcResult result = doLogin(userName, RAW_PWD, companyId);
+ JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString());
+ assertThat(resp.path("code").asInt()).isEqualTo(40302);
+ assertThat(resp.path("data").path("token").isMissingNode()
+ || resp.path("data").isNull()).isTrue();
+
+ // 禁用登录不更新 tLastLoginDate。
+ assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull();
+ }
+
+ @Test
+ void ac7MissingParam40001() throws Exception {
+ String userName = USER_PREFIX + "ac7";
+ insertUser(userName, 0);
+
+ // 缺 sUserName
+ assertThat(objectMapper.readTree(doLogin(null, RAW_PWD, companyId)
+ .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001);
+ // 缺 password
+ assertThat(objectMapper.readTree(doLogin(userName, null, companyId)
+ .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001);
+ // 缺 companyId
+ assertThat(objectMapper.readTree(doLogin(userName, RAW_PWD, null)
+ .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001);
+ }
+
+ @Test
+ void ac8IllegalCompanyId40001() throws Exception {
+ String userName = USER_PREFIX + "ac8";
+ insertUser(userName, 0);
+
+ JsonNode resp = objectMapper.readTree(
+ doLogin(userName, RAW_PWD, Integer.MAX_VALUE).getResponse().getContentAsString());
+ assertThat(resp.path("code").asInt()).isEqualTo(40001);
+ }
+
+ @Test
+ void ac9RateLimitAfter5Fails42901() throws Exception {
+ String userName = RL_USER_PREFIX + "ac9";
+ insertUser(userName, 0);
+
+ // 连续 5 次密码错误 → 每次 40101
+ for (int i = 0; i < 5; i++) {
+ JsonNode resp = objectMapper.readTree(
+ doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString());
+ assertThat(resp.path("code").asInt()).isEqualTo(40101);
+ }
+ // 第 6 次即使密码正确也 42901(锁定窗内)
+ JsonNode locked = objectMapper.readTree(
+ doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString());
+ assertThat(locked.path("code").asInt()).isEqualTo(42901);
+
+ // 成功登录后计数清零分支:用独立用户验证 失败<5 → 成功 → 再失败仍不触发 42901。
+ String resetUser = RL_USER_PREFIX + "ac9_reset";
+ insertUser(resetUser, 0);
+ for (int i = 0; i < 3; i++) {
+ doLogin(resetUser, "wrongpwd", companyId);
+ }
+ // 一次成功清零
+ assertThat(objectMapper.readTree(doLogin(resetUser, RAW_PWD, companyId)
+ .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(0);
+ // 再连续失败 5 次:第 5 次才到阈值,前几次仍 40101(验证计数已清零,未沿用旧计数)
+ JsonNode afterReset = objectMapper.readTree(
+ doLogin(resetUser, "wrongpwd", companyId).getResponse().getContentAsString());
+ assertThat(afterReset.path("code").asInt()).isEqualTo(40101);
+ }
+
+ @Test
+ void ac10CompaniesListNoToken() throws Exception {
+ mockMvc.perform(get("/api/usr/companies"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data").isArray())
+ .andExpect(jsonPath("$.data[?(@.sCompanyName == '" + CO_PREFIX + "总部')]").exists());
+ }
+
+ @Test
+ void ac11PasswordNeverLeaks() throws Exception {
+ String userName = USER_PREFIX + "ac11";
+ insertUser(userName, 0);
+
+ String ok = doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString();
+ String fail = doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString();
+
+ for (String resp : new String[] {ok, fail}) {
+ assertThat(resp).doesNotContain("sPassword");
+ assertThat(resp.toLowerCase()).doesNotContain("password");
+ assertThat(resp).doesNotContain(RAW_PWD);
+ }
+ }
+
+ @Test
+ void ac12JwtHasExpiry() throws Exception {
+ String userName = USER_PREFIX + "ac12";
+ insertUser(userName, 0);
+
+ String token = objectMapper.readTree(
+ doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString())
+ .path("data").path("token").asText();
+ assertThat(token).isNotBlank();
+
+ // 解析 JWT payload(第二段 base64url)→ 断言含 exp 且晚于 iat(有限有效期)。
+ String[] segments = token.split("\\.");
+ assertThat(segments.length).isEqualTo(3);
+ String payloadJson = new String(
+ Base64.getUrlDecoder().decode(segments[1]), StandardCharsets.UTF_8);
+ JsonNode payload = objectMapper.readTree(payloadJson);
+ assertThat(payload.has("exp")).isTrue();
+ assertThat(payload.has("iat")).isTrue();
+ assertThat(payload.path("exp").asLong()).isGreaterThan(payload.path("iat").asLong());
+ }
+}