Node+Express搭建的企业微信前端服务器

了解前端的人应该都知道,Node.js作为前端依赖环境,与 JavaScript 密不可分。Node.js是一个基于 Chrome V8 引擎的 JavaScript 运行时,Node.js的的应用也十分的广泛,我们大多数时候将它作为一门服务器语言来使用。这里主要讲的是运用Node.js构建一个微信平台上使用的前端服务器。源码在个人的GitHub上面可以看到,这里主要是讲述一下个人的一些构建思路。该前端服务器我主要是构建了关于单点登录、消息接收、日志收集以及数据库连接操作的一些基本案例,在我看来这些最基本的案例可以帮助我们从头了解到Node.js搭建服务器的过程。

初始化及依赖包

Express是一款基于 Node.js 平台,快速、开放、极简的 Web 开发框架,它将 Node.js 的一些基本api封装过后更方便快捷的供我们使用。首先我们引入Express及其它一些依赖包

1
2
3
4
5
6
7
var express = require('express');
var path = require('path');
var serveStatic = require('serve-static');
var bodyParser = require('body-parser');
require('body-parser-xml')(bodyParser);
var mongoose = require("mongoose");
var redis = require('redis');

引进这些依赖包之后,我们开始进入正题,服务器的debug调试大多数时候是靠日志进行,而且日志信息的量也是非常大的,所以我们不光要对日志进行输出查看,还要对其进行分类处理,所以我们单独建一个log.js的文件对整个日志输出统一管理

首先引入log4js与colors的依赖,log4js是基于console的输出

1
2
var log4js = require('log4js');
var colors = require('colors');

接下来是log的基本输出配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
log4js.configure({
appenders: {
server: {
type: 'file',
filename: 'server.log'
}
},
categories: {
default: {
appenders: ['server'],
level: 'debug',
format:':method :url :status'
}
}
});
colors.setTheme({
input: 'grey',
verbose: 'cyan',
prompt: 'red',
info: 'green',
data: 'blue',
help: 'cyan',
warn: 'yellow',
debug: 'magenta',
error: 'red'
});

设置好基本配置之后,我们对其进行分类输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
var Logger = {
log4js: log4js,
init(name) {
var logger = log4js.getLogger(name);
return logger;
},
console(logger, context, color) {
logger.info(context);
switch (color) {
case 'debug':
console.log(context.debug);
break;
case 'verbose':
console.log(context.verbose);
break;
case 'prompt':
console.log(context.prompt);
break;
case 'error':
console.log(context.error);
break;
case 'warn':
console.log(context.warn);
break;
case 'help':
console.log(context.help);
break;
case 'data':
console.log(context.data);
break;
case 'info':
console.log(context.info);
break;
case 'input':
console.log(context.input);
break;
default:
}
}
}

由于微信平台的一些服务器返回参数是通过xml进行包装的,所以我们需要用到bodyParser进行解析,所以我们一开始需要对其初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.use(bodyParser.urlencoded({extended:true}));
app.use(bodyParser.json());
app.use(bodyParser.xml({
limit: '1MB',
xmlParseOptions: {
normalize: true,
normalizeTags: true,
explicitArray: false
},
verify: function(req, res, buf, encoding) {
if(buf && buf.length) {
req.rawBody = buf.toString(encoding || "utf8");
}
}
}));

公共配置文件

