Content Table

微信网页中精确定位

支持 H5 的浏览器提供了获取 GPS 的接口 navigator.geolocation.getCurrentPosition(success, error),由于各种原因,国内得到的定位都是不准确的,百度地图 API 也提供了定位接口 new BMap.Geolocation().getCurrentPosition(callback),不过也不准确,有时候会偏离几十公里,为了能够在浏览器里精确的定位,可以在微信浏览器中打开网页,使用微信的 JS SDK 中的 wx.getLocation() 进行定位得到大地坐标系 WGS84 的坐标,然后转换为百度 BD09 坐标,使用百度 API 获取此坐标对应的中文地址。

使用微信 JS SDK

微信 JS-SDK 是微信公众平台面向网页开发者提供的基于微信内的网页开发工具包。参考 JS-SDK 说明文档,调用 JS API 主要步骤如下:

  1. 注册微信公众号 (几分钟就可完成)
  2. 开启公众号的开发者模式: 开发 > 基本配置 > 启用
  3. 得到 AppID 和 开发者密码 AppSecret: 开发 > 基本配置 > 公众号开发信息 (关闭弹窗前要保存好 AppSecret,如果忘了就需要重新生成)
  4. 添加服务器的 IP 到公众号的白名单中: 开发 > 基本配置 > 公众号开发信息 > IP 白名单,因为需要从服务器端向微信服务器发起 HTTP 请求获取 Access Token 和 Api Ticket
  5. 绑定使用此公众号开发的域名: 设置 > 公众号设置 > 功能设置 > JS 接口安全域名 (每个月只能修改 3 次)
  6. 生成网页中调用微信 JS API 需要的签名:
    1. 获取 Access Token (GET 请求): https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APP_ID&secret=APP_SECRET
    2. 使用上面得到的 Access Token 获取 JS API Ticket (GET 请求): https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi&access_token=ACCESS_TOKEN
    3. 使用上面得到的 JS API Ticket 计算生成签名 signature (给 wx.config 使用),可以使用 微信 JS 接口签名校验工具 进行测试 (就是把参数拼成一个字符串使用 SHA1 进行编码得到签名)
  7. 网页中引入 JS 文件: https://res.wx.qq.com/open/js/jweixin-1.6.0.js
  8. 网页中调用 wx.config
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 下面参数中的 nonceStr, timestamp, signature 由服务器端生成
    wx.config({
    debug : false, // 开启调试模式,调用的所有 api 的返回值会在客户端 alert 出来
    appId : 'wxd499c94dd151d520', // 必填,公众号的唯一标识
    nonceStr : 'echo', // 必填,生成签名的随机串
    timestamp: '1587879955', // 必填,生成签名的时间戳
    signature: '023b9930e2c1a7a8d6b15319b6a19efb1fde8364',// 必填,签名
    jsApiList: ['getLocation'] // 必填,需要使用的JS接口列表
    });
  9. 在 wx.ready 中调用微信的 JS API
    1
    2
    3
    4
    5
    6
    7
    8
    9
    wx.ready(function() {
    // 微信默认为 wgs84 的 gps 坐标,如果要返回直接给 openLocation 用的火星坐标,可传入 'gcj02'
    wx.getLocation({ type: 'wgs84', success: function (res) {
    var longitude = res.longitude; // 经度,浮点数,范围为 180 ~ -180
    var latitude = res.latitude; // 纬度,浮点数,范围为 90 ~ -90

    document.querySelector('#info').innerHTML = longitude + ', ' + latitude;
    } });
    });

