快轉到主要內容
Docker 部署 Spring Boot + Vue + PostgreSQL 實現一個會員註冊登入系統

Docker 部署 Spring Boot + Vue + PostgreSQL 實現一個會員註冊登入系統

·
類別 
前端開發 後端開發
標籤 
Vue Spring-Boot Jwt Postgresql Docker
Eason Chiu
作者
Eason Chiu
一個不做筆記就容易忘記的工程師
目錄

前言
#

會員註冊登入系統在網站開發中是最普遍的一項功能,雖然表面上流程單純,實際上除了要確保用戶身份驗證的準確性,還必須兼顧多方面的需求,例如信箱驗證密碼強度與加密Token 管理等

最近在寫 Side Project,才發現到原來要設計一個完善的會員系統需要考慮的項目,比想像中還要多,這裡簡單做個介紹與紀錄,以一個擁有基本會員資料儲存,與信箱驗證功能的登入系統進行介紹。


主要用的框架與工具如下,詳細說明請參考下方的 系統實作部分

類別技術
前端框架Vue.js
後端框架Spring Boot
資料庫PostgreSQL
容器化Docker / Docker Compose

會員系統行為流程分析
#

首先我們畫一個行為流程圖,分析一個非會員的使用者,從註冊完成驗證,再到後續登入的所有一連串的行為。

stateDiagram-v2 [*] --> 未註冊 未註冊 --> 註冊中: 提交註冊表單 註冊中 --> 註冊成功: 註冊成功 註冊中 --> 註冊失敗: 註冊失敗 註冊失敗 --> 未註冊: 重新註冊 註冊成功 --> 待驗證: 等待信箱驗證 待驗證 --> 驗證成功: 信箱驗證成功 待驗證 --> 驗證失敗: 信箱驗證失敗 驗證失敗 --> 待驗證: 重新發送驗證 驗證成功 --> 已註冊: 帳戶啟用 已註冊 --> 未登入: 訪問登入頁面 未登入 --> 登入中: 提交登入表單 登入中 --> 登入成功: 認證成功 登入中 --> 登入失敗: 認證失敗 登入失敗 --> 未登入: 重新輸入 登入成功 --> 已登入: 儲存 Token 已登入 --> 登入過期: Token 過期 已登入 --> 未登入: 主動登出 登入過期 --> 未登入: 清除 Token 已登入 --> [*]: 導向頁面

接著我們把註冊、與登入流程拆開,分別將用戶行為,還有系統動作條列出來。這樣的分析可以幫助我們更清楚地了解每個階段用戶需要做什麼,以及系統在背後如何處理這些請求。透過詳細的流程分析,我們也能更好地設計使用者體驗系統架構

用戶註冊行為分析
#

詳細步驟
  1. 用戶訪問註冊頁面

    • 用戶點擊註冊按鈕或直接訪問註冊頁面
    • 系統檢查是否已登入,如果已登入則跳轉到首頁
  2. 用戶填寫註冊資訊

    • 用戶輸入用戶名、信箱、密碼和確認密碼
    • 前端進行即時表單驗證
    • 顯示驗證錯誤或成功提示
  3. 提交註冊請求

    • 點擊註冊按鈕觸發 API 請求
    • 顯示載入狀態,防止重複提交
    • 發送 POST 請求到後端註冊端點
  4. 後端註冊處理

    • 驗證請求格式和必填欄位
    • 檢查用戶名和信箱是否已存在
    • 加密密碼並創建用戶記錄
    • 生成信箱驗證連結
  5. 返回註冊結果

    • 註冊成功:返回用戶資訊和驗證提示
    • 註冊失敗:返回錯誤訊息
    • 前端處理回應並更新狀態
  6. 信箱驗證處理

    • 發送驗證信件到用戶信箱
    • 用戶點擊驗證連結
    • 驗證成功後啟用帳戶

用戶登入行為分析
#

詳細步驟
  1. 用戶訪問登入頁面

    • 用戶點擊登入按鈕或直接訪問登入頁面
    • 系統檢查是否已登入,如果已登入則跳轉到首頁
  2. 用戶輸入憑證

    • 用戶輸入帳號(信箱)和密碼
    • 前端進行表單驗證
    • 顯示驗證錯誤或成功提示
  3. 提交登入請求

    • 點擊登入按鈕觸發 API 請求
    • 顯示載入狀態,防止重複提交
    • 發送 POST 請求到後端認證端點
  4. 後端認證處理

    • 驗證請求格式和必填欄位
    • 查詢用戶資料庫
    • 驗證密碼是否正確
    • 檢查用戶狀態和信箱驗證狀態
    • 生成 JWT Token
  5. 返回認證結果

    • 認證成功:返回 Token 和用戶資訊
    • 認證失敗:返回錯誤訊息
    • 前端處理回應並更新狀態
  6. 登入後處理

    • 儲存 Token 到本地存儲
    • 更新認證狀態
    • 跳轉到目標頁面或首頁

會員系統模組架構圖
#

系統架構圖
#

大致可分為前端層後端層資料層,分別如下:

graph TB subgraph "前端層 (Vue)" VueRouter[Vue Router 路由管理 router/index.ts] LoginPage[登入頁面 Login.vue] RegisterPage[註冊頁面 Register.vue] VerifyPage[信箱驗證頁面 VerifyEmail.vue] AuthStore[認證狀態管理] HTTPClient[HTTP 客戶端 Fetch API] end subgraph "後端層 (Spring Boot)" AuthController[認證 Controller AuthController.java] AuthService[認證服務 AuthService.java] UserService[用戶服務 UserService.java] EmailService[信件服務 EmailService.java] JwtUtil[JWT JwtUtil.java] end subgraph "資料層" UserRepo[用戶 Repository UserRepository.java] EmailVerificationRepo[信箱驗證 Repository EmailVerificationRepository.java] PostgreSQL[(PostgreSQL 資料庫)] end VueRouter --> LoginPage VueRouter --> RegisterPage VueRouter --> VerifyPage LoginPage --> AuthStore RegisterPage --> AuthStore VerifyPage --> AuthStore AuthStore --> HTTPClient HTTPClient --> AuthController AuthController --> AuthService AuthService --> UserService AuthService --> EmailService AuthService --> JwtUtil UserService --> UserRepo EmailService --> EmailVerificationRepo UserRepo --> PostgreSQL EmailVerificationRepo --> PostgreSQL

架構圖大致列出了整個會員註冊登入系統的分層設計。簡單來說,最上層是前端(Vue),負責顯示登入、註冊、信箱驗證等頁面,並透過localStorage認證狀態管理,所有 API 請求則統一經由Fetch API發送,路由切換則交給Vue Router處理。

後端則是Spring Boot,這裡有認證 Controller認證服務用戶服務信件服務,還有專門處理JWT的Util Class,負責所有的業務邏輯安全驗證JwtUtil主要負責生成和驗證Token,包含用戶身份資訊過期時間等,並使用HMAC-SHA256進行加密。

最底層是資料層,像用戶資料信箱驗證資料都會透過對應的Repository存取,資料主要放在PostgreSQL

這樣的分層設計不僅讓前後端可以各自獨立開發維護。如果未來要擴充功能或調整架構,也會相對容易許多。

註冊系統流程圖
#

註冊的完整流程圖,從用戶填寫表單完成註冊發送驗證信件的所有步驟,分別如下:

