🧰 Tools —— 聊天里比贴纸更大的「互动小工具」
Jeff 2026-08-28 22:26 语音派题(原话见①)。这份是系统设计 + as-built: 凡是「我们现在有什么」的断言都带 file:line 或一条真跑过的命令;没验过的一律写没验过,不编。
已上生产标 已落地 · 没开工标 待做 · 要你拍板标 待拍板 · 被挡住标 卡住
Tora2026-08-29代码基线 origin/master 5788e95a迁移 000310App v1.442

一句话

App 里只做「一个会播的壳」,画面和规则全部由服务器下发。 以后新增一个 Tool = 往一张表插一行 + 传一份网页,不用重新出 App 包

这句不是我定的,是他自己在语音里定的 —— 我只是照着做:
「所以你讲的 App 只是播放能力,而不是这些画面,对不对?所以它并不冲突,是不是。」

他的原话(2026-08-28 22:26 语音,本地 Whisper 转的)

「我记得不是哦,不能动到那个 App。我的想法是,本来是想做一个 Tools,它比贴图还要大一点… 这个 Tools 也是很多人做…它可以夹夹进去,还有各种各样的功能呢。 比如它做一个 paper scissor rock,然后你可以点,我可以点,它在屏幕上来玩各种各样的这种小 Tools。 像是这个小 Tools 最好是做到用我们做 App 的延伸,比如你这样它泼墨的效果啦,我可能泼水啊,我可能泼红色的墨啊,各种各样。 所以你讲的 App 只是播放能力,而不是这些画面,对不对?所以它并不冲突,是不是。」

之前那两条把方向定死的话也在同一段对话里:

  • 22:23:05 —— 「我是要以后可以直接加载,不用去 build 呀… 不是更好?」
  • 22:35:04 —— 「@Gode 你别动代码,但详细告诉 Tora 怎么写」;Gode 随后给了字段清单,这份图纸照着落的。
「Tools」这个词是问出来的,不是猜的。 语音转出来是 TOOS, 可能是 Tools(工具) 也可能是 Toss(往屏幕上丢) —— 这两个做出来是两样东西。 问了一句,他回「tools」。猜错的代价是整套白做,问一句是 30 秒。

三层模型

整件事只有三层,每一层只知道自己那一份。这是为了让「加一个新玩法」不需要动前两层。

┌──────────────────────────────────────────────────────────┐ │ ① 目录 chat_tools 表(后端) │ │ 有哪些 Tool · 去哪儿取 · 多大 · 播多久 · 要不要同步 │ │ 加一个玩法 = 插一行 │ └──────────────────────────────────────────────────────────┘ ↓ GET /v1/tools ┌──────────────────────────────────────────────────────────┐ │ ② 容器 chat_tool_layer.dart(App) │ │ 打开 · 盖住 · 收掉 · 把两边的动作接起来 │ │ 这一层里一行都不是关于猜拳或小猫的 │ └──────────────────────────────────────────────────────────┘ ↓ 加载 entry_url ┌──────────────────────────────────────────────────────────┐ │ ③ Tool 本体 一份静态网页(服务器上) │ │ 画面 + 规则 + 素材(WebP / 音效 / 逻辑) │ │ 改它不用碰 ① ②,更不用出 App 包 │ └──────────────────────────────────────────────────────────┘

互动怎么走

猜拳这种「你点一下、我点一下」的,事件不直连,一律经后端转:

我的页面 ──hoopTool.send()──▶ App 容器 ──WS tool.event──▶ 后端 │ ① 这个 tool 是不是 live? │ ② 我是不是这间会话的成员? │ 判据 ③ 序号往前走了吗?(挡重放) │ ④ 这一分钟是不是点太多了? │ ▼ 对方的页面 ◀──hoopTool._recv()── 对方 App ◀──WS tool.event── 广播给这间房的人
为什么不点对点直连: 直连就没人验「这条是不是他真发的」。 经后端转,「谁出的拳」由后端填 —— 客户端报的那个字段后端根本不收。

分工:判据在一边,转发在另一边

问题谁答得了住在哪
这个 Tool 是不是 live?序号重放了没?这一分钟几条了?只有 Tools 这个域知道domain/chattool
这条要转给谁?他是这间会话的人吗?只有消息/会话域知道domain/message

两边各答自己知道的那一半,谁都不用抄对方的表(铁律 23)。 合成一处的话,总有一边要复制另一边的数据,而复制出来的那份一定会腐。

已经做好的 已落地

两刀:e00bd1fb(后端)· 5788e95a(App,v1.442)。

后端 —— 已上生产

东西在哪证据
目录表 chat_toolsmigrations/000310_chat_tools.up.sql 生产库 select version,dirty310 | f;18 个列都在
GET /v1/toolsdomain/chattool/handler.go:21 打真接口 HTTP 200 {"tools":[]}
读目录domain/chattool/chattool.go:109 ListLive只返回 status='live'
转发判据chattool.go:203 AllowEvent · :163 ValidateShape · :229 allowRate · :258 bumpSeqGo 单测 + 3 把坏刀
WS 那条路domain/message/handler.go:1087 case "tool.event" 生产二进制里 tool.event 命中 2 处
转给谁domain/message/tool_relay.go:34 RelayToolEvent
接线server/router.go:111-112 · :485-491

