visionA/docs/autoflow/04-architecture/feature-device-mgmt-tdd.md
jim800121chen f6d15b7b14 docs(arch): B 設備管理 + C 模型共享 + camera ADR-020 規劃文件
- feature-device-mgmt-tdd.md + api-device-mgmt.md(B 設備管理 TDD)
- feature-model-sharing-tdd.md + api-model-sharing.md + PRD feature + 設計規格(C 模型共享三方規劃)
- adr-020-ffmpeg-camera-indev.md(camera 三平台 indev)
- PRD.md / TDD.md 索引增補

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:31:38 +08:00

25 KiB
Raw Blame History

技術設計文件TDD— 個人設備管理 / 設備與註冊P0


0. 一句話與範圍

「取序號 → 上報 → 存 serial」地基ADR-018 WP-0/WP-B已完整落地registered_at 欄可讀寫、List/Get API 已回傳,但整條「註冊」語意軸沒接上:沒有任何路徑能把 registered_at 由 NULL 翻成有值,前端 store 也把後端回的 registered_at 丟掉。本 TDD 補完 P0 範圍:

P0 細項 對應 work-stream 一句話
1. 註冊 API WS-BE POST /api/devices/:id/registerregistered_at NULL→now()
2. 已註冊檢查 WS-BE register 端點對「已註冊」回衝突(冪等取捨見 §3.2
3. 取消註冊(退回未註冊) WS-BE POST /api/devices/:id/unregister → 清 registered_at保留列,與 unpair 分開
4. 三態分色 WS-FE 前端 store 補 registeredAt 欄 + 三態運算 + 配色
5. 排序 + filter WS-FE 排序切換(名稱/狀態/註冊時間)+ 依三態 filter + UI
6. 資料模型 WS-BE 確認欄位現況、判定不需 migration
7. 安全 WS-BE register/unregister 的 owner 檢查 + IDOR 防護

非目標(本 TDD 不做)

  • 不改既有 unpairdevices.go:256-346 軟刪 + cascade的任何行為。
  • 不做批次註冊 / 自動註冊Q3 已定手動逐顆註冊)。
  • 不動 serial 路由 / agents 表 / session_tokens。
  • 不寫 production code、不寫 migration 實檔(本文件只給設計方向與契約)。

1. 關鍵決策:取消註冊 ≠ unpair(使用者已拍板,覆蓋 ADR-018 §1.1 Q5

⚠️ 這是本 TDD 最容易做壞的地方,工程師務必先讀懂再動手。

系統裡有兩個語意不同、絕不可合併的「移除類」動作:

動作 端點 registered_at 對 device 列 對 tokenpairing/session 語意
取消註冊(本 TDD 新增) POST /api/devices/:id/unregister 清成 NULL 保留(列還在、仍在清單顯示為「未註冊」) 不動 「這顆 USB 退回未註冊狀態,但我還看得到它」
unpair / 移除裝置(既有,不動) POST /api/devices/:id/unpair 不特別處理(連列都軟刪了) 軟刪deleted_at=now()、從清單消失) cascade 撤銷DeviceUnpairer 「把整顆已配對裝置移除、斷開連線」

1.1 為什麼與 ADR-018 §1.1 不同(決策沿革)

ADR-018 §1.1 記載的 Q5 是「取消註冊 = 真實體刪除 + 清 session_tokens FK」。使用者在本次2026-08-02重新拍板為「取消註冊 = 退回未註冊、保留裝置列、不硬刪」,理由:實體刪除會讓使用者「取消註冊後就再也看不到那顆已插著的 USB」體驗不合理而「退回未註冊」讓已連接的 USB 仍留在清單、可再次註冊,符合三態模型(未註冊態就是要能看到的第三態)。

  • ADR-018 為不可變決策紀錄、其 §1.1 原文不改;本 TDD 於此明載新決策為準。實作以本 TDD 為準。
  • 「真實體刪除 + 清 session_tokens」的語意已由既有 unpair 覆蓋(雖是軟刪、但那是既有行為、本 TDD 不動)。取消註冊是全新的第三種較輕動作。

