# 05f 第 2 關複查(第三輪):聽力練習模式——事件流水帳版(2026-10-04) **一句話:43 條中一樣 39 條、不一樣 4 條(第 15、31、37、38 條)、沒做到 0 條;第二輪 7 個問題裡,流水帳解決了 4 個、大致解決 2 個,第 15 條沒動;攻擊找到 1 個高(同一台裝置兩次開頁可能抽到同一組事件編號,後來那次的動作會被默默丟掉)和 4 個中。** > 複查者:獨立審查(沒參與製作,也沒做第一、二輪)。方法:逐行讀目前的 js/plan.js、js/store.js、js/ui.js、server.py、index.html、app.css、tests/。 > 實際執行:`node --test tests/plan.test.js` 28/28 通過;`python tests/server_test.py` 6/6 通過。沒有跑 e2e.js。 > 另外重新跑 `tools/extract_book.py`,產出的 book.js 和現在的 js/book.js 逐位元組相同(第 40 條)。 > 攻擊用的暫存腳本放在 `C:\Users\user\AppData\Local\Temp\claude\c--D--------\88ca62cf-98c4-42b3-987f-5328619d3ad4\scratchpad\audit3\`(s1~s10),直接載入 book.js+plan.js(store.js 用假的 localStorage 載入),重放手做的事件序列。 ## 先講結論:流水帳本身做對了什麼 - **單一裝置不會走樣。** 我用亂數產生 3000 組、每組 120 個動作(作答、點字、複習、完成、計時、清除…),比較「一邊做一邊套用」(Store.record 的做法)和「從頭重放」(Plan.replay)。3000 組全部一模一樣(s9_fuzz.js)。原因是 record 給的時間一定比手上所有事件都晚(store.js:205),而且「完成」之後立刻排的下一次,和重放時晚一點才排的,中間沒有會影響排程的事件。 - **同樣的事件,每台排出的「這一次」都一樣。** 排程只看事件內容,重放是固定的。 - **重複送出不會重複算。** server 以 id 去重(server.py:68),前端也以 id 去重(plan.js:206-210、store.js:34-38)。第二輪「缺口次數、已練分鐘被多算」已經不會發生。 - **問題出在三個地方:** ① 事件編號太短,會撞號;② 點字是「切換」,不是「設成有/沒有」;③「這一次」要複習哪些句子不是記下來的,而是每次重放時重新算。晚到的事件一插進過去,之後每一次的清單都可能變,別台在那幾次做的複習就對不上而被略過。 ## 對照表 | 條號 | 判定 | 證據(file:line) | 白話說明 | |---|---|---|---| | 1 | 一樣 | plan.js:20;ui.js:295-326;index.html:17 | 第一次打開 `methodSeen` 是 false,說明卡出現在最上方。 | | 2 | 一樣 | ui.js:28、ui.js:297、ui.js:477-478;plan.js:240 | 「我懂了」記一筆 pref 事件,設成 true;沒有任何事件會把它改回 false。「重看方法」只改畫面上的 `methodOpen`。 | | 3 | 一樣 | ui.js:298、ui.js:366、ui.js:478 | 收起後上方一直有「📖 重看方法」,面板裡也有。 | | 4 | 一樣 | ui.js:27、ui.js:527、ui.js:220-228 | 開機固定顯示這一次的三區,順序 ① ② ③。小瑕疵見攻擊 14(同一題可能同時出現在複習和新學)。 | | 5 | 一樣 | ui.js:411-419、ui.js:470 | 兩個畫面可切換,按鈕字會換。 | | 6 | 一樣 | plan.js:69-71、plan.js:224-239;ui.js:409、ui.js:460-461、ui.js:471 | 播放、作答、點字、複習、連續聽都會觸發「開始」,記下開始時間(事件的時間)。 | | 7 | 一樣 | store.js:48-53;plan.js:54-67;ui.js:530 | 重開時把事件重放一次,排出同一個「這一次」(已用 s9 驗證和邊做邊算一致),並自動捲到還沒做的地方。只有「已練」最後不到 2 分鐘還在記憶體、沒變成事件的部分,頁面被強制關掉時會少算(ui.js:501-510)。 | | 8 | 一樣 | plan.js:187-190;store.js:208;ui.js:421-429 | 按完成 → 次數加 1 → 立刻排好下一次。 | | 9 | 一樣 | plan.js:28、plan.js:38-51 | 排程只看「第幾次」。補充:事件的先後是用裝置的時鐘排的,時鐘不準會影響「這個動作算在哪一次」(見攻擊 8),但不是按日曆排程。 | | 10 | 一樣 | plan.js:10、plan.js:31-35、plan.js:46-51;ui.js:224-226 | 從 6-1 開始,6→10 組後接 1→5 組;做一半的組從還沒學的題接著做,顯示這組的 5 個新字。 | | 11 | 一樣 | plan.js:9、plan.js:50、plan.js:180-182;ui.js:253 | 一次最多 10 題(同一組),都學完標「✓ 已完成」。 | | 12 | 一樣 | plan.js:48、plan.js:182;ui.js:226 | 全部學完時顯示「第 1 冊的新題都學完了 🎉」,算作完成。 | | 13 | 一樣 | plan.js:38-44 | 只挑到期的,先到期的排前面,最多 10 句。 | | 14 | 一樣 | ui.js:221;plan.js:181 | 沒有到期句時顯示「這次沒有要複習的 ✓」,算完成。 | | 15 | 不一樣 | plan.js:80-85(第 82、83 行) | 跟第二輪一模一樣,沒有修。①已學會的句子再點字,不會排進複習(第 82 行直接返回),畫面卻寫「它會排進之後的複習」。②已在複習中、要隔 3 或 7 次才到期的句子,點了新的字,到期日不變(第 83 行)。已用 s5_c15.js 實測:6-1 學會後點字,`review` 仍是空的;6-2 排在第 5 次,第 3 次點字後仍是第 5 次,不是第 4 次。 | | 16 | 一樣 | plan.js:7、plan.js:157-165;s10 | 1 → 3 → 7,第 7 次那輪再按「都聽出來了」就算學會。 | | 17 | 一樣 | plan.js:161、plan.js:147-154 | 「還是有沒聽出來的」→ 下一次到期,可以再點字。 | | 18 | 一樣 | ui.js:131-136;app.css:85 | 上次點的字用淡色(`prev`)標出。 | | 19 | 一樣 | plan.js:168-175;ui.js:192-205、ui.js:242 | 連播這次學的句子,播放中只顯示「第 k / n 句」,播完記一筆 cont。 | | 20 | 一樣 | plan.js:172、plan.js:183 | 沒新學就播複習句;兩者都沒有算完成。 | | 21 | 一樣 | ui.js:256-258、ui.js:357-358、ui.js:371 | 三區完成出現「這次完成 ✓」;沒完成有小字「提早結束」。 | | 22 | 一樣 | ui.js:347-351、ui.js:261 | 面板顯示複習 x/y、新學 x/題數、連續聽、已練分鐘。 | | 23 | 一樣 | ui.js:503-509 | 只有頁面可見且視窗在使用中才累計,每 2 分鐘記一筆 tick。 | | 24 | 一樣 | ui.js:248-254 | 每區標題顯示「✓ 已完成」或 x / y,整區變色。 | | 25 | 一樣 | ui.js:101-103 | 句子和選項照原樣顯示。 | | 26 | 一樣 | ui.js:12-13、ui.js:99-100 | 播放鍵那行下面就是提示,文字照預告頁。 | | 27 | 一樣 | ui.js:314-321 對照 02-preview.md:75-83 | 7 列逐字相同。 | | 28 | 一樣 | ui.js:89、ui.js:440-444;plan.js:138-154 | 每個字可點,點了變色,再點取消。複習中「再點」只取消這次的點擊(gate 3 H1 的設計)。跨裝置時「點兩次會互相抵銷」的問題見攻擊 2。 | | 29 | 一樣 | ui.js:11-20、ui.js:220-228 | 說明句沿用第二輪已比對過的文字,沒改。 | | 30 | 一樣 | ui.js:375-381;plan.js:127-136 | 清單列出字、次數、可播放的句子。邊角情況下會漏掉還標著的字(攻擊 11,低)。 | | 31 | 不一樣 | store.js:28-29、store.js:206;server.py:68 | 正常路徑對:每個動作都記成事件、先存這台、0.4 秒後送 server。但事件編號=裝置編號+**每次開頁抽的 4 個字**+從 1 數起的流水號。同一台裝置開頁上千次後,很可能有兩次抽到同一組字,後來那次的動作和前一次編號相同:server 用 `INSERT OR IGNORE` 直接丟掉,重放時也被當成重複丟掉。已實測(s4_idclash.js):開頁 1000 次約 25% 會撞到、3000 次約 92%。詳見攻擊 1。 | | 32 | 一樣 | plan.js:192-200;store.js:116-122 | 第一次啟動、這台還沒有任何事件時,記一筆 legacy 事件:原封不動保留舊紀錄,並補上作答。 | | 33 | 一樣 | store.js:225-236;ui.js:489-497;plan.js:216-222 | 匯出下載 `{events, state}`;匯入先 confirm,再記一筆 reset 事件,進度換成檔案內容。 | | 34 | 一樣 | store.js:73、store.js:85-93、store.js:126-137 | 開頁先向 server 取全部事件,和這台的合在一起重放,接著上次的地方。 | | 35 | 一樣 | store.js:95-106、store.js:160-166;ui.js:519 | 開頁連不上或做到一半斷線,都顯示紅色「連不上 server」,事件先存這台,每 15 秒重試、連上自動補送。殘留一個低問題:沒有待送事件時,切走分頁或網路恢復會直接顯示「已同步」(攻擊 12)。 | | 36 | 一樣 | ui.js:264-292、ui.js:454-468 | 全部題目的作答、統計、篩選、清除、翻譯、詳解、拆解、單字表、字族、播放、語速都在。清除改成 clear 事件,只清作答/看過/星號,跟舊頁一樣。 | | 37 | 不一樣 | store.js:126-137、store.js:172-185、ui.js:523(D1 有照寫做);plan.js:138-143、plan.js:147-154、plan.js:230、plan.js:235(漏洞) | D1 說的「合在一起、提示『已載入另一台裝置的新進度』、保持捲動位置」都有做到(store.js:181、ui.js:523)。但 D1 承諾的「任何一邊都不會被蓋掉」沒有完全成立:①兩邊都點同一個字會互相抵銷,句子還會掉出複習(攻擊 2,已實測);②晚到的事件會讓過去某一次的複習清單變了,另一台在那一次按的複習結果和點的字被略過(攻擊 3,已實測);③撞號的事件被丟(攻擊 1)。 | | 38 | 不一樣 | store.js:42-47、store.js:107-112 | 本機模式現在可以用。但瀏覽器空間存滿時,saveLocal 改成「只留還沒送到 server 的事件」;本機模式根本沒有「還沒送」的清單,所以寫進去的是**空的**。已實測(s7_full.js,用較小的空間模擬):存滿那一刻起,存起來的事件是 0 筆;重新打開後學過的題目從 100 變 0。詳見攻擊 4。 | | 39 | 一樣 | app.css:142-155、app.css:177 起;ui.js:371 | 小於 1240px 時面板收成底部一條,點開才看到完整面板;觸控裝置放大點擊範圍。這輪沒有用手機實機重測。 | | 40 | 一樣 | tools/extract_book.py;js/book.js | 重新從 toeic-drill-01.html 抽一次,結果和 js/book.js 逐位元組相同。 | | 41 | 一樣 | DEPLOY.md:101-105 | 以後出題的規則寫在部署說明,這次沒新增題目。 | | 42 | 一樣 | server.py:28;guide.html | guide.html 在允許清單裡,任何裝置都能開。 | | 43 | 一樣 | ui.js:298、ui.js:324、ui.js:365;guide.html:72 | 三處入口;手冊裡有「← 回到練習」。 | **05b 的 D1~D3 是否照寫的做:** - D1:合併、提示、保持捲動位置都有做;但「任何一邊都不會被蓋掉」有漏洞,見第 37 條。 - D2:有做。答題前有播放才顯示「✓ 用新方法練過」,否則顯示灰字「已作答(這題沒先聽)」,一樣算學過(plan.js:225、plan.js:231;ui.js:137-140)。小寬鬆:先答題、後播放、再按「這題用新方法練完了」,也會拿到綠勾(plan.js:231 只看「有沒有播過」)。 - D3:有做。拆解的中文先收起,按「看中文意思」才出現(ui.js:66、ui.js:476)。 ## 第二輪問題的現況 | 第二輪問題 | 現況 | 為什麼 | |---|---|---| | 合約 15:已學會、或排在後面才到期的句子,點字後不會在下一次到期 | 沒解決 | 跟流水帳無關,plan.js:80-85 一字未改。s5_c15.js 重現。 | | 合約 37 情境 C:手機的舊暫存蓋掉電腦較新的複習排程 | 解決 | 現在不是「整份比較」,而是每個動作照時間排。手機較早的「還是有沒聽出來的」排在電腦較晚的「都聽出來了」前面,結果是電腦的(s10_r2CD.js:6-4 學會)。前提是兩台時鐘大致準。 | | 合約 37 情境 D:後按的「還是有沒聽出來的」被「學會」吃掉 | 解決 | 後按的 miss 排在後面,重放時會拿掉學會、排成下一次到期(s10_r2CD.js:`review = {stage:0, due:13}`、不再是學會)。 | | server 重建或還原成舊版(情境 A、A2、B) | 大致解決 | server 只追加不覆蓋。開頁一律拿全部事件比對,server 少的就補送(store.js:129-133),所以 A、A2 會自己補回。情境 B 也不會再「整份蓋掉」。殘留兩點:①頁面一直開著時,只有「server 的流水號比我記得的小」才會發現;別台先存了夠多筆、流水號追過去,就發現不了,要等重新整理(攻擊 9);②這台的快取若因空間滿被裁掉,就沒有東西可補(攻擊 5)。 | | 新(低):離線時短暫顯示「已同步」 | 沒解決(形式改變) | push 沒有東西要送時,一律設成 synced(store.js:146-149),不管連不連得上。見攻擊 12。 | | 新(低):重複合併讓缺口次數和已練分鐘變多 | 解決 | 事件以 id 去重,同一個動作只算一次(plan.js:206-210;測試 plan.test.js:255)。 | | 新(低):API 回 200 但不是 JSON 時卡在「載入中…」 | 解決 | request 遇到 200 但不是 JSON 時回 502(store.js:64),走「連不上 server」。 | | 第一輪其他風險:同一瀏覽器兩分頁+斷線(情境 E) | 大致解決 | 每個分頁的事件編號不同,暫存的是事件聯集,另一分頁寫入時用 storage 事件合併(store.js:195-200)。殘留一個很窄的時間差(攻擊 13,低)。 | ## 攻擊情境 ### 1. 同一台裝置兩次開頁抽到同一組編號,後來那次的動作被默默丟掉 —— 嚴重度:高(腳本實證) - **步驟:** 1. 事件編號是 `裝置編號.4個亂數字-流水號`。4 個字每次開頁重抽,流水號每次從 1 數起(store.js:28-29、store.js:206)。 2. 平常使用:每天在同一支手機開頁幾次(含 Safari 把分頁丟掉後自動重載)。一年下來上千次。 3. 某天第 k 次開頁抽到和以前某次一樣的 4 個字。這次的第 1、2、3…個動作,編號和那次的第 1、2、3…個一模一樣。 4. 送到 server:`INSERT OR IGNORE` 認為是重複,直接丟掉(server.py:68)。這台下次重整,重放時也把它當重複丟掉(plan.js:208)。 - **應該:** 每個動作都保存。 - **實際:** 撞號那次開頁的前 N 個動作(N=兩次開頁中動作較少的那次)全部消失:作答、學過、點字、複習結果。當下畫面看起來正常,重新整理後才不見,而且沒有任何提示。 - **證據:** s4_idclash.js:重放後 6-2 的作答與「學過」不見。實測 `Math.random().toString(36).slice(2,6)` 只有約 117 萬種(200 萬次抽樣),同一台開頁 300 次約 1.7%、1000 次約 25%、3000 次約 92% 至少撞一次。 ### 2. 兩邊點同一個字,互相抵銷,句子還掉出複習 —— 嚴重度:中(腳本實證) - **步驟:** 1. 電腦上,6-3 的 "was" 點成「沒聽出來」。 2. 手機還沒拉到這筆(30 秒內,或手機離線、用舊快取打開),畫面上 "was" 沒標,使用者也點了它。 3. 兩筆都是「mark」事件,意思是**切換**(plan.js:138-143),不是「設成沒聽出來」。 - **應該:** 兩邊都想標,結果是「有標」。 - **實際:** 重放時第一筆打開、第二筆關掉。s1_toggle.js 結果:`marks = undefined`、耳朵缺口清單變空、6-3 的複習也被刪掉(plan.js:84:沒有標記又是剛排的複習,就拿掉)。複習中的 remark 也是切換(plan.js:150-152),一樣會抵銷。 - **最常見的觸發:** 手機用舊快取離線打開,看到的標記比較少,重新點一次 → 連上後反而全部取消。 ### 3. 晚到的事件改寫了過去某一次的複習清單,另一台在那一次做的複習被略過 —— 嚴重度:中(腳本實證) - **原因:** 「這一次要複習哪些句子」沒有記成事件,而是重放到那裡時才算(plan.js:54-67、plan.js:213)。複習和複習中點字的事件,只有在**重放算出的清單裡**才會生效(plan.js:230、plan.js:235)。 - **情境 3a(不用到上限):** 1. 兩台都在第 2 次。6-2 在這次的複習清單裡。 2. 手機離線,6-2 按「這次都聽出來了」。 3. 電腦不知道,沒做 6-2 就按「提早結束」。第 3 次 6-2 還是到期,電腦在第 3 次按「還是有沒聽出來的」,又點了一個新字。 4. 手機連上,送出第 2 步的事件(時間比電腦的「完成」早)。 - **應該:** 電腦較晚的判斷(沒聽出來)和新點的字都留著。 - **實際(s2b_nocap.js):** 重放後第 3 次的清單沒有 6-2,電腦的 miss 和新點的字都被略過:6-2 變成「隔 3 次」、`marks` 只剩舊的 `[0]`。 - **情境 3b(清單滿 10 句時擠掉別句):** 1. 第 2 次學完 7-1~7-10,每句都點了字 → 第 3 次的複習清單是 7-1~7-10。 2. 另一台在第 2 次(還沒同步)在全部題目裡點了 6-5 的一個字 → 6-5 也排到第 3 次。 3. 電腦在第 3 次複習 7-10,按「都聽出來了」,再點一個字。 - **實際(s2_evict.js):** 合併後清單變成 6-5、7-1~7-9,7-10 被擠出去。電腦對 7-10 的「都聽出來了」和點的字都被略過,耳朵缺口那個字消失,7-10 下次又出現。 - **注意:** 不一定要離線。電腦每 30 秒才拉一次(store.js:17、store.js:191),兩台同時開著、在 30 秒內一邊點字一邊按完成,也會發生。 ### 4. 本機模式存滿瀏覽器空間 → 重開後進度全空 —— 嚴重度:中(腳本實證) - **步驟:** 1. 還沒架 server,用本機模式(合約 38)每天練。每按一次播放、每點一個字、每 2 分鐘計時,都是一筆約 100 字的事件。 2. 事件越積越多,直到超過瀏覽器給這個網址的空間(Chrome 約 5MB;file:// 開的頁面還跟其他本機檔案共用)。 3. saveLocal 寫入失敗 → 改成只存「還沒送到 server 的事件」(store.js:44-46)。本機模式沒有這份清單,所以存進去的是空陣列。 4. 關掉、再打開。 - **應該:** 進度還在,或至少提醒「空間快滿,請匯出」。 - **實際(s7_full.js):** 存滿那一刻起存起來的事件是 0 筆;重開後學過的題目 100 → 0,次數歸零。全程沒有任何提示。 - **大概多久會遇到:** 照一天 2 小時、幾百筆事件估,大約幾個月到一年(瀏覽器實際上限不確定)。本機模式本來只是過渡,所以列中;但一旦遇到就是全部不見。 ### 5. 流水帳只增不減:開頁越來越慢、存滿後離線開頁會用殘缺的進度 —— 嚴重度:中(推理) - **步驟與影響:** 1. 每次開頁都下載**全部**事件(store.js:76,`after=0`),每次拉到新事件都**從頭重放全部**(store.js:180、store.js:51)。一年約十萬筆、十幾 MB,手機開一次就要下載一次。 2. 開頁時連不上、又沒有待送事件時,每 15 秒做一次「下載全部」(store.js:104),直到頁面關掉都不停(只有補送成功才清掉這個計時器,store.js:157)。 3. server 模式下存滿時,快取被裁成「只留還沒送的」。之後如果**離線打開**,畫面是用那幾筆重放出來的:幾乎空白、從第 1 次、6-1 重新排。橫幅還寫「先用這台裝置上次的進度繼續」(store.js:100-101),其實不是。在這個殘缺畫面上點字(切換)、按完成,連上後會套到真正的進度上,變成取消標記(攻擊 2)、多算一次(攻擊 7)。 - **備註:** `tq-app-seq-v4` 每次都寫(store.js:135),但從來沒有讀,開頁還是從 0 開始拿。 ### 6. 開頁時排的「這一次」,兩台會不會不一樣? —— 嚴重度:無(腳本實證) - **檢查:** 重放時「這一次」是在第一個需要它的事件出現時才排(plan.js:213),開頁時 rebuild 又在最後補排一次(store.js:52);Store.record 則在每個動作後立刻補排(store.js:208)。 - **結果:** 兩台手上的事件相同,排出來就相同;同一台「邊做邊算」和「從頭重放」也相同(s9_fuzz.js,3000 組 0 差異)。因為「完成」和下一個需要排程的事件之間,只可能有看翻譯、星號、語速、清除這類不影響排程的事件。 - **會不一樣的唯一情況:** 有一台還沒收到別台較早的事件。收到後清單會重算,這就是攻擊 3。 ### 7. 用舊快取的裝置按「這次完成」→ 多算一次學習 —— 嚴重度:低(腳本實證) - **步驟:** 電腦按完成(第 1 次 → 第 2 次)。手機沒拉到這筆(離線或 30 秒內),手機上也按「這次完成」,以為在結束第 1 次。 - **實際(s3_phantom.js):** 合併後變第 3 次,電腦正在做的第 2 次被手機的「完成」切斷,之後的動作都算到第 3 次。複習間隔少一次;電腦畫面會突然跳到下一次。資料沒有不見。 - **根源:** finish 事件沒有帶「我要結束的是第幾次」(ui.js:426;plan.js:237)。 ### 8. 時鐘不準的裝置 —— 嚴重度:低(推理,機制同攻擊 3) - **時鐘慢:** 離線時做的事,時間落在過去,連上後會插到別台已經完成的那幾次中間,觸發攻擊 3、7。 - **時鐘快很多(例如手機時間被設成明年):** 只要有一筆「明年」的事件,其他裝置拉到後都會在它後面接著記(store.js:205,lastT+1)。可是任何一台在**還沒拉到它之前**離線做的事,時間都是今年,會被排到所有「明年之後」的動作前面。往後很長一段時間,每次離線練習都會被插回過去,一再觸發攻擊 3。 ### 9. server 被還原成舊版,而別台已經存到比較大的流水號 —— 嚴重度:低(推理) - **步驟:** 1. 電腦記得流水號 80。server 被還原成 50。 2. 手機先打開(會把自己有的全補上),又學了 40 個動作,server 到 90 以上。 3. 電腦那頁一直開著,每 30 秒拉 `after=80`:server 的 90 不比 80 小,所以不會被當成「倒退」(store.js:158、store.js:177)。 - **實際:** 電腦拿不到手機在 51~80 號的事件;電腦自己在還原時被弄丟的那些也不會補送。要等電腦重新整理(開頁一律全量比對)才會好。不會永久遺失,除非這台的快取已被裁掉(攻擊 5)。 ### 10. server 讀事件和讀最大流水號是兩個查詢 —— 嚴重度:低(推理) - server.py:61-62 先查事件、再查 `MAX(seq)`,中間沒包在同一個交易裡。剛好有另一台在這兩步之間存進一筆,回傳的 `lastSeq` 會比事件多一號。前端記下這個 lastSeq(store.js:134),那一筆以後增量拉不到,要等重新整理。機率很低。 ### 11. 耳朵缺口清單漏掉還標著的字 —— 嚴重度:低(腳本實證) - **步驟:** 6-3 在這次複習清單裡,上次點過 "was"。① 在全部題目裡把 "was" 點掉(次數 1→0,從清單刪除);② 回到複習卡(快照還顯示淡色)點 "was"(又沒聽到,0→1);③ 改主意再點一次取消這次(1→0,清單刪除,但因為上次就標過,標記保留)。 - **實際(s6_gaps.js):** `marks = [2]`("was" 還標著),缺口清單裡沒有它。 ### 12. 離線時仍可能顯示「已同步」 —— 嚴重度:低(推理) - 開頁就連不上、而且沒有待送事件時,切走分頁(ui.js:510 觸發 flush)或系統發出 `online`(store.js:193),push 看到沒東西要送就設成「已同步」(store.js:146-149),紅色橫幅被清掉(ui.js:520)。之後一做動作就會變回「連不上」,資料不會掉。 ### 13. 同一瀏覽器兩分頁的極窄時間差 —— 嚴重度:低(推理) - 兩分頁各自把「自己手上的事件」整份寫進同一個 key(store.js:43)。分頁 2 讀到分頁 1 剛加的「待送編號」,卻還沒收到分頁 1 的事件(storage 事件還沒到),如果分頁 2 這時剛好沒有自己的待送事件,就會把那個編號當成「別的分頁送掉了」刪除(store.js:147)。分頁 1 之後 push 時那筆已不在待送清單,不會送。重新整理時開頁全量比對會補回。發生的時間窗只有幾毫秒。 ### 14. 同一題同時出現在「複習」和「新學」 —— 嚴重度:低(腳本實證) - **步驟:** 在全部題目裡,對還沒學的 7-5 點一個字(沒作答也可以點)。syncFirstReview 會把它排進下一次複習(plan.js:83)。等到新學輪到第 7 組,同一次裡 7-5 既在複習、也在新學。 - **實際(s8_dup.js):** `review: 7-5 | new: 7-1…7-10`。畫面上會有兩張 7-5 卡,一張點字算 remark、一張算 mark。另外合約 15 寫的是「作答後」,程式在作答前點字也排複習,比合約多做。 ### 15. 匯入會清掉別台更早、但還沒同步的離線進度 —— 嚴重度:低(推理) - reset 事件照時間排。手機離線時練的(時間在匯入之前),連上後會排在 reset 前面,被整份換掉。符合第 33 條「換成檔案內容」的字面意思,但使用者通常是把本機紀錄搬上去,不會想丟掉手機那段。事件本身還在 server 上,可以人工救回。 ### 其他確認過、沒有問題的 - 重複送出、回應遺失後再送、兩分頁同時送同一批:server 以 id 去重,結果只算一次。 - play、tick、touch 事件:重放結果只看事件,一台和多台一致;tick 只在「這一次已開始」時才算(plan.js:73-75)。小副作用:別台已結束那一次後才送到的 tick,會算到下一次,或因下一次還沒開始而被略過,影響的是「已練分鐘」,不是學習進度。 ## 建議修正 依重要性排。 1. **事件編號改成不會撞的(攻擊 1,高)。** - 分頁編號用 `crypto.randomUUID()`(或至少 16 個亂數字)。 - 流水號 `n` 存在 localStorage,跨開頁持續往上加,不要每次從 1 開始。 - server 收到「同 id、內容不同」時記一筆警告(或回錯誤),讓這種事以後看得到。 - 補測試:兩次開頁的編號不重複。 2. **點字事件帶「要設成什麼」,不要用切換(攻擊 2,中)。** - mark 事件改成 `{q, i, on: true/false}`,remark 改成 `{q, i, again: true/false}`。重放時照 `on` 設定。 - 缺口次數只在狀態真的從「沒標」變「有標」時加 1,重複的「設成有標」不重複算。 - 補測試:兩台都標同一個字 → 有標、次數 1。 3. **把「這一次的清單」記成事件,或讓事件帶上「第幾次」(攻擊 3、7、8,中)。** - 最小做法:每個事件加 `ses`(記錄當下是第幾次)。finish 只在 `ses` 等於目前次數時生效(擋掉多算一次)。review、remark 用「事件那一次的清單」判斷,不用重放到那裡時重算的清單。 - 更穩的做法:「這一次」開始時記一筆 `plan` 事件(內容是複習清單、新學清單、快照)。重放時直接用它,不再重算。之後晚到的事件只會改複習排程,不會改已經開始的那幾次清單。 - 補測試:攻擊 3a、3b 兩個序列,電腦在第 3 次的複習結果和點字都要留著。 4. **本機模式不能在存滿時清空(攻擊 4,中)。** - 只有 server 模式、而且確定 server 已經有的事件,才可以從快取裡裁掉。本機模式寫不進去時,保留舊的那份,並顯示紅色提示「瀏覽器空間快滿,請先匯出進度」。 - 補測試:模擬空間滿,重開後進度還在。 5. **加快照,流水帳不要每次全量(攻擊 5,中)。** - 這台快取「重放結果+已經重放到的事件」,開頁只拿 `after=lastSeq` 的新事件(`tq-app-seq-v4` 已經在寫,接上就好)。 - 全量比對改成「發現可能被還原時」才做,例如加一個資料庫編號(見第 6 點),或一天做一次。 - 開頁連不上時的 15 秒重試,在任何一次拉取成功後也要停掉。 6. **server 被還原的偵測(攻擊 9、10,低)。** - 資料庫建立時產生一個隨機編號,GET/POST 都回傳。前端發現編號不同,就做一次全量比對+補送。 - `read_events` 的 lastSeq 改用「這次回傳的事件裡最大的 seq」,或把兩個查詢包在同一個交易裡。 7. **合約 15(第二輪就提過,這輪沒動)。** 照第二輪建議:已學會的句子點字 → 拿掉學會、下一次到期;已在複習中、到期日比下一次晚的 → 改成下一次到期、從第一輪開始。並在 05b 補一行「全部題目裡點字也會排進複習」讓主理人追認。 8. **小修(低)。** - push 沒東西要送時,不要把「連不上」改成「已同步」(攻擊 12)。 - 缺口清單改成從標記與次數算出來,或在 remark 取消時同步檢查(攻擊 11)。 - 作答前點字不要排複習,或新學清單排除已在複習中的題(攻擊 14)。 - 匯入前提醒「其他裝置離線時做的事,如果比現在早,會被換掉」(攻擊 15)。 --- 本報告的每一項判斷都來自實際查證(逐行讀程式碼、跑 plan 28/28 與 server 6/6、重跑 extract_book.py 比對 book.js、用 s1~s10 暫存腳本重放事件序列)。標「推理」的攻擊 5、8、9、10、12、13、15 是照程式碼一步一步推出來的,沒有實際跑過;其餘標「腳本實證」的都有實際結果。