Convex 文件存储与 Action 实战:用 dall-e-storage-action 示例把 Dall-E 图片持久化到聊天应用

原创2026-09-22 16:53:04413 阅读
文章标签:数据库后端

Convex 文件存储与 Action 实战:用 dall-e-storage-action 示例把 Dall-E 图片持久化到聊天应用

本文以开源仓库 convex-backend 中的 dall-e-storage-action 示例应用为研究对象,完整讲解如何将 Convex 的文件存储(Storage)与 Action(服务端操作)组合使用:当用户输入 /dall-e cute cat 之类的指令时,Action 调用 OpenAI 生成图片、下载图片二进制内容并存入 Convex 存储,再把 storageId 写入数据库,最后在查询中按需把 storageId 转换为可访问的图片 URL 展示在聊天流中。读完本文,你将掌握 ctx.storage.store / ctx.storage.getUrl 的完整调用链、convex.config.ts 中环境变量的声明方式,以及前后端如何通过 useAction 触发这一异步流程。

一、示例应用解决的核心问题

OpenAI 的 Dall-E 生成的图片 URL 默认只在一小时内有效,因此如果聊天应用直接把图片 URL 存进数据库,用户很快就会发现图片“过期”打不开。dall-e-storage-action 的解决思路是:在图片 URL 失效之前,把它下载下来并存入 Convex 文件存储,然后只把存储返回的 storageId 写入消息记录;展示时再把 storageId 动态转换为 Convex 托管的图片地址。

这个思路对应 README.md 中描述的三个代码落点,对应到当前仓库中的实际文件为:

  • 在 convex/dallE.ts 的 action 中下载图片并存进 Convex 存储,同时把 storageId 随消息一起写入;
  • 在 convex/messages.ts 中把 storageId 作为消息正文落库;
  • 在 convex/messages.ts 的查询中,按需把 storageId 转成图片 URL 返回给前端。

应用本身构建在 Convex 官方教程(tutorial)demo 的聊天应用之上,教程版本位于 npm-packages/demos/tutorial,区别在于本示例引入了 Action(Node.js 运行时)与文件存储,使聊天流中能出现 Dall-E 生成的图片消息。

二、运行前的环境准备

1. 安装依赖并初始化部署

按 README.md 的指引,在项目根目录执行:

npm install
npx convex init

其中 convex init 会创建本地 Convex 项目配置并建立部署连接。项目的依赖构成见 package.json:运行时依赖 convex、openai(示例锁定 ^6.0.0)、react 与 react-dom,构建侧使用 Vite 与 TypeScript。

2. 配置 OPENAI_API_KEY 环境变量

本应用的 convex/convex.config.ts 使用 defineApp 显式声明了所需环境变量:

import { defineApp } from "convex/server";
import { v } from "convex/values";

const app = defineApp({
  env: {
    OPENAI_API_KEY: v.string(),
  },
});

export default app;

env: { OPENAI_API_KEY: v.string() } 的含义是:该部署必须配置字符串类型的 OPENAI_API_KEY,否则 Convex 会拒绝接收代码(即 convex dev / deploy 会因缺少环境变量而报错)。这也是文档中“deployment won't accept code until it is set”的原因。

设置环境变量的推荐方式是使用 CLI 交互式输入,避免密钥出现在 shell 历史记录中:

npx convex env set OPENAI_API_KEY

执行后会提示粘贴密钥,输入内容被隐藏。也可以在 Convex dashboard 的 Environment Variables 页面设置。密钥的值在代码中通过 env.OPENAI_API_KEY 读取(见下文 dallE.ts),env 由 convex/_generated/server 导出,类型与 convex.config.ts 的声明严格对应。

3. 启动开发服务器

npm run dev

package.json 中该脚本定义为 convex dev --start 'vite --open',即同时启动 Convex 本地开发服务与 Vite 前端,并自动打开浏览器。随后访问 localhost:3000 即可看到聊天界面。

三、核心实现:Action 中完成“生成—下载—存储—落库”全链路

文件 convex/dallE.ts 是整个示例的心脏。它定义了一个带 "use node" 指令的 Action,运行在 Node.js 运行时上,因此可以自由调用外部 HTTP 服务与第三方 SDK:

"use node";

import OpenAI from "openai";
import { action, env } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

