import { NextRequest, NextResponse } from 'next/server'; import { Prisma } from '@prisma/client'; import { prisma } from '@/lib/db'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { OrderStatus } from '@prisma/client'; /** 业务时区偏移(东八区,+8 小时) */ const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000; const BIZ_TZ_NAME = 'Asia/Shanghai'; /** 获取业务时区下的 YYYY-MM-DD */ function toBizDateStr(d: Date): string { const local = new Date(d.getTime() + BIZ_TZ_OFFSET_MS); return local.toISOString().split('T')[0]; } /** 获取业务时区下"今天 00:00"对应的 UTC 时间 */ function getBizDayStartUTC(d: Date): Date { const bizDateStr = toBizDateStr(d); // bizDateStr 00:00 在业务时区 = bizDateStr 00:00 - offset 在 UTC return new Date(`${bizDateStr}T00:00:00+08:00`); } export async function GET(request: NextRequest) { if (!(await verifyAdminToken(request))) return unauthorizedResponse(); const searchParams = request.nextUrl.searchParams; const days = Math.min(365, Math.max(1, Number(searchParams.get('days') || '30'))); const now = new Date(); const todayStart = getBizDayStartUTC(now); const startDate = new Date(todayStart.getTime() - days * 24 * 60 * 60 * 1000); const paidStatuses: OrderStatus[] = [ OrderStatus.PAID, OrderStatus.RECHARGING, OrderStatus.COMPLETED, OrderStatus.REFUNDING, OrderStatus.REFUNDED, OrderStatus.REFUND_FAILED, ]; const [todayStats, totalStats, todayOrders, totalOrders, dailyRaw, leaderboardRaw, paymentMethodStats] = await Promise.all([ // Today paid aggregate prisma.order.aggregate({ where: { status: { in: paidStatuses }, paidAt: { gte: todayStart } }, _sum: { amount: true }, _count: { _all: true }, }), // Total paid aggregate prisma.order.aggregate({ where: { status: { in: paidStatuses } }, _sum: { amount: true }, _count: { _all: true }, }), // Today total orders prisma.order.count({ where: { createdAt: { gte: todayStart } } }), // Total orders prisma.order.count(), // Daily series: use AT TIME ZONE to group by business timezone date // Prisma.raw() inlines the timezone name to avoid parameterization mismatch between SELECT and GROUP BY prisma.$queryRaw<{ date: string; amount: string; count: bigint }[]>` SELECT (paid_at AT TIME ZONE 'UTC' AT TIME ZONE ${Prisma.raw(`'${BIZ_TZ_NAME}'`)})::date::text as date, SUM(amount)::text as amount, COUNT(*) as count FROM orders WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED') AND paid_at >= ${startDate} GROUP BY (paid_at AT TIME ZONE 'UTC' AT TIME ZONE ${Prisma.raw(`'${BIZ_TZ_NAME}'`)})::date ORDER BY date `, // Leaderboard: GROUP BY user_id only, MAX() for name/email prisma.$queryRaw< { user_id: number; user_name: string | null; user_email: string | null; total_amount: string; order_count: bigint; }[] >` SELECT user_id, MAX(user_name) as user_name, MAX(user_email) as user_email, SUM(amount)::text as total_amount, COUNT(*) as order_count FROM orders WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED') AND paid_at >= ${startDate} GROUP BY user_id ORDER BY SUM(amount) DESC LIMIT 10 `, // Payment method distribution (within time range) prisma.order.groupBy({ by: ['paymentType'], where: { status: { in: paidStatuses }, paidAt: { gte: startDate } }, _sum: { amount: true }, _count: { _all: true }, }), ]); // Fill missing dates for continuous line chart const dailyMap = new Map(); for (const row of dailyRaw) { dailyMap.set(row.date, { amount: Number(row.amount), count: Number(row.count) }); } const dailySeries: { date: string; amount: number; count: number }[] = []; const cursor = new Date(startDate); while (cursor <= now) { const dateStr = toBizDateStr(cursor); const entry = dailyMap.get(dateStr); dailySeries.push({ date: dateStr, amount: entry?.amount ?? 0, count: entry?.count ?? 0 }); cursor.setTime(cursor.getTime() + 24 * 60 * 60 * 1000); } // Deduplicate: toBizDateStr on consecutive UTC days near midnight can produce the same biz date const seen = new Set(); const deduped = dailySeries.filter((d) => { if (seen.has(d.date)) return false; seen.add(d.date); return true; }); // Calculate summary const todayPaidAmount = Number(todayStats._sum?.amount || 0); const todayPaidCount = todayStats._count._all; const totalPaidAmount = Number(totalStats._sum?.amount || 0); const totalPaidCount = totalStats._count._all; const successRate = totalOrders > 0 ? (totalPaidCount / totalOrders) * 100 : 0; const avgAmount = totalPaidCount > 0 ? totalPaidAmount / totalPaidCount : 0; // Payment method total for percentage calc const paymentTotal = paymentMethodStats.reduce((sum, m) => sum + Number(m._sum?.amount || 0), 0); return NextResponse.json({ summary: { today: { amount: todayPaidAmount, orderCount: todayOrders, paidCount: todayPaidCount }, total: { amount: totalPaidAmount, orderCount: totalOrders, paidCount: totalPaidCount }, successRate: Math.round(successRate * 10) / 10, avgAmount: Math.round(avgAmount * 100) / 100, }, dailySeries: deduped, leaderboard: leaderboardRaw.map((row) => ({ userId: row.user_id, userName: row.user_name, userEmail: row.user_email, totalAmount: Number(row.total_amount), orderCount: Number(row.order_count), })), paymentMethods: paymentMethodStats.map((m) => { const amount = Number(m._sum?.amount || 0); return { paymentType: m.paymentType, amount, count: m._count._all, percentage: paymentTotal > 0 ? Math.round((amount / paymentTotal) * 1000) / 10 : 0, }; }), meta: { days, generatedAt: now.toISOString() }, }); }