一句话
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_tools | migrations/000310_chat_tools.up.sql | 生产库 select version,dirty → 310 | f;18 个列都在 |
GET /v1/tools | domain/chattool/handler.go:21 | 打真接口 HTTP 200 {"tools":[]} |
| 读目录 | domain/chattool/chattool.go:109 ListLive | 只返回 status='live' |
| 转发判据 | chattool.go:203 AllowEvent · :163 ValidateShape · :229 allowRate · :258 bumpSeq | Go 单测 + 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/height | 0 = 铺满聊天画面;>0 = 一块浮层(dp) | — |
duration_ms | >0 = 到点自动收;0 = 用户自己关 | 铺满的那种不给上限 = 挡住对方的聊天 |
trigger_mode | send 发出去双方就播 / tap 点了才开 / manual 只从「+」开 | 认不得的值客户端一律当 tap(最保守那个) |
interactive | 要不要双方同步 | false 的后端一条事件都不转 |
perms | 能力白名单,默认空 | 相机/通讯录不在可选项里,不是"默认关" |
status | draft / 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 的三步
- 把网页传到我们自己的静态站(和 H5 游戏同一条路)
- 往
chat_tools插一行,status='draft'先自己试 - 改成
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 |
| 放行不互动的 Tool | Rejections/不互动 |
| 不查会话成员 | 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 传上去。还没做的
- 待做 接进聊天页 —— 容器做好了,但聊天里还没有开它的入口 (从「+」里选一个 Tool 发出去 / 收到那张卡点开)。
- 待做 猜拳和小猫泼墨两个网页。
- 卡住 把网页传上线 —— 这台机器没有 Cloudflare 的钥匙。
可执行的解法:在终端跑一次
npx wrangler login,或者给一个 Pages:Edit 的 API token。 - 待做 两台真手机对着玩一局 —— 这是唯一能证明"真的通了"的验收, 而它得等下一班 TestFlight。
待 Jeff 拍板 待拍板
- Tool 从哪儿进? 现在的设想是聊天「+」面板里一格。 但那个面板已经有 9 项了(相册/相机/文件/名片/群/投票/接龙/怪兽/歌)。 是再加一格,还是和贴纸面板并成一排(它本来就"比贴纸大一点")?
- 群里能不能用? 现在的转发是发给这间会话的所有人,一对一和群都能跑。 但猜拳是两个人的事 —— 20 个人的群里三个人同时开三局猜拳,界面上怎么摆? 最省事的一刀:互动型的 Tool 只在一对一开放,群里只给不互动的(泼墨那种)。要不要这么切?
- 谁能上架 Tool? 现在只有改数据库这一条路(等于只有我们自己)。 以后要不要让外面的开发者传?那要接一整套审核 —— 先不做,除非你说要。
- 泼墨那种能不能"强制"盖住对方的屏幕?
现在的设计是能(
trigger_mode=send发出去双方就播)。 这会被拿来骚扰 —— 现有的挡法只有「每分钟几条」。 要不要加一条「对方可以关掉这类特效」的开关?
相关
- 这套东西的近亲是小游戏平台(
docs/GAME_PLATFORM_SPEC.md)和 小程序平台(docs/MINIAPP_PLATFORM.md)—— 都是「服务器下发一份网页」。 故意没有并进它们:那两个都是整屏的、有排行榜/收入/审核队列, 而 Tool 是聊天里的一小片、活几秒钟。并进去的代价是每一条现有查询都得加一句"排除 Tool",漏一处就会有一个猜拳出现在游戏中心里。 - 全屏特效的老做法是 怪兽(
app/lib/widgets/chat_monster.dart)—— 那是 Flutter 手画的,加一个新的就要出一次包。Tools 就是来替掉这条路的。