微信小程序调用腾讯地图驾车距离接口的一系列坑
引言
最近突然有同事反馈,我们微信小程序查看门店距离的功能失效了...
这个功能是这样的:用户点击微信小程序门店tabBar时,返回门店列表(12个门店),这12个门店按照门店与当前用户的距离由近到远排序,主要是方便用户快速找到离自己最近的门店,增加获客量。
功能的实现思路其实还挺简单的:
- 门店列表后端返回,包含每个门店的店名、地址信息(经纬度)。
- 前端根据当前用户位置与每个门店的地址信息,算出驾车距离,我这里用的是腾讯地图sdk,使用的是 /ws/distance/v1 这个接口。
- 最后根据驾车距离由近到远给每个门店排序。
当时也是快速成功实现并上线了这个功能,也已经上线使用了一段时间了,咋最近突然不能用了?赶紧去排查原因。
解决问题的过程还真是一波三折,所以特意在此记录下。
每日调用量已达到上限
把项目用微信小程序打开,查看了一下network面板中调用https://apis.map.qq.com/ws/distance/v1接口的调用情况,发现报错 此key每日调用量已达到上限:
是因为调用这个 API 太多次造成额度不够了吗?可是我记得当时做这个需求的时候,这个 API 的每日可调用量有好几千次啊!不是,同一天真有几千个人用我们微信小程序啊,我有点不太信=.=
保险起见,还是去腾讯地图 - 配额管理那看下额度吧。
什么情况,这个接口咋找不到额度是多少?能找到 /ws/distance/v1/matrix 接口额度,有6千次,没找到 ws/distance/v1 这接口额度啊?
一番搜索,甚至还创建了个工单,问了下腾讯地图的客服,原来 ws/distance/v1 这个接口已经逐步下线了...
在ws/distance/v1 接口详情页中,顶部有一个产品通知:
看完了这通知,说实话有点无语:
- 为什么之前能用,现在却突然不能用了,通知上明明写的 已接入用户不受影响 。
- 既然接口都下线了,为什么返回报错还是 此key每日调用量已达到上限 ,都不能返回个比如 接口已经下线 这种友好的提示嘛,返回的报错信息有误就不能第一时间找到问题。
唉,吐槽归吐槽,bug 还是要改的,毕竟调用腾讯地图这接口,人家也没要我们钱,那还要啥自行车啊。既然通知提到了建议使用 距离矩阵(多对多)服务,那就去试试吧。
微信小程序SDK中接口缺失
在微信小程序中调用腾讯地图位置服务,有使用 SDK 和 WebServiceAPI 这两种方法。
当前我们的微信小程序使用腾讯地图位置服务,是靠引入SDk方法实现的。方法是先下载微信小程序JavaScriptSDK,之后使用SDK里封装的方法调用腾讯地图相关的接口。
SDK 使用的方法大概如下
/**
* 腾讯地图SDK - 使用示例
*
*/
// 引入SDK核心类,js文件根据自己业务,位置可自行放置
var QQMapWX = require('../js/sdk/qqmap-wx-jssdk.js');
var qqmapsdk;
// Vue2 Mixin 示例
export default {
onLoad() {
// 实例化API核心类
const key = (this.$BASIC_SETTINGS && this.$BASIC_SETTINGS.tencentMapKey) || "";
qqmapsdk = new QQMapWX({
key: key
});
},
methods: {
// 示例:使用calculateDistance方法计算距离
calculateDistanceExample() {
// 准备起点坐标
const from = {
latitude: 39.908823,
longitude: 116.397470
};
// 准备终点坐标数组
// 注意:to 参数支持数组,每个元素可以是:
// 1. 对象格式:{latitude: 39.918823, longitude: 116.407470}
// 2. 字符串格式:"39.918823,116.407470"
const to = [
{ latitude: 39.918823, longitude: 116.407470 },
{ latitude: 39.928823, longitude: 116.417470 }
];
// 调用接口
qqmapsdk.calculateDistance({
from: from, // 起点坐标
to: to, // 终点坐标数组
mode: 'driving', // 计算方式:'driving'-驾车,'walking'-步行
success: function(res) {
console.log('距离计算成功:', res);
// 返回结果结构:
// res.result.elements[0].distance - 距离(米)
// res.result.elements[0].duration - 时间(秒)
// res.result.elements[0].from - 起点坐标
// res.result.elements[0].to - 终点坐标
if (res.result && res.result.elements) {
res.result.elements.forEach((element, index) => {
console.log(`到第${index + 1}个终点的距离:`, element.distance, '米');
console.log(`预计时间:`, element.duration, '秒');
});
}
},
fail: function(res) {
console.log('距离计算失败:', res);
},
complete: function(res) {
console.log('距离计算完成:', res);
}
});
}
}
}
其实就是通过调用 SDK 中 calculateDistance 函数去调用 https://apis.map.qq.com/ws/distance/v1/ 接口,现在这个接口不能用了,得使用https://apis.map.qq.com/ws/distance/v1/matrix。想要使用 SDK 方法调用该接口,就得先找到这个接口在 SDK 中的调用函数是什么。
尴尬的是,我在 SDK 源码里找了半天,竟然没有调用这个接口的函数,我使用的SDK是 JavaScriptSDK V1.2,已经是最新的 SDK 了,竟然没找到。不是,你们家 SDK 里 接口都不全的吗?
查了下 WebService API 的更新日志:
距离矩阵ws/distance/v1/matrix接口是 2018-08-15 上线的。
又查了下微信小程序 JavaScriptSDK 的 更新日志 ,
JavaScriptSDK V1.2 是 2019-03-06 上线的,这个 SDK 上线的时候距离矩阵接口 ws/distance/v1/matrix 接口已经有了,没搞明白为什么当时封装 JavaScriptSDK V1.2 的时候没把ws/distance/v1/matrix 给封装进去。
看来 SDK 这条路子是行不通了,只能使用 WebServiceAPI 方式调用接口。
此key每秒请求量已达上限
在微信小程序中使用WebServiceAPI其实就是使用 wx.request 直接调用腾讯地图的相关接口。
边看文档边做,急赤白脸一顿写完,又有报错:此key每秒请求量已达上限
看了下 腾讯地图 - 配额管理 - Key额度 中 ws/distance/v1/matrix 接口的并发量额度,是 5次/秒。
这就奇怪了,接口只请求了 1 次,也没超过 5 次啊,咋会达到并发量上限呢?
我又测试了好几回,都是报同样的错误。但是在测试过程中,我发现了一个诡异的情况:network面板里接口每请求了1次,接口的今日调用量就用掉 12 次。
等等,我们的门店刚好是 12 个,看来请求距离这个接口的调用量和并发量的计算方式有蹊跷,于是我打开 ws/distance/v1/matrix 接口的 文档 ,找到了它的计量方式:
原来如此。
这个接口无论是今日调用量还是并发量的次数计算方式,都是按照距离的计算次数计算,而不是按照接口的请求次数计算。在当前我的这个业务中,实际上是一个起点对多个终点的情况:
起点为当前地址,终点是各个门店(总计12个)的地址,尽管接口只请求了一次,但是腾讯地图底层接口却调用了 1 * 12 = 12 次。所以接口的今日调用量用了 12 次,并且由于这 12个接口是同一秒调用的,并发量为12 次/秒,超过了并发量额度 5 次/秒的限制,所以就出现了错误: 此key每秒请求量已达上限。
思路理顺了,解决方案也就很清晰了,有两个方案:
方案一:完成企业认证,提升并发量额度(由于一些原因,我们的腾讯地图到现在还没完成企业认证)。
方案二:控制接口的请求频率。既然一次请求并发量额度是 5次/秒,那我们就把这 12 个门店分三次,逐秒请求,第一秒请求 5 个门店距离,第二秒请求 5 个门店距离,第三秒请求 2 个门店距离。
其实方案一应该是最好的,可以一劳永逸的解决这个问题。但是在我们这,企业认证是个麻烦事,审批流程又长又慢,等到领导审批完黄花菜都凉了。现在线上都报错了,等不了那么长时间,直接用方案二。
方案二也得优化以下,不能僵硬的按照 5-5-2 这种方式,门店可能会增加,也可能会减少,需要动态分组。
分组函数如下:
/**
* 将数组按指定大小分批
* @param {Array} array - 要分批的数组
* @param {Number} chunkSize - 每批的大小
* @returns {Array} 分批后的数组
*/
chunkArray(array, chunkSize) {
if (!Array.isArray(array) || chunkSize <= 0) return [];
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
},
完整业务功能代码:
/**
* 腾讯地图距离计算
* 使用 WebService API 的 distance/v1/matrix 接口
* 支持分批请求,每批最多5条,每秒最多5条
*/
export default {
data() {
return {
userLocation: {}, // 用户的位置信息 {latitude, longitude}
storeList: [], // 门店列表
sortedStoreList: [] // 按距离排序后的门店列表
}
},
methods: {
/**
* 获取门店列表
* @param {Object} cond - 查询条件 {page, limit, keyWord}
* @returns {Promise<Array>} 门店列表
*/
async fetchStoreList(cond = {}) {
try {
const params = {
page: cond.page || 1,
limit: cond.limit || 15,
keyWord: cond.keyWord || ''
};
const res = await this.$api('store.list', params);
if (res && res.data) {
this.storeList = res.data;
return res.data;
}
this.storeList = [];
return [];
} catch (error) {
console.error('获取门店列表失败:', error);
this.storeList = [];
return [];
}
},
/**
* 获取用户位置
* @returns {Promise<Object>} 用户位置 {latitude, longitude}
*/
getUserLocation() {
return new Promise((resolve, reject) => {
wx.getLocation({
type: 'gcj02',
isHighAccuracy: 'true',
success(res) {
const location = {
latitude: res.latitude,
longitude: res.longitude
};
this.userLocation = location;
resolve(location);
},
fail(res) {
console.error('获取用户位置失败:', res);
reject(res);
}
});
});
},
/**
* 构建终点坐标查询字符串
* @param {Array} list - 门店列表,每个元素包含 latitude 和 longitude
* @returns {String} 格式:"lat,lng;lat,lng;..."
*/
buildToLocationsQuery(list) {
if (!Array.isArray(list)) return "";
return list
.map((item) => {
const lat = item && (item.latitude ?? item.lat);
const lng = item && (item.longitude ?? item.lng);
if (lat === undefined || lng === undefined || lat === null || lng === null) return null;
return `${lat},${lng}`;
})
.filter(Boolean)
.join(";");
},
/**
* 将数组按指定大小分批
* @param {Array} array - 要分批的数组
* @param {Number} chunkSize - 每批的大小
* @returns {Array} 分批后的数组
*/
chunkArray(array, chunkSize) {
if (!Array.isArray(array) || chunkSize <= 0) return [];
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
},
/**
* 延迟函数
* @param {Number} ms - 延迟时间(毫秒)
* @returns {Promise}
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
/**
* 单次距离矩阵请求(最多5条to)
* @param {String} from - 起点坐标 "lat,lng"
* @param {Array} toBatch - 终点坐标数组
* @param {String} key - 腾讯地图key
* @returns {Promise} 返回距离元素数组
*/
requestDistanceMatrix(from, toBatch, key) {
return new Promise((resolve, reject) => {
const to = this.buildToLocationsQuery(toBatch);
wx.request({
url: "https://apis.map.qq.com/ws/distance/v1/matrix",
data: {
mode: "driving",
from,
to,
output: "json",
key,
},
success: function (resp) {
// matrix 返回:result.rows[0].elements[i].distance
const rows = resp && resp.data && resp.data.result && resp.data.result.rows;
const elements = rows && rows[0] && rows[0].elements ? rows[0].elements : [];
resolve(elements);
},
fail: function(error) {
console.error('距离矩阵请求失败:', error);
reject(error);
}
});
});
},
/**
* 计算门店距离并排序
* 使用 this.storeList 和 this.userLocation
* @returns {Promise} 返回排序后的门店列表
*/
async calculateStoreDistances() {
// 如果门店列表为空,直接返回
if (!this.storeList || this.storeList.length === 0) {
this.sortedStoreList = [];
return [];
}
// 检查用户位置信息
if (!this.userLocation || this.userLocation.latitude === undefined || this.userLocation.longitude === undefined) {
console.warn('用户位置信息缺失,无法计算距离');
this.sortedStoreList = this.storeList;
return this.storeList;
}
const key = (this.$BASIC_SETTINGS && this.$BASIC_SETTINGS.tencentMapKey) || "";
const from = `${this.userLocation.latitude},${this.userLocation.longitude}`;
// 将门店列表按每批5条分批
const MAX_BATCH_SIZE = 5;
const batches = this.chunkArray(this.storeList, MAX_BATCH_SIZE);
// 串行请求所有批次,每批之间间隔1秒(限制:每秒最多5条)
const distances = [];
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
const batch = batches[batchIndex];
// 如果不是第一批,等待1秒后再请求(避免超过每秒5条的限制)
if (batchIndex > 0) {
await this.delay(1000);
}
// 请求当前批次
const elements = await this.requestDistanceMatrix(from, batch, key);
// 合并当前批次的结果,保持原有索引顺序
for (let i = 0; i < elements.length; i++) {
const distance = elements[i].distance;
const globalIndex = batchIndex * MAX_BATCH_SIZE + i;
distances.push(distance);
// 将距离信息添加到门店数据中
if (batch[i] && this.storeList[globalIndex]) {
this.storeList[globalIndex].distance = distance;
}
}
}
// 按距离排序
this.sortedStoreList = this.storeList.sort((a, b) => {
const distanceA = a.distance || Infinity;
const distanceB = b.distance || Infinity;
return distanceA - distanceB;
});
return this.sortedStoreList;
},
/**
* 示例:完整使用流程
* @param {Object} options - 选项 {page, limit, keyWord}
* @returns {Promise<Array>} 按距离排序后的门店列表
*/
async exampleUsage(options = {}) {
try {
// 1. 获取门店列表(会自动赋值给 this.storeList)
await this.fetchStoreList({
page: options.page || 1,
limit: options.limit || 15,
keyWord: options.keyWord || ''
});
if (!this.storeList || this.storeList.length === 0) {
console.warn('门店列表为空');
return [];
}
// 2. 获取用户位置(会自动赋值给 this.userLocation)
await this.getUserLocation();
// 3. 计算距离并排序(使用 this.storeList 和 this.userLocation)
const sortedStores = await this.calculateStoreDistances();
// 4. 使用结果
console.log('排序后的门店列表:', sortedStores);
sortedStores.forEach((store, index) => {
const distanceKm = store.distance ? (store.distance / 1000).toFixed(2) : '未知';
console.log(`第${index + 1}近的门店:${store.orgName || store.name},距离:${distanceKm}km`);
});
return sortedStores;
} catch (error) {
console.error('示例执行失败:', error);
return [];
}
}
}
}