fix(visit): 修复客户拜访页定位时好时坏

根因:定位链路用区县「中文名」查 sys_area,重名区县(白云区=广州/贵阳、
朝阳区=北京/长春、南山区=深圳/鹤岗…)命中多条,后端 getOne 默认
throwEx=true 抛 TooManyResultsException,页面表现为「有时能定位、有时不能」
——取决于用户当前所在区县名是否唯一。

改动:
- 新增 util/geo.js(纯函数):buildGeocodeUrl / normalizeAdcode /
  parseGeocodeResult / formatLocationText
  · 天地图 postStr 补 URL 编码:实测未编码时接口返回 HTTP 400
    「参数格式错误」(admin 端 merchantEdit.vue 已是正确写法)
  · 严格校验响应结构,限流(429)/参数错误(400) 一律返回 null
  · 补直辖市 city 为空时用省名兜底(天地图对上海/北京/天津/重庆返回 city:"")
- util/index.js:抽出 locateOnce / fetchGeocode / resolveAreaInfo 共用
  · 清除两处模块作用域的 this.areaId 误用(严格模式下抛 TypeError 会让
    Promise 永久悬空,locating 再也回不到 false,即「反复点没反应」)
  · 优先按天地图 county_code(去 156 前缀 = sys_area.id)精确查询,
    从根上绕开重名;按中文名仅作降级,并注释其歧义风险
  · 行政区划查不到不再阻断定位:返回 null,仍给出经纬度与地址
    (area_id/city_id/province_id 全仓库无消费方)
- api/request.js:get/del 不再吞掉非 2xx 的 reject,避免调用方状态永久悬空
- pages/visit/index.vue:fetchLocation 加 15s 超时兜底 + 运行号丢弃迟到结果,
  保证失败后仍可重试;静默失败也给出可见提示
- 新增 util/geo.test.mjs(10 个单测),package.json 补 test 脚本

