# 第十堂:打造全端 (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 ```