lrf402788946 %!s(int64=4) %!d(string=hai) anos
achega
cb8babe52d

+ 29 - 0
.autod.conf.js

@@ -0,0 +1,29 @@
+'use strict';
+
+module.exports = {
+  write: true,
+  prefix: '^',
+  plugin: 'autod-egg',
+  test: [
+    'test',
+    'benchmark',
+  ],
+  dep: [
+    'egg',
+    'egg-scripts',
+  ],
+  devdep: [
+    'egg-ci',
+    'egg-bin',
+    'egg-mock',
+    'autod',
+    'autod-egg',
+    'eslint',
+    'eslint-config-egg',
+  ],
+  exclude: [
+    './test/fixtures',
+    './dist',
+  ],
+};
+

+ 1 - 0
.eslintignore

@@ -0,0 +1 @@
+coverage

+ 3 - 0
.eslintrc

@@ -0,0 +1,3 @@
+{
+  "extends": "eslint-config-egg"
+}

+ 42 - 0
.github/workflows/nodejs.yml

@@ -0,0 +1,42 @@
+# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
+
+name: Node.js CI
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+  schedule:
+    - cron: '0 2 * * *'
+
+jobs:
+  build:
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      fail-fast: false
+      matrix:
+        node-version: [10]
+        os: [ubuntu-latest, windows-latest, macos-latest]
+
+    steps:
+    - name: Checkout Git Source
+      uses: actions/checkout@v2
+
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+
+    - name: Install Dependencies
+      run: npm i -g npminstall && npminstall
+
+    - name: Continuous Integration
+      run: npm run ci
+
+    - name: Code Coverage
+      uses: codecov/codecov-action@v1
+      with:
+        token: ${{ secrets.CODECOV_TOKEN }}

+ 14 - 0
.gitignore

@@ -0,0 +1,14 @@
+logs/
+npm-debug.log
+yarn-error.log
+node_modules/
+package-lock.json
+yarn.lock
+coverage/
+.idea/
+run/
+.DS_Store
+*.sw*
+*.un~
+typings/
+.nyc_output/

+ 12 - 0
.travis.yml

@@ -0,0 +1,12 @@
+
+language: node_js
+node_js:
+  - '10'
+before_install:
+  - npm i npminstall -g
+install:
+  - npminstall
+script:
+  - npm run ci
+after_script:
+  - npminstall codecov && codecov

+ 33 - 0
README.md

@@ -0,0 +1,33 @@
+# weixin-gateway
+
+微信相关的处理
+
+## QuickStart
+
+<!-- add docs here for user -->
+
+see [egg docs][egg] for more detail.
+
+### Development
+
+```bash
+$ npm i
+$ npm run dev
+$ open http://localhost:7001/
+```
+
+### Deploy
+
+```bash
+$ npm start
+$ npm stop
+```
+
+### npm scripts
+
+- Use `npm run lint` to check code style.
+- Use `npm test` to run unit test.
+- Use `npm run autod` to auto detect dependencies upgrade, see [autod](https://www.npmjs.com/package/autod) for more detail.
+
+
+[egg]: https://eggjs.org

+ 12 - 0
app/controller/home.js

@@ -0,0 +1,12 @@
+'use strict';
+
+const Controller = require('egg').Controller;
+
+class HomeController extends Controller {
+  async index() {
+    const { ctx } = this;
+    ctx.body = 'hi, egg';
+  }
+}
+
+module.exports = HomeController;

+ 25 - 0
app/controller/weixin.js

@@ -0,0 +1,25 @@
+'use strict';
+const Controller = require('egg').Controller;
+const { CrudController } = require('naf-framework-mongoose/lib/controller');
+
+class WeixinController extends Controller {
+  constructor(ctx) {
+    super(ctx);
+    this.service = this.ctx.service.weixin;
+  }
+  async auth() {
+    await this.service.auth(this.ctx.params, this.ctx.query);
+  }
+  async authBack() {
+    await this.service.authBack(this.ctx.query);
+  }
+  async wxUser() {
+    const data = await this.service.wxUser(this.ctx.params, this.ctx.query);
+    this.ctx.ok({ data });
+  }
+  async jsapiAuth() {
+    const res = await this.service.jsapiAuth(this.ctx.request.body);
+    this.ctx.ok({ data: res });
+  }
+}
+module.exports = CrudController(WeixinController, {});

+ 12 - 0
app/router.js

@@ -0,0 +1,12 @@
+'use strict';
+
+/**
+ * @param {Egg.Application} app - egg application
+ */
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/', controller.home.index);
+  router.get('/wxgateway/wxlogin/:site', controller.weixin.auth);
+  router.get('/wxgateway/authBack', controller.weixin.authBack);
+  router.get('/wxgateway/wxuser/:site', controller.weixin.wxUser);
+};

