笔记图片:调过宽度的图片保存后变孤儿(claimUploads 拿 key|50 去匹配 key) #18

Closed
opened 2026-10-28 07:58:27 +00:00 by laoyang · 2 comments
Owner

网页版,笔记功能,上传图片失败。

现象

上传图片时看着成功(图出现在块里、能预览),但保存后图片就没了。

复现(本地真实浏览器,已抓到数据库层)

_tooling/_b35_width_falsify.py,同一套判据只改一个变量(图片宽度档位):

用例 图片块 content attachments.target_id 结果
点 100% photos/42d90aa9….jpg 1707(= 笔记 id) 正常
点 50% photos/a2f16cc3….jpg|50 0(草稿态) 图片变孤儿

反证 7 通过 / 0 失败:唯一差异就是宽度后缀,结果却相反。

根因

两处对同一份数据的格式约定不一致:

  1. 前端(assets/js/note_editor.js 的 serialize())把宽度写进 content:

    content = (key !== '' && w > 20 && w < 100) ? (key + '|' + w) : key;
    

    所以调过宽度(25/50/75)的图片块,content 是 photos/xxx.jpg|50。

  2. 服务端 App\Note::cleanBlocks() 把 image 块的 content 原样保留(只去空白),
    接着 claimUploads() 直接拿它当 key 去匹配:

    // app/Note.php:472
    if ($b['kind'] === 'image' && $b['content'] !== '') {
        $keys[] = $b['content'];        // ← 这里带上了 |50
    }
    // 然后 WHERE `key` IN (?) 永远匹配不到 'photos/xxx.jpg'
    

于是草稿附件(target_id=0)永远认领不到真实笔记上。图片数据还在库里,
但挂在一个谁也看不到的 target_id 上,页面渲染时按块内容去取图 → 取不到 → 用户看到"图没了"。

同一处还有第二个受害点:Note::files() 用 $used[(string)$b['content']] 排除"已用作图片块的附件",
带后缀时也排除不掉 → 同一张图会既当图片块、又在底部附件里重复出现。

影响面

只要用户调过图片大小(25/50/75%)再保存,图片就丢。
默认 100% 不受影响 —— 这也是为什么"有时候好、有时候坏",容易误判成"上传失败"。

验收标准

  1. 100% 与 50% 两个用例的 attachments.target_id 都等于笔记 id(都认领成功)
  2. note_blocks 里图片块 content 保留宽度信息(宽度功能不能因此丢失)
  3. 渲染时按 key 取图正常、宽度按设定生效
  4. 底部附件区不出现与图片块重复的同名文件
  5. 必须是「服务端剥后缀」而不是「前端不写后缀」—— 后者会让 faç 宽度功能失效
网页版,笔记功能,上传图片失败。 ## 现象 上传图片时看着成功(图出现在块里、能预览),但**保存后图片就没了**。 ## 复现(本地真实浏览器,已抓到数据库层) `_tooling/_b35_width_falsify.py`,同一套判据只改一个变量(图片宽度档位): | 用例 | 图片块 content | `attachments.target_id` | 结果 | |---|---|---|---| | 点 **100%** | `photos/42d90aa9….jpg` | **1707**(= 笔记 id) | 正常 | | 点 **50%** | `photos/a2f16cc3….jpg\|50` | **0**(草稿态) | **图片变孤儿** | 反证 **7 通过 / 0 失败**:唯一差异就是宽度后缀,结果却相反。 ## 根因 两处对同一份数据的格式约定不一致: 1. **前端**(`assets/js/note_editor.js` 的 `serialize()`)把宽度写进 content: ```js content = (key !== '' && w > 20 && w < 100) ? (key + '|' + w) : key; ``` 所以调过宽度(25/50/75)的图片块,content 是 `photos/xxx.jpg|50`。 2. **服务端** `App\Note::cleanBlocks()` 把 image 块的 content 原样保留(只去空白), 接着 `claimUploads()` 直接拿它当 key 去匹配: ```php // app/Note.php:472 if ($b['kind'] === 'image' && $b['content'] !== '') { $keys[] = $b['content']; // ← 这里带上了 |50 } // 然后 WHERE `key` IN (?) 永远匹配不到 'photos/xxx.jpg' ``` 于是草稿附件(`target_id=0`)永远认领不到真实笔记上。图片数据还在库里, 但挂在一个谁也看不到的 target_id 上,页面渲染时按块内容去取图 → 取不到 → **用户看到"图没了"**。 同一处还有第二个受害点:`Note::files()` 用 `$used[(string)$b['content']]` 排除"已用作图片块的附件", 带后缀时也排除不掉 → 同一张图会**既当图片块、又在底部附件里重复出现**。 ## 影响面 只要用户调过图片大小(25/50/75%)再保存,图片就丢。 默认 100% 不受影响 —— 这也是为什么"有时候好、有时候坏",容易误判成"上传失败"。 ## 验收标准 1. 100% 与 50% 两个用例的 `attachments.target_id` 都等于笔记 id(都认领成功) 2. `note_blocks` 里图片块 content 保留宽度信息(宽度功能不能因此丢失) 3. 渲染时按 key 取图正常、宽度按设定生效 4. 底部附件区不出现与图片块重复的同名文件 5. 必须是「服务端剥后缀」而不是「前端不写后缀」—— 后者会让 faç 宽度功能失效
Author
Owner

