创建时间: 2026-06-03最后更新: 2026-06-03

1. 头像到底该怎么存

前面我们已经把认证、session、token、D1 migration 这些基础部分搭起来了。接下来我们思考如何处理头像。

先说明一下这篇的位置:它讲的是一套推荐落法,帮助我们把头像存储边界先设计清楚。当前仓库里还没有把头像上传、R2 绑定、元数据表这些实现正式接进去,所以下面的代码都按「推荐实现示例」来理解会更准确。

头像看着像个小功能,真写起来却很麻烦。因为它至少牵着 3 件事:

  • 图片文件放哪
  • 数据库里应该如何存储
  • 上传和读取应该交给谁处理

很多人一开始都会想到一个很直接的做法:用户传完头像,系统拿到一个 URL,再把这个 URL 存回 users.avatar_url

这个做法在很轻的场景里不是不能用。但只要你开始认真做上传校验、缓存、历史版本、默认头像替换,avatar_url 这种字段就会慢慢显得太粗。

更好的一套边界清楚的方案是:文件本体归对象存储,数据库只管记录文件标识和元数据,访问规则统一放在 API 里。

2. 为什么文件不要直接放进 D1

我们先来看数据库应该如何存储。

D1 适合存的是结构化数据。用户、邮箱、session、refresh token、角色关系,这些东西都有明确字段,也要经常查、改、关联。

头像文件不是这一类。

你不会写 SQL 去查某张头像的像素宽度,也不会关心图片二进制内部长什么样。你通常只会做两件事:上传一张图,再按某个标识把它取回来。

除此之外,数据库职责会变重。

它本来该管关系数据,现在又顺手兼了一层文件存储,边界会越来越别扭。

数据体积会变大。 用户表、权限表、会话表本来都比较轻,图片一进来,库的读写特征就不一样了。

后面扩展不舒服。 以后你要做 CDN 缓存、按 key 删除旧头像、保留版本历史,或者替换存储服务,对象存储都会比数据库顺手得多。

所以头像更稳的分工应该是这样的:R2 负责存文件,D1 负责存结构化信息。

3. 数据库里为什么更适合存 key

通常,我们会更推荐在 D1 数据库里存 key,而不是直接存公开 URL。

比如可以长这样:

index.txt
1
avatars/users/<userId>/<timestamp>-<uuid>.webp

这是因为 key 很稳定。只要对象还在,这个 key 就能唯一指向那张头像,不用担心对象移动或删除导致找不到。

key 也不绑死访问方式。今天你也许让前端通过 API 读头像,明天也许接 CDN,后天也许连 bucket 域名都换了。只要库里存的是 key,访问层怎么变都还有空间。

而且 key 天然适合表达规则。默认头像可以放 avatars/default/,用户头像可以放 avatars/users/<userId>/。以后你想查历史、批量清理、按前缀扫描,都会自然很多。

如果数据库里直接塞完整 URL,看起来像省了一步,实际上是把两层东西绑死了:一层是内部标识,一层是对外访问地址。前者应该稳定,后者以后很可能会变。

所以如果你现在表里还是 avatar_url,不算错。但等头像上传真的接入对象存储之后,更稳的方向通常是把它往 avatar_key 这类语义上迁移。

4. R2 绑定和 key 规则

数据库里只记 key 之后,API 就得有一个地方真正去存文件。在 Cloudflare 这套栈里,这个角色最合适的就是 R2。

先看运行时绑定。下面这段不是当前仓库已有文件,而是推荐的绑定结构:

bindings.ts
1
type ApiBindings = CloudflareBindings & {
2
DB: D1Database
3
AVATAR_BUCKET: R2Bucket
4
ADMIN_ORIGIN: string
5
JWT_ACCESS_SECRET: string
6
JWT_REFRESH_SECRET: string
7
ACCESS_TOKEN_TTL_SEC: string | number
8
REFRESH_TOKEN_TTL_SEC: string | number
9
}

这里最关键的点只有一个:头像上传和读取依赖 AVATAR_BUCKET,这件事要在类型层面明确下来。

