最近和 AI 一起折腾了一个网站,叫摸豆。

上班摸鱼,认真拼豆。名字多少带点私心,听起来也没那么正经。

它可以把图片转成拼豆图纸,也可以直接在画板上画,改颜色、擦掉几颗、调整尺寸,然后下载图纸或者 3D 模型。想分享的话,还可以把作品发到广场,别人得到授权后能接着改。

先放张现在的样子。

摸豆首页,右侧是蓝发少女的拼豆图案

这一轮总算把它放上了服务器。我也不想把这篇写成「基于什么什么技术,实现了什么什么平台」,毕竟真正做的时候,大部分时间都没那么体面。

前面还在讨论架构,后面就盯着一个不停闪烁的滚动条生气。刚问完能不能上线,又发现注册页面的输入框长得不一样。模板库都开始做了,想想还是删了。

所以,记一下这段折腾的过程。

先让人用,再说注册的事

有些工具第一次打开,连能做出什么都没看见,就先让你注册。用户进来是想处理一张图片,先填一遍邮箱,总有点扫兴。

摸豆也没必要这样。用户只是想把一张图转成图纸,为什么得先把邮箱交出来?

后来定下来的边界很明确:转换、编辑、本机保存和下载都不要求登录。账号留给云端保存、发布作品和社区互动。登录也不意味着把浏览器里的所有草稿一股脑传上去。

这件事定下来之后,后面的技术路线也跟着明确了。图片转换放在浏览器里做,画板和模型导出也尽量留在本机。服务器负责账号和社区,不替游客兜底跑转换。

当然,这也有代价。设备慢,转换就会慢;本机草稿存在 IndexedDB 里,换电脑不会跟着过来,清理站点数据也可能把它清掉。所以除了本机草稿,还得能下载一个可以重新编辑的工程文件。我给它用了 .pindou 这个扩展名。

「保存了」也要说清楚保存到了哪。自动保存在本机,主动保存到云端,是两件事情。这个地方如果含糊,等用户换了电脑再发现作品没了,就晚了。

浏览器与服务器的职责分工

图片转换、画板和导出在浏览器里;主动云端保存、发布、社区和审核由服务器处理。图中省略了反向代理与容器网络。

这个分法既符合我想要的使用方式,也给后面那台不太富裕的服务器减轻了不少负担。

一万颗豆子,先别让它们都变成组件

前端是 Vue 3、Vite 和 TypeScript。但画板不能完全按普通业务页面的方式写。

一块 100×100 的板子就有一万个格子。每个格子都做一个组件,再让鼠标拖动时的变化经过整套响应式更新,实在没什么必要。页面上的工具栏、弹窗、颜色面板交给 Vue;真正高频的画板操作,放进独立的 TypeScript 内核。

画板底下是一维的 TypedArray,0 表示空格,其余值表示色号索引加一。小色板用 Uint8Array,超过 255 色时换成 Uint16Array。这样一块满尺寸画板,单算格子数据就是 10KB 或 20KB,不需要一个套着一个的对象。

渲染用两层 Canvas:一层画底板和豆子,一层画光标、选区之类的动态反馈。鼠标只是经过画板,也不用把所有豆子重新安排一遍。

图片生成的图纸进入完整编辑器,可以继续修改

撤销也有一个很容易忽略的点:按住鼠标拖过二十个格子,用户理解的是画了一笔。如果撤销一次只退掉一颗,他得按二十次,挺折磨人的。

所以一次拖动算一次操作,结束时再提交历史。当前撤销栈用的是整板快照,没有一开始就做复杂的操作日志。按这个尺寸,保留一百步的格子数据仍然只是 MB 级,先把行为做好,比提前折腾一套复杂的增量历史划算。

保存时又换成另一种表示:RLE,也就是把连续相同的格子合并。

1
3.,2x5,1.

这表示三个空格、两颗索引为 5 的豆子,再一个空格。空白多、同色区域大的图案很适合这样存;颜色特别碎的图,压缩效果就没那么好了,不能拿一个好看的压缩率当作所有作品的结论。

还有,光保存索引不够。第 5 个颜色在这套色板里是红色,换一套色板可能就不是了。作品必须带着色板 ID 和版本,重新打开时按原来的定义解释。后来加了本机微调色,也不能直接把原来的标准颜色改掉,而是追加独立的本机颜色信息。

这些东西看起来都不像「新功能」,但是少一个,存下来的图就可能在以后变样。

3D 也是读取同一份画板数据,再按颜色分组,用 Three.js 的 InstancedMesh 批量放置豆子。没必要给一万颗豆子分别建一万个独立 Mesh。预览和导出的豆子几何共用一套生成逻辑,至少不会出现预览里一种形状、下载后又是另一种形状。

