/** * Web 端:Tus 分片上传(与 Apifox / uni 版协议对齐);独立审核走上传服务 /upload/simple 等,不再调用 verify 审核域 */ import { useUserStore } from "@/stores/modules/user"; import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL, BASE_DEV_UPLOAD_SIMPLE } from "@/utils/config"; const TUS_VERSION = "1.0.0"; const TUS_CHUNK_SIZE = 1024 * 1024; function authHeader(): string { try { return useUserStore().token || ""; } catch { return ""; } } function pickPayload(res: unknown): unknown { if (res == null) return null; if (typeof res === "object" && !Array.isArray(res) && (res as any).data !== undefined) { return (res as any).data; } return res; } function headerGet(headers: Headers, name: string): string { const v = headers.get(name) || headers.get(name.toLowerCase()); return v ? String(v).trim() : ""; } function pickUploadIdFromLocation(loc: string): string { if (!loc) return ""; const m = String(loc).match(/\/files\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : ""; } export function pickUploadIdFromResponse(res: Response, body?: unknown): string { const h = res.headers; const idHdr = headerGet(h, "upload-id") || headerGet(h, "x-upload-id"); if (idHdr) return idHdr; const fromLoc = pickUploadIdFromLocation(headerGet(h, "location")); if (fromLoc) return fromLoc; const d = pickPayload(body ?? null); const inner = d && typeof d === "object" && (d as any).data !== undefined ? (d as any).data : d; const x = inner && typeof inner === "object" ? (inner as Record) : {}; const id = (x.upload_id as string) || (x.uploadId as string) || (x.id as string) || (typeof inner === "string" ? inner : "") || ""; return id || ""; } function pickFileUrlFromBody(body: unknown): string { const fromObj = (o: unknown): string => { if (!o || typeof o !== "object" || Array.isArray(o)) return ""; const r = o as Record; return r.url || r.file_url || r.fileUrl || r.access_url || r.accessUrl || r.ossUrl || r.cdnUrl || ""; }; const d = pickPayload(body); let inner = d && typeof d === "object" && (d as any).data !== undefined ? (d as any).data : d; let u = fromObj(inner); if (u) return u; if (inner && typeof inner === "object" && (inner as any).data !== undefined) { u = fromObj((inner as any).data); } if (u) return u; if (typeof inner === "string" && inner.startsWith("http")) return inner; return typeof d === "string" && d.startsWith("http") ? d : ""; } /** 从 simple 上传或列表接口的 data 中取封面地址 */ function pickCoverUrlFromBody(body: unknown): string { const d = pickPayload(body); let cur: any = d; if (cur && typeof cur === "object" && cur.data !== undefined && !Array.isArray(cur.data)) { cur = cur.data; } if (!cur || typeof cur !== "object") return ""; let cover = String(cur.cover || cur.coverUrl || cur.poster || cur.thumbnailUrl || cur.thumbnail || "").trim(); const imgUrlRaw = cur.imgUrl; if (!cover && imgUrlRaw != null) { if (typeof imgUrlRaw === "object" && imgUrlRaw !== null) { const o = imgUrlRaw as Record; cover = String(o.cover || o.coverUrl || o.poster || "").trim(); } else if (typeof imgUrlRaw === "string") { const s = imgUrlRaw.trim(); if (s.startsWith("{")) { try { const o = JSON.parse(s) as Record; cover = String(o.cover || o.coverUrl || o.poster || "").trim(); } catch { /* ignore */ } } } } return cover; } /** simple 上传返回的 fileUrl 可能为 JSON 字符串 {"cover","video"} */ function normalizeSimpleUploadUrls(body: unknown, fileUrl: string): { fileUrl: string; coverUrl?: string } { let url = (fileUrl || "").trim(); let coverUrl = pickCoverUrlFromBody(body); if (url.startsWith("{")) { try { const o = JSON.parse(url) as Record; if (o && typeof o === "object") { const v = String(o.video || o.videoUrl || o.url || "").trim(); const c = String(o.cover || o.coverUrl || o.poster || "").trim(); if (v) url = v; if (c && !coverUrl) coverUrl = c; } } catch { /* keep url as-is */ } } return { fileUrl: url, coverUrl: coverUrl || undefined }; } const DEV_SIMPLE_UPLOAD_PATH = "/upload/simple"; function buildDevSimpleUploadRequestUrl(): string { const base = String(BASE_DEV_UPLOAD_SIMPLE || "").replace(/\/$/, ""); if (base) { return `${base}${DEV_SIMPLE_UPLOAD_PATH}`; } return DEV_SIMPLE_UPLOAD_PATH; } /** * 官方相册等:multipart 直传(替代 Tus `POST .../upload`) * POST `/dev-upload-ailien/upload/simple`,表单字段 `file` */ export async function uploadFileViaDevSimpleEndpoint(file: File): Promise<{ fileUrl: string; coverUrl?: string }> { const reqUrl = buildDevSimpleUploadRequestUrl(); const fd = new FormData(); fd.append("file", file, file.name); const res = await fetch(reqUrl, { method: "POST", headers: { Authorization: authHeader() }, body: fd, credentials: "omit" }); let body: unknown = null; const ct = res.headers.get("content-type") || ""; if (ct.includes("application/json")) { try { body = await res.json(); } catch { body = null; } } else { const t = await res.text(); try { body = t ? JSON.parse(t) : null; } catch { body = t ? { raw: t } : null; } } if (res.status < 200 || res.status >= 300) { const msg = body && typeof body === "object" && (body as any).msg != null ? String((body as any).msg) : `上传失败(${res.status})`; throw new Error(msg); } if (body && typeof body === "object" && (body as any).code !== undefined) { const c = (body as any).code; if (c !== 200 && c !== 0) { throw new Error((body as any).msg || (body as any).message || "上传失败"); } } const rawUrl = pickFileUrlFromBody(body); if (!rawUrl) { throw new Error("上传完成但未返回文件地址"); } return normalizeSimpleUploadUrls(body, rawUrl); } /** 创建上传会话 POST /upload */ export async function createUploadSession(data: { filename: string; size: number; }): Promise<{ response: Response; body: unknown }> { const res = await fetch(`${BASE_AI_URL}/upload`, { method: "POST", headers: { "Content-Type": "application/json", "Tus-Resumable": TUS_VERSION, Authorization: authHeader() }, body: JSON.stringify(data) }); let body: unknown = null; const ct = res.headers.get("content-type") || ""; if (ct.includes("application/json")) { try { body = await res.json(); } catch { body = null; } } else { const t = await res.text(); body = t || null; } if (res.status < 200 || res.status >= 300) { const msg = body && typeof body === "object" && (body as any).message ? String((body as any).message) : `创建上传会话失败(${res.status})`; throw new Error(msg); } if (body && typeof body === "object" && (body as any).code !== undefined) { const c = (body as any).code; if (c !== 200 && c !== 0) { throw new Error((body as any).msg || (body as any).message || "创建上传会话失败"); } } return { response: res, body }; } /** PATCH 二进制分片 */ export async function patchBinaryToUpload(uploadId: string, arrayBuffer: ArrayBuffer, uploadOffset = 0): Promise { const url = `${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`; const res = await fetch(url, { method: "PATCH", headers: { Authorization: authHeader(), "Tus-Resumable": TUS_VERSION, "Content-Type": "application/offset+octet-stream", "Upload-Offset": String(uploadOffset) }, body: arrayBuffer }); if (res.status >= 200 && res.status < 300) { return res; } const t = await res.text().catch(() => ""); throw new Error(`上传失败(${res.status})${t ? ` ${t.slice(0, 200)}` : ""}`); } export async function patchBinaryToUploadChunked( uploadId: string, arrayBuffer: ArrayBuffer, onProgress?: (ratio: number) => void ): Promise { const total = arrayBuffer.byteLength; if (total === 0) { await patchBinaryToUpload(uploadId, new ArrayBuffer(0), 0); onProgress?.(1); return; } let offset = 0; while (offset < total) { const end = Math.min(offset + TUS_CHUNK_SIZE, total); const chunk = arrayBuffer.slice(offset, end); await patchBinaryToUpload(uploadId, chunk, offset); offset = end; onProgress?.(offset / total); } } /** HEAD /files/{id} */ export async function getUploadProgress(uploadId: string): Promise { return fetch(`${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`, { method: "HEAD", headers: { Authorization: authHeader(), "Tus-Resumable": TUS_VERSION, "Content-Type": "application/octet-stream" } }); } /** DELETE /files/{id} */ export async function deleteUploadSession(uploadId: string): Promise { await fetch(`${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`, { method: "DELETE", headers: { Authorization: authHeader(), "Tus-Resumable": TUS_VERSION } }); } /** POST /upload/{id}/finalize */ export async function finalizeUploadSession(uploadId: string, data: Record = {}): Promise { const res = await fetch(`${BASE_AI_URL}/upload/${encodeURIComponent(uploadId)}/finalize`, { method: "POST", headers: { "Content-Type": "application/json", "Tus-Resumable": TUS_VERSION, Authorization: authHeader() }, body: JSON.stringify(data) }); let body: unknown = null; const ct = res.headers.get("content-type") || ""; if (ct.includes("application/json")) { try { body = await res.json(); } catch { body = null; } } else { body = await res.text(); } if (res.status < 200 || res.status >= 300) { throw new Error(`上传完成确认失败(${res.status})`); } if (body && typeof body === "object" && (body as any).code !== undefined) { const c = (body as any).code; if (c !== 200 && c !== 0) { throw new Error((body as any).msg || (body as any).message || "上传完成确认失败"); } } return body; } /** * 是否为视频(MIME 或后缀),用于 Tus 上传命名等 */ export function isLikelyVideoFileForAiUpload(file: File): boolean { if (!(file instanceof File)) return false; if (typeof file.type === "string" && file.type.startsWith("video/")) return true; const n = file.name || ""; return /\.(mp4|m4v|webm|ogg|mov)(\?.*)?$/i.test(n); } /** * Tus 上传 → finalize → 返回公网 URL(图片/视频同一套上传;不含审核) */ async function uploadViaTusToPublicUrl(file: File, onProgress?: (p: number) => void): Promise { const report = (n: number) => onProgress?.(Math.min(100, Math.max(0, Math.round(n)))); report(5); const buf = await file.arrayBuffer(); const size = buf.byteLength; report(18); const defaultName = isLikelyVideoFileForAiUpload(file) ? `video_${Date.now()}.mp4` : `img_${Date.now()}.jpg`; const { response: sessionRes, body: sessionBody } = await createUploadSession({ filename: file.name || defaultName, size }); const uploadId = pickUploadIdFromResponse(sessionRes, sessionBody); if (!uploadId) { throw new Error("创建上传会话失败:未返回 upload_id"); } const publicUrl = `${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}`; report(35); await patchBinaryToUploadChunked(uploadId, buf, ratio => { report(35 + ratio * (82 - 35)); }); report(82); const finalizeBody = await finalizeUploadSession(uploadId, {}); report(90); const urlFromApi = pickFileUrlFromBody(finalizeBody); const fileUrl = urlFromApi || publicUrl; if (!fileUrl) { throw new Error("上传完成确认失败:未返回文件地址"); } return fileUrl; } export interface UploadFileViaAiOptions { /** 0~100 */ onProgress?: (progress: number) => void; /** 已废弃;保留仅为兼容旧调用,不再触发独立审核服务 */ skipModerate?: boolean; } /** * 图片与视频:Tus 上传 → finalize 得访问 URL(不再调用 verify / ai-moderate) */ export async function uploadFileViaAi(file: File, options: UploadFileViaAiOptions = {}): Promise { const { onProgress } = options; const report = (p: number) => { onProgress?.(Math.min(100, Math.max(0, Math.round(p)))); }; const fileUrl = await uploadViaTusToPublicUrl(file, onProgress); report(100); return fileUrl; } /** 多文件顺序上传,整体进度 0~100 */ export async function uploadFilesViaAi(files: File[], options: UploadFileViaAiOptions = {}): Promise { const list = files.filter(f => f instanceof File); if (!list.length) return []; const n = list.length; const urls: string[] = []; for (let i = 0; i < n; i++) { const u = await uploadFileViaAi(list[i], { ...options, onProgress: p => { const overall = (i / n) * 100 + (p / 100) * (100 / n); options.onProgress?.(overall); } }); urls.push(u); } return urls; } /** 与 uni 版 aiApi 命名对齐的聚合对象,便于按需解构 */ export const aiImageApi = { createUploadSession: (data: { filename: string; size: number }) => createUploadSession(data), patchBinaryToUpload, getUploadProgress, deleteUploadSession, finalizeUploadSession, uploadFileViaAi, uploadFilesViaAi, uploadFileViaDevSimpleEndpoint }; export default aiImageApi;