export const send = action({
  args: {
    prompt: v.string(),
    author: v.string(),
  },
  handler: async (ctx, { prompt, author }) => {
    const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY });

    // 1. 内容安全检查:拒绝违规 prompt
    const modResponse = await openai.moderations.create({
      input: prompt,
    });
    const modResult = modResponse.results[0];
    if (modResult.flagged) {
      throw new Error(
        `Your prompt was flagged: ${JSON.stringify(modResult.categories)}`,
      );
    }

    // 2. 调用 Dall-E 生成图片
    const openaiResponse = await openai.images.generate({
      prompt,
      size: "256x256",
    });
    const dallEImageUrl = openaiResponse.data![0]["url"]!;

    // 3. 下载图片并存入 Convex 存储,把 storageId 作为消息正文写入
    const imageResponse = await fetch(dallEImageUrl);
    if (!imageResponse.ok) {
      throw new Error(`failed to download: ${imageResponse.statusText}`);
    }
    const image = await imageResponse.blob();
    const storageId = await ctx.storage.store(image as Blob);

    await ctx.runMutation(internal.messages.sendDallEMessage, {
      body: storageId,
      author,
      prompt,
    });
  },
});

这段代码值得逐段拆解:

  • 内容安全前置检查:在生成图片之前先用 openai.moderations.create 对 prompt 做审查,若返回 flagged: true 则直接抛出异常,把被标记的类别一并写入错误信息,从源头拦截违规内容。
  • 图片生成:openai.images.generate 传入 prompt 与尺寸参数(示例固定为 256x256),从响应中取出临时图片 URL。
  • 关键一步——下载并持久化:由于 Dall-E 返回的 URL 约一小时后过期,必须立刻 fetch 下载并转为 Blob,随后调用 ctx.storage.store(blob)。store 返回的 storageId 才是需要长期保存的“凭证”,数据库里存的不是 URL,而是这个 ID。
  • 跨函数写入数据库:Action 不能直接写数据库,因此通过 ctx.runMutation(internal.messages.sendDallEMessage, ...) 调用内部 mutation 完成插入,同时把 prompt 一并保存以便前端展示图片提示词。

Action 中不能写数据库的原因与 runMutation 的作用

这是本示例体现出的一个重要的 Convex 架构约束:Action 运行在独立于事务引擎的 Node.js 运行时,为了执行长时间的外部 I/O(HTTP 调用等)而设计,因此不具备直接的数据库读写能力。凡是需要落库的数据,都必须通过 ctx.runMutation / ctx.runQuery 委托给 mutation / query 执行,保证数据变更仍发生在 ACID 事务中。本例把“外部 HTTP 耗时操作”与“事务性写入”清晰分离,正是 Action 的标准用法。

四、数据层:storageId 如何随消息持久化

convex/messages.ts 定义了三种函数,分别负责图片消息落库、文本消息落库与消息列表查询:

export const sendDallEMessage = internalMutation({
  args: {
    body: v.string(),
    author: v.string(),
    prompt: v.string(),
  },
  handler: async (ctx, { body, author, prompt }) => {
    const message = { body, author, format: "dall-e", prompt };
    await ctx.db.insert("messages", message);
  },
});

export const send = mutation({
  args: {
    body: v.string(),
    author: v.string(),
  },
  handler: async (ctx, { body, author }) => {
    const message = { body, author, format: "text" };
    await ctx.db.insert("messages", message);
  },
});

export const list = query({
  args: {},
  handler: async (ctx) => {
    const messages = await ctx.db.query("messages").collect();
    for (const message of messages) {
      if (message.format === "dall-e") {
        message.body = await ctx.storage.getUrl(message.body);
      }
    }
    return messages;
  },
});

要点如下:

  • 统一的消息表与 format 字段:文本消息与图片消息共用 messages 表,通过 format: "text" | "dall-e" 区分。图片消息额外携带 prompt,正文 body 保存的是 storageId(字符串)。
  • 内部 mutation 只允许服务端调用:sendDallEMessage 声明为 internalMutation,前端无法直接调用,只能由 Action 通过 internal.messages.sendDallEMessage 触发,避免用户绕过生成流程直接写入伪造的图片消息。
  • 查询时按需转 URL:list 遍历消息时,仅对 format === "dall-e" 的消息调用 ctx.storage.getUrl(message.body),把 storageId 实时转换成 Convex 托管的可访问地址。这保证了客户端拿到的一直是有效 URL,同时数据库里保存的始终是紧凑、稳定的 ID。
  • 本示例未提供 schema.ts,生成的 convex/_generated/dataModel.d.ts 中 Doc 为宽松的 any 类型;如需更严格的类型检查,可按该文件中的提示补充 schema 后重新运行 npx convex dev 触发代码生成。