+ 228 - 0
app/service/weixin.js

@@ -0,0 +1,228 @@
+'use strict';
+
+const assert = require('assert');
+const uuid = require('uuid');
+const random = require('string-random');
+const crypto = require('crypto');
+const urljoin = require('url-join');
+const _ = require('lodash');
+const moment = require('moment');
+const { BusinessError, ErrorCode } = require('naf-core').Error;
+const jwt = require('jsonwebtoken');
+const { AxiosService } = require('naf-framework-mongoose/lib/service');
+
+class WeixinAuthService extends AxiosService {
+  constructor(ctx, options) {
+    super(ctx, {}, _.get(ctx.app.config, 'wxapi', {}));
+    this.prefix = 'auth:';
+    this.jsapiKey = 'jsapi_ticket';
+    this.wxInfo = ctx.app.config.wxapi;
+    this.authBackUrl = `${ctx.app.config.baseUrl}/wxgateway/authBack`;
+  }
+  /**
+   * 网页授权
+   * @param {String} site 公众号标识,对config中设置
+   * @param {Object} query 参数
+   */
+  async auth({ site }, query) {
+    const { redirect_uri, ...others } = query;
+    const { baseUrl } = this.wxInfo;
+    if (!this.wxInfo[site]) throw new BusinessError(ErrorCode.ACCESS_DENIED, '缺少公众号站点设置');
+    const { appid } = this.wxInfo[site];
+    if (!appid) {
+      throw new BusinessError(ErrorCode.SERVICE_FAULT, '缺少公众号设置');
+    }
+    // 用于redis
+    const state = uuid.v4();
+    const key = `${this.prefix}${state}`;
+    const val = JSON.stringify({ ...others, redirect_uri, site });
+    await this.app.redis.set(key, val, 'EX', 600);
+    // const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${this.authBackUrl}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`;
+    let backUrl;
+    if (this.authBackUrl.startsWith('http')) {
+      backUrl = encodeURI(`${this.authBackUrl}?state=${state}`);
+    } else {
+      backUrl = encodeURI(`${this.ctx.protocol}://${this.ctx.host}${this.authBackUrl}?state=${state}`);
+    }
+    const to_uri = `${baseUrl}/api/auth?appid=${appid}&response_type=code&redirect_uri=${backUrl}#wechat`;
+    this.ctx.redirect(to_uri);
+  }
+
+  /**
+   * 网页授权回调,获取openid
+   * @param {Object} query 参数
+   */
+  async authBack(query) {
+    const { code, state } = query;
+    if (!code) throw new BusinessError(ErrorCode.SERVICE_FAULT, '授权未成功');
+    const req = await this.httpGet('/api/fetch', { code });
+    if (req.errcode && req.errcode !== 0) throw new BusinessError(ErrorCode.SERVICE_FAULT, 'openid获取失败');
+    const openid = _.get(req, 'openid');
+    if (!openid) {
+      this.ctx.logger.error(JSON.stringify(req.data));
+      throw new BusinessError(ErrorCode.SERVICE_FAULT, '未获取到openid');
+    }
+    // 验证获取openid结束,接下来应该返回前端
+    const key = `${this.prefix}${state}`;
+    let fqueries = await this.app.redis.get(key);
+    if (fqueries)fqueries = JSON.parse(fqueries);
+    let { redirect_uri } = fqueries;
+    const queryStr = `?openid=${openid}`;
+    redirect_uri = urljoin(redirect_uri, queryStr);
+    this.ctx.redirect(redirect_uri);
+  }
+  /**
+   * 换微信用户信息
+   * @param {String} site 公众号标识,对config中设置
+   * @param {Object} {openid}
+   */
+  async wxUser({ site }, { openid }) {
+    if (!this.wxInfo[site]) throw new BusinessError(ErrorCode.ACCESS_DENIED, '缺少公众号站点设置');
+    const { appid } = this.wxInfo[site];
+    const res = await this.httpGet('/api.weixin.qq.com/cgi-bin/user/info?lang=zh_CN', { appid, openid });
+    const object = _.pick(res, [ 'nickname', 'headimgurl', 'openid' ]); // 昵称,头像,openid
+    return { name: object.nickname, icon: object.headimgurl, openid };
+  }
+  /**
+   * JsApi验证
+   * @param {String} site 公众号标识,对config中设置
+   * @param {Object} query 参数
+   */
+  async jsapiAuth({ site }, query) {
+    let { url } = query;
+    url = decodeURIComponent(url);
+    let jsapi_ticket = await this.app.redis.get(`${site}:${this.jsapiKey}`);
+    if (!this.wxInfo[site]) throw new BusinessError(ErrorCode.ACCESS_DENIED, '缺少公众号站点设置');
+    const { appid, appSecret } = this.wxInfo[site];
+    if (!jsapi_ticket) {
+      // 1,获取access_token
+      const atUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${appSecret}`;
+      const req = await this.ctx.curl(atUrl, { method: 'GET', dataType: 'json' });
+      if (req.status !== 200) throw new BusinessError(ErrorCode.SERVICE_FAULT, 'access_token获取失败');
+      const access_token = _.get(req, 'data.access_token');
+      // 2,获取jsapi_token
+      const jtUrl = `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${access_token}&type=jsapi`;
+      const jtReq = await this.ctx.curl(jtUrl, { method: 'GET', dataType: 'json' });
+      if (jtReq.status !== 200) throw new BusinessError(ErrorCode.SERVICE_FAULT, 'jsapi_ticket获取失败');
+      jsapi_ticket = _.get(jtReq, 'data.ticket');
+      // 实际过期时间是7200s(2h),系统默认设置6000s
+      const expiresIn = _.get(jtReq, 'data.expires_in', 6000);
+      // 缓存jsapi_ticket,重复使用
+      await this.app.redis.set(`${site}:${this.jsapiKey}`, jsapi_ticket, 'EX', expiresIn);
+    }
+    const noncestr = random(16).toLowerCase();
+    const timestamp = moment().unix();
+    const signStr = `jsapi_ticket=${jsapi_ticket}&noncestr=${noncestr}&timestamp=${timestamp}&url=${url}`;
+    const sign = crypto.createHash('sha1').update(signStr).digest('hex');
+    return { jsapi_ticket, noncestr, timestamp, sign, appid, url };
+  }
+
+
+  async createJwt({ openid, nickname, subscribe }) {
+    const { secret, expiresIn = '1d', issuer = 'weixin' } = this.config.jwt;
+    const subject = openid;
+    const userinfo = { nickname, subscribe };
+    const token = await jwt.sign(userinfo, secret, { expiresIn, issuer, subject });
+    return token;
+  }
+  /**
+   * 创建二维码
+   * 随机生成二维码,并保存在Redis中,状态初始为pending
+   * 状态描述:
+   * pending - 等待扫码
+   * consumed - 使用二维码登录完成
+   * scand:token - Jwt登录凭证
+   */
+  async createQrcode() {
+    const qrcode = uuid();
+    const key = `visit:qrcode:group:${qrcode}`;
+    await this.app.redis.set(key, 'pending', 'EX', 600);
+    return qrcode;
+  }
+
+  /**
+   * 创建二维码
+   * 生成群二维码
+   * 状态描述:
+   * pending - 等待扫码
+   * consumed - 使用二维码登录完成
+   * scand:token - Jwt登录凭证
+   */
+  async createQrcodeGroup({ groupid }) {
+    const { authUrl = this.ctx.path } = this.app.config;
+    let backUrl;
+    if (authUrl.startsWith('http')) {
+      backUrl = encodeURI(`${authUrl}?state=${groupid}`);
+    } else {
+      backUrl = encodeURI(`${this.ctx.protocol}://${this.ctx.host}${authUrl}?state=${groupid}`);
+    }
+    console.log(backUrl);
+    return backUrl;
+  }
+
+  /**
+   * 扫码登录确认
+   */
+  async scanQrcode({ qrcode, token }) {
+    assert(qrcode, 'qrcode不能为空');
+    assert(token, 'token不能为空');
+    const key = `smart:qrcode:login:${qrcode}`;
+    const status = await this.app.redis.get(key);
+    if (!status) {
+      throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码已过期');
+    }
+    if (status !== 'pending') {
+      throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码状态无效');
+    }
+
+    // 验证Token
+    const { secret } = this.config.jwt;
+    const decoded = jwt.verify(token, secret, { issuer: 'weixin' });
+    this.ctx.logger.debug(`[weixin] qrcode login - ${decoded}`);
+
+    // TODO: 修改二维码状态,登录凭证保存到redis
+    await this.app.redis.set(key, `scaned:${token}`, 'EX', 600);
+
+    // TODO: 发布扫码成功消息
+    const { mq } = this.ctx;
+    const ex = 'qrcode.login';
+    if (mq) {
+      await mq.topic(ex, qrcode, 'scaned', { durable: true });
+    } else {
+      this.ctx.logger.error('!!!!!!没有配置MQ插件!!!!!!');
+    }
+  }
+
+  // 使用二维码换取登录凭证
+  async qrcodeLogin(qrcode) {
+    assert(qrcode, 'qrcode不能为空');
+    const key = `smart:qrcode:login:${qrcode}`;
+    const val = await this.app.redis.get(key);
+    if (!val) {
+      throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码已过期');
+    }
+    const [ status, token ] = val.split(':', 2);
+    if (status !== 'scaned' || !token) {
+      throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码状态无效');
+    }
+
+    // TODO: 修改二维码状态
+    await this.app.redis.set(key, 'consumed', 'EX', 600);
+
+    return { token };
+  }
+
+  // 检查二维码状态
+  async checkQrcode(qrcode) {
+    assert(qrcode, 'qrcode不能为空');
+    const key = `smart:qrcode:login:${qrcode}`;
+    const val = await this.app.redis.get(key);
+    if (!val) {
+      throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码已过期');
+    }
+    const [ status ] = val.split(':', 2);
+    return { status };
+  }
+}
+
+module.exports = WeixinAuthService;

