微信小程序调用腾讯地图驾车距离接口的一系列坑

63 阅读6分钟

微信小程序调用腾讯地图驾车距离接口的一系列坑

引言

最近突然有同事反馈,我们微信小程序查看门店距离的功能失效了...

这个功能是这样的:用户点击微信小程序门店tabBar时,返回门店列表(12个门店),这12个门店按照门店与当前用户的距离由近到远排序,主要是方便用户快速找到离自己最近的门店,增加获客量。

功能的实现思路其实还挺简单的:

  1. 门店列表后端返回,包含每个门店的店名、地址信息(经纬度)。
  2. 前端根据当前用户位置与每个门店的地址信息,算出驾车距离,我这里用的是腾讯地图sdk,使用的是 /ws/distance/v1 这个接口。
  3. 最后根据驾车距离由近到远给每个门店排序。

当时也是快速成功实现并上线了这个功能,也已经上线使用了一段时间了,咋最近突然不能用了?赶紧去排查原因。

解决问题的过程还真是一波三折,所以特意在此记录下。

每日调用量已达到上限

把项目用微信小程序打开,查看了一下network面板中调用https://apis.map.qq.com/ws/distance/v1接口的调用情况,发现报错 此key每日调用量已达到上限

image.png

是因为调用这个 API 太多次造成额度不够了吗?可是我记得当时做这个需求的时候,这个 API 的每日可调用量有好几千次啊!不是,同一天真有几千个人用我们微信小程序啊,我有点不太信=.=

保险起见,还是去腾讯地图 - 配额管理那看下额度吧。

什么情况,这个接口咋找不到额度是多少?能找到 /ws/distance/v1/matrix 接口额度,有6千次,没找到 ws/distance/v1 这接口额度啊?

image.png

一番搜索,甚至还创建了个工单,问了下腾讯地图的客服,原来 ws/distance/v1 这个接口已经逐步下线了... 在ws/distance/v1 接口详情页中,顶部有一个产品通知:

image.png

看完了这通知,说实话有点无语:

  1. 为什么之前能用,现在却突然不能用了,通知上明明写的 已接入用户不受影响
  2. 既然接口都下线了,为什么返回报错还是 此key每日调用量已达到上限 ,都不能返回个比如 接口已经下线 这种友好的提示嘛,返回的报错信息有误就不能第一时间找到问题。

唉,吐槽归吐槽,bug 还是要改的,毕竟调用腾讯地图这接口,人家也没要我们钱,那还要啥自行车啊。既然通知提到了建议使用 距离矩阵(多对多)服务,那就去试试吧。

微信小程序SDK中接口缺失

在微信小程序中调用腾讯地图位置服务,有使用 SDKWebServiceAPI 这两种方法。

当前我们的微信小程序使用腾讯地图位置服务,是靠引入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);
        }
      });
    }
  }
}


其实就是通过调用 SDKcalculateDistance 函数去调用 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更新日志

image.png

距离矩阵ws/distance/v1/matrix接口是 2018-08-15 上线的。

又查了下微信小程序 JavaScriptSDK更新日志

image.png

JavaScriptSDK V1.22019-03-06 上线的,这个 SDK 上线的时候距离矩阵接口 ws/distance/v1/matrix 接口已经有了,没搞明白为什么当时封装 JavaScriptSDK V1.2 的时候没把ws/distance/v1/matrix 给封装进去。

看来 SDK 这条路子是行不通了,只能使用 WebServiceAPI 方式调用接口。

此key每秒请求量已达上限

在微信小程序中使用WebServiceAPI其实就是使用 wx.request 直接调用腾讯地图的相关接口。

边看文档边做,急赤白脸一顿写完,又有报错:此key每秒请求量已达上限

image.png

看了下 腾讯地图 - 配额管理 - Key额度ws/distance/v1/matrix 接口的并发量额度,是 5次/秒

image.png

这就奇怪了,接口只请求了 1 次,也没超过 5 次啊,咋会达到并发量上限呢?

我又测试了好几回,都是报同样的错误。但是在测试过程中,我发现了一个诡异的情况:network面板里接口每请求了1次,接口的今日调用量就用掉 12 次。

等等,我们的门店刚好是 12 个,看来请求距离这个接口的调用量和并发量的计算方式有蹊跷,于是我打开 ws/distance/v1/matrix 接口的 文档 ,找到了它的计量方式:

image.png

原来如此。

这个接口无论是今日调用量还是并发量的次数计算方式,都是按照距离的计算次数计算,而不是按照接口的请求次数计算。在当前我的这个业务中,实际上是一个起点对多个终点的情况:

image.png

起点为当前地址,终点是各个门店(总计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 [];
      }
    }
  }
}