用户管理模块
获取个人信息
// server/api/user/profile.get.ts
export default defineEventHandler((event) => {
const userId = event.context.userId
const [user] = await db.select({
id: users.id,
phone: users.phone,
email: users.email,
nickname: users.nickname,
avatar: users.avatar,
role: users.role,
vipExpiresAt: users.vipExpiresAt,
createdAt: users.createdAt,
}).from(users).where(eq(users.id, userId)).limit(1)
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'Not Found', message: '用户不存在' })
}
return user
})为什么 select 指定字段而不是 select()?
// ✅ 安全:只返回需要的字段
db.select({
id: users.id,
phone: users.phone,
nickname: users.nickname,
}).from(users)
// ❌ 危险:返回所有字段,包括 passwordHash
db.select().from(users)关键原则:查询用户信息时永远不要返回 passwordHash。即使你自己不使用,也可能因 API 响应泄露给前端。Drizzle 的 select({ ... }) 显式列出字段,是最安全的做法。
select() 无参数会返回所有列
包括 password_hash。这在列表 API 中尤其危险——可能一次性泄露所有用户的密码哈希。
更新个人信息
// server/api/user/profile.put.ts
import { z } from 'zod'
const schema = z.object({
nickname: z.string().min(2).max(20).optional(),
avatar: z.string().url().optional(),
email: z.string().email().optional(),
})
export default defineEventHandler(async (event) => {
const userId = event.context.userId
const data = await readValidatedBody(event, schema.parse)
const [updated] = await db.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.id, userId))
.returning()
return { message: '更新成功', user: updated }
})更新操作的安全要点
| 安全措施 | 代码 | 原因 |
|---|---|---|
| Zod 校验 | z.string().min(2).max(20) | 防止空值或超长字符串 |
所有字段 optional | .optional() | 只更新传入的字段,不强制修改所有字段 |
显式 where 条件 | where(eq(users.id, userId)) | 防止误更新其他用户 |
| 更新时间戳 | updatedAt: new Date() | 确保记录更新时间,方便排查 |
为什么不需要检查 userId 是否存在?
userId 来自认证中间件,已经验证过 JWT 有效性。如果用户不存在,update 的 returning() 会返回空数组,前端可以据此判断。
为什么所有字段是 optional?
用户可能只修改昵称,不修改头像和邮箱。Zod 的 optional() 允许字段缺失,set({ ...data }) 只会设置传入的字段。如果用 required,用户必须同时提交所有字段才能修改一个。
修改密码
// server/api/user/password.put.ts
import { z } from 'zod'
const schema = z.object({
oldPassword: z.string(),
newPassword: z.string().min(6).max(20),
})
export default defineEventHandler(async (event) => {
const userId = event.context.userId
const { oldPassword, newPassword } = await readValidatedBody(event, schema.parse)
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1)
if (!user) throw createError({ statusCode: 404 })
const valid = await verifyPassword(oldPassword, user.passwordHash)
if (!valid) throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '旧密码错误' })
const passwordHash = await hashPassword(newPassword)
await db.update(users).set({ passwordHash, updatedAt: new Date() }).where(eq(users.id, userId))
return { message: '密码修改成功' })
})为什么修改密码后不自动登出?
当前实现修改密码后旧 Token 仍然有效(直到过期)。如果需要即时失效,修改密码后应将旧 Token 加入 Redis 黑名单:
// 修改密码后使旧 Token 失效
const auth = getRequestHeader(event, 'authorization')
if (auth?.startsWith('Bearer ')) {
const token = auth.replace('Bearer ', '')
const decoded = decode(token) as { exp?: number }
const ttl = decoded?.exp ? Math.max(decoded.exp - Math.floor(Date.now() / 1000), 0) : 7 * 24 * 3600
await blacklistToken(token, ttl)
}新密码不能与旧密码相同
// 可选:增加校验
if (oldPassword === newPassword) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '新密码不能与旧密码相同' })
}管理后台 - 用户列表
// server/api/admin/users.get.ts
export default defineEventHandler(async (event) => {
if (event.context.userRole !== 'admin') {
throw createError({ statusCode: 403, statusMessage: 'Forbidden', message: '需要管理员权限' })
}
const query = getQuery(event)
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 20
const keyword = query.keyword as string
let whereCondition
if (keyword) {
whereCondition = or(
like(users.phone, `%${keyword}%`),
like(users.nickname, `%${keyword}%`),
like(users.email, `%${keyword}%`),
)
}
const [list, [{ count }]] = await Promise.all([
db.select().from(users)
.where(whereCondition)
.limit(pageSize)
.offset((page - 1) * pageSize)
.orderBy(desc(users.createdAt)),
db.select({ count: sql`count(*)` }).from(users).where(whereCondition),
])
return {
list,
total: Number(count),
page,
pageSize,
totalPages: Math.ceil(Number(count) / pageSize),
}
})分页查询要点
并行查询列表 + 总数
const [list, [{ count }]] = await Promise.all([
db.select().from(users).where(whereCondition).limit(pageSize).offset(offset),
db.select({ count: sql`count(*)` }).from(users).where(whereCondition),
])为什么用 Promise.all? 列表和总数是两个独立查询,并行执行比串行快一倍。这在数据量大时效果明显。
注意
Promise.all 中两个查询使用相同的 whereCondition,确保列表和总数的筛选条件一致。
offset 分页的性能问题
offset 分页在数据量大时性能差——PostgreSQL 需要先跳过 offset 行再返回结果:
-- 第 1000 页,每页 20 条:需要扫描 20000 行
SELECT * FROM users ORDER BY created_at DESC LIMIT 20 OFFSET 19980大数据量优化方案:使用游标分页(keyset pagination)
// 游标分页:基于最后一条记录的 ID
const lastId = Number(query.lastId) || 0
const list = await db.select().from(users)
.where(and(
whereCondition,
lt(users.id, lastId) // 只取 ID 小于 lastId 的记录
))
.limit(pageSize)
.orderBy(desc(users.id))游标分页的缺点
不支持跳页(不能直接跳到第 10 页),适合移动端的无限滚动场景。
LIKE 搜索的性能
like(users.phone, `%${keyword}%`) // %keyword% 无法利用索引%keyword% 模糊查询无法使用 B-tree 索引,全表扫描。如果搜索是高频操作,考虑:
- PostgreSQL 的
pg_trgm扩展支持三元组索引 - 使用 Elasticsearch/MeiliSearch 做全文搜索
- 手机号精确搜索时用
eq而非like
管理后台权限检查
if (event.context.userRole !== 'admin') {
throw createError({ statusCode: 403, statusMessage: 'Forbidden', message: '需要管理员权限' })
}为什么每个管理 API 都要检查?
不能只依赖前端隐藏管理按钮——攻击者可以直接调 API。服务端权限检查是安全保障,前端权限控制只是用户体验优化。
更好的做法
将权限检查提取为工具函数,避免每个 API 重复写 if (event.context.userRole !== 'admin'):
// server/utils/auth.ts
export const requireAdmin = (event: H3Event) => {
if (event.context.userRole !== 'admin') {
throw createError({ statusCode: 403, statusMessage: 'Forbidden', message: '需要管理员权限' })
}
}
// 使用
export default defineEventHandler(async (event) => {
requireAdmin(event)
// ... 业务逻辑
})管理后台 - 更新用户状态
// server/api/admin/users/[id].put.ts
import { z } from 'zod'
const schema = z.object({
status: z.number().min(0).max(1).optional(),
role: z.enum(['user', 'vip', 'admin']).optional(),
nickname: z.string().optional(),
})
export default defineEventHandler(async (event) => {
if (event.context.userRole !== 'admin') {
throw createError({ statusCode: 403 })
}
const id = Number(getRouterParam(event, 'id'))
const data = await readValidatedBody(event, schema.parse)
const [updated] = await db.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.id, id))
.returning()
return updated
})管理员操作的安全考虑
| 安全问题 | 风险 | 建议 |
|---|---|---|
| 管理员禁用自己 | 自己被锁定无法操作 | 检查 id === event.context.userId 时拒绝 |
| 修改自己的角色降级 | 丢失管理员权限 | 同上,禁止修改自身角色 |
id 类型转换 | NaN 导致 where(eq(users.id, NaN)) 无匹配 | 添加 if (!id) throw createError(...) |
| 缺少操作日志 | 无法审计谁修改了谁 | 记录操作者、被操作用户、变更内容 |
// 安全加固版
export default defineEventHandler(async (event) => {
requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
if (!id || isNaN(id)) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '无效的用户 ID' })
}
// 不能禁用自己
if (id === event.context.userId && data.status === 0) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '不能禁用自己' })
}
const data = await readValidatedBody(event, schema.parse)
// 记录操作日志
logger.info('管理员修改用户', {
operatorId: event.context.userId,
targetUserId: id,
changes: data,
})
const [updated] = await db.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.id, id))
.returning()
return updated
})