资源模块点选附件无任何界面反馈(暂存上传只接了拖放没接 change) #15

Closed
opened 2026-10-26 23:26:41 +00:00 by laoyang · 3 comments
Owner

现象

资源模块点「新建」→ 弹窗里点「上传附件」选文件 → 页面没有任何变化。

  • 文件其实已经上传成功(后端暂存目录里有文件,保存后附件也确实挂上了)
  • 但界面上完全没有提示:既没有缩略图、没有文件名、没有「上传中/已上传」字样,
    那个虚线框里永远只写着「点击或拖动进行上传」
  • ⇒ 用户无法确认到底传上没有,只能靠"保存后去看看"来猜

复现路径

  1. 资源模块 → 新建
  2. 弹窗里「附件」那一行,点虚线框(不是拖,是点)
  3. 文件选择框里选中一个文件 → 确定
  4. 观察:虚线框文字不变、下方没有任何清单、无任何状态提示
  5. 但点「保存」后,资源详情里的附件墙确实有这个文件

关键区别:同一个框,拖进来是有显示的(会出缩略图卡片),
点选则什么反应都没有 —— 这是两条不同的代码路径。

初步定位(待实测确认)

templates/_stage_drop.php 渲染的框带 data-drop-stage(「暂存上传」开关)。
assets/js/app.js 里两条链路的分工是:

交互 走的路径 有没有 UI 反馈
拖文件进框 bindZone 的 drop 事件 → queueDrop() ✅ 有卡片/缩略图
点击框选文件 box.click() → input.click() → 触发 input 的 change ❌ 无人监听

证据:

  • bindZone() 只绑了 dragenter / dragover / dragleave / drop,没有 change
  • 唯一的 change 监听在 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,比例不同)会有影响,属于同类隐患。

诉求

点「上传附件」选中文件后,界面必须立刻有反馈,与拖动一致:

  • 显示已选/已上传的文件(图片出缩略图,其他出类型图标 + 文件名)
  • 有「上传中」与「已上传」的区分
  • 上传失败要看得见(不能静默)
  • 选多个文件时都显示
  • 点选与拖动行为一致

验收标准

  • 资源模块新建弹窗,点虚线框选一个 PDF/图片 → 立刻出现该文件的条目(名称 + 状态)
  • 选多个文件 → 每个都出现
  • 上传成功的显示「已上传」类状态,失败的显示红字原因
  • 保存后附件确实挂上(回归,原本就没问题)
  • 拖动上传仍然正常(回归,不能为了修点选把拖动弄坏)
  • 其他用到同一片段的地方(观影海报/票根、旅程照片、任务附件、笔记附件)都不退化
  • 判据必须落在真实浏览器渲染层(该 bug 的 DOM 结构本身是存在的,只判元素存在会漏)
## 现象 资源模块点「新建」→ 弹窗里点「上传附件」选文件 → **页面没有任何变化**。 - 文件其实**已经上传成功**(后端暂存目录里有文件,保存后附件也确实挂上了) - 但界面上**完全没有提示**:既没有缩略图、没有文件名、没有「上传中/已上传」字样, 那个虚线框里永远只写着「点击或拖动进行上传」 - ⇒ 用户无法确认到底传上没有,只能靠"保存后去看看"来猜 ## 复现路径 1. 资源模块 → 新建 2. 弹窗里「附件」那一行,点虚线框(不是拖,是**点**) 3. 文件选择框里选中一个文件 → 确定 4. 观察:虚线框文字不变、下方没有任何清单、无任何状态提示 5. 但点「保存」后,资源详情里的附件墙**确实有**这个文件 **关键区别**:同一个框,**拖进来**是有显示的(会出缩略图卡片), **点选**则什么反应都没有 —— 这是两条不同的代码路径。 ## 初步定位(待实测确认) `templates/_stage_drop.php` 渲染的框带 `data-drop-stage`(「暂存上传」开关)。 `assets/js/app.js` 里两条链路的分工是: | 交互 | 走的路径 | 有没有 UI 反馈 | |---|---|---| | 拖文件进框 | `bindZone` 的 `drop` 事件 → `queueDrop()` | ✅ 有卡片/缩略图 | | **点击框选文件** | `box.click()` → `input.click()` → 触发 input 的 **`change`** | ❌ **无人监听** | 证据: - `bindZone()` 只绑了 `dragenter` / `dragover` / `dragleave` / `drop`,**没有 `change`** - 唯一的 `change` 监听在 `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,比例不同)会有影响,属于同类隐患。 ## 诉求 点「上传附件」选中文件后,界面**必须立刻有反馈**,与拖动一致: - 显示已选/已上传的文件(图片出缩略图,其他出类型图标 + 文件名) - 有「上传中」与「已上传」的区分 - 上传失败要看得见(不能静默) - 选多个文件时都显示 - 点选与拖动**行为一致** ## 验收标准 - [ ] 资源模块新建弹窗,**点**虚线框选一个 PDF/图片 → 立刻出现该文件的条目(名称 + 状态) - [ ] 选多个文件 → 每个都出现 - [ ] 上传成功的显示「已上传」类状态,失败的显示红字原因 - [ ] 保存后附件确实挂上(回归,原本就没问题) - [ ] **拖动**上传仍然正常(回归,不能为了修点选把拖动弄坏) - [ ] 其他用到同一片段的地方(观影海报/票根、旅程照片、任务附件、笔记附件)都不退化 - [ ] 判据必须落在**真实浏览器渲染层**(该 bug 的 DOM 结构本身是存在的,只判元素存在会漏)
Author
Owner

