---
# System prepended metadata

title: 第十堂：打造全端 (Full Stack) 網站架構
tags: [2025 Node.js 後端工程師培訓營]

---

# 第十堂：打造全端 (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 展開流程圖
![Untitled diagram-2025-02-12-150603](https://hackmd.io/_uploads/B1nfF4cF1x.svg)
:::



### [POST] 將使用者新增為教練：{url}/api/admin/coaches/:userId
### [POST] 新增教練課程資料：{url}/api/admin/coaches/courses
### [PUT] 編輯教練課程資料：{url}/api/admin/coaches/courses/:courseId
:::spoiler 展開流程圖
![Untitled diagram-2025-02-12-150959](https://hackmd.io/_uploads/H1CQ9N9Y1l.svg)

:::

## 建立資料表
```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
```