到这里就可以在微信的浏览器中使用微信提供的 JS API 了,具体都提供了哪些可用的 API,请参考 JS SDK 说明文档 中的附录 2 - 所有 JS 接口列表。此外,在开发中注意一下以下几点:

  • Access Token 和 Api Ticket 的有效时间都是 2 小时,获取后可以先缓存起来,不要每次都请求新的
  • signature 需要使用 Api Ticket 和访问微信 Api 的页面的 url 一起生成 (当前网页的 URL,不包含 # 及其后面部分)
  • 签名用的 noncestr 和 timestamp 必须与 wx.config 中的 nonceStr 和 timestamp 相同

使用公众平台测试账号进行开发

微信需要通过域名访问我们的服务器,大多数情况下在开发的时候我们的电脑一般都没有独立 IP 和域名,为了让微信能够访问我们的开发电脑,可以借助内网映射工具如 ngrok, natapp 等映射一个域名到我们的电脑,把这个域名配置到上面第 5 步处要求的 JS 接口安全域名。由于每个月只能修改这个域名 3 次,为了安全起见,还可以使用微信提供的公众平台测试账号进行开发,在它里面设置开发时的临时域名,不用配置 IP 白名单,此外测试账号还开放了所有权限:

  1. 开发 > 开发者工具 > 公众平台测试账号,可以获得 App ID 和 App Secret

  2. 接口配置信息中填写 URL 和 Token,验证通过即可:

    1. URL: 微信会访问这个 URL 验证我们的服务可不可用,只要在这个 URL 的响应中原样返回微信传来的参数 echostr 即可,例如我们的 URL 为 http://d2hp9x.natappfree.cc/api/demo/wechat/checkSignature,响应代码为

      1
      2
      3
      4
      5
      @GetMapping("/api/demo/wechat/checkSignature")
      @ResponseBody
      public String checkSignature(@RequestParam String echostr) {
      return echostr;
      }
    2. Token: 随便填就行 (与上面提到的 Access Token 没有任何关系)

  3. 微信关注下面的 测试号二维码 的公众号

  4. 在微信中访问我们使用了微信 JS API 的网页,不出意外,微信的 JS API 能够调用了

使用百度地图 SDK

请参考百度地图说明文档账号和获取秘钥,然后在网页中引入百度地图的 JS 就可以使用了。也可以直接使用下面的 <script src="https://api.map.baidu.com/api?v=2.0&ak=By5uc80cGiGYzgc0XUHGyDCoAGsiIi0x"></script>,因为是免费无限制调用次数的。

下面附上相关代码作为参考。

网页中定位

下面的代码为使用微信定位得到 WGS84 的大地坐标系坐标,然后把其转换为百度的 BD09 坐标,然后获取位置的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>定位</title>
</head>
<body>
<!-- 定位不精确: 微信得到的 wgs84 位置 -->
<div id="wgs84-1"></div>
<div id="wgs84-2"></div>

<!-- 定位精确: 百度的 bd09 位置 -->
<div id="bd09-1"></div>
<div id="bd09-2"></div>

<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script src="https://api.map.baidu.com/api?v=2.0&ak=By5uc80cGiGYzgc0XUHGyDCoAGsiIi0x"></script>
<script src="/static-x/lib/axios.min.js"></script>
<script>
// 当前页面的 url (不包含参数部分)
const url = window.location.origin + window.location.pathname;

// [1] 获取微信 JS Api 的签名
axios.get('/api/demo/wechat', { params: { url } }).then(result => {
const config = result.data;

wx.config({
debug : false, // 开启调试模式,调用的所有 api 的返回值会在客户端 alert 出来
appId : config.appId, // 必填,公众号的唯一标识
nonceStr : config.nonceStr, // 必填,生成签名的随机串
timestamp: config.timestamp, // 必填,生成签名的时间戳
signature: config.signature, // 必填,签名
jsApiList: ['getLocation'], // 必填,需要使用的JS接口列表
});
});

// [2] 使用微信 API 获取经纬度
wx.ready(function() {
// 微信默认为 wgs84 的 gps 坐标,如果要返回直接给 openLocation 用的火星坐标,可传入 'gcj02'
wx.getLocation({ type: 'wgs84', success: function (res) {
var longitude = res.longitude; // 经度,浮点数,范围为 180 ~ -180
var latitude = res.latitude; // 纬度,浮点数,范围为 90 ~ -90

// 不进过转换,直接显示微信得到的地址,用于比较 (不准确)
gpsToAddress(longitude, latitude).then(address => {
document.querySelector('#wgs84-1').innerHTML = longitude + ', ' + latitude;
document.querySelector('#wgs84-2').innerHTML = address;
});

// [3] 把微信定位得到的 WGS84 经纬度转换为百度 BD09 的经纬度,然后使用百度地图获取对应的中文地址
wgs84ToBD09(longitude, latitude).then(point => {
document.querySelector('#bd09-1').innerHTML = point.longitude + ', ' + point.latitude;

return gpsToAddress(point.longitude, point.latitude);
}).then(address => {
document.querySelector('#bd09-2').innerHTML = address;
});
} });
});

/**
* 把大地坐标系 WGS84 的经纬度转为百度地图使用的 BD09 经纬度 (微信默认使用 WGS84)。
* 参考: http://lbsyun.baidu.com/index.php?title=jspopular/guide/coorinfo
*
* @param {Double} WGS84 坐标系的 longitude 经度
* @param {Double} WGS84 坐标系的 latitude 纬度
* @return 返回 Promise 对象,resolve 的参数为百度的 BD09 坐标系的经纬度
*/
function wgs84ToBD09(longitude, latitude) {
return new Promise((resolve, reject) => {
const points = [new BMap.Point(longitude, latitude)];

// 提示:
// 参数 1, 5 把原始坐标转为百度坐标
// 参数 3, 5 把 Google 坐标转为百度坐标
new BMap.Convertor().translate(points, 1, 5, function(data) {
const point = data.points[0];
resolve({ longitude: point.lng, latitude: point.lat });
});
})
}

/**
* 使用百度地图 Api 获取经纬度坐标对应的中文地址
*
* @param {Double} longitude 经度
* @param {Double} latitude 纬度
* @return 返回 Promise 对象,resolve 的参数为中文地址
*/
function gpsToAddress(longitude, latitude) {
return new Promise((resolve, reject) => {
const point = new BMap.Point(longitude, latitude);

new BMap.Geocoder().getLocation(point, function (res) {
// 详细地址为省,市,行政区,街道,街道地址
const addComp = res.addressComponents;
const address = addComp.province + addComp.city + addComp.district + addComp.street + addComp.streetNumber;
resolve(address);
})
})
}
</script>
</body>
</html>

服务器端代码

Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
public class DemoController {
@Autowired
private WeChatService weChatService;

/**
* 测试获取微信 JS API 的配置
*
* 网址: http://localhost:8080/api/demo/wechat
*/
@GetMapping("/api/demo/wechat")
@ResponseBody
public Result<Map<String, String>> wechatConfig(@RequestParam String url) {
return Result.single(weChatService.getJsApiConfig("your app id", "your app secret", url));
}
}

在 Service 中请求 Access Token, API Ticket 和计算 Signature,其中

  • 缓存使用了 JetCache
  • Easy-OkHttp 发送 HTTP 请求
  • FastJSON 解析 JSON
  • Apache Commons Codec 计算 SHA1

以上几点都不难,大家可以根据项目中使用的库酌情进行替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alicp.jetcache.anno.CacheInvalidate;
import com.alicp.jetcache.anno.Cached;
import com.edu.training.bean.CacheConst;
import com.edu.training.util.Utils;
import com.mzlion.easyokhttp.HttpClient;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

/**
* 微信服务,可获取 Access token, JS Api Ticket 和获取 JS Api 需要的配置
*/
@Service
@Slf4j
public class WeChatService {
@Autowired
private WeChatService self;

// 获取 Access Token 的 URL
private static final String URL_ACCESS_TOKEN = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APP_ID&secret=APP_SECRET";

// 获取 JS Api Ticket 的 URL
private static final String URL_JS_API_TICKET = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi&access_token=ACCESS_TOKEN";

/**
* 获取微信的 Access Token
*
* @param appId 微信公众平台的 App ID
* @param appSecret 微信公众平台的 App Secret
* @return 返回 Access Token,请求无效时返回 null
*/
@Cached(name = CacheConst.CACHE, key = CacheConst.KEY_WECHAT_ACCESS_TOKEN)
public String getAccessToken(String appId, String appSecret) {
// 1. 请求 Access Token
// 2. 解析得到 Access Token
// 3. 删除此 appId 对应的 JS Api Ticket 缓存

log.info("[开始] 获取微信 Access Token: App ID {}", appId);

// [1] 请求 Access Token
// [2] 解析得到 Access Token
String url = URL_ACCESS_TOKEN.replace("APP_ID", appId).replace("APP_SECRET", appSecret);
JSONObject result = JSON.parseObject(HttpClient.get(url).execute().asString());
String token = result.getString("access_token");

// 如果 token 不存在,则输出错误信息
if (StringUtils.isBlank(token)) {
String errorMessage = result.getString("errmsg");
log.warn("[错误] 获取微信 Access Token 失败: {}", errorMessage);
}

// [3] 删除此 appId 对应的 JS Api Ticket 缓存
self.invalidateJsApiTicket(appId);

log.info("[结束] 获取微信 Access Token: App ID {}", appId);
return token;
}

/**
* 使用 Access Token 获取 JS Api Ticket
*
* @param appId 微信公众平台的 App ID (用于缓存)
* @param accessToken 微信的 Access Token
* @return 返回 JS Api Ticket,请求无效时返回 null
*/
@Cached(name = CacheConst.CACHE, key = CacheConst.KEY_WECHAT_JS_API_TICKET)
public String getJsApiTicket(String appId, String accessToken) {
log.info("[开始] 获取微信 JS Api Ticket: App ID {}", appId);

if (accessToken == null) {
log.warn("[错误] 获取微信 JS Api Ticket: Access Token 为 null");
return null;
}

String url = URL_JS_API_TICKET.replace("ACCESS_TOKEN", accessToken);
JSONObject result = JSON.parseObject(HttpClient.get(url).execute().asString());
String ticket = result.getString("ticket");

// 如果 ticket 不存在,则输出错误信息
if (StringUtils.isBlank(ticket)) {
String errorMessage = result.getString("errmsg");
log.warn("[错误] 获取微信 JS Api Ticket 失败: {}", errorMessage);
}

log.info("[结束] 获取微信 JS Api Ticket: App ID {}", appId);
return ticket;
}

/**
* 删除 JS Api Ticket
*
* @param appId 微信公众平台的 App ID
*/
@CacheInvalidate(name = CacheConst.CACHE, key = CacheConst.KEY_WECHAT_JS_API_TICKET)
public void invalidateJsApiTicket(String appId) {
log.info("[成功] 删除微信 JS Api Ticket: App ID {}", appId);
}

/**
* 获取要访问的 URL 的 JS Api 的配置,使得在微信中能够访问微信提供的 JS API。
*
* @param appId 微信公众平台的 App ID
* @param appSecret 微信公众平台的 App Secret
* @param url 要访问的 URL
* @return 返回配置的 Map,其中包括 { appId, nonceStr, timestamp, signature },出错时返回 null
*/
public Map<String, String> getJsApiConfig(String appId, String appSecret, String url) {
// 1. 获取 AppId 的 Access Token 和 JS Api Ticket
// 2. 生成签名 (参考 https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=jsapisign)
// 3. 创建配置

// [1] 获取机构的微信 AppId, AppSecret
String accessToken = self.getAccessToken(appId, appSecret);
String apiTicket = self.getJsApiTicket(appId, accessToken);

// 有一个为 null 则返回
if (accessToken == null || apiTicket == null) {
return null;
}

if (StringUtils.isBlank(url)) {
return null;
}

// [2] 生成签名
String nonceStr = Utils.uuid().substring(0, 8); // 取 UUID 的前 8 个字符
String timestamp = System.currentTimeMillis() + "";
String text = "jsapi_ticket=" + apiTicket + "&noncestr=" + nonceStr + "&timestamp=" + timestamp + "&url=" + url;// 将参数排序并拼接字符串
String signature = Utils.sha1(text);

// [3] 创建配置
Map<String, String> config = new HashMap<>();
config.put("appId", appId);
config.put("nonceStr", nonceStr);
config.put("signature", signature);
config.put("timestamp", timestamp);

return config;
}
}