实测复现:确认是「暂存框只接了拖放、没接 change」

环境

本地夹具站 + 真实 Chromium(Playwright headed),登录后进资源模块,
点「新建」打开弹窗(#resAddBtn → #resAddCard)。

用真实浏览器复现,不是读代码猜

新写探针 _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 里的接线情况:

入口 代码路径 结果
点框(打开选择框) bindBox → input.click() ✅ 能打开
拖文件进来 bindZone 的 drop → queueDrop() ✅ 出卡片
点选文件 触发 input 的 change 事件 ❌ 没人监听

具体两处:

  1. bindZone() 只绑了 dragenter / dragover / dragleave / drop,没有 change。

  2. 唯一的 change 监听在 bindFileEcho(),它开头就是:

    // ⚠️ **只给非暂存框**挂(暂存型框由 queueDrop 画缩略图卡片,别去抢它的 UI)。
    if (box.hasAttribute('data-drop-stage')) { return; }
    

    暂存框被故意跳过 —— 理由是"暂存框有自己的清单 UI"。
    但那个清单 UI 只在 queueDrop() 里画,而 queueDrop() 只被 drop 调用。

⇒ 点选时文件确实进了 input.files,表单提交也真的把它存上了
(这就是你说的"保存也能正常保存"),但清单一次都没画过。

顺带查出的第二个 bug

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 恒为空串,fd.append('slot', ...) 从来没执行过。
资源附件默认就是 photo 所以看不出差别,但海报/票根(2:1、2:3 要按比例重裁)
会拿到错的槽位 —— 属于同一处代码的隐患,本次一并修掉。


修复方案与验证结果,见下一条评论。

## 实测复现:确认是「暂存框只接了拖放、没接 change」 ### 环境 本地夹具站 + 真实 Chromium(Playwright headed),登录后进资源模块, 点「新建」打开弹窗(`#resAddBtn` → `#resAddCard`)。 ### 用真实浏览器复现,不是读代码猜 新写探针 `_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` 里的接线情况: | 入口 | 代码路径 | 结果 | |---|---|---| | 点框(打开选择框) | `bindBox` → `input.click()` | ✅ 能打开 | | 拖文件进来 | `bindZone` 的 `drop` → `queueDrop()` | ✅ 出卡片 | | **点选文件** | 触发 input 的 `change` 事件 | ❌ **没人监听** | 具体两处: 1. `bindZone()` 只绑了 `dragenter` / `dragover` / `dragleave` / `drop`,**没有 `change`**。 2. 唯一的 `change` 监听在 `bindFileEcho()`,它开头就是: ```js // ⚠️ **只给非暂存框**挂(暂存型框由 queueDrop 画缩略图卡片,别去抢它的 UI)。 if (box.hasAttribute('data-drop-stage')) { return; } ``` 暂存框被**故意**跳过 —— 理由是"暂存框有自己的清单 UI"。 但那个清单 UI **只在 `queueDrop()` 里画**,而 `queueDrop()` 只被 `drop` 调用。 ⇒ 点选时文件确实进了 `input.files`,表单提交也真的把它存上了 (这就是你说的"保存也能正常保存"),但**清单一次都没画过**。 ### 顺带查出的第二个 bug `queueDrop()` 里取槽位用的是 `input.getAttribute('data-stage-slot')`: ```js var slot = input.getAttribute('data-stage-slot') || ''; ``` 但 `data-stage-slot` 实际渲染在**框**(`.upbox`)上,不在 `<input>` 上: ```html <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` **恒为空串**,`fd.append('slot', ...)` 从来没执行过。 资源附件默认就是 `photo` 所以看不出差别,但海报/票根(2:1、2:3 要按比例重裁) 会拿到错的槽位 —— 属于同一处代码的隐患,本次一并修掉。 --- 修复方案与验证结果,见下一条评论。
Author
Owner

已修复

改动(一个文件 assets/js/app.js,+132 / -3)

① 新增 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] 框挂上它。

