目录

9实战篇6心情签到页面开发

本资源由 itjc8.com 收集整理

实战篇 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-colordaysStyle 进行绑定:

<!--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 接口获取 openidsession_key

wx.login({
  success: () => {
    if (res.code) {
        // example: 081LXytJ1xxxxcdfxxx1FWxdfdsfXyth
        // 将 code 发送给开发者服务器
    }
  }
})

2. 获取 openidsession_key

微信内,同一用户在任意小程序、公众号或者服务号中,都会有一个不同的唯一标识 openid,所以可以认为,我们在应用中获取的用户 openid 是唯一的,并且该用户在另外一个应用中的 openid 跟其他应用的是不同的。

session_key 是微信服务派发给我们的一个用户登录有效性的凭证,通过它我们可以间接维护用户微信的登录态。

获取 openidsession_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

其中 appidsecret 可以在小程序管理后台找到,具体路径为「设置 -> 开发设置 -> 开发者 ID」。appid 直接可见,而 secret 需要点击「生成」链接,并用开发者账号的微信扫码才能生成,生成之后需自行保存。

secret 是授权中保证安全性的一个重要 ID,不能外泄,因此必须放在开发者自己的服务器上使用,不能直接放到前端页面调用微信服务接口,因为如果这样的话 secret 就暴露了,这也是整个授权过程需要小程序、开发者服务器、微信服务三方都介入的原因。如果忘记或泄露了 secret,需要在微信后台重置。

https://user-gold-cdn.xitu.io/2018/8/13/1653149778229d90?w=1298&h=447&f=png&s=39517

js_code 就是第一步中我们通过 wx.login 获取到的临时授权凭证 code。有了appidsecretjs_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,我们还需要注意以下两点。

  1. session_keywx.login 获取的 code 是一一对应的,同一 code 只能换取一次 session_key。每次调用 wx.login,都会下发一个新的 code 和对应的 session_key,为了保证用户体验和登录态的有效性,开发者需要清楚用户需要重新登录时才去调用 wx.login
  2. session_key 是有时效性的,即便是不调用 wx.loginsession_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 存入用户数据表中。

整个授权流程图和代码

https://user-gold-cdn.xitu.io/2018/8/13/1653149c0d99db7d?w=1024&h=768&f=jpeg&s=116659

用代码来表示如下:

// 或者在 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 的集合(数据库)。

这个集合中的文档数据格式如下图所示。

https://user-gold-cdn.xitu.io/2018/9/1/165938bd3dbe91ab?w=311&h=143&f=jpeg&s=13693

其中,_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

这里计算 startend 的时候,遇见了 Date 兼容性的两个问题:

  1. localDateString 问题
  2. 时区问题

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()
  }
})

心情签到页面整体流程图

https://user-gold-cdn.xitu.io/2018/8/13/16531487782ac35b?w=1024&h=768&f=jpeg&s=210592

小结

本节介绍了新鲜天气日历使用、用户授权流程和数据库操作。

日历使用需要在小程序管理后台搜索对应的插件 id,然后申请授权。日历的日期背景颜色是跟当时签到心情相对应的,当切换了日历的月份之后,应该重新获取当前月份的签到数据信息。

用户授权流程由小程序、开发者服务器和微信接口服务三方参与,整个流程包括调用 wx.login 授权获取临时登录凭证,使用临时登录凭证获取 openidsession_key,以及获取用户信息三个步骤。session_key 可以用于解密敏感数据,但是 session_key 具有时效性,需要调用 wx.checkSession 方法来校验其是否失效。

云开发的数据库每条记录自带 _openid 字段,可以单独来设置数据库权限。笔者在心情签到功能中主动通过授权获得用户 openid 然后增加记录。在进行跟日期、时间戳相关的数据查询时应该注意云环境的时区,最佳实践是使用格林尼治时间,使用 Date 对象的时候也应该注意生产环节和本地环境 localeDateString 的差异。