前言 #
會員註冊登入系統在網站開發中是最普遍的一項功能,雖然表面上流程單純,實際上除了要確保用戶身份驗證的準確性,還必須兼顧多方面的需求,例如信箱驗證、密碼強度與加密、Token 管理等。
最近在寫 Side Project,才發現到原來要設計一個完善的會員系統需要考慮的項目,比想像中還要多,這裡簡單做個介紹與紀錄,以一個擁有基本會員資料儲存,與信箱驗證功能的登入系統進行介紹。
主要用的框架與工具如下,詳細說明請參考下方的 系統實作部分:
| 類別 | 技術 |
|---|---|
| 前端框架 | Vue.js |
| 後端框架 | Spring Boot |
| 資料庫 | PostgreSQL |
| 容器化 | Docker / Docker Compose |
會員系統行為流程分析 #
首先我們畫一個行為流程圖,分析一個非會員的使用者,從註冊到完成驗證,再到後續登入的所有一連串的行為。
接著我們把註冊、與登入流程拆開,分別將用戶行為,還有系統動作條列出來。這樣的分析可以幫助我們更清楚地了解每個階段用戶需要做什麼,以及系統在背後如何處理這些請求。透過詳細的流程分析,我們也能更好地設計使用者體驗和系統架構。
用戶註冊行為分析 #
詳細步驟
-
用戶訪問註冊頁面
- 用戶點擊註冊按鈕或直接訪問註冊頁面
- 系統檢查是否已登入,如果已登入則跳轉到首頁
-
用戶填寫註冊資訊
- 用戶輸入用戶名、信箱、密碼和確認密碼
- 前端進行即時表單驗證
- 顯示驗證錯誤或成功提示
-
提交註冊請求
- 點擊註冊按鈕觸發 API 請求
- 顯示載入狀態,防止重複提交
- 發送 POST 請求到後端註冊端點
-
後端註冊處理
- 驗證請求格式和必填欄位
- 檢查用戶名和信箱是否已存在
- 加密密碼並創建用戶記錄
- 生成信箱驗證連結
-
返回註冊結果
- 註冊成功:返回用戶資訊和驗證提示
- 註冊失敗:返回錯誤訊息
- 前端處理回應並更新狀態
-
信箱驗證處理
- 發送驗證信件到用戶信箱
- 用戶點擊驗證連結
- 驗證成功後啟用帳戶
用戶登入行為分析 #
詳細步驟
-
用戶訪問登入頁面
- 用戶點擊登入按鈕或直接訪問登入頁面
- 系統檢查是否已登入,如果已登入則跳轉到首頁
-
用戶輸入憑證
- 用戶輸入帳號(信箱)和密碼
- 前端進行表單驗證
- 顯示驗證錯誤或成功提示
-
提交登入請求
- 點擊登入按鈕觸發 API 請求
- 顯示載入狀態,防止重複提交
- 發送 POST 請求到後端認證端點
-
後端認證處理
- 驗證請求格式和必填欄位
- 查詢用戶資料庫
- 驗證密碼是否正確
- 檢查用戶狀態和信箱驗證狀態
- 生成 JWT Token
-
返回認證結果
- 認證成功:返回 Token 和用戶資訊
- 認證失敗:返回錯誤訊息
- 前端處理回應並更新狀態
-
登入後處理
- 儲存 Token 到本地存儲
- 更新認證狀態
- 跳轉到目標頁面或首頁
會員系統模組架構圖 #
系統架構圖 #
大致可分為前端層、後端層與資料層,分別如下:
架構圖大致列出了整個會員註冊登入系統的分層設計。簡單來說,最上層是前端(Vue),負責顯示登入、註冊、信箱驗證等頁面,並透過localStorage做認證狀態管理,所有 API 請求則統一經由Fetch API發送,路由切換則交給Vue Router處理。
後端則是Spring Boot,這裡有認證 Controller、認證服務、用戶服務、信件服務,還有專門處理JWT的Util Class,負責所有的業務邏輯和安全驗證。JwtUtil主要負責生成和驗證Token,包含用戶身份資訊、過期時間等,並使用HMAC-SHA256進行加密。
最底層是資料層,像用戶資料、信箱驗證資料都會透過對應的Repository存取,資料主要放在PostgreSQL。
這樣的分層設計不僅讓前後端可以各自獨立開發、維護。如果未來要擴充功能或調整架構,也會相對容易許多。
註冊系統流程圖 #
註冊的完整流程圖,從用戶填寫表單到完成註冊並發送驗證信件的所有步驟,分別如下:
首先,用戶在註冊頁面(Register.vue)填寫用戶名、信箱和密碼等資訊,前端會進行即時的表單驗證,確保資料格式正確。
當用戶點擊註冊按鈕後,前端會發送POST請求/api/auth/register到後端的AuthController,請求內容包含用戶的基本資訊。
後端的AuthService接收到請求後,會呼叫UserService來檢查用戶是否已經存在。這個檢查是透過查詢PostgreSQL資料庫來完成的,系統會同時檢查用戶名和信箱是否已被使用。
如果發現用戶已存在,系統會立即返回錯誤訊息,告知用戶該帳戶已被註冊。如果用戶不存在,系統會進入註冊流程:首先在資料庫中創建新的用戶記錄,包括加密後的密碼、用戶角色和狀態等資訊。
接著,認證服務會呼叫EmailService來發送信箱驗證信件。這個過程會在資料庫中創建驗證記錄,包含驗證連結和過期時間等資訊。
同時,系統還會生成一個JWT Token,雖然在註冊階段這個Token主要用於後續的驗證流程。最後,系統會返回註冊成功的資訊,並引導用戶到信箱驗證頁面。
登入系統流程圖 #
登入的完整流程圖,從用戶輸入到完成認證並獲取存取權限的所有步驟,分別如下:
首先,用戶在登入頁面(Login.vue)輸入用戶名(或信箱)和密碼,前端會進行即時的表單驗證,確保輸入格式正確。
當用戶點擊登入按鈕後,前端會發送POST請求/api/auth/login到後端的AuthController,請求內容包含用戶的認證資訊。
後端的AuthService接收到請求後,會根據用戶名或信箱查詢PostgreSQL資料庫,獲取用戶的完整資訊,包括加密後的密碼、用戶狀態和信箱驗證狀態等。
接著,系統會將用戶輸入的密碼加密並比對是否與資料庫中儲存的加密密碼相符。如果密碼驗證失敗,系統會立即返回錯誤訊息,告知用戶密碼錯誤。
如果密碼驗證成功,系統會進一步檢查用戶的狀態。如果用戶尚未完成信箱驗證,系統會提示用戶需要先驗證信箱才能登入。如果用戶狀態正常,系統會進入登入成功流程。
在登入成功流程中,JWT Util會生成一個包含用戶身份資訊的JWT Token,前端會將Token儲存到localStorage中,用於後續的API請求認證。
最後,如果驗證都沒問題,系統會返回登入成功的資訊,包含Token和用戶資訊,前端會更新認證狀態並跳轉到首頁或目標頁面。
信箱驗證系統流程圖 #
信箱驗證的完整流程圖,從用戶點擊驗證連結到完成帳戶啟用的所有步驟,分別如下:
首先,用戶會收到註冊時發送的驗證信件,其中包含一個唯一的驗證連結。用戶點擊驗證連結後,會進入驗證頁面(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
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)
# 資料庫配置
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順利啟動後,就會看到正在運行中的服務

