实战篇 6:心情签到页面开发¶
「新鲜天气」的心情签到页面结构比较简单,本小节主要介绍三部分内容:
- 使用日历插件
- 用户授权和登录流程
- 使用小程序云开发的数据库功能
使用日历插件¶
心情签到页面最重要的模块就是日历,日历使用了一个开源的日历插件,在小程序内使用插件需要经过下面三步。
1. 在小程序管理后台添加三方服务插件¶
登录小程序管理后台,依次进入「设置 -> 第三方服务」搜索日历插件的 AppID(wx92c68dae5a8bb046)就可以搜索到「极点日历」,这时候申请授权即可。
2. 在 app.json 中增加插件配置¶
第二步是在项目的 app.json 中增加 plugins 字段内容:
"plugins": {
"calendar": {
"version": "1.1.3",
"provider": "wx92c68dae5a8bb046"
}
}
3. 在 diary 页面增加组件配置¶
在 pages/diary/index.json 的页面配置中的 usingComponents 里增加 calendar 的插件地址:
{
"usingComponents": {
"calendar": "plugin://calendar/calendar",
"icon": "../../components/icon/index"
}
}
经过上面三步之后,我们就可以在页面中使用 <calendar /> 标签了。具体日历的用法,可以参考它的 wiki 主页。
设置日历的心情颜色¶
在心情设置上,笔者设计了 5 种心情,由 5 种颜色来表示,具体数值如下:
// client/pages/diary/index.js Page data
emotions: ['serene', 'hehe', 'ecstatic', 'sad', 'terrified'],
colors: {
serene: '#64d9fe',
hehe: '#d3fc1e',
ecstatic: '#f7dc0e',
sad: '#ec238a',
terrified: '#ee1aea'
}
签到不同的心情,最终在日历上会展现出下面的效果:
要在某天设置该天的背景颜色,需要使用日历的 days-color 属性,这里笔者将 days-color 与 daysStyle 进行绑定:
<!--diary/index.wxml-->
<calendar days-color="{{daysStyle}}" />
daysStyle 的计算和赋值是在 setCalendarColor 方法内的:
// diary/index.js
setCalendarColor(year, month) {
year = year || new Date().getFullYear()
month = month || new Date().getMonth() + 1
// 从数据库读取数据
getEmotionByOpenidAndDate(this.data.openid, year, month)
.then((r) => {
const data = r.data || []
const styles = []
const now = new Date()
const today = dateFormat(now)
let todayEmotion = ''
let colors = this.data.colors
// 遍历日期,存在表情的日期则设置对应的颜色
data.forEach((v) => {
let ts = v.tsModified
let date = new Date(ts)
let day = date.getDate()
if (today === dateFormat(date)) {
todayEmotion = v.emotion || ''
}
styles.push({
month: 'current',
day,
color: 'black',
background: colors[v.emotion]
})
})
// 设置 daysStyle
this.setData({
lastMonth: `${year}-${('00' + month).slice(-2)}`,
showPublish: true,
todayEmotion,
daysStyle: styles
})
})
.catch((e) => {
wx.showToast({
title: '加载已签数据失败,请稍后再试',
icon: 'none',
duration: 3000
})
})
}
日历事件绑定¶
当日历切换月份的时候,我们应该获取当前切换到的月份,获取当前月份的心情数据,所以在 calendar 上绑定 dateChange 事件:
<!--diary/index.wxml-->
<calendar binddateChange="dateChange" />
// diary/index.js page
dateChange(e) {
// console.log(e)
let {currentYear, currentMonth} = e.detail
this.setData({
daysStyle: []
})
this.setCalendarColor(currentYear, currentMonth)
}
小程序用户登录和授权流程¶
在心情签到的功能开发中,需要得到用户信息,获取用户信息需要用户账号授权才可以。用户账号授权是小程序开发中经常碰见的技术点,本节重点介绍下小程序的登录授权机制。
小程序开发文档中有一张很完整的流程图(见下图),笔者会围绕这张图来介绍用户授权流程,然后结合云函数来实现一个获取用户授权信息的功能。
从这张图来看,整个数据通信过程包含了小程序、开发者服务器(云函数)和微信接口服务,这三方是都参与其中的,整个流程跟公众号和第三方登录授权流程都基本类似。
整个授权流程可分为下面五个步骤。
1. wx.login 获取临时登录凭证 code¶
「小程序」内调用 wx.login 方法,如果用户是第一次授权或者授权过期,则会弹出授权窗口,提示用户个人信息会被授权给第三方服务使用。这时候如果用户同意授权,则会拿到**临时登录凭证 code**,这个临时登录凭证有效期只有 5 分钟。我们拿到这个临时登录凭证需要调用「开发者服务器(云函数)」的接口,将临时凭证发送给服务器,然后「开发者服务器」调用「微信接口服务」的 jscode2session 接口获取 openid 和 session_key。
wx.login({
success: () => {
if (res.code) {
// example: 081LXytJ1xxxxcdfxxx1FWxdfdsfXyth
// 将 code 发送给开发者服务器
}
}
})
2. 获取 openid 和 session_key¶
微信内,同一用户在任意小程序、公众号或者服务号中,都会有一个不同的唯一标识 openid,所以可以认为,我们在应用中获取的用户 openid 是唯一的,并且该用户在另外一个应用中的 openid 跟其他应用的是不同的。
session_key 是微信服务派发给我们的一个用户登录有效性的凭证,通过它我们可以间接维护用户微信的登录态。
获取 openid 和 session_key 需要调用微信的接口:
https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
这个接口的参数为:
| 参数 | 必填 | 说明 |
|---|---|---|
| appid | 是 | 小程序唯一标识 |
| secret | 是 | 小程序的 app secret |
| js_code | 是 | 登录时获取的 code |
| grant_type | 是 | 填写为 authorization_code |
其中 appid 和 secret 可以在小程序管理后台找到,具体路径为「设置 -> 开发设置 -> 开发者 ID」。appid 直接可见,而 secret 需要点击「生成」链接,并用开发者账号的微信扫码才能生成,生成之后需自行保存。
secret 是授权中保证安全性的一个重要 ID,不能外泄,因此必须放在开发者自己的服务器上使用,不能直接放到前端页面调用微信服务接口,因为如果这样的话 secret 就暴露了,这也是整个授权过程需要小程序、开发者服务器、微信服务三方都介入的原因。如果忘记或泄露了 secret,需要在微信后台重置。
js_code 就是第一步中我们通过 wx.login 获取到的临时授权凭证 code。有了appid、secret和js_code,我们可以写一个云函数来请求微信的 jscode2session 接口:
// 云函数名称:jscode2session
const API_URL = 'https://api.weixin.qq.com/sns/jscode2session'
const request = require('request')
const querystring = require('querystring')
/*<jdists import="../../inline/utils.js" />*/
/*<remove>*/
const $ = require('../../inline/utils')
/*</remove>*/
exports.main = async (event) => {
let {code} = event
// 这里微信的 id 和 secret 从配置文件中获取
let {id, sk} = $.getWechatAppConfig()
const data = {
appid: id,
secret: sk,
js_code: code,
grant_type: 'authorization_code'
}
let url = API_URL + '?' + querystring.stringify(data)
return new Promise((resolve, reject) => {
request.get(url, (error, response, body) => {
if (error || response.statusCode !== 200) {
reject(error)
} else {
try {
const r = JSON.parse(body)
resolve(r)
} catch (e) {
reject(e)
}
}
})
})
}
有了 jscode2session 这个云函数,我们就可以在小程序中调用云函数,将 wx.login 获取的 code 作为参数传递过去:
wx.login({
success: (res) => {
if(res.code){
wx.cloud.callFunctions({
name: 'jscode2session',
data: {
code: res.code
}
}).then(res => {
let {openid = '', session_key = ''} = res.result || {}
console.log(openid, session_key)
wx.setStorage({
key: 'openid',
data: openid
})
})
}
})
关于获取到的 session_key,我们还需要注意以下两点。
session_key和wx.login获取的 code 是一一对应的,同一 code 只能换取一次session_key。每次调用wx.login,都会下发一个新的 code 和对应的session_key,为了保证用户体验和登录态的有效性,开发者需要清楚用户需要重新登录时才去调用wx.login。session_key是有时效性的,即便是不调用wx.login,session_key也会过期,过期时间跟用户使用小程序的频率成正相关,但具体的时间长短开发者和用户都是获取不到的。
由于 session_key 具有实效性,因而我们可以将 session_key 存入本地缓存,每次进入小程序的时候判断下 session_key 是否过期即可:
wx.setStorage({
key: 'session_key',
data: session_key
})
3. 获取用户昵称等信息¶
获取用户信息需要用到 open-type="getUserInfo" 的 button 组件,具体做法是:
<button open-type="getUserInfo" bindgetuserinfo="getUserInfo">使用该功能需要授权登录</button>
上面的代码定义了一个 getUserInfo 类型的按钮,如果授权成功,则调用页面的 getUserInfo 方法(通过 bindgetuserinfo 绑定的)。getUserInfo 代码如下:
getUserInfo(){
wx.getUserInfo({
success: (res) => {
let rs = res.userInfo
this.setData({
nickname: rs.nickName,
avatarUrl: rs.avatarUrl
})
}
})
}
获取到的用户信息,包括以下几部分:
| 参数 | 类型 | 说明 |
|---|---|---|
| userInfo | OBJECT | 用户信息对象,不包含 openid 等敏感信息 |
| rawData | String | 不包括敏感信息的原始数据字符串,用于计算签名 |
| signature | String | 使用 sha1( rawData + sessionkey ) 得到字符串,用于校验用户信息 |
| encryptedData | String | 包括敏感数据在内的完整用户信息的加密数据 |
| iv | String | 加密算法的初始向量 |
除了实战中使用的包含用户昵称和头像的 userInfo 外,还有敏感信息的 encryptedData 字段,如果需要使用该字段,则需要按照加密数据解密算法的文档来解密。
4. 解密敏感数据¶
尽管心情签到功能并没有涉及敏感信息的解密,这里笔者还是简单介绍下如何解密敏感数据。
解密敏感信息需要用到小程序的 AppID 和 session_key,在开发者文档中有提供 Node.js 版本的解密 demo,下面来简单实现个云函数:
const crypto = require('crypto');
/*<jdists import="../../inline/utils.js" />*/
/*<remove>*/
const $ = require('../../inline/utils')
/*</remove>*/
function WXBizDataCrypt(appId, sessionKey) {
this.appId = appId;
this.sessionKey = sessionKey;
}
WXBizDataCrypt.prototype.decryptData = function (encryptedData, iv) {
// base64 decode
const sessionKey = new Buffer(this.sessionKey, 'base64');
encryptedData = new Buffer(encryptedData, 'base64');
iv = new Buffer(iv, 'base64');
let decoded;
try {
// 解密
const decipher = crypto.createDecipheriv('aes-128-cbc', sessionKey, iv);
// 设置自动 padding 为 true,删除填充补位
decipher.setAutoPadding(true);
decoded = decipher.update(encryptedData, 'binary', 'utf8');
decoded += decipher.final('utf8');
decoded = JSON.parse(decoded);
} catch (err) {
throw new Error('Illegal Buffer');
}
if (decoded.watermark.appid !== this.appId) {
throw new Error('Illegal Buffer');
}
return decoded;
};
exports.main = async (event) => {
let {iv, data, session_key} = event
// 这里微信的 id 和 secret 从配置文件中获取
let appId = $.getWechatAppConfig().id
return new Promise((resolve, reject) => {
const pc = new WXBizDataCrypt(appId, session_key)
resolve(pc.decryptData(data, iv))
})
}
5. 检测 session_key 是否失效¶
前面提到 session_key 需要存入本地缓存,但是存在可能失效的情况,小程序提供的 wx.checkSession 方法可以检测当前的 session_key 是否失效,如果失效则重新调用 wx.login 登录授权流程。
wx.checkSession 方法并不需要传入任何有关 session_key 的信息参数,而是小程序自己去调自己的服务来查询用户最近一次生成的 session_key 是否过期。如果当前 session_key 过期,就让用户来重新登录,更新 session_key,并将最新的 session_key 存入用户数据表中。
整个授权流程图和代码¶
用代码来表示如下:
// 或者在 app.js 内使用 onLaunch
onLoad(){
let loginFlag = wx.getStorageSync('session_key');
if (loginFlag) {
// 检查 session_key 是否过期
wx.checkSession({
// session_key 有效(未过期)
success: function() {
// 业务逻辑处理
},
// session_key 过期
fail: function() {
// session_key 过期,重新登录
this.doLogin();
}
});
) else {
// 无 session_key,作为首次登录
this.doLogin();
}
}
使用云开发数据库来存储心情数据¶
可以在小程序中通过 wx.cloud 相关的方法使用云开发的数据库,而且它支持权限设置,很方便存储 UGC(用户原创内容)的数据(在第 3 节介绍过)。在心情签到功能中,就使用了小程序云的数据库,用它来存储用户的签到心情。
在云开发控制台创建数据库¶
首先从小程序开发者工具中的「云开发」进入数据库管理 tab,然后点击「添加集合」,创建一个 diary 的集合(数据库)。
这个集合中的文档数据格式如下图所示。
其中,_id 和 _openid 是系统自动生成的,文档(表)中其他字段的意思解释如下:
- emotion:当天的心情,一共有 5 种心情
- tsModified:签到的时间戳
- openid:根据授权信息获取的 openid,跟
_openid一致
有了数据库的信息,需要编写增加一条心情数据(addEmotion)和获取某个月份所有心情数据(getEmotionByOpenidAndDate)。
增加心情数据¶
// lib/api.js
// 初始化 cloud 环境
wx.cloud.init({
env: 'envID'
})
// 获取数据库实例
const db = wx.cloud.database()
// 用户心情签到
export const addEmotion = (openid, emotion) => {
return db.collection('diary').add({
data: {
openid,
emotion,
tsModified: Date.now()
}
})
}
获取心情数据¶
// lib/api.js
// 初始化 cloud 环境
wx.cloud.init({
env: 'envID'
})
// 获取数据库实例
const db = wx.cloud.database()
// 根据用户 openid 和日期获取心情数据
export const getEmotionByOpenidAndDate = (openid, year, month) => {
const _ = db.command
year = parseInt(year)
month = parseInt(month)
let start = new Date(year, month - 1, 1).getTime()
let end = new Date(year, month, 1).getTime()
// console.log(start, end, `${year}-${nextMonth}-01 00:00:00`,`${year}-${month}-01 00:00:00`)
return db
.collection('diary')
.where({
openid,
tsModified: _.gte(start).and(_.lt(end))
})
.get()
}
小程序云开发的数据库为了提升查询性能,不能够一次查询出来超过20条以上的数据。所以上面的代码最多能够查询出20条数据,当签到数据超过20天(一个月最多31天),这时候就需要做两次查询(根据tsModified 正序,反序各取一次),然后合并数据了,所以最后的代码如下:
export const getEmotionByOpenidAndDate = (openid, year, month) => {
const _ = db.command
year = parseInt(year)
month = parseInt(month)
let start = new Date(year, month - 1, 1).getTime()
let end = new Date(year, month, 1).getTime()
// 这里因为限制 limit 20,所以查询两次,一共31条(最多31天)记录
// 正序反序各取一次,使用 orderBy 排序
return new Promise((resolve, reject) => {
Promise.all([
db
.collection('diary')
.where({
openid,
tsModified: _.gte(start).and(_.lt(end))
})
.orderBy('tsModified', 'desc')
.limit(15)
.get(),
db
.collection('diary')
.where({
openid,
tsModified: _.gte(start).and(_.lt(end))
})
.orderBy('tsModified', 'asc')
.limit(16)
.get()
])
.then((data) => {
let [data1, data2] = data
let set = new Set()
data1 = data1.data || []
data2 = data2.data || []
data = data1.concat(data2).filter((v) => {
if (set.has(v._id)) {
return false
}
set.add(v._id)
return true
})
resolve({data})
})
.catch((e) => {
console.log(e)
reject(e)
})
})
}
心情数据是根据 openid 和月份获取的,日期范围为:月份 1 日的凌晨 0 点(start)到下一月份 1 日的凌晨 0 点(end),在云数据库中可以使用 _.gte(start).and(_.lt(end)),即大于等于 start 小于 end。
这里计算 start 和 end 的时候,遇见了 Date 兼容性的两个问题:
localDateString问题- 时区问题
localDateString 问题¶
笔者一开始使用将日期转化成类似 2018-01-01 00:00:00 的格式,然后使用 new Date('2018-01-01 00:00:00'),可以得到 Date 实例,这在开发者工具和 Android 手机上都没有问题,但是在 iOS 系统下却识别成了 Invalid Date,变成了 1970-01-01。这是因为 iOS 上 localDateString(本地时间)的问题,使用 new Date().toLocaleDateString() 就可以知道,iOS 下识别的数据是 2018/01/01 00:00:00 这样的格式的。
时区问题¶
小程序云开发的云函数和数据库是面向全球开发者的,它们使用的时区并不是我们的东八区(北京时间),因此我们在获取 Date 的时候就要小心,简单拼接 2018-01-01 00:00:00 获取的时间并不是北京时间,数据库存入的数据如果使用北京时间(本地 JS),那么获取数据的时候就应该使用北京时间(云端执行 JS 时)。
为了解决 Date 的问题,笔者在计算时区的时候,都转换成了 UTC 标准时间,比如在云函数中,笔者使用了 new Date().getUTCHours() 这样的时间,详见 server/inline/utils.js。
而在获取特定某一天的 Date 实例的时候,则使用 new Date(year, month, day) 的方式,这样在数据库获取某个月份时间戳时,就不会出现不同系统环境不同数值的问题,详见 client/lib/api.js 的 getEmotionByOpenidAndDate 方法。
使用 navigator 增加跳转¶
心情签到页面做完之后,还需要在天气预报页面给它做跳转。在天气预报页面增加跳转的 WXML 代码如下:
<!--weather/index.wxml-->
<view class="navigator" bindtap="goDiary">
<icon type="edit"/>
</view>
页面绑定了事件 goDiary 代码:
// weather/index.js
Page({
goDiary() {
let url = `/pages/diary/index`
wx.navigateTo({
url
})
}
})
在心情签到页面,顶部导航需要增加返回操作:
<!--diary/index.wxml-->
<view class="navigator">
<icon type="back" bindtap="goBack"/>
</view>
// diary/index.js
Page({
goBack() {
wx.navigateBack()
}
})
心情签到页面整体流程图¶
小结¶
本节介绍了新鲜天气日历使用、用户授权流程和数据库操作。
日历使用需要在小程序管理后台搜索对应的插件 id,然后申请授权。日历的日期背景颜色是跟当时签到心情相对应的,当切换了日历的月份之后,应该重新获取当前月份的签到数据信息。
用户授权流程由小程序、开发者服务器和微信接口服务三方参与,整个流程包括调用 wx.login 授权获取临时登录凭证,使用临时登录凭证获取 openid 和 session_key,以及获取用户信息三个步骤。session_key 可以用于解密敏感数据,但是 session_key 具有时效性,需要调用 wx.checkSession 方法来校验其是否失效。
云开发的数据库每条记录自带 _openid 字段,可以单独来设置数据库权限。笔者在心情签到功能中主动通过授权获得用户 openid 然后增加记录。在进行跟日期、时间戳相关的数据查询时应该注意云环境的时区,最佳实践是使用格林尼治时间,使用 Date 对象的时候也应该注意生产环节和本地环境 localeDateString 的差异。