☰
小程序收货地址解析:云函数调用腾讯位置服务实现省市区联动回填
2026/11/2 8:05:59 网站建设 项目流程

简介:这份面向微信小程序开发者的地址解析示例工程,围绕快递收货地址的智能识别与标准化展开,借助腾讯云地址解析API,将用户输入的非结构化地址提取为省、市、区等信息,适用于物流、电商及需要表单地址规范化的练手与课程设计场景。压缩包共18个文件、约16KB,以JavaScript逻辑代码和JSON配置为主,配合WXML页面结构与WXSS样式,另有少量系统缓存文件;整体目录精简,便于快速导入微信开发者工具查看运行脉络。已有8242人学习下载,说明该主题在实战学习中具有一定关注度。读者可据此梳理API接入、HMAC-SHA1签名、Base64编码、请求构造与JSON响应解析的完整链路,并参考页面交互与异常处理思路;项目中确认按钮事件尚待补全,也留出了继续完善地址确认与微调功能的练习空间。

1. 一段乱糟糟的收货地址,为什么非要拆成省市区再存

用户在小程序里粘贴一句「朝阳区建国路 88 号 SOHO 现代城 A 座 1201 张三 13800000000」,如果把这串原样塞进数据库,后面的运费模板、分区库存、电子面单全都得靠人肉判断。地址解析要干的事就一件:用腾讯云 API 把这坨自由文本做地址识别,拆出省、市、区、街道、门牌,再按统一的地址格式化规则回填,让地址变成能被程序消费的结构化字段。压缩包里的 AddressParseTest 就是跑通这条链路的小程序 Demo——输入框贴一段文本,点一下出结构化结果,省市区直接回填三级联动。

这个 Demo 适合两类人:一是手上正做电商、外卖、寄件类小程序,被地址输入折磨过的同学;二是想看清「小程序 + 云函数 + 第三方 API」这条标准链路该怎么搭的后端或全栈开发者。它不复杂,但该踩的坑一个不少。

2. 选型与链路:正则、位置服务、腾讯云 API 3.0 三条路怎么选

先说结论:手工正则只能做「兜底清洗」,做不了「识别」。这不是代码写得糙的问题,是地址本身就没有语法。中文地址是一套约定俗成的省略表达,「北京市朝阳区」省略成「朝阳区」,「浙江省杭州市余杭区」省略成「余杭」,「内蒙古自治区」写成「内蒙」,这些都不是规则能穷举的。凡是试图用一条正则打天下的方案,上线两周就会被真实数据打回原形。

2.1 正则到底卡在哪几个地方

我把线上遇到过的脏样本归了几类,看一遍就明白为什么必须调接口:

原始输入正则方案的判断结果接口方案的判断结果
朝阳区建国路 88 号全国有两个「朝阳区」,无法区分结合上下文/region 参数给出一个,另一个作为候选
江苏昆山玉山镇 XX 路「昆山」不在省市区字典里,直接漏识别为江苏省苏州市昆山市,县级市落到 district
北京市海淀区上地十街 10 号院能拆,但姓名、手机号粘在尾部,需要额外剥离结构化字段本身就是分开的
浙江省杭州市余杭区仓前街道「仓前街道」是 street 还是 detail,正则没有依据street / street_number / detail 分层返回
重庆渝北区龙溪街道直辖市省份字段为空,前端三级联动直接崩补齐 province = city = 重庆市

真正让开发者感到困惑的是:本地开发者工具里一切正常,真机上却解析得乱七八糟——那是两条链路的输入清洗逻辑不一致,下面会讲。所以这个项目里正则依然有位置,但只负责「前置清洗」,比如把姓名、手机号、收货人:这种前缀从文本里剔掉,让送给接口的字符串尽可能干净。清洗不做,接口返回的 detail 里会混进张三13800000000,后面写面单时你还得再洗一次。

2.2 位置服务直连与腾讯云 API 3.0 网关的差别

这个 Demo 用的是腾讯位置服务的 WebService 地址解析接口,鉴权就是 URL 上带一个key,返回体里除了省市区街道,还会带上经纬度和相似度分数。另有一条路是把能力包一层腾讯云 API 3.0 网关,用SecretId/SecretKey做 TC3-HMAC-SHA256 签名。两者不是替代关系,是不同阶段的选择:

维度位置服务 WebService 直连腾讯云 API 3.0 网关
鉴权成本URL 带 key,一行搞定要写 TC3 签名,几十行
密钥安全key 会出现在请求里,不能裸露在小程序端密钥只在服务端,前端拿不到
配额与审计控制台看调用量,粒度粗可以按子账号、按接口做配额和审计
小程序接入要把接口域名加进 request 合法域名走自己的网关域名,域名自己控
适合阶段验证期、Demo、小体量已有云账号体系、要合规留痕的项目

体量不大、想先把功能跑通,就先用位置服务,把 key 放在云函数环境变量里,小程序端只调云函数。等业务上来了、需要按业务线分账或做调用审计,再换成 API 3.0 网关。签名的代价是一次性的,写一次封装好就行,后面所有接口复用同一个签名函数。

2.3 一次解析请求的完整链路

从用户点按钮到三级联动亮起来,中间其实有五步,每一环都能单独出问题,所以我在项目里把它们拆成独立函数,方便定位到底是哪一步坏了:

// miniprogram/utils/address.js —— 请求前的清洗,尽量别把脏东西送给接口 const NOISE_PATTERNS = [ /(收货人|收件人|联系人)\s*[::]?/g, // "收货人:张三" /1[3-9]\d{9}/g, // 手机号 /[\u4e00-\u9fa5]{2,4}\s*1[3-9]\d{9}/g, // 姓名和手机号粘在一起 /\s+/g // 多空格归一 ] function clean(raw) { let s = String(raw || '') NOISE_PATTERNS.forEach((re) => { s = s.replace(re, ' ') }) return s.trim() } module.exports = { clean }

clean只做减法,不改写内容。注意手机号那条正则要放在姓名前面执行,否则「张三13800000000」这种连写的输入会先被姓名规则切一半,剩下半截数字反而更难处理。清洗完的字符串长度如果小于 6 个字符,直接在小程序端拦掉,不必浪费一次调用——这是我用真实数据换来的经验,短文本调接口的失败率高得离谱,还占配额。

链路剩下的四步是:调云函数拿结构化结果 → 前端做标准化补全(直辖市、县级市)→ 校验必填字段 → 回填三级联动和详细地址输入框。第 4 章会把每一段代码摊开讲。

3. 目录与模块拆解:AddressParseTest 里每个文件管什么

这类 Demo 的组织方式基本一致,按「页面层 / 组件层 / 工具层 / 云函数层」四块切。拆开压缩包之后,先看目录,心里就有数了:

3.1 目录结构

AddressParseTest/ ├── miniprogram/ │ ├── app.js # 云开发初始化 │ ├── app.json # 页面注册、navigationBar 配置 │ ├── app.wxss │ ├── pages/ │ │ ├── index/ # 单条地址解析(主入口) │ │ │ ├── index.js │ │ │ ├── index.wxml │ │ │ ├── index.wxss │ │ │ └── index.json │ │ └── batch/ # 批量粘贴解析,二次开发预留 │ │ └── ... │ ├── components/ │ │ └── region-picker/ # 省市区三级联动,负责回填和取值 │ └── utils/ │ ├── request.js # 云函数调用封装,统一错误码 │ ├── address.js # 清洗 + 标准化(核心) │ └── validate.js # 必填校验、长度校验 ├── cloudfunctions/ │ └── parseAddress/ │ ├── index.js # 调腾讯位置服务,key 走环境变量 │ ├── package.json # 依赖 axios │ └── config.json └── project.config.json

两个地方值得留意。一是utils/address.js被页面和组件同时引用,清洗逻辑只写一份,避免「列表页能解析、详情页解析不出来」这种低级不一致。二是cloudfunctions/parseAddress是独立云函数,不是塞在页面里直接发请求——这条设计决定了密钥会不会泄露,后面第 5 章会专门讲翻车的场景。

3.2 页面层的状态设计

pages/index/index.js的data里其实只放四个状态就够用:

字段类型作用
rawTextString用户粘贴的原始文本,不清洗,留着做二次解析
parsingBoolean请求中标记,用来禁用按钮、显示 loading
resultObject | null结构化结果,null表示还没解析过
errMsgString错误提示,任何一步失败都往这里写

