快速實(shí)現(xiàn)微信掃碼關(guān)注公眾號(hào)/用戶注冊(cè)并登陸

??上周二開(kāi)晨會(huì)分配任務(wù)的時(shí)候悠瞬,分配到了一個(gè)微信掃碼關(guān)注公眾號(hào)的需求。剛開(kāi)始以為只要截個(gè)公眾號(hào)二維碼的圖涯捻,然后按照UI出的設(shè)計(jì)稿把二維碼放到指定位置浅妆,再加上一波加邊框加陰影的操作提交就完事了。所以當(dāng)部門(mén)大佬問(wèn)多久能做完的時(shí)候障癌,我毫不猶豫地說(shuō):小Case啦凌外,兩天妥妥的!

??回到座位上本想著時(shí)間還早涛浙,先刷會(huì)微博康辑。刷著刷著看到廣州東站的宜家要搬遷的消息,活動(dòng)大減價(jià)全場(chǎng)5折起=瘟痢疮薇!忍不住點(diǎn)進(jìn)去宜家的網(wǎng)上商城,看了兩圈好想剁手買(mǎi)買(mǎi)買(mǎi)我注。這時(shí)按咒,部門(mén)大佬站了起來(lái),好像正往我這邊看但骨。算了算了励七,先關(guān)注宜家公眾號(hào)干活去吧,拿出手機(jī)準(zhǔn)備掃碼的時(shí)候整個(gè)人都懵了...

??誒嗽冒?誒誒呀伙?!早上的需求好像是要實(shí)現(xiàn)掃碼關(guān)注公眾號(hào)并登陸的添坊,但瀏覽器怎么會(huì)知道我掃碼了,而且掃的還是登陸用的二維碼...這怎么跟想象的不一樣箫锤,我還打包票說(shuō)兩天內(nèi)做完贬蛙,真的完了完了雨女。

??當(dāng)然,兩天后這掃碼的功能還是經(jīng)過(guò)測(cè)試按時(shí)提交阳准,并在線上穩(wěn)定運(yùn)行了一周氛堕。如何才能快速實(shí)現(xiàn),并盡量少踩坑野蝇,我想讼稚,有些思路和和代碼寫(xiě)下來(lái),以后可能會(huì)用得著绕沈。

??項(xiàng)目主要以Nodejs進(jìn)行開(kāi)發(fā)锐想,優(yōu)先選用Koa2 + ioredis等一些比較輕量級(jí)的模塊實(shí)現(xiàn),配合Alpine Linux制作Docker Image乍狐,最后得到的一個(gè)開(kāi)箱即用的Docker鏡像也僅僅只有33M赠摇。

??瀏覽項(xiàng)目的完整代碼可以點(diǎn)擊這里github,如果對(duì)你有幫助歡迎Star浅蚪。

先整理一下需求:

  • 登錄入口實(shí)現(xiàn)掃描二維碼關(guān)注公眾號(hào)并登錄網(wǎng)站藕帜。已關(guān)注的直接跳轉(zhuǎn)登陸,未關(guān)注的等待用戶關(guān)注再跳轉(zhuǎn)惜傲;
  • 新的公眾平臺(tái)掃碼登錄機(jī)制代替原有的微信開(kāi)放平臺(tái)的掃碼登錄洽故;
  • 掃碼關(guān)注后需要根據(jù)情況返回不同的提示(歡迎)信息。

??但目前已上線的網(wǎng)站同時(shí)提供微信掃碼和手機(jī)/郵箱注冊(cè)登錄盗誊,新需求實(shí)際是想讓更多的用戶關(guān)注公眾號(hào)收津。完全按照需求上做的話就會(huì)變成強(qiáng)制用戶必須關(guān)注公眾號(hào),否則無(wú)法完成登錄浊伙∽睬铮考慮到市場(chǎng)上有不少同類(lèi)型產(chǎn)品,這種強(qiáng)制的行為可能會(huì)導(dǎo)致用戶反感嚣鄙,從而選擇其他產(chǎn)品吻贿。

