react + koa2打造點餐系統

前言

第一次寫文章,用做我的記錄和分享交流,很差之處還請諒解。因本人喜好吃都城(健康),在公司叫的外賣都是都城,而後愈來愈多人跟着我點,並且每次都是我去統計人數,每一個人點餐詳情,我都是經過企業微信最後彙總到txt文本上再去打電話叫外賣,最後跟都城工做人員確認防止多點少點(真是一把辛酸淚,誰讓我這麼偉大呢?)。後來本人以爲太麻煩了,便抽了點時間去開發一個專爲都城點餐的PC端系統,主要爲了方便本身。javascript

涉及功能點

  1. 登陸註冊修改帳號密碼
  2. 查看訂餐列表
  3. 點餐功能
  4. 簡單聊天功能
  5. 評論功能
  6. 點贊功能
  7. 刪除評論功能
  8. 查看當天全部訂單詳情功能

項目圖片

首頁
圖片描述
菜單列表頁
圖片描述
聊天頁
圖片描述

項目地址

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

前端項目小結

一、搭建本身的服務

  1. 項目沒有用到腳手架,而是本身搭建前端服務器,也是koa2框架。經過koa2解析webpack配置,經過webpack打包生成資源,而後前端服務將資源引入到xtpl中達到渲染效果。
  2. 搭建本身的服務器也有好處,能夠解決跨域問題,或者經過node做爲中間層請求後臺服務器。嗯,本項目這些好處都沒有用到。
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()
})

二、引入HappyPack快速打包

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>

四、經過webpack設置主題色

{
    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'
                },
            }
        }
    ]
}

五、其餘

  1. 網頁保存cookie的用戶id,請求時放入header帶去服務器,識別哪一個用戶操做
  2. 每一個頁面都是零散的組件拼起來,因此組件之間的數據要處理好

後端項目小結

框架設計

主要分爲 controller層, service層, model層。
  1. controller層做用於接收參數,而後作參數校驗,再將參數傳入到service層作業務邏輯
  2. service層作業務邏輯
  3. model層調用數據庫

數據庫詳情

  1. 數據庫用的是mysql
  2. 查詢數據庫用的是SQL查詢構建器Knex
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
    },
});
  1. 上面代碼用了兩個查詢構造器區分寫入數據庫動做和讀取數據庫動做

寫一個鑑權的中間件

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'));

數據庫建立sql(命名不規範,請見諒)

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 -g

npm run buildreact

會生成一個build的文件夾,裏面是線上須要用到的資源webpack

nginx設置

// /opt/food/fontend/build/ 是npm run build的文件夾路徑
location /assets/ {
   alias /opt/food/fontend/build/;
}
location / {
  proxy_pass http://127.0.0.1:3006/;
}

使用pm2開啓項目

pm2 start pm2.prod.json

後端部署

本地開發

pm2 start app.js --watch

開啓 --watch 模式監聽項目日誌nginx

線上部署

pm2 start app.js

千萬不要開啓 --watch,由於沒請求一次服務會刷新產生數據庫和redis重連,致使報錯git

結尾

開發完這個系統用了三個星期遇上寒冬我就離職了...而後去面試一些公司拿這個小玩意給面試官看,HR挺滿意的,就是不知道技術官滿不滿意。

歡迎你們來交流哦~

相關文章
相關標籤/搜索