App —— 写好了,但真机上还看不到

东西在哪
容器app/lib/widgets/chat_tool_layer.dart:43 ChatToolLayer · :231 showChatTool
同源闸(纯函数,能穷举测)chat_tool_layer.dart:31 toolUrlAllowed
目录模型app/lib/models.dart:2196 ChatTool
取目录app/lib/api/api_client.dart:916 chatTools()
⚠️ 还没接进聊天页 —— chat_screen.dart 一个字没碰, 所以这一版装到手机上也看不到入口。而且整包没编过(出这份图纸的机器编不了 iOS)。

验过什么、没验过什么

验过(能复跑): 后端 go build ./... / go vet ./... 全绿; Go 侧 8 组用例、App 侧 11 条用例全绿;flutter analyze 0 issue; 9 把「故意弄坏」的刀,每把都红在该红的那一条(见⑤)。
没验过(照实说): ① 真机上网页到底加载得出来吗 —— 平台视图在测试里起不来,这要真机; ② 两台手机对着玩一局猜拳 —— 没做过; ③ 那几句建表 SQL 推之前在任何库上都没跑过(这台机器没有 Postgres)。 生产上跑通了,但那是赌对了,不是验过了 —— 所以事先把列名 trigger 改成了 trigger_mode(TRIGGER 是 PostgreSQL 的关键字)。

怎么写一个 Tool

写一份单文件网页就够了。它在容器里跑,能用的东西只有下面这三句。

目录里那一行长这样

字段意思写错了会怎样
id短名,只收英数和 - _带冒号/斜杠会被拒(它要进 Redis 键名)
version换了内容就 +1不加 = 用户手机上还是旧的那份
entry_url网页地址。同时就是白名单必须 https
width/height0 = 铺满聊天画面;>0 = 一块浮层(dp)
duration_ms>0 = 到点自动收;0 = 用户自己关铺满的那种不给上限 = 挡住对方的聊天
trigger_modesend 发出去双方就播 / tap 点了才开 / manual 只从「+」开认不得的值客户端一律当 tap(最保守那个)
interactive要不要双方同步false 的后端一条事件都不转
perms能力白名单,默认空相机/通讯录不在可选项里,不是"默认关"
statusdraft / live / off只有 live 进目录、只有 live 转事件

页面里能用的三句

// ① 我做了个动作 → 转给对方 window.flutter_inappwebview.callHandler('hoopToolSend', { action: 'throw', // 短名,≤32 字 payload: JSON.stringify({m:'rock'}) // 你自己的私事,后端不解释,≤4 KB }); // ② 对方的动作进来(容器会调它) window.hoopTool = { _recv: function (e) { // e = {tool_id, instance_id, sender_id, action, sequence, payload} // sender_id 是**后端填的**,可以信 } }; // ③ 我玩完了,自己收掉 window.flutter_inappwebview.callHandler('hoopToolClose');
就这三句,没有第四句。 一个 Tool 拿不到 HOOP 的令牌、拿不到真实用户 id、 碰不到相机通讯录、跳不出自己那个网址。能力少 = 以后能放心让外面的人写

上架一个 Tool 的三步

  1. 把网页传到我们自己的静态站(和 H5 游戏同一条路)
  2. chat_tools 插一行,status='draft' 先自己试
  3. 改成 status='live' —— 用户下次打开聊天就有了,不用出包

五道闸

每一道后面那句「不装会怎样」才是它存在的理由。 五道全都有「故意弄坏 → 确认它真报红」的记录。

① 只准去它自己那个源

白名单不是另一张表 —— 就是这一行的 entry_url 自己的源。 协议 + 主机 + 端口三样都一样才放行;子域名不算http:// · javascript: · file:// · data: · about: 全拦。

不装会怎样: 页面一句 location.href 就能把整块画面换成别人的站, 而在手机上看起来完全正常(它照样是一个网页窗)。
为什么白名单不另立一张表: 那张表得靠人记得和 entry_url 同步, 漏了的表现是这个 Tool 白屏,而且零报错。同一件事只能有一个出处(铁律 23)。

② 权限一律拒

相机 / 麦克风 / 定位一律 DENY,不许自己开新窗口,读不了本地文件。

写成"拒"而不是"不配置": 不配置时各平台的默认值不一样,而"默认"会随版本变。

③ 到点自己收

不装会怎样: 一个铺满屏幕的 Tool 能把对方的聊天挡到天荒地老

④ 同一局只开一次

「这一局」用 instance_id 认,不是用 Tool 的编号认。

不装会怎样: 网络重发那条广播会把刚开的又开一遍,表现是玩到一半整个重来,零报错。
instance_id 认错了同样出事: 用 Tool 编号的话,同一间房先后开两局, 第二局的第 1 手会被当成第一局的重放丢掉。

