用 Express & Sequelize 做一個餐廳網站


Posted by Christy on 2022-02-17

本文為使用後端框架實作一個餐廳網站的實作過程,主要改寫 Lidemy 程式導師計畫 W6 - W8 做的餐廳網站,並有個後台管理頁面,可以管理菜單品項以及抽獎獎項,有新增、刪除、修改等功能。

零、內容摘要

0. 實作前的想法

參考筆記 用後端好朋友們 express & sequelize 實作部落格利用後端框架 express 實作抽獎網站的 API常見重點整理

如果不計入挑戰題的話,就是上一週的作業再練習一次

我把網站改成全英文的,風格的部份之後自己做專案就可以自己決定了。

CSS 跟 bootstrap 混在一起寫,不是太好,之後專案改進。

本來要把這份作業當作新的專案來做,可是想想我應該把這個作業當作「想要學習實作功能」,之後自己的專案再自行規劃版面,這樣好像會比較順利。

1. 思考產品全貌,規劃功能與路由

2. 規劃資料庫結構

3. 設置環境

4. 開始實作

a. 用了後端好朋友以後,models 裡面的東西就不用管了

b. 先把畫面實作出來

c. index.js 路由

d. 實作 controllers

5. 遇到的困難

6. 心得

一、思考餐廳網站全貌

1. 需要的功能

應該要思考整個網站的大方向,一次一併思考完,而不是一個個作業分開思考,一次想完會比較省事。

a. 前台的頁面

  • 左上角 Just A Bite 首頁

  • 抽個大獎

    • 改成跟 W17 一樣的方法
  • 我要點餐

    • 前台新增「我要點餐」那頁,包含以下:

      1. 名稱

      2. 價格

      3. 圖片

  • 查詢訂單

    • 我要點餐設計稿:身為一個顧客,我希望登入後,點擊首頁商品的「加入購物車」,可以新增品項至購物車。

    • 購物清單設計稿:身為一個顧客,我希望在查詢訂單的頁面,點擊訂單編號,可以顯示購物清單,能夠刪除單獨品項,以及填寫帳單資訊。

    • 我的訂單設計稿:身為一個顧客,我希望在查詢訂單的頁面,可以顯示「我的訂單」,裡面有訂單編號、訂單日期、數量、金額、狀態等訂單狀況。

    • 訂單明細設計稿:身為一個顧客,我希望點擊訂單編號,可以顯示訂單明細,裡面有縮圖、商品名稱、價格、數量、小計,並且有帳單資訊。

    • 身為一個顧客,我希望可以在結帳頁面結帳。(串接金流)

    • 身為一個顧客,我希望訂單成立後,網站會寄一封信給我,裡面提供訂單資訊以及連結,點了連結之後可以查看訂單資訊。

  • 常見問題

b. 後台的頁面

  • 左上角 Just A Bite 首頁

  • CMS

  • 登出

    • 抽獎管理

      • 有一個後台,可以在後台新增抽獎品項並且設定機率。

        詳細需求如下:

        1. 身為一個管理員,我希望有一個抽獎頁面讓我管理獎項

        2. 身為一個管理員,我希望可以在後台新增抽獎的品項(名字、圖片網址以及說明)以及機率

        3. 身為一個管理員,我希望可以在後台編輯抽獎的品項(名字、圖片網址以及說明)以及機率

        4. 身為一個管理員,我希望可以在後台刪除抽獎的品項

        5. 身為一個管理員,我希望在前台能夠抽出我在後台所設定的獎項

        由於 API 的規則會跟第八週的 API 長不一樣,所以第八週的東西可能會有小幅調整。

    • 餐點管理

      • 新增品項

      • 刪除品項

      • 編輯品項

      上傳圖片的部分請參考 week17 的延伸挑戰題,如果上傳圖片不好做的話,也可以改成填入圖片網址就好。

    • 訂單管理

    • 問題管理

      • 做個後台可以管理常見問題列表,然後在前台的部分動態載入這些資料。

        1. 身為一個管理員,我希望常見問題的內容可以儲存在資料庫,這樣我才能方便修改

        2. 身為一個管理員,我希望管理後台可以管理常見問題,這樣我才能方便修改

        3. 身為一個管理員,我希望在後台可以新增常見問題,會有標題跟內容以及順序

        4. 身為一個管理員,我希望在後台可以編輯常見問題,包括標題跟內容以及順序

        5. 身為一個管理員,我希望在後台可以刪除常見問題

        6. 身為一個管理員,我希望前端頁面的資料是從後端拿的,這樣才能跟後台連動

