# 第十堂:打造全端 (Full Stack) 網站架構
- 錄影
## week5 資料架構
```
week5/
├── bin/ # 伺服器啟動程式
│ └── www.js # 主要伺服器啟動檔案
├── config/ # 設定檔目錄
│ ├── db.js # 資料庫設定
│ ├── index.js # 設定檔管理器
│ └── web.js # Web伺服器設定
├── db/ # 資料庫相關
│ └── data-source.js # TypeORM資料來源設定
├── entities/ # 資料庫實體模型
│ └── CreditPackages.js # 點數包實體定義
├── routes/ # 路由處理
│ └── creditPackage.js # 點數包API路由
├── utils/ # 工具函數
│ └── logger.js # 日誌工具
├── app.js # Express應用程式主檔
├── Dockerfile # Docker建置檔
└── docker-compose.yml # Docker組態檔
```
## 環境安裝、NPM 指令
* 先 fork [repo](https://github.com/hexschool/node-training-postgresql/tree/main) 後,再 clone 下來
* `npm install`:安裝套件
* 檢視 `.env` 設定,調整為 `localhost`
* `npm run start`: 運作 Docker,將資料庫環境在本地端運作
* `npm run dev`:開啟 Node 應用程式
* 使用 `DBeaver` 觀看資料庫狀態
* Host:localhost
* Database:test
* DB_USERNAME:testHexschool
* DB_PASSWORD:pgStartkit4test
## bin/www.js 與 app.js 差異
讓程式碼更容易維護和測試,也符合單一職責原則(Single Responsibility Principle)。
### 關注點分離 (職責劃分)
- **www.js**:負責伺服器層級,專注於伺服器啟動和運行時的設定 , 處理伺服器層級的錯誤(如:連接埠被佔用)
- **app.js**:負責應用程式層級,專注於 Express 應用程式的邏輯和路由設定,處理應用程式層級的錯誤(如:路由錯誤、API 錯誤)
## console.log 與 pino NPM 差異
- **結構化日誌**:`pino` 輸出的日誌是結構化(例如 JSON 格式)的,方便分析跟檢視
- **日誌等級控制**:`pino` 提供多種日誌等級(debug、info、warn、error 等),可以根據環境不同調整輸出,方便在開發或生產環境中過濾資訊
- **擴展性與串流**:`pino` 支援輸出到多個目標(檔案、遠端日誌系統、控制台等),並且可進行格式化與轉換。
- **錯誤追蹤與除錯**:結構化日誌加上額外的上下文訊息,能更容易追蹤錯誤和除錯。
```jsx
// 舉例同時輸出到多個地方
const logger = pino({
transport: {
targets: [
{
// 輸出到控制台:美化版
target: 'pino-pretty',
options: { colorize: true }
},
{
// 輸出到檔案
target: 'pino/file',
options: { destination: './logs/app.log', mkdir: true }
},
{
// 輸出到自定義遠端 HTTP 服務(需有支援的 transport)
target: 'pino-http-send', // 假設有 pino 模組支援
options: { url: 'http://remote-log-server.example.com/logs' }
}
]
}
})
```
## config 資料夾(主要檔案 index.js)
1. 方便集中管理各種所需的參數集中管理,讓專案配置更更方便維護。
## db、entitities 資料夾
1. 資料庫連線相關的設定與邏輯
2. entitities:存放各個實體(Entity),定義各資料表的結構
分離的好處在,方便各自獨立維護與擴充,同時也符合模組化設計的原則。
## 中場休息
## 第五週任務
- [第五週任務](https://rpg.hexschool.com/#/training/12062543649527256883/board/content/12062543649527256884_12062543649527256902?tid=12062543649555005601)
- API 應用可參考[線稿圖](https://miro.com/app/board/uXjVLbiDml0=/)
- API 開發規格請依照 [此 API 文件](https://liberating-turtle-5a2.notion.site/38e4dd6775894e94a8f575f20ca5b867?v=17c6a246851881208205000cacf67220)
- db 欄位設計請依照 [此資料庫欄位圖片](https://firebasestorage.googleapis.com/v0/b/hexschool-courses.appspot.com/o/hex-website%2Fnode%2F1739179853094-fitness_5.png?alt=media&token=a65de209-3ae6-4263-bdc0-d4f08638ff20) 定義
### 1-1
1. 建立 skill.js,到 entities 資料表,掛到 datasource 連線
2. 建立 routes,連接到 express 應用程式層
## 組合包方案邏輯
* [展開組合包流程圖](https://hackmd.io/_uploads/HkSIvVcF1g.svg)
## 教練專長邏輯
* [展開教練專長流程圖](https://hackmd.io/_uploads/rJ4ru45Fkx.svg)
#### routes > credit-package.js
```jsx
const express = require('express')
const router = express.Router()
const { dataSource } = require('../db/data-source')
const logger = require('../utils/logger')('CreditPackage')
function isUndefined (value) {
return value === undefined
}
function isNotValidSting (value) {
return typeof value !== 'string' || value.trim().length === 0 || value === ''
}
function isNotValidInteger (value) {
return typeof value !== 'number' || value < 0 || value % 1 !== 0
}
router.get('/', async (req, res, next) => {
try {
const creditPackage = await dataSource.getRepository('CreditPackage').find({
select: ['id', 'name', 'credit_amount', 'price']
})
res.status(200).json({
status: 'success',
data: creditPackage
})
} catch (error) {
logger.error(error)
next(error)
}
})
router.post('/', async (req, res, next) => {
try {
const { name, credit_amount: creditAmount, price } = req.body
if (isUndefined(name) || isNotValidSting(name) ||
isUndefined(creditAmount) || isNotValidInteger(creditAmount) ||
isUndefined(price) || isNotValidInteger(price)) {
res.status(400).json({
status: 'failed',
message: '欄位未填寫正確'
})
return
}
const creditPurchaseRepo = await dataSource.getRepository('CreditPackage')
const existCreditPurchase = await creditPurchaseRepo.find({
where: {
name
}
})
if (existCreditPurchase.length > 0) {
res.status(409).json({
status: 'failed',
message: '資料重複'
})
return
}
const newCreditPurchase = await creditPurchaseRepo.create({
name,
credit_amount: creditAmount,
price
})
const result = await creditPurchaseRepo.save(newCreditPurchase)
res.status(200).json({
status: 'success',
data: result
})
} catch (error) {
logger.error(error)
next(error)
}
})
router.delete('/:creditPackageId', async (req, res, next) => {
try {
const { creditPackageId } = req.params
if (isUndefined(creditPackageId) || isNotValidSting(creditPackageId)) {
res.status(400).json({
status: 'failed',
message: '欄位未填寫正確'
})
return
}
const result = await dataSource.getRepository('CreditPackage').delete(creditPackageId)
if (result.affected === 0) {
res.status(400).json({
status: 'failed',
message: 'ID錯誤'
})
return
}
res.status(200).json({
status: 'success',
data: result
})
} catch (error) {
logger.error(error)
next(error)
}
})
module.exports = router
```
#### skill 程式邏輯
```jsx
// skill.js 實體資料表
const { EntitySchema } = require('typeorm')
module.exports = new EntitySchema({
name: 'Skill',
tableName: 'SKILL',
columns: {
id: {
primary: true,
type: 'uuid',
generated: 'uuid',
nullable: false
},
name: {
type: 'varchar',
length: 50,
nullable: false,
unique: true
},
createdAt: {
type: 'timestamp',
createDate: true,
name: 'created_at',
nullable: false
}
}
})
// skill.js routes
const express = require('express')
const router = express.Router()
const { dataSource } = require('../db/data-source')
const logger = require('../utils/logger')('Skill')
function isUndefined (value) {
return value === undefined
}
function isNotValidSting (value) {
return typeof value !== 'string' || value.trim().length === 0 || value === ''
}
router.get('/', async (req, res, next) => {
try {
const skill = await dataSource.getRepository('Skill').find({
select: ['id', 'name']
})
res.status(200).json({
status: 'success',
data: skill
})
} catch (error) {
logger.error(error)
next(error)
}
})
router.post('/', async (req, res, next) => {
try {
const { name } = req.body
if (isUndefined(name) || isNotValidSting(name)) {
res.status(400).json({
status: 'failed',
message: '欄位未填寫正確'
})
return
}
const skillRepo = await dataSource.getRepository('Skill')
const existSkill = await skillRepo.find({
where: {
name
}
})
if (existSkill.length > 0) {
res.status(409).json({
status: 'failed',
message: '資料重複'
})
return
}
const newSkill = await skillRepo.create({
name
})
const result = await skillRepo.save(newSkill)
res.status(200).json({
status: 'success',
data: result
})
} catch (error) {
logger.error(error)
next(error)
}
})
router.delete('/:skillId', async (req, res, next) => {
try {
const skillId = req.url.split('/').pop()
if (isUndefined(skillId) || isNotValidSting(skillId)) {
res.status(400).json({
status: 'failed',
message: 'ID錯誤'
})
return
}
const result = await dataSource.getRepository('Skill').delete(skillId)
if (result.affected === 0) {
res.status(400).json({
status: 'failed',
message: 'ID錯誤'
})
return
}
res.status(200).json({
status: 'success',
data: result
})
res.end()
} catch (error) {
logger.error(error)
next(error)
}
})
module.exports = router
```
### 1-2
1. 安裝 `npm install bcrypt`
2. 建立 Coach.js、Course.js、User.js、skill.js 到 entities 資料表,掛到 datasource 連線
### [POST] 註冊使用者:{url}/api/users/signup
:::spoiler 展開流程圖

:::
### [POST] 將使用者新增為教練:{url}/api/admin/coaches/:userId
### [POST] 新增教練課程資料:{url}/api/admin/coaches/courses
### [PUT] 編輯教練課程資料:{url}/api/admin/coaches/courses/:courseId
:::spoiler 展開流程圖

:::
## 建立資料表
```jsx
// 資料表
//User.js
const { EntitySchema } = require('typeorm')
module.exports = new EntitySchema({
name: 'User',
tableName: 'USER',
columns: {
id: {
primary: true,
type: 'uuid',
generated: 'uuid'
},
name: {
type: 'varchar',
length: 50,
nullable: false
},
email: {
type: 'varchar',
length: 320,
nullable: false,
unique: true
},
role: {
type: 'varchar',
length: 20,
nullable: false
},
password: {
type: 'varchar',
length: 72,
nullable: false,
select: false
},
created_at: {
type: 'timestamp',
createDate: true,
nullable: false
},
updated_at: {
type: 'timestamp',
updateDate: true,
nullable: false
}
}
})
// Coach.js
const { EntitySchema } = require('typeorm')
module.exports = new EntitySchema({
name: 'Coach',
tableName: 'COACH',
columns: {
id: {
primary: true,
type: 'uuid',
generated: 'uuid'
},
user_id: {
type: 'uuid',
unique: true,
nullable: false
},
experience_years: {
type: 'integer',
nullable: false
},
description: {
type: 'text',
nullable: false
},
profile_image_url: {
type: 'varchar',
length: 2048,
nullable: true
},
created_at: {
type: 'timestamp',
createDate: true,
nullable: false
},
updated_at: {
type: 'timestamp',
updateDate: true,
nullable: false
}
},
relations: {
User: {
target: 'User',
type: 'one-to-one',
inverseSide: 'Coach',
joinColumn: {
name: 'user_id',
referencedColumnName: 'id',
foreignKeyConstraintName: 'coach_user_id_fk'
}
}
}
})
// Course.js
const { EntitySchema } = require('typeorm')
module.exports = new EntitySchema({
name: 'Course',
tableName: 'COURSE',
columns: {
id: {
primary: true,
type: 'uuid',
generated: 'uuid'
},
user_id: {
type: 'uuid',
nullable: false,
foreignKey: {
name: 'course_user_id_fkey',
columnNames: ['user_id'],
referencedTableName: 'USER',
referencedColumnNames: ['id']
}
},
skill_id: {
type: 'uuid',
nullable: false,
foreignKey: {
name: 'course_skill_id_fkey',
columnNames: ['skill_id'],
referencedTableName: 'SKILL',
referencedColumnNames: ['id']
}
},
name: {
type: 'varchar',
length: 100,
nullable: false
},
description: {
type: 'text',
nullable: false
},
start_at: {
type: 'timestamp',
nullable: false
},
end_at: {
type: 'timestamp',
nullable: false
},
max_participants: {
type: 'integer',
nullable: false
},
meeting_url: {
type: 'varchar',
length: 2048,
nullable: false
},
created_at: {
type: 'timestamp',
createDate: true,
nullable: false
},
updated_at: {
type: 'timestamp',
updateDate: true,
nullable: false
}
}
})
```
### routes>user.js
```jsx
const express = require('express')
const bcrypt = require('bcrypt')
const router = express.Router()
const { dataSource } = require('../db/data-source')
const logger = require('../utils/logger')('Users')
const saltRounds = 10
function isUndefined (value) {
return value === undefined
}
function isNotValidSting (value) {
return typeof value !== 'string' || value.trim().length === 0 || value === ''
}
// 新增使用者
router.post('/signup', async (req, res, next) => {
try {
const passwordPattern = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,16}/
const { name, email, password } = req.body
// 驗證必填欄位
if (isUndefined(name) || isNotValidSting(name) || isUndefined(email) || isNotValidSting(email) || isUndefined(password) || isNotValidSting(password)) {
logger.warn('欄位未填寫正確')
res.status(400).json({
status: 'failed',
message: '欄位未填寫正確'
})
return
}
if (!passwordPattern.test(password)) {
logger.warn('建立使用者錯誤: 密碼不符合規則,需要包含英文數字大小寫,最短8個字,最長16個字')
res.status(400).json({
status: 'failed',
message: '密碼不符合規則,需要包含英文數字大小寫,最短8個字,最長16個字'
})
return
}
const userRepository = dataSource.getRepository('User')
// 檢查 email 是否已存在
const existingUser = await userRepository.findOne({
where: { email }
})
if (existingUser) {
logger.warn('建立使用者錯誤: Email 已被使用')
res.status(409).json({
status: 'failed',
message: 'Email 已被使用'
})
return
}
// 建立新使用者
const hashPassword = await bcrypt.hash(password, saltRounds)
const newUser = userRepository.create({
name,
email,
role: 'USER',
password: hashPassword
})
const savedUser = await userRepository.save(newUser)
logger.info('新建立的使用者ID:', savedUser.id)
res.status(201).json({
status: 'success',
data: {
user: {
id: savedUser.id,
name: savedUser.name
}
}
})
} catch (error) {
logger.error('建立使用者錯誤:', error)
next(error)
}
})
module.exports = router
```
#### routes >admin.js
```jsx
const express = require('express')
const router = express.Router()
const { dataSource } = require('../db/data-source')
const logger = require('../utils/logger')('Admin')
function isUndefined (value) {
return value === undefined
}
function isNotValidSting (value) {
return typeof value !== 'string' || value.trim().length === 0 || value === ''
}
function isNotValidInteger (value) {
return typeof value !== 'number' || value < 0 || value % 1 !== 0
}
router.post('/coaches/courses', async (req, res, next) => {
try {
const {
user_id: userId, skill_id: skillId, name, description, start_at: startAt, end_at: endAt,
max_participants: maxParticipants, meeting_url: meetingUrl
} = req.body
if (isUndefined(userId) || isNotValidSting(userId) ||
isUndefined(skillId) || isNotValidSting(skillId) ||
isUndefined(name) || isNotValidSting(name) ||
isUndefined(description) || isNotValidSting(description) ||
isUndefined(startAt) || isNotValidSting(startAt) ||
isUndefined(endAt) || isNotValidSting(endAt) ||
isUndefined(maxParticipants) || isNotValidInteger(maxParticipants) ||
isUndefined(meetingUrl) || isNotValidSting(meetingUrl) || !meetingUrl.startsWith('https')) {
logger.warn('欄位未填寫正確')
res.status(400).json({
status: 'failed',
message: '欄位未填寫正確'
})
return
}
const userRepository = dataSource.getRepository('User')
const existingUser = await userRepository.findOne({
select: ['id', 'name', 'role'],
where: { id: userId }
})
if (!existingUser) {
logger.warn('使用者不存在')
res.status(400).json({
status: 'failed',
message: '使用者不存在'
})
return
} else if (existingUser.role !== 'COACH') {
logger.warn('使用者尚未成為教練')
res.status(400).json({
status: 'failed',
message: '使用者尚未成為教練'
})
return
}
const courseRepo = dataSource.getRepository('Course')
const newCourse = courseRepo.create({
user_id: userId,
skill_id: skillId,
name,
description,
start_at: startAt,
end_at: endAt,
max_participants: maxParticipants,
meeting_url: meetingUrl
})
const savedCourse = await courseRepo.save(newCourse)
const course = await courseRepo.findOne({
where: { id: savedCourse.id }
})
res.status(201).json({
status: 'success',
data: {
course
}
})
} catch (error) {
logger.error(error)
next(error)
}
})
router.put('/coaches/courses/:courseId', async (req, res, next) => {
try {
const { courseId } = req.params
const {
skill_id: skillId, name, description, start_at: startAt, end_at: endAt,
max_participants: maxParticipants, meeting_url: meetingUrl
} = req.body
if (isNotValidSting(courseId) ||
isUndefined(skillId) || isNotValidSting(skillId) ||
isUndefined(name) || isNotValidSting(name) ||
isUndefined(description) || isNotValidSting(description) ||
isUndefined(startAt) || isNotValidSting(startAt) ||
isUndefined(endAt) || isNotValidSting(endAt) ||
isUndefined(maxParticipants) || isNotValidInteger(maxParticipants) ||
isUndefined(meetingUrl) || isNotValidSting(meetingUrl) || !meetingUrl.startsWith('https')) {
logger.warn('欄位未填寫正確')
res.status(400).json({
status: 'failed',
message: '欄位未填寫正確'
})
return
}
const courseRepo = dataSource.getRepository('Course')
const existingCourse = await courseRepo.findOne({
where: { id: courseId }
})
if (!existingCourse) {
logger.warn('課程不存在')
res.status(400).json({
status: 'failed',
message: '課程不存在'
})
return
}
const updateCourse = await courseRepo.update({
id: courseId
}, {
skill_id: skillId,
name,
description,
start_at: startAt,
end_at: endAt,
max_participants: maxParticipants,
meeting_url: meetingUrl
})
if (updateCourse.affected === 0) {
logger.warn('更新課程失敗')
res.status(400).json({
status: 'failed',
message: '更新課程失敗'
})
return
}
const savedCourse = await courseRepo.findOne({
where: { id: courseId }
})
res.status(200).json({
status: 'success',
data: {
course: savedCourse
}
})
} catch (error) {
logger.error(error)
next(error)
}
})
router.post('/coaches/:userId', async (req, res, next) => {
try {
const { userId } = req.params
const { experience_years: experienceYears, description, profile_image_url: profileImageUrl = null } = req.body
if (isUndefined(experienceYears) || isNotValidInteger(experienceYears) || isUndefined(description) || isNotValidSting(description)) {
logger.warn('欄位未填寫正確')
res.status(400).json({
status: 'failed',
message: '欄位未填寫正確'
})
return
}
if (profileImageUrl && !isNotValidSting(profileImageUrl) && !profileImageUrl.startsWith('https')) {
logger.warn('大頭貼網址錯誤')
res.status(400).json({
status: 'failed',
message: '欄位未填寫正確'
})
return
}
const userRepository = dataSource.getRepository('User')
const existingUser = await userRepository.findOne({
select: ['id', 'name', 'role'],
where: { id: userId }
})
if (!existingUser) {
logger.warn('使用者不存在')
res.status(400).json({
status: 'failed',
message: '使用者不存在'
})
return
} else if (existingUser.role === 'COACH') {
logger.warn('使用者已經是教練')
res.status(409).json({
status: 'failed',
message: '使用者已經是教練'
})
return
}
const coachRepo = dataSource.getRepository('Coach')
const newCoach = coachRepo.create({
user_id: userId,
experience_years: experienceYears,
description,
profile_image_url: profileImageUrl
})
const updatedUser = await userRepository.update({
id: userId,
role: 'USER'
}, {
role: 'COACH'
})
if (updatedUser.affected === 0) {
logger.warn('更新使用者失敗')
res.status(400).json({
status: 'failed',
message: '更新使用者失敗'
})
return
}
const savedCoach = await coachRepo.save(newCoach)
const savedUser = await userRepository.findOne({
select: ['name', 'role'],
where: { id: userId }
})
res.status(201).json({
status: 'success',
data: {
user: savedUser,
coach: savedCoach
}
})
} catch (error) {
logger.error(error)
next(error)
}
})
module.exports = router
```