aiImageUpload.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. /**
  2. * Web 端:Tus 分片上传 + AI 图片审核(与 Apifox / uni 版协议对齐)
  3. */
  4. import { useUserStore } from "@/stores/modules/user";
  5. import { ResultEnum } from "@/enums/httpEnum";
  6. import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_MODERATE_URL, BASE_AI_URL } from "@/utils/config";
  7. const TUS_VERSION = "1.0.0";
  8. const TUS_CHUNK_SIZE = 1024 * 1024;
  9. const DEFAULT_MODERATE_USER_TIP = "图片未通过内容审核,请更换后重试";
  10. function authHeader(): string {
  11. try {
  12. return useUserStore().token || "";
  13. } catch {
  14. return "";
  15. }
  16. }
  17. function mapModerateReasonToUserMessage(raw?: string | null): string {
  18. const s = raw == null ? "" : String(raw).trim();
  19. if (!s) return DEFAULT_MODERATE_USER_TIP;
  20. if (/赌|赌博|casino|gambl/i.test(s)) return "图片涉及赌博等违规内容,请更换后重试";
  21. if (/色情|淫秽|porn|sex/i.test(s)) return "图片涉及色情等违规内容,请更换后重试";
  22. if (/暴力|血腥|violence|gore/i.test(s)) return "图片涉及暴力等违规内容,请更换后重试";
  23. if (/辱骂|谩骂|人身攻击|abuse|insult/i.test(s)) return "图片涉及不文明内容,请更换后重试";
  24. if (/政治|谣言/i.test(s)) return "图片涉及违规信息,请更换后重试";
  25. if (/广告|spam|营销/i.test(s)) return "图片涉及违规推广内容,请更换后重试";
  26. if (/违法|违禁|illegal/i.test(s)) return "图片含违法违规内容,请更换后重试";
  27. if (/黄赌毒/i.test(s)) return "请勿上传涉黄、涉赌、涉毒等违规内容";
  28. if (/审核|moderat|vlm|violation|flagged/i.test(s)) return DEFAULT_MODERATE_USER_TIP;
  29. return DEFAULT_MODERATE_USER_TIP;
  30. }
  31. function pickPayload(res: unknown): unknown {
  32. if (res == null) return null;
  33. if (typeof res === "object" && !Array.isArray(res) && (res as any).data !== undefined) {
  34. return (res as any).data;
  35. }
  36. return res;
  37. }
  38. function headerGet(headers: Headers, name: string): string {
  39. const v = headers.get(name) || headers.get(name.toLowerCase());
  40. return v ? String(v).trim() : "";
  41. }
  42. function pickUploadIdFromLocation(loc: string): string {
  43. if (!loc) return "";
  44. const m = String(loc).match(/\/files\/([^/?#]+)/);
  45. return m ? decodeURIComponent(m[1]) : "";
  46. }
  47. export function pickUploadIdFromResponse(res: Response, body?: unknown): string {
  48. const h = res.headers;
  49. const idHdr = headerGet(h, "upload-id") || headerGet(h, "x-upload-id");
  50. if (idHdr) return idHdr;
  51. const fromLoc = pickUploadIdFromLocation(headerGet(h, "location"));
  52. if (fromLoc) return fromLoc;
  53. const d = pickPayload(body ?? null);
  54. const inner = d && typeof d === "object" && (d as any).data !== undefined ? (d as any).data : d;
  55. const x = inner && typeof inner === "object" ? (inner as Record<string, unknown>) : {};
  56. const id =
  57. (x.upload_id as string) || (x.uploadId as string) || (x.id as string) || (typeof inner === "string" ? inner : "") || "";
  58. return id || "";
  59. }
  60. function pickFileUrlFromBody(body: unknown): string {
  61. const fromObj = (o: unknown): string => {
  62. if (!o || typeof o !== "object" || Array.isArray(o)) return "";
  63. const r = o as Record<string, string>;
  64. return r.url || r.file_url || r.fileUrl || r.access_url || r.accessUrl || r.ossUrl || r.cdnUrl || "";
  65. };
  66. const d = pickPayload(body);
  67. let inner = d && typeof d === "object" && (d as any).data !== undefined ? (d as any).data : d;
  68. let u = fromObj(inner);
  69. if (u) return u;
  70. if (inner && typeof inner === "object" && (inner as any).data !== undefined) {
  71. u = fromObj((inner as any).data);
  72. }
  73. if (u) return u;
  74. if (typeof inner === "string" && inner.startsWith("http")) return inner;
  75. return typeof d === "string" && d.startsWith("http") ? d : "";
  76. }
  77. /** 创建上传会话 POST /upload */
  78. export async function createUploadSession(data: {
  79. filename: string;
  80. size: number;
  81. }): Promise<{ response: Response; body: unknown }> {
  82. const res = await fetch(`${BASE_AI_URL}/upload`, {
  83. method: "POST",
  84. headers: {
  85. "Content-Type": "application/json",
  86. "Tus-Resumable": TUS_VERSION,
  87. Authorization: authHeader()
  88. },
  89. body: JSON.stringify(data)
  90. });
  91. let body: unknown = null;
  92. const ct = res.headers.get("content-type") || "";
  93. if (ct.includes("application/json")) {
  94. try {
  95. body = await res.json();
  96. } catch {
  97. body = null;
  98. }
  99. } else {
  100. const t = await res.text();
  101. body = t || null;
  102. }
  103. if (res.status < 200 || res.status >= 300) {
  104. const msg =
  105. body && typeof body === "object" && (body as any).message
  106. ? String((body as any).message)
  107. : `创建上传会话失败(${res.status})`;
  108. throw new Error(msg);
  109. }
  110. if (body && typeof body === "object" && (body as any).code !== undefined) {
  111. const c = (body as any).code;
  112. if (c !== 200 && c !== 0) {
  113. throw new Error((body as any).msg || (body as any).message || "创建上传会话失败");
  114. }
  115. }
  116. return { response: res, body };
  117. }
  118. /** PATCH 二进制分片 */
  119. export async function patchBinaryToUpload(uploadId: string, arrayBuffer: ArrayBuffer, uploadOffset = 0): Promise<Response> {
  120. const url = `${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`;
  121. const res = await fetch(url, {
  122. method: "PATCH",
  123. headers: {
  124. Authorization: authHeader(),
  125. "Tus-Resumable": TUS_VERSION,
  126. "Content-Type": "application/offset+octet-stream",
  127. "Upload-Offset": String(uploadOffset)
  128. },
  129. body: arrayBuffer
  130. });
  131. if (res.status >= 200 && res.status < 300) {
  132. return res;
  133. }
  134. const t = await res.text().catch(() => "");
  135. throw new Error(`上传失败(${res.status})${t ? ` ${t.slice(0, 200)}` : ""}`);
  136. }
  137. export async function patchBinaryToUploadChunked(
  138. uploadId: string,
  139. arrayBuffer: ArrayBuffer,
  140. onProgress?: (ratio: number) => void
  141. ): Promise<void> {
  142. const total = arrayBuffer.byteLength;
  143. if (total === 0) {
  144. await patchBinaryToUpload(uploadId, new ArrayBuffer(0), 0);
  145. onProgress?.(1);
  146. return;
  147. }
  148. let offset = 0;
  149. while (offset < total) {
  150. const end = Math.min(offset + TUS_CHUNK_SIZE, total);
  151. const chunk = arrayBuffer.slice(offset, end);
  152. await patchBinaryToUpload(uploadId, chunk, offset);
  153. offset = end;
  154. onProgress?.(offset / total);
  155. }
  156. }
  157. /** HEAD /files/{id} */
  158. export async function getUploadProgress(uploadId: string): Promise<Response> {
  159. return fetch(`${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`, {
  160. method: "HEAD",
  161. headers: {
  162. Authorization: authHeader(),
  163. "Tus-Resumable": TUS_VERSION,
  164. "Content-Type": "application/octet-stream"
  165. }
  166. });
  167. }
  168. /** DELETE /files/{id} */
  169. export async function deleteUploadSession(uploadId: string): Promise<void> {
  170. await fetch(`${BASE_AI_URL}/files/${encodeURIComponent(uploadId)}`, {
  171. method: "DELETE",
  172. headers: {
  173. Authorization: authHeader(),
  174. "Tus-Resumable": TUS_VERSION
  175. }
  176. });
  177. }
  178. /** POST /upload/{id}/finalize */
  179. export async function finalizeUploadSession(uploadId: string, data: Record<string, unknown> = {}): Promise<unknown> {
  180. const res = await fetch(`${BASE_AI_URL}/upload/${encodeURIComponent(uploadId)}/finalize`, {
  181. method: "POST",
  182. headers: {
  183. "Content-Type": "application/json",
  184. "Tus-Resumable": TUS_VERSION,
  185. Authorization: authHeader()
  186. },
  187. body: JSON.stringify(data)
  188. });
  189. let body: unknown = null;
  190. const ct = res.headers.get("content-type") || "";
  191. if (ct.includes("application/json")) {
  192. try {
  193. body = await res.json();
  194. } catch {
  195. body = null;
  196. }
  197. } else {
  198. body = await res.text();
  199. }
  200. if (res.status < 200 || res.status >= 300) {
  201. throw new Error(`上传完成确认失败(${res.status})`);
  202. }
  203. if (body && typeof body === "object" && (body as any).code !== undefined) {
  204. const c = (body as any).code;
  205. if (c !== 200 && c !== 0) {
  206. throw new Error((body as any).msg || (body as any).message || "上传完成确认失败");
  207. }
  208. }
  209. return body;
  210. }
  211. export interface ModerateImageParams {
  212. text?: string;
  213. image_urls?: string | string[];
  214. file?: File | null;
  215. }
  216. /** POST multipart /api/v1/moderate,文件字段名 files */
  217. export async function moderateImage(params: ModerateImageParams = {}): Promise<any> {
  218. const urlsStr = Array.isArray(params.image_urls)
  219. ? params.image_urls.filter(Boolean).join(",")
  220. : String(params.image_urls ?? "");
  221. const fd = new FormData();
  222. fd.append("text", String(params.text ?? ""));
  223. fd.append("image_urls", urlsStr);
  224. if (params.file) {
  225. fd.append("files", params.file, params.file.name);
  226. }
  227. const res = await fetch(`${BASE_AI_MODERATE_URL}/api/v1/moderate`, {
  228. method: "POST",
  229. headers: {
  230. Authorization: authHeader()
  231. },
  232. body: fd
  233. });
  234. let body: any = null;
  235. const ct = res.headers.get("content-type") || "";
  236. if (ct.includes("application/json")) {
  237. try {
  238. body = await res.json();
  239. } catch {
  240. body = null;
  241. }
  242. } else {
  243. const t = await res.text();
  244. try {
  245. body = t ? JSON.parse(t) : null;
  246. } catch {
  247. body = { raw: t };
  248. }
  249. }
  250. if (res.status < 200 || res.status >= 300) {
  251. throw new Error(`审核请求失败(${res.status})`);
  252. }
  253. if (body && typeof body === "object" && body.code !== undefined && body.code !== 200 && body.code !== 0) {
  254. throw new Error(body.msg || body.message || "审核请求失败");
  255. }
  256. return body;
  257. }
  258. export interface UploadFileViaAiOptions {
  259. /** 0~100 */
  260. onProgress?: (progress: number) => void;
  261. /** 为 true 时跳过审核(仅上传) */
  262. skipModerate?: boolean;
  263. }
  264. /**
  265. * 浏览器 File 走 Tus 上传 → finalize →(可选)AI 审核,返回可访问的文件 URL
  266. */
  267. export async function uploadFileViaAi(file: File, options: UploadFileViaAiOptions = {}): Promise<string> {
  268. const { onProgress, skipModerate } = options;
  269. const report = (p: number) => {
  270. onProgress?.(Math.min(100, Math.max(0, Math.round(p))));
  271. };
  272. let uploadId = "";
  273. try {
  274. report(5);
  275. const buf = await file.arrayBuffer();
  276. const size = buf.byteLength;
  277. report(18);
  278. const { response: sessionRes, body: sessionBody } = await createUploadSession({
  279. filename: file.name || `img_${Date.now()}.jpg`,
  280. size
  281. });
  282. uploadId = pickUploadIdFromResponse(sessionRes, sessionBody);
  283. if (!uploadId) {
  284. throw new Error("创建上传会话失败:未返回 upload_id");
  285. }
  286. const publicUrl = `${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}`;
  287. report(35);
  288. await patchBinaryToUploadChunked(uploadId, buf, ratio => {
  289. report(35 + ratio * (82 - 35));
  290. });
  291. report(82);
  292. const finalizeBody = await finalizeUploadSession(uploadId, {});
  293. report(90);
  294. const urlFromApi = pickFileUrlFromBody(finalizeBody);
  295. const fileUrl = urlFromApi || publicUrl;
  296. if (!fileUrl) {
  297. throw new Error("上传完成确认失败:未返回文件地址");
  298. }
  299. if (!skipModerate) {
  300. const moderateRes = await moderateImage({
  301. text: "",
  302. image_urls: fileUrl,
  303. file
  304. });
  305. const first = moderateRes?.results?.[0];
  306. if (first?.flagged) {
  307. if (first.reason) console.warn("[AI审核]", first.reason);
  308. throw new Error(mapModerateReasonToUserMessage(first.reason));
  309. }
  310. }
  311. report(100);
  312. return fileUrl;
  313. } catch (e) {
  314. throw e;
  315. }
  316. }
  317. /** 与 /file/uploadMore 成功态兼容,供 Upload 组件、http 封装统一消费 */
  318. export type UploadMoreLikeResponse = {
  319. code: number;
  320. msg: string;
  321. data: { fileUrl: string };
  322. /** 部分组件直接读顶层 fileUrl */
  323. fileUrl: string;
  324. };
  325. export function isAiImageModerationEnabled(): boolean {
  326. return import.meta.env.VITE_UPLOAD_IMAGE_AI_MODERATE !== "false";
  327. }
  328. /** 图片走 Tus + 审核,返回与 uploadMore 相近结构 */
  329. export async function uploadImageAsUploadMoreResponse(file: File): Promise<UploadMoreLikeResponse> {
  330. const fileUrl = await uploadFileViaAi(file);
  331. return {
  332. code: ResultEnum.SUCCESS,
  333. msg: "success",
  334. data: { fileUrl },
  335. fileUrl
  336. };
  337. }
  338. /**
  339. * FormData 内含字段 `file` 且为图片时走 AI 上传+审核,否则走原接口
  340. */
  341. export function runUploadMoreWithOptionalAi<T = UploadMoreLikeResponse>(
  342. params: FormData,
  343. fallback: () => Promise<T>
  344. ): Promise<T> {
  345. if (!isAiImageModerationEnabled()) {
  346. return fallback();
  347. }
  348. const file = params.get("file");
  349. if (file instanceof File && typeof file.type === "string" && file.type.startsWith("image/")) {
  350. return uploadImageAsUploadMoreResponse(file) as Promise<T>;
  351. }
  352. return fallback();
  353. }
  354. /** 多文件顺序上传,整体进度 0~100 */
  355. export async function uploadFilesViaAi(files: File[], options: UploadFileViaAiOptions = {}): Promise<string[]> {
  356. const list = files.filter(f => f instanceof File);
  357. if (!list.length) return [];
  358. const n = list.length;
  359. const urls: string[] = [];
  360. for (let i = 0; i < n; i++) {
  361. const u = await uploadFileViaAi(list[i], {
  362. ...options,
  363. onProgress: p => {
  364. const overall = (i / n) * 100 + (p / 100) * (100 / n);
  365. options.onProgress?.(overall);
  366. }
  367. });
  368. urls.push(u);
  369. }
  370. return urls;
  371. }
  372. /** 与 uni 版 aiApi 命名对齐的聚合对象,便于按需解构 */
  373. export const aiImageApi = {
  374. createUploadSession: (data: { filename: string; size: number }) => createUploadSession(data),
  375. patchBinaryToUpload,
  376. getUploadProgress,
  377. deleteUploadSession,
  378. finalizeUploadSession,
  379. moderateImage,
  380. uploadFileViaAi,
  381. uploadFilesViaAi
  382. };
  383. export default aiImageApi;