sequenceDiagram participant U as 用戶 participant R as 註冊頁面 (Register.vue) participant A as 認證 Controller (AuthController) participant S as 認證服務 (AuthService) participant US as 用戶服務 (UserService) participant ES as 信件服務 (EmailService) participant R as 用戶 Repository participant DB as PostgreSQL participant J as JWT Util U->>R: 1. 填寫註冊表單 R->>R: 2. 前端表單驗證 R->>A: 3. POST /api/auth/register Note over R,A: {username: "newuser", email: "user@example.com", password: "Password123!"} A->>S: 4. 呼叫註冊服務 S->>US: 5. 檢查用戶是否存在 US->>R: 6. 查詢用戶資料庫 R->>DB: 7. SELECT * FROM users WHERE username = ? OR email = ? DB->>R: 8. 返回查詢結果 alt 用戶已存在 R->>US: 9. 返回用戶已存在 US->>S: 10. 拋出註冊失敗異常 S->>A: 11. 返回註冊失敗回應 A->>R: 12. 返回 {success: false, message: "用戶已存在"} R->>U: 13. 顯示錯誤訊息 else 用戶不存在 S->>US: 14. 創建新用戶 US->>R: 15. 插入用戶記錄 R->>DB: 16. INSERT INTO users (username, email, password, role, status) DB->>R: 17. 返回新用戶ID R->>US: 18. 返回用戶實體 US->>S: 19. 返回用戶資訊 S->>ES: 20. 發送信箱驗證 ES->>DB: 21. 創建驗證記錄 DB->>ES: 22. 返回驗證記錄 ES->>S: 23. 返回信件發送結果 S->>J: 24. 生成 JWT Token J->>S: 25. 返回 Token S->>A: 26. 返回註冊成功回應 A->>R: 27. 返回 {success: true, message: "註冊成功,請驗證信箱"} R->>U: 28. 跳轉到信箱驗證頁面 end

首先,用戶在註冊頁面(Register.vue)填寫用戶名信箱密碼等資訊,前端會進行即時的表單驗證,確保資料格式正確。

當用戶點擊註冊按鈕後,前端會發送POST請求/api/auth/register到後端的AuthController,請求內容包含用戶的基本資訊

後端的AuthService接收到請求後,會呼叫UserService來檢查用戶是否已經存在。這個檢查是透過查詢PostgreSQL資料庫來完成的,系統會同時檢查用戶名信箱是否已被使用。

如果發現用戶已存在,系統會立即返回錯誤訊息,告知用戶該帳戶已被註冊。如果用戶不存在,系統會進入註冊流程:首先在資料庫中創建新的用戶記錄,包括加密後的密碼用戶角色狀態等資訊。

接著,認證服務會呼叫EmailService來發送信箱驗證信件。這個過程會在資料庫中創建驗證記錄,包含驗證連結過期時間等資訊。

同時,系統還會生成一個JWT Token,雖然在註冊階段這個Token主要用於後續的驗證流程。最後,系統會返回註冊成功的資訊,並引導用戶到信箱驗證頁面

登入系統流程圖
#

登入的完整流程圖,從用戶輸入完成認證獲取存取權限的所有步驟,分別如下:

sequenceDiagram participant U as 用戶 participant L as 登入頁面 (Login.vue) participant A as 認證 Controller (AuthController) participant S as 認證服務 (AuthService) participant R as 用戶 Repository participant DB as PostgreSQL participant J as JWT Util U->>L: 1. 輸入用戶名和密碼 L->>L: 2. 前端表單驗證 L->>A: 3. POST /api/auth/login Note over L,A: {username: "user@gmail.com", password: "Password-123"} A->>S: 4. 呼叫認證服務 S->>R: 5. 根據用戶名查詢用戶 R->>DB: 6. SELECT * FROM users WHERE username = ? OR email = ? DB->>R: 7. 返回用戶資料 R->>S: 8. 返回用戶實體 S->>S: 9. 驗證密碼 alt 密碼正確 S->>S: 10. 檢查用戶狀態 alt 用戶狀態正常 S->>J: 11. 生成 JWT Token J->>S: 12. 返回 Token S->>A: 13. 返回認證成功回應 A->>L: 15. 返回 {success: true, token: "..."} L->>L: 16. 儲存 Token 到 localStorage L->>U: 17. 跳轉到首頁 else 用戶未驗證 S->>A: 18. 返回需要驗證信箱 A->>L: 19. 返回 {success: false, message: "請先驗證信箱"} L->>U: 20. 顯示驗證提示 end else 密碼錯誤 S->>A: 21. 返回認證失敗 A->>L: 22. 返回 {success: false, message: "密碼錯誤"} L->>U: 23. 顯示錯誤訊息 end

首先,用戶在登入頁面(Login.vue)輸入用戶名(或信箱)和密碼,前端會進行即時的表單驗證,確保輸入格式正確。

當用戶點擊登入按鈕後,前端會發送POST請求/api/auth/login到後端的AuthController,請求內容包含用戶的認證資訊。

後端的AuthService接收到請求後,會根據用戶名或信箱查詢PostgreSQL資料庫,獲取用戶的完整資訊,包括加密後的密碼、用戶狀態和信箱驗證狀態等。

接著,系統會將用戶輸入的密碼加密並比對是否與資料庫中儲存的加密密碼相符。如果密碼驗證失敗,系統會立即返回錯誤訊息,告知用戶密碼錯誤。

如果密碼驗證成功,系統會進一步檢查用戶的狀態。如果用戶尚未完成信箱驗證,系統會提示用戶需要先驗證信箱才能登入。如果用戶狀態正常,系統會進入登入成功流程。

在登入成功流程中,JWT Util會生成一個包含用戶身份資訊的JWT Token,前端會將Token儲存到localStorage中,用於後續的API請求認證。

最後,如果驗證都沒問題,系統會返回登入成功的資訊,包含Token和用戶資訊,前端會更新認證狀態並跳轉到首頁或目標頁面。

信箱驗證系統流程圖
#

信箱驗證的完整流程圖,從用戶點擊驗證連結完成帳戶啟用的所有步驟,分別如下:

sequenceDiagram participant U as 用戶 participant V as 驗證頁面 (VerifyEmail.vue) participant A as 認證 Controller (AuthController) participant S as 認證服務 (AuthService) participant ES as 信件服務 (EmailService) participant R as 信箱驗證 Repository participant UR as 用戶 Repository participant DB as PostgreSQL U->>V: 1. 點擊驗證連結 V->>A: 2. GET /api/auth/verify?token={token} A->>S: 3. 呼叫驗證服務 S->>ES: 4. 驗證信件驗證連結 ES->>R: 5. 查詢驗證記錄 R->>DB: 6. SELECT * FROM email_verifications WHERE token = ? DB->>R: 7. 返回驗證記錄 R->>ES: 8. 返回驗證記錄 alt 驗證連結有效 ES->>ES: 9. 檢查驗證連結是否過期 alt 驗證連結未過期 ES->>R: 10. 更新驗證狀態 R->>DB: 11. UPDATE email_verifications SET verified = true WHERE token = ? DB->>R: 12. 返回更新結果 R->>ES: 13. 返回更新成功 ES->>S: 14. 返回驗證成功 S->>UR: 15. 更新用戶狀態 UR->>DB: 16. UPDATE users SET status = 'ACTIVE' WHERE email = ? DB->>UR: 17. 返回更新結果 UR->>S: 18. 返回更新成功 S->>A: 19. 返回驗證成功回應 A->>V: 20. 返回 {success: true, message: "信箱驗證成功"} V->>U: 21. 跳轉到登入頁面 else 驗證連結已過期 ES->>S: 22. 返回驗證連結過期 S->>A: 23. 返回驗證失敗回應 A->>V: 24. 返回 {success: false, message: "驗證連結已過期"} V->>U: 25. 顯示過期提示 end else 驗證連結無效 ES->>S: 26. 返回驗證連結無效 S->>A: 27. 返回驗證失敗回應 A->>V: 28. 返回 {success: false, message: "驗證連結無效"} V->>U: 29. 顯示錯誤提示 end

首先,用戶會收到註冊時發送的驗證信件,其中包含一個唯一的驗證連結。用戶點擊驗證連結後,會進入驗證頁面(VerifyEmail.vue)