为什么是"接进 queueDrop"而不是"给暂存框也挂 bindFileEcho":
bindFileEcho 只会把框里那句话换成文件名,而你要知道的是**"上传成功没有"——
状态、失败原因、能否撤掉。这些只有 queueDrop 的卡片能给。
所以让点选和拖放走
同一条**链路(行为一致),而不是各写一套 UI。

② 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 的形态,只是换了个触发原因。

⑤ 新增 dropFromInput(),传完就把文件从 input.files 摘掉。
这一条是第一版漏掉的,被流水线打回来才补上 —— 没有它,同一个文件会被
服务端两条路径各存一遍,库里出现两行。完整复盘见下面「补:一个我自己引入的 bug」。

两个刻意的取舍

① 加了去重指纹。 change 在"同一个文件清空再选"时也会触发,
不去重会让同一份文件传两遍、出两张卡片。

② 传完就把文件从 input.files 摘掉(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
拖放路径仍正常(回归) ✅ ✅
观影新建(2 个上传框)点选出卡片 — ✅
旅程新建上传区数量符合预期(0 个) — ✅

点选后实际拿到的状态:

{'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:

  • 注入①:删掉 bindStagePick 里调用 queueDrop 的那行
    → 4 条精确变红(资源 3 条 + 观影 1 条),且拖放断言仍然绿
    ⇒ 注入只破坏了点选、没波及拖放,断言确实盯着这件事。
  • 注入②:删掉"没清单就别跑"守卫 → 探针仍全绿
    ⇒ 如实记录:这条守卫是防御性的,e2e 抓不到
    (e2e 造不出"框在、但清单宿主被挪走"的畸形结构)。
  • 恢复后工作区干净,grep "注入:" = 0 处残留。

补记:注入② 暴露的问题,以及我怎么补上的

第一版反向验证在注入② 那里写了这么一行:

note(True, '⇒ 该守卫由静态断言覆盖(见 _fb_v95_static / 人工复核)')

这行有两个毛病,都是假绿:

  1. note(True, ...) 是恒真断言 —— 不管代码什么样它都打 [OK],
    等于什么都没验,却让输出看起来很绿。
  2. _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; } ❌ 红:无清单守卫(期望 1 处,实际 0 处)
if (!form) { return false; } ❌ 红:无表单守卫(期望 1 处,实际 0 处)
末尾 return true; ❌ 红:末尾成功返回(期望 1 处,实际 0 处)
都不删(正常代码) ✅ 绿:3 条齐全且各出现 1 次

顺便修了脚本末尾一行恒真输出:原来 '通过 %d 项' % 0 永远打印"通过 0 项",
会被读成"一条都没测"。改成真实的检查计数。

又一个:这个 falsify 的注入锚点,从来没被体检过一次

改完上面那两处,我顺手跑了一次锚点体检(_fb_anchor_check.py),结果:

[NOANCHOR] _res_attach_pick_falsify.py  ← 一条锚点都没抽出来,本脚本等于没查

意思是:流水线里那道"30 秒抓锚点漂移"的前置体检,对这个脚本一直是失效的。
它那两条注入锚点(queueDrop(box, input, picked); 和 if (!list) { return false; })
从来没被检查过,将来 app.js 一改就会悄悄漂掉,和 #409 记的坑一模一样。

原因:体检靠"注入函数的第 1 个参数是不是像路径"来判断打的是哪个文件,
而这个脚本的签名是 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 后:

全部 1 个脚本的锚点都命中真实源码,且各自唯一(共 2 条)。

这一段的教训:[NOANCHOR] 不是"没事",它是"这道关卡对你不生效"。
而且它会掩盖后面那个错 —— 我要是看到 NOANCHOR 就跳过,永远发现不了路径拼错。

补:一个我自己引入的 bug,和它教给我的事

这条要单开一节写,因为它是这轮最贵的一课,而且形态很反直觉。

现象

第一版改完,我的探针 17/0 全绿,我准备提交了。然后流水线跑完,EXIT=1:

失败清单:
  - ④ 🔴 新建时上传两张 → 库里真有 2 张照片("只存一张"的回归守卫)
  - ⑤ 🔴 编辑补传后 = 3 张(旧的 2 张没被冲掉 + 新的 1 张)
  - ⑥ 详情页停留不会改动照片数(仍 3 张)

实测数字: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,文件名一模一样)。

