目前为止, 我们的小程序基本还处于”一个人玩”的状态, 无论你是谁, 发表的日记都记在同一个用户(u1)的账下. 本部分我们将建立各个用户相互独立的”账户体系”.

28. 注册与登录

用户账号, 简单来说, 可认为即是数据库中 user_info 表的数据.

对于任何一个软件来说, 除非只供一个人使用, 否则均需要为每个用户建立独立的账号, 记录用户资料, 权限等信息. 同时在用户进行各项操作时(如: 发表日记), 记录下当前操作的用户是谁, 即是谁发表的日记…

对于我们的小程序来说, 创建用户账号即是在 user_info 表中添加一条记录.

让我们来回顾一下, user_info 表的结构, 目前它有如下字段:

id, wxId, nickName, favCnt, fansCnt, diaryCnt

其中, id 是在添加记录时生成的用户唯一标识(主键), 和前面日记表中的 id 一样, 将使用 uuid 填充.

wxId 主要为建立我们的小程序与用户微信账号之间的一对一关联, 后面详述.

nickName 则是用户昵称, 考虑直接使用用户的微信昵称.

其它的几个字段, 在创建账号时取默认值 0 即可.

呵呵, 把大家都知道事情又重复了一遍 ~

在微信小程序中, 我们可以直接使用微信接口获取用户在微信中的基本信息, 比如: 昵称, 性别, 所在地等, 这些信息可以用于创建用户账号(注册). 也就是说, 对于小程序用户, 完全没必要让用户像注册一般网站会员一样傻傻地填半天注册信息, 可以直接从微信接口取得即可. 对于微信接口不能提供, 而你的业务系统又需要的用户资料, 可以让用户先成为你的”会员”后, 再引导他去慢慢补充.

在用户成为你的人之前, 千万别让他觉得你很烦… 过 N 道关, 填 N 个表, 一不小心还失败 N 次, 最后才牵手成功… 除非你的程序是皇帝的女儿, 不愁嫁, 那另当别论.

所以, 现在越来越多的软件系统都尽可能地让注册流程变得简单, 最好点个”同意”就行….

于是, 我们在注册很多系统的会员(用户)时, 可以选择直接使用你的社交账号注册/登录(微信/QQ/微博…), 只要你”点头”, 立马你就成为它 VIP 中的 P 了~

怎么做到的呢? OAuth可以了解一下… 本文不展开了. 在微信世界运行的小程序可以使用微信 API 来实现类似功能.

总之, 千万不要用设计一般网站的思路来设计小程序的注册/登录流程, 这样会显得你”很不专业”, 呵呵~

好了, 闲扯了半天, 让我们开干吧~ 考虑两个场景:

(1) 对于已经创建过账号的用户, 直接使用自动登录进入小程序主界面

(2) 对于新用户, 需要获取他的基本信息(昵称…), 创建账号, 然后进入小程序主界面.

这里有一个共同的问题, 我们以什么东西来标识我们的用户呢?

也就是说, 得有一个唯一的标识来区分当前的用户是谁, 回顾 user_info 表, 只有 id 和 wxId 两个字段有这个潜力. 而 id 字段是在创建用户账号时才产生的, 对于新用户来说, id 字段还不存在. 所以… 就只剩下 wxId 可用了…

wxId 是什么东东呢? 它是微信用户唯一的编号, 微信官方称作 openid. 怎么取得这个 openid (wxId) 呢? 微信小程序文档中有个小程序登录流程时序图, 可以看一下…

呵呵, 虽然这个所谓的时序图基本还算比较清晰地描述了微信官方给出的游戏规则. 但我想对于多数小白来说, 可能还是一脸懵B…

结合我们自己的业务, 把这事描述得更通俗些吧 ~ ( 服务器端登录流程 )

(1) 在用户启动小程序时, 在小程序端通过 wx.login() 这个 API 取得一个登录凭证 code, 然后, 把 code 传到你自己的服务器端, 用你服务器端的程序向微信服务器发出 HTTPS 请求(带上 code 和其它几个参数), 微信服务器校验后若认为你的请求是合法请求, 则返回传说中的 openid.

(2) 根据微信服务器返回的 openid 在我们的 user_info 表中查找对应的用户账号, 若存在, 说明是老用户, 跳至(3). 若不存在, 说明是新用户, 则自动创建账号(即: 添加一行记录), 然后转(3).

(3) 我们的服务器端程序向小程序端返回一个token, 小程序端得到 token 后即可认为登录成功, 转至主界面.

Token: 令牌, 小程序端拿到服务器端给的 token 即表明小程序端和你自己的服务器端是互相信任的. 此后, 所有从小程序端发送向服务器端的网络请求均应带上这个 token, 服务器端根据 token 值即可知道这个网络请求是否合法? 以及是哪个用户发送来的…

可能你会在想, 你的小程序端和服务器端程序本来就都是你自己写的, 它们之间不应该是天生就互相信任的吗? 嘿嘿 ~ 你存在银行里的钱和你钱包里的钱, 不都是你自己的钱吗, 难道你就可以直接从银行把钱装到钱包里吗? 不怎么也得验名正身嘛 ~ 你的”储蓄卡+取款密码”即相当于这里的 token.

实际应用中, token 由服务器端在对用户验名正身后生成(通常验证用户登录的登录名和密码), 然后颁发给客户端. 通常, token 应还具备时效性, 即: 在多长时间内有效, 过期则需要重新验名正身. 有时, token 也可能是一次性的, 即用后即毁, 那就更安全了.

