Skip to content

管理后台 - 订单与会员

订单管理 API

ts
// server/api/admin/orders.get.ts
export default defineEventHandler(async (event) => {
  if (event.context.userRole !== 'admin') {
    throw createError({ statusCode: 403 })
  }

  const query = getQuery(event)
  const page = Number(query.page) || 1
  const pageSize = Number(query.pageSize) || 20
  const status = query.status ? Number(query.status) : undefined

  const whereCondition = status !== undefined ? eq(orders.status, status) : undefined

  const [list, [{ count }]] = await Promise.all([
    db.select({
      id: orders.id,
      orderNo: orders.orderNo,
      userId: orders.userId,
      amount: orders.amount,
      paymentMethod: orders.paymentMethod,
      status: orders.status,
      paidAt: orders.paidAt,
      createdAt: orders.createdAt,
      userNickname: users.nickname,
      userPhone: users.phone,
    })
    .from(orders)
    .leftJoin(users, eq(orders.userId, users.id))
    .where(whereCondition)
    .limit(pageSize).offset((page - 1) * pageSize)
    .orderBy(desc(orders.createdAt)),
    db.select({ count: sql`count(*)` }).from(orders).where(whereCondition),
  ])

  return { list, total: Number(count), page, pageSize }
})

leftJoin 关联查询说明

ts
.leftJoin(users, eq(orders.userId, users.id))

为什么用 leftJoin 而不是 innerJoin leftJoin 即使没有匹配的用户(如用户已删除),订单仍然会出现在列表中,userNicknameuserPhonenullinnerJoin 会丢失这些订单。

实际场景

用户注销后,关联的用户记录可能被删除或匿名化。订单作为交易记录必须保留,不能因用户不存在而消失。

select 指定字段的原因

ts
db.select({
  id: orders.id,
  orderNo: orders.orderNo,
  // ...
  userNickname: users.nickname,
  userPhone: users.phone,
})
  • 避免 select():订单表的 paymentNo(支付流水号)是敏感信息,不应返回给前端
  • 关联用户信息:管理后台需要显示"谁下的单",通过 leftJoin 一次查询获取
  • 字段重命名users.nickname 重命名为 userNickname,避免与订单字段混淆

筛选逻辑

ts
const status = query.status ? Number(query.status) : undefined
const whereCondition = status !== undefined ? eq(orders.status, status) : undefined
query.statusstatuswhereCondition效果
未传undefinedundefined无筛选,返回所有订单
?status=00eq(orders.status, 0)只返回待支付订单
?status=11eq(orders.status, 1)只返回已支付订单

为什么 undefined 时不用 where

Drizzle 的 where(undefined) 等于不加条件,返回所有记录。

订单列表页面

vue
<!-- app/pages/admin/orders/index.vue -->
<script setup>
definePageMeta({
  layout: 'admin',
  middleware: 'admin-auth',
})

const page = ref(1)
const statusFilter = ref<number | undefined>(undefined)

const { data } = await useFetch('/api/admin/orders', {
  query: { page, status: statusFilter },
})

const statusMap: Record<number, { label: string; color: string }> = {
  0: { label: '待支付', color: 'yellow' },
  1: { label: '已支付', color: 'green' },
  2: { label: '已取消', color: 'gray' },
  3: { label: '已退款', color: 'red' },
}
</script>

<template>
  <div>
    <div class="flex justify-between items-center mb-4">
      <h2 class="text-xl font-bold">订单管理</h2>
      <UButtonGroup>
        <UButton :variant="statusFilter === undefined ? 'solid' : 'ghost'" @click="statusFilter = undefined">全部</UButton>
        <UButton :variant="statusFilter === 0 ? 'solid' : 'ghost'" @click="statusFilter = 0">待支付</UButton>
        <UButton :variant="statusFilter === 1 ? 'solid' : 'ghost'" @click="statusFilter = 1">已支付</UButton>
        <UButton :variant="statusFilter === 3 ? 'solid' : 'ghost'" @click="statusFilter = 3">已退款</UButton>
      </UButtonGroup>
    </div>

    <UTable :rows="data?.list ?? []" :columns="[
      { key: 'orderNo', label: '订单号' },
      { key: 'userNickname', label: '用户' },
      { key: 'amount', label: '金额' },
      { key: 'paymentMethod', label: '支付方式' },
      { key: 'status', label: '状态' },
      { key: 'paidAt', label: '支付时间' },
      { key: 'createdAt', label: '创建时间' },
    ]">
      <template #status-data="{ row }">
        <UBadge :color="statusMap[row.status]?.color">
          {{ statusMap[row.status]?.label }}
        </UBadge>
      </template>
      <template #amount-data="{ row }">
        ¥{{ row.amount }}
      </template>
    </UTable>

    <div class="flex justify-end mt-4">
      <UPagination v-model="page" :total="data?.total ?? 0" :page-size="20" />
    </div>
  </div>
</template>

金额显示

vue
<template #amount-data="{ row }">
  ¥{{ row.amount }}
</template>

金额精度问题

decimal(10,2) 返回的值可能是 "29.9" 而不是 "29.90"。建议统一格式化:

ts
const formatPrice = (val: string | number) => Number(val).toFixed(2)

状态筛选切换时重置分页

ts
watch(statusFilter, () => {
  page.value = 1  // 切换筛选条件时回到第一页
})

不重置分页的话,可能请求的页码超过新筛选结果的总页数,返回空列表。

套餐管理 API