我错在哪(这是重点)

我在代码注释里写过这么一段:

直接调 queueDrop 不能清空 input.files:……清空会让某些路径下
"点选的文件反而没存上"。而两边都认领也不会重复挂 —— 两次都按同一张
附件表插入、position 顺排,与"拖一次 + 再选一次"等价。

这段的后半句是完全错的,而且它是我猜的,没有实测。
更糟的是:它的前半句("清空会丢文件")也是猜的 —— 文件已经在暂存区、
拿着 stoken,$_FILES 只是第二条冗余通路,摘掉根本不会丢。

一个没实测的猜测,被我写成了带"⚠️"的红线注释。 这种注释比没有更危险:
后来的人(包括几小时后的我自己)会把它当成已验证的结论,于是不去查。

修法

传完这一张就把它从 input.files 摘掉,只留暂存令牌一条路径:

// 令牌进隐藏字段之后
dropFromInput(input, f);
function dropFromInput(input, target) {
  // input.files 是只读 FileList ⇒ 用 DataTransfer 重装剩下的,再整体赋回
  // 只摘**一张**:多选时后面的还排在上传链里,整个清空会让它们连令牌都带不上
}

为什么我的探针 17/0 全绿却没发现

因为那条探针只验"清单里出现了条目"。 重复插入时清单里仍然只有 1 条
—— 用户看到的就是一张卡片,跟正常情况一模一样。
所以这个 bug 在渲染层原理上不可见,必须查库。

于是补了一条查库探针 _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 张,说明摘掉不影响保存。

反向验证:证明这条查库断言有牙齿

把这行摘掉的调用注入删除:

【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 写错会被当场抓住。

(旅程新建页确实不该有上传区:照片是挂在每一段行程下面的,不是挂在旅程上。)

改动打破了一个既有反向验证的注入锚点(已修)

这里如实记一笔,因为它差点变成一次误判。

我把 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 → 那组注入根本没打上。

危害方向很反直觉:补丁没打上,探针跑的还是好代码 ⇒ 全绿。
如果不看 patch() 的返回值,就会把"全绿"读成**「判据没牙齿」**,
然后去改本来完全正确的判据 —— 修错东西。

流水线的 1d3 锚点体检在 30 秒内就抓住了它([BAD] _b30_falsify2.py 1/5 条锚点有问题),
没有白跑 2 小时。修法是把锚点从整行缩到稳定片段:

# 改后(去掉行首 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 确认:补丁已打上,且 ① 组两条断言精确变红
(「选中文件后框里有反馈」「框有已选视觉态」)。

