jim800121chen fac07c39d0 fix(local-agent): 配對前自動 rescan USB,解序號偵測時序問題
問題:配對時 exchange 撈 GET /api/devices(ListDevices=讀快取、不重新
偵測)。若 agent 啟動時 USB 尚未插入/未偵測到,快取為空 → 配對撈不到
序號 → payload 不帶 devices → 雲端裝置「尚未回報序號」,即便之後插上
USB 也不會自動重偵測。

修法(方案 A、最小侵入):
- NewLocalDeviceLister 改打 POST /api/devices/scan(ScanDevices →
  Manager.Rescan() 重新偵測 USB),配對前強制重掃一次
- localDeviceListTimeout 2s → localDeviceScanTimeout 8s(真 SDK scan
  kp.core.scan_devices 較慢、給餘裕,逾時走 fallback 不卡配對)
- app.go DeviceLister 注入註解同步更新(Reviewer Mi-1)

不變量保留(Reviewer 獨立驗證):
- rescan 失敗/逾時 → lister return nil(不 error)→ payload omit devices
  → 配對照常(序號是加值資訊,不中斷配對)
- Rescan 對序號身分未變的 session 保持連線(serialIdentity 相等 continue)、
  只斷被拔除的 → 不誤斷在線 tunnel/inference session
- Pair 由 lifecycleMu 序列化、Rescan write lock 全量重建 → 重試冪等安全

Reviewer 通過(0C/0M/1Mi/2Sug)。兩 module build/vet/test/race 全綠、
8 個新 tunnel 行為測試 PASS(端到端快取空→rescan→序號進 payload +
逾時 fallback)、gitleaks 0。

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