2. 思考路由設計

a. 登入、登出功能

開放註冊及登入、登出

a.1 登入:

get: /login

post: /login → /

a.2 登出:get: /logout

a.3 註冊:get: /signup

b. CRUD 夥伴們

b.1 抽獎:

  • b.1.1 新增:post: /create_draw/id → /

  • b.1.2 刪除:get: /delete_draw/id → /

  • b.1.3 編輯:

    • get /edit_draw/id → form

    • post /edit_draw/id → /

b.2 菜單:

  • b.2.1 新增:post: /create_menu/id → /

  • b.2.2 刪除:get: /delete_menu/id → /

  • b.2.3 編輯:

    • get /edit_menu/id → form

    • post /edit_menu/id → /

b.3 訂單:

  • b.3.1 新增:post: /create_order/id → /

  • b.3.2 刪除:get: /delete_order/id → /

  • b.3.3 編輯:

    • get /edit_order/id → form

    • post /edit_order/id → /

b.4 問答:

  • b.4.1 新增:post: /create_question/id → /

  • b.4.2 刪除:get: /delete_question/id → /

  • b.4.3 編輯:

    • get /edit_question/id → form

    • post /edit_question/id → /

二、規劃資料庫結構

Sequelize 沒有 number,用了會報錯;另外 enum 的用法我還不熟悉,不知道為什麼無法用 Sequelize.ENUM('value 1', 'value 2')Datatypes

1. 抽獎:item, content, url, ratio

$ npx sequelize-cli model:generate --name Prize --attributes item:text,content:text,url:text,ratio:integer

2. 使用者:id, username, password, createdAt, updatedAt

$ npx sequelize-cli model:generate --name User --attributes username:string,password:string

3. 菜單:item, price, photo

$ npx sequelize-cli model:generate --name Menu --attributes item:string,price:integer,photo:string

4. 訂單:po, item, quantity, price, amount, freight, status

$ npx sequelize-cli model:generate --name Order --attributes po:string,item:string,quantity:integer,price:integer,amount:integer,freight:integer,status:string

5. 問答:sequence, title, content

$ npx sequelize-cli model:generate --name Question --attributes sequence:integer,title:string,content:text

三、設置環境

註:安裝 eslint 那些的部分,參考 EsLint 我們重頭來過吧,如果已經 init 過了,就不需要再一次了,因此我從步驟三開始。

1. $ npm init -y

2. $ npm install eslint prettier prettier-eslint,接著再 eslint --init

3. $ npm install express,接著再 $ npm install ejs body-parser express-session connect-flash bcrypt

4. $ npm install --save sequelize mysql2

5. $ npm install --save-dev sequelize-cli

6. $ npx sequelize-cli init

7. 改 config 檔案 development

8. 建立四個 models,參考上面寫好的資料庫結構

註:別忘記要連進 Xampp,建立 3306 port 的連線,並且建立好資料庫

9. Running Migrations $ npx sequelize-cli db:migrate

註:到這裡後,資料庫裡面應該會有剛剛建的五個表格了

四、開始實作

1. 建立 MVC 資料夾、index.js 引入基本設置、先把首頁做好、連結 css 檔案

2. 獎項 view & controller 之間調整

3. Q&A view & controller 之間調整

顯示 FAQ 頁面,原本估計兩小時,只花了 30 分鐘,主要就是不熟悉轉移檔案的方式。

到目前為止,表訂的作業一大致上完成了 🎉

4. 菜單頁面:全部

作業二完成了,但是欠缺上傳圖片這個功能。

5. 註冊、登入頁面

重複註冊:未解決

6. 後台:FAQ、draw & menu

終於把後台的問答跟抽獎頁面做好了,然後也把程式碼都重構成 async await 了,開心。

原來常見問題管理後台是作業三,那這樣我也把作業三寫好囉,耶。

五、遇到的困難 Errors

