接入文件服务和 TOS

ChatKit 负责附件交互,不替接入方保存文件。要让附件在刷新和历史会话中仍然可用,并让用户和模型安全读取私有文件,需要接入自己的文件服务。

为什么需要文件服务

附件功能页的默认示例不需要后端也能显示文件,因为它把小文件转成了 source.type: 'inline',文件字节就在当前页面的消息里。这适合体验上传、附件栏、内联混排和编辑,但示例刷新后消息会消失,也没有验证私有文件授权或模型读取。

只做本地演示、文件很小且不需要历史时,可以继续使用内联资源。实际应用出现以下任一需求时,应接入文件服务:

  • 刷新页面或重新打开历史会话后,附件仍然可用。
  • 文件放在私有 TOS、S3 或自己的存储中,不能使用永久公共 URL。
  • 模型需要读取附件,而且模型所在网络与用户浏览器不同。
  • 文件较大,不能把 base64 长期放进消息、数据库和每次模型请求。
  • 需要登录鉴权、租户隔离、配额、审计、病毒扫描或删除回收。

文件服务解决的核心问题是:消息历史只保存稳定资源,真正读取文件时再换短期地址。ChatKit 不持有对象存储 AK/SK,也不限制存储厂商。

数据流

附件文件服务数据流浏览器ChatKit业务文件服务对象存储TOS / S3 / 本地模型服务上传:浏览器只拿短期权限,不持有对象存储密钥申请上传文件名、类型、大小短期上传地址PUT 文件字节确认上传完成稳定 AttachmentResource历史只保存稳定资源 ID、名称、类型和大小,不保存 Blob URL 或过期签名用户预览:resolvePreviewUrl用资源 ID 申请预览地址用户可读的短期 URLGET 文件发送给模型:resolveForSend用资源 ID 申请模型读取地址模型可读的短期 URL模型请求 + 短期 URLGET 文件
文件只上传一次;消息历史保存稳定资源。用户预览和模型读取分别申请当前请求可用的短期地址。

链路中有三种不同的数据,不要混用:

数据生命周期保存位置
AttachmentResource稳定;只要附件存在就有效消息历史和业务数据库
用户预览 URL短期;只授权当前用户读取不持久化,由 resolvePreviewUrl 按需申请
模型读取 URL短期;只供当前模型请求读取不持久化,由 resolveForSend 每轮申请

用户预览和模型读取必须分别鉴权。 不要把 Blob URL、base64 临时预览或过期签名写回稳定资源。

文件服务契约

一个最小文件服务包含五个步骤:

步骤接口结果
申请上传POST /uploads{ uploadId, method: 'PUT', url, headers }
上传对象对返回的 url 执行 PUTHTTP 2xx
完成确认POST /uploads/:uploadId/complete{ id, name, mimeType, size, source: { type, reference } }
预览GET /resources/:id/preview{ url, expiresAt }
模型读取POST /resources/:id/model-source{ type: 'url', url }

POST /uploads 只提交文件元数据,返回短期上传地址。浏览器把文件字节直接 PUT 到该地址,再调用完成接口;服务端核对对象后才返回稳定资源。

客户端接入

下面的客户端实现可同时用于本地 Node 服务、TOS、S3 或自有存储。只需让业务后端实现上面的接口,并修改 baseUrl:

attachment-client.ts
import type { ChatkitAttachmentsFeature, UploadResult } from '@volcengine/chatkit';
import type { ResolveAttachmentForSend } from '@volcengine/chatkit/core';

interface StorageOptions {
  /** 业务文件服务 API 根地址。对象存储 AK/SK 不能放进浏览器。 */
  baseUrl: string;
  /** 登录态不在 Cookie 中时,按请求读取最新令牌。 */
  headers?: () => Record<string, string>;
  /** 跨域文件服务使用 Cookie 登录态时设为 include。 */
  credentials?: RequestCredentials;
  /** 签名地址强制下载时,先转为浏览器可预览的 Blob URL。 */
  materializePreview?: boolean;
}

