巧用Koa接管“對接微信開發”的工作 - 多使用者微信JS-SDK API服務

軼哥發表於2018-11-11

涉及微信開發的技術人員總會面對一些“對接”工作,每當做好一個產品賣給對方的時候,都需要程式設計師介入進行一些配置。例如:

  1. 使用“微信JS-SDK”的應用,我們需要新增微信公眾號“JS介面安全域名”。

  2. 為了解決微信頁面安全提示,我們需要新增微信公眾號“業務域名”。

  3. 為了在小程式中使用WebView頁面,我們需要新增微信小程式“業務域名”。

以上三種情況都不是簡單的將域名填入到微信管理後臺,而是需要下載一個txt檔案,儲存到伺服器根目錄,能夠被微信伺服器直接訪問,才能正常儲存域名。

如果只需要對接一個或幾個應用,開啟Nginx配置,如下新增:

location /YGCSYilWJs.txt {
    default_type text/html;
    return 200 '78362e6cae6a33ec4609840be35b399b';
}
複製程式碼

假如有幾十個甚至幾百個專案需要接入?。

讓我們花20分鐘徹底解決這個問題。

進行域名泛解析:*.abc.com -> 伺服器,反向代理根目錄下.txt結尾的請求。順便配置一下萬用字元SSL證書(網上有免費版本)。

location ~* ^/+?\w+\.txt$ {
        	proxy_http_version 1.1;
        	proxy_set_header X-Real-IP $remote_addr;
        	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        	proxy_set_header Host $http_host;
        	proxy_set_header X-NginX-Proxy true;
       		proxy_set_header Upgrade $http_upgrade;
        	proxy_set_header Connection "upgrade";
	        proxy_pass http://127.0.0.1:8362$request_uri;
        	proxy_redirect off;
}
複製程式碼

建立一個新專案,yarn add koa(或許你需要一個腳手架)。

正如上面所說,我們需要攔截根目錄下以.txt結尾的請求。因此我們新增koa路由模組koa-router。為了處理API中的資料,還需要koa-body模組。我們使用Sequelize作為ORM,用Redis作為快取。

在入口檔案(例如main.js)中引入Koa及KoaBody(為了方便閱讀,此處為關鍵程式碼,並非完整程式碼,下同)。

import Koa from 'koa'
import KoaBody from 'koa-body'

const app = new Koa()

app.proxy = true

var server = require('http').createServer(app.callback())

app
  .use((ctx, next) => { // 解決跨域
    ctx.set('Access-Control-Allow-Origin', '*')
    ctx.set('Access-Control-Allow-Headers', 'Authorization, DNT, User-Agent, Keep-Alive, Origin, X-Requested-With, Content-Type, Accept, x-clientid')
    ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS')
    if (ctx.method === 'OPTIONS') {
      ctx.status = 200
      ctx.body = ''
    }
    return next()
  })
  .use(KoaBody({
    multipart: true, // 開啟對multipart/form-data的支援
    strict: false, // 取消嚴格模式,parse GET, HEAD, DELETE requests
    formidable: { // 設定上傳引數
      uploadDir: path.join(__dirname, '../assets/uploads/tmpfile')
    },
    jsonLimit: '10mb', // application/json 限制,default 1mb 1mb
    formLimit: '10mb', // multipart/form-data 限制,default 56kb
    textLimit: '10mb' // application/x-www-urlencoded 限制,default 56kb
  }))

server.listen(8362) // 監聽的埠號
複製程式碼

這裡沒有引用koa-router,因為使用傳統的koa-router方式,閱讀起來不夠直觀,因此我們進行一個簡單改造——檔案即路由(PS:容易閱讀的資料能大幅提高生產力,API如果需要便於閱讀,則需要引入swagger等工具,為了方便,我們改造為檔案即路由,一眼掃過去的樹形資料就是我們的各個API及其結構)。

檔案目錄對應到API路徑是這樣的:

|-- controller
   |-- :file.txt.js // 對應API:/xxx.txt ,其中ctx.params('file')代表檔名
   |-- index.html
   |-- index.js // 對應API: /
   |-- api
       |-- index.js // 對應API: /api 或 /api/
       |-- weixin-js-sdk
           |-- add.js // 對應API: /api/weixin-js-sdk/add
           |-- check.js // 對應API: /api/weixin-js-sdk/check
           |-- get.js // 對應API: /api/weixin-js-sdk/get
複製程式碼

這樣一來,API介面的結構一目瞭然(天然的樹形圖),而且維護controller檔案本身即可,無需同步維護一次路由表。

檔案即路由的具體改造方法(參考自:github.com/dominicbarn…):

import inject, { client } from './inject'
import flatten from 'array-flatten'
import path from 'path'
import Router from 'koa-router'
import eachModule from 'each-module'
var debug = require('debug')('koa-file-router')

const methods = [
  'get',
  'post',
  'put',
  'head',
  'delete',
  'options',
  'data'
]

export const redisClient = client

export default (dir, options) => {
  if (!options) options = {}
  debug('initializing with options: %j', options)
  var router = new Router()
  return mount(router, discover(dir))
}

function discover (dir) {
  var resources = {
    params: [],
    routes: []
  }

  debug('searching %s for resources', dir)

  eachModule(dir, function (id, resource, file) {
    // console.log(id)
    // console.log(file)
    if (id.startsWith('_params')) {
      var name = path.basename(file, '.js')
      debug('found param %s in %s', name, file)
      resources.params.push({
        name: name,
        handler: resource.default
      })
    } else {
      methods.concat('all').forEach(function (method) {
        if (method in resource.default) {
          var url = path2url(id)
          debug('found route %s %s in %s', method.toUpperCase(), url, file)
          resources.routes.push({
            name: resource.name,
            url: url,
            method: method,
            handler: resource.default[method]
          })
        }
      })
    }
  })

  resources.routes.sort(sorter)

  return resources
}

function mount (router, resources) {
  resources.params.forEach(function (param) {
    debug('mounting param %s', param.name)
    router.param(param.name, param.handler)
  })

  let binds = {}
  resources.routes.forEach(function (route) {
    debug('mounting route %s %s', route.method.toUpperCase(), route.url)
    if (route.method === 'data') {
      binds = route.handler()
    }
  })

  resources.routes.forEach(function (route) {
    debug('mounting route %s %s', route.method.toUpperCase(), route.url)
    // console.log('mounting route %s %s', route.method.toUpperCase(), route.url)
    if (route.method !== 'data') {
      route.handler = route.handler.bind(Object.assign(binds, inject))
      let args = flatten([route.url, route.handler])
      if (route.method === 'get' && route.name) args.unshift(route.name)
      router[route.method].apply(router, args)
      // router[route.method](route.url, route.handler)
    }
  })

  // console.log(router)

  return router
}

function path2url (id) {
  var parts = id.split(path.sep)
  var base = parts[parts.length - 1]

  if (base === 'index') parts.pop()
  return '/' + parts.join('/')
}

function sorter (a, b) {
  var a1 = a.url.split('/').slice(1)
  var b1 = b.url.split('/').slice(1)

  var len = Math.max(a1.length, b1.length)

  for (var x = 0; x < len; x += 1) {
    // same path, try next one
    if (a1[x] === b1[x]) continue

    // url params always pushed back
    if (a1[x] && a1[x].startsWith(':')) return 1
    if (b1[x] && b1[x].startsWith(':')) return -1

    // normal comparison
    return a1[x] < b1[x] ? -1 : 1
  }
}
複製程式碼

在程式碼的第一行引入一個inject.js檔案,這個檔案主要是注入一些資料到對應的函式中,模擬前端vue的寫法:

import utils from './lib/utils'
import { Redis } from './config'
import redis from 'redis'
import { promisify } from 'util'
import plugin from './plugins'
import fs from 'fs'
import path from 'path'
import Sequelize from 'sequelize'
import jwt from 'jsonwebtoken'

