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

ASP.NET Core Razor Pages如何防止表单重复提交至PageModel/API

解决表单重复提交问题

问题背景

我的PageModel存在用户重复提交表单的问题,连续按Space键触发保存按钮就能复现。试过用遮罩层防止主动点击,但这种方式能被绕过;也尝试在表单提交时禁用保存按钮,但代码没生效:

<form id="questionnaireForm" method="post" onsubmit="disableSave(); toggleOverlay();">

<script>
function disableSave()
{
    document.querySelector("#saveSectionButton").disabled = true
}
function toggleOverlay() {
    const overlay = document.querySelector("#overlay")
    var isHidden = overlay.classList.contains("d-none")
    if (!isHidden) {
        overlay.classList.add("d-none")
    } else {
        overlay.classList.remove("d-none")
    }
}
</script>

原本以为模型绑定能避免这类问题,后来在OnPostAsync方法里加了检查,试图更新现有数据而非保存重复项:

public async Task<IActionResult> OnPostAsync()
{
    // Work on the page modifies the public Answers BindProperty.
    if (this.Answers == null || this.Answers.Count == 0)
    {
        throw new ArgumentException("Answers cannot be null or empty.");
    }

    var answered = this.Answers.Where(x => x.Value is not null);
    foreach (var answer in answered)
    {
        // Basic validation removed for brevity.
        var question = await context.QuestionnaireQuestions
            .SingleOrDefaultAsync(x => x.Id == answer.QuestionId);

        // Handle potential updates to the answer.
        var existing = context.QuestionnaireAnswers
            .Where(x => x.Question.Id == answer.QuestionId && x.Booking.Id == this.BookingId);

        if (existing.Any())
        {
            await UpdateAnswer(this.BookingId, answer, question, existing);
            continue;
        }

        // Continue to process and save the new answer to the database.
    }
}

但这里有个明显缺陷:快速连续提交多个请求时,第一个请求还没完成保存,第二个请求就已经检查完现有答案,导致还是会插入重复数据。

解决方案

1. 修复前端按钮禁用逻辑

之前的onsubmit事件禁用逻辑可能因为表单提交流程问题没生效,改成按钮点击时立即禁用,同时添加提交标记防止重复触发:

<form id="questionnaireForm" method="post">
    <button id="saveSectionButton" type="submit">保存</button>
</form>

<script>
let isSubmitting = false;
const form = document.querySelector("#questionnaireForm");
const saveBtn = document.querySelector("#saveSectionButton");
const overlay = document.querySelector("#overlay");

form.addEventListener("submit", function(e) {
    if (isSubmitting) {
        e.preventDefault();
        return;
    }
    isSubmitting = true;
    saveBtn.disabled = true;
    overlay.classList.remove("d-none");
});
</script>

2. 后端添加并发控制

方式一:悲观锁控制(单实例场景)

在查询现有答案时,使用数据库悲观写锁锁定对应行,阻止其他请求同时插入:

public async Task<IActionResult> OnPostAsync()
{
    if (this.Answers == null || this.Answers.Count == 0)
    {
        throw new ArgumentException("Answers cannot be null or empty.");
    }

    var answered = this.Answers.Where(x => x.Value is not null);
    foreach (var answer in answered)
    {
        var question = await context.QuestionnaireQuestions
            .SingleOrDefaultAsync(x => x.Id == answer.QuestionId);

        // 加悲观写锁,锁定匹配的行
        var existing = await context.QuestionnaireAnswers
            .Where(x => x.Question.Id == answer.QuestionId && x.Booking.Id == this.BookingId)
            .WithLock(LockMode.PessimisticWrite)
            .FirstOrDefaultAsync();

        if (existing != null)
        {
            await UpdateAnswer(this.BookingId, answer, question, existing);
            continue;
        }

        // 新增答案逻辑
        var newAnswer = new QuestionnaireAnswer
        {
            BookingId = this.BookingId,
            QuestionId = answer.QuestionId,
            Value = answer.Value
        };
        context.QuestionnaireAnswers.Add(newAnswer);
    }

    await context.SaveChangesAsync();
    return RedirectToPage("./Success");
}

方式二:分布式锁(多实例部署场景)

如果是多服务器部署,用Redis实现分布式锁,确保同一BookingId下的提交同一时间只有一个在处理:

private readonly IDistributedLock _distributedLock;

public async Task<IActionResult> OnPostAsync()
{
    using (var lockHandle = await _distributedLock.TryAcquireLockAsync($"questionnaire:{this.BookingId}", TimeSpan.FromSeconds(10)))
    {
        if (lockHandle == null)
        {
            ModelState.AddModelError("", "操作过于频繁,请稍后再试");
            return Page();
        }

        // 原有处理逻辑(同悲观锁版本)
        if (this.Answers == null || this.Answers.Count == 0)
        {
            throw new ArgumentException("Answers cannot be null or empty.");
        }

        var answered = this.Answers.Where(x => x.Value is not null);
        foreach (var answer in answered)
        {
            var question = await context.QuestionnaireQuestions
                .SingleOrDefaultAsync(x => x.Id == answer.QuestionId);

            var existing = await context.QuestionnaireAnswers
                .FirstOrDefaultAsync(x => x.Question.Id == answer.QuestionId && x.Booking.Id == this.BookingId);

            if (existing != null)
            {
                await UpdateAnswer(this.BookingId, answer, question, existing);
                continue;
            }

            var newAnswer = new QuestionnaireAnswer
            {
                BookingId = this.BookingId,
                QuestionId = answer.QuestionId,
                Value = answer.Value
            };
            context.QuestionnaireAnswers.Add(newAnswer);
        }

        await context.SaveChangesAsync();
    }
    return RedirectToPage("./Success");
}

3. 数据库层面添加唯一约束(终极保障)

在QuestionnaireAnswers表上添加联合唯一约束,约束字段为QuestionId和BookingId,即使前后端控制失效,数据库也会直接拒绝重复插入。

用EF Core Fluent API配置:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<QuestionnaireAnswer>()
        .HasIndex(a => new { a.QuestionId, a.BookingId })
        .IsUnique();
}

后端捕获唯一约束异常,返回友好提示:

try
{
    await context.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
    if (ex.InnerException is SqlException sqlEx && sqlEx.Number == 2601) // SQL Server唯一约束冲突错误码
    {
        ModelState.AddModelError("", "该问题已提交答案,无需重复提交");
        return Page();
    }
    throw;
}

内容的提问来源于stack exchange,提问作者Ortund

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.24 09:51:22