16.10-文件存储服务

要点

  • 业务逻辑不应该直接依赖 R2 或 S3 的 API——加一层 StorageProvider 接口抽象,R2 和 S3 两种实现共享同一套签名
  • 文件元数据(名称、大小、类型、上传者、处理状态)存在 D1,和对象存储分离
  • 访问控制分三档:公开文件走 CDN、私有文件走 Worker 代理鉴权、临时分享链接带过期时间
  • 文件生命周期:pending → processing → completed / failed → deleted,配合定时任务做清理
  • 通过 Hono 中间件注入存储实现,环境变量决定用哪个后端

1. 为什么需要存储抽象层

前面的文章里,上传文件直接调用 c.env.BUCKET.put(key, body),下载直接调用 bucket.get(key)。如果项目只用 R2、只跑在 Cloudflare 上,这样做没问题。

但实际情况往往会有变化:本地开发没有 R2 绑定、团队同时维护 Cloudflare 和 AWS 两套部署、自建环境用 MinIO、测试时需要 mock 存储层。这些场景都指向同一件事:业务代码应该依赖一个接口,而不是某个具体的存储 SDK

在 Workers 环境里做这件事有一个方便的条件——存储操作的接口足够简单。上传、下载、删除、列出,四个操作就能覆盖大部分需求,用 TypeScript 的 interface 就能把契约描述清楚。

2. 统一的 StorageProvider 接口

// src/storage/types.ts
export interface StorageProvider {
  put(
    key: string,
    data: Uint8Array | ReadableStream,
    options: { contentType?: string; metadata?: Record<string, string> }
  ): Promise<void>
 
  get(key: string): Promise<{
    data: ReadableStream
    contentType?: string
    metadata?: Record<string, string>
  } | null>
 
  delete(key: string): Promise<void>
 
  list(prefix: string, limit?: number): Promise<
    Array<{ key: string; size: number; uploaded: Date }>
  >
}

几个设计考虑:

  1. dataUint8Array | ReadableStream——R2 和 S3 都支持这两种格式,接口层不需要做转换
  2. metadata 是业务元数据——上传者 ID、关联文档 ID 这类信息。在 R2 里对应 customMetadata,在 S3 里对应 Metadata
  3. 接口只描述文件操作,不管元数据持久化——文件记录存 D1 还是 PostgreSQL,跟这个接口无关

3. R2 实现

R2 和 Workers 原生集成,通过 R2Bucket 绑定直接访问,不需要额外的认证配置。

// src/storage/r2-provider.ts
import type { StorageProvider } from './types'
 
export class R2StorageProvider implements StorageProvider {
  constructor(private bucket: R2Bucket) {}
 
  async put(key: string, data: Uint8Array | ReadableStream, options: {
    contentType?: string; metadata?: Record<string, string>
  }): Promise<void> {
    await this.bucket.put(key, data, {
      httpMetadata: { contentType: options.contentType ?? 'application/octet-stream' },
      customMetadata: options.metadata,
    })
  }
 
  async get(key: string) {
    const object = await this.bucket.get(key)
    if (!object) return null
    return {
      data: object.body,
      contentType: object.httpMetadata?.contentType,
      metadata: object.customMetadata,
    }
  }
 
  async delete(key: string): Promise<void> {
    await this.bucket.delete(key)
  }
 
  async list(prefix: string, limit = 1000) {
    const listed = await this.bucket.list({ prefix, limit })
    return listed.objects.map((obj) => ({
      key: obj.key, size: obj.size, uploaded: obj.uploaded,
    }))
  }
}

R2 的优势:零出口流量费(S3 收 $0.09/GB)、和 Workers 深度集成不需要配置 access key、S3 兼容 API 让外部工具也能操作同一个 bucket。

4. S3 兼容实现

AWS S3、MinIO、Backblaze B2 都支持 S3 API。用 @aws-sdk/client-s3 可以覆盖这些平台:

// src/storage/s3-provider.ts
import { S3Client, PutObjectCommand, GetObjectCommand,
         DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import type { StorageProvider } from './types'
 
export class S3StorageProvider implements StorageProvider {
  constructor(private bucket: string, private client: S3Client) {}
 
  async put(key: string, data: Uint8Array | ReadableStream, options: {
    contentType?: string; metadata?: Record<string, string>
  }): Promise<void> {
    await this.client.send(new PutObjectCommand({
      Bucket: this.bucket, Key: key, Body: data,
      ContentType: options.contentType, Metadata: options.metadata,
    }))
  }
 
  async get(key: string) {
    const output = await this.client.send(new GetObjectCommand({
      Bucket: this.bucket, Key: key,
    }))
    if (!output.Body) return null
    return {
      data: output.Body as ReadableStream,
      contentType: output.ContentType,
      metadata: output.Metadata,
    }
  }
 
  async delete(key: string): Promise<void> {
    await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }))
  }
 
  async list(prefix: string, limit = 1000) {
    const output = await this.client.send(new ListObjectsV2Command({
      Bucket: this.bucket, Prefix: prefix, MaxKeys: limit,
    }))
    return (output.Contents ?? []).map((obj) => ({
      key: obj.Key!, size: obj.Size ?? 0, uploaded: obj.LastModified ?? new Date(),
    }))
  }
}
 
export function createS3Provider(options: {
  endpoint?: string; region: string
  accessKeyId: string; secretAccessKey: string; bucket: string
}): S3StorageProvider {
  const client = new S3Client({
    endpoint: options.endpoint, region: options.region,
    credentials: { accessKeyId: options.accessKeyId, secretAccessKey: options.secretAccessKey },
    forcePathStyle: !!options.endpoint, // MinIO 等自建服务需要
  })
  return new S3StorageProvider(options.bucket, client)
}

两种实现的对比:R2 零出口流量费、Worker 绑定直接调用,但签名 URL 需要 Worker 代理;S3 兼容 API 覆盖 AWS / MinIO / 多云场景,SDK 内置 presigned URL,但需要配置认证。

在 Cloudflare 上跑的项目,R2 是更合适的默认选择。需要多云部署或者已有 S3 资产的场景,再切 S3 实现。

5. 文件元数据管理

对象存储只管文件内容和简单的 HTTP 头信息。业务层面的元数据——原始文件名、上传者、处理状态、关联的业务实体——存在 D1 里。

表结构

// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
 