+ 14 - 0
appveyor.yml

@@ -0,0 +1,14 @@
+environment:
+  matrix:
+    - nodejs_version: '10'
+
+install:
+  - ps: Install-Product node $env:nodejs_version
+  - npm i npminstall && node_modules\.bin\npminstall
+
+test_script:
+  - node --version
+  - npm --version
+  - npm run test
+
+build: off

+ 76 - 0
config/config.default.js

@@ -0,0 +1,76 @@
+/* eslint valid-jsdoc: "off" */
+
+'use strict';
+
+/**
+ * @param {Egg.EggAppInfo} appInfo app info
+ */
+module.exports = appInfo => {
+  /**
+   * built-in config
+   * @type {Egg.EggAppConfig}
+   **/
+  const config = exports = {};
+
+  // use for cookie sign key, should change to your own and keep security
+  config.keys = appInfo.name + '_1624959227229_4804';
+
+  // add your middleware config here
+  config.middleware = [];
+
+  // add your user config here
+  const userConfig = {
+    // myAppName: 'egg',
+  };
+  config.cluster = {
+    listen: {
+      port: 19999,
+    },
+  };
+
+  config.amqp = {
+    client: {
+      hostname: '127.0.0.1',
+      username: 'visit',
+      password: 'visit',
+      vhost: 'platform',
+    },
+    app: true,
+    agent: true,
+  };
+
+  config.redis = {
+    client: {
+      port: 6379, // Redis port
+      host: '127.0.0.1', // Redis host
+      password: '123456',
+      db: 3,
+    },
+  };
+  // 服务器发布路径
+  config.baseUrl = 'http://broadcast.waityou24.cn';
+  config.wxapi = {
+    baseUrl: 'http://wx.cc-lotus.info', // 微信网关地址
+    kjzl: {
+      appid: 'wxcf1b5457939b0932',
+      appSecret: 'f9a947c4a11f5304fc0bcabef98093d8',
+    },
+    jlstcompany: {
+      appid: 'wx696d9dc3f5c25e42',
+      appSecret: 'b794494632f448f56a6e722f57b42bc9',
+    },
+    jlstcoupons: {
+      appid: 'wxc98fa7131638a37c',
+      appSecret: 'f55f1734ee919a8e4eac64aea67f350a',
+    },
+    free: {
+      appid: 'wxdf3ed83c095be97a',
+      appSecret: 'd85dbe075c090cb12ce416bbda8e698c',
+    },
+  };
+
+  return {
+    ...config,
+    ...userConfig,
+  };
+};