當用戶點擊驗證連結後,前端會發送GET請求/api/auth/verify到後端的AuthController,請求參數包含驗證Token。

後端的AuthService接收到請求後,會呼叫EmailService來驗證信件中的驗證連結。這個驗證過程會查詢PostgreSQL資料庫中的email_verifications Table,根據Token查找對應的驗證記錄。

系統會進一步檢查驗證連結是否已經過期。驗證連結為10分鐘的有效期,如果超過這個時間,系統會返回過期提示,要求用戶重新申請驗證信件。

如果驗證連結未過期,系統會進入驗證成功流程:首先更新email_verificationstable中的驗證狀態,將verified欄位設為true,表示該驗證連結已被使用。

接著,系統會更新users表中對應用戶的狀態,將status欄位從INACTIVE改為ACTIVE,表示用戶帳戶已經啟用,可以正常登入使用系統功能。

最後,系統會返回驗證成功的回應,前端會顯示成功訊息並自動跳轉到登入頁面,讓用戶可以使用新啟用的帳戶進行登入。

如果驗證連結無效,系統會返回錯誤訊息,提示用戶驗證連結無效,可能需要重新申請驗證信件。

系統實作部分
#

Docker 部署
#

這裡使用 Docker Compose 快速進行部署,這是用來組合多個 docker container 成為一個完整服務的工具,包含前端後端資料庫的完整配置。這樣的部署方式不僅簡化了環境配置服務管理,而且能夠一次性啟動前端、後端、資料庫等多個服務,讓開發測試上線都能保持一致,並方便日後擴充維護

Docker Compose 配置
#

在這份 docker-compose.yml 配置檔中,將前端後端資料庫(PostgreSQL)整合在一個文件下。只需要執行docker-compose up即可,所有的服務就會依照docker-compose.yml的設置進行部署。

docker-compose.yml
yaml
services:
  # PostgreSQL 資料庫
  postgres:
    image: postgres:15-alpine
    container_name: apname-postgres
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: Asia/Taipei
    ports:
      - "${POSTGRES_PORT}"
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    networks:
      - apname-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 30s
      timeout: 10s
      retries: 3

  # Spring Boot 後端
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: apname-backend
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - DATABASE_URL=jdbc:postgresql://postgres:${POSTGRES_PORT}/${POSTGRES_DB}
      - JWT_SECRET=${JWT_SECRET}
    ports:
      - "${BACKEND_PORT}"
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - apname-network
    volumes:
      - ./logs/backend:/app/logs

  # Vue.js 前端
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: apname-frontend
    environment:
      - VITE_API_BASE_URL=http://${HOST}:${BACKEND_PORT}/api
    ports:
      - "${FRONTEND_PORT}"
    depends_on:
      - backend
    networks:
      - apname-network

  # pgAdmin (資料庫管理工具)
  pgadmin:
    image: dpage/pgadmin4:latest
    container_name: apname-pgadmin
    environment:
      PGADMIN_DEFAULT_EMAIL: ${PGADMIN_USE}
      PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
      TZ: Asia/Taipei
    ports:
      - "${PGADMIN_PORT}"
    depends_on:
      - postgres
    networks:
      - apname-network
    volumes:
      - ./data/pgadmin:/var/lib/pgadmin
      - /etc/localtime:/etc/localtime:ro
      
networks:
  apname-network:
    driver: bridge

部署指令步驟
#

簡單的部署指令如下,其他指令可以參考 Docker Compose 常用指令 這篇文章

步驟指令說明
1. 確認安裝docker --version && docker-compose --version檢查 Docker 版本環境
2. 建立環境變數vim .env撰寫環境變數(參考下方範例)
3. 啟動服務docker-compose up -d背景啟動所有服務
4. 查看服務狀態docker-compose ps檢查服務運行狀態

環境變數配置
#

環境變數(.env)
bash
# 資料庫配置
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=apame_db
POSTGRES_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=your_secure_password
DATABASE_URL=jdbc:postgresql://localhost:5432/apame_db

# 資料庫管理工具配置
PGADMIN_USE=admin@apname.com
PGADMIN_PASSWORD=your_secure_password
PGADMIN_PORT=5050 

# JWT 配置
JWT_SECRET=your_very_long_secret_key_for_jwt_token
JWT_EXPIRATION=86400000
JWT_REFRESH_EXPIRATION=604800000

# 主機配置
HOST=localhost

# 前端、後端 port 配置
BACKEND_PORT=8080
FRONTEND_PORT=3000
SERVER_PORT=8080

# 郵件配置
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=your_email@gmail.com
MAIL_PASSWORD=your_app_password

# 應用程式配置
FRONTEND_URL=http://localhost:3000
EMAIL_MOCK=false

順利啟動後,就會看到正在運行中的服務 container list

前端實作
#

這裡我們以註冊頁面進行說明,這部分是會員模組最複雜的部分,包含表單驗證加密處理信箱驗證

使用 Vue 來開發前端頁面,包含登入註冊信箱驗證等頁面等。會選用 Vue 來開發,主要是因為本身對於前端沒有太深入的研究,Vue 相對於 ReactAngular 來說算是簡單好上手的,也適合獨立開發中小型專案快速開發維護

  1. 首先註冊頁面會對用戶的輸入進行表單驗證的檢查
  2. 信箱密碼都通過驗證後,就會透過後端發出驗證連結到用戶信箱
  3. 用戶收到信件並點擊連結後,如果驗證的token沒問題,就完成註冊動作

路由設定與頁面對應
#

../router/index.ts
typescript
// 路由配置
const routes: RouteRecordRaw[] = [
  {
    path: '/register',
    name: 'Register',
    component: () => import('@/views/auth/Register.vue'),
    meta: {
      title: '註冊'
    }
  },
  {
    path: '/verify-email',
    name: 'VerifyEmail',
    component: () => import('@/views/auth/VerifyEmail.vue'),
    meta: {
      title: '信箱驗證'
    }
  },
  {
    path: '/verification-sent',
    name: 'VerificationSent',
    component: () => import('@/views/auth/VerificationSent.vue'),
    meta: {
      title: '驗證連結已發送'
    }
  },
  {
    path: '/pending-verification',
    name: 'PendingVerification',
    component: () => import('@/views/auth/PendingVerification.vue'),
    meta: {
      title: '帳號待驗證'
    }
  }
]

註冊表單欄位
#

表單欄位(Register.vue)
html
<template>
  <div class="register-container">
    <div class="register-form">
      <div class="form-header">
        <h2>註冊</h2>
        <p>創建您的XXXXX帳號</p>
      </div>
      
      <el-form
        ref="registerFormRef"
        :model="registerForm"
        :rules="registerRules"
        class="register-form-content"
        label-width="0"
        @submit.prevent="handleRegister"
      >
        <el-form-item prop="email">
          <el-input
            v-model="registerForm.email"
            size="large"
            placeholder="請輸入信箱"
            prefix-icon="Message"
            :disabled="loading"
            inputmode="email"
          />
        </el-form-item>
        
        <el-form-item prop="password">
          <el-input
            v-model="registerForm.password"
            type="password"
            size="large"
            placeholder="請輸入密碼"
            prefix-icon="Lock"
            show-password
            :disabled="loading"
            inputmode="text"
            @input="handlePasswordInput"
            @compositionstart="handleCompositionStart"
            @compositionend="handleCompositionEnd"
          />
        </el-form-item>
        
        <el-form-item prop="confirmPassword">
          <el-input
            v-model="registerForm.confirmPassword"
            type="password"
            size="large"
            placeholder="請確認密碼"
            prefix-icon="Lock"
            show-password
            :disabled="loading"
            inputmode="text"
            @input="handleConfirmPasswordInput"
            @compositionstart="handleCompositionStart"
            @compositionend="handleCompositionEnd"
          />
        </el-form-item>
        
        <el-form-item>
          <el-checkbox v-model="registerForm.agreeTerms" :disabled="loading">
            我已閱讀並同意
            <el-link @click="showTerms">服務協議</el-link>
            <el-link @click="showPrivacy">隱私政策</el-link>
          </el-checkbox>
        </el-form-item>
        
        <el-form-item>
          <el-button
            size="large"
            :loading="loading"
            :disabled="!canRegister"
            class="register-button"
            @click="handleRegister"
          >
            <span v-if="!loading">註冊</span>
            <span v-else>註冊中...</span>
          </el-button>
        </el-form-item>
      </el-form>
      
      <div class="form-footer">
        <p>
          已有帳號?
          <el-link @click="router.push('/login')">
            立即登入
          </el-link>
        </p>
      </div>
    </div>
  </div>
