4b10dc800e
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 1m37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m58s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m29s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m27s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m31s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m52s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 15s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 23s
195 lines
7.3 KiB
TypeScript
195 lines
7.3 KiB
TypeScript
/**
|
||
* 上传相关 API(表单上传 + OSS 直传)
|
||
*/
|
||
import apiClient from "../client"
|
||
import { getOrCreateDefaultProject } from "../projects"
|
||
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
||
import { computeFileHash, makeClientUploadId } from "./uploadDedup"
|
||
|
||
/** 预签名直传准备 */
|
||
export const prepareDirectUpload = async (data: {
|
||
project_id: string
|
||
library_id: string
|
||
filename: string
|
||
content_type: string
|
||
file_size: number
|
||
/** 前端算好的文件内容哈希(SHA-256 hex),打开后端 file_hash 去重闸门 */
|
||
file_hash?: string
|
||
/** 前端生成的上传幂等 token,同一次逻辑上传(含重试)保持不变 */
|
||
client_upload_id?: string
|
||
}): Promise<DirectUploadPrepareResult> => {
|
||
// prepare 单独放宽到 30s(全局 axios 实例只有 10s,staging 抖动时易超时)
|
||
const response = await apiClient.post("/upload/direct/prepare", data, { timeout: 30_000 })
|
||
return response.data
|
||
}
|
||
|
||
/** 直传完成确认 */
|
||
export const completeDirectUpload = async (data: {
|
||
project_id: string
|
||
library_id: string
|
||
storage_key: string
|
||
/** 前端算好的文件内容哈希(与 prepare 一致),后端按 hash 幂等去重 */
|
||
file_hash?: string
|
||
/** 前端上传幂等 token(与 prepare 一致),同一次上传重发 complete 不重复建记录 */
|
||
client_upload_id?: string
|
||
}): Promise<DirectUploadCompleteResult> => {
|
||
// complete 内含 OSS 存在性检查 + 建库 + 派单,放宽到 60s;
|
||
// 超时不代表失败(记录可能已建成),调用方禁止超时后盲目重传整个文件
|
||
const response = await apiClient.post("/upload/direct/complete", data, { timeout: 60_000 })
|
||
return response.data
|
||
}
|
||
|
||
/** 直传 OSS 的底层传输(POST 表单到 OSS),带进度回调 */
|
||
const putToOSS = (
|
||
prepared: DirectUploadPrepareResult,
|
||
file: File,
|
||
onProgress?: (percent: number) => void,
|
||
): Promise<void> =>
|
||
new Promise<void>((resolve, reject) => {
|
||
const directForm = new FormData()
|
||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||
directForm.append("file", file)
|
||
|
||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||
const xhr = new XMLHttpRequest()
|
||
xhr.open(prepared.method, prepared.upload_url)
|
||
|
||
// 超时 10 分钟
|
||
xhr.timeout = 10 * 60 * 1000
|
||
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable && onProgress) {
|
||
onProgress(Math.round((e.loaded / e.total) * 100))
|
||
}
|
||
}
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
resolve()
|
||
} else {
|
||
// 解析 OSS 返回的 XML 错误信息
|
||
let ossError = ""
|
||
try {
|
||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
|
||
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
|
||
if (codeMatch || msgMatch) {
|
||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
|
||
}
|
||
} catch {
|
||
// 无法解析响应体
|
||
}
|
||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
|
||
console.error("[OSS Upload] 直传失败:", {
|
||
url: prepared.upload_url,
|
||
storage_key: prepared.storage_key,
|
||
status: xhr.status,
|
||
statusText: xhr.statusText,
|
||
})
|
||
reject(new Error(detail))
|
||
}
|
||
}
|
||
xhr.onerror = () => {
|
||
console.error("[OSS Upload] 网络错误:", {
|
||
url: prepared.upload_url,
|
||
storage_key: prepared.storage_key,
|
||
})
|
||
reject(new Error("OSS 上传网络错误,请检查网络连接"))
|
||
}
|
||
xhr.ontimeout = () => {
|
||
console.error("[OSS Upload] 上传超时:", {
|
||
url: prepared.upload_url,
|
||
storage_key: prepared.storage_key,
|
||
})
|
||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
|
||
}
|
||
xhr.send(directForm)
|
||
})
|
||
|
||
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */
|
||
export interface DirectUploadHandle {
|
||
/** prepare 返回(含可能的预建 asset_id) */
|
||
prepared: DirectUploadPrepareResult
|
||
/** 直传 OSS(可重复调用用于重试) */
|
||
transfer: (onProgress?: (percent: number) => void) => Promise<void>
|
||
/** 直传完成后调用 complete 确认入库 */
|
||
complete: () => Promise<DirectUploadCompleteResult>
|
||
}
|
||
|
||
/**
|
||
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
|
||
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
|
||
*/
|
||
export const prepareDirectUploadHandle = async (data: {
|
||
file: File
|
||
library_id: string
|
||
/** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */
|
||
fileHash?: string
|
||
/** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */
|
||
clientUploadId?: string
|
||
}): Promise<DirectUploadHandle> => {
|
||
// 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示,
|
||
// 不与 prepare 的签名接口错误混在一起
|
||
let project: Awaited<ReturnType<typeof getOrCreateDefaultProject>>
|
||
try {
|
||
project = await getOrCreateDefaultProject()
|
||
} catch (err) {
|
||
const reason = err instanceof Error ? err.message : "网络异常"
|
||
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
|
||
}
|
||
|
||
const prepared = await prepareDirectUpload({
|
||
project_id: project.id,
|
||
library_id: data.library_id,
|
||
filename: data.file.name,
|
||
content_type: data.file.type || "application/octet-stream",
|
||
file_size: data.file.size,
|
||
file_hash: data.fileHash,
|
||
client_upload_id: data.clientUploadId,
|
||
})
|
||
|
||
return {
|
||
prepared,
|
||
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
|
||
complete: () =>
|
||
completeDirectUpload({
|
||
project_id: project.id,
|
||
library_id: data.library_id,
|
||
storage_key: prepared.storage_key,
|
||
file_hash: data.fileHash,
|
||
client_upload_id: data.clientUploadId,
|
||
}),
|
||
}
|
||
}
|
||
|
||
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
|
||
export const uploadAssetDirect = async (data: {
|
||
file: File
|
||
library_id: string
|
||
onProgress?: (percent: number) => void
|
||
/** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */
|
||
fileHash?: string
|
||
/** 幂等 token;未传时自动生成 */
|
||
clientUploadId?: string
|
||
}): Promise<DirectUploadCompleteResult> => {
|
||
// 自动补算哈希与幂等 token:确保 file_hash 去重闸门对所有上传链路生效
|
||
const fileHash = data.fileHash ?? (await computeFileHash(data.file))
|
||
const clientUploadId = data.clientUploadId ?? makeClientUploadId()
|
||
const handle = await prepareDirectUploadHandle({
|
||
file: data.file,
|
||
library_id: data.library_id,
|
||
fileHash,
|
||
clientUploadId,
|
||
})
|
||
// prepare 阶段后端 file_hash 命中素材库已有相同文件:跳过 transfer + complete
|
||
if (handle.prepared.skip_transfer || handle.prepared.duplicated) {
|
||
return {
|
||
storage_key: handle.prepared.storage_key,
|
||
ingest_job_id: "",
|
||
url: "",
|
||
duplicated: true,
|
||
asset_id: handle.prepared.asset_id,
|
||
}
|
||
}
|
||
await handle.transfer(data.onProgress)
|
||
return handle.complete()
|
||
}
|