545 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// pairing.go — 雛形配對流程實作AB5 範圍)。
//
// 對應 TDD §4.3「配對流程(雛形 + Phase 1」與 Design spec §5。
//
// 責任:
// 1. 驗證 Pairing Token 格式vAc_ + 32 hex
// 2. 呼叫雲端 visionA-backend 的 POST /api/pairing/exchange
// 3. 若 mock mode = trueAB11 尚未上線),本地產生假 Session Token 供 dev 測試
// 4. 回傳 session token + account + relay URL 給 Manager 寫入 TokenStore
//
// ⚠️ 這個檔不動 visionA-backend 程式碼(那是 AB11 的事)。當 AB11 做完
// /api/pairing/exchange 上線,把 config.MockMode 設回 false 就會走真實呼叫。
package tunnel
import (
"bytes"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"
"time"
)
// PairingMockEnvVar 是控制 mock pairing 模式的環境變數名。
// 預設行為unset / 任何 ≠ "true" 的值 → 走真實 exchangeproduction-safe default
// 必須明確設成 "true"(不分大小寫)才啟用 mock 模式。
//
// 此預設策略由 Fix-A3 引入;歷史上預設為 mock=onAB11 完成後改為明確 opt-in 避免
// 使用者忘設環境變數誤用 mock token。
const PairingMockEnvVar = "VISIONA_PAIRING_MOCK"
// IsPairingMockOptIn 根據環境變數判斷是否啟用 mock pairing 模式。
//
// 規則(必須明確 opt-in
// - "true"(不分大小寫)→ true
// - 其他任何值unset / "false" / "1" / 拼錯字 / 空字串)→ false
//
// 抽出來的目的app.go 與測試共用同一份判斷邏輯,避免規則飄移。
func IsPairingMockOptIn(envValue string) bool {
return strings.EqualFold(envValue, "true")
}
// InsecureSkipTLSVerifyEnvVar 是控制「跳過 TLS 憑證驗證」的環境變數名。
//
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。
//
// 用途:連線到使用自簽憑證的 stage / 測試環境(例如 stage 用 self-signed cert
// curl 需要 -k 才連得上。Go 預設 client 遇自簽憑證會直接 x509 驗證失敗,
// 導致 pairing exchange 與 tunnel WSS 連線都無法建立。
//
// 預設行為production-safeunset / 任何 ≠ "true" 的值 → 維持安全的 TLS 驗證。
// 必須明確設成 "true"(不分大小寫)才會跳過驗證。
const InsecureSkipTLSVerifyEnvVar = "VISIONA_INSECURE_SKIP_TLS_VERIFY"
// IsInsecureSkipTLSVerify 根據環境變數判斷是否要跳過 TLS 憑證驗證。
//
// 規則比照 IsPairingMockOptIn必須明確 opt-in
// - "true"(不分大小寫)→ true
// - 其他任何值unset / "false" / "1" / 拼錯字 / 前導空白 / 空字串)→ false
//
// ⚠️ 回傳 true 代表進入不安全模式(中間人攻擊可竊聽 / 竄改流量),僅供 dev/test。
// 抽出來的目的app.go、exchanger、tunnel client 共用同一份判斷邏輯,避免規則飄移。
func IsInsecureSkipTLSVerify(envValue string) bool {
return strings.EqualFold(envValue, "true")
}
// ErrInvalidTokenFormat 表示 Pairing Token 不符合 vAc_ + 32 hex 格式。
var ErrInvalidTokenFormat = errors.New("invalid pairing token format (expected vAc_ + 32 hex)")
// ErrTokenInvalid / ErrTokenExpired / ErrTokenUsed / ErrTokenRevoked 對應雲端
// /api/pairing/exchange 401 回應的四種 codeDesign spec §5.4)。
// Manager 呼叫 Pair() 失敗時會把這些錯誤 emit 成 pairing:result event前端依
// code 顯示本地化訊息。
var (
ErrTokenInvalid = errors.New("pairing token invalid")
ErrTokenExpired = errors.New("pairing token expired")
ErrTokenUsed = errors.New("pairing token already used")
ErrTokenRevoked = errors.New("pairing token revoked")
)
// ErrExchangeNetwork 為 network 層錯誤DNS / TCP / TLS
var ErrExchangeNetwork = errors.New("exchange network error")
// pairingTokenRegex 對應 TDD §4.3vAc_ 開頭 + 正好 32 個小寫 hex。
// 大寫 hex 不接受,與 visionA-backend 雛形生成格式一致。
var pairingTokenRegex = regexp.MustCompile(`^vAc_[0-9a-f]{32}$`)
// ValidatePairingToken 回傳 nil 代表格式正確。
func ValidatePairingToken(token string) error {
if !pairingTokenRegex.MatchString(token) {
return ErrInvalidTokenFormat
}
return nil
}
// ExchangeResult 是 exchange 成功後回傳給 Manager 的資料。
type ExchangeResult struct {
SessionToken string
// Account 是雲端帳號 email。雛形 mock 下為 "demo@visionA.local"。
Account string
// RelayURL 雲端告訴 agent 接下來要連哪個 relayws(s)://host/tunnel/connect
// 若回應沒帶此欄位Manager 會 fallback 用 Config 中原本的 RelayURL。
RelayURL string
}
// exchangeRequest 對應 TDD §4.3 定義的 request body。
//
// Devices 為 WP-0ADR-018 序號地基)新增的 optional 欄位exchange 前向本地
// server 撈 `GET /api/devices` 取得實體 USB 清單(含 Kneron kn_number 序號),
// 讓雲端把序號填進 devices.serial_numberserial 路由的資料來源)。
// `omitempty` 保證舊行為相容撈不到清單local server 未起 / timeout / 0 裝置)
// 時不送此欄位,雲端走現行「自建 serial=NULL device」路徑。
type exchangeRequest struct {
PairingToken string `json:"pairing_token"`
Devices []exchangeDevice `json:"devices,omitempty"`
}
// exchangeDevice 是 exchange payload 中的單顆實體 USB 裝置。
// 用陣列因應「一 agent 多 USB」WP-0 只保證序號送達,多顆的完整模型是 WP-B
type exchangeDevice struct {
SerialNumber string `json:"serial_number"`
DeviceType string `json:"device_type,omitempty"`
Firmware string `json:"firmware,omitempty"`
}
// LocalDevice 是 DeviceLister 回傳的本地 USB 裝置摘要exchange payload 的來源)。
type LocalDevice struct {
SerialNumber string
DeviceType string
Firmware string
}
// DeviceLister 回傳本地 server 目前偵測到的 USB 裝置清單。
//
// 由 app.go 組裝時注入NewLocalDeviceLister(port)Exchanger 不自己猜 port。
// 回傳 error 或 nil 清單都不會讓 exchange 失敗——序號是加值資訊,配對本身
// 不能因為撈不到 USB 而中斷fallback 到不帶 devices 的現行行為)。
type DeviceLister func() ([]LocalDevice, error)
// exchangeResponse 對齊雲端 /api/pairing/exchange 的成功 envelopeapi/errors.go WriteSuccess
//
// {
// "success": true,
// "data": {
// "session_token": "vAs_...",
// "account": "...@visionA.local",
// "relay_url": "wss://...",
// "expires_at": "2026-07-21T00:00:00Z"
// }
// }
//
// ⚠️ 歷史 contract drift早期 agent 假設 session_token 在頂層(雛形註解寫「對齊雛形
// 雲端 handler」但雲端後來統一改用 SuccessBody envelopesuccess + data
// payload 全在 data. 底下。直接解頂層會讓 session_token 永遠為空、報
// "exchange response missing session_token"。此 struct 已對齊現行 envelope。
type exchangeResponse struct {
Success bool `json:"success"`
Data exchangeResponseData `json:"data"`
}
// exchangeResponseData 是成功 envelope 的 data payload。
// 對齊雲端 api.PairingExchangeResponse 的 json 欄位。
type exchangeResponseData struct {
SessionToken string `json:"session_token"`
ExpiresAt string `json:"expires_at,omitempty"`
Account string `json:"account,omitempty"`
RelayURL string `json:"relay_url,omitempty"`
}
// exchangeErrorResponse 對齊雲端的錯誤 envelopeapi/errors.go WriteError
//
// {
// "success": false,
// "error": { "code": "INVALID_PAIRING_TOKEN", "message": "...", "request_id": "..." }
// }
//
// ⚠️ 同樣的 contract drift早期 agent 假設 401 body 為頂層 { "code": "token_invalid" }
// 但雲端用 ErrorBody envelopecode 在 error. 底下、且為大寫常數(見
// api.ErrCodeInvalidPairingToken 等)。此 struct + mapExchangeErrorCode 已對齊現行格式。
type exchangeErrorResponse struct {
Success bool `json:"success"`
Error exchangeErrorDetail `json:"error"`
}
// exchangeErrorDetail 是錯誤 envelope 的 error payload。
type exchangeErrorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
// PairingExchanger 介面讓 Manager 在測試時能注入 fake避免真的打 HTTP
type PairingExchanger interface {
Exchange(pairingToken string) (ExchangeResult, error)
}
// HTTPPairingExchanger 是生產用的實作,打真實的 HTTP 端點。
// MockMode = true 時不打 HTTP改為本地產 fake session token方便 AB11 未完成前
// 先做 end-to-end 驗證。
type HTTPPairingExchanger struct {
// CloudAPIURL 是 visionA-backend 的 base URL不含 path
// 例https://api.visionA.cloud
CloudAPIURL string
// Client 可注入自訂 http.Clienttimeout 測試nil 用預設 10 秒 timeout。
Client *http.Client
// MockMode = true 時跳過真實 HTTP、直接產假 session token。
// 僅用於 AB11 尚未落地的 dev 流程;正式上線必須 false。
MockMode bool
// MockAccount 可覆寫 mock 模式下回傳的 account email空值用預設。
MockAccount string
// MockRelayURL 可覆寫 mock 模式下回傳的 relay URL空值時不帶讓 Manager
// fallback 用 Config.RelayURL。
MockRelayURL string
// InsecureSkipTLSVerify = true 時exchange 用的 http.Client 會跳過 TLS 憑證
// 驗證tls.Config{InsecureSkipVerify: true})。
//
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的 stage / 測試環境。
// 由 VISIONA_INSECURE_SKIP_TLS_VERIFY 控制,見 IsInsecureSkipTLSVerify。
// 僅在 Client == nil走預設 client 建構)時生效;若呼叫者注入自訂 Client
// 以該 Client 自帶的 Transport 為準。
InsecureSkipTLSVerify bool
// DeviceLister 供 exchange 前撈本地 USB 清單(含序號)塞進 payload。
// nil 或回傳失敗 → payload 不帶 devicesexchange 照常進行,見 DeviceLister 註解)。
DeviceLister DeviceLister
// Logf 為 optional 的 log 函式app.go 注入 appLog。nil 時 fallback 到
// 標準 log.Printf。撈裝置清單失敗屬「可恢復、不中斷」情況必須留下紀錄。
Logf func(format string, args ...interface{})
}
// NewHTTPPairingExchanger 建立一個生產預設實例。
func NewHTTPPairingExchanger(cloudAPIURL string) *HTTPPairingExchanger {
return &HTTPPairingExchanger{
CloudAPIURL: cloudAPIURL,
Client: &http.Client{Timeout: 10 * time.Second},
}
}
// Exchange 執行 pairing exchange。Manager 會在 Pair() 流程呼叫。
func (e *HTTPPairingExchanger) Exchange(pairingToken string) (ExchangeResult, error) {
if err := ValidatePairingToken(pairingToken); err != nil {
return ExchangeResult{}, err
}
if e.MockMode {
return e.exchangeMock(pairingToken)
}
return e.exchangeReal(pairingToken)
}
// exchangeMock 不打 HTTP僅用 crypto/rand 產一個合法格式的 Session Token。
// 格式vAs_ + 64 hex對齊 TDD §4.3 雛形規格。
func (e *HTTPPairingExchanger) exchangeMock(_ string) (ExchangeResult, error) {
token, err := generateMockSessionToken()
if err != nil {
return ExchangeResult{}, err
}
account := e.MockAccount
if account == "" {
account = "demo@visionA.local"
}
return ExchangeResult{
SessionToken: token,
Account: account,
RelayURL: e.MockRelayURL, // 空字串代表讓 Manager 用既有 RelayURL
}, nil
}
// exchangeReal 真實呼叫 visionA-backend /api/pairing/exchange。
func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult, error) {
if e.CloudAPIURL == "" {
return ExchangeResult{}, errors.New("cloud API URL not configured")
}
client := e.Client
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
if e.InsecureSkipTLSVerify && client.Transport == nil {
// ⚠️ DEV / TEST ONLY跳過 TLS 憑證驗證,供連自簽憑證的 stage。
// 正式環境絕不可開。WARNING log 由 app.go 啟動時統一印一次。
//
// 注意:只在 client.Transport == nil含 NewHTTPPairingExchanger 的預設 client
// 時覆寫,避免踩掉呼叫者注入的自訂 Transport且不直接改 e.Client用區域複本
// 不影響傳入的共享 client。
//
// Clone DefaultTransport 而非新建 zero-value &http.Transport{}:只覆寫
// TLSClientConfig跳過憑證驗證保留預設的 Proxy: http.ProxyFromEnvironment
// 與 dial / TLS handshake timeout。避免「開 skip 順便改掉 proxy 行為」的隱性
// 副作用(例如經 corp proxy 的 dev 環境會被迫改走直連)。
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY
c := *client
c.Transport = tr
client = &c
}
body, err := json.Marshal(exchangeRequest{
PairingToken: pairingToken,
Devices: e.collectLocalDevices(),
})
if err != nil {
return ExchangeResult{}, err
}
endpoint := strings.TrimRight(e.CloudAPIURL, "/") + "/api/pairing/exchange"
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return ExchangeResult{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return ExchangeResult{}, fmt.Errorf("%w: %v", ErrExchangeNetwork, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
switch resp.StatusCode {
case http.StatusOK:
var ok exchangeResponse
if err := json.Unmarshal(respBody, &ok); err != nil {
return ExchangeResult{}, fmt.Errorf("decode exchange response: %w", err)
}
// HTTP 200 + success:false 是 envelope 契約允許表達、但正常不該發生的異常組合。
// 先明確攔截避免落到下方「missing session_token」這個誤導性訊息。
if !ok.Success {
return ExchangeResult{}, fmt.Errorf("exchange returned success=false: %s", truncate(string(respBody), 256))
}
// session_token 在 envelope 的 data. 底下(見 exchangeResponse 註解)。
if ok.Data.SessionToken == "" {
return ExchangeResult{}, errors.New("exchange response missing session_token")
}
return ExchangeResult{
SessionToken: ok.Data.SessionToken,
Account: ok.Data.Account,
RelayURL: ok.Data.RelayURL,
}, nil
case http.StatusUnauthorized:
var errResp exchangeErrorResponse
_ = json.Unmarshal(respBody, &errResp)
// error code 在 envelope 的 error. 底下(見 exchangeErrorResponse 註解)。
return ExchangeResult{}, mapExchangeErrorCode(errResp.Error.Code)
case http.StatusNotFound:
// AB11 尚未完成時的清楚訊號;訊息指引使用者往 mock_mode 或等 AB11。
return ExchangeResult{}, fmt.Errorf("exchange endpoint not found (AB11 pending; set mock_mode or wait for backend deploy)")
default:
return ExchangeResult{}, fmt.Errorf("exchange failed: http %d: %s", resp.StatusCode, truncate(string(respBody), 256))
}
}
// collectLocalDevices 在 exchange 前撈本地 USB 清單、轉成 payload 形狀。
//
// 不弄壞守則device-serial-task1-mapping.md §2 段 4a撈清單失敗local
// server 沒起 / timeout / 0 裝置)**不可讓 exchange 失敗**——回 nil 讓 payload
// 省略 devices 欄位(`omitempty`雲端走現行「serial=NULL」路徑。
func (e *HTTPPairingExchanger) collectLocalDevices() []exchangeDevice {
if e.DeviceLister == nil {
return nil
}
devs, err := e.DeviceLister()
if err != nil {
e.logf("pairing: list local devices failed (exchange continues without serials): %v", err)
return nil
}
if len(devs) == 0 {
return nil
}
out := make([]exchangeDevice, 0, len(devs))
for _, d := range devs {
// serial 是 devices payload 的唯一用途(雲端據此填 serial_number 做 serial
// 路由)。空 serial 的裝置對雲端無意義、雲端本來就會濾掉——agent 端先濾,
// 減少 payload 面積、也讓語意對稱WP-0 review S-3
serial := strings.TrimSpace(d.SerialNumber)
if serial == "" {
continue
}
out = append(out, exchangeDevice{
SerialNumber: serial,
DeviceType: d.DeviceType,
Firmware: d.Firmware,
})
}
// 全部 serial 皆空 → 回 nil讓 payload 省略 devices 欄位omitempty
// 與「無 DeviceLister」的舊行為一致。
if len(out) == 0 {
return nil
}
return out
}
// logf 走注入的 Logfapp.go 的 appLog未注入時 fallback 標準 log。
func (e *HTTPPairingExchanger) logf(format string, args ...interface{}) {
if e.Logf != nil {
e.Logf(format, args...)
return
}
log.Printf(format, args...)
}
// localDeviceScanTimeout 是配對前觸發本地 `POST /api/devices/scan`Rescan
// timeout。
//
// 為什麼比舊的 2s 長:舊版打 `GET /api/devices` 只讀 Manager 快取毫秒級2s
// 綽綽有餘。改打 scan 端點後會觸發真實 USB 偵測kp.core.scan_devices 經 Python
// bridge第一次插上、SDK 冷啟或多顆 dongle 時可能耗數秒。timeout 太短會讓
// scan 還沒回就被 client 掐斷 → 每次配對都撈不到序號fallback 不帶 devices
// 等於這個修法白做。8s 給偵測足夠餘裕、又不至於在真的卡死時把配對拖太久
// (逾時走 fallback、配對照常進行、不中斷
const localDeviceScanTimeout = 8 * time.Second
// localDevicesEnvelope 對齊 local server device 端點的回應 envelope。
// `GET /api/devices`ListDevices快取與 `POST /api/devices/scan`ScanDevices
// 重新偵測)回傳同一個 `{ "success": true, "data": { "devices": [...] } }` 形狀,
// 差別只在後者會先跑一次 USB 偵測。此 struct 兩者共用。
//
// { "success": true, "data": { "devices": [ { "id", "serialNumber", "type", "firmwareVersion", ... } ] } }
//
// 只解需要的欄位serialNumber / type / firmwareVersion
type localDevicesEnvelope struct {
Success bool `json:"success"`
Data struct {
Devices []struct {
SerialNumber string `json:"serialNumber"`
Type string `json:"type"`
FirmwareVersion string `json:"firmwareVersion"`
} `json:"devices"`
} `json:"data"`
}
// NewLocalDeviceLister 建立一個向本地 server127.0.0.1:port觸發
// `POST /api/devices/scan`Rescan的 DeviceLister。port 由 app.go 從
// ServerController 取得後注入Exchanger 不自己猜 port
//
// 為什麼打 scan 而非 GET /api/devices時序修正
// agent 啟動時若 USB 尚未插好Manager 沒有 device session快取空。之後才插
// USB + 配對——但配對 exchange 若只讀快取GET /api/devices就撈到空序號進不了
// payload雲端只能建 serial=NULL 的 device前端顯示「尚未回報序號」。改打
// scan 端點讓配對前強制重新偵測一次 USB確保剛插上的實體序號能被撈到。
//
// scan 端點內部走 Manager.Rescan():對「序號身分未變」的既有 session 保持連線
// 狀態不動manager.go Rescan 的 serialIdentity 相等即 continue只會 disconnect
// 真的被拔除 / 位移的 stale 裝置——因此配對前 rescan 不會誤斷仍在線的既有 tunnel /
// inference session副作用安全。
//
// 失敗 / 逾時不中斷配對:回 error 由 collectLocalDevices 轉為「不帶 devices」
// 雲端 fallback 走現行 serial=NULL 路徑(見 DeviceLister 註解)。
func NewLocalDeviceLister(port int) DeviceLister {
client := &http.Client{Timeout: localDeviceScanTimeout}
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices/scan", port)
return func() ([]LocalDevice, error) {
// POST無 body觸發 Rescan。ScanDevices handler 回傳與 ListDevices 同形狀
// 的 envelope含剛偵測到的 devices一次呼叫即完成「rescan + 取清單」。
resp, err := client.Post(url, "application/json", nil)
if err != nil {
return nil, fmt.Errorf("local device scan: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("local device scan: http %d", resp.StatusCode)
}
var env localDevicesEnvelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
return nil, fmt.Errorf("local device scan: decode: %w", err)
}
out := make([]LocalDevice, 0, len(env.Data.Devices))
for _, d := range env.Data.Devices {
out = append(out, LocalDevice{
SerialNumber: d.SerialNumber,
DeviceType: d.Type,
Firmware: d.FirmwareVersion,
})
}
return out, nil
}
}
// mapExchangeErrorCode 把雲端錯誤 envelope 的 error.code 映射成 agent 內部 sentinel error。
//
// code 為雲端的大寫常數(見 visionA-backend api.ErrCodeInvalidPairingToken 等):
// - INVALID_PAIRING_TOKEN → ErrTokenInvalid
// - PAIRING_TOKEN_EXPIRED → ErrTokenExpired
// - PAIRING_TOKEN_USED → ErrTokenUsed
// - PAIRING_TOKEN_REVOKED → ErrTokenRevoked
//
// ⚠️ contract drift 修正:早期 agent 比對的是 token_invalid 等小寫值(與雲端不符),
// 導致所有 401 都落到 default 分支、前端拿不到正確的 error code 顯示對應 UI 文案。
func mapExchangeErrorCode(code string) error {
switch code {
case "INVALID_PAIRING_TOKEN":
return ErrTokenInvalid
case "PAIRING_TOKEN_EXPIRED":
return ErrTokenExpired
case "PAIRING_TOKEN_USED":
return ErrTokenUsed
case "PAIRING_TOKEN_REVOKED":
return ErrTokenRevoked
default:
return fmt.Errorf("%w (code=%q)", ErrTokenInvalid, code)
}
}
// generateMockSessionToken 產生 vAs_ + 64 hex 的假 tokenmock mode 用)。
func generateMockSessionToken() (string, error) {
buf := make([]byte, 32) // 32 bytes → 64 hex chars
if _, err := rand.Read(buf); err != nil {
return "", err
}
return "vAs_" + hex.EncodeToString(buf), nil
}
// MaskSessionToken 產生 Session Token 的遮蔽顯示字串,供 UI / log 使用。
// 格式:前綴(vAs_) + 前 8 hex + " ··· " + 後 4 hex例「vAs_a1b2c3d4 ··· e7f8」。
// 對齊 Design spec §4.2 (B) 的 Session Token 遮蔽規則。
func MaskSessionToken(token string) string {
if !strings.HasPrefix(token, "vAs_") {
// 非預期格式;回空字串避免洩漏
return ""
}
rest := strings.TrimPrefix(token, "vAs_")
if len(rest) < 12 {
return ""
}
return "vAs_" + rest[:8] + " ··· " + rest[len(rest)-4:]
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}