fix(area): 区县重名时按名字查不再抛 TooManyResults,并支持按 adcode 精确查

根因: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 文件。
This commit is contained in:
2026-10-16 21:23:58 +08:00
parent e01acf486d
commit ffc5ee80c7
5 changed files with 400 additions and 9 deletions
@@ -89,9 +89,22 @@ public class AreaController extends BaseController {
//return success(areaService.listRel(param));
}
@ApiOperation("查询全部")
/**
* 按行政区划名或编码查询。
*
* <p>传 id(行政区划编码/adcode)时精确命中,用于绕开区县重名;
* 只传 name 时按名字查唯一一条(重名时稳定取 id 最小的一条,不抛异常)。
* 查无结果返回 data=null,而不是失败码。
*
* <p>注意:本接口在 SecurityConfig 中为 permitAll(小程序定位链路依赖),
* 因此不得在此加 @PreAuthorize。
*/
@ApiOperation("按名称或编码查询行政区划")
@PostMapping("/area-info-by-name")
public ApiResult<Area> areaInfoByName(@RequestBody AreaParam param) {
if (param.getId() != null) {
return success(areaService.areaInfoById(param.getId()));
}
return success(areaService.areaInfoByName(param.getName(), null));
}
@@ -50,4 +50,16 @@ public interface AreaService extends IService<Area> {
Area areaInfoByName(String name, Integer level);
Area areaInfoByName(String name, Integer level, Integer parentId);
/**
* 按行政区划编码(adcode,即 sys_area.id)精确查询。
*
* <p>用于绕开区县重名:sys_area.id 即国标 adcode,天然唯一。
* 前端拿到定位 SDK 返回的 adcode 后可直接精确命中,不受「白云区/朝阳区」等
* 同名区县影响。
*
* @param id 行政区划编码
* @return Area;查无结果时返回 null(不抛异常)
*/
Area areaInfoById(Integer id);
}
@@ -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<AreaMapper, Area> implements AreaService {
/** sys_area.level_id:区县(level_id=3)。三级区划的父级即所属城市。 */
private static final int LEVEL_COUNTY = 3;
@Override
public PageResult<Area> pageRel(AreaParam param) {
PageParam<Area, AreaParam> page = new PageParam<>(param);
@@ -80,21 +84,68 @@ public class AreaServiceImpl extends ServiceImpl<AreaMapper, Area> implements Ar
@Override
public Area areaInfoByName(String name, Integer level) {
LambdaQueryWrapper<Area> 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;
}
/**
* 按名字查唯一一条行政区划,**不抛异常**。
*
* <p>不能用 {@code getOne(wrapper)}:其默认 throwEx=true,底层走 MyBatis
* {@code selectOne},只要命中多条就抛 TooManyResultsException。而 sys_area 中
* 同名区县广泛存在(白云区=广州/贵阳、朝阳区=北京/长春…),会导致按名字查询
* 随机失败,表现为「定位有时能、有时不能」。
*
* <p>这里改为 selectList 后在**Java 侧按 id 升序取首条**,保证:
* <ul>
* <li>多条命中不再抛异常;</li>
* <li>结果稳定(多次调用命中同一条),不随数据库返回顺序漂移。</li>
* </ul>
*
* <p>按名字消歧本质上是有损的(重名区县无法从名字区分)。调用方若已知行政区划
* 编码,应改走 {@link #areaInfoById(Integer)} 精确命中。
*
* @return 命中项;查无结果时返回 null(不抛 NPE)
*/
private Area findOneByName(String name, Integer level, Integer parentId) {
if (name == null || name.trim().isEmpty()) return null;
LambdaQueryWrapper<Area> 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<Area> 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()));
}
}
}
@@ -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)精确查询。
*
* <p>小程序定位链路改为「用天地图 county_code 去 156 前缀得到的 adcode 精确命中
* sys_area.id」,以绕开区县重名。若 Controller 不把 id 派发给
* {@code areaInfoById},前端的修复就形同虚设(实测旧实现在重名区县上仍返回失败)。
*
* <p>同时锁定:只传 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<Area> 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<Area> 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<Area> 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());
}
}
@@ -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。
*
* <p>背景:小程序定位链路 getLocationOnly() → 天地图逆地理编码拿「区县中文名」
* → 调 POST /api/common/area/area-info-by-name。而 {@code sys_area} 里同名区县
* 广泛存在(白云区=广州/贵阳、朝阳区=北京/长春、南山区=深圳/鹤岗…)。
*
* <p>原实现 {@code AreaServiceImpl.areaInfoByName} 只按 name 精确匹配后调用
* {@code getOne(wrapper)},其默认 throwEx=true 走 MyBatis {@code selectOne},
* 多条命中即抛 TooManyResultsException,被全局异常处理器包成
* {"code":1,"message":"操作失败"},最终表现为「拜访页有时能定位、有时不能」——
* 取决于用户当前所处区县名是否唯一。
*
* <p>这里 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);
}
}