zs 1 éve
commit
3913cd5b42

+ 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"
+}

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

@@ -0,0 +1,46 @@
+# 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:
+      - main
+      - master
+  pull_request:
+    branches:
+      - main
+      - 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@5 && 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@5 -g
+install:
+  - npminstall
+script:
+  - npm run ci
+after_script:
+  - npminstall codecov && codecov

+ 33 - 0
README.md

@@ -0,0 +1,33 @@
+# server-exam
+
+保安-考试服务
+
+## 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

+ 13 - 0
app.js

@@ -0,0 +1,13 @@
+'use strict';
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  async willReady() {
+    const ctx = await this.app.createAnonymousContext();
+    ctx.service.install.index();
+  }
+
+}
+module.exports = AppBootHook;

+ 44 - 0
app/controller/.answer.js

@@ -0,0 +1,44 @@
+module.exports = {
+  create: {
+    requestBody: ['!exam_place_id', 'exam_info', '!user_id', 'user_name', 'user_card', 'score', 'config', '!questions'],
+  },
+  destroy: {
+    params: ['!id'],
+    service: 'delete',
+  },
+  update: {
+    params: ['!id'],
+    requestBody: ['!exam_place_id', 'exam_info', '!user_id', 'user_name', 'user_card', 'score', 'config', '!questions'],
+  },
+  show: {
+    parameters: {
+      params: ['id'],
+    },
+    service: 'fetch',
+  },
+  index: {
+    parameters: {
+      query: {
+        exam_place_id: 'exam_place_id',
+        user_id: 'user_id',
+        user_name: 'user_name',
+        user_card: 'user_card',
+        'meta.createdAt@start': 'meta.createdAt@start',
+        'meta.createdAt@end': 'meta.createdAt@end',
+      },
+      // options: {
+      //   "meta.state": 0 // 默认条件
+      // },
+    },
+    service: 'query',
+    options: {
+      query: ['skip', 'limit'],
+      sort: ['meta.createdAt'],
+      desc: true,
+      count: true,
+    },
+  },
+  getExamQuestion: {
+    requestBody: ['exam', 'exam_place_id', 'exam_info', 'user_id', 'user_name', 'user_card'],
+  },
+};

+ 37 - 0
app/controller/.config.js

@@ -0,0 +1,37 @@
+module.exports = {
+  create: {
+    requestBody: ['can_exam', 'proportion', 'exam_proportion', 'rules', 'score'],
+  },
+  destroy: {
+    params: ['!id'],
+    service: 'delete',
+  },
+  update: {
+    params: ['id'],
+    requestBody: ['can_exam', 'proportion', 'exam_proportion', 'rules', 'score'],
+  },
+  show: {
+    parameters: {
+      params: ['id'],
+    },
+    service: 'fetch',
+  },
+  index: {
+    parameters: {
+      query: {
+        'meta.createdAt@start': 'meta.createdAt@start',
+        'meta.createdAt@end': 'meta.createdAt@end',
+      },
+      // options: {
+      //   "meta.state": 0 // 默认条件
+      // },
+    },
+    service: 'query',
+    options: {
+      query: ['skip', 'limit'],
+      sort: ['meta.createdAt'],
+      desc: true,
+      count: true,
+    },
+  },
+};

+ 38 - 0
app/controller/.exam.js

@@ -0,0 +1,38 @@
+module.exports = {
+  create: {
+    requestBody: ['type', 'questions', 'proportion'],
+  },
+  destroy: {
+    params: ['!id'],
+    service: 'delete',
+  },
+  update: {
+    params: ['!id'],
+    requestBody: ['type', 'questions', 'proportion'],
+  },
+  show: {
+    parameters: {
+      params: ['!id'],
+    },
+    service: 'fetch',
+  },
+  index: {
+    parameters: {
+      query: {
+        type: 'type',
+        'meta.createdAt@start': 'meta.createdAt@start',
+        'meta.createdAt@end': 'meta.createdAt@end',
+      },
+      // options: {
+      //   "meta.state": 0 // 默认条件
+      // },
+    },
+    service: 'query',
+    options: {
+      query: ['skip', 'limit'],
+      sort: ['meta.createdAt'],
+      desc: true,
+      count: true,
+    },
+  },
+};

+ 42 - 0
app/controller/.question.js

@@ -0,0 +1,42 @@
+module.exports = {
+  create: {
+    requestBody: ['title', 'type', 'selects'],
+  },
+  destroy: {
+    params: ['!id'],
+    service: 'delete',
+  },
+  update: {
+    params: ['!id'],
+    requestBody: ['title', 'type', 'selects'],
+  },
+  show: {
+    parameters: {
+      params: ['!id'],
+    },
+    service: 'fetch',
+  },
+  index: {
+    parameters: {
+      query: {
+        title: '%title%',
+        type: 'type',
+        'meta.createdAt@start': 'meta.createdAt@start',
+        'meta.createdAt@end': 'meta.createdAt@end',
+      },
+      // options: {
+      //   "meta.state": 0 // 默认条件
+      // },
+    },
+    service: 'query',
+    options: {
+      query: ['skip', 'limit'],
+      sort: ['meta.createdAt'],
+      desc: true,
+      count: true,
+    },
+  },
+  import: {
+    requestBody: ['url'],
+  },
+};