对应的 Wrangler 配置也要补上:

wrangler.jsonc
1
{
2
"r2_buckets": [
3
{
4
"binding": "AVATAR_BUCKET",
5
"bucket_name": "ai-agent-local-avatars"
6
}
7
]
8
}

这样开发、测试、生产各自可以配自己的 bucket,但业务代码里统一只认 c.env.AVATAR_BUCKET

接着我们再看 key 规则。

avatar-storage.ts
1
export function buildDefaultAvatarKey(file: File, nowMs: number) {
2
const extension = assertAvatarFile(file)
3
return `avatars/default/${nowMs}-${uuidv7()}.${extension}`
4
}
5
6
export function buildUserAvatarKey(userId: string, file: File, nowMs: number) {
7
const extension = assertAvatarFile(file)
8
return `avatars/users/${userId}/${nowMs}-${uuidv7()}.${extension}`
9
}

这种写法目录语义很清楚。你一眼就知道这是默认头像,还是某个用户自己的头像。

文件名也不容易撞。时间戳加 uuidv7(),基本就把覆盖冲突避开了。

扩展名也不是直接相信原始文件名,而是从校验过的 MIME type 里推出来。这样会稳很多。

5. 上传前需要先校验

头像上传这种入口,最怕的不是代码写不出来,而是边界没卡住。

所以在真正上传之前,我们最好先把文件规则写死。

avatar-storage.ts
01
const avatarMaxBytes = 2 * 1024 * 1024
02
03
const avatarExtensionByMimeType: Record<string, string> = {
04
'image/jpeg': 'jpg',
05
'image/png': 'png',
06
'image/webp': 'webp',
07
}
08
09
export function assertAvatarFile(file: File) {
10
const extension = avatarExtensionByMimeType[file.type]
11
12
if (!extension) {
13
throw new AppError(
14
BizCode.COMMON_INVALID_REQUEST,
15
'Avatar must be a JPG, PNG, or WebP image',
16
400,
17
)
18
}
19
20
if (file.size <= 0) {
21
throw new AppError(
22
BizCode.COMMON_INVALID_REQUEST,
23
'Avatar file is empty',
24
400,
25
)
26
}
27
28
if (file.size > avatarMaxBytes) {
29
throw new AppError(
30
BizCode.COMMON_INVALID_REQUEST,
31
'Avatar file must be 2MB or smaller',
32
400,
33
)
34
}
35
36
return extension
37
}

这里我们主要拦的是 3 类问题。

一类是文件类型不对。头像只允许 JPEG、PNG、WebP 就够了,别把任意文件放进来。

一类是空文件。字段存在,不代表文件内容真的有效。

还有一类是体积太大。头像不是原图备份仓库,2MB 这种限制通常已经足够实用。

6. 上传接口

把 key 规则和文件校验都准备好之后,上传接口就很好理解了。

profile.route.ts
01
userRoute.post('/default-avatar/upload', async (c) => {
02
const formData = await c.req.formData()
03
const avatarFile = formData.get('file')
04
05
if (!(avatarFile instanceof File)) {
06
throw new AppError(BizCode.COMMON_INVALID_REQUEST, 'Avatar file is required', 400)
07
}
08
09
const nowMs = Date.now()
10
const avatarKey = buildDefaultAvatarKey(avatarFile, nowMs)
11
const contentType = avatarFile.type
12
const extension = assertAvatarFile(avatarFile)
13
14
await c.env.AVATAR_BUCKET.put(avatarKey, await avatarFile.arrayBuffer(), {
15
httpMetadata: {
16
contentType,
17
cacheControl: 'public, max-age=31536000, immutable',
18
contentDisposition: `inline; filename="default-avatar.${extension}"`,
19
},
20
})
21
22
await insertDefaultAvatarVersion({
23
db: getDb(c.env.DB),
24
id: uuidv7(),
25
key: avatarKey,
26
fileName: avatarFile.name,
27
contentType,
28
sizeBytes: avatarFile.size,
29
createdByUserId: claims.sub,
30
createdAtMs: nowMs,
31
})
32
33
return c.json({
34
ok: true,
35
data: {
36
key: avatarKey,
37
updatedAtMs: nowMs,
38
},
39
})
40
})