编辑器右侧打开3D预览,并显示模型体积估算

100×100 满板已经是一万颗豆子。界面里的模型体积提示,也是在提醒这张图并不「轻」。

不过,能导出 STL 不等于一定能直接打印成牢固的连体成品。空心豆子之间的连接、图案有没有悬空部分、切片单位,都要实际检查。目前我没有拿一张预览图就宣布所有作品都能打印成功,这个坑还是别挖。

图片转图纸,还真不是缩小一下就行

一开始看这个功能,很容易想:把图片缩到几十个像素,再给每个像素找个差不多的颜色,不就完了吗。

做出来之后才发现,「差不多」这个词能藏好多问题。

缩小一张插画,眼睛和嘴巴可能只剩几格。直接取平均色,细线很容易被背景吃掉;过分保留深色,蓝头发里的阴影又会被误当成黑色轮廓。色数拉满,看起来细腻一些,但真要照着拼,买几十种颜色也是一件麻烦事。

同一张蓝发少女插画的原图与拼豆转换结果

这里用的是 100×100、最多 24 色。右边不是 AI 重新画的图片,而是转换后可以逐格编辑的图纸。

现在的处理过程大致是:裁剪和背景处理之后,对每个目标格子做多点采样,再映射到标准色板;如果限制了用色数量,就选择一组能尽量覆盖原图颜色的色号,最后重新映射整张图。

找「最近的颜色」时,用的是 Oklab 空间里的距离,而不是直接比较 RGB 三个数的差值。它更适合拿来近似人眼感受到的颜色差异,原理可以看作者对 Oklab 的介绍。不过屏幕上的近似,仍然替代不了实物色卡,豆子的品牌和批次也会影响最后的观感。

插画和照片也不能完全用同一套取色习惯。照片更需要平滑的明暗,插画往往更在意轮廓。所以有细线保留、主色块和平滑取色几个方向,让人自己比较。

去白底没有假装自己是万能抠图。它处理的是和图片边缘连通的近白区域,加上透明区域。封闭在图案内部的白色不会因为「它也是白的」就直接被清掉。但如果人物的白衣服和背景连在一起,还是要检查,复杂背景更不能指望这个办法解决。

计算放在 Web Worker,结果带着任务编号返回,页面只接纳对应的当前结果。用户快速改参数时,不能让前一次比较慢的计算回来,把后一次的新结果盖掉。

我觉得这个功能比较合理的终点,是先给一份可以继续修的底稿。因此后面接了查色、整版替色、擦除和进入完整画板。生成完还有不对的地方,可以继续改,不用重新找一套工具。

顺便说一句,开发这个功能用了 AI,但用户每次转换图片时,并没有在背后调用一个收费的大模型接口。

做着做着,模板库被我删了

原来导航里还有模板库,里面放一些简单图案。后来我嫌它们太简单,还让 AI 去生图,准备转成更好看的模板。

折腾到这里,我突然觉得不太对。

作品广场本来就能看作品,在作者允许的情况下,也可以复刻成自己的草稿继续改。模板库又单独放一批图,用户需要理解两个差不多的入口,我还要维护两份内容。

于是停掉模板扩充,把模板库整个拿掉,旧链接转到作品广场。已经做了一些,不代表必须把它留下来。这次删除之后,反而觉得导航舒服了。

浅色主题下的作品广场

广场保留分类、排序和作品入口;能不能复刻,由作者的授权决定。

「怎么玩」和「3D 打印」也合在了一起,打印说明放进帮助页。默认画板从 29×29 改成 58×58,调整尺寸的按钮搬到顶栏,不再只藏在左下角那个小图标里。

当然,改默认值不能顺手把用户原来的作品放大。它只影响没有明确尺寸偏好的新建画板,旧草稿和已经保存的尺寸照旧。

自己做项目有一点比较舒服:发现原来的想法没什么必要,可以直接删。不用为了证明当初规划得很周全,硬把每一个框都填满。

功能都在,就是用起来哪里不对

这段时间我发给 AI 很多截图。后来有点像拿着放大镜找茬,但这些东西不改,我自己看着都难受。

比如选择器。广场排序是一套样子,图纸预览的缩放又是一套样子;有的展开以后还会把下面的结构往下挤。点个排序,作品列表跟着往下跑,鼠标都得重新找位置。

统一组件之后,菜单浮在内容上方,展开不再参与下面的布局,同时把键盘操作和视口边缘的处理一起补上。不能只是颜色改得一样,就说组件统一了。