經(jīng)討論需求改為:

  • 保留現(xiàn)有的微信掃碼和手機(jī)/郵箱注冊(cè)登錄,完成注冊(cè)/登錄流程后哑子,新增一個(gè)掃碼關(guān)注公眾號(hào)的頁(yè)面舅列;
  • 用戶掃碼關(guān)注,關(guān)注后利用unionid機(jī)制綁定賬戶卧蜓,讓手機(jī)/郵箱注冊(cè)的用戶以后可以直接微信掃碼登錄帐要;
  • 點(diǎn)擊關(guān)注后,網(wǎng)站自動(dòng)跳轉(zhuǎn)進(jìn)入控制臺(tái)弥奸,或點(diǎn)擊暫不關(guān)注直接跳轉(zhuǎn)榨惠;
  • 掃碼關(guān)注后需要根據(jù)情況返回不同的提示(歡迎)信息。

實(shí)現(xiàn)思路和步驟:

  1. 實(shí)現(xiàn)一個(gè)與微信公眾號(hào)平臺(tái)交互的API,接收并處理公眾號(hào)推送的事件(關(guān)注赠橙、掃碼和文字消息等)耽装;
  2. 實(shí)現(xiàn)一個(gè)生成二維碼的API供瀏覽器調(diào)用,API可通過(guò)參數(shù)聲明需要返回的格式期揪;
  3. 請(qǐng)求公眾平臺(tái) →【生成帶參數(shù)的二維碼】接口生成帶有場(chǎng)景值的二維碼掉奄,生成成功后記錄到數(shù)據(jù)庫(kù)并返回;
  4. 瀏覽器獲取二維碼信息后輪詢(xún)二維碼的掃描狀態(tài)凤薛,掃描成功后自動(dòng)跳轉(zhuǎn)姓建;
  5. 用戶掃碼后,公眾平臺(tái)會(huì)向1實(shí)現(xiàn)的API推送事件缤苫,如果是關(guān)注就獲取用戶信息速兔,然后記錄到數(shù)據(jù)庫(kù)。

第一步榨馁,搭建Koa的環(huán)境并接入微信公眾平臺(tái)

??提供的源碼里包含刪減過(guò)的 Koa2和 koa-router的代碼憨栽,也可以使用原版的代碼。建議使用Nodejs10以上版本翼虫,特別是Nodejs12屑柔,換了新的HTTP解析器(llhttp)性能直接提高了一倍。

安裝依賴(lài)

package.json

"dependencies": {
    "debug": "^4.1.1",
    "got": "^9.6.0",
    "ioredis": "^4.10.0",
    "mime-types": "^2.1.24",
    "negotiator": "^0.6.2",
    "xml2js": "^0.4.19",
    "ylru": "^1.2.1"
 }

如果是直接用官方的 Koa珍剑,mime-types掸宛,negotiator,ylru都不用安裝

目錄結(jié)構(gòu)

dir.png

Koa APP的代碼結(jié)構(gòu)跟官方的栗子差不多招拙,就直接看吧
app.js

const http = require('http')
const Koa = require('./vendor/koa2/application')
const XMLParser = require('./middlewares/XMLParser')
const router = require('./routes/wechat')
const app = new Koa()
?
app.use(XMLParser) // 解析xml的中間件唧瘾,用于預(yù)處理微信公眾號(hào)推送的事件
?
app.use(router.routes())
app.use(router.allowedMethods())
?
http.createServer(app.callback()).listen(3000)

middlewares/XMLParser.js

const parseXML = require('xml2js').parseString
const debug = require('debug')('xml-parse')
?
const parse = (req, options = {}) => {
    return new Promise((resolve, reject) => {
        let xml = ''
        req.on('data', chunk => { xml += chunk.toString('utf-8') })
           .on('error', reject)
           .on('end', () => parseXML(xml, options, (err, res) => {
                if (err) reject(err)
                resolve(res)
        }))
    })
}
?
module.exports = async (ctx, next) => {
    // 這里先嘗試直接匹配,匹配失敗再到mime庫(kù)里查詢(xún)
    if (ctx.request.type === 'text/xml' || ctx.is('xml')) {
        try {
            ctx.request.body = await parse(ctx.req)
        } catch (e) {
            debug(e.message)
        }
    }
    await next()
}

routes/wechat.js

