|
|
@@ -1,15 +1,12 @@
|
|
|
/**
|
|
|
- * Web 端:Tus 分片上传 + AI 图片审核(与 Apifox / uni 版协议对齐)
|
|
|
+ * Web 端:Tus 分片上传(与 Apifox / uni 版协议对齐);独立审核走上传服务 /upload/simple 等,不再调用 verify 审核域
|
|
|
*/
|
|
|
import { useUserStore } from "@/stores/modules/user";
|
|
|
-import { ResultEnum } from "@/enums/httpEnum";
|
|
|
-import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_MODERATE_URL, BASE_AI_URL, BASE_DEV_UPLOAD_SIMPLE } from "@/utils/config";
|
|
|
+import { AI_UPLOAD_FILES_PUBLIC_BASE, BASE_AI_URL, BASE_DEV_UPLOAD_SIMPLE } 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 || "";
|
|
|
@@ -18,23 +15,6 @@ function authHeader(): string {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-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) {
|
|
|
@@ -161,7 +141,7 @@ export async function uploadFileViaDevSimpleEndpoint(file: File): Promise<{ file
|
|
|
Authorization: authHeader()
|
|
|
},
|
|
|
body: fd,
|
|
|
- credentials: "include"
|
|
|
+ credentials: "omit"
|
|
|
});
|
|
|
|
|
|
let body: unknown = null;
|
|
|
@@ -340,232 +320,8 @@ export async function finalizeUploadSession(uploadId: string, data: Record<strin
|
|
|
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 ModerateVideoParams {
|
|
|
- /** 可选;与图片同一套 Tus 上传后应只传 `video_url`,不在此再传文件 */
|
|
|
- file?: File | null;
|
|
|
- filePath?: string;
|
|
|
- path?: string;
|
|
|
- /** 服务端视频地址,无 Tus 时可传空字符串 */
|
|
|
- video_path?: string;
|
|
|
- /** 标题/简介等 */
|
|
|
- text?: string;
|
|
|
- /** 远程 http(s) 视频地址 */
|
|
|
- video_url?: string;
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * 仅提交「已上传视频」的审核任务:POST `/api/v1/video/submit`
|
|
|
- * 与图片共用 Tus 上传拿到地址后,传 `video_url`;`getVideoModerateResult` 查询审核进度/结果。
|
|
|
- */
|
|
|
-export async function moderateVideo(params: ModerateVideoParams = {}): Promise<any> {
|
|
|
- const fd = new FormData();
|
|
|
- fd.append("video_path", String(params.video_path ?? ""));
|
|
|
- fd.append("text", String(params.text ?? ""));
|
|
|
- fd.append("video_url", String(params.video_url ?? ""));
|
|
|
-
|
|
|
- const file = params.file ?? null;
|
|
|
- if (file) {
|
|
|
- fd.append("file", file, file.name);
|
|
|
- }
|
|
|
-
|
|
|
- const res = await fetch(`${BASE_AI_MODERATE_URL}/api/v1/video/submit`, {
|
|
|
- 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;
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * 查询视频审核进度与结果:GET `/api/v1/video/status/{task_id}`
|
|
|
- * @param data.task_id 或传入字符串即 task_id
|
|
|
- */
|
|
|
-export async function getVideoModerateResult(data: { task_id?: string } | string): Promise<any> {
|
|
|
- const taskId = typeof data === "string" ? data : String(data?.task_id ?? "").trim();
|
|
|
- if (!taskId) {
|
|
|
- throw new Error("缺少 task_id");
|
|
|
- }
|
|
|
-
|
|
|
- const res = await fetch(`${BASE_AI_MODERATE_URL}/api/v1/video/status/${encodeURIComponent(taskId)}`, {
|
|
|
- method: "GET",
|
|
|
- headers: {
|
|
|
- Authorization: authHeader()
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- 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;
|
|
|
-}
|
|
|
-
|
|
|
-function mapVideoModerateReasonToUserMessage(raw?: string | null): string {
|
|
|
- return mapModerateReasonToUserMessage(raw).replace(/图片/g, "视频");
|
|
|
-}
|
|
|
-
|
|
|
-function pickVideoTaskIdFromSubmit(res: unknown): string {
|
|
|
- const r = res as Record<string, any> | null | undefined;
|
|
|
- if (!r || typeof r !== "object") return "";
|
|
|
- let cur: any = r.data !== undefined ? r.data : r;
|
|
|
- if (cur && typeof cur === "object" && cur.data !== undefined) cur = cur.data;
|
|
|
- if (cur && typeof cur === "object") {
|
|
|
- return String(cur.task_id ?? cur.taskId ?? cur.taskID ?? "").trim();
|
|
|
- }
|
|
|
- return "";
|
|
|
-}
|
|
|
-
|
|
|
-function flattenVideoStatusPayload(body: Record<string, any>): Record<string, any> {
|
|
|
- const d = body?.data;
|
|
|
- if (d && typeof d === "object" && !Array.isArray(d)) {
|
|
|
- return { ...body, ...d };
|
|
|
- }
|
|
|
- return body;
|
|
|
-}
|
|
|
-
|
|
|
-function isVideoModerationStatusTerminal(payload: Record<string, any>): boolean {
|
|
|
- const p = flattenVideoStatusPayload(payload);
|
|
|
- const s = String(p?.status ?? p?.state ?? "").toLowerCase();
|
|
|
- const terminal = [
|
|
|
- "completed",
|
|
|
- "complete",
|
|
|
- "success",
|
|
|
- "succeeded",
|
|
|
- "done",
|
|
|
- "finished",
|
|
|
- "failed",
|
|
|
- "fail",
|
|
|
- "rejected",
|
|
|
- "error",
|
|
|
- "cancelled",
|
|
|
- "canceled"
|
|
|
- ];
|
|
|
- if (terminal.includes(s)) return true;
|
|
|
- const r = p?.result;
|
|
|
- if (r && typeof r === "object" && (r.flagged !== undefined || r.passed !== undefined || r.approved !== undefined)) {
|
|
|
- return true;
|
|
|
- }
|
|
|
- return false;
|
|
|
-}
|
|
|
-
|
|
|
-function isVideoModerationRejectedPayload(payload: Record<string, any>): boolean {
|
|
|
- const p = flattenVideoStatusPayload(payload);
|
|
|
- if (p?.flagged === true) return true;
|
|
|
- const r = p?.result;
|
|
|
- if (r && typeof r === "object" && r.flagged === true) return true;
|
|
|
- const s = String(p?.status ?? "").toLowerCase();
|
|
|
- if (["failed", "fail", "rejected", "error"].includes(s)) return true;
|
|
|
- return false;
|
|
|
-}
|
|
|
-
|
|
|
-function pickVideoRejectReason(payload: Record<string, any>): string | null {
|
|
|
- const p = flattenVideoStatusPayload(payload);
|
|
|
- const r = p?.result;
|
|
|
- const fromResult = r && typeof r === "object" ? (r.reason ?? r.message) : null;
|
|
|
- const raw = fromResult ?? p?.reason ?? p?.message ?? p?.msg;
|
|
|
- return raw != null && typeof raw === "string" ? raw : null;
|
|
|
-}
|
|
|
-
|
|
|
/**
|
|
|
- * 是否为视频(MIME 或后缀)。上传与图片同走 Tus;通过后以 `moderateVideo({ video_url })` + 轮询区分审核链路。
|
|
|
+ * 是否为视频(MIME 或后缀),用于 Tus 上传命名等
|
|
|
*/
|
|
|
export function isLikelyVideoFileForAiUpload(file: File): boolean {
|
|
|
if (!(file instanceof File)) return false;
|
|
|
@@ -613,139 +369,26 @@ async function uploadViaTusToPublicUrl(file: File, onProgress?: (p: number) => v
|
|
|
return fileUrl;
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * Tus 已得到视频 URL:`moderateVideo` 仅提交审核(video_url),`getVideoModerateResult` 轮询至结束。
|
|
|
- */
|
|
|
-async function submitAndAwaitVideoModeration(fileUrl: string, onProgress?: (p: number) => void): Promise<void> {
|
|
|
- const report = (n: number) => onProgress?.(Math.min(100, Math.max(0, Math.round(n))));
|
|
|
- const submitRes: any = await moderateVideo({ video_url: fileUrl, text: "", video_path: "" });
|
|
|
- report(92);
|
|
|
- const taskId = pickVideoTaskIdFromSubmit(submitRes);
|
|
|
- const submitFlat =
|
|
|
- submitRes && typeof submitRes === "object" ? flattenVideoStatusPayload(submitRes as Record<string, any>) : {};
|
|
|
-
|
|
|
- if (!taskId) {
|
|
|
- if (isVideoModerationStatusTerminal(submitFlat)) {
|
|
|
- if (isVideoModerationRejectedPayload(submitFlat)) {
|
|
|
- throw new Error(mapVideoModerateReasonToUserMessage(pickVideoRejectReason(submitFlat)));
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
- const c = submitRes?.code;
|
|
|
- if (c === 200 || c === 0) return;
|
|
|
- throw new Error("视频审核提交未返回任务信息,请稍后重试");
|
|
|
- }
|
|
|
-
|
|
|
- const maxMs = 5 * 60 * 1000;
|
|
|
- const step = 2000;
|
|
|
- const t0 = Date.now();
|
|
|
- let pollCount = 0;
|
|
|
- while (Date.now() - t0 < maxMs) {
|
|
|
- const raw: any = await getVideoModerateResult(taskId);
|
|
|
- const flat = raw && typeof raw === "object" ? flattenVideoStatusPayload(raw as Record<string, any>) : {};
|
|
|
- pollCount += 1;
|
|
|
- report(Math.min(98, 92 + Math.min(6, pollCount)));
|
|
|
-
|
|
|
- if (isVideoModerationStatusTerminal(flat)) {
|
|
|
- if (isVideoModerationRejectedPayload(flat)) {
|
|
|
- throw new Error(mapVideoModerateReasonToUserMessage(pickVideoRejectReason(flat)));
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
- await new Promise<void>(resolve => setTimeout(resolve, step));
|
|
|
- }
|
|
|
- throw new Error("视频审核等待超时,请稍后重试");
|
|
|
-}
|
|
|
-
|
|
|
export interface UploadFileViaAiOptions {
|
|
|
/** 0~100 */
|
|
|
onProgress?: (progress: number) => void;
|
|
|
- /** 为 true 时跳过审核(仅上传) */
|
|
|
+ /** 已废弃;保留仅为兼容旧调用,不再触发独立审核服务 */
|
|
|
skipModerate?: boolean;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 图片与视频均:Tus 上传 → finalize 得同一套访问 URL。
|
|
|
- * 图片再 `moderateImage`;视频再 `moderateVideo({ video_url })` + `getVideoModerateResult` 轮询。`skipModerate` 则只上传不审。
|
|
|
+ * 图片与视频:Tus 上传 → finalize 得访问 URL(不再调用 verify / ai-moderate)
|
|
|
*/
|
|
|
export async function uploadFileViaAi(file: File, options: UploadFileViaAiOptions = {}): Promise<string> {
|
|
|
- const { onProgress, skipModerate } = options;
|
|
|
+ const { onProgress } = options;
|
|
|
|
|
|
const report = (p: number) => {
|
|
|
onProgress?.(Math.min(100, Math.max(0, Math.round(p))));
|
|
|
};
|
|
|
|
|
|
- try {
|
|
|
- const fileUrl = await uploadViaTusToPublicUrl(file, onProgress);
|
|
|
-
|
|
|
- if (!skipModerate) {
|
|
|
- if (isLikelyVideoFileForAiUpload(file)) {
|
|
|
- await submitAndAwaitVideoModeration(fileUrl, onProgress);
|
|
|
- } else {
|
|
|
- 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 上传;图 moderateImage、视频 moderateVideo(url)+轮询;返回与 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` 且为图片或视频时走 Tus + 对应审核(图 moderateImage,视频 moderateVideo 的 video_url + 轮询),否则走原接口
|
|
|
- */
|
|
|
-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)) {
|
|
|
- return fallback();
|
|
|
- }
|
|
|
- const isImage = typeof file.type === "string" && file.type.startsWith("image/");
|
|
|
- const isVideo = isLikelyVideoFileForAiUpload(file);
|
|
|
- if (isImage || isVideo) {
|
|
|
- return uploadImageAsUploadMoreResponse(file) as Promise<T>;
|
|
|
- }
|
|
|
- return fallback();
|
|
|
+ const fileUrl = await uploadViaTusToPublicUrl(file, onProgress);
|
|
|
+ report(100);
|
|
|
+ return fileUrl;
|
|
|
}
|
|
|
|
|
|
/** 多文件顺序上传,整体进度 0~100 */
|
|
|
@@ -775,9 +418,6 @@ export const aiImageApi = {
|
|
|
getUploadProgress,
|
|
|
deleteUploadSession,
|
|
|
finalizeUploadSession,
|
|
|
- moderateImage,
|
|
|
- moderateVideo,
|
|
|
- getVideoModerateResult,
|
|
|
uploadFileViaAi,
|
|
|
uploadFilesViaAi,
|
|
|
uploadFileViaDevSimpleEndpoint
|