├── docker-compose.yml ├── constants └── keys.js ├── redis └── index.js ├── serverless.yml ├── proxy └── index.js ├── routes ├── qywx-proxy.js └── qywx-utils.js ├── controllers ├── QywxProxyController.js └── QywxUtilsController.js ├── package.json ├── LICENSE ├── middlewares └── accessToken.js ├── README.md ├── sls.js ├── bin └── www.js └── .gitignore /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | redis: 4 | image: redis 5 | container_name: 'wecom-sidebar-redis' 6 | ports: 7 | - "6379:6379" 8 | restart: always -------------------------------------------------------------------------------- /constants/keys.js: -------------------------------------------------------------------------------- 1 | const keys = { 2 | ACCESS_TOKEN: 'access_token', 3 | CORP_JSAPI_TICKET: 'corp_jsapi_ticket', 4 | APP_JSAPI_TICKET: 'app_jsapi_ticket', 5 | } 6 | 7 | module.exports = keys; 8 | -------------------------------------------------------------------------------- /redis/index.js: -------------------------------------------------------------------------------- 1 | const Redis = require('ioredis'); 2 | 3 | const redis = new Redis({ 4 | host: process.env.REDIS_HOST || 'localhost', 5 | port: process.env.REDIS_PORT || 6379, 6 | }); 7 | 8 | module.exports = redis 9 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | component: koa 2 | name: koaDemo 3 | app: koa-starter 4 | inputs: 5 | src: 6 | src: ./ 7 | exclude: 8 | - .env 9 | region: ap-guangzhou 10 | runtime: Nodejs10.15 11 | apigatewayConf: 12 | protocols: 13 | - http 14 | - https 15 | environment: release 16 | -------------------------------------------------------------------------------- /proxy/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | 3 | const baseURL = 'https://qyapi.weixin.qq.com/cgi-bin'; 4 | 5 | const proxy = axios.create({ 6 | baseURL, 7 | proxy: false // 不指定会报错 SSL routines:ssl3_get_record:wrong version number,参考:https://github.com/guzzle/guzzle/issues/2593 8 | }) 9 | 10 | module.exports = proxy 11 | -------------------------------------------------------------------------------- /routes/qywx-proxy.js: -------------------------------------------------------------------------------- 1 | const router = require('koa-router')() 2 | const QywxBaseController = require("../controllers/QywxProxyController"); 3 | 4 | const prefix = '/api/qywx-proxy/'; 5 | 6 | router.prefix(prefix) 7 | 8 | const getUrl = (fullUrl) => { 9 | const [rawUrl] = fullUrl.split('?'); 10 | return rawUrl.replace(prefix, ''); 11 | } 12 | 13 | router.get('*', async (ctx) => { 14 | const url = getUrl(ctx.request.url); 15 | 16 | ctx.body = await QywxBaseController.getRequest(url, ctx.request.query, ctx.accessToken) 17 | }) 18 | 19 | router.post('*', async (ctx) => { 20 | const url = getUrl(ctx.request.url); 21 | 22 | ctx.body = await QywxBaseController.postRequest(url, ctx.request.body, ctx.accessToken) 23 | }) 24 | 25 | module.exports = router 26 | -------------------------------------------------------------------------------- /controllers/QywxProxyController.js: -------------------------------------------------------------------------------- 1 | const proxy = require('../proxy') 2 | 3 | const getRequest = async (url, query, accessToken) => { 4 | const response = await proxy.get(url, { 5 | params: { 6 | ...query, 7 | access_token: accessToken, 8 | }, 9 | }) 10 | 11 | console.log(`get ${url}`, response.data) 12 | 13 | return response.data; 14 | } 15 | 16 | const postRequest = async (url, body, accessToken) => { 17 | const response = await proxy.post(url, body, { 18 | params: { 19 | access_token: accessToken 20 | } 21 | }) 22 | 23 | console.log(`post ${url}`, response.data) 24 | 25 | return response.data; 26 | } 27 | 28 | const QywxProxyController = { 29 | getRequest, 30 | postRequest, 31 | } 32 | 33 | module.exports = QywxProxyController; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wecom-sidebar-sls", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "sls.js", 6 | "scripts": { 7 | "start": "node bin/www", 8 | "dev": "./node_modules/.bin/nodemon bin/www", 9 | "prd": "pm2 start bin/www", 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "deploy": "sls deploy" 12 | }, 13 | "author": "HaixiangYan", 14 | "license": "MIT", 15 | "dependencies": { 16 | "axios": "^0.21.1", 17 | "debug": "^4.1.1", 18 | "dotenv": "^8.2.0", 19 | "ioredis": "^4.28.0", 20 | "koa": "^2.11.0", 21 | "koa-bodyparser": "^4.2.1", 22 | "koa-json": "^2.0.2", 23 | "koa-logger": "^3.2.0", 24 | "koa-onerror": "^4.1.0", 25 | "koa-router": "^8.0.8", 26 | "koa-static": "^5.0.0", 27 | "koa2-cors": "^2.0.6", 28 | "sha1": "^1.1.1" 29 | }, 30 | "devDependencies": { 31 | "@types/axios": "^0.14.0", 32 | "@types/ioredis": "^4.27.7", 33 | "@types/koa": "^2.13.1", 34 | "@types/node": "^14.14.32", 35 | "nodemon": "^2.0.13" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Haixiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /middlewares/accessToken.js: -------------------------------------------------------------------------------- 1 | const proxy = require('../proxy') 2 | const redis = require("../redis"); 3 | const keys = require("../constants/keys"); 4 | 5 | const OFFSET = 100; 6 | 7 | const fetchAccessToken = async () => { 8 | const response = await proxy.get('/gettoken', { 9 | params: { 10 | corpid: process.env.CORP_ID, 11 | corpsecret: process.env.CORP_SECRET, 12 | }, 13 | }) 14 | 15 | console.log('fetchAccessToken response', response.data); 16 | 17 | const { access_token, expires_in } = response.data; 18 | 19 | // 存入 redis 20 | await redis.set(keys.ACCESS_TOKEN, access_token, 'ex', expires_in - OFFSET); 21 | 22 | console.log('远程获取 access_token: ', access_token) 23 | 24 | return access_token 25 | } 26 | 27 | module.exports = () => { 28 | return async (ctx, next) => { 29 | const cacheAccessToken = await redis.get(keys.ACCESS_TOKEN); 30 | 31 | console.log('redis access_token', cacheAccessToken); 32 | 33 | if (cacheAccessToken) { 34 | console.log('获取缓存 access_token', cacheAccessToken) 35 | } 36 | 37 | ctx.accessToken = cacheAccessToken || (await fetchAccessToken()) 38 | 39 | await next() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wecom-sidebar-express-tpl 2 | 3 | 企业微信服务端API的服务器。 4 | 5 | ## 功能 6 | 7 | - [x] [企业微信服务端](https://open.work.weixin.qq.com/api/doc/90001/90143/91201) 的转发服务 8 | - [x] Redis 缓存 `access_token`, `app_jsapi_ticket`, `corp_jsapi_ticket` 9 | - [x] Docker 启动 Redis 10 | 11 | ## 配置 12 | 13 | 其中需要用到 `corpId`,`agentId`,`corpSecret`,需要在项目根目录创建 `.env`(目前已隐藏),示例 14 | 15 | ```dotenv 16 | # .env 17 | 18 | # Redis 19 | REDIS_HOST=localhost 20 | REDIS_PORT=6379 21 | 22 | # 在 https://work.weixin.qq.com/wework_admin/frame#profile 这里可以找到 23 | CORP_ID=企业ID 24 | 25 | # 在 https://work.weixin.qq.com/wework_admin/frame#apps 里的自建应用里可以找到(注意这里是自建应用里的 secret,不是客户联系里的回调 secret) 26 | CORP_SECRET=自建应用的CORP_SECRET 27 | 28 | # 在 https://work.weixin.qq.com/wework_admin/frame#apps 里的自建应用里可以找到 29 | AGENT_ID=自建应用的AGENT_ID 30 | ``` 31 | 32 | ## 启动 33 | 34 | 先使用 docker 来启动 redis: 35 | 36 | ```shell 37 | docker-compose -f docker-compose.yml up -d 38 | ``` 39 | 40 | 然后使用 npm 启动项目: 41 | 42 | ```bash 43 | npm run dev 44 | ``` 45 | 46 | ## 更多 47 | 48 | * 侧边栏的前端开发模板(React)可以看 [wecom-sidebar-react-tpl](https://github.com/wecom-sidebar/wecom-sidebar-react-tpl) 49 | * 侧边栏的微前端开发模式(Qiankun)可以看 [weccom-sidebar-qiankun-tpl](https://github.com/wecom-sidebar/wecom-sidebar-qiankun-tpl) 50 | -------------------------------------------------------------------------------- /sls.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | const Koa = require('koa') 4 | const app = new Koa() 5 | const json = require('koa-json') 6 | const onerror = require('koa-onerror') 7 | const bodyparser = require('koa-bodyparser') 8 | const logger = require('koa-logger') 9 | const cors = require('koa2-cors') 10 | 11 | const accessToken = require('./middlewares/accessToken') 12 | 13 | const qywxProxy = require('./routes/qywx-proxy') 14 | const qywxUtils = require('./routes/qywx-utils') 15 | 16 | // 错误处理 17 | onerror(app) 18 | 19 | // 中间件 20 | app.use(cors()) 21 | app.use(bodyparser({ 22 | enableTypes:['json', 'form', 'text'] 23 | })) 24 | app.use(json()) 25 | app.use(logger()) 26 | app.use(require('koa-static')(__dirname + '/public')) 27 | 28 | // 日志 29 | app.use(async (ctx, next) => { 30 | const start = new Date() 31 | await next() 32 | const ms = new Date() - start 33 | console.log(`${ctx.method} ${ctx.url} - ${ms}ms`) 34 | }) 35 | 36 | // 使用绑定 accessToken 的中间件 37 | app.use(accessToken()) 38 | 39 | // 路由 40 | app.use(qywxProxy.routes(), qywxProxy.allowedMethods()) 41 | app.use(qywxUtils.routes(), qywxUtils.allowedMethods()) 42 | 43 | // 错误处理 44 | app.on('error', (err) => { 45 | console.error('server error', err) 46 | }); 47 | 48 | module.exports = app 49 | -------------------------------------------------------------------------------- /routes/qywx-utils.js: -------------------------------------------------------------------------------- 1 | const router = require('koa-router')() 2 | 3 | const {sign} = require('../controllers/QywxUtilsController'); 4 | const QywxUtilsController = require("../controllers/QywxUtilsController"); 5 | 6 | const prefix = '/api/qywx-utils/'; 7 | 8 | router.prefix(prefix) 9 | 10 | const nonceStr = Buffer.from(new Date().toISOString()).toString('base64') 11 | const timestamp = Date.now(); 12 | 13 | // 获取应用签名,agentConfig 需要的 sign 字段 14 | router.get('/signatures', async (ctx) => { 15 | const {url} = ctx.request.query; 16 | 17 | const [parsedUrl] = decodeURIComponent(url).split('#'); 18 | 19 | // 获取 js api ticket(包含 corp 和 app) 20 | const {corpTicket, appTicket} = await QywxUtilsController.getJsApiTickets(parsedUrl, ctx.accessToken); 21 | 22 | console.log('获取 ticket', corpTicket, appTicket); 23 | 24 | // 生成签名 25 | const corpSignature = sign(corpTicket, nonceStr, timestamp, parsedUrl) 26 | const appSignature = sign(appTicket, nonceStr, timestamp, parsedUrl) 27 | 28 | ctx.body = { 29 | meta: { 30 | nonceStr, 31 | timestamp, 32 | url: parsedUrl, 33 | }, 34 | app: { 35 | ticket: appTicket, 36 | signature: appSignature, 37 | }, 38 | corp: { 39 | ticket: corpTicket, 40 | signature: corpSignature, 41 | }, 42 | } 43 | }) 44 | 45 | module.exports = router 46 | -------------------------------------------------------------------------------- /bin/www.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | const app = require('../sls'); 8 | const debug = require('debug')('demo:server'); 9 | const http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | const port = normalizePort(process.env.PORT || '5000'); 16 | // app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | const server = http.createServer(app.callback()); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | const port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | const bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | const addr = server.address(); 86 | const bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /controllers/QywxUtilsController.js: -------------------------------------------------------------------------------- 1 | const sha1 = require('sha1'); 2 | const keys = require("../constants/keys"); 3 | const redis = require("../redis"); 4 | const QywxBaseController = require("../controllers/QywxProxyController"); 5 | 6 | const OFFSET = 100; 7 | 8 | // 生成签名,具体可参考:https://work.weixin.qq.com/api/doc/90001/90144/90539 9 | // 参与签名的参数有四个: noncestr(随机字符串), jsapi_ticket, timestamp(时间戳), url(当前网页的URL, 不包含#及其后面部分) 10 | // 这里的 URL 需要前端传过来 11 | const sign = (ticket, nonceStr, timestamp, fullUrl) => { 12 | const [url] = fullUrl.split('#'); // 最好不用 history 模式,不然每次都要 config 13 | 14 | const rawStr = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`; 15 | 16 | return sha1(rawStr) 17 | } 18 | 19 | const getJsApiTickets = async (url, accessToken) => { 20 | const [urlKey] = url.split('#') 21 | 22 | // 使用前缀和 url 生成当前的 key 23 | const corpJsApiTicketsKey = `${keys.CORP_JSAPI_TICKET}_${urlKey}`; 24 | const appJsApiTicketsKey = `${keys.APP_JSAPI_TICKET}_${urlKey}`; 25 | 26 | // 缓存 ticket 27 | const cacheCorpJsApiTickets = await redis.get(corpJsApiTicketsKey); 28 | const cacheAppJsApiTicket = await redis.get(appJsApiTicketsKey); 29 | 30 | // 是否有缓存的 tickets 31 | if (cacheAppJsApiTicket || cacheCorpJsApiTickets) { 32 | console.log('使用 redis 的 ticket', cacheCorpJsApiTickets, cacheAppJsApiTicket) 33 | return { 34 | corpTicket: cacheCorpJsApiTickets, 35 | appTicket: cacheAppJsApiTicket 36 | } 37 | } 38 | 39 | // 获取企业 jsapi_ticket 和应用 jsapi_ticket 40 | console.log('远程获取 ticket', cacheCorpJsApiTickets, cacheAppJsApiTicket) 41 | const [corpTicketRes, appTicketRes] = await Promise.all([ 42 | QywxBaseController.getRequest('/get_jsapi_ticket', {}, accessToken), 43 | QywxBaseController.getRequest('/ticket/get', { type: 'agent_config'}, accessToken) 44 | ]); 45 | 46 | // 写入缓存 47 | await redis.set(corpJsApiTicketsKey, corpTicketRes.ticket, 'EX', corpTicketRes.expires_in - OFFSET) 48 | await redis.set(appJsApiTicketsKey, appTicketRes.ticket, 'EX', appTicketRes.expires_in - OFFSET) 49 | 50 | return { 51 | corpTicket: corpTicketRes.ticket, 52 | appTicket: appTicketRes.ticket, 53 | } 54 | } 55 | 56 | const QywxUtilsController = { 57 | sign, 58 | getJsApiTickets, 59 | } 60 | 61 | module.exports = QywxUtilsController; 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### macOS template 3 | # General 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | ### Node template 31 | # Logs 32 | logs 33 | *.log 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | lerna-debug.log* 38 | 39 | # Diagnostic reports (https://nodejs.org/api/report.html) 40 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 41 | 42 | # Runtime data 43 | pids 44 | *.pid 45 | *.seed 46 | *.pid.lock 47 | 48 | # Directory for instrumented libs generated by jscoverage/JSCover 49 | lib-cov 50 | 51 | # Coverage directory used by tools like istanbul 52 | coverage 53 | *.lcov 54 | 55 | # nyc test coverage 56 | .nyc_output 57 | 58 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 59 | .grunt 60 | 61 | # Bower dependency directory (https://bower.io/) 62 | bower_components 63 | 64 | # node-waf configuration 65 | .lock-wscript 66 | 67 | # Compiled binary addons (https://nodejs.org/api/addons.html) 68 | build/Release 69 | 70 | # Dependency directories 71 | node_modules/ 72 | jspm_packages/ 73 | 74 | # Snowpack dependency directory (https://snowpack.dev/) 75 | web_modules/ 76 | 77 | # TypeScript cache 78 | *.tsbuildinfo 79 | 80 | # Optional npm cache directory 81 | .npm 82 | 83 | # Optional eslint cache 84 | .eslintcache 85 | 86 | # Microbundle cache 87 | .rpt2_cache/ 88 | .rts2_cache_cjs/ 89 | .rts2_cache_es/ 90 | .rts2_cache_umd/ 91 | 92 | # Optional REPL history 93 | .node_repl_history 94 | 95 | # Output of 'npm pack' 96 | *.tgz 97 | 98 | # Yarn Integrity file 99 | .yarn-integrity 100 | 101 | # dotenv environment variables file 102 | .env 103 | .env.test 104 | 105 | # parcel-bundler cache (https://parceljs.org/) 106 | .cache 107 | .parcel-cache 108 | 109 | # Next.js build output 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | .nuxt 115 | dist 116 | 117 | # Gatsby files 118 | .cache/ 119 | # Comment in the public line in if your project uses Gatsby and not Next.js 120 | # https://nextjs.org/blog/next-9-1#public-directory-support 121 | # public 122 | 123 | # vuepress build output 124 | .vuepress/dist 125 | 126 | # Serverless directories 127 | .serverless/ 128 | 129 | # FuseBox cache 130 | .fusebox/ 131 | 132 | # DynamoDB Local files 133 | .dynamodb/ 134 | 135 | # TernJS port file 136 | .tern-port 137 | 138 | # Stores VSCode versions used for testing VSCode extensions 139 | .vscode-test 140 | 141 | # yarn v2 142 | .yarn/cache 143 | .yarn/unplugged 144 | .yarn/build-state.yml 145 | .yarn/install-state.gz 146 | .pnp.* 147 | 148 | .idea 149 | --------------------------------------------------------------------------------