</template>

驗證架構與邏輯
#

這段程式碼主要分為三個部分:

  1. 狀態管理:用來記錄註冊表單的欄位內容(如信箱密碼等)和載入狀態
  2. 檢查是否可以點擊註冊:根據輸入內容是否符合規則(如信箱格式密碼長度兩次密碼一致同意條款)來決定註冊按鈕是否可用。
  3. 表單驗證規則:定義每個欄位的驗證方式,確保使用者輸入正確,避免送出錯誤資料
驗證架構與邏輯(Register.vue)
typescript
// 組件結構
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'

// 狀態管理
const router = useRouter()
const loading = ref(false)
const registerFormRef = ref<FormInstance>()

const registerForm = reactive({
  email: '',
  password: '',
  confirmPassword: '',
  agreeTerms: false
})

// 檢查是否可以點擊註冊
const canRegister = computed(() => {
  return !loading.value &&
         isEmailValid.value &&
         registerForm.password &&
         registerForm.password.length >= 6 &&
         /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]).{6,}$/.test(registerForm.password) &&
         registerForm.confirmPassword &&
         registerForm.confirmPassword === registerForm.password &&
         registerForm.agreeTerms
})

// 表單驗證規則
const registerRules: FormRules = {
  email: [
    { validator: validateEmail, trigger: 'blur' }
  ],
  password: [
    { validator: validatePassword, trigger: 'blur' }
  ],
  confirmPassword: [
    { validator: validateConfirmPassword, trigger: 'blur' }
  ],
  agreeTerms: [
    { validator: validateTerms, trigger: 'change' }
  ]
}
</script>

表單驗證檢查
#

表單驗證檢查(Register.vue)
typescript
// 信箱格式驗證
const validateEmail = (_rule: any, value: string, callback: any) => {
  if (!value) {
    callback(new Error('請輸入信箱'))
  } else if (/[^\x00-\x7F]/.test(value)) {
    callback(new Error('信箱只能包含英文、數字和符號,且不能有空白'))
  } else if (!/^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})$/.test(value)) {
    callback(new Error('請輸入正確的信箱格式'))
  } else {
    callback()
  }
}

// 密碼強度驗證
const validatePassword = (_rule: any, value: string, callback: any) => {
  if (!value) {
    callback(new Error('請輸入密碼'))
  } else if (value.length < 6) {
    callback(new Error('密碼長度不能少於6位'))
  } else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]).{6,}$/.test(value)) {
    callback(new Error('密碼必須包含至少一個大寫字母、一個小寫字母、一個數字和一個符號'))
  } else {
    callback()
  }
}

// 密碼確認驗證
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
  if (value === '') {
    callback(new Error('請確認密碼'))
  } else if (value !== registerForm.password) {
    callback(new Error('兩次輸入的密碼不一致'))
  } else {
    callback()
  }
}
API 請求處理
#

註冊流程包含兩個 API Request註冊用戶發送驗證信件

API 請求處理(Register.vue)
typescript
const handleRegister = async () => {
  try {
    await registerFormRef.value.validate()
    loading.value = true
    
    // 準備註冊數據
    const registerData = {
      username: registerForm.email.split('@')[0], // 使用信箱前綴作為用戶名
      email: registerForm.email,
      password: registerForm.password
    }
    
    // 調用註冊 API
    const response = await fetch('/api/auth/register', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(registerData)
    })
    
    const result = await response.json()
    
    if (!response.ok || !result.success) {
      ElMessage.error(result.message || '註冊失敗,請稍後再試')
      return
    }
    
    // 註冊成功後,發送驗證連結
    const sendResult = await sendVerificationLink(registerForm.email)
    
    if (!sendResult.success) {
      ElMessage.error(sendResult.message)
      return
    }
    
    // 跳轉到驗證連結發送提示頁面
    router.push({
      path: '/verification-sent',
      query: { email: registerForm.email }
    })
    
  } catch (error) {
    console.error('註冊失敗:', error)
    ElMessage.error('註冊失敗,請檢查網絡連接')
  } finally {
    loading.value = false
  }
}

後端實作
#

這裡我們同樣以 後端註冊功能 進行說明,包含 用戶資料驗證密碼加密JWT Token 生成信箱驗證 等。

後端 API 採用 Spring Boot 開發,涵蓋認證 Controller業務服務資料存取等模組。選擇 Spring Boot 的原因,除了本身是 Java 開發者外,Spring Boot 提供了依賴注入AOP安全框架等功能,非常適合用來快速開發 RESTful API,並且易於維護擴充

後端註冊功能的處理流程如下:

  1. 首先接收前端傳來的註冊請求,進行資料驗證
  2. 檢查用戶名信箱是否已存在,避免重複註冊
  3. 密碼加密後,建立用戶資料並儲存到資料庫,此時會員為非驗證狀態
  4. 發送信箱驗證信件,並產生 JWT Token(Token 主要用於後續驗證流程)
  5. 返回註冊結果給前端

認證 Controller 實作
#

認證 Controller 負責處理來自前端的 HTTP 請求,包含註冊登入信箱驗證等等的 Request。這裡用 RESTful API 架構做設計。

認證 Controller (AuthController.java)
java
/**
 * 認證 Controller
 * 處理用戶註冊、登入、信箱驗證等認證相關請求
 */
@RestController
@RequestMapping("/auth")
@CrossOrigin(origins = "*")
public class AuthController {
    
    private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
    
    @Autowired
    private AuthService authService;
    
    @Autowired
    private UserService userService;
    
    @Autowired(required = false)
    private EmailService emailService;
    
    @Autowired(required = false)
    private MockEmailService mockEmailService;
    
    @Autowired
    private JwtUtil jwtUtil;