const Router = require('../vendor/koa-router')
const wechatController = require('../controllers/wechat')
?
?
const router = new Router({
    prefix: '/wechat'
})
?
// 測(cè)試號(hào)配置接口信息時(shí)需要校驗(yàn)别凤,但傳輸?shù)臄?shù)據(jù)跟推送消息一樣饰序,所以放在同一個(gè)controller里處理
// conntroller的完整path是/wechat/event,這個(gè)后面配置測(cè)試號(hào)URL的時(shí)候會(huì)用到
router.get('/', ctx => ctx.body = 'hello wechat')
      .get('/event', wechatController)
      .post('/event', wechatController)
?
module.exports = router

先新建一個(gè)配置文件规哪,與app.js同目錄
config.js
WXMP的信息暫時(shí)留空求豫,到配置微信公眾平臺(tái)的時(shí)候再填寫(xiě)

const CACHE = {
    host: 'localhost',
    port: 6379
}
?
// WeChat Media Platform
const WXMP = {
    appID: '',
    appSecret: '',
    token: ''
}
?
module.exports = {
    CACHE,
    WXMP
}

/controllers/wechat.js

const { WXMP } = require('../config');
const { SHA1 } = require('../utils/mUtils')
?
module.exports = async (ctx, next) => {
    const token = WXMP.token
    const { signature, nonce, timestamp, echostr } = ctx.query
?
    /**
    * https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421135319
    * 1)將token、timestamp诉稍、nonce三個(gè)參數(shù)進(jìn)行字典序排序
    * 2)將三個(gè)參數(shù)字符串拼接成一個(gè)字符串進(jìn)行sha1加密
    * 3)開(kāi)發(fā)者獲得加密后的字符串可與signature對(duì)比蝠嘉,標(biāo)識(shí)該請(qǐng)求來(lái)源于微信
    */
    const str = [token, timestamp, nonce].sort().join('')
?
    const signVerified = SHA1(str) === signature
?
    if (!signVerified) {
        ctx.status = 404 // 可以不設(shè)為404,koa默認(rèn)的狀態(tài)值就是404
        return
    }
?
    if (ctx.method === 'GET') ctx.body = echostr
    else if (ctx.method === 'POST') {
        // 實(shí)現(xiàn)思路里的第一步
        // 推送的消息會(huì)以POST的方式進(jìn)到這里杯巨,暫時(shí)用不著蚤告,先放著
    }
}

??來(lái)到這里,先測(cè)試一下Web服務(wù)是否能正常跑起來(lái)服爷。這里使用Postman直接發(fā)請(qǐng)求杜恰,也可以用瀏覽器訪問(wèn)http://localhost:3000/wechat/获诈。

hello_wechat.png

接下來(lái)配置一下微信公眾平臺(tái)。

??線上的環(huán)境經(jīng)不起折騰箫章,還是用公眾號(hào)測(cè)試號(hào)進(jìn)行調(diào)試吧烙荷,如果你是剛開(kāi)始接觸微信公眾號(hào)開(kāi)發(fā)镜会,推薦使用測(cè)試號(hào)檬寂。

掃碼登陸后會(huì)看到這樣一個(gè)界面:

mp-sandbox.png

??appID和appsecret是系統(tǒng)生成的,只需要填寫(xiě)URL和Token戳表。把a(bǔ)ppID和appsecret貼到之前創(chuàng)建的config.js中桶至,token自己隨便輸入,保證兩個(gè)token一致即可匾旭。配置URL之前镣屹,我們需要一個(gè)域名和一個(gè)內(nèi)網(wǎng)穿透的環(huán)境。舊版本的微信web開(kāi)發(fā)工具提供一個(gè)類(lèi)似的方式价涝,讓微信服務(wù)器可以向我們?cè)趦?nèi)網(wǎng)的機(jī)器推送消息女蜈,新版沒(méi)有這功能我們就自己百度一個(gè)吧。