+ 39 - 0
app/controller/.question_type.js

@@ -0,0 +1,39 @@
+module.exports = {
+  create: {
+    requestBody: ['title', 'type', 'score'],
+  },
+  destroy: {
+    params: ['!id'],
+    service: 'delete',
+  },
+  update: {
+    params: ['!id'],
+    requestBody: ['title', 'type', 'score'],
+  },
+  show: {
+    parameters: {
+      params: ['!id'],
+    },
+    service: 'fetch',
+  },
+  index: {
+    parameters: {
+      query: {
+        title: 'title',
+        type: 'type',
+        'meta.createdAt@start': 'meta.createdAt@start',
+        'meta.createdAt@end': 'meta.createdAt@end',
+      },
+      // options: {
+      //   "meta.state": 0 // 默认条件
+      // },
+    },
+    service: 'query',
+    options: {
+      query: ['skip', 'limit'],
+      sort: ['meta.createdAt'],
+      desc: true,
+      count: true,
+    },
+  },
+};

+ 13 - 0
app/controller/answer.js

@@ -0,0 +1,13 @@
+'use strict';
+const meta = require('./.answer.js');
+const Controller = require('egg').Controller;
+const { CrudController } = require('naf-framework-mongoose-free/lib/controller');
+
+// 答案
+class AnswerController extends Controller {
+  constructor(ctx) {
+    super(ctx);
+    this.service = this.ctx.service.answer;
+  }
+}
+module.exports = CrudController(AnswerController, meta);

+ 13 - 0
app/controller/config.js

@@ -0,0 +1,13 @@
+'use strict';
+const meta = require('./.config.js');
+const Controller = require('egg').Controller;
+const { CrudController } = require('naf-framework-mongoose-free/lib/controller');
+
+// 设置
+class ConfigController extends Controller {
+  constructor(ctx) {
+    super(ctx);
+    this.service = this.ctx.service.config;
+  }
+}
+module.exports = CrudController(ConfigController, meta);

+ 13 - 0
app/controller/exam.js

@@ -0,0 +1,13 @@
+'use strict';
+const meta = require('./.exam.js');
+const Controller = require('egg').Controller;
+const { CrudController } = require('naf-framework-mongoose-free/lib/controller');
+
+// 试卷
+class ExamController extends Controller {
+  constructor(ctx) {
+    super(ctx);
+    this.service = this.ctx.service.exam;
+  }
+}
+module.exports = CrudController(ExamController, meta);

+ 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;

+ 13 - 0
app/controller/question.js

@@ -0,0 +1,13 @@
+'use strict';
+const meta = require('./.question.js');
+const Controller = require('egg').Controller;
+const { CrudController } = require('naf-framework-mongoose-free/lib/controller');
+
+// 题目
+class QuestionController extends Controller {
+  constructor(ctx) {
+    super(ctx);
+    this.service = this.ctx.service.question;
+  }
+}
+module.exports = CrudController(QuestionController, meta);

+ 13 - 0
app/controller/question_type.js

@@ -0,0 +1,13 @@
+'use strict';
+const meta = require('./.question_type.js');
+const Controller = require('egg').Controller;
+const { CrudController } = require('naf-framework-mongoose-free/lib/controller');
+
+// 题目类型
+class Question_typeController extends Controller {
+  constructor(ctx) {
+    super(ctx);
+    this.service = this.ctx.service.questionType;
+  }
+}
+module.exports = CrudController(Question_typeController, meta);

+ 29 - 0
app/model/answer.js

@@ -0,0 +1,29 @@
+'use strict';
+const Schema = require('mongoose').Schema;
+const moment = require('moment');
+const metaPlugin = require('naf-framework-mongoose-free/lib/model/meta-plugin');
+const { ObjectId } = require('mongoose').Types;
+// 答案,结果表
+const answer = {
+  exam_place_id: { type: String }, // 考场id
+  exam_info: { type: Object }, // 本次考试的相关信息
+  user_id: { type: String }, // 保安员id
+  user_name: { type: String }, // 保安员姓名
+  user_card: { type: String }, // 保安员身份号
+  score: { type: Number }, // 分数
+  config: { type: Object }, // 设置,生成试卷时的设置
+  questions: { type: Array }, // 具体详情(此处都存起来,将该转换的数据都转换了,要当时的值保存在这)
+  remark: { type: String },
+};
+const schema = new Schema(answer, { toJSON: { virtuals: true } });
+schema.index({ id: 1 });
+schema.index({ exam_place_id: 1 });
+schema.index({ user_id: 1 });
+schema.index({ user_name: 1 });
+schema.index({ user_card: 1 });
+schema.index({ 'meta.createdAt': 1 });
+schema.plugin(metaPlugin);
+module.exports = app => {
+  const { mongoose } = app;
+  return mongoose.model('Answer', schema, 'answer');
+};

+ 22 - 0
app/model/config.js

