tosUpload.ts 7.54 KB
import TOS from "@volcengine/tos-sdk";
import { getTosCredential } from "@/api/files";

const TOS_BUCKET = "linkmed";
const TOS_REGION = "cn-beijing";

interface TosCredentialCache {
  client: TOS;
  bucket: string;
  region: string;
  expiredTime: string; // ISO string
}

let credentialCache: TosCredentialCache | null = null;

function isCredentialExpired(expiredTime: string): boolean {
  const expiry = new Date(expiredTime).getTime();
  const now = Date.now();
  const fiveMinutes = 5 * 60 * 1000;
  return now >= expiry - fiveMinutes;
}

export async function fetchTosCredential(
  sessionName = "frontend-upload",
): Promise<TosCredentialCache> {
  if (credentialCache && !isCredentialExpired(credentialCache.expiredTime)) {
    return credentialCache;
  }

  const res = await getTosCredential(sessionName);
  const data = (res as any).data ?? res;

  const region: string = data.region ?? data.responseMetadata?.region ?? data.responseMetadata?.Region ?? TOS_REGION;
  const creds = data.credentials ?? data;
  const accessKeyId: string = creds.accessKeyId;
  const accessKeySecret: string = creds.secretAccessKey;
  const stsToken: string = creds.sessionToken;
  const expiredTime: string = creds.expiredTime ?? creds.expiration ?? "";

  const client = new TOS({
    accessKeyId,
    accessKeySecret,
    stsToken,
    region,
    bucket: TOS_BUCKET,
    endpoint: `tos-${region}.volces.com`,
  });

  credentialCache = { client, bucket: TOS_BUCKET, region, expiredTime };
  return credentialCache;
}

export interface TosUploadOptions {
  userId?: string | number;
  onFileProgress?: (fileIndex: number, percent: number) => void;
}

export interface TosUploadResult {
  storageKey: string;
  storageUrl: string;
  originalFileName: string;
  fileSize: number;
  mimeType: string;
}

function encodeTosKeyPathForUrl(key: string): string {
  // 仅对 URL 路径的每一段做编码,避免把 '/' 编码成 '%2F'。
  // 否则会改变 TOS object key 的语义,导致后端下载/预签名失败。
  return key
    .split("/")
    .map((seg) => encodeURIComponent(seg))
    .join("/");
}

function getFileExt(fileName: string): string {
  const idx = fileName.lastIndexOf(".");
  if (idx < 0 || idx === fileName.length - 1) return "";
  return fileName.slice(idx + 1).toLowerCase();
}

function genUploadId(): string {
  // 浏览器端尽量使用 randomUUID,兼容性不足则回退到 Math.random
  const anyCrypto = crypto as any;
  return (
    anyCrypto?.randomUUID?.()?.replace(/-/g, "") ??
    Math.random().toString(36).slice(2)
  );
}

export async function uploadFilesToTos(
  files: File[],
  options: TosUploadOptions = {},
): Promise<TosUploadResult[]> {
  const { client, bucket, region } = await fetchTosCredential();

  const results: TosUploadResult[] = [];

  for (let i = 0; i < files.length; i++) {
    const file = files[i]!;
    const timestamp = Date.now();
    const userId = options.userId ?? "u";
    // objectKey 不直接包含原始文件名,避免后端/URL 处理时把“文件名”当成“key 结构的一部分”
    // 仅保留扩展名用于类型识别(originalFileName 仍会单独传给后端)。
    const ext = getFileExt(file.name || "");
    const extension = ext ? `.${ext}` : "";
    const key = `uploads/${userId}/${timestamp}_${genUploadId()}${extension}`;

    await client.uploadFile({
      key,
      file,
      bucket,
      taskNum: 1,
      dataTransferStatusChange: (status: any) => {
        const consumed = status?.consumedBytes;
        const total = status?.totalBytes;
        if (consumed != null && total != null && total > 0) {
          const percent = Math.floor((consumed / total) * 95);
          options.onFileProgress?.(i, percent);
        }
      },
    });

    // storageUrl 会被后端用于拉取/转换;如果 key 中包含中文/空格等字符,
    // 需要对 URL path 做编码,避免构造出的 URL 非法或被解析错误。
    const storageUrl = `https://${bucket}.tos-${region}.volces.com/${encodeTosKeyPathForUrl(key)}`;

    results.push({
      storageKey: key,
      storageUrl,
      originalFileName: file.name,
      fileSize: file.size,
      mimeType: file.type || "application/octet-stream",
    });
  }

  return results;
}