说到这里, 做过网站开发的同学是不是感觉 token 和 session 很像啊, 嘿嘿 ~ 是很像, 但是这里我们不能使用传统网站中使用的 session, 必须自己维护 token, 因为微信小程序端和服务器端不在同一个”域”, 涉及跨域请求的问题…. 不扯了…. 当我没说….

最后罗嗦两句… 为什么这里我们拿到微信服务器返回的 openId 即认为小程序用户”合法”? 不需要他用登录名和密码验名正身? 因为… 这事, 微信 APP 不是已经做过了吗? 我们的小程序信任微信就不行了吗?

上面的故事确实有点绕 ~ 如果看完还有些小迷惑, 可暂时放下, 跟前后文的教程做完, 再回头看一遍可能就清楚多了…

28.1 登录页面

在小程序端创建一个新的页面: login, 并将其设置为小程序的启动后显示的第一个页面.

8-1

编写代码, 完成如下图所示的页面:

8-2

界面非常简单, 而且最初新建小程序项目时官方给出的示例不就这样子吗?

下面, 直接给出 login.wxml, login.wxss, login.js 的代码. ( login.json 保持原样即可 )

Code 28-1: /pages/login/login.wxml

1
2
3
4
5
6
<view class="content">
<open-data class="avatar" type="userAvatarUrl"></open-data>
<open-data class="nickName" type="userNickName" lang="zh_CN"></open-data>
<button wx:if="{{canIUse}}" type="primary" open-type="getUserInfo" lang="zh_CN" bindgetuserinfo="popAuth">授权登录</button>
<view wx:else>请升级微信版本</view>
</view>

