# 05g 第 2 關複查(第四輪):聽力練習模式——plan 事件+ses 版(2026-10-04) **一句話:43 條中一樣 36 條、不一樣 7 條(第 15、21、31、33、34、37、38 條)、沒做到 0 條;第三輪的 1 高 4 中裡,已修好 3 個(撞號、點字互相抵銷、本機存滿清空)、只修一半 2 個(晚到事件改清單、流水帳太大);這輪的新改動帶進 1 個高(本機模式時做的事,之後接上 server 永遠不會上傳,畫面卻顯示「已同步」)和 6 個中。** > 複查者:獨立審查(沒參與製作,也沒做第一~三輪)。方法:逐行讀目前的 js/plan.js、js/store.js、js/ui.js、server.py、index.html、app.css、tests/。 > 實際執行:`node --test tests/plan.test.js` 36/36 通過;`python tests/server_test.py` 6/6 通過。沒有跑 e2e.js。js/book.js 的修改時間早於這幾輪修改,第 40 條沒受影響。 > 攻擊腳本放在 `C:\Users\user\AppData\Local\Temp\claude\c--D--------\88ca62cf-98c4-42b3-987f-5328619d3ad4\scratchpad\audit4\`。 > 這輪新做了一個模擬器 `sim.js`:把**真正的** book.js、plan.js、store.js 各自放進獨立的執行環境(一個環境=一台裝置或一個分頁),給它假的瀏覽器儲存空間、可以切斷的網路、和一個照 server.py 行為寫的記憶體 server。所以下面的「腳本實證」走的是 Store.record、push、pull、init 的實際程式,不只是 Plan。 ## 先講結論:這輪做對了什麼 - **單一裝置不會走樣。** 用真正的 Store.record(會自動多記一筆 plan 事件)亂數做 400 組、每組 150 個動作,和「從頭重放」比對:0 組不同(s1_fuzz_store.js)。所以「一次記兩筆(plan+動作)」沒有讓邊做邊算和重放分岔。 - **兩台裝置最後一定一致。** 兩台亂數斷線、亂數同步,各 200 個動作、200 組:最後兩台和「server 上的流水帳重放」三者完全一樣(s1b_fuzz_two.js)。 - **第三輪的撞號、點字互相抵銷、按兩次完成多算一次、本機存滿被清空,都修好了**(見下表)。 - **問題集中在三處:** ① 「只拿新事件」的開頁方式,在三種情況下會永遠漏資料;② 兩台各自開始同一次時,各記一筆不同清單的 plan,後記的那台的複習答案被丟掉;③ 「沒開始不能完成」讓「什麼都不用做的一次」卡死。 ## 第三輪問題的現況 | 項目 | 判定 | 證據(file:line、腳本) | 白話 | |---|---|---|---| | 合約 15(已學會/排在很後面的句子點新字,不會下一次複習) | 只修一半 | plan.js:98-113 修好第三輪的兩種情況(s8_c15.js 情況 B:學會的 6-1、排在第 9 次的 6-2,點新字後都變成下一次)。但 plan.js:100「這次複習清單裡的句子交給複習鈕處理」直接返回,造成新漏洞:先按「這次都聽出來了」,再到「看全部題目」點同一句的新字,句子仍排在 3 次後(s8 情況 A:`review {stage:1, due:5}`,應該是 3)。另外「取消新點的字會恢復原本安排」只在那句沒有舊標記時成立(s8 情況 C)。 | 第三輪說的兩種情況修好了;但「按過複習鈕之後」這條路還是會讓新點的字不排進下一次。 | | 合約 31(撞號就被丟) | 只修一半 | 撞號已修:store.js:30、store.js:228 用 128 位元亂數(s13_toggle_ids.js:全部 32 位 16 進位、不重複)。但這輪加的「開頁只拿新事件」造成新的漏存:store.js:103 用「快取完整」決定要不要比對 server 缺什麼,本機模式時做的事永遠不會上傳(攻擊 1,高);store.js:47 把裁過的快取標成完整(攻擊 3)。 | 撞號不會再發生。但換了一個新的洞:有些動作永遠到不了 server。 | | 合約 37(D1「任何一邊都不會被蓋掉」) | 只修一半 | 點字互相抵銷、晚到事件重排清單都修好(s13、s12_r3_attack3.js)。仍有:兩台各記一筆 plan,後面那台的複習答案被丟(攻擊 5,plan.js:77、plan.js:289);晚到的「複習卡取消點字」會刪掉上次的舊標記(攻擊 6,plan.js:279);server 還原後永遠補不回(攻擊 2);舊快取的晚到複習答案蓋掉比較新的排程(攻擊 10)。 | 大部分「被蓋掉」的路已經關上,還剩四條。 | | 合約 38(本機模式存滿後清空) | 只修一半 | 存滿已修:store.js:46-52 本機模式寫不進去時不縮減、改顯示紅色提示(s14_local_full.js:重開後保留舊的部分,出現 full 提示)。但本機模式根本沒有掛「另一個分頁寫入」的監聽(store.js:123-128 沒有呼叫 startTimers),同一瀏覽器開兩個分頁時,後寫的分頁整份蓋掉前一個分頁的動作(攻擊 7)。 | 存滿不會再清空了;但本機模式開兩個分頁會互相蓋掉。 | | 攻擊 1(高):同一台兩次開頁撞號 | 已修好 | store.js:30、store.js:228;s13_toggle_ids.js | 事件編號改成 128 位元亂數,不會撞。 | | 攻擊 2(中):兩邊點同一個字互相抵銷 | 已修好 | plan.js:167-174、plan.js:178-187、plan.js:286;ui.js:447-448;s13_toggle_ids.js(兩台都點 "was":標記 [0]、次數 1、仍排複習) | 點字事件帶「設成有/沒有」,兩台重複點只算一次。舊版沒帶 on 的事件仍當切換(只在過渡期有影響,見攻擊 13)。 | | 攻擊 3(中):晚到的事件改寫過去某一次的複習清單 | 只修一半 | 第三輪的 3a、3b 兩個序列都修好(s12_r3_attack3.js:3a 電腦的「還是有沒聽出來」和新點的字都留著;3b 7-10 沒被擠掉)。但「兩台各自開始同一次」時會有兩筆 plan,先記的贏,後記那台在那一次的複習答案整個被略過(攻擊 5;s2_plan_race.js;亂數測試 200 組裡 347 個複習答案被略過)。 | 單一清單的情況修好了;兩台同時開始同一次時,還是會丟答案。 | | 攻擊 4(中):本機模式存滿 → 重開全空 | 已修好 | store.js:46-52;ui.js:525;s14_local_full.js | 不再縮減,並跳紅色提示請匯出。 | | 攻擊 5(中):流水帳只增不減、每次開頁全下載 | 只修一半 | 開頁只拿新事件(store.js:87-88)、計時改 5 分鐘一筆(ui.js:513)有做。沒做:開頁連不上時的 15 秒重試,在 server 恢復後仍每 15 秒下載**整份**流水帳,直到使用者做任何動作(store.js:120;s15_retry_loop.js:5 分鐘內 21 次全量下載);裁過的快取離線打開仍是幾乎空白的進度,而且現在會把快取「毒化」成完整(攻擊 3)。每次收到新事件仍從頭重放(store.js:61)。 | 下載量變小了;但重試迴圈沒停,裁過的快取還多了一個新問題。 | ## 對照表(只列不一樣的條;其餘 36 條一樣) 第 1~14、16~20、22~30、32、35、36、39~43 條一樣:這輪改動沒有讓它們退步(D2、D3 照寫的做:plan.js:264、plan.js:281、ui.js:138-141;ui.js:67、ui.js:482)。 | 條號 | 判定 | 證據(file:line) | 白話說明 | |---|---|---|---| | 15 | 不一樣 | plan.js:100;s8_c15.js 情況 A;s12_r3_attack3.js 3b 的 6-10 | 這一次複習清單裡的句子,按過「這次都聽出來了」之後,再到「看全部題目」點新字,句子仍排在 3 次後,不是下一次。在複習卡上點字沒有這個問題(會自動算「還是有沒聽出來的」)。 | | 21 | 不一樣 | plan.js:294;ui.js:258、ui.js:361、ui.js:375、ui.js:479;s7_empty_session.js | 第 1 冊全部學完、這一次又沒有到期的複習時,三區都算完成,卻看不到「這次完成」,只看到「開始做第一題後,這裡會出現『這次完成』」。畫面上沒有任何可以做的題目,所以這一次永遠開始不了、也結束不了;排在之後幾次的複習永遠不會到期。詳見攻擊 4。 | | 31 | 不一樣 | store.js:103、store.js:87、store.js:47、store.js:233;s4_local_then_server.js、s6_trim_poison.js | 本機模式(server 沒有 API、或選了「先不用」)時做的事,之後接上 server 永遠不會上傳,而且顯示「✓ 已同步」(攻擊 1)。 | | 33 | 不一樣 | store.js:257;ui.js:531-534;s9_import_inplace.js;s16_old_schema_import.js | 匯入後進度確實換成檔案內容,但畫面還是舊的那一次的題目卡(直到重新整理)。原因:匯入時傳給畫面的是「同一個物件」,畫面比對「清單有沒有變」永遠得到「沒變」,只做就地更新。另外匯入舊版整份進度檔(schema 3)會讓之後每按一次播放都出錯(攻擊 11)。 | | 34 | 不一樣 | store.js:87、store.js:99、store.js:103;s5_restore.js、s6_trim_poison.js | 一般情況正常。但 server 被還原過、或這台快取被裁過又離線開過一次,這台開頁只拿「比我記得的編號新」的事件,永遠讀不到中間那段,重新整理也沒用(攻擊 2、3)。 | | 37 | 不一樣 | plan.js:77、plan.js:289、plan.js:279、plan.js:290;store.js:103 | D1 的合併、提示、保持捲動位置都有做。但「任何一邊都不會被蓋掉」還有四個破口:兩台各記一筆 plan(攻擊 5)、晚到的取消點字刪掉舊標記(攻擊 6)、server 還原補不回(攻擊 2)、舊快取的晚到複習答案蓋掉新排程(攻擊 10)。 | | 38 | 不一樣 | store.js:123-128(本機模式沒呼叫 startTimers,沒有 storage 監聽);store.js:47;s10_local_two_tabs.js | 本機模式存滿的問題修好了。但同一瀏覽器開兩個分頁,各自把自己手上的整份寫進去,後寫的蓋掉先寫的。實測:分頁 1 學了 6-1、6-2,分頁 2 只按了一次播放,重開後學過的是 0 題。 | ## 攻擊情境 ### 1. 本機模式時做的事,接上 server 後永遠不會上傳,還顯示「已同步」 —— 嚴重度:高(腳本實證 s4_local_then_server.js) - **步驟:** 1. 用 http 網址打開,但 server 沒有 API(例如 server.py 還是舊版,第 3 關試用時真的發生過),或密碼框選了「先不用(只存在這台裝置)」。網頁進入本機模式。 2. 照常學:作答、點字。本機模式的事件不會被列進「待送」(store.js:233 只有 server 模式才 addUnsent),存進快取時順便寫 `complete = "1"`(store.js:47)。 3. 隔天 server 好了/輸入密碼,網頁變成 server 模式。 4. 開頁時因為快取是「完整的」,`takeServer(..., full = false)`(store.js:103),不會比對「server 缺哪些」,所以前一天的事件永遠不會進待送清單。 - **應該:** 前一天的進度補存到 server,別台也看得到(合約 31、34)。 - **實際:** 兩種觸發方式結果相同:這支手機畫面顯示學過 6-1、6-2,狀態「✓ 已同步」;server 上 0 筆;電腦打開看到學過 0 題。之後這台若因空間滿把快取裁成「只留待送的」,這些動作就連這台也沒了。 - **根源:** 「要不要比對 server 缺什麼」應該看「這次是不是全量下載(after=0)」,不是看「快取完不完整」。在 K.seq 還沒存過時,store.js:87 其實已經用 after=0 拿了全部,卻仍當成增量處理。 ### 2. server 被還原後,有一台永遠補不回來,重新整理也沒用 —— 嚴重度:中(腳本實證 s5_restore.js) - **步驟:** 1. 電腦學到 6-6,記得的流水號是 7。手機上次同步在流水號 2。server 的備份在流水號 2。 2. server 硬碟壞了,還原備份(流水號回到 2)。 3. 手機先打開:server 的 2 不小於它記得的 2,正常增量;手機又學了 6-7~6-10,server 長到 12。 4. 電腦打開:記得 7,server 是 12,不算「倒退」(store.js:99),只拿 7 以後的(store.js:87)。 - **應該:** 電腦補送 6-2~6-6,拿到手機的 6-7~6-10。 - **實際:** 電腦開兩次都只有 6-1~6-6;server(任何新裝置)只有 6-1、6-7~6-10。兩邊永遠不一致。第三輪時這個情況「重新整理就會好」,因為那時開頁一律全量比對;這輪改成增量後,變成永久。 - **同一類(推理):** server.py:61-62 先查事件、再查最大流水號,中間別台寫入一筆,前端記下的流水號會跳過那筆。以前重新整理會補,現在永遠拿不到。機率很低。 ### 3. 快取被裁過、又離線打開一次 → 這台永遠只剩殘缺進度 —— 嚴重度:中(腳本實證 s6_trim_poison.js) - **步驟:** 1. server 模式,瀏覽器空間滿了:快取裁成「只留還沒送的」,`complete = "0"`(store.js:53-56)。到這裡是設計好的。 2. 某次在捷運上離線打開:畫面用那幾筆重放,幾乎是空的。 3. 隨手按一次播放:快取現在很小、寫得進去,store.js:47 就把 `complete` 寫成 `"1"`。 4. 回到有網路的地方打開:因為「快取完整」,只拿流水號 21 以後的事件。 - **實際:** 原本學過 10 題,之後每次打開都是 0 題,狀態「已同步」,重新整理也一樣。這台接著會把「第 1 次、從 6-1 開始」的動作送上 server(以晚到事件處理,影響有限,但畫面一直是錯的)。 - **前提:** 要先遇到空間滿。第三輪估計大約幾個月到一年。 ### 4. 全部學完、又沒有到期的複習 → 這一次永遠結束不了 —— 嚴重度:中(腳本實證 s7_empty_session.js+讀程式) - **步驟:** 1. 第 1 冊 100 題都學完(第 12 條)。 2. 某一次剛好沒有到期的複習(例如上次按「都聽出來了」的句子排到 3 次後)。 3. 這一次畫面:① 「這次沒有要複習的 ✓」② 「新題都學完了 🎉」③ 「這次沒有句子可以連續聽 ✓」。沒有任何題目卡、沒有連續聽按鈕。 4. 完成區只顯示「開始做第一題後,這裡會出現『這次完成』」(ui.js:258);面板是「↓ 開始這一次」,按了只會捲到同一句話(ui.js:339)。 - **應該:** 三區都完成 → 出現「這次完成 ✓」(第 21 條),按了進到下一次。 - **實際:** 這一次沒有「開始」(只有作答、播放、點字等動作會開始),所以完成鈕不出現;就算記了一筆 finish,plan.js:294 也不承認。次數停住,排在之後的複習永遠不到期。唯一的出路是到「看全部題目」隨便播一句,使用者不會知道。 - **根源:** 第 3 關 r2-M1 的修法(「沒開始不能完成」)沒有排除「本來就沒有東西可做」的一次。 ### 5. 兩台各自開始同一次,清單不同 → 後開始那台的複習答案被丟掉 —— 嚴重度:中(腳本實證 s2_plan_race.js、s1b_fuzz_two.js) - **回答任務 3 的問題:可以。** 兩台都在第 N 次、都還沒動作時,各自的第一個動作都會記一筆 plan(store.js:222-225)。兩台手上的事件不同(一台離線、或 30 秒還沒拉到),清單就不同。重放時先記的那筆生效,後一筆被 pinPlan 忽略(plan.js:77)。後記那台在這一次的「複習」事件,只要句子不在贏的清單裡就被丟掉(plan.js:289),沒有任何提示。 - **步驟(s2):** 1. 手機離線,在「看全部題目」點了 6-5 的一個字(排到第 2 次)。 2. 電腦結束第 1 次、開始第 2 次:清單 [6-1]。 3. 手機(不知道電腦已結束)也結束第 1 次、開始它的第 2 次:清單 [6-1, 6-5],對 6-5 按「這次都聽出來了」。 4. 手機連上。 - **應該:** 手機對 6-5 的判斷被保留(6-5 下次隔 3 次)。 - **實際:** 合併後第 2 次的清單是 [6-1],6-5 還是 `{stage:0, due:2}`,手機的「都聽出來了」不見;手機畫面整個換成電腦的清單。server 上同時有兩筆 `ses 2` 的 plan。點的字沒丟(remark 有 on 時改走 markWord),只丟複習答案。 - **規模:** 亂數兩台(約 5% 機率切換離線),200 組裡每組都有「同一次兩筆 plan」,其中清單不同的 1065 次,總共 347 個複習答案被略過。 - **影響:** 被丟的句子下一次會再出現,使用者要重做一次;不會永久遺失資料,但違反 D1 的承諾。 ### 6. 晚到的「複習卡取消點字」會刪掉上次的舊標記 —— 嚴重度:中(腳本實證 s3_remark_late.js) - **步驟:** 1. 6-1 上次點過第 3 個字 "growth",這次在複習清單裡。 2. 手機離線,在複習卡上點 "growth"(這次又沒聽到),再點一次取消(改主意)。手機自己的畫面:舊標記保留、缺口次數 1。 3. 同時電腦已經結束這一次。手機的兩筆事件到 server 時成了「晚到」(ses 比目前小)。 - **應該:** 跟手機自己的畫面一樣:舊標記保留。 - **實際:** 晚到的 remark 改走 markWord(plan.js:279):「設成有」因為已經有了所以沒變,「設成沒有」直接把舊標記刪掉、缺口次數扣到 0、從耳朵缺口清單消失。合併後 `marks = undefined`、`gaps.growth = undefined`。 - **根源:** 晚到路徑沒有照 remark 的規則「上次就有的字,取消只取消這次的點擊」(plan.js:184)。在攻擊 5 的「句子不在贏的清單」時也走同一條路,結果一樣。 ### 7. 本機模式開兩個分頁,互相蓋掉 —— 嚴重度:中(腳本實證 s10_local_two_tabs.js) - **步驟:** 用檔案方式打開(合約 38 現在的用法),不小心開了兩個分頁。分頁 1 學 6-1、6-2;分頁 2(較早開的)按一次播放。重開網頁。 - **實際:** 重開後學過 0 題。本機模式不會呼叫 startTimers(store.js:123-128),所以沒有監聽「另一個分頁寫入」,每個分頁都把自己手上的整份寫進同一個位置,最後寫的贏。 - **備註:** server 模式有監聽(store.js:211-216),沒有這個問題。 ### 8. 合約 15:按過「都聽出來了」,再到全部題目點新字,不會下一次複習 —— 嚴重度:低(腳本實證 s8_c15.js 情況 A) - 第 2 次複習 6-1 按「這次都聽出來了」(排到第 5 次)→ 到「看全部題目」點 6-1 的新字(mark 事件,ui.js:448)→ syncFirstReview 在 plan.js:100 看到「這句在這次的複習清單裡」就返回 → 仍是第 5 次。 - 小問題(情況 C):學會的句子點新字(拉回下一次),再取消那個字,如果句子還有舊標記,不會恢復成「已學會」(plan.js:102-104 只看「還有沒有標記」)。04-implementation 寫「取消那個字會恢復原本的安排」只在沒有舊標記時成立。 ### 9. 匯入後畫面沒換 —— 嚴重度:低(腳本實證 s9_import_inplace.js) - Store.replace 記一筆 reset、就地改同一個 state 物件,再呼叫 onReplace(state)(store.js:257)。ui.js:531-534 比較 `sig(S)` 和 `sig(next)`,兩個是同一個物件,永遠「一樣」,只就地更新。 - 實測:畫面是第 1 次、新學 6-1~6-10;匯入後實際是第 7 次、複習 7-3、新學 9-1~9-10,畫面卻沒重畫。重新整理才會對。這是這輪「就地更新」帶進來的退步。 ### 10. 舊快取離線打開,晚到的複習答案蓋掉比較新的排程 —— 嚴重度:低(腳本實證 s11_old_cache.js) - 手機上次停在第 2 次(清單 [6-1])。之後電腦一路做到第 12 次,6-1 已學會。手機離線打開舊快取,畫面仍是第 2 次,對 6-1 按「還是有沒聽出來的」。 - 合併後 6-1 從「已學會」變回 `{stage:0, due:3}`。按「這次都聽出來了」也一樣會取消學會(reviewAt 先刪掉 mastered,再用第 2 次的快照算,plan.js:191-197、plan.js:290)。 - 不會丟資料,只是多複習。但它是用「很舊那一次的快照」覆蓋了比較新的判斷。 ### 11. 匯入舊版整份進度檔(schema 3)後,播放會出錯 —— 嚴重度:低(機率低、後果重;腳本實證 s16_old_schema_import.js+讀程式) - fromFile 遇到 schema 3 直接拿來用(store.js:250),裡面的 session 是舊版的,沒有 `plays`、`reviewMarks`、`fixed`。reset 把它原樣放進進度(plan.js:257),ensureSession 看到已經有 session 就不重建(plan.js:57)。 - 實測:匯入後按播放 → `Cannot read properties of undefined (reading '6-2')`(plan.js:270)。畫面的 ui.js:142 也讀 `S.session.plays[id]`,推理會在重畫題目卡時出同樣的錯;reset 事件已經送上 server,所以每台打開都會壞,直到完成這一次。 - 只有拿「事件流水帳以前的版本」匯出的檔案才會遇到。 ### 12. 開頁連不上時的重試迴圈不停 —— 嚴重度:低(腳本實證 s15_retry_loop.js) - store.js:120:沒有待送事件時每 15 秒 `pull(true)`(整份下載)。只有 push 成功才清掉(store.js:173)。server 恢復後,使用者只是在看、不按任何東西,就一直整份下載。5 分鐘 21 次。 ### 13. 舊版事件(沒有 ses/on)—— 嚴重度:低(推理) - 重放時沒有 ses 的事件當成「目前這一次」,沒有 on 的 mark/remark 當切換(plan.js:251、plan.js:286、plan.js:182)。單看舊事件,結果和舊版相同,相容沒問題。 - 唯一的風險在部署過渡期:某台裝置還開著舊版網頁沒重新整理,它送出的切換式 mark 會把新版「設成有」的字翻掉,它的 finish 也不帶 ses。重新整理後就停止。 ### 14. 同一瀏覽器兩個 server 模式分頁的極窄時間差 —— 嚴重度:低(推理) - 分頁 1 拉到新事件,先寫 K.seq 再寫 K.events;分頁 2 剛好在收到 storage 通知前存檔,寫回一份沒有那幾筆的事件、仍標「完整」。如果這時兩個分頁都關掉,下次開頁只拿 K.seq 以後的,那幾筆這台永遠拿不到。以前開頁全量會補回,現在不會。時間窗只有幾毫秒。 ### 其他確認過、沒有問題的 - **單一裝置邊做邊算=從頭重放**(s1_fuzz_store.js,400 組 0 差異)。plan 事件的清單、快照和動作事件的時間順序在兩條路上完全一樣。 - **兩台最後一定一致**(s1b_fuzz_two.js,200 組 0 差異)。 - **兩台都按「這次完成」只算一次**,「沒開始的一次不能完成」在合併時也一致(plan.js:294)。 - **時鐘不準:** 因為 record 的時間一定晚於這台已看過的所有事件(store.js:226),每台的事件在重放時一定排在它看過的 finish 之後,不會出現「ses 比目前大」的情況;時鐘慢的裝置離線做的事,會被當成晚到事件,照它自己那一次的快照算(問題見攻擊 6、10)。 - **晚到的 tick、touch、cont、finish** 一律忽略;晚到的 play 只算總次數,不算進那一次的「已聽 N 遍」。 - **本機模式存滿**、**事件編號撞號**、**點字互相抵銷**:已修好(上表)。 ## 建議修正 依重要性排。 1. **「要不要比對 server 缺什麼」改看「這次是不是全量下載」(攻擊 1,高)。** - store.js:103 的 full 改成 `cached === 0`(也就是這次用 after=0 拿的)。 - 更穩:本機模式記錄時也把事件加進待送清單(store.js:233 不限 server 模式),一接上 server 就會送。 - 補測試:本機模式記 3 筆 → 換成 server 模式開頁 → server 有 3 筆。 2. **「快取完整」只在真的完整時才標(攻擊 3,中)。** - 用一個記憶體裡的旗標:開頁時快取是完整的、或做過一次全量下載合併後,才算完整;從裁過的快取開始(離線打開)就一直是不完整,saveLocal 不能把它寫成 "1"。 - 補測試:裁過 → 離線開+一個動作 → 上線開頁,進度要回來。 3. **server 還原的偵測不要只靠流水號(攻擊 2、14,中)。** - 資料庫建立時產生一個隨機編號,GET/POST 都回傳;前端記住,不同就做一次全量比對+補送。 - server.py:61-62 的 lastSeq 改成「這次回傳事件裡最大的 seq」(沒有事件時才用 MAX),或兩個查詢包在同一個交易裡。 - 保底:每 N 次開頁(或每天第一次)做一次全量比對。 4. **沒有東西可做的一次,也要能完成(攻擊 4,中)。** - plan.js:294 改成「已開始,或這一次的複習和新學都是空的」就生效;ui.js:258、361、375 的顯示條件跟著改。保留 r2-M1 想擋的情況(有東西可做卻什麼都沒做,就不能完成)。 - 補測試:全部學完、沒有到期 → 按完成 → 進到下一次。 5. **兩筆 plan 時,後記那台的複習答案也要算(攻擊 5,中)。** - 做法一:review 事件帶上「按的時候那句的快照」(stage),重放時句子不在贏的清單,就用事件自己的快照走 reviewAt,不要丟掉。 - 做法二:每個 ses 保留所有 plan 事件(以裝置分),review 用「同一台的那筆 plan」的快照。 - 補測試:s2 的序列,6-5 要變成隔 3 次。 6. **晚到的 remark 照 remark 的規則(攻擊 6,中)。** - plan.js:279 的晚到路徑:on=false 時,如果那個字在「那一次的快照」(plans[ses].reviewSnap)裡就有,不要刪標記,只扣這次那一下的次數(而且只在之前確實加過時才扣)。 - 補測試:s3 的序列,舊標記與次數要留著。 7. **本機模式也要合併其他分頁(攻擊 7,中)。** - 本機模式也掛 storage 監聽;saveLocal 寫入前先把儲存裡現有的事件併進來再寫。 - 補測試:兩個分頁各做一件事 → 重開兩件都在。 8. **合約 15 的剩下一條路(攻擊 8,低)。** - 這次複習清單裡的句子,從「看全部題目」點了新字,比照複習卡:自動算「還是有沒聽出來的」,或至少排成下一次。 - 「取消新字恢復原本安排」改成記住「這個字是拉回來的原因」,不要只看「還有沒有標記」;或在 04 把說法改成實際的行為。 9. **匯入要整頁重畫(攻擊 9,低)。** Store.replace 呼叫 onReplace 時帶一個「強制重畫」的旗標,或畫面記住「上次畫的是哪一份清單」(字串),不要拿同一個物件比。 10. **舊版整份進度檔匯入時把 session 清掉(攻擊 11,低)。** fromFile 遇到 schema 3 時把 `session` 設成 null(或補齊 plays、reviewMarks、reviewSnap、fixed),讓這一次重新排。 11. **晚到的複習答案不要蓋掉比較新的排程(攻擊 10,低)。** reviewAt 的晚到路徑只在「那句目前的排程還是快照當時那樣」時才套用;否則只保留「還是有沒聽出來」(拉回下一次),忽略「都聽出來了」。 12. **重試迴圈在任何一次拉取成功後就停(攻擊 12,低)。** --- 本報告的每一項判斷都來自實際查證(逐行讀程式碼、跑 plan 36/36 與 server 6/6、用 audit4 的模擬器載入真正的 plan.js 與 store.js 重放兩台裝置、斷線、還原、存滿等序列)。標「推理」的攻擊 13、14,以及攻擊 2 的「server 兩個查詢」與攻擊 11 的「畫面 ui.js:142 出錯」,是照程式碼一步一步推出來的,沒有實際跑過;其餘都有腳本結果。