## 已修复 ### 改动(一个文件 `assets/js/app.js`,`+132 / -3`) **① 新增 `bindStagePick()` —— 把"点选"接进 `queueDrop`(主修复)** ```js 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]` 框挂上它。 **为什么是"接进 queueDrop"而不是"给暂存框也挂 bindFileEcho"**: `bindFileEcho` 只会把框里那句话换成文件名,而你要知道的是**"上传成功没有"**—— 状态、失败原因、能否撤掉。这些只有 `queueDrop` 的卡片能给。 所以让点选和拖放走**同一条**链路(行为一致),而不是各写一套 UI。 **② `slot` 读错元素(顺带修的第二个 bug)** ```js // 改前:input 上没有这个属性 ⇒ 恒为空串 var slot = input.getAttribute('data-stage-slot') || ''; // 改后:先读框(属性实际在框上),再兜底读 input var slot = box.getAttribute('data-stage-slot') || input.getAttribute('data-stage-slot') || ''; ``` **③④ `queueDrop` 增加"没清单就别跑"的守卫**,并返回布尔说明是否接管了。 防止将来某处结构变了(`.upfield` 被删/挪)出现"传上去了、令牌也塞了、但用户看不见" ——那正是本次这个 bug 的形态,只是换了个触发原因。 **⑤ 新增 `dropFromInput()`,传完就把文件从 `input.files` 摘掉。** 这一条是第一版**漏掉的**,被流水线打回来才补上 —— 没有它,同一个文件会被 服务端两条路径各存一遍,库里出现两行。完整复盘见下面「补:一个我自己引入的 bug」。 ### 两个刻意的取舍 **① 加了去重指纹。** `change` 在"同一个文件清空再选"时也会触发, 不去重会让同一份文件传两遍、出两张卡片。 **② 传完就把文件从 `input.files` 摘掉**(`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` | | 拖放路径仍正常(回归) | ✅ | ✅ | | 观影新建(2 个上传框)点选出卡片 | — | ✅ | | 旅程新建上传区数量符合预期(0 个) | — | ✅ | 点选后实际拿到的状态: ``` {'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`: - **注入①**:删掉 `bindStagePick` 里调用 `queueDrop` 的那行 → **4 条精确变红**(资源 3 条 + 观影 1 条),且**拖放断言仍然绿** ⇒ 注入只破坏了点选、没波及拖放,断言确实盯着这件事。 - **注入②**:删掉"没清单就别跑"守卫 → 探针仍全绿 ⇒ 如实记录:这条守卫是**防御性**的,e2e **抓不到** (e2e 造不出"框在、但清单宿主被挪走"的畸形结构)。 - 恢复后工作区干净,`grep "注入:"` = 0 处残留。 ### 补记:注入② 暴露的问题,以及我怎么补上的 第一版反向验证在注入② 那里写了这么一行: ```python note(True, '⇒ 该守卫由静态断言覆盖(见 _fb_v95_static / 人工复核)') ``` 这行有两个毛病,都是**假绿**: 1. **`note(True, ...)` 是恒真断言** —— 不管代码什么样它都打 `[OK]`, 等于什么都没验,却让输出看起来很绿。 2. **`_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; }` | ❌ 红:无清单守卫(期望 1 处,实际 0 处) | | `if (!form) { return false; }` | ❌ 红:无表单守卫(期望 1 处,实际 0 处) | | 末尾 `return true;` | ❌ 红:末尾成功返回(期望 1 处,实际 0 处) | | 都不删(正常代码) | ✅ 绿:3 条齐全且各出现 1 次 | 顺便修了脚本末尾一行恒真输出:原来 `'通过 %d 项' % 0` **永远打印"通过 0 项"**, 会被读成"一条都没测"。改成真实的检查计数。 ### 又一个:这个 falsify 的注入锚点,从来没被体检过一次 改完上面那两处,我顺手跑了一次锚点体检(`_fb_anchor_check.py`),结果: ``` [NOANCHOR] _res_attach_pick_falsify.py ← 一条锚点都没抽出来,本脚本等于没查 ``` 意思是:**流水线里那道"30 秒抓锚点漂移"的前置体检,对这个脚本一直是失效的**。 它那两条注入锚点(`queueDrop(box, input, picked);` 和 `if (!list) { return false; }`) **从来没被检查过**,将来 `app.js` 一改就会悄悄漂掉,和 #409 记的坑一模一样。 原因:体检靠"注入函数的第 1 个参数是不是像路径"来判断打的是哪个文件, 而这个脚本的签名是 `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` 后: ``` 全部 1 个脚本的锚点都命中真实源码,且各自唯一(共 2 条)。 ``` **这一段的教训**:`[NOANCHOR]` 不是"没事",它是"**这道关卡对你不生效**"。 而且它会**掩盖后面那个错** —— 我要是看到 NOANCHOR 就跳过,永远发现不了路径拼错。 ## 补:一个我自己引入的 bug,和它教给我的事 这条要单开一节写,因为它是**这轮最贵的一课**,而且形态很反直觉。 ### 现象 第一版改完,我的探针 17/0 全绿,我准备提交了。然后流水线跑完,`EXIT=1`: ``` 失败清单: - ④ 🔴 新建时上传两张 → 库里真有 2 张照片("只存一张"的回归守卫) - ⑤ 🔴 编辑补传后 = 3 张(旧的 2 张没被冲掉 + 新的 1 张) - ⑥ 详情页停留不会改动照片数(仍 3 张) ``` 实测数字:**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,文件名一模一样)。 ### 我错在哪(这是重点) 我在代码注释里写过这么一段: > 直接调 queueDrop **不能清空 input.files**:……清空会让某些路径下 > "点选的文件反而没存上"。而**两边都认领也不会重复挂** —— 两次都按同一张 > 附件表插入、position 顺排,与"拖一次 + 再选一次"等价。 这段的后半句是**完全错的**,而且它是**我猜的,没有实测**。 更糟的是:它的前半句("清空会丢文件")也是猜的 —— 文件已经在暂存区、 拿着 stoken,`$_FILES` 只是第二条冗余通路,摘掉根本不会丢。 **一个没实测的猜测,被我写成了带"⚠️"的红线注释。** 这种注释比没有更危险: 后来的人(包括几小时后的我自己)会把它当成已验证的结论,于是不去查。 ### 修法 传完这一张就把它从 `input.files` 摘掉,只留暂存令牌一条路径: ```js // 令牌进隐藏字段之后 dropFromInput(input, f); ``` ```js function dropFromInput(input, target) { // input.files 是只读 FileList ⇒ 用 DataTransfer 重装剩下的,再整体赋回 // 只摘**一张**:多选时后面的还排在上传链里,整个清空会让它们连令牌都带不上 } ``` ### 为什么我的探针 17/0 全绿却没发现 **因为那条探针只验"清单里出现了条目"。** 重复插入时清单里**仍然只有 1 条** —— 用户看到的就是一张卡片,跟正常情况一模一样。 所以这个 bug 在渲染层**原理上不可见**,必须查库。 于是补了一条查库探针 `_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 张,说明摘掉不影响保存。 ### 反向验证:证明这条查库断言有牙齿 把这行摘掉的调用注入删除: ``` 【3】注入③:删掉 queueDrop 里 `dropFromInput(input, f);` 查库探针退出码 = 1 查库失败项:['[FAIL] 🔴 点选 1 个文件 → 库里正好 1 行附件 实际 2 行(>1 = 重复插入,=0 = 没存上)'] [OK] 注入③后**查库探针变红**了 [OK] 红在**"库里正好 1 行"**那条上(不是别的偶然红) [OK] 注意:同样注入下**渲染探针仍然全绿**("看得见"≠"存对了") ``` 最后那条输出是特意加的断言 —— 它把这个教训**固化进流水线**: 以后谁再想"渲染探针绿了就行",流水线会当场告诉他"渲染绿、查库红"。 两条探针都进了流水线,而且**刻意不合并**: ```bash 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 写错会被当场抓住。 (旅程新建页确实不该有上传区:照片是挂在**每一段行程**下面的,不是挂在旅程上。) ### 改动打破了一个既有反向验证的注入锚点(已修) 这里如实记一笔,因为它差点变成一次误判。 我把 `bindInput` 里那个回调从一行折成两行(为了挂 `bindStagePick`): ```js // 改前 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` → **那组注入根本没打上**。 **危害方向很反直觉**:补丁没打上,探针跑的还是好代码 ⇒ **全绿**。 如果不看 `patch()` 的返回值,就会把"全绿"读成**「判据没牙齿」**, 然后去改本来完全正确的判据 —— 修错东西。 流水线的 **1d3 锚点体检**在 30 秒内就抓住了它(`[BAD] _b30_falsify2.py 1/5 条锚点有问题`), 没有白跑 2 小时。修法是把锚点从整行缩到**稳定片段**: ```python # 改后(去掉行首 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` 确认:**补丁已打上**,且 ① 组两条断言精确变红 (「选中文件后框里有反馈」「框有已选视觉态」)。
Author
Owner

