From ffc5ee80c79c5074fb1b7063dccbcc934a861cb6 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 16 Oct 2026 21:23:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(area):=20=E5=8C=BA=E5=8E=BF=E9=87=8D?= =?UTF-8?q?=E5=90=8D=E6=97=B6=E6=8C=89=E5=90=8D=E5=AD=97=E6=9F=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=9B=20TooManyResults=EF=BC=8C=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8C=89=20adcode=20=E7=B2=BE=E7=A1=AE=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:AreaServiceImpl.areaInfoByName 只按 name 精确匹配后调用 getOne(wrapper), 其默认 throwEx=true 走 MyBatis selectOne,重名区县(白云区=广州/贵阳、 朝阳区=北京/长春…)命中多条即抛 TooManyResultsException,被全局异常处理器 包成 {"code":1,"message":"操作失败"}。小程序客户拜访页定位链路依赖该接口, 表现为「有时能定位、有时不能」。 改动: - areaInfoByName(两个重载):改用 findOneByName,以 selectList + Java 侧按 id 取最小替代 getOne —— 多条命中不再抛异常,且结果稳定(不随数据库返回顺序 漂移);查无结果返回 null,修掉原实现对 null 调 getLevelId() 的 NPE - 新增 areaInfoById:按行政区划编码(adcode = sys_area.id)精确查询, 天然唯一,用于彻底绕开区县重名 - AreaController:POST /api/common/area/area-info-by-name 支持传 id 精确命中, 只传 name 时行为不变;查无结果返回 data=null 而非失败码 (该接口在 SecurityConfig 中为 permitAll,不得加 @PreAuthorize) - 抽出 fillCity 与 LEVEL_COUNTY 常量,消除层级魔数 - 新增单测 11 个:AreaInfoByNameDuplicateTest(7) 覆盖重名不抛异常/结果稳定/ 按 id 取最小/唯一名仍补 city/查无返 null/adcode 精确命中; AreaControllerDispatchTest(4) 锁定 id 派发与 name 兼容 验证:单测 11/11;全量 22/22 BUILD SUCCESS。转红验证:临时还原为 getOne 后 4 个测试转红,证明测试非假绿。真实库集成验证(临时测试,已删除,仅 SELECT): 重名区县全部不再抛异常;adcode 520113→贵阳白云区(520100)、440111→广州白云区 (440100) 精确区分。纯代码修复,无 DDL/DML,故未新增 SQL 文件。 --- .../system/controller/AreaController.java | 15 +- .../common/system/service/AreaService.java | 12 ++ .../system/service/impl/AreaServiceImpl.java | 67 +++++- .../AreaControllerDispatchTest.java | 118 +++++++++++ .../service/AreaInfoByNameDuplicateTest.java | 197 ++++++++++++++++++ 5 files changed, 400 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/gxwebsoft/common/system/controller/AreaControllerDispatchTest.java create mode 100644 src/test/java/com/gxwebsoft/common/system/service/AreaInfoByNameDuplicateTest.java diff --git a/src/main/java/com/gxwebsoft/common/system/controller/AreaController.java b/src/main/java/com/gxwebsoft/common/system/controller/AreaController.java index c403967..b7e3048 100644 --- a/src/main/java/com/gxwebsoft/common/system/controller/AreaController.java +++ b/src/main/java/com/gxwebsoft/common/system/controller/AreaController.java @@ -89,9 +89,22 @@ public class AreaController extends BaseController { //return success(areaService.listRel(param)); } - @ApiOperation("查询全部") + /** + * 按行政区划名或编码查询。 + * + *

传 id(行政区划编码/adcode)时精确命中,用于绕开区县重名; + * 只传 name 时按名字查唯一一条(重名时稳定取 id 最小的一条,不抛异常)。 + * 查无结果返回 data=null,而不是失败码。 + * + *

注意:本接口在 SecurityConfig 中为 permitAll(小程序定位链路依赖), + * 因此不得在此加 @PreAuthorize。 + */ + @ApiOperation("按名称或编码查询行政区划") @PostMapping("/area-info-by-name") public ApiResult areaInfoByName(@RequestBody AreaParam param) { + if (param.getId() != null) { + return success(areaService.areaInfoById(param.getId())); + } return success(areaService.areaInfoByName(param.getName(), null)); } diff --git a/src/main/java/com/gxwebsoft/common/system/service/AreaService.java b/src/main/java/com/gxwebsoft/common/system/service/AreaService.java index e7c63b6..3b9a711 100644 --- a/src/main/java/com/gxwebsoft/common/system/service/AreaService.java +++ b/src/main/java/com/gxwebsoft/common/system/service/AreaService.java @@ -50,4 +50,16 @@ public interface AreaService extends IService { Area areaInfoByName(String name, Integer level); Area areaInfoByName(String name, Integer level, Integer parentId); + + /** + * 按行政区划编码(adcode,即 sys_area.id)精确查询。 + * + *

用于绕开区县重名:sys_area.id 即国标 adcode,天然唯一。 + * 前端拿到定位 SDK 返回的 adcode 后可直接精确命中,不受「白云区/朝阳区」等 + * 同名区县影响。 + * + * @param id 行政区划编码 + * @return Area;查无结果时返回 null(不抛异常) + */ + Area areaInfoById(Integer id); } diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/AreaServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/AreaServiceImpl.java index fa5b1cf..f091b2f 100644 --- a/src/main/java/com/gxwebsoft/common/system/service/impl/AreaServiceImpl.java +++ b/src/main/java/com/gxwebsoft/common/system/service/impl/AreaServiceImpl.java @@ -10,6 +10,7 @@ import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageResult; import org.springframework.stereotype.Service; +import java.util.Comparator; import java.util.List; /** @@ -21,6 +22,9 @@ import java.util.List; @Service public class AreaServiceImpl extends ServiceImpl implements AreaService { + /** sys_area.level_id:区县(level_id=3)。三级区划的父级即所属城市。 */ + private static final int LEVEL_COUNTY = 3; + @Override public PageResult pageRel(AreaParam param) { PageParam page = new PageParam<>(param); @@ -80,21 +84,68 @@ public class AreaServiceImpl extends ServiceImpl implements Ar @Override public Area areaInfoByName(String name, Integer level) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(Area::getName, name); - if (level != null) wrapper.eq(Area::getLevelId, level); - Area item = getOne(wrapper); - if (item.getLevelId().equals(3)) - item.setCity(getById(item.getFid())); + Area item = findOneByName(name, level, null); + if (item == null) return null; + fillCity(item); return item; } @Override public Area areaInfoByName(String name, Integer level, Integer parentId) { + Area item = findOneByName(name, level, parentId); + if (item == null) return null; + fillCity(item); + return item; + } + + @Override + public Area areaInfoById(Integer id) { + if (id == null) return null; + Area item = getById(id); + if (item == null) return null; + fillCity(item); + return item; + } + + /** + * 按名字查唯一一条行政区划,**不抛异常**。 + * + *

不能用 {@code getOne(wrapper)}:其默认 throwEx=true,底层走 MyBatis + * {@code selectOne},只要命中多条就抛 TooManyResultsException。而 sys_area 中 + * 同名区县广泛存在(白云区=广州/贵阳、朝阳区=北京/长春…),会导致按名字查询 + * 随机失败,表现为「定位有时能、有时不能」。 + * + *

这里改为 selectList 后在**Java 侧按 id 升序取首条**,保证: + *

+ * + *

按名字消歧本质上是有损的(重名区县无法从名字区分)。调用方若已知行政区划 + * 编码,应改走 {@link #areaInfoById(Integer)} 精确命中。 + * + * @return 命中项;查无结果时返回 null(不抛 NPE) + */ + private Area findOneByName(String name, Integer level, Integer parentId) { + if (name == null || name.trim().isEmpty()) return null; LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(Area::getName, name); + wrapper.eq(Area::getName, name.trim()); if (parentId != null) wrapper.eq(Area::getFid, parentId); if (level != null) wrapper.eq(Area::getLevelId, level); - return getOne(wrapper); + List list = list(wrapper); + if (list == null || list.isEmpty()) return null; + // 显式在 Java 侧排序:不依赖数据库返回顺序,保证同名多命中时结果确定 + return list.stream() + .filter(a -> a != null && a.getId() != null) + .min(Comparator.comparingInt(Area::getId)) + .orElse(list.get(0)); + } + + /** level_id=3(区县)时补齐所属城市,供前端取 city_id;其余层级保持原样。 */ + private void fillCity(Area item) { + if (item == null) return; + if (Integer.valueOf(LEVEL_COUNTY).equals(item.getLevelId()) && item.getFid() != null) { + item.setCity(getById(item.getFid())); + } } } diff --git a/src/test/java/com/gxwebsoft/common/system/controller/AreaControllerDispatchTest.java b/src/test/java/com/gxwebsoft/common/system/controller/AreaControllerDispatchTest.java new file mode 100644 index 0000000..5c90f61 --- /dev/null +++ b/src/test/java/com/gxwebsoft/common/system/controller/AreaControllerDispatchTest.java @@ -0,0 +1,118 @@ +package com.gxwebsoft.common.system.controller; + +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.system.entity.Area; +import com.gxwebsoft.common.system.param.AreaParam; +import com.gxwebsoft.common.system.service.AreaService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 回归测试:POST /api/common/area/area-info-by-name 必须支持按 id(adcode)精确查询。 + * + *

小程序定位链路改为「用天地图 county_code 去 156 前缀得到的 adcode 精确命中 + * sys_area.id」,以绕开区县重名。若 Controller 不把 id 派发给 + * {@code areaInfoById},前端的修复就形同虚设(实测旧实现在重名区县上仍返回失败)。 + * + *

同时锁定:只传 name 时行为不变,仍走按名字查询。 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class AreaControllerDispatchTest { + + @Mock + private AreaService areaService; + + @InjectMocks + private AreaController areaController; + + private Area baiyun() { + Area a = new Area(); + a.setId(440111); + a.setName("白云区"); + a.setFid(440100); + a.setLevelId(3); + return a; + } + + @Test + @DisplayName("传 id 时:必须走精确查询,不得退化成按名字查") + public void idRoutesToExactLookup() { + when(areaService.areaInfoById(440111)).thenReturn(baiyun()); + + AreaParam param = new AreaParam(); + param.setId(440111); + + ApiResult result = areaController.areaInfoByName(param); + + assertEquals(0, result.getCode()); + assertNotNull(result.getData(), "按 adcode 应精确命中"); + assertEquals(440111, result.getData().getId()); + // 关键:不得再按名字查(那正是重名区县失败的根源) + verify(areaService, never()).areaInfoByName(any(), any()); + } + + @Test + @DisplayName("只传 name 时:走按名字查询,行为保持不变") + public void nameOnlyRoutesToNameLookup() { + when(areaService.areaInfoByName(eq("白云区"), any())).thenReturn(baiyun()); + + AreaParam param = new AreaParam(); + param.setName("白云区"); + + ApiResult result = areaController.areaInfoByName(param); + + assertEquals(0, result.getCode()); + assertNotNull(result.getData()); + verify(areaService, never()).areaInfoById(anyInt()); + } + + /** + * 查无结果必须是「成功 + data=null」,而不是失败码。 + * 前端据此降级(仍用天地图的经纬度与地址文本提交),不应阻断用户提交。 + */ + @Test + @DisplayName("查无结果:返回 code=0 且 data=null,而不是失败") + public void missingReturnsNullDataNotFailure() { + when(areaService.areaInfoById(999999)).thenReturn(null); + + AreaParam param = new AreaParam(); + param.setId(999999); + + ApiResult result = areaController.areaInfoByName(param); + + assertEquals(0, result.getCode(), "查不到行政区划不应视为接口失败"); + assertNull(result.getData()); + } + + @Test + @DisplayName("id 与 name 同时给出时:id 优先(精确优于模糊)") + public void idTakesPrecedenceOverName() { + when(areaService.areaInfoById(520113)).thenReturn(baiyun()); + + AreaParam param = new AreaParam(); + param.setId(520113); + param.setName("白云区"); + + areaController.areaInfoByName(param); + + verify(areaService).areaInfoById(520113); + verify(areaService, never()).areaInfoByName(any(), any()); + } +} diff --git a/src/test/java/com/gxwebsoft/common/system/service/AreaInfoByNameDuplicateTest.java b/src/test/java/com/gxwebsoft/common/system/service/AreaInfoByNameDuplicateTest.java new file mode 100644 index 0000000..11756c7 --- /dev/null +++ b/src/test/java/com/gxwebsoft/common/system/service/AreaInfoByNameDuplicateTest.java @@ -0,0 +1,197 @@ +package com.gxwebsoft.common.system.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.gxwebsoft.common.system.entity.Area; +import com.gxwebsoft.common.system.mapper.AreaMapper; +import com.gxwebsoft.common.system.service.impl.AreaServiceImpl; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.exceptions.TooManyResultsException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * 回归测试:区县重名时,按名字查行政区划不得抛 TooManyResultsException。 + * + *

背景:小程序定位链路 getLocationOnly() → 天地图逆地理编码拿「区县中文名」 + * → 调 POST /api/common/area/area-info-by-name。而 {@code sys_area} 里同名区县 + * 广泛存在(白云区=广州/贵阳、朝阳区=北京/长春、南山区=深圳/鹤岗…)。 + * + *

原实现 {@code AreaServiceImpl.areaInfoByName} 只按 name 精确匹配后调用 + * {@code getOne(wrapper)},其默认 throwEx=true 走 MyBatis {@code selectOne}, + * 多条命中即抛 TooManyResultsException,被全局异常处理器包成 + * {"code":1,"message":"操作失败"},最终表现为「拜访页有时能定位、有时不能」—— + * 取决于用户当前所处区县名是否唯一。 + * + *

这里 mock {@code AreaMapper.selectOne} 抛异常来复现线上真实机制(不依赖数据库): + * 修复前必然转红,修复后转绿。 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class AreaInfoByNameDuplicateTest { + + @Mock + private AreaMapper areaMapper; + + @InjectMocks + private AreaServiceImpl areaService; + + /** + * LambdaQueryWrapper 的列名解析依赖 MyBatis-Plus 的 TableInfo 缓存; + * 纯单元测试没有 Spring 容器,必须手动注册实体,否则会抛 + * "can not find lambda cache for this entity"。 + */ + @BeforeAll + public static void initTableInfoCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), Area.class); + } + + /** 广州白云区(adcode 440111,父级 广州市 440100)。 */ + private Area guangzhouBaiyun() { + Area a = new Area(); + a.setId(440111); + a.setName("白云区"); + a.setFid(440100); + a.setLevelId(3); + return a; + } + + /** 贵阳白云区(adcode 520113,父级 贵阳市 520100):与广州白云区同名。 */ + private Area guiyangBaiyun() { + Area a = new Area(); + a.setId(520113); + a.setName("白云区"); + a.setFid(520100); + a.setLevelId(3); + return a; + } + + private Area guangzhouCity() { + Area a = new Area(); + a.setId(440100); + a.setName("广州市"); + a.setFid(440000); + a.setLevelId(2); + return a; + } + + /** + * 复现线上机制:MyBatis-Plus getOne(throwEx=true) → selectOne,多条命中抛异常。 + * 同时把 selectList 配成返回两条同名区县,供修复后的实现使用。 + */ + private void duplicateNameInDatabase() { + when(areaMapper.selectOne(any())).thenThrow(new TooManyResultsException( + "Expected one result (or null) to be returned by selectOne(), but found: 2")); + when(areaMapper.selectList(any())) + .thenReturn(Arrays.asList(guangzhouBaiyun(), guiyangBaiyun())); + } + + @Test + @DisplayName("重名区县:按名字查询不应抛异常,应稳定返回一条") + public void duplicateNameDoesNotThrow() { + duplicateNameInDatabase(); + + Area result = areaService.areaInfoByName("白云区", null); + + assertNotNull(result, "重名区县也应返回一条结果,而不是抛异常"); + assertEquals("白云区", result.getName()); + } + + @Test + @DisplayName("重名区县:结果应稳定(多次调用同一条),不能随机漂移") + public void duplicateNameIsDeterministic() { + duplicateNameInDatabase(); + + Area first = areaService.areaInfoByName("白云区", null); + Area second = areaService.areaInfoByName("白云区", null); + + assertNotNull(first); + assertNotNull(second); + assertEquals(first.getId(), second.getId(), "同名区县应稳定命中同一条,避免用户定位结果跳变"); + } + + @Test + @DisplayName("重名区县:结果稳定取 adcode 最小的一条(广州白云区 440111)") + public void duplicateNamePicksLowestAdcode() { + // 故意让数据库返回顺序与 adcode 顺序相反,验证实现不依赖返回顺序 + when(areaMapper.selectList(any())) + .thenReturn(Arrays.asList(guiyangBaiyun(), guangzhouBaiyun())); + + Area result = areaService.areaInfoByName("白云区", null); + + assertNotNull(result); + assertEquals(440111, result.getId(), "应按 id 升序取首条,保证结果稳定可预期"); + } + + @Test + @DisplayName("唯一区县:行为保持不变,并补齐 city 关联") + public void uniqueNameStillFillsCity() { + when(areaMapper.selectList(any())) + .thenReturn(Collections.singletonList(guangzhouBaiyun())); + when(areaMapper.selectById(440100)).thenReturn(guangzhouCity()); + + Area result = areaService.areaInfoByName("白云区", null); + + assertNotNull(result); + assertNotNull(result.getCity(), "levelId=3 的区县应补齐所属城市,供前端取 city_id"); + assertEquals("广州市", result.getCity().getName()); + } + + /** + * 原实现在查不到时会对 null 调 getLevelId(),把「查无此区」变成 NPE(500)。 + * 空名/港澳台等拿不到匹配时必须是「返回 null」,而不是抛异常。 + */ + @Test + @DisplayName("查无此区县:应返回 null,不得抛 NullPointerException") + public void missingNameReturnsNull() { + when(areaMapper.selectList(any())).thenReturn(Collections.emptyList()); + + Area result = areaService.areaInfoByName("不存在的区县名", null); + + assertNull(result, "查不到应返回 null,由调用方决定降级,而不是抛 NPE"); + } + + /** + * 按 adcode(行政区划编码)精确命中:同名区县的正解。 + * sys_area.id 即国标 adcode,天然唯一,不受重名影响。 + */ + @Test + @DisplayName("按 adcode 查询:即使区县重名也能精确命中") + public void adcodeLookupIsExact() { + when(areaMapper.selectById(520113)).thenReturn(guiyangBaiyun()); + when(areaMapper.selectById(520100)).thenReturn(null); + + Area result = areaService.areaInfoById(520113); + + assertNotNull(result); + assertEquals(520113, result.getId(), "应按 adcode 精确命中贵阳白云区,而非广州白云区"); + assertEquals("白云区", result.getName()); + } + + @Test + @DisplayName("按 adcode 查询:查不到时返回 null,不抛异常") + public void adcodeLookupMissingReturnsNull() { + when(areaMapper.selectById(999999)).thenReturn(null); + + Area result = areaService.areaInfoById(999999); + + assertNull(result); + } +}