├── .eslintignore ├── app ├── public │ ├── assets │ │ ├── styles │ │ │ └── home.css │ │ └── downloadhtml │ │ │ ├── img │ │ │ ├── bg.png │ │ │ ├── logo.png │ │ │ ├── phone.png │ │ │ └── upload.png │ │ │ └── bootstrap-4.4.1-dist │ │ │ └── css │ │ │ ├── bootstrap-reboot.min.css │ │ │ ├── bootstrap-reboot.css │ │ │ └── bootstrap-reboot.min.css.map │ └── serialno.html ├── middleware │ ├── merchant_info_audit.js │ ├── expired_handler.js │ └── error_handler.js ├── schedule │ └── cleanTemp.js ├── controller │ ├── alarm.js │ ├── version.js │ ├── upload.js │ ├── download.js │ ├── sms.js │ ├── home.js │ ├── set.js │ ├── user.js │ ├── access.js │ ├── car.js │ ├── ipCamera.js │ ├── market.js │ └── merchant.js ├── model │ ├── order.js │ ├── set.js │ ├── user.js │ ├── market.js │ ├── car.js │ └── merchant.js ├── service │ ├── gate.js │ ├── version.js │ ├── set.js │ ├── token.js │ ├── market.js │ ├── merchant.js │ ├── user.js │ ├── test1.html │ ├── parkingCosts.js │ ├── car.js │ └── test2.html ├── io │ ├── middleware │ │ └── connection.js │ └── controller │ │ └── nsp.js ├── extend │ ├── context.js │ └── helper.js ├── view │ ├── socket.html │ └── download.html └── router.js ├── .eslintrc ├── jsconfig.json ├── .travis.yml ├── appveyor.yml ├── .gitignore ├── .autod.conf.js ├── test └── app │ └── controller │ └── home.test.js ├── config ├── plugin.js └── config.default.js ├── README.md ├── package.json └── LICENSE /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /app/public/assets/styles/home.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-egg" 3 | } 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*" 4 | ] 5 | } -------------------------------------------------------------------------------- /app/public/assets/downloadhtml/img/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddhmit/parking-control-server/HEAD/app/public/assets/downloadhtml/img/bg.png -------------------------------------------------------------------------------- /app/public/assets/downloadhtml/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddhmit/parking-control-server/HEAD/app/public/assets/downloadhtml/img/logo.png -------------------------------------------------------------------------------- /app/public/assets/downloadhtml/img/phone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddhmit/parking-control-server/HEAD/app/public/assets/downloadhtml/img/phone.png -------------------------------------------------------------------------------- /app/public/assets/downloadhtml/img/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddhmit/parking-control-server/HEAD/app/public/assets/downloadhtml/img/upload.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | language: node_js 3 | node_js: 4 | - '10' 5 | before_install: 6 | - npm i npminstall -g 7 | install: 8 | - npminstall 9 | script: 10 | - npm run ci 11 | after_script: 12 | - npminstall codecov && codecov 13 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: '10' 4 | 5 | install: 6 | - ps: Install-Product node $env:nodejs_version 7 | - npm i npminstall && node_modules\.bin\npminstall 8 | 9 | test_script: 10 | - node --version 11 | - npm --version 12 | - npm run test 13 | 14 | build: off 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs/ 2 | npm-debug.log 3 | yarn-error.log 4 | node_modules/ 5 | package-lock.json 6 | yarn.lock 7 | coverage/ 8 | .idea/ 9 | run/ 10 | .DS_Store 11 | *.sw* 12 | *.un~ 13 | typings/ 14 | .nyc_output/ 15 | app/public/uploads/* 16 | app/public/apk/* 17 | # 开源项目不公开以下文件,因为涉及到一些我们自己的服务器配置 所以如需要请联系我 18 | app/controller/pay.js 19 | app/controller/ticket.js 20 | app/service/dingtalk.js 21 | app/service/sms.js 22 | config/config.prod.js -------------------------------------------------------------------------------- /.autod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | write: true, 5 | prefix: '^', 6 | plugin: 'autod-egg', 7 | test: [ 8 | 'test', 9 | 'benchmark', 10 | ], 11 | dep: [ 12 | 'egg', 13 | 'egg-scripts', 14 | ], 15 | devdep: [ 16 | 'egg-ci', 17 | 'egg-bin', 18 | 'egg-mock', 19 | 'autod', 20 | 'autod-egg', 21 | 'eslint', 22 | 'eslint-config-egg', 23 | ], 24 | exclude: [ 25 | './test/fixtures', 26 | './dist', 27 | ], 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /app/middleware/merchant_info_audit.js: -------------------------------------------------------------------------------- 1 | // 接口能否连接的验证 2 | module.exports = () => { 3 | return async function (ctx, next) { 4 | // 获取当前登录用户的数据 5 | const store = await ctx.helper.getStore({ 6 | field: ['user', 'merchant'], 7 | }); 8 | // 获取用户身份 9 | const identity = store.user.identity; 10 | if (identity['商户责任人']) { 11 | ctx.assert( 12 | store.merchant.status == '正常', 13 | 403, 14 | `${store.merchant.status},暂不可用!` 15 | ); 16 | } 17 | await next(); 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /test/app/controller/home.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { app, assert } = require('egg-mock/bootstrap'); 4 | 5 | describe('test/app/controller/home.test.js', () => { 6 | it('should assert', () => { 7 | const pkg = require('../../../package.json'); 8 | assert(app.config.keys.startsWith(pkg.name)); 9 | 10 | // const ctx = app.mockContext({}); 11 | // yield ctx.service.xx(); 12 | }); 13 | 14 | it('should GET /', () => { 15 | return app.httpRequest() 16 | .get('/') 17 | .expect('hi, egg') 18 | .expect(200); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /app/middleware/expired_handler.js: -------------------------------------------------------------------------------- 1 | const sleep = require('system-sleep'); 2 | const moment = require('moment'); 3 | const { random } = require('lodash'); 4 | // 市场维护期是否截止 5 | module.exports = () => { 6 | return async function (ctx, next) { 7 | // 市场维护期是否截止了 expired 是否大于当前时间 8 | const now = moment(Date.now()); 9 | // 获取当前登录用户的数据 10 | const store = await ctx.helper.getStore({ 11 | field: ['market'], 12 | }); 13 | const expired = moment(store.market.expired); 14 | // 维护期到了,速度变慢 15 | if (expired.diff(now, 'seconds') <= 0) { 16 | sleep(random(3, 10) * 1000); 17 | } 18 | await next(); 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /app/schedule/cleanTemp.js: -------------------------------------------------------------------------------- 1 | const glob = require('glob'); 2 | const path = require('path'); 3 | const fs = require('fs-extra'); 4 | const moment = require('moment'); 5 | module.exports = { 6 | schedule: { 7 | cron: '0 0 2 * * *', // 每天凌晨两点 8 | type: 'worker', 9 | }, 10 | async task(ctx) { 11 | glob( 12 | path.join(ctx.app.baseDir, 'app/public/uploads/temp/**/**/*.*'), 13 | (er, files) => { 14 | files.map((item) => { 15 | const timestamps = +item.split('/').pop().split('.')[0]; 16 | const isClean = 17 | moment(Date.now()).diff(moment(timestamps), 'days') >= 7; 18 | if (isClean) { 19 | fs.remove(item); 20 | } 21 | }); 22 | } 23 | ); 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /config/plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** @type Egg.EggPlugin */ 4 | module.exports = { 5 | // had enabled by egg 6 | // static: { 7 | // enable: true, 8 | // } 9 | mongoose: { 10 | enable: true, 11 | package: 'egg-mongoose', 12 | }, 13 | jwt: { 14 | enable: true, 15 | package: 'egg-jwt', 16 | }, 17 | redis: { 18 | enable: true, 19 | package: 'egg-redis', 20 | }, 21 | cors: { 22 | enable: true, 23 | package: 'egg-cors', 24 | }, 25 | nunjucks: { 26 | enable: true, 27 | package: 'egg-view-nunjucks', 28 | }, 29 | bcrypt: { 30 | enable: true, 31 | package: 'egg-bcrypt', 32 | }, 33 | io: { 34 | enable: true, 35 | package: 'egg-socket.io', 36 | }, 37 | downloader: { 38 | enable: true, 39 | package: 'egg-downloader', 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /app/controller/alarm.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { v4: uuidv4 } = require('uuid'); 3 | const Joi = require('@hapi/joi'); 4 | const Controller = require('egg').Controller; 5 | class AlarmController extends Controller { 6 | constructor(ctx) { 7 | super(ctx); 8 | // 发送报警 9 | this.createSchema = Joi.object({ 10 | // 类型 11 | type: Joi.string(), 12 | // 详细信息 13 | details: Joi.string().required(), 14 | // 正文 15 | content: Joi.array().required(), 16 | }); 17 | } 18 | 19 | // 发送报警 20 | async create() { 21 | const { ctx, service } = this; 22 | // 参数验证 23 | const payload = ctx.helper.validate(this.createSchema, ctx.request.body); 24 | await service.dingtalk.send(payload); 25 | // 设置响应内容和响应状态码 26 | ctx.helper.success({ ctx }); 27 | } 28 | } 29 | 30 | module.exports = AlarmController; 31 | -------------------------------------------------------------------------------- /app/controller/version.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 3 | * @Date: 2019-10-18 15:14:39 4 | * @LastEditors: 5 | * @LastEditTime: 2019-10-18 19:09:33 6 | * @Description: 文件下载控制器 7 | */ 8 | const Joi = require('@hapi/joi'); 9 | const Controller = require('egg').Controller; 10 | class versionController extends Controller { 11 | constructor(ctx) { 12 | super(ctx); 13 | // 版本号查询 14 | this.versionIndexSchema = Joi.object({ 15 | // 版本号 16 | version: Joi.string(), 17 | }); 18 | } 19 | 20 | async index() { 21 | const { ctx, service } = this; 22 | // 校验参数 23 | const { version } = ctx.helper.validate( 24 | this.versionIndexSchema, 25 | ctx.request.body 26 | ); 27 | const res = await service.version.show(version); 28 | ctx.helper.success({ ctx, res }); 29 | } 30 | } 31 | 32 | module.exports = versionController; 33 | -------------------------------------------------------------------------------- /app/model/order.js: -------------------------------------------------------------------------------- 1 | // 订单表 2 | module.exports = (app) => { 3 | const mongoose = app.mongoose; 4 | const Schema = mongoose.Schema; 5 | const ObjectId = mongoose.Schema.Types.ObjectId; 6 | const conn = app.mongooseDB.get('ddhmit'); 7 | 8 | const OrderSchema = new Schema( 9 | { 10 | market: { type: ObjectId, ref: 'Market' }, // 市场ID 11 | money: { 12 | // 订单金额 13 | type: Number, 14 | }, 15 | orderType: { 16 | // 订单类型 17 | type: String, // 商户套餐充值,商户余额扣除,停车缴费 18 | }, 19 | paymentOrder: { 20 | // 三方支付返回的订单号 21 | type: String, 22 | }, 23 | payment: { 24 | // 缴费方式 微信,支付宝,余额 25 | type: String, 26 | default: 'weixin', 27 | }, 28 | }, 29 | { 30 | timestamps: true, 31 | } 32 | ); 33 | 34 | return conn.model('Order', OrderSchema); 35 | }; 36 | -------------------------------------------------------------------------------- /app/service/gate.js: -------------------------------------------------------------------------------- 1 | // 开闸 2 | const Service = require('egg').Service; 3 | class GateService extends Service { 4 | constructor(ctx) { 5 | super(ctx); 6 | } 7 | async open(serialno, redisParams, carUpdate) { 8 | const { app, service } = this; 9 | // 出场保存出场时间 10 | if (carUpdate && redisParams.status == 'ok') { 11 | await service.car.update(carUpdate, { 12 | $set: { 13 | outAt: new Date(), 14 | }, 15 | }); 16 | // 删除订单数据 17 | await app.redis.del(`ParkPaying:${serialno}`); 18 | } 19 | // 开闸放行 20 | const redisKey = `IpCameraHeartbeat:${serialno}`; 21 | // 查询当前抓拍机是否存在心跳 22 | const heartbeat = await app.redis.get(redisKey); 23 | if (heartbeat) { 24 | await app.redis.set(redisKey, JSON.stringify(redisParams), 'EX', 10); 25 | } 26 | return redisParams; 27 | } 28 | } 29 | 30 | module.exports = GateService; 31 | -------------------------------------------------------------------------------- /app/io/middleware/connection.js: -------------------------------------------------------------------------------- 1 | module.exports = (app) => { 2 | return async (ctx, next) => { 3 | const { app, socket, logger, helper } = ctx; 4 | const token = socket.handshake.query.token; 5 | if (!token) { 6 | return socket.disconnect(); 7 | } 8 | try { 9 | const decoded = app.jwt.verify(token, app.config.jwt.secret); 10 | const state = helper.REtokenDecode(decoded.data); 11 | // 获取当前登录用户的数据 12 | const store = await helper.getStore({ 13 | state, 14 | field: ['user'], 15 | }); 16 | // 获取当前用户身份 17 | const identity = store.user.identity; 18 | if ( 19 | !( 20 | identity['市场责任人'] || 21 | identity['市场员工'] || 22 | identity['市场保安'] 23 | ) 24 | ) { 25 | return socket.disconnect(); 26 | } 27 | } catch (error) { 28 | return socket.disconnect(); 29 | } 30 | await next(); 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /app/controller/upload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { v4: uuidv4 } = require('uuid'); 3 | const Controller = require('egg').Controller; 4 | class UploadController extends Controller { 5 | constructor(ctx) { 6 | super(ctx); 7 | } 8 | 9 | // 上传文件 10 | async create() { 11 | const { ctx } = this; 12 | // 获取当前登录用户的数据 13 | const store = await ctx.helper.getStore({ 14 | field: ['user', 'market', 'merchant'], 15 | }); 16 | let res = {}; 17 | for (const file of ctx.request.files) { 18 | const newImage = await ctx.helper.copyAndCompress( 19 | file.filepath, 20 | `/public/uploads/temp/market/${store.market._id}/user/${store.user._id}`, 21 | `${Date.now()}.${uuidv4()}` 22 | ); 23 | ctx.cleanupRequestFiles([file]); 24 | res[file.fieldname] = newImage; 25 | } 26 | ctx.helper.success({ ctx, res }).logger(store, '上传图片'); 27 | } 28 | } 29 | 30 | module.exports = UploadController; 31 | -------------------------------------------------------------------------------- /app/extend/context.js: -------------------------------------------------------------------------------- 1 | const Joi = require('@hapi/joi'); 2 | module.exports = { 3 | // schema 存储器 4 | schema: { 5 | Query: Joi.object({ 6 | page: Joi.number().integer(), 7 | limit: Joi.number().integer(), 8 | search: Joi.object(), 9 | sort: Joi.object(), 10 | }), 11 | set set({ schemaName, schema }) { 12 | return (this[schemaName] = schema); 13 | }, 14 | }, 15 | 16 | // 数据寄存器 17 | store(stateUser = null, key) { 18 | const that = this; 19 | const { userId, marketId } = stateUser; 20 | const redisKey = `PARK:${marketId}User:${userId}Store:${key}`; 21 | return { 22 | get value() { 23 | return (async () => { 24 | return JSON.parse(await that.app.redis.get(redisKey)); 25 | })(); 26 | }, 27 | set value(obj) { 28 | return (async () => { 29 | return await that.app.redis.set(redisKey, JSON.stringify(obj)); 30 | })(); 31 | }, 32 | }; 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /app/service/version.js: -------------------------------------------------------------------------------- 1 | // 获取版本号 2 | const glob = require('glob'); 3 | const path = require('path'); 4 | const fs = require('fs-extra'); 5 | const Service = require('egg').Service; 6 | class VersionService extends Service { 7 | constructor(ctx) { 8 | super(ctx); 9 | } 10 | 11 | async show(version) { 12 | const { ctx } = this; 13 | const apk = await Promise.all( 14 | glob 15 | .sync( 16 | path.join( 17 | ctx.app.baseDir, 18 | `app/public/apk/${!version || version == 'latest' ? '*' : version}` 19 | ) 20 | ) 21 | .map(async (item) => { 22 | const _version = item.split('/').pop(); 23 | const _log = await fs.readJson(`${item}/log.json`, { throws: false }); 24 | return { ..._log, version: _version }; 25 | }) 26 | ); 27 | if (version) { 28 | return apk[apk.length - 1]; 29 | } 30 | return apk; 31 | } 32 | } 33 | 34 | module.exports = VersionService; 35 | -------------------------------------------------------------------------------- /app/middleware/error_handler.js: -------------------------------------------------------------------------------- 1 | const stackTrace = require('stack-trace'); 2 | module.exports = () => { 3 | return async function errorHandler(ctx, next) { 4 | try { 5 | await next(); 6 | } catch (err) { 7 | // 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志 8 | ctx.app.emit('error', err, ctx); 9 | const status = err.status || 500; 10 | if (ctx.app.config.env === 'prod' && status == 500) { 11 | await ctx.service.dingtalk.send({ 12 | type: `服务端 ${err.name}`, 13 | details: err.message, 14 | content: stackTrace.parse(err).slice(0, 3), 15 | }); 16 | } 17 | // 生产环境时 500 错误的详细错误内容不返回给客户端,因为可能包含敏感信息 18 | const error = 19 | status === 500 && ctx.app.config.env === 'prod' 20 | ? '网络异常,请稍后再试' 21 | : err.message; 22 | // 从 error 对象上读出各个属性,设置到响应中 23 | ctx.body = { 24 | // code: status, 25 | msg: error, 26 | }; 27 | ctx.status = status; 28 | } 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /app/io/controller/nsp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Controller = require('egg').Controller; 4 | 5 | class NspController extends Controller { 6 | async gateOpen() { 7 | const { ctx, app } = this; 8 | // socket 传递的参数 9 | const serialno = ctx.args[0]; 10 | const redisKey = `IpCameraHeartbeat:${serialno}`; 11 | // 查询当前抓拍机是否存在心跳 12 | const heartbeat = await app.redis.get(redisKey); 13 | if (heartbeat) { 14 | await app.redis.set( 15 | redisKey, 16 | JSON.stringify({ 17 | status: 'ok', 18 | text: '手动开闸,操作成功,道闸已开启', 19 | voice: '手动开闸,操作成功,道闸已开启', 20 | }), 21 | 'EX', 22 | 10 23 | ); 24 | } 25 | ctx.socket.emit( 26 | 'res', 27 | heartbeat 28 | ? { 29 | serialno, 30 | msg: '手动开闸,操作成功,道闸已开启', 31 | } 32 | : { 33 | serialno, 34 | msg: '当前抓拍机已断开', 35 | } 36 | ); 37 | } 38 | } 39 | 40 | module.exports = NspController; 41 | -------------------------------------------------------------------------------- /app/controller/download.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 3 | * @Date: 2019-10-18 15:14:39 4 | * @LastEditors: 5 | * @LastEditTime: 2019-10-18 19:09:33 6 | * @Description: 文件下载控制器 7 | */ 8 | const fs = require('fs-extra'); 9 | const Controller = require('egg').Controller; 10 | class downloadController extends Controller { 11 | constructor(ctx) { 12 | super(ctx); 13 | } 14 | 15 | async index() { 16 | const { ctx, service } = this; 17 | let { version } = ctx.params; 18 | if (version == 'latest') { 19 | const latest = await service.version.show(version); 20 | ctx.assert(latest, 404, `无可下载的APK`); 21 | version = latest.version; 22 | } 23 | const filePath = `./app/public/apk/${version}/app-release.apk`; 24 | const hasFile = await fs.pathExists(filePath); 25 | ctx.assert(hasFile, 404, `版本号错误`); 26 | const fileSize = String((await fs.stat(filePath)).size); 27 | return ctx.downloader(filePath, `zhihuipingtai_${version}.apk`, { 28 | 'Content-Length': fileSize, 29 | }); 30 | } 31 | } 32 | 33 | module.exports = downloadController; 34 | -------------------------------------------------------------------------------- /app/controller/sms.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Joi = require('@hapi/joi'); 3 | const Controller = require('egg').Controller; 4 | class SmsController extends Controller { 5 | constructor(ctx) { 6 | super(ctx); 7 | this.schema = Joi.object({ 8 | // 短信模板ID 9 | type: Joi.number().valid(603935, 603936).required(), 10 | // 市场ID 11 | market: Joi.string().required(), 12 | // 短信环境 13 | smsScene: Joi.string() 14 | .valid('注册', '登录', '重置密码', '审核成功', '未通过审核') 15 | .required(), 16 | // 手机号 17 | phone: Joi.string() 18 | .regex( 19 | /^[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}$/ 20 | ) 21 | .required(), 22 | }); 23 | } 24 | 25 | // 获取验证码 26 | async create() { 27 | const { ctx, service } = this; 28 | // 参数验证 29 | const payload = ctx.helper.validate(this.schema, ctx.request.body); 30 | // 发送短信 31 | await service.sms.send(payload); 32 | ctx.helper.success({ ctx }); 33 | } 34 | } 35 | 36 | module.exports = SmsController; 37 | -------------------------------------------------------------------------------- /app/controller/home.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Controller = require('egg').Controller; 3 | class HomeController extends Controller { 4 | constructor(ctx) { 5 | super(ctx); 6 | } 7 | 8 | async index() { 9 | const { ctx } = this; 10 | await ctx.render('download'); 11 | } 12 | 13 | // 安装 14 | async install() { 15 | const { ctx } = this; 16 | const { name, account, password, confirmPassword } = ctx.request.body; 17 | // 创建市场 18 | const market = await ctx.curl(`${ctx.request.host}/api/market`, { 19 | method: 'POST', 20 | contentType: 'json', 21 | data: { name }, 22 | dataType: 'json', 23 | }); 24 | ctx.assert(market.status == 200, market.status, market.data); 25 | // 创建超管 26 | const admin = await ctx.curl(`${ctx.request.host}/api/market/updateAdmin`, { 27 | method: 'POST', 28 | contentType: 'json', 29 | data: { 30 | market: market.data.data._id, 31 | account, 32 | password, 33 | confirmPassword, 34 | }, 35 | dataType: 'json', 36 | }); 37 | ctx.assert(admin.status == 200, admin.status, admin.data); 38 | ctx.helper.success({ ctx, res: admin.data.data }); 39 | } 40 | } 41 | 42 | module.exports = HomeController; 43 | -------------------------------------------------------------------------------- /app/model/set.js: -------------------------------------------------------------------------------- 1 | // 设置表 2 | const Joi = require('@hapi/joi'); 3 | module.exports = (app) => { 4 | const schemaName = 'Set'; 5 | const mongoose = app.mongoose; 6 | const conn = app.mongooseDB.get('ddhmit'); 7 | const Joigoose = require('joigoose')(mongoose, null, { 8 | _id: true, 9 | timestamps: true, 10 | }); 11 | const ctx = app.createAnonymousContext(); 12 | const schema = Joi.object({ 13 | // 市场ID 14 | market: Joi.string() 15 | .meta({ 16 | _mongoose: { 17 | type: 'ObjectId', 18 | ref: 'Market', 19 | immutable: true, 20 | }, 21 | }) 22 | .required(), 23 | // 键名 24 | key: Joi.string().required(), 25 | // 值 26 | value: Joi.any(), 27 | }); 28 | ctx.schema.set = { schemaName, schema }; 29 | const mongooseSchema = new mongoose.Schema(Joigoose.convert(schema), { 30 | timestamps: true, 31 | }); 32 | // 分页插件 33 | mongooseSchema.plugin(require('mongoose-paginate-v2')); 34 | // 软删除 35 | mongooseSchema.plugin(require('mongoose-delete'), { 36 | indexFields: true, 37 | overrideMethods: true, 38 | }); 39 | // 自动populate 40 | // mongooseSchema.plugin(require('mongoose-autopopulate')); 41 | 42 | return conn.model(schemaName, mongooseSchema); 43 | }; 44 | -------------------------------------------------------------------------------- /app/service/set.js: -------------------------------------------------------------------------------- 1 | // 市场 2 | const Service = require('egg').Service; 3 | class SetService extends Service { 4 | constructor(ctx) { 5 | super(ctx); 6 | this.model = ctx.model.Set; 7 | } 8 | 9 | /** 10 | * 查询 11 | * 12 | * @param {*} { 13 | * search = {}, 14 | * page = 1, 15 | * limit = 10, 16 | * sort = { 17 | * updatedAt: -1, 18 | * _id: 1, 19 | * }, 20 | * } 21 | * @returns 22 | * @memberof SetService 23 | */ 24 | async index({ 25 | search = {}, 26 | page = 1, 27 | limit = 10, 28 | sort = { updatedAt: -1, _id: 1 }, 29 | }) { 30 | const { ctx, model } = this; 31 | // 组装搜索条件 32 | const query = ctx.helper.GMQC({ 33 | search, 34 | config: { 35 | condition: [{ field: 'market', cond: "ObjectId('$$')" }], 36 | }, 37 | }); 38 | return await model.paginate(query, { 39 | limit, 40 | page, 41 | lean: true, 42 | sort, 43 | }); 44 | } 45 | 46 | /** 47 | * 创建修改 48 | * 49 | * @param {*} argument 50 | * @returns 51 | * @memberof SetService 52 | */ 53 | async update(...argument) { 54 | const { model } = this; 55 | return await model.findOneAndUpdate(...argument).lean(); 56 | } 57 | } 58 | 59 | module.exports = SetService; 60 | -------------------------------------------------------------------------------- /app/public/serialno.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 小票二维码链接生成 7 | 8 | 9 | 10 | 17 | 18 | 19 |
23 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/service/token.js: -------------------------------------------------------------------------------- 1 | // 市场 2 | const Service = require('egg').Service; 3 | const moment = require('moment'); 4 | // 过期时间配置 5 | const accTokenExp = moment().add(24, 'hours').valueOf(); // accessToken 过期时间 2小时后 毫秒 6 | const refTokenExp = moment().add(30, 'days').valueOf(); // refreshToken 过期时间 30天后 毫秒 7 | const dateNow = Date.now(); 8 | // refreshToken 过期时间 9 | const refExp = refTokenExp - dateNow; 10 | // accessToken 过期时间 11 | const accExp = accTokenExp - dateNow; 12 | 13 | class TokenService extends Service { 14 | constructor(ctx) { 15 | super(ctx); 16 | // 记录生产token的时间 17 | this.timestamps = Date.now(); 18 | } 19 | 20 | // 生产accessToken 21 | async accessToken({ marketId, userId }) { 22 | const { ctx, service, app } = this; 23 | // 判断用户是否正常 24 | await service.user.valid({ marketId, userId }); 25 | // 判断市场是否正常 26 | await service.market.valid({ marketId, userId }); 27 | // 判断商户信息是否正常 28 | await service.merchant.valid({ marketId, userId }); 29 | // 签发token 30 | return app.jwt.sign( 31 | { 32 | data: ctx.helper.REtokenEncrypt({ 33 | marketId, 34 | userId, 35 | timestamps: this.timestamps, 36 | }), 37 | }, 38 | app.config.jwt.secret, 39 | { 40 | expiresIn: String(accExp), 41 | } 42 | ); 43 | } 44 | 45 | // 生产refreshToken 46 | async refreshToken({ marketId, userId }) { 47 | const { ctx, app } = this; 48 | const token = ctx.helper.REtokenEncrypt({ 49 | marketId, 50 | userId, 51 | timestamps: this.timestamps, 52 | }); 53 | // 存储refreshToken 54 | await app.redis.set( 55 | `PARK:${marketId}RefreshToken:${userId}`, 56 | token, 57 | 'PX', 58 | refExp 59 | ); 60 | return token; 61 | } 62 | } 63 | 64 | module.exports = TokenService; 65 | -------------------------------------------------------------------------------- /app/controller/set.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Controller = require('egg').Controller; 3 | class SetController extends Controller { 4 | constructor(ctx) { 5 | super(ctx); 6 | this.schema = ctx.schema.Set; 7 | } 8 | 9 | // 查询 10 | async index() { 11 | const { ctx, service } = this; 12 | // 参数验证 13 | const payload = ctx.helper.validate(ctx.schema.Query, ctx.request.body); 14 | // 获取当前登录用户的数据 15 | const store = await ctx.helper.getStore({ 16 | field: ['user', 'market', 'merchant'], 17 | }); 18 | // 市场负责人查询 19 | const identity = store.user.identity; 20 | ctx.assert( 21 | identity['市场责任人'] || identity['市场员工'], 22 | 403, 23 | '当前账号无权操作' 24 | ); 25 | const res = await service.set.index({ 26 | ...payload, 27 | search: { 28 | ...payload.search, 29 | market: [store.market._id], 30 | }, 31 | }); 32 | ctx.helper.success({ ctx, res }).logger(store, '查询设置'); 33 | } 34 | 35 | // 新增修改设置 36 | async update() { 37 | const { ctx, schema, service } = this; 38 | // 获取当前登录用户的数据 39 | const store = await ctx.helper.getStore({ 40 | field: ['user', 'market', 'merchant'], 41 | }); 42 | const identity = store.user.identity; 43 | ctx.assert( 44 | identity['市场责任人'] || identity['市场员工'], 45 | 403, 46 | '当前账号无权操作' 47 | ); 48 | // 参数验证 49 | const payload = ctx.helper.validate(schema, { 50 | ...ctx.request.body, 51 | market: store.market._id, 52 | }); 53 | // 创建修改 54 | const res = await service.set.update( 55 | { 56 | market: store.market._id, 57 | key: payload.key, 58 | }, 59 | payload, 60 | { 61 | new: true, 62 | upsert: true, 63 | setDefaultsOnInsert: true, 64 | } 65 | ); 66 | ctx.helper.success({ ctx, res }).logger(store, '新增修改设置'); 67 | } 68 | } 69 | 70 | module.exports = SetController; 71 | -------------------------------------------------------------------------------- /app/service/market.js: -------------------------------------------------------------------------------- 1 | // 市场 2 | const Service = require('egg').Service; 3 | const moment = require('moment'); 4 | class MarketService extends Service { 5 | constructor(ctx) { 6 | super(ctx); 7 | this.model = ctx.model.Market; 8 | } 9 | 10 | /** 11 | * 校验市场合法性 12 | * 13 | * @param {*} marketId 14 | * @returns 15 | * @memberof MarketService 16 | */ 17 | async valid({ marketId, userId }) { 18 | const { ctx, model } = this; 19 | // 查询市场 20 | const res = await model.findById(marketId).lean(); 21 | // 存储市场信息 22 | ctx.store({ marketId, userId }, 'market').value = res; 23 | // 市场是否存在 24 | ctx.assert(res, 404, '市场密钥错误'); 25 | // 市场状态是否正常 enable是否为true 26 | ctx.assert(res.enable, 403, '目前市场处于关闭状态'); 27 | return res; 28 | } 29 | 30 | /** 31 | * 查询市场 32 | * 33 | * @param {*} { 34 | * search = {}, 35 | * page = 1, 36 | * limit = 10, 37 | * sort = { 38 | * updatedAt: -1, 39 | * _id: 1, 40 | * }, 41 | * } 42 | * @returns 43 | * @memberof MarketService 44 | */ 45 | async index({ 46 | search = {}, 47 | page = 1, 48 | limit = 10, 49 | sort = { updatedAt: -1, _id: 1 }, 50 | }) { 51 | const { ctx, model } = this; 52 | // 组装搜索条件 53 | const query = ctx.helper.GMQC({ 54 | search, 55 | config: { 56 | condition: [ 57 | { field: 'name', cond: '{ $regex: /$$/ }' }, 58 | { field: '_id', cond: "ObjectId('$$')" }, 59 | ], 60 | }, 61 | }); 62 | return await model.paginate(query, { 63 | limit, 64 | page, 65 | lean: true, 66 | sort, 67 | }); 68 | } 69 | 70 | /** 71 | * 创建修改市场 72 | * 73 | * @param {*} argument 74 | * @returns 75 | * @memberof MarketService 76 | */ 77 | async update(...argument) { 78 | const { model } = this; 79 | return await model.findOneAndUpdate(...argument).lean(); 80 | } 81 | } 82 | 83 | module.exports = MarketService; 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 停车控制系统 2 | 3 | ![](https://user-images.githubusercontent.com/66936909/94420163-57952580-01b6-11eb-87ec-3f866b383e24.png) 4 | 5 | 安全、高效、可定制的智慧停车解决方案,全套开源 6 | 7 | ## 本项目一共分为 5 端 可根据实际情况选择是否使用 8 | 9 | 1. 服务端 https://github.com/ddhmit/parking-control-server 10 | 2. APP 端 https://github.com/ddhmit/parking-control-app 11 | 3. 管理端 https://github.com/ddhmit/parking-control-admin 12 | 4. 小票端 https://github.com/ddhmit/parking-control-ticket 13 | 5. 微信扫码支付预览 https://github.com/ddhmit/parking-control-wxpay 14 | 15 | ## 特性 16 | 17 | 1. ⛲ 科学计费 多种计费方案灵活切换,商场、小区、停车场等场景均适用 18 | 2. ⏲ 商户放行 配套商户 APP 可由商户控制经停车辆放行,加强市场安全 19 | 3. ♉ 无人值守 云端控制实现无岗亭模式下的车辆自主进出,降低人工成本 20 | 4. ⛳ 应急开闸 在意外突发情况下,管理员无需到场可随时远程进行开闸放行 21 | 5. 🍓 强兼容性 不更换原有抓拍机,可兼容市面上 90%的抓拍机品牌 22 | 6. 📱 移动支付 直接使用微信支付宝等扫码支付,无需人工干预提升效率 23 | 7. 🎫 电子小票 三轮车等无牌车可采用领取小票方式入场,全流程无缝衔接 24 | 8. ⏳ 经停追踪 搭配商户 APP,可随时调阅车辆经停记录,确保装卸货万无一失 25 | 9. 🙋 人像识别 智能人像识别系统,确保小区业主通行无阻,保障小区安全 26 | 27 | ## 技术架构 28 | 29 | 该系统服务端理论上兼容全平台,目前我们在生产环境中是在 ubuntu 上使用 docker 部署的 30 | 该套系统采用 web 全栈解决方案,服务端 node.js,APP ionic,管理端 react 31 | 但还包括 docker,eggjs,redis,mongodb,socket.io,typescript,微信支付等技术细节 32 | 33 | ## 使用 34 | 35 | ### 服务端部署方法 36 | 37 | 省略 ubuntu 上安装 docker 的步骤 38 | 仅演示 ubuntu 上的服务端部署 39 | 40 | 1. `cd ../srv` 41 | 2. `git clone https://github.com/ddhmit/parking-control-server.git` 42 | 3. `docker network create net-1` 43 | 4. `docker run -ti -p 7002:7002 --name parkserver --network net-1 --network-alias parkserver --restart always -v /srv/parking-control-server:/srv/parking-control-server -v /logs:/root/logs -v /etc/localtime:/etc/localtime:ro --privileged=true -d node sh -c 'cd /srv/parking-control-server && npm i && npm run start'` 44 | 45 | ### 前端 46 | 47 | 管理端、小票端、微信扫码支付预览 及 APP 打包部署 48 | 均使用以下方式 49 | 50 | 1. `npm i` 51 | 2. `npm run build` 52 | 53 | ## 截图 54 | 55 | ![](https://user-images.githubusercontent.com/66936909/94420486-ce322300-01b6-11eb-84af-fd1fdd81d565.png) 56 | ![](https://user-images.githubusercontent.com/66936909/94420774-37199b00-01b7-11eb-9d4c-8e4753537067.png) 57 | ![](https://user-images.githubusercontent.com/66936909/94421786-a80d8280-01b8-11eb-8b03-4a5dceefcd4d.png) 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parkServer", 3 | "version": "1.0.0", 4 | "description": "", 5 | "private": true, 6 | "egg": { 7 | "declarations": true 8 | }, 9 | "dependencies": { 10 | "@hapi/joi": "^17.1.1", 11 | "await-stream-ready": "^1.0.1", 12 | "crc": "^3.8.0", 13 | "crypto": "^1.0.1", 14 | "dingtalk-robot-sender": "^1.2.0", 15 | "egg": "^2.15.1", 16 | "egg-bcrypt": "^1.1.0", 17 | "egg-cors": "^2.2.3", 18 | "egg-downloader": "^1.0.5", 19 | "egg-jwt": "^3.1.7", 20 | "egg-mongoose": "^3.2.0", 21 | "egg-redis": "^2.4.0", 22 | "egg-scripts": "^2.11.0", 23 | "egg-socket.io": "^4.1.6", 24 | "egg-view-nunjucks": "^2.2.0", 25 | "fs-extra": "^9.0.0", 26 | "glob": "^7.1.6", 27 | "iconv-lite": "^0.5.1", 28 | "joigoose": "^7.0.0", 29 | "lodash": "^4.17.15", 30 | "moment": "^2.24.0", 31 | "moment-precise-range-plugin": "^1.3.0", 32 | "mongoose-aggregate-paginate-v2": "^1.0.4", 33 | "mongoose-autopopulate": "^0.12.2", 34 | "mongoose-delete": "^0.5.2", 35 | "mongoose-paginate-v2": "^1.3.9", 36 | "os": "^0.1.1", 37 | "sharp": "^0.25.2", 38 | "stack-trace": "0.0.10", 39 | "stream-wormhole": "^1.1.0", 40 | "superagent": "^5.2.2", 41 | "system-sleep": "^1.3.6", 42 | "tenpay": "^2.1.18", 43 | "uuid": "^7.0.3" 44 | }, 45 | "devDependencies": { 46 | "autod": "^3.0.1", 47 | "autod-egg": "^1.1.0", 48 | "egg-bin": "^4.11.0", 49 | "egg-ci": "^1.11.0", 50 | "egg-mock": "^3.21.0", 51 | "eslint": "^5.13.0", 52 | "eslint-config-egg": "^7.1.0" 53 | }, 54 | "engines": { 55 | "node": ">=10.0.0" 56 | }, 57 | "scripts": { 58 | "start": "egg-scripts start --port=7002 --sticky --title=egg-server-parkServer", 59 | "stop": "egg-scripts stop --title=egg-server-parkServer", 60 | "dev": "egg-bin dev --sticky --port=7002", 61 | "debug": "egg-bin debug", 62 | "test": "npm run lint -- --fix && npm run test-local", 63 | "test-local": "egg-bin test", 64 | "cov": "egg-bin cov", 65 | "lint": "eslint .", 66 | "ci": "npm run lint && npm run cov", 67 | "autod": "autod" 68 | }, 69 | "ci": { 70 | "version": "10" 71 | }, 72 | "repository": { 73 | "type": "git", 74 | "url": "" 75 | }, 76 | "author": "", 77 | "license": "MIT" 78 | } 79 | -------------------------------------------------------------------------------- /app/model/user.js: -------------------------------------------------------------------------------- 1 | // 用户表 2 | const Joi = require('@hapi/joi'); 3 | module.exports = (app) => { 4 | const schemaName = 'User'; 5 | const mongoose = app.mongoose; 6 | const conn = app.mongooseDB.get('ddhmit'); 7 | const Joigoose = require('joigoose')(mongoose, null, { 8 | _id: true, 9 | timestamps: true, 10 | }); 11 | const ctx = app.createAnonymousContext(); 12 | const schema = Joi.object({ 13 | // 市场ID 14 | market: Joi.string() 15 | .meta({ 16 | _mongoose: { 17 | type: 'ObjectId', 18 | ref: 'Market', 19 | immutable: true, 20 | // autopopulate: true, 此处不能自动填充,会和其他model形成死循环 21 | }, 22 | }) 23 | .required(), 24 | // 姓名 25 | name: Joi.string(), 26 | // 身份证 27 | idCard: Joi.object({ 28 | number: Joi.string() 29 | .meta({ 30 | _mongoose: { 31 | default: '', 32 | }, 33 | }) 34 | .allow(''), // 身份证号码 35 | photo: Joi.object({ 36 | head: Joi.string() 37 | .meta({ 38 | // 身份证照片 头像面 39 | _mongoose: { 40 | default: '', 41 | }, 42 | }) 43 | .allow(''), 44 | emblem: Joi.string() 45 | .meta({ 46 | // 身份证照片 国徽面 47 | _mongoose: { 48 | default: '', 49 | }, 50 | }) 51 | .allow(''), 52 | }), 53 | }), 54 | // 用户开关 55 | enable: Joi.bool().meta({ 56 | _mongoose: { 57 | default: true, 58 | }, 59 | }), 60 | // 手机号 61 | phone: Joi.string().regex( 62 | /^[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}$/ 63 | ), 64 | // 账号 65 | account: Joi.string(), 66 | // 密码 67 | password: Joi.string(), 68 | }).with('account', 'password'); 69 | ctx.schema.set = { schemaName, schema }; 70 | const mongooseSchema = new mongoose.Schema(Joigoose.convert(schema), { 71 | timestamps: true, 72 | }); 73 | // 分页插件 74 | mongooseSchema.plugin(require('mongoose-paginate-v2')); 75 | // 软删除 76 | mongooseSchema.plugin(require('mongoose-delete'), { 77 | indexFields: true, 78 | overrideMethods: true, 79 | }); 80 | // 自动populate 81 | // mongooseSchema.plugin(require('mongoose-autopopulate')); 82 | 83 | return conn.model(schemaName, mongooseSchema); 84 | }; 85 | -------------------------------------------------------------------------------- /app/model/market.js: -------------------------------------------------------------------------------- 1 | // 市场表 2 | const Joi = require('@hapi/joi'); 3 | module.exports = (app) => { 4 | const schemaName = 'Market'; 5 | const mongoose = app.mongoose; 6 | const conn = app.mongooseDB.get('ddhmit'); 7 | const Joigoose = require('joigoose')(mongoose, null, { 8 | _id: true, 9 | timestamps: true, 10 | }); 11 | const ctx = app.createAnonymousContext(); 12 | const schema = Joi.object({ 13 | // 市场名称 14 | name: Joi.string() 15 | .min(5) 16 | .max(30) 17 | .meta({ 18 | _mongoose: { 19 | unique: true, 20 | trim: true, 21 | immutable: true, 22 | }, 23 | }) 24 | .required(), 25 | // 营业执照 26 | businessLicense: Joi.object({ 27 | // 统一社会信用代码 28 | creditCode: Joi.string().required(), 29 | // 营业执照照片 30 | photo: Joi.string() 31 | .regex( 32 | /^(?:(?:https?|ftp):\/\/)?(?:[\da-z.-]+)\.(?:[a-z.]{2,6})(?:\/\w\.-]*)*\/?\S+\.(gif|jpeg|png|jpg|JPG|bmp)/ 33 | ) 34 | .required(), 35 | }), 36 | // 开关 37 | enable: Joi.bool().meta({ 38 | _mongoose: { 39 | default: true, 40 | }, 41 | }), 42 | // 主体责任人 43 | user: Joi.string().meta({ 44 | _mongoose: { 45 | type: 'ObjectId', 46 | ref: 'User', 47 | }, 48 | }), 49 | // 绑定的车辆 50 | car: Joi.array().items( 51 | Joi.object({ 52 | num: Joi.string().regex( 53 | /^(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}(?:(?:[0-9]{5}[DF])|(?:[DF](?:[A-HJ-NP-Z0-9])[0-9]{4})))|(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9 挂学警港澳]{1})$/ 54 | ), 55 | // 车辆过期时间 56 | expired: Joi.date() 57 | .meta({ 58 | _mongoose: { 59 | default: '', 60 | }, 61 | }) 62 | .allow(''), 63 | }) 64 | ), 65 | // 员工 66 | staff: Joi.array().items( 67 | Joi.object({ 68 | user: Joi.string() 69 | .meta({ 70 | _mongoose: { 71 | type: 'ObjectId', 72 | ref: 'User', 73 | }, 74 | }) 75 | .required(), 76 | role: Joi.string() 77 | .valid('普通员工', '普通保安') 78 | .meta({ 79 | _mongoose: { 80 | default: '普通员工', 81 | }, 82 | }), 83 | }).required() 84 | ), 85 | // 维护过期时间 86 | expired: Joi.date().required(), 87 | }); 88 | ctx.schema.set = { schemaName, schema }; 89 | const mongooseSchema = new mongoose.Schema(Joigoose.convert(schema), { 90 | timestamps: true, 91 | }); 92 | // 分页插件 93 | mongooseSchema.plugin(require('mongoose-paginate-v2')); 94 | // 自动populate 95 | // mongooseSchema.plugin(require('mongoose-autopopulate')); 96 | return conn.model(schemaName, mongooseSchema); 97 | }; 98 | -------------------------------------------------------------------------------- /app/service/merchant.js: -------------------------------------------------------------------------------- 1 | // 商户 2 | const Service = require('egg').Service; 3 | 4 | class MerchantService extends Service { 5 | constructor(ctx) { 6 | super(ctx); 7 | this.model = ctx.model.Merchant; 8 | } 9 | 10 | /** 11 | * 校验商户合法性 12 | * 13 | * @param {*} { marketId, userId } 14 | * @returns 15 | * @memberof MerchantService 16 | */ 17 | async valid({ marketId, userId }) { 18 | const { ctx, model } = this; 19 | // 查询商户 20 | const res = await model 21 | .findOne({ 22 | market: marketId, 23 | $or: [{ user: userId }, { 'staff.user': userId }], 24 | }) 25 | .lean(); 26 | // 是商户 27 | if (res) { 28 | // 存入商户信息 29 | ctx.store({ marketId, userId }, 'merchant').value = res; 30 | // 状态是否正常 enable是否为true 31 | ctx.assert(res.enable, 403, '当前商户已被禁用'); 32 | // 如果当前账号是员工 33 | const staff = res.staff.find((item) => item.user.equals(userId)); 34 | if (staff) { 35 | ctx.assert( 36 | res.status == '正常', 37 | 403, 38 | `当前商户${res.status},暂不可用!` 39 | ); 40 | ctx.assert(staff.status == '正常', 403, `${staff.status},暂不可用!`); 41 | } 42 | } 43 | return res; 44 | } 45 | 46 | /** 47 | * 商户列表 48 | * 49 | * @param {*} { 50 | * search = {}, 51 | * page = 1, 52 | * limit = 10, 53 | * sort = { updatedAt: -1, _id: 1 }, 54 | * } 55 | * @returns 56 | * @memberof MerchantService 57 | */ 58 | async index({ 59 | search = {}, 60 | page = 1, 61 | limit = 10, 62 | sort = { updatedAt: -1, _id: 1 }, 63 | select = {}, 64 | }) { 65 | const { ctx, model } = this; 66 | // 组装搜索条件 67 | const query = ctx.helper.GMQC({ 68 | search, 69 | config: { 70 | condition: [ 71 | { field: 'name', cond: '{ $regex: /$$/ }' }, 72 | { field: 'market', cond: "ObjectId('$$')" }, 73 | { field: '_id', cond: "ObjectId('$$')" }, 74 | ], 75 | }, 76 | }); 77 | return await model.paginate(query, { 78 | populate: { 79 | path: 'user', 80 | select: { name: 1, phone: 1 }, 81 | }, 82 | limit, 83 | page, 84 | sort, 85 | lean: true, 86 | select, 87 | }); 88 | } 89 | 90 | /** 91 | * 修改商户 92 | * 93 | * @param {*} argument 94 | * @returns 95 | * @memberof MerchantService 96 | */ 97 | async update(...argument) { 98 | const { model } = this; 99 | return await model.findOneAndUpdate(...argument).lean(); 100 | } 101 | 102 | /** 103 | * 删除商户 104 | * 105 | * @param {*} ids 106 | * @returns 107 | * @memberof MerchantService 108 | */ 109 | async delete(ids) { 110 | const { model } = this; 111 | return await model.delete({ 112 | _id: { $in: ids }, 113 | }); 114 | } 115 | } 116 | 117 | module.exports = MerchantService; 118 | -------------------------------------------------------------------------------- /app/model/car.js: -------------------------------------------------------------------------------- 1 | // 车辆入场出场记录 2 | const Joi = require('@hapi/joi'); 3 | module.exports = (app) => { 4 | const schemaName = 'Car'; 5 | const mongoose = app.mongoose; 6 | const conn = app.mongooseDB.get('ddhmit'); 7 | const Joigoose = require('joigoose')(mongoose, null, { 8 | _id: true, 9 | timestamps: true, 10 | }); 11 | const ctx = app.createAnonymousContext(); 12 | const schema = Joi.object({ 13 | // 市场ID 14 | market: Joi.string() 15 | .meta({ 16 | _mongoose: { 17 | type: 'ObjectId', 18 | ref: 'Market', 19 | }, 20 | }) 21 | .required(), 22 | // 经过的商户 23 | pathway: Joi.array().items( 24 | Joi.object({ 25 | merchant: Joi.string() 26 | .meta({ 27 | _mongoose: { 28 | type: 'ObjectId', 29 | ref: 'Merchant', 30 | }, 31 | }) 32 | .required(), 33 | // 操作人 34 | operator: Joi.string() 35 | .meta({ 36 | _mongoose: { 37 | type: 'ObjectId', 38 | ref: 'User', 39 | }, 40 | }) 41 | .required(), 42 | operation: Joi.string() 43 | .meta({ 44 | _mongoose: { 45 | index: true, 46 | }, 47 | }) 48 | .valid('装车', '卸车') 49 | .required(), 50 | status: Joi.string() 51 | .valid('进行中', '放行') 52 | .meta({ 53 | _mongoose: { 54 | default: '进行中', 55 | index: true, 56 | }, 57 | }), 58 | }) 59 | ), 60 | // 车辆信息 61 | car: Joi.object({ 62 | license: Joi.string().meta({ 63 | _mongoose: { 64 | index: true, 65 | }, 66 | }), 67 | type: Joi.string() 68 | .meta({ 69 | _mongoose: { 70 | index: true, 71 | }, 72 | }) 73 | .valid('三轮车', '非三轮车') 74 | .required(), 75 | info: Joi.any(), 76 | }).required(), 77 | // 手机号 78 | phone: Joi.string().regex( 79 | /^[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}$/ 80 | ), 81 | // 出场时间 82 | outAt: Joi.date().meta({ 83 | _mongoose: { 84 | index: true, 85 | }, 86 | }), 87 | }); 88 | 89 | ctx.schema.set = { schemaName, schema }; 90 | const mongooseSchema = new mongoose.Schema(Joigoose.convert(schema), { 91 | timestamps: true, 92 | }); 93 | // 自动删除15天前的数据 94 | mongooseSchema.index( 95 | { 96 | createdAt: 1, 97 | }, 98 | { expireAfterSeconds: 60 * 60 * 24 * 15 } 99 | ); 100 | // 分页插件 101 | // mongooseSchema.plugin(require('mongoose-paginate-v2')); 102 | mongooseSchema.plugin(require('mongoose-aggregate-paginate-v2')); 103 | // 自动populate 104 | // mongooseSchema.plugin(require('mongoose-autopopulate')); 105 | 106 | return conn.model(schemaName, mongooseSchema); 107 | }; 108 | -------------------------------------------------------------------------------- /app/service/user.js: -------------------------------------------------------------------------------- 1 | // 用户 2 | const Service = require('egg').Service; 3 | 4 | class UserService extends Service { 5 | constructor(ctx) { 6 | super(ctx); 7 | this.model = ctx.model.User; 8 | } 9 | 10 | /** 11 | * 校验用户合法性 12 | * 13 | * @param {*} { marketId, userId } 14 | * @returns 15 | * @memberof UserService 16 | */ 17 | async valid({ marketId, userId }) { 18 | const { ctx, model } = this; 19 | // 查询用户 20 | const res = await model.findById(userId).lean(); 21 | // 获取用户角色 22 | const identity = await this.identity({ 23 | marketId, 24 | userId, 25 | }); 26 | // 存储用户信息 27 | ctx.store({ marketId, userId }, 'user').value = { 28 | ...res, 29 | identity, 30 | }; 31 | // 用户是否存在 32 | ctx.assert(res, 404, '当前用户不存在'); 33 | // 用户状态是否正常 enable是否为true 34 | ctx.assert(res.enable, 403, '当前用户已被禁止登陆'); 35 | return res; 36 | } 37 | 38 | /** 39 | * 获取用户身份 40 | * 41 | * @param {*} state 42 | * @returns 43 | * @memberof UserService 44 | */ 45 | async identity({ marketId, userId }) { 46 | const { ctx } = this; 47 | const marketAdmin = await ctx.model.Market.findOne({ 48 | _id: marketId, 49 | user: userId, 50 | }); 51 | const marketStaff1 = await ctx.model.Market.findOne({ 52 | _id: marketId, 53 | staff: { $elemMatch: { user: userId, role: '普通员工' } }, 54 | }); 55 | const marketStaff2 = await ctx.model.Market.findOne({ 56 | _id: marketId, 57 | staff: { $elemMatch: { user: userId, role: '普通保安' } }, 58 | }); 59 | const merchantAdmin = await ctx.model.Merchant.findOne({ 60 | market: marketId, 61 | user: userId, 62 | }); 63 | const merchantStaff = await ctx.model.Merchant.findOne({ 64 | market: marketId, 65 | 'staff.user': userId, 66 | }); 67 | return { 68 | 市场责任人: marketAdmin ? true : false, 69 | 市场员工: marketStaff2 ? false : marketStaff1 ? true : false, 70 | 市场保安: marketStaff2 ? true : false, 71 | 商户责任人: merchantAdmin ? true : false, 72 | 商户员工: merchantStaff ? true : false, 73 | }; 74 | } 75 | 76 | /** 77 | * 查询用户 78 | * 79 | * @param {*} { 80 | * search = {}, 81 | * page = 1, 82 | * limit = 10, 83 | * sort = { 84 | * updatedAt: -1, 85 | * _id: 1, 86 | * }, 87 | * } 88 | * @returns 89 | * @memberof MarketService 90 | */ 91 | async index({ 92 | search = {}, 93 | page = 1, 94 | limit = 10, 95 | sort = { updatedAt: -1, _id: 1 }, 96 | select = {}, 97 | }) { 98 | const { ctx, model } = this; 99 | // 组装搜索条件 100 | const query = ctx.helper.GMQC({ search }); 101 | return await model.paginate(query, { 102 | limit, 103 | page, 104 | sort, 105 | lean: true, 106 | select, 107 | }); 108 | } 109 | 110 | /** 111 | * 完善资料 112 | * 113 | * @param {*} argument 114 | * @returns 115 | * @memberof UserService 116 | */ 117 | async update(...argument) { 118 | const { model } = this; 119 | return await model.findOneAndUpdate(...argument).lean(); 120 | } 121 | 122 | /** 123 | * 删除用户 124 | * 125 | * @param {*} ids 126 | * @returns 127 | * @memberof UserService 128 | */ 129 | async delete(ids) { 130 | const { model } = this; 131 | return await model.delete({ 132 | _id: { $in: ids }, 133 | }); 134 | } 135 | } 136 | 137 | module.exports = UserService; 138 | -------------------------------------------------------------------------------- /app/view/socket.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Demo 8 | 19 | 20 | 21 | 22 |
23 |

 24 |     
25 | 26 | 27 | 28 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /app/public/assets/downloadhtml/bootstrap-4.4.1-dist/css/bootstrap-reboot.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.4.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2019 The Bootstrap Authors 4 | * Copyright 2011-2019 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]){color:inherit;text-decoration:none}a:not([href]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.min.css.map */ -------------------------------------------------------------------------------- /config/config.default.js: -------------------------------------------------------------------------------- 1 | /* eslint valid-jsdoc: "off" */ 2 | 'use strict'; 3 | const os = require('os'); 4 | const path = require('path'); 5 | /** 6 | * @param {Egg.EggAppInfo} appInfo app info 7 | */ 8 | module.exports = (appInfo) => { 9 | /** 10 | * built-in config 11 | * @type {Egg.EggAppConfig} 12 | **/ 13 | const config = (exports = {}); 14 | 15 | // use for cookie sign key, should change to your own and keep security 16 | config.keys = appInfo.name + '_1585104456252_5128'; 17 | 18 | // 无需验证token的接口 19 | const jwtIgnore = [ 20 | '/home', 21 | '/api/pay', 22 | '/api/local', 23 | '/api/access', 24 | '/api/sms', 25 | '/api/ipCamera', 26 | '/api/ticket/print', 27 | '/api/ticket/padScan', 28 | '/api/download', 29 | ]; 30 | config.middleware = ['errorHandler', 'expiredHandler', 'merchantInfoAudit']; 31 | config.bodyParser = { 32 | enableTypes: ['json', 'form', 'text'], 33 | extendTypes: { 34 | text: ['text/xml', 'application/xml'], 35 | }, 36 | }; 37 | config.jwt = { 38 | enable: true, 39 | secret: 40 | 'Decryptor will be sent to prison. ©DDHMIT.COM All rights reserved.', 41 | ignore: jwtIgnore, 42 | }; 43 | config.expiredHandler = { 44 | enable: true, 45 | ignore: [...jwtIgnore, '/api/market/index', '/api/merchant/index'], 46 | }; 47 | config.merchantInfoAudit = { 48 | enable: true, 49 | ignore: [...jwtIgnore, '/api/merchant', '/api/user', '/api/upload'], 50 | }; 51 | 52 | // 只对 /api 前缀的 url 路径生效 53 | config.errorHandler = { 54 | match: '/api', 55 | }; 56 | 57 | config.proxy = true; 58 | // 避免用户通过请求头来伪造 IP 地址 59 | config.maxProxyCount = 1; 60 | 61 | config.cors = { 62 | origin: '*', 63 | allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS', 64 | }; 65 | 66 | config.security = { 67 | csrf: { 68 | // 内部 ip 关闭部分安全防范 69 | enable: false, 70 | }, 71 | domainWhiteList: ['http://localhost:7001', 'http://localhost:8100'], 72 | }; 73 | 74 | config.mongoose = { 75 | clients: { 76 | customer: { 77 | url: 'mongodb://127.0.0.1/park', 78 | options: { 79 | useNewUrlParser: true, 80 | useFindAndModify: false, 81 | useCreateIndex: true, 82 | useUnifiedTopology: true, 83 | selectPopulatedPaths: false, 84 | }, 85 | }, 86 | ddhmit: { 87 | url: 'mongodb://127.0.0.1/park', 88 | options: { 89 | useNewUrlParser: true, 90 | useFindAndModify: false, 91 | useCreateIndex: true, 92 | useUnifiedTopology: true, 93 | selectPopulatedPaths: false, 94 | }, 95 | }, 96 | }, 97 | }; 98 | 99 | config.redis = { 100 | client: { 101 | port: 6379, // Redis port 102 | host: '127.0.0.1', // Redis host 103 | password: '', 104 | db: 0, 105 | }, 106 | }; 107 | 108 | config.view = { 109 | defaultViewEngine: 'nunjucks', 110 | }; 111 | 112 | config.bcrypt = { 113 | saltRounds: 10, 114 | }; 115 | 116 | config.io = { 117 | namespace: { 118 | '/ipCamera': { 119 | connectionMiddleware: ['connection'], 120 | packetMiddleware: [], 121 | }, 122 | '/payNotice': { 123 | connectionMiddleware: [], 124 | packetMiddleware: [], 125 | }, 126 | }, 127 | redis: { 128 | host: '127.0.0.1', 129 | port: 6379, 130 | }, 131 | }; 132 | 133 | config.multipart = { 134 | whitelist: [ 135 | '.jpg', 136 | '.jpeg', 137 | '.JPG', // image/jpeg 138 | '.png', // image/png, image/x-png 139 | '.gif', // image/gif 140 | ], 141 | mode: 'file', 142 | tmpdir: path.join(os.tmpdir(), 'egg-multipart-tmp', appInfo.name), 143 | cleanSchedule: { 144 | cron: '0 30 4 * * *', 145 | }, 146 | fileSize: '5mb', 147 | }; 148 | 149 | return config; 150 | }; 151 | -------------------------------------------------------------------------------- /app/service/test1.html: -------------------------------------------------------------------------------- 1 | 2 |

停车时长:

3 |

免费时长:

4 |

起步时长:

5 |

起步金额:

6 |

计费周期:

7 |

周期金额:

8 |

封顶金额:

9 |

10 | 11 | 118 | -------------------------------------------------------------------------------- /app/model/merchant.js: -------------------------------------------------------------------------------- 1 | // 商户表 2 | const Joi = require('@hapi/joi'); 3 | module.exports = (app) => { 4 | const schemaName = 'Merchant'; 5 | const mongoose = app.mongoose; 6 | const conn = app.mongooseDB.get('ddhmit'); 7 | const Joigoose = require('joigoose')(mongoose, null, { 8 | _id: true, 9 | timestamps: true, 10 | }); 11 | const ctx = app.createAnonymousContext(); 12 | const schema = Joi.object({ 13 | // 市场 14 | market: Joi.custom(ctx.helper.joiObjectIdValid()) 15 | .meta({ 16 | _mongoose: { 17 | type: 'ObjectId', 18 | ref: 'Market', 19 | immutable: true, 20 | }, 21 | }) 22 | .required(), 23 | // 余额 24 | balance: Joi.number().meta({ 25 | _mongoose: { 26 | default: 0, 27 | }, 28 | }), 29 | // 商户名称 30 | name: Joi.string() 31 | .meta({ 32 | _mongoose: { 33 | unique: true, 34 | trim: true, 35 | immutable: true, 36 | }, 37 | }) 38 | .required(), 39 | // 主体责任人 40 | user: Joi.custom(ctx.helper.joiObjectIdValid()) 41 | .meta({ 42 | _mongoose: { 43 | type: 'ObjectId', 44 | ref: 'User', 45 | immutable: true, 46 | }, 47 | }) 48 | .required(), 49 | // 积分 50 | integral: Joi.number().meta({ 51 | _mongoose: { 52 | default: 0, 53 | }, 54 | }), 55 | // 营业执照 56 | businessLicense: Joi.object({ 57 | creditCode: Joi.string(), // 统一社会信用代码 58 | photo: Joi.string() 59 | .meta({ 60 | // 营业执照照片 61 | _mongoose: { 62 | default: '', 63 | }, 64 | }) 65 | .allow(''), 66 | }), 67 | // 租房合同 68 | rentalContract: Joi.object({ 69 | page1: Joi.string() 70 | .meta({ 71 | // 第一页 72 | _mongoose: { 73 | default: '', 74 | }, 75 | }) 76 | .allow(''), 77 | page999: Joi.string() 78 | .meta({ 79 | // 最后一页 80 | _mongoose: { 81 | default: '', 82 | }, 83 | }) 84 | .allow(''), 85 | }), 86 | // 员工 87 | staff: Joi.array().items( 88 | Joi.object({ 89 | user: Joi.string() 90 | .meta({ 91 | _mongoose: { 92 | type: 'ObjectId', 93 | ref: 'User', 94 | }, 95 | }) 96 | .required(), 97 | status: Joi.string() 98 | .meta({ 99 | _mongoose: { 100 | default: '审核中', 101 | }, 102 | }) 103 | .valid('审核中', '正常', '审核不通过'), 104 | role: Joi.string() 105 | .valid('普通员工') 106 | .meta({ 107 | _mongoose: { 108 | default: '普通员工', 109 | }, 110 | }), 111 | }) 112 | ), 113 | // 商户开关 114 | enable: Joi.bool().meta({ 115 | _mongoose: { 116 | default: true, 117 | }, 118 | }), 119 | // 绑定的车辆 120 | car: Joi.array().items( 121 | Joi.object({ 122 | // 车牌号 123 | num: Joi.string().regex( 124 | /^(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}(?:(?:[0-9]{5}[DF])|(?:[DF](?:[A-HJ-NP-Z0-9])[0-9]{4})))|(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9 挂学警港澳]{1})$/ 125 | ), 126 | // 车辆过期时间 127 | expired: Joi.date() 128 | .meta({ 129 | _mongoose: { 130 | default: '', 131 | }, 132 | }) 133 | .allow(''), 134 | }) 135 | ), 136 | // 状态 137 | status: Joi.string() 138 | .meta({ 139 | _mongoose: { 140 | default: '审核中', 141 | }, 142 | }) 143 | .valid('审核不通过', '正常', '审核中'), 144 | }); 145 | ctx.schema.set = { schemaName, schema }; 146 | const mongooseSchema = new mongoose.Schema(Joigoose.convert(schema), { 147 | timestamps: true, 148 | }); 149 | // 分页插件 150 | mongooseSchema.plugin(require('mongoose-paginate-v2')); 151 | // 软删除 152 | mongooseSchema.plugin(require('mongoose-delete'), { 153 | indexFields: true, 154 | overrideMethods: true, 155 | }); 156 | // 自动populate 157 | // mongooseSchema.plugin(require('mongoose-autopopulate')); 158 | return conn.model(schemaName, mongooseSchema); 159 | }; 160 | -------------------------------------------------------------------------------- /app/controller/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const Joi = require('@hapi/joi'); 4 | const Controller = require('egg').Controller; 5 | class UserController extends Controller { 6 | constructor(ctx) { 7 | super(ctx); 8 | this.model = ctx.model.User; 9 | this.schema = ctx.schema.User; 10 | this.changePwdSchema = Joi.object({ 11 | password: Joi.string().required(), 12 | newPassword: Joi.string().required(), 13 | confirmPassword: Joi.ref('newPassword'), 14 | }); 15 | } 16 | 17 | // 用户列表 18 | async index() { 19 | const { ctx, service } = this; 20 | // 参数验证 21 | const payload = ctx.helper.validate(ctx.schema.Query, ctx.request.body); 22 | // 本地查询 23 | // if (ctx.request.host == 'localhost:7002') { 24 | // const res = await service.user.index(payload); 25 | // return ctx.helper.success({ ctx, res }); 26 | // } 27 | // 获取当前登录用户的数据 28 | const store = await ctx.helper.getStore({ 29 | field: ['user', 'market', 'merchant'], 30 | }); 31 | // 获取当前用户身份 32 | const identity = store.user.identity; 33 | // 市场负责人查询 34 | if (identity['市场责任人'] || identity['市场员工']) { 35 | const res = await service.user.index({ 36 | ...payload, 37 | search: { 38 | ...payload.search, 39 | market: [store.market._id], 40 | }, 41 | }); 42 | return ctx.helper 43 | .success({ ctx, res }) 44 | .logger(store, '市场负责人查询用户列表'); 45 | } else { 46 | // 查询自己的信息 47 | const search = { 48 | _id: [store.user._id], 49 | }; 50 | const res = await service.user.index({ search }); 51 | return ctx.helper.success({ ctx, res }).logger(store, '查询自己的信息'); 52 | } 53 | } 54 | 55 | // 完善资料 56 | async update() { 57 | const { ctx, service, schema } = this; 58 | // 获取当前登录用户的数据 59 | const store = await ctx.helper.getStore({ 60 | field: ['user', 'market', 'merchant'], 61 | }); 62 | // 参数验证 63 | const payload = ctx.helper.validate(schema, { 64 | ...ctx.request.body, 65 | market: store.market._id, 66 | }); 67 | // 压缩并放置图片 68 | const imagePath = `/public/uploads/formal/market/${store.market._id}/user/${store.user._id}`; 69 | payload.idCard.photo.head = 70 | payload.idCard.photo.head && 71 | (await ctx.helper.copyAndCompress( 72 | path.join(ctx.app.baseDir, 'app', payload.idCard.photo.head), 73 | imagePath, 74 | '身份证头像面', 75 | true 76 | )); 77 | payload.idCard.photo.emblem = 78 | payload.idCard.photo.emblem && 79 | (await ctx.helper.copyAndCompress( 80 | path.join(ctx.app.baseDir, 'app', payload.idCard.photo.emblem), 81 | imagePath, 82 | '身份证国徽面', 83 | true 84 | )); 85 | // 修改用户 86 | const res = await service.user.update( 87 | { 88 | market: store.market._id, 89 | _id: store.user._id, 90 | }, 91 | payload, 92 | { 93 | new: true, 94 | setDefaultsOnInsert: true, 95 | } 96 | ); 97 | ctx.helper.success({ ctx, res }).logger(store, '完善用户资料'); 98 | } 99 | 100 | // 重置密码 101 | async changePwd() { 102 | const { ctx, service } = this; 103 | // 参数验证 104 | const payload = ctx.helper.validate(this.changePwdSchema, ctx.request.body); 105 | // 获取当前登录用户的数据 106 | const store = await ctx.helper.getStore({ 107 | field: ['user', 'market'], 108 | }); 109 | // 市场负责人查询 110 | const identity = store.user.identity; 111 | ctx.assert(identity['市场责任人'], 403, `当前账号,无权操作!`); 112 | // 判断传递的老密码是否正确 和 两次密码是否一致 113 | const user = ( 114 | await service.user.index({ 115 | search: { 116 | market: [store.market._id], 117 | _id: [store.user._id], 118 | }, 119 | }) 120 | ).docs[0]; 121 | const verifyPsw = await ctx.compare(payload.password, user.password); 122 | ctx.assert(verifyPsw, 422, '旧密码错误'); 123 | const password = await ctx.genHash(payload.confirmPassword); 124 | const res = await service.user.update( 125 | { _id: store.user._id, market: store.market._id }, 126 | { 127 | $set: { password }, 128 | }, 129 | { 130 | new: true, 131 | setDefaultsOnInsert: true, 132 | } 133 | ); 134 | ctx.helper.success({ ctx, res }).logger(store, '市场责任人修改自身密码'); 135 | } 136 | } 137 | 138 | module.exports = UserController; 139 | -------------------------------------------------------------------------------- /app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const tenpay = require('tenpay'); 3 | /** 4 | * @param {Egg.Application} app - egg application 5 | */ 6 | module.exports = (app) => { 7 | const { router, controller, io, config } = app; 8 | /* 9 | CRUD 路由结构 router.resources 10 | GET /any any app.controllers.any.index 11 | GET /any/new new_post app.controllers.any.new 12 | GET /any/:id post app.controllers.any.show 13 | GET /any/:id/edit edit_post app.controllers.any.edit 14 | POST /any any app.controllers.any.create 15 | PUT /any/:id post app.controllers.any.update 16 | DELETE /any/:id post app.controllers.any.destroy 17 | */ 18 | 19 | // 本地接口 20 | router.post('/api/local/market', controller.market.update); // 创建市场 21 | router.post('/api/local/market/updateAdmin', controller.market.updateAdmin); // 创建市场主体责任人 22 | 23 | // 安装页 24 | router.get('/home', controller.home.index); // 安装界面 25 | router.get('/home/install', controller.home.install); // 安装界面 26 | 27 | // 市场 28 | router.post('/api/market/index', controller.market.index); 29 | 30 | // 市场车辆管理 31 | router.post('/api/market/car/update', controller.market.updateCar); // 新增修改 32 | router.post('/api/market/car/delete', controller.market.deleteCar); // 删除 33 | 34 | // 市场员工 35 | router.post('/api/market/staff/update', controller.market.updateStaff); // 新增修改 36 | router.post('/api/market/staff/delete', controller.market.deleteStaff); // 删除 37 | router.post('/api/market/staff/index', controller.market.indexStaff); // 查询 38 | 39 | // 商户 40 | router.post('/api/merchant/index', controller.merchant.index); // 查询 41 | router.post('/api/merchant/audit', controller.merchant.audit); // 审核 42 | router.post('/api/merchant/delete', controller.merchant.delete); // 删除 43 | router.post('/api/merchant', controller.merchant.update); // 商户自己修改信息 44 | router.post('/api/merchant/integral', controller.merchant.integral); // 超管增减商户积分 45 | 46 | // 商户车辆管理 47 | router.post('/api/merchant/car/update', controller.merchant.updateCar); // 新增修改 48 | router.post('/api/merchant/car/delete', controller.merchant.deleteCar); // 删除 49 | 50 | // 商户员工 51 | router.post('/api/merchant/staff/index', controller.merchant.indexStaff); // 查询 52 | router.post('/api/merchant/staff/audit', controller.merchant.auditStaff); // 审核员工 53 | router.post('/api/merchant/staff/update', controller.merchant.updateStaff); // 新增修改 54 | router.post('/api/merchant/staff/delete', controller.merchant.deleteStaff); // 删除 55 | 56 | // 查询用户 57 | router.post('/api/user/index', controller.user.index); // 查询 58 | router.post('/api/user', controller.user.update); // 修改 59 | router.post('/api/user/changePwd', controller.user.changePwd); // 重置密码 60 | 61 | // 登录相关 62 | router.post('/api/access/login', controller.access.login); // 登录 63 | router.post('/api/access/refreshToken', controller.access.refreshToken); // 刷新令牌 64 | router.post('/api/sms', controller.sms.create); 65 | 66 | // 相机请求接口 67 | router.post('/api/ipCamera/heartbeat', controller.ipCamera.heartbeat); // 心跳 68 | // 车牌识别结果 69 | router.post( 70 | '/api/ipCamera/alarmInfoPlate', 71 | controller.ipCamera.alarmInfoPlate 72 | ); 73 | 74 | // 图片上传 75 | router.post('/api/upload', controller.upload.create); 76 | 77 | // 小票获取 78 | router.post('/api/ticket/print', controller.ticket.print); 79 | router.post('/api/ticket/padScan', controller.ticket.padScan); 80 | 81 | // apk 下载 82 | router.get('/api/download/:version', controller.download.index); // 下载 83 | router.post('/api/version/index', controller.version.index); // 获取版本 84 | 85 | // 车辆出入记录 86 | router.post('/api/car/inAndOut/index', controller.car.index); // 车辆出入记录 87 | router.post('/api/car/loadAndUnload/index', controller.car.index); // 装卸记录 88 | router.post('/api/car/operation', controller.car.operation); // 装卸车,放行 89 | router.post('/api/car/delete', controller.car.delete); // 删除 90 | router.post('/api/car/inAndOut/manual', controller.car.manual); // 手动录入入场记录 91 | 92 | // 后台设置 93 | router.post('/api/set', controller.set.update); // 新增修改 94 | router.post('/api/set/index', controller.set.index); // 查询 95 | 96 | // app报错上报 97 | router.post('/api/alarm/create', controller.alarm.create); // 上报 98 | 99 | // 支付 100 | router.post('/api/pay/wechat/create', controller.pay.wechatCreate); // 下单 101 | router.post( 102 | '/api/pay/wechat/callback', 103 | new tenpay(config.wechatPay).middleware('pay'), 104 | controller.pay.wechatCallback 105 | ); // 微信支付回调 106 | 107 | // socket.io 108 | io.of('/ipCamera').route('gateOpen', io.controller.nsp.gateOpen); // 开闸 109 | }; 110 | -------------------------------------------------------------------------------- /app/controller/access.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Joi = require('@hapi/joi'); 3 | const Controller = require('egg').Controller; 4 | class AccessController extends Controller { 5 | constructor(ctx) { 6 | super(ctx); 7 | this.model = ctx.model.User; 8 | this.schema = Joi.object({ 9 | // 验证码 10 | code: Joi.string(), 11 | // 手机号 12 | phone: Joi.string().regex( 13 | /^[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}$/ 14 | ), 15 | // 账号 16 | account: Joi.string(), 17 | // 密码 18 | password: Joi.string(), 19 | // 市场ID 20 | market: Joi.string().required(), 21 | // app版本号 22 | version: Joi.string(), 23 | }) 24 | .without('phone', ['account', 'password']) 25 | .with('phone', ['code']) 26 | .without('account', ['phone', 'code', 'version']) 27 | .with('account', ['password']); 28 | 29 | this.refreshTokenSchema = Joi.object({ 30 | refreshToken: Joi.string().required(), 31 | }); 32 | } 33 | 34 | async login() { 35 | const { ctx, service, schema } = this; 36 | // 参数验证 37 | const payload = ctx.helper.validate(schema, ctx.request.body); 38 | // 用户信息 39 | let user = {}; 40 | // 如果是账号密码登录 41 | if (payload.account) { 42 | // 判断当前账号是否已注册 43 | user = ( 44 | await service.user.index({ 45 | search: { 46 | market: [payload.market], 47 | account: [payload.account], 48 | }, 49 | }) 50 | ).docs[0]; 51 | ctx.assert(user, 404, '未找到用户,账号或市场密钥错误'); 52 | // 判断密码是否正确 53 | const verifyPsw = await ctx.compare(payload.password, user.password); 54 | ctx.assert(verifyPsw, 403, '账号或密码错误'); 55 | } 56 | // 如果是手机号验证码登录 57 | if (payload.phone) { 58 | // 最新版本号 判断APP版本 59 | const latest = await service.version.show('latest'); 60 | ctx.assert( 61 | // payload.version && payload.version >= latest.version, 62 | payload.version && payload.version >= '1.1.3', 63 | 406, 64 | // 前端通过空格分割获取的链接,此处请不要去掉空格 65 | '低版本已停止维护,请从以下网址获取最新版本 https://park.ddhmit.com/home' 66 | ); 67 | // 校验验证码 68 | await service.sms.check(payload); 69 | // 保存并修改用户 70 | user = await service.user.update( 71 | { phone: payload.phone, market: payload.market }, 72 | payload, 73 | { 74 | new: true, 75 | upsert: true, 76 | setDefaultsOnInsert: true, 77 | } 78 | ); 79 | } 80 | // 生产token 81 | const state = { marketId: user.market, userId: user._id }; 82 | const accessToken = await service.token.accessToken(state); 83 | const refreshToken = await service.token.refreshToken(state); 84 | // 获取当前登录用户的数据 85 | const store = await ctx.helper.getStore({ 86 | state, 87 | field: ['user', 'market', 'merchant'], 88 | }); 89 | // 返回token 90 | ctx.helper 91 | .success({ 92 | ctx, 93 | res: { 94 | ...user, 95 | accessToken, 96 | refreshToken, 97 | identity: store.user.identity, 98 | }, 99 | }) 100 | .filter() 101 | .logger(store, '登录'); 102 | } 103 | 104 | async refreshToken() { 105 | const { app, ctx, service, refreshTokenSchema } = this; 106 | // 参数验证 107 | const payload = ctx.helper.validate( 108 | refreshTokenSchema, 109 | ctx.request.body, 110 | () => { 111 | ctx.assert(false, 410, '身份验证过期,请重新登录'); 112 | } 113 | ); 114 | // 解密refreshToken 115 | const { marketId, userId } = ctx.helper.REtokenDecode(payload.refreshToken); 116 | // 从redis中读取refreshToken 117 | const refTokenData = await app.redis.get( 118 | `PARK:${marketId}RefreshToken:${userId}` 119 | ); 120 | // 比较传过来的和redis中的refreshToken是否一致 121 | ctx.assert( 122 | refTokenData === payload.refreshToken, 123 | 410, 124 | '身份验证过期,请重新登录' 125 | ); 126 | // 生产token 127 | const state = { marketId, userId }; 128 | const accessToken = await service.token.accessToken(state); 129 | const refreshToken = await service.token.refreshToken(state); 130 | // 获取当前登录用户的数据 131 | const store = await ctx.helper.getStore({ 132 | state, 133 | field: ['user', 'market', 'merchant'], 134 | }); 135 | // 返回token 136 | ctx.helper 137 | .success({ 138 | ctx, 139 | res: { 140 | user: state.user, 141 | accessToken, 142 | refreshToken, 143 | identity: store.user.identity, 144 | }, 145 | }) 146 | .logger(store, '刷新令牌'); 147 | } 148 | } 149 | 150 | module.exports = AccessController; 151 | -------------------------------------------------------------------------------- /app/service/parkingCosts.js: -------------------------------------------------------------------------------- 1 | // 停车缴费 2 | const moment = require('moment'); 3 | const Service = require('egg').Service; 4 | class ParkingCostsService extends Service { 5 | constructor(ctx) { 6 | super(ctx); 7 | } 8 | 9 | async calc({ serialno, hasCar, carType }) { 10 | const { ctx, app, service } = this; 11 | 12 | // 默认响应 13 | let res = { 14 | text: '一路平安', 15 | voice: '祝您一路平安', 16 | price: 0, 17 | status: 'no', 18 | }; 19 | 20 | // 当前时间 21 | const nowDate = Date.now(); 22 | 23 | // 停车总时间 分钟 24 | const parkingTime = moment(nowDate).diff( 25 | moment(hasCar.createdAt), 26 | 'minutes' 27 | ); 28 | 29 | // 获取收费标准 30 | const charge = ( 31 | await service.set.index({ 32 | search: { 33 | key: ['收费标准'], 34 | market: [hasCar.market], 35 | }, 36 | }) 37 | ).docs[0].value.find((item) => { 38 | return ( 39 | item.enable && 40 | item.type == carType && 41 | item.effectCycle.includes(moment().weekday()) && 42 | ctx.helper.checkTimeIsBetween({ 43 | beforeTime: item.effectTime[0], 44 | afterTime: item.effectTime[1], 45 | }) 46 | ); 47 | }); 48 | 49 | // 如果没有该车型的收费标准直接放行 50 | if (!charge) { 51 | res.status = 'ok'; 52 | res.text = res.voice = '免费通行'; 53 | return res; 54 | } 55 | 56 | ////////////////////////////////////////////////////////////////////////////////////// 57 | 58 | // 开始计费 59 | const { 60 | freeDuration, //免费时长 61 | billingCycle, //计费周期 62 | cycleChargeAmount, //周期收费金额 63 | startTime, //起步时长 64 | startMoney, //起步金额; 65 | capMoney, //封顶金额; 66 | } = charge, 67 | dayMinutes = 24 * 60; // 一天的分钟数 68 | 69 | // 停车时长小于等于 免费时长 不收费 70 | if (parkingTime <= freeDuration) { 71 | res.status = 'ok'; 72 | res.text = '免费时间内,请通行'; 73 | res.voice = '免费通行'; 74 | return res; 75 | } 76 | // 单日金额计算 77 | const dayMoneyCalc = ({ calcTime, in24Hours = 0 }) => { 78 | // 计费周期只能为正整数 79 | let dayPrice = 0; 80 | if (billingCycle) { 81 | dayPrice = 82 | Math.trunc( 83 | (calcTime - (in24Hours && startTime) + billingCycle - 1) / 84 | billingCycle 85 | ) * 86 | cycleChargeAmount + 87 | (in24Hours && startMoney); 88 | } 89 | return { 90 | isCap: dayPrice >= capMoney, 91 | price: Math.min(dayPrice, capMoney), 92 | }; 93 | }; 94 | 95 | // 多日封顶与不封顶的处理方式 96 | const dayMoneyIsCap = ({ isCap, price, integerDays, decimalDayMinus }) => { 97 | const _decimalPrice = dayMoneyCalc({ calcTime: decimalDayMinus }).price; 98 | if (!isCap) { 99 | return ( 100 | price + 101 | (integerDays - 1) * 102 | dayMoneyCalc({ 103 | calcTime: dayMinutes, 104 | }).price + 105 | _decimalPrice 106 | ); 107 | } 108 | return price * integerDays + _decimalPrice; 109 | }; 110 | 111 | // 应缴费 112 | res.price = (() => { 113 | let total = 0; 114 | const minPrice = Math.min(startMoney, capMoney); 115 | if (parkingTime <= startTime) { 116 | return minPrice; 117 | } 118 | const startTimeOverDayMinutes = startTime > dayMinutes; 119 | const _dayMoneyCalc = dayMoneyCalc({ 120 | calcTime: Math.min(parkingTime, dayMinutes), 121 | in24Hours: startTimeOverDayMinutes ? 0 : 1, 122 | }); 123 | if (parkingTime > dayMinutes) { 124 | const lackParkingTime = startTimeOverDayMinutes 125 | ? parkingTime - startTime 126 | : parkingTime; 127 | total = dayMoneyIsCap({ 128 | ..._dayMoneyCalc, 129 | integerDays: Math.trunc(lackParkingTime / dayMinutes), // 整数部分天, 130 | decimalDayMinus: lackParkingTime % dayMinutes, // 剩余分钟数, 131 | }); 132 | } else { 133 | total = _dayMoneyCalc.price; 134 | } 135 | if (startTimeOverDayMinutes) { 136 | total += minPrice; 137 | } 138 | return total; 139 | })(); 140 | 141 | // 格式化时间 142 | const _preciseDiff = ctx.helper.preciseDiff(nowDate, hasCar.createdAt); 143 | res.text = res.voice = `停车时长${_preciseDiff.days}天${_preciseDiff.hours}小时${_preciseDiff.minutes}分钟,请缴费${res.price}元`; 144 | res = { ...res, ..._preciseDiff }; 145 | 146 | // 过滤掉car 147 | let { car, ...otherCarInfo } = hasCar; 148 | // 向redis中存入 等待支付的车辆信息 149 | await app.redis.set( 150 | `ParkPaying:${serialno}`, 151 | JSON.stringify({ 152 | car: { 153 | serialno, 154 | ...otherCarInfo, 155 | ...hasCar.car, 156 | }, 157 | payInfo: res, 158 | }), 159 | 'EX', 160 | 10 * 60 161 | ); 162 | return res; 163 | } 164 | } 165 | 166 | module.exports = ParkingCostsService; 167 | -------------------------------------------------------------------------------- /app/service/car.js: -------------------------------------------------------------------------------- 1 | // 车辆出入场 2 | const Service = require('egg').Service; 3 | class CarService extends Service { 4 | constructor(ctx) { 5 | super(ctx); 6 | this.model = ctx.model.Car; 7 | } 8 | 9 | /** 10 | * 查询车辆出入场记录 11 | * 12 | * @param {*} { 13 | * search = {}, 14 | * page = 1, 15 | * limit = 10, 16 | * sort = { 17 | * updatedAt: -1, 18 | * _id: 1, 19 | * }, 20 | * } 21 | * @returns 22 | * @memberof CarService 23 | */ 24 | async index( 25 | { 26 | search = {}, 27 | page = 1, 28 | limit = 10, 29 | sort = { _id: -1, 'pathway._id': -1, 'pathway.status': 1 }, 30 | }, 31 | specialCond = false 32 | ) { 33 | const { ctx, model } = this; 34 | // 组装搜索条件 35 | const query = ctx.helper.GMQC({ 36 | search, 37 | config: { 38 | condition: [ 39 | { field: '_id', cond: "ObjectId('$$')" }, 40 | { field: 'market', cond: "ObjectId('$$')" }, 41 | { field: 'merchant', cond: "ObjectId('$$')" }, 42 | { field: 'operator', cond: "ObjectId('$$')" }, 43 | { field: 'perpage_last_id', cond: "{ $lt: ObjectId('$$') }" }, 44 | { field: 'perpage_last_pathway_id', cond: "{ $lt: ObjectId('$$') }" }, 45 | { 46 | field: 'createdAt', 47 | cond: "{ $gte: new Date('$0'), $lte: new Date('$1 23:59:59') }", 48 | }, 49 | ], 50 | returnDirectAndIndirect: { 51 | direct: ['perpage_last_id', 'market', '_id', 'createdAt'], 52 | }, 53 | prefix$suffix$rename: [ 54 | { 55 | suff: 'license', 56 | rename: 'car', 57 | field: ['carNo'], 58 | }, 59 | { 60 | pref: 'pathway', 61 | field: ['merchant', 'operator', 'operation', 'status'], 62 | }, 63 | { 64 | rename: '_id', 65 | field: ['perpage_last_id'], 66 | }, 67 | { 68 | rename: 'pathway._id', 69 | field: ['perpage_last_pathway_id'], 70 | }, 71 | ], 72 | }, 73 | }); 74 | let params = [ 75 | { 76 | $match: query.direct, 77 | }, 78 | { $unwind: { path: '$pathway', preserveNullAndEmptyArrays: true } }, 79 | { 80 | $match: query.indirect, 81 | }, 82 | { 83 | // https://stackoverflow.com/questions/50562160/select-fields-to-return-from-lookup 84 | $lookup: { 85 | from: 'merchants', 86 | localField: 'pathway.merchant', 87 | foreignField: '_id', 88 | // let: { merchant: '$pathway.merchant' }, 89 | // pipeline: [ 90 | // { 91 | // $match: { 92 | // $expr: { $eq: ['$_id', '$$merchant'] }, 93 | // }, 94 | // }, 95 | // { $project: { name: 1 } }, 96 | // ], 97 | as: 'pathway.merchant', 98 | }, 99 | }, 100 | { 101 | $unwind: { 102 | path: '$pathway.merchant', 103 | preserveNullAndEmptyArrays: true, 104 | }, 105 | }, 106 | { 107 | $lookup: { 108 | from: 'users', 109 | localField: 'pathway.operator', 110 | foreignField: '_id', 111 | // let: { operator: '$pathway.operator' }, 112 | // pipeline: [ 113 | // { 114 | // $match: { 115 | // $expr: { $eq: ['$_id', '$$operator'] }, 116 | // }, 117 | // }, 118 | // { $project: { name: 1, phone: 1 } }, 119 | // ], 120 | as: 'pathway.operator', 121 | }, 122 | }, 123 | { 124 | $unwind: { 125 | path: '$pathway.operator', 126 | preserveNullAndEmptyArrays: true, 127 | }, 128 | }, 129 | ]; 130 | if (specialCond) { 131 | params.push({ 132 | $group: { 133 | _id: '$_id', 134 | car: { $first: '$car' }, 135 | createdAt: { $first: '$createdAt' }, 136 | market: { $first: '$market' }, 137 | outAt: { $first: '$outAt' }, 138 | updatedAt: { $first: '$updatedAt' }, 139 | pathway: { 140 | $addToSet: { 141 | $cond: [{ $ne: ['$pathway', {}] }, '$pathway', null], 142 | }, 143 | }, 144 | }, 145 | }); 146 | } 147 | const aggregate = model.aggregate(params); 148 | return await model.aggregatePaginate(aggregate, { 149 | allowDiskUse: true, 150 | limit, 151 | // page, 152 | lean: true, 153 | sort, 154 | }); 155 | } 156 | 157 | /** 158 | * 创建修改 159 | * 160 | * @param {*} argument 161 | * @returns 162 | * @memberof CarService 163 | */ 164 | async update(...argument) { 165 | const { model } = this; 166 | return await model.findOneAndUpdate(...argument).lean(); 167 | } 168 | 169 | /** 170 | * 删除车辆 171 | * 172 | * @param {*} ids 173 | * @returns 174 | * @memberof CarService 175 | */ 176 | async delete(ids) { 177 | const { model } = this; 178 | return await model.remove({ 179 | _id: { $in: ids }, 180 | }); 181 | } 182 | } 183 | 184 | module.exports = CarService; 185 | -------------------------------------------------------------------------------- /app/service/test2.html: -------------------------------------------------------------------------------- 1 | 免费时长 > 起步时长 直接 为 0 2 | 3 |

停车时长:

4 |

免费时长:

5 |

起步时长:

6 |

起步金额:

7 |

计费周期:

8 |

周期金额:

9 |

封顶金额:

10 |

11 | 12 | 157 | -------------------------------------------------------------------------------- /app/view/download.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 车辆放行系统 14 | 64 | 65 | 66 |
67 |
68 | 110 |
111 |
112 |
113 |

122 | 智能车辆放行系统,打造智慧市场生态。 123 |

124 |
125 |
126 |
127 | 131 |
132 |
136 |
139 | upload 143 |

151 | 扫码下载APP 152 |

153 |
154 | 157 |
158 |
159 |
160 |

164 | 10大功能,保障市场商户利益,杜绝财产损失! 165 |

166 |
167 |
168 | 178 |
179 | 180 | 190 | 191 | -------------------------------------------------------------------------------- /app/public/assets/downloadhtml/bootstrap-4.4.1-dist/css/bootstrap-reboot.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.4.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2019 The Bootstrap Authors 4 | * Copyright 2011-2019 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | } 13 | 14 | html { 15 | font-family: sans-serif; 16 | line-height: 1.15; 17 | -webkit-text-size-adjust: 100%; 18 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 19 | } 20 | 21 | article, aside, figcaption, figure, footer, header, hgroup, main, nav, section { 22 | display: block; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 28 | font-size: 1rem; 29 | font-weight: 400; 30 | line-height: 1.5; 31 | color: #212529; 32 | text-align: left; 33 | background-color: #fff; 34 | } 35 | 36 | [tabindex="-1"]:focus:not(:focus-visible) { 37 | outline: 0 !important; 38 | } 39 | 40 | hr { 41 | box-sizing: content-box; 42 | height: 0; 43 | overflow: visible; 44 | } 45 | 46 | h1, h2, h3, h4, h5, h6 { 47 | margin-top: 0; 48 | margin-bottom: 0.5rem; 49 | } 50 | 51 | p { 52 | margin-top: 0; 53 | margin-bottom: 1rem; 54 | } 55 | 56 | abbr[title], 57 | abbr[data-original-title] { 58 | text-decoration: underline; 59 | -webkit-text-decoration: underline dotted; 60 | text-decoration: underline dotted; 61 | cursor: help; 62 | border-bottom: 0; 63 | -webkit-text-decoration-skip-ink: none; 64 | text-decoration-skip-ink: none; 65 | } 66 | 67 | address { 68 | margin-bottom: 1rem; 69 | font-style: normal; 70 | line-height: inherit; 71 | } 72 | 73 | ol, 74 | ul, 75 | dl { 76 | margin-top: 0; 77 | margin-bottom: 1rem; 78 | } 79 | 80 | ol ol, 81 | ul ul, 82 | ol ul, 83 | ul ol { 84 | margin-bottom: 0; 85 | } 86 | 87 | dt { 88 | font-weight: 700; 89 | } 90 | 91 | dd { 92 | margin-bottom: .5rem; 93 | margin-left: 0; 94 | } 95 | 96 | blockquote { 97 | margin: 0 0 1rem; 98 | } 99 | 100 | b, 101 | strong { 102 | font-weight: bolder; 103 | } 104 | 105 | small { 106 | font-size: 80%; 107 | } 108 | 109 | sub, 110 | sup { 111 | position: relative; 112 | font-size: 75%; 113 | line-height: 0; 114 | vertical-align: baseline; 115 | } 116 | 117 | sub { 118 | bottom: -.25em; 119 | } 120 | 121 | sup { 122 | top: -.5em; 123 | } 124 | 125 | a { 126 | color: #007bff; 127 | text-decoration: none; 128 | background-color: transparent; 129 | } 130 | 131 | a:hover { 132 | color: #0056b3; 133 | text-decoration: underline; 134 | } 135 | 136 | a:not([href]) { 137 | color: inherit; 138 | text-decoration: none; 139 | } 140 | 141 | a:not([href]):hover { 142 | color: inherit; 143 | text-decoration: none; 144 | } 145 | 146 | pre, 147 | code, 148 | kbd, 149 | samp { 150 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 151 | font-size: 1em; 152 | } 153 | 154 | pre { 155 | margin-top: 0; 156 | margin-bottom: 1rem; 157 | overflow: auto; 158 | } 159 | 160 | figure { 161 | margin: 0 0 1rem; 162 | } 163 | 164 | img { 165 | vertical-align: middle; 166 | border-style: none; 167 | } 168 | 169 | svg { 170 | overflow: hidden; 171 | vertical-align: middle; 172 | } 173 | 174 | table { 175 | border-collapse: collapse; 176 | } 177 | 178 | caption { 179 | padding-top: 0.75rem; 180 | padding-bottom: 0.75rem; 181 | color: #6c757d; 182 | text-align: left; 183 | caption-side: bottom; 184 | } 185 | 186 | th { 187 | text-align: inherit; 188 | } 189 | 190 | label { 191 | display: inline-block; 192 | margin-bottom: 0.5rem; 193 | } 194 | 195 | button { 196 | border-radius: 0; 197 | } 198 | 199 | button:focus { 200 | outline: 1px dotted; 201 | outline: 5px auto -webkit-focus-ring-color; 202 | } 203 | 204 | input, 205 | button, 206 | select, 207 | optgroup, 208 | textarea { 209 | margin: 0; 210 | font-family: inherit; 211 | font-size: inherit; 212 | line-height: inherit; 213 | } 214 | 215 | button, 216 | input { 217 | overflow: visible; 218 | } 219 | 220 | button, 221 | select { 222 | text-transform: none; 223 | } 224 | 225 | select { 226 | word-wrap: normal; 227 | } 228 | 229 | button, 230 | [type="button"], 231 | [type="reset"], 232 | [type="submit"] { 233 | -webkit-appearance: button; 234 | } 235 | 236 | button:not(:disabled), 237 | [type="button"]:not(:disabled), 238 | [type="reset"]:not(:disabled), 239 | [type="submit"]:not(:disabled) { 240 | cursor: pointer; 241 | } 242 | 243 | button::-moz-focus-inner, 244 | [type="button"]::-moz-focus-inner, 245 | [type="reset"]::-moz-focus-inner, 246 | [type="submit"]::-moz-focus-inner { 247 | padding: 0; 248 | border-style: none; 249 | } 250 | 251 | input[type="radio"], 252 | input[type="checkbox"] { 253 | box-sizing: border-box; 254 | padding: 0; 255 | } 256 | 257 | input[type="date"], 258 | input[type="time"], 259 | input[type="datetime-local"], 260 | input[type="month"] { 261 | -webkit-appearance: listbox; 262 | } 263 | 264 | textarea { 265 | overflow: auto; 266 | resize: vertical; 267 | } 268 | 269 | fieldset { 270 | min-width: 0; 271 | padding: 0; 272 | margin: 0; 273 | border: 0; 274 | } 275 | 276 | legend { 277 | display: block; 278 | width: 100%; 279 | max-width: 100%; 280 | padding: 0; 281 | margin-bottom: .5rem; 282 | font-size: 1.5rem; 283 | line-height: inherit; 284 | color: inherit; 285 | white-space: normal; 286 | } 287 | 288 | progress { 289 | vertical-align: baseline; 290 | } 291 | 292 | [type="number"]::-webkit-inner-spin-button, 293 | [type="number"]::-webkit-outer-spin-button { 294 | height: auto; 295 | } 296 | 297 | [type="search"] { 298 | outline-offset: -2px; 299 | -webkit-appearance: none; 300 | } 301 | 302 | [type="search"]::-webkit-search-decoration { 303 | -webkit-appearance: none; 304 | } 305 | 306 | ::-webkit-file-upload-button { 307 | font: inherit; 308 | -webkit-appearance: button; 309 | } 310 | 311 | output { 312 | display: inline-block; 313 | } 314 | 315 | summary { 316 | display: list-item; 317 | cursor: pointer; 318 | } 319 | 320 | template { 321 | display: none; 322 | } 323 | 324 | [hidden] { 325 | display: none !important; 326 | } 327 | /*# sourceMappingURL=bootstrap-reboot.css.map */ -------------------------------------------------------------------------------- /app/controller/car.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Joi = require('@hapi/joi'); 3 | const Controller = require('egg').Controller; 4 | class CarController extends Controller { 5 | constructor(ctx) { 6 | super(ctx); 7 | // 商户操作车辆 装/卸车、放行 8 | this.carInOutUpdateSchema = Joi.object({ 9 | carInOutId: Joi.string().required(), 10 | operation: Joi.string().valid('装车', '卸车').required(), 11 | status: Joi.string().valid('进行中', '放行').default('进行中'), 12 | // 手机号 13 | phone: Joi.string() 14 | .regex( 15 | /^[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}$/ 16 | ) 17 | .allow(''), 18 | }); 19 | // 删除车辆 20 | this.deleteSchema = Joi.object({ 21 | // 车辆ID 22 | cars: Joi.array().items(Joi.string().required()).required(), 23 | }); 24 | // 手动录入入场记录 25 | this.manualSchema = Joi.object({ 26 | car: Joi.object({ 27 | license: Joi.string() 28 | .regex( 29 | /^(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}(?:(?:[0-9]{5}[DF])|(?:[DF](?:[A-HJ-NP-Z0-9])[0-9]{4})))|(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9 挂学警港澳]{1})$/ 30 | ) 31 | .required(), 32 | type: Joi.string().valid('非三轮车').required(), 33 | info: Joi.any(), 34 | }).required(), 35 | }); 36 | } 37 | 38 | // 车辆出入场,装卸货列表 39 | async index() { 40 | const { ctx, service } = this; 41 | // 参数验证 42 | const payload = ctx.helper.validate(ctx.schema.Query, ctx.request.body); 43 | // 获取当前登录用户的数据 44 | const store = await ctx.helper.getStore({ 45 | field: ['user', 'market', 'merchant'], 46 | }); 47 | // 获取用户身份 48 | const identity = store.user.identity; 49 | let params = { 50 | ...payload, 51 | search: { 52 | ...payload.search, 53 | market: [store.market._id], 54 | }, 55 | }, 56 | specialCond = false; 57 | // 装卸货列表 58 | if (ctx.url.includes('loadAndUnload')) { 59 | params.search['pathway'] = [{ $ne: null }]; 60 | if (identity['商户责任人'] || identity['商户员工']) { 61 | params.search['merchant'] = [store.merchant._id]; 62 | params.search['operator'] = [store.user._id]; 63 | } 64 | } else { 65 | // 出入场列表 将所有pathway 展开 66 | specialCond = true; 67 | } 68 | const res = await service.car.index(params, specialCond); 69 | if ( 70 | specialCond && 71 | res.docs.length && 72 | (identity['商户责任人'] || identity['商户员工']) 73 | ) { 74 | res.docs[0].pathway.map((item) => { 75 | if ( 76 | item && 77 | !item.operator._id.equals(store.user._id) && 78 | item.merchant._id.equals(store.merchant._id) && 79 | item.status != '放行' 80 | ) { 81 | ctx.throw( 82 | 403, 83 | `此车辆已锁定,${ 84 | item.operator.name || item.operator.phone 85 | }正在操作当前车辆` 86 | ); 87 | } 88 | }); 89 | } 90 | return ctx.helper 91 | .success({ ctx, res }) 92 | .logger(store, '查询车辆出入场,装卸货列表'); 93 | } 94 | 95 | // 商户操作车辆 装/卸车、放行 96 | async operation() { 97 | const { ctx, service } = this; 98 | // 参数验证 99 | const payload = ctx.helper.validate( 100 | this.carInOutUpdateSchema, 101 | ctx.request.body 102 | ); 103 | // 获取当前登录用户的数据 104 | const store = await ctx.helper.getStore({ 105 | field: ['user', 'market', 'merchant'], 106 | }); 107 | // 获取用户身份 108 | const identity = store.user.identity; 109 | // 判断当前用户是否为市场责任人 110 | ctx.assert( 111 | identity['商户责任人'] || identity['商户员工'], 112 | 403, 113 | `当前账号,无权操作!` 114 | ); 115 | // 删除车辆在redis中的收费订单 116 | const ParkPayingArr = await ctx.app.redis.keys('ParkPaying:*'); 117 | for (let i = 0, len = ParkPayingArr.length; i < len; i++) { 118 | const carId = JSON.parse(await ctx.app.redis.get(ParkPayingArr[i])).car 119 | ._id; 120 | if (carId == payload.carInOutId) { 121 | await ctx.app.redis.del(ParkPayingArr[i]); 122 | } 123 | } 124 | // 修改车辆入场记录 125 | const setStatus = await service.car.update( 126 | { _id: payload.carInOutId, 'pathway.operator': store.user._id }, 127 | { 128 | $set: { 129 | phone: payload.phone, 130 | 'pathway.$.status': payload.status, 131 | }, 132 | }, 133 | { 134 | new: true, 135 | setDefaultsOnInsert: true, 136 | } 137 | ); 138 | if (payload.status == '放行' && setStatus) { 139 | // 增加积分 140 | await service.merchant.update( 141 | { 142 | market: store.market._id, 143 | _id: store.merchant._id, 144 | }, 145 | { 146 | $inc: { 147 | integral: 1, 148 | }, 149 | } 150 | ); 151 | ctx.helper 152 | .success({ ctx, res: setStatus }) 153 | .logger(store, '修改车辆入场装卸货放行记录'); 154 | } else { 155 | // 新车入场 156 | const res = await service.car.update( 157 | { 158 | _id: payload.carInOutId, 159 | 'pathway.operator': { $ne: store.user._id }, 160 | }, 161 | { 162 | $push: { 163 | pathway: { 164 | $each: [ 165 | { 166 | merchant: store.merchant._id, 167 | operator: store.user._id, 168 | operation: payload.operation, 169 | status: payload.status, 170 | }, 171 | ], 172 | $position: 0, 173 | }, 174 | }, 175 | }, 176 | { 177 | new: true, 178 | setDefaultsOnInsert: true, 179 | } 180 | ); 181 | ctx.helper.success({ ctx, res }).logger(store, '新车入场记录'); 182 | } 183 | } 184 | 185 | // 删除车辆 186 | async delete() { 187 | const { ctx, service } = this; 188 | // 参数验证 189 | const payload = ctx.helper.validate(this.deleteSchema, ctx.request.body); 190 | // 获取当前登录用户的数据 191 | const store = await ctx.helper.getStore({ 192 | field: ['user', 'market', 'merchant'], 193 | }); 194 | // 获取当前用户身份 195 | const identity = store.user.identity; 196 | ctx.assert( 197 | identity['市场责任人'] || identity['市场员工'] || identity['市场保安'], 198 | 403, 199 | `当前账号,无权操作!` 200 | ); 201 | // 删除车辆 202 | const res = await service.car.delete(payload.cars); 203 | ctx.helper.success({ ctx, res }).logger(store, '删除车辆'); 204 | } 205 | 206 | // 手动录入车牌 207 | async manual() { 208 | const { ctx, service } = this; 209 | // 参数验证 210 | const payload = ctx.helper.validate(this.manualSchema, ctx.request.body); 211 | // 获取当前登录用户的数据 212 | const store = await ctx.helper.getStore({ 213 | field: ['user', 'market'], 214 | }); 215 | // 获取当前用户身份 216 | const identity = store.user.identity; 217 | ctx.assert( 218 | identity['市场责任人'] || identity['市场员工'] || identity['市场保安'], 219 | 403, 220 | `当前账号,无权操作!` 221 | ); 222 | // 创建车辆出入场记录 223 | const res = await service.car.update( 224 | { _id: ctx.app.mongoose.Types.ObjectId() }, 225 | { 226 | market: store.market._id, 227 | ...payload, 228 | }, 229 | { 230 | new: true, 231 | upsert: true, 232 | setDefaultsOnInsert: true, 233 | } 234 | ); 235 | return ctx.helper.success({ ctx, res }).logger(store, '创建车辆出入场记录'); 236 | } 237 | } 238 | 239 | module.exports = CarController; 240 | -------------------------------------------------------------------------------- /app/controller/ipCamera.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { isEmpty } = require('lodash'); 3 | const Controller = require('egg').Controller; 4 | const PlateType = new Map([ 5 | [0, '未知'], 6 | [1, '普通蓝牌'], 7 | [2, '普通黑牌'], 8 | [3, '普通黄牌'], 9 | [4, '双层黄牌'], 10 | [5, '警察车牌'], 11 | [6, '武警车牌'], 12 | [7, '双层武警'], 13 | [8, '单层军牌'], 14 | [9, '双层军牌'], 15 | [10, '个性车牌'], 16 | [11, '新能源小车牌'], 17 | [12, '新能源大车牌'], 18 | [13, '大使馆车牌'], 19 | [14, '领事馆车牌'], 20 | [15, '民航车牌'], 21 | [16, '应急车牌'], 22 | ]); 23 | class IpCameraController extends Controller { 24 | constructor(ctx) { 25 | super(ctx); 26 | } 27 | 28 | // 车牌识别结果交互 29 | async alarmInfoPlate() { 30 | const { ctx, app, service } = this; 31 | const { AlarmInfoPlate } = ctx.request.body; 32 | const license = AlarmInfoPlate.result.PlateResult.license; 33 | app.io.of('/ipCamera').emit('alarmInfoPlate', AlarmInfoPlate); 34 | const market = ctx.app.mongoose.Types.ObjectId('5eba3a8c835b0e003e8d7456'); 35 | // 当前时间 36 | const nowDate = Date.now(); 37 | // 军警车判断 38 | const govCarJugde = () => { 39 | try { 40 | return ( 41 | PlateType.get(AlarmInfoPlate.result.PlateResult.type).search( 42 | /警|军|使|领|应急/ 43 | ) != -1 44 | ); 45 | } catch (error) { 46 | return false; 47 | } 48 | }; 49 | // 月租车判断 50 | const vipCarJugde = async () => { 51 | const params = { 52 | search: { 53 | car: [ 54 | { 55 | $elemMatch: { 56 | num: license, 57 | $or: [ 58 | { 59 | expired: { $gte: new Date() }, 60 | }, 61 | { expired: { $eq: '' } }, 62 | { expired: { $eq: null } }, 63 | ], 64 | }, 65 | }, 66 | ], 67 | }, 68 | }; 69 | // 如果是市场月租车 70 | const marketCar = await service.market.index({ 71 | search: { 72 | ...params.search, 73 | _id: [market], 74 | }, 75 | }); 76 | // 如果是商户月租车 77 | const merchantCar = await service.merchant.index({ 78 | search: { 79 | ...params.search, 80 | market: [market], 81 | }, 82 | }); 83 | 84 | const vipArr = [...marketCar.docs, ...merchantCar.docs]; 85 | if (vipArr.length) { 86 | for (let i = 0, len = vipArr.length; i < len; i++) { 87 | const car = vipArr[i].car.find((item) => item.num == license); 88 | if (car) { 89 | return car; 90 | } 91 | } 92 | } 93 | return false; 94 | }; 95 | // 创建车辆入场记录 96 | const inLog = async () => { 97 | // 创建车辆出入场记录 98 | return await service.car.update( 99 | { _id: ctx.app.mongoose.Types.ObjectId() }, 100 | { 101 | market, 102 | car: { 103 | license, 104 | type: '非三轮车', 105 | info: AlarmInfoPlate, 106 | }, 107 | }, 108 | { 109 | new: true, 110 | upsert: true, 111 | setDefaultsOnInsert: true, 112 | } 113 | ); 114 | }; 115 | // 创建车辆出场记录 116 | const outLog = async (query) => { 117 | return await service.car.update( 118 | { 119 | ...query, 120 | market, 121 | outAt: null, 122 | }, 123 | { 124 | $set: { 125 | outAt: new Date(), 126 | }, 127 | } 128 | ); 129 | }; 130 | let redisParams = { 131 | status: 'no', 132 | voice: `一路平安`, 133 | text: `祝您一路平安`, 134 | }; 135 | // // 不是小票机摄像头序列号 (2020年5月27日 说的三轮车道同时要过小车,所以取消掉了判断) 136 | // if ( 137 | // !['23d022da94b8469b', 'd0c72e4742703228'].includes( 138 | // AlarmInfoPlate.serialno 139 | // ) 140 | // ) { 141 | 142 | // } 143 | 144 | // 如果是入场 145 | if (AlarmInfoPlate.deviceName.includes('IN')) { 146 | // 特殊牌照直接放行 147 | if (govCarJugde()) { 148 | redisParams.status = 'ok'; 149 | redisParams.text = redisParams.voice = `军警车免费通行`; 150 | // 创建车辆出入场记录 151 | await inLog(); 152 | } else { 153 | if ( 154 | /^(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}(?:(?:[0-9]{5}[DF])|(?:[DF](?:[A-HJ-NP-Z0-9])[0-9]{4})))|(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9 挂学警港澳]{1})$/.test( 155 | license 156 | ) 157 | ) { 158 | redisParams.status = 'ok'; 159 | redisParams.text = redisParams.voice = `临时车,欢迎光临`; 160 | const _vipCarJugde = await vipCarJugde(); 161 | if (_vipCarJugde) { 162 | // 格式化时间 163 | const _preciseDiff = ctx.helper.preciseDiff( 164 | _vipCarJugde.expired, 165 | nowDate 166 | ); 167 | redisParams.text = redisParams.voice = `月租车,欢迎光临`; 168 | if ( 169 | !_preciseDiff.years && 170 | !_preciseDiff.months && 171 | _preciseDiff.days <= 10 172 | ) { 173 | redisParams.text = redisParams.voice = `月租车剩余${_preciseDiff.days}天${_preciseDiff.hours}小时${_preciseDiff.minutes}分钟`; 174 | } 175 | } 176 | // 创建车辆出入场记录 177 | await inLog(); 178 | } else { 179 | redisParams.status = 'no'; 180 | redisParams.text = redisParams.voice = `车辆匹配异常,禁止通行`; 181 | } 182 | } 183 | } 184 | // 如果是出场 185 | if (AlarmInfoPlate.deviceName.includes('OUT')) { 186 | // 特殊牌照直接放行 187 | if (govCarJugde()) { 188 | redisParams.status = 'ok'; 189 | redisParams.text = redisParams.voice = `军警车免费通行`; 190 | // 保存出场时间 191 | await outLog({ 'car.license': license }); 192 | } else { 193 | // 判断车牌号是否正确 194 | const hasCar = await service.car.index({ 195 | search: { 196 | carNo: [license], 197 | market: [market], 198 | outAt: [null], 199 | }, 200 | }); 201 | if (!hasCar.totalDocs) { 202 | redisParams.text = `无入场记录,车辆禁止出场`; 203 | redisParams.voice = `无入场记录,车辆禁止出场`; 204 | } else { 205 | // if (hasCar.docs.every((item) => isEmpty(item.pathway))) { 206 | // redisParams.text = `该车未经过任何商户`; 207 | // redisParams.voice = '车辆禁止出场'; 208 | // } 209 | // 判断能否出去 210 | const carCanOut = hasCar.docs.filter( 211 | (item) => item.pathway.status == '进行中' 212 | ); 213 | // 不允许通行 214 | if (carCanOut.length) { 215 | redisParams.text = `请通知${carCanOut[0].pathway.merchant.name}放行`; 216 | redisParams.voice = `车辆禁止出场,请等待人工放行`; 217 | } else { 218 | const _vipCarJugde = await vipCarJugde(); 219 | if (_vipCarJugde) { 220 | // 格式化时间 221 | const _preciseDiff = ctx.helper.preciseDiff( 222 | _vipCarJugde.expired, 223 | nowDate 224 | ); 225 | redisParams.text = redisParams.voice = `月租车,祝您一路平安`; 226 | if ( 227 | !_preciseDiff.years && 228 | !_preciseDiff.months && 229 | _preciseDiff.days <= 10 230 | ) { 231 | redisParams.text = redisParams.voice = `月租车剩余${_preciseDiff.days}天${_preciseDiff.hours}小时${_preciseDiff.minutes}分钟`; 232 | } 233 | redisParams.status = 'ok'; 234 | // 保存出场时间 235 | await outLog({ _id: hasCar.docs[0]._id }); 236 | } else { 237 | const carType = 238 | PlateType.get(AlarmInfoPlate.result.PlateResult.type).search( 239 | /蓝牌|小车/ 240 | ) != -1 241 | ? '小车' 242 | : '大车'; 243 | // 停车缴费计算 244 | redisParams = await service.parkingCosts.calc({ 245 | serialno: AlarmInfoPlate.serialno, 246 | hasCar: hasCar.docs[0], 247 | carType, 248 | }); 249 | if (redisParams.status == 'ok') { 250 | // 保存出场时间 251 | await outLog({ _id: hasCar.docs[0]._id }); 252 | // 删除订单数据 253 | await app.redis.del(`ParkPaying:${AlarmInfoPlate.serialno}`); 254 | } 255 | } 256 | } 257 | } 258 | } 259 | } 260 | 261 | const serialText = await ctx.helper 262 | .generateSerialData(`${license},${redisParams.text}`) 263 | .text(); 264 | const serialVoice = await ctx.helper 265 | .generateSerialData(`${license},${redisParams.voice}`) 266 | .voice(); 267 | const res = { 268 | Response_AlarmInfoPlate: { 269 | info: redisParams.status, 270 | content: 'retransfer_stop', 271 | is_pay: 'true', 272 | serialData: [ 273 | { 274 | serialChannel: 0, 275 | data: serialText.toString('base64'), 276 | dataLen: serialText.length, 277 | }, 278 | { 279 | serialChannel: 0, 280 | data: serialVoice.toString('base64'), 281 | dataLen: serialVoice.length, 282 | }, 283 | ], 284 | }, 285 | }; 286 | ctx.body = res; 287 | ctx.status = 200; 288 | ctx.logger.info('alarmInfoPlate', { 289 | ...redisParams, 290 | license, 291 | serialText, 292 | serialVoice, 293 | reqBody: ctx.request.body, 294 | }); 295 | } 296 | 297 | // 心跳交互数据0064FFFF300B01C7EBBDBBB7D13130D4AA431E 298 | async heartbeat() { 299 | const { ctx, app } = this; 300 | const { heartbeat } = ctx.request.body; 301 | const redisKey = `IpCameraHeartbeat:${heartbeat.serialno}`; 302 | // 向socket客户端下发本次心跳 303 | app.io.of('/ipCamera').emit('heartbeat', heartbeat); 304 | const hb = await app.redis.get(redisKey); 305 | const hbJson = hb ? JSON.parse(hb) : {}; 306 | const serialText = await ctx.helper.generateSerialData(hbJson.text).text(); 307 | const serialVoice = await ctx.helper 308 | .generateSerialData(hbJson.voice) 309 | .voice(); 310 | const res = { 311 | Response_Heartbeat: { 312 | info: hbJson.status || 'no', 313 | serialData: [ 314 | { 315 | serialChannel: 0, 316 | data: hbJson.text && serialText.toString('base64'), 317 | dataLen: serialText.length, 318 | }, 319 | { 320 | serialChannel: 0, 321 | data: hbJson.voice && serialVoice.toString('base64'), 322 | dataLen: serialVoice.length, 323 | }, 324 | ], 325 | // snapnow: 'yes', // 抓拍 326 | }, 327 | }; 328 | // 将心跳存放到redis 329 | await app.redis.set(redisKey, JSON.stringify({ status: 'no' }), 'EX', 30); 330 | ctx.body = res; 331 | ctx.status = 200; 332 | ctx.logger.info('heartbeat', { 333 | ...hbJson, 334 | serialText, 335 | serialVoice, 336 | reqBody: ctx.request.body, 337 | }); 338 | } 339 | } 340 | 341 | module.exports = IpCameraController; 342 | -------------------------------------------------------------------------------- /app/controller/market.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { v4: uuidv4 } = require('uuid'); 3 | const moment = require('moment'); 4 | const Joi = require('@hapi/joi'); 5 | const Controller = require('egg').Controller; 6 | 7 | class MarketController extends Controller { 8 | constructor(ctx) { 9 | super(ctx); 10 | this.model = ctx.model.Market; 11 | this.schema = ctx.schema.Market; 12 | // 创建市场主体责任人schema 13 | this.updateAdminSchema = Joi.object({ 14 | // 市场ID 15 | market: Joi.string().required(), 16 | // 账号 17 | account: Joi.string().required(), 18 | // 密码 19 | password: Joi.string().required(), 20 | // 确认密码 21 | confirmPassword: Joi.ref('password'), 22 | }).with('password', 'confirmPassword'); 23 | // 修改或新增市场员工schema 24 | this.updateStaffSchema = Joi.object({ 25 | // 账号 26 | account: Joi.string().required(), 27 | // 密码 28 | password: Joi.string().required(), 29 | // 角色 30 | role: Joi.string().valid('普通员工', '普通保安').required(), 31 | }); 32 | // 刪除市场员工 33 | this.deleteStaffSchema = Joi.object({ 34 | // 员工ID 35 | user: Joi.string().required(), 36 | }); 37 | // 新增车辆 38 | this.updateCarSchema = Joi.object({ 39 | // 车牌号 40 | num: Joi.string() 41 | .regex( 42 | /^(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}(?:(?:[0-9]{5}[DF])|(?:[DF](?:[A-HJ-NP-Z0-9])[0-9]{4})))|(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9 挂学警港澳]{1})$/ 43 | ) 44 | .required(), 45 | // 车辆过期时间 46 | expired: Joi.date().allow(''), 47 | }); 48 | } 49 | 50 | // 查询市场信息 51 | async index() { 52 | const { ctx, service } = this; 53 | // 参数验证 54 | const payload = ctx.helper.validate(ctx.schema.Query, ctx.request.body); 55 | // 本地查询 56 | if (ctx.request.host == 'localhost:7002') { 57 | const res = await service.market.index(payload); 58 | return ctx.helper.success({ ctx, res }); 59 | } 60 | // 获取当前登录用户的数据 61 | const store = await ctx.helper.getStore({ 62 | field: ['user', 'market', 'merchant'], 63 | }); 64 | // 市场负责人查询 65 | const identity = store.user.identity; 66 | ctx.assert( 67 | identity['市场责任人'] || identity['市场员工'] || identity['市场保安'], 68 | 403, 69 | '当前账号无权操作' 70 | ); 71 | const res = await service.market.index({ 72 | search: { 73 | _id: [store.market._id], 74 | }, 75 | }); 76 | ctx.helper.success({ ctx, res }).logger(store, '查询市场信息'); 77 | } 78 | 79 | // 创建市场 80 | async update() { 81 | const { ctx, service } = this; 82 | // 该接口仅允许本地调用 83 | ctx.assert( 84 | ctx.request.host == 'localhost:7002', 85 | 403, 86 | `非法操作,IP:${ctx.ip}` 87 | ); 88 | // 参数验证 89 | const payload = ctx.helper.validate(this.schema, { 90 | expired: moment().add(1, 'years').format(), 91 | ...ctx.request.body, 92 | }); 93 | // 创建修改市场 94 | const res = await service.market.update( 95 | { 96 | $or: [{ name: payload.name }, { _id: payload._id }], 97 | }, 98 | payload, 99 | { 100 | new: true, 101 | upsert: true, 102 | setDefaultsOnInsert: true, 103 | } 104 | ); 105 | ctx.helper.success({ ctx, res }); 106 | } 107 | 108 | // 创建市场主体责任人 109 | async updateAdmin() { 110 | const { ctx, service } = this; 111 | // 该接口仅允许本地调用 112 | ctx.assert( 113 | ctx.request.host == 'localhost:7002', 114 | 403, 115 | `非法操作,IP:${ctx.ip}` 116 | ); 117 | // 参数验证 118 | const payload = ctx.helper.validate( 119 | this.updateAdminSchema, 120 | ctx.request.body 121 | ); 122 | // 密码加密 123 | payload.password = await ctx.genHash(payload.password); 124 | // 创建用户 125 | const user = await service.user.update( 126 | { account: payload.account }, 127 | payload, 128 | { 129 | new: true, 130 | upsert: true, 131 | setDefaultsOnInsert: true, 132 | } 133 | ); 134 | // 向市场添加责任人 135 | const res = await service.market.update( 136 | { _id: payload.market }, 137 | { 138 | $set: { 139 | user: user._id, 140 | }, 141 | }, 142 | { new: true } 143 | ); 144 | ctx.helper.success({ ctx, res }); 145 | } 146 | 147 | // 修改员工密码或新增市场员工 148 | async updateStaff() { 149 | const { ctx, service } = this; 150 | // 参数验证 151 | const payload = ctx.helper.validate( 152 | this.updateStaffSchema, 153 | ctx.request.body 154 | ); 155 | // 获取当前登录用户的数据 156 | const store = await ctx.helper.getStore({ 157 | field: ['user', 'market', 'merchant'], 158 | }); 159 | // 获取用户身份 160 | const identity = store.user.identity; 161 | // 判断当前用户是否为市场责任人 162 | ctx.assert(identity['市场责任人'], 403, `当前账号,无权操作!`); 163 | // 密码加密 164 | payload.password = await ctx.genHash(payload.password); 165 | payload.market = store.market._id; 166 | // 判断当前市场是否有权限修改用户 167 | const hasUser = await ctx.model.User.findOne({ 168 | market: payload.market, 169 | account: payload.account, 170 | }); 171 | if (hasUser) { 172 | const marketStaff = await ctx.model.Market.findOne({ 173 | _id: payload.market, 174 | 'staff.user': hasUser._id, 175 | }); 176 | ctx.assert(marketStaff, 403, `无权添加该用户,请修改账号后重试`); 177 | } 178 | // 创建用户 179 | const user = await service.user.update( 180 | { account: payload.account, market: payload.market }, 181 | payload, 182 | { 183 | new: true, 184 | upsert: true, 185 | setDefaultsOnInsert: true, 186 | } 187 | ); 188 | // 向市场添加员工 有就修改 没有就新增 189 | const res = 190 | (await service.market.update( 191 | { _id: store.market._id, 'staff.user': user._id }, 192 | { 193 | $set: { 194 | 'staff.$.role': payload.role, 195 | }, 196 | }, 197 | { new: true } 198 | )) || 199 | (await service.market.update( 200 | { _id: store.market._id, 'staff.user': { $ne: user._id } }, 201 | { 202 | $push: { 203 | staff: { 204 | $each: [ 205 | { 206 | user: user._id, 207 | role: payload.role, 208 | }, 209 | ], 210 | $position: 0, 211 | }, 212 | }, 213 | }, 214 | { new: true } 215 | )); 216 | 217 | ctx.helper 218 | .success({ ctx, res }) 219 | .logger(store, '修改员工密码或新增市场员工'); 220 | } 221 | 222 | // 删除员工 223 | async deleteStaff() { 224 | const { ctx, service } = this; 225 | // 参数验证 226 | const payload = ctx.helper.validate( 227 | this.deleteStaffSchema, 228 | ctx.request.body 229 | ); 230 | // 获取当前登录用户的数据 231 | const store = await ctx.helper.getStore({ 232 | field: ['user', 'market', 'merchant'], 233 | }); 234 | // 获取用户身份 235 | const identity = store.user.identity; 236 | // 判断当前用户是否为市场责任人 237 | ctx.assert(identity['市场责任人'], 403, `当前账号,无权操作!`); 238 | // 删除用户 239 | await service.user.delete([payload.user]); 240 | // 删除市场员工 241 | const res = await service.market.update( 242 | { _id: store.market._id }, 243 | { 244 | $pull: { 245 | staff: { 246 | user: payload.user, 247 | }, 248 | }, 249 | }, 250 | { new: true } 251 | ); 252 | ctx.helper.success({ ctx, res }).logger(store, '删除市场员工'); 253 | } 254 | 255 | // 查询市场员工 256 | async indexStaff() { 257 | const { ctx, service } = this; 258 | // 参数验证 259 | const payload = ctx.helper.validate(ctx.schema.Query, ctx.request.body); 260 | // 获取当前登录用户的数据 261 | const store = await ctx.helper.getStore({ 262 | field: ['user', 'market'], 263 | }); 264 | // 市场负责人查询 265 | const identity = store.user.identity; 266 | ctx.assert(identity['市场责任人'], 403, `当前账号,无权操作!`); 267 | // 查询负责人所在的市场,取出员工ID 268 | const staff = ( 269 | await service.market.index({ 270 | search: { _id: [store.market._id] }, 271 | }) 272 | ).docs[0].staff; 273 | let res = []; 274 | if (staff.length) { 275 | // 根据员工ID查询用户 276 | res = await service.user.index({ 277 | ...payload, 278 | search: { 279 | ...payload.search, 280 | _id: staff.map((item) => item.user), 281 | }, 282 | select: { password: 0 }, 283 | }); 284 | // 渲染角色字段 285 | res.docs = res.docs.map((item) => { 286 | item.role = staff.find((i) => i.user.equals(item._id)).role; 287 | return item; 288 | }); 289 | } 290 | return ctx.helper 291 | .success({ ctx, res }) 292 | .logger(store, '市场负责人查询市场员工'); 293 | } 294 | 295 | // 绑定车辆 296 | async updateCar() { 297 | const { ctx, service } = this; 298 | // 参数验证 299 | const payload = ctx.helper.validate(this.updateCarSchema, ctx.request.body); 300 | // 获取当前登录用户的数据 301 | const store = await ctx.helper.getStore({ 302 | field: ['user', 'market', 'merchant'], 303 | }); 304 | // 获取用户身份 305 | const identity = store.user.identity; 306 | // 判断当前用户身份 307 | ctx.assert( 308 | identity['市场责任人'] || identity['市场员工'], 309 | 403, 310 | `当前账号,无权操作!` 311 | ); 312 | // 判断车辆是否已经被绑定 313 | const judgeCar = async (num) => { 314 | const merchantCar = await service.merchant.index({ 315 | search: { 316 | 'car.num': [num], 317 | market: [store.market._id], 318 | }, 319 | }); 320 | const marketCar = await service.market.index({ 321 | search: { 322 | 'car.num': [num], 323 | _id: [store.market._id], 324 | }, 325 | }); 326 | ctx.assert( 327 | !merchantCar.totalDocs && !marketCar.totalDocs, 328 | 403, 329 | `该车辆已被绑定` 330 | ); 331 | }; 332 | // 向市场添加车辆 333 | const res = 334 | (await service.market.update( 335 | // 修改 336 | { _id: store.market._id, 'car.num': payload.num }, 337 | { 338 | $set: { 339 | 'car.$.num': payload.num, 340 | 'car.$.expired': payload.expired, 341 | }, 342 | }, 343 | { new: true } 344 | )) || 345 | (await (async () => { 346 | // 新增 347 | await judgeCar(payload.num); 348 | return await service.market.update( 349 | { _id: store.market._id, 'car.num': { $ne: payload.num } }, 350 | { 351 | $push: { 352 | car: { 353 | $each: [ 354 | { 355 | num: payload.num, 356 | expired: payload.expired, 357 | }, 358 | ], 359 | $position: 0, 360 | }, 361 | }, 362 | }, 363 | { new: true } 364 | ); 365 | })()); 366 | 367 | ctx.helper.success({ ctx, res }).logger(store, '绑定市场车辆'); 368 | } 369 | 370 | // 删除车辆 371 | async deleteCar() { 372 | const { ctx, service } = this; 373 | // 参数验证 374 | const payload = ctx.helper.validate(this.updateCarSchema, ctx.request.body); 375 | // 获取当前登录用户的数据 376 | const store = await ctx.helper.getStore({ 377 | field: ['user', 'market', 'merchant'], 378 | }); 379 | // 获取用户身份 380 | const identity = store.user.identity; 381 | // 判断当前用户身份 382 | ctx.assert( 383 | identity['市场责任人'] || identity['市场员工'], 384 | 403, 385 | `当前账号,无权操作!` 386 | ); 387 | // 删除市场车辆 388 | const res = await service.market.update( 389 | { _id: store.market._id }, 390 | { 391 | $pull: { 392 | car: { 393 | num: payload.num, 394 | }, 395 | }, 396 | }, 397 | { new: true } 398 | ); 399 | ctx.helper.success({ ctx, res }).logger(store, '删除市场车辆'); 400 | } 401 | } 402 | 403 | module.exports = MarketController; 404 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /app/extend/helper.js: -------------------------------------------------------------------------------- 1 | // 工具函数 2 | const { 3 | random, 4 | isEmpty, 5 | isArray, 6 | omit, 7 | mapKeys, 8 | mapValues, 9 | isString, 10 | drop, 11 | omitBy, 12 | } = require('lodash'); 13 | const path = require('path'); 14 | const fs = require('fs-extra'); 15 | const sharp = require('sharp'); 16 | const iconv = require('iconv-lite'); 17 | const moment = require('moment'); 18 | require('moment-precise-range-plugin'); 19 | const { crc16modbus } = require('crc'); 20 | module.exports = { 21 | /** 22 | * 获取store的值 23 | * 24 | * @param {*} [state=null] 25 | * @param {*} [field=[]] 26 | * @returns 27 | */ 28 | async getStore({ state = this.REtokenDecode(), field = [] }) { 29 | const stores = {}; 30 | for (let i = 0, len = field.length; i < len; i++) { 31 | const key = field[i]; 32 | stores[key] = await this.ctx.store(state, key).value; 33 | } 34 | // 如果redis被清空,则强制客户端退出 35 | if (Object.values(stores).every((item) => !item)) { 36 | return this.ctx.throw(410, '密钥有误'); 37 | } 38 | return stores; 39 | }, 40 | 41 | /** 42 | * 处理成功响应 43 | * 默认过滤 password 44 | * @param {*} { ctx, res = null, msg = '请求成功' } 45 | */ 46 | success({ ctx, res = null, msg = '请求成功' }) { 47 | const body = (data) => { 48 | ctx.body = { 49 | // code: 0, 50 | data, 51 | msg, 52 | }; 53 | ctx.status = 200; 54 | }; 55 | body(res); 56 | const that = {}; 57 | // 过滤 58 | Object.defineProperty(that, 'filter', { 59 | value: (...field) => { 60 | const defField = ['password', ...field]; 61 | if (res.docs) { 62 | res.docs = res.docs.map((item) => omit(item, defField)); 63 | body(res); 64 | } 65 | body(omit(res, defField)); 66 | return that; 67 | }, 68 | }); 69 | // 日志 70 | Object.defineProperty(that, 'logger', { 71 | value: (store = {}, name) => { 72 | // 输出日志 73 | ctx.logger.info(name, { 74 | ...store, 75 | reqBody: ctx.request.body, 76 | }); 77 | return that; 78 | }, 79 | }); 80 | return that; 81 | }, 82 | /** 83 | * 验证器 84 | * 85 | * @param {*} schema 86 | * @param {*} payload 87 | * @param {boolean} [callback=(valid) => { 88 | * return this.ctx.assert(false, 422, valid.error); 89 | * }] 90 | * @returns 91 | */ 92 | validate( 93 | schema, 94 | payload, 95 | callback = (valid) => { 96 | this.ctx.throw(422, valid.error); 97 | } 98 | ) { 99 | const valid = schema.validate(payload); 100 | if (valid.error) { 101 | return callback(valid); 102 | } 103 | return valid.value; 104 | }, 105 | /** 106 | * token 再加密 107 | * 108 | * @param {*} obj 109 | * @returns 110 | */ 111 | REtokenEncrypt(obj) { 112 | const key = String(random(9)); 113 | let arr = Buffer.from(JSON.stringify(obj)).toString('hex').split(key); 114 | arr.splice(arr.length - 1, 0, arr.length + key); 115 | return Buffer.from(String(arr)).toString('base64'); 116 | }, 117 | /** 118 | * token 再解密 119 | * 120 | * @param {*} str 121 | * @returns 122 | */ 123 | REtokenDecode(str) { 124 | try { 125 | str = str || this.ctx.state.user.data; 126 | let arr = Buffer(str, 'base64').toString().split(','); 127 | const key = arr[arr.length - 2].slice(-1); 128 | arr.splice(arr.length - 2, 1); 129 | return JSON.parse(Buffer(arr.join(key), 'hex').toString('utf8') || '{}'); 130 | } catch (error) { 131 | this.ctx.throw(410, '密钥有误', error); 132 | return; 133 | } 134 | }, 135 | /** 136 | * 生成mongoose查询条件 137 | * 138 | * @param {*} { 139 | * search = {}, // 查询参数 140 | * config = {}, // 配置 141 | * callback = (query) => query, // 回调 142 | * } 143 | * @returns 144 | */ 145 | GMQC({ 146 | search = {}, // 查询参数 147 | config = {}, // 配置 148 | callback = (query) => query, // 回调 149 | }) { 150 | let _search = {}; 151 | // 将参数拼接为mongoose认识的参数 152 | // $and: [ 153 | // { 154 | // 'user.sex.value': 1 155 | // }, 156 | // { 157 | // $or: [ 158 | // { 'dream.salary.value': { $gte: 8800, $lte: 10000 } }, 159 | // { 'dream.salary.value': { $gte: 10000, $lte: 10000 } }, 160 | // ], 161 | // }, 162 | // ] 163 | const and$or = (obj) => { 164 | const toFilterObjects = ([key, values]) => { 165 | this.ctx.assert( 166 | isArray(values), 167 | 422, 168 | 'search对象中key的值需为一个数组' 169 | ); 170 | return values.map((value) => ({ [key]: value })); 171 | }; 172 | const combine = (key) => (arr) => { 173 | if (arr.length) { 174 | if (arr.length === 1) return arr[0]; 175 | return { 176 | [key]: arr, 177 | }; 178 | } 179 | return {}; 180 | }; 181 | return combine('$and')( 182 | Object.entries(obj) 183 | .map(toFilterObjects) 184 | .map(combine('$or')) 185 | .filter((value) => Object.keys(value).length) 186 | ); 187 | }; 188 | 189 | // search 字段特殊条件配置 示例: 190 | // const condition = [ 191 | // { field: 'salary', cond: "{ $gt: $0, $lt: $1 }" } 192 | // ] 193 | // 输出 194 | // { 'dream.salary.value': { $gte: 1000, $lte: 3000 } } 195 | config.condition && 196 | (() => { 197 | // 用户ID的传递 不能删 eval 会用到 198 | const ObjectId = this.ctx.app.mongoose.Types.ObjectId; 199 | const reg = (idx) => { 200 | return new RegExp('(\\$' + idx + '+|\\$\\$)', 'g'); 201 | }; 202 | config.condition.map((item) => { 203 | search = mapValues(search, (value, key) => { 204 | if (key == item.field) { 205 | this.ctx.assert( 206 | isString(item.cond), 207 | 500, 208 | 'GMQC config.condition配置:item.cond 必须为字符串' 209 | ); 210 | return value.map((vi, idx) => { 211 | if (isArray(vi)) { 212 | if (!vi.length) return vi; 213 | let tempCond = item.cond; 214 | vi.map((i, d) => { 215 | tempCond = tempCond.replace(reg(d), i); 216 | }); 217 | return eval('(' + tempCond + ')'); 218 | } else { 219 | item.cond = item.cond.replace(reg(idx), vi); 220 | return eval('(' + item.cond + ')'); 221 | } 222 | }); 223 | } 224 | return value; 225 | }); 226 | }); 227 | })(); 228 | 229 | // search 对象输出 直接 和间接参数 示例: 230 | // const returnDirectAndIndirect = { 231 | // direct: ['applyStatus', 'maritalStatus', 'highestEdu'], 232 | // // indirect 只传一个的话,默认第二个为剩余的参数 233 | // } 234 | config.returnDirectAndIndirect && 235 | (() => { 236 | const rdai = config.returnDirectAndIndirect; 237 | const ps = (v, b) => { 238 | return omitBy(search, (value, key) => { 239 | let r = v.includes(key); 240 | return (r ^= b); 241 | }); 242 | }; 243 | if (rdai.direct && rdai.indirect) { 244 | mapKeys(rdai, (value, key) => { 245 | _search[key] = ps(value, true); 246 | }); 247 | } else if (rdai.direct) { 248 | _search.direct = ps(rdai.direct, true); 249 | _search.indirect = ps(rdai.direct, false); 250 | } else if (rdai.indirect) { 251 | _search.indirect = ps(rdai.indirect, true); 252 | _search.direct = ps(rdai.indirect, false); 253 | } else { 254 | return; 255 | } 256 | })(); 257 | 258 | // search 字段前缀后缀配置 示例: 259 | // const prefix$suffix$rename = [ 260 | // { pref: 'user', suff: 'value', field: ['sex', 'review'] }, 261 | // { pref: 'dream', suff: 'value', field: ['jobCategory', 'salary'] }, 262 | // { suff: 'value', field: ['applyStatus', 'maritalStatus', 'highestEdu'] } 263 | // { suff: 'info.result.PlateResult.license', rename: 'car', field: ['carNo'], }, 264 | // ]; 265 | config.prefix$suffix$rename && 266 | (() => { 267 | const structureField = (k, item) => { 268 | if (item.pref && item.suff) { 269 | return `${item.pref}.${k}.${item.suff}`; 270 | } else if (item.pref) { 271 | return `${item.pref}.${k}`; 272 | } else if (item.suff) { 273 | return `${k}.${item.suff}`; 274 | } else { 275 | return k; 276 | } 277 | }; 278 | const prefix$suffix$rename = (obj) => { 279 | const psr = config.prefix$suffix$rename; 280 | for (let i = 0, len = psr.length; i < len; i++) { 281 | obj = mapKeys(obj, (value, key) => { 282 | if (!psr[i].field.includes(key)) { 283 | return key; 284 | } 285 | if (psr[i].rename) { 286 | return structureField(psr[i].rename, psr[i]); 287 | } else { 288 | return structureField(key, psr[i]); 289 | } 290 | }); 291 | } 292 | return obj; 293 | }; 294 | if (!isEmpty(_search)) { 295 | _search = mapValues(_search, (value, key) => { 296 | return prefix$suffix$rename(value); 297 | }); 298 | } else { 299 | search = prefix$suffix$rename(search); 300 | } 301 | })(); 302 | 303 | // 输出查询结果 304 | const query = () => { 305 | const res = callback(and$or(search)); 306 | const opt = { writable: true, enumerable: false, configurable: true }; 307 | if (!isEmpty(_search)) { 308 | Object.defineProperty(res, 'direct', { 309 | value: and$or(_search.direct), 310 | ...opt, 311 | }); 312 | Object.defineProperty(res, 'indirect', { 313 | value: and$or(_search.indirect), 314 | ...opt, 315 | }); 316 | } 317 | return res; 318 | }; 319 | 320 | return query(); 321 | }, 322 | 323 | /** 324 | * 压缩文件到指定大小并存储到新目录 325 | * 326 | * @param {*} file 327 | * @param {*} savePath 328 | * @param {number} [quality=80] 329 | * @param {number} [drop=2] 330 | * @returns 331 | */ 332 | async constraintImage(file, savePath, quality = 80, drop = 2) { 333 | if (file == savePath) { 334 | return; 335 | } 336 | const done = sharp(file).jpeg({ quality }); 337 | const buf = await done.toBuffer(); 338 | // 控制图片质量为1024kb 339 | if (buf.byteLength > 1024 * 1024 && quality > 2) { 340 | return await this.constraintImage(buf, savePath, quality - drop); 341 | } 342 | return done.toFile(savePath); 343 | }, 344 | 345 | /** 346 | * 文件的复制和压缩 347 | * 348 | * @param {*} targetFile 349 | * @param {*} newPath 350 | * @param {*} filename 351 | * @param {boolean} [constraintImage=false] 352 | * @returns 353 | */ 354 | async copyAndCompress( 355 | targetFile, 356 | newPath, 357 | filename, 358 | constraintImage = false 359 | ) { 360 | const { ctx } = this; 361 | try { 362 | const extname = path.extname(targetFile).toLowerCase(); // 后缀名 363 | const url = `${newPath}/${filename}${extname}`; // 最终返回的文件url 364 | const savePath = path.join(ctx.app.baseDir, 'app', url); // 最新存放的地址 365 | if (constraintImage) { 366 | await fs.ensureFile(savePath); 367 | await this.constraintImage(targetFile, savePath); 368 | } else { 369 | await fs.copy(targetFile, savePath); 370 | } 371 | return url; 372 | } catch (err) { 373 | ctx.logger.error(err); 374 | return; 375 | } 376 | }, 377 | 378 | /** 379 | * 生成串口16进制buffer数据 380 | * 381 | * @returns 382 | */ 383 | generateSerialData(TEXT = '') { 384 | let s16 = ['0064FFFF'], // 存放16进制数的字符串数组 385 | i = 1; // 数组下标 386 | const textLen = TEXT.replace(/[\u4e00-\u9fa5]/g, 'aa').length; 387 | const result = (_s16) => { 388 | let serial = _s16.join(''); 389 | // 计算crc 390 | const crc = crc16modbus(Buffer.from(serial, 'hex')).toString(16); 391 | // 颠倒crc并拼接 392 | serial += Buffer.from(crc, 'hex').reverse().toString('hex'); 393 | return Buffer.from(serial, 'hex'); 394 | }; 395 | return { 396 | text: async () => { 397 | s16[i++] = '62'; // 显示临时文本 398 | s16[i++] = Buffer.from([19 + textLen], 'hex').toString('hex'); // DL 等于 19 +文本长度 399 | s16[i++] = '00'; // 第几行 400 | s16[i++] = '15'; // 连续左移 401 | s16[i++] = '01000215010300FF00000000000000'; // 固定参数 402 | s16[i++] = Buffer.from([textLen], 'hex').toString('hex') + '00'; // TL文本长度 403 | s16[i++] = iconv.encode(TEXT, 'GB2312').toString('hex'); // 文本 404 | return result(s16); 405 | }, 406 | voice: async () => { 407 | s16[i++] = '30'; // 播放语音 408 | s16[i++] = Buffer.from([1 + textLen], 'hex').toString('hex'); // DL 等于 1 + 文本长度 409 | s16[i++] = '02'; // 先清除队列,再添加新语音到队列,然后开始播放 410 | s16[i++] = iconv.encode(TEXT, 'GB2312').toString('hex'); // 文本 411 | return result(s16); 412 | }, 413 | }; 414 | }, 415 | 416 | /** 417 | * 强制分页 一般用于mongoose插件无法进行分页的情况 418 | * 419 | * @param {*} items 420 | * @param {*} page 421 | * @param {*} limit 422 | * @returns 423 | */ 424 | forcePaginate(items, page, limit) { 425 | const offset = (page - 1) * limit, 426 | docs = drop(items, offset).slice(0, limit); 427 | const totalDocs = items.length, 428 | totalPages = (totalDocs + limit - 1) / limit, 429 | nextPage = page + 1 >= totalPages ? null : page + 1, 430 | prevPage = page - 1 || null, 431 | hasNextPage = nextPage ? true : false, 432 | hasPrevPage = prevPage ? true : false; 433 | return { 434 | docs, 435 | hasNextPage, 436 | hasPrevPage, 437 | limit, 438 | nextPage, 439 | page, 440 | pagingCounter: 1, 441 | prevPage, 442 | totalDocs, 443 | totalPages, 444 | }; 445 | }, 446 | 447 | /** 448 | * joi ObjectId 验证 449 | * 450 | * @returns 451 | */ 452 | joiObjectIdValid() { 453 | const that = this; 454 | return (value, helpers) => { 455 | if (!that.ctx.app.mongoose.Types.ObjectId.isValid(value)) { 456 | return helpers.message('识别码传递错误'); 457 | } 458 | return value; 459 | }; 460 | }, 461 | 462 | /** 463 | * 判断 某个时分秒 是否在 一个时分秒区间内 464 | * 465 | * @param {*} [nowTime=new Date()] 466 | * @param {*} beforeTime 467 | * @param {*} afterTime 468 | * @returns 469 | */ 470 | checkTimeIsBetween({ nowTime = new Date(), beforeTime, afterTime }) { 471 | const format = 'hh:mm:ss'; 472 | const _nowTime = moment(nowTime, format), 473 | _beforeTime = moment(beforeTime, format), 474 | _afterTime = moment(afterTime, format); 475 | if (_nowTime.isBetween(_beforeTime, _afterTime, null, '[]')) { 476 | return true; 477 | } else { 478 | return false; 479 | } 480 | }, 481 | 482 | /** 483 | * 格式化时间 几天 几小时 几分 484 | * 485 | * @param {*} date1 486 | * @param {*} date2 487 | * @param {boolean} [opt=true] 488 | * @returns 489 | */ 490 | preciseDiff(date1, date2, opt = true) { 491 | return moment.preciseDiff(moment(date1), moment(date2), opt); 492 | }, 493 | }; 494 | -------------------------------------------------------------------------------- /app/controller/merchant.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Joi = require('@hapi/joi'); 3 | const path = require('path'); 4 | const Controller = require('egg').Controller; 5 | class MerchantController extends Controller { 6 | constructor(ctx) { 7 | super(ctx); 8 | this.model = ctx.model.Merchant; 9 | this.schema = ctx.schema.Merchant; 10 | // 审核商户 11 | this.auditSchema = Joi.object({ 12 | // 商户ID 13 | merchant: Joi.string().required(), 14 | // 备注 15 | remark: Joi.string(), 16 | // 状态 17 | status: Joi.string().valid('审核不通过', '正常', '审核中').required(), 18 | }); 19 | // 新增修改员工 20 | this.updateStaffSchema = Joi.object({ 21 | // 员工手机 22 | phone: Joi.string().required(), 23 | // 员工姓名 24 | name: Joi.string(), 25 | }); 26 | // 审核商户员工 27 | this.auditStaffSchema = Joi.object({ 28 | // 员工ID 29 | staff: Joi.string().required(), 30 | // 备注 31 | remark: Joi.string(), 32 | // 状态 33 | status: Joi.string().valid('审核不通过', '正常', '审核中').required(), 34 | }); 35 | // 删除商户 36 | this.deleteSchema = Joi.object({ 37 | // 商户ID 38 | merchants: Joi.array().items(Joi.string().required()).required(), 39 | }); 40 | // 刪除商户员工 41 | this.deleteStaffSchema = Joi.object({ 42 | // 员工ID 43 | user: Joi.string().required(), 44 | }); 45 | // 新增车辆 46 | this.updateCarSchema = Joi.object({ 47 | // 商户ID 48 | merchant: Joi.string().required(), 49 | // 车牌号 50 | num: Joi.string() 51 | .regex( 52 | /^(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}(?:(?:[0-9]{5}[DF])|(?:[DF](?:[A-HJ-NP-Z0-9])[0-9]{4})))|(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9 挂学警港澳]{1})$/ 53 | ) 54 | .required(), 55 | // 车辆过期时间 56 | expired: Joi.date().allow(''), 57 | }); 58 | // 积分管理 59 | this.integralSchema = Joi.object({ 60 | // 商户ID 61 | merchant: Joi.string().required(), 62 | // 积分 63 | integral: Joi.number().required(), 64 | }); 65 | } 66 | 67 | // 商户列表 68 | async index() { 69 | const { ctx, service } = this; 70 | // 参数验证 71 | const payload = ctx.helper.validate(ctx.schema.Query, ctx.request.body); 72 | // 本地查询 73 | // if (ctx.request.host == 'localhost:7002') { 74 | // const res = await service.merchant.index(payload); 75 | // return ctx.helper.success({ ctx, res }); 76 | // } 77 | // 获取当前登录用户的数据 78 | const store = await ctx.helper.getStore({ 79 | field: ['user', 'market', 'merchant'], 80 | }); 81 | // 获取当前用户身份 82 | const identity = store.user.identity; 83 | // 市场负责人查询 84 | if (identity['市场责任人'] || identity['市场员工']) { 85 | const res = await service.merchant.index({ 86 | ...payload, 87 | search: { 88 | ...payload.search, 89 | market: [store.market._id], 90 | }, 91 | }); 92 | return ctx.helper 93 | .success({ ctx, res }) 94 | .logger(store, '市场负责人查询商户列表'); 95 | } else { 96 | // 查询自己所在商户的信息 97 | const search = { 98 | market: [store.market._id], 99 | $or: [[{ user: store.user._id }, { 'staff.user': store.user._id }]], 100 | }; 101 | let select = {}; 102 | if (identity['商户员工']) { 103 | select = { name: 1, _id: 0 }; 104 | } 105 | const res = await service.merchant.index({ search, select }); 106 | return ctx.helper 107 | .success({ ctx, res }) 108 | .logger(store, '查询自己所在商户的信息'); 109 | } 110 | } 111 | 112 | // 创建修改商户 113 | async update() { 114 | const { ctx, service, schema } = this; 115 | // 获取当前登录用户的数据 116 | const store = await ctx.helper.getStore({ 117 | field: ['user', 'market', 'merchant'], 118 | }); 119 | // 参数验证 120 | const payload = ctx.helper.validate(schema, { 121 | ...ctx.request.body, 122 | market: store.market._id, 123 | user: store.user._id, 124 | }); 125 | // 如果是首次创建商户 126 | if (!store.merchant) { 127 | // 检查商户名称是否被占用 128 | const merchantTotalDocs = ( 129 | await service.merchant.index({ 130 | search: { 131 | name: [payload.name], 132 | }, 133 | }) 134 | ).totalDocs; 135 | ctx.assert(!merchantTotalDocs, 422, '商户名被占用'); 136 | // 生成一个ObjectId 137 | store.merchant = { 138 | _id: ctx.app.mongoose.Types.ObjectId(), 139 | }; 140 | } 141 | 142 | // 压缩并放置图片 143 | const imagePath = `/public/uploads/formal/market/${store.market._id}/merchant/${store.merchant._id}`; 144 | payload.businessLicense.photo = 145 | payload.businessLicense.photo && 146 | (await ctx.helper.copyAndCompress( 147 | path.join(ctx.app.baseDir, 'app', payload.businessLicense.photo), 148 | imagePath, 149 | '营业执照照片', 150 | true 151 | )); 152 | payload.rentalContract.page1 = 153 | payload.rentalContract.page1 && 154 | (await ctx.helper.copyAndCompress( 155 | path.join(ctx.app.baseDir, 'app', payload.rentalContract.page1), 156 | imagePath, 157 | '租房合同首页', 158 | true 159 | )); 160 | payload.rentalContract.page999 = 161 | payload.rentalContract.page999 && 162 | (await ctx.helper.copyAndCompress( 163 | path.join(ctx.app.baseDir, 'app', payload.rentalContract.page999), 164 | imagePath, 165 | '租房合同尾页', 166 | true 167 | )); 168 | 169 | // 创建商户 170 | const res = await service.merchant.update( 171 | { 172 | _id: store.merchant._id, 173 | market: store.market._id, 174 | user: store.user._id, 175 | }, 176 | payload, 177 | { 178 | new: true, 179 | upsert: true, 180 | setDefaultsOnInsert: true, 181 | } 182 | ); 183 | ctx.helper.success({ ctx, res }).logger(store, '修改商户'); 184 | } 185 | 186 | // 审核商户 187 | async audit() { 188 | const { ctx, service } = this; 189 | // 参数验证 190 | const payload = ctx.helper.validate(this.auditSchema, ctx.request.body); 191 | // 获取当前登录用户的数据 192 | const store = await ctx.helper.getStore({ 193 | field: ['user', 'market', 'merchant'], 194 | }); 195 | // 获取当前用户身份 196 | const identity = store.user.identity; 197 | ctx.assert( 198 | identity['市场责任人'] || identity['市场员工'], 199 | 403, 200 | `当前账号,无权操作!` 201 | ); 202 | // 修改商户 203 | const res = await service.merchant.update( 204 | { 205 | market: store.market._id, 206 | _id: payload.merchant, 207 | }, 208 | { 209 | $set: { 210 | status: payload.status, 211 | }, 212 | }, 213 | { 214 | new: true, 215 | } 216 | ); 217 | let smsScene = ''; 218 | switch (payload.status) { 219 | case '审核不通过': 220 | smsScene = '未通过审核'; 221 | break; 222 | case '正常': 223 | smsScene = '审核成功'; 224 | break; 225 | } 226 | // 获取用户手机 227 | const user = ( 228 | await service.user.index({ 229 | search: { 230 | market: [store.market._id], 231 | _id: [res.user], 232 | }, 233 | }) 234 | ).docs[0]; 235 | ctx.assert(user, 404, `当前用户不存在`); 236 | // 发送短信 237 | await service.sms.send({ 238 | phone: user.phone, 239 | type: 603936, 240 | smsScene, 241 | market: store.market._id, 242 | }); 243 | ctx.helper.success({ ctx, res }).logger(store, '审核商户'); 244 | } 245 | 246 | // 删除商户 247 | async delete() { 248 | const { ctx, service } = this; 249 | // 参数验证 250 | const payload = ctx.helper.validate(this.deleteSchema, ctx.request.body); 251 | // 获取当前登录用户的数据 252 | const store = await ctx.helper.getStore({ 253 | field: ['user', 'market', 'merchant'], 254 | }); 255 | // 获取当前用户身份 256 | const identity = store.user.identity; 257 | ctx.assert( 258 | identity['市场责任人'] || identity['市场员工'], 259 | 403, 260 | `当前账号,无权操作!` 261 | ); 262 | // 删除商户 263 | const res = await service.merchant.delete(payload.merchants); 264 | ctx.helper.success({ ctx, res }).logger(store, '删除商户'); 265 | } 266 | 267 | // 查询商户员工 268 | async indexStaff() { 269 | const { ctx, service } = this; 270 | // 参数验证 271 | const payload = ctx.helper.validate(ctx.schema.Query, ctx.request.body); 272 | // 获取当前登录用户的数据 273 | const store = await ctx.helper.getStore({ 274 | field: ['user', 'market', 'merchant'], 275 | }); 276 | // 当前登录用户身份查询 277 | const identity = store.user.identity; 278 | let staff = []; 279 | if (identity['市场责任人'] || identity['市场员工']) { 280 | // 参数 281 | const cond = 282 | payload.search && payload.search.merchantId 283 | ? { 284 | search: { 285 | _id: [payload.search.merchantId], 286 | market: [store.market._id], 287 | }, 288 | } 289 | : {}; 290 | // 查询负责人所在的市场,取出员工ID 291 | staff = (await service.merchant.index(cond)).docs[0].staff; 292 | // 删除多余的merchantId参数 293 | if (payload.search && payload.search.merchantId) 294 | delete payload.search.merchantId; 295 | } else if (identity['商户责任人']) { 296 | staff = ( 297 | await service.merchant.index({ 298 | search: { 299 | _id: [store.merchant._id], 300 | market: [store.market._id], 301 | }, 302 | }) 303 | ).docs[0].staff; 304 | } else { 305 | ctx.assert(false, 403, `当前账号,无权操作!`); 306 | } 307 | // 根据员工ID查询用户 308 | let res = []; 309 | if (staff.length) { 310 | res = await service.user.index({ 311 | ...payload, 312 | search: { 313 | ...payload.search, 314 | _id: staff.map((item) => item.user), 315 | market: [store.market._id], 316 | }, 317 | select: { password: 0 }, 318 | }); 319 | // 渲染角色字段 320 | res.docs = res.docs.map((item) => { 321 | item.role = staff.find((i) => i.user.equals(item._id)).role; 322 | return item; 323 | }); 324 | // 渲染审核状态字段 325 | res.docs = res.docs.map((item) => { 326 | item.status = staff.find((i) => i.user.equals(item._id)).status; 327 | return item; 328 | }); 329 | } 330 | return ctx.helper.success({ ctx, res }).logger(store, '查询员工'); 331 | } 332 | 333 | // 审核商户员工 334 | async auditStaff() { 335 | const { ctx, service } = this; 336 | // 参数验证 337 | const payload = ctx.helper.validate( 338 | this.auditStaffSchema, 339 | ctx.request.body 340 | ); 341 | // 获取当前登录用户的数据 342 | const store = await ctx.helper.getStore({ 343 | field: ['user', 'market', 'merchant'], 344 | }); 345 | // 获取当前用户身份 346 | const identity = store.user.identity; 347 | ctx.assert( 348 | identity['市场责任人'] || identity['市场员工'], 349 | 403, 350 | `当前账号,无权操作!` 351 | ); 352 | // 修改员工审核状态 353 | const res = await service.merchant.update( 354 | { 355 | market: store.market._id, 356 | 'staff.user': payload.staff, 357 | }, 358 | { 359 | $set: { 360 | 'staff.$.status': payload.status, 361 | }, 362 | }, 363 | { 364 | new: true, 365 | } 366 | ); 367 | ctx.helper.success({ ctx, res }).logger(store, '审核商户员工'); 368 | } 369 | 370 | // 新增修改商户员工 371 | async updateStaff() { 372 | const { ctx, service } = this; 373 | // 参数验证 374 | const payload = ctx.helper.validate( 375 | this.updateStaffSchema, 376 | ctx.request.body 377 | ); 378 | // 获取当前登录用户的数据 379 | const store = await ctx.helper.getStore({ 380 | field: ['user', 'market', 'merchant'], 381 | }); 382 | // 获取用户身份 383 | const identity = store.user.identity; 384 | // 判断当前用户是否为商户责任人 385 | ctx.assert(identity['商户责任人'], 403, `当前账号,无权操作!`); 386 | payload.market = store.market._id; 387 | // 判断当前商户是否有权限修改用户 388 | const hasUser = await ctx.model.User.findOne({ 389 | market: payload.market, 390 | phone: payload.phone, 391 | }); 392 | if (hasUser) { 393 | const merchantStaff = await ctx.model.Merchant.findOne({ 394 | market: payload.market, 395 | 'staff.user': hasUser._id, 396 | }); 397 | ctx.assert( 398 | merchantStaff && merchantStaff._id.equals(store.merchant._id), 399 | 403, 400 | `无权添加该用户,请修改手机号后重试` 401 | ); 402 | } 403 | // 创建用户 404 | const user = await service.user.update( 405 | { phone: payload.phone, market: payload.market }, 406 | payload, 407 | { 408 | new: true, 409 | upsert: true, 410 | setDefaultsOnInsert: true, 411 | } 412 | ); 413 | // 向商户添加员工 414 | const res = await service.merchant.update( 415 | { 416 | _id: store.merchant._id, 417 | 'staff.user': { $ne: user._id }, 418 | }, 419 | { 420 | $push: { 421 | staff: { 422 | $each: [ 423 | { 424 | user: user._id, 425 | }, 426 | ], 427 | $position: 0, 428 | }, 429 | }, 430 | }, 431 | { new: true } 432 | ); 433 | ctx.helper.success({ ctx, res }).logger(store, '新增修改商户员工'); 434 | } 435 | 436 | // 删除员工 437 | async deleteStaff() { 438 | const { ctx, service } = this; 439 | // 参数验证 440 | const payload = ctx.helper.validate( 441 | this.deleteStaffSchema, 442 | ctx.request.body 443 | ); 444 | // 获取当前登录用户的数据 445 | const store = await ctx.helper.getStore({ 446 | field: ['user', 'market', 'merchant'], 447 | }); 448 | // 获取用户身份 449 | const identity = store.user.identity; 450 | // 判断当前用户是否为商户责任人 451 | ctx.assert(identity['商户责任人'], 403, `当前账号,无权操作!`); 452 | // 删除商户员工 453 | const res = await service.merchant.update( 454 | { _id: store.merchant._id }, 455 | { 456 | $pull: { 457 | staff: { 458 | user: payload.user, 459 | }, 460 | }, 461 | }, 462 | { new: true } 463 | ); 464 | ctx.helper.success({ ctx, res }).logger(store, '删除商户员工'); 465 | } 466 | 467 | // 绑定车辆 468 | async updateCar() { 469 | const { ctx, service } = this; 470 | // 获取当前登录用户的数据 471 | const store = await ctx.helper.getStore({ 472 | field: ['user', 'market', 'merchant'], 473 | }); 474 | // 获取用户身份 475 | const identity = store.user.identity; 476 | // 判断车辆是否已经被绑定 477 | const judgeCar = async (num) => { 478 | const merchantCar = await service.merchant.index({ 479 | search: { 480 | 'car.num': [num], 481 | market: [store.market._id], 482 | }, 483 | }); 484 | const marketCar = await service.market.index({ 485 | search: { 486 | 'car.num': [num], 487 | _id: [store.market._id], 488 | }, 489 | }); 490 | ctx.assert( 491 | !(merchantCar.totalDocs || marketCar.totalDocs), 492 | 403, 493 | `该车辆已被绑定` 494 | ); 495 | }; 496 | // 判断当前用户身份 497 | if (identity['市场责任人'] || identity['市场员工']) { 498 | // 参数验证 499 | const payload = ctx.helper.validate( 500 | this.updateCarSchema, 501 | ctx.request.body 502 | ); 503 | // 向商户添加车辆 504 | const res = 505 | (await service.merchant.update( 506 | // 修改 507 | { _id: payload.merchant, 'car.num': payload.num }, 508 | { 509 | $set: { 510 | 'car.$.num': payload.num, 511 | 'car.$.expired': payload.expired, 512 | }, 513 | }, 514 | { new: true } 515 | )) || 516 | (await (async () => { 517 | // 新增 518 | await judgeCar(payload.num); 519 | return await service.merchant.update( 520 | { _id: payload.merchant, 'car.num': { $ne: payload.num } }, 521 | { 522 | $push: { 523 | car: { 524 | $each: [ 525 | { 526 | num: payload.num, 527 | expired: payload.expired, 528 | }, 529 | ], 530 | $position: 0, 531 | }, 532 | }, 533 | }, 534 | { new: true } 535 | ); 536 | })()); 537 | return ctx.helper 538 | .success({ ctx, res }) 539 | .logger(store, '市场协助商户绑定车辆'); 540 | } 541 | // if (identity['商户责任人']) { 542 | // // 参数验证 543 | // const payload = ctx.helper.validate(this.updateCarSchema, { 544 | // ...ctx.request.body, 545 | // merchant: store.merchant._id, 546 | // }); 547 | // await judgeCar(payload.num) 548 | // // 向商户添加车辆 549 | // const res = await service.merchant.update( 550 | // { _id: payload.merchant, 'car.num': { $ne: payload.num } }, 551 | // { 552 | // $push: { 553 | // car: { 554 | // num: payload.num, 555 | // }, 556 | // }, 557 | // }, 558 | // { new: true } 559 | // ); 560 | // return ctx.helper.success({ ctx, res }).logger(store, '商户绑定车辆'); 561 | // } 562 | ctx.assert(false, 403, `当前账号,无权操作!`); 563 | } 564 | 565 | // 删除车辆 566 | async deleteCar() { 567 | const { ctx, service } = this; 568 | // 获取当前登录用户的数据 569 | const store = await ctx.helper.getStore({ 570 | field: ['user', 'market', 'merchant'], 571 | }); 572 | // 获取用户身份 573 | const identity = store.user.identity; 574 | // 判断当前用户身份 575 | if (identity['市场责任人'] || identity['市场员工']) { 576 | // 参数验证 577 | const payload = ctx.helper.validate( 578 | this.updateCarSchema, 579 | ctx.request.body 580 | ); 581 | // 删除车辆 582 | const res = await service.merchant.update( 583 | { _id: payload.merchant }, 584 | { 585 | $pull: { 586 | car: { 587 | num: payload.num, 588 | }, 589 | }, 590 | }, 591 | { new: true } 592 | ); 593 | return ctx.helper.success({ ctx, res }).logger(store, '市场删除商户车辆'); 594 | } 595 | if (identity['商户责任人']) { 596 | // 参数验证 597 | const payload = ctx.helper.validate(this.updateCarSchema, { 598 | ...ctx.request.body, 599 | merchant: store.merchant._id, 600 | }); 601 | // 删除车辆 602 | const res = await service.merchant.update( 603 | { _id: payload.merchant }, 604 | { 605 | $pull: { 606 | car: { 607 | num: payload.num, 608 | }, 609 | }, 610 | }, 611 | { new: true } 612 | ); 613 | return ctx.helper.success({ ctx, res }).logger(store, '商户删除车辆'); 614 | } 615 | ctx.assert(false, 403, `当前账号,无权操作!`); 616 | } 617 | 618 | // 积分 619 | async integral() { 620 | const { ctx, service } = this; 621 | // 参数验证 622 | const payload = ctx.helper.validate(this.integralSchema, ctx.request.body); 623 | // 获取当前登录用户的数据 624 | const store = await ctx.helper.getStore({ 625 | field: ['user', 'market', 'merchant'], 626 | }); 627 | // 获取用户身份 628 | const identity = store.user.identity; 629 | ctx.assert(identity['市场责任人'], 403, '当前账号,无权操作'); 630 | const res = await service.merchant.update( 631 | { 632 | market: store.market._id, 633 | _id: payload.merchant, 634 | }, 635 | { 636 | $inc: { 637 | integral: payload.integral, 638 | }, 639 | } 640 | ); 641 | ctx.helper.success({ ctx, res }).logger(store, '市场责任人增减清空积分'); 642 | } 643 | } 644 | 645 | module.exports = MerchantController; 646 | -------------------------------------------------------------------------------- /app/public/assets/downloadhtml/bootstrap-4.4.1-dist/css/bootstrap-reboot.min.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../scss/bootstrap-reboot.scss","../../scss/_reboot.scss","dist/css/bootstrap-reboot.css","../../scss/vendor/_rfs.scss","bootstrap-reboot.css","../../scss/mixins/_hover.scss"],"names":[],"mappings":"AAAA;;;;;;ACkBA,ECTA,QADA,SDaE,WAAA,WAGF,KACE,YAAA,WACA,YAAA,KACA,yBAAA,KACA,4BAAA,YAMF,QAAA,MAAA,WAAA,OAAA,OAAA,OAAA,OAAA,KAAA,IAAA,QACE,QAAA,MAUF,KACE,OAAA,EACA,YAAA,aAAA,CAAA,kBAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBEgFI,UAAA,KF9EJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,WAAA,KACA,iBAAA,KGlBF,0CH+BE,QAAA,YASF,GACE,WAAA,YACA,OAAA,EACA,SAAA,QAaF,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAOF,EACE,WAAA,EACA,cAAA,KC9CF,0BDyDA,YAEE,gBAAA,UACA,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,cAAA,EACA,iCAAA,KAAA,yBAAA,KAGF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QCnDF,GDsDA,GCvDA,GD0DE,WAAA,EACA,cAAA,KAGF,MCtDA,MACA,MAFA,MD2DE,cAAA,EAGF,GACE,YAAA,IAGF,GACE,cAAA,MACA,YAAA,EAGF,WACE,OAAA,EAAA,EAAA,KAGF,ECvDA,ODyDE,YAAA,OAGF,MExFI,UAAA,IFiGJ,IC5DA,ID8DE,SAAA,SEnGE,UAAA,IFqGF,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAON,EACE,MAAA,QACA,gBAAA,KACA,iBAAA,YIhLA,QJmLE,MAAA,QACA,gBAAA,UASJ,cACE,MAAA,QACA,gBAAA,KI/LA,oBJkME,MAAA,QACA,gBAAA,KC7DJ,KACA,IDqEA,ICpEA,KDwEE,YAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UEpJE,UAAA,IFwJJ,IAEE,WAAA,EAEA,cAAA,KAEA,SAAA,KAQF,OAEE,OAAA,EAAA,EAAA,KAQF,IACE,eAAA,OACA,aAAA,KAGF,IAGE,SAAA,OACA,eAAA,OAQF,MACE,gBAAA,SAGF,QACE,YAAA,OACA,eAAA,OACA,MAAA,QACA,WAAA,KACA,aAAA,OAGF,GAGE,WAAA,QAQF,MAEE,QAAA,aACA,cAAA,MAMF,OAEE,cAAA,EAOF,aACE,QAAA,IAAA,OACA,QAAA,IAAA,KAAA,yBCxGF,OD2GA,MCzGA,SADA,OAEA,SD6GE,OAAA,EACA,YAAA,QErPE,UAAA,QFuPF,YAAA,QAGF,OC3GA,MD6GE,SAAA,QAGF,OC3GA,OD6GE,eAAA,KAMF,OACE,UAAA,OC3GF,cACA,aACA,cDgHA,OAIE,mBAAA,OC/GF,6BACA,4BACA,6BDkHE,sBAKI,OAAA,QClHN,gCACA,+BACA,gCDsHA,yBAIE,QAAA,EACA,aAAA,KCrHF,qBDwHA,kBAEE,WAAA,WACA,QAAA,EAIF,iBCxHA,2BACA,kBAFA,iBDkIE,mBAAA,QAGF,SACE,SAAA,KAEA,OAAA,SAGF,SAME,UAAA,EAEA,QAAA,EACA,OAAA,EACA,OAAA,EAKF,OACE,QAAA,MACA,MAAA,KACA,UAAA,KACA,QAAA,EACA,cAAA,MEjSI,UAAA,OFmSJ,YAAA,QACA,MAAA,QACA,YAAA,OAGF,SACE,eAAA,SGvIF,yCFGA,yCD0IE,OAAA,KGxIF,cHgJE,eAAA,KACA,mBAAA,KG5IF,yCHoJE,mBAAA,KAQF,6BACE,KAAA,QACA,mBAAA,OAOF,OACE,QAAA,aAGF,QACE,QAAA,UACA,OAAA,QAGF,SACE,QAAA,KGzJF,SH+JE,QAAA","sourcesContent":["/*!\n * Bootstrap Reboot v4.4.1 (https://getbootstrap.com/)\n * Copyright 2011-2019 The Bootstrap Authors\n * Copyright 2011-2019 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)\n */\n\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"reboot\";\n","// stylelint-disable at-rule-no-vendor-prefix, declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// 1. Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n// 2. Change the default font family in all browsers.\n// 3. Correct the line height in all browsers.\n// 4. Prevent adjustments of font size after orientation changes in IE on Windows Phone and in iOS.\n// 5. Change the default tap highlight to be completely transparent in iOS.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box; // 1\n}\n\nhtml {\n font-family: sans-serif; // 2\n line-height: 1.15; // 3\n -webkit-text-size-adjust: 100%; // 4\n -webkit-tap-highlight-color: rgba($black, 0); // 5\n}\n\n// Shim for \"new\" HTML5 structural elements to display correctly (IE10, older browsers)\n// TODO: remove in v5\n// stylelint-disable-next-line selector-list-comma-newline-after\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Set an explicit initial text-align value so that we can later use\n// the `inherit` value on things like `` elements.\n\nbody {\n margin: 0; // 1\n font-family: $font-family-base;\n @include font-size($font-size-base);\n font-weight: $font-weight-base;\n line-height: $line-height-base;\n color: $body-color;\n text-align: left; // 3\n background-color: $body-bg; // 2\n}\n\n// Future-proof rule: in browsers that support :focus-visible, suppress the focus outline\n// on elements that programmatically receive focus but wouldn't normally show a visible\n// focus outline. In general, this would mean that the outline is only applied if the\n// interaction that led to the element receiving programmatic focus was a keyboard interaction,\n// or the browser has somehow determined that the user is primarily a keyboard user and/or\n// wants focus outlines to always be presented.\n//\n// See https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible\n// and https://developer.paciellogroup.com/blog/2018/03/focus-visible-and-backwards-compatibility/\n[tabindex=\"-1\"]:focus:not(:focus-visible) {\n outline: 0 !important;\n}\n\n\n// Content grouping\n//\n// 1. Add the correct box sizing in Firefox.\n// 2. Show the overflow in Edge and IE.\n\nhr {\n box-sizing: content-box; // 1\n height: 0; // 1\n overflow: visible; // 2\n}\n\n\n//\n// Typography\n//\n\n// Remove top margins from headings\n//\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n// stylelint-disable-next-line selector-list-comma-newline-after\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: $headings-margin-bottom;\n}\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Remove the bottom border in Firefox 39-.\n// 5. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-original-title] { // 1\n text-decoration: underline; // 2\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n border-bottom: 0; // 4\n text-decoration-skip-ink: none; // 5\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // Undo browser default\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: $font-weight-bolder; // Add the correct font weight in Chrome, Edge, and Safari\n}\n\nsmall {\n @include font-size(80%); // Add the correct font size in all browsers\n}\n\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n//\n\nsub,\nsup {\n position: relative;\n @include font-size(75%);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n//\n// Links\n//\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n background-color: transparent; // Remove the gray background on active links in IE 10.\n\n @include hover() {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]) {\n color: inherit;\n text-decoration: none;\n\n @include hover() {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n//\n// Code\n//\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-monospace;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\npre {\n // Remove browser default top margin\n margin-top: 0;\n // Reset browser default of `1em` to use `rem`s\n margin-bottom: 1rem;\n // Don't allow content to break outside\n overflow: auto;\n}\n\n\n//\n// Figures\n//\n\nfigure {\n // Apply a consistent margin strategy (matches our type styles).\n margin: 0 0 1rem;\n}\n\n\n//\n// Images and content\n//\n\nimg {\n vertical-align: middle;\n border-style: none; // Remove the border on images inside links in IE 10-.\n}\n\nsvg {\n // Workaround for the SVG overflow bug in IE10/11 is still required.\n // See https://github.com/twbs/bootstrap/issues/26878\n overflow: hidden;\n vertical-align: middle;\n}\n\n\n//\n// Tables\n//\n\ntable {\n border-collapse: collapse; // Prevent double borders\n}\n\ncaption {\n padding-top: $table-cell-padding;\n padding-bottom: $table-cell-padding;\n color: $table-caption-color;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n // Matches default `` alignment by inheriting from the ``, or the\n // closest parent with a set `text-align`.\n text-align: inherit;\n}\n\n\n//\n// Forms\n//\n\nlabel {\n // Allow labels to use `margin` for spacing.\n display: inline-block;\n margin-bottom: $label-margin-bottom;\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n//\n// Details at https://github.com/twbs/bootstrap/issues/24093\nbutton {\n // stylelint-disable-next-line property-blacklist\n border-radius: 0;\n}\n\n// Work around a Firefox/IE bug where the transparent `button` background\n// results in a loss of the default `button` focus styles.\n//\n// Credit: https://github.com/suitcss/base/\nbutton:focus {\n outline: 1px dotted;\n outline: 5px auto -webkit-focus-ring-color;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // Remove the margin in Firefox and Safari\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible; // Show the overflow in Edge\n}\n\nbutton,\nselect {\n text-transform: none; // Remove the inheritance of text transform in Firefox\n}\n\n// Remove the inheritance of word-wrap in Safari.\n//\n// Details at https://github.com/twbs/bootstrap/issues/24990\nselect {\n word-wrap: normal;\n}\n\n\n// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n// controls in Android 4.\n// 2. Correct the inability to style clickable types in iOS and Safari.\nbutton,\n[type=\"button\"], // 1\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button; // 2\n}\n\n// Opinionated: add \"hand\" cursor to non-disabled button elements.\n@if $enable-pointer-cursor-for-buttons {\n button,\n [type=\"button\"],\n [type=\"reset\"],\n [type=\"submit\"] {\n &:not(:disabled) {\n cursor: pointer;\n }\n }\n}\n\n// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n box-sizing: border-box; // 1. Add the correct box sizing in IE 10-\n padding: 0; // 2. Remove the padding in IE 10-\n}\n\n\ninput[type=\"date\"],\ninput[type=\"time\"],\ninput[type=\"datetime-local\"],\ninput[type=\"month\"] {\n // Remove the default appearance of temporal inputs to avoid a Mobile Safari\n // bug where setting a custom line-height prevents text from being vertically\n // centered within the input.\n // See https://bugs.webkit.org/show_bug.cgi?id=139848\n // and https://github.com/twbs/bootstrap/issues/11266\n -webkit-appearance: listbox;\n}\n\ntextarea {\n overflow: auto; // Remove the default vertical scrollbar in IE.\n // Textareas should really only resize vertically so they don't break their (horizontal) containers.\n resize: vertical;\n}\n\nfieldset {\n // Browsers set a default `min-width: min-content;` on fieldsets,\n // unlike e.g. `

`s, which have `min-width: 0;` by default.\n // So we reset that to ensure fieldsets behave more like a standard block element.\n // See https://github.com/twbs/bootstrap/issues/12359\n // and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements\n min-width: 0;\n // Reset the default outline behavior of fieldsets so they don't affect page layout.\n padding: 0;\n margin: 0;\n border: 0;\n}\n\n// 1. Correct the text wrapping in Edge and IE.\n// 2. Correct the color inheritance from `fieldset` elements in IE.\nlegend {\n display: block;\n width: 100%;\n max-width: 100%; // 1\n padding: 0;\n margin-bottom: .5rem;\n @include font-size(1.5rem);\n line-height: inherit;\n color: inherit; // 2\n white-space: normal; // 1\n}\n\nprogress {\n vertical-align: baseline; // Add the correct vertical alignment in Chrome, Firefox, and Opera.\n}\n\n// Correct the cursor style of increment and decrement buttons in Chrome.\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n[type=\"search\"] {\n // This overrides the extra rounded corners on search inputs in iOS so that our\n // `.form-control` class can properly style them. Note that this cannot simply\n // be added to `.form-control` as it's not specific enough. For details, see\n // https://github.com/twbs/bootstrap/issues/11586.\n outline-offset: -2px; // 2. Correct the outline style in Safari.\n -webkit-appearance: none;\n}\n\n//\n// Remove the inner padding in Chrome and Safari on macOS.\n//\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n//\n// 1. Correct the inability to style clickable types in iOS and Safari.\n// 2. Change font properties to `inherit` in Safari.\n//\n\n::-webkit-file-upload-button {\n font: inherit; // 2\n -webkit-appearance: button; // 1\n}\n\n//\n// Correct element displays\n//\n\noutput {\n display: inline-block;\n}\n\nsummary {\n display: list-item; // Add the correct display in all browsers\n cursor: pointer;\n}\n\ntemplate {\n display: none; // Add the correct display in IE\n}\n\n// Always hide an element with the `hidden` HTML attribute (from PureCSS).\n// Needed for proper display in IE 10-.\n[hidden] {\n display: none !important;\n}\n","/*!\n * Bootstrap Reboot v4.4.1 (https://getbootstrap.com/)\n * Copyright 2011-2019 The Bootstrap Authors\n * Copyright 2011-2019 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)\n */\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-family: sans-serif;\n line-height: 1.15;\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n\nbody {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n text-align: left;\n background-color: #fff;\n}\n\n[tabindex=\"-1\"]:focus:not(:focus-visible) {\n outline: 0 !important;\n}\n\nhr {\n box-sizing: content-box;\n height: 0;\n overflow: visible;\n}\n\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title],\nabbr[data-original-title] {\n text-decoration: underline;\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n cursor: help;\n border-bottom: 0;\n -webkit-text-decoration-skip-ink: none;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 80%;\n}\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -.25em;\n}\n\nsup {\n top: -.5em;\n}\n\na {\n color: #007bff;\n text-decoration: none;\n background-color: transparent;\n}\n\na:hover {\n color: #0056b3;\n text-decoration: underline;\n}\n\na:not([href]) {\n color: inherit;\n text-decoration: none;\n}\n\na:not([href]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n font-size: 1em;\n}\n\npre {\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg {\n vertical-align: middle;\n border-style: none;\n}\n\nsvg {\n overflow: hidden;\n vertical-align: middle;\n}\n\ntable {\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.75rem;\n padding-bottom: 0.75rem;\n color: #6c757d;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n text-align: inherit;\n}\n\nlabel {\n display: inline-block;\n margin-bottom: 0.5rem;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus {\n outline: 1px dotted;\n outline: 5px auto -webkit-focus-ring-color;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\nselect {\n word-wrap: normal;\n}\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\nbutton:not(:disabled),\n[type=\"button\"]:not(:disabled),\n[type=\"reset\"]:not(:disabled),\n[type=\"submit\"]:not(:disabled) {\n cursor: pointer;\n}\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n box-sizing: border-box;\n padding: 0;\n}\n\ninput[type=\"date\"],\ninput[type=\"time\"],\ninput[type=\"datetime-local\"],\ninput[type=\"month\"] {\n -webkit-appearance: listbox;\n}\n\ntextarea {\n overflow: auto;\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n max-width: 100%;\n padding: 0;\n margin-bottom: .5rem;\n font-size: 1.5rem;\n line-height: inherit;\n color: inherit;\n white-space: normal;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n[type=\"search\"] {\n outline-offset: -2px;\n -webkit-appearance: none;\n}\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\ntemplate {\n display: none;\n}\n\n[hidden] {\n display: none !important;\n}\n/*# sourceMappingURL=bootstrap-reboot.css.map */","// stylelint-disable property-blacklist, scss/dollar-variable-default\n\n// SCSS RFS mixin\n//\n// Automated font-resizing\n//\n// See https://github.com/twbs/rfs\n\n// Configuration\n\n// Base font size\n$rfs-base-font-size: 1.25rem !default;\n$rfs-font-size-unit: rem !default;\n\n// Breakpoint at where font-size starts decreasing if screen width is smaller\n$rfs-breakpoint: 1200px !default;\n$rfs-breakpoint-unit: px !default;\n\n// Resize font-size based on screen height and width\n$rfs-two-dimensional: false !default;\n\n// Factor of decrease\n$rfs-factor: 10 !default;\n\n@if type-of($rfs-factor) != \"number\" or $rfs-factor <= 1 {\n @error \"`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.\";\n}\n\n// Generate enable or disable classes. Possibilities: false, \"enable\" or \"disable\"\n$rfs-class: false !default;\n\n// 1 rem = $rfs-rem-value px\n$rfs-rem-value: 16 !default;\n\n// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14\n$rfs-safari-iframe-resize-bug-fix: false !default;\n\n// Disable RFS by setting $enable-responsive-font-sizes to false\n$enable-responsive-font-sizes: true !default;\n\n// Cache $rfs-base-font-size unit\n$rfs-base-font-size-unit: unit($rfs-base-font-size);\n\n// Remove px-unit from $rfs-base-font-size for calculations\n@if $rfs-base-font-size-unit == \"px\" {\n $rfs-base-font-size: $rfs-base-font-size / ($rfs-base-font-size * 0 + 1);\n}\n@else if $rfs-base-font-size-unit == \"rem\" {\n $rfs-base-font-size: $rfs-base-font-size / ($rfs-base-font-size * 0 + 1 / $rfs-rem-value);\n}\n\n// Cache $rfs-breakpoint unit to prevent multiple calls\n$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);\n\n// Remove unit from $rfs-breakpoint for calculations\n@if $rfs-breakpoint-unit-cache == \"px\" {\n $rfs-breakpoint: $rfs-breakpoint / ($rfs-breakpoint * 0 + 1);\n}\n@else if $rfs-breakpoint-unit-cache == \"rem\" or $rfs-breakpoint-unit-cache == \"em\" {\n $rfs-breakpoint: $rfs-breakpoint / ($rfs-breakpoint * 0 + 1 / $rfs-rem-value);\n}\n\n// Responsive font-size mixin\n@mixin rfs($fs, $important: false) {\n // Cache $fs unit\n $fs-unit: if(type-of($fs) == \"number\", unit($fs), false);\n\n // Add !important suffix if needed\n $rfs-suffix: if($important, \" !important\", \"\");\n\n // If $fs isn't a number (like inherit) or $fs has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n @if not $fs-unit or $fs-unit != \"\" and $fs-unit != \"px\" and $fs-unit != \"rem\" or $fs == 0 {\n font-size: #{$fs}#{$rfs-suffix};\n }\n @else {\n // Variables for storing static and fluid rescaling\n $rfs-static: null;\n $rfs-fluid: null;\n\n // Remove px-unit from $fs for calculations\n @if $fs-unit == \"px\" {\n $fs: $fs / ($fs * 0 + 1);\n }\n @else if $fs-unit == \"rem\" {\n $fs: $fs / ($fs * 0 + 1 / $rfs-rem-value);\n }\n\n // Set default font-size\n @if $rfs-font-size-unit == rem {\n $rfs-static: #{$fs / $rfs-rem-value}rem#{$rfs-suffix};\n }\n @else if $rfs-font-size-unit == px {\n $rfs-static: #{$fs}px#{$rfs-suffix};\n }\n @else {\n @error \"`#{$rfs-font-size-unit}` is not a valid unit for $rfs-font-size-unit. Use `px` or `rem`.\";\n }\n\n // Only add media query if font-size is bigger as the minimum font-size\n // If $rfs-factor == 1, no rescaling will take place\n @if $fs > $rfs-base-font-size and $enable-responsive-font-sizes {\n $min-width: null;\n $variable-unit: null;\n\n // Calculate minimum font-size for given font-size\n $fs-min: $rfs-base-font-size + ($fs - $rfs-base-font-size) / $rfs-factor;\n\n // Calculate difference between given font-size and minimum font-size for given font-size\n $fs-diff: $fs - $fs-min;\n\n // Base font-size formatting\n // No need to check if the unit is valid, because we did that before\n $min-width: if($rfs-font-size-unit == rem, #{$fs-min / $rfs-rem-value}rem, #{$fs-min}px);\n\n // If two-dimensional, use smallest of screen width and height\n $variable-unit: if($rfs-two-dimensional, vmin, vw);\n\n // Calculate the variable width between 0 and $rfs-breakpoint\n $variable-width: #{$fs-diff * 100 / $rfs-breakpoint}#{$variable-unit};\n\n // Set the calculated font-size.\n $rfs-fluid: calc(#{$min-width} + #{$variable-width}) #{$rfs-suffix};\n }\n\n // Rendering\n @if $rfs-fluid == null {\n // Only render static font-size if no fluid font-size is available\n font-size: $rfs-static;\n }\n @else {\n $mq-value: null;\n\n // RFS breakpoint formatting\n @if $rfs-breakpoint-unit == em or $rfs-breakpoint-unit == rem {\n $mq-value: #{$rfs-breakpoint / $rfs-rem-value}#{$rfs-breakpoint-unit};\n }\n @else if $rfs-breakpoint-unit == px {\n $mq-value: #{$rfs-breakpoint}px;\n }\n @else {\n @error \"`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.\";\n }\n\n @if $rfs-class == \"disable\" {\n // Adding an extra class increases specificity,\n // which prevents the media query to override the font size\n &,\n .disable-responsive-font-size &,\n &.disable-responsive-font-size {\n font-size: $rfs-static;\n }\n }\n @else {\n font-size: $rfs-static;\n }\n\n @if $rfs-two-dimensional {\n @media (max-width: #{$mq-value}), (max-height: #{$mq-value}) {\n @if $rfs-class == \"enable\" {\n .enable-responsive-font-size &,\n &.enable-responsive-font-size {\n font-size: $rfs-fluid;\n }\n }\n @else {\n font-size: $rfs-fluid;\n }\n\n @if $rfs-safari-iframe-resize-bug-fix {\n // stylelint-disable-next-line length-zero-no-unit\n min-width: 0vw;\n }\n }\n }\n @else {\n @media (max-width: #{$mq-value}) {\n @if $rfs-class == \"enable\" {\n .enable-responsive-font-size &,\n &.enable-responsive-font-size {\n font-size: $rfs-fluid;\n }\n }\n @else {\n font-size: $rfs-fluid;\n }\n\n @if $rfs-safari-iframe-resize-bug-fix {\n // stylelint-disable-next-line length-zero-no-unit\n min-width: 0vw;\n }\n }\n }\n }\n }\n}\n\n// The font-size & responsive-font-size mixin uses RFS to rescale font sizes\n@mixin font-size($fs, $important: false) {\n @include rfs($fs, $important);\n}\n\n@mixin responsive-font-size($fs, $important: false) {\n @include rfs($fs, $important);\n}\n","/*!\n * Bootstrap Reboot v4.4.1 (https://getbootstrap.com/)\n * Copyright 2011-2019 The Bootstrap Authors\n * Copyright 2011-2019 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)\n */\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-family: sans-serif;\n line-height: 1.15;\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n\nbody {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n text-align: left;\n background-color: #fff;\n}\n\n[tabindex=\"-1\"]:focus:not(:focus-visible) {\n outline: 0 !important;\n}\n\nhr {\n box-sizing: content-box;\n height: 0;\n overflow: visible;\n}\n\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title],\nabbr[data-original-title] {\n text-decoration: underline;\n text-decoration: underline dotted;\n cursor: help;\n border-bottom: 0;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 80%;\n}\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -.25em;\n}\n\nsup {\n top: -.5em;\n}\n\na {\n color: #007bff;\n text-decoration: none;\n background-color: transparent;\n}\n\na:hover {\n color: #0056b3;\n text-decoration: underline;\n}\n\na:not([href]) {\n color: inherit;\n text-decoration: none;\n}\n\na:not([href]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n font-size: 1em;\n}\n\npre {\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg {\n vertical-align: middle;\n border-style: none;\n}\n\nsvg {\n overflow: hidden;\n vertical-align: middle;\n}\n\ntable {\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.75rem;\n padding-bottom: 0.75rem;\n color: #6c757d;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n text-align: inherit;\n}\n\nlabel {\n display: inline-block;\n margin-bottom: 0.5rem;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus {\n outline: 1px dotted;\n outline: 5px auto -webkit-focus-ring-color;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\nselect {\n word-wrap: normal;\n}\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\nbutton:not(:disabled),\n[type=\"button\"]:not(:disabled),\n[type=\"reset\"]:not(:disabled),\n[type=\"submit\"]:not(:disabled) {\n cursor: pointer;\n}\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n box-sizing: border-box;\n padding: 0;\n}\n\ninput[type=\"date\"],\ninput[type=\"time\"],\ninput[type=\"datetime-local\"],\ninput[type=\"month\"] {\n -webkit-appearance: listbox;\n}\n\ntextarea {\n overflow: auto;\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n max-width: 100%;\n padding: 0;\n margin-bottom: .5rem;\n font-size: 1.5rem;\n line-height: inherit;\n color: inherit;\n white-space: normal;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n[type=\"search\"] {\n outline-offset: -2px;\n -webkit-appearance: none;\n}\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\ntemplate {\n display: none;\n}\n\n[hidden] {\n display: none !important;\n}\n\n/*# sourceMappingURL=bootstrap-reboot.css.map */","// Hover mixin and `$enable-hover-media-query` are deprecated.\n//\n// Originally added during our alphas and maintained during betas, this mixin was\n// designed to prevent `:hover` stickiness on iOS-an issue where hover styles\n// would persist after initial touch.\n//\n// For backward compatibility, we've kept these mixins and updated them to\n// always return their regular pseudo-classes instead of a shimmed media query.\n//\n// Issue: https://github.com/twbs/bootstrap/issues/25195\n\n@mixin hover() {\n &:hover { @content; }\n}\n\n@mixin hover-focus() {\n &:hover,\n &:focus {\n @content;\n }\n}\n\n@mixin plain-hover-focus() {\n &,\n &:hover,\n &:focus {\n @content;\n }\n}\n\n@mixin hover-focus-active() {\n &:hover,\n &:focus,\n &:active {\n @content;\n }\n}\n"]} --------------------------------------------------------------------------------