接下来,我们需要对整个服务建立一个公共的配置文件,以便我们在需要的时候直接从配置文件里面读取,配置文件包括环境变量配置、mongodb连接、redis连接、微信平台应用配置、接口配置等,这里我们分为了开发环境(development)、测试环境(staging)、正式环境(production)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
var args = process.argv.splice(2);
if (!args || !args.length)
return;
config.env(args);
const config = {
//环境变量
env: function(args) {
this.NODE_CLOUD = args[0].split('.')[0];
this.NODE_ENV = args[0].split('.')[1];
Logger.console(logger, 'NODE_ENV =========== ' + this.NODE_ENV, 'info');
Logger.console(logger, 'NODE_CLOUD =========== ' + this.NODE_CLOUD, 'info');
},
//mongodb数据库
mongodb: function() {
switch (this.NODE_ENV) {
case 'development': {
const mongodb = {
host: '*.*.*.*',
port: '8888',
database: 'jackhancloud',
user: 'jackhan',
password: '********',
session_name: 'master'
};
return `mongodb://${mongodb.user}:` +
`${mongodb.password}@` +
`${mongodb.host}:` +
`${mongodb.port}/` +
`${mongodb.database}` +
'?server_selection_timeout=10';
}
case 'staging': {
const mongodb = {
host: '*.*.*.*',
port: '8889',
database: 'jackhancloud',
user: 'jackhan',
password: '********',
session_name: 'master'
};
return `mongodb://${mongodb.user}:` +
`${mongodb.password}@` +
`${mongodb.host_stag}:` +
`${mongodb.port}/` +
`${mongodb.database}` +
'?server_selection_timeout=10';
}
case 'production': {
const mongodb = {
host: '*.*.*.*',
port: '8890',
database: 'jackhancloud',
}
return `mongodb://${mongodb.host}:` +
`${mongodb.port}/` +
`${mongodb.database}` +
'?server_selection_timeout=10';
}
}
},
//redis
redis: {
port: 6379,
host: '127.0.0.1'
},
db: {
redisClient: null,
mongoose: null
},
//应用配置
app: function() {
switch (this.NODE_ENV) {
case 'development': {
return {
cropId: '***********',
suiteId: '***********',
suitSecret: '***********',
token: '***********',
encodingAESKey: '***********',
indexCallBack: 'http://***********/api/v1/login/callback'
}
}
case 'staging': {
return {
cropId: '**********',
suiteId: '**********',
suitSecret: '**********',
token: '**********',
encodingAESKey: '**********',
indexCallBack: 'http://***********/api/v1/login/callback'
}
}
case 'production': {
return {
cropId: '***********',
suiteId: '***********',
suitSecret: '***********',
token: '***********',
encodingAESKey: '***********',
indexCallBack: 'http://***********/api/v1/login/callback'
}
}
}
}
}

路由及API

路由的配置必不可少,在登录之前我们需要对其进行配置,利用wxCrypt进行加解密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
var express = require('express');
var config = require('../conf/config');
var WxCrypt = require('../until/wxCrypt');
var Message = require('../auth/message');
var Logger = require('../until/log');
const logger = Logger.init('Router');
// 创建一个路由对象,此对象将会监听文件下的url
var router = express.Router();
//配置文件
var appConf = config.app();
//加密解密对像
var wxCrypt = new WxCrypt(appConf.token, appConf.encodingAESKey, appConf.cropId);
//设置跨域访问
router.all('*', function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "X-Requested-With");
res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
res.header("X-Powered-By", '3.2.1');
res.header("Content-Type", "application/json;charset=utf-8");
next();
});
//消息接收
router.post('/v1/auth/message', function(req, res, next) {
var suiteid = req.body.xml ? req.body.xml.tousername : '';
var encrypt = req.body.xml ? req.body.xml.encrypt : '';
var agentid = req.body.xml ? req.body.xml.agentid : '';
var msg_signature = req.query.msg_signature;
var timestamp = req.query.timestamp;
var nonce = req.query.nonce;
if (wxCrypt.getSignature(timestamp, nonce, encrypt) === msg_signature) {
var cryptMsg = wxCrypt.decrypt(encrypt);
var message = new Message(cryptMsg);
var infoData = message.receiveMsgType();
res.status(200).send('success');
} else {
Logger.console(logger, '刷新suiteTicket失败', 'error');
res.status(401).end();
}
return;
});

配置了路由对象之后需要对一些特殊的微信平台api进行分类对接,我们用axios来进行接口请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
var axios = require('axios');
const authApi = {
/**
* 对返回结果的一层封装,如果遇见微信返回的错误,将返回一个错误
* 参见:http://mp.weixin.qq.com/wiki/index.php?title=返回码说明
*/
wrapper: function(data) {
if (data.errcode) {
err = new Error();
err.name = 'WeChatAPIError';
err.errmsg = data.errmsg;
err.errcode = data.errcode;
return err;
}
return data;
},
get: function(url, cb) {
axios({
url: url,
method: 'get',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
}).then(function (response) {
cb && cb(authApi.wrapper(response.data));
return;
})
.catch(function (error) {
return error;
});
},
post: function(url, data, cb) {
axios({
url: url,
method: 'post',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
data: JSON.stringify(data)
}).then(function (response) {
cb && cb(authApi.wrapper(response.data));
return;
})
.catch(function (error) {
return error;
});
}
}

