前言:这个“附近门店”功能,其实没你想的那么简单

做本地生活类产品时,“附近门店”几乎是标配功能:

  • 用户打开页面自动定位
  • 展示附近门店列表
  • 按距离排序
  • 可在地图上查看位置
  • 一键导航

听起来非常简单,甚至很多人觉得:

“调个地图 API 就行了”

但真正上线之后我们发现:

这个功能的复杂度,远不在“实现”,而在“位置数据链路”。

一、整体实现流程

整个系统分为两部分:

小程序端

  • 获取用户 GPS
  • 请求后端获取门店列表
  • 渲染列表 + 地图标注

Node.js 后端

  • 接收用户坐标
  • 调用 POI 搜索接口
  • 调用逆地理编码
  • 返回标准化数据

二、整体架构

小程序
   ↓
Node.js 后端
   ↓
位置服务层(POI / 逆解析 / 坐标转换)
   ↓
地图服务 Provider

三、小程序端实现

1. 获取用户位置

// pages/nearby/nearby.js
Page({
  data: {
    stores: [],
    markers: [],
    userLat: null,
    userLng: null,
    keyword: '',
    currentAddress: '',
    loading: false,
  },

  onLoad() {
    this.getUserLocation()
  },

  getUserLocation() {
    wx.getLocation({
      type: 'gcj02',
      success: (res) => {
        const { latitude, longitude } = res

        this.setData({
          userLat: latitude,
          userLng: longitude,
        })

        this.fetchNearbyStores(latitude, longitude, '')
        this.getReverseGeocode(latitude, longitude)
      },
      fail: () => {
        wx.showToast({
          title: '请开启定位权限',
          icon: 'none',
        })
      },
    })
  },

2. 拉取附近门店

  async fetchNearbyStores(lat, lng, keyword = '') {
    this.setData({ loading: true })

    wx.request({
      url: 'https://your-server.com/api/nearby-stores',
      method: 'POST',
      data: {
        lat,
        lng,
        radius: 3000,
        keyword,
      },
      success: (res) => {
        const stores = res.data.list || []

        this.setData({
          stores,
          markers: this.buildMarkers(stores),
        })
      },
      complete: () => {
        this.setData({ loading: false })
      },
    })
  },

3. 逆地理编码

  getReverseGeocode(lat, lng) {
    wx.request({
      url: 'https://your-server.com/api/reverse-geocode',
      method: 'POST',
      data: { lat, lng },
      success: (res) => {
        this.setData({
          currentAddress: res.data.shortAddress,
        })
      },
    })
  },

4. 构建地图标注

  buildMarkers(stores) {
    return stores.map((store, index) => ({
      id: index,
      latitude: store.lat,
      longitude: store.lng,
      title: store.name,
      width: 30,
      height: 30,
      callout: {
        content: `${store.name}\n${store.distanceText}`,
        display: 'BYCLICK',
      },
    }))
  },
})

四、小程序页面结构

<view class="container">

  <view class="location">
    📍 {{currentAddress || '定位中...'}}
  </view>

  <map
    latitude="{{userLat}}"
    longitude="{{userLng}}"
    markers="{{markers}}"
    scale="15"
    style="width:100%;height:400rpx"
  />

  <input
    placeholder="搜索门店"
    bindinput="onSearchInput"
    value="{{keyword}}"
  />

  <scroll-view scroll-y>
    <view
      wx:for="{{stores}}"
      wx:key="id"
      bindtap="onStoreClick"
    >
      <view>{{item.name}}</view>
      <view>{{item.address}}</view>
      <view>距您 {{item.distanceText}}</view>
    </view>
  </scroll-view>

</view>

五、Node.js 后端实现

1. 基础服务

npm install express axios dotenv
require('dotenv').config()
const express = require('express')
const axios = require('axios')

const app = express()
app.use(express.json())

const BASE = 'https://lts.maiyun.net/api/service'
const HEADERS = {
  Authorization: `Bearer ${process.env.LTS_API_KEY}`,
}

六、附近门店 API

app.post('/api/nearby-stores', async (req, res) => {
  const { lat, lng, radius = 3000, keyword = '' } = req.body

  const { data } = await axios.post(
    `${BASE}/poi/search`,
    {
      keyword,
      location: { lat, lng },
      radius,
      page: 1,
      pageSize: 20,
    },
    { headers: HEADERS }
  )

  const list = (data.list || []).map((poi) => {
    const distance = poi.distance || 0

    return {
      id: poi.id,
      name: poi.name,
      address: poi.address,
      lat: poi.point.lat,
      lng: poi.point.lng,
      distanceText:
        distance > 1000
          ? `${(distance / 1000).toFixed(1)}km`
          : `${distance}m`,
    }
  })

  res.json({ list })
})

七、逆地理编码

app.post('/api/reverse-geocode', async (req, res) => {
  const { lat, lng } = req.body

  const { data } = await axios.post(
    `${BASE}/geocode`,
    {
      from: 3,
      point: { lat, lng },
    },
    { headers: HEADERS }
  )

  const ctx = data.address?.context || []

  const district = ctx.find((c) => c.type === 'district')?.name || ''
  const township = ctx.find((c) => c.type === 'township')?.name || ''

  res.json({
    shortAddress: `${district}${township}`,
  })
})

八、关键优化点(非常重要)

1. 不要在前端调用地图 API

所有位置能力必须走后端:

  • 防止 Key 泄露
  • 防止被刷接口
  • 方便统一缓存

2. POI 必须做缓存

const cache = new Map()

function getCacheKey(lat, lng, keyword) {
  return `${lat.toFixed(3)}_${lng.toFixed(3)}_${keyword}`
}

3. 逆解析不要每次请求都调用

建议在“写入阶段”解析一次即可。

九、架构演进(关键部分)

当业务放大后,我们做了一次结构调整:

业务系统
   ↓
Location Service(统一位置层)
   ↓
LTS / 地图服务 Provider

这样做之后:

  • 调用量下降约 60%+
  • 代码重复逻辑消失
  • 多地图 SDK 混用问题消失
  • 后续切换服务商成本极低

十、为什么最后选择 LTS

在位置服务抽象完成后,我们评估 Provider 时主要看三点:

  • POI 覆盖能力
  • 逆地理编码准确性
  • 接入成本

最终选择 LTS 的原因很简单:

它提供的是“位置能力层”,而不是单一地图 API。

对我们这种已经做了位置中台的系统来说: 切换成本几乎为 0

写在最后

“附近门店”功能看起来简单,但本质上它涉及三层能力:

  • 定位
  • POI搜索
  • 地理编码

如果不做抽象,很容易变成:

到处调用地图 API,到处重复计算,到处埋坑。

真正的优化不是“换一个更便宜的API”,而是:

让位置能力变成统一服务,而不是散落在业务里的调用。

如果你后续要做同类系统:

  • 顺风车
  • 同城配送
  • 外勤管理
  • 门店系统

建议一开始就考虑“位置中台”,否则后期重构成本会非常高。

如果需要了解服务商信息,可以去直接访问 迈云LTS

更多推荐