Skip to content

用户管理模块

获取个人信息

ts
// 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()

ts
// ✅ 安全:只返回需要的字段
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 中尤其危险——可能一次性泄露所有用户的密码哈希。

更新个人信息

ts
// 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 有效性。如果用户不存在,updatereturning() 会返回空数组,前端可以据此判断。

为什么所有字段是 optional

用户可能只修改昵称,不修改头像和邮箱。Zod 的 optional() 允许字段缺失,set({ ...data }) 只会设置传入的字段。如果用 required,用户必须同时提交所有字段才能修改一个。

修改密码

ts
// 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 黑名单:

ts
// 修改密码后使旧 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)
}

新密码不能与旧密码相同

ts
// 可选:增加校验
if (oldPassword === newPassword) {
  throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '新密码不能与旧密码相同' })
}

管理后台 - 用户列表

ts
// 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),
  }
})

分页查询要点

并行查询列表 + 总数

ts
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 行再返回结果:

sql
-- 第 1000 页,每页 20 条:需要扫描 20000 行
SELECT * FROM users ORDER BY created_at DESC LIMIT 20 OFFSET 19980

大数据量优化方案:使用游标分页(keyset pagination)

ts
// 游标分页:基于最后一条记录的 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 搜索的性能

ts
like(users.phone, `%${keyword}%`)  // %keyword% 无法利用索引

%keyword% 模糊查询无法使用 B-tree 索引,全表扫描。如果搜索是高频操作,考虑:

  • PostgreSQL 的 pg_trgm 扩展支持三元组索引
  • 使用 Elasticsearch/MeiliSearch 做全文搜索
  • 手机号精确搜索时用 eq 而非 like

管理后台权限检查

ts
if (event.context.userRole !== 'admin') {
  throw createError({ statusCode: 403, statusMessage: 'Forbidden', message: '需要管理员权限' })
}

为什么每个管理 API 都要检查?

不能只依赖前端隐藏管理按钮——攻击者可以直接调 API。服务端权限检查是安全保障,前端权限控制只是用户体验优化。

更好的做法

将权限检查提取为工具函数,避免每个 API 重复写 if (event.context.userRole !== 'admin')

ts
// 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)
  // ... 业务逻辑
})

管理后台 - 更新用户状态

ts
// 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(...)
缺少操作日志无法审计谁修改了谁记录操作者、被操作用户、变更内容
ts
// 安全加固版
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
})

基于 Nuxt 4 官方文档整理编写