visionA/visionA-frontend/src/lib/local-media.ts
jim800121chen b10fbb8091 feat(adr-019): 影片分頁接線 localhost 直連,上限 90MB→500MB(WP-4)
影片分頁上傳從舊 tunnel 路徑(/api/media/upload/video + 90MB)切到同機
localhost 直連(取 token → resolveLocalAgent → /api/local/media/upload/video)。
端到端啟用 ADR-019,取代 90MB 過渡限制。

- 新 lib/local-media.ts 編排層:getLocalUploadTicket + uploadVideoViaLocalAgent
- MAX_LOCAL_VIDEO_BYTES=500MB + validateLocalVideoFile(只綁 localhost 路徑;
  舊 MAX_VIDEO_BYTES=90MB + tunnel uploadVideo 完全不碰,向下相容)
- R-3 tunnel 離線三層防護:UI disable 不渲染 uploader + ticket 502 + 錯誤映射
- 5 種錯誤 i18n(NOT_FOUND/MISMATCH/離線/401/413)+ AbortError 靜默

reviewer 通過(0C/0M)。tsc/eslint/build 0 error、WP-4 相關 70 test pass。
⚠️ 實機驗證(真序號 hash 同形 fail-closed / PNA / 500MB 大檔實傳 / 混合路徑
結果面)待 stage 部署後驗證。Minor M-1/M-2 留 WP-6 一併處理。

Refs: ADR-019 WP-4。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 14:23:21 +08:00

136 lines
5.9 KiB
TypeScript
Raw 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.