const publicKey = fs.readFileSync(path.join(__dirname, '../publicKey.pub'))

export const client = redis.createClient(Redis)

const getAsync = promisify(client.get).bind(client)

const modelsDir = path.join(__dirname, './models')
const sequelize = require(modelsDir).default.sequelize
const models = sequelize.models

export default {
  $plugin: plugin,
  $utils: utils,
  Model: Sequelize,
  model (val) {
    return models[val]
  },
  /**
   * send success data
   */
  success (data, status = 1, msg) {
    return {
      status,
      msg,
      result: data
    }
  },
  /**
   * send fail data
   */
  fail (data, status = 10000, msg) {
    return {
      status,
      msg,
      result: data
    }
  },
  /**
   * 通過Redis進行快取
   */
  cache: {
    set (key, val, ex) {
      if (ex) {
        client.set(key, val, 'PX', ex)
      } else {
        client.set(key, val)
      }
    },
    get (key) {
      return getAsync(key)
    }
  },
  /**
   * 深拷貝物件、陣列
   * @param  {[type]} source 原始物件或陣列
   * @return {[type]}        深拷貝後的物件或陣列
   */
  deepCopy (o) {
    if (o === null) {
      return null
    } else if (Array.isArray(o)) {
      if (o.length === 0) {
        return []
      }
      let n = []
      for (let i = 0; i < o.length; i++) {
        n.push(this.deepCopy(o[i]))
      }
      return n
    } else if (typeof o === 'object') {
      let z = {}
      for (let m in o) {
        z[m] = this.deepCopy(o[m])
      }
      return z
    } else {
      return o
    }
  },
  async updateToken (userGUID, expiresIn = '365d') {
    const userInfo = await models['user'].findOne({
      where: {
        userGUID
      }
    })

    models['userLog'].create({
      userGUID: userInfo.userGUID,
      type: 'update'
    })

    const token = jwt.sign({
      userInfo
    }, publicKey, {
      expiresIn
    })

    return token
  },

  decodeToken (token) {
    return jwt.verify(token.substr(7), publicKey)
  }
}

複製程式碼

以上檔案所實現的效果是這樣的:

// 示例Controller檔案

export default {
  async get (ctx, next) { // get則是GET請求,post則為POST請求,其餘同理
	this.Model // inject.js檔案中注入到this裡面的Sequelize物件
	this.model('abc') // 獲取對應的model物件,abc即為sequelize.define('abc'...
	this.cache.set('key', 'value') // 設定快取,例子中用Redis作為快取
	this.cache.get('key') //獲取快取
    ctx.body = this.success('ok') // 同理,由injec.js注入
    next()
  }
}

複製程式碼

看到上面的寫法,是不是有一種在寫vue的感覺?

為了獲取引數更為方便,我們進行一些優化。新增一個controller中介軟體:

const defaultOptions = {}

export default (options, app) => {
  options = Object.assign({}, defaultOptions, options)
  return (ctx, next) => {
    ctx.post = function (name, value) {
      return name ? this.request.body[name] : this.request.body
    }
    ctx.file = function (name, value) {
      return name ? ctx.request.body.files[name] : ctx.request.body.files
    }
    ctx.put = ctx.post
    ctx.get = function (name, value) {
      return name ? this.request.query[name] : this.request.query
    }
    ctx.params = function (name, value) {
      return name ? this.params[name] : this.params
    }
    return next()
  }
}
複製程式碼

這樣一來,我們在controller檔案中的操作是這個效果:

// 示例Controller檔案, 檔案路徑對應API路徑