现在的预览缩放菜单浮在图纸上方,不挤动下面的内容

还有色卡边缘的闪烁。鼠标一放到边界,滚动条就有点不安分。排查时发现色块悬停会放大,外层又是自动滚动容器,边缘一溢出,滚动条和可用宽度就可能跟着变化。

最后把这种放大拿掉,用内侧描边表达悬停;需要纵向滚动的大色板保留稳定占位。这里要的就是一个稳当,没必要每颗豆子都在鼠标下面膨胀一下。

登录注册页也有类似的问题。标签居中,密码的显隐按钮单独挤在输入框右边,导致密码框视觉上比账号框短一截;找回密码的输入框和提交按钮又贴在一起。头像外面一个小圆角容器,里面的图片却裁成圆形,边角也别扭。

这些事情单独拿出来,都不像值得开一个大需求的事。但人打开网站,先接触到的就是这些。点起来晃一下,输密码时短一截,颜色看不清楚,积累起来就会让人觉得这个东西有点糙。

我也明确说过,设计稿只是初版的大概方向,不是每一个地方都得照着抄。不能因为「设计稿就是这样」,就让一个不舒服的交互一直留着。

后来有人反馈底板颜色看着不舒服,就把底板单独做成了可切换的显示偏好。换的是观看时的底板,不应该连豆子的颜色和导出文件也一起变。看着舒服和作品数据正确,得分别处理。

说好的 2C4G,原来没有 4G

部署之前,一直按 2C4G 在讨论。真正看服务器配置,系统报告的总内存只有 1677MiB,而且上面还跑着主站和博客。

我只能跟 AI 说,我记错了,就这一台,将就用吧。

这时候就很庆幸,前面的图片转换和导出没有塞进后端。不然几个人同时上传、转图、生成模型,怎么都得给这台机器找点麻烦。

后端没有拆微服务。一个模块化的 NestJS API,加一个独立 Worker,下面是 PostgreSQL、Redis 和 MinIO。Docker Compose 跑起来,每个服务给限额,队列并发也收着点。算下来这几项常驻容器的内存上限合计 752MiB,迁移工具另算,剩下还得给系统和已有服务留地方。

上限只是约束,不能拿来证明一定够用。线上做过一轮混合读测试,8 并发时测到大约每秒 58 次请求,再加到 16 并发,吞吐没有继续明显增长,等待却变长了。排查时也能看到 API 容器的 CPU 配额在限速。

这个数没有被我换算成「能支持多少在线用户」。读列表、登录、上传、生成预览、账号归档,根本不是同一种负载,拿一个读请求结果去报在线人数就太乐观了。

CI/CD 也经历了一轮「有了,但还能不能合理一点」。最开始发布一次,就整套构建、备份、切换。我就问,只改了前端,后端也要跟着重跑一遍吗?再问一句,每次都备份,我那点硬盘够存多久?

后来把完整检查和实际发布范围分开了。

根据线上前后端实际版本选择发布范围的流程图

只改前端,更新静态资源、切换链接,不重启 API 和 Worker,也不额外备份数据库。后端、共享包或者相关运行配置变了,再走全量,先完成备份,再处理迁移和应用切换。只有文档变化,就不动线上程序。

这里有个小细节:不能只比较最后一个 commit。可能前面有一笔后端改动还没上线,最后一笔恰好只改了样式。所以要读服务器实际运行的前端和后端版本,分别和目标提交比较累计差异。

备份也做了有限保留:日常三份、发布前两份,另外保护当前和上一版关联的恢复点。不能为了硬凑磁盘预算把仅有的退路删了,但也不能每次发版都堆一份,然后假装硬盘无限大。

有新增迁移时,失败恢复更不能一句「切回旧镜像」就带过。数据库可能已经变了,甚至迁移只执行了一部分。该保留现场、停下来人工处理的时候,就得停。

上线之后,先来的还有扫路径的

看网站日志的时候,会发现访问量里夹着不少莫名其妙的请求。找 .env 的,翻 Git 目录的,还有来试 PHP 路径的。摸豆又不是 PHP 项目,看着多少有点出戏。

我当时第一反应也是,这种路径扫描未免太低级了,前后端分离的项目不至于犯这种错吧。

不过实际测了一轮,值得优先修的还真不在这些路径上。

其中一个问题在登录:跨站的普通表单可以向登录接口提交另一套账号,浏览器里的当前身份就可能被替换。它不等于直接读走旧账号的数据,但用户如果没留意,接着把自己的作品保存进了另一个账号,也很麻烦。