??粗略比較了一下色瘩,發(fā)現(xiàn)續(xù)斷內(nèi)網(wǎng)穿透很大方伪窖,只要9.9就有兩條8M永久使用的隧道(只比KFC會(huì)員價(jià)的原味雞貴4毛,尊貴的VIP吃雞怎么還這么貴(╯‵□′)╯︵┻━┻)居兆。注冊(cè)交了9.9入會(huì)費(fèi)覆山,裝上客戶端后進(jìn)行簡(jiǎn)單配置就可以了,體驗(yàn)挺棒的泥栖。設(shè)置過(guò)程中發(fā)現(xiàn)他家支持好多系統(tǒng)簇宽,像群暉、OpenWRT那些都有吧享,還有樹(shù)莓派魏割,看來(lái)吃塵多年的B+可以拿出來(lái)發(fā)揮余熱了。好像還有一些有趣的功能钢颂,可惜體驗(yàn)隧道不支持钞它。不過(guò)只要9.9,還要啥自行車(chē)呢甸陌,果斷開(kāi)干吧滓走!

xd.png

??點(diǎn)擊保存稍等一會(huì)會(huì)得到一個(gè)外網(wǎng)訪問(wèn)地址,類(lèi)似http://sd8xxxxxxxxs.gzhttp.cn

tunnel.png

