You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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])
}

问题分析与解决方案

核心原因

  1. Vercel Serverless函数默认时区为UTC:本地开发环境可能已设置Asia/Kolkata时区,而Vercel函数默认运行在UTC环境,导致日期边界计算时出现隐性偏差。
  2. date-fns-tz时区转换逻辑不严谨:当前代码手动通过setHours设置零点,存在日期对象 mutable 导致的计算风险;同时生产环境可能缺失完整时区数据,导致转换失效。
  3. 无效时区转换代码: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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 13:40:54