@@ -0,0 +1,22 @@
+'use strict';
+const Schema = require('mongoose').Schema;
+const moment = require('moment');
+const metaPlugin = require('naf-framework-mongoose-free/lib/model/meta-plugin');
+const { ObjectId } = require('mongoose').Types;
+// 设置表
+const config = {
+  can_exam: { type: Boolean, default: false }, // 是否开放考试
+  proportion: { type: Array }, // 题型比例
+  exam_proportion: { type: Array }, // 考试题型比例
+  rules: { type: Array }, // 额外规则
+  score: { type: Number, default: 100 }, // 试卷满分
+  remark: { type: String },
+};
+const schema = new Schema(config, { toJSON: { virtuals: true } });
+schema.index({ id: 1 });
+schema.index({ 'meta.createdAt': 1 });
+schema.plugin(metaPlugin);
+module.exports = app => {
+  const { mongoose } = app;
+  return mongoose.model('Config', schema, 'config');
+};

+ 20 - 0
app/model/exam.js

@@ -0,0 +1,20 @@
+'use strict';
+const Schema = require('mongoose').Schema;
+const moment = require('moment');
+const metaPlugin = require('naf-framework-mongoose-free/lib/model/meta-plugin');
+const { ObjectId } = require('mongoose').Types;
+// 试卷表
+const exam = {
+  type: { type: String }, // 类型:exam-考试;practise-练习
+  questions: { type: Array }, // 问题列表(只存id,之后去换,保持联动的状态)
+  proportion: { type: Array }, // 题型比例
+  remark: { type: String },
+};
+const schema = new Schema(exam, { toJSON: { virtuals: true } });
+schema.index({ id: 1 });
+schema.index({ 'meta.createdAt': 1 });
+schema.plugin(metaPlugin);
+module.exports = app => {
+  const { mongoose } = app;
+  return mongoose.model('Exam', schema, 'exam');
+};

+ 20 - 0
app/model/question.js

@@ -0,0 +1,20 @@
+'use strict';
+const Schema = require('mongoose').Schema;
+const metaPlugin = require('naf-framework-mongoose-free/lib/model/meta-plugin');
+const { ObjectId } = require('mongoose').Types;
+// 题目表
+const question = {
+  title: { type: String }, // 题目
+  type: { type: String }, // 类型
+  selects: { type: Array }, // 选项: {label,value,isRight:Boolean, true:正确/false:错误}
+  remark: { type: String },
+  times: { type: Number, default: 0 }, // 出题次数
+};
+const schema = new Schema(question, { toJSON: { virtuals: true } });
+schema.index({ id: 1 });
+schema.index({ 'meta.createdAt': 1 });
+schema.plugin(metaPlugin);
+module.exports = app => {
+  const { mongoose } = app;
+  return mongoose.model('Question', schema, 'question');
+};

+ 22 - 0
app/model/question_type.js

@@ -0,0 +1,22 @@
+'use strict';
+const Schema = require('mongoose').Schema;
+const moment = require('moment');
+const metaPlugin = require('naf-framework-mongoose-free/lib/model/meta-plugin');
+const { ObjectId } = require('mongoose').Types;
+// 题目类型表
+const question_type = {
+  title: { type: String }, // 类型名称,中文显示
+  type: { type: String }, // select:单选; checkBox:多选; input:简答
+  // checkbox: 多选: 全对全分,有错0分 / 有对半分,全错0分; 2种模式
+  // input: 关键词: 包含关键词就正确
+  score: { type: Number }, // 每题的分数
+  remark: { type: String },
+};
+const schema = new Schema(question_type, { toJSON: { virtuals: true } });
+schema.index({ id: 1 });
+schema.index({ 'meta.createdAt': 1 });
+schema.plugin(metaPlugin);
+module.exports = app => {
+  const { mongoose } = app;
+  return mongoose.model('Question_type', schema, 'question_type');
+};

+ 14 - 0
app/router.js

@@ -0,0 +1,14 @@
+'use strict';
+
+/**
+ * @param {Egg.Application} app - egg application
+ */
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/', controller.home.index);
+  require('./z_router/answer')(app); // 答案
+  require('./z_router/config')(app);
+  require('./z_router/exam')(app);
+  require('./z_router/question_type')(app);
+  require('./z_router/question')(app);
+};

+ 244 - 0
app/service/answer.js