+ 12 - 0
config/plugin.js

@@ -0,0 +1,12 @@
+'use strict';
+
+/** @type Egg.EggPlugin */
+exports.amqp = {
+  enable: true,
+  package: 'egg-naf-amqp',
+};
+exports.redis = {
+  enable: true,
+  package: 'egg-redis',
+};
+

+ 54 - 0
package.json

@@ -0,0 +1,54 @@
+{
+  "name": "weixin-gateway",
+  "version": "1.0.0",
+  "description": "微信相关的处理",
+  "private": true,
+  "egg": {
+    "framework": "naf-framework-mongoose"
+  },
+  "dependencies": {
+    "egg": "^2.15.1",
+    "egg-naf-amqp": "0.0.13",
+    "egg-redis": "^2.4.0",
+    "egg-scripts": "^2.11.0",
+    "jsonwebtoken": "^8.5.1",
+    "lodash": "^4.17.21",
+    "moment": "^2.29.1",
+    "naf-framework-mongoose": "^0.6.11",
+    "string-random": "^0.1.3",
+    "url-join": "^4.0.1"
+  },
+  "devDependencies": {
+    "autod": "^3.0.1",
+    "autod-egg": "^1.1.0",
+    "egg-bin": "^4.11.0",
+    "egg-ci": "^1.11.0",
+    "egg-mock": "^3.21.0",
+    "eslint": "^5.13.0",
+    "eslint-config-egg": "^7.1.0"
+  },
+  "engines": {
+    "node": ">=10.0.0"
+  },
+  "scripts": {
+    "start": "egg-scripts start --daemon --title=egg-server-weixin-gateway",
+    "stop": "egg-scripts stop --title=egg-server-weixin-gateway",
+    "dev": "egg-bin dev",
+    "debug": "egg-bin debug",
+    "test": "npm run lint -- --fix && npm run test-local",
+    "test-local": "egg-bin test",
+    "cov": "egg-bin cov",
+    "lint": "eslint .",
+    "ci": "npm run lint && npm run cov",
+    "autod": "autod"
+  },
+  "ci": {
+    "version": "10"
+  },
+  "repository": {
+    "type": "git",
+    "url": ""
+  },
+  "author": "lrf",
+  "license": "MIT"
+}

+ 20 - 0
test/app/controller/home.test.js

@@ -0,0 +1,20 @@
+'use strict';
+
+const { app, assert } = require('egg-mock/bootstrap');
+
+describe('test/app/controller/home.test.js', () => {
+  it('should assert', () => {
+    const pkg = require('../../../package.json');
+    assert(app.config.keys.startsWith(pkg.name));
+
+    // const ctx = app.mockContext({});
+    // yield ctx.service.xx();
+  });
+
+  it('should GET /', () => {
+    return app.httpRequest()
+      .get('/')
+      .expect('hi, egg')
+      .expect(200);
+  });
+});