| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- /**
- * 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<string, unknown>) : {};
- 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<string, string>;
- 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<string, string>;
- 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<string, string>;
- 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<string, string>;
- 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<Response> {
- 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<void> {
- 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<Response> {
- 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<void> {
- 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<string, unknown> = {}): Promise<unknown> {
- 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<string> {
- 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<string> {
- 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<string[]> {
- 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;
|