rawText保留原文这个细节很重要。用户看到解析结果不对,第一反应是改原文重试;如果你解析完就把输入框清空,他会非常烦躁。parsing这个布尔值不只是用来展示 loading 的,它还是连点保护的开关——地址解析接口是按次计费的,手抖点三下就是三次调用。

3.3 工具层:把「请求」和「业务」隔开

utils/request.js里只做一件事:调云函数,把各种异常收敛成一个统一结构{ ok, data, code, msg }。页面里不出现wx.cloud.callFunction,也不出现任何errCode判断,这样后面从云函数切到自建服务器,只改这一个文件。

// miniprogram/utils/request.js function parseAddress(address, region = '') { return new Promise((resolve) => { wx.cloud.callFunction({ name: 'parseAddress', data: { address, region }, success: (res) => resolve(res.result || { ok: false, msg: '云函数无返回' }), fail: (err) => resolve({ ok: false, code: 'CLOUD_FAIL', msg: err.errMsg || '网络异常' }) }) }) } module.exports = { parseAddress }

注意这里用的是resolve而不是reject。地址解析失败是很常见的业务分支(地址太短、识别不出、配额超了),不该当成异常抛出去让上层try/catch到处包。统一走ok字段判断,页面的错误处理就只有一条路径,可读性高很多。这是我踩过坑之后改的写法,早期版本用 reject,结果每个调用点都要写两层 catch,代码越写越乱。

4. 核心代码:从一段文本到省市区三级联动的完整实现

整条链路的核心就三段代码:云函数里发请求、页面里接结果、工具层做标准化。逐段看。

4.1 云函数侧:密钥收进环境变量,请求加超时

// cloudfunctions/parseAddress/index.js const cloud = require('wx-server-sdk') const axios = require('axios') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const KEY = process.env.QQMAP_KEY // 密钥只存在云函数环境变量里 const ENDPOINT = 'https://apis.map.qq.com/ws/addressparsing/v1/' exports.main = async (event) => { const raw = String(event.address || '').trim() if (raw.length < 6) { return { ok: false, code: 'TOO_SHORT', msg: '地址太短,请补全省市区' } } try { const { data } = await axios.get(ENDPOINT, { params: { address: raw, region: event.region || '', // 传当前城市名,歧义地址命中率明显提升 key: KEY, output: 'json' }, timeout: 3000 // 超过 3 秒就走降级,别让用户干等 }) if (data.status !== 0) { // 常见:key 未开通该接口、配额用尽、参数不合法 return { ok: false, code: `MAP_${data.status}`, msg: data.message || '解析服务返回异常' } } return { ok: true, data: data.result } } catch (e) { const code = e.code === 'ECONNABORTED' ? 'TIMEOUT' : (e.code || 'NETWORK') return { ok: false, code, msg: e.message } } }

几个参数值得说明。timeout: 3000是用户体验的红线,小程序里用户等 5 秒以上基本就会退出页面,宁可返回失败让他手动填,也别让界面卡着。region传当前定位城市,能把「朝阳区」这种同名区县判对——不传的话接口按全国范围猜,猜错的概率不低。output: 'json'是必须的,不传某些环境会走到 JSONP 分支。

返回体里的结构化字段,不同接口版本可能平铺在result上,也可能包在address_components里,取字段那段写一行兼容就行,别把字段名写死在八个地方。

如果走的是腾讯云 API 3.0 网关而不是位置服务直连,鉴权换成 TC3 签名,封装一次全项目复用:

// TC3-HMAC-SHA256 签名,仅腾讯云 API 3.0 网关需要 const crypto = require('crypto') const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex') const hmac = (key, s) => crypto.createHmac('sha256', key).update(s).digest() function tc3Sign({ secretId, secretKey, host, service, payload, timestamp }) { const date = new Date(timestamp * 1000).toISOString().slice(0, 10) const canonicalRequest = [ 'POST', '/', '', `content-type:application/json; charset=utf-8\nhost:${host}\n`, 'content-type;host', sha256hex(payload) ].join('\n') const scope = `${date}/${service}/tc3_request` const stringToSign = ['TC3-HMAC-SHA256', timestamp, scope, sha256hex(canonicalRequest)].join('\n') const kSigning = hmac(hmac(hmac('TC3' + secretKey, date), service), 'tc3_request') const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex') return `TC3-HMAC-SHA256 Credential=${secretId}/${scope}, SignedHeaders=content-type;host, Signature=${signature}` }