Code 28-2: /pages/login/login.wxss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.content {
padding-top: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.avatar {
display: block;
width: 150rpx;
height: 150rpx;
border-radius: 50%;
overflow: hidden;
}
.nickName {
margin-top: 40rpx;
margin-bottom: 300rpx;
color: var(--grey);
}

Code 28-3: /pages/login/login.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Page({
data: {
canIUse: wx.canIUse('button.open-type.getUserInfo') // 用户的使用微信版本是否支持获得用户信息
},
onLoad: function () {
},
// 授权弹窗
popAuth: function (e) {
if (e.detail.userInfo) { // 用户选择"允许"时, 回调参数e.detail.userInfo将带回用户信息
console.log(e.detail.userInfo);
} else { // 用户拒绝
wx.showModal({
title: '提示',
content: '请授权之后进入阅读打卡',
showCancel: false,
confirmText: '返回授权'
});
}
}
})

界面的设计与布局很简单, 就不解释了. 重点说一下代码实现的功能:

上面这些代码主要想获得用户的信息, 如: 昵称, 头像, 所在地 …

可能你会有疑问, 这些信息我们不是已经展示在界面上了吗? 不就说明我们已经得到了这些信息了吗?

呵呵, 如果只是简单的展示, 你可以使用微信开放数据接口<open-data>即可, 但现在, 我们需要把用户信息通过网络请求送到服务器端, 用于创建用户账号. 这就就有点麻烦了….

要在 JavaScript 代码中获得用户信息, 需要用户同意(即: 用户授权), 否则是取不到用户信息的…

所以, 我们在界面上放了一个”授权登录”按钮, 注意这个按钮可不简单, 它有这样一些属性: open-type="getUserInfo" lang="zh_CN" bindgetuserinfo="popAuth", 参见: https://developers.weixin.qq.com/miniprogram/dev/component/button.html

这些属性说明了, 当用户轻触此按钮时, 将弹出用户授权对话框, 等待用户同意. (什么样子? 一会儿自己试一下就知道了…)

我们的用户可能会有 2 个选择: 同意/不同意. 无论同意与否, 均会触发 bindgetuserinfo属性绑定的回调函数:popAuth.

观察 Code 28-3 第 8 ~ 19 行的代码, 用户授权的结果会通过 e.detail.userInfo传入. 若用户”同意”, 则在第 10 行输出用户信息(测试用). 否则, 弹出一个模态对话框 (第 12 ~ 17 行), 提示用户必须授权, 否则不跟你玩…

当然, 以上的故事都基于一个前提: 用户的微信 APP 版本支持”获取用户信息”的 API, 否则一些都是空谈…

所以, 我们通过微信 API: wx.canIUse('button.open-type.getUserInfo')先了解一下用户的微信 APP 是否支持… 若不支持, 则提示用户升级微信. (参见: Code 28-3 第 3 行, Code 28-1 第 4 ~ 5 行). 这只是一个兼容性处理, 实际情况现在几乎没人用那么旧的微信.

好了, 现在你可以测试一下了…

轻触”授权登录”按钮, 将弹出”微信授权”对话框, 好熟悉的样子…

先选择”拒绝”, 试试… 然后, 再次轻触”授权登录”按钮, 这回选择”允许”, 注意观察控制台的输出 (Code 28-3, 第 10 行)

一旦用户”允许”, 代表着永远允许, 除非用户通过别的接口主动取消授权.

即: 选择”允许”后, 即使再次点击”授权登录”, 也不会再弹出授权对话框, 而是直接取得用户信息. 当然, 后文可看到, 一旦用户”允许”, 我们可调用 wx.getUserInfo() 直接取得用户信息(无须用户再点什么按钮).

在微信开发者工具中, 点击”真机调试”按钮旁边的旁边的”清缓存”按钮, 可清除授权状态, 便于调试.

从前, 程序员们可直接调用 wx.getUserInfo() 接口取得用户信息, 当然, 若用户还未授权过, 那么会自动弹出授权对话框. 想想… 这是多么人性化的接口呀 ~

但自 2018.4.30 后, 微信官方突然禁用了直接通过 wx.getUserInfo() 拉起”微信授权”对话框的功能(参见: 小程序官方公告), 必须通过按钮触发授权对话框(如本教程的做法).

于是, 一时间哀鸿遍野… 加之, 直至本教程成文时, 微信小程序官方文档中的部分阐述还未更正. 导致很多小白一头雾水…

wx.getUserInfo()

刚才提到, 若用户已授权, 则可直接使用wx.getUserInfo()取得用户信息.

所以, 我们可以在登录页面加载时判断用户若已授权, 则直接取得用户信息, 修改 Code 28-3 代码…

Code 28-4: /pages/login/login.js

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
Page({
data: {
canIUse: wx.canIUse('button.open-type.getUserInfo') // 用户的使用微信版本是否支持获得用户信息
},
onLoad: function () {
// 查看"获取用户信息"的权限是否已授权
wx.getSetting({
success: (res) => {
if (res.authSetting['scope.userInfo']) { // 已授权
// 取得用户信息
wx.getUserInfo({
lang: 'zh_CN',
success: (res) => {
console.log(res.userInfo);
}
});
}
}
});
},
// 授权弹窗
popAuth: function (e) {
if (e.detail.userInfo) { // 用户选择"允许"时, 回调参数e.detail.userInfo将带回用户信息
console.log(e.detail.userInfo);
} else { // 用户拒绝
wx.showModal({
title: '提示',
content: '请授权之后进入阅读打卡',
showCancel: false,
confirmText: '返回授权'
});
}
}
})

以上代码在 Code 28-3 的基础上增加了第 6 ~ 18 行. 通过 wx.getSetting()可获得用户的授权情况, 参见wx.getSetting()

若已授权, 则第 10 ~ 15 行通过 wx.getUserInfo() 取得用户信息并输出(第 13 行).

无论是通过弹出授权对话框取得的用户信息, 还是直接通过 wx.getUserInfo() 取得用户信息, 其内容均一致.

对于向用户请求过的权限, 无论用户同意或拒绝, 均可调用 wx.openSetting() 转至小程序设置界面, 让用户进行变更.

28.2 小程序端登录/注册逻辑实现

上一小节结束, 嗅觉敏感的同学可能已经察觉到了, Code 28-4 中的第 13, 23 两行处将对接用户注册/登录接口.

是的, 注册与登录其实是同一个接口, 稍有不同的是: 已注册用户直接走登录流程, 而未注册用户则创建用户后走登录流程. 这个不同点主要在服务器端, 对于小程序端来说可以认为是相同的(参见后文代码)

修改 login.js 代码:

Code 28-5: /pages/login/login.js

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
const api = require('../../utils/api');

Page({
data: {
canIUse: wx.canIUse('button.open-type.getUserInfo'), // 用户的使用微信版本是否支持获得用户信息
logging: false // 是否正在执行登录操作
},
onLoad: function () {
// 查看"获取用户信息"的权限是否已授权
wx.getSetting({
success: (res) => {
if (res.authSetting['scope.userInfo']) { // 已授权
// 取得用户信息
wx.getUserInfo({
lang: 'zh_CN',
success: (res) => {
this.login(res.userInfo);
}
});
}
}
});
},
// 授权弹窗
popAuth: function (e) {
if (e.detail.userInfo) { // 用户选择"允许"时, 回调参数e.detail.userInfo将带回用户信息
this.login(e.detail.userInfo);
} else { // 用户拒绝
wx.showModal({
title: '提示',
content: '请授权之后进入阅读打卡',
showCancel: false,
confirmText: '返回授权'
});
}
},
// 登录
login: async function (userInfo) {
if (this.data.logging) return; // 若正在登录, 直接返回, 避免重复执行
this.setData({ logging: true });

try {
await api.login(userInfo);
wx.redirectTo({ url: '/pages/index/index' });
} catch (e) {
wx.showModal({ title: '提示', content: '登录失败: ' + e.errMsg, showCancel: false });
this.setData({ logging: false });
}
}
})

有变化的地方:

  • 第 37 ~ 49 行, 自定义函数 login, 实现登录功能, 在第 17, 27 行调用.
    • 第 43 行, 调用 api.js 模块中的 login 函数(见Code 28-7), 执行登录逻辑. 注意, 参数 userInfo 中携带了用户的信息.
    • 若登录成功, 则转至主界面(第 44 行), 否则提示失败(第 46 行). 注意 44 行用的是 wx.redirectTo(), 而不是此前使用过的 wx.navigateTo(). (有什么区别? 自己查微信小程序文档吧 ~)
  • 第 6 行添加的 logging 属性, 用于标识当前是否正在执行登录操作, 用以控制界面上”授权登录”按钮的状态(见 Code 28-6). logging 的值在第 39, 46 行有变化, 应该一看就能明白.
  • 第 1 行引入了 api.js 模块

先改简单的吧…

Code 28-6: /pages/login/login.wxml

1
2
3
4
5
6
<view class="content">
<open-data class="avatar" type="userAvatarUrl"></open-data>
<open-data class="nickName" type="userNickName" lang="zh_CN"></open-data>
<button wx:if="{{canIUse}}" type="primary" open-type="getUserInfo" lang="zh_CN" bindgetuserinfo="popAuth" disabled="{{logging}}">{{logging?'登录中...':'授权登录'}}</button>
<view wx:else>请升级微信版本</view>
</view>

第 4 行有变化, 不解释.

重点来看前面多次提到的 api.js 模块…

Code 28-7: /utils/api.js (局部)

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
// 向服务器发送登录请求
const login = (userInfo) => {
return new Promise((resolve, reject) => {
wx.login({
success: (res) => {
if (!res.code) return reject(res.errMsg);
wx.request({
url: `${SERVER_API}/login`,
method: 'POST',
data: {
code: res.code,
userInfo
},
success: (resp) => {
if (resp.statusCode === 200 && resp.data.token) {
wx.setStorageSync('TOKEN', resp.data.token);
resolve();
} else {
reject({errMsg: resp.data});
}
},
fail: (err) => {
wx.showToast({ title: '登录失败', icon: 'none' });
reject(err);
}
});
},
fail: (err) => {
wx.showToast({ title: '登录失败', icon: 'none' });
reject(err);
}
})
});
}

module.exports = {
login // 在原来的基础上增加此项
}

可以看到, 上面的代码主要是定义了一个 login 函数, 并将其”暴露”出去.

  • 第 7 ~ 24 行, 调用 wx.request 方法向服务器端发送登录请求.
    • 第 10 ~ 13 行, 注意: 带到服务器端的数据有 2 项: code 和 userInfo, 其中 code 由外层的 wx.login() (参看第 4 ~ 6 行) 取得的登录凭证. 不记得的话, 回去看看第 28 节开头闲扯的那段吧 ~
    • 第 16 行, 登录成功, 服务器端将返回 token, 此处将其存储到”本地存储” (用户手机), 以便后续使用. 参见wx.setStorageSync()

代码中其它的部分不难看懂, 只是嵌套了两层回调(wx.login, wx.request), 所以显得有些让人头晕. 慢慢研究一下 ~

28.3 服务器端登录/注册逻辑实现

再次回顾第 28 节开篇的闲扯内容(黑体标注的”服务器端登录流程”), 用户信息(userInfo)及登录凭证(code)来到服务器端后, 先要去微信服务器换取 openid, 若能正常取得 openid, 则说明该用户已通过了微信的认证, 然后才能执行其余流程… 否则, 直接向小程序端返回”登录失败”…

有点晕, 是吧 ~ 呵呵, 不急, 让我们一步步来…..

先实现与小程序端登录请求对接的接口…

28.3.1 服务器端 login 接口

Code 28-8: /inde.js (login)

1
2
3
4
5
app.post('/login',async (req, resp) => {
const userInfo = req.body.userInfo;
const code = req.body.code;
resp.send({ token: 'TEST-TOKEN'}); // 模拟向小程序端返回token
});

这段代码老简单的, 唯一有点难度的还写了注释…

重新启动一下服务端程序, 刷新小程序端, 你会发现小程序端在执行了一个所谓的登录过程后, 自己进入主界面了, 呵呵 ~

当然, 看上面的代码也知道, 这只是会了测试 login 接口是否通畅, 而写的测试代码… 你可以在第 4 行设一个断点, 观察一下小程序端是否真的把 userInfo 和 code 送来了…

让我们继续吧 ~ 路还长着呢…

28.3.2 换取 openid

先跳到微信小程序官方文档: auth.code2Session, 看一下, 然后再回来…

如果你真的看了上面的文档, 呵呵 ~ 那么应该知道了, 我们需要写服务器端代码, 向微信服务器发送 HTTPS GET 请求, 同时携带 3 个关键参数: APPID, SECRET, JSCODE, 微信服务器就会返回 openid 给我们了.

那么, 问题来了… 这三个参数从何而来?

  • APPID : 你的小程序的ID, 点击”微信开发者工具”右上角的”详情”按钮可以看到.
  • SECRET : 你的小程序密钥, 呵呵 ~ 这个故事要从很久很久以前说起了… 看看这里吧~
  • JSCODE : 登录凭证, 即是 Code 28-8 第 3 行获得的那个 code.

每个小程序的 appid 和 secret 都不一样, 而且还挺重要的, 不能随便让别人知道! 虽然现在你也不必知道被别人偷窥到会有什么严重后果, 呵呵 ~

所以, 本教程后续内容中(含代码), 涉及 appid 和 secret 的地方均会使用大写的 APPID, SECRET 表示 (手动打码), 你需要替换成自己的 appid/secret, 别在傻呼呼的复制代码了~ 望周知!

好了, 让我们来写一个 wxUtil 模块(放utils文件夹中), 把这部分的功能装进去…

Code 28-9: /utils/wxUtil.js

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
const https = require('https');
const APPID = 'APPID'; // 换成你自己的appid
const SECRET = 'SECRET'; // 换成你自己的secret

function code2Session(code) {
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'api.weixin.qq.com',
path: `/sns/jscode2session?appid=${APPID}&secret=${SECRET}&js_code=${code}&grant_type=authorization_code`,
method: 'GET'
}, res => {
let result = '';
res.on('data', secCheck => {
result += secCheck;
});
res.on('end', secCheck => {
resolve( JSON.parse(result) );
})
res.on('error', err => {
reject(err);
});
});