Error log 1:

ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ' `photo` TEXT, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, ...' at line 1

問題描述:在使用 Sequelize 建立資料庫時,出現上述錯誤訊息

Try 1:

把原本的版本:

$ npx sequelize-cli model:generate --name Menu --attributes item:text,price:number,photo:text

改成:

$ npx sequelize-cli model:generate --name Menu --attributes item:string,price:number,photo:string

然後記得要去 migrations 裡面手動把有更動的地方改掉。

結論:沒有效果,還是一直有錯

Try 2:

全部刪掉重裝,這次資料庫建好了,不知道為什麼。

最後 status:enum 沒有預設值,所以先改成 string,之後再改吧。

終於可以開始了。

小結:Sequelize 不可以用 number,參考 Datatypes

Bugs:

1. 抽獎的獎項不該是 hard code,應是動態產生

<div class="draw__prize-content">
  <% prizes.forEach(function(prize) { %>
    <div>❤ <%= `${prize.item}: ${prize.content}`%></div>
  <% }) %>
</div>

2. 抽獎完獎項的圖片沒有取代原本的背景圖片

3. 獎品敘述應該為標題、內文、再抽一次的按鈕

用 bootstrap inline style 的方式,把背景圖片寫進去了

<div class="prize__result bg-image" style="background-image:url(${result.url})">
  <div class="text-center prize__result-content">
    <div class="card-body">
      <h1 class="card-text">${result.item}</h1>
      <h4 class="card-text">${result.content}</h4>
      <button class="draw__btn"><a href="/draw">Draw again!</a></button>
    </div>
  </div>
</div>

4. menu 頁面三個小圖無法置中,用了 bootstrap 的 class= "w-75"

5. 原來往下箭頭可以這樣寫:

// html
<div class="arrow">
  <h1>Find Us</h1>
</div>
// css
.arrow {
  font-size: 18px;
  color: #414141;
  margin: 1rem auto 6rem;
  display: flex;
  justify-content: center;
  align-items: inherit;
  position: relative;
}

.arrow::after {
  content: "";
  width: 10px;
  height: 10px;
  border-bottom: 1px solid #000;
  border-right: 1px solid #000;
  transform: rotate(45deg);
  position: absolute;
  top: 180%;
}

.arrow::before {
  content: "";
  width: 15px;
  height: 15px;
  border-bottom: 1px solid #000;
  border-right: 1px solid #000;
  transform: rotate(45deg);
  position: absolute;
  top: 150%;
}

6. 密碼用 bcrypt 加密,還有這種寫法

const hashPassword = bcrypt.hashSync(password, saltRounds);

7. 後台除了畫面以外,也要記得做權限管理

<% if (username === 'admin') { %>
  ...
<% } %>

註:後端必須一定要做,可以參考下面的第十一個 bug 紀錄。

8. menu 圖片動態顯示問題

menu 的圖片顯示,暫時用 http://localhost:5001/images/menu/menu-4.png + <img src="<%= menu.photo %>"> 偽裝成動態顯示,再來想想怎麼解決。

註:後來發現寫成 http://images/menu/menu-4.png 就可以了。

9. menu 頁面的圖片間距、售完字樣

<div class="wrapper">
  <section class="section">
    <title>Hot sale NOW!</title>
    <div class="menu__dishes">
      <div class="menu__dish"><img src="images/menu/menu-1.png"/></div>
      <div class="menu__dish"><img src="images/menu/menu-2.png"/></div>
      <div class="menu__dish"><img src="images/menu/menu-3.png"/></div>
    </div>
  </section>
</div>

.menu__dishes {
  display: flex;
  justify-content: center;  
}

.menu__dish {
  width: 100%;
  position: relative;
}

.menu__dish > img {
  max-width: 420px;
  width: 100%;
  filter: brightness(105%) opacity(0.5);
}

.menu__dish::before {
  content: "Sold Out";
  position: absolute;
  display: block;
  z-index: 2;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 80px;
  height: 80px;
  line-height: 80px;
  border-radius: 80%;
  background-color: rgb(49, 45, 45);
  color: #fefefe;
}

10. 重複註冊問題

結論就是,我必須搞清楚,設定資料庫時,哪些欄位的相對應設定,這樣才能治本。

