Next.js API路由时区转换在开发与Vercel生产环境不一致
Next.js + Prisma + PostgreSQL 跨环境时区处理不一致问题
问题详情
- 本地开发环境:保存IST时间(如2月7日凌晨3:30)会自动转为UTC时间(2月6日晚10:00)存入数据库,查询“今日”数据时能正确转回IST,考勤记录显示正常。
- Vercel生产环境:API路由未将数据库中的UTC时间转换为Asia/Kolkata时区,直接返回原始UTC时间,导致凌晨IST时段保存的考勤记录在UI中被显示为前一天的数据。
预期行为
无论运行环境如何,API在返回数据前需将数据库中的UTC时间戳统一转换为Asia/Kolkata时区对应的“今日”范围,确保考勤记录归属日期正确。
已尝试方案
在API路由的GET方法中使用date-fns-tz库的utcToZonedTime显式处理时区转换,但问题仍未解决。
前端代码
"use client"; import { useState, useEffect } from "react"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { format } from 'date-fns-tz'; interface Worker { id: string; name: string; type: string; photoUrl: string | null; hourlyRate: number; present: boolean; hoursWorked: number; isModified?: boolean; } export default function AttendancePage({ params }: { params: { id: string } }) { const [workers, setWorkers] = useState<Worker[]>([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); useEffect(() => { const fetchWorkers = async () => { try { const response = await fetch(`/api/attendance?projectId=${params.id}`); if (!response.ok) throw new Error("Failed to fetch workers"); const data = await response.json(); setWorkers(data); } catch (error) { console.error("Error:", error); } finally { setLoading(false); } }; fetchWorkers(); }, [params.id]); const updateWorkerAttendance = (workerId: string, present: boolean) => { setWorkers( workers.map((worker) => worker.id === workerId ? { ...worker, present, isModified: true } : worker ) ); }; const updateWorkerHours = (workerId: string, hours: number) => { setWorkers( workers.map((worker) => worker.id === workerId ? { ...worker, hoursWorked: hours, isModified: true } : worker ) ); }; const handleSave = async () => { const modifiedAttendance = workers.filter((worker) => worker.isModified); if (modifiedAttendance.length === 0) { toast.info("No changes to save"); return; } setSaving(true); try { const response = await fetch("/api/attendance", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId: params.id, attendance: modifiedAttendance.map( ({ id, present, hoursWorked }) => ({ id, present, hoursWorked, }) ), }), }); if (!response.ok) throw new Error("Failed to save attendance"); setWorkers(workers.map((worker) => ({ ...worker, isModified: false }))); toast.success("Attendance saved successfully"); } catch (error) { console.error("Error:", error); toast.error("Failed to save attendance"); } finally { setSaving(false); } }; if (loading) { return <div className="p-6">Loading...</div>; } return ( <div className="p-6"> <div className="flex justify-between items-center mb-6"> <h1 className="text-2xl font-bold">Daily Attendance</h1> <div className="flex items-center gap-4"> <div className="text-muted-foreground"> {format(new Date(), "EEEE, MMMM d, yyyy", { timeZone: 'Asia/Kolkata' })} </div> <Button onClick={handleSave} disabled={saving}> {saving ? "Saving..." : "Save Attendance"} </Button> </div> </div> <div className="rounded-md border"> <Table> <TableHeader> <TableRow> <TableHead>Name</TableHead> <TableHead>Worker Type</TableHead> <TableHead>Present</TableHead> <TableHead>Working Hours</TableHead> <TableHead>Daily Income</TableHead> </TableRow> </TableHeader> <TableBody> {workers.map((worker) => ( <TableRow key={worker.id}> <TableCell className="flex items-center gap-2"> <Avatar> <AvatarImage src={worker.photoUrl || ""} /> <AvatarFallback> {worker.name .split(" ") .map((n) => n[0]) .join("")} </AvatarFallback> </Avatar> {worker.name} </TableCell> <TableCell> <Badge variant="secondary">{worker.type}</Badge> </TableCell> <TableCell> <Checkbox checked={worker.present} onCheckedChange={(checked) => updateWorkerAttendance(worker.id, checked as boolean) } /> </TableCell> <TableCell> <Input type="number" className="w-[100px]" placeholder="0" value={worker.hoursWorked || ""} onChange={(e) => updateWorkerHours( worker.id, parseFloat(e.target.value) || 0 ) } disabled={!worker.present} /> </TableCell> <TableCell> ₹{(worker.hourlyRate * (worker.hoursWorked || 0)).toFixed(2)} </TableCell> </TableRow> ))} </TableBody> </Table> </div> </div> ); }
API路由代码
// /api/attendance.ts import { prisma } from "@/lib/prisma"; import { NextRequest } from "next/server"; import { zonedTimeToUtc, utcToZonedTime, format } from 'date-fns-tz'; const TIME_ZONE = 'Asia/Kolkata'; // Define the timezone consistently export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; const projectId = searchParams.get("projectId"); if (!projectId) { return Response.json( { error: "Project ID is required" }, { status: 400 } ); } const workers = await prisma.workerAssignment.findMany({ where: { projectId: projectId, endDate: null, // Only get currently assigned workers }, include: { worker: true, }, }); // --- Get start of TODAY in Asia/Kolkata --- const today = new Date(); const startOfToday = utcToZonedTime(today, TIME_ZONE); // Convert *now* to Asia/Kolkata startOfToday.setHours(0, 0, 0, 0); // Set to midnight const startOfTodayUTC = zonedTimeToUtc(startOfToday, TIME_ZONE); // Convert back to UTC for querying const tomorrow = new Date(startOfTodayUTC); tomorrow.setDate(tomorrow.getDate() + 1); const attendance = await prisma.attendance.findMany({ where: { projectId: projectId, date: { gte: startOfTodayUTC, lt: tomorrow, }, }, }); const formattedWorkers = workers.map((assignment) => { const todayAttendance = attendance.find( (a) => a.workerId === assignment.worker.id ); // --- Convert database UTC timestamps to Asia/Kolkata --- let present = false; let hoursWorked = 0; if (todayAttendance) { const zonedDate = utcToZonedTime(todayAttendance.date, TIME_ZONE); present = todayAttendance.present; hoursWorked = todayAttendance.hoursWorked } return { id: assignment.worker.id, name: assignment.worker.name, type: assignment.worker.type, photoUrl: assignment.worker.photoUrl, hourlyRate: assignment.worker.hourlyRate, present: present, hoursWorked: hoursWorked, }; }); return Response.json(formattedWorkers); } catch (error) { console.error("Error fetching workers:", error); return Response.json({ error: "Failed to fetch workers" }, { status: 500 }); } } export async function POST(request: NextRequest) { try { const body = await request.json(); const { projectId, attendance } = body; if (!projectId || !attendance) { return Response.json( { error: "Project ID and attendance data are required" }, { status: 400 } ); } // --- Get start of TODAY in Asia/Kolkata --- const today = new Date(); const startOfToday = utcToZonedTime(today, TIME_ZONE); startOfToday.setHours(0, 0, 0, 0); const startOfTodayUTC = zonedTimeToUtc(startOfToday, TIME_ZONE); const promises = attendance.map(async (record: any) => { return prisma.attendance.upsert({ where: { workerId_projectId_date: { workerId: record.id, projectId: projectId, date: startOfTodayUTC, // Use the consistent date object }, }, create: { workerId: record.id, projectId: projectId, date: startOfTodayUTC, present: record.present, hoursWorked: record.hoursWorked, }, update: { present: record.present, hoursWorked: record.hoursWorked, }, }); }); await Promise.all(promises); return Response.json({ success: true }); } catch (error) { console.error("Error saving attendance:", error); return Response.json( { error: "Failed to save attendance" }, { status: 500 } ); } }
数据库Schema(Prisma)
model Attendance { id String @id @default(cuid()) worker Worker @relation(fields: [workerId], references: [id]) workerId String project Project @relation(fields: [projectId], references: [id]) projectId String date DateTime @default(now()) present Boolean @default(false) hoursWorked Float @default(0) overtime Float @default(0) photoUrl String? confidence Float? createdAt DateTime @default(now()) @@unique([workerId, projectId, date]) @@index([projectId]) }
问题分析与解决方案
核心原因
- Vercel Serverless函数默认时区为UTC:本地开发环境可能已设置Asia/Kolkata时区,而Vercel函数默认运行在UTC环境,导致日期边界计算时出现隐性偏差。
- date-fns-tz时区转换逻辑不严谨:当前代码手动通过
setHours设置零点,存在日期对象 mutable 导致的计算风险;同时生产环境可能缺失完整时区数据,导致转换失效。 - 无效时区转换代码:GET方法中定义了
zonedDate但未实际使用,属于冗余代码,未解决实际问题。
针对性解决方案
1. 修复日期范围计算逻辑
使用date-fns的startOfDay配合时区,明确计算Asia/Kolkata时区的当日零点,避免手动修改日期对象的风险:
// 替换GET/POST方法中的日期计算代码 import { startOfDay } from 'date-fns'; const nowInIST = utcToZonedTime(new Date(), TIME_ZONE); const startOfTodayIST = startOfDay(nowInIST); const startOfTodayUTC = zonedTimeToUtc(startOfTodayIST, TIME_ZONE);
2. 确保生产环境时区数据完整
在API路由中显式导入Asia/Kolkata时区数据,避免Serverless环境下时区缺失:
import 'date-fns-tz/esm/timezones/Asia/Kolkata';
3. 统一数据库时区配置
在Prisma Schema中明确指定数据库时区为UTC,消除存储和查询时的时区歧义:
datasource db { provider = "postgresql" url = env("DATABASE_URL") timezone = "UTC" }
4. 添加日志验证生产环境计算结果
在API路由中添加日志,输出关键日期变量,通过Vercel函数日志确认日期计算是否符合预期:
console.log('Server UTC time:', new Date()); console.log('IST start of today:', startOfTodayIST); console.log('UTC start of today:', startOfTodayUTC);
5. 前端日期处理强制指定时区
确保前端所有日期格式化操作都显式指定Asia/Kolkata时区,避免依赖客户端本地时区:
format(new Date(), "EEEE, MMMM d, yyyy", { timeZone: 'Asia/Kolkata' })
内容的提问来源于stack exchange,提问作者RayJay
相关产品推荐
相关产品推荐