req.on('error', err => {
reject(err);
})
req.end();
});
}

module.exports = {
code2Session
}

再友情提醒一下, 第 2, 3 行要替换你自己的 appid, secret

上面这段代码精彩的地方在于向你展示了, 如何从你的服务器向别人的服务器发送网络请求.

  • 第 7 ~ 22 行, 向 api.weixin.qq.com服务器发送 HTTPS GET 请求 (参看第1, 7 ~ 10 行), 其中第 9+10 行的东西拼起来即是 auth.code2Session 中提到的请求地址和参数.
  • 需要注意的是, 第 24 ~ 26 行是网络请求(Request, req)出错的监听, 可别写漏了, 天晓得在 Internet 的世界里会出什么幺蛾子…
  • 执行第 27 行才真正发出网络请求.
  • 在第 12 ~ 21 行中, 我们监听了 3 个关于回应的事件(Response, res):
    • 第 13 ~ 15 行, 监听 ‘data’ 事件, 即有数据从对方服务器流淌回来. 这里特别需要注意 2 件事情:
      • 回应数据(Reponse)不一定是一坨地回来, 若数据较长, 可能是一波一波地回来. 所以, 第 14 行把每一波回应的数据累加到了 result 中.
      • 打包到 HTTP/HTTPS 数据包中的东西只有两种类型: 字符串 和 二进制流. 其中, 二进制流用于传输文件, 而其它类型的数据, 无论原本是整数, 布尔型, 字符串… 均会变成字符串!!! ( 重要的事情, 自己默念三遍! 很多同学直到进入公司参与项目开发, 仍然不厌其烦地掉进这个坑… )
    • 第 16 ~ 18 行, 若回应结束(end), 则将完整的回应数据传成 JavaScript 对象返回(resolve). 微信服务器返回的数据是 JSON 格式的, 所以这里使用 JSON.parse() 进行解析后返回, 便于外部处理.
    • 第 19 ~ 21 行, 回应过程中出错的处理, 没什么好说的…