已完成

代码:34625cd(已推送 main),只改 assets/js/app.js,+132 / -3。

部署:全量流水线 EXIT=0(2h37m),已上线。
线上 assets/js/app.js md5 = a74c652c40d96ce63565486638a6facb,与本地一致;
bindStagePick / dropFromInput 均已在线上文件中(各 3 处引用)。
本次无需 Schema 迁移(指纹已是最新)。

本地验收(部署前)

  • 点选反馈 e2e 17/0(修复前 3 红)
  • 查库 e2e 8/0:点选 1 个文件 → attachments 正好 1 行(修复前 2 行)
  • 反向验证 10 条全过:三处注入各自精确变红,且确认不波及拖放
  • 观影第 35 批 13/3 → 16/0
  • 全量 69+ 个套件全部 失败 0

线上需要你本人验一下(按之前约定,线上冒烟由你来):

bash D:/Yongquan/code/_tooling/familybook_smoke.sh

重点看这两件事:

  1. 资源模块 → 新建 → 点「上传附件」选一个文件 → 虚线框下方应立刻出现一张卡片
    (文件名 + 大小 + ×),并且有一行「已上传,可以继续拖;点「保存」时才真正记到这条上。」
  2. 保存后进详情页,附件墙里只有一条(不是两条重复的)。