ts
// server/api/admin/plans.get.ts
export default defineEventHandler(async (event) => {
  if (event.context.userRole !== 'admin') throw createError({ statusCode: 403 })
  return await db.select().from(plans).orderBy(plans.sortOrder)
})

// server/api/admin/plans.post.ts
const schema = z.object({
  name: z.string().min(1),
  durationDays: z.number().positive(),
  price: z.string(),
  originalPrice: z.string().optional(),
  sortOrder: z.number().default(0),
  status: z.number().default(1),
})

export default defineEventHandler(async (event) => {
  if (event.context.userRole !== 'admin') throw createError({ statusCode: 403 })
  const data = await readValidatedBody(event, schema.parse)
  const [plan] = await db.insert(plans).values(data).returning()
  return plan
})

// server/api/admin/plans/[id].put.ts
export default defineEventHandler(async (event) => {
  if (event.context.userRole !== 'admin') throw createError({ statusCode: 403 })
  const id = Number(getRouterParam(event, 'id'))
  const data = await readBody(event)
  const [updated] = await db.update(plans).set(data).where(eq(plans.id, id)).returning()
  return updated
})

套餐管理的安全注意事项

1. 修改价格影响未支付订单吗?

不影响。订单在创建时已经保存了 amount(从当时的套餐价格复制),后续修改套餐价格不会改变已创建的订单金额。

text
套餐价格 29.9 → 用户创建订单 → 订单 amount = 29.9
→ 管理员改价格 39.9 → 用户支付 → 仍然扣 29.9

这是正确的行为

用户看到什么价格就应该付什么价格,不能因为管理员调价就多收钱。

2. 下架套餐影响已购买的用户吗?

不影响。套餐下架只是 status 改为 0,新用户看不到这个套餐。已购买该套餐的用户的 VIP 会员资格不受影响。

3. plans.post.ts 的 price 为什么是 z.string()

ts
price: z.string()

因为 Drizzle 的 decimal 类型在 TypeScript 中是 string,避免浮点精度问题。如果用 z.number()"29.90" 会被拒绝。

但这也意味着前端可以传入任意字符串

建议增加校验:

ts
price: z.string().regex(/^\d+(\.\d{1,2})?$/, '价格格式不正确'),

4. 更新套餐缺少 Zod 校验

ts
// server/api/admin/plans/[id].put.ts
const data = await readBody(event)  // ❌ 未校验!

这是安全隐患——攻击者可以传入任意字段。应使用 readValidatedBody

ts
const updateSchema = z.object({
  name: z.string().min(1).optional(),
  price: z.string().regex(/^\d+(\.\d{1,2})?$/).optional(),
  durationDays: z.number().positive().optional(),
  sortOrder: z.number().optional(),
  status: z.number().min(0).max(1).optional(),
})

const data = await readValidatedBody(event, updateSchema.parse)

套餐管理页面

vue
<!-- app/pages/admin/plans/index.vue -->
<script setup>
definePageMeta({
  layout: 'admin',
  middleware: 'admin-auth',
})

const { data: planList, refresh } = await useFetch('/api/admin/plans')
const isOpen = ref(false)
const editingPlan = ref(null)

async function toggleStatus(plan: any) {
  const action = plan.status === 1 ? '下架' : '上架'
  if (!confirm(`确定${action}套餐"${plan.name}"?`)) return

  try {
    await $fetch(`/api/admin/plans/${plan.id}`, {
      method: 'PUT',
      body: { status: plan.status === 1 ? 0 : 1 },
    })
    refresh()
  } catch (error: any) {
    alert(error.data?.message || '操作失败')
  }
}
</script>

<template>
  <div>
    <div class="flex justify-between items-center mb-4">
      <h2 class="text-xl font-bold">套餐管理</h2>
      <UButton icon="i-heroicons-plus" @click="isOpen = true; editingPlan = null">新增套餐</UButton>
    </div>

    <UTable :rows="planList ?? []" :columns="[
      { key: 'name', label: '名称' },
      { key: 'durationDays', label: '天数' },
      { key: 'price', label: '价格' },
      { key: 'status', label: '状态' },
      { key: 'actions', label: '操作' },
    ]">
      <template #price-data="{ row }">¥{{ Number(row.price).toFixed(2) }}</template>
      <template #status-data="{ row }">
        <UBadge :color="row.status === 1 ? 'green' : 'red'">{{ row.status === 1 ? '上架' : '下架' }}</UBadge>
      </template>
      <template #actions-data="{ row }">
        <UButton size="xs" @click="toggleStatus(row)">{{ row.status === 1 ? '下架' : '上架' }}</UButton>
      </template>
    </UTable>
  </div>
</template>

套餐上下架的影响

操作效果不影响
下架新用户看不到该套餐已购买用户的 VIP 不受影响
上架新用户可以购买该套餐不改变套餐价格(需单独修改)
删除❌ 不建议会导致历史订单的 planId 指向不存在的记录

为什么不建议删除套餐?

订单表通过 planId 关联套餐。如果删除套餐,历史订单会显示不了套餐名称。正确做法是下架(status = 0),而非物理删除。

常见问题

订单列表中的用户名显示 null

如果用户被删除或匿名化,leftJoin 返回的 userNicknamenull。前端应处理:

vue
<template #userNickname-data="{ row }">
  {{ row.userNickname ?? '已注销用户' }}
</template>

如何处理退款?

退款需要:

  1. 管理后台添加退款操作按钮
  2. 调用微信/支付宝的退款 API
  3. 更新订单状态为 3(已退款)
  4. 考虑是否回退用户的 VIP(取决于业务规则)

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