已修复并部署(commit fc81347)

改了什么

新增 App\Note::imageKey(),统一把图片块 content 剥成纯附件 key:

public static function imageKey(string $content): string
{
    $c = trim($content);
    if ($c === '') {
        return '';
    }
    $p = explode('|', $c, 2);
    return trim($p[0]);
}

两个受害点都改成先过它:

位置 改前 改后
claimUploads() $keys[] = $b['content']; $k = self::imageKey(...),非空才入 $keys
files() $used[(string)$b['content']] = true; $k = self::imageKey(...),非空才入 $used

updateBlock() 走同一条 claimUploads(),自动被覆盖,无需单独改。

修在服务端的原因(对应验收第 5 条):如果反过来让前端不写后缀,宽度功能就废了
——渲染时要按 content 里的 |50 出图。顺带说明这是不对称实现:
templates/notes/form.php:70、templates/notes/index.php:98、assets/js/note_editor.js:152
这三处本来就是剥了后缀的,只有服务端 Note.php 两处漏了。

验证(本地 PHP 8.5 + MariaDB,走真实 UI,非手搓 DOM)

主判据 _tooling/_b35_width_falsify.py —— 7 通过 / 0 失败

[P100] content=photos/f341f348e7553ae37e571a10.jpg      target_id=1713 (期望 1713)
[P50]  content=photos/b17327fce37f49031a41f972.jpg|50   target_id=1714 (期望 1714)
  [OK] B. 50%  → content 带 |50(宽度功能没被牺牲)
  [OK] B. 50%  → 附件也被认领到笔记(#18 修复点)
  [OK] 两条路径结果一致:宽度后缀不再影响认领

修复前同一套脚本:100% → 1707 成功,50% → target_id=0 失败。

附件不重复 _tooling/_b35_dup_attach.py —— 5 通过 / 0 失败

note id = 1719
图片块 content = photos/605d212d035ba24d3a76f0de.jpg|50
  [OK] ① 图片块 content 带上了 |50(宽度功能在)
  [OK] ② 阅读态图片块渲染出来了
  [OK] ③ 底部附件区没有重复的同名文件(应为 0 条)
  [OK] ④ 图片字节能取到(200)
几何:img 宽 = {'img': 61, 'fig': 990}
  [OK] ⑤ 50% 宽度真的生效(img 明显窄于容器)

判据有牙齿:把 claimUploads() 注入回旧形态($keys[] = $b['content'];)后立刻变红:

[P50] content=photos/c73682321ec8cbafed319b1a.jpg|50  target_id=0 (期望 1716)
  [FAIL] B. 50% → 附件也被认领到笔记(#18 修复点)  target_id=0 期望=1716
  [FAIL] 两条路径结果一致  A=1715 B=0

恢复后回到 7 通过 / 0 失败。

部署

  • git push → ff818a3..fc81347
  • 线上 family.yangyq.net 已更新,md5sum 与本地一致:9a00d152cc8a7dffc3ea87092ed6374f
  • 属主保持 family、mtime 为部署时刻(未被 1980 时间戳坑 OPcache)、php -l 通过
  • 线上 CLI 实跑 imageKey() 六种形态全部正确:
'photos/a.jpg'          => [photos/a.jpg]
'photos/b.jpg|50'       => [photos/b.jpg]
'  photos/c.jpg|33  '   => [photos/c.jpg]
''                      => []
'|50'                   => []
'photos/d.jpg|100'      => [photos/d.jpg]

线上 grep -c imageKey = 3(1 处定义 + 2 处调用点),与本地一致。

顺带完成

  • 本地 fb_test 测试数据已清理:笔记 6 → 2(只留 10-27 的 v52/v55),
    附件 25 → 4,photos 目录 4303 → 4282。21 个测试图片移到
    _tooling/_fb_trash/20261028-note18/photos/(没直接删),库表备份在同目录 fb_test_before_cleanup.sql。

按之前的分工,线上登录态的冒烟测试留给您来做。

## 已修复并部署(commit `fc81347`) ### 改了什么 新增 `App\Note::imageKey()`,统一把图片块 content 剥成**纯附件 key**: ```php public static function imageKey(string $content): string { $c = trim($content); if ($c === '') { return ''; } $p = explode('|', $c, 2); return trim($p[0]); } ``` 两个受害点都改成先过它: | 位置 | 改前 | 改后 | |---|---|---| | `claimUploads()` | `$keys[] = $b['content'];` | `$k = self::imageKey(...)`,非空才入 `$keys` | | `files()` | `$used[(string)$b['content']] = true;` | `$k = self::imageKey(...)`,非空才入 `$used` | `updateBlock()` 走同一条 `claimUploads()`,自动被覆盖,无需单独改。 **修在服务端的原因**(对应验收第 5 条):如果反过来让前端不写后缀,宽度功能就废了 ——渲染时要按 content 里的 `|50` 出图。顺带说明这是**不对称实现**: `templates/notes/form.php:70`、`templates/notes/index.php:98`、`assets/js/note_editor.js:152` 这三处本来就是剥了后缀的,只有服务端 `Note.php` 两处漏了。 ### 验证(本地 PHP 8.5 + MariaDB,走真实 UI,非手搓 DOM) **主判据 `_tooling/_b35_width_falsify.py` —— 7 通过 / 0 失败** ``` [P100] content=photos/f341f348e7553ae37e571a10.jpg target_id=1713 (期望 1713) [P50] content=photos/b17327fce37f49031a41f972.jpg|50 target_id=1714 (期望 1714) [OK] B. 50% → content 带 |50(宽度功能没被牺牲) [OK] B. 50% → 附件也被认领到笔记(#18 修复点) [OK] 两条路径结果一致:宽度后缀不再影响认领 ``` 修复前同一套脚本:100% → 1707 成功,50% → `target_id=0` 失败。 **附件不重复 `_tooling/_b35_dup_attach.py` —— 5 通过 / 0 失败** ``` note id = 1719 图片块 content = photos/605d212d035ba24d3a76f0de.jpg|50 [OK] ① 图片块 content 带上了 |50(宽度功能在) [OK] ② 阅读态图片块渲染出来了 [OK] ③ 底部附件区没有重复的同名文件(应为 0 条) [OK] ④ 图片字节能取到(200) 几何:img 宽 = {'img': 61, 'fig': 990} [OK] ⑤ 50% 宽度真的生效(img 明显窄于容器) ``` **判据有牙齿**:把 `claimUploads()` 注入回旧形态(`$keys[] = $b['content'];`)后立刻变红: ``` [P50] content=photos/c73682321ec8cbafed319b1a.jpg|50 target_id=0 (期望 1716) [FAIL] B. 50% → 附件也被认领到笔记(#18 修复点) target_id=0 期望=1716 [FAIL] 两条路径结果一致 A=1715 B=0 ``` 恢复后回到 7 通过 / 0 失败。 ### 部署 - `git push` → `ff818a3..fc81347` - 线上 `family.yangyq.net` 已更新,`md5sum` 与本地一致:`9a00d152cc8a7dffc3ea87092ed6374f` - 属主保持 `family`、mtime 为部署时刻(未被 1980 时间戳坑 OPcache)、`php -l` 通过 - 线上 CLI 实跑 `imageKey()` 六种形态全部正确: ``` 'photos/a.jpg' => [photos/a.jpg] 'photos/b.jpg|50' => [photos/b.jpg] ' photos/c.jpg|33 ' => [photos/c.jpg] '' => [] '|50' => [] 'photos/d.jpg|100' => [photos/d.jpg] ``` 线上 `grep -c imageKey` = 3(1 处定义 + 2 处调用点),与本地一致。 ### 顺带完成 - 本地 `fb_test` 测试数据已清理:笔记 6 → 2(只留 10-27 的 v52/v55), 附件 25 → 4,`photos` 目录 4303 → 4282。21 个测试图片**移到** `_tooling/_fb_trash/20261028-note18/photos/`(没直接删),库表备份在同目录 `fb_test_before_cleanup.sql`。 按之前的分工,线上登录态的冒烟测试留给您来做。
Author
Owner

修复已提交(fc81347)并部署到 family.yangyq.net,线上 md5 与本地一致,imageKey() 六种输入形态线上实跑全部正确。

三组验证:主判据 7/0、附件不重复 5/0、注入回旧形态后 2 条 FAIL(判据有牙齿)。本地测试数据已清理,21 个测试图片移到 _tooling/_fb_trash/20261028-note18/ 而非删除。

线上登录态冒烟按分工留给用户。关闭。

修复已提交(`fc81347`)并部署到 family.yangyq.net,线上 md5 与本地一致,`imageKey()` 六种输入形态线上实跑全部正确。 三组验证:主判据 7/0、附件不重复 5/0、注入回旧形态后 2 条 FAIL(判据有牙齿)。本地测试数据已清理,21 个测试图片移到 `_tooling/_fb_trash/20261028-note18/` 而非删除。 线上登录态冒烟按分工留给用户。关闭。
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: laoyang/FamilyBook#18