简介:面向微信小程序开发者与电商物流场景的快递地址智能解析示例,重点演示如何借助腾讯云地址解析API,把用户输入的非标准收货地址自动识别为省、市、区等结构化信息,并按统一格式回填,减少后台清洗成本。压缩包共18个文件,约16KB,以JavaScript业务与工具脚本、JSON项目配置、WXML页面结构和WXSS样式为主,另含少量系统缓存文件,工程体量轻便,便于快速导入微信开发者工具对照阅读。已有8242人学习,说明该主题在课程设计与功能原型中较受关注。内容覆盖API密钥接入、HMAC-SHA1签名与Base64编码、请求参数构造、JSON响应解析以及用户确认交互和异常处理思路,可直接参考其目录分工与调用链路;确认按钮事件尚未完成,也为二次开发留出练习空间。
1. 从一堆脏地址说起:这个 zip 里装的是什么
快递地址是最不讲道理的一类输入。用户在小程序里粘贴进来的可能是「张先生 13800001234,XX省XX市XX区示例路88号3栋201,放门口谢谢」,也可能只有「示例路88号」,中间夹着全角空格、表情符号、换行和「省」字省略。用正则硬拆,「XX省XX市」还能应付,一旦遇到直辖市、自治区、县级市、开发区,规则表就会以肉眼可见的速度失控。
这份 AddressParseTest.zip 走的是另一条路:把脏文本丢给腾讯云 api 的地址解析能力做地址识别,一次返回省、市、区、街道、门牌、姓名、电话等结构化字段,再在小程序端做地址格式化和回填。它适合正在做电商下单页、校园快递代取、社区团购收货地址、物流面单录入的开发者,尤其是那种「不想自己维护一份几万条行政区划词表」的团队。下面按拆包、跑通、参数、标准化、踩坑、验证的顺序讲。
2. 拆包与跑通:小程序端 + 云函数把密钥挡在门外
2.1 先看目录,再决定跑在哪
压缩包解开后结构通常不复杂,属于「一个页面 + 一个请求封装 + 一个服务端代理」的最小闭环。真正需要你先做决策的不是代码,而是这套东西跑在哪里:小程序端只能发 HTTPS 请求,且必须在小程序后台配置合法域名;而地址解析接口的 SecretKey 绝对不能出现在小程序代码里——小程序包是可以被反编译的,密钥写在前端等于直接送人。
常见的三种跑法我列在下面,选型理由也一并写清楚,别上来就照抄代码。
| 跑法 | 请求链路 | 适合场景 | 代价 |
|---|---|---|---|
| 微信云开发云函数 | 小程序 → 云函数 → 腾讯云 api | 个人开发者、小团队、无自有服务器 | 需要开通云开发,函数要配外网访问 |
| 自建 Node/Java 服务 | 小程序 → 自有域名 → 腾讯云 api | 已有后端、要做统一鉴权和限流 | 域名要备案 + HTTPS 证书 + 白名单配置 |
| 云函数 HTTP 触发器 | 小程序 → 函数 URL → 腾讯云 api | 想省掉服务器但又不想用云开发 | 触发器域名同样要进小程序白名单 |
新手最容易翻车的是第三种:以为云函数开了 HTTP 触发器就能直接调,结果小程序报「不在以下 request 合法域名列表中」。域名白名单只认备案过的自有域名,触发器给的默认域名经常配不进去,这个后面第 5 章还会细说。
跑通前需要准备三样东西:小程序的 AppID(真机调试要用)、腾讯云账号下的 SecretId 与 SecretKey(建议单独建一个子账号,只授予地址解析相关权限,别用主账号密钥)、以及一个能走 HTTPS 的请求入口。三样缺一,代码写得再对也是 403。
2.2 云函数:一个函数干三件事
服务端这个函数职责很窄:收文本、调接口、把结果裁成小程序要的形状。中间还要干一件容易被忽略的事——把密钥从环境变量里读出来,而不是写在代码里。下面这段是 Node 运行时下的写法,云开发和云函数都能直接用。
// cloudfunctions/addressParse/index.js const crypto = require('crypto') const https = require('https') // 密钥一律走环境变量,代码里出现明文 SecretKey 就是事故起点 const SECRET_ID = process.env.TC_SECRET_ID const SECRET_KEY = process.env.TC_SECRET_KEY const HOST = 'nlp.tencentcloudapi.com' // 地址解析所在的服务域名 const SERVICE = 'nlp' const ACTION = 'AddressParse' const API_VERSION = process.env.TC_API_VERSION // 版本号从控制台文档抄,别凭记忆写 function sha256hex(str) { return crypto.createHash('sha256').update(str, 'utf8').digest('hex') } function hmac(key, str) { return crypto.createHmac('sha256', key).update(str, 'utf8').digest() } // TC3-HMAC-SHA256 签名,注意 canonicalRequest 的换行一个都不能多 function buildAuthorization(payload, timestamp) { const date = new Date(timestamp * 1000).toISOString().slice(0, 10) // UTC 日期,别用本地时间 const contentType = 'application/json; charset=utf-8' const canonicalHeaders = `content-type:${contentType}\nhost:${HOST}\n` const signedHeaders = 'content-type;host' const canonicalRequest = [ 'POST', '/', '', canonicalHeaders, signedHeaders, sha256hex(payload) ].join('\n') const scope = `${date}/${SERVICE}/tc3_request` const stringToSign = [ 'TC3-HMAC-SHA256', timestamp, scope, sha256hex(canonicalRequest) ].join('\n') const kDate = hmac('TC3' + SECRET_KEY, date) const kService = hmac(kDate, SERVICE) const kSigning = hmac(kService, 'tc3_request') const signature = crypto.createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex') return `TC3-HMAC-SHA256 Credential=${SECRET_ID}/${scope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}` } function callAddressParse(text) { return new Promise((resolve, reject) => { const payload = JSON.stringify({ Text: text }) const timestamp = Math.floor(Date.now() / 1000) const req = https.request({ host: HOST, method: 'POST', path: '/', timeout: 5000, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Host': HOST, 'X-TC-Action': ACTION, 'X-TC-Version': API_VERSION, 'X-TC-Timestamp': String(timestamp), 'X-TC-Region': process.env.TC_REGION || '', // 按文档要求填,留空也常见 'Authorization': buildAuthorization(payload, timestamp) } }, (res) => { let buf = '' res.on('data', c => buf += c) res.on('end', () => { try { const body = JSON.parse(buf) if (body.Response && body.Response.Error) { return reject(new Error(`${body.Response.Error.Code}: ${body.Response.Error.Message}`)) } resolve(body.Response) } catch (e) { reject(e) } }) }) req.on('timeout', () => req.destroy(new Error('upstream_timeout'))) req.on('error', reject) req.write(payload) req.end() }) } exports.main = async (event) => { const text = String(event.text || '').trim() if (!text) return { code: 400, msg: 'empty_text' } if (text.length > 200) return { code: 400, msg: 'text_too_long' } // 超长先截断或走分段 try { const resp = await callAddressParse(text) return { code: 0, data: resp } } catch (e) { return { code: 500, msg: e.message } } }逻辑上分四段看。第一段是密钥读取,process.env在云函数控制台的「环境变量」里配,本地调试时用.env注入,任何时候都不要把 SecretKey 提交进仓库。第二段是签名,canonicalRequest用数组 join 拼出来,canonicalHeaders本身就带尾随换行,所以中间那个空行是靠''这一项产生的——这里多一个或少一个换行,服务端返回的就是AuthFailure.SignatureFailure,而且不告诉你哪错了。第三段是请求体,Text就是待解析的原始字符串,长度控制在 200 字符以内比较稳,超长的先按标点切段。第四段是返回,把Response.Error单独拎出来 reject,否则小程序端拿到的永远是null。
参数上还有两个细节:X-TC-Timestamp必须和服务端时间差在五分钟内,云函数环境一般是同步好的,自建服务器上跑过定时任务的机器经常因为 NTP 没配准而签名失败;X-TC-Region按官方文档要求填,不是所有接口都需要,但少了可能被拒。
2.3 小程序端:request 封装与结果映射
小程序这一侧别直接把整坨返回塞进setData,字段多、体积大,渲染层会卡。做法是先做一层归一再回填,同时把 loading 和错误提示处理掉。
// pages/address/index.js const PARSE_API = 'https://your-domain.com/api/address/parse' // 必须 HTTPS 且在合法域名列表里 Page({ data: { raw: '', loading: false, form: null, errMsg: '' }, onRawInput(e) { this.setData({ raw: e.detail.value, errMsg: '' }) }, async onParse() { const text = this.data.raw.trim() if (!text) return this.setData({ errMsg: '请先粘贴收货信息' }) if (this.data.loading) return // 防连点,重复请求也要计费 this.setData({ loading: true }) try { const res = await new Promise((resolve, reject) => { wx.request({ url: PARSE_API, method: 'POST', timeout: 8000, // 客户端超时要比服务端长一点 header: { 'content-type': 'application/json' }, data: { text }, success: r => (r.statusCode === 200 ? resolve(r.data) : reject(new Error('http_' + r.statusCode))), fail: reject }) }) if (res.code !== 0) throw new Error(res.msg || 'parse_failed') // 只取业务要用的字段,原始响应留在日志里排查用 const d = res.data || {} this.setData({ form: { name: d.Name || '', phone: d.Phone || '', province: d.Province || '', city: d.City || '', district: d.District || '', street: d.Street || '', detail: d.Detail || '' } }) } catch (e) { // 解析失败不要静默,退化成手动填写,用户才不会卡住 this.setData({ errMsg: '自动识别失败,请手动填写详细地址' }) } finally { this.setData({ loading: false }) } } })这段的关键点有三个。timeout设 8000 而不是默认值,是因为地址解析偶发慢响应,客户端先超时会让用户重复点,等于白花一次调用。返回字段做白名单映射,Name、Phone、Province、City、District、Street、Detail之外的东西一律不往前端传,避免把内部日志字段带出去。失败分支一定要能给用户「手动填」的出口,智能识别这东西本来就是概率性的,把它做成必经关卡,用户体验会崩。
另外wx.request的data如果传对象,默认会被序列化成 JSON,但header里的content-type必须写对,否则服务端云函数拿到的event可能是字符串而不是对象,这个坑很隐蔽。
3. 接口参数逐项核对:AddressParse 的输入、输出与签名
3.1 请求体与返回字段
地址解析的请求体很简单,但字段名的大小写和必填项要按文档来,写错一个字母就是InvalidParameter。下面这张表是我自己核对时用的,改动参数时对着看比翻文档快。
| 参数 | 位置 | 是否必填 | 说明与常见取值 |
|---|---|---|---|
X-TC-Action | Header | 是 | 固定为地址解析的 Action 名,大小写敏感 |
X-TC-Version | Header | 是 | 接口版本号,从控制台文档取,不要猜 |
X-TC-Timestamp | Header | 是 | 秒级时间戳,与服务端偏差超过 5 分钟会签名失败 |
X-TC-Region | Header | 视文档 | 地域参数,部分接口可留空 |
Authorization | Header | 是 | TC3-HMAC-SHA256 签名串,见 3.3 |
Text | Body | 是 | 待解析的原始地址文本,建议 ≤ 200 字符 |
返回侧字段数量比请求多得多,实际业务里真正会用到的就那么几个,剩下的是给你做兜底和排查用的。
| 返回字段 | 含义 | 业务用途 |
|---|---|---|
Province/City/District | 省、市、区县 | 三级联动回填,入库主键 |
Street/Detail | 街道、详细门牌 | 面单打印、配送员导航 |
Name/Phone | 收件人、联系电话 | 自动填充表单 |
RequestId | 本次请求唯一标识 | 出问题时拿它提工单,别拿截图 |
有个细节值得单独说:返回的省市区有时是「XX省」「XX市」全称,有时会带「自治区」「地区」字样,和你在数据库里存的简称对不上。这不是接口的问题,是你自己的行政区划表没做别名映射,第 4 章会处理。
3.2 文本预处理:决定准确率的一半
同一段地址,清洗前后丢进去,解析结果的完整度能差出一截。用户粘贴的内容里最常见的是不可见字符:全角空格、零宽字符、从聊天软件带过来的换行和表情。这些不处理,接口会把它们当成地址的一部分,Detail里就会混进一堆乱码。
// utils/preprocess.js function normalizeText(raw) { if (!raw) return '' let s = String(raw) // 1. 全角转半角(数字、字母、括号、冒号),地址里的门牌号经常是全角 s = s.replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)) // 2. 去掉零宽字符和表情,表情会让后续的长度判断和正则全部失准 s = s.replace(/[\u200B-\u200D\uFEFF]/g, '') s = s.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '') // 3. 各种分隔符统一成空格,连续空白压成一个 s = s.replace(/[,,、;;|/\\\t\r\n]+/g, ' ').replace(/\s+/g, ' ').trim() // 4. 手机号从正文里摘出来单独传更稳,正文里留着也不影响 return s } // 超长文本按标点切段,逐段解析后合并,避免被长度限制直接拒掉 function splitByLength(text, limit = 180) { if (text.length <= limit) return [text] const parts = [] let rest = text while (rest.length > limit) { let cut = rest.lastIndexOf(' ', limit) if (cut < 60) cut = limit // 找不到合适断点就硬切,别死循环 parts.push(rest.slice(0, cut).trim()) rest = rest.slice(cut).trim() } if (rest) parts.push(rest) return parts } module.exports = { normalizeText, splitByLength }全角转半角那一步用的是码位偏移,\uFF01到\uFF5E与 ASCII 的0x21到0x7E一一对应,减0xFEE0就对齐了,比写一张映射表省事。零宽字符和表情必须在前面去掉,否则后面length判断全是错的——一个 emoji 在 JS 里占两个码元,截断时还可能切出半个代理对,直接变成乱码。
分段的阈值我一般设 180,比接口上限略保守,留出拼装的余量。断点优先找空格,找不到才硬切,硬切之后一定要在合并阶段把重复的省市区去重,否则一个地址可能被解析成两段各自带省市。
3.3 TC3-HMAC-SHA256 签名:手写版最容易折在这三行
不用 SDK 而手写签名,多半是因为云函数打包体积限制,或者不想为了一个接口引入整包依赖。手写能省事,但三行代码最容易出错:日期取的是 UTC 不是本地、canonicalRequest的换行数、SecretKey前缀拼的是TC3而不是服务名。
// 三个高频错误点的最小对照 const date = new Date(timestamp * 1000).toISOString().slice(0, 10) // 对:UTC 日期 // const date = new Date(timestamp * 1000).toLocaleDateString() // 错:本地时区会差一天 const kDate = hmac('TC3' + SECRET_KEY, date) // 对:前缀固定 TC3 // const kDate = hmac(SECRET_KEY, date) // 错:少了前缀,签名必错 const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, payloadHash].join('\n') // canonicalHeaders 末尾必须带 \n,中间那个 '' 才产生出"空行"分隔签名阶段的报错信息通常只有一句AuthFailure.SignatureFailure,不会告诉你差在哪。我的排查顺序是:先把payload原样打印出来,确认发出去的 JSON 和签名用的是同一个字符串(对象重新JSON.stringify一次,键顺序可能变);再打印stringToSign,和官方文档里的示例逐字符对比;最后才怀疑密钥。八成的问题出在第一步和第二步之间——签名时用了一个字符串,发请求时又序列化了另一个。
还有一点,X-TC-Timestamp进了stringToSign,所以签名和发请求必须用同一个时间戳变量。写过一次在两个函数里各自取Date.now(),跨秒的时候签名直接失效,这种问题只在压测并发时才暴露,属于典型的玄学 bug。
4. 从解析结果到可入库的标准化字段
4.1 三级行政区划对齐与编码映射
接口返回的是可读的省市区名,但你的数据库里存的可能是行政区划编码,或者一份带简称的字典表。直接把返回的字符串塞进外键字段,用不了多久就会出现「XX省」和「XX省XX市」两套数据,统计报表直接对不上。
标准化的思路是:先做别名归一,再用编码表反查,反查不到就降级到名称匹配并打标记。
// utils/region.js // 别名表只维护「不标准写法 → 标准名」,别在代码里维护全量行政区划 const ALIAS = { 'XX自治区': 'XX自治区', 'XX壮族自治区': 'XX自治区', // 实际项目里这里从后端字典拉,写死在代码里迟早过期 } function normalizeName(name) { if (!name) return '' const s = String(name).replace(/\s/g, '') return ALIAS[s] || s } /** * @param {object} parsed 地址解析返回对象 * @param {Map} regionMap 标准名 -> 行政区划编码 * @returns {{code:string, level:string, matched:boolean}} */ function toRegionCode(parsed, regionMap) { const province = normalizeName(parsed.Province) const city = normalizeName(parsed.City) const district = normalizeName(parsed.District) // 从细到粗逐级匹配,区县命中优先,因为区县名全国基本不重复 const candidates = [ { name: district, level: 'district' }, { name: city, level: 'city' }, { name: province, level: 'province' } ] for (const c of candidates) { if (c.name && regionMap.has(c.name)) { return { code: regionMap.get(c.name), level: c.level, matched: true } } } // 完全匹配不上就别硬编,落到待人工确认队列 return { code: '', level: 'unknown', matched: false } } module.exports = { normalizeName, toRegionCode }匹配顺序从区县往上,是因为「XX市」在全国有重名,而区县名重复率低得多。matched: false的这条记录必须能查出来,别默默丢掉——常见的是一批新划的开发区、撤县设区的老地址,解析结果看着对,字典里没有对应编码。
别名表要放在后端字典里定期同步,不要写死在代码里。我见过一个项目把几十条别名硬编码进小程序,后来行政区划调整,发版都来不及,只能眼睁睁看着一批订单地址进不了分拣系统。
4.2 置信度、兜底与人工复核队列
地址解析输出的是字段,不是「对错」。业务上必须给每个结果一个可判断的完成度,否则前端没法决定是自动填还是让用户确认。
我一般按下面这个规则打分,简单但够用:
| 判断条件 | 处理方式 | 理由 |
|---|---|---|
| 省市区齐全且编码匹配成功 | 直接回填,用户可改 | 绝大多数正常地址走这条 |
| 缺区县但有街道门牌 | 回填并高亮区县为空 | 用户补一个字比重新填快 |
| 省市区齐全但编码未匹配 | 回填但标记待复核 | 大概率是新区划或别名缺失 |
| 只有一段模糊文本 | 不自动填,走手动 | 强行填反而误导用户 |
复核队列的字段最少要有:原始文本、解析返回、命中级别、创建时间、处理状态。这条链路的价值在于,跑两周你就能从失败样本里看出规律——是某个省的地址普遍识别不出来,还是某类输入格式(比如带「自提点」「驿站」字样)系统性失败。有了这个,优化方向是数据驱动的,而不是拍脑袋换接口。
5. 避坑与常见问题排查
5.1 六条血泪记录
现象一:本地 Postman 能通,小程序里一直 400。原因是小程序wx.request传对象时默认序列化成 JSON,但如果header里没写content-type: application/json,云函数拿到的event是字符串,event.text取出来是undefined,直接返回空文本错误。解决是把content-type显式写进 header,服务端再加一层typeof event === 'string' ? JSON.parse(event) : event的兼容。
现象二:报「不在以下 request 合法域名列表中」。小程序只认已备案且配置进后台白名单的 HTTPS 域名,云开发默认域名、云函数触发器默认域名通常配不进去。解决有两个方向:绑自有域名并配好证书,或者干脆用wx.cloud.callFunction走云开发通道绕开 request 域名限制。
现象三:偶发AuthFailure.SignatureExpire。时间戳和签名不在同一秒,或者服务器 NTP 没同步,本地时间比标准时间快了几分钟。现象是压测时批量失败、单次调用又正常。解决是签名和请求复用同一个timestamp变量,机器上开时间同步。
现象四:Text传了 300 多字,返回参数错误。接口对文本长度有上限,长地址(尤其是带备注、门牌、楼栋、单元、房间号的)很容易超。解决是走 3.2 的分段函数,逐段解析后按省市区去重合并,别指望把上限调大。
现象五:同一条地址,上午解析对,下午解析错。不是接口不稳定,是输入里混进了不可见字符。用户从聊天窗口复制时带上了零宽连接符,肉眼完全看不出来。解决是在服务端入口强制跑一遍normalizeText,并且把清洗前后的文本都打进日志,对比一次就能定位。
现象六:并发一上来就大面积超时。默认超时设得太短,或者没做并发控制,一瞬间几百个请求打出去,前面的把连接池占满了。解决是服务端加信号量限制并发数,客户端做防连点,失败重试只做一次且加退避。
5.2 上线前的自检清单
这套东西不像静态资源,出问题往往不是「页面打不开」而是「地址悄悄填错了」,用户不会报错,只会收到货不对板。上线前我会固定过一遍下面这几项:
- 密钥是否只存在于服务端环境变量,仓库历史和前端包里搜一遍
Secret关键字,必须为零命中 - 请求入口是否 HTTPS,证书链是否完整(部分安卓机会因为中间证书缺失直接失败)
- 解析失败是否有手动填写兜底,且兜底路径不依赖任何网络请求
- 日志里是否同时保留原始文本、清洗后文本、返回
RequestId,三者缺一,事后都没法复盘 - 是否有单日调用量监控,异常增长要么是被刷,要么是客户端死循环重试
最后一条尤其重要。地址解析按调用计费,一个没做防抖的输入框,用户每敲一个字就请求一次,一天跑掉一个月的量并不稀奇。我一般会在客户端做 500ms 防抖,再在服务端按用户维度做每分钟限流,两层都上才踏实。
6. 批量清洗与准确率验证:一套土办法
存量地址怎么验证这套方案值不值得上?不用搞复杂的评测框架,抽两百条真实历史订单,跑一遍离线脚本,人工标注一遍,就能看出问题在哪。
// scripts/batch-check.js 离线跑,不经过小程序 const { normalizeText, splitByLength } = require('../utils/preprocess') const { toRegionCode } = require('../utils/region') async function runBatch(rows, parseFn, regionMap) { const stat = { total: rows.length, ok: 0, partial: 0, fail: 0, samples: [] } for (const row of rows) { const text = normalizeText(row.raw) const segs = splitByLength(text) let merged = null for (const seg of segs) { const r = await parseFn(seg) merged = merged ? Object.assign(merged, pickFilled(r)) : r // 后段只补空字段 } const hit = toRegionCode(merged || {}, regionMap) if (hit.matched && merged.District) stat.ok++ else if (hit.matched) stat.partial++ else { stat.fail++; stat.samples.push({ raw: row.raw, got: merged }) } } return stat } function pickFilled(r) { const out = {} for (const k of Object.keys(r)) if (r[k]) out[k] = r[k] return out }pickFilled那一步是整个批量脚本的核心:多段解析时,后面的段只能补前面为空的字段,不能覆盖。否则第二段里的「XX市」会把第一段正确的省市区冲掉。跑完之后重点看samples,通常前二十条就能看出失败集中在哪:是某类带「驿站」「自提柜」字样的地址,还是港澳台及海外地址,还是纯粹的手写缩写。
验证时别只看整体准确率。把样本按来源分一下——App 内选择器录入的、用户手打的、从聊天软件粘贴的——三类的成功率经常差出二三十个点。只盯着总数,你会以为方案及格了,实际上最需要优化的那类输入被平均掉了。
还有个成本上的经验:批量清洗不要一次性全量跑。先抽 1% 跑通、看样本、调预处理,再放到 10%,最后才全量。中间任何一步发现失败率异常,立刻停,因为全量跑一遍的钱够你调半个月参数了。
这套东西我前后在三个项目里用过,最大的教训不是签名也不是域名,而是「先假设解析会失败」。从那以后我每次接入这类识别能力,都强制先把手动填写的兜底路径跑通、把失败日志埋好,再去调准确率。地址这东西,用户填错了不会告诉你,只会在收货那天用差评提醒你。希望帮到你。
本文还有配套的精品资源,点击获取