1.2 工程師落地時的三條紅線

  1. 不要改 devicesUnpairHandlerdevices.go:256-346。unregister 是新 handler、新端點,與 unpair 各走各的。
  2. unregister 不呼叫 DeviceUnpairer、不呼叫 Delete/DeleteTx、不碰 token。 它只做一件事:把該列 registered_at set NULL。
  3. register / unregister 對 representative device 一律拒絕(見 §7.3)——只有真實 USBis_representative=false)能被註冊 / 取消註冊。

2. 現況地基盤點(實作前必讀,避免重造輪子)

元件 現況 檔案:行 對本 TDD 的意義
Device.RegisteredAt *time.Time 已存在domain device.go:83 直接用,不加欄
DB 欄 registered_at TIMESTAMPTZnullable 已存在0005 migrations/0005_create_agents.up.sql:38 不需 migration
index idx_devices_registered 已存在partial未刪除 同上:44 filter「未註冊」可用本 P0 前端 client-side filter 為主index 供未來後端 filter
scanDeviceregistered_at 已接nullable *time.Time postgres_repository.go:391 讀取路徑完整
Save upsert 寫 registered_at 已接($15 = d.RegisteredAt postgres_repository.go:268,286 register 可直接走 Save/SaveTx、不必新寫 SQL但見 §3.3 建議加專用 UPDATE
List/Get API 回 registered_at 已接 devices.go:65,129,220 後端→前端資料已備妥
is_representative=false filter List 已濾(只列真 USB device.go:190 / postgres List register/unregister 端點另需在 handler 再擋一次 representative縱深
owner 檢查範式 既有 handler 皆用 UserContextFrom + d.OwnerUserID != userID → 403 devices.go:197-201,299-302 register/unregister 照抄這個範式
前端 DeviceSummary.registeredAt 不存在(資料到前端被丟) device-store.ts:74-96 WS-FE 第一件事:補欄 + normalize

結論:後端 register/unregister 是「加 2 個 handler + 2 條 route + 1 個 repo 專用方法(可選)」;前端三態是「補 1 個欄位 + 三態運算 + 配色」。地基全在,只差翻轉開關與前端消費。


3. 註冊 API細項 1 + 2 + 6— WS-BE

3.1 端點:POST /api/devices/:id/register

  • 路由註冊位置registerDeviceRoutesdevices.go:27-49與既有 unpair 並列(都是純雲端 DB 操作、用 UUID :id、不 proxy
  • 識別值UUID:id)。理由(對齊 ADR-018 FE-Aregister 是純雲端 DB 操作(只翻 registered_at、不路由到 local agent用主鍵 UUID 最自然;且序號可能為空的 device 也要能被查詢與擋掉。
  • Request body:無(空 body
  • 行為
    1. UserContextFrom 取 userID缺 → 500照既有範式 devices.go:95-100
    2. :id 空 → 400 VALIDATION_FAILED
    3. DeviceRepo.Get(ctx, id)ErrNotFound → 404其他 DB error → WriteDBError
    4. owner 檢查d.OwnerUserID != userID → 403 FORBIDDEN§7
    5. representative 檢查d.IsRepresentative == true → 409 CONFLICTrepresentative 不是真 USB、不可註冊§7.3)。
    6. 已註冊檢查(細項 2d.RegisteredAt != nil → 見 §3.2。
    7. 翻轉set registered_at = now()、回 200 + 更新後的 DeviceListItemregistered_at)。

3.2 已註冊檢查(細項 2— 取「409 衝突」,理由如下

兩個方案:

方案 行為 取捨
A. 409 CONFLICT採用 已註冊再 register → 回 409 ALREADY_REGISTERED 語意明確、前端可提示「已註冊」;符合需求「避免重複註冊」的字面
B. 冪等 200 已註冊再 register → 回 200不改 registered_at 對 retry 友善,但掩蓋「使用者以為沒註冊卻已註冊」的狀態

採 A需求明確要「已註冊檢查避免重複註冊409 最貼合。前端收到 409 顯示 toast「此裝置已註冊」並 refetch。錯誤碼新增 ALREADY_REGISTERED(見 api 規格 §錯誤碼)。

註:registered_at 一旦設值,不因後續 exchange 覆寫——upsertUSBDeviceTx 復用分支pairing_exchange.go:329-341只更新 PairedAt/AgentID/type不碰 RegisteredAt,故重配對不會清掉註冊態(現況已正確、不需改)。

3.3 Repo 落地建議:新增專用 SetRegisteredAtTx(優於走 Save

雖然 Save/SaveTx 已能寫 registered_atupsert 全欄),但 register/unregister 建議新增精準的單欄 UPDATE,理由:

  • Save 是全欄 upsertregister handler 得先 Get 整個 device 再 Save 回去,有「讀到寫之間被其他請求改動」的競態面(雖 P0 單裝置風險低,但語意不乾淨)。
  • 專用 UPDATE devices SET registered_at = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL AND is_representative = false 一句到位、天然只影響該欄、RowsAffected()==0 可轉 404/409 判定。

建議簽名domain Repository interface 加一法、in-memory + postgres 各實作):

// SetRegistered 設定/清除註冊時間。at=nil 表示取消註冊(清 NULL。
// 只作用於未刪除、非 representative 的 device不符則回 ErrNotFound。
SetRegistered(ctx context.Context, id string, at *time.Time) error
  • registerSetRegistered(ctx, id, &now)handler 已先擋 already-registered故這裡不再判
  • unregisterSetRegistered(ctx, id, nil)
  • 若不想動 interface退路是 handler 內 Get→改欄→Save可接受但較不乾淨推薦加 interface 方法,並補 in-memory 對稱實作 + postgres db_testtestcontainers 130見 §8

3.4 資料模型(細項 6不需 migration

  • registered_at 欄、idx_devices_registered index、domain 欄位、scan/save 讀寫路徑全部已存在§2 表。register/unregister 純粹是「翻轉既有欄」+「加 handler」不新增欄、不改 schema、不寫 migration 檔
  • 唯一的 code 面新增是可選的 SetRegistered repo 方法Go 層,非 schema

4. 取消註冊 API細項 3— WS-BE

4.1 端點:POST /api/devices/:id/unregister

  • 路由:同 register並列 registerDeviceRoutesUUID 識別。
  • Request body:無。
  • 行為(與 register 對稱、但更寬鬆):
    1. userID / :id / Get / owner / representative 檢查同 §3.1 步驟 1-5。
    2. 不做「已註冊才可取消」的硬擋:採冪等——若 registered_at 已是 NULL未註冊unregister 回 200no-op 成功),避免使用者連點兩次第二次報錯。(SetRegistered(id, nil) 對已 NULL 的列 UPDATE 到相同值、RowsAffected 仍為 1因 WHERE 命中;語意上「取消一個未註冊的 = 已達成目標」。)
    3. SetRegistered(ctx, id, nil) → 清 registered_at
    4. 回 200 + 更新後 DeviceListItemregistered_at 為 null
  • 絕不做:不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 session§1.2 紅線)。

4.2 與 unpair 的並存驗證testing 重點)

  • unregister 後device 仍 GET /api/devices 列得到、registered_at=null、pairing/session token 不變、tunnel 狀態不變。
  • unpair 後device 從 List 消失軟刪、token 被撤。
  • 兩端點互不影響:對同一 device 先 unregister 再 unpair 應正常unregister 只清欄、unpair 照樣軟刪)。

5. 三態分色(細項 4— WS-FE

5.1 前端資料斷點修復(第一步,最關鍵)

device-store.tsDeviceSummary74-96沒有 registeredAtnormalizeDevice128-164也沒接後端回的 registered_at 在前端被丟棄。必須先補:

  • DeviceSummaryregisteredAt?: string | null;ISO 8601nil=未註冊)。
  • Deviceextends Summary自動繼承。
  • normalizeDeviceregisteredAt: pick<string>("registered_at", "registeredAt") ?? null,(沿用既有 pick snake/camel 相容範式)。

5.2 三態運算規則(給前端明確定義)

三態 = 連線軸remoteStatus× 註冊軸registeredAt 的組合:

條件 語意 配色 token
已連接(已註冊在線) remoteStatus === "online" registeredAt != null 正常可用的個人設備 --status-online(綠,既有)
已連接未註冊 remoteStatus === "online" registeredAt == null 插著、連線中但還沒註冊 → 第三態 --warning(黃,既有 warning token 組)
未連接 remoteStatus !== "online"offline/reconnecting/error/unknown 離線(不論註冊與否) 沿用既有 RemoteDeviceBadge 各狀態色

運算實作建議:在 device-store.ts 或一支 lib/device-state.ts 加純函式:

type DeviceTriState = "online-registered" | "online-unregistered" | "offline";
function deriveTriState(d: Pick<DeviceSummary,"remoteStatus"|"registeredAt">): DeviteTriState
// online + registeredAt != null → "online-registered"
// online + registeredAt == null → "online-unregistered"
// 其餘                          → "offline"
  • 純函式便於 testing 單測(真值表 6 格)。

5.3 配色落地(沿用既有 design tokens不新增裸色

第三態「已連接未註冊」用既有 warning token 組--warning / --warning-foreground / --warning-subtleglobals.css:159-161Light/Dark 都有定義),與 pairing 頁 / login 頁 / flash-dialog 的警示 UI 同色系。禁止bg-yellow-* 裸色(對齊 flash-progress.tsx:11 的既有慣例)。

改動點:

  • remote-device-badge.tsx:目前 badge 只吃 remoteStatus63-101方案:不改 badge 的內部語意badge 仍表達連線狀態),而是在 DeviceCard 層疊加「未註冊」標記——online 且未註冊時,卡片額外顯示一個 warning 色的「未註冊」pill/badge新增小元件或用既有 warning classes。理由連線狀態與註冊狀態是正交兩軸硬塞進同一個 badge 會讓「online 但未註冊」的顏色語意打架。
  • device-card.tsxisOnline44旁加 isRegistered = !!device.registeredAtonline && !registered → 卡片邊框 / 角落 pill 走 warning 色 + i18n 文案「未註冊」。
  • 不只靠顏色(沿用 design-review M2 無障礙原則):第三態要有文字「未註冊」+ icon不能只有黃色。

