如何正确地将 JS 日期发送到 EF Core5 并返回?

How to properly send JS dates to EF Core5 and back?

更新:我已经在此处添加了修复程序,以供任何正在寻找其正常工作的前后示例的人使用。

我知道这有点重复,但我似乎一直在寻找部分答案,但没有找到完整的解决方案。我看到很多关于在后端以 UTC 格式存储日期的评论,我确实想这样做,但是 how/where 上没有真正的整体解决方案来处理过去几年内的翻译。

我知道 JS Date() 对象存储时区信息,然后当您在其上使用 JSON.stringify() 时,它会将其交换为 UTC 时间。这将时间提前 7 小时(对于我的语言环境),因此每次我保存日期时它都会提前 7 小时。我该如何解决?我确实希望将日期作为 UTC 存储在数据库中,但我不确定我需要在哪里执行转换才能将它从 UTC 返回到我的语言环境时间,当它 returns 到浏览器时。

我试图在下面包含整个代码路径,希望有人能指出我遗漏的内容。

首先,我有两个 EFCore5 类,我用它们以数据优先的方法构建我的数据库。一个income Source和一个Schedule,这些对象之间是一对一的关系。

public class Schedule
{
    [Key]
    public int ScheduleID { get; set; }

    [Required]
    public ScheduleFrequency Frequency { get; set; }

    [DataType(DataType.Date)]
    public DateTime Occurrence_First { get; set; }

    [DataType(DataType.Date)]
    public DateTime? Occurrence_LastConfirmed { get; set; }

    [DataType(DataType.Date)]
    public DateTime? Occurrence_LastPlanned { get; set; }

    [DataType(DataType.Date)]
    public DateTime? Occurrence_Final { get; set; }

    public bool IsAutoConfirm { get; set; }

    [DataType(DataType.Date)]
    public DateTime DateTime_Created { get; set; }

    [DataType(DataType.Date)]
    public DateTime? DateTime_Deactivated { get; set; }

    public bool HasCustomTransactionTime { get; set; }

    [System.Text.Json.Serialization.JsonIgnore]
    public IncomeSource IncomeSource { get; set; }
}

public class IncomeSource
{
    [Key]
    public int IncomeSourceID { get; set; }

    [DataType(DataType.Currency)]
    [Column(TypeName = "money")]
    public decimal ExpectedAmount { get; set; }

    [DataType(DataType.Currency)]
    [Column(TypeName = "money")]
    public decimal TotalFromSource { get; set; }

    public int? DefaultToAccountID { get; set; }

    public int ScheduleID { get; set; }

    public BudgetorAccount Account { get; set; }

    public Schedule Schedule { get; set; }


    public int AccountID { get; set; }

    [ForeignKey("DefaultToAccountID")]
    public BudgetorAccount DefaultToAccount { get; set; }
}

public class BudgetorDbContext : DbContext
{
    public BudgetorDbContext(DbContextOptions<BudgetorDbContext> options) : base (options)
    {

    }

    public DbSet<BudgetorAccount> Accounts { get; set; }
    public DbSet<IncomeSource> IncomeSources { get; set; }
    public DbSet<Schedule> Schedules { get; set; }

}

API 控制器调用一个服务来转换to/from 视图模型并处理所有 CRUD 操作。该服务利用 EFCore 自动保存相关对象的能力来保存附加到 IncomeSource

Schedule
public class IncomeSourceDetailVM : AccountDetailVM
{
    public int IncomeSourceId { get; set; }
    public decimal ExpectedAmount { get; set; }
    public decimal TotalFromSource { get; set; }
    public int? DefaultToAccountID { get; set; }
    public Schedule Schedule { get; set; }

    public  IncomeSourceDetailVM() : base(AccountType.IncomeSource)
    {
        this.Schedule = new Schedule()
        {
            Occurrence_First = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day)                
        };
    }
}

public class BudgetorDataService : IBudgetorService
{
    public async Task<IncomeSourceDetailVM> GetIncomeSourceDetailVM(int id)
    {
        var vm = await getIncomeSourceData()
            .Where(inc => inc.AccountID == id)
            .Select(incSrc => new IncomeSourceDetailVM()
            {
                IncomeSourceId = incSrc.IncomeSourceID,
                ExpectedAmount = incSrc.ExpectedAmount,
                AccountId = incSrc.AccountID,
                AccountName = incSrc.Account.Name,
                DateTime_Created = incSrc.Account.DateTime_Created,
                DateTime_Deactivated = incSrc.Account.DateTime_Deactivated,
                DefaultToAccountID = incSrc.DefaultToAccountID,
                Schedule = incSrc.Schedule,
                Notes = incSrc.Account.Notes,
            })
            .FirstOrDefaultAsync();

        return vm;
    }

    public async Task<IncomeSourceDetailVM> CreateIncomeSource(IncomeSourceDetailVM incomeSourceToAdd)
    {
        if (incomeSourceToAdd == null)
        {
            throw new ArgumentNullException(nameof(incomeSourceToAdd));
        }

        try
        {
            BudgetorAccount newAcct = new BudgetorAccount()
            {
                AccountType = AccountType.IncomeSource,
                DateTime_Created = DateTime.Now,
                Name = incomeSourceToAdd.AccountName,
                Notes = incomeSourceToAdd.Notes,
                IsSystem = false
            };
            await _context.Accounts.AddAsync(newAcct);
            await _context.SaveChangesAsync();

            incomeSourceToAdd.AccountId = newAcct.AccountID;
            incomeSourceToAdd.DateTime_Created = newAcct.DateTime_Created;

            IncomeSource newIncSrc = new IncomeSource()
            {
                AccountID = newAcct.AccountID,
                DefaultToAccountID = incomeSourceToAdd.DefaultToAccountID,
                ExpectedAmount = incomeSourceToAdd.ExpectedAmount,
                Schedule = incomeSourceToAdd.Schedule
            };

            await _context.IncomeSources.AddAsync(newIncSrc);
            await _context.SaveChangesAsync();

            incomeSourceToAdd.IncomeSourceId = newIncSrc.IncomeSourceID;
        }
        catch(Exception err)
        {
            throw err;
        }
        return incomeSourceToAdd;
    }
}