我们可以把这个过程拆开看一下。

先从 multipart/form-data 里把文件取出来,再确认拿到的真是 File

然后生成头像 key。这里顺手会再次触发文件校验,所以文件类型、文件大小这些边界也一起把控住了。

接着把文件本体写进 R2。这里除了 body 本身,还顺手写了 httpMetadata。这一步非常值,因为浏览器以后拿到这张图时,content-typecache-controlcontent-disposition 都会更完整。

尤其是这句:

profile.route.ts
1
cacheControl: 'public, max-age=31536000, immutable'

它不是随手一写。前面我们已经把头像 key 设计成每次更新都会换一个新 key,所以旧 key 对应的内容天然可以长期缓存。也就是说,缓存时间能拉长,不是因为头像特殊,而是因为 key 规则已经把版本切换这件事考虑进去了。

最后再把元数据写进数据库,或者至少把 key 返回给上层。这里数据库记录的不是图片本体,而是和这次上传相关的结构化信息。

7. 读取接口

上传解决了,读取也要约定一个规则。

这里常见的做法有两种。一种是把 R2 的公网地址直接给前端,让前端自己去拿。另一种是前端始终只找 API,再由 API 去 bucket 里读对象。

头像这类资源,我更建议第二种。

profile.route.ts
01
userRoute.get('/avatar', async (c) => {
02
const key = c.req.query('key')?.trim()
03
04
if (!key) {
05
throw new AppError(BizCode.COMMON_INVALID_REQUEST, 'Avatar key is required', 400)
06
}
07
08
const object = await c.env.AVATAR_BUCKET.get(key)
09
10
if (!object) {
11
throw new AppError(BizCode.COMMON_NOT_FOUND, 'Avatar is not found', 404)
12
}
13
14
const headers = new Headers()
15
object.writeHttpMetadata(headers)
16
headers.set('etag', object.httpEtag)
17
18
return new Response(object.body, {
19
headers,
20
})
21
})

这样做的好处很实在。

访问边界是统一的。以后你要加鉴权、限流、灰度策略,或者做更细的访问控制,入口都还在 API 这一层。

底层实现也更容易替换。今天头像放在 R2,明天就算你换成别的对象存储,前端也不用跟着改一堆地址。

还有一个很容易被忽略的点:数据库里存的是 key,不是底层 bucket URL。内部标识和对外访问地址分开之后,整个系统会稳很多。

8. 元数据单独放数据库表中

如果头像只是用户改一张图,然后系统永远只认最后那个值,那么 users 表里加一个 avatar_key 已经够用了。

但只要你想保留上传历史、支持回滚、做审计,单一个字段就不够了。这时候就该把元数据单独放表。

schema.ts
01
export const defaultAvatarVersions = sqliteTable('default_avatar_versions', {
02
id: text('id').primaryKey(),
03
avatarKey: text('avatar_key').notNull(),
04
fileName: text('file_name').notNull(),
05
contentType: text('content_type').notNull(),
06
sizeBytes: integer('size_bytes').notNull(),
07
createdByUserId: text('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
08
createdAtMs: integer('created_at_ms').notNull(),
09
}, (table) => [
10
index('idx_default_avatar_versions_created_at_ms').on(table.createdAtMs),
11
])

这张表里保存的不是图片,而是图片旁边那层信息,比如 key、原始文件名、content type、文件大小、上传人、上传时间。

这些信息就非常适合进数据库。因为以后你查历史、做审计、按时间排序、支持回滚,依赖的都是这些结构化字段,不是图片本体。

9. 总结

图片文件放 R2,数据库只存 key 和元数据,上传与读取统一通过 API 处理。

这样一来,存储职责、访问职责、业务职责就是分开的。以后你继续往前做用户自定义头像、默认头像替换、历史版本、旧文件清理,都是沿着现有结构往前扩,不需要回头推翻整套方案。