@@ -0,0 +1,244 @@
+'use strict';
+const { CrudService } = require('naf-framework-mongoose-free/lib/service');
+const { BusinessError, ErrorCode } = require('naf-core').Error;
+const _ = require('lodash');
+const { ObjectId } = require('mongoose').Types;
+const axios = require('axios');
+
+// 结果
+class AnswerService extends CrudService {
+  constructor(ctx) {
+    super(ctx, 'answer');
+    this.model = this.ctx.model.Answer;
+    this.configModel = this.ctx.model.Config;
+    this.questionModel = this.ctx.model.Question;
+    this.questTypeModel = this.ctx.model.QuestionType;
+  }
+
+  /**
+   * 生成试卷,不是考试就不创建数据
+   * @param {Object} body 参数
+   * @property {Boolean} exam 是否考试
+   * @property {String} exam_place_id 考场id
+   * @property {String} user_id 保安员id
+   */
+  async getExamQuestion({ exam = false, ...info }) {
+    const config = await this.configModel.findOne();
+    const questionTypeList = await this.questTypeModel.find();
+    // 是否真的生成数据
+    let save = false;
+    if (exam && _.get(config, 'can_exam')) save = true;
+    // 分为;练习试卷和考试试卷2套题型分配比例,规则是为考试试卷提供额外条件
+    const { proportion, exam_proportion, score: maxScore } = config;
+    const questions = [];
+    let plan;
+    if (save) {
+      // 因为有 额外的规则会影响正常进行分配的方式.所以需要处额外规则与正常规则之间问题
+      // 额外规则: exam_proportion中is_exam的总和加一起不能超过5%
+      const pMinScore = exam_proportion.reduce((p, n) => p + _.multiply(_.get(n, 'number_min', 0), _.get(n, 'score', 0)), 0);
+      if (pMinScore > maxScore) throw new BusinessError(ErrorCode.DATA_INVALID, '考试题型设置出现错误');
+      plan = this.getPlan(exam_proportion, maxScore, true);
+    } else {
+      const pMinScore = proportion.reduce((p, n) => p + _.multiply(_.get(n, 'number_min', 0), _.get(n, 'score', 0)), 0);
+      if (pMinScore > maxScore) throw new BusinessError(ErrorCode.DATA_INVALID, '练习题型设置出现错误');
+      // 循环题型,抽题,生成答案字段放到questions中
+      // 先计算出方案,再取出题目
+      plan = this.getPlan(proportion, maxScore);
+    }
+    for (const p of plan) {
+      const { type, num } = p;
+      for (let i = 0; i < num; i++) {
+        const notIn = questions.map(i => i._id);
+        const quest = await this.getQuestion(type, notIn);
+        if (quest && quest.type) {
+          const questionType = questionTypeList.find(f => ObjectId(f._id).equals(quest.type));
+          if (questionType) {
+            quest.type = _.omit(questionType, [ 'meta', '__v' ]);
+            // 生成试卷,把答案的位置也给放上,免去前端生成或者没生成报错
+            if (questionType.type === 'checkbox') quest.answer = [];
+            else quest.answer = null;
+            questions.push(quest);
+          }
+        }
+      }
+    }
+
+    if (!save) return { questions };
+    // 组织数据,生成答案
+    const obj = { ...info, config: _.pick(config, [ 'can_exam', 'exam_proportion', 'rules', 'score' ]), questions };
+    // 创建答案
+    const result = await this.model.create(obj);
+    return result;
+  }
+  /**
+   * 计算计划:什么类型多少题
+   * @param {Array} proportion 比例
+   * @param {Number} maxScore 总分数
+   */
+  getPlan(proportion, maxScore) {
+    const plan = [];
+    // 剩余分数
+    let lessScore = maxScore;
+    // 分为2种,1种是考试专用类型,另一种是通常考试类型
+    proportion = proportion.map(i => ({ ...i, num: 0 }));
+    const exam_proportion = proportion.filter(f => f.is_exam);
+    let mscore = this.computedPercent(5, maxScore);
+    // 考试类型不能超过5%
+    let limit = 0;
+    do {
+      for (const p of exam_proportion) {
+        const { score } = p;
+        if (mscore - score < 0) {
+          // 减完成负数了,不能减
+          limit++;
+          continue;
+        } else {
+          p.num++;
+          mscore -= score;
+          lessScore -= score;
+        }
+      }
+      // limit到100次基本说明无限循环了,跳出来,有问题咱可以外面抛异常.别这里搞事消耗
+    } while (mscore > 0 && limit < 100);
+    // 整理成plan,合并进去么
+    const exam_plan = exam_proportion.map(i => {
+      const { score, _id: type, num } = i;
+      const obj = { type, score, num, overRange: true };
+      return obj;
+    });
+    plan.push(...exam_plan);
+    const usual_proportion = proportion.filter(f => !f.is_exam);
+    for (const p of usual_proportion) {
+      const { number_min, number_max, score, _id: type } = p;
+      let { max = 0, min = 0 } = p;
+      // 为0说明是没限制,需要计算下
+      if (min === 0) min = this.computedNum(number_min, score, maxScore);
+      if (max === 0) max = this.computedNum(number_max, score, maxScore);
+      const num = _.random(min, max);
+      lessScore -= _.multiply(score, num);
+      plan.push({ type, num, score, min, max });
+    }
+
+    // 小于0相当于重来一遍
+    if (lessScore < 0) return this.getPlan(proportion, maxScore);
+    // 等于0,直接返回就行
+    else if (lessScore === 0) return plan;
+    // 大于0,还有分数,需要继续分配,拿着plan挨个轮,轮到没分为止
+    let breakWhile = false;
+    // 能不能用该计划
+    let cantUse = true;
+    do {
+      if (lessScore > 0) {
+        // 这里,不一定是所有的题型都超出范围.还有可能是 差1分 但是1分的题已经到上限了,只剩下2分的题了
+        // 所以只看所有类型超没超出范围没有意义,而是需要看没超出范围的题目,能不能把剩下的 分 填上
+        const inRange = plan.filter(f => !f.overRange);
+        let next = false;
+        for (const ir of inRange) {
+          const { score } = ir;
+          if (lessScore - score >= 0) {
+            // 至少这个类型能填上,可以往下继续
+            next = true;
+            break;
+          }
+        }
+        if (!next) {
+          breakWhile = true;
+          cantUse = false;
+        } else {
+          for (const p of plan) {
+            if (lessScore === 0) {
+              breakWhile = true;
+              break;
+            }
+            const { score, overRange, min, max } = p;
+            // 是否超出范围,超出下一个
+            if (overRange) continue;
+
+            // 先计算分数够不够减的
+            if (lessScore - score < 0) continue;
+            // 再计算 该类型加完 1道题后,会不会超出范围
+            if (!_.inRange(p.num + 1, min, max + 1)) {
+              p.overRange = true;
+              continue;
+            }
+            lessScore -= score;
+            p.num += 1;
+          }
+        }
+      } else breakWhile = true;
+    } while (!breakWhile);
+    if (cantUse) return plan;
+    return this.getPlan(proportion, maxScore);
+  }
+
+  /**
+   * 计算该类型的题数
+   * @param {Number} num 该类型占比
+   * @param {Number} score 该类型题分数
+   * @param {Number} maxScore 试卷满分
+   * @return {Number} 返回多少道题
+   */
+  computedNum(num, score, maxScore) {
+    return _.ceil(_.divide(_.multiply(_.divide(_.multiply(score, num), maxScore), 100), score));
+  }
+  /**
+   * 算该百分比的分数
+   * @param {Number} num 占比
+   * @param {Number} maxScore 满分
+   */
+  computedPercent(num, maxScore) {
+    return _.ceil(_.multiply(_.divide(num, maxScore), 100));
+  }
+
+  /**
+   * 根据题型查找,考试调用次数最少的题
+   * @param {ObjectId} type 题型
+   * @param {Array} notIn 已经被取出来的题.不要再拿了
+   */
+  async getQuestion(type, notIn) {
+    // TODO 正式环境需要将这个删了,不能出重复的题
+    notIn = [];
+    const data = await this.questionModel.findOne({ type, _id: { $nin: notIn } }).sort({ times: 1 });
+    if (data) {
+      data.times = data.times + 1;
+      await data.save();
+    }
+
+    return _.pick(data, '_id', 'title', 'type', 'selects');
+  }
+
+  async update(filter, update, { projection } = {}) {
+    const { _id, id } = filter;
+    if (_id || id) filter = { _id: ObjectId(_id || id) };
+    const entity = await this.model.findOne(filter).exec();
+    if (!entity) throw new BusinessError(ErrorCode.DATA_NOT_EXIST);
+    entity.set(update);
+    await entity.save();
+    // 需要处理原数据
+    const r = await this.updateScore(entity);
+    if (!r) throw new BusinessError(ErrorCode.DATABASE_FAULT, '未能同步数据,交卷失败');
+    const res = await this.model.findOne(filter, projection).exec();
+
+    return res;
+  }
+  /**
+   * 修改原数据的考试成绩
+   * @param {Object} data 数据
+   */
+  async updateScore(data) {
+    const id = _.get(data, 'exam_info.id');
+    const exam_achieve = _.get(data, 'score');
+    const body = { data: { exam_achieve, status: '3' }, query: { id } };
+    const middleConfig = this.app.config.middleServer;
+    const { ip, port } = middleConfig;
+    const res = await axios({
+      method: 'post',
+      url: `http://${ip}:${port}/db/update?table=examination_examinee`,
+      data: body,
+      responseType: 'json',
+    });
+    if (res && _.get(res, 'data.code') === 0) return true;
+  }
+}
+
+module.exports = AnswerService;