??接著把外網(wǎng)訪問(wèn)地址+之前定義的path(http://sd8xxxxxxxxs.gzhttp.cn/wechat/event)填寫(xiě)到測(cè)試號(hào)接口配置的URL中译株,然后點(diǎn)擊提交吉执。這時(shí)我們已經(jīng)成功接入到微信公眾平臺(tái)了。

第二步牲尺,實(shí)現(xiàn)一個(gè)生成二維碼的API并完善與微信公眾號(hào)平臺(tái)交互的API

??開(kāi)始第二步之前卵酪,先來(lái)了解一下創(chuàng)建二維碼及用戶掃碼后公眾號(hào)給web服務(wù)推送消息的流程:

sequence.png

??首先在utils/wechat/文件夾中新建一個(gè)helper.js幌蚊,負(fù)責(zé)提供公眾號(hào)配置(用于下面創(chuàng)建wechat對(duì)象)和get/set access_token的兩個(gè)方法。

utils/wechat/helper.js

const { WXMP } = require('../../config')
const { redis } = require('../dbHelper')
?
const config = {
    MP: {
        appID: WXMP.appID,
        appSecret: WXMP.appSecret,
        token: WXMP.token,
        getAccessToken: async () => {
            let token = await redis.get('access_token')
            return token
        },
        saveAccessToken: async (data = {}) => {
            await redis.set('access_token', data.access_token ,'EX', data.expires_in)
        }
    }
}
?
module.exports = {
    ...config
}

??在utils/wechat/文件夾中新建一個(gè)wxmp.js溃卡,定義一個(gè)Wechat類(lèi)

...
const api = {
    accessToken: 'token?grant_type=client_credential',
    user: {
        info: 'user/info?',
    },
    QRCodeTicket: 'qrcode/create?',
    QRCode: 'showqrcode?'
}
?
class Wechat {
    constructor (opts) {
        // 這里的opts傳入的是上面定義的config
        ...
        this.fetchAccessToken(true)
    }
    // 獲取access_token
    async fetchAccessToken (init = false) {
        let token = await this.getAccessToken()
?
        if (!token) {
            token = await this.updateAccessToken()
            await this.saveAccessToken(token)
            token = token.access_token
        }
        return token
    }
?
    async updateAccessToken () {
        const url = api.accessToken + '&appid=' + this.appID + '&secret=' + this.appSecret
        return await got(url)
    }
?
    // 提供一個(gè)統(tǒng)一操作的入口溢豆,第一個(gè)參數(shù)傳入操作函數(shù)名就可以拿到對(duì)應(yīng)的配置
    async handle (operation, ...args) {
        const token = await this.fetchAccessToken()
        if (!token) return null
?
        const options = this[operation](token, ...args)
        let res = await wxGot(options)

        return res
    }
?
    // 獲取用戶信息
    getUserInfo (token, openID, lang) {
        const url = `${api.user.info}access_token=${token}&openid=${openID}&lang=${lang || 'zh_CN'}`
?
        return { url: url }
    }
?
    // 申請(qǐng)二維碼Ticket
    getQRCodeTicket (token, sceneStr, timeout) {
        return {
            url: `${api.QRCodeTicket}access_token=${token}`,
            method: 'post',
            body: {
                "expire_seconds": timeout || 60,
                "action_name": "QR_STR_SCENE", // 臨時(shí)二維碼
                "action_info": {
                "scene": {
                    "scene_str": sceneStr
                }
            }
        }
    }
}
?
module.exports = Wechat

??我們繼續(xù)回到剛才的/routes/wechat.js,增加 “+” 標(biāo)識(shí)的代碼(新增獲取二維碼的路由)

const Router = require('../vendor/koa-router')
const wechatController = require('../controllers/wechat')
+ const { createQRCodeMB } = require('../controllers/wechat')
?
const router = new Router({
    prefix: '/wechat'
})
?
// 測(cè)試號(hào)配置接口信息時(shí)需要校驗(yàn)瘸羡,但傳輸?shù)臄?shù)據(jù)跟推送消息一樣漩仙,所以放在同一個(gè)controller里處理
// conntroller的完整path是/wechat/event,這個(gè)后面配置測(cè)試號(hào)URL的時(shí)候會(huì)用到
router.get('/', ctx => ctx.body = 'hello wechat')
      .get('/event', wechatController)
      .post('/event', wechatController)
+     .get('/qrcode', createQRCodeMB)
?
module.exports = router

然后打開(kāi)/controllers/wechat.js犹赖,公眾號(hào)推送事件類(lèi)型可以參考這里

const { WXMP } = require('../config');
const { SHA1, fmtNormalXML, streamToBuffer, createTimestamp } = require('../utils/mUtils')
const { redis } = require('../utils/dbHelper')
const Wechat = require('../utils/wechat/wxmp')
const MPConfig = require('../utils/wechat/helper').MP
const got = require('got')
const qr = require('../vendor/qr')
const fs = require('fs')
const pathResolve = require('path').resolve
?
const MP = new Wechat(MPConfig)
?
module.exports = async (ctx, next) => {
    ...
    if (ctx.method === 'GET') ctx.body = echostr
    else if (ctx.method === 'POST') {
        // 把數(shù)組形態(tài)的xmlObject轉(zhuǎn)換可讀性更高的結(jié)構(gòu)
        const message = fmtNormalXML(ctx.request.body.xml)
?        const msgType = message.MsgType
        const msgEvent = message.Event
        const userID = message.FromUserName
        let eventKey = message.EventKey
        let body = null
?
        if (msgType === 'event') {
            switch (msgEvent) {
                // 關(guān)注&取關(guān)
                case 'subscribe':
                case 'unsubscribe':
                    body = await subscribe(message)
                    break
                // 關(guān)注后掃碼
                case 'SCAN':
                    body = '掃碼成功'
                    break
            }

            if (!!eventKey) {
                // 有場(chǎng)景值(掃了我們生成的二維碼)
                let user = await MP.handle('getUserInfo', userID)
                let userInfo = `${user.nickname}(${user.sex ? '男' : '女'}, ${user.province}${user.city})`
                if (eventKey.slice(0, 8) === 'qrscene_') {
                    // 掃碼并關(guān)注
                    // 關(guān)注就創(chuàng)建帳號(hào)的話可以在這里把用戶信息寫(xiě)入數(shù)據(jù)庫(kù)完成用戶注冊(cè)
                    eventKey = eventKey.slice(8)
                    console.log(userInfo + '掃碼并關(guān)注了公眾號(hào)')
                } else {
                    // 已關(guān)注
                    console.log(userInfo + '掃碼進(jìn)入了公眾號(hào)')
                }
?
                // 更新掃碼記錄队他,供瀏覽器掃碼狀態(tài)輪詢(xún)
                await redis.pipeline()
                           .hset(eventKey, 'unionID', user.unionid || '') // 僅unionid機(jī)制下有效
                           .hset(eventKey, 'openID', user.openid)
                           .exec()
            }
        }
    }
}
?
async function subscribe (message) {
    let userID = message.FromUserName
    if (message.Event === 'subscribe') {
        return '感謝您的關(guān)注'
    } else {
        // 用戶取消關(guān)注后我們不能再通過(guò)微信的接口拿到用戶信息,
        // 如果要記錄用戶信息峻村,需要從我們自己的用戶記錄里獲取該信息麸折。
        // 所以建議創(chuàng)建用戶時(shí)除了unionid,最好把openid也保存起來(lái)粘昨。
        console.log(userID + '取關(guān)了')
    }
}
?
const templetData = fs.readFileSync(pathResolve(__dirname, '../vendor/qrcode-templet.html'))
?
// 創(chuàng)建二維碼
async function createQRCodeMB (ctx, next) {
    let userID = ctx.query.userID
    let type = +ctx.query.type
    let errno = 0
    let responseDate = {}
    let id = createTimestamp()
?
    let res = await MP.handle('getQRCodeTicket', id)
?
    if (res === null) errno = 1
    else {
        responseDate = {
            expiresIn: res.expire_seconds,
            id
        }
?
        let imgBuffer = await streamToBuffer(qr.image(res.url))
        let imgSrc = imgBuffer.toString('base64')
?
        if (type === 1) {
            // 返回圖片
            ctx.body = `<img src="data:image/png;base64,${imgSrc}" />`
        } else if (type === 2) {
            // 返回一個(gè)自帶查詢(xún)狀態(tài)和跳轉(zhuǎn)的網(wǎng)頁(yè)
            let templetValue = `
                <script>var imgSrc='${imgSrc}',id='${responseDate.id}',
                timeout=${responseDate.expiresIn},width=100,height=100</script>`
?
            ctx.body = templetValue + templetData.toString('utf-8')
        } else {
            // 返回圖片內(nèi)容
            responseDate.imgSrc = imgSrc
        }
     }
?
    if (!ctx.body) {
        ctx.body = {
            errno,
            ...responseDate
        }
    }
}
?
module.exports.createQRCodeMB = createQRCodeMB

??到這里應(yīng)該是可以接收到公眾號(hào)推送的掃碼事件和生成二維碼垢啼。

??保存后我們先測(cè)試一下,首先不帶參數(shù)訪問(wèn)http://localhost:3000/wechat/qrcode

normal.png

??接著嘗試獲取二維碼圖片(使用參數(shù)type=1)并使用微信掃描二維碼:

scan.png

??首次掃描二維碼會(huì)提示關(guān)注张肾,點(diǎn)擊關(guān)注后數(shù)據(jù)庫(kù)就會(huì)更新芭析,控制臺(tái)也會(huì)打印出類(lèi)似 “XXX掃碼并關(guān)注了公眾號(hào)“ 的日志。但這時(shí)候公眾號(hào)里應(yīng)該會(huì)提示 ”該公眾號(hào)提供的服務(wù)出現(xiàn)故障捌浩,請(qǐng)稍后再試“ 的提示放刨,因?yàn)槌绦虿](méi)有把提示信息正確得返回。下一步我們需要格式化返回的信息(即ctx.body的內(nèi)容)尸饺。