⑤ 后端那四条

不装会怎样
「谁发的」由后端填谁都能冒充对方出拳,两边界面都不报错
序号必须往前走(每人每局各一条线)抓一条「我出石头」重发十遍,对面收到十次
合成一条线的话:你出拳会被判成我的重放,对面永远等不到
必须是这间会话的成员拿到别人的会话号就能往里灌事件(路人替你出拳)
不互动的 Tool 一条都不转「转了也没人用」的路,就是没人盯着的滥用面

坏刀记录(9 把,每把红在该红的那一条)

把哪儿弄坏哪条守卫报红
拆掉重放检查Replay
序号键里不带发件人SeqIsPerSender
放行不互动的 ToolRejections/不互动
不查会话成员RelayToolEvent_Guards
sender_id 改成信客户端报的SenderIsFilledByServer
同源改成「结尾对得上就行」别人家的一律拦 + 子域不算(两条)
拿掉自动收到点自己收掉
拿掉「同一局只开一次」同一局只开一次
触发方式默认改成 send认不得当 tap
还有一把是砍扫描器自己的:喂它一段故意不合格的样本,确认它真分得出来。 不然正则写错时「零命中」会冒充「全都合格」—— 那是最像绿灯的假绿灯。

两个 Tool 待做

Gode 定的验收判据是:同一个容器要能跑出两种完全不同的玩法 —— 一个要互动、一个不要;一个是浮层、一个铺满;一个没有时限、一个到点就收。 挑这两个不是随便挑的,它们把容器的每一档都撑到了两头

✊ 猜拳🐱 小猫泼墨
互动(两边各出一手)不要(发出去就播)
尺寸浮层(约 300×380)铺满聊天画面
时长0 —— 打完自己关3000 ms 到点收
触发tap(收到一张卡,点了才开)send(发出去双方就播)
它验证了什么事件转发那条路真的通播放能力真的能盖满、能自己退场

猜拳的一局怎么走

A 点开 → 出「石头」 → send('throw', {m:'rock'}) seq=1 B 收到 _recv(...) → 出「布」 → send('throw', {m:'paper'}) seq=1 两边各自算胜负(同样的输入 → 同样的结果,不用再问服务器)
胜负不放服务器算。 两边收到的是同一组动作,算出来必然一样; 放服务器算就等于给一个纯展示的玩法加一条后端接口 —— 那条接口以后每加一个玩法都要改。
⚠️ 这两个页面现在还没有。 而且 卡住 出这份图纸的机器没有上传网页的钥匙(wrangler whoami 回「未登录」)—— 页面写完也得 Jeff 或 NOVA 传上去。

还没做的

  1. 待做 接进聊天页 —— 容器做好了,但聊天里还没有开它的入口 (从「+」里选一个 Tool 发出去 / 收到那张卡点开)。
  2. 待做 猜拳和小猫泼墨两个网页
  3. 卡住 把网页传上线 —— 这台机器没有 Cloudflare 的钥匙。 可执行的解法:在终端跑一次 npx wrangler login,或者给一个 Pages:Edit 的 API token。
  4. 待做 两台真手机对着玩一局 —— 这是唯一能证明"真的通了"的验收, 而它得等下一班 TestFlight。

待 Jeff 拍板 待拍板

  1. Tool 从哪儿进? 现在的设想是聊天「+」面板里一格。 但那个面板已经有 9 项了(相册/相机/文件/名片/群/投票/接龙/怪兽/歌)。 是再加一格,还是和贴纸面板并成一排(它本来就"比贴纸大一点")?
  2. 群里能不能用? 现在的转发是发给这间会话的所有人,一对一和群都能跑。 但猜拳是两个人的事 —— 20 个人的群里三个人同时开三局猜拳,界面上怎么摆? 最省事的一刀:互动型的 Tool 只在一对一开放,群里只给不互动的(泼墨那种)。要不要这么切?
  3. 谁能上架 Tool? 现在只有改数据库这一条路(等于只有我们自己)。 以后要不要让外面的开发者传?那要接一整套审核 —— 先不做,除非你说要。
  4. 泼墨那种能不能"强制"盖住对方的屏幕? 现在的设计是能(trigger_mode=send 发出去双方就播)。 这会被拿来骚扰 —— 现有的挡法只有「每分钟几条」。 要不要加一条「对方可以关掉这类特效」的开关?

相关

  • 这套东西的近亲是小游戏平台(docs/GAME_PLATFORM_SPEC.md)和 小程序平台(docs/MINIAPP_PLATFORM.md)—— 都是「服务器下发一份网页」。 故意没有并进它们:那两个都是整屏的、有排行榜/收入/审核队列, 而 Tool 是聊天里的一小片、活几秒钟。并进去的代价是每一条现有查询都得加一句"排除 Tool",漏一处就会有一个猜拳出现在游戏中心里
  • 全屏特效的老做法是 怪兽(app/lib/widgets/chat_monster.dart)—— 那是 Flutter 手画的,加一个新的就要出一次包。Tools 就是来替掉这条路的。