[ApiController]
public class IncSrcController : ControllerBase
{
    private readonly IBudgetorService _budgetorService;

    public IncSrcController(IBudgetorService budgetorService)
    {
        this._budgetorService = budgetorService;
    }

    [HttpPost]
    [Consumes("application/json")]
    public async Task<ActionResult<IncomeSourceDetailVM>> PostIncSrc([FromBody] IncomeSourceDetailVM incSrcToAdd)
    {
        IncomeSourceDetailVM createdIncSrc = incSrcToAdd.AccountId == 0
            ? await this._budgetorService.CreateIncomeSource(incSrcToAdd)
            : await this._budgetorService.UpdateIncomeSource(incSrcToAdd);

        return StatusCode(201, createdIncSrc);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<IncSrcManagementVM>> GetAccount(int? id)
    {
        IncSrcManagementVM result = new IncSrcManagementVM()
        {
            Account = (id == 0 || !id.HasValue)
                ? new IncomeSourceDetailVM()
                : await this._budgetorService.GetIncomeSourceDetailVM(id.Value),
            ToAccounts = await this._budgetorService.GetIncSrcToAccounts()
        };

        if (result.Account.DefaultToAccountID.HasValue)
        {
            result.ToAccounts
                .Find(s => s.AccountId == result.Account.DefaultToAccountID)
                .IsDefault = true;
        }

        return result;
    }
}

在前端,我在形状与上述 C# 中的 IncomeSourceDetailVM 视图模型完全相同的 JS 对象上调用 JSON.stringify() 以将对象发送回服务器。 (我在一篇文章中读到在 JSON.stringify() 调用中使用替换器来手动处理字符串转换并调用 .toLocalDateString() 但随后 API 控制器无法将其识别为日期并拒绝调用。另外,我希望在后端将其存储为 UTC,以便日期始终在视图中本地化。)

const saveIncomeSource = async (incSrcDetailVM) => {
    const transportObj = JSON.stringify(incSrcDetailVM);
    const { data } = await axios.post(INC_SRC_ROUTE_PATH, transportObj, headerConfig);
    await getIncSrcListItemVMs();
}

当对象回到我的 React 应用程序的状态时,我使用效果来更新本地状态。作为其中的一部分,我转换了服务器发送到 new Date() 的所有字符串日期。此过程将这些 UTC 日期字符串解释为当地时间而不是 UTC。

useEffect(() => {
    const { account, toAccounts} = incSrcEditorVM;
    if ((account.schedule 
        && !(account.schedule instanceof ScheduleBase))
    ) {
        account.schedule = ScheduleBase.clone(account.schedule);
    }
    setIncSrc(account)
    setToAccounts(toAccounts);
    setLoading(false);
    return () => {
        setLoading(true);
        setToAccounts([...blankIncSrc.toAccounts]);
    }
}, [incSrcEditorVM]);
export default class ScheduleBase {
    scheduleID = 0;
    frequency = 0;
    occurrence_First;
    occurrence_LastConfirmed = null;
    occurrence_LastPlanned = null;
    occurrence_Final = null;
    dateTime_Created = new Date();
    dateTime_Deactivated = null;
    hasCustomTransactionTime = false;
    isAutoConfirm = false;

    constructor() {
        this.occurrence_First = new Date(Date.now());
        this.dateTime_Created = new Date(Date.now());
    }

    static clone(schedBaseShapedObj) {
        const clone = new ScheduleBase();
        clone.frequency = schedBaseShapedObj.frequency;
        clone.scheduleID = schedBaseShapedObj.scheduleID;
        clone.occurrence_First = new Date(schedBaseShapedObj.occurrence_First);
        clone.occurrence_LastConfirmed = populateDate(schedBaseShapedObj.occurrence_LastConfirmed);
        clone.occurrence_LastPlanned = populateDate(schedBaseShapedObj.occurrence_LastPlanned);
        clone.occurrence_Final = populateDate(schedBaseShapedObj.occurrence_Final);
        clone.dateTime_Created = new Date(schedBaseShapedObj.dateTime_Created);
        clone.dateTime_Deactivated = populateDate(schedBaseShapedObj.dateTime_Deactivated);
        clone.hasCustomTransactionTime = schedBaseShapedObj.hasCustomTransactionTime;
        clone.isAutoConfirm = schedBaseShapedObj.isAutoConfirm;
        return clone;
    }
}

// **Fix goes in here**
function populateDate(nullableDate) {
    return (nullableDate === null) 
        ? null
        : new Date(nullableDate + "Z");
}

我觉得这必须是一个足够普遍的问题,所以有更直接的方法可以做到这一点,而我只是想念它。

提前感谢所有花时间阅读本文的人。

对于坚持这个的人,答案就在这里:

添加“Z”以指示来自服务器的时间是 UTC 或 ZULU 让它停止尝试将服务器的 UTC 时间戳读取为本地时间。