管理后台 - 订单与会员
订单管理 API
// 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 关联查询说明
.leftJoin(users, eq(orders.userId, users.id))为什么用 leftJoin 而不是 innerJoin? leftJoin 即使没有匹配的用户(如用户已删除),订单仍然会出现在列表中,userNickname 和 userPhone 为 null。innerJoin 会丢失这些订单。
实际场景
用户注销后,关联的用户记录可能被删除或匿名化。订单作为交易记录必须保留,不能因用户不存在而消失。
select 指定字段的原因
db.select({
id: orders.id,
orderNo: orders.orderNo,
// ...
userNickname: users.nickname,
userPhone: users.phone,
})- 避免
select():订单表的paymentNo(支付流水号)是敏感信息,不应返回给前端 - 关联用户信息:管理后台需要显示"谁下的单",通过
leftJoin一次查询获取 - 字段重命名:
users.nickname重命名为userNickname,避免与订单字段混淆
筛选逻辑
const status = query.status ? Number(query.status) : undefined
const whereCondition = status !== undefined ? eq(orders.status, status) : undefinedquery.status | status | whereCondition | 效果 |
|---|---|---|---|
| 未传 | undefined | undefined | 无筛选,返回所有订单 |
?status=0 | 0 | eq(orders.status, 0) | 只返回待支付订单 |
?status=1 | 1 | eq(orders.status, 1) | 只返回已支付订单 |
为什么 undefined 时不用 where?
Drizzle 的 where(undefined) 等于不加条件,返回所有记录。
订单列表页面
<!-- 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>金额显示
<template #amount-data="{ row }">
¥{{ row.amount }}
</template>金额精度问题
decimal(10,2) 返回的值可能是 "29.9" 而不是 "29.90"。建议统一格式化:
const formatPrice = (val: string | number) => Number(val).toFixed(2)状态筛选切换时重置分页
watch(statusFilter, () => {
page.value = 1 // 切换筛选条件时回到第一页
})不重置分页的话,可能请求的页码超过新筛选结果的总页数,返回空列表。
套餐管理 API
// 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(从当时的套餐价格复制),后续修改套餐价格不会改变已创建的订单金额。
套餐价格 29.9 → 用户创建订单 → 订单 amount = 29.9
→ 管理员改价格 39.9 → 用户支付 → 仍然扣 29.9这是正确的行为
用户看到什么价格就应该付什么价格,不能因为管理员调价就多收钱。
2. 下架套餐影响已购买的用户吗?
不影响。套餐下架只是 status 改为 0,新用户看不到这个套餐。已购买该套餐的用户的 VIP 会员资格不受影响。
3. plans.post.ts 的 price 为什么是 z.string()?
price: z.string()因为 Drizzle 的 decimal 类型在 TypeScript 中是 string,避免浮点精度问题。如果用 z.number(),"29.90" 会被拒绝。
但这也意味着前端可以传入任意字符串
建议增加校验:
price: z.string().regex(/^\d+(\.\d{1,2})?$/, '价格格式不正确'),4. 更新套餐缺少 Zod 校验
// server/api/admin/plans/[id].put.ts
const data = await readBody(event) // ❌ 未校验!这是安全隐患——攻击者可以传入任意字段。应使用 readValidatedBody:
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)套餐管理页面
<!-- 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 返回的 userNickname 为 null。前端应处理:
<template #userNickname-data="{ row }">
{{ row.userNickname ?? '已注销用户' }}
</template>如何处理退款?
退款需要:
- 管理后台添加退款操作按钮
- 调用微信/支付宝的退款 API
- 更新订单状态为
3(已退款) - 考虑是否回退用户的 VIP(取决于业务规则)