前端實作 #
這裡我們以註冊頁面進行說明,這部分是會員模組最複雜的部分,包含表單驗證、加密處理、信箱驗證。
使用 Vue 來開發前端頁面,包含登入、註冊、信箱驗證等頁面等。會選用 Vue 來開發,主要是因為本身對於前端沒有太深入的研究,Vue 相對於 React、Angular 來說算是簡單好上手的,也適合獨立開發、中小型專案的快速開發與維護。
- 首先註冊頁面會對用戶的輸入進行
表單驗證的檢查 信箱與密碼都通過驗證後,就會透過後端發出驗證連結到用戶信箱- 用戶收到信件並點擊連結後,如果驗證的
token沒問題,就完成註冊動作
路由設定與頁面對應 #
../router/index.ts
// 路由配置
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)
<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>驗證架構與邏輯 #
這段程式碼主要分為三個部分:
狀態管理:用來記錄註冊表單的欄位內容(如信箱、密碼等)和載入狀態。檢查是否可以點擊註冊:根據輸入內容是否符合規則(如信箱格式、密碼長度、兩次密碼一致、同意條款)來決定註冊按鈕是否可用。表單驗證規則:定義每個欄位的驗證方式,確保使用者輸入正確,避免送出錯誤資料。
驗證架構與邏輯(Register.vue)
// 組件結構
<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)
// 信箱格式驗證
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)
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,並且易於維護與擴充。
後端註冊功能的處理流程如下:
- 首先接收前端傳來的
註冊請求,進行資料驗證 - 檢查
用戶名和信箱是否已存在,避免重複註冊 - 將
密碼加密後,建立用戶資料並儲存到資料庫,此時會員為非驗證狀態 - 發送
信箱驗證信件,並產生JWT Token(Token 主要用於後續驗證流程) - 返回
註冊結果給前端
認證 Controller 實作 #
認證 Controller 負責處理來自前端的 HTTP 請求,包含註冊、登入、信箱驗證等等的 Request。這裡用 RESTful API 架構做設計。
認證 Controller (AuthController.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的主要用途有:
- 身份驗證:用戶登入成功後,系統會生成一個
包含用戶資訊的Token - 會話管理:前端會將
Token儲存在localStorage,後續所有API請求都會帶上這個Token - 權限控制:後端可以從
Token中解析出用戶身份,決定用戶可以存取哪些功能
JWT Util 實作 (JwtUtil.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)
/**
* 認證服務類別
* 處理用戶登入、註冊等認證相關業務邏輯
*/
@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)
/**
* 用戶服務類別
* 處理用戶相關的業務邏輯,包含創建、查詢、更新等功能
*/
@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)
/**
* 郵件服務類別
* 處理信箱驗證相關業務邏輯
*/
@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 的設定,是透過 Gmail 的 SMTP 來發送驗證連結郵件。
# 郵件配置
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=your_email@gmail.com
MAIL_PASSWORD=your_app_passwordMAIL_PASSWORD 取得非常容易,步驟如下:
- 到
管理你的Google帳戶的地方,找到安全性然後搜尋應用程式密碼。
- 然後為
新增的應用程式取一個名稱,輸入完畢之後就會拿到密碼。
Spring Boot 應用程式配置 #
需要配置 application.yml 檔案來設定資料庫連接、JWT、郵件服務等參數。
application.yml
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 的操作管理頁面。
可以透過網頁介面對資料庫進行操作。

資料庫表設計 #
資料庫表結構 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 來實作 UserRepository、EmailVerificationRepository,只要繼承 JpaRepository interface,就能自動生成基本的 CRUD 操作,也能自訂查詢方法,可以更專注在其他邏輯實作上,並同時支援多種資料庫像是PostgreSQL。
用戶 Repository (UserRepository.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)
/**
* 信箱驗證資料庫存取層
* 提供信箱驗證相關的資料庫操作
*/
@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 作為快取和會話管理,可以集中管理用戶登入狀態和快取用戶資訊,例如將熱門用戶資料快取在記憶體中,減少資料庫查詢壓力並提升回應速度。
而在使用者體驗的部分,註冊功能還可以整合第三方登入,像是 Google、Facebook 登入,讓用戶不需要記住額外的帳號密碼。我們不但可以透過這些第三方登入來取得 OAuth(授權協議)來認證用戶,而且在現代這麼多應用程式的環境中,每個都要註冊的話,用戶通常會覺得填寫註冊資料又要驗證信箱的流程是很麻煩的。