本文為使用後端框架實作一個餐廳網站的實作過程,主要改寫 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 一樣的方法
我要點餐
前台新增「我要點餐」那頁,包含以下:
名稱
價格
圖片
查詢訂單
我要點餐設計稿:身為一個顧客,我希望登入後,點擊首頁商品的「加入購物車」,可以新增品項至購物車。
購物清單設計稿:身為一個顧客,我希望在查詢訂單的頁面,點擊訂單編號,可以顯示購物清單,能夠刪除單獨品項,以及填寫帳單資訊。
我的訂單設計稿:身為一個顧客,我希望在查詢訂單的頁面,可以顯示「我的訂單」,裡面有訂單編號、訂單日期、數量、金額、狀態等訂單狀況。
訂單明細設計稿:身為一個顧客,我希望點擊訂單編號,可以顯示訂單明細,裡面有縮圖、商品名稱、價格、數量、小計,並且有帳單資訊。
身為一個顧客,我希望可以在結帳頁面結帳。(串接金流)
身為一個顧客,我希望訂單成立後,網站會寄一封信給我,裡面提供訂單資訊以及連結,點了連結之後可以查看訂單資訊。
常見問題
b. 後台的頁面
左上角 Just A Bite 首頁
CMS
登出
抽獎管理
有一個後台,可以在後台新增抽獎品項並且設定機率。
詳細需求如下:
身為一個管理員,我希望有一個抽獎頁面讓我管理獎項
身為一個管理員,我希望可以在後台新增抽獎的品項(名字、圖片網址以及說明)以及機率
身為一個管理員,我希望可以在後台編輯抽獎的品項(名字、圖片網址以及說明)以及機率
身為一個管理員,我希望可以在後台刪除抽獎的品項
身為一個管理員,我希望在前台能夠抽出我在後台所設定的獎項
由於 API 的規則會跟第八週的 API 長不一樣,所以第八週的東西可能會有小幅調整。
餐點管理
新增品項
刪除品項
編輯品項
上傳圖片的部分請參考 week17 的延伸挑戰題,如果上傳圖片不好做的話,也可以改成填入圖片網址就好。
訂單管理
問題管理
做個後台可以管理常見問題列表,然後在前台的部分動態載入這些資料。
身為一個管理員,我希望常見問題的內容可以儲存在資料庫,這樣我才能方便修改
身為一個管理員,我希望管理後台可以管理常見問題,這樣我才能方便修改
身為一個管理員,我希望在後台可以新增常見問題,會有標題跟內容以及順序
身為一個管理員,我希望在後台可以編輯常見問題,包括標題跟內容以及順序
身為一個管理員,我希望在後台可以刪除常見問題
身為一個管理員,我希望前端頁面的資料是從後端拿的,這樣才能跟後台連動
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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 失敗讓人好灰心,不過該來的總是會來的,今天遇到的坑遇過了,下次再遇一次也不陌生了…
總算交出最後一個作業,好感動,內心百感交集,很感謝老師開啟這個計畫,也很榮幸可以加入。