最近,我做了一个叫「摸鱼表格」的小项目。
它看起来像表格,却没有表头、公式、筛选和工作任务。这里有的,只是一面可以无限拖动、缩放和探索的公共格子墙。
任何人都能选一个空格,写下一句话、一个突然冒出的想法,或者把它当成临时树洞。
项目已经可以直接体验:
这篇文章不只展示成品。我想完整复盘一次:一个看似简单的互动想法,如何逐步变成拥有数据库、登录、部署、备份和公网入口的产品。
一、它像表格,但这里没有标准答案
传统表格围绕效率组织信息:表头定义含义,行列约束结构,公式负责计算。
我想保留的不是 Excel 外观,而是共享表格里偶尔出现的另一种体验:大家会在空白区域留言、接龙、拼字,甚至自然形成一些主题区域。
这种玩法最有趣的地方,是空间本身也成为内容。
坐标不只是数据库字段。一个人在 (0, 0) 留下欢迎语,另一个人在很远的位置写下近况,两句话之间的距离也会产生想象。
因此,产品从一开始就明确了几个边界:
- 不复刻 Excel,不做公式、筛选和复杂表头。
- 不做强实时协同编辑器。
- 每个坐标最多只能写入一个格子。
- 内容写入后永久锁定,不允许修改。
- 匿名用户也能参与,降低第一步门槛。
这些限制看似减少功能,实际上是在保护产品最核心的体验:选择一个位置,然后留下某个时刻的自己。
二、核心交互:把坐标变成可探索的空间
项目的主体验不是传统 DOM 列表,而是一块 Canvas 画布。
Canvas 是浏览器提供的绘图画布。相比为每个格子创建一个 DOM 元素,它更适合绘制大面积网格、透视效果和大量动态元素。
项目中的坐标系统分成三层:
单元格坐标:用户理解的整数坐标,例如 x: 3, y: -2
世界坐标:Canvas 场景中的位置
屏幕坐标:浏览器视口内的像素位置
单元格坐标的 y 轴向上递增,而屏幕坐标的 y 轴向下递增。两者不能直接混用,必须通过明确的转换函数连接。
/**
* 把单元格 y 坐标转换为 Canvas 世界坐标。
*/
function cellToWorldY(cellY: number): number {
return -cellY
}
这种看似基础的约束非常重要。如果坐标语义没有集中管理,拖拽、跳转、小地图和范围查询很快就会各自形成一套方向规则。
为了让空旷的格子墙不至于“能逛,但不知道去哪”,我后来增加了几种探索入口:
- 随机发现:跳转到一个已有格子。
- 最新写入:看看公共空间刚刚发生了什么。
- 近邻发现:从当前位置继续向附近探索。
- 坐标跳转:输入明确坐标,直接移动相机。
- 小地图:观察内容密度和当前位置。
这些能力没有试图做推荐算法。它们只是为用户提供几个继续走动的理由,让一面空旷的墙逐渐产生现场感。
三、写入体验:每次发布都应当有一点重量
格子的写入流程并不复杂:选择内容类型、输入正文、选择色调,然后发布。
但因为格子写入后无法修改,编辑窗口必须帮助用户在发布前确认结果。
编辑区采用左右分栏:
- 左侧输入 Markdown 源文本。
- 右侧始终展示实时预览。
- 工具栏支持加粗、引用、列表和分隔线。
- 色板用于选择格子的视觉色调。
Markdown 是一种使用普通字符表达格式的轻量标记方式。例如,**文字** 表示加粗,- 文字 表示列表。
项目只支持少量格式,没有引入完整富文本编辑器。这是一个刻意的选择:格子应该适合留下一句话,而不是逐渐变成一篇排版复杂的文档。
每个格子的 Markdown 源文本最多存储 10,000 个字符。这个限制同时存在于前端输入、API 校验、领域规则和 PostgreSQL CHECK 约束中。
数据库约束是最后一道保护。即使未来某个接口绕过了前端校验,数据库仍会拒绝超限内容。
四、技术架构:让产品规则有明确归属
项目使用的主要技术栈是:
Next.js App Router
React + TypeScript
Canvas
PostgreSQL
Prisma
Vitest
Tailwind CSS
Next.js 同时承载网页和 API。Prisma 是 ORM,也就是对象关系映射工具,它让 TypeScript 代码可以通过类型安全的接口读写 PostgreSQL。
代码按职责分成几个区域:
src/
├── domain/cells/ # 格子规则、坐标、内容、格式与探索算法
├── data/ # Prisma 仓库、API 客户端与数据适配
├── features/wall/ # Canvas、面板、交互与页面状态
└── lib/ # 通用基础设施
领域层不依赖 React 或浏览器 API。像坐标转换、写入准备、内容限制和探索计算,都可以独立测试。
这种分层让 UI 不必承担所有规则,也让数据库仓库不需要理解画布交互。
坐标唯一性必须由数据库保证
用户点击空格时,前端会先检查该位置是否已经被占用。
但前端检查只能改善体验,不能保证最终正确。两个用户可能同时看到同一个空格,并在极短时间内一起提交。
因此,数据库通过唯一约束保证同一坐标只能有一个格子:
model Cell {
id String @id
x Int
y Int
content String
@@unique([x, y])
}
最终写入冲突会由数据库明确拒绝。前端负责友好,数据库负责权威。
匿名写入也需要准入规则
项目允许匿名用户写入,但匿名不等于没有限制。
服务端会基于可信代理传来的客户端 IP,通过 HMAC 生成不可逆摘要,并按时间窗口限制写入次数。
HMAC 是使用服务端密钥生成摘要的方法。数据库只保存摘要,不直接保存用户原始 IP。
登录用户与匿名用户使用不同的写入额度。这样既能保留低门槛参与,也能减少公开写入接口被滥用的风险。
五、从“本机能跑”到真正可访问
开发环境跑通以后,我遇到一个很现实的问题:这个项目应该部署在哪里?
最后选择了一套有点特别、但很适合当前阶段的方案:
正式域名
→ ECS 上的 OpenResty
→ ECS 上的 FRPS
→ 本机 OrbStack 中的 FRPC
→ 本机生产应用
→ 独立生产 PostgreSQL
FRP 是一套内网穿透工具。FRPC 是客户端,运行在本机;FRPS 是服务端,运行在云服务器。FRPC 主动连接 FRPS,让公网域名可以访问本机服务。
本机同时存在开发环境和生产环境,但两者完全隔离:
开发应用:localhost:3000
开发数据库:localhost:5433
生产应用:127.0.0.1:3007
生产数据库:独立 Docker 网络,不暴露宿主机端口
FRPS 出口:ECS 127.0.0.1:3008
生产环境由 Docker Compose 管理。Docker Compose 是用于统一定义和运行多个容器的工具。
生产栈包括三个长期运行的容器:
app:Next.js standalone 生产应用。postgres:独立生产数据库。frpc:连接 ECS 的内网穿透客户端。
所有生产容器使用 restart: unless-stopped。OrbStack 或电脑重启后,它们会自动恢复;手动停止后则保持停止。
为什么坚持独立生产数据库
开发数据库经常需要重置、写入测试数据、验证 migration。如果生产和开发共用数据库,一次普通调试就可能影响真实数据。
因此,生产 PostgreSQL 使用独立数据卷,且不映射宿主机端口。日常开发工具无法误连,应用只能通过 Compose 内部网络访问它。
每次生产部署前,脚本会自动执行 pg_dump 创建备份,并保留最近 14 份。
为什么使用标签部署
生产部署脚本不接受当前工作区,也不直接部署 main 分支,只接受已经推送到 GitHub 的 Git 标签。
标签是指向固定提交的版本标记。这样可以避免未提交代码意外进入生产,也确保当前运行版本可以重新构建。
部署流程大致如下:
验证标签已推送
→ 导出独立源码快照
→ 启动并检查生产数据库
→ 创建部署前备份
→ 构建生产镜像
→ 应用 migration
→ 重启应用和 FRPC
→ 验证本机入口与代理状态
六、开发过程中几个值得记录的坑
1. 数据库已有表,但没有 migration 历史
早期本地数据库已经存在部分表结构,但 Prisma 的 migration 历史表为空。
直接执行 prisma migrate deploy 会收到 P3005:数据库 schema 不为空,无法直接应用初始 migration。
解决方式不是重置数据库,而是先比较现有结构与目标结构,将已经真实存在的历史 migration 标记为已应用,再部署剩余 migration。
这个过程叫 baseline,也就是为已有数据库补齐迁移基线。
2. 面板定位尺寸不等于真实内容高度
格子阅读面板最初按固定高度定位。短内容正常,但登录提示出现后,真实内容高度超过定位高度,内容便从面板底部溢出。
最终方案是把面板拆成三部分:
固定顶部:类型、标题、关闭按钮
滚动中部:正文
固定底部:坐标、时间、收藏与分享操作
面板宽度调整为 380px,最大高度根据视口动态计算。短内容自然收缩,长内容才出现滚动。
3. OpenResty 与 FRPS 的网络关系必须确认
1Panel 中的 OpenResty 与 FRPS 都使用 host 网络模式,因此 OpenResty 可以反向代理到 127.0.0.1:3008。
如果 OpenResty 使用普通 Docker bridge 网络,容器里的 127.0.0.1 只代表容器自身,此时同样的代理配置就不会工作。
部署不能只看配置长什么样,还要确认进程究竟运行在哪个网络空间里。
4. 真实客户端 IP 不能盲目信任
应用使用 X-Real-IP 进行匿名写入限流。
OpenResty 必须覆盖写入该请求头,而不是原样信任客户端传来的值。否则访问者可以伪造 IP,绕过写入限制。
同时,FRPS 的 3008 端口不应该直接向公网开放。用户请求应该只经过域名的 80/443,再由 OpenResty 从 ECS 本机访问 FRPS。
七、测试不是最后补上的清单
项目目前使用 Vitest 编写自动化测试,覆盖领域规则、数据仓库、API 路由、面板行为和架构约束。
最近修复阅读面板溢出时,我使用了红-绿-重构的 TDD 循环:
RED:先写“关闭按钮能取消选中”的失败测试
GREEN:实现最小关闭行为
RED:写“长正文与固定操作区分离”的失败测试
GREEN:拆分滚动正文区
RED:写“380px 宽度且不越过视口”的失败测试
GREEN:调整定位模型
TDD 是测试驱动开发,即先用失败测试描述用户可观察行为,再写最少代码使测试通过。
它的价值不只是增加覆盖率。更重要的是,测试迫使我们先说清楚:用户最终应该看到什么,而不是直接沉入 CSS 细节。
八、现在,这面墙刚刚开始
我在 (0, 0) 写下了第一句话:
欢迎来到摸鱼表格。
这里没有表头,也没有标准答案。
选一个空格,留下一句话,让这面墙慢慢长出来。
现在整面墙依然很空。
但我期待有一天打开它时,会看到陌生人在很远的坐标留下近况。大家也许会自然发明留言、接龙、拼字和主题区域。
如果你想体验,可以访问:
如果是你,会在这面墙的第一个格子里写什么?