验证:前端单测 10/10;真实天地图 + 生产后端端到端 5 个重名区县全部可正常
定位(后端修复尚未部署即已生效)。
This commit is contained in:
2026-10-16 21:23:48 +08:00
parent 945cf9c104
commit 8816dd2867
6 changed files with 425 additions and 114 deletions
+115 -93
View File
@@ -2,6 +2,7 @@ import {getLocation, getUserInfo, setLocation} from "@/util/user";
import {API, MAIN_API} from "@/config";
import {get} from "@/api/request";
import {areaInfoByNameReq, areaInfoReq} from "@/api/common";
import {buildGeocodeUrl, formatLocationText, parseGeocodeResult} from "./geo";
import dayjs from "dayjs";
import md5 from "md5";
@@ -117,61 +118,116 @@ export const uploadFileToAliOss = async (filePath) => {
}
export const getLocationAction = (save = true) => {
return new Promise((resolve, reject) => {
const res = getLocation()
if (res.area_id) resolve(res)
else {
uni.authorize({
scope: 'scope.userLocation',
success: () => {
uni.getLocation({
isHighAccuracy: true,
success: async ({longitude, latitude}) => {
const {result} = await get(`https://api.tianditu.gov.cn/geocoder?postStr={'lon':${longitude},'lat':${latitude},'ver':1}&type=geocode&tk=1099ec280e86f4e9b632173326cde90b`)
const areaRes = await areaInfoByNameReq({name: result.addressComponent.county}).catch(() => {
if (!this.areaId) reject(false)
})
if (save) setLocation({
longitude,
latitude,
province_str: result.addressComponent.province,
city_str: result.addressComponent.city,
area_str: result.addressComponent.county,
area_id: areaRes.data.id,
city_id: areaRes.data.fid,
province_id: areaRes.data.city.fid
})
resolve({
longitude,
latitude,
area_id: areaRes.data.id,
city_id: areaRes.data.fid,
province_id: areaRes.data.city.fid,
province_str: result.addressComponent.province,
city_str: result.addressComponent.city,
area_str: result.addressComponent.county,
})
},
fail: () => {
reject(false)
}
})
},
fail: () => {
uni.showModal({
title: '提示',
content: '为了更好的提供服务,请开启定位服务',
success: ({confirm}) => {
if (confirm) uni.openSetting()
}
})
}
})
/**
* 调天地图逆地理编码,返回结构化结果(见 util/geo.js)。
* 网络失败 / 限流(429) / 参数错误(400) 一律 reject,不再静默吞掉。
*/
const fetchGeocode = async (longitude, latitude) => {
const body = await get(buildGeocodeUrl(longitude, latitude))
const geo = parseGeocodeResult(body)
if (!geo) throw new Error('天地图逆地理编码返回异常')
return geo
}
/**
* 把天地图结果映射到 sys_area,取 area_id / city_id / province_id。
*
* 关键修复:**优先按 adcode 精确查**(天地图 county_code 去 156 前缀即
* sys_area.id),从根上绕开区县重名问题。
*
* 降级说明:拿不到 adcode 时才按中文名查,此时若区县重名(如「南山区」
* 深圳/鹤岗都有),后端只能稳定返回 id 最小的那条,**可能不是用户实际
* 所在的那个区县**。因此这里的结果仅用于 area_id/city_id/province_id;
* 用于展示的省市区文本一律取天地图原始字段(geo.province/city/county),
* 不受该歧义影响。
*
* 无论哪种方式查不到都返回 null 而不是让整条定位失败——页面提交只需要
* 经纬度与地址文本,行政区划编码缺失不应阻断用户。
*/
const resolveAreaInfo = async geo => {
if (geo.adcode) {
const byCode = await areaInfoByNameReq({id: Number(geo.adcode)}).catch(() => null)
if (byCode && byCode.data) return byCode.data
}
if (geo.county) {
// 有歧义风险(见上),仅作为 adcode 缺失时的兜底
const byName = await areaInfoByNameReq({name: geo.county}).catch(() => null)
if (byName && byName.data) return byName.data
}
return null
}
/** 引导用户开启定位授权。用户此前已拒绝过时 authorize 会直接失败,这里兜底提示。 */
const showLocationSettingGuide = () => {
uni.showModal({
title: '提示',
content: '为了更好的提供服务,请开启定位服务',
success: ({confirm}) => {
if (confirm) uni.openSetting({})
}
})
}
/**
* 定位一次并返回结果:授权 → 取经纬度 → 逆地理编码 → 映射行政区划。
*
* 所有失败路径都显式 reject,保证调用方的 await 一定结算。
* (原实现在 catch 回调里用了模块作用域的 this.areaId,在严格模式下会抛
* TypeError 并让 Promise 永久悬空,调用方的 locating 标志再也回不到 false,
* 表现为「点定位没反应、反复点也没用」。)
*
* @param {boolean} save 是否写入本地缓存(首页等复用缓存时用)
*/
const locateOnce = ({save = false} = {}) => {
return new Promise((resolve, reject) => {
uni.authorize({
scope: 'scope.userLocation',
success: () => {
uni.getLocation({
isHighAccuracy: true,
success: async ({longitude, latitude}) => {
try {
const geo = await fetchGeocode(longitude, latitude)
const area = await resolveAreaInfo(geo)
const payload = {
longitude,
latitude,
area_id: area ? area.id : '',
city_id: area ? area.fid : '',
province_id: area && area.city ? area.city.fid : '',
province_str: geo.province,
city_str: geo.city,
area_str: geo.county,
full: formatLocationText(geo)
}
if (save) setLocation(payload)
resolve(payload)
} catch (error) {
reject(false)
}
},
fail: () => {
reject(false)
}
})
},
fail: () => {
showLocationSettingGuide()
reject(false)
}
})
})
}
export const getLocationAction = (save = true) => {
// 保持原有判据:缓存中已有 area_id 才复用。刻意不改判据,避免悄悄改变
// 首页/业绩/分享/我的这 4 个调用方的刷新行为(它们只消费 city_str,但
// 「有坐标就跳过重定位」会让跨城移动后的 currentCity 长期陈旧)。
const cached = getLocation()
if (cached.area_id) return Promise.resolve(cached)
return locateOnce({save})
}
export const chooseVideo = (sourceType = ['album', 'camera']) => {
return new Promise((resolveMain, reject) => {
uni.chooseVideo({
@@ -221,48 +277,14 @@ export const chooseVideo = (sourceType = ['album', 'camera']) => {
})
}
export const getLocationOnly = () => {
return new Promise((resolve, reject) => {
uni.authorize({
scope: 'scope.userLocation',
success: () => {
uni.getLocation({
isHighAccuracy: true,
success: async ({longitude, latitude}) => {
const {result} = await get(`https://api.tianditu.gov.cn/geocoder?postStr={'lon':${longitude},'lat':${latitude},'ver':1}&type=geocode&tk=1099ec280e86f4e9b632173326cde90b`)
const {data} = await areaInfoByNameReq({name: result.addressComponent.county}).catch(() => {
if (!this.areaId) reject(false)
})
resolve({
longitude,
latitude,
area_id: data.id,
city_id: data.fid,
province_id: data.city.fid,
province_str: result.addressComponent.province,
city_str: result.addressComponent.city,
area_str: result.addressComponent.county,
full: result.formatted_address
})
},
fail: () => {
uni.setStorageSync("no_more_realtime_location", 1)
reject(false)
}
})
},
fail: err => {
uni.showModal({
title: '提示',
content: '为了更好的提供服务,请开启定位服务',
success: ({confirm}) => {
if (confirm) uni.openSetting()
}
})
}
})
})
}
/**
* 强制实时定位(不读缓存),供拜访页等「每次都要当前位置」的场景使用。
*
* 原实现的问题:把天地图 URL 的 postStr 裸拼在查询串里(实测接口返回
* HTTP 400),且 catch 回调里误用 this.areaId 导致 Promise 可能永不结算。
* 现已统一走 locateOnce,保证一定 reject。
*/
export const getLocationOnly = () => locateOnce({save: false})
export const getExpressMode = () => parseInt(uni.getStorageSync("express_mode"))