aiImageUpload.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. /**
  2. * Web 端:Tus 分片上传(与 Apifox / uni 版协议对齐);独立审核走上传服务 /upload/simple 等,不再调用 verify 审核域
  3. */
  4. import { useUserStore } from "@/stores/modules/user";
  5. import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL, BASE_DEV_UPLOAD_SIMPLE } from "@/utils/config";
  6. const TUS_VERSION = "1.0.0";
  7. const TUS_CHUNK_SIZE = 1024 * 1024;
  8. function authHeader(): string {
  9. try {
  10. return useUserStore().token || "";
  11. } catch {
  12. return "";
  13. }
  14. }
  15. function pickPayload(res: unknown): unknown {
  16. if (res == null) return null;
  17. if (typeof res === "object" && !Array.isArray(res) && (res as any).data !== undefined) {
  18. return (res as any).data;
  19. }
  20. return res;
  21. }
  22. function headerGet(headers: Headers, name: string): string {
  23. const v = headers.get(name) || headers.get(name.toLowerCase());
  24. return v ? String(v).trim() : "";
  25. }
  26. function pickUploadIdFromLocation(loc: string): string {
  27. if (!loc) return "";
  28. const m = String(loc).match(/\/files\/([^/?#]+)/);
  29. return m ? decodeURIComponent(m[1]) : "";
  30. }
  31. export function pickUploadIdFromResponse(res: Response, body?: unknown): string {
  32. const h = res.headers;
  33. const idHdr = headerGet(h, "upload-id") || headerGet(h, "x-upload-id");
  34. if (idHdr) return idHdr;
  35. const fromLoc = pickUploadIdFromLocation(headerGet(h, "location"));
  36. if (fromLoc) return fromLoc;
  37. const d = pickPayload(body ?? null);
  38. const inner = d && typeof d === "object" && (d as any).data !== undefined ? (d as any).data : d;
  39. const x = inner && typeof inner === "object" ? (inner as Record<string, unknown>) : {};
  40. const id =
  41. (x.upload_id as string) || (x.uploadId as string) || (x.id as string) || (typeof inner === "string" ? inner : "") || "";
  42. return id || "";
  43. }
  44. function pickFileUrlFromBody(body: unknown): string {
  45. const fromObj = (o: unknown): string => {
  46. if (!o || typeof o !== "object" || Array.isArray(o)) return "";
  47. const r = o as Record<string, string>;
  48. return r.url || r.file_url || r.fileUrl || r.access_url || r.accessUrl || r.ossUrl || r.cdnUrl || "";
  49. };
  50. const d = pickPayload(body);
  51. let inner = d && typeof d === "object" && (d as any).data !== undefined ? (d as any).data : d;
  52. let u = fromObj(inner);
  53. if (u) return u;
  54. if (inner && typeof inner === "object" && (inner as any).data !== undefined) {
  55. u = fromObj((inner as any).data);
  56. }
  57. if (u) return u;
  58. if (typeof inner === "string" && inner.startsWith("http")) return inner;
  59. return typeof d === "string" && d.startsWith("http") ? d : "";
  60. }
  61. /** 从 simple 上传或列表接口的 data 中取封面地址 */
  62. function pickCoverUrlFromBody(body: unknown): string {
  63. const d = pickPayload(body);
  64. let cur: any = d;
  65. if (cur && typeof cur === "object" && cur.data !== undefined && !Array.isArray(cur.data)) {
  66. cur = cur.data;
  67. }
  68. if (!cur || typeof cur !== "object") return "";
  69. let cover = String(cur.cover || cur.coverUrl || cur.poster || cur.thumbnailUrl || cur.thumbnail || "").trim();
  70. const imgUrlRaw = cur.imgUrl;
  71. if (!cover && imgUrlRaw != null) {
  72. if (typeof imgUrlRaw === "object" && imgUrlRaw !== null) {
  73. const o = imgUrlRaw as Record<string, string>;
  74. cover = String(o.cover || o.coverUrl || o.poster || "").trim();
  75. } else if (typeof imgUrlRaw === "string") {
  76. const s = imgUrlRaw.trim();
  77. if (s.startsWith("{")) {
  78. try {
  79. const o = JSON.parse(s) as Record<string, string>;
  80. cover = String(o.cover || o.coverUrl || o.poster || "").trim();
  81. } catch {
  82. /* ignore */
  83. }
  84. }
  85. }
  86. }
  87. return cover;
  88. }
  89. /** simple 上传返回的 fileUrl 可能为 JSON 字符串 {"cover","video"} */
  90. function normalizeSimpleUploadUrls(body: unknown, fileUrl: string): { fileUrl: string; coverUrl?: string } {
  91. let url = (fileUrl || "").trim();
  92. let coverUrl = pickCoverUrlFromBody(body);
  93. if (url.startsWith("{")) {
  94. try {
  95. const o = JSON.parse(url) as Record<string, string>;
  96. if (o && typeof o === "object") {
  97. const v = String(o.video || o.videoUrl || o.url || "").trim();
  98. const c = String(o.cover || o.coverUrl || o.poster || "").trim();
  99. if (v) url = v;
  100. if (c && !coverUrl) coverUrl = c;
  101. }
  102. } catch {
  103. /* keep url as-is */
  104. }
  105. }
  106. return { fileUrl: url, coverUrl: coverUrl || undefined };
  107. }
  108. const DEV_SIMPLE_UPLOAD_PATH = "/upload/simple";
  109. function buildDevSimpleUploadRequestUrl(): string {
  110. const base = String(BASE_DEV_UPLOAD_SIMPLE || "").replace(/\/$/, "");
  111. if (base) {
  112. return `${base}${DEV_SIMPLE_UPLOAD_PATH}`;
  113. }
  114. return DEV_SIMPLE_UPLOAD_PATH;
  115. }
  116. /**
  117. * 官方相册等:multipart 直传(替代 Tus `POST .../upload`)
  118. * POST `/dev-upload-ailien/upload/simple`,表单字段 `file`
  119. */
  120. export async function uploadFileViaDevSimpleEndpoint(file: File): Promise<{ fileUrl: string; coverUrl?: string }> {
  121. const reqUrl = buildDevSimpleUploadRequestUrl();
  122. const fd = new FormData();
  123. fd.append("file", file, file.name);
  124. const res = await fetch(reqUrl, {
  125. method: "POST",
  126. headers: {
  127. Authorization: authHeader()
  128. },
  129. body: fd,
  130. credentials: "omit"
  131. });
  132. let body: unknown = null;
  133. const ct = res.headers.get("content-type") || "";
  134. if (ct.includes("application/json")) {
  135. try {
  136. body = await res.json();
  137. } catch {
  138. body = null;
  139. }
  140. } else {
  141. const t = await res.text();
  142. try {
  143. body = t ? JSON.parse(t) : null;
  144. } catch {
  145. body = t ? { raw: t } : null;
  146. }
  147. }
  148. if (res.status < 200 || res.status >= 300) {
  149. const msg =
  150. body && typeof body === "object" && (body as any).msg != null ? String((body as any).msg) : `上传失败(${res.status})`;
  151. throw new Error(msg);
  152. }
  153. if (body && typeof body === "object" && (body as any).code !== undefined) {
  154. const c = (body as any).code;
  155. if (c !== 200 && c !== 0) {
  156. throw new Error((body as any).msg || (body as any).message || "上传失败");
  157. }
  158. }
  159. const rawUrl = pickFileUrlFromBody(body);
  160. if (!rawUrl) {
  161. throw new Error("上传完成但未返回文件地址");
  162. }
  163. return normalizeSimpleUploadUrls(body, rawUrl);
  164. }
  165. /** 创建上传会话 POST /upload */
  166. export async function createUploadSession(data: {
  167. filename: string;
  168. size: number;
  169. }): Promise<{ response: Response; body: unknown }> {
  170. const res = await fetch(`${BASE_AI_URL}/upload`, {
  171. method: "POST",
  172. headers: {
  173. "Content-Type": "application/json",
  174. "Tus-Resumable": TUS_VERSION,
  175. Authorization: authHeader()
  176. },
  177. body: JSON.stringify(data)
  178. });
  179. let body: unknown = null;
  180. const ct = res.headers.get("content-type") || "";
  181. if (ct.includes("application/json")) {
  182. try {
  183. body = await res.json();
  184. } catch {
  185. body = null;
  186. }
  187. } else {
  188. const t = await res.text();
  189. body = t || null;
  190. }
  191. if (res.status < 200 || res.status >= 300) {
  192. const msg =
  193. body && typeof body === "object" && (body as any).message
  194. ? String((body as any).message)
  195. : `创建上传会话失败(${res.status})`;
  196. throw new Error(msg);
  197. }
  198. if (body && typeof body === "object" && (body as any).code !== undefined) {
  199. const c = (body as any).code;
  200. if (c !== 200 && c !== 0) {
  201. throw new Error((body as any).msg || (body as any).message || "创建上传会话失败");
  202. }
  203. }
  204. return { response: res, body };
  205. }
  206. /** PATCH 二进制分片 */
  207. export async function patchBinaryToUpload(uploadId: string, arrayBuffer: ArrayBuffer, uploadOffset = 0): Promise<Response> {
  208. const url = `${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`;
  209. const res = await fetch(url, {
  210. method: "PATCH",
  211. headers: {
  212. Authorization: authHeader(),
  213. "Tus-Resumable": TUS_VERSION,
  214. "Content-Type": "application/offset+octet-stream",
  215. "Upload-Offset": String(uploadOffset)
  216. },
  217. body: arrayBuffer
  218. });
  219. if (res.status >= 200 && res.status < 300) {
  220. return res;
  221. }
  222. const t = await res.text().catch(() => "");
  223. throw new Error(`上传失败(${res.status})${t ? ` ${t.slice(0, 200)}` : ""}`);
  224. }
  225. export async function patchBinaryToUploadChunked(
  226. uploadId: string,
  227. arrayBuffer: ArrayBuffer,
  228. onProgress?: (ratio: number) => void
  229. ): Promise<void> {
  230. const total = arrayBuffer.byteLength;
  231. if (total === 0) {
  232. await patchBinaryToUpload(uploadId, new ArrayBuffer(0), 0);
  233. onProgress?.(1);
  234. return;
  235. }
  236. let offset = 0;
  237. while (offset < total) {
  238. const end = Math.min(offset + TUS_CHUNK_SIZE, total);
  239. const chunk = arrayBuffer.slice(offset, end);
  240. await patchBinaryToUpload(uploadId, chunk, offset);
  241. offset = end;
  242. onProgress?.(offset / total);
  243. }
  244. }
  245. /** HEAD /files/{id} */
  246. export async function getUploadProgress(uploadId: string): Promise<Response> {
  247. return fetch(`${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`, {
  248. method: "HEAD",
  249. headers: {
  250. Authorization: authHeader(),
  251. "Tus-Resumable": TUS_VERSION,
  252. "Content-Type": "application/octet-stream"
  253. }
  254. });
  255. }
  256. /** DELETE /files/{id} */
  257. export async function deleteUploadSession(uploadId: string): Promise<void> {
  258. await fetch(`${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`, {
  259. method: "DELETE",
  260. headers: {
  261. Authorization: authHeader(),
  262. "Tus-Resumable": TUS_VERSION
  263. }
  264. });
  265. }
  266. /** POST /upload/{id}/finalize */
  267. export async function finalizeUploadSession(uploadId: string, data: Record<string, unknown> = {}): Promise<unknown> {
  268. const res = await fetch(`${BASE_AI_URL}/upload/${encodeURIComponent(uploadId)}/finalize`, {
  269. method: "POST",
  270. headers: {
  271. "Content-Type": "application/json",
  272. "Tus-Resumable": TUS_VERSION,
  273. Authorization: authHeader()
  274. },
  275. body: JSON.stringify(data)
  276. });
  277. let body: unknown = null;
  278. const ct = res.headers.get("content-type") || "";
  279. if (ct.includes("application/json")) {
  280. try {
  281. body = await res.json();
  282. } catch {
  283. body = null;
  284. }
  285. } else {
  286. body = await res.text();
  287. }
  288. if (res.status < 200 || res.status >= 300) {
  289. throw new Error(`上传完成确认失败(${res.status})`);
  290. }
  291. if (body && typeof body === "object" && (body as any).code !== undefined) {
  292. const c = (body as any).code;
  293. if (c !== 200 && c !== 0) {
  294. throw new Error((body as any).msg || (body as any).message || "上传完成确认失败");
  295. }
  296. }
  297. return body;
  298. }
  299. /**
  300. * 是否为视频(MIME 或后缀),用于 Tus 上传命名等
  301. */
  302. export function isLikelyVideoFileForAiUpload(file: File): boolean {
  303. if (!(file instanceof File)) return false;
  304. if (typeof file.type === "string" && file.type.startsWith("video/")) return true;
  305. const n = file.name || "";
  306. return /\.(mp4|m4v|webm|ogg|mov)(\?.*)?$/i.test(n);
  307. }
  308. /**
  309. * Tus 上传 → finalize → 返回公网 URL(图片/视频同一套上传;不含审核)
  310. */
  311. async function uploadViaTusToPublicUrl(file: File, onProgress?: (p: number) => void): Promise<string> {
  312. const report = (n: number) => onProgress?.(Math.min(100, Math.max(0, Math.round(n))));
  313. report(5);
  314. const buf = await file.arrayBuffer();
  315. const size = buf.byteLength;
  316. report(18);
  317. const defaultName = isLikelyVideoFileForAiUpload(file) ? `video_${Date.now()}.mp4` : `img_${Date.now()}.jpg`;
  318. const { response: sessionRes, body: sessionBody } = await createUploadSession({
  319. filename: file.name || defaultName,
  320. size
  321. });
  322. const uploadId = pickUploadIdFromResponse(sessionRes, sessionBody);
  323. if (!uploadId) {
  324. throw new Error("创建上传会话失败:未返回 upload_id");
  325. }
  326. const publicUrl = `${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}`;
  327. report(35);
  328. await patchBinaryToUploadChunked(uploadId, buf, ratio => {
  329. report(35 + ratio * (82 - 35));
  330. });
  331. report(82);
  332. const finalizeBody = await finalizeUploadSession(uploadId, {});
  333. report(90);
  334. const urlFromApi = pickFileUrlFromBody(finalizeBody);
  335. const fileUrl = urlFromApi || publicUrl;
  336. if (!fileUrl) {
  337. throw new Error("上传完成确认失败:未返回文件地址");
  338. }
  339. return fileUrl;
  340. }
  341. export interface UploadFileViaAiOptions {
  342. /** 0~100 */
  343. onProgress?: (progress: number) => void;
  344. /** 已废弃;保留仅为兼容旧调用,不再触发独立审核服务 */
  345. skipModerate?: boolean;
  346. }
  347. /**
  348. * 图片与视频:Tus 上传 → finalize 得访问 URL(不再调用 verify / ai-moderate)
  349. */
  350. export async function uploadFileViaAi(file: File, options: UploadFileViaAiOptions = {}): Promise<string> {
  351. const { onProgress } = options;
  352. const report = (p: number) => {
  353. onProgress?.(Math.min(100, Math.max(0, Math.round(p))));
  354. };
  355. const fileUrl = await uploadViaTusToPublicUrl(file, onProgress);
  356. report(100);
  357. return fileUrl;
  358. }
  359. /** 多文件顺序上传,整体进度 0~100 */
  360. export async function uploadFilesViaAi(files: File[], options: UploadFileViaAiOptions = {}): Promise<string[]> {
  361. const list = files.filter(f => f instanceof File);
  362. if (!list.length) return [];
  363. const n = list.length;
  364. const urls: string[] = [];
  365. for (let i = 0; i < n; i++) {
  366. const u = await uploadFileViaAi(list[i], {
  367. ...options,
  368. onProgress: p => {
  369. const overall = (i / n) * 100 + (p / 100) * (100 / n);
  370. options.onProgress?.(overall);
  371. }
  372. });
  373. urls.push(u);
  374. }
  375. return urls;
  376. }
  377. /** 与 uni 版 aiApi 命名对齐的聚合对象,便于按需解构 */
  378. export const aiImageApi = {
  379. createUploadSession: (data: { filename: string; size: number }) => createUploadSession(data),
  380. patchBinaryToUpload,
  381. getUploadProgress,
  382. deleteUploadSession,
  383. finalizeUploadSession,
  384. uploadFileViaAi,
  385. uploadFilesViaAi,
  386. uploadFileViaDevSimpleEndpoint
  387. };
  388. export default aiImageApi;