| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- /**
- * 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;
|