1. 项目定位:为什么不做单纯的博客或论坛
市面上不缺论坛系统。Discuz、Flarum、Discourse 都是好东西。市面上也不缺博客系统。WordPress、Ghost、Hugo 都是好东西。
但我要的是一个混合体:用户既能写个人博客(有自己的主题色、封面图、副标题),也能在公共版块里提问讨论。一个账号,两种身份。
这个决策在 30 天后回头看是对的。我的 22 篇帖子里,7 篇是深度博客(RAG 指南、API 双轨实战、AI 架构设计),15 篇是论坛讨论(MCP 踩坑、AI 职业讨论)。这两类内容在同一个社区里形成了互补------博客吸引搜索流量,论坛留住活跃用户。
但代价是:需要自己写所有东西。认证、权限、编辑器、通知、搜索、金币、管理后台------一个 Spring Boot + Thymeleaf 的项目可能不需要这些,但一个现代社区平台一个都不能少。
2. 技术选型:全栈 TypeScript 是正确选择吗
Stack:
- 前端:Next.js 15 + React 19 + Tailwind CSS + shadcn/ui
- 后端:NestJS + Prisma + PostgreSQL/pgvector + Redis
- 部署:Docker Compose + Nginx + Cloudflare + Hetzner VPS
做对了的:
Prisma ORM。这是我做的最正确的技术选型。Schema 定义即类型来源,迁移管理清晰(30 天跑了 4 次 migration),查询写法比 TypeORM 简洁一个数量级。中途改了 5 次 Schema,Prisma 没给我添乱。
shadcn/ui。组件复制到项目中,Tailwind 原生支持,想改就改。不像 MUI/Ant Design 那样,改一个按钮颜色需要读一页文档。
Docker Compose 。5 个容器(前端、后端、PostgreSQL、Redis、Nginx),一个 docker-compose.prod.yml 描述整个系统。部署是 git pull && ./deploy.sh。
做错了的:
Edge Runtime 用了又换回来了 。OG 图片生成端点最初设了 export const runtime = 'edge',结果 ImageResponse 在 Edge Runtime 下报 TypeError: immutable。改成 runtime = 'nodejs' 立刻好了。Edge Runtime 的「真 Serverless」承诺很诱人,但实际兼容性问题太多。小型项目不值得踩这个坑。
Next.js Promise.allSettled 的坑 。首页用 Promise.allSettled 拉 4 个 API(节点、帖子、热门、置顶),某个 API 失败时静默返回空数组,导致首页渲染「暂无热门内容」但明明数据库里有两万条。应该用 Promise.all 或者至少对 reject 做 fallback 处理。
3. 数据库 Schema 的五次演进
Prisma Schema 的变化本身就是项目的缩影:
erlang
v1 (Week 1): User, Post, Comment, Node --- 4 个模型
v2 (Week 2): + Media, Collection, Favorite, Notification --- 8 个
v3 (Week 3): + CoinLedger, Purchase, UserSession --- 11 个
v4 (Week 4): + searchVector (tsvector), messagePermission, AI 相关字段
v5 (Week 5): + isFeatured, featuredAt, featuredScore --- 精选算法
教训一 :UserSession 不要为了省事用内存。我的第一版在线状态用 Map 存,服务重启全丢。改 Redis + 数据库持久化后,在线状态才真正可用。
教训二 :tsvector 比你想的复杂。我以为 ALTER TABLE ADD COLUMN searchVector tsvector 就完事了。结果发现需要创建 BEFORE INSERT OR UPDATE 触发器、GIN 索引、safe_plainto_tsquery wrapper 函数防止空查询抛错,以及写 SQL 回填历史数据。总共 32 行迁移 SQL。
教训三 :加字段容易,加完忘了跑 prisma db push 是灾难。User 表加了 messagePermission 字段但数据库没同步,Prisma Client 编译时不知道这个字段,查询直接 500。记住:改 Prisma Schema → 跑迁移 → 重新生成 Prisma Client,三步少一步都不行。
4. 评论系统:从全局状态到隔离组件的重构
这是整个项目里改动最深的一次重构。
第一版 :一个 CommentTree 组件管理所有评论的回复状态。
typescript
// 问题代码
const [replyText, setReplyText] = useState('');
const [replying, setReplying] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// 所有评论共享同一套状态!
// 评论 A 点回复 → 评论 B 的输入框也被填了
Bug 表现 :回复评论 A 时,评论 B 的回复表单也被打开。回复提交后,setSubmitting(false) 影响的是全局状态------如果此时评论 B 正在提交,它的 isSubmitting 也会被改成 false,导致评论 B 的回复请求根本没发出去。
第二版 :引入 submittingRef 解决闭包过期,但底层问题还在------共享状态。
第三版 :把 render() 函数拆成独立的 CommentItem 组件。
typescript
// 每个 CommentItem 封装自己的回复状态
function CommentItem({ comment, onReply }) {
const [replyText, setReplyText] = useState('');
const [replying, setReplying] = useState(false);
const [submitting, setSubmitting] = useState(false);
// 状态隔离。评论 A 和评论 B 的回复表单互不影响。
}
重构代价 :+172 行 / -52 行,总代码量增加但可维护性是质变。教训:React 里能用组件隔离就不要用「全局状态 + 函数」。 一个状态变量被多个 UI 节点共享,早晚会出 Bug。
5. 生产环境最危险的 Bug:崩溃循环
现象:网站突然 502,后端容器不停重启(30 天累计 100 次)。
排查过程:
docker ps→ 后端容器Up 6 seconds (healthy),过 10 秒再看又是Up 3 seconds→ 容器在不断重启docker logs→ 看到Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client- 但这不是根因------哪个正常请求会重复发响应?
- 继续翻日志 → 发现
ScrapingDetection的 429 响应先发出去了,然后HttpExceptionFilter又尝试response.json(),因为响应已经发出去了,response.headersSent === true,直接抛未捕获的ERR_HTTP_HEADERS_SENT,Node.js 进程崩溃
崩溃链:
ini
搜索爬虫快速请求 → ScrapingDetection 触发 → 返回 429(响应已发送)
→ NestJS 异常处理 → HttpExceptionFilter 尝试 response.json()
→ headersSent = true → 抛 ERR_HTTP_HEADERS_SENT → 未捕获!
→ Node.js 进程 exit(1) → Docker restart: always → 重启
→ 重启后爬虫又来 → 再次崩溃 → 无限循环
修复:3 行代码。
typescript
// http-exception.filter.ts
if (response.headersSent) {
return; // 响应已经被拦截器发过了,别再发一次
}
response.status(status).json({...});
这个 Bug 教会我一件事:永远在全局异常过滤器里检查 headersSent。 你的拦截器可能在过滤器之前就发送了响应。
6. Nginx 301 陷阱:一个消失了 3 天的 POST 请求
这是整个项目最诡异的 Bug------它让我困惑了整整 3 天。
现象:点击「发布帖子」按钮,前端显示「发布成功!」,但数据库里没有新帖子。后端日志里没有任何 POST 请求记录。
排查:
- 检查
CreatePageContent.tsx→api.post('/posts', payload)确实调了 - 检查
api.ts→fetch('/api/posts', { method: 'POST' })没问题 - 在浏览器 DevTools Console 直接调
fetch('/api/posts', { method: 'POST' })→ 能创建帖子! - 在 React 代码里加
console.log→api.post确实执行了 - 在浏览器 Network 面板看 → 只有 GET 请求,没有 POST!
- 拦截
window.fetch→ 有 POST!但 URL 是/api/posts/(带斜杠),状态码 301 → 302 → 请求体丢失
根因:Nginx 配置里:
bash
location /api/posts/ { ... } # 匹配带斜杠
location /api/ { ... } # 匹配不带斜杠
POST /api/posts(不带斜杠)先匹配 /api/,然后被重定向到 /api/posts/(301)。浏览器跟随 301 时把 POST 变成了 GET,请求体丢失。后端收到一个 GET 请求,返回 200 和帖子列表,前端以为发布成功。
修复 :加 location = /api/posts 精确匹配。
修复后 3 天 Bug 又复现 :因为 nginx -s reload 没从 Docker volume 挂载里读到新文件。必须 docker restart。
最终修复 :docker restart zhiqu-nginx-prod。
教训:Docker volume 挂载的配置文件,nginx -s reload 不一定读得到。保险方案是 docker restart。
7. SSR 与反爬中间件的内战
现象:某天开始,首页「热门问题」和「精华推荐」区域不显示任何帖子,显示「暂无热门内容」。
排查:
- 后端 API 正常 → 能返回热门帖子
- 前端 DevTools Network → 首页根本没发
GET /api/posts/hot请求 - 但 Next.js 是 SSR 的,数据在服务端拉 → 前端不显示代表 SSR 失败了
- 进前端容器
wget http://backend:4000/api/posts/hot→ 403 Forbidden!
根因 :前端 SSR 的 Node.js 原生 fetch() 不带浏览器 User-Agent。后端反爬中间件看到缺失或非浏览器的 UA,直接返回 403。Next.js SSR 在请求失败时用 Promise.allSettled 吞了错误,返回空数组,页面渲染空状态。
修复:
- 前端
api.ts里给 SSR 请求加User-Agent: Zhiqu/1.0 (internal-ssr) - 后端反爬中间件 + 反爬拦截器对 Docker 内网 IP(
172.17.0.0/16,172.18.0.0/16)白名单放行
教训:如果你同时有 SSR 和反爬措施,它们会打架。SSR 的请求从服务端发出,它们不是浏览器。你的反爬规则必须给这些内部请求留白名单。按 IP 白名单比按 UA 白名单更安全------UA 任何人都能伪造,但 Docker 内网 IP 不能。
8. SEO:给搜索引擎写代码
写了一个月代码,发现被搜索引擎「看不见」。这是独立开发者最容易忽视的一环。
JSON-LD 结构化数据
json
{
"@type": "DiscussionForumPosting",
"headline": "DeepSeek API 返回 429 怎么办",
"comment": [
{
"@type": "Comment",
"text": "建议加指数退避...",
"author": {
"@type": "Person",
"name": "小鼻子的猫",
"url": "https://zhiqu.ac/user/1304674612" // ← Google 要求有 url
}
}
]
}
Google Search Console 报 comment.author 缺少 url 字段。加了三行代码修好。
百度收录
百度推送 API 踩的坑:
site参数不能用encodeURIComponent(编码后百度不认)site的值必须和百度站长平台验证的域名完全一致(www.zhiqu.acvszhiqu.ac→not_same_site)- 百度推送有每日配额(免费新站约 10 条/天)
- HTTPS 不支持,只能用 HTTP
修了 5 次才通。第一次看到 {"success":7,"remain":2} 的时候,比代码跑通还激动。
Sitemap
Next.js 的 sitemap.xml 配置很简单但容易忘:每发布一篇新博客,确认 slug 后手动更新 lastmod。或者写个脚本自动生成。我没写脚本,每次手动改------早晚会忘。
9. 监控:从零到 Grafana Alloy
前 25 天我没有任何监控。每次服务挂了,靠用户告诉我。
第 26 天,配了 Grafana Cloud + Alloy:
bash
# 一条命令装好
ARCH="amd64" GCLOUD_HOSTED_METRICS_URL="..." /bin/sh -c "$(curl -fsSL ...)"
30 分钟后,Grafana 大盘上开始出数据:CPU 2.2%、内存 52%、磁盘 14%、Nginx QPS 0.5。
教训:监控应该在项目上线的第一天配好,而不是第 26 天。那中间 25 天的「感觉一切正常」只是运气好。
UptimeRobot 也是必需品------免费套餐每 5 分钟从全球节点检测一次你的网站,挂了立刻邮件通知。我从第 28 天才开始用,应该第 1 天就开。
10. 管理后台的三次重构
管理后台是我个人看得第二多的页面(第一是首页)。它经历了三次迭代:
v1(单文件巨石) :一个 admin/page.tsx,700 行。所有功能------统计、用户管理、内容管理、SEO 推送、系统设置------全在一个页面里。
v2(标签页切换) :拆成 4 个 tab。统计、用户、内容、设置。每个 tab 还是在这个文件里,靠 activeTab state 切换。
v3(多页面 + 独立 hooks):56 个文件变更。每个模块独立页面 + 独立 hook。Dashboard、Users、Posts、AI、SEO、Audit、Reports、Sessions、Settings------各走各的路由。
为什么要迭代三次? 因为每次迭代都是「当前代码太乱了,忍不了才改」。如果一开始就知道要拆成多页面,v1 就不会写成一个文件。
但如果一开始就设计成多页面架构,又会过度设计------因为前两周根本没那么多管理功能。这个矛盾没法完美解决。折中方案:前两周单页面快速迭代,功能稳定后立刻拆。
11. 成本清单
| 项目 | 月费 |
|---|---|
| Hetzner CX22 (2C4G/50G) | €4.51 |
| Cloudflare CDN + DNS | 免费 |
| AI API (DeepSeek + Claude) | ~$35 |
| 阿里云 OSS (图片存储) | ~¥5 |
| Resend (邮件) | 免费额度 |
| Grafana Cloud | 免费额度 |
| UptimeRobot | 免费额度 |
| 合计 | ~¥75/月 |
一个月 75 块钱,能跑一个全栈 AI 社区。独立开发者的时代,是真的来了。
12. 如果重来一次
- 第一天就配监控和告警。 不是第 26 天。UptimeRobot 5 分钟搞定。
- Prisma Schema 先想清楚再动手。 5 次 migration 里有 3 次是因为「后面发现需要这个字段」。如果第一天就把
searchVector、messagePermission、isFeatured都考虑进去,后面不用反复迁移。 - 管理后台从第二天就开始用。 我前两周靠直接查数据库管理内容。效率极低。一个简单的 CRUD 表格比任何 SQL 命令都管用。
- 安全中间件留白名单。 反爬 UA 中间件、反爬拦截器、Nginx 限流------这些都应该
第一天就留好内部网络的白名单。不然你在排查「为什么 SSR 拉不到数据」时,会盯着403 Forbidden怀疑人生。 - 百度站长平台先把站点验证和领域设置做完再发帖。 我发了 22 篇帖子才通百度推送,前面 22 篇要等每天 10 条的配额慢慢补推。
- Docker 构建缓存要定期清理。 30 天累计 32GB,不清理的话一个月就能占满磁盘。
30 天前,我在 VS Code 里按 mkdir zhiqu。30 天后,有人在这里写博客、提问、收藏、点赞。有人搜索「DeepSeek 429」从 Google 点进来。有人在凌晨三点给我的帖子点了个赞。
独立开发最好的部分不是代码跑通了。是你造的东西有人在用。
本文所有代码和架构已在 志趣社区 zhiqu.ac 生产环境运行。如果你也在做独立开发,欢迎来社区聊聊你踩过的坑。