所以后面补了可信来源检查,登录注册只接受需要的 JSON 正文,敏感写操作继续校验绑定会话的 CSRF 令牌。CORS 管的是跨域读取等浏览器访问限制,不能拿它代替这些写请求的校验,OWASP 的说明也把登录 CSRF 单独拿出来讲了。

还有一些更偏运维的问题。私有下载链接里带着签名,如果完整记进访问日志,日志里就可能多出一份仍在有效期内的下载凭证。前端 HTML 的防护头也得单独配,API 有,不代表静态页面就自动有了。

这轮测试之后,我对「项目已经前后端分离了,所以应该没事」这种判断谨慎了一点。部署结构没法替账号、权限、日志和浏览器行为把所有事都兜住。

人工审核,先别把管理员烦死

能发作品、能评论,就会有内容管理的问题。

云端审核当然是一个方案,但要钱,我暂时不考虑。自己在这台机器上跑一套识别模型,也不太现实。那就保留人工审核,把流程做得适合一个人维护。

首先是作品不能随便绕过审核。编辑中的像素和公开作品是两份状态:私有草稿继续改,公众看到的仍是发布快照;明确重新发布之后,再进入审核。关闭复刻授权这种事情又要及时生效,不能拿快照当理由拖着不处理。

保存还带着版本号。两个窗口都从版本 N 开始编辑,第一个保存成功后变成 N+1,第二个再拿 N 来提交,就返回冲突,不能悄悄把前一个窗口的内容盖掉。管理员处置同样要核对看到的那份内容,旧举报不能顺手下架一个已经修改过的新版本。

然后才是我这个唯一管理员的使用体验。

早先每次审核都得填处理原因,连点「通过」也要写至少两个字。我越看越觉得,这有什么可填的?看到没问题就通过,难道每条都写一句「审核通过」吗。

于是通过改成一键操作。驳回、下架和封禁等动作保留确认,但说明可选,常用原因点一下就行,没填就由服务端补默认说明。操作记录照样保存,只是不让管理员反复给系统当复读机。

审核确认框,提供常用原因,补充说明可选

这是审核流程联调时的截图,列表里是测试数据。通过审核不经过这个弹窗,驳回等操作才需要确认。

另一件事更实际:有人提交了,我怎么知道?我总不能一天到晚把审核后台挂着。

最后接了邮件摘要。首次发现待审就提醒,后续新增按十分钟合并,持续积压每两小时再提醒,清空就停止。邮件里只放数量和后台入口,不把用户提交的正文、图片到处转发。

实现上没只发一个内存事件就算完事。提醒状态存在数据库,每个管理员一条;发送前领一个有期限的租约,避免两个进程同时发;失败按间隔重试,服务重启后还能继续扫描。SMTP 接受了邮件,数据库回执却没写上,这种情况下仍可能重发,所以重试沿用投递编号,也不吹「一定只收到一次」。

这些加起来看着琐碎,但比一个只有我主动打开才能发现待审的后台实用多了。

AI 能帮忙写,最后还是得自己用

摸豆这次相当一部分实现、测试和部署工作,都是我和 AI 来回配合做的。它能把一段想法很快变成代码,也愿意陪着查一堆很细的问题,确实省了不少力气。

但这段过程也没有变成「说一句话,等它给我一个完美的网站」。

模板库该不该留下,免费工具要不要先注册,审核原因是不是非填不可,哪个按钮看着别扭,底板盯久了舒不舒服,这些判断最后还是会落回到实际使用上。我不打开页面点一遍,不把鼠标挪到那个边界上,有些问题就一直躺在那里。

测试能帮忙守住已经明确的规则,但也得知道它测了什么。最新这轮上线通过了 216 项工程测试和 30 项发布规则测试,之前还做了实际浏览器、权限和故障回滚的联调。它们很有用,只是我不会因为数字变绿,就认为往后再也不会出毛病。

这次比较有收获的一点,是能从头到尾跟着一个自己的东西走。画板怎么存,图纸怎么转,服务器怎么放,用户说看得难受以后改哪里,甚至有人请我喝咖啡的入口该放在哪,都得自己想。

现在摸豆是免费使用的。支持作者的入口放在「关于」里,觉得不错可以请我喝杯咖啡,给服务器和日常维护加点燃料。没有把它做成用户创作到一半必须跨过去的一道门。

关于摸豆和支持作者的入口

如果你也想试试,地址在这里:摸豆 MODOU。图片转图纸和下载可以直接用,精细编辑建议在电脑上操作,做实物之前记得核对色卡和尺寸。

先写到这里。网站已经放上去了,剩下的事情,边用边改吧。