const json = async <T>(response: Response): Promise<T> => {
  if (!response.ok) {
    const result = (await response.json().catch(() => ({}))) as { message?: string };
    throw new Error(result.message || `文件服务返回 ${response.status}`);
  }
  return response.json() as Promise<T>;
};

export const createStorageAttachments = (options: StorageOptions): ChatkitAttachmentsFeature => ({
  allowUploaded: true,
  // 这里只负责提前提示;文件服务仍要校验文件大小、类型、数量和实际上传字节。
  maxTotalSize: 100 * 1024 * 1024,
  upload: async (file, context) => {
    // 1. 用文件元数据申请短期上传地址。
    const signed = await fetch(`${options.baseUrl}/uploads`, {
      method: 'POST',
      signal: context.signal,
      credentials: options.credentials,
      headers: { 'Content-Type': 'application/json', ...options.headers?.() },
      body: JSON.stringify({
        uploadId: context.uploadId,
        draftId: context.draftId,
        sessionId: context.sessionId,
        name: file.name,
        mimeType: file.type || 'application/octet-stream',
        size: file.size,
      }),
    }).then(json<{ uploadId: string; method: 'PUT'; url: string; headers: Record<string, string> }>);

    // 2. 文件直接上传到签名地址。XHR 用于上报真实字节进度。
    await new Promise<void>((resolve, reject) => {
      context.signal.throwIfAborted();
      const xhr = new XMLHttpRequest();
      const abort = () => xhr.abort();
      const finish = (error?: Error) => {
        context.signal.removeEventListener('abort', abort);
        if (error) reject(error);
        else resolve();
      };
      xhr.open(signed.method, signed.url);
      Object.entries(signed.headers).forEach(([key, value]) => xhr.setRequestHeader(key, value));
      xhr.upload.onprogress = event =>
        context.onProgress({
          stage: 'uploading',
          loaded: event.loaded,
          total: event.lengthComputable ? event.total : file.size,
        });
      xhr.onload = () =>
        finish(xhr.status >= 200 && xhr.status < 300 ? undefined : new Error(`上传返回 ${xhr.status}`));
      xhr.onerror = () => finish(new Error('文件上传网络错误'));
      xhr.onabort = () => finish(new DOMException('上传已取消', 'AbortError'));
      context.signal.addEventListener('abort', abort, { once: true });
      xhr.send(file);
    });

    // 3. 服务端核对对象后返回稳定资源。历史消息只保存这个结果。
    context.onProgress({ stage: 'processing', percent: 100 });
    return fetch(`${options.baseUrl}/uploads/${encodeURIComponent(signed.uploadId)}/complete`, {
      method: 'POST',
      signal: context.signal,
      credentials: options.credentials,
      headers: options.headers?.(),
    }).then(json<UploadResult>);
  },
  resolvePreviewUrl: async (attachment, { signal }) => {
    if (attachment.source?.type !== 'resource') return attachment.url;
    const preview = await fetch(`${options.baseUrl}/resources/${encodeURIComponent(attachment.id)}/preview`, {
      signal,
      credentials: options.credentials,
      headers: options.headers?.(),
    }).then(json<{ url: string; expiresAt: number }>);
    if (!options.materializePreview) return preview;

    const response = await fetch(preview.url, { signal });
    if (!response.ok) throw new Error(`文件服务返回 ${response.status}`);
    const url = URL.createObjectURL(await response.blob());
    return { url, revoke: () => URL.revokeObjectURL(url) };
  },
});

export const createStorageSendResolver =
  (options: StorageOptions): ResolveAttachmentForSend =>
  async (resource, context) => {
    if (resource.source.type !== 'resource') return resource.source;
    return fetch(`${options.baseUrl}/resources/${encodeURIComponent(resource.id)}/model-source`, {
      method: 'POST',
      signal: context.signal,
      credentials: options.credentials,
      headers: { 'Content-Type': 'application/json', ...options.headers?.() },
      body: JSON.stringify({ sessionId: context.sessionId, runId: context.runId }),
    }).then(json<{ type: 'url'; url: string }>);
  };
const storage = { baseUrl: '/api/attachments' };
const attachments = createStorageAttachments(storage);

