影片分頁上傳從舊 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>
136 lines
5.9 KiB
TypeScript
136 lines
5.9 KiB
TypeScript
/**
|
||
* 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 cookie(BFF)+ tunnel forward(保留不動)。
|
||
* - 本檔走「上傳走 localhost、控制面(token)走雲端」的混合路徑(ADR-019 §2.1)。
|
||
* - 影片分頁 **完全切 localhost、不做 tunnel fallback**(ADR §4.2「非同機停用分頁」),
|
||
* 故本檔不引用 media.ts 的 uploadVideo。
|
||
*
|
||
* 錯誤模型(讓 caller 能對 UI 分流 i18n,api-spec §6.5):
|
||
* - resolve 階段用 `LocalMediaError`(code: LOCAL_AGENT_NOT_FOUND / LOCAL_AGENT_MISMATCH)
|
||
* - 取 token / 上傳階段的 HTTP 錯誤沿用 api.ts 的 ApiError(code: LOCAL_TOKEN_INVALID /
|
||
* LOCAL_TOKEN_LIMIT / LOCAL_UPLOAD_TOO_LARGE / TUNNEL_DISCONNECTED / …)。
|
||
* caller(workspace-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 endpoint(api-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.3:issue-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 目前裝置 serialNumber(kn_number;ADR-018 serial 路由)
|
||
* @throws ApiError(TUNNEL_DISCONNECTED / LOCAL_TOKEN_LIMIT / … 由後端 envelope 帶出)
|
||
*/
|
||
export function getLocalUploadTicket(
|
||
serial: string,
|
||
): Promise<LocalUploadTicket> {
|
||
// api.post 已解 envelope、回 data;走 same-origin cookie(BFF),與其他雲端呼叫一致。
|
||
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 → 拋 LocalMediaError(caller 對應 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 裝置 serialNumber(kn_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,
|
||
});
|
||
}
|