+ 18 - 0
app/service/config.js

@@ -0,0 +1,18 @@
+'use strict';
+const { CrudService } = require('naf-framework-mongoose-free/lib/service');
+const { BusinessError, ErrorCode } = require('naf-core').Error;
+const _ = require('lodash');
+const assert = require('assert');
+
+// 设置
+class ConfigService extends CrudService {
+  constructor(ctx) {
+    super(ctx, 'config');
+    this.model = this.ctx.model.Config;
+  }
+  async fetch() {
+    return await this.model.findOne();
+  }
+}
+
+module.exports = ConfigService;

+ 16 - 0
app/service/exam.js

@@ -0,0 +1,16 @@
+'use strict';
+const { CrudService } = require('naf-framework-mongoose-free/lib/service');
+const { BusinessError, ErrorCode } = require('naf-core').Error;
+const _ = require('lodash');
+const assert = require('assert');
+const { ObjectId } = require('mongoose').Types;
+
+// 试卷表
+class ExamService extends CrudService {
+  constructor(ctx) {
+    super(ctx, 'exam');
+  }
+
+}
+
+module.exports = ExamService;

+ 69 - 0
app/service/excel.js

@@ -0,0 +1,69 @@
+'use strict';
+const { CrudService } = require('naf-framework-mongoose-free/lib/service');
+const { BusinessError, ErrorCode } = require('naf-core').Error;
+const _ = require('lodash');
+const assert = require('assert');
+const Excel = require('exceljs');
+const { sep } = require('path');
+const fs = require('fs');
+
+//
+class ExcelService extends CrudService {
+  constructor(ctx) {
+    super(ctx, 'excel');
+  }
+  // eslint-disable-next-line jsdoc/require-param
+  /**
+   * 检查路径,创建路径;返回前端接收路径
+   * @param {Array} filesPath 地址拆开 例如: ['www','dir','example']
+   * @return {Object} path 短地址;rPath 文件真实路径
+   */
+  checkPath(filesPath = []) {
+    const { root_path, project, export_dir } = this.ctx.app.config.filesConfig;
+    const arr = [ root_path, project, export_dir, ...filesPath ];
+    let rPath = '';
+    for (const i of arr) {
+      rPath = `${rPath}${i}${sep}`;
+      if (!fs.existsSync(rPath)) {
+        fs.mkdirSync(rPath);
+      }
+    }
+    arr.shift();
+    arr.unshift('files');
+    const path = `/${arr.join('/')}`;
+    return { path, rPath };
+  }
+  /**
+   * 将短地址转换为真实地址
+   * @param {String} path 短地址
+   */
+  toRealPath(path) {
+    const r = path.includes('/files');
+    if (!r) return path;
+    const { root_path } = this.ctx.app.config.filesConfig;
+    const rPath = _.replace(path, '/files', root_path);
+    return rPath;
+  }
+
+  /**
+   * 导入,读取excel文件
+   * @param {String} path 导入的文件短地址
+   * @return {Array[Array]} values 二维数组,外维:行;第二维:列
+   */
+  async importExcel(path) {
+    const realPath = this.toRealPath(path);
+    const workbook = new Excel.Workbook();
+    await workbook.xlsx.readFile(realPath);
+    const sheet = workbook.getWorksheet(1);
+    const values = [];
+    sheet.eachRow(row => {
+      const data = row.values;
+      // 第一列是空的,扔了
+      data.shift();
+      values.push(data);
+    });
+    return values;
+  }
+}
+
+module.exports = ExcelService;

