第一次寫文章,用做我的記錄和分享交流,很差之處還請諒解。因本人喜好吃都城(健康),在公司叫的外賣都是都城,而後愈來愈多人跟着我點,並且每次都是我去統計人數,每一個人點餐詳情,我都是經過企業微信最後彙總到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挺滿意的,就是不知道技術官滿不滿意。
歡迎你們來交流哦~