Files
yxe-mp/util/geo.test.mjs
weicw-dshsa 8816dd2867 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 个重名区县全部可正常
定位(后端修复尚未部署即已生效)。
2026-10-16 21:23:48 +08:00

169 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildGeocodeUrl,
normalizeAdcode,
parseGeocodeResult
} from './geo.js'
/**
* 回归测试:客户拜访页定位链路的两处确定性缺陷。
*
* 1) 天地图 geocoder 的 postStr 未编码 —— 实测同一 key:
* 原样 {'lon':..} -> HTTP 400 参数格式错误
* 仅编码引号 {%27lon%27:..} -> HTTP 400
* 花括号/冒号编码(encodeURI) -> HTTP 200
* 全编码(admin 端写法) -> HTTP 200
* 所以必须确保 postStr 经过完整 URL 编码。
*
* 2) 行政区划按「中文名」查询在重名区县上必然失败(白云区=广州/贵阳、
* 朝阳区=北京/长春…)。天地图已直接返回 county_code,其去 156 前缀
* 即 sys_area.id(实测:越秀区 156440104→440104 与库中 id 一致),
* 可直接精确命中,绕开重名。
*/
// ==================== buildGeocodeUrl ====================
test('buildGeocodeUrl:postStr 必须被 URL 编码(不得残留裸花括号,否则接口返回 400)', () => {
const url = buildGeocodeUrl(113.2644, 23.1291)
const query = url.slice(url.indexOf('?') + 1)
const encodedPostStr = query.split('&').find(p => p.startsWith('postStr='))
// 解码后应还原为天地图要求的原始形态
assert.equal(
decodeURIComponent(encodedPostStr.slice('postStr='.length)),
"{'lon':113.2644,'lat':23.1291,'ver':1}"
)
// 关键:裸的 { } 必须被编码。未编码时接口实测返回 HTTP 400「参数格式错误」。
// 注意单引号 ' 按 RFC 3986 属 unreserved 之外的子分隔符,encodeURIComponent
// 不会编码它;实测该形态(花括号/冒号已编码、引号保留)接口返回 HTTP 200,
// 因此这里只断言花括号与冒号必须被编码。
assert.ok(!/[{}]/.test(encodedPostStr), `花括号未被编码: ${encodedPostStr}`)
assert.ok(!encodedPostStr.includes(':'), `冒号未被编码: ${encodedPostStr}`)
})
test('buildGeocodeUrl:经纬度与 tk 参数齐全', () => {
const url = buildGeocodeUrl(121.4737, 31.2304)
assert.match(url, /^https:\/\/api\.tianditu\.gov\.cn\/geocoder\?/)
assert.ok(url.includes('type=geocode'))
assert.ok(url.includes('tk='))
assert.ok(decodeURIComponent(url).includes("'lon':121.4737"))
assert.ok(decodeURIComponent(url).includes("'lat':31.2304"))
})
test('buildGeocodeUrl:负数/小数坐标不应破坏参数结构', () => {
const url = buildGeocodeUrl(-73.9857, 40.7484)
const query = url.slice(url.indexOf('?') + 1)
// 恰好 3 个参数,说明坐标里的符号没有把它撑开
assert.equal(query.split('&').length, 3)
assert.ok(decodeURIComponent(url).includes("'lon':-73.9857"))
})
// ==================== normalizeAdcode ====================
test('normalizeAdcode:去掉天地图的 156(中国)前缀,得到 6 位 adcode', () => {
assert.equal(normalizeAdcode('156440104'), '440104') // 广州越秀区
assert.equal(normalizeAdcode('156520113'), '520113') // 贵阳白云区
assert.equal(normalizeAdcode('156310101'), '310101') // 上海黄浦区
})
test('normalizeAdcode:已是 6 位裸码时原样返回', () => {
assert.equal(normalizeAdcode('440104'), '440104')
assert.equal(normalizeAdcode(440104), '440104')
assert.equal(normalizeAdcode(' 440104 '), '440104')
})
test('normalizeAdcode:非法值返回空串,绝不产出错误编码', () => {
for (const bad of [null, undefined, '', ' ', 'abc', '156', '12345', {}, []]) {
assert.equal(normalizeAdcode(bad), '', `应拒绝非法输入: ${JSON.stringify(bad)}`)
}
})
// ==================== parseGeocodeResult ====================
const successPayload = {
status: '0',
msg: 'ok',
result: {
formatted_address: '广东省广州市越秀区北京街道禺山社区北京路374号',
addressComponent: {
province: '广东省',
city: '广州市',
county: '越秀区',
county_code: '156440104',
city_code: '156440100',
province_code: '156440000'
}
}
}
test('parseGeocodeResult:正常响应解析出省/市/区县/adcode/地址', () => {
const geo = parseGeocodeResult(successPayload)
assert.equal(geo.province, '广东省')
assert.equal(geo.city, '广州市')
assert.equal(geo.county, '越秀区')
assert.equal(geo.adcode, '440104')
assert.equal(geo.address, '广东省广州市越秀区北京街道禺山社区北京路374号')
})
test('parseGeocodeResult:直辖市 city 为空时用省级名兜底(与 sys_area 一致)', () => {
// 实测天地图对上海/北京/天津/重庆返回 city="" ,而 sys_area 中黄浦区的 city 是「上海市」
const shanghai = {
status: '0',
result: {
formatted_address: '上海市黄浦区人民广场',
addressComponent: {
province: '上海市',
city: '',
county: '黄浦区',
county_code: '156310101',
city_code: '',
province_code: '156310000'
}
}
}
const geo = parseGeocodeResult(shanghai)
assert.equal(geo.city, '上海市', '直辖市应以省名兜底,避免 city_str 为空导致筛选失效')
assert.equal(geo.county, '黄浦区')
assert.equal(geo.adcode, '310101')
})
test('parseGeocodeResult:天地图的各类错误响应一律返回 null(不得当成成功)', () => {
const failures = [
{ msg: '参数格式错误', status: 400 }, // postStr 未编码
{ code: 301012, msg: '权限类型错误' }, // key 类型不符
{ code: 302010, msg: '该tk已限流' }, // 限流
{ status: '0' }, // 缺 result
{ result: {} }, // 缺 addressComponent
{},
null,
undefined
]
for (const payload of failures) {
assert.equal(parseGeocodeResult(payload), null,
`错误响应必须返回 null: ${JSON.stringify(payload)}`)
}
})
test('parseGeocodeResult:adcode 缺失时不崩,且不产生假编码', () => {
const geo = parseGeocodeResult({
status: '0',
result: {
formatted_address: '某地',
addressComponent: { province: '某省', city: '某市', county: '某区' }
}
})
assert.equal(geo.adcode, '', '缺 county_code 时应为空串,由调用方降级为按名字查')
assert.equal(geo.county, '某区')
})