├── .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 | [](https://travis-ci.org/liuxiaodong/weixin-service)
5 | [](https://coveralls.io/github/liuxiaodong/weixin-service)
6 | [](https://codeclimate.com/github/liuxiaodong/weixin-service)
7 | [](https://david-dm.org/liuxiaodong/weixin-service)
8 | [](https://david-dm.org/liuxiaodong/weixin-service#info=devDependencies)
9 | [](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(''));
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 ' ';
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 = '
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 |
--------------------------------------------------------------------------------