export const files = sqliteTable('files', {
  id: text('id').primaryKey(),
  key: text('key').notNull().unique(),            // 对象存储的 key
  originalName: text('original_name').notNull(),  // 用户上传时的文件名
  mimeType: text('mime_type').notNull(),
  size: integer('size').notNull(),
  status: text('status').notNull().default('pending'),
  userId: text('user_id').notNull(),
  tenantId: text('tenant_id').notNull(),
  knowledgeBaseId: text('knowledge_base_id'),     // 关联的知识库
  processingResult: text('processing_result'),    // 处理结果(如解析后的文本)
  errorMessage: text('error_message'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})
 
export type FileStatus =
  | 'pending'     // 已上传,等待处理
  | 'processing'  // 处理中
  | 'completed'   // 处理完成
  | 'failed'      // 处理失败
  | 'deleted'     // 已标记删除(软删除)

对象存储和数据库的分工:对象存储负责文件二进制内容 + Content-Type + 轻量自定义 metadata;D1 负责业务元数据 + 状态流转 + 权限关联 + 处理结果。

记录操作

async function createFileRecord(
  db: DrizzleD1Database,
  input: { key: string; originalName: string; mimeType: string
           size: number; userId: string; tenantId: string }
) {
  const record = {
    id: crypto.randomUUID(), ...input,
    status: 'pending' as const,
    createdAt: new Date(), updatedAt: new Date(),
  }
  await db.insert(files).values(record)
  return record
}
 
async function updateFileStatus(
  db: DrizzleD1Database, fileId: string, status: FileStatus,
  extra?: { processingResult?: string; errorMessage?: string }
) {
  await db.update(files)
    .set({ status, ...extra, updatedAt: new Date() })
    .where(eq(files.id, fileId))
}

6. 访问控制

访问控制分三种场景。

公开文件

头像、公开文档附件——任何人拿到 URL 就能访问,直接走 CDN,不经过 Worker:

function getPublicUrl(key: string, publicDomain: string): string {
  return `https://${publicDomain}/${key}`
}

私有文件

合同、个人数据——只有鉴权用户能访问。Worker 充当下载代理,先查权限再返回文件:

app.get('/api/files/:key/download', async (c) => {
  const user = c.get('user')
  const storage = c.get('storage') as StorageProvider
  const key = decodeURIComponent(c.req.param('key'))
  const record = await db.query.files.findFirst({ where: eq(files.key, key) })
  if (!record) return c.json({ error: 'Not found' }, 404)
  if (record.tenantId !== user.tenantId) return c.json({ error: 'Forbidden' }, 403)
 
  const result = await storage.get(key)
  if (!result) return c.json({ error: 'File not found' }, 404)
  return new Response(result.data, {
    headers: {
      'Content-Type': result.contentType ?? 'application/octet-stream',
      'Content-Disposition': `inline; filename="${record.originalName}"`,
    },
  })
})

代价是每次下载都经过 Worker,下载量大时 CPU 时间和请求数都会增加。好处是权限检查完全可控,不存在签名 URL 泄露后被滥用的风险。

临时分享链接

用户想把私有文件分享给外部人员,生成带过期时间的 token:

app.post('/api/files/:id/share', async (c) => {
  const user = c.get('user')
  const { expiresInHours = 24 } = await c.req.json()
  const token = crypto.randomUUID()
  const expiresAt = new Date(Date.now() + expiresInHours * 3600 * 1000)
 
  await db.insert(shareLinks).values({
    id: crypto.randomUUID(), token, fileId: c.req.param('id'),
    createdBy: user.id, expiresAt, usedCount: 0, createdAt: new Date(),
  })
  return c.json({ shareUrl: `https://example.com/share/${token}`, expiresAt })
})
 
app.get('/share/:token', async (c) => {
  const storage = c.get('storage') as StorageProvider
  const link = await db.query.shareLinks.findFirst({
    where: eq(shareLinks.token, c.req.param('token')),
  })
  if (!link) return c.json({ error: 'Not found' }, 404)
  if (link.expiresAt < new Date()) return c.json({ error: 'Expired' }, 410)
 
  const record = await db.query.files.findFirst({ where: eq(files.id, link.fileId) })
  if (!record) return c.json({ error: 'File not found' }, 404)
  const result = await storage.get(record.key)
  if (!result) return c.json({ error: 'File not found' }, 404)
 
  await db.update(shareLinks).set({ usedCount: link.usedCount + 1 })
    .where(eq(shareLinks.id, link.id))
 
  return new Response(result.data, {
    headers: { 'Content-Type': record.mimeType,
      'Content-Disposition': `inline; filename="${record.originalName}"` },
  })
})

7. 文件生命周期

文件从上传到删除,经历一组明确的状态:

pending → processing → completed → deleted
                  ↘ failed → processing(重试)

上传和处理

async function uploadAndQueue(c: Context, file: File, userId: string, tenantId: string) {
  const storage = c.get('storage') as StorageProvider
  const key = `${tenantId}/${crypto.randomUUID()}/${file.name}`
 
  await storage.put(key, new Uint8Array(await file.arrayBuffer()), {
    contentType: file.type, metadata: { userId, originalName: file.name },
  })
  const record = await createFileRecord(c.get('db'), {
    key, originalName: file.name, mimeType: file.type,
    size: file.size, userId, tenantId,
  })
  await c.env.FILE_PROCESSING_QUEUE.push({
    fileId: record.id, key: record.key, mimeType: record.mimeType,
  })
  return record
}

消费者从队列取出任务后,根据 mimeType 分发到不同处理器(PDF 解析、OCR、纯文本提取等),处理完更新状态为 completed,失败则更新为 failed 并记录 errorMessage。具体的队列消费模式参考工作流章节。

清理策略

用户删除文件时做软删除,实际清理交给 Cron Trigger 定时任务:

async function runCleanupJobs(storage: StorageProvider) {
  const now = new Date()
  const daysAgo = (n: number) => new Date(now.getTime() - n * 86400_000)
 
  // 失败超过 7 天的文件——给用户留了重试窗口
  for (const f of await db.query.files.findMany({
    where: and(eq(files.status, 'failed'), lt(files.updatedAt, daysAgo(7))),
  })) {
    await storage.delete(f.key)
    await db.delete(files).where(eq(files.id, f.id))
  }
 
  // 软删除超过 30 天的文件——回收站到期
  for (const f of await db.query.files.findMany({
    where: and(eq(files.status, 'deleted'), lt(files.updatedAt, daysAgo(30))),
  })) {
    await storage.delete(f.key)
    await db.delete(files).where(eq(files.id, f.id))
  }
 
  // 临时文件(tmp/ 前缀)超过 24 小时
  const oneDayAgo = daysAgo(1)
  for (const f of await storage.list('tmp/')) {
    if (f.uploaded < oneDayAgo) await storage.delete(f.key)
  }
}

8. 存储成本优化

对象存储的成本来自三个方面:存储容量、出口流量、请求次数。

过期策略是最直接的手段。不同类型的数据设置不同的生命周期:临时文件 24 小时、处理失败文件 7 天、软删除文件 30 天、正式文件跟随业务需要长期保留。

出口流量在 S3 上是 $0.09/GB,累积起来不是小数。R2 免出口流量费,部署在 Cloudflare 上的项目迁移到 R2 可以直接省掉这笔开支。

存储分层——冷数据(很少访问的归档文件)可以迁移到低频存储。S3 有 Glacier,R2 暂时没有分层,但可以通过定期扫描数据库里长期未访问的文件,把它们移到单独的 bucket 或导出到外部归档。

存储用量查询——给用户展示配额,也可以用来生成账单:

async function getStorageStats(tenantId: string) {
  const result = await db
    .select({ totalFiles: count(files.id), totalSize: sum(files.size) })
    .from(files)
    .where(and(eq(files.tenantId, tenantId), ne(files.status, 'deleted')))
  return result[0]
}

9. 依赖注入:切换存储后端

在应用启动时根据环境变量创建对应的 StorageProvider,通过 Hono 中间件注入到请求上下文:

// src/storage/factory.ts
export function createStorageProvider(env: Env): StorageProvider {
  switch (env.STORAGE_BACKEND) {
    case 'r2':
      return new R2StorageProvider(env.BUCKET)
    case 's3':
      return createS3Provider({
        endpoint: env.S3_ENDPOINT, region: env.S3_REGION,
        accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY,
        bucket: env.S3_BUCKET,
      })
    case 'memory':
      return new InMemoryStorageProvider() // 本地开发用,用 Map 模拟
    default:
      throw new Error(`Unknown storage backend: ${env.STORAGE_BACKEND}`)
  }
}
// src/index.ts
import { createStorageProvider } from './storage/factory'
 
app.use('*', async (c, next) => {
  c.set('storage', createStorageProvider(c.env))
  await next()
})

InMemoryStorageProvider 用一个 Map&lt;string, &#123; data, contentType, metadata, uploaded &#125;&gt; 模拟存储操作,本地开发时不需要任何外部服务。.dev.vars 里设置 STORAGE_BACKEND=memory,部署到 Cloudflare 时设置为 r2,切换到 AWS 时设置为 s3 并补上对应环境变量。业务代码全程只调用 StorageProvider 接口的方法。

总结

回顾这篇的要点:

  • 存储抽象层让业务代码不绑死某个具体的存储 SDK,切换后端、本地开发、测试 mock 都更方便
  • StorageProvider 接口定义四个核心操作——R2 和 S3 两种实现共享同一套签名
  • 文件元数据存在 D1,和对象存储分离,状态流转在数据库里管理
  • 访问控制分三档:公开文件走 CDN、私有文件走 Worker 代理鉴权、临时链接带过期时间
  • 文件生命周期有明确的状态机:pending → processing → completed / failed → deleted
  • 清理策略用定时任务执行——失败文件保留 7 天,软删除保留 30 天,临时文件 24 小时
  • 成本优化:过期策略、R2 零出口流量、存储统计查配额
  • 依赖注入通过工厂函数 + 中间件实现,环境变量决定用哪个后端

下一篇讲对象存储接入的具体细节——R2 的高级用法和 S3 兼容 API 的完整接入流程。