Convex文件存储Skill convex-file-storage

Convex 文件存储技能是一个云原生文件处理解决方案,用于在Convex平台上上传、存储、服务和文件。它支持多种文件类型,提供自动URL生成、元数据访问和与后端操作的集成。关键词:Convex、文件存储、云存储、上传文件、服务文件、文件管理、无服务器计算。

Serverless 0 次安装 0 次浏览 更新于 3/17/2026

name: convex-file-storage displayName: Convex 文件存储 description: 完整的文件处理包括上传流程、通过URL服务文件、存储从操作生成的文件、删除以及从系统表访问文件元数据 version: 1.0.0 author: Convex tags: [convex, file-storage, uploads, images, files]

Convex 文件存储

在Convex应用程序中处理文件上传、存储、服务和管理,采用适用于图像、文档和生成文件的正确模式。

文档来源

在实施之前,不要假设;获取最新文档:

说明

文件存储概述

Convex提供内置文件存储,具有:

  • 自动URL生成以服务文件
  • 支持任何文件类型(图像、PDF、视频等)
  • 通过 _storage 系统表的文件元数据
  • 与突变和操作的集成

生成上传URL

// convex/files.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const generateUploadUrl = mutation({
  args: {},
  returns: v.string(),
  handler: async (ctx) => {
    return await ctx.storage.generateUploadUrl();
  },
});

客户端上传

// React组件
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useState } from "react";

function FileUploader() {
  const generateUploadUrl = useMutation(api.files.generateUploadUrl);
  const saveFile = useMutation(api.files.saveFile);
  const [uploading, setUploading] = useState(false);

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setUploading(true);
    try {
      // 步骤1:获取上传URL
      const uploadUrl = await generateUploadUrl();

      // 步骤2:上传文件到存储
      const result = await fetch(uploadUrl, {
        method: "POST",
        headers: { "Content-Type": file.type },
        body: file,
      });

      const { storageId } = await result.json();

      // 步骤3:保存文件引用到数据库
      await saveFile({
        storageId,
        fileName: file.name,
        fileType: file.type,
        fileSize: file.size,
      });
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleUpload}
        disabled={uploading}
      />
      {uploading && <p>上传中...</p>}
    </div>
  );
}

保存文件引用

// convex/files.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";

export const saveFile = mutation({
  args: {
    storageId: v.id("_storage"),
    fileName: v.string(),
    fileType: v.string(),
    fileSize: v.number(),
  },
  returns: v.id("files"),
  handler: async (ctx, args) => {
    return await ctx.db.insert("files", {
      storageId: args.storageId,
      fileName: args.fileName,
      fileType: args.fileType,
      fileSize: args.fileSize,
      uploadedAt: Date.now(),
    });
  },
});

通过URL服务文件

// convex/files.ts
export const getFileUrl = query({
  args: { storageId: v.id("_storage") },
  returns: v.union(v.string(), v.null()),
  handler: async (ctx, args) => {
    return await ctx.storage.getUrl(args.storageId);
  },
});

