|
|
@@ -0,0 +1,426 @@
|
|
|
+/**
|
|
|
+ * 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<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 : "";
|
|
|
+}
|
|
|
+
|
|
|
+/** 创建上传会话 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;
|
|
|
+}
|
|
|
+
|
|
|
+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<any> {
|
|
|
+ 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<string> {
|
|
|
+ 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<UploadMoreLikeResponse> {
|
|
|
+ const fileUrl = await uploadFileViaAi(file);
|
|
|
+ return {
|
|
|
+ code: ResultEnum.SUCCESS,
|
|
|
+ msg: "success",
|
|
|
+ data: { fileUrl },
|
|
|
+ fileUrl
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * FormData 内含字段 `file` 且为图片时走 AI 上传+审核,否则走原接口
|
|
|
+ */
|
|
|
+export function runUploadMoreWithOptionalAi<T = UploadMoreLikeResponse>(
|
|
|
+ params: FormData,
|
|
|
+ fallback: () => Promise<T>
|
|
|
+): Promise<T> {
|
|
|
+ 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<T>;
|
|
|
+ }
|
|
|
+ return fallback();
|
|
|
+}
|
|
|
+
|
|
|
+/** 多文件顺序上传,整体进度 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,
|
|
|
+ moderateImage,
|
|
|
+ uploadFileViaAi,
|
|
|
+ uploadFilesViaAi
|
|
|
+};
|
|
|
+
|
|
|
+export default aiImageApi;
|