比如說,username 應該是唯一,而且字元長度是多少,也就是說怎麼開資料庫,可以寫一個操作手冊。

如果要在 try… catch… 中拋出錯誤,應該要在 DB 中設定 username 為 unique,這樣才接的到錯誤,就可以寫成下面那樣:

handleRegister: async (req, res, next) => {
    const { username, password } = req.body
    if(!username || !password) {
      req.flash('errMessage', '請輸入帳號及密碼')
      next()
      return
    }
    const hashPassword = await bcrypt.hash(password, saltRounds)
    try {
      await UserDb.create({
        username,
        password: hashPassword
      })
    } catch (err) {
      console.log(err)
      req.flash('errMessage', '發生錯誤, 帳號已存在')
      next()
      return
    }
    req.session.isLogin = true
    res.redirect('/admin-item')
    return
  },

我原本寫的這樣:

handleSignup: async (req, res, next) => {
    const { username, password } = req.body;
    if (!username || !password) {
      req.flash("errorMessage", "Please fill in all the required fields.");
      return next();
    }

    // 這一段雖然可以找到已被註冊的使用者,但是當沒有重複使用者時,會出現這個錯誤
    // TypeError: Cannot read property 'username' of null

    // 師:是因為當沒有重複的 user 的時候,User.findOne 就會回傳 null,因此 user 就是 null,你存取 user.username 時就會出錯,因為 user 不是 object
    try {
      let user = await User.findOne({
        where: {
          username,
        },
      });
      if (user.username === username) {
        req.flash("errorMessage", "user exists");
        return next();
      }
    } catch (err) {
      req.flash("errorMessage", err.toString());
      return next();
    }

    bcrypt.hash(password, saltRounds, async (err, hash) => {
      if (err) {
        req.flash("errorMsg", "please try again");
        return next();
      }

      try {
        const userData = await User.create({
          username,
          password: hash,
        });

        req.session.username = userData.username;
        res.redirect("/");
      } catch (err) {
        if (err.parent.errno === 1062) {
          req.flash("errorMsg", "user exits");
          return next();
        }
        req.flash("errorMsg", "something went wrong");
        return next();
      }
    });
  },

老師說,「所以其實你根本也不用檢查 username,因為你 where 時已經去指定條件了,所以只要 user 有東西,就代表一定重複,只要 if (user) 就好」,改寫成這樣就可以了:

handleSignup: async (req, res, next) => {
  const { username, password } = req.body;
  if (!username || !password) {
    req.flash("errorMessage", "Please fill in all the required fields.");
    return next();
  }

  try {
    let user = await User.findOne({
      where: {
        username,
      },
    });
    if (user) {
      req.flash("errorMessage", "user exists");
      return next();
    }
  } catch (err) {
    req.flash("errorMessage", err.toString());
    return next();
  }

  bcrypt.hash(password, saltRounds, async (err, hash) => {
    if (err) {
      req.flash("errorMsg", "please try again");
      return next();
    }

    try {
      const userData = await User.create({
        username,
        password: hash,
      });

      req.session.username = userData.username;
      res.redirect("/");
    } catch (err) {
      if (err.parent.errno === 1062) {
        req.flash("errorMsg", "user exits");
        return next();
      }
      req.flash("errorMsg", "something went wrong");
      return next();
    }
  });

bugs 紀錄:

bug 1: TypeError: Cannot read property 'username' of null

描述:無法新增一個使用者

嘗試 1: 加入 let username = [];

let username = [];
if (user.username === username) {
  req.flash("errorMessage", "user exists");
  return next();
}

bug 2: ReferenceError: Cannot access 'username' before initialization

描述:無法新增一個使用者

嘗試 2: var username = [];

bug 3: Error: WHERE parameter "username" has invalid "undefined" value

描述:無法新增一個使用者

嘗試 3:

註冊流程:

輸入帳密 → 檢查使用者是否已存在 → 是:return;否:註冊成功

handleSignup: async (req, res, next) => {
    const { username, password } = req.body;
    if (!username || !password) {
      req.flash("errorMessage", "Please fill in all the required fields.");
      return next();
    }
    const hashPassword = await bcrypt.hash(password, saltRounds);
    try {
      await User.create({
        username,
        password: hashPassword,
      });
      req.session.username = username;
      return res.redirect("/");
    } catch (err) {
      req.flash("errorMessage", err.toString());
      return next();
    }
  },

11. 權限檢查

因為這個網站有後台的關係,因此應該檢查使用者是否為 admin,如果不是的話,要將其他使用者轉到首頁。

方法就是寫一個權限檢查的函式,要記得加在路由上面

我原本的方式是放在畫面上,這樣不太好,應該要由後端檢查。

// index.js

app.use((req, res, next) => {
  res.locals.username = req.session.username;
  // 我後來用了 username 來判斷是否為 admin,沒有用 role
  // res.locals.role = req.session.role;
  res.locals.errorMessage = req.flash("errorMessage");
  next();
});

function isAdmin(req, res, next) {
  // if (req.session.role !== "admin") {
  if (req.session.username !== "admin") {
    return res.redirect("/");
  }
  return next();
}

// 記得要寫在 controller 前面
app.get("/cms", isAdmin, userController.cms);
app.get("/cms/prize", isAdmin, prizeController.managePrize);
app.get("/cms/faq", isAdmin, faqController.manageFaq);
app.get("/cms/menu", isAdmin, menuController.manageMenu);
app.get("/cms/order", isAdmin, orderController.manageOrder);

app.get("/cms/add-prize", isAdmin, prizeController.add);
app.post("/cms/add-prize", isAdmin, prizeController.handleNewPrize);
app.get("/cms/delete-prize/:id", isAdmin, prizeController.delete);
app.get("/cms/edit-prize/:id", isAdmin, prizeController.edit);
app.post("/cms/edit-prize/:id", isAdmin, prizeController.handleEdit);

app.get("/cms/add-faq", isAdmin, faqController.add);
app.post("/cms/add-faq", isAdmin, faqController.handleNewFaq);
app.get("/cms/delete-faq/:id", isAdmin, faqController.delete);
app.get("/cms/edit-faq/:id", isAdmin, faqController.edit);
app.post("/cms/edit-faq/:id", isAdmin, faqController.handleEdit);

app.get("/cms/add-menu", isAdmin, menuController.add);
app.post("/cms/add-menu", isAdmin, menuController.handleNewMenu);
app.get("/cms/delete-menu/:id", isAdmin, menuController.delete);
app.get("/cms/edit-menu/:id", isAdmin, menuController.edit);
app.post("/cms/edit-menu/:id", isAdmin, menuController.handleEdit);

12. 要記得所有的「可以輸入的地方」都必須防止 xss 攻擊

What is the HtmlSpecialChars equivalent in JavaScript?

// public/script/api.js

function escapeHtml(text) {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

async function drawResult() {
  const result = await getDrawAPI();
  try {
    mainHTML.innerHTML = `
      <div class="prize__result bg-image" style="background-image:url(${result.url})">
        <div class="text-center prize__result-content">
          <div class="card-body">
            <h1 class="card-text">${escapeHtml(result.item)}</h1>
            <h4 class="card-text">${escapeHtml(result.content)}</h4>
            <button class="draw__btn"><a href="/draw">Draw again!</a></button>
          </div>
        </div>
      </div>
    `;
  } catch (err) {
    return console.log(`Error: ${result.error} & ${result.message}`);
  }
}

六、心得

本來想要把挑戰題全部做出來,可是後來還是覺得「以練習必要功能為主」,畢竟要走後端的話,要學的東西可多的呢,就先放著,有機會再回頭看吧。

部署到 AWS 失敗讓人好灰心,不過該來的總是會來的,今天遇到的坑遇過了,下次再遇一次也不陌生了…

總算交出最後一個作業,好感動,內心百感交集,很感謝老師開啟這個計畫,也很榮幸可以加入。










Related Posts

[Docker] Docker Volume

[Docker] Docker Volume

30-Day LeetCoding Challenge 2020 April Week 3 || Leetcode 解題

30-Day LeetCoding Challenge 2020 April Week 3 || Leetcode 解題

Git 筆記 - 將該次 commit 變動的檔案打包壓縮

Git 筆記 - 將該次 commit 變動的檔案打包壓縮


Comments