// OpenAI、ADK、A2A、Hermes 适配器都在这里配置模型读取。
const adapter = createOpenAiAdapter({
  run,
  resolveForSend: createStorageSendResolver(storage),
});

// runtime 使用上面的 adapter 创建。
<Chatkit
  runtime={runtime}
  features={{
    attachments: {
      ...attachments,
      mode: 'strip', // 改成 inline 即使用正文混排
    },
  }}
/>;

推荐使用同源 baseUrl,让现有 Cookie 或网关处理登录态。跨域并使用 Cookie 时设置 credentials: 'include';使用令牌时通过 headers 每次读取最新值。AK/SK、STS 主密钥和 bucket 管理凭证都不能进入浏览器。

maxTotalSize 只用于界面提前提示,服务端仍需独立校验。附件栏与内联混排的行为见附件。

示例一:本地 Node 服务

下面的纯 Node 服务把对象和资源状态保存在进程内存中,无需数据库、TOS 账号或第三方依赖。它实现了申请上传、实际 PUT、完成确认、预览和模型读取,适合先在本地验证 ChatKit 接入。

attachment-server.mjs
import { randomUUID } from 'node:crypto';
import { createServer } from 'node:http';

const port = Number(process.env.PORT ?? 8787);
const appOrigin = process.env.APP_ORIGIN ?? 'http://localhost:5173';
const maxFileSize = 20 * 1024 * 1024;
const resources = new Map();
const objects = new Map();
const tokens = new Map();
const pendingUploads = new Set();

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

const sendJson = (response, value, status = 200) => {
  response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
  response.end(JSON.stringify(value));
};

const readBytes = async (request, limit) => {
  const chunks = [];
  let size = 0;
  for await (const chunk of request) {
    size += chunk.length;
    if (size > limit) throw new HttpError(413, '文件或请求体过大');
    chunks.push(Buffer.from(chunk));
  }
  return Buffer.concat(chunks);
};

const readJson = async request => {
  try {
    return JSON.parse((await readBytes(request, 1024 * 1024)).toString('utf8'));
  } catch (error) {
    if (error instanceof HttpError) throw error;
    throw new HttpError(400, '请求需要有效 JSON');
  }
};

const issueUrl = (requestUrl, id, scope) => {
  const token = randomUUID();
  const expiresAt = Date.now() + 5 * 60 * 1000;
  tokens.set(token, { id, scope, expiresAt });
  const url = new URL(`/api/attachments/objects/${id}`, requestUrl);
  url.searchParams.set('scope', scope);
  url.searchParams.set('expires', String(expiresAt));
  url.searchParams.set('token', token);
  return { url: url.href, expiresAt };
};

const verifyUrl = (url, id, scope) => {
  const token = tokens.get(url.searchParams.get('token'));
  if (
    !token ||
    token.id !== id ||
    token.scope !== scope ||
    token.expiresAt <= Date.now() ||
    Number(url.searchParams.get('expires')) !== token.expiresAt
  ) {
    throw new HttpError(403, '上传或读取地址无效或已过期');
  }
};