OK ~ 现在回头改一下 Code 28-8 的代码…

Code 28-10: /inde.js (login)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const wxUtil = require('./utils/wxUtil.js');
//......
app.post('/login',async (req, resp) => {
const userInfo = req.body.userInfo;
const code = req.body.code;
try {
// 使用code从微信服务器换取openid
const wxResp = await wxUtil.code2Session(code);
if (!wxResp.openid) throw new Error('Error response from code2Session');

resp.send({token: 'TEST-TOKEN'});
} catch (err) {
resp.status(401).send(err.message);
}
});

现在, 我们对接上了 Code 28-9 中定义的 code2Session函数

  • 第 13 行, 如果第 7 ~ 11 行的代码在执行中抛出异常, 则向前端返回错误信息. 同时, 注意 resp.status(401)将回应的状态码设置成了 401 (未授权), 这将会触发小程序端进入出错处理流程 (参看: Code 28-7 第 15 行)
  • 有意思的是第 9 行, 判定若微信服务器的返回数据中无 openid 时 (即微信登录异常), 直接手动抛出异常, 于是代码将直接跳到第 12 行. ( 这一招可以学起来 )

好了, 现在你又可以重启服务端, 测试一下了….

正常情况下, 你的小程序仍然可以进入主界面… 当然, 如果网络异常, APPID, SECRET, CODE 不正确…. 都可能会导致”登录失败”, 此时可在第 13 行设断点, 分析一下具体是什么原因导致…

如果你人品爆发, 一切顺利, 但又想看看出错是什么样子, 呵呵 ~ 可以在第 5 行 code 后面随便拼点什么试试 ~

28.3.3 数据库操作

想半天不知道取什么标题好, 真对不起我的小学语文老师…

本小节干 2 件事:

(1) 使用上节(28.3.2)得到的 openid 查数据库 user_info 表, 看是否有对应用户, 即: user_info.wxId == openid

  • 若无, 说明是新用户, 则添加新用户记录(注册), 转 (2)
  • 若有, 说明是老用户, 转 (2)

(2) 随机生成一个 token, 记入数据库, 同时返回

哎呀 ~ 突然发现一个问题, 当初我们设计数据库结构的时候忘记了用于存储 token 和 token 过期时间的字段了…

来呗 ~ 修改数据库结构… 添加两个字段: token char(36) , tokenExpires datetime

你可以直接执行下面的语句. 当然, 也可以使用你掌握的任何方法修改表结构…

1
2
ALTER TABLE user_info ADD token char(36) NULL COMMENT 'token';
ALTER TABLE user_info ADD tokenExpires datetime NULL COMMENT 'token过期时间';

这个小插曲并非我有意为之, 确实是在写第五部分的忘记了…

本想直接去修改第五部分的内容, 把这个”洞”神不知鬼不觉的补上. 但转念一想, 写本教程的一个主要目的就是为了带领各位感受实际项目的开发过程… 有点纰漏也很正常, 在后期的开发过程中逐渐完善也许才是真正的项目开发日常…

对于数据库设计而言, 正确地将项目涉及的数据项分离成”坨”(表), 定义主键, 建立表之间的关系(外键)往往更重要, 相对而言, 调整表内的其它字段一般而言并不是灾难性的, 所以… 没必要那么谨小慎微, 进退两难…

呵呵~ 真是佩服自己! 填坑都坑得这么理直气壮…

继续 ~ 在service文件夹中新建 authority.js, 代码如下:

Code 28-11: /service/authority.js

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
const dbUtil = require('../utils/dbUtil.js');   // 引入dbUtil模块
const uuid = require('uuid');
const TOKEN_EXPIRES = 1000 * 60 * 60; // TOKEN有效期: 1小时

