第一次写文章,用做我的记录和分享交流,很差之处还请谅解。因本人喜好吃都城(健康),在公司叫的外卖都是都城,而后愈来愈多人跟着我点,并且每次都是我去统计人数,每一个人点餐详情,我都是经过企业微信最后汇总到txt文本上再去打电话叫外卖,最后跟都城工做人员确认防止多点少点(真是一把辛酸泪,谁让我这么伟大呢?)。后来本人以为太麻烦了,便抽了点时间去开发一个专为都城点餐的PC端系统,主要为了方便本身。javascript
首页
![]()
菜单列表页
![]()
聊天页
![]()
github: https://github.com/FEA-Dven/d...css
线上: https://dywsweb.com/food/login (帐号:admin, 密码:123)前端
前端: react + antd java
后端: nodejs + koa2node
|---ducheng 最外层项目目录 |---fontend 前端项目 |---app 主要项目代码 |---api 请求api |---assets 资源管理 |---libs 包含公用函数 |---model redux状态管理 |---router 前端路由 |---style 前端样式 |---views 前端页面组件 |---chat 聊天页 |---component 前端组件 |---index 订餐系统首页 |---login 登陆页 |---App.js |---config.js 前端域名配置 |---main.js 项目主函数 |---fontserver 前端服务 |---config 前端服务配置 |---controller 前端服务控制层 |---router 前端服务路由 |---utils 前端服务公用库 |---views 前端服务渲染模板 |---app.js 前端服务主函数 |---node_modules |---.babelrc |---.gitignore |---gulpfile.js |---package.json |---pm2.prod.json 构建线上的前端服务pm2配置 |---README.md |---webpack.config.js 构建配置 |---backend 后台项目 |---app 主要项目代码 |---controller 控制层 |---model 模型层(操做数据库) |---service 服务层 |---route 路由 |---validation 参数校验 |---config 服务配置参数 |---library 定义类库 |---logs 存放日志 |---middleware 中间件 |---node_modules |---sql 数据库sql语句在这里 |---util 公共函数库 |---app.js 项目主函数 |---package.json
if (isDev) { // koawebpack模快 let koaWebpack = require('koa-webpack-middleware') let devMiddleware = koaWebpack.devMiddleware let hotMiddleware = koaWebpack.hotMiddleware let clientCompiler = require('webpack')(webpackConfig) app.use(devMiddleware(clientCompiler, { stats: { colors: true }, publicPath: webpackConfig.output.publicPath, })) app.use(hotMiddleware(clientCompiler)) } app.use(async function(ctx, next) { //设置环境和打包资源路径 if (isDev) { let assets ={} const publicPath = webpackConfig.output.publicPath assets.food = { js : publicPath + `food.js` } ctx.assets = assets } else { ctx.assets = require('../build/assets.json') } await next() })
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length / 2 }); //根据CPU线程数建立线程池
plugins: [ new HappyPack({ id: 'happyBabel', loaders: [{ loader: 'babel-loader?cacheDirectory=true', }], threadPool: happyThreadPool, verbose: true, }), new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify(env), }) ].concat(isDev?[ new webpack.HotModuleReplacementPlugin(), ]:[ new AssetsPlugin({filename: './build/assets.json'}), new webpack.optimize.ModuleConcatenationPlugin(), new MiniCssExtractPlugin({ filename: '[name].[hash:8].css', chunkFilename: "[id].[hash:8].css" }), ]),
function requireAuthentication(Component) { // 组件有已登录的模块 直接返回 (防止重新渲染) if (Component.AuthenticatedComponent) { return Component.AuthenticatedComponent } // 建立验证组件 class AuthenticatedComponent extends React.Component { state = { login: true, } componentWillMount() { this.checkAuth(); } componentWillReceiveProps(nextProps) { this.checkAuth(); } checkAuth() { // 未登录重定向到登录页面 let login = UTIL.shouldRedirectToLogin(); if (login) { window.location.href = '/food/login'; return; } this.setState({ login: !login }); } render() { if (this.state.login) { return <Component {...this.props} /> } return '' } } return AuthenticatedComponent }
思路:这个权限校验的组件将其余组件设为参数传入,当加载页面的时候,权限校验组件会先进行权限校验,当浏览器没有cookie指定的参数时,直接返回登陆页
<Provider store={store}> <Router history={browserHistory} > <Switch> <Route path="/food/login" exact component={Login}/> <Route path="/food/index" component={requireAuthentication(Index)}/> <Route path="/food/chat" component={requireAuthentication(Chat)}/> <Route component={Nomatchpage}/> </Switch> </Router> </Provider>
{ test: /\.less|\.css$/, use: [ { loader: isDev ? 'style-loader' : MiniCssExtractPlugin.loader }, { loader: "css-loader" }, { loader: "less-loader", options: { javascriptEnabled: true, modifyVars: { 'primary-color': '#0089ce', 'link-color': '#0089ce' }, } } ] }
主要分为 controller层, service层, model层。
this.readMysql = new Knex({ client: 'mysql', debug: dbConfig.plat_read_mysql.debug, connection: { host: dbConfig.plat_read_mysql.host, user: dbConfig.plat_read_mysql.user, password: dbConfig.plat_read_mysql.password, database: dbConfig.plat_read_mysql.database, timezone: dbConfig.plat_read_mysql.timezone, }, pool: { min: dbConfig.plat_read_mysql.minConnection, max: dbConfig.plat_read_mysql.maxConnection }, }); this.writeMysql = new Knex({ client: 'mysql', debug: dbConfig.plat_write_mysql.debug, connection: { host: dbConfig.plat_write_mysql.host, user: dbConfig.plat_write_mysql.user, password: dbConfig.plat_write_mysql.password, database: dbConfig.plat_write_mysql.database, timezone: dbConfig.plat_write_mysql.timezone, }, pool: { min: dbConfig.plat_write_mysql.minConnection, max: dbConfig.plat_write_mysql.maxConnection }, });
checkHeader: async function(ctx, next) { await validator.validate( ctx.headerInput, userValidation.checkHeader.schema, userValidation.checkHeader.options ) let cacheUserInfo = await db.redis.get(foodKeyDefines.userInfoCacheKey(ctx.headerInput.fid)) cacheUserInfo = UTIL.jsonParse(cacheUserInfo); // 若是没有redis层用户信息和token信息不对称,须要用户从新登陆 if (!cacheUserInfo || ctx.headerInput.token !== cacheUserInfo.token) { throw new ApiError('food.userAccessTokenForbidden'); } await next(); }
使用鉴权中间件,拿一个路由做为例子
//引入 const routePermission = require('../../middleware/routePermission.js'); // 用户点餐 router.post('/api/user/order', routePermission.checkHeader, userMenuController.userOrder);
定义一个请求错误类
class ApiError extends Error { /** * 构造方法 * @param errorName 错误名称 * @param params 错误信息参数 */ constructor(errorName, ...params) { super(); let errorInfo = apiErrorDefines(errorName, params); this.name = errorName; this.code = errorInfo.code; this.status = errorInfo.status; this.message = errorInfo.message; } }
错误码定义
const defines = { 'common.all': {code: 1000, message: '%s', status: 500}, 'request.paramError': {code: 1001, message: '参数错误 %s', status: 200}, 'access.forbidden': {code: 1010, message: '没有操做权限', status: 403}, 'auth.notPermission': {code: 1011, message: '受权失败 %s', status: 403}, 'role.notExist': {code: 1012, message: '角色不存在', status: 403}, 'auth.codeExpired': {code: 1013, message: '受权码已失效', status: 403}, 'auth.codeError': {code: 1014, message: '受权码错误', status: 403}, 'auth.pargramNotExist': {code: 1015, message: '程序不存在', status: 403}, 'auth.pargramSecretError': {code: 1016, message: '程序秘钥错误', status: 403}, 'auth.pargramSecretEmpty': {code: 1016, message: '程序秘钥为空,请后台配置', status: 403}, 'db.queryError': { code: 1100, message: '数据库查询异常', status: 500 }, 'db.insertError': { code: 1101, message: '数据库写入异常', status: 500 }, 'db.updateError': { code: 1102, message: '数据库更新异常', status: 500 }, 'db.deleteError': { code: 1103, message: '数据库删除异常', status: 500 }, 'redis.setError': { code: 1104, message: 'redis设置异常', status: 500 }, 'food.illegalUser' : {code: 1201, message: '非法用户', status: 403}, 'food.userHasExist' : {code: 1202, message: '用户已经存在', status: 200}, 'food.objectNotExist' : {code: 1203, message: '%s', status: 200}, 'food.insertMenuError': {code: 1204, message: '批量插入菜单失败', status: 200}, 'food.userNameInvalid': {code: 1205, message: '我不信你叫这个名字', status: 200}, 'food.userOrderAlready': {code: 1206, message: '您已经定过餐了', status: 200}, 'food.userNotOrderToday': {code: 1207, message: '您今天尚未订餐', status: 200}, 'food.orderIsEnd': {code: 1208, message: '订餐已经截止了,欢迎下次光临', status: 200}, 'food.blackHouse': {code: 1209, message: '别搞太多骚操做', status: 200}, 'food.userAccessTokenForbidden': { code: 1210, message: 'token失效', status: 403 }, 'food.userHasStared': { code: 1211, message: '此评论您已点过赞', status: 200 }, 'food.canNotReplySelf': { code: 1212, message: '不能回复本身的评论', status: 200 }, 'food.overReplyLimit': { code: 1213, message: '回复评论数已超过%s条,不能再回复', status: 200 } }; module.exports = function (errorName, params) { if(defines[errorName]) { let result = { code: defines[errorName].code, message: defines[errorName].message, status: defines[errorName].status }; params.forEach(element => { result.message = (result.message).replace('%s', element); }); return result; } return { code: 1000, message: '服务器内部错误', status: 500 }; }
当程序判断到有错误产生时,能够抛出错误给到前端,例如token不正确。mysql
// 若是没有redis层用户信息和token信息不对称,须要用户从新登陆 if (!cacheUserInfo || ctx.headerInput.token !== cacheUserInfo.token) { throw new ApiError('food.userAccessTokenForbidden'); }
由于程序有一个回调处理的中间件,因此能捕捉到定义的ApiError
// requestError.js module.exports = async function (ctx, next) { let beginTime = new Date().getTime(); try { await next(); let req = ctx.request; let res = ctx.response; let input = ctx.input; let endTime = new Date().getTime(); let ip = req.get("X-Real-IP") || req.get("X-Forwarded-For") || req.ip; let fields = { status: res.status, accept: req.header['accept'], cookie: req.header['cookie'], ua: req.header['user-agent'], method: req.method, headers: ctx.headers, url: req.url, client_ip: ip, cost: endTime - beginTime, input: input }; logger.getLogger('access').trace('requestSuccess', fields); } catch (e) { if (e.code === 'ECONNREFUSED') { //数据库链接失败 logger.getLogger('error').fatal('mysql链接失败', e.message, e.code); e.code = 1; e.message = '数据库链接异常'; } if (e.code === 'ER_DUP_ENTRY') { logger.getLogger('error').error('mysql操做异常', e.message, e.code); e.code = 1; e.message = '数据库操做违反惟一约束'; } if (e.code === 'ETIMEDOUT') { logger.getLogger('error').error('mysql操做异常', e.message, e.code); e.code = 1; e.message = '数据库链接超时'; } let req = ctx.request; let res = ctx.response; let status = e.status || 500; let msg = e.message || e; let input = ctx.input; let endTime = new Date().getTime(); let ip = req.get("X-Real-IP") || req.get("X-Forwarded-For") || req.ip; let fields = { status: res.status, accept: req.header['accept'], cookie: req.header['cookie'], ua: req.header['user-agent'], method: req.method, headers: ctx.headers, url: req.url, client_ip: ip, cost: endTime - beginTime, input: input, msg: msg }; ctx.status = status; if (status === 500) { logger.getLogger('access').error('requestError', fields); } else { logger.getLogger('access').warn('requestException', fields); } let errCode = e.code || 1; if (!(parseInt(errCode) > 0)) { errCode = 1; } return response.output(ctx, {}, errCode, msg, status); } };
在app.js中引入中间件
/** * 请求回调处理中间件 */ app.use(require('./middleware/requestError.js'));
CREATE DATABASE food_program; USE food_program; # 用户表 CREATE TABLE t_food_user( fid int(11) auto_increment primary key COMMENT '用户id', user_name varchar(255) NOT NULL COMMENT '用户昵称', password varchar(255) NOT NULL COMMENT '用户密码', role TINYINT(2) DEFAULT 0 COMMENT '用户角色(项目关系,没有用关联表)', create_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '建立时间', update_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '修改时间', status TINYINT(2) DEFAULT 1 NOT NULL COMMENT '状态 0:删除, 1:正常', UNIQUE KEY `uidx_fid_user_name` (`fid`,`user_name`) USING BTREE )ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'food 用户表' ; CREATE TABLE t_food_menu( menu_id int(11) auto_increment primary key COMMENT '菜单id', menu_name varchar(255) NOT NULL COMMENT '菜单昵称', type TINYINT(2) DEFAULT 0 NOT NULL COMMENT '状态 0:每日菜单, 1:常规, 2:明炉烧腊', price int(11) NOT NULL COMMENT '价格', status TINYINT(2) DEFAULT 1 NOT NULL COMMENT '状态 0:删除, 1:正常', create_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '建立时间', update_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '修改时间', UNIQUE KEY `uidx_menu_id_menu_name` (`menu_id`,`menu_name`) USING BTREE, UNIQUE KEY `uidx_menu_id_menu_name_type` (`menu_id`,`menu_name`,`type`) USING BTREE )ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'food 菜单列表' ; CREATE TABLE t_food_user_menu_refs( id int(11) auto_increment primary key COMMENT '记录id', fid int(11) NOT NULL COMMENT '用户id', menu_id int(11) NOT NULL COMMENT '菜单id' create_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '建立时间', update_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '修改时间', status TINYINT(2) DEFAULT 1 NOT NULL COMMENT '状态 0:删除, 1:正常', KEY `idx_fid_menu_id` (`fid`,`menu_id`) USING BTREE, KEY `idx_fid_menu_id_status` (`fid`,`menu_id`,`status`) USING BTREE )ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户选择什么菜单' ; CREATE TABLE t_food_system( id int(11) auto_increment primary key COMMENT '系统id', order_end TINYINT(2) DEFAULT 0 NOT NULL COMMENT '订单是否截止', update_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '修改时间' )ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '都城订单系统' ; CREATE TABLE t_food_comment( comment_id int(11) auto_increment primary key COMMENT '评论id', fid int(11) NOT NULL COMMENT '用户id', content TEXT COMMENT '评论内容', star int(11) DEFAULT 0 NOT NULL COMMENT '点赞数', create_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '建立时间', update_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '修改时间' )ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '都城聊天表' ; CREATE TABLE t_food_reply( reply_id int(11) auto_increment primary key COMMENT '回复id', reply_fid int(11) NOT NULL COMMENT '回复用户fid', comment_fid int(11) NOT NULL COMMENT '评论用户fid', content TEXT COMMENT '回复内容', create_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '建立时间', update_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '修改时间', KEY `idx_reply_fid_comment_fid` (`reply_fid`,`comment_fid`) USING BTREE )ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '都城聊天表' ; CREATE TABLE t_food_comment_star_refs( id int(11) auto_increment primary key COMMENT '关系id', comment_id int(11) NOT NULL COMMENT '评论id', comment_fid int(11) NOT NULL COMMENT '用户id', star_fid int(11) NOT NULL COMMENT '点赞用户fid', create_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '建立时间', update_time BIGINT(20) DEFAULT 0 NOT NULL COMMENT '修改时间', UNIQUE KEY `idx_comment_id_fid_star_fid` (`comment_id`,`fid`,`star_fid`) USING BTREE )ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '都城评论点赞关联表' ;
npm run dev
http://localhost:3006/food/login
npm install pm2 -gnpm run buildreact
会生成一个build的文件夹,里面是线上须要用到的资源webpack
// /opt/food/fontend/build/ 是npm run build的文件夹路径 location /assets/ { alias /opt/food/fontend/build/; } location / { proxy_pass http://127.0.0.1:3006/; }
pm2 start pm2.prod.json
pm2 start app.js --watch
开启 --watch 模式监听项目日志nginx
pm2 start app.js
千万不要开启 --watch,由于没请求一次服务会刷新产生数据库和redis重连,致使报错git
开发完这个系统用了三个星期遇上寒冬我就离职了...而后去面试一些公司拿这个小玩意给面试官看,HR挺满意的,就是不知道技术官满不满意。
欢迎你们来交流哦~