payload必须是实际发送的 JSON 字符串,一个字节都不能差,host要跟请求头里的 Host 完全一致,否则签名对不上返回 401,而错误信息只说「签名错误」,排查起来毫无头绪。

4.2 小程序侧:输入不做实时请求,只做连点保护

// miniprogram/pages/index/index.js const { parseAddress } = require('../../utils/request') const { clean, standardize } = require('../../utils/address') Page({ data: { rawText: '', parsing: false, result: null, errMsg: '' }, onInput(e) { // 输入过程中绝不发请求,地址解析是按次计费的 this.setData({ rawText: e.detail.value, errMsg: '' }) }, async onParse() { if (this.data.parsing) return // 连点保护 const raw = this.data.rawText.trim() if (!raw) return this.setData({ errMsg: '先把收货信息粘进来' }) this.setData({ parsing: true, errMsg: '' }) try { const res = await parseAddress(clean(raw)) if (!res.ok) throw new Error(res.msg || '解析失败,请手动填写') const normalized = standardize(raw, res.data) if (!normalized.province) throw new Error('没识别出省份,请手动选择') this.setData({ result: normalized }) this.selectComponent('#regionPicker').setValue(normalized) // 回填三级联动 } catch (e) { this.setData({ errMsg: e.message }) } finally { this.setData({ parsing: false }) // 无论如何都要复位 } } })

finally里复位parsing是必须的。早期版本我把复位写在success回调里,结果一超时按钮就永远处于禁用状态,用户只能杀掉小程序重进——这种问题在开发阶段极难复现,上线后才被投诉。

selectComponent拿到组件实例再setValue,比在data里挂一堆regionValue数组要干净得多,三级联动的内部状态由组件自己管。

4.3 结果标准化:直辖市、县级市、置信度三道兜底

接口返回的结构化字段不能直接拿来用,必须再过一层标准化,否则三级联动会因为选项匹配不上而回填失败。

// miniprogram/utils/address.js const MUNICIPALITY = ['北京市', '上海市', '天津市', '重庆市'] function standardize(raw, result) { const c = result.address_components || result || {} const pick = (v) => (typeof v === 'string' ? v.trim() : '') let province = pick(c.province) let city = pick(c.city) const district = pick(c.district) const street = pick(c.street) const streetNumber = pick(c.street_number) // 兜底 1:直辖市的 city 字段常为空,补成同名,否则联动组件找不到下标 if (MUNICIPALITY.includes(province) && !city) city = province // 兜底 2:只回了省没回市,用省占位,让用户自己去选 if (province && !city && !MUNICIPALITY.includes(province)) city = '' // 详细地址 = 原文去掉已识别的省市区的部分,避免丢失小区名和楼栋号 let detail = raw ;[province, city, district].filter(Boolean).forEach((seg) => { detail = detail.replace(seg, '') }) detail = (detail + street + streetNumber).replace(/\s+/g, '').slice(0, 120) return { province, city, district, detail, full: [province, city, district, detail].filter(Boolean).join(''), confidence: Number(result.similarity ?? result.score ?? 0) // 0~1 或 0~100,看接口 } } module.exports = { clean, standardize }

三个点要解释。第一,直辖市的province和city必须都填成同一个名字,因为省市区选择器的数据结构通常是[省, 市, 区]三级数组,少一级就取不到 value。第二,detail我是从原文里「减掉」已识别部分推导出来的,不是直接取接口字段——接口的detail有时会把小区名吃掉,而小区名恰恰是快递员最需要的信息。第三,confidence低于阈值(我一般设 0.6 以下)时,界面要显式提示「请核对识别结果」,别让用户闭眼点提交,一个错地址的退件成本远高于一次提示。

5. 避坑与排查:域名、密钥、配额、回填这四类问题最容易翻车

以下五条都是真实遇到过、且新人一定会遇到的,按「现象 → 原因 → 解决」记。

第一条:开发者工具里正常,真机上一律失败。