// 用户登录
async function login(openId, userInfo) {
const token = uuid.v1(); // 使用uuid作为token
const tokenExpires = new Date(Date.now() + TOKEN_EXPIRES); // token失效时间

// 直接尝试更新 wxId == openId 的用户的token值
const updateTokenResult = await dbUtil.query('update user_info set token=?, tokenExpires=? where wxId=?', [token, tokenExpires, openId]);

// 若affectedRows大于0, 则说明有对应用户, 并且第10行已将新的token写入数据库, 直接返回即可
// 若affectedRows为0则说明无对应用户(新用户), 继续走16行后的流程
if (updateTokenResult.affectedRows > 0) return token;

// 将新用户信息及token插入数据库(参看发表日记的代码)
const fields = ['id', 'wxId', 'nickName', 'token', 'tokenExpires'];
const params = {
id: uuid.v1(),
wxId: openId,
nickName: userInfo.nickName,
token,
tokenExpires
};
const sqlAndValues = dbUtil.buildInsert('user_info', fields, params);
await dbUtil.query(sqlAndValues.sql, sqlAndValues.values);

return token;
}

module.exports = {
login
}

代码中都写满注释了, 就不过多解释了… 仅提示一点, 上述代码使用了点”奇技淫巧”…

按逻辑, 应先查数据库, 有 wxId == openid 的记录的话才更新 token, 否则插入新记录, 更新 token. 但懒得那样做了…

所以, 第 10 行相当于把查询和更新合并为一步, 而 17 ~ 25 行的将插入新记录和更新 token 合并为一步.

第 17 ~ 25 行的代码借助了第 27.2.4 节中的”选修内容”, 若你没那样做… 可将此段代码改为:

1
2
3
4
5
6
7
await dbUtil.query('insert into user_info(id, wxId, nickName, token, tokenExpires) values (?, ?, ?, ?, ?)', [
uuid.v1(),
openId,
userInfo.nickName,
token,
tokenExpires
]);

最后, 再次回到 index.js, 对接一下 login 函数

Code 28-12: /inde.js (login)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const wxUtil = require('./utils/wxUtil.js');
const authority = require('./service/authority.js');
//......
app.post('/login',async (req, resp) => {
const userInfo = req.body.userInfo;
const code = req.body.code;
try {
// 使用code从微信服务器换取openid
const wxResp = await wxUtil.code2Session(code);
if (!wxResp.openid) throw new Error('Error response from code2Session');

const token = await authority.login(wxResp.openid, userInfo);

resp.send({token: token});
} catch (err) {
resp.status(401).send(err.message);
}
});
  • 第 2 行, 引入新的 authority.js 模块
  • 第 12 行, 调用 Code 28-11 中的 login 函数, 执行登录/注册功能的写数据库操作后, 取得新的 token
  • 第 14 行, 将 token 送回小程序端

重启服务端, 再次测试小程序端功能是否正常, 如果发生什么不幸…. 对照上面的代码, 慢慢改吧~

前面我们折腾这个所谓的 token 老半天了, 都不知道该怎么用它… 呵呵 ~ 且看下回分解…

29. 建立小程序与服务端之间的信任

故事讲到这里, 大家应该都已明白, 无论是网站还是微信小程序都涉及到前端程序和服务器端程序, 它们之间通过网络连接起来进行数据通信.

一个关键的问题是, 如何证明服务器端收到的某个网络请求确实是我们信任的前端程序发送过来的, 而不是由某个坏人伪造的呢? 反之亦然.

一个常见的做法即是引入token(令牌). 在建立可信任通信前, 首先生成 token 并由通信双方持有, 此后的每一次网络通信均携带 token, 当网络请求到达对方时, 通过比对 token 值便可确定本次网络请求是否来自可信任的另一端.

所以, token值应唯一, 则难以被坏人猜到, 本教程中偷懒, 直接使用了随机生成的 uuid (Code 28-11第 6 行). 当然, 你可以使用别的算法按你自己的规则来生成 token.

同样出于安全考虑, token 应具有”时效性”, 即过了有效期后即失效. 回想一下, 你用手机接收各种验证码时, 是不是都提示你: 此验证码将在XX分钟后失效.

好了, 又复习了一下理论… 下面开始实干吧 ~

在小程序端实现登录功能的代码中 (Code 28-7: /utils/api.js, 第 16 行) 我们已经把登录成功后服务器端返回的 token 存储到了微信小程序的本地存储中(手机).

接下来的事情, 首先需要在每一次向服务器端发送网络请求时都带上这个token.

还好 ~ 此前我们已经对小程序端的网络请求进行了统一封装 (/utils/api.js 中的 request 函数), 所以, 现在我们就只需要修改 request()函数即可.

Code 29-1: /utils/api.js (request)

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
// 向服务器发送http/https请求
const request = (url, data) => {
return new Promise((resolve, reject) => {
wx.showLoading({ title: '加载数据中...'});
wx.request({
url: `${SERVER_API}${url}`,
method: 'POST',
data: data || {},
header: {
'daily-reading-token': wx.getStorageSync('TOKEN')
},
success: (resp) => {
wx.hideLoading();
if (resp.statusCode === 200) {
resolve(resp.data);
} else if (resp.statusCode === 403) {
wx.showToast({ title: resp.data, icon: 'none' });
wx.navigateTo({ url: '/pages/login/login' });
} else {
wx.showToast({ title: '网络请求失败: ' + resp.statusCode, icon: 'none' });
}
},
fail: (err) => {
wx.showToast({ title: '网络请求失败', icon: 'none' });
reject(err);
}
});
});
}
  • 第 9 ~ 11 行: 我们把 token 从本地存储中取出, 放进请求数据包的 header(数据包头) 中, 随其它数据一起带到服务器端.

    呵呵, 没想到吧, header 中还可以放东西 ~

    之所以放在 header 中, 而不是直接放在 data (body) 部分, 主要是不想干扰正常业务数据的处理.

  • 第 16 ~ 18 行: 若服务器端返回状态码 403 则表明 token 无效(参见 Code 29-3), 于是转到登录页面.