// 获取带URL的文件
export const getFile = query({
  args: { fileId: v.id("files") },
  returns: v.union(
    v.object({
      _id: v.id("files"),
      fileName: v.string(),
      fileType: v.string(),
      fileSize: v.number(),
      url: v.union(v.string(), v.null()),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    const file = await ctx.db.get(args.fileId);
    if (!file) return null;

    const url = await ctx.storage.getUrl(file.storageId);
    
    return {
      _id: file._id,
      fileName: file.fileName,
      fileType: file.fileType,
      fileSize: file.fileSize,
      url,
    };
  },
});

在React中显示文件

import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";

function FileDisplay({ fileId }: { fileId: Id<"files"> }) {
  const file = useQuery(api.files.getFile, { fileId });

  if (!file) return <div>加载中...</div>;
  if (!file.url) return <div>文件未找到</div>;

  // 处理不同文件类型
  if (file.fileType.startsWith("image/")) {
    return <img src={file.url} alt={file.fileName} />;
  }

  if (file.fileType === "application/pdf") {
    return (
      <iframe
        src={file.url}
        title={file.fileName}
        width="100%"
        height="600px"
      />
    );
  }

  return (
    <a href={file.url} download={file.fileName}>
      下载 {file.fileName}
    </a>
  );
}

存储从操作生成的文件

// convex/generate.ts
"use node";

import { action } from "./_generated/server";
import { v } from "convex/values";
import { api } from "./_generated/api";

export const generatePDF = action({
  args: { content: v.string() },
  returns: v.id("_storage"),
  handler: async (ctx, args) => {
    // 生成PDF(示例使用库)
    const pdfBuffer = await generatePDFFromContent(args.content);

    // 转换为Blob
    const blob = new Blob([pdfBuffer], { type: "application/pdf" });

    // 存储在Convex中
    const storageId = await ctx.storage.store(blob);

    return storageId;
  },
});

// 生成并保存图像
export const generateImage = action({
  args: { prompt: v.string() },
  returns: v.id("_storage"),
  handler: async (ctx, args) => {
    // 调用外部API生成图像
    const response = await fetch("https://api.example.com/generate", {
      method: "POST",
      body: JSON.stringify({ prompt: args.prompt }),
    });

    const imageBuffer = await response.arrayBuffer();
    const blob = new Blob([imageBuffer], { type: "image/png" });

    return await ctx.storage.store(blob);
  },
});

访问文件元数据

// convex/files.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { Id } from "./_generated/dataModel";

type FileMetadata = {
  _id: Id<"_storage">;
  _creationTime: number;
  contentType?: string;
  sha256: string;
  size: number;
};

export const getFileMetadata = query({
  args: { storageId: v.id("_storage") },
  returns: v.union(
    v.object({
      _id: v.id("_storage"),
      _creationTime: v.number(),
      contentType: v.optional(v.string()),
      sha256: v.string(),
      size: v.number(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    const metadata = await ctx.db.system.get(args.storageId);
    return metadata as FileMetadata | null;
  },
});

删除文件

// convex/files.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const deleteFile = mutation({
  args: { fileId: v.id("files") },
  returns: v.null(),
  handler: async (ctx, args) => {
    const file = await ctx.db.get(args.fileId);
    if (!file) return null;

    // 从存储中删除
    await ctx.storage.delete(file.storageId);

    // 删除数据库记录
    await ctx.db.delete(args.fileId);

    return null;
  },
});

图像上传与预览

import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useState, useRef } from "react";

function ImageUploader({ onUpload }: { onUpload: (id: Id<"files">) => void }) {
  const generateUploadUrl = useMutation(api.files.generateUploadUrl);
  const saveFile = useMutation(api.files.saveFile);
  const [preview, setPreview] = useState<string | null>(null);
  const [uploading, setUploading] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // 验证文件类型
    if (!file.type.startsWith("image/")) {
      alert("请选择图像文件");
      return;
    }

    // 验证文件大小(最大10MB)
    if (file.size > 10 * 1024 * 1024) {
      alert("文件大小必须小于10MB");
      return;
    }

    // 显示预览
    const reader = new FileReader();
    reader.onload = (e) => setPreview(e.target?.result as string);
    reader.readAsDataURL(file);

    // 上传
    setUploading(true);
    try {
      const uploadUrl = await generateUploadUrl();
      const result = await fetch(uploadUrl, {
        method: "POST",
        headers: { "Content-Type": file.type },
        body: file,
      });

      const { storageId } = await result.json();
      const fileId = await saveFile({
        storageId,
        fileName: file.name,
        fileType: file.type,
        fileSize: file.size,
      });

      onUpload(fileId);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        accept="image/*"
        onChange={handleFileSelect}
        style={{ display: "none" }}
      />
      
      <button
        onClick={() => inputRef.current?.click()}
        disabled={uploading}
      >
        {uploading ? "上传中..." : "选择图像"}
      </button>

      {preview && (
        <img
          src={preview}
          alt="预览"
          style={{ maxWidth: 200, marginTop: 10 }}
        />
      )}
    </div>
  );
}

示例

文件存储模式

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  files: defineTable({
    storageId: v.id("_storage"),
    fileName: v.string(),
    fileType: v.string(),
    fileSize: v.number(),
    uploadedBy: v.id("users"),
    uploadedAt: v.number(),
  })
    .index("by_user", ["uploadedBy"])
    .index("by_type", ["fileType"]),

  // 用户头像
  users: defineTable({
    name: v.string(),
    email: v.string(),
    avatarStorageId: v.optional(v.id("_storage")),
  }),

  // 带图像的帖子
  posts: defineTable({
    authorId: v.id("users"),
    content: v.string(),
    imageStorageIds: v.array(v.id("_storage")),
    createdAt: v.number(),
  }).index("by_author", ["authorId"]),
});

最佳实践

  • 除非明确指示,否则不要运行 npx convex deploy
  • 除非明确指示,否则不要运行任何git命令
  • 在上传前在客户端验证文件类型和大小
  • 在自己的表中存储文件元数据(名称、类型、大小)
  • 仅将 _storage 系统表用于Convex元数据
  • 在删除数据库引用时删除存储文件
  • 上传时使用适当的Content-Type头
  • 考虑对大图像进行优化

常见陷阱

  1. 未设置Content-Type头 - 文件可能无法正确服务
  2. 忘记删除存储 - 孤立文件浪费存储空间
  3. 未验证文件类型 - 恶意上传的安全风险
  4. 大文件上传无进度 - 用户体验差
  5. 使用已弃用的getMetadata - 改用ctx.db.system.get

参考