资源模块点「新建」→ 弹窗里点「上传附件」选文件 → 页面没有任何变化。
关键区别:同一个框,拖进来是有显示的(会出缩略图卡片), 点选则什么反应都没有 —— 这是两条不同的代码路径。
templates/_stage_drop.php 渲染的框带 data-drop-stage(「暂存上传」开关)。 assets/js/app.js 里两条链路的分工是:
templates/_stage_drop.php
data-drop-stage
assets/js/app.js
bindZone
drop
queueDrop()
box.click()
input.click()
change
证据:
bindZone()
dragenter
dragover
dragleave
bindFileEcho()
if (box.hasAttribute('data-drop-stage')) { return; }
⇒ 点选这条路:文件进了 input.files,但没有任何人把它交给 queueDrop()。
input.files
queueDrop() 里取 slot 用的是 input.getAttribute('data-stage-slot'), 但 data-stage-slot 实际渲染在框(.upbox)上,不在 <input> 上 ⇒ slot 恒为空串,fd.append('slot', ...) 从不执行。
input.getAttribute('data-stage-slot')
data-stage-slot
.upbox
<input>
slot
fd.append('slot', ...)
对资源附件(slot='photo')影响可能不明显(默认就是 photo), 但对海报/票根(poster/ticket,比例不同)会有影响,属于同类隐患。
点「上传附件」选中文件后,界面必须立刻有反馈,与拖动一致:
本地夹具站 + 真实 Chromium(Playwright headed),登录后进资源模块, 点「新建」打开弹窗(#resAddBtn → #resAddCard)。
#resAddBtn
#resAddCard
新写探针 _tooling/_res_attach_pick.py,专走点选这条路(set_input_files), 因为拖放本来就能用、抓不到问题。
_tooling/_res_attach_pick.py
set_input_files
修复前跑出来:
点选后:{'list_found': True, 'list_hidden': True, 'cell_count': 0, 'cell_texts': [], 'cell_classes': [], 'has_img': False, 'tip_hidden': True, 'tip_text': '', 'box_text': '+ 点击或拖动进行上传'} [FAIL] 点选后清单出现条目 cell_count=0 [FAIL] 点选后清单已显示 list_hidden=True [FAIL] 条目里有文字(文件名或类型) ''
点选前与点选后,探测结果一模一样 —— 界面确实一个像素都没变。 而同一页面上走拖放:
拖放后:{'list_hidden': False, 'cell_count': 1, 'cell_texts': ['PDFdrop_probe.pdf9 B×'], 'cell_classes': ['stage-cell'], 'tip_text': '已上传,可以继续拖;点「保存」时才真正记到这条上。'}
⇒ 同一份文件、同一个框,拖进来有卡片,点选什么都没有。这与你的描述完全一致。
_stage_drop.php 渲染的那个框有三个入口,app.js 里的接线情况:
_stage_drop.php
app.js
bindBox
具体两处:
bindZone() 只绑了 dragenter / dragover / dragleave / drop,没有 change。
唯一的 change 监听在 bindFileEcho(),它开头就是:
// ⚠️ **只给非暂存框**挂(暂存型框由 queueDrop 画缩略图卡片,别去抢它的 UI)。 if (box.hasAttribute('data-drop-stage')) { return; }
暂存框被故意跳过 —— 理由是"暂存框有自己的清单 UI"。 但那个清单 UI 只在 queueDrop() 里画,而 queueDrop() 只被 drop 调用。
⇒ 点选时文件确实进了 input.files,表单提交也真的把它存上了 (这就是你说的"保存也能正常保存"),但清单一次都没画过。
queueDrop() 里取槽位用的是 input.getAttribute('data-stage-slot'):
var slot = input.getAttribute('data-stage-slot') || '';
但 data-stage-slot 实际渲染在框(.upbox)上,不在 <input> 上:
<div class="upbox" data-drop-stage data-sd-for="sd-attach" data-stage-field="stage_token[]" data-stage-slot="photo" <!-- ← 在这里 -->
探针探测确认:{'slot_on_box': 'photo', 'slot_on_input': None}
{'slot_on_box': 'photo', 'slot_on_input': None}
⇒ slot 恒为空串,fd.append('slot', ...) 从来没执行过。 资源附件默认就是 photo 所以看不出差别,但海报/票根(2:1、2:3 要按比例重裁) 会拿到错的槽位 —— 属于同一处代码的隐患,本次一并修掉。
photo
修复方案与验证结果,见下一条评论。
+132 / -3
① 新增 bindStagePick() —— 把"点选"接进 queueDrop(主修复)
bindStagePick()
queueDrop
function bindStagePick(box, input) { if (!box || !input || box.getAttribute('data-sd-pick') === '1') { return; } if (!box.hasAttribute('data-drop-stage')) { return; } // 只给暂存框挂 box.setAttribute('data-sd-pick', '1'); input.addEventListener('change', function () { var picked = input.files ? Array.prototype.slice.call(input.files) : []; if (!picked.length) { return; } var fp = picked.map(function (f) { return (f.name || '') + '|' + (f.size || 0) + '|' + (f.lastModified || 0); }).join(','); if (fp === box.getAttribute('data-sd-picked')) { return; } // 去重 box.setAttribute('data-sd-picked', fp); queueDrop(box, input, picked); }); }
并在 bindInput 里对每个 [data-sd-for] 框挂上它。
bindInput
[data-sd-for]
为什么是"接进 queueDrop"而不是"给暂存框也挂 bindFileEcho": bindFileEcho 只会把框里那句话换成文件名,而你要知道的是**"上传成功没有"—— 状态、失败原因、能否撤掉。这些只有 queueDrop 的卡片能给。 所以让点选和拖放走同一条**链路(行为一致),而不是各写一套 UI。
bindFileEcho
② slot 读错元素(顺带修的第二个 bug)
// 改前:input 上没有这个属性 ⇒ 恒为空串 var slot = input.getAttribute('data-stage-slot') || ''; // 改后:先读框(属性实际在框上),再兜底读 input var slot = box.getAttribute('data-stage-slot') || input.getAttribute('data-stage-slot') || '';
③④ queueDrop 增加"没清单就别跑"的守卫,并返回布尔说明是否接管了。 防止将来某处结构变了(.upfield 被删/挪)出现"传上去了、令牌也塞了、但用户看不见" ——那正是本次这个 bug 的形态,只是换了个触发原因。
.upfield
⑤ 新增 dropFromInput(),传完就把文件从 input.files 摘掉。 这一条是第一版漏掉的,被流水线打回来才补上 —— 没有它,同一个文件会被 服务端两条路径各存一遍,库里出现两行。完整复盘见下面「补:一个我自己引入的 bug」。
dropFromInput()
① 加了去重指纹。 change 在"同一个文件清空再选"时也会触发, 不去重会让同一份文件传两遍、出两张卡片。
② 传完就把文件从 input.files 摘掉(dropFromInput)。 理由见下一节 —— 这一条我一开始判断错了,是被流水线打回来才改对的。
dropFromInput
_tooling/_res_attach_pick.py,17 条断言全通过 0 失败。
修复前 → 修复后:
cell_count=0
cell_count=1
hidden
hidden=True
hidden=False
''
PDF_res_pick_probe.pdf595 B×
stage-cell
点选后实际拿到的状态:
{'list_hidden': False, 'cell_count': 1, 'cell_texts': ['PDF_res_pick_probe.pdf595 B×'], 'cell_classes': ['stage-cell'], 'tip_hidden': False, 'tip_text': '已上传,可以继续拖;点「保存」时才真正记到这条上。'}
_tooling/_res_attach_pick_falsify.py:
_tooling/_res_attach_pick_falsify.py
bindStagePick
grep "注入:"
第一版反向验证在注入② 那里写了这么一行:
note(True, '⇒ 该守卫由静态断言覆盖(见 _fb_v95_static / 人工复核)')
这行有两个毛病,都是假绿:
note(True, ...)
[OK]
_fb_v95_static
所以补了两件事:
_res_attach_pick_falsify.py
static_guard()
\n function
!form
!list
return true;
逐条删除验证(三个守卫分别删、分别跑):
if (!list) { return false; }
if (!form) { return false; }
顺便修了脚本末尾一行恒真输出:原来 '通过 %d 项' % 0 永远打印"通过 0 项", 会被读成"一条都没测"。改成真实的检查计数。
'通过 %d 项' % 0
改完上面那两处,我顺手跑了一次锚点体检(_fb_anchor_check.py),结果:
_fb_anchor_check.py
[NOANCHOR] _res_attach_pick_falsify.py ← 一条锚点都没抽出来,本脚本等于没查
意思是:流水线里那道"30 秒抓锚点漂移"的前置体检,对这个脚本一直是失效的。 它那两条注入锚点(queueDrop(box, input, picked); 和 if (!list) { return false; }) 从来没被检查过,将来 app.js 一改就会悄悄漂掉,和 #409 记的坑一模一样。
queueDrop(box, input, picked);
原因:体检靠"注入函数的第 1 个参数是不是像路径"来判断打的是哪个文件, 而这个脚本的签名是 inject(anchor, repl, label) —— 三个参数里没有路径。 改成 inject(rel, anchor, repl, label) 后体检就能认出来了。
inject(anchor, repl, label)
inject(rel, anchor, repl, label)
改完立刻暴露第二个问题:
[BAD] 行123 FamilyBook/assets/js/app.js 文件不存在
因为体检的 rel 是拼在 PHP_ROOT(D:/Yongquan/code/FamilyBook)上的, 写 FamilyBook/assets/js/app.js 会被拼成 FamilyBook/FamilyBook/assets/js/app.js。 改成 assets/js/app.js 后:
rel
PHP_ROOT
D:/Yongquan/code/FamilyBook
FamilyBook/assets/js/app.js
FamilyBook/FamilyBook/assets/js/app.js
全部 1 个脚本的锚点都命中真实源码,且各自唯一(共 2 条)。
这一段的教训:[NOANCHOR] 不是"没事",它是"这道关卡对你不生效"。 而且它会掩盖后面那个错 —— 我要是看到 NOANCHOR 就跳过,永远发现不了路径拼错。
[NOANCHOR]
这条要单开一节写,因为它是这轮最贵的一课,而且形态很反直觉。
第一版改完,我的探针 17/0 全绿,我准备提交了。然后流水线跑完,EXIT=1:
EXIT=1
失败清单: - ④ 🔴 新建时上传两张 → 库里真有 2 张照片("只存一张"的回归守卫) - ⑤ 🔴 编辑补传后 = 3 张(旧的 2 张没被冲掉 + 新的 1 张) - ⑥ 详情页停留不会改动照片数(仍 3 张)
实测数字:2 张照片变成 4 行,编辑补 1 张变成 6 行 —— 全部翻倍。
我为了让"点选"也能出缩略图,把它接进了 queueDrop()。这个函数会把文件 传到服务器暂存区、拿回 stoken、塞进隐藏字段 stage_token[]。 但它没有把文件从 input.files 里摘掉 —— 于是同一个文件同时存在于两条通路:
stoken
stage_token[]
attach_save_files()
StagedUpload::claim(...)
$_FILES['attach']
Attachment::save(...)
两条路都往同一张 attachments 表插入,于是同一张图两行 (position 分别是 10 和 20,文件名一模一样)。
position
我在代码注释里写过这么一段:
直接调 queueDrop 不能清空 input.files:……清空会让某些路径下 "点选的文件反而没存上"。而两边都认领也不会重复挂 —— 两次都按同一张 附件表插入、position 顺排,与"拖一次 + 再选一次"等价。
这段的后半句是完全错的,而且它是我猜的,没有实测。 更糟的是:它的前半句("清空会丢文件")也是猜的 —— 文件已经在暂存区、 拿着 stoken,$_FILES 只是第二条冗余通路,摘掉根本不会丢。
$_FILES
一个没实测的猜测,被我写成了带"⚠️"的红线注释。 这种注释比没有更危险: 后来的人(包括几小时后的我自己)会把它当成已验证的结论,于是不去查。
传完这一张就把它从 input.files 摘掉,只留暂存令牌一条路径:
// 令牌进隐藏字段之后 dropFromInput(input, f);
function dropFromInput(input, target) { // input.files 是只读 FileList ⇒ 用 DataTransfer 重装剩下的,再整体赋回 // 只摘**一张**:多选时后面的还排在上传链里,整个清空会让它们连令牌都带不上 }
因为那条探针只验"清单里出现了条目"。 重复插入时清单里仍然只有 1 条 —— 用户看到的就是一张卡片,跟正常情况一模一样。 所以这个 bug 在渲染层原理上不可见,必须查库。
于是补了一条查库探针 _tooling/_res_attach_db.py:
_tooling/_res_attach_db.py
数据库 = fb_test(attachments 现有 0 行) 点选后清单有 1 条 cell_count=1 暂存令牌 = ['cfe3dbcb...'] 库里找到刚建的资源 id=3 附件行(id / position / name): 1 10 _res_db_probe.pdf 2 20 _res_db_probe.pdf [FAIL] 🔴 点选 1 个文件 → 库里正好 1 行附件 实际 2 行(>1 = 重复插入,=0 = 没存上)
修完后:
附件行(id / position / name): 3 10 _res_db_probe.pdf [OK] 🔴 点选 1 个文件 → 库里正好 1 行附件 实际 1 行
第 35 批那 3 条也一起转绿(13/3 → 16/0), 且它的诊断输出里 #photo 里的文件数 = 0(原来是 2)—— 正是"摘掉了"的证据, 同时照片仍然正确落库 2 张,说明摘掉不影响保存。
#photo 里的文件数 = 0
把这行摘掉的调用注入删除:
【3】注入③:删掉 queueDrop 里 `dropFromInput(input, f);` 查库探针退出码 = 1 查库失败项:['[FAIL] 🔴 点选 1 个文件 → 库里正好 1 行附件 实际 2 行(>1 = 重复插入,=0 = 没存上)'] [OK] 注入③后**查库探针变红**了 [OK] 红在**"库里正好 1 行"**那条上(不是别的偶然红) [OK] 注意:同样注入下**渲染探针仍然全绿**("看得见"≠"存对了")
最后那条输出是特意加的断言 —— 它把这个教训固化进流水线: 以后谁再想"渲染探针绿了就行",流水线会当场告诉他"渲染绿、查库红"。
两条探针都进了流水线,而且刻意不合并:
run_suite_if 全路由 "上传框点选反馈 e2e(…)" _res_attach_pick.py run_suite_if 全路由 "上传落库 e2e(点选 1 个文件 → attachments 正好 1 行)" _res_attach_db.py
合并的风险很实际:合并后一旦有人为了"跑得快"删掉查库那半, 这个 bug 就再也没人守了。
"界面上看得见" 和 "数据库里存对了" 是两件事,前端改动只要动到"文件从哪来", 就必须同时有一条查库断言。 渲染层能证明的那部分,只是最外面那一层。
第一版探针把观影路由写成 movies/create(那是提交端点,不是新建页), 结果 boxes=0 被我的"没有上传区就跳过"逻辑当成正常情形 —— 跳过和通过输出一样。 已改成声明式期望:观影新建 = 2 个框、旅程新建 = 0 个框, 数量不符即报红,URL 写错会被当场抓住。
movies/create
boxes=0
(旅程新建页确实不该有上传区:照片是挂在每一段行程下面的,不是挂在旅程上。)
这里如实记一笔,因为它差点变成一次误判。
我把 bindInput 里那个回调从一行折成两行(为了挂 bindStagePick):
// 改前 function (bx) { bindZone(bx, input); bindBox(bx, input); bindFileEcho(bx, input); }); // 改后 function (bx) { bindZone(bx, input); bindBox(bx, input); bindFileEcho(bx, input); bindStagePick(bx, input); });
而 _b30_falsify2.py 的 ① 组注入是整行字面量替换,锚在这一整行上。 于是锚点失效 → patch() 返回 False → 那组注入根本没打上。
_b30_falsify2.py
patch()
False
危害方向很反直觉:补丁没打上,探针跑的还是好代码 ⇒ 全绿。 如果不看 patch() 的返回值,就会把"全绿"读成**「判据没牙齿」**, 然后去改本来完全正确的判据 —— 修错东西。
流水线的 1d3 锚点体检在 30 秒内就抓住了它([BAD] _b30_falsify2.py 1/5 条锚点有问题), 没有白跑 2 小时。修法是把锚点从整行缩到稳定片段:
[BAD] _b30_falsify2.py 1/5 条锚点有问题
# 改后(去掉行首 function (bx) { 和行尾 });,将来再加东西也不会失效) ok = patch('assets/js/app.js', "bindZone(bx, input); bindBox(bx, input); bindFileEcho(bx, input);", "bindZone(bx, input); bindBox(bx, input);")
单独重跑 _b30_falsify2.py 确认:补丁已打上,且 ① 组两条断言精确变红 (「选中文件后框里有反馈」「框有已选视觉态」)。
代码:34625cd(已推送 main),只改 assets/js/app.js,+132 / -3。
34625cd
main
部署:全量流水线 EXIT=0(2h37m),已上线。 线上 assets/js/app.js md5 = a74c652c40d96ce63565486638a6facb,与本地一致; bindStagePick / dropFromInput 均已在线上文件中(各 3 处引用)。 本次无需 Schema 迁移(指纹已是最新)。
EXIT=0
a74c652c40d96ce63565486638a6facb
本地验收(部署前)
失败 0
线上需要你本人验一下(按之前约定,线上冒烟由你来):
bash D:/Yongquan/code/_tooling/familybook_smoke.sh
重点看这两件事:
另:观影模块新建/编辑也走同一个上传框,点选后同样应当出卡片, 照片数量不应当在保存后翻倍(这条是本次修掉的第二个 bug)。
这一单顺带修掉了一个我自己在第一版引入的 bug(文件传完没从 input.files 摘掉, 导致服务端两条通路各存一次)。完整复盘写在上一节评论里,重点是那句 「界面上看得见 ≠ 数据库里存对了」——我的渲染探针 17/0 全绿的时候, 库里其实存的是双份。现在流水线里两条探针并存且刻意不合并。
No dependencies set.
The note is not visible to the blocked user.
现象
资源模块点「新建」→ 弹窗里点「上传附件」选文件 → 页面没有任何变化。
那个虚线框里永远只写着「点击或拖动进行上传」
复现路径
关键区别:同一个框,拖进来是有显示的(会出缩略图卡片),
点选则什么反应都没有 —— 这是两条不同的代码路径。
初步定位(待实测确认)
templates/_stage_drop.php渲染的框带data-drop-stage(「暂存上传」开关)。assets/js/app.js里两条链路的分工是:bindZone的drop事件 →queueDrop()box.click()→input.click()→ 触发 input 的change证据:
bindZone()只绑了dragenter/dragover/dragleave/drop,没有changechange监听在bindFileEcho(),而它开头就if (box.hasAttribute('data-drop-stage')) { return; }—— 暂存框被明确跳过(因为"暂存框有自己的清单 UI,不抢"),
但暂存框自己的清单 UI 只在
queueDrop()里画,而queueDrop()只在drop时被调⇒ 点选这条路:文件进了
input.files,但没有任何人把它交给queueDrop()。顺带发现的问题(同一处)
queueDrop()里取 slot 用的是input.getAttribute('data-stage-slot'),但
data-stage-slot实际渲染在框(.upbox)上,不在<input>上⇒
slot恒为空串,fd.append('slot', ...)从不执行。对资源附件(slot='photo')影响可能不明显(默认就是 photo),
但对海报/票根(poster/ticket,比例不同)会有影响,属于同类隐患。
诉求
点「上传附件」选中文件后,界面必须立刻有反馈,与拖动一致:
验收标准
实测复现:确认是「暂存框只接了拖放、没接 change」
环境
本地夹具站 + 真实 Chromium(Playwright headed),登录后进资源模块,
点「新建」打开弹窗(
#resAddBtn→#resAddCard)。用真实浏览器复现,不是读代码猜
新写探针
_tooling/_res_attach_pick.py,专走点选这条路(set_input_files),因为拖放本来就能用、抓不到问题。
修复前跑出来:
点选前与点选后,探测结果一模一样 —— 界面确实一个像素都没变。
而同一页面上走拖放:
⇒ 同一份文件、同一个框,拖进来有卡片,点选什么都没有。这与你的描述完全一致。
根因:三个入口只接了俩
_stage_drop.php渲染的那个框有三个入口,app.js里的接线情况:bindBox→input.click()bindZone的drop→queueDrop()change事件具体两处:
bindZone()只绑了dragenter/dragover/dragleave/drop,没有change。唯一的
change监听在bindFileEcho(),它开头就是:暂存框被故意跳过 —— 理由是"暂存框有自己的清单 UI"。
但那个清单 UI 只在
queueDrop()里画,而queueDrop()只被drop调用。⇒ 点选时文件确实进了
input.files,表单提交也真的把它存上了(这就是你说的"保存也能正常保存"),但清单一次都没画过。
顺带查出的第二个 bug
queueDrop()里取槽位用的是input.getAttribute('data-stage-slot'):但
data-stage-slot实际渲染在框(.upbox)上,不在<input>上:探针探测确认:
{'slot_on_box': 'photo', 'slot_on_input': None}⇒
slot恒为空串,fd.append('slot', ...)从来没执行过。资源附件默认就是
photo所以看不出差别,但海报/票根(2:1、2:3 要按比例重裁)会拿到错的槽位 —— 属于同一处代码的隐患,本次一并修掉。
修复方案与验证结果,见下一条评论。
已修复
改动(一个文件
assets/js/app.js,+132 / -3)① 新增
bindStagePick()—— 把"点选"接进queueDrop(主修复)并在
bindInput里对每个[data-sd-for]框挂上它。为什么是"接进 queueDrop"而不是"给暂存框也挂 bindFileEcho":
bindFileEcho只会把框里那句话换成文件名,而你要知道的是**"上传成功没有"——状态、失败原因、能否撤掉。这些只有
queueDrop的卡片能给。所以让点选和拖放走同一条**链路(行为一致),而不是各写一套 UI。
②
slot读错元素(顺带修的第二个 bug)③④
queueDrop增加"没清单就别跑"的守卫,并返回布尔说明是否接管了。防止将来某处结构变了(
.upfield被删/挪)出现"传上去了、令牌也塞了、但用户看不见"——那正是本次这个 bug 的形态,只是换了个触发原因。
⑤ 新增
dropFromInput(),传完就把文件从input.files摘掉。这一条是第一版漏掉的,被流水线打回来才补上 —— 没有它,同一个文件会被
服务端两条路径各存一遍,库里出现两行。完整复盘见下面「补:一个我自己引入的 bug」。
两个刻意的取舍
① 加了去重指纹。
change在"同一个文件清空再选"时也会触发,不去重会让同一份文件传两遍、出两张卡片。
② 传完就把文件从
input.files摘掉(dropFromInput)。理由见下一节 —— 这一条我一开始判断错了,是被流水线打回来才改对的。
验证
端到端(真实浏览器,专走点选)
_tooling/_res_attach_pick.py,17 条断言全通过 0 失败。修复前 → 修复后:
cell_count=0cell_count=1hidden)hidden=Truehidden=False''PDF_res_pick_probe.pdf595 B×stage-cell点选后实际拿到的状态:
反向验证(证明断言有牙齿)
_tooling/_res_attach_pick_falsify.py:bindStagePick里调用queueDrop的那行→ 4 条精确变红(资源 3 条 + 观影 1 条),且拖放断言仍然绿
⇒ 注入只破坏了点选、没波及拖放,断言确实盯着这件事。
⇒ 如实记录:这条守卫是防御性的,e2e 抓不到
(e2e 造不出"框在、但清单宿主被挪走"的畸形结构)。
grep "注入:"= 0 处残留。补记:注入② 暴露的问题,以及我怎么补上的
第一版反向验证在注入② 那里写了这么一行:
这行有两个毛病,都是假绿:
note(True, ...)是恒真断言 —— 不管代码什么样它都打[OK],等于什么都没验,却让输出看起来很绿。
_fb_v95_static这个文件根本不存在 —— 全库搜只有这一句引用。也就是说那是一条空头承诺:说了"由静态断言覆盖",其实没有任何断言。
所以补了两件事:
_res_attach_pick_falsify.py里真的写了一条静态检查static_guard():读
app.js,切出queueDrop函数体(按\n function定位边界,已量化确认边界正好止于
bindStagePick,不会把后者算进来),要求三条返回值约束各自恰好出现 1 次(
!form/!list/return true;)。—— 这才是证明它不是摆设的唯一办法。恢复后再断言它转回绿。
逐条删除验证(三个守卫分别删、分别跑):
if (!list) { return false; }if (!form) { return false; }return true;顺便修了脚本末尾一行恒真输出:原来
'通过 %d 项' % 0永远打印"通过 0 项",会被读成"一条都没测"。改成真实的检查计数。
又一个:这个 falsify 的注入锚点,从来没被体检过一次
改完上面那两处,我顺手跑了一次锚点体检(
_fb_anchor_check.py),结果:意思是:流水线里那道"30 秒抓锚点漂移"的前置体检,对这个脚本一直是失效的。
它那两条注入锚点(
queueDrop(box, input, picked);和if (!list) { return false; })从来没被检查过,将来
app.js一改就会悄悄漂掉,和 #409 记的坑一模一样。原因:体检靠"注入函数的第 1 个参数是不是像路径"来判断打的是哪个文件,
而这个脚本的签名是
inject(anchor, repl, label)—— 三个参数里没有路径。改成
inject(rel, anchor, repl, label)后体检就能认出来了。改完立刻暴露第二个问题:
因为体检的
rel是拼在PHP_ROOT(D:/Yongquan/code/FamilyBook)上的,写
FamilyBook/assets/js/app.js会被拼成FamilyBook/FamilyBook/assets/js/app.js。改成
assets/js/app.js后:这一段的教训:
[NOANCHOR]不是"没事",它是"这道关卡对你不生效"。而且它会掩盖后面那个错 —— 我要是看到 NOANCHOR 就跳过,永远发现不了路径拼错。
补:一个我自己引入的 bug,和它教给我的事
这条要单开一节写,因为它是这轮最贵的一课,而且形态很反直觉。
现象
第一版改完,我的探针 17/0 全绿,我准备提交了。然后流水线跑完,
EXIT=1:实测数字:2 张照片变成 4 行,编辑补 1 张变成 6 行 —— 全部翻倍。
根因
我为了让"点选"也能出缩略图,把它接进了
queueDrop()。这个函数会把文件传到服务器暂存区、拿回
stoken、塞进隐藏字段stage_token[]。但它没有把文件从
input.files里摘掉 —— 于是同一个文件同时存在于两条通路:stage_token[](隐藏字段)attach_save_files()里StagedUpload::claim(...)认领$_FILES['attach'](input 本体)attach_save_files()里接着Attachment::save(...)再存一遍两条路都往同一张 attachments 表插入,于是同一张图两行
(
position分别是 10 和 20,文件名一模一样)。我错在哪(这是重点)
我在代码注释里写过这么一段:
这段的后半句是完全错的,而且它是我猜的,没有实测。
更糟的是:它的前半句("清空会丢文件")也是猜的 —— 文件已经在暂存区、
拿着 stoken,
$_FILES只是第二条冗余通路,摘掉根本不会丢。一个没实测的猜测,被我写成了带"⚠️"的红线注释。 这种注释比没有更危险:
后来的人(包括几小时后的我自己)会把它当成已验证的结论,于是不去查。
修法
传完这一张就把它从
input.files摘掉,只留暂存令牌一条路径:为什么我的探针 17/0 全绿却没发现
因为那条探针只验"清单里出现了条目"。 重复插入时清单里仍然只有 1 条
—— 用户看到的就是一张卡片,跟正常情况一模一样。
所以这个 bug 在渲染层原理上不可见,必须查库。
于是补了一条查库探针
_tooling/_res_attach_db.py:修完后:
第 35 批那 3 条也一起转绿(13/3 → 16/0),
且它的诊断输出里
#photo 里的文件数 = 0(原来是 2)—— 正是"摘掉了"的证据,同时照片仍然正确落库 2 张,说明摘掉不影响保存。
反向验证:证明这条查库断言有牙齿
把这行摘掉的调用注入删除:
最后那条输出是特意加的断言 —— 它把这个教训固化进流水线:
以后谁再想"渲染探针绿了就行",流水线会当场告诉他"渲染绿、查库红"。
两条探针都进了流水线,而且刻意不合并:
合并的风险很实际:合并后一旦有人为了"跑得快"删掉查库那半,
这个 bug 就再也没人守了。
一句话总结
"界面上看得见" 和 "数据库里存对了" 是两件事,前端改动只要动到"文件从哪来",
就必须同时有一条查库断言。 渲染层能证明的那部分,只是最外面那一层。
一个差点漏掉的假绿
第一版探针把观影路由写成
movies/create(那是提交端点,不是新建页),结果
boxes=0被我的"没有上传区就跳过"逻辑当成正常情形 —— 跳过和通过输出一样。已改成声明式期望:观影新建 = 2 个框、旅程新建 = 0 个框,
数量不符即报红,URL 写错会被当场抓住。
(旅程新建页确实不该有上传区:照片是挂在每一段行程下面的,不是挂在旅程上。)
改动打破了一个既有反向验证的注入锚点(已修)
这里如实记一笔,因为它差点变成一次误判。
我把
bindInput里那个回调从一行折成两行(为了挂bindStagePick):而
_b30_falsify2.py的 ① 组注入是整行字面量替换,锚在这一整行上。于是锚点失效 →
patch()返回False→ 那组注入根本没打上。危害方向很反直觉:补丁没打上,探针跑的还是好代码 ⇒ 全绿。
如果不看
patch()的返回值,就会把"全绿"读成**「判据没牙齿」**,然后去改本来完全正确的判据 —— 修错东西。
流水线的 1d3 锚点体检在 30 秒内就抓住了它(
[BAD] _b30_falsify2.py 1/5 条锚点有问题),没有白跑 2 小时。修法是把锚点从整行缩到稳定片段:
单独重跑
_b30_falsify2.py确认:补丁已打上,且 ① 组两条断言精确变红(「选中文件后框里有反馈」「框有已选视觉态」)。
已完成
代码:
34625cd(已推送main),只改assets/js/app.js,+132 / -3。部署:全量流水线
EXIT=0(2h37m),已上线。线上
assets/js/app.jsmd5 =a74c652c40d96ce63565486638a6facb,与本地一致;bindStagePick/dropFromInput均已在线上文件中(各 3 处引用)。本次无需 Schema 迁移(指纹已是最新)。
本地验收(部署前)
失败 0线上需要你本人验一下(按之前约定,线上冒烟由你来):
重点看这两件事:
(文件名 + 大小 + ×),并且有一行「已上传,可以继续拖;点「保存」时才真正记到这条上。」
另:观影模块新建/编辑也走同一个上传框,点选后同样应当出卡片,
照片数量不应当在保存后翻倍(这条是本次修掉的第二个 bug)。
这一单顺带修掉了一个我自己在第一版引入的 bug(文件传完没从
input.files摘掉,导致服务端两条通路各存一次)。完整复盘写在上一节评论里,重点是那句
「界面上看得见 ≠ 数据库里存对了」——我的渲染探针 17/0 全绿的时候,
库里其实存的是双份。现在流水线里两条探针并存且刻意不合并。