項目概述: WhisperBoard,一個匿名留言板。功能不復雜——註冊、登錄、發消息、看消息、刪自己的消息。但麻雀雖小,五臟俱全:JWT 認證、ORM 數據層、中間件體系、前後端分離、部署上線,一條完整的全棧鏈路。
6 天,從零到線上。這篇文章記錄整個過程,順便梳理我學到的每一塊知識。
一、技術棧與項目架構
先看看用到了什麼:
| 層 | 技術 | 為什麼選它 |
|---|---|---|
| 前端框架 | React 18 + TypeScript | 生態最廣,TypeScript 類型安全 |
| 構建工具 | Vite | 比 CRA 快一個數量級,ESM 原生支持 |
| 樣式 | Tailwind CSS | 原子化 CSS,寫樣式不寫文件 |
| 路由 | react-router-dom v6 | SPA 路由標準方案 |
| 後端框架 | Express 4 + TypeScript | Node.js 最成熟的 Web 框架 |
| ORM | Drizzle ORM | 類型安全、輕量、SQL-like API |
| 數據庫 | SQLite → Turso (libsql) | 本地開發零配置,上線平滑遷移 |
| 認證 | JWT 雙令牌 | 無狀態,適合前後端分離 |
| 密碼加密 | bcryptjs (12 rounds) | 純 JS 實現,免 C++ 編譯依賴 |
| 參數校驗 | Zod | 運行時 + 編譯時雙重校驗 |
架構概覽:
┌──────────────────────────────────────────────┐
│ 瀏覽器 │
│ React SPA (localhost:5173) │
└──────────────────┬───────────────────────────┘
│ HTTP (JSON)
▼
┌──────────────────────────────────────────────┐
│ Express Server │
│ ┌─────────┐ ┌─────────┐ ┌───────────────┐ │
│ │ CORS │ │ Auth │ │ Controllers │ │
│ │ JSON │ │ Validate│ │ │ │
│ │ Cookie │ │ Error │ │ auth / msg │ │
│ └─────────┘ └─────────┘ └───────┬───────┘ │
│ │ │
│ ┌───────▼───────┐ │
│ │ Drizzle ORM │ │
│ └───────┬───────┘ │
└──────────────────────────────────┼───────────┘
│
▼
┌───────────────┐
│ Turso (libsql)│
│ aws-ap-northeast│
└───────────────┘
二、DAY 1 — 從零初始化 + JWT 認證核心
2.1 環境搭建
第一天先搭架子。我給 AI 的指令是:
關鍵配置在根目錄 package.json:
{
"scripts": {
"dev": "concurrently "npm run dev:server" "npm run dev:client"",
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm run dev"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}
npm run dev 一條命令同時啟動前後端,不用開兩個終端窗口。
2.2 JWT 雙令牌機制
認證是整個項目的地基,地基不牢後面全是補丁。我和 AI 討論了半小時,定了這個方案:
access token(15分鐘) → 用來訪問 API
refresh token(7天) → 用來換新的 access token
為什麼要兩個令牌?
如果只有一個令牌,有效期長了不安全(被偷了能用很久),有效期短了用戶體驗差(每 15 分鐘就要重新登錄)。雙令牌折中:access token 短時效降低洩露風險,refresh token 長時效保證用戶不用頻繁登錄。
核心代碼 server/src/utils/jwt.ts:
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
// 兩個密鑰獨立,一個洩露不會影響另一個
// refresh token 帶 jti(JWT ID),支持主動吊銷
export function signAccessToken(userId: string, username: string) {
return jwt.sign(
{ sub: userId, username },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
}
export function signRefreshToken(userId: string) {
const jti = crypto.randomUUID(); // 唯一標識,用於黑名單吊銷
return {
token: jwt.sign(
{ sub: userId, jti },
REFRESH_SECRET,
{ expiresIn: '7d' }
),
jti
};
}
export function verifyAccessToken(token: string) {
return jwt.verify(token, ACCESS_SECRET) as {
sub: string; username: string;
};
}
export function verifyRefreshToken(token: string) {
return jwt.verify(token, REFRESH_SECRET) as {
sub: string; jti: string;
};
}
幾個設計要點:
- 雙密鑰分離:
ACCESS_SECRET和REFRESH_SECRET是兩個不同的環境變量。即使 access secret 洩露,攻擊者也造不出 refresh token - refresh token 帶 jti:每個 refresh token 有唯一 ID,登出時把 jti 放進內存黑名單,即使 token 沒過期也無法使用
- access token 不帶 role:角色信息實時查數據庫,不在 JWT 裡緩存。這樣管理員權限變更立竿見影,不用等 token 過期
補充:JWT 到底安不安全?
JWT 本身不加密(它是簽名的,不是加密的),payload 裡的內容任何人都能 base64 解碼看到。所以絕對不能把密碼、手機號等敏感信息放進去。JWT 的安全性依賴 HTTPS——在傳輸層加密,防止中間人截獲 token。
三、DAY 2 — 數據層:Drizzle ORM + 數據庫設計
3.1 ORM 選型
給 AI 的指令:
為什麼 Drizzle 而不是 Prisma?
Prisma 很成熟,但它生成的 client 動輒幾十 MB,啟動慢,而且它的 schema 語法是自定義的 DSL,不是 TypeScript。Drizzle 的 schema 就是純 TypeScript,你寫的類型就是數據庫的類型,沒有中間層。
3.2 表結構定義
server/src/db/schema.ts:
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
// UUID 主鍵——防止用戶 ID 被枚舉
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // UUID v4
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// 自增整數主鍵——消息不需要隱藏 ID,整數索引更高效
export const messages = sqliteTable('messages', {
id: integer('id').primaryKey({ autoIncrement: true }),
content: text('content').notNull(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
關鍵決策——users 用 UUID,messages 用自增整數:
如果用戶 ID 是 1, 2, 3...,任何人改 URL 就能遍歷所有用戶。UUID 長這樣:550e8400-e29b-41d4-a716-446655440000,幾乎不可能猜到。而 messages 表不需要隱藏 ID,自增整數在 B+ 樹索引上比 UUID 有序得多,寫入和排序都更快。
3.3 數據庫連接
server/src/db/index.ts:
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
const client = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_AUTH_TOKEN,
});
export const db = drizzle(client);
本地開發時 DATABASE_URL 指向 file:./data.db,後面上線切到 Turso 只需改環境變量,一行代碼不用動。
3.4 跑通遷移
npx drizzle-kit generate # 生成 SQL 遷移文件
npx drizzle-kit push # 推到數據庫
生成的遷移文件就是純 SQL,可讀可審:
-- drizzle/0000_xxx.sql
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
看到 data.db 文件出現在項目根目錄,數據庫層搞定。
四、DAY 3 — 中間件體系 + 人生第一個 Git commit
4.1 請求流水線
Express 的中間件是"洋蔥模型"——請求經過一層層中間件,到達 controller,響應再一層層返回:
請求 → CORS → JSON解析 → auth → validate → controller
↓
響應 ← error(異常時兜底) ← ← ← ← ← ← ← ← ← ← ← ← ←
今天寫三個中間件:auth(認證)、validate(校驗)、error(錯誤處理)。
4.2 auth.middleware
server/src/middlewares/auth.middleware.ts:
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
// 擴展 Express 的 Request 類型,掛上 user 信息
declare global {
namespace Express {
interface Request {
user?: {
id: string;
username: string;
};
}
}
}
// 強制登錄——任何受保護接口都必須經過這個中間件
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
) {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({
error: { message: '未登錄,請先登錄' }
});
}
const token = authHeader.split(' ')[1];
const payload = verifyAccessToken(token);
// 實時查數據庫確認用戶存在(防止 token 有效但用戶被刪除的情況)
const [user] = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, payload.sub))
.limit(1);
if (!user) {
return res.status(401).json({
error: { message: '用戶不存在' }
});
}
req.user = user;
next();
} catch (err: any) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: { message: '登錄已過期,請重新登錄' }
});
}
return res.status(401).json({
error: { message: '無效的登錄憑證' }
});
}
}
三個錯誤分支對應三種情況:沒 token、token 過期、token 無效——每種返回不同的錯誤信息,方便前端做不同處理(過期了自動刷新,無效了跳登錄頁)。
4.3 validate.middleware
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
try {
schema.parse(req.body);
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({
error: {
message: '請求參數錯誤',
details: err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
})),
}
});
}
next(err);
}
};
}
Zod 的 parse 不通過會拋異常,我們的中間件捕獲後把錯誤格式化返回。controller 拿到的 req.body 一定是通過校驗的合法數據,不用再寫一堆 if 判斷。
4.4 error.middleware
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction
) {
console.error(`[${new Date().toISOString()}] ${err.message}`);
res.status(500).json({
error: {
message: process.env.NODE_ENV === 'production'
? '服務器內部錯誤'
: err.message,
}
});
}
注意:錯誤中間件有四個參數(多了一個 err),Express 靠參數個數來識別它是錯誤處理中間件。必須放在所有路由之後註冊。
4.5 第一個 Git commit
git init
git add .
git commit -m "feat: init project - Express + React + Drizzle + JWT auth"
42 個文件,一次性提交。這是我這輩子第一個正經的代碼提交。之前寫代碼要麼放文件夾裡吃灰,要麼靠 QQ 傳文件"備份",從來沒有正經版本管理過。
五、DAY 4 — 後端 API 全線跑通
今天是"組裝日"——把之前寫的 JWT、數據庫、中間件,全部串起來。
5.1 認證業務
server/src/controllers/auth.controller.ts:
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../utils/jwt';
// 內存黑名單——生產環境應改用 Redis
const refreshTokenBlacklist = new Set<string>();
export async function register(req: Request, res: Response) {
const { username, password } = req.body;
// 1. 查重
const [existing] = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
if (existing) {
return res.status(409).json({
error: { message: '用戶名已存在' }
});
}
// 2. 哈希密碼(12 輪加密)
const passwordHash = await bcrypt.hash(password, 12);
// 3. 入庫
const id = crypto.randomUUID();
await db.insert(users).values({ id, username, passwordHash });
// 4. 簽發令牌,註冊即登錄
const accessToken = signAccessToken(id, username);
const refreshToken = signRefreshToken(id);
res.status(201).json({
user: { id, username },
accessToken,
refreshToken: refreshToken.token,
});
}
export async function login(req: Request, res: Response) {
const { username, password } = req.body;
const [user] = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
// 用戶不存在和密碼錯誤返回相同錯誤信息
// 防止攻擊者枚舉已註冊的用戶名
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({
error: { message: '用戶名或密碼錯誤' }
});
}
const accessToken = signAccessToken(user.id, user.username);
const refreshToken = signRefreshToken(user.id);
res.json({
user: { id: user.id, username: user.username },
accessToken,
refreshToken: refreshToken.token,
});
}
export async function refresh(req: Request, res: Response) {
const { refreshToken: token } = req.body;
const payload = verifyRefreshToken(token);
// 檢查是否在黑名單中(已登出)
if (refreshTokenBlacklist.has(payload.jti)) {
return res.status(401).json({
error: { message: '登錄已失效,請重新登錄' }
});
}
const [user] = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, payload.sub))
.limit(1);
if (!user) {
return res.status(401).json({
error: { message: '用戶不存在' }
});
}
// 簽發新的 access token(refresh token 不換,保持原有效期)
const newAccessToken = signAccessToken(user.id, user.username);
res.json({ accessToken: newAccessToken });
}
export async function logout(req: Request, res: Response) {
const { refreshToken: token } = req.body;
const payload = verifyRefreshToken(token);
// jti 加入黑名單,這個 refresh token 立即失效
refreshTokenBlacklist.add(payload.jti);
res.json({ message: '已登出' });
}
為什麼 login 的錯誤信息不能區分"用戶不存在"和"密碼錯誤"?
安全性考慮。如果分別返回不同錯誤,攻擊者可以用腳本批量試用戶名,碰到"密碼錯誤"就知道這個用戶名已註冊。統一返回"用戶名或密碼錯誤"堵死了這個口子。
5.2 留言業務
server/src/controllers/message.controller.ts:
export async function list(req: Request, res: Response) {
// 游標分頁——客戶端傳最後一條消息的 id
const cursor = req.query.cursor
? parseInt(req.query.cursor as string)
: undefined;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const query = db
.select({
id: messages.id,
content: messages.content,
creator: {
id: users.id,
username: users.username,
},
createdAt: messages.createdAt,
})
.from(messages)
.leftJoin(users, eq(messages.userId, users.id))
.orderBy(desc(messages.id))
.limit(limit + 1);
if (cursor) {
query.where(lt(messages.id, cursor)); // 游標之後的數據
}
const result = await query;
const hasMore = result.length > limit;
const items = hasMore ? result.slice(0, limit) : result;
// 標記哪些消息屬於當前用戶(用於前端顯示刪除按鈕)
const data = items.map(msg => ({
...msg,
isOwner: req.user?.id === msg.creator.id,
}));
res.json({
data,
nextCursor: hasMore ? String(items[items.length - 1].id) : null,
});
}
export async function create(req: Request, res: Response) {
const { content } = req.body;
await db.insert(messages).values({
content,
userId: req.user!.id,
});
res.status(201).json({ message: '發佈成功' });
}
export async function remove(req: Request, res: Response) {
const messageId = parseInt(req.params.id);
const [message] = await db
.select()
.from(messages)
.where(eq(messages.id, messageId))
.limit(1);
if (!message) {
return res.status(404).json({
error: { message: '消息不存在' }
});
}
// 只能刪自己的消息
if (message.userId !== req.user!.id) {
return res.status(403).json({
error: { message: '無權刪除他人的消息' }
});
}
await db.delete(messages).where(eq(messages.id, messageId));
res.json({ message: '刪除成功' });
}
游標分頁 vs 偏移分頁:
傳統分頁用 LIMIT 20 OFFSET 40,但如果中間插入了新消息,第 41-60 條和之前看到的 21-40 條會有重疊或遺漏。游標分頁用"上一條的 id"作為錨點,無論新數據怎麼插入,分頁結果始終穩定。
5.3 路由組裝
// routes/auth.routes.ts
import { Router } from 'express';
import * as authController from '../controllers/auth.controller';
import { validate } from '../middlewares/validate.middleware';
import { z } from 'zod';
const router = Router();
router.post('/register',
validate(z.object({
username: z.string().min(2).max(20),
password: z.string().min(6).max(100),
})),
authController.register
);
router.post('/login',
validate(z.object({
username: z.string(),
password: z.string(),
})),
authController.login
);
router.post('/refresh', authController.refresh);
router.post('/logout', requireAuth, authController.logout);
router.get('/me', requireAuth, (req, res) => {
res.json({ user: req.user });
});
export default router;
index.ts 入口——把所有零件拼起來:
import express from 'express';
import cors from 'cors';
import authRoutes from './routes/auth.routes';
import messageRoutes from './routes/message.routes';
import { errorHandler } from './middlewares/error.middleware';
const app = express();
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/messages', messageRoutes);
// 錯誤處理必須放最後
app.use(errorHandler);
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
分層的好處:controller 只處理業務邏輯,route 只做 URL 匹配和中間件鏈。以後換框架(比如 Fastify),只改 route 和 index.ts,controller 不用動。
5.4 curl 全鏈路測試
# 註冊
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"123456"}'
# 登錄
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"123456"}'
# 發消息(用登錄返回的 accessToken)
curl -X POST http://localhost:3000/api/messages \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbG..." \
-d '{"content":"Hello WhisperBoard!"}'
# 獲取消息列表
curl http://localhost:3000/api/messages
四條命令全部返回預期結果。後端全線綠燈。
六、DAY 5 — 前端全鏈路
6.1 AuthContext:登錄態全局管理
前端最核心的是 AuthContext——管理整個應用的登錄狀態。
client/src/contexts/AuthContext.tsx:
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface User {
id: string;
username: string;
}
interface AuthContextType {
user: User | null;
loading: boolean; // 初始化驗證中
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 應用啟動時驗證已有 token 是否有效
useEffect(() => {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
setLoading(false);
return;
}
fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${accessToken}` }
})
.then(async (res) => {
if (res.ok) {
const data = await res.json();
setUser(data.user);
} else {
// token 無效,清掉
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
})
.finally(() => setLoading(false));
}, []);
// 封裝 fetch,自動處理 401 刷新邏輯
async function authFetch(url: string, options: RequestInit = {}) {
const token = localStorage.getItem('accessToken');
const res = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
// access token 過期,嘗試用 refresh token 換新的
if (res.status === 401) {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (refreshRes.ok) {
const { accessToken: newToken } = await refreshRes.json();
localStorage.setItem('accessToken', newToken);
// 用新 token 重試
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`,
},
});
}
}
// 刷新也失敗,跳登錄
setUser(null);
localStorage.clear();
throw new Error('SESSION_EXPIRED');
}
return res;
}
async function login(username: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error.message);
}
const data = await res.json();
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
setUser(data.user);
}
// ... register、logout 類似實現省略
// loading 期間顯示空白或 spinner,防止閃爍
if (loading) return null;
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, authFetch }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
return ctx;
};
這個 Context 做了三件事:
- 初始化驗證:打開頁面時用 localStorage 的 token 調
/api/auth/me,確認登錄態 - 自動刷新:封裝的
authFetch遇到 401 自動用 refresh token 換新 access token,用戶無感知 - 全局共享:任何組件通過
useAuth()都能拿到當前用戶信息和登錄/登出方法
6.2 HomePage:消息列表
client/src/pages/HomePage.tsx 核心邏輯:
export default function HomePage() {
const { user, logout, authFetch } = useAuth();
const [messages, setMessages] = useState<Message[]>([]);
const [content, setContent] = useState('');
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// 加載消息(支持游標分頁)
async function loadMessages(reset = false) {
setLoading(true);
const url = `/api/messages?limit=20${
!reset && cursor ? `&cursor=${cursor}` : ''
}`;
const res = await authFetch(url);
const data = await res.json();
setMessages(prev => reset ? data.data : [...prev, ...data.data]);
setCursor(data.nextCursor);
setLoading(false);
}
useEffect(() => { loadMessages(true); }, []);
// 發佈消息
async function handlePost() {
if (!content.trim()) return;
const res = await authFetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (res.ok) {
setContent('');
loadMessages(true); // 刷新列表
}
}
// 刪除自己的消息
async function handleDelete(id: number) {
const res = await authFetch(`/api/messages/${id}`, { method: 'DELETE' });
if (res.ok) {
setMessages(prev => prev.filter(m => m.id !== id));
}
}
return (
<div className="min-h-screen bg-gray-50">
{/* 導航欄 */}
<nav className="bg-white shadow-sm px-6 py-3 flex justify-between items-center">
<h1 className="text-lg font-bold text-gray-800">WhisperBoard</h1>
<div className="flex items-center gap-4">
<span className="text-gray-600">{user?.username}</span>
<button onClick={logout}
className="text-red-500 hover:text-red-700">
登出
</button>
</div>
</nav>
{/* 發消息區域 */}
<div className="max-w-2xl mx-auto mt-6 px-4">
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="說點什麼..."
className="w-full border rounded-lg p-3 resize-none"
rows={3}
maxLength={500}
/>
<button onClick={handlePost}
className="mt-2 bg-blue-500 text-white px-6 py-2 rounded-lg
hover:bg-blue-600 transition-colors">
發佈
</button>
</div>
{/* 消息列表 */}
<div className="max-w-2xl mx-auto mt-6 px-4 pb-10 space-y-3">
{messages.map(msg => (
<div key={msg.id}
className="bg-white rounded-lg shadow p-4">
<div className="flex justify-between items-start">
<span className="text-sm text-gray-500">
匿名用戶 #{msg.creator?.username?.slice(0, 8)}
</span>
{msg.isOwner && (
<button onClick={() => handleDelete(msg.id)}
className="text-red-400 hover:text-red-600 text-sm">
刪除
</button>
)}
</div>
<p className="mt-2 text-gray-800">{msg.content}</p>
<span className="text-xs text-gray-400 mt-2 block">
{new Date(msg.createdAt).toLocaleString('zh-CN')}
</span>
</div>
))}
{cursor && (
<button onClick={() => loadMessages()}
disabled={loading}
className="w-full py-2 text-blue-500 hover:text-blue-700">
{loading ? '加載中...' : '加載更多'}
</button>
)}
</div>
</div>
);
}
6.3 ProtectedRoute:路由守衛
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return null; // 驗證中,什麼都不顯示
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
配合路由使用:
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
} />
</Routes>
6.4 踩坑:白屏 Bug
聯調時打開 http://localhost:5173,一片空白,控制台無報錯。
排查過程:先看 Network——沒有任何請求,說明頁面根本沒渲染。看 index.tsx——空的,就一行 export {}。再看 index.css——文件不存在。原來是 Vite 初始化時覆蓋了空文件。
AI 重新生成內容後頁面正常渲染。從那以後我養成了習慣:初始化完成後先確認核心文件內容,再看效果。
七、DAY 6 — 部署上線
7.1 數據庫遷移到 Turso
Turso 是分佈式的 libsql 數據庫,和本地 SQLite 完全兼容。
# 創建數據庫
turso db create whisperboard-zenith-of-serenity \
--location aws-ap-northeast-1
# 獲取連接信息
turso db tokens create whisperboard-zenith-of-serenity
拿到 DATABASE_URL(形如 libsql://xxx.turso.io)和 AUTH_TOKEN,改 .env:
DATABASE_URL=libsql://whisperboard-zenith-of-serenity-xxx.turso.io
DATABASE_AUTH_TOKEN=eyJhbG...
把表結構推到 Turso:
npx drizzle-kit push
驗證連接:curl http://localhost:3000/api/messages——返回空數組,說明連上了遠程數據庫。
7.2 部署踩坑全記錄
| 嘗試 | 平臺 | 結果 | 原因 |
|---|---|---|---|
| 1 | Vercel | ❌ | Windows 用戶名是中文("張順"),Vercel CLI 路徑解析報錯 |
| 2 | Railway CLI | ❌ | npm i -g @railway/cli 下載被牆,重試三次都超時 |
| 3 | Render | ❌ | 只支持 GitHub / GitLab,不支持 Gitee |
| 4 | Fly.io | ❌ | fly.toml 裡 app 名寫錯了,部署到一半才發現 |
| 5 | Railway | ✅ | 開了科學上網重新裝 CLI,終於成功 |
四次失敗,每一次都有明確的原因。回頭看,這些都不是玄學問題——中文用戶名、被牆、平臺限制、拼寫錯誤。排查一個,解決一個,離成功近一步。
最終 Railway 部署成功,https://charming-spirit-production-fb72.up.railway.app 上線。
7.3 前後端合體 + SPA fallback
賈維斯(系統方向的 AI 助手)接手優化:既然 Railway 只跑一個服務,不如把前端 build 產物嵌入 Express。
// index.ts 生產環境部分
import path from 'path';
// 託管前端靜態文件
app.use(express.static(path.join(__dirname, '../../client/dist')));
// SPA fallback:所有非 API 請求返回 index.html
// react-router 在客戶端處理路由
app.get('*', (req, res) => {
if (!req.path.startsWith('/api')) {
res.sendFile(path.join(__dirname, '../../client/dist/index.html'));
}
});
構建腳本更新:
{
"scripts": {
"build": "cd client && npm run build && cd ../server && npm run build",
"start": "cd server && node dist/index.js"
}
}
一個端口、一個服務,前後端全包。
八、項目總結
四大里程碑
| 里程碑 | 狀態 | 完成日 |
|---|---|---|
| 後端 API | ✅ | Day 4 |
| 前端全鏈路 | ✅ | Day 5 |
| 部署上線 | ✅ | Day 6 |
| 技術博客 | ✅ | 就是這篇 |
技術收穫
- JWT 雙令牌:access 短時效 + refresh 帶 jti 可吊銷,比單令牌安全得多
- 中間件拆分:auth → validate → controller → error,每層職責單一,出問題定位快
- ORM 的意義:不用手寫 SQL,而且類型安全——改了表結構,TypeScript 會告訴你哪些代碼需要改
- 部署的真相:不是"點一下按鈕就上線",而是一串報錯接著一串排查。耐心比運氣重要
- AI 是加速器,不是替代品:代碼是 AI 寫的,但架構設計、安全考量、技術選型、bug 排查思路都是自己的
項目地址
- 線上訪問:charming-spirit-production-fb72.up.railway.app
- 源碼倉庫:Gitee
Zenith_of_Serenity/whisperboard
如有不足之處歡迎指正,感謝












