返回技术资讯

十堰小程序开发实战:多门店小程序定位、附近门店距离排序与一键导航完整实现(2026版)

十堰小程序开发附近门店地理位置腾讯地图多门店

一、一个真实项目:7家门店,用户却总跑到关掉的那家

2026年上半年,我们给十堰一家连锁烘焙品牌做小程序。他们在十堰城区有 7 家门店,分布在茅箭区、张湾区,另外在郧阳区还有一家。上线前的版本非常简单:首页放一张门店列表图,用户自己看地址、自己判断哪家近。

上线一个月,后台数据暴露了三个问题:

  • 37% 的用户在门店详情页停留超过 40 秒,说明他们在反复确认地址,但依然不确定该去哪家;
  • 客服每天接到 5 到 8 通电话问「你们XX店还开吗」,原因是列表里还挂着一家已经搬迁的老店;
  • 「到店自提」订单里有 11% 被用户下到了距离自己 5 公里以外的门店,最后要么取消,要么由店员电话改单。

改造目标很明确:用户打开小程序,允许定位,首屏直接告诉他「离你最近的是十堰北京路店,1.2 公里,步行 16 分钟」,并提供一键导航。改造上线两个月后,自提订单的门店错配率降到 1.4%,客服相关来电下降约七成。

这篇文章把这次改造的完整技术路径拆开写清楚,包括代码、坐标系的坑、以及上线后才知道的隐私合规细节。做十堰小程序开发的同行,基本可以照着落地。

二、先搞清楚坐标系:这是距离算错的头号原因

在写第一行代码之前,必须先统一坐标系。国内至少存在三套坐标:

  • WGS-84:GPS 原始坐标,国际标准。手机硬件定位拿到的原始值、Google Maps 海外版使用这套;
  • GCJ-02:国测局加密坐标,高德、腾讯、微信小程序统一使用这套;
  • BD-09:百度在 GCJ-02 基础上再加密一次,只有百度地图用。

实际踩过的坑是这样的:客户在后台录门店位置时,运营同事直接打开百度地图复制了坐标(BD-09),前端用 wx.getLocation 拿到的是 GCJ-02,两边混算,结果十堰城区内的距离偏差达到 500 米到 1.5 公里,在山区路段甚至能差出三公里——因为十堰是典型的山地城市,一条路绕两个山头,直线距离本身就不代表通行距离。

结论只有一条:数据库里只存 GCJ-02,入口处统一转换。如果运营只能用百度地图取点,就在后台保存时做一次 BD-09 → GCJ-02 转换,而不是让前端去猜。

// 百度坐标转国测局坐标(简单近似版,误差米级,门店选点足够用)
function bd09ToGcj02(bdLng, bdLat) {
  const x = bdLng - 0.0065;
  const y = bdLat - 0.006;
  const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * Math.PI);
  const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * Math.PI);
  return { lng: z * Math.cos(theta), lat: z * Math.sin(theta) };
}

三、定位授权:被拒绝之后不能白屏

微信小程序的定位授权有一个很反直觉的规则:wx.getLocation 调用时如果用户点了「拒绝」,短时间内再次调用不会重新弹窗,而是直接走 fail 回调。很多十堰小程序开发项目在这里只写了一个 console.log(err),结果就是用户看到一个永远转圈或者空白的页面。

正确的处理是一条三段式链路:先查授权状态,再决定是直接定位、弹窗申请,还是引导去设置页。

// 1. 读取授权状态
wx.getSetting({
  success(res) {
    const auth = res.authSetting['scope.userLocation'];
    if (auth === true) {
      doLocate();                       // 已授权,直接定位
    } else if (auth === false) {
      // 曾经拒绝过,wx.getLocation 不会再弹窗,只能引导去设置页
      wx.showModal({
        title: '需要位置权限',
        content: '开启定位后可以为你推荐最近的门店',
        confirmText: '去开启',
        success(r) {
          if (r.confirm) wx.openSetting({ success: () => doLocate() });
        }
      });
    } else {
      doLocate();                       // 从未申请过,会正常弹窗
    }
  }
});

// 2. 定位本身要设超时兜底
function doLocate() {
  wx.getLocation({
    type: 'gcj02',                      // 必须显式指定 gcj02
    isHighAccuracy: true,
    highAccuracyExpireTime: 3000,       // 高精度定位超过 3 秒降级
    success(res) {
      app.globalData.location = { lat: res.latitude, lng: res.longitude };
      loadNearbyStores(res.latitude, res.longitude);
    },
    fail() {
      // 关键:定位失败要有兜底,不能白屏
      loadNearbyStores(null, null, { fallbackCity: '十堰' });
    }
  });
}

注意 type: 'gcj02' 这个参数。wx.getLocation 默认返回的是 wgs84,如果你后续用腾讯地图的逆地址解析或距离服务,不指定 gcj02 就会产生几百米的系统性偏移。

