惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LangChain Blog
Security Latest
Security Latest
P
Proofpoint News Feed
GbyAI
GbyAI
PCI Perspectives
PCI Perspectives
博客园 - Franky
N
Netflix TechBlog - Medium
博客园_首页
WordPress大学
WordPress大学
K
Kaspersky official blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Vercel News
Vercel News
T
Threatpost
The Hacker News
The Hacker News
H
Help Net Security
S
Securelist
Recent Announcements
Recent Announcements
腾讯CDC
T
Tailwind CSS Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Engineering at Meta
Engineering at Meta
C
Cisco Blogs
V
V2EX
C
Check Point Blog
S
Schneier on Security
Cyberwarzone
Cyberwarzone
C
Cybersecurity and Infrastructure Security Agency CISA
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Jina AI
Jina AI
M
MIT News - Artificial intelligence
T
Threat Research - Cisco Blogs
博客园 - 叶小钗
A
Arctic Wolf
AWS News Blog
AWS News Blog
Latest news
Latest news
Martin Fowler
Martin Fowler
Recorded Future
Recorded Future
Last Week in AI
Last Week in AI
The GitHub Blog
The GitHub Blog
小众软件
小众软件
B
Blog
aimingoo的专栏
aimingoo的专栏
C
Cyber Attacks, Cyber Crime and Cyber Security
V
Visual Studio Blog
P
Palo Alto Networks Blog
Spread Privacy
Spread Privacy

掘金

Win 安装Claude Code FastAPI 的 CORSMiddleware 跨域中间件 Java 自研 ReAct Agent 半年后,我用 LangGraph 验证了这些设计取舍 🚀AI编程工作流终极形态:GitNexus!零Token消耗实现代码知识图谱化!让Claude Code和Codex拥有上帝视角彻底告别盲目改代码,复杂项目重 LeetCode 72. 编辑距离:动态规划经典题解 被The Graph的GraphQL查询坑了三天,我用一个真实DeFi项目把链上数据索引彻底搞懂了 (AI) 编写简单 AI 助手 (ds-agent) 别再让 pnpm 跟着 nvm 跑了!独立安装终极指南 Claude Code 为什么这么顺?Anthropic 最新复盘:真正撑住它的不是模型,而是缓存 从 /simplify 指令深挖 Claude Code 多 Agent 协同机制 Function-Calling与工具使用 新手上路(六):Claude code装上ECC全家桶:38 个子代理、156 个技能、生产级 Hooks 与 Rules 体系 我在 Claude、Kimi、opencode 三个 AI 之间搭了一条自动协作管道 【技能篇】OpenClaw Skill 详解:给 AI 装上"专业外挂" wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑 两周浅学 RAG 我把 Python re 模块比喻成摸金手套 新手上路(三):Claude Code Skills 装了一堆没用?20+ 个 Skill 横向对比 + 三套组合方案,按需抄 K2.6、DeepSeek V4、GPT-5.5 都来了,组合拳打起来 Claude Code 进阶之路:从记忆系统到子代理编排 [java] 编译之后的记录类(Record Classes)长什么样子(上) 国产大模型能力大比拼,社区有话说 我研读了 500 个 Spring Boot 生产级代码库,90% 都犯了这 7 个致命错误 JAVA重点难点 转发-中央网信办部署开展“清朗·整治AI应用乱象”专项行动 合同同步逻辑 【合并已排序数组的三种实现策略,哪一种更可取?】 30天减20斤挑战:少一斤发100红包(2) 我竟然被JavaScript的隐式类型转换坑了三天! 二十五.Electron 初体验与进阶 本地到生产,解决 AI 全栈最后一公里——构建&部署&运维 程序员创业半年:顺的事、不顺的事,和我一直没想清楚的事 UI组件库elementplus 像使用 Redis 一样操作 LocalStorage 向量检索的流程是怎样的?Embedding 和 Rerank 各自的作用? LangChain DeepAgents 速通指南(七)—— DeepAgents使用Agent Skill 为什么越来越多的大厂抛弃MCP,转向CLI? 【节点】[SquareRoot节点]原理解析与实际应用 juejin.cn juejin.cn 从 “存得下” 到 “算得快”:工业物联网需要新一代时序数据平台越来越多工业用户开始意识到一个问题:**数据是存下来了, - 掘金 放弃 Claude 订阅?我用 8 年前的服务器,强跑 Google 最强开源模型 Gemma 4 真实测评! Python开发者狂喜!200+课时FastAPI全栈实战合集,10大模块持续更新中🔥 从 Claw-Code 看 AI 驱动的大型项目开发:2 人 + 10 个自治 Agent 如何产出 48K 行 Rust 代码 秒级创建实例,火山引擎 Milvus Serverless 让 AI Agent 开发更快更省火山引擎MilvusSer MediaPlayer 播放器架构:NuPlayer 的 Source/Decoder/Renderer 三驾马车 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn OrbStack:一键将你的 Mac 变为本地服务器 NginxPulse:Nginx日志监控革命!实时洞察Web流量与安全态势的智能利器引言:当Nginx日志成为运维的“数 - 掘金 juejin.cn 大V说’AI替代不了你’,但现实是——用AI的人正在替代你2026年是AI落地的元年,自从Claude Code爆火之后 - 掘金 juejin.cn 你以为是技术问题,其实是流程问题:工程效率的真相引言 在软件工程领域,效率问题始终是团队管理者和工程师们关注的焦点。当项 - 掘金 大模型工程三驾马车:Prompt Engineering、Context Engineering 与 Harness Engineering 深度解析 juejin.cn 4.响应式系统基础:从发布订阅模式的角度理解 Vue3 的数据响应式原理本文从发布订阅模式的核心思想出发,深入剖析了 V - 掘金 慌了!Android 17 取消图标文字,你的 App 可能要找不到了用户终于可以隐藏桌面图标下面的文字了。 这个功能在 juejin.cn 我用 AI 搓了一个"比谁更持久"的微信小游戏,AI实现只用了一天,微信审核却用了一个月!!!起因:一个沙雕想法的诞生 - 掘金 juejin.cn 第12章 工具(Tools)与函数调用(LangChain实战)在前几章中,我们搭建的RAG系统、对话链,核心能力局限 - 掘金 juejin.cn CmComposeUI —— 基于 Kotlin Multiplatform Compose 的 UI 组件库 Android 开发的 AI coding 与 AI debugging在目前整个行业都在大规模使用 AI coding juejin.cn juejin.cn juejin.cn juejin.cn 一文搞懂Harness Engineering与Meta-Harness 越用越强不是广告语:拆解 Hermes Agent 的三层学习机制 P2G-Python字符串方法完全指南-split、join、strip、replace的Python编程利器 AI 周刊【2026.04.06-04.12】:Anthropic 藏起最强模型、AI 社会矛盾激化、"欢乐马"登顶 从 AI Skills 学实战技能(六):让 AI 帮你总结网页、PDF、视频 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 详解 karpathy 的 microgpt:实现一个浏览器运行的 gpt 不用 Tailscale:3 步把 Mac mini 通过 FRP 暴露到公网(稳定开机自启) P2B-Python可迭代对象完全指南-从列表到生成器的Python编程利器 手把手带你部署本地模型,让你Token自由(小白专属) juejin.cn 10分钟掌握 JSON-RPC 协议,面试加分、设计不踩坑 ReAct:让大模型学会边想边做 聊聊AI的发展史,AI的爆发并不是偶然 Python的列表推导式里藏了个坑,差点让我加班到凌晨 重排、重绘与合成——浏览器渲染性能的底层逻辑 podman与docker的区别和生产环境最佳实践 juejin.cn ConcurrentHashMap线程安全实现原理全解析 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn OpenAI Codex深度解析:终端里的AI代码特工,一个指令重构整个项目 UE5.6 Cesium 插件编译踩坑记录(UE 5.6 + MSVC 14.38 + CMake 3.31)
关于一个新手小白靠claude帮助下的全栈留言板项目开发
Zenith_of_Se · 2026-05-24 · via 掘金

