Skip to content

fix(app): 字數上限與後端數的不是同一種單位——計數器說沒超過,後端擋下(第二十六輪) - #98

Merged
thothawei merged 1 commit into
mainfrom
claude/round26-input-limits
Aug 1, 2026
Merged

fix(app): 字數上限與後端數的不是同一種單位——計數器說沒超過,後端擋下(第二十六輪)#98
thothawei merged 1 commit into
mainfrom
claude/round26-input-limits

Conversation

@thothawei

Copy link
Copy Markdown
Owner

本輪掃的族

跨端輸入邊界對帳——App 的欄位上限 vs 後端 validator。從沒掃過,掃出來的東西比預期硬:不只有三個欄位沒擋,有擋的那三個擋的單位也是錯的

根因一句話

Flutter 的 TextField.maxLength 數的是 grapheme cluster(使用者感知的「一個字」),後端每一支上限數的都是 runeutf8.RuneCountInString / len([]rune(...)))。對 ASCII 與中文相同,對 emoji 差到 7 倍。

取證(不是查文件,是實跑探針)

「👨‍👩‍👧‍👦」characters=1 runes=7    「🇹🇼」characters=1 runes=2    「👍🏽」characters=1 runes=2
TextField(maxLength: 5) 擋完後 → characters=5 runes=35

maxLength: 5 放行了 35 個 rune。於是評分畫面的計數器顯示「200/200 沒超過」,送出卻被後端以 400 擋下——而錯誤訊息(「評論長度超過上限」)不會說上限是多少,對照著計數器看只會更困惑。

六個欄位的對帳表

欄位 後端上限(rune) 修改前的 App 判定
評分評論 ratingCommentMaxRunes 200 maxLength: 200(數 cluster) ❌ 單位錯
遺失物描述 lostItemDescMaxRunes 300 maxLength: 300(數 cluster) ❌ 單位錯
預約備註 maxScheduleNoteRunes 200 maxLength: 200(數 cluster) ❌ 單位錯
聊天訊息 chatMaxRunes 500 完全沒有上限 ❌ 送出後才知道
常用地點名稱 maxPlaceLabelRunes 40 完全沒有上限 ❌ 同上
常用地點地址 maxPlaceAddressRunes 200 唯讀(地圖選點反查) ⚠️ 刻意不改,見下

前三個最值得注意:數字與後端完全相同(200/300/200),程式碼上看起來對得整整齊齊——單位不同這件事在 diff 裡是看不出來的

修法

  • 共用 RuneLimitingTextInputFormatterruneCounter()擋的單位與顯示的數字都與後端一致
  • 裁切落在 cluster 邊界,不是直接砍 rune 陣列——從 ZWJ 序列中間切下去會留下半個字(孤立的修飾符/ZWJ 尾巴),畫面會出現使用者沒打過的東西。寧可少收一個完整的字。
  • maxLength 只留著讓 Flutter 畫出計數器的位置,強制交給 formatter(maxLengthEnforcement: none)——兩套同時生效會先被 cluster 那套擋掉。
  • 聊天輸入框不放計數器:訊息框下面多一行 x/500 太吵,而 500 字平常打不到。
  • 常用地點「地址」刻意不加限制器:它是唯讀的、由地圖選點反查填入。真的超過 200 rune 時應該讓後端拒絕,而不是在客戶端把地址默默截短——截短過的地址是錯的地址,司機會開到別的地方去。

驗收

  • flutter analyze 無 issue、flutter test 434 passed(425 +新 9)、55 個測試檔。
  • 反向驗證兩個半邊各一次
    • 拿掉聊天輸入框的 formatter(回到先前無上限)→ 700 個 rune 整段進得去,該案 FAIL
    • 把限制器改回數 cluster(=maxLength 的行為)→ 5 案 FAIL
  • 有一個案子專門驗「真畫面接上去了」(常用地點名稱:開編輯表單 → 輸入 30 面旗子 → 只留 20 面、計數器顯示 40/40)——只驗共用元件本身的話,某個畫面漏接 inputFormatters 不會有任何測試變紅
  • flutter build apk --debug --flavor customer -t lib/main_customer.dart 成功。

誠實說明沒做到的

遺失物描述/預約備註/評分評論三個畫面沒有各自的「真畫面接上去了」測試(只有常用地點與聊天室有)。三個接法完全相同、analyze 也綠,但嚴格說它們的接線目前靠 review 不靠測試。已寫進 TODO 的「同族還沒碰的角落」,條件是下次動到那三個畫面時順手補。

開工前的例行實查(第九次)

三個外部卡點依然都不在(android/app/google-services.jsonios/Runner/GoogleService-Info.plist 不存在、xcrun devicectl list devices → No devices found);開工時三個 repo 的 open PR 皆為 0。

🤖 Generated with Claude Code

本輪掃的族是跨端輸入邊界對帳(App 欄位上限 vs 後端 validator),從沒掃過。
掃出來的東西比預期硬:不只有三個欄位沒擋,**有擋的那三個擋的單位也是錯的**。

根因:Flutter 的 TextField.maxLength 數的是 grapheme cluster,後端每一支上限數的
都是 rune。對 ASCII 與中文相同,對 emoji 差 7 倍——實跑探針:
「👨‍👩‍👧‍👦」characters=1 runes=7;maxLength: 5 放行了 35 個 rune。

於是評分畫面計數器顯示「200/200 沒超過」、送出卻被後端以 400 擋下,而錯誤訊息
(「評論長度超過上限」)不會說上限是多少,對照計數器只會更困惑。

六個欄位:評分評論 200/遺失物描述 300/預約備註 200 三個單位錯;
聊天訊息 500/常用地點名稱 40 兩個完全沒擋;常用地點地址唯讀,刻意不加限制器
——它由地圖選點反查填入,客戶端截短會產生**錯的地址**,司機會開到別的地方。

修法:共用 RuneLimitingTextInputFormatter + runeCounter,擋的單位與顯示的數字
都與後端一致。裁切落在 cluster 邊界而不是直接砍 rune 陣列——從 ZWJ 序列中間切
會留下半個字。maxLength 只留著讓 Flutter 畫計數器的位置,強制交給 formatter。

驗收:flutter analyze 無 issue、flutter test 434 passed(425+新 9)、
flutter build apk --debug --flavor customer 成功。
反向驗證兩個半邊各一次:拿掉聊天的 formatter → 700 rune 整段進得去該案 FAIL;
把限制器改回數 cluster → 5 案 FAIL。
另有一案專驗「真畫面接上去了」(常用地點開表單→輸入 30 面旗子→只留 20 面、
計數器顯示 40/40)——只驗共用元件的話,某個畫面漏接 inputFormatters 不會變紅。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thothawei
thothawei merged commit c4e4bbd into main Aug 1, 2026
2 checks passed
@thothawei
thothawei deleted the claude/round26-input-limits branch August 1, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant