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
index 0bef1c0..42522d7 100644
--- a/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java
+++ b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java
@@ -3,12 +3,14 @@ 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;
/**
@@ -36,6 +38,8 @@ public class SecurityConfig {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .exceptionHandling(eh -> eh
+ .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/usr/login",
diff --git a/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java b/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java
index 289e611..c5c3c39 100644
--- a/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java
+++ b/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java
@@ -1,5 +1,6 @@
package com.xly.erp.modules.usr.dto;
+import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
@@ -12,40 +13,51 @@ import java.util.List;
*
*
REQ-USR-001 T4。Bean Validation 注解校验格式 / 必填 / 枚举;
* 关联 id 存在性、枚举兜底后越界判定在 Service 处理。
+ *
+ * 字段为匈牙利前缀命名(与列名一致),其 getter 形如 {@code getSUserName} 会被
+ * Jackson 推断为属性名 {@code SUserName},与契约 JSON 键不符;故对带前缀字段显式
+ * {@link JsonProperty} 锁定 JSON 键名,确保反序列化绑定正确。
*/
public class CreateUserDTO {
/** 用户名:必填,3-20 位字母 / 数字 / 下划线,全局唯一。 */
+ @JsonProperty("sUserName")
@NotBlank(message = "用户名不能为空")
@Pattern(regexp = "^[A-Za-z0-9_]{3,20}$", message = "用户名须为 3-20 位字母、数字或下划线")
private String sUserName;
/** 用户号:可选。 */
+ @JsonProperty("sUserNo")
@Size(max = 50, message = "用户号长度不能超过 50")
private String sUserNo;
/** 关联职员 ID:可选,存在性在 Service 校验。 */
+ @JsonProperty("iEmployeeId")
private Integer iEmployeeId;
/** 用户类型:可选,为空时 Service 兜底「普通用户」;非空须为合法枚举。 */
+ @JsonProperty("sUserType")
@Pattern(regexp = "^(普通用户|超级管理员)$", message = "用户类型取值非法")
private String sUserType;
/** 界面语言:必填,取值 ∈ {中文, 英文, 繁体}。 */
+ @JsonProperty("sLanguage")
@NotBlank(message = "语言不能为空")
@Pattern(regexp = "^(中文|英文|繁体)$", message = "语言取值非法")
private String sLanguage;
/** 单据修改权限:可选,0 / 1,为空时 Service 兜底 0。 */
+ @JsonProperty("iCanModifyBill")
@Min(value = 0, message = "单据修改权限取值非法")
@Max(value = 1, message = "单据修改权限取值非法")
private Integer iCanModifyBill;
/** 权限组 ID 列表:可选,元素存在性在 Service 校验。 */
+ @JsonProperty("permissionIds")
private List permissionIds;
/** 初始密码:可选,为空时 Service 兜底 666666。 */
- @Size(max = 100, message = "初始密码长度不能超过 100")
+ @JsonProperty("initialPassword")
private String initialPassword;
public String getSUserName() {
diff --git a/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java
new file mode 100644
index 0000000..964f3f8
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java
@@ -0,0 +1,206 @@
+package com.xly.erp.modules.usr;
+
+import static org.assertj.core.api.Assertions.assertThat;
+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.xly.erp.common.security.JwtUtil;
+import com.xly.erp.modules.usr.entity.UsrPermission;
+import com.xly.erp.modules.usr.entity.UsrUser;
+import com.xly.erp.modules.usr.entity.UsrUserPermission;
+import com.xly.erp.modules.usr.mapper.UsrPermissionMapper;
+import com.xly.erp.modules.usr.mapper.UsrUserMapper;
+import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+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-001 T8:新增用户端到端验收回归(spec § 7)。
+ *
+ * @SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。
+ * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入;
+ * 真实 BCrypt 哈希 + 真实库写入做端到端确认。每个用例自管理 fixture 清理。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class UsrUserCreateIT {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private JwtUtil jwtUtil;
+
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+
+ @Autowired
+ private UsrUserMapper usrUserMapper;
+
+ @Autowired
+ private UsrUserPermissionMapper usrUserPermissionMapper;
+
+ @Autowired
+ private UsrPermissionMapper usrPermissionMapper;
+
+ private static final String CALLER = "it_admin";
+
+ @AfterEach
+ void cleanup() {
+ // 删除本测试创建的用户(及其权限授权由外键 CASCADE 清理)与 fixture 权限。
+ usrUserMapper.delete(Wrappers.lambdaQuery()
+ .likeRight(UsrUser::getSUserName, "it_user_"));
+ usrPermissionMapper.delete(Wrappers.lambdaQuery()
+ .likeRight(UsrPermission::getSPermissionCode, "IT_PERM_"));
+ }
+
+ private String adminToken() {
+ return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员");
+ }
+
+ private String normalToken() {
+ return "Bearer " + jwtUtil.generateToken("it_normal", "普通用户");
+ }
+
+ private Map baseBody(String userName) {
+ Map body = new HashMap<>();
+ body.put("sUserName", userName);
+ body.put("sLanguage", "中文");
+ return body;
+ }
+
+ private int insertPermissionFixture(String codeSuffix) {
+ UsrPermission perm = new UsrPermission();
+ perm.setSPermissionName("IT权限" + codeSuffix);
+ perm.setSPermissionCode("IT_PERM_" + codeSuffix);
+ usrPermissionMapper.insert(perm);
+ return perm.getIIncrement();
+ }
+
+ @Test
+ void ac1NormalCreatePersistsHashedPassword() throws Exception {
+ String userName = "it_user_ac1";
+ MvcResult result = mockMvc.perform(post("/api/usr/users")
+ .header("Authorization", adminToken())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(baseBody(userName))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").isNumber())
+ .andReturn();
+
+ Map, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class);
+ Map, ?> data = (Map, ?>) resp.get("data");
+ Integer newId = (Integer) data.get("id");
+
+ UsrUser saved = usrUserMapper.selectById(newId);
+ assertThat(saved).isNotNull();
+ assertThat(saved.getSPassword()).isNotEqualTo("666666");
+ assertThat(passwordEncoder.matches("666666", saved.getSPassword())).isTrue();
+ assertThat(saved.getIIsVoid()).isZero();
+ assertThat(saved.getSCreator()).isEqualTo(CALLER);
+ assertThat(saved.getTCreateDate()).isNotNull();
+ }
+
+ @Test
+ void ac2DuplicateUserNameRollsBack() throws Exception {
+ String userName = "it_user_ac2";
+ mockMvc.perform(post("/api/usr/users")
+ .header("Authorization", adminToken())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(baseBody(userName))))
+ .andExpect(jsonPath("$.code").value(0));
+
+ long before = usrUserMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(UsrUser::getSUserName, userName));
+
+ mockMvc.perform(post("/api/usr/users")
+ .header("Authorization", adminToken())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(baseBody(userName))))
+ .andExpect(jsonPath("$.code").value(40901));
+
+ long after = usrUserMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(UsrUser::getSUserName, userName));
+ assertThat(after).isEqualTo(before).isEqualTo(1L);
+ }
+
+ @Test
+ void ac4PermissionGrantWritesRows() throws Exception {
+ int permA = insertPermissionFixture("A");
+ int permB = insertPermissionFixture("B");
+ String userName = "it_user_ac4";
+ Map body = baseBody(userName);
+ body.put("permissionIds", List.of(permA, permB, permA));
+
+ MvcResult result = mockMvc.perform(post("/api/usr/users")
+ .header("Authorization", adminToken())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn();
+
+ Map, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class);
+ Integer newId = (Integer) ((Map, ?>) resp.get("data")).get("id");
+
+ long grants = usrUserPermissionMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(UsrUserPermission::getIUserId, newId));
+ assertThat(grants).isEqualTo(2L);
+ }
+
+ @Test
+ void ac5NonAdminForbidden() throws Exception {
+ String userName = "it_user_ac5";
+
+ // 普通用户 token → 40301
+ mockMvc.perform(post("/api/usr/users")
+ .header("Authorization", normalToken())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(baseBody(userName))))
+ .andExpect(jsonPath("$.code").value(40301));
+
+ // 无 token → 401(安全链拦截,未认证)
+ mockMvc.perform(post("/api/usr/users")
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(baseBody(userName))))
+ .andExpect(status().isUnauthorized());
+
+ long created = usrUserMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(UsrUser::getSUserName, userName));
+ assertThat(created).isZero();
+ }
+
+ @Test
+ void ac7ResponseHasNoPassword() throws Exception {
+ String userName = "it_user_ac7";
+ MvcResult result = mockMvc.perform(post("/api/usr/users")
+ .header("Authorization", adminToken())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(baseBody(userName))))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.sPassword").doesNotExist())
+ .andExpect(jsonPath("$.data.password").doesNotExist())
+ .andReturn();
+
+ String responseBody = result.getResponse().getContentAsString();
+ assertThat(responseBody).doesNotContain("666666");
+ assertThat(responseBody.toLowerCase()).doesNotContain("password");
+ }
+}