# 05i 第 2 關複查(第五輪):聽力練習模式——epoch 確認+合併清單版(2026-10-05) **一句話:43 條中一樣 39 條、不一樣 4 條(第 11、13、15、37 條,都是邊角情況)、沒做到 0 條;第四輪的 1 高 6 中,已修好 5 個(本機模式不上傳、server 還原、裁過的快取、全學完卡死、本機兩分頁互蓋),只修一半 2 個(兩台各開始同一次、流水帳太大);這輪新找到 3 個中:`--restore` 選錯檔會把正式資料庫直接毀掉、整段「晚到的一次」裡的複習答案仍會被丟、快取大約 10 個月就超過瀏覽器上限(不是 04 寫的 2 年),之後每次開頁都重下整份。沒有新的高。** > 複查者:獨立審查(沒參與製作,也沒做第一~四輪)。方法:逐行讀目前的 js/plan.js、js/store.js、js/ui.js、server.py、index.html、guide.html 連結、tests/。 > 實際執行:`node --test tests/plan.test.js` 43/43、`node --test tests/sync.test.js` 7/7、`python tests/server_test.py` 8/8 通過(04 寫 server 9 個,實際檔案裡是 8 個 test 方法,數字對不上但全過)。沒有跑 e2e.js。 > 攻擊腳本放在 `C:\Users\user\AppData\Local\Temp\claude\c--D--------\88ca62cf-98c4-42b3-987f-5328619d3ad4\scratchpad\audit5\`。全部用 `app/tests/sim.js`(載入**真正的** plan.js+store.js)。`r4_*.js` 是第四輪的 17 支腳本改成用新模擬器重跑;`a*.js`、`p1_*.py`、`perf_40k.js` 是這輪新寫的。 > js/book.js 修改時間(10/4 21:57)早於這幾輪修改,第 40 條沒受影響。 ## 先講結論:這輪做對了什麼 - **「每筆事件自己記得被哪個資料庫確認過」(`_a`=epoch)這個設計是對的。** 本機模式、離線、其他分頁、server 還原,四種「server 少了東西」的情況現在都會自動補送(r4_s4、r4_s5、a3 情況 a、b 實測)。 - **單台、兩台都不會走樣。** 第四輪的亂數測試重跑:單台 400 組 0 差異(r4_s1);兩台 200 組最後完全一致,被略過的複習答案從 347 個降到 0 個(r4_s1b)。 - **三台時鐘差 ±1 小時、各自離線完成同一次再上線**:60 組 0 組不一致、0 個複習答案遺失、每次新題都沒超過 10 題(a2_three_skewed.js)。前提是三台開始那一次時清單相同。 - **剩下的問題集中在兩處:** ① 「晚到」的路徑(另一台已經結束那一次)還不看事件自帶的快照;② server 的維運面(還原指令沒有防呆、兩個查詢不在同一個交易裡)。 ## 第四輪問題的現況 | 項目 | 判定 | 證據(file:line、腳本) | 白話 | |---|---|---|---| | 合約 15(按過「都聽出來了」再到全部題目點新字) | 只修一半 | plan.js:308 修好 r4 的順序(r4_s8 情況 A 現在是 `due:3`)。但**反過來的順序**還在:先在全部題目點新字(自動變「還是有沒聽出來」),再回複習卡按「這次都聽出來了」→ 排到 3 次後(a7_c15_rest.js:`{stage:1,due:5}`,應該是 3)。原因:複習卡只畫「上次的字+這次在複習卡點的字」(ui.js:132-136),看不到剛在全部題目點的字;「都聽出來了」也只看複習卡點的字決定能不能按(ui.js:152)。r4 情況 C(取消新字不會恢復「已學會」)也沒改(r4_s8 C)。 | r4 說的那條路修好了;同一件事換個先後順序做,還是會讓新點的字不排進下一次。 | | 合約 21(全學完又沒到期 → 卡死) | 已修好 | plan.js:40-51 沒有新題也沒有到期時,把最近一批提前(D5);r4_s7 現在這一次有 6-1 可以複習;plan.test 有對應測試。 | 不會再卡死。只剩「全部都學會、連一句待複習都沒有」時,畫面沒有完成鈕(那時本來就沒有東西可做,只是外觀,見攻擊 12)。 | | 合約 31(本機模式做的事不上傳) | 已修好 | store.js:37 沒有被目前資料庫確認的事件一律送出;r4_s4 兩種觸發方式:server 有 4 筆、電腦看到 6-1、6-2;sync.test 第 1 個測試。 | 本機模式、選「先不用」時做的事,接上 server 後會補送。 | | 合約 33(匯入後畫面沒換、schema 3 會壞) | 已修好 | store.js:279 匯入時帶「強制重畫」;ui.js:568 `!force` 才走就地更新;plan.js:278 reset 一律丟掉檔案裡的 session;r4_s16「play ok/another device opens fine」;sync.test 第 6 個測試。 | 匯入後整頁重畫;舊格式檔案不會再讓所有裝置打不開。 | | 合約 34(還原後、裁過的快取永遠讀不到中間那段) | 已修好 | server.py:57-58、67-72 加 epoch;store.js:146-150 epoch 不同就整份重拿;store.js:157 只有「從頭拿、而且沒被裁」才標完整;r4_s5、r4_s6、sync.test 第 2、3 個測試。 | 兩種情況都會自己補回來。r4 在攻擊 2 順帶提的「server 兩個查詢中間被插隊」還在,但機率很低,另列攻擊 5。 | | 合約 37(D1「任何一邊都不會被蓋掉」的四個破口) | 只修一半 | 修好 2 個:晚到的「取消這次點字」不再刪舊標記(plan.js:300;r4_s3 標記和次數都留著)、server 還原(同上)。半個:兩台**同時**在同一次時清單合併、複習答案保留(plan.js:85-92、312;r4_s2、r4_s1b 0 個被略過),但若另一台**已經結束**那一次,晚到那台的 plan 被忽略(plan.js:83),晚到的複習只看贏家的快照(plan.js:315),清單外的答案仍被丟(a1_late_plan_review.js)。沒修:舊快取的晚到複習答案蓋掉比較新的排程(r4_s11:已學會的 6-1 變回 `{stage:0,due:3}`)。 | 大部分路關上了;「另一台已經結束那一次」和「很舊的快取」兩條還開著。後果都是「多複習一次」,不會永久丟東西。 | | 合約 38(本機模式兩分頁互蓋) | 已修好 | store.js:95 所有模式都監聽其他分頁;store.js:53 存檔前先和儲存區的版本合併;r4_s10 重開後 6-1、6-2 都在;r4_s14 存滿時保留舊的、跳紅色提示。 | 同一瀏覽器開兩個分頁不會互相蓋掉了。 | | r4 攻擊 1(高):本機模式做的事永遠不上傳、還顯示已同步 | 已修好 | 同合約 31。 | 會補送。 | | r4 攻擊 2(中):server 還原後有一台永遠補不回 | 已修好(主要情況);同類的查詢競態沒修 | 主要情況同合約 34;還原兩次、還原時有一台離線也都正常(a3 情況 a、b)。server.py:79-82 先查事件、再另外查最大流水號,中間別台寫入的那筆會被跳過(p1_server_checks.py 第 2 項,用插隊重現:回傳 seq [1]、lastSeq 2)。 | 還原的問題解決了。查詢競態機率很低(兩台要在不到 1 毫秒內一讀一寫),見攻擊 5。 | | r4 攻擊 3(中):裁過的快取離線開過一次就被當完整 | 已修好 | store.js:157;r4_s6:上線開頁學過 10 題回來。 | 不會再被「毒化」。 | | r4 攻擊 4(中):全學完又沒到期 → 永遠結束不了 | 已修好 | 同合約 21。 | 會把最近的複習提前。 | | r4 攻擊 5(中):兩台各開始同一次,後開始那台的複習答案被丟 | 只修一半 | 同時進行時已修好(r4_s2:6-5 保留 `{stage:1,due:5}`)。另一台已結束那一次時仍丟(a1:手機自己畫面 `{stage:1,due:5}`,合併後 `{stage:0,due:2}`;事件明明帶著 `snap`,晚到路徑 plan.js:315 沒用它)。 | 同時的情況修好;晚到的情況沒修。 | | r4 攻擊 6(中):晚到的取消點字刪掉舊標記 | 已修好 | plan.js:300;r4_s3:合併後標記 [3]、次數 1,和手機自己畫面一樣。 | 修好。 | | r4 攻擊 7(中):本機模式兩分頁互蓋 | 已修好 | 同合約 38。 | 修好。 | | r3/r4 中:流水帳只增不減 | 只修一半 | 重試迴圈只下載一次全量(r4_s15:5 分鐘 1 次)、同時只允許一個拉取(store.js:192-209),這兩個修好了。但結構沒變,而且這輪新加的「存檔前先讀出儲存區合併」(store.js:53)讓每按一下都要把整份快取讀出來。實測 4 萬筆時:快取 6.19 百萬字元,超過瀏覽器常見上限(約 5 百萬字元),之後每次開頁都重下整份 5.4 MB(見效能量測)。 | 下載次數變少了;但大約 10 個月後手機每次開頁都要重下整份,而且每按一下都會慢一點。 | ## 對照表(只列不一樣的條;其餘 39 條一樣) 第 1~10、12、14、16~36、38~43 條一樣:這輪改動沒有讓它們退步。特別檢查過的:第 21 條(nextStep() 一處決定按鈕,ui.js:252-258、269-273、380-381、398,面板、底部條、完成區一致;只複習的晚上有「今天只複習,這次完成」ui.js:275-278)、第 31/34/35 條(epoch、補送、離線提示,關頁警告 ui.js:526-528)、第 33 條(匯入強制重畫)、第 38 條(兩分頁合併)、第 42/43 條(guide.html 與三個入口、「回到練習」仍在)。D1~D5 照 05b 寫的做(D5:plan.js:45-49)。 | 條號 | 判定 | 證據(file:line) | 白話說明 | |---|---|---|---| | 11 | 不一樣(邊角) | plan.js:91;a5_union_cap.js 情況 A | 「合併兩台的清單」沒有上限。手機離線學完第 6 組並結束,電腦不知道、提早結束後又排了第 6 組;合併後這一次的新學是第 7 組+第 6 組共 **20 題**,面板顯示「新學 10 / 20」,標題寫「第 7 組(5 個新字+20 題)」。要先有一台離線做完整組才會發生。 | | 13 | 不一樣(邊角) | plan.js:87-90;a5_union_cap.js 情況 B | 同一個原因,複習清單合併後 **15 句**(上限 10 句)。兩台在離線時各自點了不同的字,算出來的到期清單不同就會發生。 | | 15 | 不一樣(邊角) | ui.js:132-136、ui.js:152、plan.js:308;a7_c15_rest.js | 今天複習清單裡的句子:先到「看全部題目」點新字,再回複習卡按「這次都聽出來了」,句子排到 3 次後。複習卡上看不到剛點的字,「都聽出來了」也還能按。另外,已學會的句子點新字被拉回、再取消那個字,如果句子有舊標記,不會恢復「已學會」(r4_s8 C)。 | | 37 | 不一樣 | plan.js:83、plan.js:315、plan.js:206-207;a1_late_plan_review.js、r4_s11_old_cache.js | D1 承諾「任何一邊都不會被蓋掉」,還有兩個破口:① 另一台已經結束同一次後,這台那一次的複習答案只要句子不在對方清單裡就被丟(攻擊 2);② 很舊的快取離線打開,對已學會的句子按複習鈕,會把「已學會」改回「下一次要複習」(攻擊 8)。兩者都只造成多複習一次。 | ## 攻擊情境 ### 1. `--restore` 選錯檔,正式資料庫直接被毀掉 —— 嚴重度:中(腳本實證 p1_server_checks.py 第 1 項) - **步驟:** 依 DEPLOY.md 第 7 節還原,但手滑選到匯出的進度檔(`toeic-progress-xxxx.json`)或其他檔案:`python server.py --restore D:\下載\toeic-progress.json`。 - **應該:** 檢查這是不是資料庫,不是就拒絕,正式資料庫不動。 - **實際:** server.py:69 先 `shutil.copyfile` 把正式資料庫整個覆蓋掉,**之後**才在 server.py:70 打開它,這時才發現「file is not a database」。正式的 `progress.db` 已經變成那個 json 檔的內容,沒有留任何副本。之後 server 每個請求都會出錯。 - **能不能救回:** 各裝置的快取若是完整的,等主理人用正確的備份再還原一次(epoch 會換),裝置會把備份裡沒有的補送回來。但手機快取若被裁過(見效能量測),那台只留「還沒送出的」,救不回。 - **同一處(推理):** 沒有檢查 server 是不是還開著。Windows 上 SQLite 開檔允許別人同時寫入,所以 server 開著時還原,剛好有請求在寫,可能寫壞。DEPLOY.md 有寫「先停掉 server」,但指令本身不擋。 ### 2. 另一台已經結束那一次,這台那一次的複習答案被丟 —— 嚴重度:中(腳本實證 a1_late_plan_review.js) - **步驟:** 1. 手機離線,在「看全部題目」點了 6-5 的字(排到第 2 次)。 2. 電腦結束第 1 次、做完第 2 次(清單只有 6-1)並結束。 3. 手機還不知道,結束它的第 1 次,在它的第 2 次(清單 6-1、6-5)對 6-5 按「這次都聽出來了」。 4. 手機連上。 - **應該:** 6-5 照手機的判斷排到 3 次後(`{stage:1,due:5}`)。 - **實際:** 合併後 6-5 是 `{stage:0,due:2}`,手機的答案不見。手機送上去的 review 事件明明帶著 `snap: {stage:0,due:2,marks:[1]}`,但晚到路徑(plan.js:315)只看贏家那筆 plan 的快照;手機那筆 plan 因為「不是目前這一次」在 plan.js:83 直接被忽略。 - **和 r4 攻擊 5 的關係:** 同一類。這輪修好「兩台同時在同一次」,沒修「另一台已經往前走了」。後者在「手機放包包裡好幾天才連上」時很常見。 - **影響:** 6-5 下一次會再出現,要重做一次。不會永久丟資料。 ### 3. 流水帳大約 10 個月就塞滿瀏覽器,之後每次開頁重下整份 —— 嚴重度:中(實測 perf_40k.js,見下一節) - 04-implementation 的「已知限制」寫「一年約 2~4 MB,約 2 年後再做快照」。實測每筆事件中位數 124 bytes,匯出檔 4 萬筆 5.37 MB;快取每筆多存一個 `_a`,4 萬筆是 6.19 百萬字元,**超過瀏覽器常見的 5 百萬字元上限**。 - 用合約的分量估(每次新學 10 題、複習 10 句、1.5 小時),一次大約 120 筆(每題聽 3~4 遍、作答、點字;每 5 分鐘一筆計時)。每天一次,大約 **10 個月**到 4 萬筆。 - 到了之後(server 模式):快取只留「還沒送出的」,標成不完整(store.js:60-62),所以**每次開頁都用 after=0 重下整份**(實測 `GET after=0`,5.4 MB),再整份重放。手機用行動網路時,每次打開就是 5 MB 以上。 - 本機模式(合約 38 現在的用法)到了上限會跳「空間滿了,請匯出」,最新的動作存不進去(r4_s14)。 - 不會丟資料,但「快照」那個長期方案的時程要提前到 1 年內。 ### 4. 合併清單沒有上限:一次新學 20 題、複習 15 句 —— 嚴重度:低(腳本實證 a5_union_cap.js) - 見對照表第 11、13 條。這是為了修 r4 攻擊 5 加的「兩台的清單都留下」(plan.js:85-92)帶進來的。 - 其實 review 事件現在已經自帶快照(store.js:249、plan.js:312),「答過的句子」不需要靠合併清單也能算。合併清單只需要留「真的有人答過的」,不用整份併進來。 ### 5. server 回傳的最大流水號可能跳過一筆 —— 嚴重度:低(用插隊實證 p1_server_checks.py 第 2 項) - server.py:81 查事件、server.py:82 另外查 `MAX(seq)`,兩個查詢不在同一個交易裡。中間別台寫入一筆,回傳的 lastSeq 會比回傳的事件多一號。 - 實測(在兩個查詢中間插入一筆):回傳 seq [1],lastSeq 2。前端記住 2,之後只拿 2 以後的,**那一筆這台永遠拿不到**(快取完整時開頁也只拿新的)。 - 要兩台同時在用、而且一讀一寫落在不到 1 毫秒內,機率很低;但一旦發生就是無聲、永久的不一致。改法一行(見建議修正 4)。 ### 6. 手動還原(直接覆蓋檔案)不會被發現 —— 嚴重度:低(腳本實證 a3_restore_variants.js 情況 c) - DEPLOY.md 寫「一定要用指令,不要直接覆蓋檔案」。但常見的還原方式(整個資料夾從備份蓋回來、系統還原、雲端硬碟回到舊版本)都是直接覆蓋,epoch 不會換。 - 實測:電腦記得的流水號比 server 大,什麼都不補送;server(和任何新裝置)只看得到 6-1、6-5,電腦有的 6-2、6-3 永遠不會回到 server,電腦也拿不到手機之後的 6-5。 - 防呆很便宜:server 回的 lastSeq 比這台記得的還小,就當成 epoch 換了(見建議修正 5)。 ### 7. 合約 15 反過來的順序 —— 嚴重度:低(腳本實證 a7_c15_rest.js) - 見對照表第 15 條。第 2 次複習 6-1:先在「看全部題目」點 6-1 的新字(自動變「還是有沒聽出來」、排第 3 次),回到複習卡,卡上看不到那個字,「這次都聽出來了」可以按,按了之後排到第 5 次。 ### 8. 很舊的快取,晚到的複習答案蓋掉比較新的排程(r4 攻擊 10,沒改) —— 嚴重度:低(腳本實證 r4_s11_old_cache.js) - 電腦已經在第 12 次把 6-1 學會;手機用第 2 次的舊快取離線打開,對 6-1 按「還是有沒聽出來的」。合併後 6-1 從「已學會」變回 `{stage:0,due:3}`(第 3 次,早就過了,所以下一次馬上出現)。plan.js:206-207 晚到時也先刪掉 mastered。 ### 9. 匯入時另一台正在學 —— 嚴重度:低(腳本實證 a4_import_midsession.js) - 電腦匯入一份舊的進度檔(次數回到第 2 次);手機離線、還在它的第 7 次學 6-7 並點字。合併後次數是第 2 次,但 6-7 排在「第 8 次」複習,要再過 6 次才出現(合約 15 要下一次)。 - 原因:手機的事件帶 ses 7,比目前的 2 還大,程式只處理「等於目前」和「比較舊」兩種。匯入很少用,影響小。 ### 10. 新版本寫的事件給舊頁面讀 —— 嚴重度:低(腳本實證 a6_newer_version_events.js) - 不認識的事件類型(例如 `bookmark`):直接略過,沒問題。 - 不在這本書裡的題號(例如之後第 2 冊的 `11-3`): - mark 事件做到一半出錯(plan.js:185 先改了標記,plan.js:186 數缺口時才出錯),被跳過時已經改了一半(`marks['11-3'] = [1]`)。「跳過壞事件」不是完整的退回。 - review 事件帶 snap 時,`11-3` 會被加進這一次的複習清單(plan.js:312)。舊頁面畫題目卡時 `item('11-3')` 出錯(ui.js:33、98),這一次的畫面畫不出來。 - 只在「出第 2 冊、而某台還開著舊頁面沒重新整理」時發生。 ### 11. server 的小毛病 —— 嚴重度:低(p1_server_checks.py 第 3~6 項) - **Range:** `bytes=-0` 回 416(對);`bytes=0-1,5-6` 多段回 200 整份(可接受);`bytes=-99999999` 回 206 整份(對);`bytes=5-2` 回 416(規範說應該忽略回 200,無害)。**一個 5000 位數的數字**會讓 `int()` 丟例外(Python 3.14 的位數上限),連線直接斷掉、沒有回應(server.py:160、163)。 - **Content-Length 不是數字:** 連線直接斷(server.py:134 的 `int()` 在 try 外面,第 4 關掃描已指出,還在)。 - **一批超過 2 MB:** server 回 400 後關連線,前端會當成「連不上」每 15 秒重送同一批(store.js:181-184)。實測 4 萬筆裡最大的一批 4000 筆只有 0.53 MB,平常碰不到。 - **路徑:** `..`、`%2e%2e`、`data/progress.db`、`config.json`、大小寫、8.3 短檔名都擋住了;`js/plan.js::$DATA`(Windows 的檔案串流寫法)會回 plan.js 本身,無害。 - **密碼比對時間:** 對、錯、長、短的密碼回應時間中位數都在 12~16 毫秒,看不出差別(先算 sha256 再用 compare_digest)。沒有次數限制(第 4 關已指出)。 ### 12. 全部學會、連一句待複習都沒有 —— 嚴重度:低(讀程式) - plan.js:46 提前複習要「至少有一句在排程裡」。如果每一句都學會了,三區都算完成,但這一次沒開始,完成區只寫「開始做第一題後,這裡會出現『這次完成』」(ui.js:269-270),沒有完成鈕。那時本來就沒有東西可做;到全部題目點一個字就會恢復正常。只是外觀。 ### 其他確認過、沒有問題的 - **三台時鐘各差一小時、離線各自完成同一次**:60 組全部一致、0 個答案遺失(a2_three_skewed.js)。 - **還原兩次、還原時有一台離線還在做事**:最後三方都有全部的題目(a3 情況 a、b)。 - **server 回一個比較舊的 epoch(兩台 server 輪流、或舊的代理)**:每次切換會重送一次,4 次切換共重送 2 筆,兩邊最後都有全部(a3 情況 d)。不會掉、也不會無限迴圈。 - **重試迴圈**:server 恢復後 5 分鐘只整份下載 1 次(r4_s15)。 - **本機模式存滿**:不縮減、跳紅色提示(r4_s14)。 - **晚到的取消點字、點字撞號、兩台點同一個字**:都正常(r4_s3、r4_s13)。 - **舊格式整份進度檔匯入**:之後播放、別台開頁都正常(r4_s16)。 ## 效能量測 用 perf_40k.js 以真正的 Store.record 產生 40,144 筆事件(1,600 次;產生器後段全學完,每次只有少量複習,所以這組資料偏向小事件,大小估計偏保守),在這台電腦的 node(v24)上量。手機通常慢 3~5 倍,下表「手機估計」是推估,沒有實測。 | 項目 | 實測(電腦) | 手機估計 | 說明 | |---|---|---|---| | 匯出檔大小 | 5.37 MB | — | 每筆中位數 124 bytes,最大 684 bytes(plan 事件) | | 快取大小(含 `_a`) | 6.19 百萬字元 | — | 超過常見的 5 百萬字元上限 → 本機模式存不進去、server 模式只留未送出的 | | 從頭重放 4 萬筆 | 206 ms(第二次 191 ms) | 約 0.6~1 秒 | 跳過 0 筆 | | 本機模式開頁(完整快取) | 377 ms | 約 1~2 秒 | | | **每按一下**(記錄+存檔) | 99~219 ms | 約 0.3~1 秒 | store.js:53 每次都把整份快取讀出來、合併、再整份寫回。這是這輪新加的;會讓點字、播放有明顯延遲 | | 另一個分頁收到一個動作 | 284 ms | 約 1 秒 | store.js:236 整份重放+重畫 | | server 模式新裝置開頁 | 344 ms | 約 1~2 秒+下載 5.4 MB | | | 快取上限 5 百萬字元的手機開頁 | 510 ms,`complete=0` | — | 之後**每次**開頁都 `GET after=0`(實測 525 ms+整份下載) | | 4000 筆一批送出(還原後重送) | 最大 0.53 MB | — | 在 server 的 2 MB 上限內 | **結論:** 4 萬筆(約 10 個月)時正確性沒問題、重放也還快;卡的是「快取塞不下 → 每次開頁重下整份」和「每按一下都讀寫整份快取」。 ## 建議修正 依重要性排。 1. **`--restore` 先檢查、先備份(攻擊 1,中)。** - 先用 `sqlite3.connect(backup)` 打開備份,確認有 `events` 表、`PRAGMA integrity_check` 是 ok,不是就拒絕。 - 覆蓋前把現在的 `progress.db` 改名成 `progress.db.before-restore-<時間>`,不要直接蓋掉。 - 能的話檢查 port 有沒有人在聽(server 還開著就拒絕)。 - 補測試:`--restore` 一個 json 檔 → 拋錯,原本的資料庫還讀得到。 2. **晚到的複習答案用事件自帶的 snap(攻擊 2、對照表 37,中)。** - plan.js:315 改成:`const snap = ((s.plans[ses] || {}).reviewSnap || {})[e.q] || e.snap;`。一行就能把 a1 的情況修好(已在 audit5\patched 的副本上實測:a1_patched_check.js 輸出 KEPT;正式程式沒改)。 - 補測試:a1 的序列,6-5 要是 `{stage:1,due:5}`。 3. **流水帳的「快照」提前做,並把每按一下的成本降下來(攻擊 3,中)。** - 04「已知限制」的時程改成實測值:約 4 萬筆(約 10 個月)就超過瀏覽器上限。 - 短期:store.js:53 的「先讀儲存區再合併」只在收到其他分頁通知、或這次寫入失敗時才做,平常直接寫;快取不存 `_a` 字串,改存一個「已確認到第幾號」或只存未確認的 id 清單。 - 長期:server 端定期把舊事件壓成一份「到第 N 號為止的進度」,新裝置只下載快照+之後的事件。 4. **server 一次查詢拿到事件與最大流水號(攻擊 5,低)。** - read_events 的 lastSeq 改成「這次回傳事件裡最大的 seq」,沒有事件時才用 MAX;或兩個查詢包在 `BEGIN` 的同一個交易裡。 5. **流水號倒退也當成 epoch 換了(攻擊 6,低)。** - takeServer 裡 `json.lastSeq < 這台記得的 K.seq` 時,比照 epoch 不同的處理(整份重拿、全部重新比對)。手動還原、整個資料夾蓋回來都能自己補。 6. **合併清單只留「真的有人答過的」(攻擊 4,對照表 11、13,低)。** - pinPlan 的合併(plan.js:85-92)拿掉,靠 review 事件自帶的 snap(plan.js:312)和建議 2 處理答過的句子;新學清單已經靠 learn 事件記錄,不需要合併。 7. **合約 15 反過來的順序(攻擊 7,低)。** - 「這次都聽出來了」能不能按,改看「這次有沒有任何新點的字」(包括全部題目點的);複習卡把全部題目點的新字也畫出來。 - r4 情況 C:04 的說法改成實際行為,或記住「這個字是拉回來的原因」。 8. **晚到的複習答案不要蓋掉比較新的排程(攻擊 8,低)。** 晚到時只在「這句目前的排程還是快照當時那樣」才套用;否則只保留「還是有沒聽出來」。 9. **ses 比目前還大的事件(攻擊 9,低)。** 匯入後,ses 大於目前次數的事件當成「目前這一次」處理,或排程用 `min(ses, current)`。 10. **跳過壞事件要完整退回、不認識的題號不進清單(攻擊 10,低)。** replay 對單筆事件先在副本上套用、成功才換回(或至少 mark 先數缺口再改標記);plan.js:312 加進複習清單前先確認題號在這本書裡。 11. **server 小毛病(攻擊 11,低)。** Range 的數字限制長度(例如最多 18 位);`int(Content-Length)` 移進 try 回 400;前端收到 400 時把批次砍半重送,不要當成連不上。 --- 本報告的每一項判斷都來自實際查證(逐行讀程式碼;跑 plan 43/43、sync 7/7、server 8/8;用 app/tests/sim.js 載入真正的 plan.js 與 store.js 跑 audit5 的 25 支 node 腳本;建議修正 2 的一行改法另外在 audit5\patched 的副本上跑過 a1,結果是 KEPT;用 p1_server_checks.py 對真的 server.py 打 HTTP 請求)。標「讀程式」或「推理」的是:攻擊 1 的「server 開著時還原會寫壞」、攻擊 12、攻擊 10 的「舊頁面畫不出來」(只證明了清單裡有不認識的題號、`item()` 會出錯,沒有開瀏覽器)、效能量測的「手機估計」欄、以及「約 10 個月」(由每次約 120 筆推算)。