+ 19 - 0
app/service/install.js

@@ -0,0 +1,19 @@
+'use strict';
+const { CrudService } = require('naf-framework-mongoose-free/lib/service');
+const { BusinessError, ErrorCode } = require('naf-core').Error;
+const _ = require('lodash');
+const assert = require('assert');
+
+// 初始化
+class InstallService extends CrudService {
+  constructor(ctx) {
+    super(ctx, 'install');
+  }
+  async index() {
+    const config = await this.ctx.model.Config.findOne();
+    if (config) return;
+    await this.ctx.model.Config.create({ proportion: [], exam_proportion: [] });
+  }
+}
+
+module.exports = InstallService;

+ 141 - 0
app/service/question.js

@@ -0,0 +1,141 @@
+'use strict';
+const { CrudService } = require('naf-framework-mongoose-free/lib/service');
+const { BusinessError, ErrorCode } = require('naf-core').Error;
+const _ = require('lodash');
+const assert = require('assert');
+// 题目
+class QuestionService extends CrudService {
+  constructor(ctx) {
+    super(ctx, 'question');
+    this.model = this.ctx.model.Question;
+    this.excelService = this.ctx.service.excel;
+    this.typeModel = this.ctx.model.QuestionType;
+  }
+
+  async import({ url }) {
+    const data = await this.excelService.importExcel(url);
+    const head = _.head(data);
+    let meta = this.getMeta();
+    // 将meta整理下,把对应的列加上,key为index
+    meta = meta.map(i => {
+      if (!i.children) {
+        const rIndex = head.findIndex(f => f === i.label);
+        if (rIndex >= 0) i.index = rIndex;
+      } else {
+        for (const c of i.children) {
+          const rcIndex = head.findIndex(f => f === c.label);
+          if (rcIndex >= 0) c.index = rcIndex;
+        }
+      }
+      return i;
+    });
+    // 表头干掉,处理数据不需要它
+    data.shift();
+    const midData = [];
+    for (const row of data) {
+      // row:每行数据
+      const questData = {};
+      for (const m of meta) {
+        // 检查有没有index;如果是直接m.index,需要排除 值=0的情况(不明白的话,let x = 0,然后if(x)走下就知道了)
+        // 所以换了个写法,只检查有没有该key
+        const keys = Object.keys(m);
+        if (!keys.includes('index') && !keys.includes('children')) continue;
+        const { key, index, type, format } = m;
+        if (!m.children) {
+          const val = row[index];
+          if (format && _.isFunction(format)) questData[key] = format(val);
+          else questData[key] = val;
+        } else {
+          // 根据meta创建类型
+          questData[key] = type === 'Array' ? [] : {};
+          // 添加数据的方法
+          const addToObject = type === 'Array' ? data => questData[key].push(data) : data => (questData[key] = { ...questData[key], ...data });
+          const midObject = {};
+          for (const c of m.children) {
+            const { key: ckey, index: cIndex, format } = c;
+            const val = row[cIndex];
+            if (format && _.isFunction(format)) midObject[ckey] = format(val);
+            else midObject[ckey] = val;
+          }
+          addToObject(midObject);
+        }
+      }
+      midData.push(questData);
+    }
+    // 处理题
+    let list = this.dealImportData(midData);
+    // 将type换成系统中有的题型的id
+    list = await this.changeType(list);
+    const res = await this.model.insertMany(list);
+    return res;
+  }
+
+  /**
+   * 处理type换成系统中有的题型的id
+   * @param {Array} array 数据数组,除type外所有数据已经处理完
+   */
+  async changeType(array) {
+    const typeList = await this.typeModel.find();
+    for (const i of array) {
+      // 此处需要检验.该题型的答案是单选/多选
+      const r = typeList.find(f => i.type === f.title);
+      if (r)i.type = r._id;
+    }
+    return array;
+  }
+
+  /**
+   * 将数据整理成数据库格式
+   * @param {Array} array 数据数组,title和type为题目分界,只是选项的话,没有
+   */
+  dealImportData(array) {
+    const list = [];
+    let operaData = {};
+    for (const i of array) {
+      if (_.get(i, 'title') && _.get(i, 'type')) {
+        if (_.get(operaData, 'title') && _.get(operaData, 'type')) {
+          // 之前的题合并完了.该下一题了
+          list.push(_.cloneDeep(operaData));
+          // 新题
+          operaData = i;
+        } else {
+          // 新题合并
+          operaData = i;
+        }
+      } else {
+        // 合并选项
+        if (_.isArray(operaData.selects) && _.isArray(i.selects)) {
+          operaData.selects = [ ...operaData.selects, ...i.selects ];
+        }
+      }
+    }
+    // 循环结束后,最后一个题扔保留在operaData中,放进list里
+    if (operaData.title) {
+      list.push(operaData);
+    }
+    return list;
+  }
+
+  getMeta() {
+    return [
+      { label: '题目', key: 'title' },
+      { label: '题型', key: 'type', remark: '需要用字符串找到question_type的title一致的数据,将id拿来' },
+      {
+        key: 'selects',
+        type: 'Array',
+        children: [
+          { label: '答案内容', key: 'title' },
+          {
+            label: '是否正确',
+            key: 'isRight',
+            format: i => {
+              if (i === '是') return true;
+            },
+          },
+        ],
+      },
+    ];
+  }
+}
+
+module.exports = QuestionService;

