diff --git a/backend/src/main/java/com/xly/erp/config/JacksonConfig.java b/backend/src/main/java/com/xly/erp/config/JacksonConfig.java
new file mode 100644
index 0000000..a8f68b6
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/config/JacksonConfig.java
@@ -0,0 +1,28 @@
+package com.xly.erp.config;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 让 Jackson 通过字段名(而非 getter/setter 推断)确定 JSON 属性名。
+ *
+ *
项目沿用 docs/03 的匈牙利前缀命名(如 {@code iIncrement} / {@code sUserName}),
+ * Lombok 生成的 getter({@code getIIncrement})经 JavaBeans Introspector 解析为
+ * {@code IIncrement}(首两字符全大写时保留),导致 JSON 输出 {@code "IIncrement"}
+ * 而非期望的 {@code "iIncrement"}。改为字段访问后,Jackson 直接用字段名作 JSON key。
+ */
+@Configuration
+public class JacksonConfig {
+
+ @Bean
+ public Jackson2ObjectMapperBuilderCustomizer fieldOnlyVisibility() {
+ return builder -> builder
+ .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
+ .visibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE)
+ .visibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE)
+ .visibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.NONE);
+ }
+}
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..3252dc8
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java
@@ -0,0 +1,26 @@
+package com.xly.erp.module.mod.controller;
+
+import com.xly.erp.common.response.ApiResponse;
+import com.xly.erp.module.mod.dto.ModuleCreateDTO;
+import com.xly.erp.module.mod.service.ModuleService;
+import com.xly.erp.module.mod.vo.ModuleVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+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;
+
+// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:CREATE')")
+@RestController
+@RequestMapping("/api/modules")
+@RequiredArgsConstructor
+public class ModuleController {
+
+ private final ModuleService moduleService;
+
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody ModuleCreateDTO dto) {
+ return ApiResponse.ok(moduleService.create(dto));
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java b/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java
new file mode 100644
index 0000000..0669941
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java
@@ -0,0 +1,128 @@
+package com.xly.erp.module.mod.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xly.erp.module.mod.dto.ModuleCreateDTO;
+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.http.MediaType;
+import org.springframework.test.annotation.Rollback;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+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;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+@Rollback
+class ModuleControllerIT {
+
+ @Autowired MockMvc mockMvc;
+ @Autowired ObjectMapper objectMapper;
+
+ private ModuleCreateDTO valid(String procName) {
+ ModuleCreateDTO d = new ModuleCreateDTO();
+ d.setSDisplayType("前端业务");
+ d.setSProcedureName(procName);
+ d.setSModuleType("USR");
+ d.setSManageDeptEn("IT");
+ d.setBShowPermission(false);
+ d.setSModuleNameZh("用户管理");
+ d.setIParentId(null);
+ d.setISortOrder(0);
+ return d;
+ }
+
+ private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); }
+
+ @Test
+ void post_validRootModule_returns200WithVO() throws Exception {
+ ModuleCreateDTO dto = valid("sp_audit_root_" + System.nanoTime());
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(dto)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.iIncrement").isNumber())
+ .andExpect(jsonPath("$.data.sProcedureName").value(dto.getSProcedureName()))
+ .andExpect(jsonPath("$.data.bDeleted").value(false));
+ }
+
+ @Test
+ void post_validChildModule_returns200() throws Exception {
+ // 先建 root
+ ModuleCreateDTO root = valid("sp_audit_parent_" + System.nanoTime());
+ String rootBody = mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(root)))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ Integer parentId = objectMapper.readTree(rootBody).path("data").path("iIncrement").asInt();
+
+ // 再建 child
+ ModuleCreateDTO child = valid("sp_audit_child_" + System.nanoTime());
+ child.setIParentId(parentId);
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(child)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.iParentId").value(parentId));
+ }
+
+ @Test
+ void post_duplicateProcedureName_returns200WithCode40911() throws Exception {
+ String procName = "sp_audit_dup_" + System.nanoTime();
+ ModuleCreateDTO first = valid(procName);
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(first)))
+ .andExpect(status().isOk());
+
+ ModuleCreateDTO second = valid(procName);
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(second)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40911));
+ }
+
+ @Test
+ void post_parentNotFound_returns200WithCode40411() throws Exception {
+ ModuleCreateDTO d = valid("sp_audit_orphan_" + System.nanoTime());
+ d.setIParentId(999999);
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40411));
+ }
+
+ @Test
+ void post_missingRequiredField_returns200WithCode40010() throws Exception {
+ ModuleCreateDTO d = valid("sp_audit_miss_" + System.nanoTime());
+ d.setSModuleNameZh(null); // 必填缺失
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40010));
+ }
+
+ @Test
+ void post_invalidDisplayTypeEnum_returns200WithCode40010() throws Exception {
+ ModuleCreateDTO d = valid("sp_audit_enum_" + System.nanoTime());
+ d.setSDisplayType("非法值");
+ mockMvc.perform(post("/api/modules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(d)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(40010));
+ }
+}