diff --git a/src/test/java/com/yunhai/homeleave/controller/api/LeaveListApiE2EIT.java b/src/test/java/com/yunhai/homeleave/controller/api/LeaveListApiE2EIT.java
new file mode 100644
index 0000000..c2fb87d
--- /dev/null
+++ b/src/test/java/com/yunhai/homeleave/controller/api/LeaveListApiE2EIT.java
@@ -0,0 +1,149 @@
+package com.yunhai.homeleave.controller.api;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.homeleave.entity.SysDict;
+import com.yunhai.homeleave.repository.SysDictRepository;
+import com.yunhai.homeleave.utils.UserContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+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.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * {@code GET /api/leave/List} 的接口级验证(真 Spring 上下文 + 真 MySQL;关掉 CAS 过滤器,身份用 UserContext 直灌)。
+ *
回归背景:请假类型名走 sys_dict 查询,该查询在 order by 里重复引用命名参数导致 Hibernate 绑参错位
+ * ("No value specified for parameter 5"),异常被 leaveTypeName 静默吞掉后回退硬编码枚举名,
+ * 于是「应用设置」里配的类型名显示不出来(枚举没有的 key,如 3=其他,直接变 null)。
+ * 注意 UserContextConfig 注册的拦截器会在每个请求结束后 {@code UserContext.clear()},
+ * 所以身份必须在每个用例请求前重新注入({@code @BeforeEach}),不能只放 {@code @BeforeAll}。
+ * 类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行:
+ * {@code mvn -o test -Dtest=LeaveListApiE2EIT}。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc(addFilters = false)
+class LeaveListApiE2EIT {
+
+ /** 云海中学联调身份(库中该生有 leave_type=1 的记录) */
+ private static final Integer SCH_ID = 3;
+ private static final Integer STU_ID = 10061;
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private SysDictRepository sysDictRepository;
+
+ private static Integer globalStubId;
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @BeforeEach
+ void initContext() {
+ UserContext.setUserId(STU_ID);
+ UserContext.setSchId(SCH_ID);
+ }
+
+ @AfterAll
+ static void clearContext() {
+ UserContext.clear();
+ }
+
+ /** 本校字典的 key→名称;同 key 的全局字典按业务口径被本校压过,故这里只取本校行 */
+ private Map schoolDictNames() {
+ Map byKey = new HashMap<>();
+ sysDictRepository.findVisibleBySchIdAndDictType(SCH_ID, "leave_type").stream()
+ .filter(d -> SCH_ID.equals(d.getSchId()))
+ .forEach(d -> byKey.put(d.getDictKey(), d.getDictValue()));
+ return byKey;
+ }
+
+ /** 造一条同 key(key=1)的全局字典(sch_id=NULL),用来验证本校字典优先;测试结束删除 */
+ private Integer plantGlobalStub() {
+ SysDict stub = new SysDict();
+ stub.setSchId(null);
+ stub.setDictType("leave_type");
+ stub.setDictKey("1");
+ stub.setDictValue("全局桩-病假");
+ stub.setSortOrder(99);
+ stub.setEnableFlag(1);
+ globalStubId = sysDictRepository.save(stub).getId();
+ return globalStubId;
+ }
+
+ private void dropGlobalStub() {
+ if (globalStubId != null) {
+ sysDictRepository.deleteById(globalStubId);
+ globalStubId = null;
+ }
+ }
+
+ @Test
+ @DisplayName("GET /api/leave/List:leaveTypeName 取自本校字典,同 key 全局桩不得遮盖")
+ void listEndpointReturnsDictTypeName() throws Exception {
+ // 断言值从库中字典读取,不写死——字典可在「应用设置」随时改名,写死会让测试随数据变化假失败
+ String expected = schoolDictNames().get("1");
+ assertEquals("事假", expected, "前置条件:云海中学字典 key=1 当前为「事假」(与枚举回退的「病假」不同,才能区分走没走字典)");
+
+ plantGlobalStub();
+ try {
+ mockMvc.perform(get("/api/leave/List")
+ .param("pageIndex", "1")
+ .param("pageSize", "10")
+ .param("keyword", "")
+ .param("leaveType", "1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").isNumber())
+ .andExpect(jsonPath("$.data.content[0].leaveType").value(1))
+ .andExpect(jsonPath("$.data.content[0].leaveTypeName").value(expected))
+ .andExpect(jsonPath("$.data.content[*].leaveTypeName").value(
+ org.hamcrest.Matchers.everyItem(org.hamcrest.Matchers.is(expected))));
+ } finally {
+ dropGlobalStub();
+ }
+ }
+
+ @Test
+ @DisplayName("GET /api/leave/List:逐行比对,leaveTypeName 与库中字典值一致(且不为 null)")
+ void everyRowTypeNameMatchesDict() throws Exception {
+ Map expected = schoolDictNames();
+ assertTrue(!expected.isEmpty(), "云海中学(3)应已配置 leave_type 字典");
+
+ MvcResult result = mockMvc.perform(get("/api/leave/List")
+ .param("pageIndex", "1")
+ .param("pageSize", "50")
+ .param("keyword", ""))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn();
+
+ // MockMvc 默认按 ISO-8859-1 读响应体,中文会变问号;这里显式按 UTF-8 解码
+ JsonNode content = mapper.readTree(
+ result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8))
+ .path("data").path("content");
+ assertTrue(content.size() > 0, "列表内容不应为空(库中已有请假记录)");
+ for (JsonNode row : content) {
+ String key = row.path("leaveType").asText();
+ String name = row.path("leaveTypeName").isNull() ? null : row.path("leaveTypeName").asText();
+ assertNotNull(name, "leaveTypeName 不应为 null(回退枚举的旧症状), leaveType=" + key);
+ assertEquals(expected.get(key), name, "leaveTypeName 应等于字典值, leaveType=" + key
+ + ", id=" + row.path("id").asText());
+ }
+ System.out.println("[e2e] /api/leave/List 逐行校验通过, 共 " + content.size() + " 行, 字典=" + expected);
+ }
+}
diff --git a/src/test/java/com/yunhai/homeleave/service/DictServiceE2EIT.java b/src/test/java/com/yunhai/homeleave/service/DictServiceE2EIT.java
new file mode 100644
index 0000000..abbd614
--- /dev/null
+++ b/src/test/java/com/yunhai/homeleave/service/DictServiceE2EIT.java
@@ -0,0 +1,184 @@
+package com.yunhai.homeleave.service;
+
+import com.yunhai.homeleave.entity.Leave;
+import com.yunhai.homeleave.entity.SysDict;
+import com.yunhai.homeleave.model.BasePageResult;
+import com.yunhai.homeleave.model.leave.LeaveQuery;
+import com.yunhai.homeleave.model.leave.LeaveVo;
+import com.yunhai.homeleave.repository.LeaveRepository;
+import com.yunhai.homeleave.repository.SysDictRepository;
+import com.yunhai.homeleave.utils.UserContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 请假类型字典解析的端到端验证:真实 Spring 上下文 + 真实 MySQL。
+ * 回归背景:可见单条查询({@code findVisibleBySchIdAndDictTypeAndDictKey})在 order by 里重复引用
+ * 命名参数,Hibernate 绑参错位抛 SQLGrammarException,被 leaveTypeName 静默回退成硬编码枚举名
+ * (枚举里没有的 key 直接变 null),字典里配置的名称读不出来。本测试同时校验:
+ * 本校字典优先于全局、字典里新增的 key(枚举无对应项)能解析、列表接口每行给出字典名。
+ * 类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行:
+ * {@code mvn -o test -Dtest=DictServiceE2EIT}。
+ */
+@SpringBootTest
+class DictServiceE2EIT {
+
+ /** 云海中学联调学校 / 学生(库中已有 7 条 leave_type=1 的请假记录) */
+ private static final Integer SCH_ID = 3;
+ private static final Integer STU_ID = 10061;
+ /** 无本校字典的学校(云海职高),用于验证回落全局字典 */
+ private static final Integer OTHER_SCH_ID = 187;
+
+ private static final String DICT_TYPE_LEAVE = "leave_type";
+ /** 全局桩字典名:本校字典存在时必须被它压过 */
+ private static final String GLOBAL_STUB_NAME = "全局桩-病假";
+
+ @Autowired
+ private IDictService dictService;
+ @Autowired
+ private ILeaveService leaveService;
+ @Autowired
+ private SysDictRepository sysDictRepository;
+ @Autowired
+ private LeaveRepository leaveRepository;
+ @Autowired
+ private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
+
+ private static Integer globalStubId;
+
+ @BeforeAll
+ static void initContext() {
+ UserContext.setUserId(STU_ID);
+ UserContext.setSchId(SCH_ID);
+ }
+
+ @AfterAll
+ static void clearContext() {
+ UserContext.clear();
+ }
+
+ /** 造一条同 key 的全局字典(sch_id=NULL),用于验证「本校优先」;测试结束删除 */
+ private Integer ensureGlobalStub() {
+ if (globalStubId != null) {
+ return globalStubId;
+ }
+ SysDict stub = new SysDict();
+ stub.setSchId(null);
+ stub.setDictType(DICT_TYPE_LEAVE);
+ stub.setDictKey("1");
+ stub.setDictValue(GLOBAL_STUB_NAME);
+ stub.setSortOrder(99);
+ stub.setEnableFlag(1);
+ globalStubId = sysDictRepository.save(stub).getId();
+ return globalStubId;
+ }
+
+ /**
+ * 造一条 id 为负的全局桩字典:id 比任何本校行都小,用来区分排序到底是
+ * 「本校优先」还是「id 升序」——自增 id 的桩恒大于本校行,无法区分两者。
+ */
+ private Integer ensureLowIdGlobalStub() {
+ jdbcTemplate.update("insert into sys_dict (id, sch_id, dict_type, dict_key, dict_value, sort_order, enable_flag)"
+ + " values (?, ?, ?, ?, ?, ?, ?)", -1, null, DICT_TYPE_LEAVE, "1", GLOBAL_STUB_NAME, 99, 1);
+ globalStubId = -1;
+ return globalStubId;
+ }
+
+ private Map schoolDictNames() {
+ Map byKey = new HashMap<>();
+ sysDictRepository.findVisibleBySchIdAndDictType(SCH_ID, DICT_TYPE_LEAVE)
+ .stream().filter(d -> SCH_ID.equals(d.getSchId()))
+ .forEach(d -> byKey.put(d.getDictKey(), d.getDictValue()));
+ return byKey;
+ }
+
+ @Test
+ @DisplayName("字典解析:本校优先(含 id 更小的全局行)、全局回落、枚举外的 key 也能取到名称")
+ void resolveNamesFromDict() {
+ Map names = schoolDictNames();
+ assertFalse(names.isEmpty(), "云海中学(3)应已配置 leave_type 字典");
+
+ Integer stubId = ensureGlobalStub();
+ try {
+ // 本校字典优先于同 key 的全局字典(修复前这里会抛异常 → 回退枚举)
+ assertEquals(names.get("1"), dictService.resolveLeaveTypeName(1, SCH_ID),
+ "本校字典应压过全局同 key 字典");
+ // 无本校字典的学校回落全局
+ assertEquals(GLOBAL_STUB_NAME, dictService.resolveLeaveTypeName(1, OTHER_SCH_ID),
+ "无本校字典时应回落全局字典");
+ // 字典里新增、枚举中没有的 key(3=其他):修复前为 null
+ String other = dictService.resolveLeaveTypeName(3, SCH_ID);
+ assertNotNull(other, "字典中 key=3 的名称应能解析(枚举无此项,回退路径给 null)");
+ assertEquals(names.get("3"), other);
+ System.out.println("[e2e] 字典名解析: key1=" + names.get("1") + " key3=" + other
+ + " 全局桩=" + GLOBAL_STUB_NAME + "(stubId=" + stubId + ")");
+ } finally {
+ sysDictRepository.deleteById(stubId);
+ globalStubId = null;
+ }
+ }
+
+ @Test
+ @DisplayName("排序语义:全局行 id 更小时本校依然优先(不是靠 id 升序碰巧排对)")
+ void schoolRowWinsOverLowerIdGlobalRow() {
+ Map names = schoolDictNames();
+ Integer stubId = ensureLowIdGlobalStub();
+ try {
+ // 桩 id=-1 比本校行(id=1,2,3)更小:若排序退化成「id 升序」,这里会返回桩名
+ assertEquals(names.get("1"), dictService.resolveLeaveTypeName(1, SCH_ID),
+ "本校字典应压过 id 更小的同 key 全局字典");
+ System.out.println("[e2e] 负 id 全局桩(id=" + stubId + ")未遮盖本校字典, 返回="
+ + dictService.resolveLeaveTypeName(1, SCH_ID));
+ } finally {
+ sysDictRepository.deleteById(stubId);
+ globalStubId = null;
+ }
+ }
+
+ @Test
+ @DisplayName("列表接口:每行 leaveTypeName 取自字典(与库中字典值一致)")
+ void listLeaveTypeNameFromDict() {
+ Integer stubId = ensureGlobalStub();
+ try {
+ LeaveQuery query = new LeaveQuery();
+ query.setPageIndex(1);
+ query.setPageSize(10);
+ query.setLeaveType(1);
+
+ BasePageResult page = leaveService.getLeaveList(query);
+ assertTrue(page.getTotal() > 0, "库中应有 leave_type=1 的记录用于断言");
+ assertTrue(page.getContent().size() > 0, "列表内容不应为空");
+
+ Map names = schoolDictNames();
+ for (LeaveVo vo : page.getContent()) {
+ Integer leaveType = vo.getLeaveType();
+ // 造一条临时记录也行,但历史数据已足够:直接断言解析结果与字典一致
+ String expected = names.get(String.valueOf(leaveType));
+ assertEquals(expected, vo.getLeaveTypeName(),
+ "请假类型名应与字典一致, leaveType=" + leaveType + ", id=" + vo.getId());
+ assertNotNull(vo.getLeaveTypeName(), "不应出现 null 类型名(回退枚举的旧症状)");
+ }
+
+ List rows = leaveRepository.findAll();
+ assertTrue(rows.size() >= page.getContent().size());
+ System.out.println("[e2e] 列表 " + page.getContent().size() + " 行, leaveTypeName="
+ + page.getContent().get(0).getLeaveTypeName() + "(全局桩未遮盖本校名)");
+ } finally {
+ sysDictRepository.deleteById(stubId);
+ globalStubId = null;
+ }
+ }
+}