企业微信API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const config = require('../conf/config');
const url = {
//获取suite_token
suiteToken: 'https://qyapi.weixin.qq.com/cgi-bin/service/get_suite_token',
//获取pre_auth_code
preAuthCode(suiteAccessToken) {
return `https://qyapi.weixin.qq.com/cgi-bin/service/get_pre_auth_code?suite_access_token=${suiteAccessToken}`
},
//授权配置
authConfig(suiteAccessToken) {
return `https://qyapi.weixin.qq.com/cgi-bin/service/set_session_info?suite_access_token=${suiteAccessToken}`
},
//获取permanent_code
permanentCode(suiteAccessToken) {
return `https://qyapi.weixin.qq.com/cgi-bin/service/get_permanent_code?suite_access_token=${suiteAccessToken}`
}
}

数据库连接与登录

有了基本配置之后,我们开始连接数据库,连接mongodb和redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
var MOGO_CON_STR = config.mongodb();
var redisClient = redis.createClient(config.redis);
//连接mogodb数据库
mongoose.connect(MOGO_CON_STR);
//如果连接成功会执行open回调
mongoose.connection.on('open', function () {
Logger.console(logger, 'mongo数据库连接成功 ===> ' + MOGO_CON_STR, 'debug');
config.db.mongoose = mongoose;
//wxapi
app.use('/api', require('./routes/api'));
//auth登录
app.get('/api/v1/login/authorize', function(req, res, next) {
var authLogin = new AuthLogin();
authLogin.getSuiteToken(function(response) {
response && response.errmsg ? res.send(response.errmsg) : response.callbackUri
? res.redirect(response.callbackUri) : res.redirect('/');
return;
});
});
app.use('/', serveStatic(path.join(__dirname, '../build')));
app.use('/*', serveStatic(path.join(__dirname, '../build/index.html')));
app.listen(9040);
});
// 链接错误
mongoose.connection.on('error', function(error) {
Logger.console(logger, '数据库连接失败' + error, 'error');
});
//redis数据库连接
redisClient.on("ready", function(err) {
if (err) return false;
Logger.console(logger, 'redis数据库连接成功 ===> port: 6379 host: 127.0.0.1', 'debug');
config.db.redisClient = redisClient;
});
redisClient.on("error", function (error) {
Logger.console(logger, 'redis数据库连接失败 port: ' + error, 'error');
});

单点登录

连接数据库成功之后我们就开始进行单点登录的请求,为此我们需要建立一个authLogin的文件来处理登录事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
var config = require('../conf/config');
var url = require('../routes/url');
var AuthApi = require('../routes/authApi');
var Store = require('../conf/store');
var Logger = require('../until/log');
const logger = Logger.init('AuthLogin');
class AuthLogin {
constructor() {
var confApp = config.app();
this.suiteId = confApp.suiteId;
this.indexCallback = confApp.indexCallBack;
this.suitSecret = confApp.suitSecret;
this.redisClient = config.db.redisClient;
}
//获取并缓存suiteAccessToken
getSuiteToken(callback, permanentCode) {
if (!this.redisClient) {
callback && callback({errmsg: 'redis数据库连接失败...'});
return;
}
this.callback = callback;
var self = this;
const suiteTicket = this.redisClient.get('suite_ticket_' + this.suiteId, function(err, suiteTicket) {
if (err) return false;
Logger.console(logger, 'redisClient:suiteTicket ===> ' + suiteTicket, 'verbose');
const data = {
suite_id: self.suiteId,
suite_secret: self.suitSecret,
suite_ticket: suiteTicket
}
var accessTokenKey = `suite_access_token:${self.suiteId}`;
self.redisClient.get(accessTokenKey, function(err, suiteAccessToken) {
if (err) return false;
Logger.console(logger, 'redisClient:suiteAccessToken ===> ' + suiteAccessToken, 'verbose');
if (suiteAccessToken) {
permanentCode ? permanentCode(suiteAccessToken) : self.getPreAuthCode(suiteAccessToken);
} else {
AuthApi.post(url.suiteToken, data, function(res) {
if (res.errcode) {
self.outputError(res.name, res.errcode, res.errmsg);
} else {
var suiteAccessToken = res.suite_access_token;
Logger.console(logger, 'suiteAccessToken ===> ' + suiteAccessToken, 'prompt');
permanentCode ? permanentCode(suiteAccessToken) : self.getPreAuthCode(suiteAccessToken);
self.redisClient.set(accessTokenKey, suiteAccessToken);
self.redisClient.expire(accessTokenKey, 7200);
}
});
}
});
});
}
//获取预授权码
getPreAuthCode(suiteAccessToken) {
var self = this;
AuthApi.get(url.preAuthCode(suiteAccessToken), function(res) {
if (res.errcode) {
self.outputError(res.name, res.errcode, res.errmsg);
} else {
var preAuthCode = res.pre_auth_code;
Logger.console(logger, 'preAuthCode ===> ' + preAuthCode, 'prompt');
self.authConfig(preAuthCode, suiteAccessToken);
}
});
}
//授权配置
authConfig(preAuthCode, suiteAccessToken) {
var self = this;
const data = {
pre_auth_code: preAuthCode,
session_info: {
auth_type: config.NODE_ENV === 'production' ? 0 : 1
}
};
AuthApi.post(url.authConfig(suiteAccessToken), data, function(res) {
if (res.errcode) {
self.outputError(res.name, res.errcode, res.errmsg);
} else {
Logger.console(logger, '======== 授权成功 ========', 'info');
}
});
}
//获取并缓存永久授权码
getPermanentCode(authCode) {
var self = this;
this.getSuiteToken(null, function(suiteAccessToken) {
console.log(url.permanentCode(suiteAccessToken));
AuthApi.post(url.permanentCode(suiteAccessToken), {auth_code: authCode}, function(res) {
console.log(res);
if (res.errcode) {
self.outputError(res.name, res.errcode, res.errmsg);
} else {
console.log(res);
}
});
});
}
//错误输出
outputError(name, errcode, errmsg) {
var errStr = `${name}(${errcode}): ${errmsg}`;
Logger.console(logger, errStr, 'error');
this.callback && this.callback({errmsg: errStr});
}
}

