├── .gitignore ├── .travis.yml ├── Makefile ├── README.md ├── index.js ├── lib ├── api.js ├── request.js ├── util.js └── wxService.js ├── package.json └── test ├── api.test.js ├── config.js ├── event.test.js ├── helper.js ├── notice.test.js ├── request.js └── signature.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | dump.rdb 4 | config/* 5 | !config/_sample.json 6 | coverage -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | sudo: required -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS = test/*.js 2 | REPORTER = spec 3 | TIMEOUT = 20000 4 | ISTANBUL = ./node_modules/.bin/istanbul 5 | MOCHA = ./node_modules/.bin/_mocha 6 | COVERALLS = ./node_modules/.bin/coveralls 7 | 8 | test: 9 | @NODE_ENV=test $(MOCHA) -R $(REPORTER) -t $(TIMEOUT) \ 10 | $(MOCHA_OPTS) \ 11 | $(TESTS) 12 | 13 | test-cov: 14 | @$(ISTANBUL) cover --report html $(MOCHA) -- -t $(TIMEOUT) -R spec $(TESTS) 15 | 16 | test-coveralls: 17 | @$(ISTANBUL) cover --report lcovonly $(MOCHA) -- -t $(TIMEOUT) -R spec $(TESTS) 18 | @echo TRAVIS_JOB_ID $(TRAVIS_JOB_ID) 19 | @cat ./coverage/lcov.info | $(COVERALLS) && rm -rf ./coverage 20 | 21 | test-all: test test-coveralls 22 | 23 | clean: 24 | rm -rf coverage 25 | 26 | .PHONY: test -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # weixin-service 2 | 微信公众服务相关 API 接口封装。 3 | --- 4 | [![Build Status](https://travis-ci.org/liuxiaodong/weixin-service.png)](https://travis-ci.org/liuxiaodong/weixin-service) 5 | [![Coverage Status](https://coveralls.io/repos/liuxiaodong/weixin-service/badge.png)](https://coveralls.io/github/liuxiaodong/weixin-service) 6 | [![Code Climate](https://codeclimate.com/github/liuxiaodong/weixin-service/badges/gpa.svg)](https://codeclimate.com/github/liuxiaodong/weixin-service) 7 | [![Dependency Status](https://david-dm.org/liuxiaodong/weixin-service.svg)](https://david-dm.org/liuxiaodong/weixin-service) 8 | [![devDependency Status](https://david-dm.org/liuxiaodong/weixin-service/dev-status.svg)](https://david-dm.org/liuxiaodong/weixin-service#info=devDependencies) 9 | [![bitHound Score](https://www.bithound.io/github/liuxiaodong/weixin-service/badges/score.svg)](https://www.bithound.io/github/liuxiaodong/weixin-service) 10 | 11 | 12 | ####微信文档 13 | 14 | 第三方服务开发文档 15 | 16 | 17 | ####安装 18 | ``` 19 | npm install weixin-service --save 20 | ``` 21 | 22 | ####使用 23 | 24 | ```js 25 | var options = { 26 | appid: "your app_id", 27 | appsecret: "your app_secret", 28 | token: "token", 29 | encrypt_key: "encrypt_key", 30 | } 31 | 32 | var wxs = require('weixin-service')(options); 33 | 34 | var app = require('express')() 35 | 36 | app.get('/wechat/notice', wxs.enable()); 37 | app.post('/wechat/notice', wxs.noticeHandle(noticeHandle)); 38 | 39 | app.get('/wechat/:appid/event', wxs.enable()); 40 | app.post('/wechat/:appid/event', wxs.eventHandle(eventHandle)) 41 | 42 | ``` 43 | 44 | 45 | #### options 说明 46 | 47 | `appid:` 第三方服务号 appid 48 | 49 | `appsecret:` 第三方服务 appsecret 50 | 51 | `token:` 第三方服务 token 52 | 53 | `encrypt_key:` 第三方服务加密 key 54 | 55 | `attrNameProcessors`: 数据属性的格式化处理,比如:{AppId: '1234'} -> {app_id: '1234'} 56 | 57 | ``` 58 | keep: 保持不变 (AppId) 59 | lowerCase: 小写 (appid) 60 | underscored: 小写并以下划线分开 (app_id) 61 | 也可以自定义函数 function(attr){ return attr; } 62 | ``` 63 | 64 | `saveToken:` 保存第三方服务的 component_access_token 函数,默认保存到内存中 65 | 66 | ``` 67 | saveToken = function(token, callback){} 68 | token: { 69 | componentAccessToken: '', 70 | expireTime: 7200 71 | } 72 | ``` 73 | 74 | `getToken:` 获取 component_access_token 函数 75 | 76 | ``` 77 | saveToken = function(callback){ callback(null, token); } 78 | 79 | ``` 80 | 81 | `saveTicket:` 保存微信推送的 component_verify_ticket 函数 82 | 83 | ``` 84 | saveTicket = function(ticket){} 85 | ``` 86 | 87 | `getTicket:` 获取 component_verify_ticket 函数 88 | 89 | ``` 90 | getTicket: function(callback){ callback(ticket); } 91 | ``` 92 | 93 | #### API 94 | 95 | * 配置 request 请求的 options,参照 urllib 96 | 97 | ``` 98 | wxs.setOpts({ 99 | timeout: 10000 100 | }) 101 | ``` 102 | 103 | * 获取可用的 component_access_token 104 | 105 | ``` 106 | wxs.getLastComponentAccessToken(function(err, token){}); 107 | ``` 108 | 109 | * 获取预授权码 pre_auth_code 110 | 111 | ``` 112 | wxs.preAuthCode(function(err, ret){}); 113 | ``` 114 | 115 | * 使用授权码换取公众号的授权信息 116 | 117 | ``` 118 | wxs.getAuthorizationiInfo(authorization_code, function(err, ret){}); 119 | ``` 120 | 121 | * 通过刷新令牌刷新(获取)授权公众号的令牌 122 | 123 | ``` 124 | wxs.refreshToken(authorizer_appid, authorizer_refresh_token, function(err, ret){}); 125 | ``` 126 | 127 | * 获取授权方账户信息 128 | 129 | ``` 130 | wxs.getAuthorizerInfo(authorizer_appid, function(err, ret){}); 131 | ``` 132 | * 获取授权方的选项设置信息 133 | 134 | ``` 135 | wxs.getAuthorizerOption(authorizer_appid, option_name, function(err, ret){}); 136 | ``` 137 | * 设置授权方的选项设置信息 138 | 139 | ``` 140 | wxs.getAuthorizerOption(authorizer_appid, option_name, option_value, function(err, ret){}); 141 | ``` 142 | 143 | * 待公众号发起网页授权时通过 code 换取 accessToken 等信息 144 | 145 | ``` 146 | wxs.getOauthAccessToken(authorizer_appid, code, function(err, ret){}); 147 | ``` 148 | 149 | * 待公众号发起网页授权 刷新 accessToken(如果需要) 150 | 151 | ``` 152 | wxs.refreshOauthAccessToken(authorizer_appid, refresh_token, function(err, ret){}); 153 | ``` 154 | 155 | * 通过网页授权access_token获取用户基本信息(需授权作用域为snsapi_userinfo) 156 | 157 | ``` 158 | wxs.getOauthInfo(access_token, openid, function(err, ret){}); 159 | ``` 160 | 161 | 162 | #### 消息回复 163 | 164 | `res:` response 165 | 166 | `media_id:` 素材 id 167 | 168 | * 文本消息 169 | 170 | ``` 171 | res.text('text'); 172 | ``` 173 | 174 | * 图片 175 | 176 | ``` 177 | res.image(media_id); 178 | ``` 179 | 180 | * 录音 181 | 182 | ``` 183 | res.voice(media_id); 184 | ``` 185 | 186 | * 视频 187 | 188 | ``` 189 | res.video({video: media_id, title:'title', description: 'description'}); 190 | ``` 191 | 192 | * 音乐 193 | 194 | ``` 195 | res.music({thumb_media: media_id, title: 'title', description: 'description', music_url: 'music_url', hq_music_url: 'hq_music_url'}) 196 | ``` 197 | 198 | * 图文消息 199 | 200 | ``` 201 | var news = [ 202 | { 203 | title: 'title', 204 | description: 'description', 205 | pic_url: 'pic_url', 206 | url : 'url' 207 | } 208 | ]; 209 | 210 | res.news(news); 211 | ``` 212 | 213 | * 客服 214 | 215 | ``` 216 | res.transfer(); 217 | ``` 218 | 219 | * IOT 设备消息 220 | 221 | ``` 222 | res.device('command'); 223 | ``` 224 | 225 | * 回复空字符串 226 | 227 | ``` 228 | res.ok(); 229 | ``` 230 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/wxService'); 2 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | var util = require('./util'); 2 | var is = util.is; 3 | 4 | /** 5 | * 获取预授权码 6 | * @param component_access_token 第三方服务的 accessToken 7 | * @param component_appid 第三方服务的 appid 8 | * @return 9 | * { 10 | * "pre_auth_code":"Cx_Dk6qiBE0Dmx4EmlT3oRfArPvwSQ-oa3NL_fwHM7VI08r52wazoZX2Rhpz1dEw", 11 | * "expires_in":600 12 | * } 13 | */ 14 | exports.preAuthCode = function(callback){ 15 | var json = { 16 | component_appid: this.appid 17 | }; 18 | var url = 'https://api.weixin.qq.com/cgi-bin/component/api_create_preauthcode?component_access_token=' + this.component_access_token; 19 | this.post(url, json, callback); 20 | }; 21 | 22 | /** 23 | * 使用授权码换取公众号的授权信息 24 | * @param component_access_token 第三方服务的 accessToken 25 | * @param component_appid 第三方服务的 appid 26 | * @param authorization_code 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明 27 | * @return 28 | * { 29 | * "authorization_info": { 30 | * "authorizer_appid": "wxf8b4f85f3a794e77", 31 | * "authorizer_access_token": "QXjUqNqfYVH0yBE1iI_7vuN_9gQbpjfK7hYwJ3P7xOa88a89-Aga5x1NMYJyB8G2yKt1KCl0nPC3W9GJzw0Zzq_dBxc8pxIGUNi_bFes0qM", 32 | * "expires_in": 7200, 33 | * "authorizer_refresh_token": "dTo-YCXPL4llX-u1W1pPpnp8Hgm4wpJtlR6iV0doKdY", 34 | * "func_info": [ 35 | * { 36 | * "funcscope_category": { 37 | * "id": 1 38 | * } 39 | * }, 40 | * { 41 | * "funcscope_category": { 42 | * "id": 2 43 | * } 44 | * }, 45 | * ] 46 | * } 47 | */ 48 | exports.getAuthorizationiInfo = function(authorization_code, callback){ 49 | var json = { 50 | component_appid: this.appid, 51 | authorization_code: authorization_code 52 | }; 53 | var url = 'https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token=' + this.component_access_token; 54 | this.post(url, json, callback); 55 | }; 56 | 57 | /** 58 | * 获取(刷新)授权公众号的令牌 59 | * @param component_access_token 第三方服务的 accessToken 60 | * @param component_appid 第三方服务的 appid 61 | * @param authorizer_appid 授权方appid 62 | * @param authorizer_refresh_token 授权方的刷新令牌 63 | * @return 64 | * { 65 | * "authorizer_access_token": "aaUl5s6kAByLwgV0BhXNuIFFUqfrR8vTATsoSHukcIGqJgrc4KmMJ-JlKoC_-NKCLBvuU1cWPv4vDcLN8Z0pn5I45mpATruU0b51hzeT1f8", 66 | * "expires_in": 7200, 67 | * "authorizer_refresh_token": "BstnRqgTJBXb9N2aJq6L5hzfJwP406tpfahQeLNxX0w" 68 | * } 69 | */ 70 | exports.refreshToken = function(authorizer_appid, authorizer_refresh_token, callback){ 71 | var json = { 72 | component_appid: this.appid, 73 | authorizer_appid: authorizer_appid, 74 | authorizer_refresh_token: authorizer_refresh_token 75 | }; 76 | var url = 'https://api.weixin.qq.com/cgi-bin/component/api_authorizer_token?component_access_token=' + this.component_access_token; 77 | this.post(url, json, callback); 78 | }; 79 | 80 | /** 81 | * 获取授权方的账户信息 82 | * @param component_access_token 第三方服务的 accessToken 83 | * @param authorizer_appid 授权方appid 84 | * @return 85 | * { 86 | * "authorizer_info": { 87 | * "nick_name": "微信SDK Demo Special", 88 | * "head_img": "http://wx.qlogo.cn/mmopen/GPyw0pGicibl5Eda4GmSSbTguhjg9LZjumHmVjybjiaQXnE9XrXEts6ny9Uv4Fk6hOScWRDibq1fI0WOkSaAjaecNTict3n6EjJaC/0", 89 | * "service_type_info": { "id": 2 }, 90 | * "verify_type_info": { "id": 0 }, 91 | * "user_name":"gh_eb5e3a772040", 92 | * "alias":"paytest01" 93 | * }, 94 | * "qrcode_url":"URL", 95 | * "authorization_info": { 96 | * "appid": "wxf8b4f85f3a794e77", 97 | * "func_info": [ 98 | * { "funcscope_category": { "id": 1 } }, 99 | * { "funcscope_category": { "id": 2 } }, 100 | * { "funcscope_category": { "id": 3 } } 101 | * ] 102 | * } 103 | * } 104 | */ 105 | exports.getAuthorizerInfo = function(authorizer_appid, callback){ 106 | var json = { 107 | component_appid: this.appid, 108 | authorizer_appid: authorizer_appid 109 | }; 110 | var url = 'https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_info?component_access_token=' + this.component_access_token; 111 | this.post(url, json, callback); 112 | }; 113 | 114 | /** 115 | * 获取授权方的选项设置信息 116 | * @param component_access_token 第三方服务的 accessToken 117 | * @param component_appid 第三方服务的 appid 118 | * @param authorizer_appid 授权方appid 119 | * @param option_name 选项名称 120 | * @return 121 | * { 122 | * "authorizer_appid":"wx7bc5ba58cabd00f4", 123 | * "option_name":"voice_recognize", 124 | * "option_value":"1" 125 | * } 126 | */ 127 | exports.getAuthorizerOption = function(authorizer_appid, option_name, callback){ 128 | var json = { 129 | component_appid: this.appid, 130 | authorizer_appid: authorizer_appid, 131 | option_name: option_name 132 | }; 133 | var url = 'https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_option?component_access_token=' + this.component_access_token; 134 | this.post(url, json, callback); 135 | }; 136 | 137 | /** 138 | * 设置授权方的选项信息 139 | * @param component_access_token 第三方服务的 accessToken 140 | * @param component_appid 第三方服务的 appid 141 | * @param authorizer_appid 授权方appid 142 | * @param option_name 选项名称 143 | * @return 144 | * { 145 | * "authorizer_appid":"wx7bc5ba58cabd00f4", 146 | * "option_name":"voice_recognize", 147 | * "option_value":"1" 148 | * } 149 | */ 150 | exports.putAuthorizerOption = function(authorizer_appid, option_name, option_value, callback){ 151 | var json = { 152 | component_appid: this.appid, 153 | authorizer_appid: authorizer_appid, 154 | option_name: option_name, 155 | option_value: option_value 156 | }; 157 | var url = 'https://api.weixin.qq.com/cgi-bin/component/api_set_authorizer_option?component_access_token=' + this.component_access_token; 158 | this.post(url, json, callback); 159 | }; 160 | 161 | /** 162 | * 代公众号发起网页授权 163 | */ 164 | 165 | 166 | /** 167 | * 通过 code 换取 accessToken 168 | */ 169 | exports.getOauthAccessToken = function(authorizer_appid, code, callback){ 170 | var url = 'https://api.weixin.qq.com/sns/oauth2/component/access_token?appid=' + authorizer_appid + '&code=' + code + '&grant_type=authorization_code&component_appid=' + this.appid + '&component_access_token=' + this.component_access_token; 171 | this.get(url, callback); 172 | }; 173 | 174 | /** 175 | * 刷新access_token(如果需要) 176 | */ 177 | exports.refreshOauthAccessToken = function(authorizer_appid, refresh_token, callback){ 178 | var url = 'https://api.weixin.qq.com/sns/oauth2/component/refresh_token?appid=' + authorizer_appid + '&grant_type=refresh_token&component_appid=' + this.appid + '&component_access_token=' + this.component_access_token + '&refresh_token=' + refresh_token; 179 | this.get(url, callback); 180 | }; 181 | 182 | /** 183 | * 通过网页授权access_token获取用户基本信息(需授权作用域为snsapi_userinfo) 184 | */ 185 | exports.getOauthInfo = function(access_token, openid, callback){ 186 | var url = 'https://api.weixin.qq.com/sns/userinfo?access_token=' + access_token + '&openid=' + openid + '&lang=zh_CN'; 187 | this.get(url, callback); 188 | }; -------------------------------------------------------------------------------- /lib/request.js: -------------------------------------------------------------------------------- 1 | var urllib = require('urllib'); 2 | var util = require('./util'); 3 | var wrapper = util.wrapper; 4 | var extend = util.extend; 5 | 6 | var mergeOpts = function (src, target){ 7 | var options = {}; 8 | extend(options, src); 9 | for (var key in target) { 10 | if (key !== 'headers') { 11 | options[key] = target[key]; 12 | } else { 13 | if (target.headers) { 14 | options.headers = options.headers || {}; 15 | extend(options.headers, target.headers); 16 | } 17 | } 18 | } 19 | return options; 20 | }; 21 | 22 | /** 23 | * POST 请求 24 | */ 25 | exports.post = function(url, data, callback){ 26 | var opts = { 27 | method: 'POST', 28 | headers: { 29 | 'Content-Type': 'application/json' 30 | }, 31 | dataType: 'json', 32 | data: data 33 | }; 34 | opts = mergeOpts(this.defaultOpts, opts); 35 | urllib.request(url, opts, wrapper(callback)); 36 | }; 37 | 38 | /** 39 | * GET 请求 40 | */ 41 | exports.get = function(url, callback){ 42 | var opts = { 43 | method: 'GET', 44 | dataType: 'json' 45 | }; 46 | opts = mergeOpts(this.defaultOpts, opts); 47 | urllib.request(url, opts, wrapper(callback)); 48 | }; -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 工具集函数 3 | */ 4 | 5 | 'use strict'; 6 | 7 | var _toString = Object.prototype.toString; 8 | 9 | /** 10 | * 类型判断 11 | * object array string function null number boolean undefined 12 | */ 13 | var _is = function(data, type){ 14 | var _type = _toString.call(data).replace('[object ','').replace(']','').toLowerCase(); 15 | type = (type || '').toLowerCase(); 16 | return _type === type; 17 | }; 18 | exports.is = _is; 19 | 20 | /** 21 | * 字符串格式化 22 | * keep: 保持不变 23 | * lowerCase: 转为消息 24 | * underscored: 转为下划线形式 25 | */ 26 | exports.defaultAttrFormatCollection = { 27 | keep: function(str){ 28 | return str.trim(); 29 | }, 30 | lowerCase: function(str){ 31 | return str.trim().toLowerCase(); 32 | }, 33 | underscored: function(str){ 34 | return str.trim().replace(/([a-z\d])([A-Z]+)/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase(); 35 | } 36 | }; 37 | 38 | /** 39 | * 属性格式化 40 | * 对 Object 的 key 的格式化 41 | */ 42 | exports.attrFormat = function(data, attrNameProcessors){ 43 | var _format = function(data){ 44 | for(var p in data){ 45 | var value = data[p]; 46 | var prot = p; 47 | if(_is(prot, 'string')) prot = attrNameProcessors(prot); 48 | if(prot !== p){ 49 | data[prot] = value; 50 | delete data[p]; 51 | } 52 | if(_is(value, 'array') || _is(value, 'object')) { 53 | if(_is(value, 'array') && value.length === 1){ 54 | data[prot] = value[0]; 55 | } 56 | _format(data[prot]); 57 | } 58 | } 59 | }; 60 | if(!_is(data, 'object') && !_is(data, 'array')) return data; 61 | _format(data); 62 | return data; 63 | }; 64 | 65 | /** 66 | * 扩展 67 | */ 68 | exports.extend = function(src, target){ 69 | for(var p in target){ 70 | src[p] = target[p]; 71 | } 72 | return src; 73 | }; 74 | 75 | /** 76 | * 错误包装 77 | */ 78 | exports.error = function(err){ 79 | if(_is(err, 'error')) { 80 | err.name = 'WeixinServer' + err.name; 81 | }else { 82 | err = new Error(err); 83 | err.name = 'WeixinServerError'; 84 | } 85 | return err; 86 | }; 87 | 88 | /** 89 | * 对微信放回错误的包装 90 | */ 91 | exports.wrapper = function(callback){ 92 | if(!_is(callback, 'function')) callback = function(){}; 93 | return function(err, data, res) { 94 | if (err) { 95 | err.name = 'WeixinServer' + err.name; 96 | return callback(err, data, res); 97 | } 98 | if(!data) { 99 | return callback(exports.error('Not get')); 100 | } 101 | if (data && data.errcode) { 102 | err = new Error(data.errmsg); 103 | err.name = 'WeixinServerError'; 104 | err.code = data.errcode; 105 | return callback(err, data, res); 106 | } 107 | callback(null, data, res); 108 | }; 109 | }; 110 | 111 | 112 | -------------------------------------------------------------------------------- /lib/wxService.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug')('weixin-server'); 2 | var crypto = require('crypto'); 3 | var getRawBody = require('raw-body'); 4 | var parseString = require('xml2js').parseString; 5 | var WXBizMsgCrypt = require('wechat-crypto'); 6 | var request = require('./request'); 7 | var util = require('./util'); 8 | var is = util.is; 9 | var attrFormat = util.attrFormat; 10 | var extend = util.extend; 11 | var error = util.error; 12 | 13 | var WxService = function(options){ 14 | if(!(this instanceof WxService)) { 15 | return new WxService(options); 16 | } 17 | 18 | var defaultOptions = { 19 | attrNameProcessors: 'keep' 20 | }; 21 | extend(defaultOptions, options); 22 | 23 | for(var p in defaultOptions){ 24 | // 自定义 Token 存储函数, Token 取得函数, ticket 函数, ticket 取得函数, 默认消息处理函数 25 | if(['saveToken', 'getToken', 'saveTicket', 'getTicket', 'defaultNoticeHandle', 'defaultEventHandle'].indexOf(p) > -1){ 26 | if(is(defaultOptions[p], 'function')) this[p] = defaultOptions[p]; 27 | }else { 28 | this[p] = defaultOptions[p]; 29 | } 30 | } 31 | // 解密器 32 | if(this.token && this.encrypt_key && this.appid && this.appsecret){ 33 | this.crypter = new WXBizMsgCrypt(this.token, this.encrypt_key, this.appid); 34 | }else { 35 | throw error('need appid, appsecret, token and encrypt_key'); 36 | } 37 | 38 | // 微信数据属性 KEY 的格式化,可以是function 39 | // 若使用的其他的包解析微信,则必须传入正确函数解析出 40 | if(!is(this.attrNameProcessors, 'function')) this.attrNameProcessors = util.defaultAttrFormatCollection[this.attrNameProcessors]; 41 | if(!this.attrNameProcessors) this.attrNameProcessors = util.defaultAttrFormatCollection.keep; 42 | }; 43 | 44 | /** 45 | * 解析微信 POST 的xml数据的中间件 46 | */ 47 | WxService.prototype.bodyParserMiddlewares = function(){ 48 | var self = this; 49 | return function(req, res, next){ 50 | getRawBody(req, { 51 | lenght: req.headers['content-length'], 52 | limit: '1mb', 53 | encodeing: 'utf8' 54 | }, function(err, str){ 55 | if(err) return next(err); 56 | if(!str || str.length === 0)return next(); 57 | req.rawBuf = str; 58 | self.xmlParser(str, function(err, ret){ 59 | if(err) return next(err); 60 | req.body = attrFormat(ret.xml, self.attrNameProcessors); 61 | next(); 62 | }); 63 | }); 64 | }; 65 | }; 66 | 67 | /** 68 | * 解析 POST 数据的 promise 版本 69 | */ 70 | WxService.prototype.bodyParserPromise = function(req){ 71 | var deferred = Q.defer(); 72 | getRawBody(req, { 73 | lenght: req.headers['content-length'], 74 | limit: '1mb', 75 | encodeing: 'utf8' 76 | }, function(err, str){ 77 | if(err) return deferred.reject(err); 78 | if(!str || str.length === 0) deferred.reject(error('No content')); 79 | req.rawBuf = str; 80 | self.xmlParser(str, function(err, ret){ 81 | if(err) return deferred.reject(e); 82 | req.body = attrFormat(ret.xml, self.attrNameProcessors); 83 | return deferred.resolve(req.body); 84 | }); 85 | }); 86 | }; 87 | 88 | /** 89 | * xml 解析 90 | */ 91 | WxService.prototype.xmlParser = function(xml, cb){ 92 | var options = { 93 | async: true, 94 | explicitArray: true, 95 | normalize: true, 96 | trim: true 97 | }; 98 | parseString(xml, options, function(err, ret){ 99 | if(err) return cb(error(err)); 100 | cb(null, ret); 101 | }); 102 | }; 103 | 104 | /** 105 | * 解密 106 | */ 107 | WxService.prototype.decrypt = function(encrypt, cb){ 108 | if(!encrypt) return cb(); 109 | if(!this.crypter) return cb(error('No crypter')); 110 | var message = this.crypter.decrypt(encrypt).message; 111 | var self = this; 112 | this.xmlParser(message, function(err, ret){ 113 | if(err) return cb(error(err)); 114 | var result = attrFormat(ret.xml, self.attrNameProcessors); 115 | cb(null, result); 116 | }); 117 | }; 118 | 119 | /** 120 | * 解密的 promise 版本 121 | */ 122 | WxService.prototype.decryptPromise = function(encrypt, cb){ 123 | var deferred = Q.defer(); 124 | if(!encrypt) return deferred.resolve(); 125 | if(!this.crypter) return deferred.reject(error('No crypter')); 126 | var message = this.crypter.decrypt(encrypt).message; 127 | var self = this; 128 | this.xmlParser(message, function(err, ret){ 129 | if(err) return deferred.reject(error(err)); 130 | var result = attrFormat(ret.xml, self.attrNameProcessors); 131 | return deferred.resolve(result); 132 | }); 133 | }; 134 | 135 | /** 136 | * 验证消息的合法性 137 | */ 138 | var _validate = function(req, token){ 139 | var signature = req.query.signature, timestamp = req.query.timestamp, nonce = req.query.nonce; 140 | var sorted = [token, timestamp, nonce].sort(); 141 | var origin = sorted.join(""); 142 | var encoded = crypto.createHash('sha1').update(origin).digest('hex'); 143 | return (encoded === signature); 144 | }; 145 | 146 | WxService.prototype.validate = function(token){ 147 | token = token || this.token; 148 | return function(req, res, next){ 149 | if(_validate(req, token)) return next(); 150 | next(error("validate failure")); 151 | }; 152 | }; 153 | 154 | 155 | 156 | /** 157 | * 处理 url 配置是微信的验证请求 158 | */ 159 | WxService.prototype.enable = function(){ 160 | return function(req, res){ 161 | res.send(req.query.echostr); 162 | }; 163 | }; 164 | 165 | /** 166 | * 对授权时间处理的handle进行包装,处理 component_verify_ticket 的推送 167 | */ 168 | var handleWrapper = function(handle){ 169 | if(!is(handle, 'function')) handle = this.defaultNoticeHandle; 170 | var self = this; 171 | return function(req, res, next){ 172 | var info_type = req.body[self.attrNameProcessors('InfoType')]; 173 | if(!info_type){ 174 | debug('unknow InfoType and can not save ticket, must set correct attrNameProcessors attr to parser InfoType'); 175 | } 176 | if(info_type === 'component_verify_ticket'){ 177 | self.saveTicket(req.body[self.attrNameProcessors('ComponentVerifyTicket')]); 178 | res.send('success'); 179 | }else { 180 | handle.call(self, req, res, next); 181 | } 182 | }; 183 | }; 184 | /** 185 | * 处理授权事件接收请求 186 | */ 187 | WxService.prototype.noticeHandle = function(handle){ 188 | var self = this; 189 | handle = handleWrapper.call(this, handle); 190 | return function(req, res, next){ 191 | if(!_validate(req, self.token)) return next(error("validate failure")); 192 | if(req.body[self.attrNameProcessors('Encrypt')]){ 193 | self.decrypt(req.body[self.attrNameProcessors('Encrypt')], function(err, data){ 194 | if(err) return res.status(500).end(); 195 | req.body = data; 196 | handle(req, res, next); 197 | }); 198 | }else { 199 | handle(req, res, next); 200 | } 201 | }; 202 | }; 203 | 204 | /** 205 | * 授权公众号的事件推送处理 206 | */ 207 | WxService.prototype.eventHandle = function(handle){ 208 | if(!is(handle, 'function')) handle = this.defaultEventHandle; 209 | var self = this; 210 | return function(req, res, next){ 211 | if(!_validate(req, self.token)) return next(error("validate failure")); 212 | if(req.body[self.attrNameProcessors('Encrypt')]){ 213 | self.decrypt(req.body[self.attrNameProcessors('Encrypt')], function(err, data){ 214 | if(err) return res.status(500).end(); 215 | req.body = data; 216 | req.is_encrypt = true; 217 | extend(res, reply(req, self)); 218 | handle(req, res, next); 219 | }); 220 | }else { 221 | extend(res, reply(req, self)); 222 | handle(req, res, next); 223 | } 224 | }; 225 | }; 226 | 227 | 228 | /** 229 | * 默认存数 Token 函数 230 | */ 231 | WxService.prototype.saveToken = function(token, callback){ 232 | callback = is(callback, 'function') ? callback : function(){}; 233 | this.tokenStore = { 234 | componentAccessToken: token.componentAccessToken, 235 | expireTime: (new Date().getTime()) + (token.expireTime - 10) * 1000 // 过期时间,因网络延迟等,将实际过期时间提前10秒,以防止临界点 236 | }; 237 | if (process.env.NODE_ENV === 'production') { 238 | console.warn('Dont save accessToken in memory, when cluster or multi-computer!'); 239 | } 240 | if(typeof callback === 'function') callback(null, this.tokenStore); 241 | }; 242 | 243 | /** 244 | * 默认获取 Token 函数 245 | */ 246 | WxService.prototype.getToken = function(callback){ 247 | callback = is(callback, 'function') ? callback : function(){}; 248 | if(this.tokenStore){ 249 | if((new Date().getTime()) < this.tokenStore.expireTime) { 250 | callback(null, this.tokenStore); 251 | }else { 252 | return callback(null); 253 | } 254 | }else { 255 | return callback(null); 256 | } 257 | }; 258 | 259 | /** 260 | * 默认存储 ticket 函数 261 | */ 262 | WxService.prototype.saveTicket = function(ticket, callback){ 263 | callback = is(callback, 'function') ? callback : function(){}; 264 | this.ticket = ticket; 265 | callback(null, ticket); 266 | }; 267 | 268 | /** 269 | * 获取 ticket 的默认函数 270 | */ 271 | WxService.prototype.getTicket = function(callback){ 272 | callback = is(callback, 'function') ? callback : function(){}; 273 | callback(null, this.ticket); 274 | }; 275 | 276 | /** 277 | * 授权事件推送的默认处理函数 278 | */ 279 | WxService.prototype.defaultNoticeHandle = function(req, res){ 280 | res.send('success'); 281 | }; 282 | 283 | /** 284 | * 公众号消息事件默认处理函数 285 | */ 286 | WxService.prototype.defaultEventHandle = function(req, res){ 287 | res.send('success'); 288 | }; 289 | 290 | /** 291 | * 公众号消息回复 292 | */ 293 | 294 | // 微信的 media_id 长度可能发送变化 295 | var regex_media_id = /^[\w\_\-]{40,70}$/; 296 | function reply(req, self) { 297 | var wechatidAttr = self.attrNameProcessors('ToUserName'), 298 | openidAttr =self. attrNameProcessors('FromUserName'), 299 | encryptAttr = self.attrNameProcessors('Encrypt'); 300 | 301 | var message, data = req.body, query = req.query; 302 | // 组装message xml 303 | 304 | if (req.is_encrypt && self.crypter) { 305 | message = function(message) { // 需要加密 306 | var encrypt = self.crypter.encrypt('' + (~~(Date.now() / 1000)) + '' + message + ''); 307 | var signature = self.crypter.getSignature(query.timestamp, query.nonce, encrypt); 308 | return '' + query.timestamp + ''; 309 | }; 310 | } else { // 不需要加密 311 | message = function(message) { 312 | return '' + (~~(Date.now() / 1000)) + '' + message + ''; 313 | }; 314 | } 315 | 316 | return { 317 | // 文本消息回复 318 | text: function(text) { 319 | return this.send(message('')); 320 | }, 321 | 322 | // 图片消息回复, image 必须为素材 id 323 | image: function(image) { 324 | if (typeof image === 'string' && image.match(regex_media_id)) { 325 | this.send(message('')); 326 | } else { 327 | throw error('image must be a media id'); 328 | } 329 | }, 330 | 331 | // 音频回复, voice 必须为素材 id 332 | voice: function(voice) { 333 | if (voice.match(regex_media_id)) { 334 | this.send(message('')); 335 | } else { 336 | throw error('voice must be a media id'); 337 | } 338 | }, 339 | 340 | // 视频回复 data.video 必须为微信素材id 341 | video: function(data) { 342 | if (data.video.match(regex_media_id)) { 343 | var video = data.video, title = data.title, description = data.description; 344 | this.send(message('')); 345 | } else { 346 | throw error('data.video must be a media id'); 347 | } 348 | }, 349 | 350 | // 音乐回复, data.music 必须为微信素材id 351 | music: function(data) { 352 | if (data.thumb_media.match(regex_media_id)) { 353 | var title = data.title, description = data.description, music_url = data.music_url, hq_music_url = data.hq_music_url, thumb_media = data.thumb_media; 354 | this.send(message('<![CDATA[' + title + ']]>')); 355 | } else { 356 | throw error('data.music must be a media id'); 357 | } 358 | }, 359 | 360 | // 图文消息 361 | news: function(articles) { 362 | articles = [].concat(articles).map(function(a) { 363 | var title = a.title || '', description = a.description || '', pic_url = a.pic_url || '', url = a.url || ''; 364 | return '<![CDATA[' + title + ']]>'; 365 | }); 366 | this.send(message('' + articles.length + '' + (articles.join('')) + '')); 367 | }, 368 | 369 | // 客服 370 | transfer: function() { 371 | this.send(message('')); 372 | }, 373 | 374 | // 设备消息回复 375 | device: function(content) { 376 | content = (new Buffer(content)).toString('base64'); 377 | this.send(message('' + data[self.attrNameProcessors('SessionID')] + '')); 378 | }, 379 | 380 | // 回复空消息,表示收到请求 381 | ok: function() { 382 | this.status(200).end(); 383 | } 384 | }; 385 | } 386 | 387 | // API 388 | 389 | /** 390 | * 设置 request 请求的 Options 391 | */ 392 | 393 | WxService.prototype.setOpts = function(opts){ 394 | this.defaultOpts = opts; 395 | }; 396 | 397 | /** 398 | * 获取设置的 request 请求配置 399 | */ 400 | WxService.prototype.getOpts = function(){ 401 | return this.defaultOpts; 402 | }; 403 | 404 | /** 405 | * request 中的函数扩张到 Wxservice中 406 | */ 407 | for(var name in request){ 408 | WxService.prototype[name] = request[name]; 409 | } 410 | 411 | /** 412 | * 从微信获取第三方服务的 accessToken 413 | * @param component_appid 第三方服务的 appid 414 | * @param component_appsecret 第三方服务的 appsecret 415 | * @param component_verify_ticket 第三方服的 ticket(审核通过后微信每10分钟推送一次) 416 | * @return 417 | * { 418 | * "component_access_token":"61W3mEpU66027wgNZ_MhGHNQDHnFATkDa9-2llqrMBjUwxRSNPbVsMmyD-yq8wZETSoE5NQgecigDrSHkPtIYA", 419 | * "expires_in":7200 420 | * } 421 | */ 422 | WxService.prototype.getComponentAccessToken = function(callback){ 423 | var self = this; 424 | this.getTicket(function(err, ticket){ 425 | if(err) return callback(err); 426 | if(!ticket) return callback(error('No ticket')); 427 | var json = { 428 | component_appid: self.appid, 429 | component_appsecret: self.appsecret, 430 | component_verify_ticket: ticket 431 | }; 432 | var url = 'https://api.weixin.qq.com/cgi-bin/component/api_component_token'; 433 | self.post(url, json, function(err, data){ 434 | if(err) return callback(err); 435 | var token = {componentAccessToken: data.component_access_token, expireTime: data.expires_in}; 436 | self.saveToken(token); 437 | return callback(null, token); 438 | }); 439 | }); 440 | }; 441 | 442 | /** 443 | * 获取最新,有效的 token 444 | */ 445 | WxService.prototype.getLastComponentAccessToken = function(callback){ 446 | var self = this; 447 | this.getToken(function(err, token){ 448 | if(err) return callback(err); 449 | if(token && token.componentAccessToken && token.expireTime > 0) return callback(null, token); 450 | return self.getComponentAccessToken(callback); 451 | }); 452 | }; 453 | 454 | 455 | /** 456 | * 将对象合并到 WxService.prototype上 457 | * @param {Object} obj 要合并的对象 458 | */ 459 | var api = require('./api'); 460 | var _apiWrapper = function(name, fn){ 461 | WxService.prototype[name] = function(){ 462 | var args = Array.prototype.slice.call(arguments, 0); 463 | var callback = args[args.length - 1]; 464 | if(!is(callback, 'function')) callback = function(){}; 465 | this.getLastComponentAccessToken(function(err, token){ 466 | if(err) return callback(err); 467 | this.component_access_token = token.componentAccessToken; 468 | fn.apply(this, args); 469 | }.bind(this)); 470 | }; 471 | }; 472 | WxService.prototype.apiWrapper = _apiWrapper; 473 | 474 | for (var name in api) { 475 | _apiWrapper.call(this, name, api[name]); 476 | } 477 | 478 | 479 | module.exports = WxService; 480 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weixin-service", 3 | "version": "1.0.6", 4 | "description": "微信公众号服务 API 接口", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "make test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/liuxiaodong/weixin-service.git" 12 | }, 13 | "keywords": [ 14 | "weixin", 15 | "wechat", 16 | "weixin-service", 17 | "service", 18 | "api", 19 | "weixin-api", 20 | "wx", 21 | "wechat-service", 22 | "wechat-api" 23 | ], 24 | "author": "leaf", 25 | "license": "ISC", 26 | "bugs": { 27 | "url": "https://github.com/liuxiaodong/weixin-service/issues" 28 | }, 29 | "homepage": "https://github.com/liuxiaodong/weixin-service", 30 | "dependencies": { 31 | "debug": "^2.2.0", 32 | "raw-body": "^1.3.1", 33 | "urllib": "^2.3.8", 34 | "wechat-crypto": "0.0.2", 35 | "xml2js": "^0.4.4" 36 | }, 37 | "devDependencies": { 38 | "coveralls": "^2.11.2", 39 | "express": "^4.13.1", 40 | "istanbul": "^0.3.17", 41 | "mocha": "^2.2.5", 42 | "mocha-lcov-reporter": "0.0.2", 43 | "q": "^1.4.1", 44 | "should": "^7.0.2", 45 | "supertest": "^1.0.1", 46 | "underscore": "^1.8.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/api.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 调用微信 API 需要正确的 access_token 3 | * 这里测试重写 api 方法,保证在调用微信 API 接口时 url 和 所传参数正确 4 | */ 5 | 6 | var should = require('should'); 7 | var config = require('./config'); 8 | 9 | var componentAccessToken = 'componentAccessToken-test'; 10 | 11 | var weixin = require('../')({ 12 | id: config.id, 13 | appid: config.appid, 14 | appsecret: config.appsecret, 15 | token: config.token, 16 | encrypt_key: config.encrypt_key, 17 | getToken: function(callback){ 18 | callback(null, {componentAccessToken: componentAccessToken, expireTime: 7200}); 19 | } 20 | }); 21 | 22 | weixin.setOpts({timeout: 20000}); 23 | 24 | describe('API', function(){ 25 | 26 | it('setOpts', function(done){ 27 | var opts = weixin.getOpts(); 28 | opts.timeout.should.equal(20000); 29 | done(); 30 | }); 31 | 32 | it('getLastComponentAccessToken', function(done){ 33 | weixin.getLastComponentAccessToken(function(err, token){ 34 | token.componentAccessToken.should.equal(componentAccessToken); 35 | token.expireTime.should.equal(7200); 36 | done(); 37 | }); 38 | }); 39 | 40 | it('getComponentAccessToken without ticket', function(done){ 41 | weixin.getComponentAccessToken(function(err, token){ 42 | err.name.should.equal('WeixinServerError'); 43 | done(); 44 | }); 45 | }); 46 | 47 | it('getComponentAccessToken with invalid ticket', function(done){ 48 | weixin.ticket = 'ticket-test'; 49 | weixin.getComponentAccessToken(function(err, token){ 50 | err.name.should.equal('WeixinServerError'); 51 | done(); 52 | }); 53 | }); 54 | 55 | it('custom getComponentAccessToken', function(done){ 56 | weixin.getComponentAccessToken = function(callback) { 57 | var self = this; 58 | this.getTicket(function(err, ticket) { 59 | if (err) return callback(err); 60 | if (!ticket) return callback(error('No ticket')); 61 | var json = { 62 | component_appid: self.appid, 63 | component_appsecret: self.appsecret, 64 | component_verify_ticket: ticket 65 | }; 66 | return callback(null, json); 67 | }); 68 | }; 69 | 70 | weixin.getComponentAccessToken(function(err, token){ 71 | token.component_appid.should.equal(config.appid); 72 | token.component_appsecret.should.equal(config.appsecret); 73 | token.component_verify_ticket.should.equal('ticket-test'); 74 | done(); 75 | }); 76 | 77 | }); 78 | 79 | it('preAuthCode with invalid component_access_token', function(done){ 80 | weixin.preAuthCode(function(err, data){ 81 | err.name.should.equal('WeixinServerError'); 82 | done(); 83 | }); 84 | }); 85 | 86 | it('custom preAuthCode', function(done){ 87 | var pre_url = 'https://api.weixin.qq.com/cgi-bin/component/api_create_preauthcode?component_access_token='; 88 | weixin.preAuthCode = function(callback){ 89 | var json = { 90 | component_appid: this.appid 91 | }; 92 | var url = pre_url + this.component_access_token; 93 | this.post(url, json, function(){ 94 | callback(null, {json:json, url:url}); 95 | }); 96 | }; 97 | 98 | weixin.preAuthCode(function(err, data){ 99 | data.json.component_appid.should.equal(config.appid); 100 | data.url.should.equal(pre_url + componentAccessToken); 101 | done(); 102 | }); 103 | }); 104 | 105 | it('getAuthorizationiInfo with invalid component_access_token', function(done){ 106 | var authorization_code = '123456'; 107 | weixin.getAuthorizationiInfo(authorization_code, function(err, data){ 108 | err.name.should.equal('WeixinServerError'); 109 | done(); 110 | }); 111 | }); 112 | 113 | it('custom getAuthorizationiInfo', function(done){ 114 | var pre_url = 'https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token='; 115 | weixin.getAuthorizationiInfo = function(authorization_code, callback){ 116 | var json = { 117 | component_appid: this.appid, 118 | authorization_code: authorization_code 119 | }; 120 | var url = pre_url + this.component_access_token; 121 | callback(null, {url:url, json: json}); 122 | }; 123 | 124 | var authorization_code = '123456'; 125 | weixin.getAuthorizationiInfo(authorization_code, function(err, data){ 126 | data.json.component_appid.should.equal(config.appid); 127 | data.json.authorization_code.should.equal(authorization_code); 128 | data.url.should.equal(pre_url + componentAccessToken); 129 | done(); 130 | }); 131 | }); 132 | 133 | it('refreshToken with invalid component_access_token', function(done){ 134 | var authorizer_appid = 'wx123456'; 135 | var authorizer_refresh_token = 'refresh-token-123456'; 136 | weixin.refreshToken(authorizer_appid, authorizer_refresh_token, function(err, data){ 137 | err.name.should.equal('WeixinServerError'); 138 | done(); 139 | }); 140 | }); 141 | 142 | 143 | it('custom refreshToken', function(done){ 144 | var pre_url = 'https://api.weixin.qq.com/cgi-bin/component/api_authorizer_token?component_access_token='; 145 | weixin.refreshToken = function(authorizer_appid, authorizer_refresh_token, callback){ 146 | var json = { 147 | component_appid: this.appid, 148 | authorizer_appid: authorizer_appid, 149 | authorizer_refresh_token: authorizer_refresh_token 150 | }; 151 | var url = pre_url + this.component_access_token; 152 | callback(null, {json:json, url:url}); 153 | }; 154 | 155 | var authorizer_appid = 'wx123456'; 156 | var authorizer_refresh_token = 'refresh-token-123456'; 157 | weixin.refreshToken(authorizer_appid, authorizer_refresh_token, function(err, data){ 158 | data.json.component_appid.should.equal(config.appid); 159 | data.json.authorizer_appid.should.equal(authorizer_appid); 160 | data.json.authorizer_refresh_token.should.equal(authorizer_refresh_token); 161 | data.url.should.equal(pre_url + componentAccessToken); 162 | done(); 163 | }); 164 | }); 165 | 166 | it('getAuthorizerInfo with invalid component_access_token', function(done){ 167 | var authorizer_appid = 'wx123456'; 168 | weixin.getAuthorizerInfo(authorizer_appid, function(err, data){ 169 | err.name.should.equal('WeixinServerError'); 170 | done(); 171 | }); 172 | }); 173 | 174 | it('custom getAuthorizerInfo', function(done){ 175 | var pre_url = 'https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_info?component_access_token='; 176 | weixin.getAuthorizerInfo = function(authorizer_appid, callback){ 177 | var json = { 178 | component_appid: this.appid, 179 | authorizer_appid: authorizer_appid 180 | }; 181 | var url = pre_url + this.component_access_token; 182 | callback(null, {json: json, url:url}); 183 | }; 184 | 185 | var authorizer_appid = 'wx123456'; 186 | weixin.getAuthorizerInfo(authorizer_appid, function(err, data){ 187 | data.json.component_appid.should.equal(config.appid); 188 | data.json.authorizer_appid.should.equal(authorizer_appid); 189 | done(); 190 | }); 191 | }); 192 | 193 | it('getAuthorizerOption with invalid component_access_token', function(done){ 194 | var authorizer_appid = 'wx123456'; 195 | var option_name = 'voice_recognize'; 196 | weixin.getAuthorizerOption(authorizer_appid, option_name, function(err, data){ 197 | err.name.should.equal('WeixinServerError'); 198 | done(); 199 | }); 200 | }); 201 | 202 | it('custom getAuthorizerOption', function(done){ 203 | var pre_url = 'https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_option?component_access_token='; 204 | weixin.getAuthorizerOption = function(authorizer_appid, option_name, callback){ 205 | var json = { 206 | component_appid: this.appid, 207 | authorizer_appid: authorizer_appid, 208 | option_name: option_name 209 | }; 210 | var url = pre_url + this.component_access_token; 211 | callback(null, {json: json, url:url}); 212 | }; 213 | 214 | var authorizer_appid = 'wx123456'; 215 | var option_name = 'voice_recognize'; 216 | weixin.getAuthorizerOption(authorizer_appid, option_name, function(err, data){ 217 | data.json.component_appid.should.equal(config.appid); 218 | data.json.authorizer_appid.should.equal(authorizer_appid); 219 | data.json.option_name.should.equal(option_name); 220 | data.url.should.equal(pre_url + componentAccessToken); 221 | done(); 222 | }); 223 | }); 224 | 225 | it('putAuthorizerOption with invalid component_access_token', function(done){ 226 | var authorizer_appid = 'wx123456'; 227 | var option_name = 'voice_recognize'; 228 | var option_value = 'option_value_value'; 229 | weixin.putAuthorizerOption(authorizer_appid, option_name, option_value, function(err, data){ 230 | err.name.should.equal('WeixinServerError'); 231 | done(); 232 | }); 233 | }); 234 | 235 | it('costom putAuthorizerOption', function(done){ 236 | var pre_url = 'https://api.weixin.qq.com/cgi-bin/component/api_set_authorizer_option?component_access_token='; 237 | weixin.putAuthorizerOption = function(authorizer_appid, option_name, option_value, callback){ 238 | var json = { 239 | component_appid: this.appid, 240 | authorizer_appid: authorizer_appid, 241 | option_name: option_name, 242 | option_value: option_value 243 | }; 244 | var url = pre_url + this.component_access_token; 245 | callback(null, {json: json, url:url}); 246 | }; 247 | 248 | var authorizer_appid = 'wx123456'; 249 | var option_name = 'voice_recognize'; 250 | var option_value = 'option_value_value'; 251 | weixin.putAuthorizerOption(authorizer_appid, option_name, option_value, function(err, data){ 252 | data.json.component_appid.should.equal(config.appid); 253 | data.json.authorizer_appid.should.equal(authorizer_appid); 254 | data.json.option_name.should.equal(option_name); 255 | data.json.option_value.should.equal(option_value); 256 | data.url.should.equal(pre_url + componentAccessToken); 257 | done(); 258 | }); 259 | }); 260 | 261 | it('getOauthAccessToken with invalid component_access_token', function(done){ 262 | var authorizer_appid = 'wx123456'; 263 | var code = 'code987654'; 264 | weixin.getOauthAccessToken(authorizer_appid, code, function(err, data){ 265 | err.name.should.equal('WeixinServerError'); 266 | done(); 267 | }); 268 | }); 269 | 270 | it('custom getOauthAccessToken', function(done){ 271 | weixin.getOauthAccessToken = function(authorizer_appid, code, callback){ 272 | var url = 'https://api.weixin.qq.com/sns/oauth2/component/access_token?appid=' + authorizer_appid + '&code=' + code + '&grant_type=authorization_code&component_appid=' + this.appid + '&component_access_token=' + this.component_access_token; 273 | callback(null, {url:url}); 274 | }; 275 | 276 | var authorizer_appid = 'wx123456'; 277 | var code = 'code987654'; 278 | weixin.getOauthAccessToken(authorizer_appid, code, function(err, data){ 279 | data.url.should.equal('https://api.weixin.qq.com/sns/oauth2/component/access_token?appid=' + authorizer_appid + '&code=' + code + '&grant_type=authorization_code&component_appid=' + config.appid + '&component_access_token=' + componentAccessToken); 280 | done(); 281 | }); 282 | }); 283 | 284 | it('refreshOauthAccessToken with invalid component_access_token', function(done){ 285 | var authorizer_appid = 'wx123456'; 286 | var refresh_token = 'refresh_token123456'; 287 | weixin.refreshOauthAccessToken(authorizer_appid, refresh_token, function(err, data){ 288 | err.name.should.equal('WeixinServerError'); 289 | done(); 290 | }); 291 | }); 292 | 293 | it('custom refreshOauthAccessToken', function(done){ 294 | weixin.refreshOauthAccessToken = function(authorizer_appid, refresh_token, callback){ 295 | var url = 'https://api.weixin.qq.com/sns/oauth2/component/refresh_token?appid=' + authorizer_appid + '&grant_type=refresh_token&component_appid=' + this.appid + '&component_access_token=' + this.component_access_token + '&refresh_token=' + refresh_token; 296 | callback(null, {url: url}); 297 | }; 298 | 299 | var authorizer_appid = 'wx123456'; 300 | var refresh_token = 'refresh_token123456'; 301 | weixin.refreshOauthAccessToken(authorizer_appid, refresh_token, function(err, data){ 302 | data.url.should.equal('https://api.weixin.qq.com/sns/oauth2/component/refresh_token?appid=' + authorizer_appid + '&grant_type=refresh_token&component_appid=' + config.appid + '&component_access_token=' + componentAccessToken + '&refresh_token=' + refresh_token); 303 | done(); 304 | }); 305 | }); 306 | 307 | it('getOauthInfo with invalid component_access_token', function(done){ 308 | var authorizer_appid = 'wx123456'; 309 | var openid = 'openid123456'; 310 | weixin.getOauthInfo(authorizer_appid, openid, function(err, data){ 311 | err.name.should.equal('WeixinServerError'); 312 | done(); 313 | }); 314 | }); 315 | 316 | it('getOauthInfo with invalid component_access_token', function(done){ 317 | var access_token = 'access_token9876543210'; 318 | var openid = 'openid123456'; 319 | weixin.getOauthInfo(access_token, openid, function(err, data){ 320 | err.name.should.equal('WeixinServerError'); 321 | done(); 322 | }); 323 | }); 324 | 325 | it('getOauthInfo with invalid component_access_token', function(done){ 326 | weixin.getOauthInfo = function(access_token, openid, callback){ 327 | var url = 'https://api.weixin.qq.com/sns/userinfo?access_token=' + access_token + '&openid=' + openid + '&lang=zh_CN'; 328 | callback(null, {url:url}); 329 | }; 330 | 331 | var access_token = 'access_token9876543210'; 332 | var openid = 'openid123456'; 333 | weixin.getOauthInfo(access_token, openid, function(err, data){ 334 | data.url.should.equal('https://api.weixin.qq.com/sns/userinfo?access_token=' + access_token + '&openid=' + openid + '&lang=zh_CN'); 335 | done(); 336 | }); 337 | }); 338 | 339 | }); 340 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "id": "gh_145200f232a3", 3 | "appid": "wxefaae69d7cad1111", 4 | "appsecret": "0c79e1fa963cd80cc0be99b20a18f111", 5 | "token": "weixin-service-test", 6 | "encrypt_key": "DaLEm2rO4NC83sJoR07gekQd1WfZHt5pGVYMF6UT111" 7 | }; -------------------------------------------------------------------------------- /test/event.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var config = require('./config'); 3 | var Helper = require('./helper'); 4 | 5 | var url = '/weixin/' + config.appid + '/event'; 6 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 7 | var media_id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_-a'; 8 | var video = {video: media_id, title:'video title', description: 'video description'}; 9 | var music = {title: 'music title', description: 'music description', music_url: 'music url', hq_music_url: 'hq music url', thumb_media: media_id}; 10 | var news = [ 11 | { 12 | title: 'news title', 13 | description: 'news description', 14 | pic_url: 'news pic url', 15 | url: 'news url' 16 | }, 17 | { 18 | title: 'news title 1', 19 | description: 'news description 1', 20 | pic_url: 'news pic url 1', 21 | url: 'news url 1' 22 | } 23 | ]; 24 | 25 | var helper; 26 | beforeEach(function(){ 27 | helper = new Helper(config, url); 28 | }); 29 | 30 | 31 | describe('Auth Event Handle', function(){ 32 | 33 | it('text', function(done){ 34 | var msg = '1234567890123456'; 35 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 36 | req.body.ToUserName.should.equal(config.id); 37 | req.body.FromUserName.should.equal(openid); 38 | req.body.Content.should.equal('this is a test'); 39 | res.text('event handle'); 40 | }); 41 | 42 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 43 | should.not.exist(err); 44 | ret.content.should.equal('event handle'); 45 | }); 46 | helper.doneWapper(p1, p2, done); 47 | }); 48 | 49 | it('image', function(done){ 50 | var msg = '1234567890123456'; 51 | 52 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 53 | req.body.PicUrl.should.equal('http://www.pic.com/url'); 54 | req.body.MediaId.should.equal('123456'); 55 | res.should.have.property('image'); 56 | res.image(media_id); 57 | }); 58 | 59 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 60 | should.not.exist(err); 61 | ret.image.media_id.should.equal(media_id); 62 | }); 63 | 64 | helper.doneWapper(p1, p2, done); 65 | }); 66 | 67 | it('voice', function(done){ 68 | var msg = '1234567890123456'; 69 | 70 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 71 | req.body.MediaId.should.equal('123456'); 72 | res.should.have.property('voice'); 73 | res.voice(media_id); 74 | }); 75 | 76 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 77 | should.not.exist(err); 78 | ret.voice.media_id.should.equal(media_id); 79 | }); 80 | 81 | helper.doneWapper(p1, p2, done); 82 | }); 83 | 84 | it('video', function(done){ 85 | var msg = '1234567890123456'; 86 | 87 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 88 | req.body.MediaId.should.equal('123457'); 89 | res.should.have.property('video'); 90 | res.video(video); 91 | }); 92 | 93 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 94 | should.not.exist(err); 95 | ret.video.media_id.should.equal(video.video); 96 | ret.video.title.should.equal(video.title); 97 | ret.video.description.should.equal(video.description); 98 | }); 99 | 100 | helper.doneWapper(p1, p2, done); 101 | }); 102 | 103 | it('shortvideo', function(done){ 104 | var msg = '1234567890123456'; 105 | 106 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 107 | req.body.MediaId.should.equal('123456'); 108 | res.should.have.property('music'); 109 | res.music(music); 110 | }); 111 | 112 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 113 | should.not.exist(err); 114 | ret.music.title.should.equal(music.title); 115 | ret.music.description.should.equal(music.description); 116 | ret.music.music_url.should.equal(music.music_url); 117 | ret.music.hqmusic_url.should.equal(music.hq_music_url); 118 | ret.music.thumb_media_id.should.equal(music.thumb_media); 119 | }); 120 | 121 | helper.doneWapper(p1, p2, done); 122 | }); 123 | 124 | it('location', function(done){ 125 | var msg = '23.134521113.358803201234567890123456'; 126 | 127 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 128 | req.body.Label.should.equal('位置信息'); 129 | res.should.have.property('news'); 130 | res.news(news); 131 | }); 132 | 133 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 134 | should.not.exist(err); 135 | ret.articles.item.should.have.lengthOf(2); 136 | }); 137 | 138 | helper.doneWapper(p1, p2, done); 139 | }); 140 | 141 | it('link', function(done){ 142 | var msg = '<![CDATA[公众平台官网链接]]>1234567890123456'; 143 | 144 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 145 | req.body.Title.should.equal('公众平台官网链接'); 146 | res.text('link'); 147 | }); 148 | 149 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 150 | should.not.exist(err); 151 | ret.content.should.equal('link'); 152 | }); 153 | 154 | helper.doneWapper(p1, p2, done); 155 | }); 156 | 157 | // 关注公众号 158 | it('subscribe', function(done){ 159 | var msg = ''; 160 | 161 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 162 | req.body.Event.should.equal('subscribe'); 163 | req.body.FromUserName.should.equal(openid); 164 | res.text('subscribe success'); 165 | }); 166 | 167 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 168 | should.not.exist(err); 169 | ret.content.should.equal('subscribe success'); 170 | }); 171 | 172 | helper.doneWapper(p1, p2, done); 173 | }); 174 | 175 | // 取消关注 176 | it('unsubscribe', function(done){ 177 | var msg = ''; 178 | 179 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 180 | req.body.Event.should.equal('unsubscribe'); 181 | req.body.FromUserName.should.equal(openid); 182 | res.text('unsubscribe success'); 183 | }); 184 | 185 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 186 | should.not.exist(err); 187 | ret.content.should.equal('unsubscribe success'); 188 | }); 189 | 190 | helper.doneWapper(p1, p2, done); 191 | }); 192 | 193 | // 用户未关注时,扫描带参数二维码事件 194 | it('scan and subscribe', function(done){ 195 | var msg = ''; 196 | 197 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 198 | req.body.Event.should.equal('subscribe'); 199 | req.body.FromUserName.should.equal(openid); 200 | req.body.EventKey.should.equal('qrscene_123123'); 201 | res.text('scan and subscribe success'); 202 | }); 203 | 204 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 205 | should.not.exist(err); 206 | ret.content.should.equal('scan and subscribe success'); 207 | }); 208 | 209 | helper.doneWapper(p1, p2, done); 210 | }); 211 | 212 | // 扫描带参数二维码事件 213 | it('scan', function(done){ 214 | var msg = ''; 215 | 216 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 217 | req.body.Event.should.equal('SCAN'); 218 | req.body.FromUserName.should.equal(openid); 219 | req.body.EventKey.should.equal('SCENE_VALUE'); 220 | res.text('scan success'); 221 | }); 222 | 223 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 224 | should.not.exist(err); 225 | ret.content.should.equal('scan success'); 226 | }); 227 | 228 | helper.doneWapper(p1, p2, done); 229 | }); 230 | 231 | // 上报地理位置, 经纬度 232 | it('reported location', function(done){ 233 | var msg = '23.137466113.352425119.385040'; 234 | 235 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 236 | req.body.Event.should.equal('LOCATION'); 237 | req.body.Latitude.should.equal('23.137466'); 238 | res.text('reported location'); 239 | }); 240 | 241 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 242 | should.not.exist(err); 243 | ret.content.should.equal('reported location'); 244 | }); 245 | 246 | helper.doneWapper(p1, p2, done); 247 | }); 248 | 249 | // 菜单栏点击事件 250 | it('menu click', function(done){ 251 | var msg = ''; 252 | 253 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 254 | req.body.Event.should.equal('CLICK'); 255 | req.body.EventKey.should.equal('click_test'); 256 | res.text('click'); 257 | }); 258 | 259 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 260 | should.not.exist(err); 261 | ret.content.should.equal('click'); 262 | }); 263 | 264 | helper.doneWapper(p1, p2, done); 265 | }); 266 | 267 | // 菜单栏页面跳转 268 | it('menu view', function(done){ 269 | var msg = ''; 270 | 271 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 272 | req.body.Event.should.equal('VIEW'); 273 | req.body.EventKey.should.equal('http://www.example.com'); 274 | res.text('view'); 275 | }); 276 | 277 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 278 | should.not.exist(err); 279 | ret.content.should.equal('view'); 280 | }); 281 | 282 | helper.doneWapper(p1, p2, done); 283 | }); 284 | 285 | 286 | // 卡券审核同通过 287 | it('card_pass_check', function(done){ 288 | var msg = ''; 289 | 290 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 291 | req.body.CardId.should.equal('123456789'); 292 | res.text('card_pass_check success'); 293 | }); 294 | 295 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 296 | should.not.exist(err); 297 | ret.content.should.equal('card_pass_check success'); 298 | }); 299 | helper.doneWapper(p1, p2, done); 300 | }); 301 | 302 | // 审核未通过 303 | it('card_not_pass_check', function(done){ 304 | var msg = ''; 305 | 306 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 307 | req.body.CardId.should.equal('123456789'); 308 | res.text('card_not_pass_check'); 309 | }); 310 | 311 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 312 | should.not.exist(err); 313 | ret.content.should.equal('card_not_pass_check'); 314 | }); 315 | helper.doneWapper(p1, p2, done); 316 | }); 317 | 318 | // 用户领取卡券 319 | it('user_get_card', function(done){ 320 | var msg = '10'; 321 | 322 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 323 | req.body.CardId.should.equal('123456789'); 324 | req.body.UserCardCode.should.equal('12312312'); 325 | res.text('user_get_card'); 326 | }); 327 | 328 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 329 | should.not.exist(err); 330 | ret.content.should.equal('user_get_card'); 331 | }); 332 | helper.doneWapper(p1, p2, done); 333 | }); 334 | 335 | // 删除卡券 336 | it('user_del_card', function(done){ 337 | var msg = ''; 338 | 339 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 340 | req.body.CardId.should.equal('123456789'); 341 | req.body.UserCardCode.should.equal('12312312'); 342 | res.text('user_del_card'); 343 | }); 344 | 345 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 346 | should.not.exist(err); 347 | ret.content.should.equal('user_del_card'); 348 | }); 349 | helper.doneWapper(p1, p2, done); 350 | }); 351 | 352 | // 用户核销卡券 353 | it('user_consume_card', function(done){ 354 | var msg = ''; 355 | 356 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 357 | req.body.CardId.should.equal('123456789'); 358 | req.body.UserCardCode.should.equal('12312312'); 359 | req.body.ConsumeSource.should.equal('FROM_API'); 360 | res.text('user_consume_card'); 361 | }); 362 | 363 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 364 | should.not.exist(err); 365 | ret.content.should.equal('user_consume_card'); 366 | }); 367 | helper.doneWapper(p1, p2, done); 368 | }); 369 | 370 | // 进入会员卡事件推送 371 | it('user_view_card', function(done){ 372 | var msg = ''; 373 | 374 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 375 | req.body.CardId.should.equal('123456789'); 376 | req.body.UserCardCode.should.equal('12312312'); 377 | res.text('user_view_card'); 378 | }); 379 | 380 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 381 | should.not.exist(err); 382 | ret.content.should.equal('user_view_card'); 383 | }); 384 | helper.doneWapper(p1, p2, done); 385 | }); 386 | 387 | // 从卡券进入公众号会话事件推送 388 | it('user_enter_session_from_card', function(done){ 389 | var msg = ''; 390 | 391 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 392 | req.body.CardId.should.equal('123456789'); 393 | req.body.UserCardCode.should.equal('12312312'); 394 | res.text('user_enter_session_from_card'); 395 | }); 396 | 397 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 398 | should.not.exist(err); 399 | ret.content.should.equal('user_enter_session_from_card'); 400 | }); 401 | helper.doneWapper(p1, p2, done); 402 | }); 403 | 404 | // 设备消息 405 | it('text', function(done){ 406 | var msg = '001122334455'; 407 | 408 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 409 | req.body.DeviceType.should.equal(config.id); 410 | req.body.DeviceID.should.equal('123456'); 411 | req.body.Content.should.equal('device_text_test'); 412 | res.should.have.property('device'); 413 | res.device('reply device text'); 414 | }); 415 | 416 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 417 | should.not.exist(err); 418 | ret.content.should.equal('cmVwbHkgZGV2aWNlIHRleHQ='); // ‘reply device text’ base64 编码后的内容 419 | }); 420 | 421 | helper.doneWapper(p1, p2, done); 422 | }); 423 | 424 | // 摇周边事件推送 425 | it('shake', function(done){ 426 | var msg = '111111110.05722222222166.8163333333315.013'; 427 | 428 | var p1 = helper.handleWrapper('post', 'eventHandle', function(req, res){ 429 | req.body.ChosenBeacon.Uuid.should.equal('121212121212'); 430 | req.body.ChosenBeacon.Major.should.equal('1111'); 431 | req.body.ChosenBeacon.Minor.should.equal('1111'); 432 | req.body.ChosenBeacon.Distance.should.equal('0.057'); 433 | req.body.AroundBeacons.AroundBeacon.should.have.lengthOf(2); 434 | res.text('shakearoundusershake'); 435 | }); 436 | 437 | var p2 = helper.requestWrapper('post', url, config, msg, openid, true, function(err, ret){ 438 | should.not.exist(err); 439 | ret.content.should.equal('shakearoundusershake'); 440 | }); 441 | 442 | helper.doneWapper(p1, p2, done); 443 | }); 444 | 445 | }); 446 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var _ = require('underscore'); 3 | var util = require('../lib/util'); 4 | var Weixin = require('../'); 5 | var Request = require('./request'); 6 | 7 | var __slice = [].slice; 8 | var Helper = module.exports = function(options, url){ 9 | var defaultOptions = { 10 | attrNameProcessors: 'keep', 11 | url: url 12 | }; 13 | options = options || {}; 14 | _.extend(defaultOptions, options); 15 | this.app = require('express')(); 16 | this.wxs = new Weixin(defaultOptions); 17 | this.request = new Request(this.app); 18 | }; 19 | 20 | Helper.prototype.handleWrapper = function(method, handleName, handle){ 21 | var deferred = Q.defer(); 22 | var wxs = this.wxs; 23 | handle = wxs[handleName](handle); 24 | var callbackWrapper = function(){ 25 | try{ 26 | handle.apply(wxs, arguments); 27 | }catch(e){ 28 | return deferred.reject(e); 29 | } 30 | return deferred.resolve(); 31 | }; 32 | 33 | this.app[method](this.wxs.url, [wxs.bodyParserMiddlewares()], callbackWrapper); 34 | return deferred.promise; 35 | }; 36 | 37 | Helper.prototype.requestWrapper = function(){ 38 | var deferred = Q.defer(); 39 | var args = __slice.call(arguments); 40 | var method = args.shift(); 41 | var cb = args.pop(); 42 | 43 | var callbackWrapper = function(){ 44 | try{ 45 | cb.apply({}, arguments); 46 | }catch(e){ 47 | return deferred.reject(e); 48 | } 49 | return deferred.resolve(); 50 | }; 51 | 52 | args.push(callbackWrapper); 53 | this.request[method].apply(this.request, args); 54 | return deferred.promise; 55 | }; 56 | 57 | Helper.prototype.doneWapper = function(){ 58 | var args = __slice.call(arguments); 59 | var done = args.pop(); 60 | Q.all(args).then(function(){ 61 | done(); 62 | }, function(err){ 63 | done(err); 64 | }); 65 | }; -------------------------------------------------------------------------------- /test/notice.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var config = require('./config'); 3 | var Helper = require('./helper'); 4 | 5 | var url = '/weixin/notice'; 6 | var ticket; 7 | var helper; 8 | beforeEach(function(){ 9 | config.saveTicket = function(str){ 10 | ticket = str; 11 | }; 12 | helper = new Helper(config, url); 13 | }); 14 | 15 | describe('Auth Notice', function(){ 16 | 17 | it('component_verify_ticket', function(done){ 18 | var msg = 'component_verify_ticket123456789987654321'; 19 | var p1 = helper.handleWrapper('post', 'noticeHandle', function(req, res){ 20 | res.send('notice handle'); 21 | }); 22 | 23 | var p2 = helper.requestWrapper('post', url, config, msg, true, function(err, ret){ 24 | should.not.exist(err); 25 | ticket.should.equal('123456789987654321'); 26 | }); 27 | helper.doneWapper(p1, p2, done); 28 | }); 29 | 30 | it('unauthorized', function(done){ 31 | var msg = 'unauthorizedwx234829348'; 32 | var p1 = helper.handleWrapper('post', 'noticeHandle', function(req, res){ 33 | req.body.AppId.should.equal(config.appid); 34 | req.body.InfoType.should.equal('unauthorized'); 35 | req.body.AuthorizerAppid.should.equal('wx234829348'); 36 | res.send('unauthorized success'); 37 | }); 38 | 39 | var p2 = helper.requestWrapper('post', url, config, msg, true, function(err, ret){ 40 | should.not.exist(err); 41 | ret.should.equal('unauthorized success'); 42 | }); 43 | helper.doneWapper(p1, p2, done); 44 | }); 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /test/request.js: -------------------------------------------------------------------------------- 1 | var request = require('supertest'); 2 | var WXBizMsgCrypt = require('wechat-crypto'); 3 | var crypto = require('crypto'); 4 | var xml2js = require('xml2js'); 5 | 6 | 7 | /* 8 | * 生成随机字符串 9 | */ 10 | var createNonceStr = function () { 11 | return Math.random().toString(36).substr(2, 15); 12 | }; 13 | 14 | /*! 15 | * 生成时间戳 16 | */ 17 | var createTimestamp = function () { 18 | return parseInt(new Date().getTime() / 1000, 0) + ''; 19 | }; 20 | 21 | var createXml = function (config, msg, timestamp, openid, need_encrypt){ 22 | var xml; 23 | if(typeof openid === 'string'){ 24 | xml = '' + timestamp + '' + msg + ''; 25 | }else{ 26 | xml = '' + config.appid + '' + timestamp + '' + msg + ''; 27 | } 28 | if(!need_encrypt) return xml; 29 | var crypter = new WXBizMsgCrypt(config.token, config.encrypt_key, config.appid); 30 | var encrypt = crypter.encrypt(xml); 31 | xml = ''; 32 | return xml; 33 | }; 34 | 35 | var createSign = function(token, timestamp, nonce){ 36 | var str = [token, timestamp, nonce].sort().join(''); 37 | return crypto.createHash('sha1').update(str).digest('hex'); 38 | }; 39 | 40 | var options = { 41 | async: true, 42 | explicitArray: true, 43 | normalize: true, 44 | trim: true 45 | }; 46 | 47 | var formatStr = function(str){ 48 | return str.trim().replace(/([a-z\d])([A-Z]+)/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase(); 49 | }; 50 | 51 | var _format = function(data){ 52 | if(data){ 53 | for(var p in data){ 54 | var prot = p; 55 | if(typeof prot === 'string') prot = formatStr(prot); 56 | if(prot !== p){ 57 | data[prot] = data[p]; 58 | delete data[p]; 59 | } 60 | if(typeof data[prot] === 'object') { 61 | if((Object.prototype.toString.call(data[prot]) === '[object Array]') && data[prot].length === 1){ 62 | data[prot] = data[prot][0]; 63 | } 64 | _format(data[prot]); 65 | } 66 | } 67 | } 68 | }; 69 | 70 | var get = function (url, token, echostr, callback){ 71 | if(typeof echostr === 'callback') { 72 | callback = echostr; 73 | echostr = createNonceStr(); 74 | } 75 | var timestamp = createTimestamp(); 76 | var nonce = createNonceStr(); 77 | var signature = createSign(token, timestamp, nonce); 78 | url = url + '?signature=' + signature + '×tamp=' + timestamp + '&nonce=' + nonce + '&echostr=' +echostr; 79 | this.request.get(url) 80 | .end(function(err, res){ 81 | if(err) return callback(err); 82 | if(res.text !== echostr) return callback('echostr error'); 83 | return callback(null, res.text); 84 | }); 85 | }; 86 | 87 | var post = function (url, config, msg, openid, need_encrypt, callback){ 88 | if(typeof openid === 'function'){ 89 | callback = openid; 90 | openid = null; 91 | need_encrypt = false; 92 | } 93 | if(typeof need_encrypt === 'function'){ 94 | callback = need_encrypt; 95 | if(typeof openid === 'string'){ 96 | need_encrypt = false; 97 | }else { 98 | need_encrypt = openid; 99 | } 100 | } 101 | var timestamp = createTimestamp(); 102 | var nonce = createNonceStr(); 103 | var signature = createSign(config.token, timestamp, nonce); 104 | var xml = createXml(config, msg, timestamp, openid, need_encrypt); 105 | url = url + '?signature=' + signature + '×tamp=' + timestamp + '&nonce=' + nonce; 106 | if(need_encrypt) url += '&encrypt_type=aes'; 107 | this.request.post(url) 108 | .set('Content-Type', 'application/xml') 109 | .send(xml) 110 | .end(function(err, res){ 111 | if(err) return callback(err); 112 | var xml = res.text; 113 | xml2js.parseString(xml, options, function(err, ret){ 114 | if(err || !ret || !ret.xml) return callback(null, xml); 115 | var result = ret.xml; 116 | _format(result); 117 | if(!result.encrypt) return callback(null, result); 118 | if(result.encrypt){ 119 | var crypter = new WXBizMsgCrypt(config.token, config.encrypt_key, config.appid); 120 | var message = crypter.decrypt(result.encrypt).message; 121 | if(!message) return callback(result); 122 | xml2js.parseString(message, options, function(err, ret){ 123 | if(err || !ret || !ret.xml) return callback(null, result); 124 | var data = ret.xml; 125 | _format(data); 126 | return callback(null, data); 127 | }); 128 | } 129 | }); 130 | }); 131 | }; 132 | 133 | 134 | var Request = module.exports = function(base){ 135 | if (!(this instanceof Request)) { 136 | return new Request(base); 137 | } 138 | 139 | this.request = request(base); 140 | this.post = post; 141 | this.get = get; 142 | }; 143 | 144 | -------------------------------------------------------------------------------- /test/signature.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var config = require('./config'); 3 | var Helper = require('./helper'); 4 | 5 | var url = '/weixin/notice'; 6 | var helper; 7 | beforeEach(function(){ 8 | helper = new Helper(config, url); 9 | }); 10 | 11 | describe('Signature', function(){ 12 | 13 | it('verify', function(done){ 14 | var p1 = helper.handleWrapper('get', 'enable'); 15 | 16 | var p2 = helper.requestWrapper('get', url, config.token, 'echostr', function(err, ret){ 17 | should.not.exist(err); 18 | ret.should.equal('echostr'); 19 | }); 20 | helper.doneWapper(p1, p2, done); 21 | }); 22 | 23 | }); 24 | --------------------------------------------------------------------------------