const handler = async (request, response) => {
  response.setHeader('Access-Control-Allow-Origin', appOrigin);
  response.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,POST,PUT,OPTIONS');
  response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  if (request.method === 'OPTIONS') {
    response.writeHead(204);
    response.end();
    return;
  }

  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `localhost:${port}`}`);
  if (url.pathname === '/api/attachments/uploads' && request.method === 'POST') {
    const input = await readJson(request);
    if (
      typeof input.name !== 'string' ||
      !input.name.trim() ||
      typeof input.mimeType !== 'string' ||
      !input.mimeType.includes('/') ||
      !Number.isSafeInteger(input.size) ||
      input.size < 0 ||
      input.size > maxFileSize
    ) {
      throw new HttpError(400, '文件信息无效或超过 20 MiB');
    }
    const id = randomUUID();
    const resource = {
      id,
      name: input.name,
      mimeType: input.mimeType,
      size: input.size,
      source: { type: 'resource', reference: id },
    };
    resources.set(id, { resource, ready: false });
    sendJson(response, {
      uploadId: id,
      method: 'PUT',
      ...issueUrl(url, id, 'upload'),
      headers: { 'Content-Type': input.mimeType },
    });
    return;
  }

  const objectMatch = url.pathname.match(/^\/api\/attachments\/objects\/([^/]+)$/);
  if (objectMatch) {
    const id = objectMatch[1];
    const record = resources.get(id);
    if (!record) throw new HttpError(404, '附件不存在');
    if (request.method === 'PUT') {
      verifyUrl(url, id, 'upload');
      if (record.ready || pendingUploads.has(id)) throw new HttpError(409, '对象已上传或正在写入');
      if (request.headers['content-type'] !== record.resource.mimeType) {
        throw new HttpError(400, 'Content-Type 与申请上传时不一致');
      }
      pendingUploads.add(id);
      try {
        const bytes = await readBytes(request, record.resource.size + 1);
        if (bytes.length !== record.resource.size) throw new HttpError(400, '上传字节数与声明大小不一致');
        objects.set(id, bytes);
        tokens.delete(url.searchParams.get('token'));
      } finally {
        pendingUploads.delete(id);
      }
      response.writeHead(200, { ETag: `"${id}"` });
      response.end();
      return;
    }
    if (request.method === 'GET' || request.method === 'HEAD') {
      const scope = url.searchParams.get('scope') === 'model' ? 'model' : 'preview';
      verifyUrl(url, id, scope);
      if (!record.ready) throw new HttpError(409, '附件尚未完成上传');
      const bytes = objects.get(id);
      if (!bytes) throw new HttpError(404, '附件内容不存在');
      response.writeHead(200, {
        'Content-Type': record.resource.mimeType,
        'Content-Length': bytes.length,
        'Cache-Control': 'no-store',
        'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(record.resource.name)}`,
        'X-Content-Type-Options': 'nosniff',
      });
      response.end(request.method === 'HEAD' ? undefined : bytes);
      return;
    }
  }

  const completeMatch = url.pathname.match(/^\/api\/attachments\/uploads\/([^/]+)\/complete$/);
  if (completeMatch && request.method === 'POST') {
    const record = resources.get(completeMatch[1]);
    const bytes = objects.get(completeMatch[1]);
    if (!record || !bytes || bytes.length !== record.resource.size) {
      throw new HttpError(409, '文件尚未上传完整');
    }
    record.ready = true;
    sendJson(response, record.resource);
    return;
  }

  const resourceMatch = url.pathname.match(/^\/api\/attachments\/resources\/([^/]+)\/(preview|model-source)$/);
  if (resourceMatch) {
    const [, id, operation] = resourceMatch;
    const record = resources.get(id);
    if (!record?.ready) throw new HttpError(404, '附件不存在或尚未完成上传');
    if (operation === 'preview' && request.method === 'GET') {
      sendJson(response, issueUrl(url, id, 'preview'));
      return;
    }
    if (operation === 'model-source' && request.method === 'POST') {
      sendJson(response, { type: 'url', url: issueUrl(url, id, 'model').url });
      return;
    }
  }

  throw new HttpError(404, '接口不存在');
};

createServer((request, response) => {
  void handler(request, response).catch(error => {
    sendJson(response, { message: error instanceof Error ? error.message : '文件服务失败' }, error.status ?? 500);
  });
}).listen(port, () => {
  console.log(`Attachment service: http://localhost:${port}/api/attachments`);
});

把代码保存为 attachment-server.mjs 后启动:

APP_ORIGIN=http://localhost:5173 node attachment-server.mjs

默认监听 http://localhost:8787,客户端 baseUrl 配置为 http://localhost:8787/api/attachments。服务重启后内存数据会清空;这份代码只用于本地联调,不提供登录、租户隔离、持久化、病毒扫描或生产级签名。

仓库内还有完整可运行的 Node 文件系统示例:

pnpm dev

打开 http://localhost:5180/?storage=local&pace=0。它会保存文件、会话和消息,可验证上传、预览、模型读取、编辑和刷新恢复;实现位于 examples/basic/src/local-storage/。

示例二:TOS 服务

安装火山引擎 TOS Node.js SDK:

pnpm add @volcengine/tos-sdk