兜底方案我们是这样设计的:定位失败时不显示空列表,而是显示「十堰全部门店」,并按行政区折叠(茅箭区、张湾区、郧阳区),同时顶部放一个「手动选择位置」入口。这个兜底把定位失败场景的跳出率从 68% 压到了 23%。

四、逆地址解析:把经纬度变成「你在十堰市茅箭区」

拿到经纬度之后,通常要做两件事:一是显示用户当前所在区域,增强信任感;二是判断用户是否在服务范围内(比如只做十堰城区配送)。这里推荐用腾讯位置服务的 qqmap-wx-jssdk,因为小程序的 wx.getLocation 本身就是腾讯系的坐标系,不需要额外转换。

import QQMapWX from '../../libs/qqmap-wx-jssdk.min.js';
const qqmapsdk = new QQMapWX({ key: '你的KEY' });

function reverseGeocode(lat, lng) {
  return new Promise((resolve) => {
    qqmapsdk.reverseGeocoder({
      location: { latitude: lat, longitude: lng },
      get_poi: 0,
      success(res) {
        const ad = res.result.ad_info;
        resolve({
          province: ad.province,
          city: ad.city,
          district: ad.district,        // 例如「茅箭区」
          address: res.result.address
        });
      },
      fail() {
        resolve(null);                  // 解析失败不能阻塞主流程
      }
    });
  });
}

