项目概述: WhisperBoard,乃匿名留言之板。其功能不繁——注册、登录、发消息、观消息、删己之消息。然麻雀虽小,五脏俱全:JWT认证、ORM数据层、中间件体系、前后端分离、部署上线,一气呵成之全栈链路。
六日之功,自零至线上。是文记其全过程,兼梳理所学之知。
一、技术栈与项目架构
先观所用:
| 层 | 技术 | 所以择之故 |
|---|---|---|
| 前端框架 | React 18 + TypeScript | 生态最广,TypeScript类型安全 |
| 构建工具 | Vite | 较之CRA快十倍,ESM原生支持 |
| 样式 | Tailwind CSS | 原子化CSS,制式无需撰文成檄 |
| 途路 | react-router-dom v6 | 单页应用之标准途路 |
| 后端架构 | Express 4 + TypeScript | Node.js最成熟能制Web之框架 |
| 对象关系映射 | Drizzle ORM | 类乎安全、轻便、类SQL之API |
| 数据库 | SQLite → Turso (libsql) | 本地开发无需配置,上线可顺滑迁移 |
| 认证 | JWT双令牌 | 无状态,宜于前后端分离 |
| 密码加密 | bcryptjs (12轮) | 纯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│
└───────────────┘
二、首日初始化,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
何故需双令?
若唯有一令牌,期效长则不安全(失窃则可久用),期效短则用户体验差(每十五分钟须再登录)。双令牌折中: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:每 refreshToken 皆有一独ID,登出时将jti置内存黑名单,纵token未届期亦不可用。
- accessToken不载角色。:角色之讯实时询数据库,不缓存于JWT。是故管理员之权更易立显,毋待token之期。
补言:JWT果安否?
JWT本不加密(乃签名非加密),payload中之情任何人皆可base64解码见之。故绝不可置密码、手机号等密讯于内。。JWT之安恃HTTPS——于传输层加密,防间人截token。
三、日二——数据层:Drizzle ORM + 数据库之设。
三一ORM之选
示以AI之命:
何故择Drizzle而非Prisma?
Prisma虽臻成熟,然其生成之client动辄数十兆,启动迟缓,且其schema语法乃自定义之DSL,非TypeScript。Drizzle之schema即纯TypeScript,所著之型即数据库之型,无间层。
三二表结构之定义
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 文件现于项目根,数据库层毕。
四、日三日 — 中间件之系 + 人生初 Git 之提交
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审之。
四四谬误之中介
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以参数之数辨其为错误处理中间件。必置于诸路由之后而注册之。
四点五 首个 Git 提交
git init
git add .
git commit -m "feat: init project - Express + React + Drizzle + JWT auth"
四十二篇之文,一时并呈。此乃吾平生首度正经之代码献也。昔时撰文,或置匣中蒙尘,或赖QQ传件"存档",未尝正经以版本管理之。
五、第四日 — 后端 API 皆通
今乃"组装日"——将前所撰 JWT、数据库、中间件,悉数联缀之。
五有一 认证之事
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 之谬误,不能辨"用户未尝有"与"密码不谙"?
虑安全之道。若各返异错,攻者可制脚本,遍试用户名。遇"密码谬误",即知此名已注册。若统返"用户名或密码谬误",则此隙可堵。
五二留言业
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: '删除成功' });
}
游标分页者,如舟行江海,逐页而观,循序渐进,至终方毕。偏移分页者,似射箭穿林,直指目标,跳页而阅,速而省力。二者各有所长,用之宜审。:
古时分页之法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(),皆可取当前用户之讯及登录、登出之法。
六二 之家:讯息之列
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>
);
}
六三 保障之道:路途之卫
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>
六四 蹈坑:白幕之弊
联调之际启之http://localhost:5173,浑然无物,控制台无报。
探查之序:先察网络——无请求,乃知页未成。察index.tsx——空,唯一行export {}。复察index.css——文未存。盖因Vite肇始时覆空文也。
AI更生其文,页始正呈。自兹以往,吾成习焉:既初始化毕,先察核要文之实,复观其效。
七、日第六 — 部署既上
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 下载被阻,重试三皆超时 |
| 三 | 塑形 | ✅ | 唯支 GitHub / GitLab,不支 Gitee |
| 四 | 飞.io(Flight.io) | ❌ | fly.toml里之应用名误书,部署将半,方觉之。 |
| 五 | 铁路 | ✅ | 启科学之途,重装 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 | ✅ | 第四日 |
| 前端全链路之境 | ✅ | 五日 |
| 部署既成,遂启之。 | ✔ | 第六日 |
| 技术之札记 | ✅ | 即是此篇 |
技业所获
- JWT二令牌:access 短时效,refresh 带 jti 可吊销,较单令牌安全甚焉
- 中件拆分:认证验真,控权致误,各司其职,察失易明
- ORM之要义:不须手撰SQL,且型安无虞——更表之构,TypeScript将示何码当易
- 部署之实:非但“一按即发”,实乃连报错而续查勘。恒心胜于机缘
- AI为助推,非替器:码乃AI所撰,然构制之思、安危之虑、技择之决、谬误之寻,皆出己手
项目所寄
- 线上可览:风雅之境生产-fb72.up.railway.app
- 源码之府:Gitee
Zenith_of_Serenity/whisperboard
倘有未逮,幸祈斧正,谢忱