下面这份服务端示例实现与本地服务相同的五组接口,但对象实际写入私有 TOS 桶。资源记录仍放在进程内存中,适合联调;生产环境应把资源 ID、对象 key、租户、会话、上传状态和引用关系写入数据库。

tos-attachment-server.mjs
import { randomUUID } from 'node:crypto';
import { createServer } from 'node:http';

import { TosClient } from '@volcengine/tos-sdk';

const port = Number(process.env.PORT ?? 8787);
const bucket = process.env.TOS_BUCKET;
const region = process.env.TOS_REGION ?? 'cn-beijing';
const endpoint = process.env.TOS_ENDPOINT ?? `tos-${region}.volces.com`;
const accessKeyId = process.env.TOS_ACCESS_KEY_ID;
const accessKeySecret = process.env.TOS_SECRET_ACCESS_KEY;
const stsToken = process.env.TOS_STS_TOKEN;
const allowedOrigins = new Set(
  (process.env.APP_ORIGINS ?? 'http://localhost:5173')
    .split(',')
    .map(value => value.trim())
    .filter(Boolean)
);
const maxFileSize = 20 * 1024 * 1024;
const resources = new Map();

if (!bucket || !accessKeyId || !accessKeySecret) {
  throw new Error('缺少 TOS_BUCKET、TOS_ACCESS_KEY_ID 或 TOS_SECRET_ACCESS_KEY');
}

const tos = new TosClient({
  accessKeyId,
  accessKeySecret,
  bucket,
  endpoint,
  region,
  stsToken,
  enableCRC: false,
});

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

const sendJson = (response, value, status = 200) => {
  response.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Cache-Control': 'no-store',
  });
  response.end(JSON.stringify(value));
};

const readBytes = async (request, limit) => {
  const chunks = [];
  let size = 0;
  for await (const chunk of request) {
    size += chunk.length;
    if (size > limit) throw new HttpError(413, '请求体过大');
    chunks.push(Buffer.from(chunk));
  }
  return Buffer.concat(chunks);
};

const readJson = async request => {
  try {
    return JSON.parse((await readBytes(request, 1024 * 1024)).toString('utf8'));
  } catch (error) {
    if (error instanceof HttpError) throw error;
    throw new HttpError(400, '请求需要有效 JSON');
  }
};

const createResource = input => {
  if (
    typeof input.name !== 'string' ||
    !input.name.trim() ||
    typeof input.mimeType !== 'string' ||
    !input.mimeType.includes('/') ||
    !Number.isSafeInteger(input.size) ||
    input.size < 0 ||
    input.size > maxFileSize
  ) {
    throw new HttpError(400, '文件信息无效或超过 20 MiB');
  }

  const id = randomUUID();
  const resource = {
    id,
    name: input.name,
    mimeType: input.mimeType,
    size: input.size,
    source: { type: 'resource', reference: id },
  };
  const uploadKey = `chatkit/uploads/${id}`;
  const key = `chatkit/resources/${id}`;
  resources.set(id, { key, uploadKey, ready: false, resource });
  return { id, uploadKey, resource };
};

const getResource = id => {
  const record = resources.get(id);
  if (!record) throw new HttpError(404, '附件不存在');
  return record;
};

const signReadUrl = record =>
  tos.getPreSignedUrl({
    bucket,
    key: record.key,
    method: 'GET',
    expires: 300,
    response: {
      contentType: record.resource.mimeType,
    },
  });