另:观影模块新建/编辑也走同一个上传框,点选后同样应当出卡片,
照片数量不应当在保存后翻倍(这条是本次修掉的第二个 bug)。


这一单顺带修掉了一个我自己在第一版引入的 bug(文件传完没从 input.files 摘掉,
导致服务端两条通路各存一次)。完整复盘写在上一节评论里,重点是那句
「界面上看得见 ≠ 数据库里存对了」——我的渲染探针 17/0 全绿的时候,
库里其实存的是双份。现在流水线里两条探针并存且刻意不合并。

## 已完成 **代码**:`34625cd`(已推送 `main`),只改 `assets/js/app.js`,`+132 / -3`。 **部署**:全量流水线 `EXIT=0`(2h37m),已上线。 线上 `assets/js/app.js` md5 = `a74c652c40d96ce63565486638a6facb`,与本地一致; `bindStagePick` / `dropFromInput` 均已在线上文件中(各 3 处引用)。 本次无需 Schema 迁移(指纹已是最新)。 **本地验收(部署前)** - 点选反馈 e2e **17/0**(修复前 3 红) - 查库 e2e **8/0**:点选 1 个文件 → attachments **正好 1 行**(修复前 2 行) - 反向验证 **10 条全过**:三处注入各自精确变红,且确认不波及拖放 - 观影第 35 批 **13/3 → 16/0** - 全量 69+ 个套件全部 `失败 0` **线上需要你本人验一下**(按之前约定,线上冒烟由你来): ``` bash D:/Yongquan/code/_tooling/familybook_smoke.sh ``` 重点看这两件事: 1. 资源模块 → 新建 → **点**「上传附件」选一个文件 → 虚线框下方应**立刻出现一张卡片** (文件名 + 大小 + ×),并且有一行「已上传,可以继续拖;点「保存」时才真正记到这条上。」 2. 保存后进详情页,附件墙里**只有一条**(不是两条重复的)。 另:观影模块新建/编辑也走同一个上传框,点选后同样应当出卡片, 照片数量不应当在保存后翻倍(这条是本次修掉的第二个 bug)。 --- 这一单顺带修掉了一个**我自己在第一版引入**的 bug(文件传完没从 `input.files` 摘掉, 导致服务端两条通路各存一次)。完整复盘写在上一节评论里,重点是那句 「**界面上看得见 ≠ 数据库里存对了**」——我的渲染探针 17/0 全绿的时候, 库里其实存的是双份。现在流水线里两条探针**并存且刻意不合并**。
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: laoyang/FamilyBook#15