├── index.js ├── package.json ├── lib ├── db_schema.js ├── wxthirdparty.js ├── query.js ├── Client.js ├── hooks │ └── index.js ├── O.js └── ThirdpartyServer.js └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/wxthirdparty'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies":{ 3 | "xml2js" : ">=0.4.8", 4 | "orm":">=2.1.25", 5 | "wechat-crypto":">=0.0.2", 6 | "wechat-api":">=1.9.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/db_schema.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'ComponentSecrets':{ 3 | table: 'component_secrets', 4 | columns: { 5 | 'ticket' : String, 6 | 'ticket_updated_at' : Number, 7 | 'access_token' : String, 8 | 'access_token_updated_at' : Number, 9 | 'access_token_expires_in' : Number, 10 | 'preauthcode' : String, 11 | 'preauthcode_updated_at' : Number, 12 | 'preauthcode_expires_in' : Number 13 | } 14 | }, 15 | 'WxUserInfo':{ 16 | table: 'wx_users_info', 17 | columns: { 18 | appid:String, 19 | access_token:String, 20 | access_token_updated_at:Date, 21 | access_token_expires_in:Date, 22 | refresh_token:String, 23 | nick_name:String, 24 | head_img:String, 25 | service_type_id:Number, 26 | verify_type_info:String, 27 | user_name:String, 28 | alias:String, 29 | wx_token:String 30 | } 31 | }, 32 | 'WxFollowers':{ 33 | table: 'wx_followers', 34 | columns: { 35 | wx_token: String, 36 | openid: String 37 | } 38 | }, 39 | 'WxFollowerInfo':{ 40 | table: 'wx_followers_info', 41 | columns: { 42 | "subscribe": Number, 43 | "openid": String, 44 | "nickname": String, 45 | "sex": Number, 46 | "language": String, 47 | "city": String, 48 | "province": String, 49 | "country": String, 50 | "headimgurl": String, 51 | "subscribe_time": Date, 52 | "unionid": String, 53 | "remark": String, 54 | "groupid": Number 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/wxthirdparty.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * 调用方式: 4 | * var thirdparty = new ThirdpartyServer( 5 | * appid, 6 | * secret, 7 | * token, 8 | * key, // 公众号消息加解密Key 9 | * database_config, // orm2 连接数据库的字符串,如果主从库是分开的,传递object:{read:'xxxxxxxxx',write:'xxxxxxxxx'} 10 | * is_cluster //!!!!!!!!!!如果你的server用的cluster,这个参数超级超级超级超级重要,仔细读下面的注意事项!!!!!!!!!!!!!! 11 | * ); 12 | * thirdparty.start(); 13 | * 14 | * 另外在授权事件接收URL的响应函数中一定要调用save_ticket方法以刷新ticket: 15 | * 16 | * //首先在中间件里获取post的数据 17 | * some_middleware: function(req,res){ 18 | * req.on('data',function(d){ 19 | * req.body += d.toString(); 20 | * }); 21 | * }; 22 | * // 然后在授权事件接受URL的响应函数中存储ticket 23 | * 'auth_callback_action': function(req,res){ 24 | * thirdparty.save_ticket(req.body); 25 | * } 26 | * 27 | * 注意事项: 28 | * 29 | * 本模块需要在两个server中分别调用: 30 | * 一个单进程的模块用于向微信api拉取access_token等私密信息,is_cluster传false或者不传,这个server需要一直保持运行,不需要附加其他任何业务 31 | * 另一个就是在实际server中用于发起微信公众号api调用,这个可以在cluster中启动, is_cluster一定要传true,否则会导致token混乱,而且access_token拉取次数是有限制的,拉完就米有了 32 | * 33 | * 由于ticket是微信服务器向第三方平台推送的,所以每次重启server的时候都很难保证当前的ticket是否还有效 34 | * 重启server时先启动cluster的server,在授权事件接收URL的处理函数中调用save_ticket方法,10分钟之内就会收到微信的push请求, 35 | * 这个过程中最好停用所有和授权有关的功能,直到ticket刷新(这个过程中最好不要启动单进程的server, 36 | * 如果ticket已经失效会浪费好几次获取access_token的请求),可以在代码中记录ticket推送事件,接到推送之后再启用授权并启动单进程的server 37 | * 38 | * 39 | */ 40 | exports.ThirdpartyServer = require('./ThirdpartyServer'); 41 | //exports.Client = require('./Client'); 42 | -------------------------------------------------------------------------------- /lib/query.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var https = require('https'); 3 | var url = require('url'); 4 | var querystring = require('querystring'); 5 | 6 | var query = exports.query = function( _url , method , data , callback ){ 7 | 8 | _url = url.parse(_url); 9 | console.log(_url,data); 10 | 11 | var protocol = _url.protocol === 'https:' ? https : http; 12 | var resData = ''; 13 | 14 | var options = { 15 | hostname : _url.host, 16 | port : _url.port || (_url.protocol == 'https:' ? 443 : 80), 17 | path : _url.path, 18 | method : method 19 | }; 20 | 21 | if( method.toLowerCase() === 'post' ){ 22 | 23 | data = JSON.stringify(data); 24 | 25 | options.headers = { 26 | 'Content-Length': data.length 27 | }; 28 | } 29 | 30 | var req = protocol.request( options , function( res ) { 31 | res.on( 'data' , function( d ){ 32 | resData += d; 33 | }); 34 | res.on( 'end' , function(){ 35 | return typeof callback === 'function' && callback( undefined , resData.toString() ); 36 | }) 37 | }); 38 | 39 | req.on( 'error' , function( err ){ 40 | return typeof callback === 'function' && callback( err ); 41 | }); 42 | 43 | 44 | if( method.toLowerCase() === 'post' ){ 45 | 46 | req.write(data); 47 | 48 | } 49 | 50 | req.end(); 51 | 52 | }; 53 | 54 | exports.get = function( _url , callback ){ 55 | query( _url , 'get' , '' , callback ); 56 | }; 57 | 58 | 59 | exports.post = function( _url , data , callback ){ 60 | query( _url , 'post' , data , callback ); 61 | } 62 | -------------------------------------------------------------------------------- /lib/Client.js: -------------------------------------------------------------------------------- 1 | var API = require('wechat-api'); 2 | 3 | const EMPTY_FUNC = function(token,callback){ callback(); }; 4 | 5 | var HOOKS = {} 6 | 7 | function Client( component , user , getToken , saveToken){ 8 | 9 | this.component = component; 10 | this.user = user; 11 | this.api = new API( user.appid , '' , getToken , saveToken ); 12 | 13 | } 14 | 15 | var ignores = ['preRequest']; 16 | 17 | Object.keys(API.prototype).forEach(function(method){ 18 | 19 | 20 | if( typeof API.prototype[method] != 'function' || ignores.indexOf(method) != -1 ) return; 21 | 22 | Client.prototype[method] = function(){ 23 | 24 | var temp_data = {}; 25 | 26 | var args = Array.prototype.slice.call(arguments) 27 | , func = args.pop() 28 | , method_name = arguments.callee.__name 29 | , that = this 30 | , hook = that.hooks[method_name] 31 | , cb_with_after = func; 32 | 33 | 34 | if( hook && typeof hook.after === 'function' ){ 35 | cb_with_after = function( err , data ){ 36 | if( err ) return func( err ); 37 | hook.after.call(that,err,data,func,temp_data); 38 | }; 39 | } 40 | 41 | if( hook && typeof hook.before === 'function' ){ 42 | var before_args = [].concat(args); 43 | before_args.push(function(){ 44 | that.api[method_name].apply(that.api,args); 45 | }); 46 | before_args.push(temp_data); 47 | args.push(cb_with_after); 48 | hook.before.apply(that,before_args); 49 | }else{ 50 | args.push(cb_with_after); 51 | that.api[method_name].apply(that.api,args); 52 | } 53 | 54 | }; 55 | 56 | Client.prototype[method].__name = method; 57 | 58 | }); 59 | 60 | Client.prototype.hooks = require('./hooks'); 61 | 62 | module.exports = Client; 63 | -------------------------------------------------------------------------------- /lib/hooks/index.js: -------------------------------------------------------------------------------- 1 | hooks = { 2 | // 示例: 3 | getUser: { 4 | before: function( openid , callback , temp_data ){ 5 | // 调用api时的参数都会传进来,在这里可以选择直接入库 6 | // 如果需要等接口返回数据一起入库可以将数据记录在temp_data中 7 | // 不要直接修改temp_data,比如temp_data = open_id是不会生效的,类似amd的exports 8 | // after调用时再将temp_data中的字段取出来入库 9 | // 例如: 10 | 11 | temp_data.openid = openid; 12 | callback(); 13 | 14 | // 注:这只是做个示范,其实这个openid不需要记录,因为调用api返回的数据里还有openid 15 | 16 | }, 17 | after: function( err , res_data , callback , temp_data ){ 18 | 19 | if( err ) return callback( err ); 20 | var that = this; 21 | var keys = ["subscribe", "openid", "nickname", "sex", "language", "city", "province", "country", "headimgurl", "subscribe_time", "unionid", "remark", "groupid"]; 22 | // 获取temp_data中记录的openid 23 | var openid = temp_data.openid; 24 | 25 | // 与返回的信息一起入库,that.component就是全局的ThirtpartyServer实例 26 | that.component.models.write.WxFollowerInfo.find( { openid : openid } , 1 , function( err , user ){ 27 | 28 | if( err ) return callback( err ); 29 | 30 | if( user.length ){ 31 | keys.forEach(function( k ){ 32 | user[0][k] = res_data[k]; 33 | }); 34 | user[0].save(function(err){ 35 | if( err ) return callback( err ); 36 | callback( null , user[0] ); 37 | }); 38 | }else{ 39 | res_data.openid = openid; 40 | res_data.created_at = new Date(); 41 | that.component.models.write.WxFollowerInfo.create( res_data , function( err ){ 42 | if( err ) return callback( err ); 43 | callback( null , res_data ); 44 | }); 45 | } 46 | }); 47 | 48 | } 49 | }, 50 | // 获取所有关注的人的openid: 51 | getFollowers: { 52 | after: function( err , res_data , callback ){ 53 | var followers = res_data.data.openid; 54 | var that = this; 55 | 56 | this.component.models.write.WxFollowers.create( followers.map( function( openid ){ 57 | return { 58 | openid: openid, 59 | wx_token: that.user.wx_token 60 | }; 61 | }) , function( err ){ 62 | if( err ) callback( err ); 63 | }); 64 | 65 | callback( null , followers ); 66 | } 67 | } 68 | 69 | }; 70 | 71 | 72 | 73 | 74 | module.exports = hooks; 75 | -------------------------------------------------------------------------------- /lib/O.js: -------------------------------------------------------------------------------- 1 | var util = require('util') 2 | 3 | function callParallel(seqFns, argsIn , cbk){ 4 | var parallel_keys = Object.keys(seqFns) 5 | var cf = parallel_keys.length 6 | var retParel = {} 7 | ,errParel = {} 8 | ,hasErr = false 9 | 10 | function cbkParel(key , err , result){ 11 | if (err) { 12 | hasErr = true 13 | errParel[key] = err 14 | }else { 15 | retParel[key] = result 16 | } 17 | 18 | cf-- 19 | if (cf <= 0 ){ 20 | cbk(hasErr && errParel , retParel ) 21 | } 22 | } 23 | if (!util.isArray(argsIn)) argsIn = [argsIn] 24 | argsIn.push(null) 25 | 26 | for (var i = 0 , j = parallel_keys.length ; i < j ; i++){ 27 | var key = parallel_keys[i] 28 | argsIn.pop() 29 | var pCbk = cbkParel.bind(null , key) 30 | argsIn.push(pCbk) 31 | var r = seqFns[key].apply(null , argsIn) 32 | if (undefined != r) pCbk(null , r) 33 | } 34 | } 35 | 36 | exports.parallel = callParallel 37 | 38 | exports.set = function(){ 39 | var args = Array.prototype.slice.call(arguments) 40 | var seqs = [] 41 | var defaultErrHelper 42 | 43 | function cbk(err , result , rawResult ){ 44 | var nextFn = seqs.splice(0 , 1)[0] 45 | if (!nextFn) return result 46 | var errFn = nextFn[1] 47 | var seqFn = nextFn[0] 48 | 49 | if (false === errFn){ 50 | result = Array.prototype.slice.call(arguments) 51 | err = null 52 | } else if (true !== rawResult){ 53 | result = [result] 54 | }else if (!util.isArray(result)) { 55 | result = [result] 56 | } 57 | 58 | if (err) return errFn && errFn(err) 59 | 60 | //if (!util.isArray(result)) result = [result] 61 | if ('function' == typeof seqFn){ 62 | result.push(cbk) 63 | var ret = seqFn.apply(null , result) 64 | //非回调形式的 有返回值 65 | if (undefined != ret) cbk(null , ret) 66 | } else { 67 | //并行调用 68 | callParallel(seqFn , result, cbk) 69 | } 70 | } 71 | 72 | 73 | ///args.push(cbk) 74 | var inO = { 75 | 'then' : function(seqFn ,errFn){ 76 | seqs.push([seqFn ,undefined === errFn ? defaultErrHelper : errFn ]) 77 | return this 78 | } 79 | ,'setErrHelper' : function(errFn){ 80 | defaultErrHelper = errFn 81 | return this 82 | } 83 | ,'skip' : function(n){ 84 | n = n || 1 85 | n = Math.min(seqs.length -1 , n) 86 | seqs.splice(0 , n) 87 | } 88 | } 89 | setImmediate(function(){ 90 | cbk(null , args ,true) 91 | }) 92 | return inO 93 | 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 安装: 2 | cd $node_modules_folder 3 | git clone git@github.com:windinsky/wxthirdparty.git 4 | cd wixthirdparty 5 | npm install 6 | 7 | 是不是很想直接npm install wxthirdparty?哈哈哈哈哈哈哈哈哈,我不会弄。。。。 8 | 9 | ## 调用: 10 | ```js 11 | 12 | /********** app.js *********/ 13 | 14 | var appid = '第三方应用appid' 15 | , appsecret = '第三方应用appsecret' 16 | , token = '第三方应用中设置的公众号消息校验Token' 17 | , key = '第三方应用中设置的公众号消息加解密Key' 18 | , db = 'mysql://user:password@host/yourdatabase'; 19 | 20 | 21 | if ( cluster.isMaster ) { 22 | // Fork workers. 23 | for (var i = 0 ; i < numCPUs ; i++) cluster.fork(); 24 | 25 | // 主进程的模块只负责刷新component_token和pre_auth_code 26 | global.thirdparty = new ThirdpartyServer( 27 | appid , 28 | appsecret , 29 | token , 30 | key , 31 | db 32 | ); 33 | thirdparty.start(); 34 | 35 | cluster.on( 'exit' , function( worker , code , signal ) { 36 | console.log( 'worker ' + worker.process.pid + ' died' ); 37 | cluster.fork(); 38 | }); 39 | 40 | thirdparty.once( 'ready' , function(){ 41 | // enable_auth_related_actions(); 42 | }) 43 | 44 | } else { 45 | // 负责所有第三方平台的业务逻辑以及对公众号的操作,注意最后一个参数 46 | global.thirdparty = new ThirdpartyServer( 47 | appid , 48 | appsecret , 49 | token , 50 | key , 51 | db , 52 | true // very important 53 | ); 54 | 55 | // Workers can share any TCP connection 56 | // In this case its a HTTP server 57 | http.createServer( function( req , res ) { 58 | // ... 59 | }).listen( 80 ); 60 | } 61 | 62 | /*********** 使用样例,展示follower的头像 **********/ 63 | 64 | app.get( 'show_head_img' , function( req , res ){ 65 | 66 | // 根据cookie从数据库中获取公众号信息 67 | thirdparty.get_user_info( req.cookie.wx_token , function( err , user_info ){ 68 | 69 | if( err ) return res.end( JSON.stringify( err ) ); 70 | 71 | var client = thirdparty.createClient( user_info ); 72 | var openid = req.__get.openid; 73 | 74 | // 调用公众号api获取目标用户的信息 75 | client.getUser( openid , function( err , follower ){ 76 | 77 | if( err ) return res.end( JSON.stringify( err ) ); 78 | 79 | res.setHeader( 'content-type' , 'text/html' ); 80 | 81 | if( data.headimgurl ){ 82 | res.end( [''].join( '' ) ); 83 | }else{ 84 | res.end( follower.nickname + ' has no head img' ); 85 | } 86 | 87 | }); 88 | 89 | }); 90 | }); 91 | 92 | ``` 93 | 94 | 在授权事件接收URL的响应函数中调用save_ticket方法以刷新ticket: 95 | ```js 96 | //首先在中间件里获取post的数据 97 | 'some_middleware' : function( req , res ){ 98 | req.on( 'data' , function( d ){ 99 | req.body += d.toString(); 100 | }); 101 | }; 102 | // 然后在授权事件接受URL的响应函数中存储ticket 103 | 'auth_callback_action ': function( req , res ){ 104 | thirdparty.save_ticket( req.body ); 105 | } 106 | ``` 107 | 108 | 这个不加整个模块都没任何用处。。。 109 | 110 | ## 注意事项: 111 | 112 | ####本模块需要在两个server中分别调用: 113 | 114 | **一个单进程的模块 或者 master进程** 用于向微信api拉取access\_token等私密信息,is\_cluster传false或者不传,这个server需要一直保持运行,不需要附加其他任何业务 115 | 116 | 其他的就是在实际server中用于处理业务请求的模块,这个只在cluster中启动, is\_cluster一定要传true,否则会导致token混乱,而且access_token拉取次数是有限制的,用完两个小时后所有业务就都歇菜了 117 | 118 | ####由于ticket是微信服务器向第三方平台推送的,所以每次重启server的时候都很难保证当前的ticket是否还有效 119 | 120 | #####解决方案: 121 | **重启server时先保证接收ticket的action正常**,在授权事件接收URL的处理函数中调用save\_ticket方法,10分钟之内就会收到微信的push请求 122 | 123 | **这个过程中最好设置一个全局标志位置为false,然后所有worker进程监听该标志位,为false时停用所有和授权有关的功能,ticket更新后再将其置为true,worker监测到变化后启动授权业务,注:这个标志位不能放在内存变量中(多进程不共享内存的),需要入库或者写文件** 124 | 125 | ### 数据库建表语句: 126 | 127 | Mysql: 128 | ```sql 129 | /* 所有密钥及刷新时间 */ 130 | CREATE TABLE `component_secrets` ( 131 | `id` int(10) NOT NULL AUTO_INCREMENT, 132 | `ticket` varchar(255) DEFAULT NULL, 133 | `access_token` varchar(255) DEFAULT NULL, 134 | `preauthcode` varchar(255) DEFAULT NULL, 135 | `ticket_updated_at` bigint(20) DEFAULT NULL, 136 | `access_token_updated_at` bigint(20) DEFAULT NULL, 137 | `preauthcode_updated_at` bigint(20) DEFAULT NULL, 138 | `access_token_expires_in` int(64) DEFAULT NULL, 139 | `preauthcode_expires_in` int(64) DEFAULT NULL, 140 | PRIMARY KEY (`id`) 141 | ) ENGINE=MyISAM AUTO_INCREMENT=27 DEFAULT CHARSET=utf8; 142 | 143 | /* 第三方公众号信息 */ 144 | CREATE TABLE `wx_users_info` ( 145 | `id` int(10) NOT NULL AUTO_INCREMENT, 146 | `appid` varchar(255) DEFAULT NULL, 147 | `access_token` varchar(255) DEFAULT NULL, 148 | `access_token_updated_at` datetime DEFAULT NULL, 149 | `access_token_expires_in` datetime DEFAULT NULL, 150 | `refresh_token` varchar(255) DEFAULT NULL, 151 | `nick_name` varchar(255) DEFAULT NULL, 152 | `head_img` varchar(255) DEFAULT NULL, 153 | `service_type_id` int(11) DEFAULT NULL, 154 | `verify_type_info` varchar(255) DEFAULT NULL, 155 | `user_name` varchar(255) DEFAULT NULL, 156 | `alias` varchar(255) DEFAULT NULL, 157 | `wx_token` varchar(255) DEFAULT NULL, 158 | PRIMARY KEY (`id`) 159 | ) ENGINE=MyISAM AUTO_INCREMENT=31 DEFAULT CHARSET=utf8; 160 | 161 | /* 第三方公众号的听众列表 */ 162 | CREATE TABLE `wx_followers` ( 163 | `id` int(10) NOT NULL AUTO_INCREMENT, 164 | `openid` varchar(255) DEFAULT NULL, 165 | `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 166 | `wx_token` varchar(255) DEFAULT NULL, 167 | PRIMARY KEY (`id`) 168 | ) ENGINE=MyISAM AUTO_INCREMENT=44 DEFAULT CHARSET=utf8; 169 | 170 | /* 听众用户信息 */ 171 | CREATE TABLE `wx_followers_info` ( 172 | `id` int(10) NOT NULL AUTO_INCREMENT, 173 | `subscribe` int(11) DEFAULT NULL, 174 | `openid` varchar(255) DEFAULT NULL, 175 | `nickname` varchar(255) DEFAULT NULL, 176 | `sex` int(11) DEFAULT NULL, 177 | `language` varchar(255) DEFAULT NULL, 178 | `city` varchar(255) DEFAULT NULL, 179 | `province` varchar(255) DEFAULT NULL, 180 | `country` varchar(255) DEFAULT NULL, 181 | `headimgurl` varchar(255) DEFAULT NULL, 182 | `subscribe_time` date DEFAULT NULL, 183 | `unionid` varchar(255) DEFAULT NULL, 184 | `remark` varchar(255) DEFAULT NULL, 185 | `groupid` int(11) DEFAULT NULL, 186 | `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 187 | `created_at` datetime NOT NULL, 188 | PRIMARY KEY (`id`) 189 | ) ENGINE=MyISAM AUTO_INCREMENT=35 DEFAULT CHARSET=utf8; 190 | ``` 191 | **TODO**: 192 | 193 | 1. 把bigint全改成date。。。存时间戳的理由已经被我忘了! 194 | 2. 添加各个api的钩子,[钩子样例](https://github.com/windinsky/wxthirdparty/blob/master/lib/hooks/index.js),这不是我一个人能干的活了。。 195 | 3. 补注释,等休完年假回来再说 196 | -------------------------------------------------------------------------------- /lib/ThirdpartyServer.js: -------------------------------------------------------------------------------- 1 | var xml2js = require('xml2js'); 2 | var orm = require('orm'); 3 | var crypto = require('crypto'); 4 | var Cryptor = require('wechat-crypto'); 5 | var O = require('./O'); 6 | var query = require('./query'); 7 | var DB_SCHEMA = require('./db_schema'); 8 | var EventEmitter = require('events').EventEmitter; 9 | var util = require('util'); 10 | var Client = require('./Client'); 11 | 12 | const HOUR = 36e5; 13 | const MINUTE = 6e4; 14 | const TICKET_VALID_DURATION = HOUR; 15 | 16 | function ThirdpartyServer( component_appid , component_appsecret , token , encoding_aes_key , db_config , is_cluster){ 17 | 18 | EventEmitter.call(this); 19 | 20 | if( !component_appid ) throw '第三方appid不能为空'; 21 | if( !component_appsecret ) throw '第三方appsecret不能为空'; 22 | if( !token ) throw 'Token不能为空'; 23 | if( !encoding_aes_key ) throw 'EncodingAESKey不能为空'; 24 | if( !db_config ) throw '没数据库配置搞毛啊'; 25 | 26 | var that = this; 27 | 28 | // wx config 29 | this.component_appsecret = component_appsecret; 30 | this.component_appid = component_appid; 31 | this.token = token; 32 | this.encoding_aes_key = encoding_aes_key; 33 | 34 | // db related 35 | this.db_config = db_config; 36 | this.read_db = null; 37 | this.write_db = null; 38 | this.models = { read:{}, write:{} }; 39 | 40 | // status 41 | this.ticket_ready = false; 42 | this.db_ready = false; 43 | this.access_token_ready = false; 44 | this.preauthcode_ready = false; 45 | this.ready = false; 46 | 47 | // utils 48 | this.crypto = new Cryptor( this.token , this.encoding_aes_key , this.component_appid ); 49 | 50 | // init 51 | this.set_db( db_config , function(){ 52 | if( !is_cluster ){ 53 | that.check_ticket(); 54 | } 55 | }); 56 | 57 | // secrets 58 | if( !is_cluster ){ 59 | this.ticket = null; 60 | this.access_token = null; 61 | this.preauthcode = null; 62 | } 63 | 64 | // last fetch time 65 | this.last_fetch_at = { 66 | access_token : null, 67 | ticket : null, 68 | preauthcode : null 69 | }; 70 | 71 | // fail count 72 | this.get_access_token_fail_count = 0; 73 | this.get_preauthcode_fail_count = 0; 74 | 75 | this.is_cluster = is_cluster; 76 | 77 | }; 78 | 79 | util.inherits( ThirdpartyServer , EventEmitter ); 80 | 81 | 82 | /* 创建一个调用公众号api的客户端实例 */ 83 | ThirdpartyServer.prototype.createClient = function( user ){ 84 | 85 | return new Client( 86 | this 87 | , user 88 | , function( callback ){ callback( null , { accessToken: user.access_token, expireTime: user.access_token_expires_in.getTime() }); } 89 | , function( token ){ callback(); } 90 | ); 91 | 92 | }; 93 | 94 | /* 检查是否所有secrets都可用 */ 95 | ThirdpartyServer.prototype.check_ready = function(){ 96 | if( this.ready ) return true; 97 | if( this.db_ready && this.ticket_ready && this.preauthcode_ready && this.access_token_ready ){ 98 | this.ready = true; 99 | return this.emit('ready'); 100 | } 101 | return false; 102 | }; 103 | 104 | /* 用户授权后的回调页面可以拿到auth_code,以其获取公众号信息 */ 105 | ThirdpartyServer.prototype.fetch_user_info = function( auth_code , callback ){ 106 | 107 | var that = this; 108 | var user_info = { 109 | wx_token: generateToken() 110 | }; 111 | 112 | this.secrets( function(secret){ 113 | // 获取authorizer_access_token 114 | query.post( 115 | 'https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token=' + secret.access_token, 116 | { 117 | "component_appid" : that.component_appid, 118 | "authorization_code": auth_code 119 | }, 120 | function( err , auth_info ){ 121 | 122 | if( err ) return callback( err ); 123 | 124 | auth_info = JSON.parse( auth_info ); 125 | if( auth_info.errcode ) return callback( auth_info ); 126 | 127 | user_info.appid = auth_info.authorization_info.authorizer_appid; 128 | user_info.access_token = auth_info.authorization_info.authorizer_access_token; 129 | user_info.access_token_updated_at = new Date(); 130 | user_info.access_token_expires_in = new Date(auth_info.authorization_info.expires_in*1000 + user_info.access_token_updated_at.getTime()); 131 | user_info.refresh_token = auth_info.authorization_info.authorizer_refresh_token; 132 | 133 | // 通过authorizer_access_token拉取公众号信息 134 | query.post( 135 | 'https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_info?component_access_token=' + secret.access_token, 136 | { 137 | "component_appid": that.component_appid, 138 | "authorizer_appid": user_info.appid 139 | }, 140 | function( err , wx_info ){ 141 | 142 | if( err ) return callback( err ); 143 | 144 | wx_info = JSON.parse( wx_info ); 145 | if( wx_info.errcode ) return callback( wx_info ); 146 | 147 | user_info.nick_name = wx_info.authorizer_info.nick_name; 148 | user_info.head_img = wx_info.authorizer_info.head_img; 149 | user_info.service_type_id = wx_info.authorizer_info.service_type_info.id; 150 | user_info.verify_type_info = wx_info.authorizer_info.verify_type_info; 151 | user_info.user_name = wx_info.authorizer_info.user_name; 152 | user_info.alias = wx_info.authorizer_info.alias; 153 | 154 | that.models.write.WxUserInfo.create( user_info , function( err , user ){ 155 | 156 | if( err ) return callback( err ); 157 | 158 | callback( null , user_info ); 159 | 160 | }); 161 | 162 | } 163 | ) 164 | } 165 | ) 166 | } ); 167 | }; 168 | 169 | 170 | /* 生成随机token,用于种回客户端cookie */ 171 | function generateToken(){ 172 | 173 | var t = new Date().getTime().toString().split(''), 174 | r = parseInt(Math.random()*1e10).toString().split(''), 175 | c = ''; 176 | 177 | while( t.length || r.length ){ 178 | c += t.shift() || ''; 179 | c += r.shift() || ''; 180 | } 181 | 182 | return md5(c); 183 | 184 | } 185 | 186 | /* 从数据库中获取公众号信息 */ 187 | ThirdpartyServer.prototype.get_user_info = function( wx_token , callback ){ 188 | 189 | var that = this; 190 | 191 | that.models.read.WxUserInfo.find({ wx_token : wx_token },1,function(err , user){ 192 | 193 | if( err ) return callback(err); 194 | 195 | if( user.length == 0 ) return callback( 'not exist' ); 196 | 197 | var expires = user[0].access_token_expires_in; 198 | 199 | // 检查token是否还有效,如果无效重新刷新 200 | if( !expires || isNaN(new Date(expires).getTime()) || expires < new Date() ){ 201 | 202 | return that.refresh_token( user[0].appid , user[0].refresh_token , function( err , token_info ){ 203 | 204 | if( err ) return callback( err ); 205 | 206 | token_info = JSON.parse( token_info ); 207 | if( token_info.errcode ) return callback( token_info ); 208 | 209 | user[0].access_token = token_info.authorizer_access_token; 210 | user[0].access_token_expires_in = new Date(new Date().getTime() + token_info.expires_in*1000); 211 | user[0].access_token_updated_at = new Date(); 212 | user[0].refresh_token = token_info.authorizer_refresh_token; 213 | 214 | user[0].save( function( err ){ 215 | 216 | if( err ) return callback( err ); 217 | 218 | callback( null , user[0] ); 219 | 220 | } ); 221 | 222 | } ); 223 | 224 | } 225 | 226 | callback( null , user[0] ); 227 | 228 | }); 229 | 230 | }; 231 | 232 | /* 通过authorizer_refresh_token 刷新 authorizer_access_token */ 233 | ThirdpartyServer.prototype.refresh_token = function( appid , refresh_token , callback ){ 234 | 235 | var that = this; 236 | 237 | this.secrets( function(secret){ 238 | 239 | query.post( 240 | 'https://api.weixin.qq.com/cgi-bin/component/api_authorizer_token?component_access_token='+ secret.access_token, 241 | { 242 | "component_appid":that.component_appid, 243 | "authorizer_appid":appid, 244 | "authorizer_refresh_token":refresh_token 245 | }, 246 | callback 247 | ); 248 | 249 | }) 250 | 251 | }; 252 | 253 | function md5(val){ 254 | var md5 = crypto.createHash('md5'); 255 | md5.update(val); 256 | return md5.digest('hex'); 257 | }; 258 | 259 | /* 从数据库中获取 component_access_token, ticket , pre_auth_code */ 260 | ThirdpartyServer.prototype.secrets = function( callback ){ 261 | 262 | var that = this; 263 | 264 | this.models.read.ComponentSecrets.find( {} , 1 , function( err , secret ){ 265 | 266 | if( err ){ 267 | callback( err ); 268 | return that.emit('db_error'); 269 | } 270 | 271 | if( !secret.length || !['ticket','access_token','preauthcode'].every( function(k){ return !!secret[0][k]; } ) ) { 272 | callback( 'not_ready' ); 273 | return that.emit('not_ready'); 274 | } 275 | 276 | var now = new Date().getTime(); 277 | 278 | if( 279 | // access_token已过期 280 | secret[0].access_token_updated_at + secret[0].access_token_expires_in*1000 < now || 281 | // preauthcode已过期 282 | secret[0].preauthcode_updated_at + secret[0].preauthcode_expires_in*1000 < now || 283 | // ticket已过期 284 | secret[0].ticket_updated_at + TICKET_VALID_DURATION < now 285 | ){ 286 | callback( 'outdated' ); 287 | return that.emit('outdated'); 288 | } 289 | 290 | secret = { 291 | ticket : secret[0].ticket, 292 | preauthcode : secret[0].preauthcode, 293 | access_token : secret[0].access_token 294 | } 295 | 296 | callback( secret ); 297 | 298 | that.emit( 'end' , secret ); 299 | 300 | }); 301 | 302 | return that; 303 | 304 | }; 305 | 306 | // 初始化数据库配置 307 | ThirdpartyServer.prototype.set_db = function( config , callback ){ 308 | 309 | var that = this; 310 | var read_db_ready = false , write_db_ready = false; 311 | 312 | function check_ready(){ 313 | if( read_db_ready && write_db_ready ) { 314 | that.db_ready = true; 315 | typeof callback === 'function' && callback(); 316 | } 317 | } 318 | 319 | orm.connect( config.write || config , function( err , db ){ 320 | 321 | if(err) throw err; 322 | 323 | that.write_db = db; 324 | 325 | Object.keys( DB_SCHEMA ).forEach( function(klass){ 326 | 327 | var schema = DB_SCHEMA[klass]; 328 | 329 | that.models.write[klass] = that.write_db.define( schema.table , schema.columns , schema.options ); 330 | 331 | }); 332 | 333 | write_db_ready = true; 334 | 335 | check_ready(); 336 | 337 | }); 338 | 339 | orm.connect( config.read || config , function( err , db ){ 340 | 341 | if(err) throw err; 342 | 343 | that.read_db = db; 344 | 345 | Object.keys( DB_SCHEMA ).forEach( function(klass){ 346 | 347 | var schema = DB_SCHEMA[klass]; 348 | 349 | that.models.read[klass] = that.read_db.define( schema.table , schema.columns , schema.options ); 350 | 351 | }); 352 | 353 | read_db_ready = true; 354 | 355 | check_ready(); 356 | 357 | }); 358 | 359 | }; 360 | 361 | // 存储ticket 362 | ThirdpartyServer.prototype.save_ticket = function(ticket_xml){ 363 | 364 | var that = this; 365 | 366 | O.set(ticket_xml) 367 | .then(xml2js.parseString) 368 | .then(function(data){ return that.crypto.decrypt(data.xml.Encrypt[0]).message; }) 369 | .then(xml2js.parseString) 370 | .then(function(result){ 371 | var ticket = result.xml.ComponentVerifyTicket[0]; 372 | var now = new Date().getTime(); 373 | that.models.read.ComponentSecrets.find( {} , 1 , function( err , secret ){ 374 | if( err ){ 375 | return console.log('读取数据库失败,无法写入ticket,请尽快检查服务器配置'); 376 | } 377 | 378 | if( !secret.length ){ 379 | return that.models.read.ComponentSecrets.create({ 380 | ticket : ticket, 381 | ticket_updated_at : now 382 | }, function(err){ 383 | if( err ){ 384 | return console.log('写入数据库失败,无法写入ticket,请尽快检查服务器配置'); 385 | } 386 | }); 387 | } 388 | 389 | secret[0].ticket = ticket; 390 | secret[0].ticket_updated_at = now; 391 | secret[0].save( function( err ){ 392 | if( err ){ 393 | return console.log('写入数据库失败,无法写入ticket,请尽快检查服务器配置'); 394 | } 395 | }); 396 | }); 397 | }); 398 | 399 | }; 400 | 401 | ThirdpartyServer.prototype.start = function(){ 402 | 403 | if( this.is_cluster ) return; 404 | 405 | var that = this; 406 | 407 | // 检查数据库 408 | if( !this.db_ready ) { 409 | console.log( 'db not ready. start over in 5 seconds' ); 410 | return setTimeout( function(){ 411 | that.start(); 412 | }, 5e3 ); 413 | }; 414 | 415 | // 检查ticket 416 | if( !this.ticket_ready ){ 417 | console.log( 'ticket not ready. start over in 5 seconds ' ); 418 | this.check_ticket(); 419 | return setTimeout( function(){ 420 | that.start(); 421 | }, 5e3 ); 422 | } 423 | 424 | // 启动脚本刷新secrets 425 | this.refresh_secrets(); 426 | 427 | }; 428 | 429 | ThirdpartyServer.prototype.check_ticket = function(){ 430 | 431 | var that = this; 432 | 433 | that.models.read.ComponentSecrets.find( {} , 1 , function( err , secret ){ 434 | 435 | if( err ) throw err; 436 | 437 | // ticket 每一个小时变化一次 438 | if( secret[0].ticket && new Date().getTime() - secret[0].ticket_updated_at < TICKET_VALID_DURATION ){ 439 | that.ticket = secret[0].ticket; 440 | that.ticket_ready = true; 441 | } 442 | 443 | }); 444 | 445 | }; 446 | 447 | ThirdpartyServer.prototype.refresh_secrets = function(){ 448 | 449 | var that = this; 450 | 451 | that.models.read.ComponentSecrets.find( {} , 1 , function( err , secret ){ 452 | 453 | if( err ) throw err; 454 | 455 | // 如果数据库里没有access_token和preauthcode,马上去获取 456 | if( !secret[0].access_token ){ 457 | return that.get_access_token( function(){ 458 | that.refresh_secrets(); 459 | }); 460 | } 461 | 462 | // 在access_token和preauthcode失效前一分钟重新获取 463 | if( secret[0].access_token ){ 464 | 465 | that.last_fetch_at.access_token = secret[0].access_token_updated_at; 466 | 467 | if( !that.last_fetch_at.access_token ){ 468 | return that.get_access_token( function(){ 469 | that.refresh_secrets(); 470 | }) 471 | } 472 | 473 | that.access_token = secret[0].access_token; 474 | that.access_token_ready = true; 475 | that.check_ready(); 476 | 477 | var timer = Math.max( that.last_fetch_at.access_token + secret[0].access_token_expires_in*1000 - new Date() - MINUTE ); 478 | console.log('当前access_token仍然有效,'+parseInt(timer/1000)+'秒之后重新获取'); 479 | setTimeout( 480 | function(){ that.get_access_token(); }, 481 | timer 482 | ); 483 | 484 | } 485 | 486 | if( !secret[0].preauthcode ){ 487 | that.get_preauthcode(); 488 | } 489 | 490 | if( secret[0].preauthcode ){ 491 | 492 | that.last_fetch_at.preauthcode = secret[0].preauthcode_updated_at; 493 | 494 | if( !that.last_fetch_at.preauthcode ){ 495 | return that.get_preauthcode(); 496 | } 497 | 498 | that.preauthcode = secret[0].preauthcode; 499 | that.preauthcode_ready = true; 500 | that.check_ready(); 501 | 502 | var timer = Math.max( that.last_fetch_at.preauthcode + secret[0].preauthcode_expires_in*1000 - new Date() - MINUTE); 503 | console.log('当前preauthcode仍然有效,'+parseInt(timer/1000)+'秒之后重新获取'); 504 | 505 | setTimeout( 506 | function(){ that.get_preauthcode(); }, 507 | timer 508 | ); 509 | 510 | } 511 | 512 | }); 513 | 514 | }; 515 | 516 | ThirdpartyServer.prototype.get_access_token = function( callback ){ 517 | 518 | var that = this; 519 | 520 | query.post( 521 | 'https://api.weixin.qq.com/cgi-bin/component/api_component_token', 522 | { 523 | "component_appid" : that.component_appid , 524 | "component_appsecret" : that.component_appsecret, 525 | "component_verify_ticket" : that.ticket 526 | }, 527 | function( err , data ){ 528 | if( err ){ 529 | that.get_access_token_fail_count++; 530 | if( that.get_access_token_fail_count >= 5 ){ 531 | console.log( 532 | '刷新第三方access_token连续失败5次,为避免被微信封杀,停止尝试,可能有以下原因:\n'+ 533 | '1.你服务器断网了\n'+ 534 | '2.微信服务器瓦了\n'+ 535 | '3.你的第三方平台账号被和谐了\n'+ 536 | '4.你的授权回调接口歇菜了\n'+ 537 | '为避免影响业务运行,请赶快排查,错误信息:'+err 538 | ); 539 | }else{ 540 | console.log( '刷新第三方access_token失败,尝试重新获取,错误信息:' , err ); 541 | } 542 | setTimeout(function(){ 543 | that.get_access_token(); 544 | },3000); 545 | return ; 546 | } 547 | 548 | var now = new Date().getTime(); 549 | 550 | data = JSON.parse(data); 551 | 552 | if(data.errcode) { 553 | that.get_access_token_fail_count++; 554 | if( that.get_access_token_fail_count >= 5 ){ 555 | return console.log( '获取access_token连续失败5次,为避免被微信封杀,停止尝试,为避免影响业务运行,请赶快排查,错误信息:'+JSON.stringify(data)); 556 | }else{ 557 | console.log( '获取第三方access_token失败,错误信息:' + data + ',5秒钟后重试'); 558 | setTimeout(function(){ 559 | that.get_access_token(); 560 | },5000); 561 | } 562 | } 563 | 564 | that.get_access_token_fail_count = 0; 565 | that.access_token = data.component_access_token; 566 | that.last_fetch_at.access_token = now; 567 | 568 | that.access_token_ready = true; 569 | that.check_ready(); 570 | 571 | that.models.read.ComponentSecrets.find( {} , 1 , function( err , secret ){ 572 | secret[0].access_token = data.component_access_token; 573 | secret[0].access_token_updated_at = now; 574 | secret[0].access_token_expires_in = data.expires_in; 575 | secret[0].save( function( err ){ 576 | if( err ){ 577 | console.log('尝试将access_token写入数据库失败,在请尽快检查数据库配置'); 578 | } 579 | }); 580 | }); 581 | typeof callback === 'function' && callback(); 582 | var timer = data.expires_in - 60; 583 | console.log('获取access_token成功,' + timer + '秒后重新获取'); 584 | setTimeout( function(){ 585 | that.get_access_token() 586 | }, timer*1000); 587 | } 588 | ); 589 | 590 | }; 591 | 592 | ThirdpartyServer.prototype.get_preauthcode = function(){ 593 | 594 | var that = this; 595 | 596 | query.post( 597 | 'https://api.weixin.qq.com/cgi-bin/component/api_create_preauthcode?component_access_token=' + that.access_token, 598 | { 599 | "component_appid" : that.component_appid 600 | }, 601 | function( err , data ){ 602 | 603 | if( err ){ 604 | that.get_preauthcode_fail_count++; 605 | if( that.get_preauthcode_fail_count >= 5 ){ 606 | return console.log( 607 | '刷新preauthcode异常,可能有以下原因:\n'+ 608 | '1.你服务器断网了\n'+ 609 | '2.微信服务器瓦了\n'+ 610 | '3.你的第三方平台账号被和谐了\n'+ 611 | '4.你的获取ticket接口歇菜了\n'+ 612 | '为避免影响业务运行,请赶快排查' 613 | ); 614 | }else{ 615 | console.log( '刷新preauthcode失败,尝试重新获取,错误信息:' , err ); 616 | } 617 | return that.get_preauthcode(); 618 | } 619 | 620 | var now = new Date().getTime(); 621 | 622 | data = JSON.parse(data); 623 | if(data.errcode) return console.log(data); 624 | 625 | that.get_preauthcode_fail_count = 0; 626 | that.preauthcode = data.pre_auth_code; 627 | that.last_fetch_at.preauthcode = now; 628 | 629 | that.preauthcode_ready = true; 630 | that.check_ready(); 631 | 632 | that.models.read.ComponentSecrets.find( {} , 1 , function( err , secret ){ 633 | 634 | secret[0].preauthcode = data.pre_auth_code; 635 | secret[0].preauthcode_updated_at = now; 636 | secret[0].preauthcode_expires_in = data.expires_in; 637 | 638 | secret[0].save( function( err ){ 639 | err && console.log('尝试将preauthcode写入数据库失败,在请尽快检查数据库配置'); 640 | }); 641 | 642 | }); 643 | 644 | var timer = data.expires_in - 60; 645 | 646 | console.log('获取preauthcode成功,'+timer+'秒后重新获取'); 647 | 648 | setTimeout(function(){ 649 | that.get_preauthcode(); 650 | }, timer*1000); 651 | } 652 | ); 653 | 654 | }; 655 | 656 | 657 | module.exports = ThirdpartyServer; 658 | --------------------------------------------------------------------------------