我做了一面互联网摸鱼墙:从无限 Canvas 到本地生产环境

最近,我做了一个叫「摸鱼表格」的小项目。

它看起来像表格,却没有表头、公式、筛选和工作任务。这里有的,只是一面可以无限拖动、缩放和探索的公共格子墙。

任何人都能选一个空格,写下一句话、一个突然冒出的想法,或者把它当成临时树洞。

项目已经可以直接体验:

moyu-table.tangyuan.art

这篇文章不只展示成品。我想完整复盘一次:一个看似简单的互动想法,如何逐步变成拥有数据库、登录、部署、备份和公网入口的产品。

一、它像表格,但这里没有标准答案

传统表格围绕效率组织信息:表头定义含义,行列约束结构,公式负责计算。

我想保留的不是 Excel 外观,而是共享表格里偶尔出现的另一种体验:大家会在空白区域留言、接龙、拼字,甚至自然形成一些主题区域。

这种玩法最有趣的地方,是空间本身也成为内容。

坐标不只是数据库字段。一个人在 (0, 0) 留下欢迎语,另一个人在很远的位置写下近况,两句话之间的距离也会产生想象。

因此,产品从一开始就明确了几个边界:

  • 不复刻 Excel,不做公式、筛选和复杂表头。
  • 不做强实时协同编辑器。
  • 每个坐标最多只能写入一个格子。
  • 内容写入后永久锁定,不允许修改。
  • 匿名用户也能参与,降低第一步门槛。

这些限制看似减少功能,实际上是在保护产品最核心的体验:选择一个位置,然后留下某个时刻的自己。

二、核心交互:把坐标变成可探索的空间

项目的主体验不是传统 DOM 列表,而是一块 Canvas 画布。

Canvas 是浏览器提供的绘图画布。相比为每个格子创建一个 DOM 元素,它更适合绘制大面积网格、透视效果和大量动态元素。

项目中的坐标系统分成三层:

text 复制代码
单元格坐标:用户理解的整数坐标,例如 x: 3, y: -2
世界坐标:Canvas 场景中的位置
屏幕坐标:浏览器视口内的像素位置

单元格坐标的 y 轴向上递增,而屏幕坐标的 y 轴向下递增。两者不能直接混用,必须通过明确的转换函数连接。

ts 复制代码
/**
 * 把单元格 y 坐标转换为 Canvas 世界坐标。
 */
function cellToWorldY(cellY: number): number {
  return -cellY
}

这种看似基础的约束非常重要。如果坐标语义没有集中管理,拖拽、跳转、小地图和范围查询很快就会各自形成一套方向规则。

为了让空旷的格子墙不至于"能逛,但不知道去哪",我后来增加了几种探索入口:

  • 随机发现:跳转到一个已有格子。
  • 最新写入:看看公共空间刚刚发生了什么。
  • 近邻发现:从当前位置继续向附近探索。
  • 坐标跳转:输入明确坐标,直接移动相机。
  • 小地图:观察内容密度和当前位置。

这些能力没有试图做推荐算法。它们只是为用户提供几个继续走动的理由,让一面空旷的墙逐渐产生现场感。

三、写入体验:每次发布都应当有一点重量

格子的写入流程并不复杂:选择内容类型、输入正文、选择色调,然后发布。

但因为格子写入后无法修改,编辑窗口必须帮助用户在发布前确认结果。

编辑区采用左右分栏:

  • 左侧输入 Markdown 源文本。
  • 右侧始终展示实时预览。
  • 工具栏支持加粗、引用、列表和分隔线。
  • 色板用于选择格子的视觉色调。

Markdown 是一种使用普通字符表达格式的轻量标记方式。例如,**文字** 表示加粗,- 文字 表示列表。

项目只支持少量格式,没有引入完整富文本编辑器。这是一个刻意的选择:格子应该适合留下一句话,而不是逐渐变成一篇排版复杂的文档。

每个格子的 Markdown 源文本最多存储 10,000 个字符。这个限制同时存在于前端输入、API 校验、领域规则和 PostgreSQL CHECK 约束中。

数据库约束是最后一道保护。即使未来某个接口绕过了前端校验,数据库仍会拒绝超限内容。

四、技术架构:让产品规则有明确归属

项目使用的主要技术栈是:

text 复制代码
Next.js App Router
React + TypeScript
Canvas
PostgreSQL
Prisma
Vitest
Tailwind CSS

Next.js 同时承载网页和 API。Prisma 是 ORM,也就是对象关系映射工具,它让 TypeScript 代码可以通过类型安全的接口读写 PostgreSQL。

代码按职责分成几个区域:

text 复制代码
src/
├── domain/cells/   # 格子规则、坐标、内容、格式与探索算法
├── data/           # Prisma 仓库、API 客户端与数据适配
├── features/wall/  # Canvas、面板、交互与页面状态
└── lib/            # 通用基础设施

领域层不依赖 React 或浏览器 API。像坐标转换、写入准备、内容限制和探索计算,都可以独立测试。

这种分层让 UI 不必承担所有规则,也让数据库仓库不需要理解画布交互。

坐标唯一性必须由数据库保证