export default {
  async get (ctx, next) { // GET請求
	ctx.get() // 獲取所有URL引數
	ctx.get('key') // 獲取名稱為'key'的引數值
	ctx.params('xxx') // 如果controller檔名為“:”開頭的變數,例如:xxx.js,則此處獲取xxx的值,例如檔名為“:file.txt.js”,請求地址是“ok.txt”,則ctx.params('file')的值為“ok”
    ctx.body = this.success('ok')
    next()
  },
  async post (ctx, next) { // POST請求
	// 在POST請求中,除了GET請求的引數獲取方法,還可以用:
	ctx.post() // 用法同ctx.get()
	ctx.file() // 上傳的檔案
    ctx.body = this.success('ok')
    next()
  },
  async put (ctx, next) { // PUT請求
	// 在PUT請求中,除了有GET和POST的引數獲取方法,還有ctx.put作為ctx.post的別名
    ctx.body = this.success('ok')
    next()
  },
  async delete (ctx, next) { // DELETE請求
	// 引數獲取方法同post
    ctx.body = this.success('ok')
    next()
  },
  async ...
}
複製程式碼

當然,光有以上用法還不夠,還得加上一些快速運算元據庫的魔法(基於Sequelize)。

新增auto-migrations模組,在gulp中監控models資料夾中的變化,如果新增或者修改了model,則自動將model同步到資料庫作為資料表(類似Django,試驗用法,請勿用於生產環境)。具體配置參考原始碼。

在models資料夾中新建檔案weixinFileIdent.js,輸入一下資訊,儲存後則資料庫中將自動出現weixinFileIdent表。

export default (sequelize, DataTypes) => {
  const M = sequelize.define('weixinFileIdent', {
    id: {
      type: DataTypes.BIGINT.UNSIGNED,
      allowNull: false,
      primaryKey: true,
      autoIncrement: true
    },
    name: {
      type: DataTypes.STRING(255),
      allowNull: false
    },
    content: {
      type: DataTypes.STRING(255),
      allowNull: true
    }
  })
  M.associate = function (models) {
    // associations can be defined here
  }
  return M
}
複製程式碼

在controller資料夾中新增:file.txt.js檔案。

export default {
  async get (ctx, next) {
    const fileContent = await this.model('weixinFileIdent').findOne({
      where: {
        name: ctx.params('file')
      },
      order: [
        ['updatedAt', 'DESC']
      ]
    })

    ctx.set('Content-Type', 'text/plain; charset=utf-8')

    if (fileContent) {
      ctx.body = fileContent.content
      next()
    } else {
      ctx.body = ''
      next()
    }
  }
}

複製程式碼

在使用者訪問https://域名/XXX.txt檔案的時候,讀取資料庫中儲存的檔案內容,以文字的方式返回。這樣一來,微信的驗證伺服器方會通過對該域名的驗證。

同理,我們需要一個新增資料的介面controller/api/check.js(僅供參考)。

export default {
  async get (ctx, next) {
    await this.model('weixinFileIdent').create({ // 僅演示邏輯,沒有驗證是否成功,是否重複等。
      name: ctx.get('name'),
      content: ctx.get('content')
    })
    ctx.body = {
      status: 1
    }
    next()
  }
}
複製程式碼

當我們訪問https://域名/api/weixin-js-sdk/check?name=XXX&content=abc的時候,將插入一條資料到資料庫中,記錄從微信後臺下載的檔案內容,當訪問https://域名/XXX.txt的時候,將返回檔案內容(此方法通用於文初提到的三種場景)。

按照同樣的方法,我們實現多使用者JS-SDK配置資訊返回。

定義模型(C+S儲存後自動創表):

export default (sequelize, DataTypes) => {
  const M = sequelize.define('weixinJSSDKKey', {
    id: {
      type: DataTypes.BIGINT.UNSIGNED,
      allowNull: false,
      primaryKey: true,
      autoIncrement: true
    },
    domain: {
      type: DataTypes.STRING(255),
      allowNull: false
    },
    APPID: {
      type: DataTypes.STRING(255),
      allowNull: false
    },
    APPSECRET: {
      type: DataTypes.STRING(255),
      allowNull: true
    }
  })
  M.associate = function (models) {
    // associations can be defined here
  }
  return M
}

複製程式碼

controller/api/weixin-js-sdk/add.js檔案:

export default {
  async get (ctx, next) {
    await this.model('weixinJSSDKKey').create({
      domain: ctx.get('domain'),
      APPID: ctx.get('APPID'),
      APPSECRET: ctx.get('APPSECRET')
    })
    ctx.body = {
      status: 1,
      msg: '新增成功'
    }
    next()
  }
}
複製程式碼

同理,當我們訪問https://域名/api/weixin-js-sdk/add?domain=XXX&APPID=XXX&APPSECRET=XXX的時候,將插入一條資料到資料庫中,記錄該使用者的二級域名字首,公眾號的APPIDAPPSECRET

controller/api/weixin-js-sdk/get.js檔案,依賴於co-wechat-api模組。


const WechatAPI = require('co-wechat-api')

export default {
  async get (ctx, next) {
    const weixinJSSDKKey = this.model('weixinJSSDKKey')
    const domain = ctx.header.host.substring(0, ctx.header.host.indexOf('.abc.com')) // 根域名
    const result = await weixinJSSDKKey.findOne({
      where: {
        domain
      },
      order: [
        ['updatedAt', 'DESC']
      ]
    })
    if (result) {
      const APPID = result.APPID
      const APPSECRET = result.APPSECRET

      if (ctx.query.url) {
        const api = new WechatAPI(APPID, APPSECRET, async () => {
          // 傳入一個獲取全域性token的方法
          let txt = null
          try {
            txt = await this.cache.get('weixin_' + APPID)
          } catch (err) {
            console.log(err)
            txt = '{"accessToken":"x","expireTime":1520244812873}'
          }
          return txt ? JSON.parse(txt) : null
        }, (token) => {
          // 請將token儲存到全域性,跨程式、跨機器級別的全域性,比如寫到資料庫、redis等
          // 這樣才能在cluster模式及多機情況下使用,以下為寫入到檔案的示例
          this.cache.set('weixin_' + APPID, JSON.stringify(token))
        })

        var param = {
          debug: false,
          jsApiList: [ // 需要用到的API列表
            'checkJsApi',
            'onMenuShareTimeline',
            'onMenuShareAppMessage',
            'onMenuShareQQ',
            'onMenuShareWeibo',
            'hideMenuItems',
            'showMenuItems',
            'hideAllNonBaseMenuItem',
            'showAllNonBaseMenuItem',
            'translateVoice',
            'startRecord',
            'stopRecord',
            'onRecordEnd',
            'playVoice',
            'pauseVoice',
            'stopVoice',
            'uploadVoice',
            'downloadVoice',
            'chooseImage',
            'previewImage',
            'uploadImage',
            'downloadImage',
            'getNetworkType',
            'openLocation',
            'getLocation',
            'hideOptionMenu',
            'showOptionMenu',
            'closeWindow',
            'scanQRCode',
            'chooseWXPay',
            'openProductSpecificView',
            'addCard',
            'chooseCard',
            'openCard'
          ],
          url: decodeURIComponent(ctx.query.url)
        }

        ctx.body = {
          status: 1,
          result: await api.getJsConfig(param)
        }
        next()
      } else {
        ctx.body = {
          status: 10000,
          err: '未知引數'
        }
        next()
      }
    } else {
      ctx.body = ''
      next()
    }
  }
}

複製程式碼

JS-SDK配置資料獲取地址:https://域名/api/weixin-js-sdk/get?APPID=XXX&url=XXX,第二個XXX為當前頁面的地址。一般為encodeURIComponent(window.location.origin) + '/',如果當前頁面存在'/#/xxx',則為encodeURIComponent(window.location.origin) + '/#/'

返回的資料中的result即為wx.config(result)

最後,我們再寫一個簡單的html頁面作為引導(http://localhost:8362/?name=xxx):

巧用Koa接管“對接微信開發”的工作 - 多使用者微信JS-SDK API服務

完整示例原始碼:github.com/yi-ge/weixi…

原創內容。文章來源:www.wyr.me/post/592

相關文章