5.4 i18n

新增 keyzh-Hant + en 同步,對齊 dictionaries/devices.state.unregistered(「未註冊」/ "Unregistered")、devices.register.action(「註冊」)、devices.unregister.action(「取消註冊」)、devices.register.error.alreadyRegistereddevices.register.confirm 等(實際 key 由 frontend agent 補全、testing 驗證存在)。


6. 排序 + filter細項 5— WS-FE

6.1 現況與範圍

  • 現況:device-list.tsx:31-37,77-79 寫死一條「在線優先」排序,無 filter、無切換 UI
  • 不需後端改動、不需分頁:個人設備數量級小(一個使用者的 USB 通常 < 20全部 client-side 排序 / 過濾即可。不做 cursor/offset 分頁(資料量不成立、徒增複雜度)。

6.2 排序(可自選鍵)

提供排序切換(下拉或 segmented control

排序鍵 規則
狀態(預設,保留既有行為) 沿用 STATUS_ORDERonline→reconnecting→unknown→offline→error同狀態內次比名稱
名稱 displayNamealias
註冊時間 registeredAt desc新註冊在前null未註冊排最後
  • 排序狀態存元件 local stateuseState即可不需持久化P0。若要記住偏好可存 localStorage可選、非必要

6.3 Filter依三態

提供 filterchips / checkbox group選項

filter 條件(用 §5.2 deriveTriState
全部(預設) 不過濾
已連接 triState === "online-registered"
未連接 triState === "offline"
已連接未註冊 triState === "online-unregistered"
  • 排序與 filter 組合:先 filter 再 sort。
  • 空結果狀態filter 後 0 筆 → 顯示「無符合條件的裝置」空狀態(與既有 EmptyState 區隔——這不是「完全沒裝置」)。

6.4 UI 落點

  • 排序 + filter 控制列放 devices/page.tsxdevice-list.tsx 頂部。
  • device-list.tsx 目前接 devices prop 後自己排序;改為接「已排序 / 已過濾的 devices」或把排序 filter state 提到 list 內。由 frontend agent 決定放置層級(保持既有 EmptyState / skeleton / pair CTA 行為不變)。

7. 安全(細項 7— WS-BE

7.1 Owner 權限檢查

register / unregister 都必須照既有範式devices.go:197-201UserContextFrom 取 userID → Get device → d.OwnerUserID != userID → 403 FORBIDDEN。缺 UserContext → 500auth middleware 沒配好,不可 fallthrough

7.2 IDOR 防護

  • 威脅:攻擊者用別人的 device UUID 打 POST /api/devices/{別人的id}/register(或 unregister若無 owner 檢查就能改別人裝置的註冊態。
  • 防護§7.1 的 owner 檢查即 IDOR 防線——先 Get 拿到 device 的 OwnerUserID、與 caller userID 比對、不符回 403。不可因「反正只是翻個 flag」而省略。
  • 列舉防護取捨owner 不符回 403既有 handler 慣例,如 devices.go:198。這會洩漏「該 UUID 存在但不屬於你」vs「不存在404」的差異。既有 unpair/get 都用 403、本 TDD 沿用一致(不特別改成 404 混淆),維持 codebase 一致性;若 security agent 要求統一改 404-混淆,另案處理、不在 P0 範圍。

7.3 Representative device 防護

  • register/unregister 前檢查 d.IsRepresentative;為 true → 409 CONFLICTrepresentative 是 agent 連線佔位、非真 USB、註冊語意不適用
  • 縱深:即使 List 已濾掉 representative前端拿不到其 UUIDhandler 仍要自己擋(攻擊者可能猜 UUID 或從其他管道拿到。repo 的 SetRegistered 的 WHERE 也帶 is_representative = false§3.3),三層防護。

7.4 其他

  • register/unregister 是狀態變更操作,走既有 auth middlewareJWT/OIDC與 unpair 同一 route group
  • 無新增 PII、無新增對外資料揭露。
  • 建議整體register/unregister/IDOR納入 testing 的 API 安全測試;若 Orchestrator 判定需深度威脅建模,可送 security agent但 P0 owner+representative+IDOR 三檢查已覆蓋主要面。

8. 測試策略(給 Testing Agent

8.1 Backend 單元 / handler 測試

  • register未認證(500) / 空 id(400) / device 不存在(404) / 非 owner(403) / representative(409) / 已註冊(409) / 正常(200 且 registered_at 非 null)。
  • unregister非 owner(403) / representative(409) / 已註冊→取消(200 且 registered_at=null) / 未註冊→取消(200 冪等 no-op)。
  • 並存回歸unregister 後 device 仍在 List、token 不變unpair 行為完全不變(跑既有 unpair_test.go / unpair_db_test.go 確認全綠)。

8.2 Repo 測試SetRegistered若採 §3.3

  • in-memoryset→get 回值、set nil→清空、representative 拒絕ErrNotFound、已刪除拒絕。
  • postgres db_testtestcontainers本機無 docker → 192.168.0.130,見 ADR-018 §4.3 dbtest 陷阱);go test -run xxx -list 先確認 case 數再跑避免假綠。UPDATE RowsAffected 對 representative / deleted 列為 0 → ErrNotFound。

8.3 Frontend 測試

  • normalizeDeviceregistered_at 有值/缺值 → registeredAt 正確 / null。
  • deriveTriState 真值表online×registered / online×null / offline×registered / offline×null / reconnecting / unknown≥6 格)。
  • 三態配色online 未註冊卡片有 warning 標記 + 「未註冊」文字(不只靠色)。
  • 排序三種鍵各驗序filter四選項各驗集合 + 空結果狀態。

8.4 E2E可選P0 視情況)

  • 註冊一顆在線未註冊 device → 卡片由黃轉綠 → 取消註冊 → 轉回黃 → device 仍在清單。

9. 並行化工作流計畫Parallelization Plan

9.1 契約contract-firstsingle source of truth

跨 BE/FE 必須一致的契約,全部定死於 api/api-device-mgmt.md

  • 端點:POST /api/devices/:id/registerPOST /api/devices/:id/unregisterUUID 識別)。
  • Response既有 DeviceListItem 形狀(含 registered_at omitemptysnake_case。前端對這個形狀寫 normalize。
  • 錯誤碼:ALREADY_REGISTERED(409)、CONFLICT(409, representative)、FORBIDDEN(403)、NOT_FOUND(404)、VALIDATION_FAILED(400)。
  • 三態運算規則§5.2 真值表)= 前後端共識,前端據此算色、後端據此保證 registered_at 語意。

9.2 依賴圖 + 關鍵路徑

graph LR
    C[契約定稿<br/>api-device-mgmt.md<br/>critical] --> BE[WS-BE: register/unregister<br/>+ SetRegistered repo]
    C --> FE1[WS-FE: store 補 registeredAt<br/>+ deriveTriState 純函式<br/>對 mock 契約]
    FE1 --> FE2[WS-FE: 三態配色 + 排序 filter UI]
    C --> TS[WS-TEST: 測試設計<br/>對契約先寫]
    BE --> INT[整合 join<br/>真後端接前端]
    FE2 --> INT
    TS --> INT
    INT --> E2E[E2E join]
  • 關鍵路徑契約定稿 → WS-BE → 整合 join → E2E。契約是所有線的前置、必須先鎖死。
  • WS-FE 可對著契約 mock 先開工store 補欄 + deriveTriState + UI不必等後端。

9.3 Work-stream 清單 + 同步點

Work-stream 派給 可與誰平行 阻擋於 同步點
WS-BEregister/unregister handler + route + SetRegistered repoin-mem+pg+ handler test backend WS-FE, WS-TEST 契約定稿 整合 join
WS-FEstore 補 registeredAt + normalize + deriveTriState + 三態配色 + 排序/filter UI + i18n frontend WS-BE, WS-TEST 契約定稿 整合 join
WS-TESTbackend handler/repo 測試設計 + frontend 真值表/配色測試 + 並存回歸 + E2E 腳本 testing WS-BE, WS-FE 契約定稿 整合 join / E2E join

同步點

  • 整合 join:真後端接上前端,驗「註冊→卡片轉綠 / 取消→轉黃、device 保留」端到端。
  • E2E join:跑 §8.4,並確認 unpair 回歸全綠。

9.4 任務卡Anthropic 四要素)

WS-BE

  • Objective實作 register/unregister 兩端點 + (建議)SetRegistered repo 方法,符合 api-device-mgmt.md 契約。
  • Output可運行 handler + route 註冊 + repo 方法in-mem + pg+ 對應 Go 測試(含 pg db_test 走 130 testcontainers
  • 來源指引:讀本 TDD §1/§3/§4/§7、api/api-device-mgmt.md、既有 devices.go(照 owner 檢查範式)、unpair.go只讀不改、理解語意差異)、postgres_repository.goSave/scan 範式)。
  • 邊界:只加 register/unregister絕不改 unpair / Delete / DeviceUnpairer / token / session。錯誤碼照契約、不自創。representative 一律擋。不寫 migration欄已存在