export async function getTosDownloadUrl(
  storageKey: string,
  fileName: string,
): Promise<string> {
  const { client } = await fetchTosCredential();

  const url = client.getPreSignedUrl({
    bucket: TOS_BUCKET,
    key: storageKey,
    method: "GET",
    expires: 3600,
    response: {
      contentDisposition: `attachment; filename="${encodeURIComponent(fileName)}"`,
    },
  });

  return url;
}

function isPresignedTosUrl(url: string): boolean {
  // TOS/S3 预签名 URL 常见参数
  return /[?&](X-Tos-Algorithm|X-Tos-Credential|X-Tos-Signature|X-Amz-Algorithm|X-Amz-Credential|X-Amz-Signature)=/i.test(url);
}

function safeDecodeURIComponent(v: string): string {
  try {
    return decodeURIComponent(v);
  } catch {
    return v;
  }
}

function tryExtractStorageKeyFromTosUrl(rawUrl: string): string | undefined {
  try {
    const u = new URL(rawUrl);
    const host = u.hostname;
    const pathname = u.pathname || "";

    // Path-style: https://tos-xx.volces.com/<bucket>/<key>
    // e.g. https://tos-cn-beijing.volces.com/linkmed/uploads/1/a%20b.pdf
    const parts = pathname.split("/").filter(Boolean);
    if (parts.length >= 2) {
      const bucket = parts[0];
      if (bucket === TOS_BUCKET) {
        const keyRaw = parts.slice(1).join("/");
        return safeDecodeURIComponent(keyRaw);
      }
    }

    // Virtual-hosted-style: https://<bucket>.tos-xx.volces.com/<key>
    // e.g. https://linkmed.tos-cn-beijing.volces.com/uploads/1/a%20b.pdf
    if (host.startsWith(`${TOS_BUCKET}.`)) {
      const keyRaw = pathname.replace(/^\/+/, "");
      if (keyRaw) return safeDecodeURIComponent(keyRaw);
    }

    return undefined;
  } catch {
    return undefined;
  }
}

/**
 * 从 OSS/TOS 获取文件内容为 Blob(用于预览,而不是触发下载)。
 * 
 * 用法:
 * 1) const blob = await fetchOssBlob({ storageKey, fileName })
 * 2) const blob = await fetchOssBlob({ downloadUrl })
 */
export async function fetchOssBlob(params: {
  storageKey?: string;
  downloadUrl?: string;
  fileName?: string;
}): Promise<Blob> {
  const { storageKey, downloadUrl, fileName } = params;

  let url = downloadUrl;
  if (!url) {
    if (!storageKey) {
      throw new Error("fetchOssBlob requires either storageKey or downloadUrl");
    }
    // fileName 只用于 content-disposition;预览时可传可不传
    url = await getTosDownloadUrl(storageKey, fileName || "file");
  } else {
    // downloadUrl 如果是原始 TOS 路径式 URL,浏览器匿名访问会 403,需要先转成预签名 URL
    if (!isPresignedTosUrl(url)) {
      const extractedKey = tryExtractStorageKeyFromTosUrl(url);
      if (extractedKey) {
        url = await getTosDownloadUrl(extractedKey, fileName || "file");
      }
    }
  }

  const resp = await fetch(url, {
    method: "GET",
    // 预签名 URL 通常不需要携带 cookie
    credentials: "omit",
  });

  if (!resp.ok) {
    const text = await resp.text().catch(() => "");
    throw new Error(`fetchOssBlob failed: ${resp.status} ${resp.statusText} ${text}`);
  }

  return await resp.blob();
}

/**
 * 从 OSS/TOS 获取文件并转成 object URL(blob:...),用于 PDF/图片预览。
 * 注意:用完后记得 URL.revokeObjectURL(url)。
 */
export async function fetchOssObjectUrl(params: {
  storageKey?: string;
  downloadUrl?: string;
  fileName?: string;
}): Promise<string> {
  const blob = await fetchOssBlob(params);
  return URL.createObjectURL(blob);
}