五、前端:用 useAction 触发长耗时操作

src/App.tsx 展示了客户端如何区分普通文本与 Dall-E 指令,并调用 Action:

const sendDallE = useAction(api.dallE.send);

async function handleSendMessage(event: FormEvent) {
  event.preventDefault();
  if (
    newMessageText.startsWith("/dalle ") ||
    newMessageText.startsWith("/dall-e ")
  ) {
    const prompt = newMessageText.split(" ").slice(1).join(" ");
    setSending(true);
    try {
      await sendDallE({ prompt, author: name });
    } finally {
      setSending(false);
    }
  } else {
    await sendMessage({ body: newMessageText, author: name });
  }
  setNewMessageText("");
}
  • 输入以 /dalle 或 /dall-e 开头时,截取剩余部分作为 prompt,调用 useAction(api.dallE.send);否则走普通 useMutation(api.messages.send)。
  • 渲染图片消息时,直接使用查询返回的 URL 作为 <img src>,并展示 prompt 与 "Powered by Dall-E (OpenAI)" 的署名:
    {message.format === "dall-e" ? (
      <figure>
        <img title={message.prompt} src={message.body} />
        <div className="dall-e-attribution">Powered by Dall-E (OpenAI)</div>
      </figure>
    ) : (
      <span>{message.body}</span>
    )}
    
  • 消息列表通过 useQuery(api.messages.list) 实时订阅,图片生成期间展示加载动画(sending 状态渲染 lds-dual-ring 指示器)。

六、底层支撑:ctx.storage 存储 API 的原理

示例中的两个核心调用 store 与 getUrl 对应 Convex 客户端 SDK 中定义的存储接口,见 npm-packages/convex/src/server/storage.ts:

  • StorageReader 接口提供 getUrl(storageId: GenericId<"_storage">): Promise<string | null>,将存储 ID 解析为可 GET 访问的 URL;文档注释还说明 GET 响应会携带标准 HTTP Digest 头(sha256 校验和)。getMetadata 已标记为废弃,推荐改用 ctx.db.system.get("_storage", storageId) 读取文件元数据(含 sha256、size、contentType 等字段)。
  • StorageWriter 接口提供 store(...),用于把文件二进制内容上传到 Convex 存储并获得新的存储 ID。示例中 ctx.storage.store(image as Blob) 的 as Blob 转换对应源码注释中提到的类型限制(fetch 返回的 Blob 与 Convex 期望的 Blob 类型存在细微差异,代码中以 TODO 标注,等待后续版本放开)。

由此可以理解整个数据流中“ID 与内容分离”的设计:_storage 是 Convex 的系统表,文件本体与元数据由平台托管,业务表只需保存 Id<"_storage"> 即可实现文件的持久引用、去重与按需取用。

七、运行验证与延伸思考

按上文步骤配置完成后启动 npm run dev,在聊天框输入 /dall-e cute cat,即可看到聊天流中出现生成的猫咪图片。整条链路的可验证点包括:

  1. Action 中 OpenAI moderation 拦截违规 prompt(可用敏感词验证报错路径);
  2. 图片消息在 messages 表中以 storageId 形式保存,而非临时 URL;
  3. 查询接口返回的 body 已是可访问的图片 URL;
  4. 刷新页面后图片依然可用(不受 Dall-E 一小时过期限制)。

这个示例模式可以推广到其他“外部服务生成内容 → 持久化 → 展示”的场景,例如语音转文字、PDF 生成、视频封面抓取等。核心范式始终一致:在 Action 中完成外部调用与文件下载,用 ctx.storage.store 持久化二进制,用 ctx.runMutation 写入业务数据,在前端查询中用 ctx.storage.getUrl 按需换取访问地址。

八、小结

dall-e-storage-action 虽然是一个小型示例,却完整覆盖了 Convex 生态中三个高频且容易混淆的能力:Action(长耗时外部调用)、文件存储(store / getUrl / _storage 系统表)与内部函数(internalMutation)。通过阅读 convex/dallE.ts、convex/messages.ts 与前端 src/App.tsx,再结合 storage.ts 中的接口文档,即可在实际项目中复刻这套“生成—持久化—按需展示”的完整链路。

登录后查看全文
convex-backend