    /**
     * 用戶登入
     */
    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest loginRequest) {
        logger.info("收到登入請求: {}", loginRequest.getUsername());
        
        AuthResponse response = authService.login(loginRequest);
        
        if (response.isSuccess()) {
            return ResponseEntity.ok(response);
        } else {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
        }
    }

    /**
     * 用戶註冊端點
     */
    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest registerRequest) {
        logger.info("收到註冊請求: {}", registerRequest.getUsername());
        
        AuthResponse response = authService.register(registerRequest);
        
        if (response.isSuccess()) {
            return ResponseEntity.status(HttpStatus.CREATED).body(response);
        } else {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
        }
    }

    /**
     * 發送郵件驗證連結
     */
    @PostMapping("/send-verification-code")
    public ResponseEntity<Map<String, Object>> sendVerificationCode(@RequestBody Map<String, String> request) {
        logger.info("收到發送驗證連結請求");
        
        Map<String, Object> response = new HashMap<>();
        String email = request.get("email");
        
        if (email == null || email.trim().isEmpty()) {
            response.put("success", false);
            response.put("message", "信箱不能為空");
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
        }
        
        // 驗證信箱格式
        if (!email.matches("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$")) {
            response.put("success", false);
            response.put("message", "信箱格式不正確");
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
        }
        
        // 檢查信箱是否已經在users表中存在
        if (userService.existsByEmail(email)) {
            Optional<User> userOptional = userService.findByEmail(email);
            if (userOptional.isPresent()) {
                User user = userOptional.get();
                if (user.getStatus() == UserStatus.ACTIVE) {
                    response.put("success", false);
                    response.put("message", "該信箱已被註冊,請使用其他信箱");
                    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
                }
            }
        }
        
        boolean result;
        if (emailService != null) {
            result = emailService.sendVerificationCode(email);
        } else if (mockEmailService != null) {
            result = mockEmailService.sendVerificationCode(email);
        } else {
            response.put("success", false);
            response.put("message", "郵件服務暫時不可用");
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
        }
        
        if (result) {
            response.put("success", true);
            response.put("message", "驗證連結已發送到您的信箱");
            return ResponseEntity.ok(response);
        } else {
            response.put("success", false);
            response.put("message", "發送失敗,請稍後再試");
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
        }
    }
    
    /**
     * 驗證郵件驗證連結
     */
    @PostMapping("/verify-email")
    public ResponseEntity<Map<String, Object>> verifyEmail(@RequestBody Map<String, String> request) {
        logger.info("收到郵件驗證請求");
        
        Map<String, Object> response = new HashMap<>();
        String email = request.get("email");
        String token = request.get("token");
        
        if (email == null || email.trim().isEmpty() || token == null || token.trim().isEmpty()) {
            response.put("success", false);
            response.put("message", "信箱和驗證token不能為空");
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
        }
        
        boolean result;
        if (emailService != null) {
            result = emailService.verifyCode(email, token);
        } else if (mockEmailService != null) {
            result = mockEmailService.verifyCode(email, token);
        } else {
            response.put("success", false);
            response.put("message", "郵件服務暫時不可用");
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
        }
        
        if (result) {
            // 驗證成功後,獲取用戶信息並返回登入token
            Optional<User> userOptional = userService.findByEmail(email);
            if (userOptional.isPresent()) {
                User user = userOptional.get();
                
                // 生成JWT Token
                String jwtToken = jwtUtil.generateToken(
                    user.getUsername(), 
                    user.getRole().name(), 
                    user.getId()
                );
                
                String refreshToken = jwtUtil.generateRefreshToken(user.getUsername());
                UserResponse userResponse = userService.convertToUserResponse(user);
                
                response.put("success", true);
                response.put("message", "信箱驗證成功");
                response.put("token", jwtToken);
                response.put("refreshToken", refreshToken);
                response.put("user", userResponse);
                return ResponseEntity.ok(response);
            } else {
                response.put("success", false);
                response.put("message", "用戶信息獲取失敗");
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
            }
        } else {
            response.put("success", false);
            response.put("message", "驗證token錯誤或已過期");
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
        }
    }
}

JWT Util 實作
#

JWT(JSON Web Token)是一種用於在用戶與伺服器之間安全傳遞資訊的標準格式。簡單來說,它就像一張「數位身分證」,裡面包含了用戶名角色ID等用戶基本資訊,以及有效期限等資訊。

在會員註冊登入系統中,JWT Token的主要用途有:

  1. 身份驗證:用戶登入成功後,系統會生成一個包含用戶資訊的Token
  2. 會話管理:前端會將Token儲存在localStorage,後續所有API請求都會帶上這個Token
  3. 權限控制:後端可以從Token中解析出用戶身份,決定用戶可以存取哪些功能
JWT Util 實作 (JwtUtil.java)
java
/**
 * JWT Util
 * 負責 JWT Token 的生成、驗證和解析
 */
@Component
public class JwtUtil {
    
    @Value("${jwt.secret}") //  secret key 參數 從 .env 帶到 application.yml
    private String secret;
    
    @Value("${jwt.expiration:86400000}") // 24小時 (毫秒)
    private Long expiration;
    
    @Value("${jwt.refresh-expiration:604800000}") // 7天 (毫秒)
    private Long refreshExpiration;
    
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }
    
    /**
     * 從Token中提取用戶名
     */
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }
    
    /**
     * 從Token中提取過期時間
     */
    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }
    
    /**
     * 從Token中提取指定的聲明
     */
    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }
    
    /**
     * 從Token中提取所有聲明
     */
    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
    
    /**
     * 檢查Token是否過期
     */
    public Boolean isTokenExpired(String token) {
        try {
            final Date expiration = getExpirationDateFromToken(token);
            return expiration.before(new Date());
        } catch (Exception e) {
            logger.warn("Token過期檢查失敗: {}", e.getMessage());
            return true;
        }
    }
    
    /**
     * 生成訪問Token
     */
    public String generateToken(String username, String role, Long userId) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", role);
        claims.put("userId", userId);
        return createToken(claims, username, expiration);
    }
    
    /**
     * 生成刷新Token
     */
    public String generateRefreshToken(String username) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("tokenType", "refresh");
        return createToken(claims, username, refreshExpiration);
    }
    
    /**
     * 創建Token
     */
    private String createToken(Map<String, Object> claims, String subject, Long expiration) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS512)
                .compact();
    }
    
    /**
     * 驗證Token
     */
    public Boolean validateToken(String token, String username) {
        try {
            final String tokenUsername = getUsernameFromToken(token);
            return (tokenUsername.equals(username) && !isTokenExpired(token));
        } catch (Exception e) {
            logger.warn("Token驗證失敗: {}", e.getMessage());
            return false;
        }
    }
    
    /**
     * 從Token中獲取用戶ID
     */
    public Long getUserIdFromToken(String token) {
        try {
            Claims claims = getAllClaimsFromToken(token);
            return claims.get("userId", Long.class);
        } catch (Exception e) {
            logger.warn("從Token獲取用戶ID失敗: {}", e.getMessage());
            return null;
        }
    }
    
    /**
     * 從Token中獲取用戶角色
     */
    public String getRoleFromToken(String token) {
        try {
            Claims claims = getAllClaimsFromToken(token);
            return claims.get("role", String.class);
        } catch (Exception e) {
            logger.warn("從Token獲取用戶角色失敗: {}", e.getMessage());
            return null;
        }
    }
}

認證服務實作
#

AuthService 主要負責用戶註冊登入等認證相關的業務邏輯,將 Controller 與資料存取層分離,提升程式碼的可維護性可測試性

認證服務實作 (AuthService.java)
java
/**
 * 認證服務類別
 * 處理用戶登入、註冊等認證相關業務邏輯
 */
@Service
public class AuthService {
    
    @Autowired
    private UserService userService
    
    @Autowired
    private PasswordEncoder passwordEncoder
    
    @Autowired(required = false)
    private EmailService emailService;
    
    @Autowired(required = false)
    private MockEmailService mockEmailService;
    
    @Autowired
    private JwtUtil jwtUtil;
    
    /**
     * 用戶登入處理
     * @param loginRequest 登入請求
     * @return AuthResponse 認證響應
     */
    public AuthResponse login(LoginRequest loginRequest) {
        logger.info("用戶登入請求: {}", loginRequest.getUsername());
        
        try {
            // 查找用戶
            Optional<User> userOptional = userService.findByUsernameOrEmail(loginRequest.getUsername());
            if (!userOptional.isPresent()) {
                logger.warn("登入失敗: 用戶不存在 - {}", loginRequest.getUsername());
                return AuthResponse.failure("用戶名或密碼錯誤");
            }
            
            User user = userOptional.get();
            
            // 檢查用戶狀態
            if (user.getStatus() == UserStatus.INACTIVE) {
                logger.warn("登入失敗: 用戶尚未驗證 - {}, 狀態: {}", user.getUsername(), user.getStatus());
                return AuthResponse.pendingVerification("帳號待驗證", user.getEmail());
            } else if (!userService.isUserActive(user)) {
                logger.warn("登入失敗: 用戶狀態不正常 - {}, 狀態: {}", user.getUsername(), user.getStatus());
                return AuthResponse.failure("用戶帳號已被暫停或停用");
            }
            
            // 驗證密碼
            if (!userService.validatePassword(user, loginRequest.getPassword())) {
                logger.warn("登入失敗: 密碼錯誤 - {}", loginRequest.getUsername());
                return AuthResponse.failure("用戶名或密碼錯誤");
            }
            
            // 生成JWT Token
            String token = jwtUtil.generateToken(
                user.getUsername(), 
                user.getRole().name(), 
                user.getId()
            );
            
            String refreshToken = jwtUtil.generateRefreshToken(user.getUsername());
            
            UserResponse userResponse = userService.convertToUserResponse(user);
            
            logger.info("用戶登入成功: {}", user.getUsername());
            return AuthResponse.success("登入成功", token, refreshToken, userResponse);
            
        } catch (Exception e) {
            logger.error("登入過程中發生錯誤: {}", e.getMessage(), e);
            return AuthResponse.failure("登入失敗,請稍後再試");
        }
    }
    