用户点击空格时,前端会先检查该位置是否已经被占用。

但前端检查只能改善体验,不能保证最终正确。两个用户可能同时看到同一个空格,并在极短时间内一起提交。

因此,数据库通过唯一约束保证同一坐标只能有一个格子:

prisma 复制代码
model Cell {
  id      String @id
  x       Int
  y       Int
  content String

  @@unique([x, y])
}

最终写入冲突会由数据库明确拒绝。前端负责友好,数据库负责权威。

匿名写入也需要准入规则

项目允许匿名用户写入,但匿名不等于没有限制。

服务端会基于可信代理传来的客户端 IP,通过 HMAC 生成不可逆摘要,并按时间窗口限制写入次数。

HMAC 是使用服务端密钥生成摘要的方法。数据库只保存摘要,不直接保存用户原始 IP。

登录用户与匿名用户使用不同的写入额度。这样既能保留低门槛参与,也能减少公开写入接口被滥用的风险。

五、从"本机能跑"到真正可访问

开发环境跑通以后,我遇到一个很现实的问题:这个项目应该部署在哪里?

最后选择了一套有点特别、但很适合当前阶段的方案:

text 复制代码
正式域名
  → ECS 上的 OpenResty
  → ECS 上的 FRPS
  → 本机 OrbStack 中的 FRPC
  → 本机生产应用
  → 独立生产 PostgreSQL

FRP 是一套内网穿透工具。FRPC 是客户端,运行在本机;FRPS 是服务端,运行在云服务器。FRPC 主动连接 FRPS,让公网域名可以访问本机服务。

本机同时存在开发环境和生产环境,但两者完全隔离:

text 复制代码
开发应用: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 标签。

标签是指向固定提交的版本标记。这样可以避免未提交代码意外进入生产,也确保当前运行版本可以重新构建。

部署流程大致如下:

text 复制代码
验证标签已推送
→ 导出独立源码快照
→ 启动并检查生产数据库
→ 创建部署前备份
→ 构建生产镜像
→ 应用 migration
→ 重启应用和 FRPC
→ 验证本机入口与代理状态

六、开发过程中几个值得记录的坑

1. 数据库已有表,但没有 migration 历史

早期本地数据库已经存在部分表结构,但 Prisma 的 migration 历史表为空。

直接执行 prisma migrate deploy 会收到 P3005:数据库 schema 不为空,无法直接应用初始 migration。

解决方式不是重置数据库,而是先比较现有结构与目标结构,将已经真实存在的历史 migration 标记为已应用,再部署剩余 migration。

这个过程叫 baseline,也就是为已有数据库补齐迁移基线。

2. 面板定位尺寸不等于真实内容高度

格子阅读面板最初按固定高度定位。短内容正常,但登录提示出现后,真实内容高度超过定位高度,内容便从面板底部溢出。

最终方案是把面板拆成三部分:

text 复制代码
固定顶部:类型、标题、关闭按钮
滚动中部:正文
固定底部:坐标、时间、收藏与分享操作

面板宽度调整为 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 循环:

text 复制代码
RED:先写"关闭按钮能取消选中"的失败测试
GREEN:实现最小关闭行为

RED:写"长正文与固定操作区分离"的失败测试
GREEN:拆分滚动正文区

RED:写"380px 宽度且不越过视口"的失败测试
GREEN:调整定位模型

TDD 是测试驱动开发,即先用失败测试描述用户可观察行为,再写最少代码使测试通过。

它的价值不只是增加覆盖率。更重要的是,测试迫使我们先说清楚:用户最终应该看到什么,而不是直接沉入 CSS 细节。

八、现在,这面墙刚刚开始

我在 (0, 0) 写下了第一句话:

欢迎来到摸鱼表格。

这里没有表头,也没有标准答案。

选一个空格,留下一句话,让这面墙慢慢长出来。

现在整面墙依然很空。

但我期待有一天打开它时,会看到陌生人在很远的坐标留下近况。大家也许会自然发明留言、接龙、拼字和主题区域。

如果你想体验,可以访问:

moyu-table.tangyuan.art/

如果是你,会在这面墙的第一个格子里写什么?


相关推荐
七夜zippoe14 小时前
OpenClaw Canvas A2UI:AI驱动的交互式界面开发实战
人工智能·canvas·交互式·a2ui·openclaw
vim怎么退出20 小时前
Dive into React——Hooks 原理
react.js·源码阅读
光影少年1 天前
react的useMemo 如何优化?
前端·react.js·掘金·金石计划
YFF菲菲兔1 天前
React 核心流程总述
react.js
三木檾1 天前
从 5 个文件读完一个生产级 AI Chatbot——Vercel AI Chatbot 源码拆解
ai编程·源码阅读·next.js
光影少年1 天前
react状态管理
前端·react.js·前端框架
珎珎啊1 天前
React 和 Vue 3的区别
前端·vue.js·react.js
Bigger1 天前
mini-cc 终端 UI:用 React 写 CLI 是什么体验
前端·react.js·ai编程
吹个口哨写代码1 天前
IIS 部署 Vue/React 单页应用 (SPA) 刷新页面 404/403.18 报错原因及终极解决方案
前端·vue.js·react.js