消息接收

单点登录时微信平台的第一步,也是最重要的一步,单点登录完成之后,我们来进行消息接收的配置,因为微信平台收到的参数需要用xml包装,所以我们用到了xml2js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
var config = require('../conf/config');
var AuthLogin = require('./authLogin');
var Store = require('../conf/store');
var parseString = require('xml2js').parseString;
var Logger = require('../until/log');
const logger = Logger.init('Message');
class Message {
constructor(cryptMsg) {
var self = this;
var xmlMessage = cryptMsg.message;
parseString(xmlMessage, function (err, result) {
self.resultJson = result.xml;
});
}
receiveMsgType() {
Logger.console(logger, 'message ===> ' + JSON.stringify(this.resultJson), 'prompt');
var infoType = this.resultJson.InfoType && this.resultJson.InfoType[0];
var msgType = this.resultJson.MsgType && this.resultJson.MsgType[0];
switch (infoType || msgType) {
case 'suite_ticket':
this.saveSuiteTicket();
break;
case 'create_auth':
this.getPermanentCodeByAuthCode();
break;
case 'cancel_auth':
break;
case 'change_contact':
break;
case 'event':
this.saveEventInfo();
break;
default:
}
}
//获取并缓存suiteTicket
saveSuiteTicket() {
var suiteTicket = this.resultJson.SuiteTicket[0];
var suiteid = this.resultJson.SuiteId[0];
const redisClient = config.db.redisClient;
redisClient.set('suite_ticket_' + suiteid, suiteTicket);
Logger.console(logger, 'suiteTicket ===> ' + suiteTicket, 'prompt');
}
//获取并缓存event信息
saveEventInfo() {
var toUserName = this.resultJson.ToUserName[0];
var fromUserName = this.resultJson.FromUserName[0];
var agentId = this.resultJson.AgentID[0];
Store.save('eventInfo', {toUserName: toUserName, fromUserName: fromUserName, agentId: agentId});
}
//使用auth_code换取永久授权码并缓存
getPermanentCodeByAuthCode() {
var authCode = this.resultJson.AuthCode[0];
Logger.console(logger, 'auth_code ===> ' + authCode, 'prompt');
var authLogin = new AuthLogin();
authLogin.getPermanentCode(authCode);
}
}

处理到这一步之后,我们已经能正常进行消息的接收,于此同时因为一些数据需要与后端业务进行交互,所以我们在之后需要进行一些接口调用,接下来就是正常的业务开发及API拓展。