现象是小程序真机点解析,转圈两秒然后提示「网络异常」,但开发者工具完全正常。原因是开发者工具默认勾了「不校验合法域名」,真机不会给你这个豁免。解决是把位置服务的接口域名加进小程序后台的request合法域名列表,并且确认是 https、没有端口号、不带路径。改完不用重新提审,但要在真机上重新进一次小程序才生效。

第二条:接口返回status非 0,但提示信息很含糊。

现象是云函数日志里能看到返回体,status是个三位数,message写着「参数错误」或干脆空白。原因一般三种:key 没开通这个接口的权限、当日配额跑满、或者address传了空字符串。解决是先在云函数里把event.address和data.status一起打日志,然后去控制台确认这个 key 的接口权限和剩余配额。配额这个问题特别隐蔽,白天测试都好好的,晚上做压测才发现半天都在报错。我一般会给这类外部调用加个简单的当日计数,超阈值就降级成手动填写。

第三条:key 出现在抓包里。

现象是抓小程序网络请求,能看到完整的 key 明文。原因是早期图省事,在页面里直接wx.request打了位置服务域名。解决是把请求整体挪进云函数,key 放环境变量,小程序端只认wx.cloud.callFunction。已经泄露过的 key 直接去控制台重置,顺便配上调用来源限制。密钥这事的后悔药是没有的,重置一次要改所有环境。

第四条:解析结果明明有省市区,三级联动就是不回填。

现象是console.log打出来province: '重庆市', city: '',界面上选择器还是空的。原因是联动组件的value是三级下标数组,city为空时算不出下标,整组就失效。解决是在standardize里补齐直辖市字段(见 4.3 的兜底 1),并且在组件的setValue里做一次indexOf校验,找不到就落回默认第一项而不是静默失败。

第五条:用户连点,日志里同一个地址出现三次请求。

现象是云函数日志里同一秒出现三条完全相同入参的记录。原因是按钮没做禁用,parsing标记只在回调里改,异步间隙里点得进去。解决办法是进入方法第一行就判断if (this.data.parsing) return,并且setData之后才发请求。另外给云函数侧也加一层幂等:同样的 address 在 5 秒内重复进来直接返回缓存结果,双重保险。

6. 二次开发:把解析能力接进真实下单页的三个改造点

Demo 是单条解析,真实业务里要复杂得多。我一般按下面三步改:

第一步,加本地缓存。同一个地址文本在 24 小时内重复解析没有意义,直接用wx.getStorageSync缓存地址哈希 → 结果,命中就跳过接口。上线后这一层能砍掉三到四成调用量,对配额敏感的项目几乎是必做项。

第二步,做成可降级的表单。解析不是主流程,填地址才是。所以onParse失败时不能弹个 toast 就完事,要直接把省市区选择器和详细地址输入框亮出来,让用户手填,同时埋个点记录失败率。真实项目里解析成功率能到 90% 就不错了,剩下那一成必须有人工兜底路径,否则订单直接流失。

第三步,批量导入场景单独处理。如果是商家后台一次粘几十条地址,就不能串行调接口——接口有 QPS 限制,串行跑起来慢得让人怀疑人生。做法是并发控制到 3~5 路,失败的重试一次,最后把成功和失败的分别列出来给用户核对:

// 批量解析:控制并发,失败重试一次 async function batchParse(list, concurrency = 3) { const queue = [...list] const ok = [], failed = [] const worker = async () => { while (queue.length) { const item = queue.shift() let r = await parseAddress(clean(item)) if (!r.ok) r = await parseAddress(clean(item)) // 重试一次,多半是超时 r.ok ? ok.push(standardize(item, r.data)) : failed.push({ item, msg: r.msg }) } } await Promise.all(Array.from({ length: concurrency }, worker)) return { ok, failed } }

验证改造效果别靠眼睛看。我习惯准备 30 条脏样本——直辖市、县级市、姓名手机号粘连、纯小区名、超长带备注的——每次改完标准化逻辑就跑一遍,比对省市区三级是否全部命中,detail有没有丢关键信息。这套样本文件一直留在项目里,比任何文档都管用。

从那次「开发者工具正常、真机全挂」之后,我每次接第三方接口都强制走一遍:真机跑一次、断网跑一次、连点五次、把 key 临时改错一次,看错误提示能不能让用户看懂。四步走完,基本不会在上线后半夜被叫起来查日志。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询