+ 15 - 0
app/service/question_type.js

@@ -0,0 +1,15 @@
+'use strict';
+const { CrudService } = require('naf-framework-mongoose-free/lib/service');
+const { BusinessError, ErrorCode } = require('naf-core').Error;
+const _ = require('lodash');
+const assert = require('assert');
+
+// 题目类型
+class Question_typeService extends CrudService {
+  constructor(ctx) {
+    super(ctx, 'question_type');
+    this.model = this.ctx.model.QuestionType;
+  }
+}
+
+module.exports = Question_typeService;

+ 9 - 0
app/z_router/answer.js

@@ -0,0 +1,9 @@
+'use strict';
+module.exports = app => {
+  const { router, controller } = app;
+  const prefix = '/api/exam/';
+  const target = 'answer';
+  router.post(target, `${prefix}${target}/getExamQuestion`, controller[target].getExamQuestion);
+  router.resources(target, `${prefix}${target}`, controller[target]); // index、create、show、destroy
+  router.post(target, `${prefix}${target}/update/:id`, controller[target].update);
+};

+ 9 - 0
app/z_router/config.js

@@ -0,0 +1,9 @@
+'use strict';
+module.exports = app => {
+  const { router, controller } = app;
+  const prefix = '/api/exam/';
+  const target = 'config';
+  // router.resources(target, `${prefix}${target}`, controller[target]); // index、create、show、destroy
+  router.get(target, `${prefix}${target}`, controller[target].show); // index、create、show、destroy
+  router.post(target, `${prefix}${target}/update/:id`, controller[target].update);
+};

+ 8 - 0
app/z_router/exam.js

@@ -0,0 +1,8 @@
+'use strict';
+module.exports = app => {
+  const { router, controller } = app;
+  const prefix = '/api/exam/';
+  const target = 'exam';
+  router.resources(target, `${prefix}${target}`, controller[target]); // index、create、show、destroy
+  router.post(target, `${prefix}${target}/update/:id`, controller[target].update);
+};

+ 9 - 0
app/z_router/question.js

@@ -0,0 +1,9 @@
+'use strict';
+module.exports = app => {
+  const { router, controller } = app;
+  const prefix = '/api/exam/';
+  const target = 'question';
+  router.post(target, `${prefix}${target}/import`, controller[target].import);
+  router.resources(target, `${prefix}${target}`, controller[target]); // index、create、show、destroy
+  router.post(target, `${prefix}${target}/update/:id`, controller[target].update);
+};