/**
* Local-agent 直連上傳的「編排層」— visionA Cloud 前端ADR-019 WP-4
*
* 職責(把 WP-3 的低階 util 串成影片分頁要用的完整流程):
* 1. `getLocalUploadTicket(serial)`:向雲端 `POST /api/devices/:serial/local-upload-ticket`
* 取 one-time upload token走既有 OIDC cookie session + tunnel見 api-spec §6.3)。
* 2. `uploadVideoViaLocalAgent(serial, file, opts)`:取 token → 探測同機 local-agent
* → 直連 POST `/api/local/media/upload/video`(帶 X-Visiona-Local-Token
*
* 與 media.ts雲端 tunnel 路徑)的分工:
* - media.ts 的 uploadVideo 走雲端 same-origin cookieBFF+ tunnel forward保留不動
* - 本檔走「上傳走 localhost、控制面token走雲端」的混合路徑ADR-019 §2.1)。
* - 影片分頁 **完全切 localhost、不做 tunnel fallback**ADR §4.2「非同機停用分頁」),
* 故本檔不引用 media.ts 的 uploadVideo。
*
* 錯誤模型(讓 caller 能對 UI 分流 i18napi-spec §6.5
* - resolve 階段用 `LocalMediaError`code: LOCAL_AGENT_NOT_FOUND / LOCAL_AGENT_MISMATCH
* - 取 token / 上傳階段的 HTTP 錯誤沿用 api.ts 的 ApiErrorcode: LOCAL_TOKEN_INVALID /
* LOCAL_TOKEN_LIMIT / LOCAL_UPLOAD_TOO_LARGE / TUNNEL_DISCONNECTED / …)。
* callerworkspace-client 影片分頁)以 error.code 對應具體 i18n 文案。
*/
import { api } from "@/lib/api";
import {
LOCAL_UPLOAD_VIDEO_PATH,
resolveLocalAgent,
uploadToLocalAgent,
} from "@/lib/local-agent";
import type { UploadMediaOptions } from "@/lib/media";
import type { KnownErrorCode } from "@/types/api";
import type { MediaUploadResponse } from "@/types/camera";
/** 雲端 ticket endpointapi-spec §6.3;驗 OIDC session + 裝置歸屬後經 tunnel issue-token。 */
export const LOCAL_UPLOAD_TICKET_PATH = (serial: string): string =>
`/api/devices/${encodeURIComponent(serial)}/local-upload-ticket`;
/** ticket endpoint 回傳api-spec §6.3issue-token 的 data 透傳)。 */
export interface LocalUploadTicket {
token: string;
/** token 到期時間unix ms。 */
expiresAt?: number;
/** TTL 秒數(契約固定 120s。 */
ttlSeconds?: number;
}
/**
* resolve local-agent 階段的錯誤(純前端狀態、非後端回傳的 HTTP 錯誤)。
*
* 為什麼獨立一個 Error 類別而非沿用 ApiError
* NOT_FOUND / MISMATCH 是「前端掃描結論」api-spec §6.5 標「—(前端內部狀態)」),
* 沒有 HTTP status用專屬類別讓 caller 能 `instanceof` 精準分流,且 code 型別安全。
*/
export class LocalMediaError extends Error {
readonly code: Extract<
KnownErrorCode,
"LOCAL_AGENT_NOT_FOUND" | "LOCAL_AGENT_MISMATCH"
>;
constructor(
code: LocalMediaError["code"],
message = code,
) {
super(message);
this.name = "LocalMediaError";
this.code = code;
}
}
/**
* 向雲端要 one-time upload token走既有 OIDC cookie session + tunnel
*
* @param serial 目前裝置 serialNumberkn_numberADR-018 serial 路由)
* @throws ApiErrorTUNNEL_DISCONNECTED / LOCAL_TOKEN_LIMIT / … 由後端 envelope 帶出)
*/
export function getLocalUploadTicket(
serial: string,
): Promise<LocalUploadTicket> {
// api.post 已解 envelope、回 data走 same-origin cookieBFF與其他雲端呼叫一致。
return api.post<LocalUploadTicket>(LOCAL_UPLOAD_TICKET_PATH(serial));
}
/** uploadVideoViaLocalAgent 的選項(沿用 media 的進度 / 取消 / timeout。 */
export type UploadLocalVideoOptions = UploadMediaOptions;
/**
* 影片分頁的完整直連上傳流程ADR-019 §2.4 契約 [1]→[4])。
*
* 流程:
* 1. 取 token雲端 ticket— 失敗直接往上拋ApiError
* 2. resolveLocalAgent(serial) → 探測 + 同機 + serial 身分驗證。
* NOT_FOUND / MISMATCH → 拋 LocalMediaErrorcaller 對應 i18n
* 3. uploadToLocalAgent(port, /api/local/media/upload/video, form, { token })。
*
* **前提caller 責任)**:呼叫前須已確認裝置 tunnel 在線R-3。tunnel 離線時
* token 取不到(雲端經 tunnel issue-token 會失敗),但為了給使用者清楚提示,
* caller影片分頁應在 UI 層先 disable 上傳、不進到這裡(見 workspace-client
*
* @param serial 裝置 serialNumberkn_number
* @param file 影片檔(副檔名 / 大小驗證由 caller 先做,見 media.ts validateLocalVideoFile
* @param options 進度 / 取消 / timeout
* @returns 解開 envelope 的 MediaUploadResponse含 streamUrl / totalFrames / durationSeconds
* @throws LocalMediaError | ApiError | NetworkError | TimeoutError | AbortError
*/
export async function uploadVideoViaLocalAgent(
serial: string,
file: File,
options: UploadLocalVideoOptions = {},
): Promise<MediaUploadResponse> {
// [1] 取 token雲端此步失敗會拋 ApiError含 TUNNEL_DISCONNECTED 等)
const ticket = await getLocalUploadTicket(serial);
// 已取消就不必再探測 / 上傳
if (options.signal?.aborted) {
// 讓 caller 的 abort 分支一致:沿用 local-agent 的 AbortError透過 upload 拋)
// 這裡直接進入 upload 前的 guard 由 uploadToLocalAgent 處理 aborted signal。
}
// [2] 探測同機 local-agent + serial 身分驗證
const resolved = await resolveLocalAgent(serial);
if (resolved.status === "NOT_FOUND") {
throw new LocalMediaError("LOCAL_AGENT_NOT_FOUND");
}
if (resolved.status === "MISMATCH" || resolved.port === undefined) {
throw new LocalMediaError("LOCAL_AGENT_MISMATCH");
}
// [3] 直連上傳FormData 欄位名維持 deviceId、值帶 serial與雲端 route 相同)
const form = new FormData();
form.append("deviceId", serial);
form.append("file", file);
return uploadToLocalAgent(resolved.port, LOCAL_UPLOAD_VIDEO_PATH, form, {
...options,
token: ticket.token,
});
}