新增一個(gè)生成模板的文件/utils/tmpl.js
格式化給公眾號(hào)返回的消息进统,這里只簡(jiǎn)單使用util.format來(lái)格式化消息。

const util = require('util')
?
const msgTemplet = `
<xml>
    <ToUserName><![CDATA[%s]]></ToUserName>
    <FromUserName><![CDATA[%s]]></FromUserName>
    <CreateTime>%d</CreateTime>
    <MsgType><![CDATA[%s]]></MsgType>
    $msgBody$
</xml>
`
?
const textMsg = `<Content><![CDATA[%s]]></Content>`
const imageMsg = `<Image><MediaId><![CDATA[%s]]></MediaId></Image>`
?
module.exports = (ctx, originMsg) => {
    let type = (ctx && ctx.type) || 'text'
    let msgTmpl = util.format(msgTemplet,
        originMsg.FromUserName,
        originMsg.ToUserName,
        Math.floor(new Date().getTime() / 1000),
        type
    )
?
    let body = ''
?
    switch (type) {
        case 'text':
            body = util.format(textMsg, ctx)
            break
    case 'image':
        break
    default:
        body = util.format(textMsg, '操作無(wú)效')
    }
?
    return msgTmpl.replace(/\$msgBody\$/, body)
}

接著我們?cè)赾ontrollers/wechat.js增加一下 ”+“ 標(biāo)記的代碼

