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 不能用 {@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);
+ }
+}
+ *
+ *
+ *