极客时间对于推广渠道会有返利优惠,比如山月在极客时间买了一门课,再把课程分享给好友购买,这时极客时间会向山月返利20元左右。
而我现在做了一个返利平台,你可以在上边通过山月的链接购买课程,此时极客时间会向我返利。为了共同学习,而你可以添加我的微信 (shanyue94),我将把极客时间给我的返利发一个红包全部返给你

# 黑客增长: 从博客引流到公众号

前几日在朋友圈刷到一篇文章 我是怎么把博客粉丝转到公众号的 (opens new window),觉得相当有创意。最近一年在做 toC 产品,一直在谈拉新留存转化。这刚好可以作为一个黑客增长的成功案例。

鉴于我自己也有一个博客,并且日均UV在200左右,决定来试一试。整理了一下思路,差不多与短信验证码的逻辑相似,于是花了一天时间搞定。

另外,在此之前我也花了一天时间调研了 serverless。所以你完全可以零成本实现从博客到公众号引流的功能

# 需求

先来谈一谈需求点:

  1. 博客的内容被二维码弹框遮挡,弹框上有口令及公众号二维码
  2. 扫码关注二维码并回复口令,回复口令后刷新博客弹框消失

需求很简单,如图下所示。你也可以去我的网站 每天学习一点点 (opens new window) 查看实现效果

博客被锁住时的截图

# 用户体验

不得不说,弹窗实在是一件伤用户体验的事情了。但也有一些措施,能够让用户体验变得稍微优化一点

  1. 简单的口令,如只有四位数字
  2. 非强制的弹窗,浏览内容时只有一定几率会出现弹窗,及时出现弹窗,刷新一下即可跳过

# 实现

简单过一遍技术栈,博客采用了 vuepress,前端的技术栈就是 vue 了。

由于是一个小小的服务,后端则尽可能轻量,于是我选择了 koa。为了方便后端迁移,如我以后将会迁移到 serverless,则使用无状态服务,即不依赖数据存储。如果没有状态,那怎么认证用户呢?使用 jwt

关于部署,则使用 dockerdocker-compose 以及 traefik。至于部署这块,我有一个基础设施很完善的服务器环境,可以参考文章 当我有一台服务器时我做了什么 (opens new window)

这下子实现思路就很清晰了:

  • css 部分使用选择器控制只显示文章的前两个标签,其余隐藏
  • js 部分有两个请求,一个根据口令请求 jwt,一个根据jwt判断是否合法,口令是四位数字随机生成
  • 后端,使用koa做一个简单的服务,使用jwt免掉存储状态,且可以使用四位简短数字,避免冲突
  • 存储,一个 lru,存在内存里,只留三分钟,用来交换 jwt
  • 部署,使用 docker composedocker 由于无状态,很容易迁移到 serverless。且在微信环境下很容易制作测试环境与生产环境
  • 网关 traefik,方便自动服务发现与证书管理

# CSS

在前端控制内容被二维码遮挡的主要实现在于 CSS,而CSS主要控制两点

  1. 弹框
  2. 只显示博客内容前N段

我们博客大部分使用静态生成器,从 markdown 生成,生成的 html 大致长这个样子。

<div class="content">
  <h1></h1>
  <p></p>
  <p></p>
</div>

现在前端的发展趋势是状态即UI,我们使用一个变量 isLock 来控制所有样式。我们在 vue template 中加入弹框。

<div :class="{ lock: isLock }">
  <!-- markdown内容 -->
  <Content />
  <!-- 弹框 -->
  <div class="content-lock">
  </div>
</div>

弹框的显示隐藏容易控制,那 Content 即文章内容的呢,如何控制只显示前N段?

这肯定难不倒曾经三个月的工作只写 CSS 的我,使用 nth-childstylus 代码如下

.theme-default-content.lock
  .content__default
    :nth-child(3)
      opacity .5

    :nth-child(4)
      opacity .2

    :nth-child(n+5)
      display none

  .content-lock
    display block

另外,我们也加了点渐变效果提升观感:前两段内容显示,第三段第四段渐变,五段以后全部隐藏。具体见 css 代码

# 解锁状态

vue 中主要控制状态 isLock,除了真实解锁逻辑还有一个随机性的弹窗。this.lock 代表是否解锁,this.isLock 代表是否显示弹窗

{
  data () {
    return {
      lock: false,
      code: ''
    }
  },
  computed: {
    isLock () {
      return this.lock ? Math.random() > 0.5 : false
    }
  },
}

# 口令

口令需要是持久化的:保证每次刷新页面口令都是一致的。因此口令存储于 localStorage 中,随机生成四位数字,代码如下

function getCode () {
  if (localStorage.code) {
    return localStorage.code
  }
  const code = Math.random().toString().slice(2, 6)
  localStorage.code = code
  return code
}

# 微信开发

接下来的逻辑是

  1. 在微信中把口令传给后端
  2. 前端刷新解锁

先看在微信这边的逻辑。我对微信开发封装成了简单的路由形式,核心逻辑如下:当接收到数字码时存储到 cache 中,这里使用了一个简单的内存 lru,只存储口令三分钟

cache 中存储了 code: userOpenId 键值对

function handleCode (message) {
  const { FromUserName: from, Content: code } = message
  // 对于 code,存储三分钟
  cache.set(code, from, 3 * 60 * 1000)
  return '您好,在三分钟内刷新网站即可无限制浏览所有文章'
}

