AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-08-04

1. 我们要做什么

前面的章节是一个知识点一个知识点地学,这篇把它们串起来——做一个完整的用户管理系统 API。

具体来说就是:注册、登录、查看个人信息、管理员管理用户。功能不多,但前面学的路由、中间件、校验、认证、数据库操作全都会用上。单独看每个知识点都不难,关键是它们怎么配合——跑一遍就知道了。

技术栈:Hono + Cloudflare D1 + Drizzle ORM + JWT + Zod

2. 先想清楚有哪些接口

动手写代码之前,先把接口列出来。这一步很重要——先想好"要暴露哪些能力",再去写实现。

API
01
// 公开接口——不需要登录就能调
02
POST /auth/register // 注册
03
POST /auth/login // 登录,返回 JWT
04
05
// 需要登录——请求头带 JWT 才能调
06
GET /users/me // 获取当前用户信息
07
PUT /users/me // 更新个人信息
08
09
// 需要 admin 角色——不仅要登录,还得是管理员
10
GET /users // 列出所有用户
11
DELETE /users/:id // 删除用户

为什么用 /auth/users 分开?因为注册登录是"身份认证",用户增删改查是"资源操作",两件不同的事放在不同的路径下,后面加中间件时也方便——/users/* 统一加鉴权,/auth/* 不加。

3. 数据库 Schema

只有一张 users 表:

src/db/schema.ts
01
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
02
import { sql } from 'drizzle-orm'
03
04
export const users = sqliteTable('users', {
05
id: integer('id').primaryKey({ autoIncrement: true }),
06
email: text('email').notNull().unique(),
07
name: text('name').notNull(),
08
passwordHash: text('password_hash').notNull(),
09
role: text('role').notNull().default('user'), // 'user' | 'admin'
10
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
11
})

这里的 sql`CURRENT_TIMESTAMP` 是 Drizzle 提供的模板标签,用来写原生 SQL 表达式。CURRENT_TIMESTAMP 是 SQLite 内置的,意思是"插入数据时自动填入当前时间"。

passwordHash 存的是哈希后的密码,永远不存明文——即使数据库泄露,攻击者拿到的也不是原始密码。

4. 类型定义

src/types.ts
01
export type Bindings = {
02
DB: D1Database
03
JWT_SECRET: string
04
}
05
06
export type Variables = {
07
jwtPayload: {
08
sub: number
09
email: string
10
role: string
11
exp: number
12
}
13
}
14
15
export type AppEnv = {
16
Bindings: Bindings
17
Variables: Variables
18
}

解释一下这几个东西:

  • Bindings 是 Cloudflare Workers 的环境变量。DB 是 D1 数据库绑定,JWT_SECRET 是签发 JWT 用的密钥
  • Variables 是 Hono 的上下文变量。JWT 中间件验证 token 后,会把解析出来的数据塞进 c.var.jwtPayload(也可以用 c.get('jwtPayload') 取),后面的路由就能拿到当前用户信息
  • sub 是 JWT 标准字段,代表 "subject"(主体),这里存用户 ID。exp 是过期时间的 Unix 时间戳

把类型统一定义成 AppEnv,所有路由文件都用它,这样 c.env.DBc.get('jwtPayload') 都有类型提示。

5. 密码哈希

Cloudflare Workers 不支持 bcrypt,但有 Web Crypto API。我们用 SHA-256 加盐来做。

先说"盐"是什么:如果两个用户的密码一样(比如都是 123456),直接做 SHA-256 的结果也一样。攻击者只要算一次,就能破解所有用同一密码的账户。加盐就是给每个用户的密码前面拼一段随机字符串,这样即使密码相同,哈希结果也完全不同。

src/utils/password.ts
01
// 生成随机盐值
02
function generateSalt(): string {
03
const array = new Uint8Array(16)
04
crypto.getRandomValues(array)
05
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('')
06
}
07
08
// 用 SHA-256 对字符串做哈希
09
async function sha256(message: string): Promise<string> {
10
const encoder = new TextEncoder()
11
const data = encoder.encode(message)
12
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
13
const hashArray = new Uint8Array(hashBuffer)
14
return Array.from(hashArray, (b) => b.toString(16).padStart(2, '0')).join('')
15
}

上面两个是内部工具函数。下面是对外暴露的两个方法:

src/utils/password.ts(续)
01
// 哈希密码:生成盐,把「盐+密码」做 SHA-256,用 : 拼起来存
02
export async function hashPassword(password: string): Promise<string> {
03
const salt = generateSalt()
04
const hash = await sha256(salt + password)
05
return `${salt}:${hash}`
06
}
07
08
// 验证密码:从存储的字符串里取出盐,对输入做同样的哈希,比较结果
09
export async function verifyPassword(
10
password: string,
11
stored: string
12
): Promise<boolean> {
13
const [salt, hash] = stored.split(':')
14
const inputHash = await sha256(salt + password)
15
return inputHash === hash
16
}

存储格式是 salt:hash。注册时 hashPassword 生成,登录时 verifyPassword 比对。

NOTE

生产环境别用 SHA-256 哈希密码。 SHA-256 速度太快,攻击者暴力穷举的成本极低。正确做法是用 PBKDF2——Web Crypto API 原生支持(crypto.subtle.deriveBits + PBKDF2 算法),不需要额外依赖。这里用 SHA-256 纯粹是为了让你先理解"加盐哈希"的流程,概念到位后换算法只是改一个函数的事。

6. 注册接口

src/routes/auth.ts
01
import { Hono } from 'hono'
02
import { zValidator } from '@hono/zod-validator'
03
import { z } from 'zod'
04
import { drizzle } from 'drizzle-orm/d1'
05
import { eq } from 'drizzle-orm'
06
import { users } from '../db/schema'
07
import { hashPassword } from '../utils/password'
08
import type { AppEnv } from '../types'
09
10
const auth = new Hono<AppEnv>()
11
12
// 注册校验规则
13
const registerSchema = z.object({
14
email: z.string().email('邮箱格式不正确'),
15
name: z.string().min(2, '名称至少 2 个字符'),
16
password: z.string().min(6, '密码至少 6 位'),
17
})
18
19
auth.post(
20
'/register',
21
zValidator('json', registerSchema),
22
async (c) => {
23
const { email, name, password } = c.req.valid('json')
24
const db = drizzle(c.env.DB)
25
26
// 检查邮箱是否已注册
27
const existing = await db
28
.select()
29
.from(users)
30
.where(eq(users.email, email))
31
.get()
32
33
if (existing) {
34
return c.json({ error: '该邮箱已注册' }, 409)
35
}
36
37
// 哈希密码并入库
38
const passwordHash = await hashPassword(password)
39
const newUser = await db
40
.insert(users)
41
.values({ email, name, passwordHash })
42
.returning()
43
.get()
44
45
return c.json(
46
{
47
id: newUser.id,
48
email: newUser.email,
49
name: newUser.name,
50
role: newUser.role,
51
},
52
201
53
)
54
}
55
)

import 看起来多,但每个都有用途——Hono 框架、Zod 校验、Drizzle 数据库操作、密码哈希。注意这里只 import 了 hashPasswordverifyPasswordsign(签发 JWT)到下面的登录接口才会用到。

流程:Zod 校验 → 查重 → 哈希密码 → 入库 → 返回用户信息。注意返回时不包含 passwordHash,永远不要把密码哈希传给前端。

7. 登录接口

登录和注册在同一个文件 auth.ts 里。注册用的是 hashPassword,登录这边要多一个 verifyPassword 来比对密码:

src/routes/auth.ts(续)
01
import { sign } from 'hono/jwt'
02
import { verifyPassword } from '../utils/password'
03
04
const loginSchema = z.object({
05
email: z.string().email(),
06
password: z.string(),
07
})
08
09
auth.post(
10
'/login',
11
zValidator('json', loginSchema),
12
async (c) => {
13
const { email, password } = c.req.valid('json')
14
const db = drizzle(c.env.DB)
15
16
const user = await db
17
.select()
18
.from(users)
19
.where(eq(users.email, email))
20
.get()
21
22
if (!user) {
23
return c.json({ error: '邮箱或密码错误' }, 401)
24
}
25
26
const valid = await verifyPassword(password, user.passwordHash)
27
if (!valid) {
28
return c.json({ error: '邮箱或密码错误' }, 401)
29
}
30
31
// 签发 JWT,24 小时过期
32
const token = await sign(
33
{
34
sub: user.id,
35
email: user.email,
36
role: user.role,
37
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24,
38
},
39
c.env.JWT_SECRET
40
)
41
42
return c.json({ token })
43
}
44
)
45
46
export default auth

有个安全细节:不管是邮箱不存在还是密码错了,都返回同一条"邮箱或密码错误"。如果分别提示"邮箱不存在"和"密码错误",攻击者就能先确认哪些邮箱注册过,再集中精力猜密码。

8. 鉴权中间件

src/middleware/auth.ts
01
import { jwt } from 'hono/jwt'
02
import { HTTPException } from 'hono/http-exception'
03
import type { Context, Next } from 'hono'
04
import type { AppEnv } from '../types'
05
06
// JWT 认证中间件:验证 token 是否有效
07
export const authMiddleware = (
08
c: Context<AppEnv>,
09
next: Next
10
) => {
11
const jwtMiddleware = jwt({ secret: c.env.JWT_SECRET })
12
return jwtMiddleware(c, next)
13
}

为什么不直接 app.use('/users/*', jwt({ secret: ... })) ?因为 JWT_SECRETc.env 里,只有请求进来时才能拿到,不能在定义路由时写死。所以包了一层,在中间件执行时动态读取 secret。

src/middleware/auth.ts(续)
01
// 角色鉴权中间件:检查是否有特定角色
02
export const requireRole = (role: string) => {
03
return async (c: Context<AppEnv>, next: Next) => {
04
const payload = c.get('jwtPayload')
05
06
if (payload.role !== role) {
07
throw new HTTPException(403, {
08
message: '权限不足',
09
})
10
}
11
12
await next()
13
}
14
}

requireRole 必须放在 authMiddleware 后面用,因为它要从 context 里取 jwtPayload,而这个值是 JWT 中间件验证通过后塞进去的。

9. 用户路由

src/routes/users.ts
01
import { Hono } from 'hono'
02
import { zValidator } from '@hono/zod-validator'
03
import { z } from 'zod'
04
import { drizzle } from 'drizzle-orm/d1'
05
import { eq } from 'drizzle-orm'
06
import { users } from '../db/schema'
07
import { requireRole } from '../middleware/auth'
08
import type { AppEnv } from '../types'
09
10
const userRoutes = new Hono<AppEnv>()
11
12
// GET /users/me — 获取当前用户信息
13
userRoutes.get('/me', async (c) => {
14
const payload = c.get('jwtPayload')
15
const db = drizzle(c.env.DB)
16
17
const user = await db
18
.select({
19
id: users.id,
20
email: users.email,
21
name: users.name,
22
role: users.role,
23
createdAt: users.createdAt,
24
})
25
.from(users)
26
.where(eq(users.id, payload.sub))
27
.get()
28
29
if (!user) {
30
return c.json({ error: '用户不存在' }, 404)
31
}
32
33
return c.json(user)
34
})

注意 select() 里显式列出了要返回的字段——不写 select() 的话会返回所有列,passwordHash 就暴露了。

src/routes/users.ts(续)
01
// PUT /users/me — 更新个人信息
02
const updateSchema = z.object({
03
name: z.string().min(2).optional(),
04
email: z.string().email().optional(),
05
})
06
07
userRoutes.put(
08
'/me',
09
zValidator('json', updateSchema),
10
async (c) => {
11
const payload = c.get('jwtPayload')
12
const data = c.req.valid('json')
13
const db = drizzle(c.env.DB)
14
15
// 如果要改邮箱,检查新邮箱是否已被别人占用
16
if (data.email) {
17
const existing = await db
18
.select()
19
.from(users)
20
.where(eq(users.email, data.email))
21
.get()
22
23
if (existing && existing.id !== payload.sub) {
24
return c.json({ error: '该邮箱已被使用' }, 409)
25
}
26
}
27
28
const updated = await db
29
.update(users)
30
.set(data)
31
.where(eq(users.id, payload.sub))
32
.returning({
33
id: users.id,
34
email: users.email,
35
name: users.name,
36
role: users.role,
37
})
38
.get()
39
40
return c.json(updated)
41
}
42
)

下面是管理员才能用的两个接口。注意 requireRole('admin') 中间件——它会检查 JWT 里的 role 字段,不是 admin 就直接 403。

src/routes/users.ts(续)
01
// GET /users — 管理员列出所有用户
02
userRoutes.get('/', requireRole('admin'), async (c) => {
03
const db = drizzle(c.env.DB)
04
05
const allUsers = await db
06
.select({
07
id: users.id,
08
email: users.email,
09
name: users.name,
10
role: users.role,
11
createdAt: users.createdAt,
12
})
13
.from(users)
14
.all()
15
16
return c.json(allUsers)
17
})
18
19
// DELETE /users/:id — 管理员删除用户
20
userRoutes.delete('/:id', requireRole('admin'), async (c) => {
21
const id = Number(c.req.param('id'))
22
const db = drizzle(c.env.DB)
23
24
const deleted = await db
25
.delete(users)
26
.where(eq(users.id, id))
27
.returning()
28
.get()
29
30
if (!deleted) {
31
return c.json({ error: '用户不存在' }, 404)
32
}
33
34
return c.json({ message: '已删除' })
35
})
36
37
export default userRoutes

这里有个路由顺序的坑:/me 必须定义在 /:id 前面。如果反过来,请求 /users/me 时,me 会被当成 id 参数去匹配,然后 Number('me') 变成 NaN,查询就乱了。

10. 全局错误处理

src/middleware/error.ts
01
import type { Context } from 'hono'
02
import { HTTPException } from 'hono/http-exception'
03
04
export const errorHandler = (err: Error, c: Context) => {
05
console.error(`[Error] ${err.message}`)
06
07
if (err instanceof HTTPException) {
08
return c.json(
09
{ error: err.message },
10
err.status
11
)
12
}
13
14
// 未知错误,不暴露内部细节
15
return c.json(
16
{ error: 'Internal Server Error' },
17
500
18
)
19
}

所有 HTTPException(包括 JWT 验证失败、角色鉴权失败)都会走到这里,返回对应的状态码和错误信息。其他未预期的错误统一返回 500,避免把堆栈信息暴露给前端。

至于 Zod 校验错误——zValidator 会在中间件层直接拦截并返回 400 响应,不会抛到 onError 里。所以这里不需要单独处理 Zod 错误。如果你想自定义 Zod 的错误格式,回前面数据校验那章看 zValidator 第三个参数的用法。

11. 主入口

src/index.ts
01
import { Hono } from 'hono'
02
import { cors } from 'hono/cors'
03
import { logger } from 'hono/logger'
04
import auth from './routes/auth'
05
import userRoutes from './routes/users'
06
import { authMiddleware } from './middleware/auth'
07
import { errorHandler } from './middleware/error'
08
import type { AppEnv } from './types'
09
10
const app = new Hono<AppEnv>()
11
12
// 全局中间件
13
app.use('*', logger())
14
app.use('*', cors())
15
16
// 全局错误处理
17
app.onError(errorHandler)
18
19
// 公开路由——注册登录不需要 token
20
app.route('/auth', auth)
21
22
// 受保护路由——/users 下的所有接口都要先过 JWT 验证
23
app.use('/users/*', authMiddleware)
24
app.route('/users', userRoutes)
25
26
// 健康检查——部署后用来确认服务是否正常运行
27
app.get('/health', (c) => {
28
return c.json({ status: 'ok' })
29
})
30
31
export default app

看这个文件就能一眼看出整个 API 的结构:公开的挂 /auth,受保护的挂 /usersapp.use('/users/*', authMiddleware) 这行是关键——它让 /users 下所有路由都必须带合法的 JWT 才能访问,不用在每个路由里单独加。

12. wrangler 配置

wrangler.jsonc
01
{
02
"name": "user-api",
03
"main": "src/index.ts",
04
"compatibility_date": "2024-12-01",
05
"d1_databases": [
06
{
07
"binding": "DB",
08
"database_name": "user-db",
09
"database_id": "your-database-id"
10
}
11
],
12
"vars": {
13
"JWT_SECRET": "dev-secret-change-in-production"
14
}
15
}

vars 里的 JWT_SECRET 只用于本地开发。部署到生产环境时,用 wrangler secret put JWT_SECRET 设置一个强密钥,不要提交到代码仓库里。

13. 项目文件结构

最终长这样:

index.ts主入口,组装路由和中间件
types.ts类型定义
schema.tsDrizzle schema(users 表)
auth.ts注册、登录
users.ts用户 CRUD
auth.tsJWT 认证 + 角色鉴权
error.ts全局错误处理
password.ts密码哈希和验证
drizzle.config.tsDrizzle Kit 配置
wrangler.jsoncCloudflare Workers 配置

按职责分目录:路由、中间件、工具函数各管各的。后面加新功能(比如文章管理),就新建 src/routes/posts.ts,定义路由,然后在 index.tsapp.route('/posts', postRoutes) 挂上去。

14. 测试一下

用 curl 把整个流程跑一遍:

terminal
01
# 1. 注册
02
curl -X POST http://localhost:8787/auth/register \
03
-H "Content-Type: application/json" \
04
-d '{"email":"alice@example.com","name":"Alice","password":"123456"}'
05
06
# 2. 登录,拿到 token
07
curl -X POST http://localhost:8787/auth/login \
08
-H "Content-Type: application/json" \
09
-d '{"email":"alice@example.com","password":"123456"}'
10
11
# 3. 用 token 查看个人信息(把 <token> 换成第 2 步返回的值)
12
curl http://localhost:8787/users/me \
13
-H "Authorization: Bearer <token>"
14
15
# 4. 更新个人信息
16
curl -X PUT http://localhost:8787/users/me \
17
-H "Authorization: Bearer <token>" \
18
-H "Content-Type: application/json" \
19
-d '{"name":"Alice Updated"}'

如果每一步都返回了预期的 JSON,这个 API 就跑通了。

总结

回头看整个过程:想清楚接口长什么样,定好数据结构,然后从底层工具函数写起,一路搭到路由和主入口。每个文件各管各的事,新增功能就是往这个骨架上加东西。