小程序开发过程中首先要解决的就是小程序登录问题,本文简单描述小程序登录的几种情况及后端部分代码。
## 一、简单的登录
参考微信官方文档中关于 [小程序登录](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html) 的描述,时序图如下:

说明
1. 调用 [wx.login()](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html) 获取 **临时登录凭证code** ,并回传到开发者服务器。
2. 调用 [auth.code2Session](https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html) 接口,换取 **用户唯一标识 OpenID** 、 用户在微信开放平台帐号下的**唯一标识UnionID**(若当前小程序已绑定到微信开放平台帐号) 和 **会话密钥 session_key**。
根据以上流程设计后端登录接口:
```java
@PostMapping("/xcx/login")
public String login(String xcxCode){
// 0 xcxCode 是小程序调用 wx.login 获取的登录凭证
if (StringUtils.isBlank(xcxCode)) return "";
// 1 调用微信接口获取用户唯一标识 openid
JSONObject jsonObject = HttpRequestWx.code2session(xcxCode);
String openid = jsonObject.getString("openid");
// 2 保存 openid 到 wx_xcx_user 表(如果不存在)
wxXcxUserService.saveIfAbsent(new WxXcxUser(openid));
// 3 生成 token 并返回
String token = TokenUtils.create(openid);
return token;
}
```
`wx_xcx_user` 表结构:
```sql
CREATE TABLE `wx_xcx_user` (
`open_id` varchar(50) NOT NULL COMMENT '微信小程序登录后获取的 OPEN_ID',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`open_id`)
);
```
如果用户没有登录或者 token 失效,则调用 `/xcx/login` 接口完成登录。
## 二、手机号为用户唯一标识
以上代码通过 openid 作为用户身份唯一标识,而实际业务更多是使用手机号作为用户唯一标识。例如美团、京东是先有手机 App 然后又在微信生态上开发微信小程序,或者公司当前业务在小程序上,将来准备开发自己的 App 这种情况也不能依赖 openid 。
我们需要在以上基础上完成扩展:获取用户手机号、将 openid 与手机号关联。
### 获取用户手机号(旧)
基础库 2.21.2 以前 [获取手机号](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/deprecatedGetPhoneNumber.html) 需要先调用 wx.login 接口,然后需要将 button 组件 open-type 的值设置为 getPhoneNumber,当用户点击同意之后将获得的加密数据 encryptedData 和 加密算法向量 iv 。然后在后端通过 session_key 来 [解密](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html#%E5%8A%A0%E5%AF%86%E6%95%B0%E6%8D%AE%E8%A7%A3%E5%AF%86%E7%AE%97%E6%B3%95) 得到当前用户绑定的手机号。
```java
public static JSONObject decodeEncryptedData(String encryptedData, String sessionKey, String iv) {
byte[] dataByte = Base64Utils.decodeFromString(encryptedData);
byte[] keyByte = Base64Utils.decodeFromString(sessionKey);
byte[] ivByte = Base64Utils.decodeFromString(iv);
try {
// 如果密钥不足16位,那么就补足
int base = 16;
if (keyByte.length % base != 0) {
int groups = keyByte.length / base + 1;
byte[] temp = new byte[groups * base];
Arrays.fill(temp, (byte) 0);
System.arraycopy(keyByte, 0, temp, 0, keyByte.length);
keyByte = temp;
}
// 初始化
Security.addProvider(new BouncyCastleProvider());
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding", "BC");
SecretKeySpec spec = new SecretKeySpec(keyByte, "AES");
AlgorithmParameters parameters = AlgorithmParameters.getInstance("AES");
parameters.init(new IvParameterSpec(ivByte));
// 初始化
cipher.init(Cipher.DECRYPT_MODE, spec, parameters);
byte[] resultByte = cipher.doFinal(dataByte);
if (null != resultByte && resultByte.length > 0) {
String result = new String(resultByte, "utf-8");
return JSON.parseObject(result);
}
} catch (Exception e) {
throw new RuntimeException("解密微信数据异常");
}
return new JSONObject();
}
```
### 关联 OpenID 与手机号
用户可以在微信随时绑定解绑手机号,所以将这 2 个数据分别存到 2 个表:
```sql
CREATE TABLE `wx_xcx_user` (
`open_id` varchar(50) NOT NULL COMMENT '微信小程序登录后获取的 OPEN_ID',
`create_time` datetime DEFAULT NULL,
`user_id` int DEFAULT NULL COMMENT '用户 ID',
PRIMARY KEY (`open_id`)
);
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '用户 ID',
`nikename` varchar(20) DEFAULT NULL COMMENT '昵称',
`phone` varchar(11) NOT NULL COMMENT '手机号',
`avatar` varchar(200) DEFAULT NULL COMMENT '头像',
`gender` int DEFAULT NULL COMMENT '性别',
`description` varchar(200) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`),
KEY `idx_nikename` (`nikename`)
);
```
那么后端登录接口需要修改:
```java
@PostMapping("/xcx/login")
public String login(String xcxCode, String encryptedData, String iv){
// 0 xcxCode 是小程序调用 wx.login 获取的登录凭证
if (StringUtils.isBlank(xcxCode)) return "";
// 1 调用微信接口获取用户唯一标识 openid
JSONObject jsonObject = HttpRequestWx.code2session(xcxCode);
String openid = jsonObject.getString("openid");
String sessionKey = jsonObject.getString("session_key");
// 2 解密数据获取手机号
JSONObject data = HttpRequestWx.decodeEncryptedData(encryptedData, sessionKey, iv);
String phoneNumber = data.getString("purePhoneNumber");
// 3 保存 openid 和 phoneNumber 到 wx_xcx_user、user 表,并关联
wxXcxUserService.saveIfAbsent(new WxXcxUser(openid));
int userId = userService.saveIfAbsent(new User(phoneNumber));
wxXcxUserService.updateUserIdByOpenId(userId, openid);
// 4 生成 token 并返回
String token = TokenUtils.create(userId);
return token;
}
```
### 用户授权问题
小程序中获取用户手机号时需要用户授权,而在新用户首次登录小程序和 token 失效时调用 `/xcx/login` 接口都需要用户授权,显然对用户不友好。所以需要另外增加一个接口,用户来获取已经授权登录用户的 token
```java
@GetMapping("/xcx/getToken")
public String getToken(String xcxCode){
if (StringUtils.isBlank(xcxCode)) return "";
JSONObject jsonObject = HttpRequestWx.code2session(xcxCode);
String openid = jsonObject.getString("openid");
WxXcxUser wxXcxUser = wxXcxUserService.getByOpenId(openid);
if (wxXcxUser == null) { // 首次登陆
wxXcxUserService.save(new WxXcxUser(openid));
return "";
}
if (wxXcxUser.getUserId() == null) { // 未授权
return "";
}
return TokenUtils.create(wxXcxUser.getUserId());
}
```
```java
@PostMapping("/xcx/login")
public String login(String xcxCode, String encryptedData, String iv){
// 0 xcxCode 是小程序调用 wx.login 获取的登录凭证
if (StringUtils.isBlank(xcxCode)) return "";
// 1 调用微信接口获取用户唯一标识 openid
JSONObject jsonObject = HttpRequestWx.code2session(xcxCode);
String openid = jsonObject.getString("openid");
String sessionKey = jsonObject.getString("session_key");
// 2 解密数据获取手机号
JSONObject data = HttpRequestWx.decodeEncryptedData(encryptedData, sessionKey, iv);
String phoneNumber = data.getString("purePhoneNumber");
// 3 保存 phoneNumber 到 user 表,并关联
int userId = userService.saveIfAbsent(new User(phoneNumber));
wxXcxUserService.updateUserIdByOpenId(userId, openid);
// 4 生成 token 并返回
return TokenUtils.create(userId);
}
```
小程序打开时先调用 `/xcx/getToken` 接口获取 token,如果获取不到则再调用 `/xcx/login` 接口完成登录。


### 退出登录与切换账号
退出登录和切换账号目的都是为了让微信号与手机号解除绑定,比如微信用户 A 要登录微信用户 B 的账号来完成一些操作。

只需要在 `xcx_user` 表中将 openid 对应的 userid 置为空即可,然后小程序重新载入首页,就会重新登录。
### 获取用户手机号(新)
从基础库 2.21.2 开始,对获取手机号的接口进行了安全升级 [使用指南](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html)。
新版本需要将 button 组件 open-type 的值设置为 getPhoneNumber,当用户点击同意之后,通过 bindgetphonenumber 事件回调获取到动态令牌 code,然后通过调用微信后台提供的 [phonenumber.getPhoneNumber](https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/phonenumber/phonenumber.getPhoneNumber.html) 接口,来换取用户手机号。
后端接口调整:
```java
@PostMapping("/xcx/login")
public String login(String xcxCode, String phoneNumberCode){
// 0 xcxCode 是小程序调用 wx.login 获取的登录凭证
if (StringUtils.isBlank(xcxCode)) return "";
// 1 调用微信接口获取用户唯一标识 openid
JSONObject jsonObject = HttpRequestWx.code2session(xcxCode);
String openid = jsonObject.getString("openid");
// 2 解密数据获取手机号
JSONObject data = HttpRequestWx.getPhoneNumber(phoneNumberCode);
String phoneNumber = data.getString("purePhoneNumber");
// 3 保存 phoneNumber 到 user 表,并关联
int userId = userService.saveIfAbsent(new User(phoneNumber));
wxXcxUserService.updateUserIdByOpenId(userId, openid);
// 4 生成 token 并返回
return TokenUtils.create(userId);
}
```
这里还是要记录用户的 openid,因为在使用小程序的开放能力时,必然会用到 openid 甚至 unionid。
## 三、微信重新绑定手机号的几种场景
在一个小程序中可以将用户 openid 等同于用户的微信号,以下主要梳理几种场景下的表中数据变化。
### OpenID 不存在、手机号不存在
此类用户就是全新用户,根据登录流程在 xcx_user 表和 user 表分别新增一条记录,如图示的用户 A。
### OpenID 不存在、手机号存在
场景:用户 A 登录过小程序,此时使用用户 B 绑定自己手机号登录小程序。
xcx_user 表新增一条记录 B,关联还是 user 表 id 为 1 的记录。此时会出现 xcx_user 表 A、B 记录的 userid 都为 1,只需在登录接口中 `wxXcxUserService.updateUserIdByOpenId(userId, openid)` 方法将 A 的 userid 设置为空值即可。

### OpenID 存在、 手机号不存在
场景:用户 A 绑定了新手机号登录,或者是用户 A 只是打开过小程序但是没有授权手机号。
新增 userid 为 2 的记录到 user 表并与 xcx_user 表的 A 记录关联。

### OpenID、手机号都存在
场景 1:已经登录授权的用户,直接正常返回 token。
场景 2:用户 C 绑定了手机号 132 后,用户 A 又绑定了手机号 132,重新在 xcx_user 表关联。


微信小程序用户登录