    /**
     * 用戶註冊處理
     * @param registerRequest 註冊請求
     * @return AuthResponse 認證響應
     */
    public AuthResponse register(RegisterRequest registerRequest) {
        logger.info("用戶註冊請求: {}", registerRequest.getUsername());
        
        try {
            // 檢查信箱是否已存在(信箱是登入帳號,不允許重複)
            if (userService.existsByEmail(registerRequest.getEmail())) {
                logger.warn("註冊失敗: 信箱已存在 - {}", registerRequest.getEmail());
                return AuthResponse.failure("信箱已存在");
            }
            
            // 註冊時不需要檢查信箱驗證,因為用戶狀態為 INACTIVE,需要後續驗證才能啟用
            
            // 創建新用戶
            User user = userService.createUser(registerRequest);
            
            // 生成JWT Token
            String token = jwtUtil.generateToken(
                user.getUsername(), 
                user.getRole().name(), 
                user.getId()
            );
            
            String refreshToken = jwtUtil.generateRefreshToken(user.getUsername());
            
            UserResponse userResponse = userService.convertToUserResponse(user);
            
            logger.info("用戶註冊成功: {}", user.getUsername());
            return AuthResponse.success("註冊成功", token, refreshToken, userResponse);
            
        } catch (RuntimeException e) {
            logger.warn("註冊失敗: {}", e.getMessage());
            return AuthResponse.failure(e.getMessage());
        } catch (Exception e) {
            logger.error("註冊過程中發生錯誤: {}", e.getMessage(), e);
            return AuthResponse.failure("註冊失敗,請稍後再試");
        }
    }
    
    /**
     * 刷新Token
     */
    public AuthResponse refreshToken(String refreshToken) {
        logger.info("刷新Token請求");
        
        try {
            // 驗證刷新Token
            String username = jwtUtil.getUsernameFromToken(refreshToken);
            if (username == null || !jwtUtil.validateToken(refreshToken, username)) {
                logger.warn("刷新Token失敗: Token無效");
                return AuthResponse.failure("Token無效");
            }
            
            // 查找用戶
            Optional<User> userOptional = userService.findByUsername(username);
            if (!userOptional.isPresent()) {
                logger.warn("刷新Token失敗: 用戶不存在 - {}", username);
                return AuthResponse.failure("用戶不存在");
            }
            
            User user = userOptional.get();
            
            // 檢查用戶狀態
            if (!userService.isUserActive(user)) {
                logger.warn("刷新Token失敗: 用戶狀態不正常 - {}", username);
                return AuthResponse.failure("用戶帳號已被暫停或停用");
            }
            
            // 生成新的Token
            String newToken = jwtUtil.generateToken(
                user.getUsername(), 
                user.getRole().name(), 
                user.getId()
            );
            
            String newRefreshToken = jwtUtil.generateRefreshToken(user.getUsername());
            
            UserResponse userResponse = userService.convertToUserResponse(user);
            
            logger.info("Token刷新成功: {}", username);
            return AuthResponse.success("Token刷新成功", newToken, newRefreshToken, userResponse);
            
        } catch (Exception e) {
            logger.error("刷新Token過程中發生錯誤: {}", e.getMessage(), e);
            return AuthResponse.failure("Token刷新失敗");
        }
    }
}

用戶服務實作
#

UserService 負責處理所有與用戶相關的業務邏輯,包含用戶的創建查詢更新密碼驗證等功能。

用戶服務實作 (UserService.java)
java
/**
 * 用戶服務類別
 * 處理用戶相關的業務邏輯,包含創建、查詢、更新等功能
 */
@Service
@Transactional
public class UserService {
    
    private static final Logger logger = LoggerFactory.getLogger(UserService.class);
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private PasswordEncoder passwordEncoder;
    
    /**
     * 創建新用戶
     * 處理用戶註冊時的用戶創建邏輯
     */
    public User createUser(RegisterRequest registerRequest) {
        logger.info("創建新用戶: {}", registerRequest.getEmail());
        
        // 檢查信箱是否已存在(信箱是登入帳號,不允許重複)
        if (userRepository.existsByEmail(registerRequest.getEmail())) {
            throw new RuntimeException("信箱已存在");
        }
        
        // 創建新用戶
        User user = new User();
        // username可以為空,後續在會員維護時再填寫
        user.setUsername(registerRequest.getUsername());
        user.setEmail(registerRequest.getEmail());
        user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
        user.setRole(UserRole.USER);
        user.setStatus(UserStatus.INACTIVE); // 註冊時狀態為非啟用,需要信箱驗證後才能啟用
        
        User savedUser = userRepository.save(user);
        logger.info("用戶創建成功: {}", savedUser.getEmail());
        
        return savedUser;
    }
    
    /**
     * 根據用戶名查找用戶
     */
    public Optional<User> findByUsername(String username) {
        return userRepository.findByUsername(username);
    }
    
    /**
     * 根據信箱查找用戶
     */
    public Optional<User> findByEmail(String email) {
        return userRepository.findByEmail(email);
    }
    
    /**
     * 根據用戶名或信箱查找用戶
     * 支援用戶使用用戶名或信箱登入
     */
    public Optional<User> findByUsernameOrEmail(String usernameOrEmail) {
        return userRepository.findByUsernameOrEmail(usernameOrEmail);
    }
    
    /**
     * 根據ID查找用戶
     */
    public Optional<User> findById(Long id) {
        return userRepository.findById(id);
    }
    
    /**
     * 檢查用戶名是否存在
     */
    public boolean existsByUsername(String username) {
        return userRepository.existsByUsername(username);
    }
    
    /**
     * 檢查信箱是否存在
     */
    public boolean existsByEmail(String email) {
        return userRepository.existsByEmail(email);
    }
    
    /**
     * 更新用戶信息
     */
    public User updateUser(User user) {
        logger.info("更新用戶信息: {}", user.getUsername());
        return userRepository.save(user);
    }
    
    /**
     * 更新用戶狀態
     * 用於啟用、停用或暫停用戶帳戶
     */
    public User updateUserStatus(Long userId, UserStatus status) {
        logger.info("更新用戶狀態: userId={}, status={}", userId, status);
        
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("用戶不存在"));
        
        user.setStatus(status);
        return userRepository.save(user);
    }
    
    /**
     * 驗證用戶密碼
     * 使用 Spring Security 的 PasswordEncoder 進行密碼比對
     */
    public boolean validatePassword(User user, String rawPassword) {
        return passwordEncoder.matches(rawPassword, user.getPassword());
    }
    
    /**
     * 更新用戶密碼
     * 新密碼會自動加密後儲存
     */
    public User updatePassword(Long userId, String newPassword) {
        logger.info("更新用戶密碼: userId={}", userId);
        
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("用戶不存在"));
        
        user.setPassword(passwordEncoder.encode(newPassword));
        return userRepository.save(user);
    }
    
    /**
     * 檢查用戶是否處於活躍狀態
     * 只有 ACTIVE 狀態的用戶才能正常登入
     */
    public boolean isUserActive(User user) {
        return user.getStatus() == UserStatus.ACTIVE;
    }
    
    /**
     * 將User實體轉換為UserResponse
     * 用於API回應,避免直接暴露實體類別
     */
    public UserResponse convertToUserResponse(User user) {
        return UserResponse.fromUser(user);
    }
}