const { WXMP } = require('../config');
const { SHA1, fmtNormalXML, streamToBuffer, createTimestamp } = require('../utils/mUtils')
+ const { tmpl } = require('../utils/wechat')
const { redis } = require('../utils/dbHelper')
const Wechat = require('../utils/wechat/wxmp')
...
module.exports = async (ctx, next) => {
    const token = WXMP.token
    const { signature, nonce, timestamp, echostr } = ctx.query
?
    const str = [token, timestamp, nonce].sort().join('')
    ...
                // 更新掃碼記錄浪听,供瀏覽器掃碼狀態(tài)輪詢(xún)
                await redis.pipeline()
                           .hset(eventKey, 'unionID', user.unionid || '') // 僅unionid機(jī)制下有效
                           .hset(eventKey, 'openID', user.openid)
                           .exec()
            }
        }
?
+       ctx.type = 'application/xml'
+       ctx.body = tmpl(body || ctx.body, message)
    }
}
?
async function subscribe (message) {
    let userID = message.FromUserName
    if (message.Event === 'subscribe') {
        return '感謝您的關(guān)注'
    } else {
        console.log(userID + '取關(guān)了')
    }
}

??保存后再獲取一次二維碼并掃描螟碎,微信上就能正確顯示提示信息了:

message.png

第三步,瀏覽器增加掃碼狀態(tài)輪詢(xún)

??這塊跟業(yè)務(wù)代碼關(guān)系比較密切迹栓,所以不做詳細(xì)介紹掉分。共通點(diǎn)就是通過(guò)二維碼返回id獲取unionid(openid)的記錄,然后按需處理克伊,最后以cookies或其他方式更新登錄狀態(tài)酥郭。

輪詢(xún)的代碼可以參考vendor/qrcode-templet.html

async function waitToSubscribe(id, timeout) {
    let countdown = Math.ceil(timeout / 3);
    return new Promise((resolve, reject) => {
        const loop = async function() {
            let res = await ky.default.get("/wechat/check", {
                searchParams: { id }
            }).json();

            if (!res) return;
            if (res.errno === 0) resolve("subscribe");
            else if (res.errno === 2) reject("timeout");
            else if (countdown-- > 0) self.QRCodeTimer = setTimeout(loop, 3000);
        };
        loop();
    });
};

(async () => {
    try {
        await waitToSubscribe(id, timeout);
        window.location.href = "/wechat/";
    } catch (e) {
        history.go(0);
    }
})();

我們可以嘗試獲取集成好獲取狀態(tài)的二維碼網(wǎng)頁(yè)(使用參數(shù)type=2,實(shí)際使用時(shí)可以用iframe嵌套):

auto.png

總結(jié):

??到這里愿吹,我們已經(jīng)實(shí)現(xiàn)了:

1.  與微信公眾號(hào)平臺(tái)交互的API不从,能夠接收并處理公眾號(hào)推送的事件;
2.  生成二維碼的API犁跪,并能分別以三種常用方式返回二維碼椿息;
3.  掃描二維碼后歹袁,微信上能正常顯示服務(wù)返回的提示信息,并成功記錄在數(shù)據(jù)庫(kù)中寝优;
4.  當(dāng)瀏覽器輪詢(xún)二維碼的掃描狀態(tài)并獲取到掃描結(jié)果后条舔,自動(dòng)跳轉(zhuǎn)。

??以上幾乎包含了公眾號(hào)開(kāi)發(fā)的完整流程乏矾,其他的功能可以參照公眾號(hào)開(kāi)發(fā)文檔上的說(shuō)明按需增加孟抗。這里有一點(diǎn)需要注意的,文中提到的unionid機(jī)制需要以公司身份申請(qǐng)正式的公眾號(hào)和微信開(kāi)放平臺(tái)妻熊,并在開(kāi)放平臺(tái)上完成公眾號(hào)綁定夸浅。同一個(gè)用戶在已綁定公眾號(hào)仑最、小程序扔役、網(wǎng)站應(yīng)用等程序里會(huì)使用同一個(gè)unionid來(lái)確定用戶的唯一性。