const handler = async (request, response) => {
  const origin = request.headers.origin;
  if (origin && allowedOrigins.has(origin)) {
    response.setHeader('Access-Control-Allow-Origin', origin);
    response.setHeader('Vary', 'Origin');
    response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
    response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  }
  if (request.method === 'OPTIONS') {
    if (origin && !allowedOrigins.has(origin)) throw new HttpError(403, 'Origin 不允许');
    response.writeHead(204);
    response.end();
    return;
  }

  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `localhost:${port}`}`);
  if (url.pathname === '/api/attachments/uploads' && request.method === 'POST') {
    const { id, uploadKey } = createResource(await readJson(request));
    sendJson(response, {
      uploadId: id,
      method: 'PUT',
      url: tos.getPreSignedUrl({ bucket, key: uploadKey, method: 'PUT', expires: 300 }),
      headers: { 'Content-Type': resources.get(id).resource.mimeType },
    });
    return;
  }

  const completeMatch = url.pathname.match(/^\/api\/attachments\/uploads\/([^/]+)\/complete$/);
  if (completeMatch && request.method === 'POST') {
    const record = getResource(completeMatch[1]);
    if (record.ready) {
      sendJson(response, record.resource);
      return;
    }
    const head = await tos.headObject({ bucket, key: record.uploadKey });
    const actualSize = Number(head.data['content-length']);
    if (actualSize !== record.resource.size) {
      throw new HttpError(409, `TOS 对象大小不一致:期望 ${record.resource.size},实际 ${actualSize}`);
    }
    const actualType = head.data['content-type'];
    if (actualType && actualType !== record.resource.mimeType) {
      throw new HttpError(409, `TOS 对象类型不一致:期望 ${record.resource.mimeType},实际 ${actualType}`);
    }
    await tos.copyObject({
      bucket,
      key: record.key,
      srcBucket: bucket,
      srcKey: record.uploadKey,
      contentType: record.resource.mimeType,
      metadataDirective: 'REPLACE',
      forbidOverwrite: true,
    });
    record.ready = true;
    sendJson(response, record.resource);
    return;
  }

  const resourceMatch = url.pathname.match(/^\/api\/attachments\/resources\/([^/]+)\/(preview|model-source)$/);
  if (resourceMatch) {
    const [, id, operation] = resourceMatch;
    const record = getResource(id);
    if (!record.ready) throw new HttpError(409, '附件尚未完成上传');

    if (operation === 'preview' && request.method === 'GET') {
      sendJson(response, {
        url: signReadUrl(record),
        expiresAt: Date.now() + 5 * 60 * 1000,
      });
      return;
    }
    if (operation === 'model-source' && request.method === 'POST') {
      sendJson(response, { type: 'url', url: signReadUrl(record) });
      return;
    }
  }

  throw new HttpError(404, '接口不存在');
};

createServer((request, response) => {
  void handler(request, response).catch(error => {
    sendJson(
      response,
      { message: error instanceof Error ? error.message : 'TOS 文件服务失败' },
      error instanceof HttpError ? error.status : 500
    );
  });
}).listen(port, '127.0.0.1', () => {
  console.log(`TOS attachment service: http://127.0.0.1:${port}/api/attachments`);
});

把代码保存为 tos-attachment-server.mjs,用服务端环境变量启动。下面的星号是占位符,不要把真实 AK/SK 写进代码、前端环境变量或版本库:

TOS_ACCESS_KEY_ID=*** \
TOS_SECRET_ACCESS_KEY=*** \
TOS_BUCKET=your-private-bucket \
TOS_REGION=cn-beijing \
APP_ORIGINS=http://localhost:5173 \
node tos-attachment-server.mjs

使用临时 STS 凭证时再配置 TOS_STS_TOKEN=***。自定义 TOS endpoint 时配置 TOS_ENDPOINT;未配置时按 tos-${TOS_REGION}.volces.com 生成。

环境变量从这些位置获取:

变量怎么获取
TOS_BUCKET在对象存储控制台创建私有桶后填写桶名称。创建步骤见官方文档创建存储桶。
TOS_REGION填创建桶时选择的 Region ID,例如 cn-beijing。Region 必须和桶所属地域一致,完整列表见地域和访问域名(Endpoint)。
TOS_ACCESS_KEY_ID / TOS_SECRET_ACCESS_KEY在API 访问密钥页面创建 Access Key,分别填写 Access Key ID 和 Secret Access Key。官方说明见API 密钥(Access Key 与 API Key)。生产环境建议为文件服务创建独立 IAM 用户并授予最小 TOS 权限,不要使用主账号长期密钥。
APP_ORIGINS调用文件服务的 Web 应用 Origin,本地文档站通常是 http://localhost:5173;生产环境填实际站点 Origin。多个 Origin 用英文逗号分隔。
TOS_STS_TOKEN仅使用临时 AK/SK 时填写 STS Token。临时 AK/SK 和 Token 需要由服务端调用 STS 获取,流程见使用 STS 临时 AK/SK+Token 访问 TOS。
TOS_ENDPOINT通常可以不填,由 TOS_REGION 自动生成外网 Endpoint。需要指定内网 Endpoint、CN 域名或特殊地域时,从地域和访问域名(Endpoint)复制对应值,例如 tos-cn-beijing.volces.com。