信箱驗證實作
#

EmailService 負責處理信箱驗證相關的業務邏輯,包含發送驗證信件和驗證 Token 的有效性。

信箱驗證實作 (EmailService.java)
java
/**
 * 郵件服務類別
 * 處理信箱驗證相關業務邏輯
 */
@Service
@ConditionalOnProperty(name = "app.email.mock", havingValue = "false", matchIfMissing = true)
public class EmailService {
    
    private static final Logger logger = LoggerFactory.getLogger(EmailService.class);
    
    @Autowired
    private JavaMailSender mailSender;
    
    @Autowired
    private EmailVerificationRepository emailVerificationRepository;
    
    @Autowired
    private UserRepository userRepository;
    
    @Value("${spring.mail.username:}")
    private String fromEmail;
    
    @Value("${app.name:your_app_name}")
    private String appName;
    
    @Value("${app.frontend.url:http://localhost:3000}")
    private String frontendUrl;
    
    /**
     * 發送郵件驗證連結
     */
    @Transactional
    public boolean sendVerificationCode(String email) {
        logger.info("發送郵件驗證連結到: {}", email);
        
        try {
            // 檢查信箱是否已經在users表中存在
            if (userRepository.existsByEmail(email)) {
                Optional<User> userOptional = userRepository.findByEmail(email);
                if (userOptional.isPresent()) {
                    User user = userOptional.get();
                    if (user.getStatus() == UserStatus.ACTIVE) {
                        logger.warn("信箱 {} 已經被註冊且已啟用", email);
                        return false;
                    }
                    logger.info("信箱 {} 已註冊但未驗證,允許重新發送驗證連結", email);
                }
            }
            
            // 使該信箱的所有舊驗證記錄失效
            emailVerificationRepository.invalidateAllVerificationsForEmail(email);
            logger.info("已使信箱 {} 的所有舊驗證連結失效", email);
            
            // 生成唯一的驗證 token
            String verificationToken = generateVerificationToken();
            
            // 保存驗證記錄
            EmailVerification emailVerification = new EmailVerification(email, verificationToken);
            emailVerificationRepository.save(emailVerification);
            
            // 生成驗證連結
            String verificationLink = getFrontendUrl() + "/verify-email?token=" + verificationToken + "&email=" + email;
            
            
            // 發送郵件
            sendVerificationEmail(email, verificationLink);
            
            logger.info("郵件驗證連結發送成功: {}", email);
            return true;
            
        } catch (Exception e) {
            logger.error("發送郵件驗證連結失敗: {}", e.getMessage(), e);
            return false;
        }
    }
    
    /**
     * 驗證郵件驗證連結
     */
    @Transactional
    public boolean verifyCode(String email, String token) {
        logger.info("驗證郵件驗證連結: email={}, token={}", email, token);
        
        try {
            Optional<EmailVerification> verificationOpt = 
                emailVerificationRepository.findByEmailAndVerificationToken(email, token);
            
            if (!verificationOpt.isPresent()) {
                logger.warn("驗證連結不存在: email={}, token={}", email, token);
                return false;
            }
            
            EmailVerification verification = verificationOpt.get();
            
            if (!verification.isValid()) {
                logger.warn("驗證連結已過期或已使用: email={}", email);
                return false;
            }
            
            // 標記為已驗證
            verification.setIsVerified(true);
            emailVerificationRepository.save(verification);
            
            // 將對應的用戶狀態改為 ACTIVE
            userRepository.activateUserByEmail(email);
            logger.info("用戶 {} 已啟用", email);
            
            logger.info("郵件驗證成功: {}", email);
            return true;
            
        } catch (Exception e) {
            logger.error("驗證郵件驗證連結失敗: {}", e.getMessage(), e);
            return false;
        }
    }
    
    /**
     * 檢查信箱是否已驗證
     */
    public boolean isEmailVerified(String email) {
        return emailVerificationRepository.isEmailVerified(email);
    }
    
    /**
     * 生成唯一的驗證 token
     */
    private String generateVerificationToken() {
        return UUID.randomUUID().toString().replace("-", "");
    }
    
    /**
     * 獲取前端 URL
     */
    private String getFrontendUrl() {
        return frontendUrl;
    }
    
    /**
     * 發送驗證郵件
     */
    private void sendVerificationEmail(String email, String verificationLink) {
        try {
            SimpleMailMessage message = new SimpleMailMessage();
            message.setFrom(fromEmail);
            message.setTo(email);
            message.setSubject("【" + appName + "】信箱驗證連結");
            
            String content = String.format(
                "親愛的用戶,\n\n" +
                "請點擊以下連結完成信箱驗證:\n\n" +
                "%s\n\n" +
                "該驗證連結將在10分鐘後過期,請及時使用。\n\n" +
                "如果您沒有申請該驗證連結,請忽略此郵件。\n\n" +
                "謝謝!\n" +
                "%s 團隊",
                verificationLink, appName
            );
            
            message.setText(content);
            mailSender.send(message);
            
            logger.info("驗證郵件發送成功: {}", email);
            
        } catch (Exception e) {
            logger.error("發送驗證郵件失敗: {}", e.getMessage(), e);
            throw new RuntimeException("郵件發送失敗", e);
        }
    }
}

環境變數中有個 mail 的設定,是透過 GmailSMTP 來發送驗證連結郵件

bash
# 郵件配置
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=your_email@gmail.com
MAIL_PASSWORD=your_app_password

MAIL_PASSWORD 取得非常容易,步驟如下:

  1. 管理你的Google帳戶的地方,找到安全性然後搜尋應用程式密碼
  2. 然後為新增的應用程式取一個名稱,輸入完畢之後就會拿到密碼

Spring Boot 應用程式配置
#

需要配置 application.yml 檔案來設定資料庫連接JWT郵件服務等參數。

application.yml
yaml
spring:
  application:
    name: your_app_name
  
  # 資料庫配置
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}
    driver-class-name: org.postgresql.Driver
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      idle-timeout: 300000
      connection-timeout: 20000
      max-lifetime: 1200000
  
  # JPA 配置
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: true
        use_sql_comments: true
        jdbc:
          time_zone: Asia/Taipei
        connection:
          timezone: Asia/Taipei
    generate-ddl: true
  
  # 郵件配置
  mail:
    host: ${MAIL_HOST}
    port: ${MAIL_PORT}
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          connectiontimeout: 5000
          timeout: 3000
          writetimeout: 5000
        debug: false

# 服務配置
server:
  port: ${SERVER_PORT}
  servlet:
    context-path: /api
  compression:
    enabled: true
    mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
    min-response-size: 1024

# JWT 配置
jwt:
  secret: ${JWT_SECRET}
  expiration: ${JWT_EXPIRATION}  # 24小時 (毫秒)
  refresh-expiration: ${JWT_REFRESH_EXPIRATION}  # 7天 (毫秒)



# 日誌配置
logging:
  level:
    com.your_app_name: INFO
    org.springframework.security: DEBUG
    org.springframework.web: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
  file:
    name: logs/your_app_name.log
    max-size: 10MB
    max-history: 30

資料庫配置
#

這裡我們選用 PostgreSQL 作為主要資料庫。PostgreSQL 不僅是開源功能強大的關聯式資料庫社群活躍資源豐富,適合各種規模的專案。對於有其他資料庫操作經驗的開發者來說,轉換到 PostgreSQL 也非常容易上手,學習曲線不高。此外,PostgreSQL 易於擴充和維護,能夠滿足未來系統成長的需求。