??像公眾號(hào)網(wǎng)頁(yè)授權(quán)警医、開(kāi)放平臺(tái)的網(wǎng)站應(yīng)用授權(quán)(類(lèi)似京東的掃碼登錄)和小程序的開(kāi)發(fā)亿胸,等有空的時(shí)候再更新。碼了這么多字预皇,差點(diǎn)忘了要去宜家掃貨侈玄,廣告上說(shuō)促銷(xiāo)商品數(shù)量有限,萬(wàn)一賣(mài)完了豈不是錯(cuò)過(guò)了幾個(gè)億::>_<::吟温,周末要找個(gè)時(shí)間過(guò)去看看才行序仙。

最后附上Dockerfile 和源碼地址

??預(yù)先拷貝文件到/build目錄,便于生成更小的Docker Image

cp -rf vendor docker/build/vendor
cp -rf utils docker/build/utils
cp -rf routes docker/build/routes
cp -rf middlewares docker/build/middlewares
cp -rf controllers docker/build/controllers
cp app.js docker/build/app.js
cp config.js docker/build/config.js
FROM alpine
COPY package.json /var/www/wechat-mp/
WORKDIR /var/www/wechat-mp
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
 && apk add nodejs npm \
 && npm install --production --registry=https://registry.npm.taobao.org \
 && npm cache clean -f \
 && rm package-lock.json \
 && apk del npm \
 && rm -rf ~/.npm \
 && rm -rf /var/cache/apk/* \
 && rm -rf /root/.cache \
 && rm -rf /tmp/*
COPY build/ /var/www/wechat-mp/
CMD node app.js</pre>

github: https://github.com/lym0r9/wechat-mp

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末鲁豪,一起剝皮案震驚了整個(gè)濱河市潘悼,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌爬橡,老刑警劉巖治唤,帶你破解...
    沈念sama閱讀 221,273評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異糙申,居然都是意外死亡宾添,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,349評(píng)論 3 398
  • 文/潘曉璐 我一進(jìn)店門(mén)柜裸,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)缕陕,“玉大人,你說(shuō)我怎么就攤上這事疙挺】敢兀” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 167,709評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵衔统,是天一觀的道長(zhǎng)鹿榜。 經(jīng)常有香客問(wèn)我海雪,道長(zhǎng),這世上最難降的妖魔是什么舱殿? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 59,520評(píng)論 1 296
  • 正文 為了忘掉前任奥裸,我火速辦了婚禮,結(jié)果婚禮上沪袭,老公的妹妹穿的比我還像新娘湾宙。我一直安慰自己,他們只是感情好冈绊,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,515評(píng)論 6 397
  • 文/花漫 我一把揭開(kāi)白布侠鳄。 她就那樣靜靜地躺著,像睡著了一般死宣。 火紅的嫁衣襯著肌膚如雪伟恶。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 52,158評(píng)論 1 308
  • 那天毅该,我揣著相機(jī)與錄音博秫,去河邊找鬼。 笑死眶掌,一個(gè)胖子當(dāng)著我的面吹牛挡育,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播朴爬,決...
    沈念sama閱讀 40,755評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼即寒,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了召噩?” 一聲冷哼從身側(cè)響起母赵,我...
    開(kāi)封第一講書(shū)人閱讀 39,660評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎蚣常,沒(méi)想到半個(gè)月后市咽,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,203評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡抵蚊,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,287評(píng)論 3 340
  • 正文 我和宋清朗相戀三年施绎,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片贞绳。...
    茶點(diǎn)故事閱讀 40,427評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡谷醉,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出冈闭,到底是詐尸還是另有隱情俱尼,我是刑警寧澤,帶...
    沈念sama閱讀 36,122評(píng)論 5 349
  • 正文 年R本政府宣布萎攒,位于F島的核電站遇八,受9級(jí)特大地震影響矛绘,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜刃永,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,801評(píng)論 3 333
  • 文/蒙蒙 一货矮、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧斯够,春花似錦囚玫、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,272評(píng)論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至束亏,卻和暖如春铃在,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背枪汪。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,393評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工涌穆, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人雀久。 一個(gè)月前我還...
    沈念sama閱讀 48,808評(píng)論 3 376
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像趁舀,于是被迫代替她去往敵國(guó)和親赖捌。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,440評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容