AK/SK、STS Token 和 bucket 配置都只放在服务端环境变量中,不要写入前端环境变量、源码或文档示例。 创建桶时保持私有读写,浏览器上传、用户预览和模型读取都通过服务端签发短期 URL。

客户端仍使用上一节的 attachment-client.ts,只需把 baseUrl 指向这项服务。私有桶还需要一条 CORS 规则,允许实际业务 Origin:

配置项建议值
Originhttp://localhost:5173 或实际业务 Origin
MethodsPUT, GET, HEAD
Allow-Headers*
Expose-HeadersETag
Max-Age3600
Vary: Origin开启

不要填写 * Origin,也不要把桶改成公共读。 浏览器直传只需要短期预签名 PUT;用户预览和模型读取都使用服务端按需签发的短期 GET。

TOS 默认桶域名会对预签名或匿名访问强制返回 Content-Disposition: attachment,不能直接用于浏览器在线预览,详见存储桶域名使用限制公告。使用默认域名时让浏览器客户端物化 Blob URL:

const attachments = createStorageAttachments({
  baseUrl: 'http://localhost:8787/api/attachments',
  materializePreview: true,
});

已绑定可在线预览的自定义域名,或由业务文件服务代理并返回 inline 响应时,保持 materializePreview: false,直接打开短期 URL。model-source 不受浏览器预览限制,仍应返回模型服务可访问的 TOS 签名 URL。

上线前改成生产实现

本地和 TOS 示例都把资源记录放在进程内存中。生产环境必须保留 HTTP 契约,并把资源状态、权限和对象生命周期接入业务数据库:

  1. POST /uploads 在数据库创建 pending 资源记录,以租户、会话和随机 ID 生成对象 key,再用服务端 TOS SDK 返回短期预签名 PUT URL。
  2. POST /uploads/:id/complete 使用 TOS HEAD Object 核对临时上传对象的字节数和 MIME,再复制到禁止覆盖的稳定 key;之后才把资源标记为 ready 并返回稳定 resource.reference。旧预签名 PUT 即使仍在有效期内,也只能改写临时 key,不能替换已确认资源。
  3. GET /resources/:id/preview 校验当前用户是否能读该资源,再签发短期 GET URL;不要把这个 URL 写入消息历史。
  4. POST /resources/:id/model-source 单独签发模型读取地址。模型服务不在用户浏览器网络内时,该地址必须能被模型所在网络访问。
  5. 删除会话、资源过期和用户主动删除时,由业务服务执行对象清理;ChatKit 不直接删除云端对象。

AK/SK、STS 和 bucket 配置必须留在服务端。 浏览器只获得当前操作所需的短期地址。TOS bucket 的 CORS 只允许业务 Origin、PUT/GET/HEAD 和签名要求的请求头。

上线检查

  • 上传申请和完成确认都校验文件名、MIME、声明大小、实际对象大小、数量与总额度。
  • 资源 ID 必须绑定租户和权限,不能仅凭可猜测 ID 返回预览 URL。
  • 上传 URL、预览 URL 和模型读取 URL 分开签发并设置较短有效期。
  • 历史只保存稳定的 { id, name, mimeType, size, source },不要保存 Blob URL、base64 临时预览或过期签名。
  • resolvePreviewUrl 和 resolveForSend 必须处理 AbortSignal;地址过期后重新申请。
  • 生产文件服务要有资源引用计数或归属关系、过期上传回收、会话删除清理和定时垃圾回收,不能依赖浏览器生命周期。
  • TOS 默认桶域名需要启用 materializePreview;可在线预览的自定义域名或业务代理可以直接返回短期签名 URL。