**记得自己改一下 /utils/api.js 中的 uploadFile函数中的对应位置, 添加上面第 10 行的代码 **

为什么改? 你也应该懂的 ~

现在, 我们来处理服务器端程序… ( 别跑错了~ )

在 /service/authority.js 中增加 checkToken 函数, 用于检验 token 有效性…

Code 29-2: /service/authority.js (新增checkToken函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 校验token有效性
// 若有效, 延长token有效期, 并返回匹配的用户ID
async function checkToken(token) {
// 查找token值匹配且未过期的用户id
const validUser = await dbUtil.query('select id from user_info where token=? and tokenExpires>?', [token, new Date()]);

// 未找到(数据库中不存在匹配的有效token), 直接返回
if (!validUser.length) return null;

const userId = validUser[0].id; // select结果为数组

// 延长token有效期
const tokenExpires = new Date(Date.now() + TOKEN_EXPIRES);
await dbUtil.query('update user_info set tokenExpires = ? where id = ?', [tokenExpires, userId]);

return userId;
}

module.exports = {
checkToken
}

代码中的注释已经很详细了… 再罗嗦两句…

  • 第 8 行, 若在 user_info 表中找不到 token 匹配并且尚未过期的记录则返回 null, 因此 checkToken 函数的调用方应判断返回值是否为 null, 以确定 token 有效性 (参见 Code 28-3)

    若 select 语句执行不出错, 则 validUser 一定是个数组, validUser.length 即为数组元素个数(≥0), 因此, !validUser.length即为查询结果中有至少一行数据的意思…

  • 第 13 ~ 14 行, 延长 token 有效期, 即每一次校验 token 有效后, 即将 token 的有效期延长

    有什么用呢? 如果用户在 token 有效期内有任何网络请求发送到服务器端, 则说明用户还在使用. 反之, 即 token 有效期内没有任何网络请求, 则说明用户似乎已经离开, 此时应令 token 失效, 此后若前端再有网络请求, 应重新触发登录流程…

    这个故事是否很像网站开发中的 Session ? 呵呵 ~ 不明白的话, 平时上网总遇到过所谓的”登录超时”吧…

上面突然冒出来一个 checkToken 函数, 有些突兀… 怎么用呢? 接着看…

显然, 在服务器端, 我们需要对除/login外的所有网络请求都进行 token 校验, 若请求中无 token 或 token 无效, 则应该向前端返回错误码.

为什么要除/login外呢? 自己想想 ~

在小程序端, 幸好我们此前把网络请求和上传文件功能进行了封装, 才令我们上文中添加 token 及校验逻辑变得简单…

但看一下服务器端 index.js 中的代码就会发现, 里面有很多个对应小程序端的接口… 比如: /getFirstPageData, /publishDiary, /upload …

难道要挨个接口去改代码? 这不疯了吗?

Fortunately… Express 框架中有一个叫做**中间件(middleware)**的东东…

简单说, 前端发来的网络请求被 Express 框架接收到后, 请求会依次经过 N 个中间件: 某个中间件对请求进行处理后递交给下一个中间件, 下一个中间件处理后交给再下一个中间件 …… 最后向前端返回最终结果. 当然, 任何中间件也可直接向前端返回结果, 而不把请求递交给下一中间件.

有点不像人话… 简单说, 每个中间件就像是网络请求处理”流水线”上的一环.

听上去很神奇 ~ 嘿嘿, 事实上你已经用过很多了… index.js 代码中的下面这些东东其实都是在使用中间件…

1
2
3
4
app.use(express.static(staticDir));
app.use(express.json());
app.post('/getFirstPageData', .... );
app.post('/publishDiary', ... );

三观被颠覆了吧 ~

所不同的是, 第 3, 4 行的中间件只对特定 url 的请求进去处理, 而第 1, 2 行的中间件对所有网络请求均进行处理.

来吧, 写一个我们自己的中间件, 专门对所有网络请求进行 token 校验…

在 index.js 中添加如下代码

Code 29-3: /index.js (添加token校验中间件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const authority = require('./service/authority.js');

// ....
app.use(express.static(staticDir));
app.use(express.json());

// token校验中间件
app.use(async function(req, res, next) {
// 若为登录请求, 直接跳过校验过程
if (req.path === '/login') return next();

// 取得token
const token = req.headers['daily-reading-token'];
let userId;

// 校验token有效信息
// 若未在req.headers取到token, 或token无效, 则向前端返回错误码
if (!token || !(userId = await authority.checkToken(token))) {
res.status(403).end('Invalid Token'); // 403: Forbidden
} else {
req.body.userId = userId; // 将userId写入body,以便后续代码使用
next(); // 将请求转交给下一个中间件(middleware)
}
});

特别注意: 中间件的书写位置与网络请求通过中间件的先后顺序相关. 第 4 ~ 5 行代码是专门放在这里让你知道 token 校验中间件应该放在哪里的, 别放错地方哦~

提示几个地方:

  • 第 13 行, 小程序端我们把 token (daily-reading-token) 放在 HTTP/HTTPS 请求的 header 中, 所以这里应从 header 中取出

  • 第 18 行, !(userId = await authority.checkToken(token)先执行 authority.checkToken 将返回值赋值给 userId, 然后判定 userId 是否为 null

  • 第 21, 22 行, 将 userId 放入 req.body, 然后再转交下一个中间件.

    试想, 若小程序端”发表日记”, 此请求将先被此处的 token 校验中间件处理, 若 token 有效会得到当前用户的 userId (参看: Code 29-2), 然后将 userId 放进请求的 body 中, 转交下一中间件继续处理….

    那么, 所谓的下一个中间件是谁呢? 嘿嘿, 对了~ 就是它: app.post(‘/publishDiary’, … );

    此后的故事, 你懂了吧 ~

最后一步, 大功告成 ~

打开 /service/diary.js 文件, 找到 addDiary 函数, 改一下下…

Code 29-4: /service/diary.js ( addDiary )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 新增日记
async function addDiary(params) {
const fields = ['id', 'userId', 'title', 'publishTime', 'cover', 'content', 'audioPath', 'audioDuration'];
const sqlAndValuesOfNewDiary = dbUtil.buildInsert('diary', fields, params, function (field, defaultValue) {
switch (field) {
case 'id': return uuid.v1(); // 使用uuid作为记录主键
// case 'userId': return 'u1'; // 假设日记都是u1用户发表的(以后处理)
case 'publishTime': return new Date();
default: return defaultValue;
}
});

// 更新作者日记数的SQL语句
const sqlUpdateDiaryCnt = 'update user_info set diaryCnt = diaryCnt + 1 where id = ?';
const valuesUpdateDiaryCnt = [ params.userId ];

// 执行两条语句
await dbUtil.query([sqlAndValuesOfNewDiary.sql, sqlUpdateDiaryCnt], [sqlAndValuesOfNewDiary.values, valuesUpdateDiaryCnt]);
}

第 7 行被干掉了, 第 15 行有变化 ~ 这意味着什么呢?

呵呵, 这意味着, 日记作者不再被假设(固定)为 u1 用户了, 而是使用 params.userId 的值, 即从 req.body.userId 取得的值. 参看第 27.2.4 节.

嘿嘿~ 故事又接回到了 Code 29-3 第 21 行…

同样, 在 index.js 中也有一个地方硬编码了 u1 用户, 你能找到吗? 呵呵~ 下面的第 5 行, 记得改哦~

1
2
3
4
5
6
7
8
9
10
11
12
13
// 获得首页数据
app.post('/getFirstPageData', async function (req, resp) {
try {
const data = {
userInfo: await userService.getUserInfoById(req.body.userId),
diaries: await diaryService.getAllDiaries(), // 所有日记
};
resp.send(data); // 向前端返回数据
} catch (err) {
// 向前端返回错误信息
resp.status(500).send(err.message);
}
});

万里长征终于快走到头了… 恭喜你, 你已经战胜了全国 98% 的用户!

现在, 打开你的小程序, 发表一篇日记试试, 新日记的作者是不是已经变成真正的你了?

打开数据库 user_info 表, 把你自己账号那条记录的 tokenExpires 改成”过去的时间”, 再次发表日记是否会提示”403”?

目前为止的完整代码, 拿走 ~ 不谢! daily-reading_part8.zip

  • create_database.sql 中代码有修改, user_info 表加了 token, tokenExpires 两个字段

  • daily-reading-server/utils/wxUtil.js 中, APPID 和 SECRET 已被手动打码, 请修改成你自己的

我们亲爱的”阅读打卡”小程序中尚有一些的功能未实现, 比如: 点赞, 关注, 评论, 查看日记详情 …

相信能坚持到现在, 聪明的你可以自己搞定… 挑战一下呗 ~

另外, 如果你想正式发布你的小程序, 可以到微信公众平台注册正式的小程序账号, 开发完成后将服务器端程序搬到公网服务器, 配置为HTTPS服务器, 测试没问题后提交审核, 通过后即可正式上线 ~

呵呵, 说得相当简单…

但你都已经走到要正式上线一款程序小程序了, 说明水平已经不低, 相信这点小困难难不倒你… 本博客中的这些儿童读物恐怕早已不适合你了 ~

~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~

特别说明:

本教程中的故事纯属虚构, 主要为展示微信小程序常见基础功能的实现方法, 体会开发过程而有意编造.

大部分功能设计简陋, 只求让大家在学习过程中保持思路清晰, 易于掌握. 甚至部分做法并不符合实际项目中的设计惯例, 切勿照搬! 基本技术掌握后, 应多站在用户的角度思考问题…

例如: 用户注册/登录功能的实现, 不应像本教程一样霸王硬上弓, 强制用户登录, 获取用户资料….

实际项目中你如果真这样做, 那基本就是在耍流氓… 并且估计是上不了线滴 , 不信的话, 可以试试,嘿嘿 ~

呼应本教程开篇, 如果你只是作为一个毕业设计的作品演示, 也并不一定非要正式上线…

假若你能模仿本教程的项目, 完成一个类似的小程序, 通过毕业设计答辩应毫无压力, 所以… 别再找枪手了…

友情提醒: 切勿直接使用本教程提供的代码参加答辩, 除非所有代码你均完全理解… 否则死相会很难看…