WS-FE

  • Objective補前端註冊軸消費——store 接 registeredAt、三態運算與配色、排序/filter UI、register/unregister 呼叫。
  • OutputDeviceSummary.registeredAt + normalize + deriveTriState 純函式 + DeviceCard 三態配色warning token+ device-list 排序/filter 控制 + store action registerDevice/unregisterDevice + i18n keyzh/en
  • 來源指引:讀本 TDD §5/§6、api/api-device-mgmt.md、既有 device-store.tspick 範式、unpairDevice action 範式)、device-card.tsx / remote-device-badge.tsx / device-list.tsxglobals.csswarning token
  • 邊界識別值——register/unregister 用 UUIDDB 操作、對齊 FE-A不改 serial 路由類操作connect/camera/media。配色只用既有 design token、禁裸色。第三態不只靠顏色加文字+icon。不動 unpairDevice 行為。

WS-TEST

  • Objective對契約設計 backend + frontend 測試 + 並存回歸 + E2E 腳本。
  • Output§8.18.4 測試清單落為可執行測試 / 腳本。
  • 來源指引:讀本 TDD §8、api/api-device-mgmt.md、既有 unpair 測試。
  • 邊界:只寫測試、不改 production code。特別覆蓋「unregister vs unpair 語意不混」與「unpair 回歸不破」。

10. 給 Orchestrator 的實作排程建議

  1. 先鎖契約:確認 api/api-device-mgmt.md(本 TDD 附)定稿。
  2. 同一輪平行 invokeWS-BEbackend+ WS-FEfrontend+ WS-TESTtesting三線並行——契約已定、各自可對 mock/stub 開工。
  3. 各線完成 → Reviewer 審 → 收斂到整合 join(真後端接前端)。
  4. E2E join + unpair 回歸全綠 → P0 收尾。
  5. effort scaling 判斷本任務中等2 個清楚分離模組 BE/FE + 測試3 線平行是甜蜜點,不過度切分。