aiImageUpload.ts 14 KB

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