关于一个新手小白靠claude帮助下的全栈留言板项目开发

项目概述: 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 运行时 + 编译时双重校验

架构概览:

javascript 复制代码
┌──────────────────────────────────────────────┐
│                   浏览器                     │
│          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

python 复制代码
{
  "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

php 复制代码
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;
  };
}

几个设计要点:

  1. 双密钥分离ACCESS_SECRETREFRESH_SECRET 是两个不同的环境变量。即使 access secret 泄露,攻击者也造不出 refresh token
  2. refresh token 带 jti:每个 refresh token 有唯一 ID,登出时把 jti 放进内存黑名单,即使 token 没过期也无法使用
  3. 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

css 复制代码
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

arduino 复制代码
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 跑通迁移

perl 复制代码
npx drizzle-kit generate  # 生成 SQL 迁移文件
npx drizzle-kit push      # 推到数据库

生成的迁移文件就是纯 SQL,可读可审:

r 复制代码
-- 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,响应再一层层返回:

go 复制代码
请求 → CORS → JSON解析 → auth → validate → controller
                                                      ↓
响应 ← error(异常时兜底) ← ← ← ← ← ← ← ← ← ← ← ← ←

今天写三个中间件:auth(认证)、validate(校验)、error(错误处理)。

4.2 auth.middleware

server/src/middlewares/auth.middleware.ts

php 复制代码
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

javascript 复制代码
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

javascript 复制代码
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

csharp 复制代码
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

php 复制代码
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

php 复制代码
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 路由组装

php 复制代码
// 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 入口------把所有零件拼起来:

javascript 复制代码
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 全链路测试

bash 复制代码
# 注册
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

typescript 复制代码
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 做了三件事:

  1. 初始化验证 :打开页面时用 localStorage 的 token 调 /api/auth/me,确认登录态
  2. 自动刷新 :封装的 authFetch 遇到 401 自动用 refresh token 换新 access token,用户无感知
  3. 全局共享 :任何组件通过 useAuth() 都能拿到当前用户信息和登录/登出方法

6.2 HomePage:消息列表

client/src/pages/HomePage.tsx 核心逻辑:

ini 复制代码
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:路由守卫

javascript 复制代码
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}</>;
}

配合路由使用:

xml 复制代码
<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 完全兼容。

lua 复制代码
# 创建数据库
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

ini 复制代码
DATABASE_URL=libsql://whisperboard-zenith-of-serenity-xxx.turso.io
DATABASE_AUTH_TOKEN=eyJhbG...

把表结构推到 Turso:

perl 复制代码
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。

csharp 复制代码
// 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'));
  }
});

构建脚本更新:

json 复制代码
{
  "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
技术博客 就是这篇

技术收获

  1. JWT 双令牌:access 短时效 + refresh 带 jti 可吊销,比单令牌安全得多
  2. 中间件拆分:auth → validate → controller → error,每层职责单一,出问题定位快
  3. ORM 的意义:不用手写 SQL,而且类型安全------改了表结构,TypeScript 会告诉你哪些代码需要改
  4. 部署的真相:不是"点一下按钮就上线",而是一串报错接着一串排查。耐心比运气重要
  5. AI 是加速器,不是替代品:代码是 AI 写的,但架构设计、安全考量、技术选型、bug 排查思路都是自己的

项目地址


如有不足之处欢迎指正,感谢

相关推荐
@蔓蔓喜欢你2 小时前
Git最佳实践:团队协作的基石
人工智能·ai
dayuOK63072 小时前
内容创作者的“第二大脑”:AI如何重塑从灵感到发布的效率链?
人工智能·职场和发展·自动化·新媒体运营·媒体
Henry-SAP2 小时前
BOM层级传递与MRP计划生成实战解析
人工智能·sap·erp
2601_957888562 小时前
2026年GEO生成式引擎优化:当AI成为信息入口,品牌如何拿到“答案资格“?
大数据·人工智能
文歌子2 小时前
认识 Prithvi:NASA × IBM 的遥感基础模型
人工智能·深度学习
2601_958815162 小时前
悟赫德(Woowhead)品牌深度拆解:用“东方智慧×全球科技”在180亿手机膜赛道定义新品类
人工智能·科技·智能手机·ar·圆偏振光护眼·观复盾护景贴·悟赫德woowhead
叶子丶苏3 小时前
Vibe_Coding 全栈知识体系总结
人工智能·vibe coding
guslegend3 小时前
第2节:老项目改造真实路径
人工智能·大模型
今日综合3 小时前
德系匠心,筑梦童行 | 德国美得丽关爱儿童脊椎健康公益行活动圆满结束!
人工智能