我們在前面有透過 docker compose 設定 5050 port 並成功部署 pgAdmin 後,打開 http://your_hostname:5050/ 就會進到 pgAdmin操作管理頁面container list 可以透過網頁介面對資料庫進行操作。 container list

資料庫表設計
#

資料庫表結構 SQL
sql
-- 創建用戶 Table
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    role VARCHAR(20) NOT NULL DEFAULT 'USER',
    status VARCHAR(20) NOT NULL DEFAULT 'INACTIVE',
    email_verified BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_login_at TIMESTAMP,
    CONSTRAINT chk_role CHECK (role IN ('USER', 'ADMIN')),
    CONSTRAINT chk_status CHECK (status IN ('ACTIVE', 'INACTIVE', 'SUSPENDED'))
);

-- 創建信箱驗證 Table
CREATE TABLE email_verifications (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    email VARCHAR(100) NOT NULL,
    token VARCHAR(255) UNIQUE NOT NULL,
    verified BOOLEAN DEFAULT FALSE,
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    verified_at TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- 創建索引以提升查詢效能
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_created_at ON users(created_at);

CREATE INDEX idx_email_verifications_token ON email_verifications(token);
CREATE INDEX idx_email_verifications_user_id ON email_verifications(user_id);
CREATE INDEX idx_email_verifications_email ON email_verifications(email);
CREATE INDEX idx_email_verifications_verified ON email_verifications(verified);
CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at);

資料庫 Spring Data JPA 實作
#

這裡使用 Spring Data JPA 來實作 UserRepositoryEmailVerificationRepository,只要繼承 JpaRepository interface,就能自動生成基本的 CRUD 操作,也能自訂查詢方法,可以更專注在其他邏輯實作上,並同時支援多種資料庫像是PostgreSQL

用戶 Repository (UserRepository.java)
java
/**
 * 用戶資料庫存取層
 * 提供用戶相關的資料庫操作
 */
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    /**
     * 根據用戶名查找用戶
     */
    Optional<User> findByUsername(String username);
    
    /**
     * 根據信箱查找用戶
     */
    Optional<User> findByEmail(String email);
    
    /**
     * 根據用戶名或信箱查找用戶
     * 支援用戶使用用戶名或信箱登入
     */
    @Query("SELECT u FROM User u WHERE u.username = :usernameOrEmail OR u.email = :usernameOrEmail")
    Optional<User> findByUsernameOrEmail(@Param("usernameOrEmail") String usernameOrEmail);
    
    /**
     * 檢查用戶名是否存在
     */
    boolean existsByUsername(String username);
    
    /**
     * 檢查信箱是否存在
     */
    boolean existsByEmail(String email);
    
    /**
     * 根據狀態查找用戶
     */
    List<User> findByStatus(UserStatus status);
    
    /**
     * 根據角色查找用戶
     */
    List<User> findByRole(UserRole role);
    
    /**
     * 查找未驗證信箱的用戶
     */
    List<User> findByEmailVerifiedFalse();
    
    /**
     * 根據創建時間範圍查找用戶
     */
    @Query("SELECT u FROM User u WHERE u.createdAt BETWEEN :startDate AND :endDate")
    List<User> findByCreatedAtBetween(@Param("startDate") LocalDateTime startDate, 
                                     @Param("endDate") LocalDateTime endDate);
    
    /**
     * 統計各狀態的用戶數量
     */
    @Query("SELECT u.status, COUNT(u) FROM User u GROUP BY u.status")
    List<Object[]> countByStatus();
    
    /**
     * 啟用指定信箱的用戶
     */
    @Modifying
    @Query("UPDATE User u SET u.status = 'ACTIVE', u.emailVerified = true WHERE u.email = :email")
    int activateUserByEmail(@Param("email") String email);
    
    /**
     * 更新用戶最後登入時間
     */
    @Modifying
    @Query("UPDATE User u SET u.lastLoginAt = :lastLoginAt WHERE u.id = :userId")
    int updateLastLoginAt(@Param("userId") Long userId, @Param("lastLoginAt") LocalDateTime lastLoginAt);
}
信箱驗證 Repository (EmailVerificationRepository.java)
java
/**
 * 信箱驗證資料庫存取層
 * 提供信箱驗證相關的資料庫操作
 */
@Repository
public interface EmailVerificationRepository extends JpaRepository<EmailVerification, Long> {
    
    /**
     * 根據 Token 查找驗證記錄
     */
    Optional<EmailVerification> findByToken(String token);
    
    /**
     * 根據信箱和 Token 查找驗證記錄
     */
    Optional<EmailVerification> findByEmailAndToken(String email, String token);
    
    /**
     * 根據信箱查找所有驗證記錄
     */
    List<EmailVerification> findByEmail(String email);
    
    /**
     * 查找未驗證的記錄
     */
    List<EmailVerification> findByVerifiedFalse();
    
    /**
     * 查找已過期的記錄
     */
    @Query("SELECT ev FROM EmailVerification ev WHERE ev.expiresAt < :now AND ev.verified = false")
    List<EmailVerification> findExpiredVerifications(@Param("now") LocalDateTime now);
    
    /**
     * 檢查信箱是否已驗證
     */
    @Query("SELECT COUNT(ev) > 0 FROM EmailVerification ev WHERE ev.email = :email AND ev.verified = true")
    boolean isEmailVerified(@Param("email") String email);
    
    /**
     * 使指定信箱的所有舊驗證記錄失效
     */
    @Modifying
    @Query("UPDATE EmailVerification ev SET ev.verified = true WHERE ev.email = :email AND ev.verified = false")
    int invalidateAllVerificationsForEmail(@Param("email") String email);
    
    /**
     * 刪除過期的驗證記錄
     */
    @Modifying
    @Query("DELETE FROM EmailVerification ev WHERE ev.expiresAt < :expiryDate")
    int deleteExpiredVerifications(@Param("expiryDate") LocalDateTime expiryDate);
    
    /**
     * 統計各信箱的驗證記錄數量
     */
    @Query("SELECT ev.email, COUNT(ev) FROM EmailVerification ev GROUP BY ev.email")
    List<Object[]> countByEmail();
}

結語
#

透過 docker compose 快速部署,並實作一個 Spring Boot + Vue + PostgreSQL 的會員註冊登入系統,有基本的註冊登入功能信箱驗證儲存會員資料的功能。但其實隨著系統規模擴大,這樣的會員登入系統其實是不夠用的。

用戶量增加時,所有的 API 請求都直接打到後端服務會造成負載過重,例如同時有 1000 個用戶登入,單一後端服務可能無法及時處理所有請求。這時候就需要考慮加入 Nginx 作為負載均衡反向代理,能夠分散請求到多個 Web Server 上並提供安全防護,大幅提升系統的處理能力穩定性

如果用戶量不大,單純使用 JWT 認證 已經足夠,但隨著系統規模擴大,加入 Redis 作為快取會話管理,可以集中管理用戶登入狀態快取用戶資訊,例如將熱門用戶資料快取在記憶體中,減少資料庫查詢壓力並提升回應速度

而在使用者體驗的部分,註冊功能還可以整合第三方登入,像是 GoogleFacebook 登入,讓用戶不需要記住額外的帳號密碼。我們不但可以透過這些第三方登入來取得 OAuth(授權協議)來認證用戶,而且在現代這麼多應用程式的環境中,每個都要註冊的話,用戶通常會覺得填寫註冊資料又要驗證信箱的流程是很麻煩的。

相關文章

Docker 常用指令、操作
類別 
後端開發 常用指令
標籤 
Docker
Html2canvas + Fabricjs 應用
類別 
前端開發
標籤 
Html2canvas Fabricjs
使用Can I Use 檢查瀏覽器相容性
類別 
前端開發
標籤 
Browser