/** * Web 端:Tus 分片上传 + AI 图片审核(与 Apifox / uni 版协议对齐) */ import { useUserStore } from "@/stores/modules/user"; import { ResultEnum } from "@/enums/httpEnum"; import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_MODERATE_URL, BASE_AI_URL } from "@/utils/config"; const TUS_VERSION = "1.0.0"; const TUS_CHUNK_SIZE = 1024 * 1024; const DEFAULT_MODERATE_USER_TIP = "图片未通过内容审核,请更换后重试"; function authHeader(): string { try { return useUserStore().token || ""; } catch { return ""; } } function mapModerateReasonToUserMessage(raw?: string | null): string { const s = raw == null ? "" : String(raw).trim(); if (!s) return DEFAULT_MODERATE_USER_TIP; if (/赌|赌博|casino|gambl/i.test(s)) return "图片涉及赌博等违规内容,请更换后重试"; if (/色情|淫秽|porn|sex/i.test(s)) return "图片涉及色情等违规内容,请更换后重试"; if (/暴力|血腥|violence|gore/i.test(s)) return "图片涉及暴力等违规内容,请更换后重试"; if (/辱骂|谩骂|人身攻击|abuse|insult/i.test(s)) return "图片涉及不文明内容,请更换后重试"; if (/政治|谣言/i.test(s)) return "图片涉及违规信息,请更换后重试"; if (/广告|spam|营销/i.test(s)) return "图片涉及违规推广内容,请更换后重试"; if (/违法|违禁|illegal/i.test(s)) return "图片含违法违规内容,请更换后重试"; if (/黄赌毒/i.test(s)) return "请勿上传涉黄、涉赌、涉毒等违规内容"; if (/审核|moderat|vlm|violation|flagged/i.test(s)) return DEFAULT_MODERATE_USER_TIP; return DEFAULT_MODERATE_USER_TIP; } 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 : ""; } /** 创建上传会话 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; } export interface ModerateImageParams { text?: string; image_urls?: string | string[]; file?: File | null; } /** POST multipart /api/v1/moderate,文件字段名 files */ export async function moderateImage(params: ModerateImageParams = {}): Promise { const urlsStr = Array.isArray(params.image_urls) ? params.image_urls.filter(Boolean).join(",") : String(params.image_urls ?? ""); const fd = new FormData(); fd.append("text", String(params.text ?? "")); fd.append("image_urls", urlsStr); if (params.file) { fd.append("files", params.file, params.file.name); } const res = await fetch(`${BASE_AI_MODERATE_URL}/api/v1/moderate`, { method: "POST", headers: { Authorization: authHeader() }, body: fd }); let body: any = 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 = { raw: t }; } } if (res.status < 200 || res.status >= 300) { throw new Error(`审核请求失败(${res.status})`); } if (body && typeof body === "object" && body.code !== undefined && body.code !== 200 && body.code !== 0) { throw new Error(body.msg || body.message || "审核请求失败"); } return body; } export interface UploadFileViaAiOptions { /** 0~100 */ onProgress?: (progress: number) => void; /** 为 true 时跳过审核(仅上传) */ skipModerate?: boolean; } /** * 浏览器 File 走 Tus 上传 → finalize →(可选)AI 审核,返回可访问的文件 URL */ export async function uploadFileViaAi(file: File, options: UploadFileViaAiOptions = {}): Promise { const { onProgress, skipModerate } = options; const report = (p: number) => { onProgress?.(Math.min(100, Math.max(0, Math.round(p)))); }; let uploadId = ""; try { report(5); const buf = await file.arrayBuffer(); const size = buf.byteLength; report(18); const { response: sessionRes, body: sessionBody } = await createUploadSession({ filename: file.name || `img_${Date.now()}.jpg`, size }); 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("上传完成确认失败:未返回文件地址"); } if (!skipModerate) { const moderateRes = await moderateImage({ text: "", image_urls: fileUrl, file }); const first = moderateRes?.results?.[0]; if (first?.flagged) { if (first.reason) console.warn("[AI审核]", first.reason); throw new Error(mapModerateReasonToUserMessage(first.reason)); } } report(100); return fileUrl; } catch (e) { throw e; } } /** 与 /file/uploadMore 成功态兼容,供 Upload 组件、http 封装统一消费 */ export type UploadMoreLikeResponse = { code: number; msg: string; data: { fileUrl: string }; /** 部分组件直接读顶层 fileUrl */ fileUrl: string; }; export function isAiImageModerationEnabled(): boolean { return import.meta.env.VITE_UPLOAD_IMAGE_AI_MODERATE !== "false"; } /** 图片走 Tus + 审核,返回与 uploadMore 相近结构 */ export async function uploadImageAsUploadMoreResponse(file: File): Promise { const fileUrl = await uploadFileViaAi(file); return { code: ResultEnum.SUCCESS, msg: "success", data: { fileUrl }, fileUrl }; } /** * FormData 内含字段 `file` 且为图片时走 AI 上传+审核,否则走原接口 */ export function runUploadMoreWithOptionalAi( params: FormData, fallback: () => Promise ): Promise { if (!isAiImageModerationEnabled()) { return fallback(); } const file = params.get("file"); if (file instanceof File && typeof file.type === "string" && file.type.startsWith("image/")) { return uploadImageAsUploadMoreResponse(file) as Promise; } return fallback(); } /** 多文件顺序上传,整体进度 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, moderateImage, uploadFileViaAi, uploadFilesViaAi }; export default aiImageApi;