+ 8 - 0
app/z_router/question_type.js

@@ -0,0 +1,8 @@
+'use strict';
+module.exports = app => {
+  const { router, controller } = app;
+  const prefix = '/api/exam/';
+  const target = 'questionType';
+  router.resources(target, `${prefix}${target}`, controller[target]); // index、create、show、destroy
+  router.post(target, `${prefix}${target}/update/:id`, controller[target].update);
+};

+ 14 - 0
appveyor.yml

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

+ 64 - 0
config/config.default.js

@@ -0,0 +1,64 @@
+/* eslint valid-jsdoc: "off" */
+
+'use strict';
+
+/**
+ * @param {Egg.EggAppInfo} appInfo app info
+ */
+const { jwt } = require('./config.secret');
+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 + '_1641883497557_9277';
+
+  // add your middleware config here
+  config.middleware = [];
+
+  // add your user config here
+  const userConfig = {
+    // myAppName: 'egg',
+  };
+
+  config.cluster = {
+    listen: {
+      port: 6104,
+    },
+  };
+
+  config.dbName = 'baoan-exam';
+  config.mongoose = {
+    url: `mongodb://localhost:27017/${config.dbName}`,
+    options: {
+      user: 'admin',
+      pass: 'admin',
+      authSource: 'admin',
+      useNewUrlParser: true,
+      useCreateIndex: true,
+    },
+  };
+  config.jwt = {
+    ...jwt,
+    expiresIn: '1d',
+    issuer: 'baoan',
+  };
+  // 服务设置
+  config.middleServer = {
+    ip: '106.12.161.200',
+    port: '6101',
+  };
+  config.filesConfig = {
+    root_path: 'D:\\temp',
+    project: 'exam',
+    export_dir: 'export',
+  };
+
+  return {
+    ...config,
+    ...userConfig,
+  };
+};

+ 31 - 0
config/config.prod.js

@@ -0,0 +1,31 @@
+'use strict';
+
+module.exports = () => {
+  const config = (exports = {});
+  config.dbServer = {
+    ip: '127.0.0.1',
+    port: '6101',
+  };
+  config.dbName = 'baoan-exam';
+  config.mongoose = {
+    url: `mongodb://localhost:27017/${config.dbName}`,
+    options: {
+      user: 'admin',
+      pass: 'admin',
+      authSource: 'admin',
+      useNewUrlParser: true,
+      useCreateIndex: true,
+    },
+  };
+  config.filesConfig = {
+    root_path: '/usr/workspace/server/service-file/upload',
+    project: 'exam',
+    export_dir: 'export',
+  };
+  // 服务设置
+  config.middleServer = {
+    ip: '127.0.0.1',
+    port: '6101',
+  };
+  return config;
+};

+ 7 - 0
config/config.secret.js

@@ -0,0 +1,7 @@
+'use strict';
+
+module.exports = {
+  jwt: {
+    secret: 'Ziyouyanfa!@#',
+  },
+};

+ 9 - 0
config/plugin.js

@@ -0,0 +1,9 @@
+'use strict';
+
+/** @type Egg.EggPlugin */
+module.exports = {
+  // had enabled by egg
+  // static: {
+  //   enable: true,
+  // }
+};

+ 17 - 0
ecosystem.config.js

@@ -0,0 +1,17 @@
+'use strict';
+
+const app = 'service-exam';
+module.exports = {
+  apps: [{
+    name: app, // 应用名称
+    script: './server.js', // 实际启动脚本
+    out: `./logs/${app}.log`,
+    error: `./logs/${app}.err`,
+    watch: [ // 监控变化的目录,一旦变化,自动重启
+      'app', 'config',
+    ],
+    env: {
+      NODE_ENV: 'production', // 环境参数,当前指定为生产环境
+    },
+  }],
+};

+ 51 - 0
package.json

@@ -0,0 +1,51 @@
+{
+  "name": "server-exam",
+  "version": "1.0.0",
+  "description": "保安-考试服务",
+  "private": true,
+  "egg": {
+    "framework": "naf-framework-mongoose-free"
+  },
+  "dependencies": {
+    "egg": "^2.15.1",
+    "egg-scripts": "^2.11.0",
+    "exceljs": "^4.3.0",
+    "lodash": "^4.17.21",
+    "moment": "^2.29.1",
+    "naf-framework-mongoose-free": "^0.0.13"
+  },
+  "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",
+    "jsonwebtoken": "^8.5.1"
+  },
+  "engines": {
+    "node": ">=10.0.0"
+  },
+  "scripts": {
+    "start": "egg-scripts start --daemon --title=egg-server-server-exam",
+    "stop": "egg-scripts stop --title=egg-server-server-exam",
+    "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"
+}

+ 9 - 0
server.js

@@ -0,0 +1,9 @@
+
+// eslint-disable-next-line strict
+const egg = require('egg');
+
+const workers = Number(process.argv[2] || require('os').cpus().length);
+egg.startCluster({
+  workers,
+  baseDir: __dirname,
+});

+ 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);
+  });
+});