小程序收货地址解析:云函数代理与三级联动回填实战
简介:面向微信小程序开发者及电商、物流等需要处理收货地址的场景,这套示例聚焦非标准地址的智能标准化:借助腾讯云地址解析API,把用户输入的自然语言地址拆解为省、市、区等结构信息,涵盖API接入、HMAC-SHA1签名、Base64编码、请求构造与响应解析等环节,并保留确认交互与异常处理的待完善处,便于二次开发对照。压缩包共18个文件、约16KB,以JavaScript为主,搭配JSON配置、WXML结构与WXSS样式,另有少量系统隐藏文件;sha1、base64和util等模块承担签名及工具函数,页面目录对应地址解析流程,体量轻巧而层次清晰。已有8242人学习,可用作小程序调用云API、地址格式化的入门参照,也能为物流电商项目的地址录入与后台数据统一提供模块拆分和排错思路。
1. 收货地址填到第三遍就想骂人:这个 zip 干的是「一句话拆五级」
一段真实的收货信息长这样:「收件人王某 138xxxxxxxx 广东省深圳市南山区科技园某某大厦 18 楼 A 座」。用户在小程序里要把它拆进姓名框、电话框、三级联动选择器和详细地址多行框,来回切换至少四五次。地址解析这件事的价值不在于炫技,而在于把这行自由文本一次性拆成省、市、区、详细地址、姓名、电话六段,顺手把格式统一掉。AddressParseTest.zip 就是一个围绕这件事搭起来的最小示例工程:前端收集原文,云函数代理调用腾讯云地址解析接口,返回结果再按字段回填到表单。它适合正在做电商下单、寄件、社区团购、门店自提这类必须收集收货信息的小程序团队,也适合想搞清楚「地址识别到底能拆到什么粒度、哪些情况拆不动」的开发者。下面按接口契约、密钥代理、表单回填、踩坑排查、准确率验证的顺序拆开讲。
2. 接口契约先立住:请求三个字段、返回六段结构
在动手写小程序页面之前,先要把腾讯云地址解析接口的输入输出钉死。这一步偷懒,后面回填表单时就会反复返工。地址解析是自然语言处理能力下的一个独立接口,它理解的是一整段带姓名、电话、行政区划的自然语言,而不是单纯拿正则做字符串切分。接口本身是典型的 HTTP + 签名调用,服务端收到Text之后,内部会做分词、行政区划对齐、姓名电话抽取,最后按字段返回。真正需要自己填的其实只有三样:待解析文本、鉴权凭证、请求端点。
2.1 请求体:真正要填的只有文本和鉴权
调用腾讯云 API 有两条路:自己拼请求做 TC3-HMAC-SHA256 签名,或者直接用官方 Node.js SDK。前者适合服务端语言没有官方 SDK 的场景,后者在小程序云函数里几乎是唯一选择,因为签名里的时间戳、规范化请求串、密钥派生步骤任何一步写错都会返回AuthFailure.SignatureFailure,排查成本远高于引入一个 SDK。SDK 内部已经把签名、重试、错误码解析都包掉了,你只需要关注业务参数。
// cloudfunctions/addressParse/index.js const tencentcloud = require('tencentcloud-sdk-nodejs'); // 产品子包与版本号以控制台「API Explorer」生成的示例为准,抄错会直接报 Action 不存在 const NlpClient = tencentcloud.nlp.v20190408.Client; const client = new NlpClient({ credential: { // 密钥从环境变量读,不要写死在源码里,后面第 5 章会专门讲这个坑 secretId: process.env.TC_SECRET_ID, secretKey: process.env.TC_SECRET_KEY, }, // 地域按控制台文档填,地址解析这类文本接口对地域不敏感,但签名必须带 region: 'ap-guangzhou', profile: { httpProfile: { // 端点写死产品域名,避免 SDK 默认端点拼错 endpoint: 'nlp.tencentcloudapi.com', // 云函数默认超时 3 秒,这里给到 8 秒,留出网络抖动余量 reqTimeout: 8, }, }, }); // 单次调用 async function parseOnce(text) { const res = await client.AddressParse({ Text: text }); return res; }这段代码里真正会变的只有三个参数。endpoint是产品域名,不同云产品不一样,抄错会 404 或签名不匹配。reqTimeout建议不低于 5 秒,跨境或弱网环境下 3 秒很容易被截断,表现出来就是「明明接口有返回,前端却提示超时」。Text是唯一业务参数,一般有长度上限,太短的文本(比如只有「深圳」两个字)解析出来的字段会大面积为空,前端最好在入口处做一次长度下限判断。
提示:Action 名、Version、端点这三项一定要以控制台「API 文档」页当前展示的为准。不同账号看到的产品入口可能挂在自然语言处理或文字识别下面,照抄控制台示例最省事。
2.2 返回结构逐字段对照与空值语义
接口返回的是一个扁平 JSON,字段不多,但每个字段的空值语义都不一样,这一点如果不提前想清楚,回填表单时就会写出「明明解析成功,选择器却是空的」这种 bug。
| 返回字段 | 含义 | 常见空值场景 | 回填建议 |
|---|---|---|---|
| Province | 省级行政区 | 用户只写到区县,没写省 | 为空时联动机器字典反查 |
| City | 地级市 | 直辖市常与 Province 同名 | 与 Province 比对去重 |
| Town | 区县 | 用户只写到街道或楼栋 | 为空时降级为手动选择 |
| DetailAddress | 门牌、楼栋、房间 | 原文本身只到区县 | 允许为空,不阻断提交 |
| Name | 收件人姓名 | 原文没写姓名 | 空则聚焦姓名输入框 |
| Phone | 联系电话 | 原文没写电话 | 空则聚焦电话输入框 |
这张表最需要留意的是 City 和 Province 同名的情况。直辖市的地址写成「北京市朝阳区……」时,接口通常会把 Province 和 City 都返回成「北京市」,如果直接塞进小程序的picker三级联动,就会出现同一级重复选中、级联索引错位。处理方式是在mapToForm里做一次同名去重,后面第 4 章会给具体代码。
2.3 正则切地址的三种必翻车场景
很多团队第一反应是「用正则把省市区切出来不就行了」,在地址格式高度规范的场景下确实能跑,但只要用户输入稍微自由一点就会翻车。我见过最常见的三种情况。
第一种是省市区连写没有分隔符,比如「广东省深圳市南山区科技园」,正则可以按「省」「市」「区」后缀切,但遇到「内蒙古自治区呼和浩特市」这种自治区全称,后缀规则就要重新枚举一遍,越写越长。第二种是别名和简称混用,「深圳」和「深圳市」、「内蒙」和「内蒙古自治区」在正则里是两套模式,而地址解析接口内部有行政区划词典,能自动对齐。第三种是姓名、电话和地址粘在一起,正则切分很容易把手机号末尾几位当成门牌号的一部分。这三种情况本质上是同一类问题:地址不是格式化的结构化数据,而是自然语言。把自然语言交给专门做语义解析的接口,比维护几百行正则更划算。
3. 密钥不进小程序:云函数代理与前端封装的完整链路
接口契约确认之后,下一步是把调用链路搭起来。这里有一条硬性约束:腾讯云的 SecretId 和 SecretKey 绝对不能出现在小程序代码包里。小程序包是可以被反编译的,密钥一旦进包,等于把账号下的调用额度公开。正确做法是把调用放进云函数或自建后端,小程序只跟自己的服务端通信。AddressParseTest 这个示例走的是云开发云函数方案,优点是省掉服务器运维,缺点是冷启动和超时需要额外处理。
3.1 云函数骨架:依赖、超时、返回值约定
云函数的职责很单一:收文本、调接口、按统一格式返回。统一返回格式这件事看着不起眼,但前端要处理的成功、业务失败、网络失败三种情况如果返回结构不一致,页面里就会写出一堆 if-else。
// cloudfunctions/addressParse/index.js const tencentcloud = require('tencentcloud-sdk-nodejs'); const NlpClient = tencentcloud.nlp.v20190408.Client; // 复用 client,避免每次冷启动都重新构造 let client = null; function getClient() { if (!client) { client = new NlpClient({ credential: { secretId: process.env.TC_SECRET_ID, secretKey: process.env.TC_SECRET_KEY, }, region: 'ap-guangzhou', profile: { httpProfile: { endpoint: 'nlp.tencentcloudapi.com', reqTimeout: 8 } }, }); } return client; } exports.main = async (event) => { const text = (event.text || '').toString().trim(); // 长度下限挡误触,上限挡接口长度限制,具体上限看文档 if (text.length < 6 || text.length > 300) { return { code: 1001, msg: '文本长度不合规' }; } try { const res = await getClient().AddressParse({ Text: text }); return { code: 0, data: res }; } catch (e) { // 把原始错误信息回传,前端按 code 分支处理 return { code: 2001, msg: e.message, requestId: e.requestId || '' }; } };这里有两个细节容易被忽略。一是client的单例复用,云函数在热实例里重复执行main,如果每次 new 一个 client,SDK 内部的连接池和配置对象都会被反复创建,冷启动时间会被拉长。二是云函数的超时时间要在控制台里调,默认 3 秒对网络调用来说太紧,建议调到 10 秒以上,同时让接口的reqTimeout比云函数超时略小,这样接口先超时,云函数还能正常返回错误码,前端拿到的是可读提示而不是一个笼统的调用失败。
注意:环境变量在云函数控制台的配置项里添加,添加后需要重新部署或重启实例才会生效,改完不生效十有八九是没重新部署。
3.2 前端调用封装:loading、错误码、重复点击
前端这一层要做的事比想象中多。wx.cloud.callFunction本身的回调风格和 Promise 混用很容易写出 callback 地狱,所以先用一层 Promise 封装。封装里做三件事:空值拦截、结果码判断、错误信息统一。
// utils/address.js function parseAddress(rawText) { return new Promise((resolve, reject) => { const text = (rawText || '').trim(); if (!text) return reject(new Error('请输入收货信息')); wx.cloud.callFunction({ name: 'addressParse', data: { text }, success(res) { const r = res.result || {}; // code 为 0 才是业务成功,2001 是接口侧失败,1001 是入参不合法 if (r.code !== 0) return reject(new Error(r.msg || '解析失败')); resolve(r.data); }, fail(err) { reject(new Error(err.errMsg || '网络异常,请重试')); }, }); }); } module.exports = { parseAddress };页面层的重点在交互,不在逻辑。用户点「识别」按钮时,必须立刻把按钮置灰、显示 loading,否则连续点击会打出多次调用,既浪费额度,又可能让后一次返回覆盖前一次结果。我用一个parsing状态位来做这件事,比单纯依赖wx.showLoading更可靠,因为 loading 只是视觉层,拦不住代码继续执行。
// pages/order/address.js const { parseAddress } = require('../../utils/address'); Page({ data: { raw: '', name: '', phone: '', region: [], detail: '', parsing: false }, onRawInput(e) { this.setData({ raw: e.detail.value }); }, async onParseTap() { // 状态位拦截重复点击,避免一次点击打两次调用 if (this.data.parsing) return; this.setData({ parsing: true }); wx.showLoading({ title: '识别中', mask: true }); try { const r = await parseAddress(this.data.raw); this.setData(mapToForm(r)); wx.showToast({ title: '已填充', icon: 'success' }); } catch (e) { wx.showToast({ title: e.message, icon: 'none' }); } finally { wx.hideLoading(); this.setData({ parsing: false }); } }, });finally里必须把parsing复位,这一步漏掉的后果是用户第一次解析失败之后按钮永久不可用,只能杀掉小程序重进。这种问题在测试阶段很难被发现,因为开发者通常一次就点成功了。
3.3 没有真实密钥时的本地 mock 联调
真实密钥配置之前,前端不可能一直干等。最省事的做法是在云函数里加一个开关,命中 mock 分支时直接返回一份预置结构,让前端先把回填逻辑跑通。这样即使接口还没开通、密钥还没配好,页面交互也能先验收。
// cloudfunctions/addressParse/index.js 顶部加入 const USE_MOCK = process.env.USE_MOCK === '1'; exports.main = async (event) => { const text = (event.text || '').toString().trim(); if (text.length < 6) return { code: 1001, msg: '文本长度不合规' }; if (USE_MOCK) { // mock 数据字段结构与真实返回保持一致,前端不用改代码 return { code: 0, data: { Province: '广东省', City: '深圳市', Town: '南山区', DetailAddress: '科技园某某大厦 18 楼 A 座', Name: '王某', Phone: '13800000000', }, }; } // ... 真实调用 };这个开关上线前必须关掉,否则所有请求都会返回同一份假数据。我的做法是把它挂在环境变量上,而不是写死在代码常量里,这样云函数控制台里改一下就能切,不用重新发版。
4. 从解析结果到可用表单:地址标准化与三级联动回填
接口返回的是扁平字段,小程序的表单用的是picker三级联动加几个输入框,两者之间的映射关系需要一层转换函数来完成。这一层写得好,用户点完识别几乎不用再动手;写得糙,用户得在每个字段上重新校一遍,体验比手填还差。
4.1 省市区字段与 picker 的对齐规则
小程序的picker用mode="region"时,value期望的是一个长度为 3 的数组,依次是省、市、区。接口返回的Province、City、Town大部分情况下能一一对上,但有两个例外必须在转换时处理:一是直辖市省市同名,二是部分开发区、新区在标准行政区划表里没有对应项。前者做去重,后者降级为只填到上一级。
// utils/format.js function pick(obj, keys) { for (const k of keys) { const v = obj[k]; if (v && String(v).trim()) return String(v).trim(); } return ''; } function mapToForm(res) { const province = pick(res, ['Province']); const city = pick(res, ['City']); const town = pick(res, ['Town', 'District', 'County']); const detail = pick(res, ['DetailAddress', 'Address']); const name = pick(res, ['Name']); const phone = pick(res, ['Phone', 'Tel']); // picker 要求三段齐全,缺任何一段都退化成空数组,让用户手动选 let region = []; if (province && city && town) { // 直辖市省市同名时,picker 里第二段仍需填入,不能简单删掉 region = [province, city, town]; } else if (province && city) { region = [province, city, '']; } else if (province) { region = [province, '', '']; } return { name, phone, region, detail }; }pick这个辅助函数的意义在于兼容。字段名在不同版本、不同入口之间偶尔会有细微差异,比如区县字段可能叫Town也可能叫District,通过一次多键尝试来兜底,比在每个调用点写||更清晰。region的降级策略也要明确:宁可让用户手动补一段,也不要把残缺的三段塞进去导致picker显示成空白。
4.2 姓名与电话的抽取边界
姓名和电话这两个字段看起来最简单,实际上是最容易出问题的地方。地址解析接口能识别「收件人王某」「联系人:王某」「手机 138xxxxxxxx」这类带标签的写法,也能识别文本中间直接出现的姓名和号码,但对几种边界情况需要前端配合兜底。
第一种是姓名里带称谓,「王先生」「李女士」接口可能原样返回,也可能只返回「王」「李」。这不是接口的问题,而是称谓本身不属于姓名字段,回填之后用户自己补全即可,前端不必强行清洗。第二种是号码格式,如果原文写的是「138 0000 0000」带空格,或写成「138-0000-0000」,返回结果可能带分隔符,直接塞进input会让用户觉得没识别干净。我一般在前端再做一次归一化。
// 去掉手机号中的空格、短横线,保留纯数字 function normalizePhone(phone) { const digits = (phone || '').replace(/[^\d]/g, ''); // 简单的内地手机号校验,不匹配就原样展示,让用户自己判断 if (/^1[3-9]\d{9}$/.test(digits)) return digits; return phone || ''; }这个函数只做格式归一,不做合法性强制。原因是有些场景确实会填座机或境外号码,前端一刀切校验会把合法输入拦掉。校验放到提交订单那一步做,识别这一步只管把数据显示出来。
4.3 详细地址的截断与二次拼接
DetailAddress返回的是去掉省市区之后的剩余部分,理论上是最干净的门牌信息。但实际使用中,用户可能会把公司名、楼栋名也写进原文,接口会把这些内容一并放进详细地址,长度有时候会超过输入框的限制。更麻烦的是,当用户需要把地址回显成一行完整文本(比如用来调物流面单接口)时,需要把省市区和详细地址重新拼回去,拼接规则不统一就会拼出「广东省深圳市深圳市南山区」这种重复。
// 把分段地址拼回一行完整文本 function buildFullAddress(form) { const [p = '', c = '', t = ''] = form.region || []; const detail = (form.detail || '').trim(); // 省市同名或市区同名时跳过重复段 const parts = [p, p === c ? '' : c, c === t ? '' : t].filter(Boolean); return parts.join('') + detail; }拼接规则的核心是「相邻两级同名就跳过后者」。直辖市的p === c,省直辖县级市的c === t,两种情况用同一行代码就能覆盖。这个函数建议只写一份放在公共工具里,订单页、地址列表页、面单预览页都调它,不要每处各写一套,否则改规则的时候一定会漏改。
5. 避坑排查:地址解析最容易翻车的五处
前面几章走的是正常路径,这一章记录的是我在实际项目里真金白银踩过的坑。每一条都按「现象、原因、解决」的结构写,照着对照能省下不少排查时间。
5.1 密钥硬编码进小程序包
现象:测试环境一切正常,上线后收到调用频次异常告警,账单比预期高出好几倍。
原因:SecretId 和 SecretKey 写在了小程序的config.js里,虽然只是在测试分支用,但打包脚本没有把它排除掉,最终随主包一起发布。小程序包被解包之后密钥就直接暴露了。
解决:把密钥从源码里彻底删掉,改成云函数环境变量读取。上线前用反编译工具扫一遍包,确认没有任何Secret关键字。这件事没有后悔药,密钥一旦泄露只能立刻轮换。
5.2 返回空字段却提示「解析成功」
现象:用户粘贴了一段只有「深圳市南山区科技园」的文本,点识别后 toast 提示「已填充」,但姓名、电话、省市区全是空的。
原因:前端只判断了云函数的code === 0,没有判断业务字段是否为空。接口在这种情况下确实返回成功,只是字段为空,因为原文里本来就没有对应信息。
解决:在mapToForm之后增加一次有效性判断,如果region和detail同时为空,就提示「未识别到有效地址,请手动填写」,而不是无脑提示成功。
5.3 直辖市的省与市同名导致 picker 错位
现象:北京、上海、天津、重庆四个直辖市的地址,识别之后picker显示成第一段正确、第二段空、第三段才对,用户要手动重新选一遍。
原因:直辖市返回的Province和City是同一个值,picker在匹配级联数据时,如果第二级没有完全同名的选项,就会落到空值上。
解决:在mapToForm里保留三段结构,不去重、不删段,让picker按标准行政区划表正常匹配。真正需要去重的是buildFullAddress拼接完整文本那一步,两者职责要分开。
5.4 超时重试把调用量放大三倍
现象:接口偶发超时,前端做了自动重试,结果某个时间段内调用量是正常的三倍,账单异常。
原因:重试逻辑写在catch里,没有区分错误类型,超时重试、限流重试、参数错误重试全都走同一条路径,而参数错误重试再多次也不会成功。
解决:只对超时和网络类错误做重试,且最多一次;业务类错误(参数不合法、余额不足、权限不足)直接透传提示,不重试。重试间隔加一个 500ms 左右的延迟,避免瞬时并发。
5.5 全角符号、空格、表情污染输入
现象:用户从聊天记录里复制地址,带了一堆全角空格、换行和表情,识别结果里区县字段为空,详细地址却把表情也带进来了。
原因:复制过来的文本里混入了不可见字符,接口在分词时把它们当成了分隔符的一部分,导致行政区划匹配失败。
解决:前端在调云函数之前先做一次清洗,把全角空格、零宽字符、连续换行、表情符号去掉,再trim。清洗逻辑要轻,不要动中文标点,否则会把「XX路(近XX地铁站)」里的括号也删掉,反而影响详细地址的完整性。
6. 进阶:用一批脏数据测出真实识别率,再决定要不要上
接口能跑通只是起点,真正决定要不要在业务里上这个能力的,是识别率数据。我的习惯是先拿三十到五十条真实场景的脏数据跑一遍,按「全字段命中」「部分字段命中」「完全失败」三档打标,算出各字段的命中率,再决定前端要不要保留手动修改入口。
测试样本要覆盖这几类:标准全字段(姓名、电话、省市区、门牌齐全)、缺姓名或缺电话、省市区连写无空格、直辖市地址、自治区全称、只写到区县、带公司名或楼栋名、从聊天记录复制的带换行文本。每条样本只需要人工标注一次期望值,之后每次接口参数调整、SDK 升级都可以拿这批样本回归。
| 指标 | 计算方式 | 关注阈值 |
|---|---|---|
| 省字段命中率 | 省匹配条数 / 总条数 | 低于 95% 说明前缀处理有问题 |
| 区县字段命中率 | 区县匹配条数 / 总条数 | 低于 90% 要看清洗逻辑 |
| 姓名命中率 | 姓名匹配条数 / 总条数 | 低于 80% 说明原文写法太自由 |
| 电话命中率 | 电话匹配条数 / 总条数 | 低于 85% 要检查号码格式 |
| 全字段命中率 | 六段全对条数 / 总条数 | 低于 60% 建议保留手动入口 |
跑完这张表会得到一个很实际的结论:地址解析能把手填六个字段压缩成「确认两个字段」,但做不到零修改。所以产品设计上一定要保留「识别后手动修改」的路径,而不是识别完直接锁死表单。成本方面,接口按调用次数计费,重复点击和失败重试是两个主要的浪费来源,前面 5.4 那条的重试策略能压掉一大半。批量场景(比如后台导入历史订单)可以做一个本地缓存,同一段文本短时间内重复请求直接返回上一次结果,省下的调用量相当可观。
从那以后我每次接地址类的需求,都会先把这批脏数据样本跑一遍再谈体验优化,而不是等上线之后被用户投诉倒推着查。样本文件就放在项目根目录,跟代码一起版本管理,谁都别想着绕过去。希望帮到你。
本文还有配套的精品资源,点击获取