两个实操提醒:第一,reverseGeocoder 要走网络请求,必须配好小程序后台的 request 合法域名(https://apis.map.qq.com),否则真机上报「不在以下 request 合法域名列表中」,而开发者工具里可能因为勾选了「不校验合法域名」而一切正常——这是最常见的上线翻车点。第二,逆地址解析的结果建议缓存在本地 10 分钟,用户在城市内移动时没必要每次都请求。

五、距离怎么算:前端算还是数据库算

门店数量少的时候(比如 7 家),最简单可靠的做法是一次性把门店列表拉下来,前端用 Haversine 公式算球面距离再排序。7 条数据的内存计算耗时在 1 毫秒以内,完全不构成性能问题。

// Haversine 公式,返回单位:米
function getDistance(lat1, lng1, lat2, lng2) {
  const R = 6371000;
  const rad = (d) => (d * Math.PI) / 180;
  const dLat = rad(lat2 - lat1);
  const dLng = rad(lng2 - lng1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(a));
}

function sortStoresByDistance(stores, userLat, userLng) {
  return stores
    .filter((s) => s.status === 1)               // 过滤已关闭/搬迁门店
    .map((s) => ({
      ...s,
      distance: getDistance(userLat, userLng, s.lat, s.lng),
      distanceText:
        getDistance(userLat, userLng, s.lat, s.lng) < 1000
          ? Math.round(getDistance(userLat, userLng, s.lat, s.lng)) + 'm'
          : (getDistance(userLat, userLng, s.lat, s.lng) / 1000).toFixed(1) + 'km'
    }))
    .sort((a, b) => a.distance - b.distance);
}

但门店数量一旦上百(比如连锁药店、连锁便利店),就不能再把全量数据丢给前端了。正确做法是在数据库里先用「外接矩形」粗筛,再精算:

-- 先用经纬度范围把候选集缩小到几十条,再按球面距离排序
SELECT id, name, address, lat, lng,
       6371000 * 2 * ASIN(SQRT(
         POWER(SIN((? - lat) * PI() / 360), 2) +
         COS(? * PI() / 180) * COS(lat * PI() / 180) *
         POWER(SIN((? - lng) * PI() / 360), 2)
       )) AS distance
FROM stores
WHERE status = 1
  AND lat BETWEEN ? AND ?
  AND lng BETWEEN ? AND ?
ORDER BY distance ASC
LIMIT 20;

注意 WHERE 里的范围条件必须给 lat、lng 建联合索引,否则这个「粗筛」等于没筛,MySQL 依然会全表扫。另外 ORDER BY distance 是计算列,无法走索引,所以 LIMIT 要尽量小,并且粗筛范围不要开太大(十堰城区 5 公里范围,纬度跨度约 0.045 度即可)。

六、一键导航:wx.openLocation 的正确用法

排序做完了,最后一公里是「让用户真的走过去」。小程序提供了 wx.openLocation,会直接唤起微信内置地图,用户可以在里面切换腾讯地图、高德、百度,并一键发起导航。

function navigateTo(store) {
  wx.openLocation({
    latitude: store.lat,        // 必须是 gcj02,number 类型
    longitude: store.lng,
    name: store.name,           // 例如「XX烘焙(北京路店)」
    address: store.address,     // 例如「十堰市茅箭区北京北路XX号」
    scale: 18,
    fail(err) {
      if (err.errMsg.indexOf('auth deny') > -1) {
        wx.showToast({ title: '请允许使用位置信息', icon: 'none' });
      }
    }
  });
}

这里有三个细节值得强调:

  • latitude / longitude 必须是 number。如果后端返回的是字符串(很多 PHP 接口会把 decimal 字段序列化成字符串),部分安卓机型上会静默失败,什么也不发生,很难排查。
  • name 不要只写「XX店」,写成「XX烘焙(十堰北京路店)」,用户在导航软件的历史记录里能一眼认出来。
  • 如果门店在商场里,address 里补上「XX商场 3 层」,比只给一个路名有用得多。十堰几个大型商业综合体楼层多、出入口多,用户按路名导航经常停在背面。

七、体验优化:让首屏在 1 秒内出结果

定位是一个天然慢的操作。如果页面结构是「先等定位、再请求门店、再渲染」,用户至少要盯着 loading 两秒。我们用了三个手段把首屏压到 1 秒以内:

7.1 缓存上一次的定位结果

用 wx.getStorageSync 存最近一次定位的经纬度和时间戳,页面 onLoad 时立刻用缓存渲染一版列表,同时异步发起新定位,成功后静默替换。用户感知到的就是「秒开」。

7.2 门店基础数据本地缓存 + 版本号校验

门店名称、地址、坐标这类数据变动频率极低。我们用「stores_version」做版本号,请求时带上本地版本,服务端没变化就返回 304 语义的空响应,前端直接用本地缓存。实测门店列表接口的平均响应时间从 180ms 降到 30ms。

7.3 距离文案要「像人说的话」

不要只显示 1234.56m。我们的规则是:小于 1000 米显示「步行约 X 分钟」(按 75 米/分钟估算),1000 米以上显示「X.X 公里 · 驾车约 Y 分钟」(按城区 25km/h 估算)。十堰是山地城市,直线距离和实际车程差距很大,加上时间预估后,用户的选择准确率明显提升。

八、隐私合规:2023 年之后必须处理的一步

微信在 2023 年收紧了隐私接口的管理,wx.getLocation 属于隐私接口,如果小程序后台没有在「用户隐私保护指引」中声明收集位置信息,调用会直接失败。检查清单如下:

  1. 小程序后台 → 设置 → 服务内容声明 → 用户隐私保护指引,勾选「位置信息」并写明用途(例如「用于推荐距离最近的门店」);
  2. 首次调用隐私接口前,需要用户同意隐私协议,可用 wx.requirePrivacyAuthorize 主动触发,或在 app.json 中配置 __usePrivacyCheck__: true 后由基础库自动处理;
  3. 协议内容要写清楚「不提供位置信息也能正常浏览全部门店」,这既是合规要求,也是降低用户拒绝率的有效话术。

我们在十堰这个项目上做过 A/B:把授权弹窗的说明文案从「是否允许获取您的位置」改成「允许后为你推荐最近的 7 家门店,不授权也可以手动选择」,授权通过率从 54% 提升到 81%。一句文案的差别,比任何技术优化都划算。

九、上线后踩到的五个坑

  • 安卓机高精度定位超时:室内或地下车库场景,isHighAccuracy: true 可能 5 秒以上才返回。必须配 highAccuracyExpireTime,并且准备降级逻辑。
  • 模拟器定位与真机不一致:开发者工具默认定位在北京,测试时一定要在工具里手动改成十堰的坐标,否则全部距离排序逻辑都测不出来。
  • 门店坐标录错一位小数:0.001 度约等于 111 米。我们后来加了一个后台校验:门店坐标必须落在十堰市行政区范围内(东经 109.5~111.4,北纬 31.5~33.3),超出范围不允许保存。
  • 营业状态没同步:门店临时停业(装修、盘点)如果不从列表里过滤,用户导航过去会扑空。现在我们在后台加了 status 和 business_hours 两个字段,前端按当前时间判断,非营业时间显示「已打烊,明日 08:30 营业」而不是直接隐藏。
  • openLocation 在做的人手机上打不开:部分机型没有安装任何地图 App,微信会提示无法打开。此时应引导用户复制地址,而不是让按钮点了没反应。

十、小结:这个功能的本质是「减少一次选择」

多门店定位看起来是个技术功能,本质上是帮用户省掉一次决策。用户不需要知道十堰有几家店、哪家在茅箭哪家在张湾,他只需要知道「现在去哪家最快」。把这个判断做对,需要坐标系统一、授权链路完整、距离算法正确、兜底方案扎实这四件事同时到位,缺一件用户就会卡住。

如果你正在做十堰小程序开发,尤其是餐饮、零售、汽修、教培这类有多门店场景的行业,建议把「附近门店」当成首屏核心功能而不是附属页面来设计。它对到店转化率的提升,通常比再做一轮 UI 美化要明显得多。

最后留一个检查清单,交付前逐条对一遍:坐标系是否统一为 GCJ-02、getLocation 是否指定了 gcj02、拒绝授权后是否有兜底、定位失败是否有手动选点、门店状态是否过滤、导航参数是否为 number、隐私协议是否声明位置信息、首页是否有缓存首屏。八条全过,这个功能才算及格。

准备好开始您的项目了吗?

无论是软件开发、网站建设还是APP定制,我们都能为您提供专业解决方案