const routes = [{
  default: true,
  handle: handleDefault
}, {
  text: /\d{4}/,
  handle: handleCode
}]

# 用户认证

此时的用户认证就很简单了,传统形式的基于 session 的用户认证。后端采用 koa 开发,代码如下,此时还使用了 JOI 做了简单的输入检验

exports.verifyCode = async function (ctx) {
  const { code } = Joi.attempt(ctx.request.body, Joi.object({
    code: Joi.string().pattern(/\d{4}/)
  }))
  if (!cache.get(code)) {
    ctx.body = ''
    return
  }
  const from = cache.get(code)
  ctx.body = jwt.sign({ from }, secret, { expiresIn: '3y' })
}

# 口令过短会不会造成冲突

不会,由于在服务端用户口令只在内存中存在三分钟,所以冲突的可能性很小。那三分钟之后,如何进行用户状态的持久化呢?

# 用户状态持久化

但是此时有一个问题,cache 只能存储维护三分钟数据状态。这个问题如何解决?

此时使用 JWT 来做用户认证。因此校验口令时返回生成的 jwt,在浏览器端持久化,使用它来保持用户状态。关于 JWT,可以看我以前的文章 JWT 深入浅出 (opens new window)

exports.verifyCode = async function (ctx) {
  // ...
  ctx.body = jwt.sign({ from }, secret, { expiresIn: '3y' })
}

持久化用户认证逻辑前端部分

async function verifyToken (token) {
  const { data: { data: verify } } = await request.post('/api/verifyToken', {
    token
  })
  return verify
}

async mounted () {
  const code = getCode()
  this.code = code
  if (!localStorage.token) {
    this.lock = true
    const token = await verifyCode(code)
    if (token) {
      localStorage.token = token
      this.lock = false
    }
  } else {
    const token = localStorage.token
    const verify = await verifyToken(token)
    if (!verify) {
      this.lock = true
    }
  }
}

持久化用户认证逻辑后端部分

exports.verifyToken = async function (ctx) {
  const { token } = Joi.attempt(ctx.request.body, Joi.object({
    token: Joi.string().required()
  }))
  ctx.body = jwt.verify(token, secret)
}

# 如何保证用户取消订阅后再次弹框

当后端使用 jwt 用户认证时,服务器端收到 openId,根据 openId 获取用户信息,如果没有获取到,说明用户取消了订阅。

但是获取用户信息此项权限个人公众号未曾拥有

# 部署

开发完成之后使用 dockerdocker-compose 部署,traefik 做服务发现,通过 https://we.shanyue.tech 暴露服务,这三者在本系列文章中有所介绍

Dockerfile 较为简单,配置文件如下

FROM node:10-alpine

WORKDIR /code

ADD package.json /code
RUN npm install --production

ADD . /code

CMD npm start

docker-compose.yaml 配置文件如下

version: '3'

services:
  wechat:
    build: .
    restart: always
    labels:
      - traefik.http.routers.wechat.rule=Host(`we.shanyue.tech`)
      - traefik.http.routers.wechat.tls=true
      - traefik.http.routers.wechat.tls.certresolver=le
    expose:
      - 3000

networks:
  default:
    external:
      name: traefik_default

# 测试环境与生产环境

当我们需要测试微信公众号时,直接使用自己的公众号不太合适,特别是当已有上线内容时。微信官方提供了测试公众号,我们可以重新填写 域名 以及 token。在测试环境使用域名 https://we.dev.shanyue.tech

我们在 docker-compose 中使用 service 中的 wechat 代表生产环境,wechat-dev 代表测试环境

wechat-dev 通过文件挂载提供服务,可以更新重启应用,便可以做到实时更新代码,并实时在测试公众号中看到效果。

docker-compose.yaml 配置文件如下

version: '3'

services:
  wechat:
    build: .
    restart: always
    labels:
      - traefik.http.routers.wechat.rule=Host(`we.shanyue.tech`)
      - traefik.http.routers.wechat.tls=true
      - traefik.http.routers.wechat.tls.certresolver=le
    expose:
      - 3000

  wechat-dev:
    image: 'node:10-alpine'
    restart: always
    volumes:
      - .:/code
    working_dir: /code
    command: npm run dev
    labels:
      - traefik.http.routers.wechat-dev.rule=Host(`we.dev.shanyue.tech`)
      - traefik.http.routers.wechat-dev.tls=true
      - traefik.http.routers.wechat-dev.tls.certresolver=le
    expose:
      - 3000

networks:
  default:
    external:
      name: traefik_default

# 代码开源

关于前端代码,皆在 shfshanyue/Daily-Question (opens new window)

关于后端代码,皆在 shfshanyue/wechat (opens new window)

而本篇文章,属于 个人服务器运维指南 (opens new window) 的案例篇,关于 dockercomposetraefik 等基础设施的搭建均在本系列中有所介绍

# 获取一个永久 token

除了通过扫码回复公众号口令获得文章解锁外,这里也有一个永久 token 可以解锁。复制以下代码到控制台刷新即可解锁全部文章

localStorage.token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1Nzc2MjI1MjEsImV4cCI6MTY3MjI5NTMyMX0.tB-CgK6aIo3whD-mAu3X37XT8q9v2bVXxG6llodznws'

关于山月

我的项目:
我的微信:shanyue94,欢迎交流
Last Updated: 7/21/2019, 11:25:08 AM