项目概述: WhisperBoard,一个匿名留言板。功能不复杂——注册、登录、发消息、看消息、删自己的消息。但麻雀虽小,五脏俱全:JWT 认证、ORM 数据层、中间件体系、前后端分离、部署上线,一条完整的全栈链路。

6 天,从零到线上。这篇文章记录整个过程,顺便梳理我学到的每一块知识。 图片.png


一、技术栈与项目架构

先看看用到了什么:

技术为什么选它
前端框架React 18 + TypeScript生态最广,TypeScript 类型安全
构建工具Vite比 CRA 快一个数量级,ESM 原生支持
样式Tailwind CSS原子化 CSS,写样式不写文件
路由react-router-dom v6SPA 路由标准方案
后端框架Express 4 + TypeScriptNode.js 最成熟的 Web 框架
ORMDrizzle 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│
                          └───────────────┘

图片.png


二、DAY 1 — 从零初始化 + JWT 认证核心

2.1 环境搭建

第一天先搭架子。我给 AI 的指令是: 图片.png

关键配置在根目录 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 一条命令同时启动前后端,不用开两个终端窗口。

89617bc42c2f4ceeaf0a662e6df3860e~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg55So5oi3NDk0NTA0MjU1MTM2_q75.png

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;
  };
}

几个设计要点:

  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 的指令: 图片.png 为什么 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 传文件"备份",从来没有正经版本管理过。 图片.png


五、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 做了三件事:

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

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 部署踩坑全记录

尝试平台结果原因
1VercelWindows 用户名是中文("张顺"),Vercel CLI 路径解析报错
2Railway CLInpm i -g @railway/cli 下载被墙,重试三次都超时
3Render只支持 GitHub / GitLab,不支持 Gitee
4Fly.iofly.toml 里 app 名写错了,部署到一半才发现
5Railway开了科学上网重新装 CLI,终于成功

四次失败,每一次都有明确的原因。回头看,这些都不是玄学问题——中文用户名、被墙、平台限制、拼写错误。排查一个,解决一个,离成功近一步。

最终 Railway 部署成功,https://charming-spirit-production-fb72.up.railway.app 上线。 图片.png

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"
  }
}

一个端口、一个服务,前后端全包。


八、项目总结

四大里程碑

里程碑状态完成日
后端 APIDay 4
前端全链路Day 5
部署上线Day 6
技术博客就是这篇

技术收获

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

项目地址

图片.png


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